feat: recurring deposit enhancements, dashboard, chart, and account view updates
This commit is contained in:
+50
-2
@@ -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
|
||||
|
||||
+12
-2
@@ -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 ?? [];
|
||||
|
||||
+2
-1
@@ -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
|
||||
]
|
||||
});
|
||||
|
||||
+2
-1
@@ -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: ['']
|
||||
|
||||
+11
-1
@@ -13,6 +13,16 @@
|
||||
<div class="layout-column">
|
||||
<h3 class="mat-h3 flex-fill">{{ title }}</h3>
|
||||
|
||||
@if (totalLienAmount > 0) {
|
||||
<p class="lien-warn">
|
||||
This deposit is pledged as loan security (lien {{ totalLienAmount | number: '1.2-2' }} -
|
||||
@for (lien of liens; track lien.loanId; let last = $last) {
|
||||
loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
|
||||
}
|
||||
). It cannot be closed until the loan is repaid or the guarantee is released.
|
||||
</p>
|
||||
}
|
||||
|
||||
<mat-form-field (click)="closedOnDatePicker.open()">
|
||||
<mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label>
|
||||
<input
|
||||
@@ -154,7 +164,7 @@
|
||||
<button type="button" mat-raised-button [routerLink]="['../general']">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" [disabled]="!closeRecurringDepositForm.valid">
|
||||
<button mat-raised-button color="primary" [disabled]="!closeRecurringDepositForm.valid || totalLienAmount > 0">
|
||||
{{ 'labels.buttons.Submit' | translate }}
|
||||
</button>
|
||||
</mat-card-actions>
|
||||
|
||||
+6
@@ -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%;
|
||||
|
||||
+15
-1
@@ -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 }],
|
||||
|
||||
+131
-2
@@ -11,6 +11,51 @@
|
||||
<form [formGroup]="prematureCloseRecurringDepositsAccountForm" (ngSubmit)="submit()">
|
||||
<mat-card-content>
|
||||
<div class="layout-column">
|
||||
@if (account) {
|
||||
<div class="pc-context">
|
||||
<div class="pc-row">
|
||||
<span>Account</span><b>{{ account.accountNo }}</b>
|
||||
</div>
|
||||
<div class="pc-row">
|
||||
<span>Current balance</span><b>{{ currencySymbol }} {{ balance | number: '1.2-2' }}</b>
|
||||
</div>
|
||||
@if (account.maturityDate) {
|
||||
<div class="pc-row">
|
||||
<span>Maturity date</span><b>{{ account.maturityDate | dateFormat }}</b>
|
||||
</div>
|
||||
}
|
||||
@if (account.maturityAmount != null) {
|
||||
<div class="pc-row">
|
||||
<span>Amount at maturity</span><b>{{ currencySymbol }} {{ account.maturityAmount | number: '1.2-2' }}</b>
|
||||
</div>
|
||||
}
|
||||
<div class="pc-row">
|
||||
<span>Pre-closure penalty</span>
|
||||
<b>
|
||||
@if (account.preClosurePenalApplicable) {
|
||||
{{ account.preClosurePenalInterest }}% less interest
|
||||
@if (account.preClosurePenalInterestOnType?.value) {
|
||||
({{ account.preClosurePenalInterestOnType.value }})
|
||||
}
|
||||
} @else {
|
||||
None
|
||||
}
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (totalLienAmount > 0) {
|
||||
<p class="pc-warn">
|
||||
This deposit is pledged as loan security (lien {{ currencySymbol }}
|
||||
{{ totalLienAmount | number: '1.2-2' }} -
|
||||
@for (lien of liens; track lien.loanId; let last = $last) {
|
||||
loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
|
||||
}
|
||||
). It cannot be closed until the loan is repaid or the guarantee is released.
|
||||
</p>
|
||||
}
|
||||
|
||||
<mat-form-field (click)="closedOnDatePicker.open()">
|
||||
<mat-label>{{ 'labels.inputs.Closed On' | translate }}</mat-label>
|
||||
<input
|
||||
@@ -23,6 +68,7 @@
|
||||
/>
|
||||
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #closedOnDatePicker></mat-datepicker>
|
||||
<mat-hint>Must be before the maturity date - on or after it, use Close instead.</mat-hint>
|
||||
@if (prematureCloseRecurringDepositsAccountForm.controls.closedOnDate.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
@@ -30,6 +76,89 @@
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (reasonOptions.length > 1) {
|
||||
<mat-form-field subscriptSizing="dynamic">
|
||||
<mat-label>Closure reason</mat-label>
|
||||
<mat-select formControlName="prematureClosureReasonId">
|
||||
@for (reason of reasonOptions; track reason.id) {
|
||||
<mat-option [value]="reason.id">{{ reason.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-hint>{{ selectedReasonHint }}</mat-hint>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<div class="pc-calc">
|
||||
<button
|
||||
type="button"
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
(click)="calculate()"
|
||||
[disabled]="
|
||||
calculating || totalLienAmount > 0 || !prematureCloseRecurringDepositsAccountForm.value.closedOnDate
|
||||
"
|
||||
>
|
||||
{{ calculating ? 'Calculating…' : 'Calculate Payout' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (preview) {
|
||||
<div class="pc-result">
|
||||
<div class="pc-row pc-row--total">
|
||||
<span>Payable on premature closure</span>
|
||||
<b>{{ currencySymbol }} {{ preview.payoutAmount | number: '1.2-2' }}</b>
|
||||
</div>
|
||||
@if (shortfallVsMaturity != null && shortfallVsMaturity > 0) {
|
||||
<div class="pc-row pc-row--muted">
|
||||
<span>Less than at maturity by</span>
|
||||
<b>{{ currencySymbol }} {{ shortfallVsMaturity | number: '1.2-2' }}</b>
|
||||
</div>
|
||||
}
|
||||
<p class="pc-note">
|
||||
Paid by transfer to the client's savings account - a recurring deposit cannot be broken for cash.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (destinationAccounts.length === 0) {
|
||||
<p class="pc-warn">
|
||||
This client has no active savings account to receive the proceeds. Open one first, then close this
|
||||
deposit.
|
||||
</p>
|
||||
} @else {
|
||||
<mat-form-field>
|
||||
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
|
||||
<mat-select required formControlName="toSavingsAccountId">
|
||||
@for (savings of destinationAccounts; track savings.id) {
|
||||
<mat-option [value]="savings.id">
|
||||
{{ savings.accountNo }} - {{ savings.productName }}
|
||||
@if (savings.owner) {
|
||||
(nominee: {{ savings.owner }})
|
||||
} @else if (savings.id === account?.linkedAccountId) {
|
||||
(linked)
|
||||
}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (prematureCloseRecurringDepositsAccountForm.controls.toSavingsAccountId.hasError('required')) {
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
|
||||
<input matInput formControlName="transferDescription" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
|
||||
<textarea matInput cdkTextareaAutosize formControlName="note"></textarea>
|
||||
</mat-form-field>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</mat-card-content>
|
||||
|
||||
@@ -37,8 +166,8 @@
|
||||
<button type="button" mat-raised-button [routerLink]="['../../']">
|
||||
{{ 'labels.buttons.Cancel' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button color="primary" [disabled]="!prematureCloseRecurringDepositsAccountForm">
|
||||
{{ 'labels.buttons.Confirm' | translate }}
|
||||
<button mat-raised-button color="primary" [disabled]="!canConfirm">
|
||||
{{ submitting ? 'Closing…' : ('labels.buttons.Confirm' | translate) }}
|
||||
</button>
|
||||
</mat-card-actions>
|
||||
</form>
|
||||
|
||||
+42
@@ -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;
|
||||
}
|
||||
|
||||
+258
-51
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -30,6 +30,14 @@
|
||||
@if (actions['Close']) {
|
||||
<mifosx-close-recurring-deposits-account></mifosx-close-recurring-deposits-account>
|
||||
}
|
||||
@if (actions['Deposit'] || actions['Withdrawal']) {
|
||||
<!-- A direct deposit from the account screen skips the manager's approval; RD installments come in through the
|
||||
cashier's Receipt or Auto Debit instead, so the Deposit action is not offered here even by URL. -->
|
||||
@if (actions['Deposit']) {
|
||||
<p>
|
||||
RD installments cannot be deposited from the account screen. Use a cashier Receipt, or Auto Debit from the linked
|
||||
savings account.
|
||||
</p>
|
||||
}
|
||||
@if (actions['Withdrawal']) {
|
||||
<mifosx-deposit-recurring-deposits-account [currency]="currency"></mifosx-deposit-recurring-deposits-account>
|
||||
}
|
||||
|
||||
+41
-2
@@ -30,11 +30,50 @@
|
||||
<div class="racc-alert racc-alert--editable">
|
||||
<div class="racc-alert__content">
|
||||
<h4>Auto Debit Facility</h4>
|
||||
<p>Enable automatic deduction of funds for recurring payments, utility bills, or EMI obligations.</p>
|
||||
@if (recurringDepositsAccountProductTemplate?.autoDebitFacility) {
|
||||
<p>
|
||||
Collect each installment automatically on its due date from the linked Savings account (Deposit Account
|
||||
Link, Details tab). A short balance leaves it overdue and it is retried on the next run.
|
||||
</p>
|
||||
} @else {
|
||||
<p>This product does not offer Auto Debit.</p>
|
||||
}
|
||||
</div>
|
||||
<mat-slide-toggle [(ngModel)]="autoDebitFacility" color="primary"></mat-slide-toggle>
|
||||
<mat-slide-toggle
|
||||
[(ngModel)]="autoDebitFacility"
|
||||
[disabled]="!recurringDepositsAccountProductTemplate?.autoDebitFacility"
|
||||
color="primary"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
|
||||
@if (lienAllowed) {
|
||||
<div class="racc-alert racc-alert--editable">
|
||||
<div class="racc-alert__content">
|
||||
<h4>Loan against this RD (Lien)</h4>
|
||||
<p>
|
||||
How much of the RD balance can be given as loan, checked on the balance at the time the loan is taken.
|
||||
Product limit: {{ productLienLimit }}%.
|
||||
</p>
|
||||
@if (lienPercentageError) {
|
||||
<p class="racc-lien-error">{{ lienPercentageError }}</p>
|
||||
}
|
||||
</div>
|
||||
<mat-form-field appearance="outline" class="racc-lien-field" subscriptSizing="dynamic">
|
||||
<mat-label>Max loan (%)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
min="1"
|
||||
[max]="productLienLimit"
|
||||
step="0.01"
|
||||
[(ngModel)]="lienPercentage"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
/>
|
||||
<span matTextSuffix>%</span>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="racc-alert">
|
||||
<div class="racc-alert__content">
|
||||
<h4>Dormancy Alert</h4>
|
||||
|
||||
+9
@@ -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;
|
||||
|
||||
+42
-2
@@ -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) } : {})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+20
-1
@@ -121,11 +121,30 @@
|
||||
{{ productTemplate.shareLinkingEnabled ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rad-field rad-info-row">
|
||||
<span class="rad-info-label">Introducer Required</span>
|
||||
<span class="rad-badge" [class.rad-badge--yes]="productTemplate.introducerRequired">
|
||||
{{ productTemplate.introducerRequired ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The product requires an introducer: an existing customer, never the depositor themselves. -->
|
||||
@if (productTemplate?.introducerRequired) {
|
||||
<mifosx-introducer-search
|
||||
[excludedClientIds]="primaryClientId ? [primaryClientId] : []"
|
||||
[preselectedIntroducer]="
|
||||
introducerId ? { clientId: introducerId, displayName: introducerName, cifNumber: introducerCif } : null
|
||||
"
|
||||
(introducerChange)="onIntroducerChange($event)"
|
||||
></mifosx-introducer-search>
|
||||
}
|
||||
|
||||
<!-- ══ Deposit Account Link ══════════════════════════════════════════ -->
|
||||
@if (productTemplate?.shareLinkingEnabled) {
|
||||
<!-- Also offered when the product allows Auto Debit: that is the account installments are collected from. -->
|
||||
@if (productTemplate?.shareLinkingEnabled || productTemplate?.autoDebitFacility) {
|
||||
<div class="dal-card">
|
||||
<div class="dal-card__header">
|
||||
<div class="dal-card__header-left">
|
||||
|
||||
+27
-1
@@ -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;
|
||||
|
||||
+39
-1
@@ -93,7 +93,13 @@
|
||||
<div class="fdrc-calc-field">
|
||||
<label class="fdrc-calc-label">
|
||||
Monthly Installment
|
||||
<span class="fdrc-calc-optional">(optional — for maturity estimation)</span>
|
||||
<span class="fdrc-calc-optional"
|
||||
>(required@if (minInstallment > 0) {
|
||||
— min ₹{{ minInstallment }}
|
||||
}@if (maxInstallment > 0) {
|
||||
, max ₹{{ maxInstallment }}
|
||||
})</span
|
||||
>
|
||||
</label>
|
||||
<div class="fdrc-amount-row">
|
||||
<span class="fdrc-amount-prefix">₹</span>
|
||||
@@ -257,6 +263,21 @@
|
||||
</span>
|
||||
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
|
||||
</div>
|
||||
@if (matchResult.interestCalculation) {
|
||||
<div class="fdrc-maturity-row">
|
||||
<span class="fdrc-maturity-row__label">Interest Type</span>
|
||||
<span class="fdrc-maturity-row__value">
|
||||
{{ matchResult.interestCalculation }}
|
||||
@if (matchResult.interestCalculation === 'Compounding') {
|
||||
({{ interestPeriodLabel(matchResult.compoundingPeriod) }})
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="fdrc-maturity-row">
|
||||
<span class="fdrc-maturity-row__label">Interest Credited</span>
|
||||
<span class="fdrc-maturity-row__value">{{ interestPeriodLabel(matchResult.postingPeriod) }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="fdrc-maturity-row fdrc-maturity-row--interest">
|
||||
<span class="fdrc-maturity-row__label">Estimated Interest Earned</span>
|
||||
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
|
||||
@@ -267,6 +288,23 @@
|
||||
>₹ {{ matchResult.maturityAmount | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
@if (matchResult.postingSchedule?.length) {
|
||||
<details class="fdrc-credits">
|
||||
<summary>Interest credits to the RD ({{ matchResult.postingSchedule.length }})</summary>
|
||||
<table class="fdrc-credits__table">
|
||||
<tr>
|
||||
<th>Credited on</th>
|
||||
<th class="text-right">Interest</th>
|
||||
</tr>
|
||||
@for (p of matchResult.postingSchedule; track p.date) {
|
||||
<tr>
|
||||
<td>{{ p.date }}</td>
|
||||
<td class="text-right">₹ {{ p.interest | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+28
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+164
-69
@@ -42,6 +42,7 @@ import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
|
||||
import { YesnoPipe } from '../../../../pipes/yesno.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { buildRdChart, tenureInMonths } from 'app/products/recurring-deposit-products/rd-product-chart';
|
||||
|
||||
export type RdPeriodUnit = 'Days' | 'Months' | 'Years';
|
||||
|
||||
@@ -53,6 +54,12 @@ export interface SlabMatchResult {
|
||||
interestEarned?: number;
|
||||
tenureDays?: number;
|
||||
daysInYear?: number;
|
||||
/** From the product, as the server calculated it: Simple / Compounding, and the period names. */
|
||||
interestCalculation?: string;
|
||||
compoundingPeriod?: string;
|
||||
postingPeriod?: string;
|
||||
/** Each interest credit the deposit will receive, if every installment is paid on time. */
|
||||
postingSchedule?: { date: string; interest: number }[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,20 +130,62 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
|
||||
return (this.cbsBaseRates || []).find((r) => r.fdType === 'DOUBLE' && r.isActive);
|
||||
}
|
||||
|
||||
/**
|
||||
* The product's own chart - the one the server prices the RD from - with each slab's senior-citizen rate taken
|
||||
* from the CBS base chart it was built from. Falls back to the base chart only when the product has no chart.
|
||||
*/
|
||||
get interestRateChartData(): any[] {
|
||||
if (this.activeBase?.slabs?.length) {
|
||||
return this.normalizeCbsSlabs(this.activeBase.slabs);
|
||||
}
|
||||
const cbsSlabs: any[] = this.activeBase?.slabs || [];
|
||||
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
|
||||
if (!template) return [];
|
||||
return (
|
||||
template.accountChart?.chartSlabs ??
|
||||
template.activeChart?.chartSlabs ??
|
||||
template.interestRateCharts?.[0]?.chartSlabs ??
|
||||
template.interestRateChart?.chartSlabs ??
|
||||
template.chartSlabs ??
|
||||
[]
|
||||
);
|
||||
const productSlabs: any[] =
|
||||
template?.accountChart?.chartSlabs ??
|
||||
template?.activeChart?.chartSlabs ??
|
||||
template?.interestRateCharts?.[0]?.chartSlabs ??
|
||||
template?.interestRateChart?.chartSlabs ??
|
||||
template?.chartSlabs ??
|
||||
[];
|
||||
if (this.chartCache?.cbsSlabs !== cbsSlabs || this.chartCache?.productSlabs !== productSlabs) {
|
||||
this.chartCache = { cbsSlabs, productSlabs, slabs: this.mergeChart(cbsSlabs, productSlabs) };
|
||||
}
|
||||
return this.chartCache.slabs;
|
||||
}
|
||||
|
||||
private chartCache: { cbsSlabs: any[]; productSlabs: any[]; slabs: any[] } | null = null;
|
||||
|
||||
/** The product's minimum deposit term in months - the server refuses anything shorter. Null when it sets none. */
|
||||
/** The product's installment limits (0 = no limit). */
|
||||
get minInstallment(): number {
|
||||
return Number(this.recurringDepositsAccountProductTemplate?.minDepositAmount) || 0;
|
||||
}
|
||||
|
||||
get maxInstallment(): number {
|
||||
return Number(this.recurringDepositsAccountProductTemplate?.maxDepositAmount) || 0;
|
||||
}
|
||||
|
||||
get productMinTenureMonths(): number | null {
|
||||
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
|
||||
return tenureInMonths(template?.minDepositTerm, template?.minDepositTermType?.id);
|
||||
}
|
||||
|
||||
private mergeChart(cbsSlabs: any[], productSlabs: any[]): any[] {
|
||||
const baseSlabs = this.normalizeCbsSlabs(buildRdChart(cbsSlabs, [], null).slabs);
|
||||
if (!productSlabs.length) {
|
||||
return baseSlabs;
|
||||
}
|
||||
// Products made before RD charts moved to months still hold the base chart's own year slabs
|
||||
const candidates = [...baseSlabs, ...this.normalizeCbsSlabs(cbsSlabs)];
|
||||
return productSlabs.map((slab: any) => {
|
||||
if (slab.seniorCitizenRate != null) {
|
||||
return slab;
|
||||
}
|
||||
const base = candidates.find(
|
||||
(candidate: any) =>
|
||||
candidate.fromPeriod === slab.fromPeriod &&
|
||||
(candidate.toPeriod ?? null) === (slab.toPeriod ?? null) &&
|
||||
this.periodLabel(candidate) === this.periodLabel(slab)
|
||||
);
|
||||
return base?.seniorCitizenRate != null ? { ...slab, seniorCitizenRate: base.seniorCitizenRate } : slab;
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeCbsSlabs(slabs: any[]): any[] {
|
||||
@@ -146,6 +195,10 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
|
||||
}));
|
||||
}
|
||||
|
||||
private periodLabel(slab: any): string {
|
||||
return String(slab.periodType?.value ?? slab.periodTypeLabel ?? '').toLowerCase();
|
||||
}
|
||||
|
||||
chartSlabsDisplayedColumns: string[] = [
|
||||
'period',
|
||||
'amountRange',
|
||||
@@ -253,6 +306,28 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
|
||||
return period; // days
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the tenure falls in the slab, counted the way the server counts it: in whole units of the slab's own
|
||||
* period type, so a 30-month RD is "2 years" against a year slab and lands in "1-2 Years" rather than between
|
||||
* slabs. Day and week slabs keep the day comparison.
|
||||
*/
|
||||
private isTenureInSlab(slab: any, tenureMonths: number, tenureDays: number): boolean {
|
||||
const label = this.periodLabel(slab) || 'days';
|
||||
let tenure: number;
|
||||
let from = slab.fromPeriod ?? 0;
|
||||
let to = slab.toPeriod ?? Infinity;
|
||||
if (label.includes('month')) {
|
||||
tenure = tenureMonths;
|
||||
} else if (label.includes('year')) {
|
||||
tenure = Math.floor(tenureMonths / 12);
|
||||
} else {
|
||||
tenure = tenureDays;
|
||||
from = this.slabPeriodToDays(from, label);
|
||||
to = to === Infinity ? Infinity : this.slabPeriodToDays(to, label);
|
||||
}
|
||||
return tenure >= from && tenure <= to;
|
||||
}
|
||||
|
||||
calculateRate() {
|
||||
this.validationWarning = null;
|
||||
this.matchResult = null;
|
||||
@@ -263,6 +338,26 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
|
||||
}
|
||||
|
||||
const enteredDays = this.toDays(this.tenureValue, this.tenurePeriodUnit);
|
||||
const enteredMonths = this.tenurePeriodUnit === 'Years' ? this.tenureValue * 12 : this.tenureValue;
|
||||
const minimumMonths = this.productMinTenureMonths;
|
||||
if (minimumMonths != null && enteredMonths < minimumMonths) {
|
||||
this.validationWarning = `Minimum RD tenure for this product is ${minimumMonths} months.`;
|
||||
return;
|
||||
}
|
||||
// The installment must be within the product's limits - the same check the submit and the server make, shown
|
||||
// here so a wrong amount is caught before the rest of the form is filled.
|
||||
if (!this.principalAmount || this.principalAmount <= 0) {
|
||||
this.validationWarning = 'Please enter the monthly installment amount.';
|
||||
return;
|
||||
}
|
||||
if (this.minInstallment > 0 && this.principalAmount < this.minInstallment) {
|
||||
this.validationWarning = `Minimum monthly installment for this product is ₹${this.minInstallment}.`;
|
||||
return;
|
||||
}
|
||||
if (this.maxInstallment > 0 && this.principalAmount > this.maxInstallment) {
|
||||
this.validationWarning = `Maximum monthly installment for this product is ₹${this.maxInstallment}.`;
|
||||
return;
|
||||
}
|
||||
const slabs: any[] = this.interestRateChartData || [];
|
||||
|
||||
if (!slabs.length) {
|
||||
@@ -275,11 +370,7 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
|
||||
|
||||
for (let i = 0; i < slabs.length; i++) {
|
||||
const slab = slabs[i];
|
||||
const periodTypeValue = slab.periodType?.value || 'Days';
|
||||
const fromDays = this.slabPeriodToDays(slab.fromPeriod ?? 0, periodTypeValue);
|
||||
const toDays = slab.toPeriod != null ? this.slabPeriodToDays(slab.toPeriod, periodTypeValue) : Infinity;
|
||||
|
||||
if (enteredDays >= fromDays && enteredDays <= toDays) {
|
||||
if (this.isTenureInSlab(slab, enteredMonths, enteredDays)) {
|
||||
matchedSlab = slab;
|
||||
matchedIndex = i;
|
||||
break;
|
||||
@@ -329,10 +420,20 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
|
||||
tenureInMonths: tenureInMonths,
|
||||
interestCompoundingPeriodInMonths: compoundingMonths
|
||||
};
|
||||
// With the product named, the server calculates with the product's own interest settings (simple/compounding,
|
||||
// compounding and posting periods, days in year) - the same figure the opened account will carry.
|
||||
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
|
||||
const productId = template?.depositProductId ?? template?.productId;
|
||||
if (productId) {
|
||||
calculationData.productId = productId;
|
||||
}
|
||||
|
||||
if (this.submittedOnDate) {
|
||||
const date = new Date(this.submittedOnDate);
|
||||
calculationData.submittedOnDate = date.toISOString().split('T')[0];
|
||||
// The local calendar date - toISOString() is UTC, which in IST turns midnight of the 9th into the 8th and
|
||||
// starts the projection a day early (a different maturity amount from the one the account gets).
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
calculationData.submittedOnDate = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
calculationData.dateFormat = 'yyyy-MM-dd'; // Standard ISO for API
|
||||
calculationData.locale = 'en';
|
||||
}
|
||||
@@ -341,6 +442,10 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
|
||||
result.maturityAmount = apiResult.maturityAmount;
|
||||
result.interestEarned = apiResult.totalInterest;
|
||||
result.daysInYear = apiResult.daysInYear;
|
||||
result.interestCalculation = apiResult.interestCalculation;
|
||||
result.compoundingPeriod = apiResult.compoundingPeriod;
|
||||
result.postingPeriod = apiResult.postingPeriod;
|
||||
result.postingSchedule = apiResult.postingSchedule;
|
||||
this.matchResult = { ...result };
|
||||
this.emitTenureSelection();
|
||||
});
|
||||
@@ -350,6 +455,18 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
|
||||
}
|
||||
}
|
||||
|
||||
/** "QUATERLY" / "BIANNUAL" / "MONTHLY" (server enum names) as a person would say them. */
|
||||
interestPeriodLabel(name?: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
DAILY: 'Daily',
|
||||
MONTHLY: 'Monthly',
|
||||
QUATERLY: 'Quarterly',
|
||||
BIANNUAL: 'Half-yearly',
|
||||
ANNUAL: 'Yearly'
|
||||
};
|
||||
return name ? (labels[name] ?? name) : '';
|
||||
}
|
||||
|
||||
private emitTenureSelection() {
|
||||
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
|
||||
if (frequencyId !== null && this.matchResult) {
|
||||
@@ -441,76 +558,54 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
|
||||
|
||||
// ── Header Getters ──────────────────────────────────────────────────────
|
||||
|
||||
get chartName(): string {
|
||||
if (this.activeBase) {
|
||||
return this.activeBase.versionLabel || 'CBS Base Interest Rate';
|
||||
}
|
||||
/**
|
||||
* The RD product's own chart - the one the deposit is actually priced on. The CBS base chart (an FD master the
|
||||
* product's chart was built from) is shown only when the product has no chart of its own.
|
||||
*/
|
||||
private get productChart(): any {
|
||||
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
|
||||
return (
|
||||
template?.accountChart?.name ||
|
||||
template?.activeChart?.name ||
|
||||
template?.interestRateCharts?.[0]?.name ||
|
||||
template?.interestRateChart?.name ||
|
||||
template?.name ||
|
||||
''
|
||||
template?.accountChart ||
|
||||
template?.activeChart ||
|
||||
template?.interestRateCharts?.[0] ||
|
||||
template?.interestRateChart ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
get chartName(): string {
|
||||
if (this.productChart) {
|
||||
return this.productChart.name || '';
|
||||
}
|
||||
return this.activeBase?.versionLabel || 'CBS Base Interest Rate';
|
||||
}
|
||||
|
||||
get chartFromDate(): any {
|
||||
if (this.activeBase) {
|
||||
return this.activeBase.effectiveFromDate;
|
||||
if (this.productChart) {
|
||||
return this.productChart.fromDate;
|
||||
}
|
||||
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
|
||||
return (
|
||||
template?.accountChart?.fromDate ||
|
||||
template?.activeChart?.fromDate ||
|
||||
template?.interestRateCharts?.[0]?.fromDate ||
|
||||
template?.interestRateChart?.fromDate ||
|
||||
template?.fromDate
|
||||
);
|
||||
return this.activeBase?.effectiveFromDate;
|
||||
}
|
||||
|
||||
get chartEndDate(): any {
|
||||
if (this.activeBase) {
|
||||
return null;
|
||||
if (this.productChart) {
|
||||
return this.productChart.endDate;
|
||||
}
|
||||
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
|
||||
return (
|
||||
template?.accountChart?.endDate ||
|
||||
template?.activeChart?.endDate ||
|
||||
template?.interestRateCharts?.[0]?.endDate ||
|
||||
template?.interestRateChart?.endDate ||
|
||||
template?.endDate
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
get chartDescription(): string {
|
||||
if (this.activeBase) {
|
||||
return this.activeBase.remarks || '';
|
||||
if (this.productChart) {
|
||||
return this.productChart.description || '';
|
||||
}
|
||||
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
|
||||
return (
|
||||
template?.accountChart?.description ||
|
||||
template?.activeChart?.description ||
|
||||
template?.interestRateCharts?.[0]?.description ||
|
||||
template?.interestRateChart?.description ||
|
||||
template?.description ||
|
||||
''
|
||||
);
|
||||
return this.activeBase?.remarks || '';
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
if (this.activeBase) {
|
||||
return this.activeBase.isActive;
|
||||
if (this.productChart) {
|
||||
return this.productChart.isActive ?? true;
|
||||
}
|
||||
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
|
||||
return (
|
||||
template?.accountChart?.isActive ??
|
||||
template?.activeChart?.isActive ??
|
||||
template?.interestRateCharts?.[0]?.isActive ??
|
||||
template?.interestRateChart?.isActive ??
|
||||
true
|
||||
);
|
||||
return this.activeBase?.isActive ?? true;
|
||||
}
|
||||
|
||||
get isPrimaryGroupingByAmount(): boolean {
|
||||
|
||||
+13
-21
@@ -84,25 +84,17 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
|
||||
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
|
||||
</mat-checkbox>
|
||||
|
||||
@if (closureSettingsForm.value.preClosurePenalApplicable) {
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Penal Interest' | translate }} (%)</mat-label>
|
||||
<input type="number" matInput formControlName="preClosurePenalInterest" min="0" step="0.01" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Period' | translate }}</mat-label>
|
||||
<mat-select formControlName="preClosurePenalInterestOnTypeId">
|
||||
@for (preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; track preClosurePenalInterestOnType) {
|
||||
<mat-option [value]="preClosurePenalInterestOnType.id">
|
||||
{{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
<!-- The premature-closure penalty is the product's, shown here and never changed per account. -->
|
||||
<div class="flex-98 margin-v rdmt-penal">
|
||||
<div class="rdmt-penal__title">{{ 'labels.inputs.Apply Penal Interest (less)' | translate }} <span class="rdmt-penal__tag">From Product</span></div>
|
||||
@if (closureSettingsForm.value.preClosurePenalApplicable) {
|
||||
<div class="rdmt-penal__row">
|
||||
<span>Penal Interest: <strong>{{ closureSettingsForm.value.preClosurePenalInterest }}%</strong></span>
|
||||
<span>Period: <strong>{{ penalPeriodLabel }}</strong></span>
|
||||
</div>
|
||||
<div class="rdmt-penal__hint">On premature closure the rate is reduced by this much.</div>
|
||||
} @else {
|
||||
<div class="rdmt-penal__row"><strong>No</strong> - no penal interest is charged on premature closure of this product.</div>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
+34
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+14
-2
@@ -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];
|
||||
|
||||
+3
-3
@@ -878,12 +878,12 @@
|
||||
}
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}</span>
|
||||
<span class="preview-value">{{ closureSettings?.preClosurePenalApplicable | yesNo }}</span>
|
||||
<span class="preview-value">{{ productPenal.applicable | yesNo }}</span>
|
||||
</div>
|
||||
@if (closureSettings?.preClosurePenalApplicable) {
|
||||
@if (productPenal.applicable) {
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">{{ 'labels.inputs.Penal Interest' | translate }} (%)</span>
|
||||
<span class="preview-value">{{ closureSettings?.preClosurePenalInterest }}%</span>
|
||||
<span class="preview-value">{{ productPenal.interest }}%</span>
|
||||
</div>
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">{{ 'labels.inputs.Period' | translate }}</span>
|
||||
|
||||
+11
-1
@@ -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 ?? '—';
|
||||
}
|
||||
|
||||
|
||||
+21
@@ -200,6 +200,27 @@
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
@if (lienLimit) {
|
||||
<div class="nx-row">
|
||||
<span class="nx-row__label">Loan against RD ({{ lienLimit.percentage }}% of balance)</span>
|
||||
<span class="nx-row__value nx-row__value--amount">
|
||||
up to {{ lienLimit.maxLoanAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}, available
|
||||
{{ lienLimit.availableForLoan | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (totalLienAmount > 0) {
|
||||
<div class="nx-row">
|
||||
<span class="nx-row__label">Lien Marked (loan security)</span>
|
||||
<span class="nx-row__value nx-row__value--amount">
|
||||
{{ totalLienAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
|
||||
-
|
||||
@for (lien of liens; track lien.loanId; let last = $last) {
|
||||
Loan {{ lien.loanAccountNo }}{{ last ? '' : ', ' }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<div class="nx-row">
|
||||
<span class="nx-row__label">{{ 'labels.inputs.Recurring Deposit Amount' | translate }}</span>
|
||||
<span class="nx-row__value nx-row__value--amount">
|
||||
|
||||
+29
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -82,6 +82,20 @@
|
||||
<span class="rd-kpi__label">{{ 'labels.inputs.Deposit Period' | translate }}</span>
|
||||
<span class="rd-kpi__value">{{ recurringDepositsAccountData.depositPeriod | number }}</span>
|
||||
</div>
|
||||
<!-- Interest credited to the deposit so far - by its product's posting period (monthly, quarterly...), plus the
|
||||
remainder on the maturity date. -->
|
||||
<div class="rd-kpi rd-kpi--interest">
|
||||
<span class="rd-kpi__label">Interest Posted</span>
|
||||
<span class="rd-kpi__value">
|
||||
{{
|
||||
(recurringDepositsAccountData.summary.totalInterestPosted || 0)
|
||||
| currency: currency.code : 'symbol-narrow' : '1.2-2'
|
||||
}}
|
||||
</span>
|
||||
@if (recurringDepositsAccountData.interestPostingPeriodType?.value) {
|
||||
<span class="rd-kpi__sub">Credited {{ recurringDepositsAccountData.interestPostingPeriodType.value }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
+11
@@ -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 */
|
||||
|
||||
+84
@@ -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.
|
||||
*/
|
||||
|
||||
+4
-10
@@ -36,12 +36,9 @@ export class RecurringDepositsButtonsConfiguration {
|
||||
setButtons(status: string) {
|
||||
switch (status) {
|
||||
case 'Active':
|
||||
// No Deposit button: a direct deposit here skips the manager's approval. RD installments come in
|
||||
// through the cashier's Receipt (maker-checker) or Auto Debit.
|
||||
this.buttonsArray = [
|
||||
{
|
||||
name: 'Deposit',
|
||||
icon: 'fa fa-arrow-up',
|
||||
taskPermissionName: 'DEPOSIT_RECURRINGDEPOSITACCOUNT'
|
||||
},
|
||||
{
|
||||
name: 'Withdrawal',
|
||||
icon: 'fa fa-arrow-down',
|
||||
@@ -103,12 +100,9 @@ export class RecurringDepositsButtonsConfiguration {
|
||||
name: 'Calculate Interest',
|
||||
icon: 'fa fa-calculator',
|
||||
taskPermissionName: 'CALCULATEINTEREST_RECURRINGDEPOSITACCOUNT'
|
||||
},
|
||||
{
|
||||
name: 'Post Interest',
|
||||
icon: 'fa fa-money',
|
||||
taskPermissionName: 'POSTINTEREST_RECURRINGDEPOSITACCOUNT'
|
||||
}
|
||||
// No Post Interest: the term's interest is credited by the server on the maturity date, and again only at
|
||||
// closure for any days after it.
|
||||
];
|
||||
break;
|
||||
case 'Rejected':
|
||||
|
||||
@@ -31,6 +31,14 @@ export class RecurringDepositsService {
|
||||
return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}`, { params: httpParams });
|
||||
}
|
||||
|
||||
/**
|
||||
* Loans this recurring deposit currently secures (lien), with the amount each one holds.
|
||||
* Returns { totalLienAmount, liens: [{ loanId, loanAccountNo, loanStatus, amount }] }.
|
||||
*/
|
||||
getRecurringDepositLiens(recurringDepositAccountId: any): Observable<any> {
|
||||
return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}/liens`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param clientId Client Id
|
||||
* @param clientName Client Name
|
||||
|
||||
Reference in New Issue
Block a user