feat: cash-only registers, day book sections and DD amendments
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
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-09-03 18:26:22 +05:30
parent 6442562df0
commit fbce73fd17
111 changed files with 9076 additions and 3507 deletions
@@ -26,6 +26,7 @@ import {
MatRowDef,
MatRow
} from '@angular/material/table';
import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service';
import { ChargesFilterPipe } from '../../../../pipes/charges-filter.pipe';
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -55,6 +56,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class CurrentProductChargesStepComponent implements OnInit {
dialog = inject(MatDialog);
private translateService = inject(TranslateService);
private facilityChargeFilter = inject(FacilityChargeFilterService);
@Input() CurrentProductsTemplate: any;
@Input() currencyCode: UntypedFormControl;
@@ -73,7 +75,9 @@ export class CurrentProductChargesStepComponent implements OnInit {
pristine = true;
ngOnInit() {
this.chargeData = this.CurrentProductsTemplate.chargeOptions;
this.facilityChargeFilter
.excludeFacilityCharges(this.CurrentProductsTemplate.chargeOptions)
.subscribe((charges: any[]) => (this.chargeData = charges));
this.chargesDataSource = this.CurrentProductsTemplate.charges || [];
this.pristine = true;
@@ -174,21 +174,6 @@
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-arrow-right-arrow-left"></i>
Withdrawal Facility
</span>
<select
class="spl-select"
[(ngModel)]="accountFacilityConfig.withdrawalFacility"
(ngModelChange)="markAsDirty()"
>
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-id-card"></i>
@@ -24,7 +24,6 @@ type BooleanFeatureKey =
| 'neftFacility'
| 'rtgsFacility'
| 'interBankTotalCapFacility'
| 'withdrawalFacility'
| 'depositFacilityKyc'
| 'virtualDebitCard';
@@ -76,7 +75,6 @@ export class CurrentProductLimitsStepComponent implements OnChanges {
neftFacility: true,
rtgsFacility: true,
interBankTotalCapFacility: true,
withdrawalFacility: true,
depositFacilityKyc: true,
virtualDebitCard: false
};
@@ -94,7 +94,6 @@ export class CurrentProductPreviewStepComponent implements OnInit, OnChanges {
{ key: 'neftFacility', label: 'NEFT Facility' },
{ key: 'rtgsFacility', label: 'RTGS Facility' },
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
];
@@ -11,6 +11,7 @@ import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DORMANCY_DEFAULTS } from '../../../dormancy-defaults';
@Component({
selector: 'mifosx-current-product-settings-step',
@@ -62,9 +63,15 @@ export class CurrentProductSettingsStepComponent implements OnInit {
? (this.CurrentProductsTemplate.accountClosedZeroBalanceInDayClosing ?? false)
: false,
isDormancyTrackingActive: isEditing ? (this.CurrentProductsTemplate.isDormancyTrackingActive ?? true) : true,
daysToInactive: isEditing ? this.CurrentProductsTemplate.daysToInactive || 30 : 30,
daysToDormancy: isEditing ? this.CurrentProductsTemplate.daysToDormancy || 60 : 60,
daysToEscheat: isEditing ? this.CurrentProductsTemplate.daysToEscheat || 90 : 90,
daysToInactive: isEditing
? this.CurrentProductsTemplate.daysToInactive || DORMANCY_DEFAULTS.daysToInactive
: DORMANCY_DEFAULTS.daysToInactive,
daysToDormancy: isEditing
? this.CurrentProductsTemplate.daysToDormancy || DORMANCY_DEFAULTS.daysToDormancy
: DORMANCY_DEFAULTS.daysToDormancy,
daysToEscheat: isEditing
? this.CurrentProductsTemplate.daysToEscheat || DORMANCY_DEFAULTS.daysToEscheat
: DORMANCY_DEFAULTS.daysToEscheat,
// Overdraft fields
allowOverdraft: isEditing ? (this.CurrentProductsTemplate.allowOverdraft ?? false) : false,
overdraftLimit: isEditing ? this.CurrentProductsTemplate.overdraftLimit : '',
@@ -113,9 +120,9 @@ export class CurrentProductSettingsStepComponent implements OnInit {
accountClosedZeroBalanceInDayClosing: [false],
// Dormancy (used internally / by accounting step)
isDormancyTrackingActive: [true],
daysToInactive: [30],
daysToDormancy: [60],
daysToEscheat: [90],
daysToInactive: [DORMANCY_DEFAULTS.daysToInactive],
daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy],
daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat],
// Overdraft (OD) Facility
allowOverdraft: [false],
overdraftLimit: [
@@ -89,7 +89,6 @@ export class CurrentProductGeneralTabComponent {
{ key: 'neftFacility', label: 'NEFT Facility' },
{ key: 'rtgsFacility', label: 'RTGS Facility' },
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
{ key: 'depositFacilityKyc', label: 'Deposit Facility KYC' },
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
];
+32
View File
@@ -0,0 +1,32 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* Default dormancy thresholds for a new product, in days from the last customer transaction.
*
* RBI's inoperative-accounts rules count the silence from the last *customer-induced* transaction —
* a deposit or withdrawal the customer made, not interest the bank posts — and set the marks at a
* year, two years and ten:
*
* 365 → inactive (an internal flag; nothing is restricted)
* 730 → inoperative (two years; debits stop, reactivation needs the branch)
* 3650 → escheat (ten years; the balance is due to RBI's DEAF)
*
* These are the values every product stepper starts a new product on, and the backend seeds the
* same numbers (see the 0521 and 0524 changelogs). They live here rather than being repeated in
* each stepper so a bank that revises its policy changes one file, not four.
*
* A product can still be configured away from these — they are defaults, not a ceiling — but a
* materially shorter period is worth questioning: it would make an ordinary account, dormant for a
* few weeks, show as overdue for inactivity.
*/
export const DORMANCY_DEFAULTS = {
daysToInactive: 365,
daysToDormancy: 730,
daysToEscheat: 3650
};
@@ -11,6 +11,7 @@ import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms
import { MatTooltip } from '@angular/material/tooltip';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DORMANCY_DEFAULTS } from '../../../dormancy-defaults';
@Component({
selector: 'mifosx-fixed-deposit-product-currency-step',
@@ -54,9 +55,9 @@ export class FixedDepositProductCurrencyStepComponent implements OnInit {
2,
dormancyAlert: this.fixedDepositProductsTemplate.dormancyAlert ?? false,
isDormancyTrackingActive: this.fixedDepositProductsTemplate.isDormancyTrackingActive ?? false,
daysToInactive: this.fixedDepositProductsTemplate.daysToInactive ?? 30,
daysToDormancy: this.fixedDepositProductsTemplate.daysToDormancy ?? 60,
daysToEscheat: this.fixedDepositProductsTemplate.daysToEscheat ?? 90,
daysToInactive: this.fixedDepositProductsTemplate.daysToInactive ?? DORMANCY_DEFAULTS.daysToInactive,
daysToDormancy: this.fixedDepositProductsTemplate.daysToDormancy ?? DORMANCY_DEFAULTS.daysToDormancy,
daysToEscheat: this.fixedDepositProductsTemplate.daysToEscheat ?? DORMANCY_DEFAULTS.daysToEscheat,
nominationFacility: this.fixedDepositProductsTemplate.nominationFacility ?? false,
autoDebitFacility: this.fixedDepositProductsTemplate.autoDebitFacility ?? false
});
@@ -77,9 +78,9 @@ export class FixedDepositProductCurrencyStepComponent implements OnInit {
],
dormancyAlert: [false],
isDormancyTrackingActive: [false],
daysToInactive: [30],
daysToDormancy: [60],
daysToEscheat: [90],
daysToInactive: [DORMANCY_DEFAULTS.daysToInactive],
daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy],
daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat],
nominationFacility: [false],
autoDebitFacility: [false]
});
+11
View File
@@ -74,6 +74,17 @@ export class ProductsService {
return this.http.put(`/savingsproducts/${savingProductId}`, savingProduct);
}
/**
* Deletes a savings product.
*
* The server refuses this once any account has been opened on the product — a product with a
* history behind it cannot be removed without taking those accounts' terms with it — so this is
* really only usable on one created by mistake or for a trial.
*/
deleteSavingProduct(savingProductId: string): Observable<any> {
return this.http.delete(`/savingsproducts/${savingProductId}`);
}
/**
* Submitting a savings product update (above) puts it in the pending-approval temp table instead of
* applying it directly. These calls drive the Approvals -> Pending Savings Product Edits screen.
@@ -11,6 +11,7 @@ import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule }
import { MatTooltip } from '@angular/material/tooltip';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DORMANCY_DEFAULTS } from '../../../dormancy-defaults';
@Component({
selector: 'mifosx-recurring-deposit-product-currency-step',
@@ -54,9 +55,9 @@ export class RecurringDepositProductCurrencyStepComponent implements OnInit {
2,
dormancyAlert: this.recurringDepositProductsTemplate.dormancyAlert ?? false,
isDormancyTrackingActive: this.recurringDepositProductsTemplate.isDormancyTrackingActive ?? false,
daysToInactive: this.recurringDepositProductsTemplate.daysToInactive ?? 30,
daysToDormancy: this.recurringDepositProductsTemplate.daysToDormancy ?? 60,
daysToEscheat: this.recurringDepositProductsTemplate.daysToEscheat ?? 90,
daysToInactive: this.recurringDepositProductsTemplate.daysToInactive ?? DORMANCY_DEFAULTS.daysToInactive,
daysToDormancy: this.recurringDepositProductsTemplate.daysToDormancy ?? DORMANCY_DEFAULTS.daysToDormancy,
daysToEscheat: this.recurringDepositProductsTemplate.daysToEscheat ?? DORMANCY_DEFAULTS.daysToEscheat,
nominationFacility: this.recurringDepositProductsTemplate.nominationFacility ?? false,
autoDebitFacility: this.recurringDepositProductsTemplate.autoDebitFacility ?? false
});
@@ -77,9 +78,9 @@ export class RecurringDepositProductCurrencyStepComponent implements OnInit {
],
dormancyAlert: [false],
isDormancyTrackingActive: [false],
daysToInactive: [30],
daysToDormancy: [60],
daysToEscheat: [90],
daysToInactive: [DORMANCY_DEFAULTS.daysToInactive],
daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy],
daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat],
nominationFacility: [false],
autoDebitFacility: [false]
});
@@ -100,7 +100,7 @@
<mifosx-saving-product-accounting-step
[savingProductsTemplate]="savingProductsTemplate"
[accountingRuleData]="accountingRuleData"
[isDormancyTrackingActive]="savingProductSettingsForm.get('isDormancyTrackingActive')"
[isDormancyTrackingActive]="savingProductCurrencyForm.get('dormancyAlert')"
[savingProductFormValid]="savingProductFormValid"
[allowOverdraft]="savingProductSettingsForm.get('allowOverdraft')"
></mifosx-saving-product-accounting-step>
@@ -100,7 +100,7 @@
<mifosx-saving-product-accounting-step
[savingProductsTemplate]="savingProductAndTemplate"
[accountingRuleData]="accountingRuleData"
[isDormancyTrackingActive]="savingProductSettingsForm.get('isDormancyTrackingActive')"
[isDormancyTrackingActive]="savingProductCurrencyForm.get('dormancyAlert')"
[savingProductFormValid]="savingProductFormValidAndNotPristine"
[allowOverdraft]="savingProductSettingsForm.get('allowOverdraft')"
></mifosx-saving-product-accounting-step>
@@ -24,6 +24,12 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
type AccountingSectionKey = 'assets' | 'liabilities' | 'expenses' | 'income';
/** Reserved otherGlMappings account names backing the two dedicated savings-interest GL rows.
* They round trip through otherGlMappings because the backend persists only the core mappings
* under CASH_BASED accounting, which is the only rule this screen offers. */
const SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME = 'Savings Interest Payable';
const SAVINGS_INTEREST_PAID_ACCOUNT_NAME = 'Savings Interest Paid';
type AccountingRowConfig = {
key: string;
label: string;
@@ -180,13 +186,9 @@ export class SavingProductAccountingStepComponent implements OnInit {
this.savingProductAccountingForm.patchValue({
savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '',
savingsControlAccountId: mappings.savingsControlAccount?.id || '',
transfersInSuspenseAccountId: mappings.transfersInSuspenseAccount?.id || '',
interestOnSavingsAccountId: mappings.interestOnSavingsAccount?.id || '',
incomeFromFeeAccountId: mappings.incomeFromFeeAccount?.id || '',
incomeFromPenaltyAccountId: mappings.incomeFromPenaltyAccount?.id || '',
incomeFromInterestId: mappings.incomeFromInterest?.id || '',
overdraftPortfolioControlId: mappings.overdraftPortfolioControl?.id || '',
writeOffAccountId: mappings.writeOffAccount?.id || '',
feesReceivableAccountId: mappings.feeReceivableAccount?.id || '',
penaltiesReceivableAccountId: mappings.penaltyReceivableAccount?.id || '',
interestPayableAccountId: mappings.interestPayableAccount?.id || '',
@@ -200,10 +202,24 @@ export class SavingProductAccountingStepComponent implements OnInit {
this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions);
this.setOtherGlMappings(
'liabilitiesOtherGlMappings',
glMappings?.liabilities || [],
this.extractNamedGlMapping(
glMappings?.liabilities || [],
SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME,
'savingsInterestPayableAccountId',
this.liabilityAccountOptions
),
this.liabilityAccountOptions
);
this.setOtherGlMappings('expensesOtherGlMappings', glMappings?.expenses || [], this.expenseAccountOptions);
this.setOtherGlMappings(
'expensesOtherGlMappings',
this.extractNamedGlMapping(
glMappings?.expenses || [],
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.expenseAccountOptions
),
this.expenseAccountOptions
);
this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions);
}
}
@@ -225,6 +241,22 @@ export class SavingProductAccountingStepComponent implements OnInit {
return this.savingProductAccountingForm.get(controlName) as UntypedFormArray;
}
/** Pulls a reserved entry out of a saved otherGlMappings list into its own dedicated control and
* returns the remaining entries, so it renders as a named row rather than a free-form "Other" row. */
private extractNamedGlMapping(
otherGlMappings: any[],
accountName: string,
controlName: string,
options: GLAccount[]
): any[] {
const match = (otherGlMappings || []).find((mapping) => mapping?.accountName === accountName);
if (match) {
const glAccountId = match.glAccountId || options.find((option) => option.glCode === match.glCode)?.id || '';
this.savingProductAccountingForm.get(controlName)?.patchValue(glAccountId);
}
return (otherGlMappings || []).filter((mapping) => mapping?.accountName !== accountName);
}
private setOtherGlMappings(controlName: string, otherGlMappings: any[], options: GLAccount[]) {
const otherGlMappingsArray = this.getOtherGlMappingsArray(controlName);
otherGlMappingsArray.clear();
@@ -282,10 +314,35 @@ export class SavingProductAccountingStepComponent implements OnInit {
}))
.filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode);
/** The two named-but-not-backend-persisted rows are emitted as reserved otherGlMappings entries so
* they survive a save/reload round trip under Cash accounting. */
const namedAsOtherGlMapping = (controlName: string, accountName: string, options: GLAccount[]) => {
const glAccountId = this.savingProductAccountingForm.get(controlName)?.value;
if (!glAccountId) {
return [];
}
const glCode = options.find((option) => option.id === glAccountId)?.glCode || '';
return glCode ? [{ accountName, glCode }] : [];
};
const otherGlMappings = {
assets: mapOtherGlMappings('assetsOtherGlMappings', this.assetAccountOptions),
liabilities: mapOtherGlMappings('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
expenses: mapOtherGlMappings('expensesOtherGlMappings', this.expenseAccountOptions),
liabilities: [
...namedAsOtherGlMapping(
'savingsInterestPayableAccountId',
SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME,
this.liabilityAccountOptions
),
...mapOtherGlMappings('liabilitiesOtherGlMappings', this.liabilityAccountOptions)
],
expenses: [
...namedAsOtherGlMapping(
'savingsInterestPaidAccountId',
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
this.expenseAccountOptions
),
...mapOtherGlMappings('expensesOtherGlMappings', this.expenseAccountOptions)
],
income: mapOtherGlMappings('incomeOtherGlMappings', this.incomeAccountOptions)
};
@@ -320,13 +377,21 @@ export class SavingProductAccountingStepComponent implements OnInit {
if (this.accounting.isCashOrAccrualAccountingRuleId(accountingRule)) {
addControl('savingsReferenceAccountId', true);
addControl('savingsControlAccountId', true);
addControl('transfersInSuspenseAccountId', true);
addControl('interestOnSavingsAccountId', true);
addControl('incomeFromFeeAccountId', true);
addControl('incomeFromPenaltyAccountId', true);
addControl('incomeFromInterestId', true);
addControl('writeOffAccountId', true);
addControl('overdraftPortfolioControlId', false);
// Savings Interest Payable / Paid are offered under Cash too, but the backend only persists the
// core mappings for CASH_BASED, so these two ride along in otherGlMappings (see getOtherGlMappings)
// rather than as named accounting params, which would be silently dropped. Optional by design.
addControl('savingsInterestPayableAccountId', false);
addControl('savingsInterestPaidAccountId', false);
// Transfers In Suspense / Income From Interest / Write Off / Overdraft Portfolio Control are
// intentionally not offered on this screen, so their controls are never added — leaving them in
// with required validators would keep the form invalid with no visible field to fix.
removeControl('transfersInSuspenseAccountId');
removeControl('incomeFromInterestId');
removeControl('writeOffAccountId');
removeControl('overdraftPortfolioControlId');
if (this.accounting.isAccrualAccountingRuleId(accountingRule)) {
addControl('feesReceivableAccountId', true);
@@ -360,6 +425,8 @@ export class SavingProductAccountingStepComponent implements OnInit {
removeControl('interestPayableAccountId');
removeControl('interestReceivableAccountId');
removeControl('escheatLiabilityId');
removeControl('savingsInterestPayableAccountId');
removeControl('savingsInterestPaidAccountId');
this.clearOtherGlMappings();
}
}
@@ -370,6 +437,9 @@ export class SavingProductAccountingStepComponent implements OnInit {
liabilitiesOtherGlMappings,
expensesOtherGlMappings,
incomeOtherGlMappings,
// Carried inside otherGlMappings instead — the backend rejects unknown top-level accounting params.
savingsInterestPayableAccountId,
savingsInterestPaidAccountId,
...savingProductAccounting
} = this.savingProductAccountingForm.value;
const otherGlMappings = this.getOtherGlMappings();
@@ -404,13 +474,6 @@ export class SavingProductAccountingStepComponent implements OnInit {
options: this.assetAccountOptions,
required: true
},
{
key: 'overdraftPortfolioControlId',
label: 'Overdraft Portfolio Control',
inputLabel: 'Overdraft portfolio control',
controlName: 'overdraftPortfolioControlId',
options: this.assetAccountOptions
},
{
key: 'interestReceivableAccountId',
label: 'Interest Receivable Account',
@@ -454,12 +517,11 @@ export class SavingProductAccountingStepComponent implements OnInit {
required: true
},
{
key: 'transfersInSuspenseAccountId',
label: 'Transfers In Suspense Account',
inputLabel: 'Transfers in suspense account',
controlName: 'transfersInSuspenseAccountId',
options: this.liabilityAccountOptions,
required: true
key: 'savingsInterestPayableAccountId',
label: 'Savings Interest Payable',
inputLabel: 'Savings interest payable',
controlName: 'savingsInterestPayableAccountId',
options: this.liabilityAccountOptions
},
{
key: 'interestPayableAccountId',
@@ -496,12 +558,11 @@ export class SavingProductAccountingStepComponent implements OnInit {
required: true
},
{
key: 'writeOffAccountId',
label: 'Write Off Account',
inputLabel: 'Write off account',
controlName: 'writeOffAccountId',
options: this.expenseAccountOptions,
required: true
key: 'savingsInterestPaidAccountId',
label: 'Savings Interest Paid',
inputLabel: 'Savings interest paid',
controlName: 'savingsInterestPaidAccountId',
options: this.expenseAccountOptions
}
],
otherMappingsControlName: 'expensesOtherGlMappings',
@@ -528,14 +589,6 @@ export class SavingProductAccountingStepComponent implements OnInit {
controlName: 'incomeFromPenaltyAccountId',
options: this.incomeAccountOptions,
required: true
},
{
key: 'incomeFromInterestId',
label: 'Income From Interest',
inputLabel: 'Income from interest',
controlName: 'incomeFromInterestId',
options: this.incomeAccountOptions,
required: true
}
],
otherMappingsControlName: 'incomeOtherGlMappings',
@@ -26,6 +26,7 @@ import {
MatRowDef,
MatRow
} from '@angular/material/table';
import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service';
import { ChargesFilterPipe } from '../../../../pipes/charges-filter.pipe';
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -55,6 +56,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class SavingProductChargesStepComponent implements OnInit {
dialog = inject(MatDialog);
private translateService = inject(TranslateService);
private facilityChargeFilter = inject(FacilityChargeFilterService);
@Input() savingProductsTemplate: any;
@Input() currencyCode: UntypedFormControl;
@@ -73,8 +75,14 @@ export class SavingProductChargesStepComponent implements OnInit {
pristine = true;
ngOnInit() {
this.chargeData = this.savingProductsTemplate.chargeOptions;
// Facility-priced charges (Cheque Book, ATM, UPI, ...) are levied by the facility mapping itself,
// so they must not be offered here as well — see FacilityChargeFilterService.
this.facilityChargeFilter
.excludeFacilityCharges(this.savingProductsTemplate.chargeOptions)
.subscribe((charges: any[]) => (this.chargeData = charges));
// Already-attached charges are shown as-is: filtering them here would silently drop them
// from the payload on save.
this.chargesDataSource = this.savingProductsTemplate.charges || [];
this.pristine = true;
@@ -63,6 +63,97 @@
</mat-radio-group>
</div>
<!--
The periods that will apply to every account opened on this product, shown as soon as
tracking is switched on. Read-only until asked for: these are the RBI periods, and a product
should get them unless somebody deliberately departs from them.
-->
@if (dormancyTrackingOn) {
<div class="spc-dorm">
<div class="spc-dorm__head">
<div>
<h4>Dormancy Periods</h4>
<p>
Counted from the last customer transaction — a deposit or withdrawal the customer makes. Interest the
bank posts does not reset the clock.
</p>
</div>
<div class="spc-dorm__actions">
@if (editingDormancyDays) {
<button type="button" class="spc-dorm__btn" (click)="resetDormancyDays()">
<fa-icon icon="undo"></fa-icon> RBI defaults
</button>
}
<button type="button" class="spc-dorm__btn" (click)="toggleDormancyDaysEdit()">
<fa-icon [icon]="editingDormancyDays ? 'times' : 'edit'"></fa-icon>
{{ editingDormancyDays ? 'Cancel' : 'Edit' }}
</button>
</div>
</div>
<div class="spc-dorm__grid">
<div class="spc-dorm__item">
<span class="spc-dorm__label">Inactive after</span>
@if (editingDormancyDays) {
<mat-form-field appearance="outline" class="spc-dorm__field">
<input matInput type="number" min="1" formControlName="daysToInactive" />
<span matSuffix>days</span>
</mat-form-field>
} @else {
<span class="spc-dorm__value">
{{ savingProductCurrencyForm.value.daysToInactive }} <small>days</small>
</span>
}
<span class="spc-dorm__note">Flagged internally. Nothing is restricted.</span>
</div>
<div class="spc-dorm__item">
<span class="spc-dorm__label">Dormant after</span>
@if (editingDormancyDays) {
<mat-form-field appearance="outline" class="spc-dorm__field">
<input matInput type="number" min="1" formControlName="daysToDormancy" />
<span matSuffix>days</span>
</mat-form-field>
} @else {
<span class="spc-dorm__value">
{{ savingProductCurrencyForm.value.daysToDormancy }} <small>days</small>
</span>
}
<span class="spc-dorm__note">Debits stop. Credits still land.</span>
</div>
<div class="spc-dorm__item">
<span class="spc-dorm__label">Escheat after</span>
@if (editingDormancyDays) {
<mat-form-field appearance="outline" class="spc-dorm__field">
<input matInput type="number" min="1" formControlName="daysToEscheat" />
<span matSuffix>days</span>
</mat-form-field>
} @else {
<span class="spc-dorm__value">
{{ savingProductCurrencyForm.value.daysToEscheat }} <small>days</small>
</span>
}
<span class="spc-dorm__note">Balance becomes due to RBI's DEAF.</span>
</div>
</div>
@if (dormancyDaysOutOfOrder) {
<div class="spc-dorm__error">
<fa-icon icon="exclamation-triangle"></fa-icon>
The periods have to run in order: dormant later than inactive, escheat later than dormant. An account
cannot reach a later stage before an earlier one.
</div>
}
<div class="spc-dorm__foot">
<fa-icon icon="info-circle"></fa-icon>
Accounts opened on this product inherit these periods. An account that receives government benefit credits
can be exempted individually, and is then never restricted for inactivity.
</div>
</div>
}
<div class="spc-alert">
<div class="spc-alert__content">
<h4>Nomination Facility</h4>
@@ -112,3 +112,133 @@ $gray-900: #202124;
flex-wrap: wrap;
}
}
/* ── Dormancy periods, shown once tracking is switched on ─────────────────── */
.spc-dorm {
margin-top: 1rem;
padding: 1rem 1.15rem;
border: 1px solid rgb(0 0 0 / 12%);
border-left: 3px solid #1565c0;
border-radius: 6px;
background: rgb(21 101 192 / 3%);
}
.spc-dorm__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
h4 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
}
p {
margin: 0.2rem 0 0;
font-size: 0.78rem;
opacity: 0.7;
max-width: 34rem;
line-height: 1.45;
}
}
.spc-dorm__actions {
display: flex;
gap: 0.4rem;
flex-shrink: 0;
}
.spc-dorm__btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.7rem;
border: 1px solid rgb(0 0 0 / 20%);
border-radius: 4px;
background: #fff;
font: inherit;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
&:hover {
border-color: #1565c0;
color: #1565c0;
}
}
.spc-dorm__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 0.9rem;
margin-top: 0.9rem;
}
.spc-dorm__item {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.spc-dorm__label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
opacity: 0.6;
}
.spc-dorm__value {
font-size: 1.35rem;
font-weight: 700;
line-height: 1.2;
small {
font-size: 0.75rem;
font-weight: 500;
opacity: 0.6;
}
}
.spc-dorm__field {
width: 100%;
/* The stepper's fields are roomier than this panel needs. */
::ng-deep .mat-mdc-form-field-subscript-wrapper {
display: none;
}
}
.spc-dorm__note {
font-size: 0.72rem;
opacity: 0.65;
line-height: 1.35;
}
/* Periods that would have an account reach a later stage before an earlier one. */
.spc-dorm__error {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
padding: 0.5rem 0.7rem;
border-radius: 4px;
background: rgb(192 57 43 / 9%);
color: #c0392b;
font-size: 0.78rem;
line-height: 1.45;
}
.spc-dorm__foot {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.6rem;
border-top: 1px solid rgb(0 0 0 / 8%);
font-size: 0.75rem;
opacity: 0.7;
line-height: 1.45;
}
@@ -12,6 +12,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DORMANCY_DEFAULTS } from '../../../dormancy-defaults';
@Component({
selector: 'mifosx-saving-product-currency-step',
@@ -50,10 +51,68 @@ export class SavingProductCurrencyStepComponent implements OnInit {
currencyCode: this.savingProductsTemplate.currency?.code || this.currencyData?.[0]?.code || '',
digitsAfterDecimal: this.savingProductsTemplate.digitsAfterDecimal ?? 2,
dormancyAlert: this.savingProductsTemplate.isDormancyTrackingActive ?? false,
nominationFacility: this.savingProductsTemplate.nominationFacility ?? false
nominationFacility: this.savingProductsTemplate.nominationFacility ?? false,
daysToInactive: this.savingProductsTemplate.daysToInactive ?? DORMANCY_DEFAULTS.daysToInactive,
daysToDormancy: this.savingProductsTemplate.daysToDormancy ?? DORMANCY_DEFAULTS.daysToDormancy,
daysToEscheat: this.savingProductsTemplate.daysToEscheat ?? DORMANCY_DEFAULTS.daysToEscheat
});
}
/**
* Whether the periods are open for editing.
*
* They start read-only and showing the RBI defaults. A bank that wants its own numbers has to ask
* for them, which keeps the regulatory periods as what a product gets unless somebody deliberately
* departs from them.
*/
editingDormancyDays = false;
get dormancyTrackingOn(): boolean {
return !!this.savingProductCurrencyForm.get('dormancyAlert')?.value;
}
toggleDormancyDaysEdit(): void {
this.editingDormancyDays = !this.editingDormancyDays;
if (!this.editingDormancyDays) {
// Leaving edit mode puts back whatever the product had, so a half-typed number is not kept.
this.savingProductCurrencyForm.patchValue({
daysToInactive: this.savingProductsTemplate.daysToInactive ?? DORMANCY_DEFAULTS.daysToInactive,
daysToDormancy: this.savingProductsTemplate.daysToDormancy ?? DORMANCY_DEFAULTS.daysToDormancy,
daysToEscheat: this.savingProductsTemplate.daysToEscheat ?? DORMANCY_DEFAULTS.daysToEscheat
});
}
}
/** Puts the RBI periods back, for a product that was edited away from them. */
resetDormancyDays(): void {
this.savingProductCurrencyForm.patchValue({
daysToInactive: DORMANCY_DEFAULTS.daysToInactive,
daysToDormancy: DORMANCY_DEFAULTS.daysToDormancy,
daysToEscheat: DORMANCY_DEFAULTS.daysToEscheat
});
}
/**
* True when the periods no longer run inactive → dormant → escheat in order.
*
* A dormant threshold at or before the inactive one would have an account reach the later stage
* first, which the job cannot act on sensibly, so the step says so rather than saving it.
*/
get dormancyDaysOutOfOrder(): boolean {
const v = this.savingProductCurrencyForm.value;
const inactive = Number(v.daysToInactive);
const dormant = Number(v.daysToDormancy);
const escheat = Number(v.daysToEscheat);
if ([
inactive,
dormant,
escheat
].some((n) => !Number.isFinite(n) || n <= 0)) {
return false;
}
return dormant <= inactive || escheat <= dormant;
}
createSavingProductCurrencyForm() {
this.savingProductCurrencyForm = this.formBuilder.group({
currencyCode: [
@@ -68,7 +127,21 @@ export class SavingProductCurrencyStepComponent implements OnInit {
]
],
dormancyAlert: [false],
nominationFacility: [false]
nominationFacility: [false],
// The RBI periods, counted from the last customer transaction. Editable, but a product gets
// these unless somebody deliberately changes them.
daysToInactive: [
DORMANCY_DEFAULTS.daysToInactive,
Validators.min(1)
],
daysToDormancy: [
DORMANCY_DEFAULTS.daysToDormancy,
Validators.min(1)
],
daysToEscheat: [
DORMANCY_DEFAULTS.daysToEscheat,
Validators.min(1)
]
});
}
@@ -82,6 +155,14 @@ export class SavingProductCurrencyStepComponent implements OnInit {
nominationFacility: formValue.nominationFacility
};
// The periods only mean anything while tracking is on; sending them with it off would store
// thresholds against a product nothing acts on.
if (formValue.dormancyAlert) {
result.daysToInactive = formValue.daysToInactive;
result.daysToDormancy = formValue.daysToDormancy;
result.daysToEscheat = formValue.daysToEscheat;
}
return result;
}
}
@@ -174,21 +174,6 @@
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-arrow-right-arrow-left"></i>
Withdrawal Facility
</span>
<select
class="spl-select"
[(ngModel)]="accountFacilityConfig.withdrawalFacility"
(ngModelChange)="markAsDirty()"
>
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-id-card"></i>
@@ -24,7 +24,6 @@ type BooleanFeatureKey =
| 'neftFacility'
| 'rtgsFacility'
| 'interBankTotalCapFacility'
| 'withdrawalFacility'
| 'depositFacilityKyc'
| 'virtualDebitCard';
@@ -76,7 +75,6 @@ export class SavingProductLimitsStepComponent implements OnChanges {
neftFacility: true,
rtgsFacility: true,
interBankTotalCapFacility: true,
withdrawalFacility: true,
depositFacilityKyc: true,
virtualDebitCard: false
};
@@ -475,42 +475,35 @@
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Overdraft portfolio control</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.overdraftPortfolioControlId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Interest receivable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.interestReceivableAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Fees receivable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.feesReceivableAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Penalties receivable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.penaltiesReceivableAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
@if (isAccrualAccounting()) {
<div class="spa-gl-row">
<span class="spa-gl-row__label">Interest receivable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.interestReceivableAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Fees receivable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.feesReceivableAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Penalties receivable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.penaltiesReceivableAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
}
</div>
<div class="spa-gl-section">
@@ -524,24 +517,17 @@
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Transfers in suspense account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.transfersInSuspenseAccountId,
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Interest payable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.interestPayableAccountId,
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
)
}}</span>
</div>
@if (isAccrualAccounting()) {
<div class="spa-gl-row">
<span class="spa-gl-row__label">Interest payable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.interestPayableAccountId,
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
)
}}</span>
</div>
}
@if (savingProduct.isDormancyTrackingActive) {
<div class="spa-gl-row">
<span class="spa-gl-row__label">Escheat liability account</span>
@@ -566,15 +552,6 @@
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Write off account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.writeOffAccountId,
savingProductsTemplate?.accountingMappingOptions.expenseAccountOptions
)
}}</span>
</div>
</div>
<div class="spa-gl-section">
@@ -597,15 +574,6 @@
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Income from interest</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.incomeFromInterestId,
savingProductsTemplate?.accountingMappingOptions.incomeAccountOptions
)
}}</span>
</div>
</div>
</div>
}
@@ -94,7 +94,6 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
{ key: 'neftFacility', label: 'NEFT Facility' },
{ key: 'rtgsFacility', label: 'RTGS Facility' },
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
];
@@ -132,23 +131,14 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
this.savingProduct.savingsReferenceAccountId,
assetAccountData
),
overdraftPortfolioControl: this.accounting.glAccountLookUp(
this.savingProduct.overdraftPortfolioControlId,
assetAccountData
),
savingsControlAccount: this.accounting.glAccountLookUp(
this.savingProduct.savingsControlAccountId,
liabilityAccountData
),
transfersInSuspenseAccount: this.accounting.glAccountLookUp(
this.savingProduct.transfersInSuspenseAccountId,
liabilityAccountData
),
interestOnSavingsAccount: this.accounting.glAccountLookUp(
this.savingProduct.interestOnSavingsAccountId,
expenseAccountData
),
writeOffAccount: this.accounting.glAccountLookUp(this.savingProduct.writeOffAccountId, expenseAccountData),
incomeFromFeeAccount: this.accounting.glAccountLookUp(
this.savingProduct.incomeFromFeeAccountId,
incomeAccountData
@@ -156,8 +146,7 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
incomeFromPenaltyAccount: this.accounting.glAccountLookUp(
this.savingProduct.incomeFromPenaltyAccountId,
incomeAccountData
),
incomeFromInterest: this.accounting.glAccountLookUp(this.savingProduct.incomeFromInterestId, incomeAccountData)
)
};
if (this.isAccrualAccounting()) {
@@ -70,11 +70,7 @@ export class SavingProductSettingsStepComponent implements OnInit {
: this.savingProductsTemplate.lockinPeriodFrequency && this.savingProductsTemplate.lockinPeriodFrequency > 0,
accountClosedZeroBalanceInDayClosing: isEditing
? (this.savingProductsTemplate.accountClosedZeroBalanceInDayClosing ?? false)
: false,
isDormancyTrackingActive: isEditing ? (this.savingProductsTemplate.isDormancyTrackingActive ?? true) : true,
daysToInactive: isEditing ? this.savingProductsTemplate.daysToInactive || 30 : 30,
daysToDormancy: isEditing ? this.savingProductsTemplate.daysToDormancy || 60 : 60,
daysToEscheat: isEditing ? this.savingProductsTemplate.daysToEscheat || 90 : 90
: false
});
if (
@@ -115,12 +111,11 @@ export class SavingProductSettingsStepComponent implements OnInit {
enableLockinPeriod: [false],
lockinPeriodFrequency: [''],
lockinPeriodFrequencyType: [''],
accountClosedZeroBalanceInDayClosing: [false],
// Dormancy tracking fields (required when isDormancyTrackingActive is true)
isDormancyTrackingActive: [true],
daysToInactive: [30],
daysToDormancy: [60],
daysToEscheat: [90]
accountClosedZeroBalanceInDayClosing: [false]
// Dormancy is deliberately absent: it belongs to the Currency step, which is where it is shown
// and edited. It was declared here as well, and because this step is spread into the payload
// after that one, the numbers an officer typed were silently replaced by these untouched
// defaults on the way to the server.
});
}
@@ -86,7 +86,6 @@ export class SavingProductGeneralTabComponent {
{ key: 'neftFacility', label: 'NEFT Facility' },
{ key: 'rtgsFacility', label: 'RTGS Facility' },
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
];
@@ -76,9 +76,7 @@
<input matInput type="number" step="0.01" min="0.01" max="100" formControlName="ratePercentage" />
<span matSuffix>&nbsp;%&nbsp;</span>
</mat-form-field>
<p class="dd-hint">
Applied to each member's own share investment, pro-rated for how long they held it.
</p>
<p class="dd-hint">Applied to each member's own share investment, pro-rated for how long they held it.</p>
</div>
@if (loadingPreview) {
@@ -248,27 +248,67 @@ export class ViewDividendComponent implements OnInit {
ws.getCell('A1').alignment = { horizontal: 'center' };
const heading: [string, any][] = [
['Share Product', h?.productName ?? ''],
['Financial Year', fy],
['Dividend Period', h ? `${h.periodStartDate} to ${h.periodEndDate}` : ''],
['Dividend Rate (% per annum)', h?.ratePercentage != null ? Number(h.ratePercentage) : 'Not recorded'],
['Declared Amount', h?.declaredAmount != null ? Number(h.declaredAmount) : ''],
['Distributed to Members', h?.allocatedAmount != null ? Number(h.allocatedAmount) : ''],
['Payment Date', h?.paymentDate ? String(h.paymentDate).slice(0, 10) : 'Not yet approved'],
['Status', h?.status === 'APPROVED' ? 'Approved' : 'Awaiting Approval'],
['Members Paid', h?.paidMembers ?? ''],
['Members Below Minimum', h?.unpaidMembers ?? '']
[
'Share Product',
h?.productName ?? ''
],
[
'Financial Year',
fy
],
[
'Dividend Period',
h ? `${h.periodStartDate} to ${h.periodEndDate}` : ''
],
[
'Dividend Rate (% per annum)',
h?.ratePercentage != null ? Number(h.ratePercentage) : 'Not recorded'
],
[
'Declared Amount',
h?.declaredAmount != null ? Number(h.declaredAmount) : ''
],
[
'Distributed to Members',
h?.allocatedAmount != null ? Number(h.allocatedAmount) : ''
],
[
'Payment Date',
h?.paymentDate ? String(h.paymentDate).slice(0, 10) : 'Not yet approved'
],
[
'Status',
h?.status === 'APPROVED' ? 'Approved' : 'Awaiting Approval'
],
[
'Members Paid',
h?.paidMembers ?? ''
],
[
'Members Below Minimum',
h?.unpaidMembers ?? ''
]
];
heading.forEach(([label, value], i) => {
const row = ws.getRow(i + 3);
row.getCell(1).value = label;
row.getCell(1).font = { bold: true };
row.getCell(2).value = value;
});
heading.forEach(
([
label,
value
], i) => {
const row = ws.getRow(i + 3);
row.getCell(1).value = label;
row.getCell(1).font = { bold: true };
row.getCell(2).value = value;
}
);
const tableStart = heading.length + 5;
const header = ws.getRow(tableStart);
['Client Name', 'Share Account', 'Dividend Amount', 'Status'].forEach((t, i) => {
[
'Client Name',
'Share Account',
'Dividend Amount',
'Status'
].forEach((t, i) => {
header.getCell(i + 1).value = t;
header.getCell(i + 1).font = { bold: true };
header.getCell(i + 1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8ECF0' } };
@@ -286,7 +326,10 @@ export class ViewDividendComponent implements OnInit {
const totalRow = ws.getRow(tableStart + 1 + (this.dataSource?.data?.length ?? 0));
totalRow.getCell(2).value = 'Total';
totalRow.getCell(2).font = { bold: true };
totalRow.getCell(3).value = (this.dataSource?.data ?? []).reduce((s: number, d: any) => s + (Number(d?.amount) || 0), 0);
totalRow.getCell(3).value = (this.dataSource?.data ?? []).reduce(
(s: number, d: any) => s + (Number(d?.amount) || 0),
0
);
totalRow.getCell(3).numFmt = '#,##0.00';
totalRow.getCell(3).font = { bold: true };