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 <noreply@anthropic.com>
130 lines
5.5 KiB
TypeScript
130 lines
5.5 KiB
TypeScript
/**
|
|
* 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 { 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',
|
|
templateUrl: './general-tab.component.html',
|
|
styleUrls: ['./general-tab.component.scss'],
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
ExternalIdentifierComponent,
|
|
FaIconComponent,
|
|
CurrencyPipe,
|
|
DateFormatPipe
|
|
]
|
|
})
|
|
export class GeneralTabComponent {
|
|
private route = inject(ActivatedRoute);
|
|
private router = inject(Router);
|
|
private dialog = inject(MatDialog);
|
|
|
|
fixedDepositsAccountData: any;
|
|
entityType: string;
|
|
currency: Currency;
|
|
|
|
constructor() {
|
|
this.route.parent.data.subscribe((data: { fixedDepositsAccountData: any }) => {
|
|
this.fixedDepositsAccountData = data.fixedDepositsAccountData;
|
|
this.currency = this.fixedDepositsAccountData.currency;
|
|
});
|
|
if (this.router.url.includes('clients')) {
|
|
this.entityType = 'Client';
|
|
} else if (this.router.url.includes('groups')) {
|
|
this.entityType = 'Group';
|
|
} else if (this.router.url.includes('centers')) {
|
|
this.entityType = 'Center';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A reinvested FD always keeps the same client/group/center as the account it came from, so the
|
|
* counterpart account's view page sits at the same route -- only the account-id segment differs.
|
|
*/
|
|
private siblingAccountLink(targetAccountId: number): string | null {
|
|
const segments = this.router.url.split('?')[0].split('/').filter(Boolean);
|
|
const idx = segments.indexOf('fixed-deposits-accounts');
|
|
if (idx === -1) return null;
|
|
return '/' + segments.slice(0, idx + 1).join('/') + '/' + targetAccountId + '/general';
|
|
}
|
|
|
|
get reinvestedFromLink(): string | null {
|
|
const id = this.fixedDepositsAccountData?.reinvestedFromAccountId;
|
|
return id ? this.siblingAccountLink(id) : null;
|
|
}
|
|
|
|
get reinvestedIntoLink(): string | null {
|
|
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;
|
|
}
|
|
}
|