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;