feat(fixed-deposits): FD quote from the account engine, FD Special payout schedule popup, Special terms locked

- Rate chart step (account opening): the rate, maturity date, maturity amount
  and interest now come from POST fixeddepositaccounts/account-preview for
  every FD type, so the quote matches the account once created (the old
  calculate-fd-interest call counted quarters from the deposit date and was
  off, e.g. FD DOUBLE by 36,979.23). The client-side formula fallback is
  removed: on an error the message is shown, nothing is estimated.
- Dates are sent as the local calendar day (toISOString() turned 21 Oct at
  local midnight into 20 Oct).
- FD Special: total interest paid out monthly, monthly payout, principal at
  maturity, maturity date, number of payouts, and "View payout schedule",
  which opens the existing Monthly Interest Payout Schedule dialog in a new
  preview mode (Days column, no paid / pending status).
- Preview step: FD Special labels (total interest paid out monthly,
  principal at maturity).
- FD product terms: choosing FD Special locks Interest Calculation Type to
  Simple, as Compounding and Posting are locked to Monthly (the backend
  refuses anything else).
- Preview step: three existing `!= null` template checks rewritten for the
  eqeqeq lint rule, same meaning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 16:44:47 +05:30
co-authored by Claude Opus 5.5
parent c3713f8a90
commit 3e5cc26e49
9 changed files with 351 additions and 105 deletions
@@ -331,12 +331,14 @@
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span> <span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
</div> </div>
<div class="fdrc-maturity-row fdrc-maturity-row--interest"> <div class="fdrc-maturity-row fdrc-maturity-row--interest">
<span class="fdrc-maturity-row__label">Interest Earned</span> <span class="fdrc-maturity-row__label">{{
isSpecial ? 'Total Interest (paid out monthly)' : 'Interest Earned'
}}</span>
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span> <span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
</div> </div>
@if (isSpecial && matchResult.estimatedMonthlyPayout) { @if (isSpecial && matchResult.estimatedMonthlyPayout) {
<div class="fdrc-maturity-row"> <div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Monthly Payout</span> <span class="fdrc-maturity-row__label">Monthly Payout (approx., by days in the month)</span>
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--highlight"> <span class="fdrc-maturity-row__value fdrc-maturity-row__value--highlight">
₹ {{ matchResult.estimatedMonthlyPayout | number: '1.2-2' }} / month ₹ {{ matchResult.estimatedMonthlyPayout | number: '1.2-2' }} / month
</span> </span>
@@ -351,13 +353,64 @@
} }
</span> </span>
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--big"> <span class="fdrc-maturity-row__value fdrc-maturity-row__value--big">
₹ {{ (isSpecial ? principalAmount : matchResult.maturityAmount) | number: '1.2-2' }} ₹ {{ matchResult.maturityAmount | number: '1.2-2' }}
</span> </span>
</div> </div>
@if (isSpecial && specialPreview) {
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Maturity Date</span>
<span class="fdrc-maturity-row__value">{{
specialPreview.maturityDate | date: 'dd MMM yyyy'
}}</span>
</div>
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Monthly payouts</span>
<span class="fdrc-maturity-row__value">{{ specialPreview.numberOfPayouts }}</span>
</div>
<div class="fdrc-maturity-row">
<span class="fdrc-maturity-row__label">Principal + all payouts</span>
<span class="fdrc-maturity-row__value"
>₹ {{ specialPreview.totalValueAtMaturity | number: '1.2-2' }}</span
>
</div>
}
</div> </div>
</div> </div>
@if (isSpecial && specialPreview) {
<div class="fdrc-schedule">
<div class="fdrc-schedule__head">
<span><i class="fas fa-calendar-alt"></i> Monthly Interest Payout Schedule</span>
<button
mat-stroked-button
type="button"
class="fdrc-schedule__toggle"
(click)="openSpecialSchedule()"
>
View payout schedule ({{ specialPreview.numberOfPayouts }})
</button>
</div>
<div class="fdrc-schedule__note">
From {{ specialPreview.startDate | date: 'dd MMM yyyy' }} (the day the account is opened) to
{{ specialPreview.maturityDate | date: 'dd MMM yyyy' }}, worked out by the account engine from the
product.
</div>
</div>
}
} }
@if (!matchResult.maturityAmount) { @if (specialPreviewLoading) {
<div class="fdrc-payout-hint">
<i class="fas fa-spinner fa-spin"></i>
Working out the maturity from the product…
</div>
}
@if (specialPreviewError) {
<div class="fdrc-payout-hint fdrc-payout-hint--error">
<i class="fas fa-exclamation-triangle"></i>
{{ specialPreviewError }}
</div>
}
@if (!matchResult.maturityAmount && !specialPreviewLoading && !specialPreviewError) {
<div class="fdrc-payout-hint"> <div class="fdrc-payout-hint">
<i class="fas fa-lightbulb"></i> <i class="fas fa-lightbulb"></i>
Enter a deposit amount above to see the maturity calculation. Enter a deposit amount above to see the maturity calculation.
@@ -1229,3 +1229,72 @@ $red-light: #fee2e2;
font-weight: 700; font-weight: 700;
} }
} }
/* FD Special: payout schedule from the backend preview */
.fdrc-schedule {
margin-top: 12px;
border: 1px solid #bbf7d0;
border-radius: 10px;
background: #fff;
overflow: hidden;
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 14px;
background: #f0fdf4;
font-size: 12px;
font-weight: 700;
color: #166534;
text-transform: uppercase;
letter-spacing: 0.05em;
}
&__note {
padding: 6px 14px;
font-size: 12px;
color: #475569;
}
&__wrap {
max-height: 360px;
overflow: auto;
}
&__table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
padding: 6px 14px;
border-top: 1px solid #e2e8f0;
text-align: left;
}
th {
position: sticky;
top: 0;
background: #f8fafc;
font-size: 11px;
text-transform: uppercase;
color: #64748b;
}
.num {
text-align: right;
}
tfoot td {
font-weight: 700;
background: #f0fdf4;
}
}
}
.fdrc-payout-hint--error {
color: #b91c1c;
}
@@ -36,12 +36,39 @@ import { FormsModule } from '@angular/forms';
import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FixedDepositsService } from '../../fixed-deposits.service'; import { FixedDepositsService } from '../../fixed-deposits.service';
import { MatDialog } from '@angular/material/dialog';
import { InterestScheduleDialogComponent } from '../../fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component';
import { ClientsService } from 'app/clients/clients.service'; import { ClientsService } from 'app/clients/clients.service';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util'; import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util';
export type FdPeriodUnit = 'Days' | 'Months' | 'Years'; export type FdPeriodUnit = 'Days' | 'Months' | 'Years';
/**
* yyyy-MM-dd of the calendar day as the user sees it. toISOString() gives the UTC day instead, which in India is the day
* before for any date at local midnight - so a deposit opened on the 21st was quoted from the 20th.
*/
function localIsoDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** FD quote from POST fixeddepositaccounts/account-preview (worked out by the account engine, every FD type). */
export interface SpecialPayoutPreview {
fdType: string;
depositAmount: number;
annualInterestRate: number;
seniorCitizen: boolean;
startDate: string;
maturityDate: string;
numberOfPayouts: number;
fullMonthPayout: number;
totalInterest: number;
maturityAmount: number;
totalValueAtMaturity: number;
periods: { period: number; fromDate: string; toDate: string; days: number; interestAmount: number }[];
}
export interface SlabMatchResult { export interface SlabMatchResult {
slab: any; slab: any;
slabIndex: number; slabIndex: number;
@@ -87,6 +114,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
private clientsService = inject(ClientsService); private clientsService = inject(ClientsService);
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private cdr = inject(ChangeDetectorRef); private cdr = inject(ChangeDetectorRef);
private dialog = inject(MatDialog);
@Input() fixedDepositsAccountTemplate: any; @Input() fixedDepositsAccountTemplate: any;
@Input() fixedDepositsAccountProductTemplate: any; @Input() fixedDepositsAccountProductTemplate: any;
@@ -163,6 +191,10 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
} }
matchResult: SlabMatchResult | null = null; matchResult: SlabMatchResult | null = null;
/** FD Special only: the backend's quote and payout schedule for the tenure and amount entered. */
specialPreview: SpecialPayoutPreview | null = null;
specialPreviewLoading = false;
specialPreviewError: string | null = null;
validationWarning: string | null = null; validationWarning: string | null = null;
// ── FD Type labels ────────────────────────────────────────────────────── // ── FD Type labels ──────────────────────────────────────────────────────
@@ -270,6 +302,8 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
calculateRate() { calculateRate() {
this.validationWarning = null; this.validationWarning = null;
this.matchResult = null; this.matchResult = null;
this.specialPreview = null;
this.specialPreviewError = null;
if (!this.tenureValue || this.tenureValue <= 0) { if (!this.tenureValue || this.tenureValue <= 0) {
this.validationWarning = 'Please enter a valid tenure value greater than 0.'; this.validationWarning = 'Please enter a valid tenure value greater than 0.';
@@ -334,30 +368,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit); const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
if (this.principalAmount && this.principalAmount > 0) { if (this.principalAmount && this.principalAmount > 0) {
// Years and Months convert exactly; a Days tenure is sent as days and never converted.
let tenureInMonths: number | undefined;
let tenureInDays: number | undefined;
if (this.tenurePeriodUnit === 'Years') {
tenureInMonths = this.tenureValue * 12;
} else if (this.tenurePeriodUnit === 'Months') {
tenureInMonths = this.tenureValue;
} else {
// Sent as days, whatever the count. Converting to whole months silently dropped the
// remainder — a 200-day deposit quoted as six months lost 20 days of interest, and the
// screen still said "200 Days" above the figure. The backend takes days directly and
// works the calendar out itself.
tenureInDays = enteredDays;
}
// Map compounding period type id → months (0=Daily, 1=Daily, 4=Monthly, 5=Quarterly, 6=SemiAnnual, 7=Annual)
const compMap: Record<number, number> = { 1: 0, 4: 1, 5: 3, 6: 6, 7: 12 };
const compId = this.fixedDepositsAccountProductTemplate?.interestCompoundingPeriodType?.id ?? 4;
const compMonths = compMap[compId] ?? 1;
// Map posting period type id → months
const postId = this.fixedDepositsAccountProductTemplate?.interestPostingPeriodType?.id ?? 4;
const postMonths = compMap[postId] ?? 1;
const emitResult = () => { const emitResult = () => {
if (frequencyId !== null) { if (frequencyId !== null) {
this.tenureSelected.emit({ this.tenureSelected.emit({
@@ -375,7 +385,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
// Get submittedOnDate — Input takes priority over template data // Get submittedOnDate — Input takes priority over template data
let submittedOnDateStr: string; let submittedOnDateStr: string;
if (this.submittedOnDate) { if (this.submittedOnDate) {
submittedOnDateStr = this.submittedOnDate.toISOString().split('T')[0]; submittedOnDateStr = localIsoDate(this.submittedOnDate);
} else { } else {
const submittedOnDateRaw = const submittedOnDateRaw =
this.fixedDepositsAccountTemplate?.timeline?.submittedOnDate ?? this.fixedDepositsAccountTemplate?.timeline?.submittedOnDate ??
@@ -384,63 +394,18 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
const d = Array.isArray(submittedOnDateRaw) const d = Array.isArray(submittedOnDateRaw)
? new Date(submittedOnDateRaw[0], submittedOnDateRaw[1] - 1, submittedOnDateRaw[2]) ? new Date(submittedOnDateRaw[0], submittedOnDateRaw[1] - 1, submittedOnDateRaw[2])
: new Date(submittedOnDateRaw); : new Date(submittedOnDateRaw);
submittedOnDateStr = !isNaN(d.getTime()) submittedOnDateStr = !isNaN(d.getTime()) ? localIsoDate(d) : localIsoDate(new Date());
? d.toISOString().split('T')[0]
: new Date().toISOString().split('T')[0];
} else { } else {
submittedOnDateStr = new Date().toISOString().split('T')[0]; submittedOnDateStr = localIsoDate(new Date());
} }
} }
const daysInYearVal = // The whole quote - rate, maturity date, maturity amount and, for FD Special, the month-by-month payout
this.fixedDepositsAccountProductTemplate?.interestCalculationDaysInYearType?.id === 360 ? 360 : 365; // schedule - comes from the backend, which builds the would-be account from the product exactly as Submit does
// (its posting periods, compounding, days in year, rate card, senior-citizen rate) and runs the account's own
// Always use backend API for accurate calculation (including daily compounding with 0) // interest engine. Nothing is worked out here: on an error the quote is simply not shown, never estimated.
this.fixedDepositsService if (frequencyId !== null) {
.calculateFixedDepositInterest({ this.loadAccountPreview(result, rate, submittedOnDateStr, frequencyId, emitResult);
principalAmount: this.principalAmount, }
annualInterestRate: rate,
tenureInMonths,
tenureInDays,
interestCompoundingPeriodInMonths: compMonths, // 0 = daily, now supported by backend
interestPostingPeriodInMonths: postMonths,
submittedOnDate: submittedOnDateStr,
daysInYear: daysInYearVal
})
.subscribe({
next: (apiResult: any) => {
result.maturityAmount = apiResult.maturityAmount;
result.interestEarned =
apiResult.totalInterest ?? Math.round((apiResult.maturityAmount - this.principalAmount) * 100) / 100;
this.matchResult = { ...result };
emitResult();
},
error: () => {
// Fallback if API fails: frontend formula
const r = rate / 100;
if (compMonths === 0) {
// Daily compounding fallback
result.maturityAmount =
Math.round(this.principalAmount * Math.pow(1 + r / daysInYearVal, enteredDays) * 100) / 100;
} else {
const n = 12 / compMonths;
const effectiveMonths = tenureInMonths ?? enteredDays / 30;
const rPerCompounding = rate / (n * 100);
// Whole compounding periods, then simple interest on the days left over. Flooring the
// periods and stopping there dropped the remainder entirely: a 200-day deposit
// compounding monthly quoted only six months of interest and lost the other twenty
// days, which is the depositor's money.
const wholePeriods = Math.floor(effectiveMonths / compMonths);
const compounded = this.principalAmount * Math.pow(1 + rPerCompounding, wholePeriods);
const leftoverDays = enteredDays - wholePeriods * compMonths * 30;
const leftoverInterest =
leftoverDays > 0 ? (compounded * rate * leftoverDays) / (100 * daysInYearVal) : 0;
result.maturityAmount = Math.round((compounded + leftoverInterest) * 100) / 100;
}
result.interestEarned = Math.round((result.maturityAmount - this.principalAmount) * 100) / 100;
this.matchResult = { ...result };
emitResult();
}
});
} else { } else {
this.matchResult = result; this.matchResult = result;
if (frequencyId !== null) { if (frequencyId !== null) {
@@ -457,6 +422,96 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
} }
} }
/** The backend's payout schedule in the same popup the account itself uses, in its quote (preview) mode. */
openSpecialSchedule(): void {
if (!this.specialPreview) return;
this.dialog.open(InterestScheduleDialogComponent, {
width: '760px',
data: {
preview: true,
currencyCode: this.fixedDepositsAccountProductTemplate?.currency?.code ?? 'INR',
note:
'Worked out by the account engine from the product, starting ' +
this.specialPreview.startDate +
' (the day the account is opened) and ending the day before maturity on ' +
this.specialPreview.maturityDate +
'. The first and last periods are part-months; each pays for the days it covers.',
schedule: this.specialPreview.periods.map((p) => ({
fromDate: p.fromDate,
toDate: p.toDate,
days: p.days,
interestAmount: Number(p.interestAmount),
paid: false
}))
}
});
}
/**
* Asks the backend for the quote (POST fixeddepositaccounts/account-preview) with the same terms Submit sends: the
* product's own interest terms, and the senior-citizen rate the same way Submit overrides it. The rate, maturity
* amount and interest shown are the backend's, so they match the account once it is created.
*/
private loadAccountPreview(
result: SlabMatchResult,
rate: number,
submittedOnDateStr: string,
frequencyId: number,
emitResult: () => void
): void {
const pt = this.fixedDepositsAccountProductTemplate ?? {};
const body: any = {
clientId: pt.clientId ?? this.fixedDepositsAccountTemplate?.clientId,
productId: pt.depositProductId ?? pt.productId ?? this.fixedDepositsAccountTemplate?.productId,
submittedOnDate: submittedOnDateStr,
dateFormat: 'yyyy-MM-dd',
locale: 'en',
depositAmount: this.principalAmount,
depositPeriod: this.tenureValue,
depositPeriodFrequencyId: frequencyId
};
if (pt.nominalAnnualInterestRate != null) body.nominalAnnualInterestRate = pt.nominalAnnualInterestRate;
if (pt.interestCompoundingPeriodType?.id != null)
body.interestCompoundingPeriodType = pt.interestCompoundingPeriodType.id;
if (pt.interestPostingPeriodType?.id != null) body.interestPostingPeriodType = pt.interestPostingPeriodType.id;
if (pt.interestCalculationType?.id != null) body.interestCalculationType = pt.interestCalculationType.id;
if (pt.interestCalculationDaysInYearType?.id != null)
body.interestCalculationDaysInYearType = pt.interestCalculationDaysInYearType.id;
if (this.isSeniorCitizen && rate > 0) {
body.isSeniorCitizen = true;
body.seniorCitizenInterestRate = rate;
body.nominalAnnualInterestRate = rate;
}
this.specialPreview = null;
this.specialPreviewError = null;
this.specialPreviewLoading = true;
this.fixedDepositsService.accountPreview(body).subscribe({
next: (preview: SpecialPayoutPreview) => {
this.specialPreviewLoading = false;
this.specialPreview = preview;
result.appliedRate = Number(preview.annualInterestRate);
result.interestEarned = Number(preview.totalInterest);
result.estimatedMonthlyPayout = preview.fullMonthPayout != null ? Number(preview.fullMonthPayout) : undefined;
result.maturityAmount = Number(preview.maturityAmount);
this.matchResult = { ...result };
emitResult();
this.cdr.markForCheck();
},
error: (e: any) => {
this.specialPreviewLoading = false;
this.specialPreviewError =
e?.error?.errors?.[0]?.defaultUserMessage ||
e?.error?.defaultUserMessage ||
'Could not work out the maturity. Check the deposit details and try again.';
result.maturityAmount = undefined;
result.interestEarned = undefined;
this.matchResult = { ...result };
this.cdr.markForCheck();
}
});
}
private resolveFrequencyId(unit: FdPeriodUnit): number | null { private resolveFrequencyId(unit: FdPeriodUnit): number | null {
const options: any[] = this.fixedDepositsAccountProductTemplate?.periodFrequencyTypeOptions ?? []; const options: any[] = this.fixedDepositsAccountProductTemplate?.periodFrequencyTypeOptions ?? [];
const unitLower = unit.toLowerCase(); const unitLower = unit.toLowerCase();
@@ -471,12 +526,16 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
this.tenureValue = null; this.tenureValue = null;
this.principalAmount = null; this.principalAmount = null;
this.matchResult = null; this.matchResult = null;
this.specialPreview = null;
this.specialPreviewError = null;
this.validationWarning = null; this.validationWarning = null;
this.tenurePeriodUnit = this.allowedPeriodUnits[0]; this.tenurePeriodUnit = this.allowedPeriodUnits[0];
} }
onPeriodUnitChange() { onPeriodUnitChange() {
this.matchResult = null; this.matchResult = null;
this.specialPreview = null;
this.specialPreviewError = null;
this.validationWarning = null; this.validationWarning = null;
} }
@@ -524,6 +583,8 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
this.calculateRate(); this.calculateRate();
} else { } else {
this.matchResult = null; this.matchResult = null;
this.specialPreview = null;
this.specialPreviewError = null;
} }
} }
@@ -326,7 +326,7 @@
<div class="preview-dorm-step__body"> <div class="preview-dorm-step__body">
<span class="preview-dorm-step__label">Inactive</span> <span class="preview-dorm-step__label">Inactive</span>
<span class="preview-dorm-step__days"> <span class="preview-dorm-step__days">
@if (fixedDepositsAccountProductTemplate?.daysToInactive != null) { @if ((fixedDepositsAccountProductTemplate?.daysToInactive ?? null) !== null) {
After {{ fixedDepositsAccountProductTemplate.daysToInactive }} days of no transaction After {{ fixedDepositsAccountProductTemplate.daysToInactive }} days of no transaction
} @else { } @else {
Not configured Not configured
@@ -340,7 +340,7 @@
<div class="preview-dorm-step__body"> <div class="preview-dorm-step__body">
<span class="preview-dorm-step__label">Dormant</span> <span class="preview-dorm-step__label">Dormant</span>
<span class="preview-dorm-step__days"> <span class="preview-dorm-step__days">
@if (fixedDepositsAccountProductTemplate?.daysToDormancy != null) { @if ((fixedDepositsAccountProductTemplate?.daysToDormancy ?? null) !== null) {
{{ fixedDepositsAccountProductTemplate.daysToDormancy }} days after inactive {{ fixedDepositsAccountProductTemplate.daysToDormancy }} days after inactive
} @else { } @else {
Not configured Not configured
@@ -354,7 +354,7 @@
<div class="preview-dorm-step__body"> <div class="preview-dorm-step__body">
<span class="preview-dorm-step__label">Escheat</span> <span class="preview-dorm-step__label">Escheat</span>
<span class="preview-dorm-step__days"> <span class="preview-dorm-step__days">
@if (fixedDepositsAccountProductTemplate?.daysToEscheat != null) { @if ((fixedDepositsAccountProductTemplate?.daysToEscheat ?? null) !== null) {
{{ fixedDepositsAccountProductTemplate.daysToEscheat }} days after dormant {{ fixedDepositsAccountProductTemplate.daysToEscheat }} days after dormant
} @else { } @else {
Not configured Not configured
@@ -458,13 +458,15 @@
<span class="preview-value">{{ maturitySummary.tenureLabel }}</span> <span class="preview-value">{{ maturitySummary.tenureLabel }}</span>
</div> </div>
<div class="preview-field"> <div class="preview-field">
<span class="preview-label">Estimated Interest Earned</span> <span class="preview-label">{{
isSpecial ? 'Total Interest (paid out monthly)' : 'Estimated Interest Earned'
}}</span>
<span class="preview-value" style="color: var(--theme-success, #28a745); font-weight: 500"> <span class="preview-value" style="color: var(--theme-success, #28a745); font-weight: 500">
+ {{ maturitySummary.totalInterest | formatNumber }} {{ currencyName }} + {{ maturitySummary.totalInterest | formatNumber }} {{ currencyName }}
</span> </span>
</div> </div>
<div class="preview-field" style="grid-column: span 2"> <div class="preview-field" style="grid-column: span 2">
<span class="preview-label">Projected Maturity Amount</span> <span class="preview-label">{{ isSpecial ? 'Principal at Maturity' : 'Projected Maturity Amount' }}</span>
<span class="preview-value preview-mono" style="font-weight: 600; font-size: 1.25rem"> <span class="preview-value preview-mono" style="font-weight: 600; font-size: 1.25rem">
{{ maturitySummary.maturityAmount | formatNumber }} {{ currencyName }} {{ maturitySummary.maturityAmount | formatNumber }} {{ currencyName }}
</span> </span>
@@ -145,6 +145,11 @@ export class FixedDepositAccountPreviewStepComponent implements OnChanges {
SPECIAL: 'FD Special (Monthly Interest Payout)' SPECIAL: 'FD Special (Monthly Interest Payout)'
}; };
/** FD Special pays its interest out monthly; the principal alone comes back at maturity. */
get isSpecial(): boolean {
return this.fixedDepositsAccountProductTemplate?.fdType === 'SPECIAL';
}
getFdTypeLabel(fdType: string): string { getFdTypeLabel(fdType: string): string {
return this.fdTypeLabels[fdType] || fdType; return this.fdTypeLabels[fdType] || fdType;
} }
@@ -11,15 +11,25 @@
<span class="isd-header__icon"><fa-icon icon="calendar-alt"></fa-icon></span> <span class="isd-header__icon"><fa-icon icon="calendar-alt"></fa-icon></span>
<span class="isd-header__text"> <span class="isd-header__text">
<span class="isd-header__title">{{ 'labels.heading.Monthly Interest Payout Schedule' | translate }}</span> <span class="isd-header__title">{{ 'labels.heading.Monthly Interest Payout Schedule' | translate }}</span>
<span class="isd-header__sub">{{ paidCount }} of {{ schedule.length }} periods paid so far</span> <span class="isd-header__sub">
@if (isPreview) {
{{ schedule.length }} monthly payouts
} @else {
{{ paidCount }} of {{ schedule.length }} periods paid so far
}
</span>
</span> </span>
</h2> </h2>
<div mat-dialog-content class="isd-content"> <div mat-dialog-content class="isd-content">
<p class="isd-note"> <p class="isd-note">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon> <fa-icon icon="info-circle" class="m-r-5"></fa-icon>
Computed by the actual interest engine for this account's full term -- the exact amount posted each month can vary @if (data.note) {
slightly with the number of days in that month. {{ data.note }}
} @else {
Computed by the actual interest engine for this account's full term -- the exact amount posted each month can
vary slightly with the number of days in that month.
}
</p> </p>
<div class="isd-scroll"> <div class="isd-scroll">
@@ -28,8 +38,13 @@
<tr> <tr>
<th>{{ 'labels.inputs.From Date' | translate }}</th> <th>{{ 'labels.inputs.From Date' | translate }}</th>
<th>{{ 'labels.inputs.To Date' | translate }}</th> <th>{{ 'labels.inputs.To Date' | translate }}</th>
@if (isPreview) {
<th class="isd-table__amount-col">Days</th>
}
<th class="isd-table__amount-col">{{ 'labels.inputs.Interest Amount' | translate }}</th> <th class="isd-table__amount-col">{{ 'labels.inputs.Interest Amount' | translate }}</th>
<th class="isd-table__status-col">{{ 'labels.inputs.Status' | translate }}</th> @if (!isPreview) {
<th class="isd-table__status-col">{{ 'labels.inputs.Status' | translate }}</th>
}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -37,28 +52,35 @@
<tr [class.isd-table__row--paid]="period.paid"> <tr [class.isd-table__row--paid]="period.paid">
<td>{{ period.fromDate | dateFormat }}</td> <td>{{ period.fromDate | dateFormat }}</td>
<td>{{ period.toDate | dateFormat }}</td> <td>{{ period.toDate | dateFormat }}</td>
@if (isPreview) {
<td class="isd-table__amount-col">{{ period.days }}</td>
}
<td class="isd-table__amount-col"> <td class="isd-table__amount-col">
{{ period.interestAmount | currency: currencyCode : 'symbol-narrow' : '1.2-2' }} {{ period.interestAmount | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
</td> </td>
<td class="isd-table__status-col"> @if (!isPreview) {
@if (period.paid) { <td class="isd-table__status-col">
<span class="isd-badge isd-badge--paid"> @if (period.paid) {
<fa-icon icon="check-circle" class="m-r-5"></fa-icon>Paid <span class="isd-badge isd-badge--paid">
</span> <fa-icon icon="check-circle" class="m-r-5"></fa-icon>Paid
} @else { </span>
<span class="isd-badge isd-badge--pending">Pending</span> } @else {
} <span class="isd-badge isd-badge--pending">Pending</span>
</td> }
</td>
}
</tr> </tr>
} }
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr>
<td colspan="2">{{ 'labels.inputs.Total Interest Payable' | translate }}</td> <td [attr.colspan]="isPreview ? 3 : 2">{{ 'labels.inputs.Total Interest Payable' | translate }}</td>
<td class="isd-table__amount-col"> <td class="isd-table__amount-col">
{{ totalInterest | currency: currencyCode : 'symbol-narrow' : '1.2-2' }} {{ totalInterest | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
</td> </td>
<td></td> @if (!isPreview) {
<td></td>
}
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
@@ -26,6 +26,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
* a 24+ month term makes for a long table that would otherwise push every other card on the tab down * a 24+ month term makes for a long table that would otherwise push every other card on the tab down
* the page. Each row also shows whether that period's interest has actually posted yet, not just what * the page. Each row also shows whether that period's interest has actually posted yet, not just what
* the engine projects it to be. * the engine projects it to be.
*
* Also opened from the account-opening screen with `preview: true`: the backend's quote for an FD Special
* not opened yet, so there is no paid / pending status, and each period shows its number of days.
*/ */
@Component({ @Component({
selector: 'mifosx-interest-schedule-dialog', selector: 'mifosx-interest-schedule-dialog',
@@ -46,14 +49,22 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class InterestScheduleDialogComponent { export class InterestScheduleDialogComponent {
dialogRef = inject<MatDialogRef<InterestScheduleDialogComponent>>(MatDialogRef); dialogRef = inject<MatDialogRef<InterestScheduleDialogComponent>>(MatDialogRef);
data: { data: {
schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }>; schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean; days?: number }>;
currencyCode: string; currencyCode: string;
/** Account-opening quote: no paid / pending status. */
preview?: boolean;
/** Shown above the table instead of the account note. */
note?: string;
} = inject(MAT_DIALOG_DATA); } = inject(MAT_DIALOG_DATA);
get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }> { get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean; days?: number }> {
return this.data.schedule ?? []; return this.data.schedule ?? [];
} }
get isPreview(): boolean {
return !!this.data.preview;
}
get currencyCode(): string { get currencyCode(): string {
return this.data.currencyCode; return this.data.currencyCode;
} }
@@ -154,6 +154,16 @@ export class FixedDepositsService {
return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams }); return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams });
} }
/**
* FD quote while opening the account, for every FD type: the same body as create (clientId, productId,
* submittedOnDate, depositAmount, depositPeriod, depositPeriodFrequencyId, interest terms, senior citizen ...). The
* backend builds the would-be account from the product, without saving it, and returns the applied rate, maturity
* date and amount Submit would store, and the interest periods (FD Special: the monthly payout schedule).
*/
accountPreview(body: any): Observable<any> {
return this.http.post(`/fixeddepositaccounts/account-preview`, body);
}
/** /**
* FD Dashboard's "Renew" action on a matured FD whose product has a renewal grace period * FD Dashboard's "Renew" action on a matured FD whose product has a renewal grace period
* configured: backdated to the original maturity date within the grace window, or from today * configured: backdated to the original maturity date within the grace window, or from today
@@ -129,12 +129,24 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
* (see DepositAccountAssembler#assembleFrom on the backend), so the product itself is locked to the * (see DepositAccountAssembler#assembleFrom on the backend), so the product itself is locked to the
* same values here to avoid an admin configuring a combination that the backend will silently override * same values here to avoid an admin configuring a combination that the backend will silently override
* at account-creation time. * at account-creation time.
*
* Its interest is paid out every month, so nothing compounds on the principal: the Interest Calculation Type is
* locked to Simple as well. The backend refuses an FD Special product with any other value for these three
* (DepositProductDataValidator), so a request from anywhere else cannot save them either.
*/ */
private applyFdTypeConstraints() { private applyFdTypeConstraints() {
if (!this.fixedDepositProductTermsForm) return; if (!this.fixedDepositProductTermsForm) return;
const postingCtrl = this.fixedDepositProductTermsForm.get('interestPostingPeriodType'); const postingCtrl = this.fixedDepositProductTermsForm.get('interestPostingPeriodType');
const compoundingCtrl = this.fixedDepositProductTermsForm.get('interestCompoundingPeriodType'); const compoundingCtrl = this.fixedDepositProductTermsForm.get('interestCompoundingPeriodType');
const calculationTypeCtrl = this.fixedDepositProductTermsForm.get('interestCalculateSelector');
if (this.fdType === 'SPECIAL') { if (this.fdType === 'SPECIAL') {
const simple = (this.interestCalculationTypeOptionData || []).find(
(o: any) => String(o.value ?? o.name ?? '').toLowerCase() === 'simple'
);
if (calculationTypeCtrl && simple) {
calculationTypeCtrl.setValue(simple.id);
calculationTypeCtrl.disable();
}
// Monthly = id 4 (MONTHLY in Fineract's SavingsPostingInterestPeriodType/SavingsCompoundingInterestPeriodType enums) // Monthly = id 4 (MONTHLY in Fineract's SavingsPostingInterestPeriodType/SavingsCompoundingInterestPeriodType enums)
const findMonthly = (data: any) => data?.find((o: any) => o.value?.toLowerCase() === 'monthly' || o.id === 4); const findMonthly = (data: any) => data?.find((o: any) => o.value?.toLowerCase() === 'monthly' || o.id === 4);
if (postingCtrl && this.interestPostingPeriodTypeData) { if (postingCtrl && this.interestPostingPeriodTypeData) {
@@ -154,6 +166,7 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
} else { } else {
postingCtrl?.enable(); postingCtrl?.enable();
compoundingCtrl?.enable(); compoundingCtrl?.enable();
calculationTypeCtrl?.enable();
} }
} }