feat(shares): add Share Withdrawal maker-checker workflow with GL settlement
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s

Clerk submits a governed share redemption request (shares, Board Resolution
No., Meeting Date, settlement Payment Type); Bank Manager reviews with a
live backend-validated checklist (account/GL state, not just submit-time
snapshot) before approving, which redeems the shares and settles the
linked savings account in one transaction. Adds a Passbook tab to the
Share Account view, a reusable search-select control, and a Savings
Product "Map Payment Types to Specific Funds" fund-source override (only
Loan Products had this before) so settlement can be GL-mapped correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 16:55:21 +05:30
co-authored by Claude Sonnet 5
parent 8c7d8287f9
commit cc5cd4c6ab
31 changed files with 3565 additions and 55 deletions
@@ -115,6 +115,51 @@
</div>
</div>
}
<div class="spa-group">
<div class="spa-group__header">
<h4>Map Payment Types to Specific Funds</h4>
<button type="button" class="spa-add-btn" (click)="addPaymentFundSourceMapping()">+ Add</button>
</div>
<div class="spa-list" [formArrayName]="'paymentChannelToFundSourceMappings'">
@for (row of paymentChannelToFundSourceMappings.controls; track $index; let i = $index) {
<div class="spa-other-row-wrap">
<div class="spa-row spa-row--other" [formGroupName]="i">
<div class="spa-row__meta spa-row__meta--other">
<mat-form-field class="spa-row__other-field" appearance="outline" subscriptSizing="dynamic">
<mat-label>Payment Type</mat-label>
<mat-select formControlName="paymentTypeId">
@for (paymentType of paymentTypeOptions; track paymentType.id) {
<mat-option [value]="paymentType.id">{{ paymentType.name }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="spa-row__actions spa-row__actions--other">
<mat-form-field class="spa-row__other-field" appearance="outline" subscriptSizing="dynamic">
<mat-label>Fund Source</mat-label>
<mat-select formControlName="fundSourceAccountId">
@for (glAccount of fundSourceAccountOptions; track glAccount.id) {
<mat-option [value]="glAccount.id">
{{ '(' + glAccount.glCode + ') ' + glAccount.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<button
type="button"
class="spa-remove-btn spa-remove-btn--side"
(click)="removePaymentFundSourceMapping(i)"
>
<i class="fas fa-times"></i>
</button>
</div>
}
</div>
</div>
}
</div>
</div>
@@ -50,6 +50,11 @@ type OtherGlMappingValue = {
glAccountId: number | null;
};
type PaymentFundSourceValue = {
paymentTypeId: number | null;
fundSourceAccountId: number | null;
};
@Component({
selector: 'mifosx-saving-product-accounting-step',
templateUrl: './saving-product-accounting-step.component.html',
@@ -78,6 +83,11 @@ export class SavingProductAccountingStepComponent implements OnInit {
expenseAccountOptions: GLAccount[] = [];
incomeAccountOptions: GLAccount[] = [];
assetAccountOptions: GLAccount[] = [];
paymentTypeOptions: any[] = [];
/** A payment-type Fund Source override can legitimately be a suspense/clearing Liability account
* (e.g. an existing "Sundry" account bridging to another module) rather than only a cash-like
* Asset account, so this field accepts either. */
fundSourceAccountOptions: GLAccount[] = [];
accountingSectionConfigs: AccountingSectionConfig[] = [];
constructor() {
@@ -98,6 +108,8 @@ export class SavingProductAccountingStepComponent implements OnInit {
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || [];
this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || [];
this.paymentTypeOptions = this.savingProductsTemplate?.paymentTypeOptions || [];
this.fundSourceAccountOptions = this.assetAccountOptions.concat(this.liabilityAccountOptions);
this.buildAccountingSectionConfigs();
this.savingProductAccountingForm.patchValue({
accountingRule: accountingRuleId
@@ -105,6 +117,7 @@ export class SavingProductAccountingStepComponent implements OnInit {
this.syncAccountingControls(accountingRuleId);
this.patchAccountingValues(mappings);
this.patchPaymentFundSourceMappings(this.savingProductsTemplate?.paymentChannelToFundSourceMappings || []);
this.setOverdraftControl();
this.setDormancyControl();
}
@@ -115,10 +128,48 @@ export class SavingProductAccountingStepComponent implements OnInit {
assetsOtherGlMappings: this.formBuilder.array([]),
liabilitiesOtherGlMappings: this.formBuilder.array([]),
expensesOtherGlMappings: this.formBuilder.array([]),
incomeOtherGlMappings: this.formBuilder.array([])
incomeOtherGlMappings: this.formBuilder.array([]),
paymentChannelToFundSourceMappings: this.formBuilder.array([])
});
}
get paymentChannelToFundSourceMappings(): UntypedFormArray {
return this.savingProductAccountingForm.get('paymentChannelToFundSourceMappings') as UntypedFormArray;
}
private createPaymentFundSourceGroup(value: Partial<PaymentFundSourceValue> = {}) {
return this.formBuilder.group({
paymentTypeId: [
value.paymentTypeId ?? this.paymentTypeOptions[0]?.id ?? null,
Validators.required
],
fundSourceAccountId: [
value.fundSourceAccountId ?? this.fundSourceAccountOptions[0]?.id ?? null,
Validators.required
]
});
}
private patchPaymentFundSourceMappings(mappings: any[]): void {
this.paymentChannelToFundSourceMappings.clear();
(mappings || []).forEach((mapping) => {
this.paymentChannelToFundSourceMappings.push(
this.createPaymentFundSourceGroup({
paymentTypeId: mapping.paymentType?.id,
fundSourceAccountId: mapping.fundSourceAccount?.id
})
);
});
}
addPaymentFundSourceMapping(): void {
this.paymentChannelToFundSourceMappings.push(this.createPaymentFundSourceGroup());
}
removePaymentFundSourceMapping(index: number): void {
this.paymentChannelToFundSourceMappings.removeAt(index);
}
private setConditionalControls() {
this.savingProductAccountingForm.get('accountingRule')?.valueChanges.subscribe((accountingRule: number) => {
this.syncAccountingControls(accountingRule);
@@ -74,6 +74,12 @@ export class ShareProductAccountingStepComponent implements OnInit {
liabilityAccountData: GLAccount[] = [];
incomeAccountData: GLAccount[] = [];
equityAccountData: GLAccount[] = [];
/** "Share Reference" is where a redemption's credit side lands pending settlement — some banks
* model that as a receivable-like Asset (e.g. "Cash Vault", this field's original default),
* others as a payable-like Liability suspense account (e.g. an existing "Sundry" account
* already used elsewhere as Shares Suspense). Both are valid conventions, so this field accepts
* either instead of Asset-only. */
referenceAccountData: GLAccount[] = [];
constructor() {
this.createShareProductAccountingForm();
@@ -85,6 +91,7 @@ export class ShareProductAccountingStepComponent implements OnInit {
this.liabilityAccountData = opts.liabilityAccountOptions || [];
this.incomeAccountData = opts.incomeAccountOptions || [];
this.equityAccountData = opts.equityAccountOptions || [];
this.referenceAccountData = this.assetAccountData.concat(this.liabilityAccountData);
this.buildAccountingSectionConfigs();
@@ -135,10 +142,10 @@ export class ShareProductAccountingStepComponent implements OnInit {
rows: [
{
key: 'shareReferenceId',
label: 'Share Reference (Cash Vault)',
label: 'Share Reference (Cash Vault or Suspense)',
inputLabel: 'Share reference',
controlName: 'shareReferenceId',
options: this.assetAccountData,
options: this.referenceAccountData,
required: true
}
],