From 395fb8ace84c082fd84e0361e7f5b921f873e863 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 26 Aug 2026 18:56:41 +0530 Subject: [PATCH] feat(clients,deposits): auto-derive senior-citizen/customer-segment, fix disbursement display and reversed-transaction balances Four independent, self-contained changes bundled from this session's work: 1. Auto-derive Senior Citizen / Minor / Customer Segment instead of manual entry Senior Citizen was a manual, staff-editable checkbox on FD/RD account creation with no validation against the client's actual age -- this let non-senior clients get the senior-citizen interest rate by mistake (found twice on live test accounts, where m_savings_account.is_senior_citizen was true despite the client being confirmed non-senior at the client record level). Likewise "minor" client classification was hardcoded to the age-18 threshold with no single source of truth, and FD/RD account creation's "Customer Segment" (Individual / Non-Individual) had no connection to the Individual/Non-Individual Legal Form already captured at client creation. - New src/app/clients/shared/client-age.util.ts centralises age/DOB derivation: MINOR_AGE_THRESHOLD (now 10, was 18), SENIOR_CITIZEN_AGE_ THRESHOLD (60), AGE_OF_MAJORITY (18, kept separate from the minor threshold -- majority date math is unrelated to the minor/adult classification threshold), calculateAge/isMinorAge/isSeniorCitizenAge/ isIndividualClient/deriveSeniorCitizenStatus. - FD/RD interest-rate-chart steps: Senior Citizen checkbox is now derived from the client's DOB (age >= 60, individual clients only) and disabled for manual editing; a hint explains why it is checked/ unchecked. - Client general step and all four account-details-steps (savings, current, FD, RD): replaced hardcoded 18/60 literals with the shared helpers; "Must be below 18" hints now read the actual threshold. - FD/RD account-details-steps: Customer Segment auto-selects to match the client's Legal Form (exact match, not substring -- a substring match would misclassify "Individual" as matching "Non-Individual"). - RD preview step: fixed a pre-existing display bug that always showed the product's default Customer Segment instead of what was actually selected on the account. 2. Fix wrong Account Name / Account Type on Disbursement Review screens Clerk disbursement review and "My Disbursement Requests" showed the account NUMBER instead of the holder's name, and always labelled the account type "SAVINGS" regardless of the real account type (FD/RD/ Share/Loan), because transaction-entry.component.ts's account-type mapping only checked for CA/CB and its account-name fallback chain never tried clientName. Added creditEntryAccountType() covering GL/CA/CB/FD/RD/SHR/LA, and accountTypeLabel() display helpers on both review screens. 3. Stop reversed transactions from distorting displayed running balances Passbook and Transactions tabs were including reversed transactions in their client-side running-balance recalculation, so a reversed posting (visible struck-through) still shifted the balance shown for every transaction after it. Both now filter out txn.reversed before computing running balances; Transactions tab also defaults "Hide Reversed" to on. 4. New Interest Schedule dialog on the FD account view Added custom-dialogs/interest-schedule-dialog, opened from the FD General tab, showing the account's period-by-period interest schedule. Excludes src/environments/.env.ts (auto-generated build version/hash stamp, regenerated on every build -- not meaningful to hand-commit). Co-Authored-By: Claude Sonnet 5 --- .../transaction-entry.component.ts | 46 +++++- .../disbursement-review.component.html | 6 +- .../disbursement-review.component.ts | 18 +++ .../my-disbursement-requests.component.html | 4 +- .../my-disbursement-requests.component.ts | 17 +++ .../client-general-step.component.html | 2 +- .../client-general-step.component.ts | 17 ++- .../client-preview-step.component.html | 2 +- .../personal-data-tab.component.html | 2 +- src/app/clients/shared/client-age.util.ts | 97 ++++++++++++ ...urrent-account-details-step.component.html | 2 +- .../current-account-details-step.component.ts | 24 ++- ...eposit-account-details-step.component.html | 2 +- ...-deposit-account-details-step.component.ts | 66 ++++++-- ...nt-interest-rate-chart-step.component.html | 4 +- ...ount-interest-rate-chart-step.component.ts | 80 +++++++++- .../interest-schedule-dialog.component.html | 71 +++++++++ .../interest-schedule-dialog.component.scss | 142 ++++++++++++++++++ .../interest-schedule-dialog.component.ts | 68 +++++++++ .../general-tab/general-tab.component.html | 81 +++++++++- .../general-tab/general-tab.component.scss | 10 ++ .../general-tab/general-tab.component.ts | 57 +++++++ ...posits-account-details-step.component.html | 2 +- ...deposits-account-details-step.component.ts | 53 ++++++- ...nt-interest-rate-chart-step.component.html | 4 +- ...ount-interest-rate-chart-step.component.ts | 78 +++++++++- ...posits-account-preview-step.component.html | 10 +- ...eposit-product-details-step.component.html | 6 + ...eposit-product-details-step.component.scss | 12 +- ...-deposit-product-details-step.component.ts | 21 ++- ...ed-deposit-product-terms-step.component.ts | 36 +++-- ...avings-account-details-step.component.html | 2 +- .../savings-account-details-step.component.ts | 24 ++- .../passbook-tab/passbook-tab.component.ts | 12 +- .../transactions-tab.component.ts | 10 +- 35 files changed, 997 insertions(+), 91 deletions(-) create mode 100644 src/app/clients/shared/client-age.util.ts create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.html create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.scss create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.ts diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts index df16141b4..29522bbcc 100644 --- a/src/app/cashier/transaction-entry/transaction-entry.component.ts +++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts @@ -1401,7 +1401,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { dbCr: 'Cr', accountNo: cr.acNo, accountName: this.rowAccountNames[cr.acNo] ?? cr.acNo, - accountType: cr.acTyp === 'CA' || cr.acTyp === 'CB' ? 'CURRENT' : cr.acTyp === 'GL' ? 'GL' : 'SAVINGS', + accountType: this.creditEntryAccountType(cr.acTyp), savingsAccountId: cr.acTyp !== 'GL' ? (this.rowAccountIds[cr.acNo] ?? null) : null, glAccountId: cr.acTyp === 'GL' ? (this.rowAccountIds[cr.acNo] ?? null) : null, amount: String(cr.amount), @@ -1421,7 +1421,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { dbCr: r.dbCr, accountNo: r.acNo, accountName: this.rowAccountNames[r.acNo] ?? r.acNo, - accountType: r.acTyp === 'CA' || r.acTyp === 'CB' ? 'CURRENT' : r.acTyp === 'GL' ? 'GL' : 'SAVINGS', + accountType: this.creditEntryAccountType(r.acTyp), savingsAccountId: r.acTyp !== 'GL' ? (this.rowAccountIds[r.acNo] ?? null) : null, glAccountId: r.acTyp === 'GL' ? (this.rowAccountIds[r.acNo] ?? null) : null, amount: String(r.amount), @@ -1490,7 +1490,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { dbCr, accountNo: r.acNo, accountName: this.rowAccountNames[r.acNo] ?? r.acNo, - accountType: r.acTyp === 'CA' || r.acTyp === 'CB' ? 'CURRENT' : 'SAVINGS', + accountType: this.creditEntryAccountType(r.acTyp), savingsAccountId: this.rowAccountIds[r.acNo], amount: String(r.amount), description: r.description ?? null @@ -1937,6 +1937,36 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { } /** Maps UI account type → { listEndpoint, detailEndpoint, nomineeType, searchResource } */ + /** + * The account-type label carried on a disbursement request's credit/debit entry. + * + * The backend (ClerkDisbursementRequestApiResource) only ever branches on 'GL' and 'CURRENT'; every + * other value takes the same path, and in the disbursement flow it resolves current-vs-savings from + * the database rather than from this string. So naming a fixed or recurring deposit accurately here + * changes only what the approving checker sees, never what gets posted. Previously anything that + * wasn't current or GL was labelled 'SAVINGS', which showed a checker an FD as a savings account -- + * exactly the wrong information to approve a disbursement against. + */ + private creditEntryAccountType(acTyp: string | undefined | null): string { + switch ((acTyp ?? '').toUpperCase()) { + case 'GL': + return 'GL'; + case 'CA': + case 'CB': + return 'CURRENT'; + case 'FD': + return 'FIXED_DEPOSIT'; + case 'RD': + return 'RECURRING_DEPOSIT'; + case 'SHR': + return 'SHARE'; + case 'LA': + return 'LOAN'; + default: + return 'SAVINGS'; + } + } + private accountTypeConfig(acTyp: string): { list: string; detail: string; @@ -2136,8 +2166,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { // Track per-row account id/name for multi-row disbursement if (this.selectedRow?.acNo) { this.rowAccountIds[this.selectedRow.acNo] = acc.id; + // clientName must be in this chain: a deposit account's detail response carries the holder + // under clientName, not name/displayName (only GL accounts have `name`). Without it every FD + // and RD fell through to accountNo, so a disbursement request showed the checker an account + // number where the account holder's name belongs. this.rowAccountNames[this.selectedRow.acNo] = - acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? ''; + acc.name ?? acc.displayName ?? acc.clientName ?? acc.glAccountName ?? acc.accountNo ?? ''; this.rowClientIds[this.selectedRow.acNo] = acc.clientId ?? null; } if (this.selectedRow) { @@ -3696,7 +3730,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { return { accountNo: this.accountDetails.accountNo, - accountType: 'SAVINGS', + accountType: this.creditEntryAccountType(row.acTyp), savingsAccountId: this.accountDetails.id, clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null, clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null, @@ -3805,7 +3839,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy { const isGl = row.acTyp === 'GL'; return { accountNo: isGl ? (this.accountDetails.glCode ?? this.accountDetails.name) : this.accountDetails.accountNo, - accountType: isGl ? 'GL' : row.acTyp === 'CA' || row.acTyp === 'CB' ? 'CURRENT' : 'SAVINGS', + accountType: this.creditEntryAccountType(row.acTyp), savingsAccountId: isGl ? null : this.accountDetails.id, // GL-specific fields — ignored by backend for non-GL types glAccountId: isGl ? this.accountDetails.id : null, diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.html b/src/app/clerk/disbursement-review/disbursement-review.component.html index b62e16e3a..f9aa574ce 100644 --- a/src/app/clerk/disbursement-review/disbursement-review.component.html +++ b/src/app/clerk/disbursement-review/disbursement-review.component.html @@ -159,7 +159,7 @@ {{ entry.accountNo }} {{ entry.accountName }} - {{ entry.accountType }} + {{ accountTypeLabel(entry.accountType) }} ₹{{ entry.amount | number: '1.2-2' }} @@ -197,7 +197,7 @@ {{ entry.accountNo }} {{ entry.accountName }} - {{ entry.accountType }} + {{ accountTypeLabel(entry.accountType) }} ₹{{ entry.amount | number: '1.2-2' }} @@ -233,7 +233,7 @@ {{ entry.accountNo }} {{ entry.accountName }} - {{ entry.accountType }} + {{ accountTypeLabel(entry.accountType) }} ₹{{ entry.amount | number: '1.2-2' }} diff --git a/src/app/clerk/disbursement-review/disbursement-review.component.ts b/src/app/clerk/disbursement-review/disbursement-review.component.ts index b37ba7f94..82d37434e 100644 --- a/src/app/clerk/disbursement-review/disbursement-review.component.ts +++ b/src/app/clerk/disbursement-review/disbursement-review.component.ts @@ -143,6 +143,24 @@ export class DisbursementReviewComponent implements OnInit { return this.drEntries.reduce((sum, e) => sum + (e.amount ?? 0), 0); } + /** + * Human-readable account type for the badge. The stored value is the backend's own code + * (SAVINGS / CURRENT / FIXED_DEPOSIT / RECURRING_DEPOSIT / SHARE / LOAN / GL), so this only + * changes presentation -- a checker should read "Fixed Deposit", not "FIXED_DEPOSIT". + */ + accountTypeLabel(accountType: string): string { + const labels: Record = { + SAVINGS: 'Savings', + CURRENT: 'Current', + FIXED_DEPOSIT: 'Fixed Deposit', + RECURRING_DEPOSIT: 'Recurring Deposit', + SHARE: 'Share', + LOAN: 'Loan', + GL: 'GL' + }; + return labels[(accountType ?? '').toUpperCase()] ?? accountType; + } + statusClass(status: string): string { const map: Record = { PENDING: 'dri-status--pending', diff --git a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html index a5dc03299..6171928f5 100644 --- a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html +++ b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.html @@ -170,7 +170,9 @@ {{ ce.accountNo || '—' }} {{ ce.accountName || '—' }} - {{ ce.accountType || '—' }} + {{ + ce.accountType ? accountTypeLabel(ce.accountType) : '—' + }} {{ ce.amount | number: '1.2-2' }} diff --git a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts index c75d6cac9..ed333c1e3 100644 --- a/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts +++ b/src/app/clerk/my-disbursement-requests/my-disbursement-requests.component.ts @@ -187,6 +187,23 @@ export class MyDisbursementRequestsComponent implements OnInit { return this.getCreditEntries(req).length; } + /** + * Human-readable account type for the badge. The stored value is the backend's own code + * (SAVINGS / CURRENT / FIXED_DEPOSIT / RECURRING_DEPOSIT / SHARE / LOAN / GL); presentation only. + */ + accountTypeLabel(accountType: string): string { + const labels: Record = { + SAVINGS: 'Savings', + CURRENT: 'Current', + FIXED_DEPOSIT: 'Fixed Deposit', + RECURRING_DEPOSIT: 'Recurring Deposit', + SHARE: 'Share', + LOAN: 'Loan', + GL: 'GL' + }; + return labels[(accountType ?? '').toUpperCase()] ?? accountType; + } + statusClass(status: string): string { const map: Record = { PENDING: 'mdr-status--pending', diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html index d892f9368..dc8109323 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.html +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.html @@ -394,7 +394,7 @@ } - + @if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index 910651bb5..7f1cab462 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -56,6 +56,12 @@ import { ContactOtpBlockComponent, ContactOtpState } from 'app/clients/edit-client/contact-otp-block/contact-otp-block.component'; +import { + MINOR_AGE_THRESHOLD, + SENIOR_CITIZEN_AGE_THRESHOLD, + isMinorAge, + isSeniorCitizenAge +} from 'app/clients/shared/client-age.util'; /** * Create Client Component @@ -299,10 +305,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges ]; /** Calculated age detail string (Y/M/D) */ calculatedAgeDetail: string | null = null; - /** Whether client is a minor (age < 18) */ + /** Whether client is a minor — threshold from the shared age util, derived from Date of Birth */ isMinor = false; - /** Whether client is a senior citizen (age >= 60) — auto-detected from Date of Birth */ + /** Whether client is a senior citizen — threshold from the shared age util, derived from DOB */ isSeniorCitizen = false; + /** Exposed for template hints so the displayed rule can never drift from the applied rule. */ + readonly minorAgeThreshold = MINOR_AGE_THRESHOLD; + readonly seniorCitizenAgeThreshold = SENIOR_CITIZEN_AGE_THRESHOLD; /** Age breakdown for DB storage */ ageBreakdown = { years: 0, months: 0, days: 0 }; @@ -1237,8 +1246,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges this.calculatedAgeDetail = `${years} yr${years !== 1 ? 's' : ''} ${months} mo${months !== 1 ? 's' : ''} ${days} day${days !== 1 ? 's' : ''}`; this.ageBreakdown = { years, months, days }; const wasMinor = this.isMinor; - this.isMinor = years < 18; - this.isSeniorCitizen = years >= 60; + this.isMinor = isMinorAge(years); + this.isSeniorCitizen = isSeniorCitizenAge(years); // Add/remove guardian form group based on minor status if (this.isMinor && !wasMinor) { this.createClientForm.addControl( diff --git a/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html b/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html index 564f7f25b..f290c2be2 100644 --- a/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html +++ b/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html @@ -195,7 +195,7 @@ client.isMinor ? 'preview-badge preview-badge--danger' : 'preview-badge preview-badge--verified' " > - {{ client.isMinor ? 'Yes (Age < 18)' : 'No' }} + {{ client.isMinor ? 'Yes' : 'No' }}
diff --git a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html index 287371407..02ce124f1 100644 --- a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html +++ b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html @@ -105,7 +105,7 @@ [class.pd-verify-badge--verified]="!clientViewData.isMinor" [class.pd-verify-badge--pending]="clientViewData.isMinor" > - {{ clientViewData.isMinor ? 'Yes (Age < 18)' : 'No' }} + {{ clientViewData.isMinor ? 'Yes' : 'No' }} diff --git a/src/app/clients/shared/client-age.util.ts b/src/app/clients/shared/client-age.util.ts new file mode 100644 index 000000000..9f2931f06 --- /dev/null +++ b/src/app/clients/shared/client-age.util.ts @@ -0,0 +1,97 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/** + * Single source of truth for every age-derived client classification. + * + * These rules were previously re-implemented as bare literals in ten separate components (the client + * stepper plus each product's account-details step), which is how a client could end up classified one + * way on the client screen and another way on the account screen -- and, in the FD case, how a + * 24-year-old ended up with a senior-citizen interest rate. Anything that needs to know whether a + * client is a minor or a senior citizen must derive it from here rather than comparing ages inline. + */ + +/** Below this age a client is a minor: guardian mapping required, restricted KYC/OVD document set. */ +export const MINOR_AGE_THRESHOLD = 10; + +/** At or above this age a client qualifies for senior-citizen benefits (higher deposit rates). */ +export const SENIOR_CITIZEN_AGE_THRESHOLD = 60; + +/** + * Age at which a minor account must be converted to a regular one. Deliberately independent of + * {@link MINOR_AGE_THRESHOLD}: legal majority in India is 18 regardless of the age at which this CBS + * chooses to stop applying minor-specific onboarding rules. + */ +export const AGE_OF_MAJORITY = 18; + +/** Parses the backend's date-of-birth representation -- either a `[yyyy, m, d]` array or a date string. */ +export function parseClientDob(dob: any): Date | null { + if (!dob) return null; + if (Array.isArray(dob) && dob.length >= 3) { + return new Date(dob[0], dob[1] - 1, dob[2]); + } + const parsed = new Date(dob); + return isNaN(parsed.getTime()) ? null : parsed; +} + +/** + * Completed years between `dob` and `asOf`. Pass the tenant's business date as `asOf` wherever it is + * available, so a back-dated or forward-dated business date classifies the client the same way the + * rest of the system does. + */ +export function calculateAge(dob: Date, asOf?: Date | null): number { + const today = asOf ?? new Date(); + let age = today.getFullYear() - dob.getFullYear(); + const monthDiff = today.getMonth() - dob.getMonth(); + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) { + age--; + } + return age; +} + +/** True when the age qualifies as a minor. `null` (unknown DOB) is never a minor. */ +export function isMinorAge(age: number | null | undefined): boolean { + return age !== null && age !== undefined && age < MINOR_AGE_THRESHOLD; +} + +/** True when the age qualifies for senior-citizen benefits. `null` (unknown DOB) never qualifies. */ +export function isSeniorCitizenAge(age: number | null | undefined): boolean { + return age !== null && age !== undefined && age >= SENIOR_CITIZEN_AGE_THRESHOLD; +} + +/** + * Whether a client record represents a natural person. Only a natural person can be a minor or a + * senior citizen -- an entity/company has no age, so age-derived benefits must never apply to one + * even if some incorporation date happens to be stored against it. + */ +export function isIndividualClient(client: any): boolean { + const legalForm = client?.legalForm; + if (!legalForm) return true; // absent legal form: treat as individual, matching the backend default + const code = String(legalForm.code ?? '').toLowerCase(); + if (code) return code.includes('individual') && !code.includes('nonindividual'); + const value = String(legalForm.value ?? legalForm.name ?? '').toLowerCase(); + return value === 'individual'; +} + +/** + * The senior-citizen decision for a whole client record: individual, known DOB, and old enough. + * Returns the derived age alongside the verdict so callers can show "why" without recomputing it. + */ +export function deriveSeniorCitizenStatus( + client: any, + asOf?: Date | null +): { isSeniorCitizen: boolean; age: number | null; isIndividual: boolean } { + const isIndividual = isIndividualClient(client); + const dob = parseClientDob(client?.dateOfBirth); + const age = dob ? calculateAge(dob, asOf) : null; + return { + isSeniorCitizen: isIndividual && isSeniorCitizenAge(age), + age, + isIndividual + }; +} diff --git a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html index 1cf4ac389..69528ae98 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html +++ b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html @@ -529,7 +529,7 @@ Age - Must be below 18 years + Must be below {{ minorAgeThreshold }} years diff --git a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts index 8c5592d1a..770117e66 100644 --- a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts +++ b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts @@ -15,6 +15,12 @@ import { Dates } from 'app/core/utils/dates'; import { CurrentAccountsService } from 'app/current-accounts/current-accounts.service'; import { SettingsService } from 'app/settings/settings.service'; import { ClientsService } from 'app/clients/clients.service'; +import { + AGE_OF_MAJORITY, + MINOR_AGE_THRESHOLD, + isMinorAge, + isSeniorCitizenAge +} from 'app/clients/shared/client-age.util'; import { MatDialog } from '@angular/material/dialog'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; import { MatIcon } from '@angular/material/icon'; @@ -99,6 +105,8 @@ export class CurrentAccountDetailsStepComponent implements OnInit { // ── Client info (auto-fetched) ───────────────────────────────────────── clientDob: Date | null = null; clientAge: number | null = null; + /** Exposed for tooltips so the displayed rule can never drift from the applied rule. */ + readonly minorAgeThreshold = MINOR_AGE_THRESHOLD; clientGender: string | null = null; primaryClientName = ''; primaryClientCif = ''; @@ -444,10 +452,10 @@ export class CurrentAccountDetailsStepComponent implements OnInit { return; } if (this.clientAge === null) return; - if (this.clientAge < 18) { + if (isMinorAge(this.clientAge)) { this.selectedAccountStatus = 'MINOR'; this.minorDob = this.clientDob; - } else if (this.clientAge >= 60) { + } else if (isSeniorCitizenAge(this.clientAge)) { this.selectedAccountStatus = 'SENIOR_CITIZEN'; } else if (this.clientGender === 'female') { this.selectedAccountStatus = 'INDIVIDUAL_FEMALE'; @@ -458,15 +466,15 @@ export class CurrentAccountDetailsStepComponent implements OnInit { isTileDisabled(value: string): boolean { if (this.readOnly) return true; - if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18; - if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60; + if (value === 'MINOR') return !isMinorAge(this.clientAge); + if (value === 'SENIOR_CITIZEN') return !isSeniorCitizenAge(this.clientAge); return false; } getTileTooltip(value: string): string { - if (value === 'MINOR' && this.clientAge !== null && this.clientAge >= 18) - return `Age ${this.clientAge} yrs — Minor only allowed below 18 years`; - if (value === 'SENIOR_CITIZEN' && this.clientAge !== null && this.clientAge < 60) + if (value === 'MINOR' && !isMinorAge(this.clientAge)) + return `Age ${this.clientAge} yrs — Minor only allowed below years`; + if (value === 'SENIOR_CITIZEN' && !isSeniorCitizenAge(this.clientAge)) return `Age ${this.clientAge} yrs — Senior Citizen only allowed at 60+ years`; if (value === 'MINOR' && this.clientAge === null) return 'DOB not available — cannot verify age'; if (value === 'SENIOR_CITIZEN' && this.clientAge === null) return 'DOB not available — cannot verify age'; @@ -609,7 +617,7 @@ export class CurrentAccountDetailsStepComponent implements OnInit { const dob = this.minorDob ?? this.clientDob; if (!dob) return ''; const d = new Date(dob); - d.setFullYear(d.getFullYear() + 18); + d.setFullYear(d.getFullYear() + AGE_OF_MAJORITY); return this.dateUtils.formatDate(d, 'M/d/yyyy') || ''; } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.html index b5f865cbd..07482706b 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.html @@ -618,7 +618,7 @@ Age - Must be below 18 years + Must be below {{ minorAgeThreshold }} years diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts index 7aa178b82..146e5b877 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts @@ -26,6 +26,12 @@ import { MatDialog } from '@angular/material/dialog'; /** Custom Services */ import { FixedDepositsService } from '../../fixed-deposits.service'; import { ClientsService } from 'app/clients/clients.service'; +import { + AGE_OF_MAJORITY, + MINOR_AGE_THRESHOLD, + isMinorAge, + isSeniorCitizenAge +} from 'app/clients/shared/client-age.util'; import { MatTooltip } from '@angular/material/tooltip'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; @@ -107,8 +113,16 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange selectedCustomerSegment: string | null = null; // ── Client info (auto-fetched) ───────────────────────────────────────── + /** + * The client's legal form ("Individual" / "Non-Individual") as captured at client creation. Customer + * Segment is derived from this rather than picked by hand, so an account can never disagree with the + * CIF about what kind of customer it belongs to. + */ + clientLegalForm: string | null = null; clientDob: Date | null = null; clientAge: number | null = null; + /** Exposed for tooltips so the displayed rule can never drift from the applied rule. */ + readonly minorAgeThreshold = MINOR_AGE_THRESHOLD; clientGender: string | null = null; primaryClientName = ''; primaryClientCif = ''; @@ -279,7 +293,9 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange currencyCode: a.currency?.code ?? '' })); this.dalLoading = false; - // Pre-select linked account in edit mode + // Pre-select linked account in edit mode. The GET response nests the Fineract association as + // linkedAccount.id, and separately carries the CBS column as linkedAccountId -- the latter is the + // fallback for accounts that have the CBS field set but no association row. const linkedId = this.fixedDepositsAccountTemplate?.linkedAccount?.id ?? this.fixedDepositsAccountTemplate?.linkedAccountId; if (linkedId) { @@ -359,6 +375,8 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange this.autoDetectAccountStatus(); } } + this.clientLegalForm = client?.legalForm?.value ?? client?.legalForm?.name ?? null; + this.applyInferredCustomerSegment(); this.cdr.detectChanges(); }); } @@ -386,8 +404,8 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange } isTileDisabled(value: string): boolean { - if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18; - if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60; + if (value === 'MINOR') return !isMinorAge(this.clientAge); + if (value === 'SENIOR_CITIZEN') return !isSeniorCitizenAge(this.clientAge); return false; } @@ -412,10 +430,10 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange } getTileTooltip(value: string): string { - if (value === 'MINOR' && this.clientAge !== null && this.clientAge >= 18) { - return `Age ${this.clientAge} yrs — Minor only allowed below 18 years`; + if (value === 'MINOR' && !isMinorAge(this.clientAge)) { + return `Age ${this.clientAge} yrs — Minor only allowed below years`; } - if (value === 'SENIOR_CITIZEN' && this.clientAge !== null && this.clientAge < 60) { + if (value === 'SENIOR_CITIZEN' && !isSeniorCitizenAge(this.clientAge)) { return `Age ${this.clientAge} yrs — Senior Citizen only allowed at 60+ years`; } if (value === 'MINOR' && this.clientAge === null) return 'DOB not available — cannot verify age'; @@ -428,10 +446,10 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange return; } if (this.clientAge === null) return; - if (this.clientAge < 18) { + if (isMinorAge(this.clientAge)) { this.selectedAccountStatus = 'MINOR'; this.minorDob = this.clientDob; - } else if (this.clientAge >= 60) { + } else if (isSeniorCitizenAge(this.clientAge)) { this.selectedAccountStatus = 'SENIOR_CITIZEN'; } else if (this.clientGender === 'female') { this.selectedAccountStatus = 'INDIVIDUAL_FEMALE'; @@ -481,6 +499,9 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment); this.selectedAccountType = response.accountType ?? null; this.selectedCustomerSegment = response.customerSegment ?? null; + // The product default above is only a fallback -- the client's own legal form wins. Re-applied + // here as well as in the client callback because the two responses can arrive in either order. + this.applyInferredCustomerSegment(); this.fixedDepositsAccountProductTemplate.emit(response); this.fieldOfficerData = response.fieldOfficerOptions; this.isProductSelected = true; @@ -591,6 +612,24 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange return fallback ? [fallback] : []; } + /** + * Selects the Customer Segment that matches the client's legal form. The segment code values + * ("Individual" / "Non-Individual") are seeded to be byte-identical to the legal-form labels, so this + * is an exact, case-insensitive comparison on purpose -- a substring match would resolve + * "Non-Individual" to the "Individual" option, since one contains the other. Leaves the product + * default untouched when the client's form is unknown or has no matching option. + */ + private applyInferredCustomerSegment(): void { + if (!this.clientLegalForm || !this.customerSegmentOptions.length) { + return; + } + const target = this.clientLegalForm.trim().toLowerCase(); + const match = this.customerSegmentOptions.find((opt) => String(opt).trim().toLowerCase() === target); + if (match) { + this.selectedCustomerSegment = match; + } + } + formatTemplateDate(value: any): string { if (!value) return ''; let dateValue: Date | null = null; @@ -733,7 +772,7 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange const dob = this.minorDob ?? this.clientDob; if (!dob) return ''; const d = new Date(dob); - d.setFullYear(d.getFullYear() + 18); + d.setFullYear(d.getFullYear() + AGE_OF_MAJORITY); return this.dateUtils.formatDate(d, 'M/d/yyyy') || ''; } @@ -797,6 +836,15 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange } if (this.selectedLinkedAccount) { + // The same picked account has to be sent under BOTH names -- they are two different backend + // parameters feeding two different things, not a duplicate: + // linkAccountId (DepositsApiConstants.linkedAccountParamName) -> creates the Fineract + // AccountAssociations row that the monthly interest-transfer job reads, and that + // FD Special's linked-account requirement validates against. + // linkedAccountId (SavingsApiConstants.linkedAccountIdParamName) -> populates the CBS-specific + // m_savings_account.linked_account_id column via applyCbsFields. + // Only linkedAccountId was being sent before, so the association was never created. + value.linkAccountId = this.selectedLinkedAccount.id; value.linkedAccountId = this.selectedLinkedAccount.id; value.linkedAccountType = this.selectedLinkedAccount.accountType; value.linkedAccountNo = this.selectedLinkedAccount.accountNo; diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html index 51bf1b47f..8eab0d0bc 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html @@ -170,12 +170,12 @@
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts index a54cf9e1d..beff3f51d 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts @@ -6,7 +6,17 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, Input, OnChanges, SimpleChanges, Output, EventEmitter, ViewChild, inject } from '@angular/core'; +import { + Component, + Input, + OnChanges, + SimpleChanges, + Output, + EventEmitter, + ViewChild, + ChangeDetectorRef, + inject +} from '@angular/core'; import { MatTableDataSource, MatTable, @@ -26,6 +36,9 @@ import { FormsModule } from '@angular/forms'; import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FixedDepositsService } from '../../fixed-deposits.service'; +import { ClientsService } from 'app/clients/clients.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util'; export type FdPeriodUnit = 'Days' | 'Months' | 'Years'; @@ -71,6 +84,9 @@ export interface SlabMatchResult { }) export class FixedDepositAccountInterestRateChartStepComponent implements OnChanges { private fixedDepositsService = inject(FixedDepositsService); + private clientsService = inject(ClientsService); + private settingsService = inject(SettingsService); + private cdr = inject(ChangeDetectorRef); @Input() fixedDepositsAccountTemplate: any; @Input() fixedDepositsAccountProductTemplate: any; @@ -110,7 +126,19 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan 'actions' ]; + /** + * Derived from the client's own date of birth -- never staff-editable. Setting this by hand is what + * let a 24-year-old's FD get priced at the senior-citizen rate, so the checkbox is display-only and + * this value comes solely from {@link deriveSeniorCitizenStatus}. + */ isSeniorCitizen = false; + readonly seniorCitizenAgeThreshold = SENIOR_CITIZEN_AGE_THRESHOLD; + /** Client age in completed years, or null when the DOB is unknown / the client is an entity. */ + clientAge: number | null = null; + /** False for a company/entity client, which has no age and so can never qualify. */ + clientIsIndividual = true; + /** True once the client lookup has come back, so the UI can explain the locked checkbox honestly. */ + clientAgeResolved = false; incentivesDisplayedColumns: string[] = [ 'entityType', 'attributeName', @@ -171,6 +199,9 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan } ngOnChanges(changes: SimpleChanges) { + if (changes['fixedDepositsAccountTemplate']) { + this.resolveSeniorCitizenFromClient(); + } // Only reset/re-apply when TEMPLATE or cbsBaseRates changes, NOT when initialTenure/submittedOnDate changes alone const templateChanged = changes['fixedDepositsAccountProductTemplate'] || changes['cbsBaseRates']; if (templateChanged && this.fixedDepositsAccountProductTemplate) { @@ -206,10 +237,10 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan if (this.initialTenure.depositAmount) { this.principalAmount = this.initialTenure.depositAmount; } - // Pre-check senior citizen if it was set during create - if (this.initialTenure.isSeniorCitizen) { - this.isSeniorCitizen = true; - } + // Deliberately NOT restoring initialTenure.isSeniorCitizen: the flag is derived from the client's + // date of birth in resolveSeniorCitizenFromClient(). Re-applying a stored value here would carry a + // previously mis-set flag forward on every edit, which is exactly how a 24-year-old's FD kept its + // senior-citizen rate. // Auto-calculate when tenure is pre-filled (edit mode) setTimeout(() => this.calculateRate(), 0); } @@ -435,6 +466,45 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan this.validationWarning = null; } + /** + * Derives the senior-citizen flag from the client's date of birth. Runs whenever the account template + * arrives (create and edit alike), so the flag always reflects the customer on record rather than + * whatever was ticked -- or previously saved -- by hand. + */ + private resolveSeniorCitizenFromClient(): void { + const clientId = this.fixedDepositsAccountTemplate?.clientId ?? this.fixedDepositsAccountTemplate?.id; + if (!clientId) { + return; + } + this.clientsService.getClientData(String(clientId)).subscribe((client: any) => { + const status = deriveSeniorCitizenStatus(client, this.settingsService.businessDate); + this.clientAge = status.age; + this.clientIsIndividual = status.isIndividual; + this.isSeniorCitizen = status.isSeniorCitizen; + this.clientAgeResolved = true; + // Re-price against the now-known status if a tenure was already entered. + this.onSeniorCitizenChange(); + this.cdr.detectChanges(); + }); + } + + /** Explains the locked checkbox to staff, so a missing benefit never looks like a system fault. */ + get seniorCitizenHint(): string { + if (!this.clientAgeResolved) { + return 'Determined from the client’s date of birth'; + } + if (!this.clientIsIndividual) { + return 'Not applicable to a non-individual client'; + } + if (this.clientAge === null) { + return 'Client has no date of birth on record'; + } + if (this.isSeniorCitizen) { + return `Client is ${this.clientAge} years old — higher rate applied`; + } + return `Client is ${this.clientAge} — applies from age ${this.seniorCitizenAgeThreshold}`; + } + onSeniorCitizenChange() { if (this.tenureValue && this.tenureValue > 0) { this.calculateRate(); diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.html new file mode 100644 index 000000000..de34802f2 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.html @@ -0,0 +1,71 @@ + + +
+

+ + + {{ 'labels.heading.Monthly Interest Payout Schedule' | translate }} + {{ paidCount }} of {{ schedule.length }} periods paid so far + +

+ +
+

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

+ +
+ + + + + + + + + + + @for (period of schedule; track $index) { + + + + + + + } + + + + + + + + +
{{ 'labels.inputs.From Date' | translate }}{{ 'labels.inputs.To Date' | translate }}{{ 'labels.inputs.Interest Amount' | translate }}{{ 'labels.inputs.Status' | translate }}
{{ period.fromDate | dateFormat }}{{ period.toDate | dateFormat }} + {{ period.interestAmount | currency: currencyCode : 'symbol-narrow' : '1.2-2' }} + + @if (period.paid) { + + Paid + + } @else { + Pending + } +
{{ 'labels.inputs.Total Interest Payable' | translate }} + {{ totalInterest | currency: currencyCode : 'symbol-narrow' : '1.2-2' }} +
+
+
+ +
+ +
+
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.scss new file mode 100644 index 000000000..f7784f675 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.scss @@ -0,0 +1,142 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +$navy: #0d1b2a; +$slate: #475569; +$muted: #64748b; +$border: #e2e8f0; +$soft: #f8fafc; +$green: #047857; +$green-bg: #ecfdf5; +$amber: #92400e; +$amber-bg: #fffbeb; + +.isd-dialog { + min-width: 560px; + max-width: 720px; +} + +.isd-header { + display: flex; + align-items: center; + gap: 10px; + margin: 0; + + &__icon { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%); + color: #fff; + font-size: 13px; + flex-shrink: 0; + } + + &__text { + display: flex; + flex-direction: column; + } + + &__title { + font-size: 15px; + font-weight: 700; + color: $navy; + } + + &__sub { + font-size: 12px; + color: $muted; + } +} + +.isd-content { + max-height: 60vh; +} + +.isd-note { + margin: 4px 0 12px; + font-size: 12px; + color: $muted; + font-style: italic; +} + +.isd-scroll { + overflow-x: auto; +} + +.isd-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th, + td { + padding: 8px 12px; + text-align: left; + border-bottom: 1px dashed #eef2f7; + white-space: nowrap; + } + + th { + color: $slate; + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.03em; + background: $soft; + position: sticky; + top: 0; + } + + tbody tr:hover { + background: $soft; + } + + &__row--paid { + background: rgb(4 120 87 / 4%); + } + + tfoot td { + font-weight: 700; + color: $navy; + border-bottom: none; + border-top: 2px solid $border; + } + + &__amount-col { + text-align: right; + font-variant-numeric: tabular-nums; + } + + &__status-col { + text-align: center; + } +} + +.isd-badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + + &--paid { + background: $green-bg; + color: $green; + } + + &--pending { + background: $amber-bg; + color: $amber; + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.ts new file mode 100644 index 000000000..612cc78f3 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.ts @@ -0,0 +1,68 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, inject } from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { MatButton } from '@angular/material/button'; +import { CurrencyPipe } from '@angular/common'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { DateFormatPipe } from '../../../../../pipes/date-format.pipe'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * FD Special's full month-wise interest schedule, in a popup rather than always inline on General -- + * 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 engine projects it to be. + */ +@Component({ + selector: 'mifosx-interest-schedule-dialog', + templateUrl: './interest-schedule-dialog.component.html', + styleUrls: ['./interest-schedule-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose, + MatButton, + FaIconComponent, + CurrencyPipe, + DateFormatPipe + ] +}) +export class InterestScheduleDialogComponent { + dialogRef = inject>(MatDialogRef); + data: { + schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }>; + currencyCode: string; + } = inject(MAT_DIALOG_DATA); + + get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }> { + return this.data.schedule ?? []; + } + + get currencyCode(): string { + return this.data.currencyCode; + } + + get totalInterest(): number { + return this.schedule.reduce((sum, row) => sum + (row.interestAmount ?? 0), 0); + } + + get paidCount(): number { + return this.schedule.filter((row) => row.paid).length; + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html index e353a802e..29491a554 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.html @@ -167,11 +167,36 @@
- {{ 'labels.inputs.Maturity Amount' | translate }} - + + {{ + (isSpecialFixedDeposit + ? 'labels.inputs.Principal Returned at Maturity' + : 'labels.inputs.Maturity Amount' + ) | translate + }} + + {{ fixedDepositsAccountData.maturityAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
+ @if (isSpecialFixedDeposit && nextInterestPayout !== null) { +
+ {{ 'labels.inputs.Next Interest Payout' | translate }} + + {{ nextInterestPayout | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + +
+ } + @if (isSpecialFixedDeposit && totalValueAtMaturity !== null) { +
+ {{ + 'labels.inputs.Total Value at Maturity (Principal + Interest)' | translate + }} + + {{ totalValueAtMaturity | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + +
+ } @if (fixedDepositsAccountData.summary.totalDeposits) {
{{ 'labels.inputs.Total Deposits' | translate }} @@ -221,13 +246,63 @@
- {{ 'labels.inputs.Maturity Amount' | translate }} + + {{ + (isSpecialFixedDeposit + ? 'labels.inputs.Principal Returned at Maturity' + : 'labels.inputs.Maturity Amount' + ) | translate + }} + {{ fixedDepositsAccountData.maturityAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
+ @if (isSpecialFixedDeposit && nextInterestPayout !== null) { +
+ {{ 'labels.inputs.Next Interest Payout' | translate }} + + {{ nextInterestPayout | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + +
+ } + @if (isSpecialFixedDeposit && totalValueAtMaturity !== null) { +
+ {{ + 'labels.inputs.Total Value at Maturity (Principal + Interest)' | translate + }} + + {{ totalValueAtMaturity | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + +
+ } } + +@if (isSpecialFixedDeposit && projectedInterestSchedule.length) { +
+
+ +

{{ 'labels.heading.Monthly Interest Payout Schedule' | translate }}

+
+
+
+ {{ 'labels.inputs.Total Interest Payable' | translate }} + + {{ totalProjectedInterest | currency: currency.code : 'symbol-narrow' : '1.2-2' }} + +
+
+ {{ 'labels.inputs.Periods Paid So Far' | translate }} + {{ paidPeriodsCount }} / {{ projectedInterestSchedule.length }} +
+ +
+
+} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.scss index f1b16485e..a205c1c24 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.scss +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.scss @@ -118,3 +118,13 @@ $soft: #f8fafc; font-weight: 500; font-style: italic; } + +/* ─── Monthly Interest Payout Schedule summary (FD Special only) ─────── */ +.gt-schedule { + margin-top: 16px; + + &__view-btn { + width: 100%; + margin-top: 8px; + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts index 7da833264..083888a2f 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts @@ -8,12 +8,14 @@ import { Component, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; import { Currency } from 'app/shared/models/general.model'; import { CurrencyPipe } from '@angular/common'; import { ExternalIdentifierComponent } from '../../../../shared/external-identifier/external-identifier.component'; import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { InterestScheduleDialogComponent } from '../custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component'; @Component({ selector: 'mifosx-general-tab', @@ -30,6 +32,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; export class GeneralTabComponent { private route = inject(ActivatedRoute); private router = inject(Router); + private dialog = inject(MatDialog); fixedDepositsAccountData: any; entityType: string; @@ -69,4 +72,58 @@ export class GeneralTabComponent { const id = this.fixedDepositsAccountData?.reinvestedIntoAccountId; return id ? this.siblingAccountLink(id) : null; } + + /** FD Special never compounds -- every month's interest is transferred out to the linked savings + * account instead of growing this account's own balance, so its "Maturity Amount" is just the + * principal, not principal-plus-interest (see FixedDepositAccount#updateMaturityDateAndAmount). + */ + get isSpecialFixedDeposit(): boolean { + return this.fixedDepositsAccountData?.fdType === 'SPECIAL'; + } + + /** Real, engine-computed period-by-period interest schedule for FD Special (see FixedDepositAccountsApiResource#retrieveOne). */ + get projectedInterestSchedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }> { + return this.fixedDepositsAccountData?.projectedInterestSchedule ?? []; + } + + get paidPeriodsCount(): number { + return this.projectedInterestSchedule.filter((period) => period.paid).length; + } + + /** Opens the full month-wise schedule in a popup rather than always inline -- a 24+ month term makes + * for a long table that would otherwise push every other card on this tab down the page. */ + openInterestSchedule(): void { + this.dialog.open(InterestScheduleDialogComponent, { + width: '680px', + data: { + schedule: this.projectedInterestSchedule, + currencyCode: this.currency?.code + } + }); + } + + /** Backend-computed sum of the schedule (see FixedDepositAccountsApiResource#retrieveOne) -- the single + * source of truth, not a client-side re-sum of the schedule array. */ + get totalProjectedInterest(): number | null { + return this.fixedDepositsAccountData?.totalProjectedInterest ?? null; + } + + /** + * The first NOT-YET-PAID period's real amount -- not just index 0, which would keep showing an + * already-posted-and-transferred period's amount as "next" forever once the account has any real + * history. Deliberately has NO rate/12 fallback: that would quietly present an inaccurate money figure + * as if it were the real one. If the schedule is missing, or every period is already paid, the row is + * hidden instead. + */ + get nextInterestPayout(): number | null { + return this.projectedInterestSchedule.find((period) => !period.paid)?.interestAmount ?? null; + } + + /** What the customer ends up with overall across the FD's whole life: the principal returned by the FD + * itself at maturity, PLUS every month's interest already paid out to the linked savings account along + * the way. Backend-computed (see FixedDepositAccountsApiResource#retrieveOne), same as maturityAmount + * itself, rather than derived here from the schedule array. */ + get totalValueAtMaturity(): number | null { + return this.fixedDepositsAccountData?.totalValueAtMaturity ?? null; + } } diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html index 702defd3a..5fd042184 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html @@ -613,7 +613,7 @@ Age - Must be below 18 years + Must be below {{ minorAgeThreshold }} years diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts index f7bb9d68f..18035cd4b 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts @@ -26,6 +26,12 @@ import { MatDialog } from '@angular/material/dialog'; /** Custom Services */ import { RecurringDepositsService } from '../../recurring-deposits.service'; import { ClientsService } from 'app/clients/clients.service'; +import { + AGE_OF_MAJORITY, + MINOR_AGE_THRESHOLD, + isMinorAge, + isSeniorCitizenAge +} from 'app/clients/shared/client-age.util'; import { MatTooltip } from '@angular/material/tooltip'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; @@ -97,8 +103,16 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC selectedCustomerSegment: string | null = null; // ── Client info (auto-fetched) ───────────────────────────────────────── + /** + * The client's legal form ("Individual" / "Non-Individual") as captured at client creation. Customer + * Segment is derived from this rather than picked by hand, so an account can never disagree with the + * CIF about what kind of customer it belongs to. + */ + clientLegalForm: string | null = null; clientDob: Date | null = null; clientAge: number | null = null; + /** Exposed for tooltips so the displayed rule can never drift from the applied rule. */ + readonly minorAgeThreshold = MINOR_AGE_THRESHOLD; clientGender: string | null = null; primaryClientName = ''; primaryClientCif = ''; @@ -337,6 +351,8 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC this.autoDetectAccountStatus(); } } + this.clientLegalForm = client?.legalForm?.value ?? client?.legalForm?.name ?? null; + this.applyInferredCustomerSegment(); this.cdr.detectChanges(); }); } @@ -364,16 +380,16 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC } isTileDisabled(value: string): boolean { - if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18; - if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60; + if (value === 'MINOR') return !isMinorAge(this.clientAge); + if (value === 'SENIOR_CITIZEN') return !isSeniorCitizenAge(this.clientAge); return false; } getTileTooltip(value: string): string { - if (value === 'MINOR' && this.clientAge !== null && this.clientAge >= 18) { - return `Age ${this.clientAge} yrs — Minor only allowed below 18 years`; + if (value === 'MINOR' && !isMinorAge(this.clientAge)) { + return `Age ${this.clientAge} yrs — Minor only allowed below years`; } - if (value === 'SENIOR_CITIZEN' && this.clientAge !== null && this.clientAge < 60) { + if (value === 'SENIOR_CITIZEN' && !isSeniorCitizenAge(this.clientAge)) { return `Age ${this.clientAge} yrs — Senior Citizen only allowed at 60+ years`; } if (value === 'MINOR' && this.clientAge === null) return 'DOB not available — cannot verify age'; @@ -386,10 +402,10 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC return; } if (this.clientAge === null) return; - if (this.clientAge < 18) { + if (isMinorAge(this.clientAge)) { this.selectedAccountStatus = 'MINOR'; this.minorDob = this.clientDob; - } else if (this.clientAge >= 60) { + } else if (isSeniorCitizenAge(this.clientAge)) { this.selectedAccountStatus = 'SENIOR_CITIZEN'; } else if (this.clientGender === 'female') { this.selectedAccountStatus = 'INDIVIDUAL_FEMALE'; @@ -441,6 +457,9 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment); this.selectedAccountType = response.accountType ?? null; this.selectedCustomerSegment = response.customerSegment ?? null; + // The product default above is only a fallback -- the client's own legal form wins. Re-applied + // here as well as in the client callback because the two responses can arrive in either order. + this.applyInferredCustomerSegment(); this.recurringDepositsAccountProductTemplate.emit(response); this.fieldOfficerData = response.fieldOfficerOptions; this.isProductSelected = true; @@ -557,6 +576,24 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC return fallback ? [fallback] : []; } + /** + * Selects the Customer Segment that matches the client's legal form. The segment code values + * ("Individual" / "Non-Individual") are seeded to be byte-identical to the legal-form labels, so this + * is an exact, case-insensitive comparison on purpose -- a substring match would resolve + * "Non-Individual" to the "Individual" option, since one contains the other. Leaves the product + * default untouched when the client's form is unknown or has no matching option. + */ + private applyInferredCustomerSegment(): void { + if (!this.clientLegalForm || !this.customerSegmentOptions.length) { + return; + } + const target = this.clientLegalForm.trim().toLowerCase(); + const match = this.customerSegmentOptions.find((opt) => String(opt).trim().toLowerCase() === target); + if (match) { + this.selectedCustomerSegment = match; + } + } + formatTemplateDate(value: any): string { if (!value) return ''; let dateValue: Date | null = null; @@ -695,7 +732,7 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC const dob = this.minorDob ?? this.clientDob; if (!dob) return ''; const d = new Date(dob); - d.setFullYear(d.getFullYear() + 18); + d.setFullYear(d.getFullYear() + AGE_OF_MAJORITY); return this.dateUtils.formatDate(d, 'M/d/yyyy') || ''; } diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html index 7fa51c3e8..237abd8dc 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html @@ -110,12 +110,12 @@
diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts index 46cf19aa6..554d9d1ee 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts @@ -6,8 +6,21 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, Input, OnChanges, SimpleChanges, ViewChild, Output, EventEmitter, inject } from '@angular/core'; +import { + Component, + Input, + OnChanges, + SimpleChanges, + ViewChild, + Output, + EventEmitter, + ChangeDetectorRef, + inject +} from '@angular/core'; import { RecurringDepositsService } from '../../recurring-deposits.service'; +import { ClientsService } from 'app/clients/clients.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util'; import { MatTable, MatColumnDef, @@ -142,7 +155,18 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O 'actions' ]; + /** + * Derived from the client's own date of birth -- never staff-editable, so a client who is not + * actually 60+ can never be priced at the senior-citizen rate by a mis-ticked box. + */ isSeniorCitizen = false; + readonly seniorCitizenAgeThreshold = SENIOR_CITIZEN_AGE_THRESHOLD; + /** Client age in completed years, or null when the DOB is unknown / the client is an entity. */ + clientAge: number | null = null; + /** False for a company/entity client, which has no age and so can never qualify. */ + clientIsIndividual = true; + /** True once the client lookup has come back, so the UI can explain the locked checkbox honestly. */ + clientAgeResolved = false; incentivesDisplayedColumns: string[] = [ 'entityType', 'attributeName', @@ -172,8 +196,14 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O ]; private recurringDepositsService = inject(RecurringDepositsService); + private clientsService = inject(ClientsService); + private settingsService = inject(SettingsService); + private cdr = inject(ChangeDetectorRef); ngOnChanges(changes: SimpleChanges) { + if (changes['recurringDepositsAccountTemplate']) { + this.resolveSeniorCitizenFromClient(); + } // Only reset/re-apply when the TEMPLATE or cbsBaseRates changes, NOT when initialTenure changes alone const templateChanged = changes['recurringDepositsAccountProductTemplate'] || @@ -201,10 +231,9 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O if (this.initialTenure.depositAmount) { this.principalAmount = this.initialTenure.depositAmount; } - // Pre-check senior citizen if it was set during create - if (this.initialTenure.isSeniorCitizen) { - this.isSeniorCitizen = true; - } + // Deliberately NOT restoring initialTenure.isSeniorCitizen: the flag is derived from the client's + // date of birth in resolveSeniorCitizenFromClient(). Re-applying a stored value here would carry a + // previously mis-set flag forward on every edit. // Auto-calculate when tenure is pre-filled (edit mode) setTimeout(() => this.calculateRate(), 0); } @@ -359,6 +388,45 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O this.validationWarning = null; } + /** + * Derives the senior-citizen flag from the client's date of birth. Runs whenever the account template + * arrives (create and edit alike), so the flag always reflects the customer on record rather than + * whatever was ticked -- or previously saved -- by hand. + */ + private resolveSeniorCitizenFromClient(): void { + const clientId = this.recurringDepositsAccountTemplate?.clientId ?? this.recurringDepositsAccountTemplate?.id; + if (!clientId) { + return; + } + this.clientsService.getClientData(String(clientId)).subscribe((client: any) => { + const status = deriveSeniorCitizenStatus(client, this.settingsService.businessDate); + this.clientAge = status.age; + this.clientIsIndividual = status.isIndividual; + this.isSeniorCitizen = status.isSeniorCitizen; + this.clientAgeResolved = true; + // Re-price against the now-known status if a tenure was already entered. + this.onSeniorCitizenChange(); + this.cdr.detectChanges(); + }); + } + + /** Explains the locked checkbox to staff, so a missing benefit never looks like a system fault. */ + get seniorCitizenHint(): string { + if (!this.clientAgeResolved) { + return 'Determined from the client’s date of birth'; + } + if (!this.clientIsIndividual) { + return 'Not applicable to a non-individual client'; + } + if (this.clientAge === null) { + return 'Client has no date of birth on record'; + } + if (this.isSeniorCitizen) { + return `Client is ${this.clientAge} years old — higher rate applied`; + } + return `Client is ${this.clientAge} — applies from age ${this.seniorCitizenAgeThreshold}`; + } + onSeniorCitizenChange() { if (this.tenureValue && this.tenureValue > 0) { this.calculateRate(); diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html index 4abb78a1a..0a12ed363 100644 --- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html +++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html @@ -298,10 +298,16 @@ {{ recurringDepositsAccountProductTemplate.accountType }} } - @if (recurringDepositsAccountProductTemplate?.customerSegment) { + + @if ( + recurringDepositAccountData?.customerSegment || recurringDepositsAccountProductTemplate?.customerSegment + ) {
Customer Segment - {{ recurringDepositsAccountProductTemplate.customerSegment }} + {{ + recurringDepositAccountData?.customerSegment ?? recurringDepositsAccountProductTemplate.customerSegment + }}
} @if (recurringDepositsAccountProductTemplate?.productCode) { diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.html b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.html index bf0e0f6b2..6cceb7ba6 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.html +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.html @@ -116,6 +116,12 @@
Deposit Account Link + @if (fixedDepositProductDetailsForm.get('fdType')?.value === 'SPECIAL') { + + Required for FD Special -- every account will need a linked savings account so its monthly interest, and its + principal at maturity, can be paid out automatically. + + }
diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.scss b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.scss index 2e487792e..6ed6d51eb 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.scss +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.scss @@ -76,6 +76,16 @@ $gray-900: #202124; .fdpd-checkbox-row { display: flex; - align-items: center; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 4px; min-height: 56px; } + +.fdpd-hint { + font-size: 12px; + color: $gray-900; + opacity: 0.65; + margin-left: 32px; +} diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.ts b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.ts index 8df6052c6..7ee45d938 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.ts +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.ts @@ -43,6 +43,24 @@ export class FixedDepositProductDetailsStepComponent implements OnInit, OnChange constructor() { this.createFixedDepositProductDetailsForm(); + // FD Special is a non-cumulative, monthly-interest-payout product: the backend requires a linked + // savings account for every SPECIAL-type FD account (it's where the monthly interest and, at + // maturity, the principal both get transferred to automatically), so force this on here rather than + // let an admin publish a SPECIAL product that later fails every account creation for missing config. + this.fixedDepositProductDetailsForm + .get('fdType') + ?.valueChanges.subscribe((fdType: string) => this.applyFdTypeConstraints(fdType)); + } + + private applyFdTypeConstraints(fdType: string | null) { + const linkCtrl = this.fixedDepositProductDetailsForm.get('shareLinkingEnabled'); + if (!linkCtrl) return; + if (fdType === 'SPECIAL') { + linkCtrl.setValue(true); + linkCtrl.disable(); + } else { + linkCtrl.enable(); + } } ngOnInit() { @@ -124,10 +142,11 @@ export class FixedDepositProductDetailsStepComponent implements OnInit, OnChange endDate: this.parseDate(template.endDate), shareLinkingEnabled: !!template.shareLinkingEnabled }); + this.applyFdTypeConstraints(this.fixedDepositProductDetailsForm.get('fdType')?.value ?? null); } get fixedDepositProductDetails() { - const formValue = { ...this.fixedDepositProductDetailsForm.value }; + const formValue = { ...this.fixedDepositProductDetailsForm.getRawValue() }; const dateFormat = this.settingsService.dateFormat; if (formValue.effectiveTo instanceof Date) { formValue.effectiveTo = this.dateUtils.formatDate(formValue.effectiveTo, dateFormat) || ''; diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts index efe589523..11061126a 100644 --- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts +++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts @@ -114,21 +114,37 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges this.applyFdTypeConstraints(); } + /** + * FD Special is a non-cumulative, monthly-interest-payout product -- the backend forces both the + * posting AND compounding period to Monthly for any account opened under a SPECIAL-type FD product + * (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 + * at account-creation time. + */ private applyFdTypeConstraints() { - if (!this.fixedDepositProductTermsForm || !this.interestPostingPeriodTypeData) return; + if (!this.fixedDepositProductTermsForm) return; const postingCtrl = this.fixedDepositProductTermsForm.get('interestPostingPeriodType'); - if (!postingCtrl) return; + const compoundingCtrl = this.fixedDepositProductTermsForm.get('interestCompoundingPeriodType'); if (this.fdType === 'SPECIAL') { - // Monthly posting = id 4 (MONTHLY in Fineract enum) - const monthlyOption = this.interestPostingPeriodTypeData?.find( - (o: any) => o.value?.toLowerCase() === 'monthly' || o.id === 4 - ); - if (monthlyOption) { - postingCtrl.setValue(monthlyOption.id); - postingCtrl.disable(); + // 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); + if (postingCtrl && this.interestPostingPeriodTypeData) { + const monthlyPosting = findMonthly(this.interestPostingPeriodTypeData); + if (monthlyPosting) { + postingCtrl.setValue(monthlyPosting.id); + postingCtrl.disable(); + } + } + if (compoundingCtrl && this.interestCompoundingPeriodTypeData) { + const monthlyCompounding = findMonthly(this.interestCompoundingPeriodTypeData); + if (monthlyCompounding) { + compoundingCtrl.setValue(monthlyCompounding.id); + compoundingCtrl.disable(); + } } } else { - postingCtrl.enable(); + postingCtrl?.enable(); + compoundingCtrl?.enable(); } } diff --git a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html index 1f8ce8870..31b97c866 100644 --- a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html +++ b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html @@ -491,7 +491,7 @@ Age - Must be below 18 years + Must be below {{ minorAgeThreshold }} years diff --git a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts index cb93fd19e..ebdea0abe 100644 --- a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts +++ b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts @@ -16,6 +16,12 @@ import { MatDialog } from '@angular/material/dialog'; /** Custom Services */ import { SavingsService } from 'app/savings/savings.service'; import { ClientsService } from 'app/clients/clients.service'; +import { + AGE_OF_MAJORITY, + MINOR_AGE_THRESHOLD, + isMinorAge, + isSeniorCitizenAge +} from 'app/clients/shared/client-age.util'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; @@ -80,6 +86,8 @@ export class SavingsAccountDetailsStepComponent implements OnInit { // ── Client info (auto-fetched) ───────────────────────────────────────── clientDob: Date | null = null; clientAge: number | null = null; + /** Exposed for tooltips so the displayed rule can never drift from the applied rule. */ + readonly minorAgeThreshold = MINOR_AGE_THRESHOLD; clientGender: string | null = null; primaryClientName = ''; primaryClientCif = ''; @@ -282,16 +290,16 @@ export class SavingsAccountDetailsStepComponent implements OnInit { isTileDisabled(value: string): boolean { if (this.readOnly) return true; - if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18; - if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60; + if (value === 'MINOR') return !isMinorAge(this.clientAge); + if (value === 'SENIOR_CITIZEN') return !isSeniorCitizenAge(this.clientAge); return false; } getTileTooltip(value: string): string { - if (value === 'MINOR' && this.clientAge !== null && this.clientAge >= 18) { - return `Age ${this.clientAge} yrs — Minor only allowed below 18 years`; + if (value === 'MINOR' && !isMinorAge(this.clientAge)) { + return `Age ${this.clientAge} yrs — Minor only allowed below years`; } - if (value === 'SENIOR_CITIZEN' && this.clientAge !== null && this.clientAge < 60) { + if (value === 'SENIOR_CITIZEN' && !isSeniorCitizenAge(this.clientAge)) { return `Age ${this.clientAge} yrs — Senior Citizen only allowed at 60+ years`; } if (value === 'MINOR' && this.clientAge === null) return 'DOB not available — cannot verify age'; @@ -306,10 +314,10 @@ export class SavingsAccountDetailsStepComponent implements OnInit { } if (this.clientAge === null) return; - if (this.clientAge < 18) { + if (isMinorAge(this.clientAge)) { this.selectedAccountStatus = 'MINOR'; this.minorDob = this.clientDob; - } else if (this.clientAge >= 60) { + } else if (isSeniorCitizenAge(this.clientAge)) { this.selectedAccountStatus = 'SENIOR_CITIZEN'; } else if (this.clientGender === 'female') { this.selectedAccountStatus = 'INDIVIDUAL_FEMALE'; @@ -597,7 +605,7 @@ export class SavingsAccountDetailsStepComponent implements OnInit { const dob = this.minorDob ?? this.clientDob; if (!dob) return ''; const d = new Date(dob); - d.setFullYear(d.getFullYear() + 18); + d.setFullYear(d.getFullYear() + AGE_OF_MAJORITY); return this.dateUtils.formatDate(d, 'M/d/yyyy') || ''; } diff --git a/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.ts b/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.ts index a368f824d..6be1eca86 100644 --- a/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.ts +++ b/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.ts @@ -84,7 +84,10 @@ export class PassbookTabComponent implements OnInit, OnDestroy { this.accountBalance = account.summary?.accountBalance || 0; this.availableBalance = account.summary?.availableBalance || 0; this.activatedOnDate = account.timeline?.activatedOnDate || null; - this.allTransactions = this.recalculateRunningBalances(this.sortAscending(account.transactions || [])); + const nonReversedTransactions = (account.transactions || []).filter( + (txn: SavingsAccountTransaction) => !txn.reversed + ); + this.allTransactions = this.recalculateRunningBalances(this.sortAscending(nonReversedTransactions)); this.filteredTransactions = [...this.allTransactions]; }); @@ -182,9 +185,12 @@ export class PassbookTabComponent implements OnInit, OnDestroy { private recalculateRunningBalances(transactions: SavingsAccountTransaction[]): SavingsAccountTransaction[] { let balance = 0; return transactions.map((txn) => { - // Accruals and on-hold entries don't affect actual account balance + // Accruals, on-hold entries, and reversed transactions don't affect actual account balance const affectsBalance = - !txn.transactionType.accrual && !txn.transactionType.amountHold && !txn.transactionType.amountRelease; + !txn.reversed && + !txn.transactionType.accrual && + !txn.transactionType.amountHold && + !txn.transactionType.amountRelease; if (affectsBalance) { if (this.isDebit(txn.transactionType)) { diff --git a/src/app/savings/savings-account-view/transactions-tab/transactions-tab.component.ts b/src/app/savings/savings-account-view/transactions-tab/transactions-tab.component.ts index 2b79720cd..f15cc9f5a 100644 --- a/src/app/savings/savings-account-view/transactions-tab/transactions-tab.component.ts +++ b/src/app/savings/savings-account-view/transactions-tab/transactions-tab.component.ts @@ -132,8 +132,8 @@ export class TransactionsTabComponent implements OnInit { ngOnInit() { this.hideAccrualsParam = new UntypedFormControl(false); - this.hideReversedParam = new UntypedFormControl(false); - this.setTransactions(); + this.hideReversedParam = new UntypedFormControl(true); + this.filterTransactions(this.hideReversedParam.value, this.hideAccrualsParam.value); } setTransactions(): void { @@ -222,6 +222,7 @@ export class TransactionsTabComponent implements OnInit { }); } this.dataSource = new MatTableDataSource(transactions); + this.accountWithTransactions = this.transactionsData && this.transactionsData.length > 0; this.dataSource.paginator = this.paginator; this.dataSource.sort = this.sort; } @@ -263,7 +264,10 @@ export class TransactionsTabComponent implements OnInit { let balance = 0; return transactions.map((txn) => { const affectsBalance = - !txn.transactionType.accrual && !txn.transactionType.amountHold && !txn.transactionType.amountRelease; + !txn.reversed && + !txn.transactionType.accrual && + !txn.transactionType.amountHold && + !txn.transactionType.amountRelease; if (affectsBalance) { if (this.isDebit(txn.transactionType)) { balance -= txn.amount;