feat: recurring deposit enhancements, dashboard, chart, and account view updates

This commit is contained in:
2026-10-12 10:23:10 +05:30
parent 6b0f218b70
commit 2d2eac8d03
69 changed files with 3619 additions and 595 deletions
@@ -195,6 +195,13 @@ export class CreateRecurringDepositsAccountComponent {
});
return;
}
if (this.recurringDepositsAccountProductTemplate?.introducerRequired && !detailsStep?.introducerId) {
this.alertService.alert({
type: 'Error',
message: 'This product requires an introducer. Please choose an existing customer as introducer before submitting.'
});
return;
}
if (!this.selectedTenureValue || this.selectedTenureFrequencyId === null) {
this.alertService.alert({
type: 'Error',
@@ -209,6 +216,39 @@ export class CreateRecurringDepositsAccountComponent {
});
return;
}
// Same limits the server enforces - checked here so the message names the numbers before anything is sent.
const minInstallment = Number(this.recurringDepositsAccountProductTemplate?.minDepositAmount) || 0;
const maxInstallment = Number(this.recurringDepositsAccountProductTemplate?.maxDepositAmount) || 0;
if (minInstallment > 0 && this.selectedDepositAmount < minInstallment) {
this.alertService.alert({
type: 'Error',
message: `Installment ${this.selectedDepositAmount} is below this product's minimum of ${minInstallment}.`
});
return;
}
if (maxInstallment > 0 && this.selectedDepositAmount > maxInstallment) {
this.alertService.alert({
type: 'Error',
message: `Installment ${this.selectedDepositAmount} is above this product's maximum of ${maxInstallment}.`
});
return;
}
if (
this.recurringDepositsAccountCurrencyStep?.autoDebitFacility &&
detailsStep?.selectedLinkedAccount?.accountType !== 'SAVINGS'
) {
this.alertService.alert({
type: 'Error',
message:
'Auto Debit collects each installment from the linked account. Link a Savings account under Deposit Account Link, or turn Auto Debit off.'
});
return;
}
const lienError = this.recurringDepositsAccountCurrencyStep?.lienPercentageError;
if (lienError) {
this.alertService.alert({ type: 'Error', message: `Loan against this RD: ${lienError}` });
return;
}
if (!detailsStep?.selectedCustomerSegment) {
this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' });
return;
@@ -255,8 +295,16 @@ export class CreateRecurringDepositsAccountComponent {
isCalendarInherited: pt?.isCalendarInherited ?? false,
isMandatoryDeposit: pt?.isMandatoryDeposit ?? false,
allowWithdrawal: pt?.allowWithdrawal ?? false,
adjustAdvanceTowardsFuturePayments: pt?.adjustAdvanceTowardsFuturePayments ?? false,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false
// Money paid ahead of a due date counts towards the next installments. Without this an advance cash
// payment stayed unallocated: the schedule kept showing the installment unpaid, so Auto Debit collected it
// a second time and the missed-installment penalty charged it. The RD product form has no such option, so
// the product's value was always false.
adjustAdvanceTowardsFuturePayments: true,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false,
// Lien Marked products only - the server ignores it otherwise and caps it at the product's limit.
...(currencyStep?.lienAllowed && currencyStep?.lienPercentage != null
? { lienPercentage: Number(currencyStep.lienPercentage) }
: {})
};
// Charges from product template
@@ -233,6 +233,11 @@ export class EditRecurringDepositAccountComponent {
});
return;
}
const lienError = this.recurringDepositsAccountCurrencyStep?.lienPercentageError;
if (lienError) {
this.alertService.alert({ type: 'Error', message: `Loan against this RD: ${lienError}` });
return;
}
if (!detailsStep?.selectedCustomerSegment) {
this.alertService.alert({ type: 'Error', message: 'Please select a Customer Segment before submitting.' });
return;
@@ -275,8 +280,13 @@ export class EditRecurringDepositAccountComponent {
isCalendarInherited: pt?.isCalendarInherited ?? false,
isMandatoryDeposit: pt?.isMandatoryDeposit ?? false,
allowWithdrawal: pt?.allowWithdrawal ?? false,
adjustAdvanceTowardsFuturePayments: pt?.adjustAdvanceTowardsFuturePayments ?? false,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false
// See create-recurring-deposits-account: advance payments must count towards the next installments.
adjustAdvanceTowardsFuturePayments: true,
autoDebitFacility: currencyStep?.autoDebitFacility ?? pt?.autoDebitFacility ?? false,
// Lien Marked products only - the server ignores it otherwise and caps it at the product's limit.
...(currencyStep?.lienAllowed && currencyStep?.lienPercentage != null
? { lienPercentage: Number(currencyStep.lienPercentage) }
: {})
};
const rawCharges: any[] = pt?.charges ?? [];
@@ -70,8 +70,9 @@ export class ActivateRecurringDepositsAccountComponent implements OnInit {
*/
createActivateRecurringDepositsAccountForm() {
this.activateRecurringDepositsAccountForm = this.formBuilder.group({
// Defaults to the business date (the branch's working day), which is what the account is normally activated on.
activatedOnDate: [
'',
this.settingsService.businessDate,
Validators.required
]
});
@@ -72,8 +72,9 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit {
*/
createApproveRecurringDepositsAccountForm() {
this.approveRecurringDepositsAccountForm = this.formBuilder.group({
// Defaults to the business date (the branch's working day), which is what the account is normally approved on.
approvedOnDate: [
'',
this.settingsService.businessDate,
Validators.required
],
note: ['']
@@ -13,6 +13,16 @@
<div class="layout-column">
<h3 class="mat-h3 flex-fill">{{ title }}</h3>
@if (totalLienAmount > 0) {
<p class="lien-warn">
This deposit is pledged as loan security (lien {{ totalLienAmount | number: '1.2-2' }} -
@for (lien of liens; track lien.loanId; let last = $last) {
loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
}
). It cannot be closed until the loan is repaid or the guarantee is released.
</p>
}
<mat-form-field (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label>
<input
@@ -154,7 +164,7 @@
<button type="button" mat-raised-button [routerLink]="['../general']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!closeRecurringDepositForm.valid">
<button mat-raised-button color="primary" [disabled]="!closeRecurringDepositForm.valid || totalLienAmount > 0">
{{ 'labels.buttons.Submit' | translate }}
</button>
</mat-card-actions>
@@ -10,6 +10,12 @@
max-width: 37rem;
}
.lien-warn {
margin: 0 0 16px;
font-size: 13px;
color: #c62828;
}
.expandcollapsebutton {
margin-top: -7px;
margin-left: 2%;
@@ -64,6 +64,9 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
maxDate = new Date();
/** Close Recurring Deposit Account form. */
closeRecurringDepositForm: UntypedFormGroup;
/** Loans this deposit secures. While any remain, the server refuses to close it. */
liens: any[] = [];
totalLienAmount = 0;
/**
* Retrieves action details template data from `resolve`
@@ -79,6 +82,11 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
this.maturityAmount = data.recurringDepositsAccountActionData.maturityAmount;
this.onAccountClosureOptions = data.recurringDepositsAccountActionData.onAccountClosureOptions;
this.paymentTypes = data.recurringDepositsAccountActionData.paymentTypeOptions;
// A matured deposit closes on or after its maturity date - the server refuses anything earlier.
const maturityDate = data.recurringDepositsAccountActionData.maturityDate;
if (Array.isArray(maturityDate) && maturityDate.length >= 3) {
this.minDate = new Date(maturityDate[0], maturityDate[1] - 1, maturityDate[2]);
}
if (data.recurringDepositsAccountActionData.maturityAmount) {
this.title = 'Recurring Deposit Closure on Maturity';
} else {
@@ -92,6 +100,10 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
this.maxDate = this.settingsService.businessDate;
this.createcloseRecurringDepositForm();
this.addTransferDetails();
this.recurringDepositsService.getRecurringDepositLiens(this.accountId).subscribe((response: any) => {
this.liens = response?.liens ?? [];
this.totalLienAmount = Number(response?.totalLienAmount) || 0;
});
}
/**
@@ -99,8 +111,10 @@ export class CloseRecurringDepositsAccountComponent implements OnInit {
*/
createcloseRecurringDepositForm() {
this.closeRecurringDepositForm = this.formBuilder.group({
// The business date, not the computer's clock: the branch's working day can differ from the calendar day,
// and closing on the clock's date (before maturity, or before the deposit's last installment) fails.
closedOnDate: [
new Date(),
this.settingsService.businessDate,
Validators.required
],
maturityAmount: [{ value: this.maturityAmount, disabled: true }],
@@ -11,6 +11,51 @@
<form [formGroup]="prematureCloseRecurringDepositsAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
@if (account) {
<div class="pc-context">
<div class="pc-row">
<span>Account</span><b>{{ account.accountNo }}</b>
</div>
<div class="pc-row">
<span>Current balance</span><b>{{ currencySymbol }} {{ balance | number: '1.2-2' }}</b>
</div>
@if (account.maturityDate) {
<div class="pc-row">
<span>Maturity date</span><b>{{ account.maturityDate | dateFormat }}</b>
</div>
}
@if (account.maturityAmount != null) {
<div class="pc-row">
<span>Amount at maturity</span><b>{{ currencySymbol }} {{ account.maturityAmount | number: '1.2-2' }}</b>
</div>
}
<div class="pc-row">
<span>Pre-closure penalty</span>
<b>
@if (account.preClosurePenalApplicable) {
{{ account.preClosurePenalInterest }}% less interest
@if (account.preClosurePenalInterestOnType?.value) {
({{ account.preClosurePenalInterestOnType.value }})
}
} @else {
None
}
</b>
</div>
</div>
}
@if (totalLienAmount > 0) {
<p class="pc-warn">
This deposit is pledged as loan security (lien {{ currencySymbol }}
{{ totalLienAmount | number: '1.2-2' }} -
@for (lien of liens; track lien.loanId; let last = $last) {
loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
}
). It cannot be closed until the loan is repaid or the guarantee is released.
</p>
}
<mat-form-field (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label>
<input
@@ -23,6 +68,7 @@
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
<mat-hint>Must be before the maturity date - on or after it, use Close instead.</mat-hint>
@if (prematureCloseRecurringDepositsAccountForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
@@ -30,6 +76,89 @@
</mat-error>
}
</mat-form-field>
@if (reasonOptions.length > 1) {
<mat-form-field subscriptSizing="dynamic">
<mat-label>Closure reason</mat-label>
<mat-select formControlName="prematureClosureReasonId">
@for (reason of reasonOptions; track reason.id) {
<mat-option [value]="reason.id">{{ reason.label }}</mat-option>
}
</mat-select>
<mat-hint>{{ selectedReasonHint }}</mat-hint>
</mat-form-field>
}
<div class="pc-calc">
<button
type="button"
mat-stroked-button
color="primary"
(click)="calculate()"
[disabled]="
calculating || totalLienAmount > 0 || !prematureCloseRecurringDepositsAccountForm.value.closedOnDate
"
>
{{ calculating ? 'Calculating…' : 'Calculate Payout' }}
</button>
</div>
@if (preview) {
<div class="pc-result">
<div class="pc-row pc-row--total">
<span>Payable on premature closure</span>
<b>{{ currencySymbol }} {{ preview.payoutAmount | number: '1.2-2' }}</b>
</div>
@if (shortfallVsMaturity != null && shortfallVsMaturity > 0) {
<div class="pc-row pc-row--muted">
<span>Less than at maturity by</span>
<b>{{ currencySymbol }} {{ shortfallVsMaturity | number: '1.2-2' }}</b>
</div>
}
<p class="pc-note">
Paid by transfer to the client's savings account - a recurring deposit cannot be broken for cash.
</p>
</div>
@if (destinationAccounts.length === 0) {
<p class="pc-warn">
This client has no active savings account to receive the proceeds. Open one first, then close this
deposit.
</p>
} @else {
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
<mat-select required formControlName="toSavingsAccountId">
@for (savings of destinationAccounts; track savings.id) {
<mat-option [value]="savings.id">
{{ savings.accountNo }} - {{ savings.productName }}
@if (savings.owner) {
(nominee: {{ savings.owner }})
} @else if (savings.id === account?.linkedAccountId) {
(linked)
}
</mat-option>
}
</mat-select>
@if (prematureCloseRecurringDepositsAccountForm.controls.toSavingsAccountId.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
<input matInput formControlName="transferDescription" />
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput cdkTextareaAutosize formControlName="note"></textarea>
</mat-form-field>
}
}
</div>
</mat-card-content>
@@ -37,8 +166,8 @@
<button type="button" mat-raised-button [routerLink]="['../../']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" [disabled]="!prematureCloseRecurringDepositsAccountForm">
{{ 'labels.buttons.Confirm' | translate }}
<button mat-raised-button color="primary" [disabled]="!canConfirm">
{{ submitting ? 'Closing…' : ('labels.buttons.Confirm' | translate) }}
</button>
</mat-card-actions>
</form>
@@ -9,3 +9,45 @@
.container {
max-width: 37rem;
}
.pc-context,
.pc-result {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
margin-bottom: 16px;
border: 1px solid rgb(128 128 128 / 30%);
border-radius: 6px;
}
.pc-row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 13px;
&--total {
font-size: 15px;
}
&--muted {
opacity: 0.75;
}
}
.pc-calc {
margin: 4px 0 16px;
}
.pc-note {
margin: 4px 0 0;
font-size: 12px;
opacity: 0.75;
}
.pc-warn {
margin: 0 0 16px;
font-size: 13px;
color: #c62828;
}
@@ -8,25 +8,41 @@
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
/** Custom Services */
import { RecurringDepositsService } from '../../recurring-deposits.service';
import { ClientsService } from 'app/clients/clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** DepositAccountOnClosureType.TRANSFER_TO_SAVINGS - the only way a broken RD may be paid out. */
const TRANSFER_TO_SAVINGS = 200;
/** PrematureClosureReasonType values the RD closure understands. */
const REASON_NORMAL = 100;
const REASON_DECEASED = 400;
const REASON_NOMINEE_HEIR = 500;
/**
* Premature Close Recurring Deposits Account Component
*
* The server will only break a recurring deposit early by transferring the proceeds to one of the client's savings
* accounts (no cash payout, no reinvest), and it needs to be told so: onAccountClosureId and toSavingsAccountId are
* mandatory. This screen used to send only the closure date, so every premature closure was rejected. It now asks the
* server what the deposit would pay on the chosen date (calculatePrematureAmount, which applies the product's
* pre-closure penalty) and only lets the closure be confirmed against that exact figure.
*/
@Component({
selector: 'mifosx-premature-close-recurring-deposit-account',
templateUrl: './premature-close-recurring-deposit-account.component.html',
styleUrls: ['./premature-close-recurring-deposit-account.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS
...STANDALONE_SHARED_IMPORTS,
CdkTextareaAutosize
]
})
export class PrematureCloseRecurringDepositAccountComponent implements OnInit {
@@ -36,72 +52,263 @@ export class PrematureCloseRecurringDepositAccountComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private settingsService = inject(SettingsService);
private clientsService = inject(ClientsService);
/** Minimum date allowed. */
/** Savings accounts of this deposit's nominees - offered as destinations only for a deceased holder. */
nomineeSavingsAccounts: any[] = [];
/** Minimum date allowed: the deposit's activation date once loaded. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
/** Maximum date allowed: the business date, and never the maturity date or later (that is a normal Close). */
maxDate = new Date();
/** premature close Recurring Deposits Account form. */
prematureCloseRecurringDepositsAccountForm: UntypedFormGroup;
/** Recurring Deposits Account Id */
accountId: any;
/** The deposit being closed, for the context panel and the default destination account. */
account: any;
/** Result of the last calculatePrematureAmount call, and the date it was calculated for. */
preview: { payoutAmount: number; savingsAccounts: any[]; closureOptionId: number } | null = null;
previewedFor: string | null = null;
calculating = false;
submitting = false;
/** Loans this deposit secures. While any remain, the server refuses to close it. */
liens: any[] = [];
totalLienAmount = 0;
/**
* @param {FormBuilder} formBuilder Form Builder
* @param {RecurringDepositsService} recurringDepositsService Recurring Deposits Service
* @param {Dates} dateUtils Date Utils
* @param {ActivatedRoute} route Activated Route
* @param {Router} router Router
* @param {SettingsService} settingsService Settings Service
*/
constructor() {
this.accountId = this.route.parent.snapshot.params['recurringDepositAccountId'];
}
/**
* Creates the premature close recurring deposits form.
*/
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.createprematureCloseRecurringDepositsAccountForm();
}
/**
* Creates the premature close recurring deposits account form.
*/
createprematureCloseRecurringDepositsAccountForm() {
this.prematureCloseRecurringDepositsAccountForm = this.formBuilder.group({
closedOnDate: [
'',
Validators.required
]
this.recurringDepositsService.getRecurringDepositsAccountData(this.accountId).subscribe((account: any) => {
this.account = account;
const activatedOn = this.toDate(account?.timeline?.activatedOnDate);
if (activatedOn) {
this.minDate = activatedOn;
}
const maturity = this.toDate(account?.maturityDate);
if (maturity) {
const dayBeforeMaturity = new Date(maturity);
dayBeforeMaturity.setDate(dayBeforeMaturity.getDate() - 1);
if (dayBeforeMaturity < this.maxDate) {
this.maxDate = dayBeforeMaturity;
}
}
});
this.recurringDepositsService.getRecurringDepositLiens(this.accountId).subscribe((response: any) => {
this.liens = response?.liens ?? [];
this.totalLienAmount = Number(response?.totalLienAmount) || 0;
});
// A different date or reason is a different payout - the old preview no longer describes what would happen.
this.prematureCloseRecurringDepositsAccountForm.get('closedOnDate').valueChanges.subscribe(() => {
this.preview = null;
this.previewedFor = null;
});
this.prematureCloseRecurringDepositsAccountForm.get('prematureClosureReasonId').valueChanges.subscribe((reason: number) => {
this.preview = null;
this.previewedFor = null;
if (reason === REASON_DECEASED) {
this.loadNomineeSavingsAccounts();
}
});
}
/**
* Submits the form and premature closes the recurring deposit account,
* if successful redirects to the recurring deposit account.
*/
submit() {
const prematureCloseRecurringDepositsAccountFormData = this.prematureCloseRecurringDepositsAccountForm.value;
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const prevClosedOnDate: Date = this.prematureCloseRecurringDepositsAccountForm.value.closedOnDate;
if (prematureCloseRecurringDepositsAccountFormData.closedOnDate instanceof Date) {
prematureCloseRecurringDepositsAccountFormData.closedOnDate = this.dateUtils.formatDate(
prevClosedOnDate,
dateFormat
);
/** Reasons this deposit's product allows; Normal is always there. */
get reasonOptions(): Array<{ id: number; label: string; hint: string }> {
const options = [{ id: REASON_NORMAL, label: 'Normal', hint: "Pre-closure penalty as per the deposit's terms." }];
if (this.account?.noPenaltyNomineeHeirClosure) {
options.push({ id: REASON_NOMINEE_HEIR, label: 'Nominee / legal heir claim', hint: 'No pre-closure penalty.' });
}
const data = {
...prematureCloseRecurringDepositsAccountFormData,
dateFormat,
locale
};
this.recurringDepositsService
.executeRecurringDepositsAccountCommand(this.accountId, 'prematureClose', data)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
if (this.account?.deceasedSettlement) {
options.push({
id: REASON_DECEASED,
label: 'Account holder deceased',
hint: "No penalty, allowed inside lock-in; may be paid to a nominee's savings account."
});
}
return options;
}
get selectedReasonHint(): string {
const reason = this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId;
return this.reasonOptions.find((option) => option.id === reason)?.hint ?? '';
}
/** The client's own savings accounts, plus - for a deceased holder - the nominees' savings accounts. */
get destinationAccounts(): any[] {
const own = (this.preview?.savingsAccounts ?? []).map((acc: any) => ({ ...acc, owner: null }));
if (this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId !== REASON_DECEASED) {
return own;
}
const ownIds = new Set(own.map((acc: any) => acc.id));
return [
...own,
...this.nomineeSavingsAccounts.filter((acc: any) => !ownIds.has(acc.id))
];
}
private loadNomineeSavingsAccounts() {
this.nomineeSavingsAccounts = [];
const clientId = this.account?.clientId;
if (!clientId || !this.account?.accountNo) {
return;
}
this.clientsService.getClientNominees(clientId, this.account.accountNo, 'RD').subscribe((nominees: any) => {
const list: any[] = Array.isArray(nominees) ? nominees : (nominees?.pageItems ?? []);
list
.filter((nominee: any) => nominee?.nomineeClientId && nominee.isActive !== false)
.forEach((nominee: any) => {
this.clientsService.getClientAccountData(nominee.nomineeClientId).subscribe((accounts: any) => {
const savings = (accounts?.savingsAccounts ?? []).filter(
(acc: any) => acc?.depositType?.id === 100 && acc?.status?.active
);
this.nomineeSavingsAccounts = [
...this.nomineeSavingsAccounts,
...savings.map((acc: any) => ({
id: acc.id,
accountNo: acc.accountNo,
productName: acc.productName,
owner: nominee.nomineeDisplayName
}))
];
});
});
});
}
createprematureCloseRecurringDepositsAccountForm() {
this.prematureCloseRecurringDepositsAccountForm = this.formBuilder.group({
closedOnDate: [
this.settingsService.businessDate,
Validators.required
],
prematureClosureReasonId: [REASON_NORMAL],
toSavingsAccountId: [
null,
Validators.required
],
transferDescription: ['Premature closure of recurring deposit'],
note: ['']
});
}
get balance(): number | null {
return this.account?.summary?.accountBalance ?? null;
}
get currencySymbol(): string {
return this.account?.currency?.displaySymbol || this.account?.currency?.code || '';
}
/** What breaking early costs compared with letting the deposit run to maturity, when both are known. */
get shortfallVsMaturity(): number | null {
const maturityAmount = this.account?.maturityAmount;
if (!this.preview || maturityAmount == null) {
return null;
}
return Number(maturityAmount) - Number(this.preview.payoutAmount);
}
get canConfirm(): boolean {
return (
this.totalLienAmount <= 0 &&
!!this.preview &&
this.previewedFor === this.formattedClosedOnDate &&
this.prematureCloseRecurringDepositsAccountForm.valid &&
!this.submitting
);
}
private get formattedClosedOnDate(): string | null {
const value = this.prematureCloseRecurringDepositsAccountForm.value.closedOnDate;
if (!value) {
return null;
}
return value instanceof Date ? this.dateUtils.formatDate(value, this.settingsService.dateFormat) : value;
}
/** Asks the server what the deposit pays if broken on the chosen date. */
calculate() {
const closedOnDate = this.formattedClosedOnDate;
if (!closedOnDate) {
return;
}
this.calculating = true;
const data = {
closedOnDate,
prematureClosureReasonId: this.prematureCloseRecurringDepositsAccountForm.value.prematureClosureReasonId,
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
this.recurringDepositsService.executeRecurringDepositsAccountCommand(this.accountId, 'calculatePrematureAmount', data).subscribe({
next: (response: any) => {
const savingsAccounts = response?.savingsAccounts ?? [];
const closureOptionId =
(response?.onAccountClosureOptions ?? []).find((option: any) => option.id === TRANSFER_TO_SAVINGS)?.id ??
TRANSFER_TO_SAVINGS;
this.preview = { payoutAmount: response?.maturityAmount, savingsAccounts, closureOptionId };
this.previewedFor = closedOnDate;
this.calculating = false;
this.selectDefaultDestination(savingsAccounts);
},
error: () => {
this.calculating = false;
}
});
}
/** The deposit's own linked savings account, when it has one the client can still receive into. */
private selectDefaultDestination(savingsAccounts: any[]) {
const control = this.prematureCloseRecurringDepositsAccountForm.get('toSavingsAccountId');
if (control.value && savingsAccounts.some((acc: any) => acc.id === control.value)) {
return;
}
const linkedId = this.account?.linkedAccountId;
const linked = savingsAccounts.find((acc: any) => acc.id === linkedId);
control.setValue(linked?.id ?? (savingsAccounts.length === 1 ? savingsAccounts[0].id : null));
}
submit() {
if (!this.canConfirm) {
return;
}
const formValue = this.prematureCloseRecurringDepositsAccountForm.value;
const data: any = {
closedOnDate: this.formattedClosedOnDate,
onAccountClosureId: this.preview.closureOptionId,
prematureClosureReasonId: formValue.prematureClosureReasonId,
toSavingsAccountId: formValue.toSavingsAccountId,
transferDescription: formValue.transferDescription,
dateFormat: this.settingsService.dateFormat,
locale: this.settingsService.language.code
};
if (formValue.note) {
data.note = formValue.note;
}
this.submitting = true;
this.recurringDepositsService.executeRecurringDepositsAccountCommand(this.accountId, 'prematureClose', data).subscribe({
next: () => {
this.router.navigate(['../../'], { relativeTo: this.route });
},
error: () => {
this.submitting = false;
}
});
}
/** API dates arrive as [yyyy, m, d] arrays or ISO strings. */
private toDate(value: any): Date | null {
if (!value) {
return null;
}
if (Array.isArray(value)) {
return new Date(value[0], value[1] - 1, value[2]);
}
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
}
@@ -30,6 +30,14 @@
@if (actions['Close']) {
<mifosx-close-recurring-deposits-account></mifosx-close-recurring-deposits-account>
}
@if (actions['Deposit'] || actions['Withdrawal']) {
<!-- A direct deposit from the account screen skips the manager's approval; RD installments come in through the
cashier's Receipt or Auto Debit instead, so the Deposit action is not offered here even by URL. -->
@if (actions['Deposit']) {
<p>
RD installments cannot be deposited from the account screen. Use a cashier Receipt, or Auto Debit from the linked
savings account.
</p>
}
@if (actions['Withdrawal']) {
<mifosx-deposit-recurring-deposits-account [currency]="currency"></mifosx-deposit-recurring-deposits-account>
}
@@ -30,11 +30,50 @@
<div class="racc-alert racc-alert--editable">
<div class="racc-alert__content">
<h4>Auto Debit Facility</h4>
<p>Enable automatic deduction of funds for recurring payments, utility bills, or EMI obligations.</p>
@if (recurringDepositsAccountProductTemplate?.autoDebitFacility) {
<p>
Collect each installment automatically on its due date from the linked Savings account (Deposit Account
Link, Details tab). A short balance leaves it overdue and it is retried on the next run.
</p>
} @else {
<p>This product does not offer Auto Debit.</p>
}
</div>
<mat-slide-toggle [(ngModel)]="autoDebitFacility" color="primary"></mat-slide-toggle>
<mat-slide-toggle
[(ngModel)]="autoDebitFacility"
[disabled]="!recurringDepositsAccountProductTemplate?.autoDebitFacility"
color="primary"
></mat-slide-toggle>
</div>
@if (lienAllowed) {
<div class="racc-alert racc-alert--editable">
<div class="racc-alert__content">
<h4>Loan against this RD (Lien)</h4>
<p>
How much of the RD balance can be given as loan, checked on the balance at the time the loan is taken.
Product limit: {{ productLienLimit }}%.
</p>
@if (lienPercentageError) {
<p class="racc-lien-error">{{ lienPercentageError }}</p>
}
</div>
<mat-form-field appearance="outline" class="racc-lien-field" subscriptSizing="dynamic">
<mat-label>Max loan (%)</mat-label>
<input
matInput
type="number"
min="1"
[max]="productLienLimit"
step="0.01"
[(ngModel)]="lienPercentage"
[ngModelOptions]="{ standalone: true }"
/>
<span matTextSuffix>%</span>
</mat-form-field>
</div>
}
<div class="racc-alert">
<div class="racc-alert__content">
<h4>Dormancy Alert</h4>
@@ -126,6 +126,15 @@ $gray-900: #202124;
}
}
.racc-lien-field {
width: 140px;
flex-shrink: 0;
}
.racc-lien-error {
color: #c62828;
}
.racc-alert--editable {
border-color: #bae6fd;
background: #f0f9ff;
@@ -6,8 +6,9 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, OnChanges, SimpleChanges, ViewChild } from '@angular/core';
import { Component, Input, OnChanges, SimpleChanges, ViewChild, inject } from '@angular/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RecurringDepositsService } from '../../recurring-deposits.service';
import { NomineeFormComponent } from 'app/shared/nominee-form/nominee-form.component';
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
import { MatSlideToggle } from '@angular/material/slide-toggle';
@@ -36,7 +37,14 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges
showNomineeForm = false;
nomineeError = '';
private recurringDepositsService = inject(RecurringDepositsService);
autoDebitFacility = false;
/**
* Lien Marked products only: how much of this RD may be given as loan, as % of its balance when the loan is taken.
* Starts at the product's percentage; may be lowered, never raised above it.
*/
lienPercentage: number | null = null;
private existingAccountApplied = false;
@@ -50,13 +58,44 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges
if (Array.isArray(ea.nominees) && ea.nominees.length > 0) {
this.nominees = [...ea.nominees];
}
// The account response does not carry the deposit's own lien percentage; the lien endpoint does.
if (ea.id) {
this.recurringDepositsService.getRecurringDepositLiens(ea.id).subscribe((response: any) => {
this.lienPercentage = response?.lienPercentage ?? response?.productLienPercentage ?? null;
});
}
}
if (changes['recurringDepositsAccountProductTemplate']?.currentValue && !this.existingAccountApplied) {
const pt = changes['recurringDepositsAccountProductTemplate'].currentValue;
this.autoDebitFacility = !!pt.autoDebitFacility;
this.lienPercentage = pt.lienMarked ? (pt.lienPercentage ?? 100) : null;
}
}
get lienAllowed(): boolean {
return !!this.recurringDepositsAccountProductTemplate?.lienMarked;
}
/** The product's ceiling; a product with no percentage allows the whole balance. */
get productLienLimit(): number {
return Number(this.recurringDepositsAccountProductTemplate?.lienPercentage) || 100;
}
/** Why the entered percentage would be refused, or null when it is fine. */
get lienPercentageError(): string | null {
if (!this.lienAllowed) {
return null;
}
if (this.lienPercentage === null || this.lienPercentage === undefined || `${this.lienPercentage}` === '') {
return 'Enter how much of the RD balance can be given as loan.';
}
const value = Number(this.lienPercentage);
if (!(value > 0) || value > this.productLienLimit) {
return `Must be more than 0% and at most ${this.productLienLimit}% (product limit).`;
}
return null;
}
get totalShare(): number {
return this.nominees.reduce((sum, n) => sum + (+n.percentageShare || 0), 0);
}
@@ -112,7 +151,8 @@ export class RecurringDepositsAccountCurrencyStepComponent implements OnChanges
get currencyStepData() {
return {
autoDebitFacility: this.autoDebitFacility
autoDebitFacility: this.autoDebitFacility,
...(this.lienAllowed && this.lienPercentage != null ? { lienPercentage: Number(this.lienPercentage) } : {})
};
}
}
@@ -121,11 +121,30 @@
{{ productTemplate.shareLinkingEnabled ? 'Yes' : 'No' }}
</span>
</div>
<div class="rad-field rad-info-row">
<span class="rad-info-label">Introducer Required</span>
<span class="rad-badge" [class.rad-badge--yes]="productTemplate.introducerRequired">
{{ productTemplate.introducerRequired ? 'Yes' : 'No' }}
</span>
</div>
</div>
</div>
<!-- The product requires an introducer: an existing customer, never the depositor themselves. -->
@if (productTemplate?.introducerRequired) {
<mifosx-introducer-search
[excludedClientIds]="primaryClientId ? [primaryClientId] : []"
[preselectedIntroducer]="
introducerId ? { clientId: introducerId, displayName: introducerName, cifNumber: introducerCif } : null
"
(introducerChange)="onIntroducerChange($event)"
></mifosx-introducer-search>
}
<!-- ══ Deposit Account Link ══════════════════════════════════════════ -->
@if (productTemplate?.shareLinkingEnabled) {
<!-- Also offered when the product allows Auto Debit: that is the account installments are collected from. -->
@if (productTemplate?.shareLinkingEnabled || productTemplate?.autoDebitFacility) {
<div class="dal-card">
<div class="dal-card__header">
<div class="dal-card__header-left">
@@ -24,6 +24,7 @@ import { Dates } from 'app/core/utils/dates';
import { MatDialog } from '@angular/material/dialog';
/** Custom Services */
import { ProductsService } from 'app/products/products.service';
import { RecurringDepositsService } from '../../recurring-deposits.service';
import { ClientsService } from 'app/clients/clients.service';
import {
@@ -62,6 +63,7 @@ import { IntroducerSearchComponent } from 'app/shared/introducer-search/introduc
export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnChanges {
private formBuilder = inject(UntypedFormBuilder);
private recurringDepositsService = inject(RecurringDepositsService);
private productsService = inject(ProductsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private cdr = inject(ChangeDetectorRef);
@@ -117,6 +119,10 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
primaryClientName = '';
primaryClientCif = '';
primaryClientId: number | null = null;
/** Introducer - asked for only when the product has "Introducer Required" on. */
introducerId: number | null = null;
introducerName: string | null = null;
introducerCif: string | null = null;
primaryHolderShare = 50;
// ── Status of Account (RBI) ────────────────────────────────────────────
@@ -453,6 +459,14 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
}
}
this.productTemplate = response;
// The account template leaves out the product's installment limits; the calculator and the submit check
// read them from this template, so they are taken from the product itself.
if (response.minDepositAmount == null && response.maxDepositAmount == null) {
this.productsService.getRecurringDepositProduct(String(productId)).subscribe((product: any) => {
response.minDepositAmount = product?.minDepositAmount ?? null;
response.maxDepositAmount = product?.maxDepositAmount ?? null;
});
}
this.accountTypeOptions = this.extractOptions(response.accountTypeOptions, response.accountType);
this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment);
this.selectedAccountType = response.accountType ?? null;
@@ -463,7 +477,7 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
this.recurringDepositsAccountProductTemplate.emit(response);
this.fieldOfficerData = response.fieldOfficerOptions;
this.isProductSelected = true;
if (response.shareLinkingEnabled) {
if (response.shareLinkingEnabled || response.autoDebitFacility) {
this.loadClientAccounts();
}
this.recurringDepositAccountDetailsForm.patchValue({
@@ -610,6 +624,12 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
return this.dateUtils.formatDate(dateValue, 'M/d/yyyy') || '';
}
onIntroducerChange(event: { clientId: number; displayName: string; cifNumber?: string } | null): void {
this.introducerId = event?.clientId ?? null;
this.introducerName = event?.displayName ?? null;
this.introducerCif = event?.cifNumber ?? null;
}
onGuardianChange(event: { clientId: number; displayName: string; cifNumber?: string } | null): void {
this.guardianClientId = event?.clientId ?? null;
this.guardianName = event?.displayName ?? '';
@@ -795,6 +815,12 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
value.minorDateOfBirth = this.minorDob ? this.dateUtils.formatDate(this.minorDob, 'dd MMMM yyyy') : null;
}
if (this.productTemplate?.introducerRequired && this.introducerId) {
value.introducerId = this.introducerId;
value.introducerName = this.introducerName;
value.introducerCifNumber = this.introducerCif;
}
if (this.selectedLinkedAccount) {
value.linkedAccountId = this.selectedLinkedAccount.id;
value.linkedAccountType = this.selectedLinkedAccount.accountType;
@@ -93,7 +93,13 @@
<div class="fdrc-calc-field">
<label class="fdrc-calc-label">
Monthly Installment
<span class="fdrc-calc-optional">(optional — for maturity estimation)</span>
<span class="fdrc-calc-optional"
>(required@if (minInstallment > 0) {
— min ₹{{ minInstallment }}
}@if (maxInstallment > 0) {
, max ₹{{ maxInstallment }}
})</span
>
</label>
<div class="fdrc-amount-row">
<span class="fdrc-amount-prefix">₹</span>
@@ -257,6 +263,21 @@
</span>
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
</div>
@if (matchResult.interestCalculation) {
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Interest Type</span>
<span class="fdrc-maturity-row__value">
{{ matchResult.interestCalculation }}
@if (matchResult.interestCalculation === 'Compounding') {
({{ interestPeriodLabel(matchResult.compoundingPeriod) }})
}
</span>
</div>
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Interest Credited</span>
<span class="fdrc-maturity-row__value">{{ interestPeriodLabel(matchResult.postingPeriod) }}</span>
</div>
}
<div class="fdrc-maturity-row fdrc-maturity-row--interest">
<span class="fdrc-maturity-row__label">Estimated Interest Earned</span>
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
@@ -267,6 +288,23 @@
>₹ {{ matchResult.maturityAmount | number: '1.2-2' }}</span
>
</div>
@if (matchResult.postingSchedule?.length) {
<details class="fdrc-credits">
<summary>Interest credits to the RD ({{ matchResult.postingSchedule.length }})</summary>
<table class="fdrc-credits__table">
<tr>
<th>Credited on</th>
<th class="text-right">Interest</th>
</tr>
@for (p of matchResult.postingSchedule; track p.date) {
<tr>
<td>{{ p.date }}</td>
<td class="text-right">₹ {{ p.interest | number: '1.2-2' }}</td>
</tr>
}
</table>
</details>
}
</div>
</div>
}
@@ -1127,3 +1127,31 @@ $red-light: #fee2e2;
font-size: 14px;
}
}
.fdrc-credits {
margin-top: 8px;
font-size: 12px;
summary {
cursor: pointer;
font-weight: 600;
color: #2563eb;
}
&__table {
width: 100%;
margin-top: 6px;
border-collapse: collapse;
th,
td {
padding: 3px 6px;
border-bottom: 1px solid #e2e8f0;
text-align: left;
}
.text-right {
text-align: right;
}
}
}
@@ -42,6 +42,7 @@ import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
import { YesnoPipe } from '../../../../pipes/yesno.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormsModule } from '@angular/forms';
import { buildRdChart, tenureInMonths } from 'app/products/recurring-deposit-products/rd-product-chart';
export type RdPeriodUnit = 'Days' | 'Months' | 'Years';
@@ -53,6 +54,12 @@ export interface SlabMatchResult {
interestEarned?: number;
tenureDays?: number;
daysInYear?: number;
/** From the product, as the server calculated it: Simple / Compounding, and the period names. */
interestCalculation?: string;
compoundingPeriod?: string;
postingPeriod?: string;
/** Each interest credit the deposit will receive, if every installment is paid on time. */
postingSchedule?: { date: string; interest: number }[];
}
/**
@@ -123,20 +130,62 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
return (this.cbsBaseRates || []).find((r) => r.fdType === 'DOUBLE' && r.isActive);
}
/**
* The product's own chart - the one the server prices the RD from - with each slab's senior-citizen rate taken
* from the CBS base chart it was built from. Falls back to the base chart only when the product has no chart.
*/
get interestRateChartData(): any[] {
if (this.activeBase?.slabs?.length) {
return this.normalizeCbsSlabs(this.activeBase.slabs);
}
const cbsSlabs: any[] = this.activeBase?.slabs || [];
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
if (!template) return [];
return (
template.accountChart?.chartSlabs ??
template.activeChart?.chartSlabs ??
template.interestRateCharts?.[0]?.chartSlabs ??
template.interestRateChart?.chartSlabs ??
template.chartSlabs ??
[]
);
const productSlabs: any[] =
template?.accountChart?.chartSlabs ??
template?.activeChart?.chartSlabs ??
template?.interestRateCharts?.[0]?.chartSlabs ??
template?.interestRateChart?.chartSlabs ??
template?.chartSlabs ??
[];
if (this.chartCache?.cbsSlabs !== cbsSlabs || this.chartCache?.productSlabs !== productSlabs) {
this.chartCache = { cbsSlabs, productSlabs, slabs: this.mergeChart(cbsSlabs, productSlabs) };
}
return this.chartCache.slabs;
}
private chartCache: { cbsSlabs: any[]; productSlabs: any[]; slabs: any[] } | null = null;
/** The product's minimum deposit term in months - the server refuses anything shorter. Null when it sets none. */
/** The product's installment limits (0 = no limit). */
get minInstallment(): number {
return Number(this.recurringDepositsAccountProductTemplate?.minDepositAmount) || 0;
}
get maxInstallment(): number {
return Number(this.recurringDepositsAccountProductTemplate?.maxDepositAmount) || 0;
}
get productMinTenureMonths(): number | null {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return tenureInMonths(template?.minDepositTerm, template?.minDepositTermType?.id);
}
private mergeChart(cbsSlabs: any[], productSlabs: any[]): any[] {
const baseSlabs = this.normalizeCbsSlabs(buildRdChart(cbsSlabs, [], null).slabs);
if (!productSlabs.length) {
return baseSlabs;
}
// Products made before RD charts moved to months still hold the base chart's own year slabs
const candidates = [...baseSlabs, ...this.normalizeCbsSlabs(cbsSlabs)];
return productSlabs.map((slab: any) => {
if (slab.seniorCitizenRate != null) {
return slab;
}
const base = candidates.find(
(candidate: any) =>
candidate.fromPeriod === slab.fromPeriod &&
(candidate.toPeriod ?? null) === (slab.toPeriod ?? null) &&
this.periodLabel(candidate) === this.periodLabel(slab)
);
return base?.seniorCitizenRate != null ? { ...slab, seniorCitizenRate: base.seniorCitizenRate } : slab;
});
}
private normalizeCbsSlabs(slabs: any[]): any[] {
@@ -146,6 +195,10 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
}));
}
private periodLabel(slab: any): string {
return String(slab.periodType?.value ?? slab.periodTypeLabel ?? '').toLowerCase();
}
chartSlabsDisplayedColumns: string[] = [
'period',
'amountRange',
@@ -253,6 +306,28 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
return period; // days
}
/**
* Whether the tenure falls in the slab, counted the way the server counts it: in whole units of the slab's own
* period type, so a 30-month RD is "2 years" against a year slab and lands in "1-2 Years" rather than between
* slabs. Day and week slabs keep the day comparison.
*/
private isTenureInSlab(slab: any, tenureMonths: number, tenureDays: number): boolean {
const label = this.periodLabel(slab) || 'days';
let tenure: number;
let from = slab.fromPeriod ?? 0;
let to = slab.toPeriod ?? Infinity;
if (label.includes('month')) {
tenure = tenureMonths;
} else if (label.includes('year')) {
tenure = Math.floor(tenureMonths / 12);
} else {
tenure = tenureDays;
from = this.slabPeriodToDays(from, label);
to = to === Infinity ? Infinity : this.slabPeriodToDays(to, label);
}
return tenure >= from && tenure <= to;
}
calculateRate() {
this.validationWarning = null;
this.matchResult = null;
@@ -263,6 +338,26 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
}
const enteredDays = this.toDays(this.tenureValue, this.tenurePeriodUnit);
const enteredMonths = this.tenurePeriodUnit === 'Years' ? this.tenureValue * 12 : this.tenureValue;
const minimumMonths = this.productMinTenureMonths;
if (minimumMonths != null && enteredMonths < minimumMonths) {
this.validationWarning = `Minimum RD tenure for this product is ${minimumMonths} months.`;
return;
}
// The installment must be within the product's limits - the same check the submit and the server make, shown
// here so a wrong amount is caught before the rest of the form is filled.
if (!this.principalAmount || this.principalAmount <= 0) {
this.validationWarning = 'Please enter the monthly installment amount.';
return;
}
if (this.minInstallment > 0 && this.principalAmount < this.minInstallment) {
this.validationWarning = `Minimum monthly installment for this product is ₹${this.minInstallment}.`;
return;
}
if (this.maxInstallment > 0 && this.principalAmount > this.maxInstallment) {
this.validationWarning = `Maximum monthly installment for this product is ₹${this.maxInstallment}.`;
return;
}
const slabs: any[] = this.interestRateChartData || [];
if (!slabs.length) {
@@ -275,11 +370,7 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
for (let i = 0; i < slabs.length; i++) {
const slab = slabs[i];
const periodTypeValue = slab.periodType?.value || 'Days';
const fromDays = this.slabPeriodToDays(slab.fromPeriod ?? 0, periodTypeValue);
const toDays = slab.toPeriod != null ? this.slabPeriodToDays(slab.toPeriod, periodTypeValue) : Infinity;
if (enteredDays >= fromDays && enteredDays <= toDays) {
if (this.isTenureInSlab(slab, enteredMonths, enteredDays)) {
matchedSlab = slab;
matchedIndex = i;
break;
@@ -329,10 +420,20 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
tenureInMonths: tenureInMonths,
interestCompoundingPeriodInMonths: compoundingMonths
};
// With the product named, the server calculates with the product's own interest settings (simple/compounding,
// compounding and posting periods, days in year) - the same figure the opened account will carry.
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
const productId = template?.depositProductId ?? template?.productId;
if (productId) {
calculationData.productId = productId;
}
if (this.submittedOnDate) {
const date = new Date(this.submittedOnDate);
calculationData.submittedOnDate = date.toISOString().split('T')[0];
// The local calendar date - toISOString() is UTC, which in IST turns midnight of the 9th into the 8th and
// starts the projection a day early (a different maturity amount from the one the account gets).
const pad = (n: number) => String(n).padStart(2, '0');
calculationData.submittedOnDate = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
calculationData.dateFormat = 'yyyy-MM-dd'; // Standard ISO for API
calculationData.locale = 'en';
}
@@ -341,6 +442,10 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
result.maturityAmount = apiResult.maturityAmount;
result.interestEarned = apiResult.totalInterest;
result.daysInYear = apiResult.daysInYear;
result.interestCalculation = apiResult.interestCalculation;
result.compoundingPeriod = apiResult.compoundingPeriod;
result.postingPeriod = apiResult.postingPeriod;
result.postingSchedule = apiResult.postingSchedule;
this.matchResult = { ...result };
this.emitTenureSelection();
});
@@ -350,6 +455,18 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
}
}
/** "QUATERLY" / "BIANNUAL" / "MONTHLY" (server enum names) as a person would say them. */
interestPeriodLabel(name?: string): string {
const labels: Record<string, string> = {
DAILY: 'Daily',
MONTHLY: 'Monthly',
QUATERLY: 'Quarterly',
BIANNUAL: 'Half-yearly',
ANNUAL: 'Yearly'
};
return name ? (labels[name] ?? name) : '';
}
private emitTenureSelection() {
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
if (frequencyId !== null && this.matchResult) {
@@ -441,76 +558,54 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
// ── Header Getters ──────────────────────────────────────────────────────
get chartName(): string {
if (this.activeBase) {
return this.activeBase.versionLabel || 'CBS Base Interest Rate';
}
/**
* The RD product's own chart - the one the deposit is actually priced on. The CBS base chart (an FD master the
* product's chart was built from) is shown only when the product has no chart of its own.
*/
private get productChart(): any {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.name ||
template?.activeChart?.name ||
template?.interestRateCharts?.[0]?.name ||
template?.interestRateChart?.name ||
template?.name ||
''
template?.accountChart ||
template?.activeChart ||
template?.interestRateCharts?.[0] ||
template?.interestRateChart ||
null
);
}
get chartName(): string {
if (this.productChart) {
return this.productChart.name || '';
}
return this.activeBase?.versionLabel || 'CBS Base Interest Rate';
}
get chartFromDate(): any {
if (this.activeBase) {
return this.activeBase.effectiveFromDate;
if (this.productChart) {
return this.productChart.fromDate;
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.fromDate ||
template?.activeChart?.fromDate ||
template?.interestRateCharts?.[0]?.fromDate ||
template?.interestRateChart?.fromDate ||
template?.fromDate
);
return this.activeBase?.effectiveFromDate;
}
get chartEndDate(): any {
if (this.activeBase) {
return null;
if (this.productChart) {
return this.productChart.endDate;
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.endDate ||
template?.activeChart?.endDate ||
template?.interestRateCharts?.[0]?.endDate ||
template?.interestRateChart?.endDate ||
template?.endDate
);
return null;
}
get chartDescription(): string {
if (this.activeBase) {
return this.activeBase.remarks || '';
if (this.productChart) {
return this.productChart.description || '';
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.description ||
template?.activeChart?.description ||
template?.interestRateCharts?.[0]?.description ||
template?.interestRateChart?.description ||
template?.description ||
''
);
return this.activeBase?.remarks || '';
}
get isActive(): boolean {
if (this.activeBase) {
return this.activeBase.isActive;
if (this.productChart) {
return this.productChart.isActive ?? true;
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.isActive ??
template?.activeChart?.isActive ??
template?.interestRateCharts?.[0]?.isActive ??
template?.interestRateChart?.isActive ??
true
);
return this.activeBase?.isActive ?? true;
}
get isPrimaryGroupingByAmount(): boolean {
@@ -84,25 +84,17 @@
</div>
}
<mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
</mat-checkbox>
@if (closureSettingsForm.value.preClosurePenalApplicable) {
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Penal Interest' | translate }} (%)</mat-label>
<input type="number" matInput formControlName="preClosurePenalInterest" min="0" step="0.01" />
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Period' | translate }}</mat-label>
<mat-select formControlName="preClosurePenalInterestOnTypeId">
@for (preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; track preClosurePenalInterestOnType) {
<mat-option [value]="preClosurePenalInterestOnType.id">
{{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
}
<!-- The premature-closure penalty is the product's, shown here and never changed per account. -->
<div class="flex-98 margin-v rdmt-penal">
<div class="rdmt-penal__title">{{ 'labels.inputs.Apply Penal Interest (less)' | translate }} <span class="rdmt-penal__tag">From Product</span></div>
@if (closureSettingsForm.value.preClosurePenalApplicable) {
<div class="rdmt-penal__row">
<span>Penal Interest: <strong>{{ closureSettingsForm.value.preClosurePenalInterest }}%</strong></span>
<span>Period: <strong>{{ penalPeriodLabel }}</strong></span>
</div>
<div class="rdmt-penal__hint">On premature closure the rate is reduced by this much.</div>
} @else {
<div class="rdmt-penal__row"><strong>No</strong> - no penal interest is charged on premature closure of this product.</div>
}
</div>
</form>
@@ -150,3 +150,37 @@
}
}
}
.rdmt-penal {
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
font-size: 13px;
&__title {
font-weight: 600;
margin-bottom: 6px;
}
&__tag {
margin-left: 6px;
padding: 1px 6px;
border-radius: 8px;
background: #e0f2fe;
color: #0369a1;
font-size: 10px;
font-weight: 700;
}
&__row {
display: flex;
gap: 24px;
}
&__hint {
margin-top: 4px;
font-size: 11px;
color: #64748b;
}
}
@@ -82,7 +82,7 @@ export class RecurringDepositsAccountMaturityTermsStepComponent implements OnCha
);
this.savingsAccountsData = template.savingsAccounts ?? [];
this.preClosurePenalInterestOnTypeData = template.preClosurePenalInterestOnTypeOptions ?? [];
if (!this.closureSettingsForm.get('preClosurePenalApplicable').dirty) {
{
this.closureSettingsForm.patchValue({
preClosurePenalApplicable: template.preClosurePenalApplicable ?? false,
preClosurePenalInterest: template.preClosurePenalInterest ?? '',
@@ -103,9 +103,21 @@ export class RecurringDepositsAccountMaturityTermsStepComponent implements OnCha
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
}
/** Chosen maturity/closure settings, ready to merge into the submit payload. */
/** "Whole Term" / "Till Premature Withdrawal", as the product has it. */
get penalPeriodLabel(): string {
const id = this.closureSettingsForm.value.preClosurePenalInterestOnTypeId;
return this.preClosurePenalInterestOnTypeData.find((option: any) => option.id === id)?.value ?? '-';
}
/**
* Chosen maturity/closure settings, ready to merge into the submit payload. The premature-closure penalty is left
* out: it is the product's, which the submit already carries and the server applies regardless.
*/
get closureSettings() {
const settings = this.closureSettingsForm.getRawValue();
delete settings.preClosurePenalApplicable;
delete settings.preClosurePenalInterest;
delete settings.preClosurePenalInterestOnTypeId;
for (const key in settings) {
if (settings[key] === '') {
delete settings[key];
@@ -878,12 +878,12 @@
}
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}</span>
<span class="preview-value">{{ closureSettings?.preClosurePenalApplicable | yesNo }}</span>
<span class="preview-value">{{ productPenal.applicable | yesNo }}</span>
</div>
@if (closureSettings?.preClosurePenalApplicable) {
@if (productPenal.applicable) {
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Penal Interest' | translate }} (%)</span>
<span class="preview-value">{{ closureSettings?.preClosurePenalInterest }}%</span>
<span class="preview-value">{{ productPenal.interest }}%</span>
</div>
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Period' | translate }}</span>
@@ -218,10 +218,20 @@ export class RecurringDepositsAccountPreviewStepComponent implements OnChanges {
return match ? `(${match.accountNo}) ${match.savingsProductName}` : '—';
}
/** The premature-closure penalty is the product's (the account cannot change it), so it is shown from the product. */
get productPenal(): { applicable: boolean; interest: any; typeId: any } {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return {
applicable: !!template?.preClosurePenalApplicable,
interest: template?.preClosurePenalInterest,
typeId: template?.preClosurePenalInterestOnType?.id
};
}
get closurePenalInterestOnTypeLabel(): string {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
const options = template?.preClosurePenalInterestOnTypeOptions ?? [];
const match = options.find((o: any) => o.id === this.closureSettings?.preClosurePenalInterestOnTypeId);
const match = options.find((o: any) => o.id === this.productPenal.typeId);
return match?.value ?? '—';
}
@@ -200,6 +200,27 @@
}}
</span>
</div>
@if (lienLimit) {
<div class="nx-row">
<span class="nx-row__label">Loan against RD ({{ lienLimit.percentage }}% of balance)</span>
<span class="nx-row__value nx-row__value--amount">
up to {{ lienLimit.maxLoanAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}, available
{{ lienLimit.availableForLoan | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (totalLienAmount > 0) {
<div class="nx-row">
<span class="nx-row__label">Lien Marked (loan security)</span>
<span class="nx-row__value nx-row__value--amount">
{{ totalLienAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
-
@for (lien of liens; track lien.loanId; let last = $last) {
Loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
}
</span>
</div>
}
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Recurring Deposit Amount' | translate }}</span>
<span class="nx-row__value nx-row__value--amount">
@@ -15,6 +15,7 @@ import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RecurringDepositsService } from '../../recurring-deposits.service';
@Component({
selector: 'mifosx-general-tab',
@@ -34,14 +35,22 @@ export class GeneralTabComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private recurringDepositsService = inject(RecurringDepositsService);
recurringDepositsAccountData: any;
isprematureAllowed = false;
entityType: string;
currency: Currency;
/** Loans this deposit secures; empty when it is free of lien. */
liens: any[] = [];
totalLienAmount = 0;
/** Loan limit today (Lien Marked products only): % that applies, what it comes to, and what is left. */
lienLimit: { percentage: number; maxLoanAmount: number; availableForLoan: number } | null = null;
constructor() {
this.route.parent.data.subscribe((data: { recurringDepositsAccountData: any }) => {
this.recurringDepositsAccountData = data.recurringDepositsAccountData;
this.loadLiens(this.recurringDepositsAccountData?.id);
this.currency = this.recurringDepositsAccountData.currency;
this.isprematureAllowed = data.recurringDepositsAccountData.maturityDate != null;
if (this.router.url.includes('clients')) {
@@ -53,4 +62,24 @@ export class GeneralTabComponent {
}
});
}
private loadLiens(accountId: any) {
this.liens = [];
this.totalLienAmount = 0;
this.lienLimit = null;
if (!accountId) {
return;
}
this.recurringDepositsService.getRecurringDepositLiens(accountId).subscribe((response: any) => {
this.liens = response?.liens ?? [];
this.totalLienAmount = Number(response?.totalLienAmount) || 0;
this.lienLimit = response?.lienAllowed
? {
percentage: Number(response.effectiveLienPercentage) || 0,
maxLoanAmount: Number(response.maxLoanAmount) || 0,
availableForLoan: Number(response.availableForLoan) || 0
}
: null;
});
}
}
@@ -82,6 +82,20 @@
<span class="rd-kpi__label">{{ 'labels.inputs.Deposit Period' | translate }}</span>
<span class="rd-kpi__value">{{ recurringDepositsAccountData.depositPeriod | number }}</span>
</div>
<!-- Interest credited to the deposit so far - by its product's posting period (monthly, quarterly...), plus the
remainder on the maturity date. -->
<div class="rd-kpi rd-kpi--interest">
<span class="rd-kpi__label">Interest Posted</span>
<span class="rd-kpi__value">
{{
(recurringDepositsAccountData.summary.totalInterestPosted || 0)
| currency: currency.code : 'symbol-narrow' : '1.2-2'
}}
</span>
@if (recurringDepositsAccountData.interestPostingPeriodType?.value) {
<span class="rd-kpi__sub">Credited {{ recurringDepositsAccountData.interestPostingPeriodType.value }}</span>
}
</div>
</div>
}
@@ -212,6 +212,17 @@ $border: #e2e8f0;
background: rgb(139 92 246 / 22%);
border-color: rgb(139 92 246 / 35%);
}
&--interest {
background: rgb(16 185 129 / 22%);
border-color: rgb(16 185 129 / 35%);
}
&__sub {
margin-top: 1px;
font-size: 9.5px;
color: rgb(255 255 255 / 75%);
}
}
/* Status pill */
@@ -40,6 +40,10 @@ import { MatTabNav, MatTabLink, MatTabNavPanel } from '@angular/material/tabs';
import { StatusLookupPipe } from '../../../pipes/status-lookup.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dormancy-alert-panel.component';
import { ProductsService } from 'app/products/products.service';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { AlertService } from 'app/core/alert/alert.service';
/**
* RecurringDeposits Account View Component
@@ -79,6 +83,8 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private authenticationService = inject(AuthenticationService);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
dialog = inject(MatDialog);
private translateService = inject(TranslateService);
@@ -105,6 +111,7 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
'Enable Withhold Tax': 'interest',
'Disable Withhold Tax': 'interest',
'Premature Close': 'danger',
'Extend Tenure': 'admin',
'Undo Activation': 'admin',
Close: 'danger',
'Modify Application': 'admin',
@@ -128,6 +135,7 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
'Enable Withhold Tax': 'percentage',
'Disable Withhold Tax': 'percentage',
'Premature Close': 'times-circle',
'Extend Tenure': 'calendar-alt',
'Undo Activation': 'undo',
Close: 'times-circle',
'Modify Application': 'edit',
@@ -184,6 +192,30 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
ngOnInit() {
this.setConditionalButtons();
this.applyProductRules();
}
/**
* Buttons the deposit's product decides: no Premature Close unless the product has "Premature Withdrawal option" on,
* and an Extend Tenure action when it has "Tenure Flexibility" on. The server enforces both as well.
*/
private applyProductRules() {
const productId = this.recurringDepositsAccountData.depositProductId;
if (!productId) {
return;
}
this.productsService.getRecurringDepositProduct(String(productId)).subscribe((product: any) => {
if (!product?.prematureWithdrawalOption) {
this.buttonConfig.buttonsArray = this.buttonConfig.buttonsArray.filter((btn) => btn.name !== 'Premature Close');
this.buttonConfig.optionArray = this.buttonConfig.optionArray.filter((opt) => opt.name !== 'Premature Close');
}
if (product?.tenureFlexibility && this.recurringDepositsAccountData.status?.value === 'Active') {
this.buttonConfig.addOption({
name: 'Extend Tenure',
taskPermissionName: 'EXTENDTENURE_RECURRINGDEPOSITACCOUNT'
});
}
});
}
/**
@@ -312,9 +344,61 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
case 'Terminate':
this.terminateRecurringDepositsAccount();
break;
case 'Extend Tenure':
this.extendTenure();
break;
}
}
/**
* Lengthens the deposit's tenure (product has Tenure Flexibility on). New installments are added after the current
* last one, so Auto Debit keeps collecting through the added months; the rate becomes the chart's rate for the new
* tenure and the maturity date and amount are worked out again.
*/
private extendTenure() {
const current = Number(this.recurringDepositsAccountData.depositPeriod) || 0;
const unit = this.recurringDepositsAccountData.depositPeriodFrequency?.value || 'Months';
const data = {
title: 'Extend Tenure',
layout: { addButtonText: 'Extend' },
formfields: [
new InputBase({
controlName: 'depositPeriod',
label: `New tenure in ${unit} (now ${current})`,
value: current + 1,
type: 'number',
required: true,
min: current + 1,
order: 1
})
]
};
this.dialog
.open(FormDialogComponent, { data })
.afterClosed()
.subscribe((response: any) => {
const newPeriod = Number(response?.data?.value?.depositPeriod);
if (!response?.data) {
return;
}
if (!newPeriod || newPeriod <= current) {
this.alertService.alert({ type: 'Extend Tenure', message: `The new tenure must be more than ${current} ${unit}.` });
return;
}
this.recurringDepositsService
.executeRecurringDepositsAccountCommand(this.recurringDepositsAccountData.id, 'extendTenure', {
depositPeriod: newPeriod,
locale: this.settingsService.language.code
})
.subscribe((result: any) => {
if (result?.commandId && !result?.changes) {
this.alertService.alert({ type: 'Extend Tenure', message: 'Tenure extension submitted for approval.' });
}
this.reload();
});
});
}
/**
* Deletes Recurring Deposits Account.
*/
@@ -36,12 +36,9 @@ export class RecurringDepositsButtonsConfiguration {
setButtons(status: string) {
switch (status) {
case 'Active':
// No Deposit button: a direct deposit here skips the manager's approval. RD installments come in
// through the cashier's Receipt (maker-checker) or Auto Debit.
this.buttonsArray = [
{
name: 'Deposit',
icon: 'fa fa-arrow-up',
taskPermissionName: 'DEPOSIT_RECURRINGDEPOSITACCOUNT'
},
{
name: 'Withdrawal',
icon: 'fa fa-arrow-down',
@@ -103,12 +100,9 @@ export class RecurringDepositsButtonsConfiguration {
name: 'Calculate Interest',
icon: 'fa fa-calculator',
taskPermissionName: 'CALCULATEINTEREST_RECURRINGDEPOSITACCOUNT'
},
{
name: 'Post Interest',
icon: 'fa fa-money',
taskPermissionName: 'POSTINTEREST_RECURRINGDEPOSITACCOUNT'
}
// No Post Interest: the term's interest is credited by the server on the maturity date, and again only at
// closure for any days after it.
];
break;
case 'Rejected':
@@ -31,6 +31,14 @@ export class RecurringDepositsService {
return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}`, { params: httpParams });
}
/**
* Loans this recurring deposit currently secures (lien), with the amount each one holds.
* Returns { totalLienAmount, liens: [{ loanId, loanAccountNo, loanStatus, amount }] }.
*/
getRecurringDepositLiens(recurringDepositAccountId: any): Observable<any> {
return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}/liens`);
}
/**
* @param clientId Client Id
* @param clientName Client Name