From 2d2eac8d03d7b02cd5d244528f03a6265696ac4e Mon Sep 17 00:00:00 2001 From: keshav Date: Mon, 12 Oct 2026 10:23:10 +0530 Subject: [PATCH] feat: recurring deposit enhancements, dashboard, chart, and account view updates --- src/app/accounts/accounts.service.ts | 5 + src/app/app.module.ts | 2 + .../transaction-entry.component.ts | 16 +- .../general-tab/general-tab.component.html | 80 +-- .../general-tab/general-tab.component.scss | 21 + .../general-tab/general-tab.component.ts | 33 + .../edit-client/edit-client.component.html | 3 + .../edit-client/edit-client.component.ts | 42 ++ src/app/core/http/cache.interceptor.ts | 7 +- src/app/core/shell/shell.component.html | 3 + ...te-recurring-deposits-account.component.ts | 52 +- ...dit-recurring-deposit-account.component.ts | 14 +- ...te-recurring-deposits-account.component.ts | 3 +- ...ve-recurring-deposits-account.component.ts | 3 +- ...-recurring-deposits-account.component.html | 12 +- ...-recurring-deposits-account.component.scss | 6 + ...se-recurring-deposits-account.component.ts | 16 +- ...e-recurring-deposit-account.component.html | 133 +++- ...e-recurring-deposit-account.component.scss | 42 ++ ...ose-recurring-deposit-account.component.ts | 309 +++++++-- ...ng-deposits-account-actions.component.html | 10 +- ...osits-account-currency-step.component.html | 43 +- ...osits-account-currency-step.component.scss | 9 + ...eposits-account-currency-step.component.ts | 44 +- ...posits-account-details-step.component.html | 21 +- ...deposits-account-details-step.component.ts | 28 +- ...nt-interest-rate-chart-step.component.html | 40 +- ...nt-interest-rate-chart-step.component.scss | 28 + ...ount-interest-rate-chart-step.component.ts | 233 +++++-- ...account-maturity-terms-step.component.html | 34 +- ...account-maturity-terms-step.component.scss | 34 + ...s-account-maturity-terms-step.component.ts | 16 +- ...posits-account-preview-step.component.html | 6 +- ...deposits-account-preview-step.component.ts | 12 +- .../general-tab/general-tab.component.html | 21 + .../general-tab/general-tab.component.ts | 29 + ...rring-deposits-account-view.component.html | 14 + ...rring-deposits-account-view.component.scss | 11 + ...curring-deposits-account-view.component.ts | 84 +++ .../recurring-deposits-buttons.config.ts | 14 +- .../recurring-deposits.service.ts | 8 + .../rbi-reserve-config.component.html | 22 +- .../rbi-reserve-config.component.ts | 114 +++- src/app/pipes/accounts-filter.pipe.ts | 2 +- ...e-recurring-deposit-product.component.html | 13 +- ...ate-recurring-deposit-product.component.ts | 104 ++- ...dit-recurring-deposit-product.component.ts | 21 + .../rd-product-chart.ts | 207 ++++++ ...posit-product-accounting-step.component.ts | 65 +- ...posit-product-currency-step.component.html | 11 +- ...eposit-product-details-step.component.html | 16 +- ...eposit-product-details-step.component.scss | 7 + ...eposit-product-preview-step.component.html | 80 +-- ...posit-product-settings-step.component.html | 111 +++- ...posit-product-settings-step.component.scss | 11 + ...deposit-product-settings-step.component.ts | 68 +- ...-deposit-product-terms-step.component.html | 117 ++-- ...ng-deposit-product-terms-step.component.ts | 175 +++-- ...curring-deposit-general-tab.component.html | 98 +-- .../reserve-return.component.html | 18 +- .../rd-dashboard-routing.module.ts | 29 + .../rd-dashboard/rd-dashboard.component.html | 256 ++++++++ .../rd-dashboard/rd-dashboard.component.scss | 620 ++++++++++++++++++ .../rd-dashboard/rd-dashboard.component.ts | 256 ++++++++ src/app/rd-dashboard/rd-dashboard.compute.ts | 214 ++++++ src/app/rd-dashboard/rd-dashboard.module.ts | 22 + src/app/settings/settings.service.ts | 10 +- src/assets/translations/en-US.json | 2 +- src/environments/.env.ts | 4 +- 69 files changed, 3619 insertions(+), 595 deletions(-) create mode 100644 src/app/products/recurring-deposit-products/rd-product-chart.ts create mode 100644 src/app/rd-dashboard/rd-dashboard-routing.module.ts create mode 100644 src/app/rd-dashboard/rd-dashboard.component.html create mode 100644 src/app/rd-dashboard/rd-dashboard.component.scss create mode 100644 src/app/rd-dashboard/rd-dashboard.component.ts create mode 100644 src/app/rd-dashboard/rd-dashboard.compute.ts create mode 100644 src/app/rd-dashboard/rd-dashboard.module.ts diff --git a/src/app/accounts/accounts.service.ts b/src/app/accounts/accounts.service.ts index ddc9efadc..2f25c2917 100644 --- a/src/app/accounts/accounts.service.ts +++ b/src/app/accounts/accounts.service.ts @@ -85,6 +85,11 @@ export class AccountsService { return this.http.get('/fixeddepositaccounts/maturity-dashboard'); } + /** RD Dashboard: running RDs with their installment position, closures and penalty income for the month. */ + getRecurringDepositDashboard(): Observable { + return this.http.get('/recurringdepositaccounts/dashboard'); + } + getRecurringDepositAccounts(params: AccountListParams = {}): Observable { let httpParams = new HttpParams() .set('paged', 'true') diff --git a/src/app/app.module.ts b/src/app/app.module.ts index b95fd0ad1..2368bb912 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -64,6 +64,7 @@ import { BankCalendarModule } from './bank-calendar/bank-calendar.module'; import { DeafModule } from './deaf/deaf.module'; import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module'; import { FdDashboardModule } from './fd-dashboard/fd-dashboard.module'; +import { RdDashboardModule } from './rd-dashboard/rd-dashboard.module'; import { DicgcModule } from './dicgc/dicgc.module'; import { FiuModule } from './fiu/fiu.module'; import { GlReportsModule } from './gl-reports/gl-reports.module'; @@ -178,6 +179,7 @@ export function HttpLoaderFactory(http: HttpClient) { DeafModule, SavingsDailyInterestModule, FdDashboardModule, + RdDashboardModule, DicgcModule, FiuModule, GlReportsModule, diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index aca25b5d5..c46f71202 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -5196,7 +5196,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.showAlert('error', 'Invalid Account Type', [ `Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`, 'Only Savings (SD) and Current (CA) accounts are permitted.', - 'GL, FD, RD, SHR accounts are not allowed — use Disbursement/Collection instead.' + 'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.', + 'RD installments are paid through a cashier Receipt or Auto Debit.' ]); return; } @@ -5250,6 +5251,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { this.rows.length >= 2 && this.rows.every((r) => r.typ === 'Disbursement' || r.typ === 'Fund Transfer') ) { + // A Recurring Deposit is never paid this way: these requests post straight into the account, so the + // installment schedule never hears of the money -- installments stay unpaid, Auto Debit collects them + // again and the maturity amount comes out wrong. RD installments come in through the cashier's + // Receipt or the RD's own Auto Debit. + const rdRows = this.rows.filter((r) => r.acTyp === 'RD'); + if (rdRows.length > 0) { + this.showAlert('error', 'Recurring Deposit Not Allowed', [ + `RD account(s) ${rdRows.map((r) => r.acNo).join(', ')} cannot be used in a Disbursement, Collection or Fund Transfer.`, + 'Pay RD installments through a cashier Receipt, or by Auto Debit from the linked savings account.' + ]); + return; + } + const drRows = this.rows.filter((r) => r.dbCr === 'Dr'); const crRows = this.rows.filter((r) => r.dbCr === 'Cr'); diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.html b/src/app/clients/clients-view/general-tab/general-tab.component.html index 242346b4e..7902eb818 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.html +++ b/src/app/clients/clients-view/general-tab/general-tab.component.html @@ -591,16 +591,13 @@

{{ 'labels.heading.Recurring Deposit Accounts' | translate }}

-
- -
- @if (!showClosedRecurringAccounts) { + + @if (savingAccounts) {
- +
+ + + + @@ -638,7 +644,7 @@ } - @if (!element.status.submittedAndPendingApproval && !element.status.active) { + @if (element.status.approved) { - + -
{{ 'labels.inputs.Account No' | translate }} @@ -614,6 +611,15 @@ Status + {{ rdStatusLabel(element) }} + @if (element.timeline?.closedOnDate) { +
on {{ element.timeline.closedOnDate | dateFormat }}
+ } +
{{ 'labels.inputs.Last Active' | translate }} {{ element.lastActiveTransactionDate | dateFormat }} +

@@ -674,67 +680,17 @@

-
- } - - - @if (showClosedRecurringAccounts) { -
- - - - - - - - - - - - - - - - - - - - -
{{ 'labels.inputs.Account No' | translate }} - - - {{ 'labels.inputs.Recurring Deposit Product' | translate }} - - {{ 'labels.inputs.Closed Date' | translate }}{{ element.timeline.closedOnDate | dateFormat }} -
- -

- {{ 'labels.messages.No Closed Recurring Deposit Accounts Available' | translate }} -

-
-
diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.scss b/src/app/clients/clients-view/general-tab/general-tab.component.scss index f913a9808..33fd52124 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.scss +++ b/src/app/clients/clients-view/general-tab/general-tab.component.scss @@ -419,3 +419,24 @@ table[mat-table] { font-size: 24px; z-index: 1; } + +.rd-status { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 12px; + font-weight: 600; + background: #dcfce7; + color: #166534; + + &--ended { + background: #e2e8f0; + color: #475569; + } +} + +.rd-status__date { + margin-top: 2px; + font-size: 11px; + color: #64748b; +} diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.ts b/src/app/clients/clients-view/general-tab/general-tab.component.ts index 333c43931..5382d6c2a 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.ts +++ b/src/app/clients/clients-view/general-tab/general-tab.component.ts @@ -187,6 +187,15 @@ export class GeneralTabComponent implements OnDestroy { 'Saving Account', 'Closed Date' ]; + /** Closed Recurring Deposit Accounts Columns - with how each one ended, for tracking. */ + recurringColumns: string[] = [ + 'Account No', + 'Saving Account', + 'Status', + 'Last Active', + 'Balance', + 'Actions' + ]; /** Open Shares Accounts Columns */ openSharesColumns: string[] = [ 'Account No', @@ -475,6 +484,30 @@ export class GeneralTabComponent implements OnDestroy { ); } + /** Where a recurring deposit stands - for one that has ended, how it ended. */ + rdStatusLabel(account: any): string { + switch (account?.status?.code) { + case 'savingsAccountStatusType.active': + return 'Active'; + case 'savingsAccountStatusType.matured': + return 'Matured (not closed)'; + case 'savingsAccountStatusType.submitted.and.pending.approval': + return 'Pending Approval'; + case 'savingsAccountStatusType.approved': + return 'Approved'; + case 'savingsAccountStatusType.closed': + return 'Matured & Closed'; + case 'savingsAccountStatusType.pre.mature.closure': + return 'Premature Closed'; + case 'savingsAccountStatusType.rejected': + return 'Rejected'; + case 'savingsAccountStatusType.withdrawn.by.applicant': + return 'Withdrawn by Client'; + default: + return account?.status?.value ?? ''; + } + } + viewAccountsLabel(closed: boolean): string { if (closed) { return 'labels.buttons.View Active Accounts'; diff --git a/src/app/clients/edit-client/edit-client.component.html b/src/app/clients/edit-client/edit-client.component.html index 01d6bb123..b3915a723 100644 --- a/src/app/clients/edit-client/edit-client.component.html +++ b/src/app/clients/edit-client/edit-client.component.html @@ -377,6 +377,8 @@ } @if (selectedTabIndex < totalTabs - 1) { + + + } @if (selectedTabIndex === totalTabs - 1) { + diff --git a/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.ts b/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.ts index d68fd0229..f7aae019d 100644 --- a/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.ts +++ b/src/app/deposits/recurring-deposits/create-recurring-deposits-account/create-recurring-deposits-account.component.ts @@ -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 diff --git a/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.ts b/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.ts index 7b5bb33f3..e5662cebe 100644 --- a/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.ts +++ b/src/app/deposits/recurring-deposits/edit-recurring-deposit-account/edit-recurring-deposit-account.component.ts @@ -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 ?? []; diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/activate-recurring-deposits-account/activate-recurring-deposits-account.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/activate-recurring-deposits-account/activate-recurring-deposits-account.component.ts index 018aa2090..99ff4923d 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/activate-recurring-deposits-account/activate-recurring-deposits-account.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/activate-recurring-deposits-account/activate-recurring-deposits-account.component.ts @@ -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 ] }); diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.ts index 847aa80e5..c01022d29 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/approve-recurring-deposits-account/approve-recurring-deposits-account.component.ts @@ -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: [''] diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.html index 4608b5edd..2eb3956b4 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.html @@ -13,6 +13,16 @@

{{ title }}

+ @if (totalLienAmount > 0) { +

+ 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. +

+ } + {{ 'labels.inputs.Closed On' | translate }} {{ 'labels.buttons.Cancel' | translate }} - diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.scss b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.scss index 6190cc4ca..973742d68 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.scss +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.scss @@ -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%; diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.ts index 05fcb06a5..29b166089 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/close-recurring-deposits-account/close-recurring-deposits-account.component.ts @@ -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 }], diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.html index 1abafde16..5ecee58b8 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.html @@ -11,6 +11,51 @@
+ @if (account) { +
+
+ Account{{ account.accountNo }} +
+
+ Current balance{{ currencySymbol }} {{ balance | number: '1.2-2' }} +
+ @if (account.maturityDate) { +
+ Maturity date{{ account.maturityDate | dateFormat }} +
+ } + @if (account.maturityAmount != null) { +
+ Amount at maturity{{ currencySymbol }} {{ account.maturityAmount | number: '1.2-2' }} +
+ } +
+ Pre-closure penalty + + @if (account.preClosurePenalApplicable) { + {{ account.preClosurePenalInterest }}% less interest + @if (account.preClosurePenalInterestOnType?.value) { + ({{ account.preClosurePenalInterestOnType.value }}) + } + } @else { + None + } + +
+
+ } + + @if (totalLienAmount > 0) { +

+ 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. +

+ } + {{ 'labels.inputs.Closed On' | translate }} + Must be before the maturity date - on or after it, use Close instead. @if (prematureCloseRecurringDepositsAccountForm.controls.closedOnDate.hasError('required')) { {{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }} @@ -30,6 +76,89 @@ } + + @if (reasonOptions.length > 1) { + + Closure reason + + @for (reason of reasonOptions; track reason.id) { + {{ reason.label }} + } + + {{ selectedReasonHint }} + + } + +
+ +
+ + @if (preview) { +
+
+ Payable on premature closure + {{ currencySymbol }} {{ preview.payoutAmount | number: '1.2-2' }} +
+ @if (shortfallVsMaturity != null && shortfallVsMaturity > 0) { +
+ Less than at maturity by + {{ currencySymbol }} {{ shortfallVsMaturity | number: '1.2-2' }} +
+ } +

+ Paid by transfer to the client's savings account - a recurring deposit cannot be broken for cash. +

+
+ + @if (destinationAccounts.length === 0) { +

+ This client has no active savings account to receive the proceeds. Open one first, then close this + deposit. +

+ } @else { + + {{ 'labels.inputs.Transfer to Savings' | translate }} + + @for (savings of destinationAccounts; track savings.id) { + + {{ savings.accountNo }} - {{ savings.productName }} + @if (savings.owner) { + (nominee: {{ savings.owner }}) + } @else if (savings.id === account?.linkedAccountId) { + (linked) + } + + } + + @if (prematureCloseRecurringDepositsAccountForm.controls.toSavingsAccountId.hasError('required')) { + + {{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + + {{ 'labels.inputs.Transfer Description' | translate }} + + + + + {{ 'labels.inputs.Note' | translate }} + + + } + }
@@ -37,8 +166,8 @@ -
diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.scss b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.scss index 7c3f1555b..e63e5c2b3 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.scss +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.scss @@ -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; +} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.ts index 0590a2ba0..fdaa472d2 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/premature-close-recurring-deposit-account/premature-close-recurring-deposit-account.component.ts @@ -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; } } diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/recurring-deposits-account-actions.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/recurring-deposits-account-actions.component.html index cd9a11499..faa045a8d 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/recurring-deposits-account-actions.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-actions/recurring-deposits-account-actions.component.html @@ -30,6 +30,14 @@ @if (actions['Close']) { } -@if (actions['Deposit'] || actions['Withdrawal']) { + +@if (actions['Deposit']) { +

+ RD installments cannot be deposited from the account screen. Use a cashier Receipt, or Auto Debit from the linked + savings account. +

+} +@if (actions['Withdrawal']) { } diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.html index 37228451c..481f7843a 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.html @@ -30,11 +30,50 @@

Auto Debit Facility

-

Enable automatic deduction of funds for recurring payments, utility bills, or EMI obligations.

+ @if (recurringDepositsAccountProductTemplate?.autoDebitFacility) { +

+ 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. +

+ } @else { +

This product does not offer Auto Debit.

+ }
- +
+ @if (lienAllowed) { +
+
+

Loan against this RD (Lien)

+

+ 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 }}%. +

+ @if (lienPercentageError) { +

{{ lienPercentageError }}

+ } +
+ + Max loan (%) + + % + +
+ } +

Dormancy Alert

diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.scss b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.scss index 6f722d291..c84d1edb3 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.scss +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.scss @@ -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; diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.ts index cb46ab05c..80771e8b4 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component.ts @@ -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) } : {}) }; } } diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html index 5fd042184..df36a57ef 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html @@ -121,11 +121,30 @@ {{ productTemplate.shareLinkingEnabled ? 'Yes' : 'No' }}
+ +
+ Introducer Required + + {{ productTemplate.introducerRequired ? 'Yes' : 'No' }} + +
+ + @if (productTemplate?.introducerRequired) { + + } + - @if (productTemplate?.shareLinkingEnabled) { + + @if (productTemplate?.shareLinkingEnabled || productTemplate?.autoDebitFacility) {
diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts index 18035cd4b..6db0d9284 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts @@ -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; diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html index 237abd8dc..02a49835c 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html @@ -93,7 +93,13 @@
₹ @@ -257,6 +263,21 @@ {{ matchResult.appliedRate | number: '1.2-2' }}% p.a.
+ @if (matchResult.interestCalculation) { +
+ Interest Type + + {{ matchResult.interestCalculation }} + @if (matchResult.interestCalculation === 'Compounding') { + ({{ interestPeriodLabel(matchResult.compoundingPeriod) }}) + } + +
+
+ Interest Credited + {{ interestPeriodLabel(matchResult.postingPeriod) }} +
+ }
Estimated Interest Earned ₹ {{ matchResult.interestEarned | number: '1.2-2' }} @@ -267,6 +288,23 @@ >₹ {{ matchResult.maturityAmount | number: '1.2-2' }}
+ @if (matchResult.postingSchedule?.length) { +
+ Interest credits to the RD ({{ matchResult.postingSchedule.length }}) + + + + + + @for (p of matchResult.postingSchedule; track p.date) { + + + + + } +
Credited onInterest
{{ p.date }}₹ {{ p.interest | number: '1.2-2' }}
+
+ }
} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.scss b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.scss index 17f2808e8..7ceee8904 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.scss +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.scss @@ -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; + } + } +} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts index 554d9d1ee..a2c5a7411 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts @@ -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 = { + 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 { diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.html index 17d6f00d7..f5d550240 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.html @@ -84,25 +84,17 @@
} - - {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} - - - @if (closureSettingsForm.value.preClosurePenalApplicable) { - - {{ 'labels.inputs.Penal Interest' | translate }} (%) - - - - - {{ 'labels.inputs.Period' | translate }} - - @for (preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; track preClosurePenalInterestOnType) { - - {{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }} - - } - - - } + +
+
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }} From Product
+ @if (closureSettingsForm.value.preClosurePenalApplicable) { +
+ Penal Interest: {{ closureSettingsForm.value.preClosurePenalInterest }}% + Period: {{ penalPeriodLabel }} +
+
On premature closure the rate is reduced by this much.
+ } @else { +
No - no penal interest is charged on premature closure of this product.
+ } +
diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.scss b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.scss index 1f807bcce..b36e1d103 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.scss +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.scss @@ -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; + } +} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.ts index d32140665..bf02c8325 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component.ts @@ -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]; diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html index 0a12ed363..28f4bba40 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html @@ -878,12 +878,12 @@ }
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }} - {{ closureSettings?.preClosurePenalApplicable | yesNo }} + {{ productPenal.applicable | yesNo }}
- @if (closureSettings?.preClosurePenalApplicable) { + @if (productPenal.applicable) {
{{ 'labels.inputs.Penal Interest' | translate }} (%) - {{ closureSettings?.preClosurePenalInterest }}% + {{ productPenal.interest }}%
{{ 'labels.inputs.Period' | translate }} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.ts index a5855be60..c03a76925 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.ts @@ -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 ?? '—'; } diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.html index 7a476f42e..7c3a57848 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.html @@ -200,6 +200,27 @@ }}
+ @if (lienLimit) { +
+ Loan against RD ({{ lienLimit.percentage }}% of balance) + + up to {{ lienLimit.maxLoanAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}, available + {{ lienLimit.availableForLoan | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + +
+ } + @if (totalLienAmount > 0) { +
+ Lien Marked (loan security) + + {{ totalLienAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + - + @for (lien of liens; track lien.loanId; let last = $last) { + Loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }} + } + +
+ }
{{ 'labels.inputs.Recurring Deposit Amount' | translate }} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.ts index 05da72274..11fd8779c 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/general-tab/general-tab.component.ts @@ -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; + }); + } } diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.html index fda86a309..5f7a36354 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.html @@ -82,6 +82,20 @@ {{ 'labels.inputs.Deposit Period' | translate }} {{ recurringDepositsAccountData.depositPeriod | number }}
+ +
+ Interest Posted + + {{ + (recurringDepositsAccountData.summary.totalInterestPosted || 0) + | currency: currency.code : 'symbol-narrow' : '1.2-2' + }} + + @if (recurringDepositsAccountData.interestPostingPeriodType?.value) { + Credited {{ recurringDepositsAccountData.interestPostingPeriodType.value }} + } +
} diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.scss b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.scss index 22f751908..bf058b248 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.scss +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.scss @@ -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 */ diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.ts index 22d3aa3da..862c73f30 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-account-view.component.ts @@ -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. */ diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-buttons.config.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-buttons.config.ts index 7e2f5dd5f..118544ca7 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-buttons.config.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-view/recurring-deposits-buttons.config.ts @@ -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': diff --git a/src/app/deposits/recurring-deposits/recurring-deposits.service.ts b/src/app/deposits/recurring-deposits/recurring-deposits.service.ts index db9af6cc8..cb399e41d 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits.service.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits.service.ts @@ -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 { + return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}/liens`); + } + /** * @param clientId Client Id * @param clientName Client Name diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html index 46124ece9..63e0e807a 100644 --- a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html @@ -230,11 +230,29 @@ - @for (mapping of selection[role.configKey]; track $index; let i = $index) { + + @for (mapping of selection[role.configKey]; track mapping.rowId; let i = $index) { - + + + + + @for (o of availableAccounts(role, i); track o.id) { {{ o.glCode }} · {{ o.name }} } diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts index 4edfb90f0..a9bed2898 100644 --- a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts +++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts @@ -7,8 +7,9 @@ */ import { Component, OnInit, inject } from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { catchError, forkJoin, of } from 'rxjs'; +import { NgxMatSelectSearchModule } from 'ngx-mat-select-search'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { OrganizationService } from '../organization.service'; @@ -20,6 +21,15 @@ export interface ReserveMapping { glAccountId: number; weightPercentage: number; liabilityClass: string | null; + /** + * Identity for the row, so the template can track rows by it rather than by position. + * + * Tracking by index made deletion impossible: removing a row shifts every row after it up one, so + * Angular reused the inputs that were bound to the old positions and ngModel wrote their values + * straight back into the shortened array. The row reappeared, the draft matched what was stored + * again, and Save stayed grey. Never sent to the backend. + */ + rowId: number; } /** @@ -43,7 +53,9 @@ export interface ReserveMapping { styleUrls: ['./rbi-reserve-config.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, - FormsModule + FormsModule, + ReactiveFormsModule, + NgxMatSelectSearchModule ] }) export class RbiReserveConfigComponent implements OnInit { @@ -80,9 +92,33 @@ export class RbiReserveConfigComponent implements OnInit { /** Every reserve role the backend returned, before the form filter narrows it. */ private allRoles: any[] = []; - /** Re-slices what is on screen. No round trip - the full set is already held. */ + /** Hands each mapping row an identity the template can track it by. See ReserveMapping.rowId. */ + private nextRowId = 1; + + /** + * What is typed into the search box inside an open account dropdown. + * + * One field for the whole screen rather than one per row: only one dropdown is open at a time, and + * it is cleared as each one opens, so a term left behind cannot narrow the next list a user opens + * without them seeing why it is short. + */ + accountSearch = ''; + + /** + * Re-slices what is on screen. No round trip - the full set is already held. + * + * The rows are rebuilt for whatever the new slice brings in. They used not to be: selection was + * filled once, for the heads the first slice happened to contain, so switching the filter showed + * the newly admitted heads with no rows at all - a card with a name and an empty body, reading as + * a head that had nothing mapped when it was simply never read. + */ onFormFilterChange(): void { this.glRoles = this.rolesForScreen(); + for (const role of this.glRoles) { + if (!this.selection[role.configKey]) { + this.reloadSelection(role); + } + } this.rebuildGroups(); } @@ -230,11 +266,7 @@ export class RbiReserveConfigComponent implements OnInit { for (const role of this.glRoles) { // Read back what is stored rather than re-derived: a weight or a class the administrator set // deliberately would otherwise be replaced by a default on the next save. - this.selection[role.configKey] = (role.accounts || []).map((a: any) => ({ - glAccountId: a.glAccountId, - weightPercentage: a.weightPercentage === null || a.weightPercentage === undefined ? 100 : +a.weightPercentage, - liabilityClass: a.liabilityClass ?? (role.classified ? 'TIME' : null) - })); + this.reloadSelection(role); } this.rebuildGroups(); } @@ -288,9 +320,16 @@ export class RbiReserveConfigComponent implements OnInit { ); } - /** A role with nothing mapped makes its return refuse to compute — surfaced, not hidden. */ + /** + * A role with nothing mapped makes its return refuse to compute — surfaced, not hidden. + * + * Read from what is STORED, not from the draft on screen. The returns run off the saved mapping, + * so a row cleared but not yet saved changes nothing for them; counting it as unmapped made the + * banner announce that returns could not compute while they were still computing perfectly well. + * The unsaved edit is already shown by Save turning active. + */ isUnmapped(role: any): boolean { - return !(this.selection[role.configKey] || []).length; + return !(role.accounts || []).length; } get unmappedCount(): number { @@ -327,11 +366,29 @@ export class RbiReserveConfigComponent implements OnInit { // a bank look compliant when it is not. this.selection[role.configKey] = [ ...(this.selection[role.configKey] || []), - { glAccountId: null as any, weightPercentage: 100, liabilityClass: role.classified ? 'TIME' : null }]; + { + glAccountId: null as any, + weightPercentage: 100, + liabilityClass: role.classified ? 'TIME' : null, + rowId: this.nextRowId++ + }]; } + /** + * Drops a row and saves the head straight away. + * + * The bin used to clear the row on screen and leave it to Save, which read as the mapping having + * been removed when nothing had reached the server - the row came back on the next refresh. A bin + * is understood to delete, so it deletes: a row with nothing chosen in it never existed on the + * server and only has to go from the screen, and anything else is written through at once. + */ removeMapping(role: any, index: number): void { - this.selection[role.configKey] = (this.selection[role.configKey] || []).filter((_, i) => i !== index); + const rows = this.selection[role.configKey] || []; + const removed = rows[index]; + this.selection[role.configKey] = rows.filter((_, i) => i !== index); + if (removed?.glAccountId) { + this.saveRole(role); + } } /** @@ -343,7 +400,23 @@ export class RbiReserveConfigComponent implements OnInit { const rows = this.selection[role.configKey] || []; const taken = new Set(rows.filter((_, i) => i !== index).map((m) => `${m.glAccountId}/${m.liabilityClass ?? ''}`)); const current = rows[index]; - return this.glAccountOptions.filter((o) => !taken.has(`${o.id}/${current?.liabilityClass ?? ''}`)); + const free = this.glAccountOptions.filter((o) => !taken.has(`${o.id}/${current?.liabilityClass ?? ''}`)); + + // Typed into the search box above the list. Matched on code AND name, because an administrator + // looking for a head knows one or the other - the GL code off a trial balance, or the name off + // the balance sheet - and should not have to know which this list is sorted by. + const term = (this.accountSearch || '').trim().toLowerCase(); + if (!term) { + return free; + } + // The chosen account stays in the list whatever is typed. Dropping it would leave mat-select + // with a value that matches no option, and the row would read as empty. + return free.filter( + (o) => + o.id === current?.glAccountId || + `${o.glCode}`.toLowerCase().includes(term) || + `${o.name}`.toLowerCase().includes(term) + ); } /** A row that cannot be saved - no account chosen, or a portion outside 0-100. */ @@ -379,6 +452,16 @@ export class RbiReserveConfigComponent implements OnInit { .filter((e) => Math.abs(e.total - 100) > 0.0001); } + /** Rebuilds one head's rows from what the server last returned, discarding the draft. */ + private reloadSelection(role: any): void { + this.selection[role.configKey] = (role.accounts || []).map((a: any) => ({ + glAccountId: a.glAccountId, + weightPercentage: a.weightPercentage === null || a.weightPercentage === undefined ? 100 : +a.weightPercentage, + liabilityClass: a.liabilityClass ?? (role.classified ? 'TIME' : null), + rowId: this.nextRowId++ + })); + } + saveRole(role: any): void { this.savingKey = role.configKey; this.errorMsg = ''; @@ -398,6 +481,11 @@ export class RbiReserveConfigComponent implements OnInit { catchError((err: any) => { this.savingKey = null; this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this mapping.'; + // Put the screen back to what the server holds. A failed save that left the draft showing + // would say the change had gone through while the server still had the old mapping - and + // the bin deletes immediately, so this is the only thing between a rejected delete and a + // row that looks gone but is not. + this.reloadSelection(role); return of(null); }) ) diff --git a/src/app/pipes/accounts-filter.pipe.ts b/src/app/pipes/accounts-filter.pipe.ts index 3b7363383..3d7ec3213 100644 --- a/src/app/pipes/accounts-filter.pipe.ts +++ b/src/app/pipes/accounts-filter.pipe.ts @@ -62,7 +62,7 @@ export class AccountsFilterPipe implements PipeTransform { account.status.code === 'savingsAccountStatusType.rejected' ); }); - } else { + } else if (status !== 'all') { accounts = accounts.filter((account: any) => { return ( account.status.code !== 'savingsAccountStatusType.withdrawn.by.applicant' && diff --git a/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.html b/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.html index bf3559643..bc9302911 100644 --- a/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.html +++ b/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.html @@ -89,7 +89,18 @@

{{ activeBaseVersion.remarks }}

} - +

+ RD tenure is counted in months, from {{ rdChart.minTenureMonths ?? '-' }} months (the Minimum Tenure on the + Terms tab). A rate below the first slab comes from the RD CBS base chart; the rest from this chart. +

+ @if (rdChart.shortTermRateMissing) { +

+ The RD CBS base chart has no rate for {{ configuredMinTenureMonths }} months, so this product can only be + opened from {{ rdChart.minTenureMonths }} months. +

+ } + +
diff --git a/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.ts b/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.ts index e67d1bf8b..c27a58bf8 100644 --- a/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.ts +++ b/src/app/products/recurring-deposit-products/create-recurring-deposit-product/create-recurring-deposit-product.component.ts @@ -35,6 +35,8 @@ import { ProductsService } from 'app/products/products.service'; import { SettingsService } from 'app/settings/settings.service'; import { Accounting } from 'app/core/utils/accounting'; import { Dates } from 'app/core/utils/dates'; +import { AlertService } from 'app/core/alert/alert.service'; +import { buildRdChart, RdChart, tenureInMonths } from '../rd-product-chart'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { RecurringDepositProductPreviewStepComponent } from '../recurring-deposit-product-stepper/recurring-deposit-product-preview-step/recurring-deposit-product-preview-step.component'; import { MatTabsModule } from '@angular/material/tabs'; @@ -74,6 +76,7 @@ export class CreateRecurringDepositProductComponent { private settingsService = inject(SettingsService); private accounting = inject(Accounting); private dateUtils = inject(Dates); + private alertService = inject(AlertService); @ViewChild(RecurringDepositProductDetailsStepComponent, { static: true }) recurringDepositProductDetailsStep: RecurringDepositProductDetailsStepComponent; @@ -92,6 +95,9 @@ export class CreateRecurringDepositProductComponent { accountingRuleData: string[] = []; selectedTabIndex = 0; rdInterestRates: any[] = []; + rdBaseSlabs: any[] = []; + private rdChartCache: { baseSlabs: any[]; rdBaseSlabs: any[]; minTenureMonths: number | null; chart: RdChart } | null = + null; readonly rdSlabColumns = [ 'srNo', 'description', @@ -122,6 +128,17 @@ export class CreateRecurringDepositProductComponent { this.rdInterestRates = []; } }); + // The RD base chart supplies the rate for 6 months to below 1 year, which the FD chart does not cover + this.productsService.getRdBaseInterestRates().subscribe({ + next: (rates: any) => { + const versions: any[] = Array.isArray(rates) ? rates : rates ? [rates] : []; + const active = versions.find((version: any) => version.isActive) ?? versions[0]; + this.rdBaseSlabs = active?.slabs || []; + }, + error: () => { + this.rdBaseSlabs = []; + } + }); } get recurringDepositProductDetailsForm() { @@ -167,11 +184,37 @@ export class CreateRecurringDepositProductComponent { return null; } + /** The product's chart in months, from 6 months up - see rd-product-chart.ts. */ + /** The minimum tenure entered on the Terms tab, in months. */ + get configuredMinTenureMonths(): number | null { + const terms = this.recurringDepositProductTermsForm?.value; + return tenureInMonths(terms?.minDepositTerm, terms?.minDepositTermTypeId); + } + + get rdChart(): RdChart { + const baseSlabs = this.activeBaseVersion?.slabs || []; + const minTenureMonths = this.configuredMinTenureMonths; + if ( + this.rdChartCache?.baseSlabs !== baseSlabs || + this.rdChartCache?.rdBaseSlabs !== this.rdBaseSlabs || + this.rdChartCache?.minTenureMonths !== minTenureMonths + ) { + this.rdChartCache = { + baseSlabs, + rdBaseSlabs: this.rdBaseSlabs, + minTenureMonths, + chart: buildRdChart(baseSlabs, this.rdBaseSlabs, minTenureMonths) + }; + } + return this.rdChartCache.chart; + } + get activeBaseSlabs() { - return this.activeBaseVersion?.slabs || []; + return this.rdChart.slabs; } get recurringDepositProduct() { + const rdChart = this.rdChart; return { ...this.recurringDepositProductDetailsStep.recurringDepositProductDetails, ...this.recurringDepositProductCurrencyStep.recurringDepositProductCurrency, @@ -179,14 +222,20 @@ export class CreateRecurringDepositProductComponent { ...this.recurringDepositProductSettingsStep.recurringDepositProductSettings, ...this.recurringDepositProductChargesStep.recurringDepositProductCharges, ...this.recurringDepositProductAccountingStep.recurringDepositProductAccounting, + // The minimum comes from the Terms tab. Only when the chart has no rate that short is it raised to where the + // chart's rates begin, so no account can be opened for a tenure the chart cannot price. + ...(rdChart.minTenureMonths != null && rdChart.minTenureMonths !== this.configuredMinTenureMonths + ? { minDepositTerm: rdChart.minTenureMonths, minDepositTermTypeId: 2 } + : {}), charts: this.recurringDepositProductCharts, - rdSlabs: this.activeBaseSlabs + rdSlabs: rdChart.slabs }; } get recurringDepositProductCharts() { const activeBaseVersion = this.activeBaseVersion; - if (!activeBaseVersion?.slabs?.length) { + const slabs = this.rdChart.slabs; + if (!activeBaseVersion || !slabs.length) { return []; } const dateFormat = this.settingsService.dateFormat; @@ -208,7 +257,7 @@ export class CreateRecurringDepositProductComponent { isPrimaryGroupingByAmount: false, locale: this.settingsService.language.code, dateFormat, - chartSlabs: activeBaseVersion.slabs.map((slab: any) => { + chartSlabs: slabs.map((slab: any) => { const chartSlab: any = { periodType: slab.periodTypeEnum, fromPeriod: slab.fromPeriod, @@ -232,7 +281,28 @@ export class CreateRecurringDepositProductComponent { ] as any[]; } + /** + * Terms (min/max installment) and Settings (lien percentage) have rules of their own that the Next buttons do not + * enforce. Opens the first tab that breaks one, with its errors shown, instead of submitting. + */ + private focusFirstInvalidStep(): boolean { + const steps = [ + { form: this.recurringDepositProductTermsForm, tab: 2 }, + { form: this.recurringDepositProductSettingsForm, tab: 3 } + ]; + const invalid = steps.find((step) => step.form?.invalid); + if (!invalid) { + return false; + } + invalid.form.markAllAsTouched(); + this.selectedTabIndex = invalid.tab; + return true; + } + submit() { + if (this.focusFirstInvalidStep()) { + return; + } const recurringDepositProduct: any = this.pruneEmptyValues({ ...this.recurringDepositProduct, charts: this.recurringDepositProductCharts, @@ -243,14 +313,24 @@ export class CreateRecurringDepositProductComponent { recurringDepositProduct.description = ''; } delete recurringDepositProduct.advancedAccountingRules; - this.productsService.createRecurringDepositProduct(recurringDepositProduct).subscribe((response: any) => { - this.router.navigate( - [ - '../', - response.resourceId - ], - { relativeTo: this.route } - ); + this.productsService.createRecurringDepositProduct(recurringDepositProduct).subscribe({ + next: (response: any) => { + if (response?.rollbackTransaction) { + this.alertService.alert({ + type: 'Success', + message: 'Recurring deposit product creation request submitted successfully for approval.' + }); + this.router.navigate(['../'], { relativeTo: this.route }); + } else { + this.router.navigate( + [ + '../', + response.resourceId + ], + { relativeTo: this.route } + ); + } + } }); } diff --git a/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.ts b/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.ts index b3efe62b1..f6affcdb4 100644 --- a/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.ts +++ b/src/app/products/recurring-deposit-products/edit-recurring-deposit-product/edit-recurring-deposit-product.component.ts @@ -241,7 +241,28 @@ export class EditRecurringDepositProductComponent { ] as any[]; } + /** + * Terms (min/max installment) and Settings (lien percentage) have rules of their own that the Next buttons do not + * enforce. Opens the first tab that breaks one, with its errors shown, instead of submitting. + */ + private focusFirstInvalidStep(): boolean { + const steps = [ + { form: this.recurringDepositProductTermsForm, tab: 2 }, + { form: this.recurringDepositProductSettingsForm, tab: 3 } + ]; + const invalid = steps.find((step) => step.form?.invalid); + if (!invalid) { + return false; + } + invalid.form.markAllAsTouched(); + this.selectedTabIndex = invalid.tab; + return true; + } + submit() { + if (this.focusFirstInvalidStep()) { + return; + } const recurringDepositProduct: any = this.pruneEmptyValues({ ...this.recurringDepositProduct, charges: (this.recurringDepositProduct.charges ?? []).map((charge: any) => ({ id: charge.id })), diff --git a/src/app/products/recurring-deposit-products/rd-product-chart.ts b/src/app/products/recurring-deposit-products/rd-product-chart.ts new file mode 100644 index 000000000..c26f4431e --- /dev/null +++ b/src/app/products/recurring-deposit-products/rd-product-chart.ts @@ -0,0 +1,207 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Builds a recurring deposit product's interest chart in months. + * + * RD products take their rates from the FD "DOUBLE" CBS base chart, which is kept in years and starts at 1 year, so + * on its own it leaves every tenure under 12 months without a rate. When the product's minimum tenure is shorter than + * that, the band from the minimum up to just below the first slab takes its rate from the RD CBS base chart - the slab + * covering the shortest possible span of that many months. + * + * Every slab is expressed in months because the backend matches a tenure to a slab by counting whole units of the + * slab's own period type: a 30-month RD is "2 years" against a year slab, so "1-2 Years" really covers 12-35 months. + * Writing that out as months keeps the chart identical in meaning while letting a single chart hold the sub-year band. + */ + +/** SavingsPeriodFrequencyType ids. */ +const MONTHS_PERIOD_TYPE = 2; +const YEARS_PERIOD_TYPE = 3; + +export interface RdChartSlab { + srNo: number; + description: string; + periodTypeEnum: number; + periodTypeLabel: string; + fromPeriod: number; + toPeriod: number | null; + annualInterestRate: number; + seniorCitizenRate?: number; + amountRangeFrom?: number; + amountRangeTo?: number; +} + +export interface RdChart { + slabs: RdChartSlab[]; + /** Shortest tenure, in months, the product opens - its minimum deposit term. */ + minTenureMonths: number | null; + /** Set when the RD base chart has no rate for the minimum tenure, so the product can only start at its first slab. */ + shortTermRateMissing: boolean; +} + +/** A tenure given as a count of months or years (SavingsPeriodFrequencyType id), in months. */ +export function tenureInMonths(term: number | string | null | undefined, termTypeId: number | null | undefined): number | null { + const value = Number(term); + if (term === null || term === undefined || term === '' || !Number.isFinite(value) || value <= 0) { + return null; + } + return termTypeId === YEARS_PERIOD_TYPE ? value * 12 : value; +} + +function unitInMonths(slab: any): number | null { + const label = String(slab.periodTypeLabel ?? slab.periodType?.value ?? '').toLowerCase(); + if (label.startsWith('year')) { + return 12; + } + if (label.startsWith('month')) { + return 1; + } + return null; +} + +function inDays(slab: any, period: number): number | null { + const label = String(slab.periodTypeLabel ?? slab.periodType?.value ?? '').toLowerCase(); + if (label.startsWith('day')) { + return period; + } + if (label.startsWith('week')) { + return period * 7; + } + if (label.startsWith('month')) { + return period * 30; + } + if (label.startsWith('year')) { + return period * 365; + } + return null; +} + +/** The fewest calendar days any run of {@code months} months can span (6 months -> 181, January to July). */ +function shortestSpanInDays(months: number): number { + let shortest = Infinity; + for (let start = 0; start < 12; start++) { + const from = Date.UTC(2025, start, 1); + const to = Date.UTC(2025, start + months, 1); + shortest = Math.min(shortest, Math.round((to - from) / 86400000)); + } + return shortest; +} + +/** The RD base chart rate for an RD of {@code months} months, or null when that chart has no slab covering it. */ +function shortTermRate(rdBaseSlabs: any[], months: number): number | null { + const days = shortestSpanInDays(months); + const slab = (rdBaseSlabs || []).find((candidate: any) => { + const from = inDays(candidate, candidate.fromPeriod ?? 0); + const to = candidate.toPeriod == null ? Infinity : inDays(candidate, candidate.toPeriod); + return from != null && to != null && from <= days && days <= to; + }); + return slab?.annualInterestRate ?? null; +} + +/** + * Month-wise slabs from {@code fromMonths} to {@code toMonths}, each month priced from the RD base chart and runs of + * months at the same rate merged into one slab - so a 3-month minimum against a chart with day bands for 46-90, + * 91-180 and 181-364 days becomes 3, 4-5 and 6-11 months rather than one rate for the lot. Empty when the base chart + * has no rate for the first month; a later month without one carries the previous month's rate. + */ +function shortTermBands(rdBaseSlabs: any[], fromMonths: number, toMonths: number): RdChartSlab[] { + const bands: RdChartSlab[] = []; + let previousRate: number | null = null; + let bandStart = fromMonths; + for (let month = fromMonths; month <= toMonths; month++) { + const rate = shortTermRate(rdBaseSlabs, month) ?? previousRate; + if (rate == null) { + return []; + } + const last = bands[bands.length - 1]; + if (last && last.annualInterestRate === rate) { + last.toPeriod = month; + last.description = monthsLabel(bandStart, month); + } else { + bandStart = month; + bands.push({ + srNo: 0, + description: monthsLabel(month, month), + periodTypeEnum: MONTHS_PERIOD_TYPE, + periodTypeLabel: 'Months', + fromPeriod: month, + toPeriod: month, + annualInterestRate: rate + }); + } + previousRate = rate; + } + return bands; +} + +function monthsLabel(from: number, to: number | null): string { + return to == null ? `${from}+ Months` : `${from} to ${to} Months`; +} + +/** + * @param minTenureMonths the product's minimum tenure in months; null when not yet entered, in which case the chart + * simply starts at its first slab + */ +export function buildRdChart(baseSlabs: any[], rdBaseSlabs: any[], minTenureMonths: number | null): RdChart { + const slabs: RdChartSlab[] = []; + const sorted = [...(baseSlabs || [])].sort((a: any, b: any) => (a.fromPeriod ?? 0) - (b.fromPeriod ?? 0)); + for (const slab of sorted) { + const unit = unitInMonths(slab); + if (unit == null) { + continue; + } + const from = (slab.fromPeriod ?? 0) * unit; + const to = slab.toPeriod == null ? null : (slab.toPeriod + 1) * unit - 1; + const converted: RdChartSlab = { + srNo: 0, + description: monthsLabel(from, to), + periodTypeEnum: MONTHS_PERIOD_TYPE, + periodTypeLabel: 'Months', + fromPeriod: from, + toPeriod: to, + annualInterestRate: slab.annualInterestRate + }; + if (slab.seniorCitizenRate != null) { + converted.seniorCitizenRate = slab.seniorCitizenRate; + } + if (slab.amountRangeFrom != null && slab.amountRangeFrom !== '') { + converted.amountRangeFrom = slab.amountRangeFrom; + } + if (slab.amountRangeTo != null && slab.amountRangeTo !== '') { + converted.amountRangeTo = slab.amountRangeTo; + } + slabs.push(converted); + } + + // The server only accepts a chart whose first slab starts at 0 or 1, so the chart always opens at 1 month and the + // product's minimum deposit term - not the chart - is what keeps an RD from being opened below the minimum. + let shortTermRateMissing = false; + let effectiveMinimum: number | null = null; + const firstFrom = slabs.length ? slabs[0].fromPeriod : null; + if (firstFrom != null) { + effectiveMinimum = minTenureMonths == null ? firstFrom : Math.max(minTenureMonths, 1); + if (firstFrom > 1) { + const needsShortTermBand = minTenureMonths != null && minTenureMonths < firstFrom; + const shortTermSlabs = needsShortTermBand ? shortTermBands(rdBaseSlabs, minTenureMonths, firstFrom - 1) : []; + if (shortTermSlabs.length) { + // The first band is opened down to 1 month for the server; its label still names the real minimum. + shortTermSlabs[0].fromPeriod = 1; + slabs.unshift(...shortTermSlabs); + } else { + // No rate below the first slab: stretch it down to 1 month to satisfy the server, but keep the minimum at + // where that slab really starts so nothing shorter can be opened at its rate. + shortTermRateMissing = needsShortTermBand; + slabs[0].fromPeriod = 1; + effectiveMinimum = Math.max(effectiveMinimum, firstFrom); + } + } + } + + slabs.forEach((slab, index) => (slab.srNo = index + 1)); + return { slabs, minTenureMonths: effectiveMinimum, shortTermRateMissing }; +} diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts index 6d3332921..7f20525e7 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts @@ -95,11 +95,14 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { otherGlMappingsMap: this.recurringDepositProductsTemplate?.otherGlMappingsMap }; const accountingMappingOptions = this.recurringDepositProductsTemplate?.accountingMappingOptions || {}; - // Defaults to Cash (2) for a brand-new product (no existing accountingRule yet), but preserves - // the product's own current accounting rule when editing - previously this was hardcoded to 2 - // unconditionally, which silently forced every edited product onto Cash-based accounting (and - // its required GL mappings) even for products that were and should remain "None". - const accountingRuleId = this.recurringDepositProductsTemplate?.accountingRule?.id ?? 2; + // Cash (2) for a brand-new product, but the product's own current accounting rule when editing - + // previously this was hardcoded to 2 unconditionally, which silently forced every edited product + // onto Cash-based accounting (and its required GL mappings) even for products that were and + // should remain "None". Only an existing product (one with an id) has a rule of its own: the + // create template always reports accountingRule = None, so reading it there submitted every new + // product as None while this screen showed "Cash", and hid the GL mappings it needs. + const isEditing = !!this.recurringDepositProductsTemplate?.id; + const accountingRuleId = isEditing ? (this.recurringDepositProductsTemplate?.accountingRule?.id ?? 2) : 2; this.assetAccountOptions = accountingMappingOptions.assetAccountOptions || []; this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || []; @@ -172,11 +175,20 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { if (this.isCashOrAccrualAccounting()) { const glMappings = mappings.otherGlMappingsMap || mappings.otherGlMappings; this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions); - this.setOtherGlMappings( - 'liabilitiesOtherGlMappings', - glMappings?.liabilities || [], + // Interest Paid is a liability clearing account (Dr Payable / Cr Paid, then Dr Paid / Cr RD), saved under + // liabilities. Products saved before that kept it under expenses, so both groups are read. + const fromLiabilities = this.extractNamedGlMapping( + this.extractNamedGlMapping( + glMappings?.liabilities || [], + RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME, + 'savingsInterestPaidAccountId', + this.liabilityAccountOptions + ), + SAVINGS_INTEREST_PAID_ACCOUNT_NAME, + 'savingsInterestPaidAccountId', this.liabilityAccountOptions ); + this.setOtherGlMappings('liabilitiesOtherGlMappings', fromLiabilities, this.liabilityAccountOptions); const fromExpenses = this.extractNamedGlMapping( this.extractNamedGlMapping( glMappings?.expenses || [], @@ -265,15 +277,15 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { const otherGlMappings = { assets: map('assetsOtherGlMappings', this.assetAccountOptions), - liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions), - expenses: [ + liabilities: [ ...namedAsOtherGlMapping( 'savingsInterestPaidAccountId', RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME, - this.expenseAccountOptions + this.liabilityAccountOptions ), - ...map('expensesOtherGlMappings', this.expenseAccountOptions) + ...map('liabilitiesOtherGlMappings', this.liabilityAccountOptions) ], + expenses: map('expensesOtherGlMappings', this.expenseAccountOptions), income: map('incomeOtherGlMappings', this.incomeAccountOptions) }; return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null; @@ -312,6 +324,7 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { setRequired('incomeFromPenaltyAccountId', cashOrAccrual); setRequired('feesReceivableAccountId', accrualOnly); setRequired('penaltiesReceivableAccountId', accrualOnly); + // Required on accrual (the accrual job credits it); optional on cash, where it only routes the closure posting. setRequired('interestPayableAccountId', accrualOnly); setRequired('escheatLiabilityId', cashOrAccrual && this.isDormancyTrackingEnabled()); @@ -330,8 +343,8 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { rows: [ { key: 'savingsReferenceAccountId', - label: 'Savings Reference Account', - inputLabel: 'Savings reference account', + label: 'Recurring Deposit Reference Account', + inputLabel: 'Recurring deposit reference account', controlName: 'savingsReferenceAccountId', options: this.assetAccountOptions, required: true @@ -364,8 +377,8 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { rows: [ { key: 'savingsControlAccountId', - label: 'Savings Control Account', - inputLabel: 'Savings control account', + label: 'Recurring Deposit Control Account', + inputLabel: 'Recurring deposit control account', controlName: 'savingsControlAccountId', options: this.liabilityAccountOptions, required: true @@ -378,13 +391,22 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { options: this.liabilityAccountOptions, required: true }, + // Offered on cash as well as accrual: the interest credited at maturity or premature closure is routed + // Dr Interest On Recurring Deposits / Cr Payable, Dr Payable / Cr Interest Paid, Dr Interest Paid / Cr + // the RD. Optional - a product naming neither posts straight to the control account as before. { key: 'interestPayableAccountId', label: 'Interest Payable Account', inputLabel: 'Interest payable account', controlName: 'interestPayableAccountId', - options: this.liabilityAccountOptions, - accrualOnly: true + options: this.liabilityAccountOptions + }, + { + key: 'savingsInterestPaidAccountId', + label: 'Recurring Deposit Interest Paid', + inputLabel: 'Recurring deposit interest paid', + controlName: 'savingsInterestPaidAccountId', + options: this.liabilityAccountOptions }, { key: 'escheatLiabilityId', @@ -411,13 +433,6 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit { controlName: 'interestOnSavingsAccountId', options: this.expenseAccountOptions, required: true - }, - { - key: 'savingsInterestPaidAccountId', - label: 'Recurring Deposit Interest Paid', - inputLabel: 'Recurring deposit interest paid', - controlName: 'savingsInterestPaidAccountId', - options: this.expenseAccountOptions } ], otherMappingsControlName: 'expensesOtherGlMappings', diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-currency-step/recurring-deposit-product-currency-step.component.html b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-currency-step/recurring-deposit-product-currency-step.component.html index 0114b7dea..7668031cd 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-currency-step/recurring-deposit-product-currency-step.component.html +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-currency-step/recurring-deposit-product-currency-step.component.html @@ -66,7 +66,10 @@

Nomination Facility

-

Allow account holders to designate individuals to receive funds in the event of account holder's death.

+

+ Yes: every RD under this product must have at least one nominee (100% share) before it can be opened. No: + nominees cannot be recorded on its RDs. +

yes @@ -77,7 +80,11 @@

Auto Debit Facility

-

Enable automatic deduction of funds for recurring payments, utility bills, or EMI obligations.

+

+ Yes: an RD can opt in to having each installment collected automatically on its due date from its linked + Savings account (needs Deposit Account Link). If the balance is short, the installment stays overdue and is + retried on the next run. +

yes diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-details-step/recurring-deposit-product-details-step.component.html b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-details-step/recurring-deposit-product-details-step.component.html index 7940e9c35..b09f60b0e 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-details-step/recurring-deposit-product-details-step.component.html +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-details-step/recurring-deposit-product-details-step.component.html @@ -68,37 +68,41 @@
- + Account Type @for (accountType of accountTypeData; track accountType) { {{ accountType }} } + Default account type pre-selected when an RD is opened under this product. Account Type is required - + GL Account (Liability) @for (account of liabilityAccountOptions; track account.id) { {{ account.name }} ({{ account.glCode }}) } + Its GL code forms part of every new RD account number. Postings use the Accounting tab. - + Effective To + Last day new RDs can be opened. Existing RDs run to maturity. - + End Date + Product wind-up date. No RD may mature after it. @if (recurringDepositProductDetailsForm.get('endDate')?.hasError('matDatepickerMin')) { End Date must be after Effective To date } @@ -115,6 +119,10 @@
Deposit Account Link + + Every RD must be linked to the customer's Savings/Current account - the default account for maturity payout + and the account Auto Debit collects installments from. +
diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-details-step/recurring-deposit-product-details-step.component.scss b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-details-step/recurring-deposit-product-details-step.component.scss index ca87b04f4..c04a7c293 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-details-step/recurring-deposit-product-details-step.component.scss +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-details-step/recurring-deposit-product-details-step.component.scss @@ -76,6 +76,13 @@ $gray-900: #202124; .rdpd-checkbox-row { display: flex; + flex-wrap: wrap; align-items: center; + gap: 4px 12px; min-height: 56px; } + +.rdpd-checkbox-hint { + font-size: 12px; + opacity: 0.7; +} diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-preview-step/recurring-deposit-product-preview-step.component.html b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-preview-step/recurring-deposit-product-preview-step.component.html index a5a5d73a7..cc2b91926 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-preview-step/recurring-deposit-product-preview-step.component.html +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-preview-step/recurring-deposit-product-preview-step.component.html @@ -150,7 +150,7 @@
- {{ 'labels.inputs.Nominal Annual Interest Rate' | translate }} (%): + Post-Maturity Interest Rate (%): {{ recurringDepositProduct.nominalAnnualInterestRate | formatNumber }} %
@@ -199,41 +199,11 @@ @if (recurringDepositProduct.effectiveDate) {
- Effective Date: + Product Effective From: {{ recurringDepositProduct.effectiveDate | dateFormat }}
} - @if (recurringDepositProduct.averagePeriod) { -
- Average Period: - {{ recurringDepositProduct.averagePeriod }} -
- } - -
- Auto Interest Post: - {{ recurringDepositProduct.autoPost | yesNo }} -
- - @if ( - recurringDepositProduct.frequencyFixDay !== undefined && - recurringDepositProduct.frequencyFixDay !== null && - recurringDepositProduct.frequencyFixDay !== '' - ) { -
- Frequency (Fix Day): - {{ recurringDepositProduct.frequencyFixDay }} -
- } - - @if (recurringDepositProduct.lastInterestDate) { -
- Last Interest Date: - {{ recurringDepositProduct.lastInterestDate | dateFormat }} -
- } -

{{ 'labels.heading.Settings' | translate }}

@@ -258,10 +228,13 @@ {{ recurringDepositProduct.lienMarked | yesNo }} -
- {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }}: - {{ recurringDepositProduct.withdrawalFeeForTransfers | yesNo }} -
+ @if (recurringDepositProduct.lienMarked && recurringDepositProduct.lienPercentage) { +
+ Max loan against RD: + {{ recurringDepositProduct.lienPercentage }}% of balance +
+ } +
Enable Lock-in Period: @@ -279,23 +252,24 @@ }
- Maturity Instructions: + Execute Maturity Instruction Automatically: {{ recurringDepositProduct.maturityInstructions | yesNo }}
- Credit Maturity Proceeds: - {{ recurringDepositProduct.creditMaturityProceeds | yesNo }} -
- -
- Penalty Grace Period (Missed Installments): - {{ recurringDepositProduct.penaltyGracePeriodMissedInstallments | yesNo }} -
- -
- Panel Request Applicable: - {{ recurringDepositProduct.panelRequestApplicable | yesNo }} + Missed Installment Penalty: + + @if (recurringDepositProduct.penaltyGracePeriodMissedInstallments) { + {{ + recurringDepositProduct.missedInstallmentPenaltyType === 2 + ? recurringDepositProduct.missedInstallmentPenaltyAmount + '% of installment' + : recurringDepositProduct.missedInstallmentPenaltyAmount + ' per installment' + }} + after {{ recurringDepositProduct.missedInstallmentGraceDays ?? 0 }} grace day(s) + } @else { + No + } +
@@ -347,7 +321,7 @@
- + @@ -406,7 +380,7 @@
Assets
- Savings Reference Account + Recurring Deposit Reference Account {{ formatGlAccountById( recurringDepositProduct.savingsReferenceAccountId, @@ -439,7 +413,7 @@
Liabilities
- Savings Control Account + Recurring Deposit Control Account {{ formatGlAccountById( recurringDepositProduct.savingsControlAccountId, @@ -472,7 +446,7 @@
Expenses
- Interest on Savings Account + Interest on Recurring Deposits {{ formatGlAccountById( recurringDepositProduct.interestOnSavingsAccountId, diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.html b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.html index 674879dc3..7d8a73540 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.html +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.html @@ -18,10 +18,34 @@ Loan Facility Premature Withdrawal option Tenure Flexibility - Lien Marked - Apply Withdrawal Fee for Transfers + Lien Marked (can be pledged for a loan)
+ @if (recurringDepositProductSettingsForm.value.lienMarked) { +
+ + Max loan against RD (% of balance) + + + e.g. 90 = loan up to 90% of the RD balance. Lien is checked when the RD is added as a loan guarantor. + + @if (recurringDepositProductSettingsForm.get('lienPercentage').hasError('required')) { + Enter the percentage of the RD balance that can be given as loan + } + @if ( + recurringDepositProductSettingsForm.get('lienPercentage').hasError('min') || + recurringDepositProductSettingsForm.get('lienPercentage').hasError('max') + ) { + Must be between 1 and 100 + } + +
+ } + {{ 'labels.inputs.Apply Penal Interest (less)' | translate }} @@ -93,34 +117,85 @@
- Maturity Instructions + + Execute maturity instruction automatically + + On maturity day the instruction chosen when the RD was opened runs by itself - Transfer to Savings (to + the chosen or linked savings account) or Reinvest. Off: the RD stays Matured, earning the post-maturity + rate, until staff close it. Never runs while the RD has a lien. + +
- Option to credit maturity proceeds directly to the linked account or reinvest in another product (like - FD). - -
-
- Penalty / Grace Period for Missed Installments + + Penalty / Grace Period for Missed Installments + + An installment still unpaid after the grace days is charged a penalty once, collected from the RD + balance (Income From Penalties). + +
+ @if (recurringDepositProductSettingsForm.value.penaltyGracePeriodMissedInstallments) { +
+ + Grace days + + Due 10th + 5 grace days = penalty from the 16th + @if (recurringDepositProductSettingsForm.get('missedInstallmentGraceDays').invalid) { + Whole days, 0 or more + } + + + Penalty type + + Flat amount per installment + % of the installment + + + + {{ + recurringDepositProductSettingsForm.value.missedInstallmentPenaltyType === 2 ? 'Penalty (%)' : 'Penalty amount' + }} + + @if (recurringDepositProductSettingsForm.get('missedInstallmentPenaltyAmount').invalid) { + {{ + recurringDepositProductSettingsForm.value.missedInstallmentPenaltyType === 2 + ? 'Between 0.01 and 100' + : 'Must be more than 0' + }} + } + +
+ }
- Panel Request Applicable - -
-
- No penalty for nominee / legal heir closure + + No penalty for nominee / legal heir closure + + Premature close with reason "Nominee / legal heir claim" pays the full slab rate - no pre-closure penalty. + +
- Deceased Settlement + + Deceased Settlement + + Premature close with reason "Account holder deceased": no penalty, allowed even inside lock-in, and the + proceeds may go to a nominee's savings account. + +
- Allow Debit Txn after due date + + Allow Debit Txn after due date + + For RDs on Auto Debit. On: a missed installment keeps being collected on later days. Off: it is debited + only on its due date; a missed one stays overdue. + +
diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.scss b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.scss index a21434a6b..f52049e12 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.scss +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.scss @@ -112,6 +112,17 @@ $gray-900: #202124; padding: 0 24px; } +.rdps-row-hint { + display: block; + margin-top: 2px; + font-size: 12px; + opacity: 0.7; +} + +.rdps-penalty-fields { + padding: 12px 0 4px; +} + .rdps-list-row { display: flex; align-items: center; diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.ts b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.ts index 6a336eed0..d463c62fc 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.ts +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-settings-step/recurring-deposit-product-settings-step.component.ts @@ -7,7 +7,7 @@ */ import { Component, OnInit, Input, inject } from '@angular/core'; -import { UntypedFormGroup, UntypedFormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { UntypedFormGroup, UntypedFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatCheckbox } from '@angular/material/checkbox'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -44,7 +44,9 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit { prematureWithdrawalOption: this.recurringDepositProductsTemplate.prematureWithdrawalOption ?? false, tenureFlexibility: this.recurringDepositProductsTemplate.tenureFlexibility ?? false, lienMarked: this.recurringDepositProductsTemplate.lienMarked ?? false, - withdrawalFeeForTransfers: this.recurringDepositProductsTemplate.withdrawalFeeForTransfers ?? false, + lienPercentage: this.recurringDepositProductsTemplate.lienPercentage ?? '', + // Not offered for recurring deposits - no withdrawal fee is ever charged on one - so it is always off. + withdrawalFeeForTransfers: false, enableLockinPeriod: this.recurringDepositProductsTemplate.enableLockinPeriod ?? false, lockinPeriodFrequency: this.recurringDepositProductsTemplate.lockinPeriodFrequency ?? '', lockinPeriodFrequencyType: this.recurringDepositProductsTemplate.lockinPeriodFrequencyType?.id ?? '', @@ -52,10 +54,11 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit { preClosurePenalInterest: this.recurringDepositProductsTemplate.preClosurePenalInterest ?? '', preClosurePenalInterestOnTypeId: this.recurringDepositProductsTemplate.preClosurePenalInterestOnType?.id ?? '', maturityInstructions: this.recurringDepositProductsTemplate.maturityInstructions ?? false, - creditMaturityProceeds: this.recurringDepositProductsTemplate.creditMaturityProceeds ?? false, penaltyGracePeriodMissedInstallments: this.recurringDepositProductsTemplate.penaltyGracePeriodMissedInstallments ?? false, - panelRequestApplicable: this.recurringDepositProductsTemplate.panelRequestApplicable ?? false, + missedInstallmentGraceDays: this.recurringDepositProductsTemplate.missedInstallmentGraceDays ?? 0, + missedInstallmentPenaltyType: this.recurringDepositProductsTemplate.missedInstallmentPenaltyType ?? 1, + missedInstallmentPenaltyAmount: this.recurringDepositProductsTemplate.missedInstallmentPenaltyAmount ?? '', noPenaltyNomineeHeirClosure: this.recurringDepositProductsTemplate.noPenaltyNomineeHeirClosure ?? false, deceasedSettlement: this.recurringDepositProductsTemplate.deceasedSettlement ?? false, allowDebitTxnAfterDueDate: this.recurringDepositProductsTemplate.allowDebitTxnAfterDueDate ?? false, @@ -72,6 +75,7 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit { prematureWithdrawalOption: [false], tenureFlexibility: [false], lienMarked: [false], + lienPercentage: [''], withdrawalFeeForTransfers: [false], enableLockinPeriod: [false], lockinPeriodFrequency: [''], @@ -80,21 +84,71 @@ export class RecurringDepositProductSettingsStepComponent implements OnInit { preClosurePenalInterest: [''], preClosurePenalInterestOnTypeId: [''], maturityInstructions: [false], - creditMaturityProceeds: [false], penaltyGracePeriodMissedInstallments: [false], - panelRequestApplicable: [false], + missedInstallmentGraceDays: [0], + missedInstallmentPenaltyType: [1], + missedInstallmentPenaltyAmount: [''], noPenaltyNomineeHeirClosure: [false], deceasedSettlement: [false], allowDebitTxnAfterDueDate: [false], accountClosedZeroBalanceInDayClosing: [false], minBalanceForInterestCalculation: [''] }); + // The loan limit only means something - and is only required - while the product allows a lien. + const lienMarked = this.recurringDepositProductSettingsForm.get('lienMarked'); + const lienPercentage = this.recurringDepositProductSettingsForm.get('lienPercentage'); + lienMarked.valueChanges.subscribe((marked: boolean) => { + if (marked) { + lienPercentage.setValidators([ + Validators.required, + Validators.min(1), + Validators.max(100) + ]); + } else { + lienPercentage.clearValidators(); + } + lienPercentage.updateValueAndValidity(); + }); + // Missed-installment penalty: its three fields are only required - and only sent - while it is switched on. + const penaltyOn = this.recurringDepositProductSettingsForm.get('penaltyGracePeriodMissedInstallments'); + const graceDays = this.recurringDepositProductSettingsForm.get('missedInstallmentGraceDays'); + const penaltyType = this.recurringDepositProductSettingsForm.get('missedInstallmentPenaltyType'); + const penaltyAmount = this.recurringDepositProductSettingsForm.get('missedInstallmentPenaltyAmount'); + const syncPenaltyValidators = () => { + if (penaltyOn.value) { + graceDays.setValidators([ + Validators.required, + Validators.min(0), + Validators.pattern(/^\d+$/) + ]); + penaltyType.setValidators([Validators.required]); + penaltyAmount.setValidators([ + Validators.required, + Validators.min(0.01), + ...(penaltyType.value === 2 ? [Validators.max(100)] : []) + ]); + } else { + graceDays.clearValidators(); + penaltyType.clearValidators(); + penaltyAmount.clearValidators(); + } + graceDays.updateValueAndValidity({ emitEvent: false }); + penaltyType.updateValueAndValidity({ emitEvent: false }); + penaltyAmount.updateValueAndValidity({ emitEvent: false }); + }; + penaltyOn.valueChanges.subscribe(syncPenaltyValidators); + penaltyType.valueChanges.subscribe(syncPenaltyValidators); } get recurringDepositProductSettings() { const settings = { ...this.recurringDepositProductSettingsForm.value }; + if (!settings.penaltyGracePeriodMissedInstallments) { + delete settings.missedInstallmentGraceDays; + delete settings.missedInstallmentPenaltyType; + delete settings.missedInstallmentPenaltyAmount; + } for (const key in settings) { - if (settings[key] === '') { + if (settings[key] === '' || settings[key] === null) { delete settings[key]; } } diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-terms-step/recurring-deposit-product-terms-step.component.html b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-terms-step/recurring-deposit-product-terms-step.component.html index 5ff5154a4..b6c9ca1af 100644 --- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-terms-step/recurring-deposit-product-terms-step.component.html +++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-terms-step/recurring-deposit-product-terms-step.component.html @@ -10,18 +10,62 @@
- Deposit Amount + Installment Amount (per period)
- + Minimum + Smallest installment an account may commit to. Blank = no limit. + @if (recurringDepositProductTermsForm.get('minDepositAmount').hasError('min')) { + Minimum must be zero or greater + } - + Maximum + Largest installment an account may commit to. Blank = no limit. + @if (recurringDepositProductTermsForm.get('maxDepositAmount').hasError('min')) { + Maximum must be zero or greater + } + +
+ @if (recurringDepositProductTermsForm.hasError('maxBelowMin')) { +
+ ⚠️ + Maximum installment cannot be less than the minimum. +
+ } +
+
+
+
+ + Tenure +
+
+
+ + Minimum Tenure + + Shortest RD this product opens. The rate chart starts here. + @if (recurringDepositProductTermsForm.get('minDepositTerm').hasError('required')) { + Minimum Tenure is required + } + @if (recurringDepositProductTermsForm.get('minDepositTerm').hasError('min')) { + Minimum Tenure must be at least 1 + } + + + + Minimum Tenure In + + @for (unit of tenureUnitOptions; track unit.id) { + {{ unit.value }} + } +
@@ -35,51 +79,54 @@
ℹ️ - Recurring Deposit base rate: - {{ recurringDepositProductTermsForm.get('nominalAnnualInterestRate').value }}% + The rate an RD earns during its term always comes from the CBS Base Chart slab for its tenure. The + rate below applies only after maturity.
- - {{ 'labels.inputs.Nominal Annual Interest' | translate }} + + Post-Maturity Interest Rate (%) + Paid from maturity date to closure date if collected late. 0 = nothing extra. @if (recurringDepositProductTermsForm.get('nominalAnnualInterestRate').hasError('required')) { - {{ 'labels.inputs.Nominal Annual Interest' | translate }} {{ 'labels.commons.is' | translate }} - {{ 'labels.commons.required' | translate }} + Post-Maturity Interest Rate {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} (enter 0 for none) } @if (recurringDepositProductTermsForm.get('nominalAnnualInterestRate').hasError('min')) { - {{ 'labels.inputs.Nominal Annual Interest' | translate }} must be zero or greater + Post-Maturity Interest Rate must be zero or greater } - - Effective Date + + Product Effective From + No RD can be opened under this product before this date. Blank = from today. - + {{ 'labels.inputs.Interest Calculation Type' | translate }} @for (opt of interestCalculationTypeOptionData; track opt.id) { {{ opt.value }} } + {{ interestCalculationTypeHint }}
@@ -117,17 +164,6 @@ Days in Year is required - @if (recurringDepositProductTermsForm.value.interestCalculationType === 2) { - - Average Period - - @for (period of periodFrequencyData; track period.id) { - {{ period.value }} - } - - - } - {{ 'labels.inputs.Interest Posting Period' | translate }} - @if (recurringDepositProduct.averagePeriod) { -
- Average Period - {{ recurringDepositProduct.averagePeriod }} -
- }
{{ 'labels.inputs.Interest Posting Period' | translate }} {{ recurringDepositProduct.interestPostingPeriodType.value }} @@ -229,30 +223,6 @@ {{ 'labels.inputs.Days in Year' | translate }} {{ recurringDepositProduct.interestCalculationDaysInYearType.value }}
-
- Auto Post - - - {{ recurringDepositProduct.autoPost | yesNo }} - - -
- @if (recurringDepositProduct.frequencyFixDay) { -
- Frequency (Fix Day) - {{ recurringDepositProduct.frequencyFixDay }} -
- } - @if (recurringDepositProduct.lastInterestDate) { -
- Last Interest Date - {{ recurringDepositProduct.lastInterestDate | dateFormat }} -
- }
@@ -313,18 +283,12 @@
-
- {{ 'labels.inputs.Apply Withdrawal Fee for Transfers' | translate }} - - - {{ recurringDepositProduct.withdrawalFeeForTransfers | yesNo }} - - -
+ @if (recurringDepositProduct.lienMarked && recurringDepositProduct.lienPercentage) { +
+ Max loan against RD + {{ recurringDepositProduct.lienPercentage }}% of balance +
+ }

{{ 'labels.inputs.Lock-in Period' | translate }}

@@ -355,7 +319,7 @@

Maturity Instructions

- Maturity Instructions + Execute Maturity Instruction Automatically
- Credit Maturity Proceeds + Missed Installment Penalty - - {{ recurringDepositProduct.creditMaturityProceeds | yesNo }} - - -
-
- Penalty Grace Period (Missed Installments) - - - {{ recurringDepositProduct.penaltyGracePeriodMissedInstallments | yesNo }} - - -
-
- Panel Request Applicable - - - {{ recurringDepositProduct.panelRequestApplicable | yesNo }} - + @if (recurringDepositProduct.penaltyGracePeriodMissedInstallments) { + {{ + recurringDepositProduct.missedInstallmentPenaltyType === 2 + ? recurringDepositProduct.missedInstallmentPenaltyAmount + '% of installment' + : recurringDepositProduct.missedInstallmentPenaltyAmount + ' per installment' + }} + after {{ recurringDepositProduct.missedInstallmentGraceDays ?? 0 }} grace day(s) + } @else { + {{ false | yesNo }} + }
@@ -752,6 +695,7 @@ diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.component.html b/src/app/rbi-circulars/reserve-return/reserve-return.component.html index 5202e731a..0228d90b2 100644 --- a/src/app/rbi-circulars/reserve-return/reserve-return.component.html +++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.html @@ -715,16 +715,14 @@
} - - @if (line.baseDates?.length) { - - - @for (d of line.baseDates; track $index) { - - } - - } + } } @if (part.surplus?.length) { diff --git a/src/app/rd-dashboard/rd-dashboard-routing.module.ts b/src/app/rd-dashboard/rd-dashboard-routing.module.ts new file mode 100644 index 000000000..3f9222e0a --- /dev/null +++ b/src/app/rd-dashboard/rd-dashboard-routing.module.ts @@ -0,0 +1,29 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +import { Route } from '../core/route/route.service'; +import { RdDashboardComponent } from './rd-dashboard.component'; + +const routes: Routes = [ + Route.withShell([ + { + path: 'rd-dashboard', + component: RdDashboardComponent, + data: { title: 'RD Dashboard', breadcrumb: 'RD Dashboard' } + } + ]) +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class RdDashboardRoutingModule {} diff --git a/src/app/rd-dashboard/rd-dashboard.component.html b/src/app/rd-dashboard/rd-dashboard.component.html new file mode 100644 index 000000000..f10a18d99 --- /dev/null +++ b/src/app/rd-dashboard/rd-dashboard.component.html @@ -0,0 +1,256 @@ + + +
+ +
+
+
+ +
+
+

RD Dashboard

+

+ All running Recurring Deposits as of {{ businessDate || '—' }} (business date). Click any + card to see those RDs in the list below. +

+
+
+ +
+ + @if (errorMessage) { +
+ + {{ errorMessage }} +
+ } + + +
+

RD Book at a Glance

+
+ + + + +
+ Closed This Month + {{ maturedClosedThisMonth + prematureClosedThisMonth }} + On maturity {{ maturedClosedThisMonth }} · Premature {{ prematureClosedThisMonth }} +
+ +
+ This Month's Installments + {{ summary.collectionPercent }}% +
+
+
+ Received ₹{{ summary.receivedThisMonth | number: '1.0-0' }} of ₹{{ + summary.dueThisMonth | number: '1.0-0' + }} +
+ + + + +
+
+ + +
+

Installment Health

+
+
+ + + +
+ + Penalty income + ₹{{ penaltyIncomeThisMonth | number: '1.0-0' }} + This month · ₹{{ penaltyIncomeTotal | number: '1.0-0' }} in all +
+
+ +
+
How late are overdue RDs?
+ @for (b of ageingBuckets; track b.key) { + + } +
+
+
+ + +
+

RD List – What to Do Next

+
+ + + + + @if (anyFilter) { + + } + + {{ visibleRows.length }} of {{ rows.length }} RDs +
+ + @if (loading) { +
Loading RDs...
+ } @else if (visibleRows.length === 0) { +
+ + {{ rows.length ? 'No RDs match these filters.' : 'No running Recurring Deposits.' }} +
+ } @else { +
+
Sr. {{ slab.srNo }} To{{ slab.toPeriod ?? '—' }}{{ slab.toPeriod ?? '—' }} Period{{ amount(v) }}
Base dates — the matching close one month earlier{{ d }}
+ + + + + + + + + + + + + + + + + @for (r of visibleRows; track r.id) { + + + + + + + + + + + + + + } + +
AccountClientProductInstallmentNext DueOverdueMaturityStatusAuto DebitLienAction
+ {{ r.accountNo }} +
{{ r.officeName }}
+
{{ r.clientName }}{{ r.productName }} + ₹{{ r.installmentAmount | number: '1.2-2' }} +
{{ r.paidInstallments }} of {{ r.totalInstallments }} paid
+
+ {{ r.nextDueDate || '—' }} +
{{ dueText(r) }}
+
+ @if (r.overdueInstallments > 0) { + ₹{{ r.overdueAmount | number: '1.2-2' }} +
+ {{ r.overdueInstallments }} installment{{ r.overdueInstallments === 1 ? '' : 's' }} +
+ } @else { + — + } +
+ {{ r.maturityDate || '—' }} +
{{ maturityText(r) }}
+
+ {{ r.meta.label }} + @if (r.penaltyCount > 0) { +
Penalty ₹{{ r.penaltyAmount | number: '1.0-0' }} ({{ r.penaltyCount }}x)
+ } +
+ @if (r.autoDebit) { + On + } @else { + Off + } + + @if (r.lienAmount > 0) { + ₹{{ r.lienAmount | number: '1.0-0' }} + } @else { + — + } + + @if (r.stage === 'MATURED') { + + } + +
+
+ } + + diff --git a/src/app/rd-dashboard/rd-dashboard.component.scss b/src/app/rd-dashboard/rd-dashboard.component.scss new file mode 100644 index 000000000..8bf347148 --- /dev/null +++ b/src/app/rd-dashboard/rd-dashboard.component.scss @@ -0,0 +1,620 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** RD Dashboard - same light, compact look as the FD Dashboard. */ + +:host { + display: block; + font-family: + 'DM Sans', + system-ui, + -apple-system, + sans-serif; + color: #0f172a; + background: #f8fafc; + min-height: 100vh; + padding: 12px 16px; + box-sizing: border-box; +} + +.rdd-container { + max-width: 1400px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 14px; +} + +/* ── Header ─────────────────────────────────────────────────────────────── */ + +.rdd-header { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 10px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + box-shadow: 0 1px 3px rgb(0 0 0 / 4%); + + &__left { + display: flex; + align-items: center; + gap: 12px; + } + + &__icon { + width: 36px; + height: 36px; + border-radius: 8px; + background: #ecfdf5; + color: #059669; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex-shrink: 0; + } + + &__title { + margin: 0; + font-size: 16px; + font-weight: 700; + } + + &__sub { + margin: 2px 0 0; + font-size: 11.5px; + color: #64748b; + } +} + +.rdd-alert { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + background: #fef2f2; + border: 1px solid #fca5a5; + color: #dc2626; +} + +/* ── Sections ───────────────────────────────────────────────────────────── */ + +.rdd-section { + display: flex; + flex-direction: column; + gap: 8px; + + &__title { + margin: 0; + font-size: 13px; + font-weight: 700; + color: #334155; + } +} + +/* ── 1. Summary cards ───────────────────────────────────────────────────── */ + +.rdd-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 10px; +} + +.rdd-card { + display: flex; + flex-direction: column; + gap: 3px; + background: #fff; + border: 1px solid #e2e8f0; + border-left: 4px solid #cbd5e1; + border-radius: 10px; + padding: 10px 14px; + text-align: left; + font-family: inherit; + cursor: pointer; + transition: all 0.15s ease; + + &:hover:not(.rdd-card--static) { + box-shadow: 0 4px 12px rgb(15 23 42 / 6%); + } + + &--static { + cursor: default; + } + + &__label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 700; + color: #334155; + } + + &__val { + font-size: 22px; + font-weight: 800; + line-height: 1.15; + } + + &__sub { + font-size: 11px; + color: #64748b; + } + + &--success { + border-left-color: #10b981; + + .rdd-card__label fa-icon { + color: #10b981; + } + + &.rdd-card--active { + background: #ecfdf5; + border-color: #6ee7b7; + } + } + + &--info { + border-left-color: #3b82f6; + + .rdd-card__label fa-icon { + color: #3b82f6; + } + + &.rdd-card--active { + background: #eff6ff; + border-color: #93c5fd; + } + } + + &--danger { + border-left-color: #ef4444; + + .rdd-card__label fa-icon { + color: #ef4444; + } + + &.rdd-card--active { + background: #fef2f2; + border-color: #fca5a5; + } + } + + &--warn { + border-left-color: #f59e0b; + + .rdd-card__label fa-icon { + color: #f59e0b; + } + + &.rdd-card--active { + background: #fffbeb; + border-color: #fcd34d; + } + } + + &--neutral { + border-left-color: #64748b; + + .rdd-card__label fa-icon { + color: #64748b; + } + } +} + +.rdd-progress { + height: 6px; + border-radius: 3px; + background: #e2e8f0; + overflow: hidden; + + &__bar { + height: 100%; + background: #10b981; + border-radius: 3px; + } +} + +/* ── 2. Installment health ──────────────────────────────────────────────── */ + +.rdd-health { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 10px; + + @media (width <= 900px) { + grid-template-columns: 1fr; + } +} + +.rdd-tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; +} + +.rdd-tile { + display: grid; + grid-template-columns: 10px 1fr; + column-gap: 8px; + row-gap: 2px; + align-items: center; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 10px 12px; + text-align: left; + font-family: inherit; + cursor: pointer; + transition: all 0.15s ease; + + &:hover:not(.rdd-tile--static) { + box-shadow: 0 4px 12px rgb(15 23 42 / 6%); + } + + &--active { + border-color: #2563eb; + background: #f8fafc; + } + + &--static { + cursor: default; + } + + &__dot { + width: 10px; + height: 10px; + border-radius: 50%; + + &--neutral { + background: #64748b; + } + + &--warn { + background: #f59e0b; + } + + &--danger { + background: #ef4444; + } + + &--success { + background: #10b981; + } + } + + &__label { + font-size: 12px; + font-weight: 700; + color: #334155; + } + + &__val, + &__sub { + grid-column: 2; + } + + &__val { + font-size: 20px; + font-weight: 800; + } + + &__sub { + font-size: 10.5px; + color: #64748b; + } +} + +.rdd-ageing { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 4px; + + &__title { + font-size: 12px; + font-weight: 700; + color: #334155; + margin-bottom: 2px; + } + + &__row { + display: grid; + grid-template-columns: 74px 1fr 28px; + align-items: center; + gap: 8px; + border: none; + background: transparent; + padding: 3px 4px; + border-radius: 6px; + font-family: inherit; + cursor: pointer; + + &:hover, + &--active { + background: #f1f5f9; + } + } + + &__label { + font-size: 11px; + color: #475569; + text-align: left; + } + + &__track { + height: 8px; + background: #f1f5f9; + border-radius: 4px; + overflow: hidden; + } + + &__bar { + display: block; + height: 100%; + background: #f97316; + border-radius: 4px; + } + + &__count { + font-size: 12px; + font-weight: 700; + text-align: right; + } +} + +/* ── 3. Action list ─────────────────────────────────────────────────────── */ + +.rdd-toolbar { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 6px 10px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + + &__spacer { + flex: 1; + } +} + +.rdd-search { + position: relative; + display: flex; + align-items: center; + width: 240px; + + &__icon { + position: absolute; + left: 9px; + color: #94a3b8; + font-size: 12px; + } + + &__input { + width: 100%; + padding: 5px 8px 5px 27px; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 12px; + font-family: inherit; + + &:focus { + outline: none; + border-color: #2563eb; + } + } +} + +.rdd-select { + padding: 5px 8px; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 12px; + font-family: inherit; + background: #fff; + color: #0f172a; +} + +.rdd-count { + font-size: 11.5px; + color: #64748b; +} + +.rdd-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 6px; + font-family: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; + border: 1px solid #cbd5e1; + background: #fff; + color: #334155; + + &:hover:not(:disabled) { + border-color: #2563eb; + color: #2563eb; + } + + &--slim { + padding: 4px 10px; + font-size: 11.5px; + } + + &--xs { + padding: 3px 9px; + font-size: 11px; + } + + &--primary { + background: #2563eb; + border-color: #2563eb; + color: #fff; + + &:hover:not(:disabled) { + background: #1d4ed8; + color: #fff; + } + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } +} + +.rdd-state { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 24px; + text-align: center; + font-size: 12px; + color: #64748b; +} + +.rdd-table-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + overflow-x: auto; +} + +.rdd-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + + th { + padding: 8px 10px; + background: #f1f5f9; + color: #475569; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + text-align: left; + white-space: nowrap; + border-bottom: 1px solid #e2e8f0; + } + + td { + padding: 7px 10px; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; + } + + .text-right { + text-align: right; + } + + .text-center { + text-align: center; + } +} + +.rdd-row { + cursor: pointer; + + &:hover { + background: #f8fafc; + } +} + +.rdd-mono { + font-family: 'JetBrains Mono', monospace; + font-size: 11.5px; + font-weight: 600; +} + +.rdd-strong { + font-weight: 600; +} + +.rdd-muted { + color: #64748b; + font-size: 11.5px; +} + +.rdd-danger { + color: #dc2626; +} + +.rdd-sub { + font-size: 10.5px; + color: #64748b; + + &--danger { + color: #dc2626; + font-weight: 700; + } +} + +.rdd-yes { + font-size: 11px; + font-weight: 700; + color: #059669; +} + +.rdd-actions { + white-space: nowrap; + + .rdd-btn + .rdd-btn { + margin-left: 4px; + } +} + +.rdd-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 10.5px; + font-weight: 700; + white-space: nowrap; + + &--success { + background: #ecfdf5; + color: #047857; + } + + &--info { + background: #eff6ff; + color: #2563eb; + } + + &--warn { + background: #fffbeb; + color: #b45309; + } + + &--danger { + background: #fef2f2; + color: #dc2626; + } + + &--neutral { + background: #f1f5f9; + color: #475569; + } +} diff --git a/src/app/rd-dashboard/rd-dashboard.component.ts b/src/app/rd-dashboard/rd-dashboard.component.ts new file mode 100644 index 000000000..92856a44d --- /dev/null +++ b/src/app/rd-dashboard/rd-dashboard.component.ts @@ -0,0 +1,256 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; + +/** Custom Modules */ +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +/** Custom Services */ +import { AccountsService } from '../accounts/accounts.service'; + +/** Dashboard data + computation */ +import { + RD_AGEING_BUCKETS, + RD_STAGES, + RdDashboardResponse, + RdRow, + RdStage, + RdSummary, + buildRdRow, + rdSummary, + sortRdRows +} from './rd-dashboard.compute'; + +/** + * RD Dashboard: every running Recurring Deposit, with its installment position as of the business date. + * + * Three parts, top to bottom: the summary cards (how the RD book stands), installment health (who is late and how + * badly), and the action list (each RD with what to do next). Every figure comes from one call to + * `/recurringdepositaccounts/dashboard`, which works out paid/late installments the same way the nightly penalty job + * does, so this screen and the penalties never disagree. + */ +@Component({ + selector: 'mifosx-rd-dashboard', + templateUrl: './rd-dashboard.component.html', + styleUrls: ['./rd-dashboard.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FormsModule, + FaIconComponent + ] +}) +export class RdDashboardComponent implements OnInit { + private accountsService = inject(AccountsService); + private router = inject(Router); + + loading = false; + errorMessage = ''; + + businessDate = ''; + maturedClosedThisMonth = 0; + prematureClosedThisMonth = 0; + penaltyIncomeThisMonth = 0; + penaltyIncomeTotal = 0; + + rows: RdRow[] = []; + summary: RdSummary = rdSummary([]); + + /** Filters */ + stageFilter: RdStage | '' = ''; + /** Narrower than a stage: 'overdue' (any late installment), 'lien', or an ageing bucket key. */ + extraFilter = ''; + officeFilter: number | '' = ''; + productFilter: number | '' = ''; + search = ''; + + offices: { id: number; name: string }[] = []; + products: { id: number; name: string }[] = []; + + readonly stages = RD_STAGES; + readonly stageKeys: RdStage[] = [ + 'MATURED', + 'PAST_GRACE', + 'IN_GRACE', + 'DUE_TODAY', + 'REGULAR' + ]; + readonly ageingBuckets = RD_AGEING_BUCKETS; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.errorMessage = ''; + this.accountsService.getRecurringDepositDashboard().subscribe({ + next: (res: RdDashboardResponse) => { + this.businessDate = res?.businessDate || ''; + this.maturedClosedThisMonth = Number(res?.maturedClosedThisMonth) || 0; + this.prematureClosedThisMonth = Number(res?.prematureClosedThisMonth) || 0; + this.penaltyIncomeThisMonth = Number(res?.penaltyIncomeThisMonth) || 0; + this.penaltyIncomeTotal = Number(res?.penaltyIncomeTotal) || 0; + const accounts = Array.isArray(res?.accounts) ? res.accounts : []; + this.rows = sortRdRows(accounts.map((a) => buildRdRow(a, this.businessDate))); + this.summary = rdSummary(this.rows); + this.offices = this.distinct(this.rows.map((r) => ({ id: r.officeId, name: r.officeName }))); + this.products = this.distinct(this.rows.map((r) => ({ id: r.productId, name: r.productName }))); + this.loading = false; + }, + error: (err: any) => { + this.rows = []; + this.summary = rdSummary([]); + this.loading = false; + this.errorMessage = + err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the RD dashboard.'; + } + }); + } + + private distinct(items: { id: number; name: string }[]): { id: number; name: string }[] { + const seen = new Map(); + items.forEach((i) => seen.set(i.id, i.name)); + return [...seen.entries()].map(([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name)); + } + + // ── Filtering ────────────────────────────────────────────────────────────── + + get visibleRows(): RdRow[] { + const needle = this.search.trim().toLowerCase(); + return this.rows.filter((r) => { + if (this.stageFilter && r.stage !== this.stageFilter) { + return false; + } + if (this.extraFilter === 'overdue' && !(r.overdueInstallments > 0 && r.stage !== 'MATURED')) { + return false; + } + if (this.extraFilter === 'lien' && !(r.lienAmount > 0)) { + return false; + } + if (this.isAgeingKey(this.extraFilter) && r.ageingKey !== this.extraFilter) { + return false; + } + if (this.officeFilter !== '' && r.officeId !== this.officeFilter) { + return false; + } + if (this.productFilter !== '' && r.productId !== this.productFilter) { + return false; + } + if (needle && !`${r.accountNo} ${r.clientName}`.toLowerCase().includes(needle)) { + return false; + } + return true; + }); + } + + private isAgeingKey(key: string): boolean { + return this.ageingBuckets.some((b) => b.key === key); + } + + /** Clicking a card or tile shows just those RDs in the list below; clicking it again shows all. */ + showStage(stage: RdStage): void { + const same = this.stageFilter === stage && !this.extraFilter; + this.stageFilter = same ? '' : stage; + this.extraFilter = ''; + } + + showExtra(key: string): void { + const same = this.extraFilter === key && !this.stageFilter; + this.extraFilter = same ? '' : key; + this.stageFilter = ''; + } + + get anyFilter(): boolean { + return !!(this.stageFilter || this.extraFilter || this.officeFilter !== '' || this.productFilter !== '' || this.search); + } + + resetFilters(): void { + this.stageFilter = ''; + this.extraFilter = ''; + this.officeFilter = ''; + this.productFilter = ''; + this.search = ''; + } + + /** Width of an ageing bar, relative to the largest bucket. */ + ageingWidth(key: string): number { + const max = Math.max(1, ...this.ageingBuckets.map((b) => this.summary.ageing[b.key] || 0)); + return Math.round(((this.summary.ageing[key] || 0) / max) * 100); + } + + // ── Navigation ───────────────────────────────────────────────────────────── + + openAccount(row: RdRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'recurring-deposits-accounts', + row.id, + 'general' + ]); + } + + closeAccount(row: RdRow): void { + this.router.navigate([ + '/clients', + row.clientId, + 'recurring-deposits-accounts', + row.id, + 'actions', + 'Close' + ]); + } + + // ── Presentation ─────────────────────────────────────────────────────────── + + stageClass(stage: RdStage): string { + return `rdd-badge rdd-badge--${RD_STAGES[stage].tone}`; + } + + /** "5 days late" / "Due today" / "Due in 12 days", as a branch officer would say it. */ + dueText(row: RdRow): string { + if (row.stage === 'MATURED') { + return 'Term complete'; + } + if (!row.nextDueDate) { + return 'All installments paid'; + } + if (row.daysLate > 0) { + return `${row.daysLate} day${row.daysLate === 1 ? '' : 's'} late`; + } + const days = this.daysUntil(row.nextDueDate); + if (days === 0) { + return 'Due today'; + } + return `Due in ${days} day${days === 1 ? '' : 's'}`; + } + + maturityText(row: RdRow): string { + if (row.daysToMaturity == null) { + return ''; + } + if (row.daysToMaturity < 0) { + return `Matured ${-row.daysToMaturity} day${row.daysToMaturity === -1 ? '' : 's'} ago`; + } + if (row.daysToMaturity === 0) { + return 'Matures today'; + } + return `In ${row.daysToMaturity} day${row.daysToMaturity === 1 ? '' : 's'}`; + } + + private daysUntil(iso: string): number { + const a = new Date(`${this.businessDate}T00:00:00Z`).getTime(); + const b = new Date(`${iso}T00:00:00Z`).getTime(); + return Math.round((b - a) / 86400000); + } +} diff --git a/src/app/rd-dashboard/rd-dashboard.compute.ts b/src/app/rd-dashboard/rd-dashboard.compute.ts new file mode 100644 index 000000000..09a61a548 --- /dev/null +++ b/src/app/rd-dashboard/rd-dashboard.compute.ts @@ -0,0 +1,214 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Where a running Recurring Deposit stands today, most urgent first: + * - MATURED: the term is over - the deposit is waiting to be closed and paid out. + * - PAST_GRACE: an installment is late beyond the product's grace days - the penalty applies. + * - IN_GRACE: an installment is late but still inside the grace days - no penalty yet. + * - DUE_TODAY: today's installment has not come in yet. + * - REGULAR: nothing due or late. + */ +export type RdStage = 'MATURED' | 'PAST_GRACE' | 'IN_GRACE' | 'DUE_TODAY' | 'REGULAR'; + +export interface RdStageMeta { + label: string; + tone: 'danger' | 'warn' | 'info' | 'success' | 'neutral'; + hint: string; +} + +export const RD_STAGES: Record = { + MATURED: { label: 'Matured – Close Pending', tone: 'info', hint: 'Term complete; close it to pay the client.' }, + PAST_GRACE: { label: 'Overdue – Penalty', tone: 'danger', hint: 'Late beyond the grace days; penalty applies.' }, + IN_GRACE: { label: 'Late – In Grace', tone: 'warn', hint: 'Late, but still within the grace days.' }, + DUE_TODAY: { label: 'Due Today', tone: 'neutral', hint: "Today's installment has not come in yet." }, + REGULAR: { label: 'Regular', tone: 'success', hint: 'All installments paid on time.' } +}; + +/** Late-installment ageing buckets, by days since the oldest unpaid installment fell due. */ +export const RD_AGEING_BUCKETS = [ + { key: '1-30', label: '1–30 days', from: 1, to: 30 }, + { key: '31-60', label: '31–60 days', from: 31, to: 60 }, + { key: '61-90', label: '61–90 days', from: 61, to: 90 }, + { key: '90+', label: '90+ days', from: 91, to: Infinity } +]; + +/** SavingsAccountStatusType.MATURED. */ +const STATUS_MATURED = 800; + +/** One account as `/recurringdepositaccounts/dashboard` returns it. */ +export interface RdDashboardAccount { + id: number; + accountNo: string; + clientId: number; + clientName: string; + officeId: number; + officeName: string; + productId: number; + productName: string; + status: number; + balance: number; + installmentAmount: number; + maturityDate: string | null; + maturityAmount: number | null; + autoDebit: boolean; + linkedAccountNo: string | null; + graceDays: number; + penaltyEnabled: boolean; + totalInstallments: number; + paidInstallments: number; + nextDueDate: string | null; + overdueInstallments: number; + overdueAmount: number; + dueTodayCount: number; + dueTodayPaid: number; + dueThisMonth: number; + receivedThisMonth: number; + paidThisMonth: number; + penaltyCount: number; + penaltyAmount: number; + lienAmount: number; +} + +export interface RdDashboardResponse { + businessDate: string; + maturedClosedThisMonth: number; + prematureClosedThisMonth: number; + penaltyIncomeThisMonth: number; + penaltyIncomeTotal: number; + accounts: RdDashboardAccount[]; +} + +export interface RdRow extends RdDashboardAccount { + stage: RdStage; + meta: RdStageMeta; + /** Days since the oldest unpaid installment fell due; 0 when nothing is late. */ + daysLate: number; + /** Days left until maturity; negative once past it. */ + daysToMaturity: number | null; + ageingKey: string | null; +} + +function daysBetween(earlier: string, later: string): number { + const a = new Date(`${earlier}T00:00:00Z`).getTime(); + const b = new Date(`${later}T00:00:00Z`).getTime(); + return Math.round((b - a) / 86400000); +} + +export function buildRdRow(a: RdDashboardAccount, today: string): RdRow { + const num = (v: any) => Number(v) || 0; + const account: RdDashboardAccount = { + ...a, + balance: num(a.balance), + installmentAmount: num(a.installmentAmount), + maturityAmount: a.maturityAmount == null ? null : num(a.maturityAmount), + graceDays: num(a.graceDays), + totalInstallments: num(a.totalInstallments), + paidInstallments: num(a.paidInstallments), + overdueInstallments: num(a.overdueInstallments), + overdueAmount: num(a.overdueAmount), + dueTodayCount: num(a.dueTodayCount), + dueTodayPaid: num(a.dueTodayPaid), + dueThisMonth: num(a.dueThisMonth), + receivedThisMonth: num(a.receivedThisMonth), + paidThisMonth: num(a.paidThisMonth), + penaltyCount: num(a.penaltyCount), + penaltyAmount: num(a.penaltyAmount), + lienAmount: num(a.lienAmount) + }; + // When anything is late, the earliest unpaid installment is the oldest late one. + const daysLate = account.overdueInstallments > 0 && account.nextDueDate ? daysBetween(account.nextDueDate, today) : 0; + const daysToMaturity = account.maturityDate ? daysBetween(today, account.maturityDate) : null; + + let stage: RdStage; + if (account.status === STATUS_MATURED || (daysToMaturity != null && daysToMaturity <= 0)) { + stage = 'MATURED'; + } else if (daysLate > 0) { + stage = daysLate > account.graceDays ? 'PAST_GRACE' : 'IN_GRACE'; + } else if (account.dueTodayCount > account.dueTodayPaid) { + stage = 'DUE_TODAY'; + } else { + stage = 'REGULAR'; + } + const bucket = daysLate > 0 ? RD_AGEING_BUCKETS.find((b) => daysLate >= b.from && daysLate <= b.to) : null; + return { + ...account, + stage, + meta: RD_STAGES[stage], + daysLate, + daysToMaturity, + ageingKey: bucket ? bucket.key : null + }; +} + +export interface RdSummary { + activeCount: number; + activeBalance: number; + maturedCount: number; + maturedAmount: number; + dueThisMonth: number; + receivedThisMonth: number; + collectionPercent: number; + overdueCount: number; + overdueAmount: number; + lienCount: number; + lienAmount: number; + dueTodayCount: number; + dueTodayPaidCount: number; + inGraceCount: number; + inGraceAmount: number; + pastGraceCount: number; + pastGraceAmount: number; + ageing: Record; +} + +export function rdSummary(rows: RdRow[]): RdSummary { + const sum = (list: RdRow[], f: (r: RdRow) => number) => list.reduce((s, r) => s + f(r), 0); + const running = rows.filter((r) => r.stage !== 'MATURED'); + const matured = rows.filter((r) => r.stage === 'MATURED'); + const overdue = running.filter((r) => r.overdueInstallments > 0); + const inGrace = rows.filter((r) => r.stage === 'IN_GRACE'); + const pastGrace = rows.filter((r) => r.stage === 'PAST_GRACE'); + const lien = rows.filter((r) => r.lienAmount > 0); + const dueThisMonth = sum(running, (r) => r.dueThisMonth); + const receivedThisMonth = sum(running, (r) => r.receivedThisMonth); + const ageing: Record = {}; + RD_AGEING_BUCKETS.forEach((b) => (ageing[b.key] = running.filter((r) => r.ageingKey === b.key).length)); + return { + activeCount: running.length, + activeBalance: sum(running, (r) => r.balance), + maturedCount: matured.length, + maturedAmount: sum(matured, (r) => r.maturityAmount ?? r.balance), + dueThisMonth, + receivedThisMonth, + collectionPercent: dueThisMonth > 0 ? Math.round((receivedThisMonth / dueThisMonth) * 100) : 100, + overdueCount: overdue.length, + overdueAmount: sum(overdue, (r) => r.overdueAmount), + lienCount: lien.length, + lienAmount: sum(lien, (r) => r.lienAmount), + dueTodayCount: running.filter((r) => r.dueTodayCount > 0).length, + dueTodayPaidCount: running.filter((r) => r.dueTodayCount > 0 && r.dueTodayPaid >= r.dueTodayCount).length, + inGraceCount: inGrace.length, + inGraceAmount: sum(inGrace, (r) => r.overdueAmount), + pastGraceCount: pastGrace.length, + pastGraceAmount: sum(pastGrace, (r) => r.overdueAmount), + ageing + }; +} + +/** Most urgent first: matured, then the longest late, then due today, then the rest by next due date. */ +export function sortRdRows(rows: RdRow[]): RdRow[] { + const rank: Record = { MATURED: 0, PAST_GRACE: 1, IN_GRACE: 2, DUE_TODAY: 3, REGULAR: 4 }; + return [...rows].sort( + (a, b) => + rank[a.stage] - rank[b.stage] || + b.daysLate - a.daysLate || + (a.nextDueDate || '9999').localeCompare(b.nextDueDate || '9999') || + a.accountNo.localeCompare(b.accountNo) + ); +} diff --git a/src/app/rd-dashboard/rd-dashboard.module.ts b/src/app/rd-dashboard/rd-dashboard.module.ts new file mode 100644 index 000000000..29c428691 --- /dev/null +++ b/src/app/rd-dashboard/rd-dashboard.module.ts @@ -0,0 +1,22 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { NgModule } from '@angular/core'; + +import { SharedModule } from '../shared/shared.module'; +import { RdDashboardRoutingModule } from './rd-dashboard-routing.module'; +import { RdDashboardComponent } from './rd-dashboard.component'; + +@NgModule({ + imports: [ + SharedModule, + RdDashboardRoutingModule, + RdDashboardComponent + ] +}) +export class RdDashboardModule {} diff --git a/src/app/settings/settings.service.ts b/src/app/settings/settings.service.ts index 6a540eec1..482a0ffc5 100644 --- a/src/app/settings/settings.service.ts +++ b/src/app/settings/settings.service.ts @@ -226,7 +226,15 @@ export class SettingsService { * Returns current Business date server */ get businessDate(): Date { - return this.dateUtils.convertToDate(localStorage.getItem('mifosXServerDate'), SettingsService.businessDateFormat); + // With business dates switched off the server works on today's date, so the screens must too - the date + // stored from when it was on would otherwise linger. Nothing stored (or unreadable) also falls back to today + // instead of an Invalid Date. + if (this.businessDateConfig === 'false') { + return new Date(); + } + const stored = localStorage.getItem('mifosXServerDate'); + const date = stored ? this.dateUtils.convertToDate(stored, SettingsService.businessDateFormat) : null; + return date && !isNaN(date.getTime()) ? date : new Date(); } /** diff --git a/src/assets/translations/en-US.json b/src/assets/translations/en-US.json index f5fd8d159..89e1ef8a9 100644 --- a/src/assets/translations/en-US.json +++ b/src/assets/translations/en-US.json @@ -749,7 +749,7 @@ "Daily": "Daily", "Monthly": "Monthly", "Weekly": "Weekly", - "Quarterly": "Quartely", + "Quarterly": "Quarterly", "Semi-Annual": "Semi-Annual", "SemiWeeks": "SemiWeeks", "Annually": "Annually", diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 888a7a52b..5cf0d7b56 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '261006', - 'hash': '5ab4e9ea' + 'version': '261012', + 'hash': '6b0f218b' }, 'allow_switching_backend_instance': true };