feat: update TDS config, RBI reserve returns, current products, and dormancy reports

This commit is contained in:
2026-09-24 18:07:54 +05:30
parent 1eaa8cc8cf
commit be35f3cbc3
66 changed files with 5474 additions and 565 deletions
@@ -50,6 +50,7 @@
<div class="cp-tab-content">
<mifosx-current-product-terms-step
[CurrentProductsTemplate]="currentProductsTemplate"
[allowOverdraft]="settingsForm.get('allowOverdraft')"
></mifosx-current-product-terms-step>
</div>
</mat-tab>
@@ -127,15 +127,11 @@ export class CurrentProductAccountingStepComponent implements OnInit {
private patchAccountingValues(mappings: any) {
this.CurrentProductAccountingForm.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 || '',
@@ -267,14 +263,16 @@ export class CurrentProductAccountingStepComponent implements OnInit {
};
if (this.accounting.isCashOrAccrualAccountingRuleId(accountingRule)) {
addControl('savingsReferenceAccountId', true);
// A current account pays no interest, has no write-off and does not use a transfers suspense account, so
// Interest On Savings, Write Off and Transfers In Suspense are not mapped. The keys stay savings-named
// because the backend's GL mapping reads them under those names.
// No Current Reference either: a current account's cash side posts to the branch cash GL (Financial
// Activity: Cash at Main Vault / Teller), the same one the cashier screen uses.
removeControl('savingsReferenceAccountId');
addControl('savingsControlAccountId', true);
addControl('transfersInSuspenseAccountId', true);
addControl('interestOnSavingsAccountId', true);
addControl('incomeFromFeeAccountId', true);
addControl('incomeFromPenaltyAccountId', true);
addControl('incomeFromInterestId', true);
addControl('writeOffAccountId', true);
addControl('overdraftPortfolioControlId', false);
if (this.accounting.isAccrualAccountingRuleId(accountingRule)) {
@@ -297,12 +295,9 @@ export class CurrentProductAccountingStepComponent implements OnInit {
} else {
removeControl('savingsReferenceAccountId');
removeControl('savingsControlAccountId');
removeControl('transfersInSuspenseAccountId');
removeControl('interestOnSavingsAccountId');
removeControl('incomeFromFeeAccountId');
removeControl('incomeFromPenaltyAccountId');
removeControl('incomeFromInterestId');
removeControl('writeOffAccountId');
removeControl('overdraftPortfolioControlId');
removeControl('feesReceivableAccountId');
removeControl('penaltiesReceivableAccountId');
@@ -345,14 +340,6 @@ export class CurrentProductAccountingStepComponent implements OnInit {
pillLabel: 'Asset',
pillClass: 'green',
rows: [
{
key: 'savingsReferenceAccountId',
label: 'Savings Reference Account',
inputLabel: 'Savings reference account',
controlName: 'savingsReferenceAccountId',
options: this.assetAccountOptions,
required: true
},
{
key: 'overdraftPortfolioControlId',
label: 'Overdraft Portfolio Control',
@@ -396,20 +383,12 @@ export class CurrentProductAccountingStepComponent implements OnInit {
rows: [
{
key: 'savingsControlAccountId',
label: 'Savings Control Account',
inputLabel: 'Savings control account',
label: 'Current Control Account',
inputLabel: 'Current control account',
controlName: 'savingsControlAccountId',
options: this.liabilityAccountOptions,
required: true
},
{
key: 'transfersInSuspenseAccountId',
label: 'Transfers In Suspense Account',
inputLabel: 'Transfers in suspense account',
controlName: 'transfersInSuspenseAccountId',
options: this.liabilityAccountOptions,
required: true
},
{
key: 'interestPayableAccountId',
label: 'Interest Payable Account',
@@ -430,32 +409,8 @@ export class CurrentProductAccountingStepComponent implements OnInit {
otherMappingsControlName: 'liabilitiesOtherGlMappings',
otherAccountOptions: this.liabilityAccountOptions
},
{
key: 'expenses',
title: 'Expenses',
pillLabel: 'Expense',
pillClass: 'amber',
rows: [
{
key: 'interestOnSavingsAccountId',
label: 'Interest On Savings Account',
inputLabel: 'Interest on savings account',
controlName: 'interestOnSavingsAccountId',
options: this.expenseAccountOptions,
required: true
},
{
key: 'writeOffAccountId',
label: 'Write Off Account',
inputLabel: 'Write off account',
controlName: 'writeOffAccountId',
options: this.expenseAccountOptions,
required: true
}
],
otherMappingsControlName: 'expensesOtherGlMappings',
otherAccountOptions: this.expenseAccountOptions
},
// No Expenses section: a current account pays no interest and has no write-off, so it maps no expense GL.
// The expensesOtherGlMappings control is kept, so an older product's saved expense mappings survive an edit.
{
key: 'income',
title: 'Income',
@@ -162,29 +162,16 @@
}}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Days in Year:</span>
<span class="flex-60">{{ CurrentProduct.interestCalculationDaysInYearType }} Days</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Auto Interest Post:</span>
<span class="flex-60">{{ CurrentProduct.autoPost | yesNo }}</span>
</div>
@if (
CurrentProduct.frequencyFixDay !== undefined &&
CurrentProduct.frequencyFixDay !== null &&
CurrentProduct.frequencyFixDay !== ''
) {
<div class="flex-100 layout-row">
<span class="flex-40">Frequency (Fix Day):</span>
<span class="flex-60">{{ CurrentProduct.frequencyFixDay }} Days</span>
</div>
}
@if (CurrentProduct.lastInterestDate) {
<div class="flex-100 layout-row">
<span class="flex-40">Last Interest Date:</span>
<span class="flex-60">{{ CurrentProduct.lastInterestDate | dateFormat }}</span>
</div>
}
<h3 class="mat-h3 flex-100">{{ 'labels.inputs.Settings' | translate }}</h3>
<mat-divider class="flex-100"></mat-divider>
@@ -282,39 +269,58 @@
</div>
@if (CurrentProduct.allowOverdraft) {
@if (CurrentProduct.avgBalanceCalcPeriod) {
<div class="flex-100 layout-row">
<span class="flex-40">Avg Balance Calculation Period:</span>
<span class="flex-60">{{ CurrentProduct.avgBalanceCalcPeriod }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">Maximum OD Limit:</span>
<span class="flex-60">₹ {{ CurrentProduct.overdraftLimit | formatNumber }}</span>
</div>
@if (CurrentProduct.validForPeriod) {
@if (CurrentProduct.odMinLimit) {
<div class="flex-100 layout-row">
<span class="flex-40">Valid for Period:</span>
<span class="flex-60">{{ CurrentProduct.validForPeriod }}</span>
</div>
}
@if (CurrentProduct.overdraftLimit !== undefined && CurrentProduct.overdraftLimit !== '') {
<div class="flex-100 layout-row">
<span class="flex-40">OD Limit %:</span>
<span class="flex-60">{{ CurrentProduct.overdraftLimit }}</span>
</div>
}
@if (
CurrentProduct.nominalAnnualInterestRateOverdraft !== undefined &&
CurrentProduct.nominalAnnualInterestRateOverdraft !== ''
) {
<div class="flex-100 layout-row">
<span class="flex-40">Interest on OD %:</span>
<span class="flex-60">{{ CurrentProduct.nominalAnnualInterestRateOverdraft }}</span>
<span class="flex-40">Minimum OD Limit:</span>
<span class="flex-60">₹ {{ CurrentProduct.odMinLimit | formatNumber }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">OD Account Closed with Zero Balance in Day Closing:</span>
<span class="flex-40">OD Security Type:</span>
<span class="flex-60">{{ CurrentProduct.odSecurityType | titlecase }}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Sanction Validity:</span>
<span class="flex-60">{{ CurrentProduct.validForPeriod }}</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Excess Drawing Allowed:</span>
<span class="flex-60">{{ CurrentProduct.odExcessAllowed | yesNo }}</span>
</div>
@if (CurrentProduct.odExcessAllowed) {
<div class="flex-100 layout-row">
<span class="flex-40">Maximum Excess:</span>
<span class="flex-60">{{ CurrentProduct.odExcessLimitPercent }} % of limit</span>
</div>
<div class="flex-100 layout-row">
<span class="flex-40">Penal Charge on Excess:</span>
<span class="flex-60">{{ CurrentProduct.odPenalRate }} % p.a.</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">OD Interest Rate:</span>
<span class="flex-60">{{ CurrentProduct.nominalAnnualInterestRateOverdraft }} % p.a.</span>
</div>
@if (CurrentProduct.minOverdraftForInterestCalculation) {
<div class="flex-100 layout-row">
<span class="flex-40">Minimum OD for Interest:</span>
<span class="flex-60">₹ {{ CurrentProduct.minOverdraftForInterestCalculation | formatNumber }}</span>
</div>
}
<div class="flex-100 layout-row">
<span class="flex-40">End OD Once Fully Repaid:</span>
<span class="flex-60">{{ CurrentProduct.odAccountClosedZeroBalanceInDayClosing | yesNo }}</span>
</div>
}
@@ -453,15 +459,6 @@
<div class="spa-gl-section">
<h5 class="mat-h5">Assets</h5>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Savings reference account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
CurrentProduct.savingsReferenceAccountId,
CurrentProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Overdraft portfolio control</span>
<span class="spa-gl-row__value">{{
@@ -503,7 +500,7 @@
<div class="spa-gl-section">
<h5 class="mat-h5">Liabilities</h5>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Savings control account</span>
<span class="spa-gl-row__label">Current control account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
CurrentProduct.savingsControlAccountId,
@@ -511,15 +508,6 @@
)
}}</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(
CurrentProduct.transfersInSuspenseAccountId,
CurrentProductsTemplate?.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">{{
@@ -542,28 +530,6 @@
}
</div>
<div class="spa-gl-section">
<h5 class="mat-h5">Expenses</h5>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Interest on savings account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
CurrentProduct.interestOnSavingsAccountId,
CurrentProductsTemplate?.accountingMappingOptions.expenseAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Write off account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
CurrentProduct.writeOffAccountId,
CurrentProductsTemplate?.accountingMappingOptions.expenseAccountOptions
)
}}</span>
</div>
</div>
<div class="spa-gl-section">
<h5 class="mat-h5">Income</h5>
<div class="spa-gl-row">
@@ -110,72 +110,126 @@
<span>Overdraft (OD) Facility</span>
</div>
<div class="sps-body">
<!-- "Is Overdraft Allowed" is ticked on the Terms tab; this section follows it. -->
@if (!CurrentProductSettingsForm.value.allowOverdraft) {
<p class="sps-od-check-hint">
Overdraft is not allowed on this product. Tick "Is Overdraft Allowed" on the Terms tab to set the OD limits.
</p>
}
<div class="sps-od-layout">
<!-- Left: checkboxes -->
<div class="sps-od-checks">
<div class="sps-od-check-row">
<mat-checkbox formControlName="allowOverdraft">
<span>Is Overdraft Allowed</span>
</mat-checkbox>
</div>
@if (CurrentProductSettingsForm.value.allowOverdraft) {
<div class="sps-od-check-row">
<mat-checkbox formControlName="odAccountClosedZeroBalanceInDayClosing">
<span>OD Account Closed with Zero Balance in Day Closing</span>
<span>End OD Once Fully Repaid (One-time OD only)</span>
</mat-checkbox>
<p class="sps-od-check-hint">
Automatically closes OD accounts when balance reaches zero during day-end closing.
At day-end, withdraws the OD sanction when the debit balance is cleared to zero. Only the OD ends, not
the account. Leave off for a regular OD: a customer who uses it and repays the same day would lose it.
</p>
</div>
}
</div>
<!-- Right: OD details panel — only visible when OD is allowed -->
<!-- Right: the OD scheme every account's sanction is held to — only visible when OD is allowed -->
@if (CurrentProductSettingsForm.value.allowOverdraft) {
<div class="sps-od-panel">
<div class="sps-od-radio-group">
<span class="sps-od-radio-label">Average Balance Calculation in Period</span>
<mat-radio-group formControlName="avgBalanceCalcPeriod" class="sps-od-radio-choices">
<mat-radio-button value="6M">6M</mat-radio-button>
<mat-radio-button value="1Y">1Y</mat-radio-button>
<mat-radio-button value="3Y">3Y</mat-radio-button>
</mat-radio-group>
</div>
<div class="sps-od-radio-group">
<span class="sps-od-radio-label">Valid for Period</span>
<mat-radio-group formControlName="validForPeriod" class="sps-od-radio-choices">
<mat-radio-button value="3M">3M</mat-radio-button>
<mat-radio-button value="1Y">1Y</mat-radio-button>
<mat-radio-button value="3Y">3Y</mat-radio-button>
</mat-radio-group>
</div>
<div class="sps-od-inputs">
<mat-form-field appearance="outline" class="sps-field">
<mat-label>OD Limit %</mat-label>
<mat-label>Maximum OD Limit (₹)</mat-label>
<input
type="number"
matInput
formControlName="overdraftLimit"
placeholder="e.g. 15"
min="0"
step="0.01"
placeholder="e.g. 500000"
min="1"
required
/>
<mat-hint>Highest per account</mat-hint>
<mat-error>Enter the maximum OD limit</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="sps-field">
<mat-label>Interest on OD %</mat-label>
<input
type="number"
matInput
formControlName="nominalAnnualInterestRateOverdraft"
placeholder="e.g. 8.5"
min="0"
step="0.01"
/>
<mat-label>Minimum OD Limit (₹)</mat-label>
<input type="number" matInput formControlName="odMinLimit" placeholder="e.g. 10000" min="0" />
<mat-hint>Lowest per account</mat-hint>
@if (CurrentProductSettingsForm.get('odMinLimit')?.hasError('exceedsMax')) {
<mat-error>Cannot exceed the maximum OD limit</mat-error>
} @else {
<mat-error>Cannot be negative</mat-error>
}
</mat-form-field>
</div>
<div class="sps-od-radio-group">
<span class="sps-od-radio-label">OD Security Type</span>
<mat-radio-group formControlName="odSecurityType" class="sps-od-radio-choices">
@for (option of odSecurityTypeOptions; track option.value) {
<mat-radio-button [value]="option.value">{{ option.label }}</mat-radio-button>
}
</mat-radio-group>
<p class="sps-od-check-hint">{{ selectedSecurityTypeHint }}</p>
</div>
<div class="sps-od-radio-group">
<span class="sps-od-radio-label">Sanction Validity</span>
<mat-radio-group formControlName="validForPeriod" class="sps-od-radio-choices">
@for (option of odValidityOptions; track option.value) {
<mat-radio-button [value]="option.value">{{ option.label }}</mat-radio-button>
}
</mat-radio-group>
</div>
<div class="sps-od-radio-group">
<span class="sps-od-radio-label">Excess Drawing (Over Limit)</span>
<mat-checkbox formControlName="odExcessAllowed">
<span>Allow withdrawal beyond the sanctioned limit</span>
</mat-checkbox>
@if (!CurrentProductSettingsForm.value.odExcessAllowed) {
<p class="sps-od-check-hint">Off: a withdrawal that would cross the limit is refused.</p>
} @else {
<div class="sps-od-inputs">
<mat-form-field appearance="outline" class="sps-field">
<mat-label>Maximum Excess (% of limit)</mat-label>
<input
type="number"
matInput
formControlName="odExcessLimitPercent"
placeholder="e.g. 10"
min="0"
step="0.01"
required
/>
<mat-hint>Beyond this, refused</mat-hint>
<mat-error>Enter above 0% and up to 25%</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="sps-field">
<mat-label>Penal Charge on Excess (% p.a.)</mat-label>
<input
type="number"
matInput
formControlName="odPenalRate"
placeholder="e.g. 2"
min="0"
step="0.01"
required
/>
<mat-hint>Only on the excess amount</mat-hint>
<mat-error>Enter above 0% and up to 50%</mat-error>
</mat-form-field>
</div>
@if (excessExample) {
<p class="sps-od-check-hint">{{ excessExample }}</p>
}
}
</div>
<p class="sps-od-check-hint">
Each account is sanctioned its own limit within this range, valid for the period above. The OD interest
rate is set on the Terms tab.
</p>
</div>
}
</div>
@@ -7,7 +7,7 @@
*/
import { Component, OnInit, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { AbstractControl, UntypedFormGroup, UntypedFormBuilder, ValidationErrors, 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';
@@ -40,6 +40,21 @@ export class CurrentProductSettingsStepComponent implements OnInit {
{ id: 'YEARS', value: 'value.years' }
];
/** How long an OD sanction on this product runs before it must be renewed. */
odValidityOptions = [
{ value: '3M', label: '3 Months' },
{ value: '6M', label: '6 Months' },
{ value: '1Y', label: '1 Year' },
{ value: '2Y', label: '2 Years' },
{ value: '3Y', label: '3 Years' }
];
odSecurityTypeOptions = [
{ value: 'CLEAN', label: 'Clean', hint: 'No security needed' },
{ value: 'SECURED', label: 'Secured', hint: 'Against FD lien, property, etc.' },
{ value: 'BOTH', label: 'Both', hint: 'Decided at each sanction' }
];
constructor() {
this.createCurrentProductSettingsForm();
this.setConditionalControls();
@@ -72,14 +87,15 @@ export class CurrentProductSettingsStepComponent implements OnInit {
daysToEscheat: isEditing
? this.CurrentProductsTemplate.daysToEscheat || DORMANCY_DEFAULTS.daysToEscheat
: DORMANCY_DEFAULTS.daysToEscheat,
// Overdraft fields
// Overdraft scheme (the OD interest rate lives on the Terms tab)
allowOverdraft: isEditing ? (this.CurrentProductsTemplate.allowOverdraft ?? false) : false,
overdraftLimit: isEditing ? this.CurrentProductsTemplate.overdraftLimit : '',
nominalAnnualInterestRateOverdraft: isEditing
? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft
: '',
avgBalanceCalcPeriod: isEditing ? this.CurrentProductsTemplate.avgBalanceCalcPeriod || '1Y' : '1Y',
validForPeriod: isEditing ? this.CurrentProductsTemplate.validForPeriod || '1Y' : '1Y',
odMinLimit: isEditing ? this.CurrentProductsTemplate.odMinLimit : '',
odSecurityType: isEditing ? this.CurrentProductsTemplate.odSecurityType || 'CLEAN' : 'CLEAN',
validForPeriod: this.validityOrDefault(isEditing ? this.CurrentProductsTemplate.validForPeriod : null),
odExcessAllowed: isEditing ? !!this.CurrentProductsTemplate.odExcessAllowed : false,
odExcessLimitPercent: isEditing ? this.CurrentProductsTemplate.odExcessLimitPercent : '',
odPenalRate: isEditing ? this.CurrentProductsTemplate.odPenalRate : '',
odAccountClosedZeroBalanceInDayClosing: isEditing
? (this.CurrentProductsTemplate.odAccountClosedZeroBalanceInDayClosing ?? false)
: false
@@ -123,23 +139,123 @@ export class CurrentProductSettingsStepComponent implements OnInit {
daysToInactive: [DORMANCY_DEFAULTS.daysToInactive],
daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy],
daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat],
// Overdraft (OD) Facility
// Overdraft (OD) Facility: the scheme an account's OD sanction is held to
allowOverdraft: [false],
overdraftLimit: [
'',
[Validators.min(0)]
],
nominalAnnualInterestRateOverdraft: [
'',
[Validators.min(0)]
],
avgBalanceCalcPeriod: ['1Y'],
overdraftLimit: [''],
odMinLimit: [''],
odSecurityType: ['CLEAN'],
validForPeriod: ['1Y'],
// Excess drawing past the sanctioned limit: off means it is refused
odExcessAllowed: [false],
odExcessLimitPercent: [''],
odPenalRate: [''],
odAccountClosedZeroBalanceInDayClosing: [false]
});
}
get selectedSecurityTypeHint(): string {
const selected = this.CurrentProductSettingsForm.get('odSecurityType')?.value;
return this.odSecurityTypeOptions.find((option) => option.value === selected)?.hint ?? '';
}
/** A worked example on the maximum limit, so the percentages read as rupees. */
get excessExample(): string {
const value = this.CurrentProductSettingsForm.value;
const limit = Number(value.overdraftLimit);
const percent = Number(value.odExcessLimitPercent);
const penal = Number(value.odPenalRate);
if (!limit || !percent) return '';
const excess = (limit * percent) / 100;
const inr = (n: number) => '₹' + n.toLocaleString('en-IN', { maximumFractionDigits: 2 });
let text = `On a ${inr(limit)} limit, up to ${inr(excess)} more may be drawn.`;
if (penal) {
text += ` If ${inr(excess)} stays over the limit for a day, the penal charge is ${inr((excess * penal) / 100 / 365)}.`;
}
return text;
}
private validityOrDefault(value: string | null): string {
return this.odValidityOptions.some((option) => option.value === value) ? value : '1Y';
}
/** The minimum sanction may not exceed the maximum. */
private odMinWithinMax = (control: AbstractControl): ValidationErrors | null => {
const max = Number(this.CurrentProductSettingsForm?.get('overdraftLimit')?.value);
const min = Number(control.value);
if (control.value === '' || control.value === null || !max) return null;
return min > max ? { exceedsMax: true } : null;
};
private applyOverdraftValidators(allowed: boolean) {
const max = this.CurrentProductSettingsForm.get('overdraftLimit');
const min = this.CurrentProductSettingsForm.get('odMinLimit');
const security = this.CurrentProductSettingsForm.get('odSecurityType');
const validity = this.CurrentProductSettingsForm.get('validForPeriod');
if (allowed) {
max.setValidators([
Validators.required,
Validators.min(1)
]);
min.setValidators([
Validators.min(0),
this.odMinWithinMax
]);
security.setValidators([Validators.required]);
validity.setValidators([Validators.required]);
} else {
[
max,
min,
security,
validity
].forEach((control) => control.clearValidators());
}
[
max,
min,
security,
validity
].forEach((control) => control.updateValueAndValidity({ emitEvent: false }));
this.applyExcessValidators();
}
/** The excess cap and penal rate are required only while both OD and excess drawing are on. */
private applyExcessValidators() {
const form = this.CurrentProductSettingsForm;
const required = !!form.get('allowOverdraft').value && !!form.get('odExcessAllowed').value;
const percent = form.get('odExcessLimitPercent');
const penal = form.get('odPenalRate');
percent.setValidators(
required
? [
Validators.required,
Validators.min(0.01),
Validators.max(25)
]
: []
);
penal.setValidators(
required
? [
Validators.required,
Validators.min(0.01),
Validators.max(50)
]
: []
);
percent.updateValueAndValidity({ emitEvent: false });
penal.updateValueAndValidity({ emitEvent: false });
}
setConditionalControls() {
this.CurrentProductSettingsForm.get('allowOverdraft').valueChanges.subscribe((allowed: boolean) =>
this.applyOverdraftValidators(!!allowed)
);
this.CurrentProductSettingsForm.get('odExcessAllowed').valueChanges.subscribe(() => this.applyExcessValidators());
this.CurrentProductSettingsForm.get('overdraftLimit').valueChanges.subscribe(() =>
this.CurrentProductSettingsForm.get('odMinLimit').updateValueAndValidity({ emitEvent: false })
);
this.CurrentProductSettingsForm.get('enableLockinPeriod').valueChanges.subscribe((enabled: boolean) => {
const freq = this.CurrentProductSettingsForm.get('lockinPeriodFrequency');
const type = this.CurrentProductSettingsForm.get('lockinPeriodFrequencyType');
@@ -168,10 +284,15 @@ export class CurrentProductSettingsStepComponent implements OnInit {
if (!formValue.allowOverdraft) {
delete formValue.overdraftLimit;
delete formValue.nominalAnnualInterestRateOverdraft;
delete formValue.avgBalanceCalcPeriod;
delete formValue.odMinLimit;
delete formValue.odSecurityType;
delete formValue.validForPeriod;
delete formValue.odAccountClosedZeroBalanceInDayClosing;
delete formValue.odExcessAllowed;
}
if (!formValue.allowOverdraft || !formValue.odExcessAllowed) {
delete formValue.odExcessLimitPercent;
delete formValue.odPenalRate;
}
return formValue;
@@ -13,20 +13,76 @@
<span>Interest On OD</span>
</div>
<div class="spt-body">
<mat-form-field appearance="outline" class="spt-field" style="max-width: 320px">
<mat-label>Interest Posting Frequency *</mat-label>
<mat-select formControlName="interestPostingPeriodType" required>
@for (item of interestPostingPeriodTypeData; track item) {
<mat-option [value]="item.id">
{{ item.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
<mat-error>
Interest Posting Frequency {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
@if (allowOverdraftControl) {
<div class="spt-toggle-card">
<mat-checkbox [formControl]="allowOverdraftControl">
<span>Is Overdraft Allowed</span>
</mat-checkbox>
</div>
}
@if (!isOverdraftAllowed) {
<div class="spt-note">
<span>ℹ️</span>
<span>Tick "Is Overdraft Allowed" to set the OD interest rate. The OD limits are set on the Settings tab.</span>
</div>
}
<div class="spt-footer__grid">
@if (isOverdraftAllowed) {
<mat-form-field appearance="outline" class="spt-field">
<mat-label>OD Interest Rate (% p.a.)</mat-label>
<input
type="number"
matInput
formControlName="nominalAnnualInterestRateOverdraft"
placeholder="e.g. 12.5"
min="0"
step="0.01"
required
/>
<mat-hint>Charged on the debit balance</mat-hint>
<mat-error>Enter a rate above 0% and up to 50%</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="spt-field">
<mat-label>Minimum OD for Interest (₹)</mat-label>
<input
type="number"
matInput
formControlName="minOverdraftForInterestCalculation"
placeholder="e.g. 100"
min="0"
/>
<mat-hint>No interest while the debit balance is below this</mat-hint>
<mat-error>Cannot be negative</mat-error>
</mat-form-field>
}
<mat-form-field appearance="outline" class="spt-field">
<mat-label>Interest Posting Frequency</mat-label>
<mat-select formControlName="interestPostingPeriodType" required>
@for (item of interestPostingPeriodTypeData; track item.id) {
<mat-option [value]="item.id">
{{ item.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
<mat-hint>Monthly, as RBI requires for advances</mat-hint>
<mat-error>
Interest Posting Frequency {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="spt-field">
<mat-label>Days in Year</mat-label>
<mat-select formControlName="interestCalculationDaysInYearType">
@for (item of daysInYearData; track item.id) {
<mat-option [value]="item.id">{{ item.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="spt-footer">
<div class="spt-footer__grid">
@@ -43,25 +99,17 @@
}
</div>
</div>
<mat-form-field appearance="outline" class="spt-field">
<mat-label>Frequency (Fix Day)</mat-label>
<input type="number" matInput formControlName="frequencyFixDay" />
<mat-hint>Days</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="spt-field">
<mat-label>Last Interest Date</mat-label>
<input matInput [matDatepicker]="lastInterestDatePicker" formControlName="lastInterestDate" />
<mat-datepicker-toggle matSuffix [for]="lastInterestDatePicker"></mat-datepicker-toggle>
<mat-datepicker #lastInterestDatePicker></mat-datepicker>
</mat-form-field>
</div>
<div class="spt-note">
<span>⚠️</span>
<span>Daily product-based interest accrual enabled — interest calculated EOD on daily closing balance.</span>
</div>
@if (isOverdraftAllowed) {
<div class="spt-note">
<span>ℹ️</span>
<span>
Rule: daily OD interest = end-of-day debit balance × rate ÷ days in year, applied to the account at the
posting frequency.
</span>
</div>
}
</div>
</div>
</div>
@@ -7,12 +7,21 @@
*/
import { Component, OnInit, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { AbstractControl, UntypedFormControl, UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Posting-period ids an OD's interest may be applied on: RBI requires monthly for advances; quarterly is the outer limit. */
const OD_POSTING_PERIOD_IDS = [
4,
5
];
/**
* Interest on OD: the rate and rules an overdraft on this product is charged by. Interest is worked out daily on the
* end-of-day debit balance and applied to the account at the posting frequency. The fields apply only when the
* Settings tab allows an overdraft, so allowOverdraft (that tab's control) switches their validators on and off.
*/
@Component({
selector: 'mifosx-current-product-terms-step',
templateUrl: './current-product-terms-step.component.html',
@@ -24,14 +33,18 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
})
export class CurrentProductTermsStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private dateUtils = inject(Dates);
private settingsService = inject(SettingsService);
@Input() CurrentProductsTemplate: any;
/** The Settings tab's allowOverdraft control. */
@Input() allowOverdraft: AbstractControl;
CurrentProductTermsForm: UntypedFormGroup;
interestPostingPeriodTypeData: any[] = [];
daysInYearData: Array<{ id: number; label: string }> = [
{ id: 365, label: '365 Days (Actual)' },
{ id: 360, label: '360 Days' }
];
yesNoOptions: Array<{ value: boolean; label: string }> = [
{ value: true, label: 'Yes' },
{ value: false, label: 'No' }
@@ -41,29 +54,59 @@ export class CurrentProductTermsStepComponent implements OnInit {
this.createCurrentProductTermsForm();
}
get isOverdraftAllowed(): boolean {
return !!this.allowOverdraft?.value;
}
/**
* The Settings tab's own control, so the box can be ticked here without going to that tab first; the two
* checkboxes are one control and always agree.
*/
get allowOverdraftControl(): UntypedFormControl | null {
return this.allowOverdraft instanceof UntypedFormControl ? this.allowOverdraft : null;
}
ngOnInit() {
this.interestPostingPeriodTypeData = this.CurrentProductsTemplate.interestPostingPeriodTypeOptions || [];
const postingOptions: any[] = this.CurrentProductsTemplate.interestPostingPeriodTypeOptions || [];
const odPostingOptions = postingOptions.filter((option: any) => OD_POSTING_PERIOD_IDS.includes(option.id));
this.interestPostingPeriodTypeData = odPostingOptions.length ? odPostingOptions : postingOptions;
this.setYesNoOptions(this.CurrentProductsTemplate?.yesNoOptions);
const isEditing = !!this.CurrentProductsTemplate.id;
const defaultPosting =
this.interestPostingPeriodTypeData.find((option: any) => option.id === 4)?.id ??
this.interestPostingPeriodTypeData[0]?.id ??
'';
const storedPosting = this.CurrentProductsTemplate.interestPostingPeriodType?.id;
const storedDaysInYear = this.CurrentProductsTemplate.interestCalculationDaysInYearType?.id;
const storedPostingOffered = this.interestPostingPeriodTypeData.some((option: any) => option.id === storedPosting);
this.CurrentProductTermsForm.patchValue({
interestPostingPeriodType: this.CurrentProductsTemplate.interestPostingPeriodType?.id || '',
autoPost: isEditing ? (this.CurrentProductsTemplate.autoPost ?? true) : true,
frequencyFixDay: isEditing ? (this.CurrentProductsTemplate.frequencyFixDay ?? 30) : 30,
lastInterestDate: this.parseDate(this.CurrentProductsTemplate.lastInterestDate)
interestPostingPeriodType: storedPostingOffered ? storedPosting : defaultPosting,
interestCalculationDaysInYearType: storedDaysInYear === 360 ? 360 : 365,
nominalAnnualInterestRateOverdraft: isEditing ? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft : '',
minOverdraftForInterestCalculation: isEditing ? this.CurrentProductsTemplate.minOverdraftForInterestCalculation : '',
autoPost: isEditing ? (this.CurrentProductsTemplate.autoPost ?? true) : true
});
// The Settings tab patches allowOverdraft in its own ngOnInit, after this one, so follow its changes as well as
// its current value.
this.applyOverdraftValidators(this.isOverdraftAllowed);
this.allowOverdraft?.valueChanges.subscribe((allowed: boolean) => this.applyOverdraftValidators(!!allowed));
}
private parseDate(dateValue: any): Date | null {
if (!dateValue) return null;
if (Array.isArray(dateValue)) {
return new Date(dateValue[0], dateValue[1] - 1, dateValue[2]);
}
if (dateValue instanceof Date) {
return dateValue;
}
return new Date(dateValue);
private applyOverdraftValidators(allowed: boolean) {
const rate = this.CurrentProductTermsForm.get('nominalAnnualInterestRateOverdraft');
rate.setValidators(
allowed
? [
Validators.required,
Validators.min(0.01),
Validators.max(50)
]
: [Validators.min(0)]
);
rate.updateValueAndValidity({ emitEvent: false });
}
private setYesNoOptions(options: any[]) {
@@ -90,23 +133,25 @@ export class CurrentProductTermsStepComponent implements OnInit {
'',
Validators.required
],
autoPost: [true],
frequencyFixDay: [
30,
interestCalculationDaysInYearType: [365],
nominalAnnualInterestRateOverdraft: [
'',
[Validators.min(0)]
],
lastInterestDate: ['']
minOverdraftForInterestCalculation: [
'',
[Validators.min(0)]
],
autoPost: [true]
});
}
get CurrentProductTerms() {
const formValue = { ...this.CurrentProductTermsForm.value };
const dateFormat = this.settingsService.dateFormat;
if (formValue.lastInterestDate instanceof Date) {
formValue.lastInterestDate = this.dateUtils.formatDate(formValue.lastInterestDate, dateFormat) || '';
if (!this.isOverdraftAllowed) {
delete formValue.nominalAnnualInterestRateOverdraft;
delete formValue.minOverdraftForInterestCalculation;
}
return { ...formValue };
return formValue;
}
}
@@ -50,6 +50,7 @@
<div class="cp-tab-content">
<mifosx-current-product-terms-step
[CurrentProductsTemplate]="currentProductAndTemplate"
[allowOverdraft]="settingsForm.get('allowOverdraft')"
></mifosx-current-product-terms-step>
</div>
</mat-tab>
@@ -257,16 +257,10 @@
</span>
</div>
}
@if (currentProduct.frequencyFixDay) {
@if (currentProduct.interestCalculationDaysInYearType?.id) {
<div class="vsp-kv">
<span class="vsp-k">Frequency Fix Day</span>
<span class="vsp-v">{{ currentProduct.frequencyFixDay }}</span>
</div>
}
@if (currentProduct.lastInterestDate) {
<div class="vsp-kv">
<span class="vsp-k">Last Interest Date</span>
<span class="vsp-v">{{ currentProduct.lastInterestDate | dateFormat }}</span>
<span class="vsp-k">Days in Year</span>
<span class="vsp-v">{{ currentProduct.interestCalculationDaysInYearType.id }} Days</span>
</div>
}
</div>
@@ -396,37 +390,57 @@
<span class="vsp-k">{{
'labels.inputs.Minimum Overdraft Required for Interest Calculation' | translate
}}</span>
<span class="vsp-v">{{ currentProduct.minOverdraftForInterestCalculation | formatNumber }}</span>
<span class="vsp-v">₹ {{ currentProduct.minOverdraftForInterestCalculation | formatNumber }}</span>
</div>
}
@if (currentProduct.nominalAnnualInterestRateOverdraft) {
<div class="vsp-kv">
<span class="vsp-k">{{ 'labels.inputs.Nominal Annual Interest for Overdraft' | translate }}</span>
<span class="vsp-v">{{ currentProduct.nominalAnnualInterestRateOverdraft | formatNumber }} %</span>
<span class="vsp-v">{{ currentProduct.nominalAnnualInterestRateOverdraft | formatNumber }} % p.a.</span>
</div>
}
@if (currentProduct.overdraftLimit) {
<div class="vsp-kv">
<span class="vsp-k">{{ 'labels.inputs.Maximum Overdraft Amount Limit' | translate }}</span>
<span class="vsp-v">{{ currentProduct.overdraftLimit | formatNumber }}</span>
<span class="vsp-v">₹ {{ currentProduct.overdraftLimit | formatNumber }}</span>
</div>
}
@if (currentProduct.allowOverdraft) {
@if (currentProduct.avgBalanceCalcPeriod) {
@if (currentProduct.odMinLimit) {
<div class="vsp-kv">
<span class="vsp-k">Average Balance Calculation Period</span>
<span class="vsp-v">{{ currentProduct.avgBalanceCalcPeriod }}</span>
<span class="vsp-k">Minimum OD Limit</span>
<span class="vsp-v">₹ {{ currentProduct.odMinLimit | formatNumber }}</span>
</div>
}
@if (currentProduct.odSecurityType) {
<div class="vsp-kv">
<span class="vsp-k">OD Security Type</span>
<span class="vsp-v">{{ currentProduct.odSecurityType | titlecase }}</span>
</div>
}
@if (currentProduct.validForPeriod) {
<div class="vsp-kv">
<span class="vsp-k">Valid for Period</span>
<span class="vsp-k">Sanction Validity</span>
<span class="vsp-v">{{ currentProduct.validForPeriod }}</span>
</div>
}
<div class="vsp-kv">
<span class="vsp-k">Excess Drawing Allowed</span>
<span class="vsp-v">{{ currentProduct.odExcessAllowed | yesNo }}</span>
</div>
@if (currentProduct.odExcessAllowed) {
<div class="vsp-kv">
<span class="vsp-k">Maximum Excess</span>
<span class="vsp-v">{{ currentProduct.odExcessLimitPercent | formatNumber }} % of limit</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Penal Charge on Excess</span>
<span class="vsp-v">{{ currentProduct.odPenalRate | formatNumber }} % p.a.</span>
</div>
}
@if (currentProduct.odAccountClosedZeroBalanceInDayClosing !== undefined) {
<div class="vsp-kv">
<span class="vsp-k">OD Account Closed with Zero Balance in Day Closing</span>
<span class="vsp-k">End OD Once Fully Repaid</span>
<span class="vsp-v">{{ currentProduct.odAccountClosedZeroBalanceInDayClosing | yesNo }}</span>
</div>
}
@@ -588,10 +602,34 @@
<div class="vsp-section-ic vsp-section-ic--green"><fa-icon icon="book"></fa-icon></div>
<h3>{{ 'labels.heading.Accounting' | translate }}</h3>
</div>
<mifosx-view-savings-accounting-details
[accountingRule]="currentProduct.accountingRule"
[accountingMappings]="currentProduct.accountingMappings"
></mifosx-view-savings-accounting-details>
<!--
The current product's own mappings only, under current-account names. The shared savings view is not used:
it labels everything as savings and lists savings-only GLs (Saving reference, Interest on savings...), and
while current and savings products share mapping rows by id it would show a savings product's GLs here.
There is no cash (reference) GL: a current account's cash side posts to the branch cash GL.
-->
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">Type</span>
<span class="vsp-v">{{ currentProduct.accountingRule?.value || 'Cash' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Cash GL (deposit / withdrawal)</span>
<span class="vsp-v">Branch cash GL — Financial Activity: Cash at Main Vault / Teller</span>
</div>
@for (gl of currentAccountingRows; track gl.label) {
<div class="vsp-kv">
<span class="vsp-k">{{ gl.label }}</span>
<span class="vsp-v">
@if (gl.account) {
({{ gl.account.glCode }}) {{ gl.account.name }}
} @else {
—
}
</span>
</div>
}
</div>
</section>
<!-- OTHER GL MAPPINGS -->
@@ -22,7 +22,6 @@ import {
MatRowDef,
MatRow
} from '@angular/material/table';
import { ViewSavingsAccountingDetailsComponent } from '../../../../shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component';
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
import { YesnoPipe } from '../../../../pipes/yesno.pipe';
@@ -47,7 +46,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow,
MatRowDef,
MatRow,
ViewSavingsAccountingDetailsComponent,
DateFormatPipe,
FormatNumberPipe,
YesnoPipe
@@ -102,4 +100,20 @@ export class CurrentProductGeneralTabComponent {
isCashOrAccrualAccounting(): boolean {
return this.accounting.isCashOrAccrualAccounting(this.currentProduct.accountingRule);
}
/** The GLs a current product maps, by the names the Accounting tab gives them. */
get currentAccountingRows(): Array<{ label: string; account: any }> {
const m = this.currentProduct?.accountingMappings || {};
const rows = [
{ label: 'Current Control (Liability)', account: m.savingsControlAccount },
{ label: 'Overdraft Portfolio Control (Asset)', account: m.overdraftPortfolioControl },
{ label: 'Income From Fees', account: m.incomeFromFeeAccount },
{ label: 'Income From Penalties', account: m.incomeFromPenaltyAccount },
{ label: 'Income From Interest', account: m.incomeFromInterest }
];
if (this.currentProduct?.isDormancyTrackingActive) {
rows.push({ label: 'Escheat Liability', account: m.escheatLiabilityAccount });
}
return rows;
}
}