+ Cash movement differs from counter movement by ₹{{ cashMovement - netTotal | number: '1.2-2' }} — vault,
+ inter-cashier and exchange legs that did not pass over the counter.
+
+ @if (scopedToCashier && closingBalance !== null) {
+ No cash crossed the counter on this date. The drawer still holds ₹{{ closingBalance | number: '1.2-2' }},
+ carried forward.
+ } @else {
+ No cash denomination data for this date.
+ }
+
}
}
diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts
index 81d6c3634..a7d938a0a 100644
--- a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts
+++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts
@@ -38,6 +38,38 @@ export class ClosingCashReportComponent implements OnInit {
loading = false;
loaded = false;
+ /**
+ * Cash taken at the counter for a Demand Draft.
+ *
+ * A cash DD asks for a denomination breakdown but does not require one, and this report is built
+ * entirely from breakdowns — so that cash was missing from the drawer figure even though the
+ * ledger held it and the cashier held the notes. It is counted from the request itself and shown
+ * on its own line, because the denomination rows below cannot account for it.
+ */
+ ddCashTotal = 0;
+ ddCashCount = 0;
+ /** Deposits including the DD cash — the figure the drawer is actually checked against. */
+ depositTotalWithDd = 0;
+
+ /**
+ * The drawer, opening to closing.
+ *
+ * `netTotal` above is only the day's counter movement — deposits less withdrawals. It is not what
+ * the cashier is holding, because it carries nothing forward from yesterday and ignores vault
+ * allocations and inter-cashier legs. The closing balance is the figure that has to match a
+ * physical count at the end of the day, so it is reported separately rather than inferred from
+ * the counter totals.
+ *
+ * Null when the server did not scope the report to one cashier: a branch-wide opening balance is
+ * the branch day register's business, not this report's.
+ */
+ openingBalance: number | null = null;
+ closingBalance: number | null = null;
+ /** Every leg of the day's cash movement, which is wider than deposits less withdrawals. */
+ cashMovement: number | null = null;
+ /** Whether the figures above are one cashier's drawer or the whole branch's counter work. */
+ scopedToCashier = false;
+
// GL View
showGl = false;
glRows: any[] = [];
@@ -66,6 +98,33 @@ export class ClosingCashReportComponent implements OnInit {
get noteRows(): any[] {
return this.rows.filter((r) => r.kind !== 'coin');
}
+
+ /**
+ * Totals for the notes table, including the un-itemised Demand Draft cash.
+ *
+ * The DD row carries an amount but no piece count, so pieces and amounts are summed separately
+ * rather than one being derived from the other — a denomination times a count would not reach the
+ * DD's figure, and adding a fictional count to make it balance would misstate the drawer.
+ */
+ get noteDepositCount(): number {
+ return this.noteRows.reduce((s, r) => s + Number(r.depositCount ?? 0), 0);
+ }
+
+ get noteDepositAmount(): number {
+ return this.noteRows.reduce((s, r) => s + Number(r.depositAmount ?? 0), 0) + this.ddCashTotal;
+ }
+
+ get noteWithdrawalCount(): number {
+ return this.noteRows.reduce((s, r) => s + Number(r.withdrawalCount ?? 0), 0);
+ }
+
+ get noteWithdrawalAmount(): number {
+ return this.noteRows.reduce((s, r) => s + Number(r.withdrawalAmount ?? 0), 0);
+ }
+
+ get noteNetAmount(): number {
+ return this.noteDepositAmount - this.noteWithdrawalAmount;
+ }
get coinRows(): any[] {
return this.rows.filter((r) => r.kind === 'coin');
}
@@ -92,6 +151,15 @@ export class ClosingCashReportComponent implements OnInit {
this.depositTotal = Number(res?.depositTotal || 0);
this.withdrawalTotal = Number(res?.withdrawalTotal || 0);
this.netTotal = Number(res?.netTotal || 0);
+ this.ddCashTotal = Number(res?.ddCashTotal || 0);
+ this.ddCashCount = Number(res?.ddCashCount || 0);
+ this.depositTotalWithDd = Number(res?.depositTotalWithDd ?? res?.depositTotal ?? 0);
+ this.scopedToCashier = res?.scopedToCashier === true;
+ // Sent only for a cashier-scoped call, and read as absent rather than zero when missing —
+ // a zero opening balance and no opening balance at all are different statements.
+ this.openingBalance = res?.openingBalance == null ? null : Number(res.openingBalance);
+ this.closingBalance = res?.closingBalance == null ? null : Number(res.closingBalance);
+ this.cashMovement = res?.cashMovement == null ? null : Number(res.cashMovement);
this.loading = false;
this.loaded = true;
},
@@ -101,6 +169,13 @@ export class ClosingCashReportComponent implements OnInit {
this.depositTotal = 0;
this.withdrawalTotal = 0;
this.netTotal = 0;
+ this.ddCashTotal = 0;
+ this.ddCashCount = 0;
+ this.depositTotalWithDd = 0;
+ this.scopedToCashier = false;
+ this.openingBalance = null;
+ this.closingBalance = null;
+ this.cashMovement = null;
this.loading = false;
this.loaded = true;
}
diff --git a/src/app/cashier/day-book/day-book.component.html b/src/app/cashier/day-book/day-book.component.html
index 12b14a2d2..54d302add 100644
--- a/src/app/cashier/day-book/day-book.component.html
+++ b/src/app/cashier/day-book/day-book.component.html
@@ -65,42 +65,6 @@
@if (errorMsg) {
+ DD GL Mapping Required: Demand Draft GL accounts have not been configured yet. Please ask an
+ administrator to set up the mapping under Admin > Sponsor DD GST / GL Settings before issuing
+ drafts.
+
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.scss b/src/app/cashier/transaction-entry/transaction-entry.component.scss
index bc31277d3..36cd56460 100644
--- a/src/app/cashier/transaction-entry/transaction-entry.component.scss
+++ b/src/app/cashier/transaction-entry/transaction-entry.component.scss
@@ -3384,3 +3384,67 @@ select.te-cell {
transform: rotate(360deg);
}
}
+
+.ws-dd-unconfigured-banner {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 10px 16px;
+ padding: 12px 16px;
+ background: #fffbeb;
+ border: 1.5px solid #fcd34d;
+ border-radius: 8px;
+ color: #92400e;
+ font-size: 13px;
+
+ mat-icon {
+ color: #d97706;
+ font-size: 24px;
+ width: 24px;
+ height: 24px;
+ flex-shrink: 0;
+ }
+
+ &__text {
+ flex: 1;
+ line-height: 1.4;
+
+ strong {
+ color: #78350f;
+ }
+ }
+}
+
+.te-dd-issue-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ background: linear-gradient(135deg, #0284c7, #0ea5e9);
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ padding: 7px 16px;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ box-shadow: 0 2px 4px rgb(2 132 199 / 35%);
+ transition: all 0.15s ease-in-out;
+ letter-spacing: 0.2px;
+
+ &:hover:not(:disabled) {
+ background: linear-gradient(135deg, #0369a1, #0284c7);
+ box-shadow: 0 3px 8px rgb(2 132 199 / 50%);
+ transform: translateY(-1px);
+ }
+
+ &:active:not(:disabled) {
+ transform: translateY(0);
+ }
+
+ &:disabled {
+ background: #cbd5e1;
+ color: #64748b;
+ cursor: not-allowed;
+ box-shadow: none;
+ }
+}
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts
index e17b55db9..fbbd61837 100644
--- a/src/app/cashier/transaction-entry/transaction-entry.component.ts
+++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts
@@ -32,6 +32,7 @@ import {
} from '../../shared/customer-search-dialog/customer-search-dialog.component';
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
import { OrganizationService } from '../../organization/organization.service';
+import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
interface TxnRow {
typ: string;
@@ -51,6 +52,7 @@ interface TxnRow {
routingCode: string;
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
ddCharges: number | null;
+ sponsorBankId?: number | null;
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
resolutionNumber?: string;
@@ -1523,7 +1525,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
* the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()).
*/
paymentTypesForRow(row: TxnRow): any[] {
- const tellerFacing = this.paymentTypes.filter((pt: any) => !pt.isSystemDefined);
+ // Sponsor-bank payment types are excluded. They were added for the Demand Draft module, which
+ // picks its bank from m_sponsor_bank on the DD row itself, and they are not counter instruments
+ // — a clerk choosing "SBI" as the TXN TP. of a withdrawal says nothing about how the money
+ // moves. They are recognised by their description rather than by name so that adding another
+ // sponsor bank does not put it back on this list.
+ const tellerFacing = this.paymentTypes.filter(
+ (pt: any) => !pt.isSystemDefined && !/sponsor bank/i.test(pt.description ?? '')
+ );
if (!this.isClerk) {
// Cashier counter takes cash only — cheque/transfer/DD instruments go through their own
// clerk/back-office approval flows, never straight through the cash drawer.
@@ -1541,6 +1550,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
if (isShareSettlementCredit) {
return tellerFacing.filter((pt: any) => !pt.isCashPayment);
}
+ // A withdrawal slip is a cash instruction and nothing else: the clerk raises it, the cashier
+ // counts the notes out against the token. There is no non-cash way to settle one, so cash is
+ // the only choice offered rather than the default among several.
+ if (row.typ === 'Withdrawal Slip') {
+ return tellerFacing.filter((pt: any) => !!pt.isCashPayment);
+ }
+ // A clerk's Demand Draft is never funded by cash they take themselves. Cash for a draft is
+ // counted at the counter, so it comes either from the cashier's own DD screen or from an
+ // approved cashier deposit request the clerk points the draft at; the clerk's own route is to
+ // debit the member's account. Offering cash here let a draft be raised against notes no drawer
+ // had counted, which is exactly how the six cash DDs with no denomination breakdown arose.
+ if (row.typ === 'DD') {
+ return tellerFacing.filter((pt: any) => !pt.isCashPayment);
+ }
+ // Everything else can go either way, so both are offered and the clerk says which. A cheque or
+ // a bank transfer normally settles through the instrument, a disbursement or fund transfer
+ // between accounts — the type alone does not fix it to one or the other.
return tellerFacing;
}
@@ -1664,9 +1690,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// wrong for crediting a GL amount into a savings/current account for any other reason (e.g.
// funding a newly-activated account's opening balance from a GL source).
{ value: 'Fund Transfer', label: 'Fund Transfer' },
- // NOTE: the exact same TxnRow.typ string is used at every dispatch/default-setting site below —
- // search for 'Disbursement' whenever this needs to change again, since 'Fund Transfer' always
- // rides alongside it in every one of those checks rather than having its own separate case.
{ value: 'Enter Bank Transfer', label: 'Enter Bank Transfer' },
{ value: 'DD', label: 'DD (Demand Draft)' }
];
@@ -1692,6 +1715,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return this.isClerk ? this.clerkTypOptions : this.cashierTypOptions;
}
+ sponsorBanks: any[] = [];
+ ddGlMapping: any = null;
+
// ── Denomination ──────────────────────────────────────────────────────────
notes: Denomination[] = [];
coins: Denomination[] = [];
@@ -1752,6 +1778,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.loadCashVaultGl();
this.loadDdGstPercentage();
+ this.loadSponsorBanks();
if (this.reviewMode) {
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
@@ -1842,12 +1869,85 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
.getGlMappings()
.pipe(catchError(() => of([])))
.subscribe((mappings: any[]) => {
- const mapping = mappings && mappings.length ? mappings[0] : null;
- this.ddGstPercentage = Number(mapping?.gstPercentage ?? 18);
- this.ddLimitAmount = mapping?.ddLimitAmount != null ? Number(mapping.ddLimitAmount) : null;
+ this.ddGlMapping = mappings && mappings.length ? mappings[0] : null;
+ this.ddGstPercentage = Number(this.ddGlMapping?.gstPercentage ?? 18);
+ this.ddLimitAmount = this.ddGlMapping?.ddLimitAmount != null ? Number(this.ddGlMapping.ddLimitAmount) : null;
+ if (this.ddGlMapping?.ddPayableGlAccountId) {
+ if (this.ddGlMapping.ddPayableGlCode) {
+ this.applyDdGlMappingToDdRows();
+ } else {
+ this.http
+ .get(`/glaccounts/${this.ddGlMapping.ddPayableGlAccountId}`)
+ .pipe(catchError(() => of(null)))
+ .subscribe((glAcc: any) => {
+ if (glAcc) {
+ this.ddGlMapping.ddPayableGlCode = glAcc.glCode || String(glAcc.id);
+ this.ddGlMapping.ddPayableGlAccountName = glAcc.name;
+ }
+ this.applyDdGlMappingToDdRows();
+ });
+ }
+ }
});
}
+ applyDdGlMappingToRow(row: TxnRow): void {
+ if (row.typ !== 'DD' || !this.ddGlMapping) return;
+ // Leaves an account-funded DD alone: its A/C NO. is the member's account the clerk typed, and
+ // overwriting it with the DD Payable GL would silently turn the row back into a cash draft.
+ if (this.isDdAccountDebitRow(row)) return;
+ row.acTyp = 'GL';
+ row.dbCr = 'Cr';
+
+ if (this.ddGlMapping.ddPayableGlCode) {
+ row.acNo = this.ddGlMapping.ddPayableGlCode;
+ this.selectedRow = row;
+ this.loadAccountDetails(row.acNo, 'GL');
+ return;
+ }
+
+ if (this.ddGlMapping.ddPayableGlAccountId) {
+ this.http
+ .get(`/glaccounts/${this.ddGlMapping.ddPayableGlAccountId}`)
+ .pipe(catchError(() => of(null)))
+ .subscribe((glAcc: any) => {
+ if (glAcc) {
+ this.ddGlMapping.ddPayableGlCode = glAcc.glCode || String(glAcc.id);
+ this.ddGlMapping.ddPayableGlAccountName = glAcc.name;
+ row.acNo = this.ddGlMapping.ddPayableGlCode;
+ this.selectedRow = row;
+ this.loadAccountDetails(row.acNo, 'GL');
+ }
+ });
+ }
+ }
+
+ private applyDdGlMappingToDdRows(): void {
+ for (const row of this.rows) {
+ if (row.typ === 'DD') {
+ this.applyDdGlMappingToRow(row);
+ }
+ }
+ }
+
+ private loadSponsorBanks(): void {
+ this.sponsorDDService
+ .getSponsorBanks()
+ .pipe(catchError(() => of([])))
+ .subscribe((banks: any[]) => {
+ this.sponsorBanks = banks || [];
+ for (const row of this.rows) {
+ if (row.typ === 'DD' && !row.sponsorBankId && this.sponsorBanks.length > 0) {
+ row.sponsorBankId = this.sponsorBanks[0].id;
+ }
+ }
+ });
+ }
+
+ get isDdGlMappingConfigured(): boolean {
+ return !!(this.ddGlMapping && this.ddGlMapping.ddPayableGlAccountId && this.ddGlMapping.cashGlAccountId);
+ }
+
private loadDayStatus(): void {
this.cashierService
.getCashierDayStatus()
@@ -2093,9 +2193,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
row.chequeNo = '';
row.cheqDate = '';
break;
+ // A cheque clears through the bank rather than the drawer, so it opens on the transfer
+ // instrument. Cash is still on its dropdown — a cheque can be cashed over the counter — but
+ // it is the exception, not what the field should start on.
case 'Cheque':
row.dbCr = 'Dr';
- row.paymentTypeId = this.findCashPaymentTypeId();
+ row.paymentTypeId = this.findTransferPaymentTypeId();
break;
case 'Disbursement':
case 'Fund Transfer':
@@ -2121,16 +2224,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
row.chequeNo = '';
row.cheqDate = '';
break;
- // A DD is bought with cash: the customer hands over Face Value + Charges + GST, so it is money
- // coming IN (Cr) against the GL the draft is drawn on. All three of Cr, a cash payment type and
- // AC.TP = GL are what isDdCashGlRow() looks for -- without them the DD Charges & GST panel never
- // appears and the row would post as an ordinary entry with the charges silently dropped.
+ // A DD is bought with cash: the purchaser hands over Face Value + Charges + GST, so it is
+ // money coming IN (Cr) against the GL the draft is drawn on. All three of Cr, a cash payment
+ // type and AC.TP = GL are what isDdCashGlRow() looks for -- without them the DD Charges & GST
+ // panel never appears and the row would post as an ordinary entry with the charges silently
+ // dropped.
+ //
+ // A clerk funds a draft through an account or an instrument instead, and their TXN TP. list no
+ // longer offers cash, so defaulting to it would open the field on a value it cannot show.
+ // isDdCashGlRow() still holds for them through AC.TP = GL.
case 'DD':
row.dbCr = 'Cr';
- row.paymentTypeId = this.findCashPaymentTypeId();
row.acTyp = 'GL';
+ row.paymentTypeId = this.isClerk ? this.findTransferPaymentTypeId() : this.findCashPaymentTypeId();
row.chequeNo = '';
row.cheqDate = '';
+ if (this.sponsorBanks.length > 0 && !row.sponsorBankId) {
+ row.sponsorBankId = this.sponsorBanks[0].id;
+ }
+ this.applyDdGlMappingToRow(row);
break;
}
}
@@ -2145,10 +2257,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return pt?.id ?? null;
}
- /** TYPE=DD + TXN TP=Cash + AC.TP=GL — the only combination that collects DD charges + GST from the customer. */
+ /** TYPE=DD + TXN TP=Cash */
isDdCashGlRow(row: TxnRow): boolean {
+ if (this.isDdAccountDebitRow(row)) return false;
const pt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
- return row.typ === 'DD' && !!pt?.isCashPayment && row.acTyp === 'GL';
+ return row.typ === 'DD' && (row.acTyp === 'GL' || !row.acTyp || !!pt?.isCashPayment);
+ }
+
+ /**
+ * TYPE=DD funded by debiting a member's savings/current account rather than by cash.
+ *
+ * Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account debit
+ * would let a draft be raised against an account with no cash to reconcile it against — and the
+ * clerk flow is the one that carries a maker-checker step over the member's balance.
+ */
+ isDdAccountDebitRow(row: TxnRow): boolean {
+ return this.isClerk && row.typ === 'DD' && (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB');
+ }
+
+ /**
+ * Any DD row the charges/GST panel applies to.
+ *
+ * Charges and GST come from the admin's DD configuration and are collected whichever way the
+ * draft is paid for — the difference is only where the money comes from, so the panel is the same
+ * for both and the label on the total changes rather than the arithmetic.
+ */
+ isDdRow(row: TxnRow): boolean {
+ return this.isDdCashGlRow(row) || this.isDdAccountDebitRow(row);
}
/** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
@@ -2179,6 +2314,258 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
}
+ branchNameFor(branchId: number): string {
+ const map: Record = {
+ 1: '001 · Main',
+ 2: '002 · M.G. Road',
+ 14: '014 · Station Rd',
+ 27: '027 · Industrial'
+ };
+ return map[branchId] || `Branch ${branchId}`;
+ }
+
+ /**
+ * Cashier Walk-in DD creation flow:
+ * 1. Validates one-time persistent GL configuration
+ * 2. Auto-selects next available leaflet
+ * 3. Opens dialog to capture purchaser/beneficiary details
+ * 4. Submits issue request directly with physical denominations for manager approval
+ */
+ openIssueWalkinDd(row: TxnRow): void {
+ if (!this.isDdGlMappingConfigured) {
+ this.showAlert('error', 'DD GL Mapping Not Configured', [
+ 'DD GL mapping is not configured.',
+ 'Please ask an administrator to set up the GL accounts in Admin > Sponsor DD GST / GL Settings before creating Demand Drafts.'
+ ]);
+ return;
+ }
+ const faceValue = Number(row.amount);
+ if (!faceValue || faceValue <= 0) {
+ this.showAlert('error', 'Invalid Amount', ['Please enter the DD Face Value amount before proceeding.']);
+ return;
+ }
+ const chargesAmount = Number(row.ddCharges) || 0;
+ const gstAmount = this.ddGstAmountFor(row);
+ const totalCashPayable = this.ddTotalPayableFor(row);
+ const accountDebit = this.isDdAccountDebitRow(row);
+
+ // An account-funded draft needs the account resolved to a Fineract id before it can be sent:
+ // the server takes a savings/current account id and a client id, not the typed number. Both are
+ // filled in by the A/C NO. lookup, so an unresolved account means the number was never found.
+ let fundingAccountId: number | null = null;
+ let fundingClientId: number | null = null;
+ if (accountDebit) {
+ const acNo = (row.acNo || '').trim();
+ if (!acNo) {
+ this.showAlert('error', 'Account Required', [
+ 'Enter the account number the Demand Draft is funded from.'
+ ]);
+ return;
+ }
+ fundingAccountId = this.rowAccountIds[acNo] ?? null;
+ fundingClientId = this.rowClientIds[acNo] ?? null;
+ if (!fundingAccountId || !fundingClientId) {
+ this.showAlert('error', 'Account Not Found', [
+ `Account ${acNo} could not be resolved.`,
+ 'Check the account number and wait for its details to load before raising the draft.'
+ ]);
+ return;
+ }
+ }
+
+ // Only a cash draft has notes to reconcile. An account debit takes no cash at the counter, so a
+ // denomination count left over from an earlier row must not be checked against it.
+ if (!accountDebit && this.denominationTotal > 0 && Math.abs(this.denominationTotal - totalCashPayable) > 0.005) {
+ this.showAlert('error', 'Denomination Mismatch', [
+ `Cash denomination counted (₹${this.denominationTotal}) does not match total payable cash (₹${totalCashPayable}).`,
+ 'Please verify and match the physical cash denomination count.'
+ ]);
+ return;
+ }
+
+ this.saving = true;
+ this.sponsorDDService.getNextAvailableLeaflet(row.br, row.sponsorBankId ?? undefined).subscribe({
+ next: (leaflet) => {
+ this.saving = false;
+ if (!leaflet) {
+ this.showAlert('error', 'No Available Leaflets', [
+ 'No available Demand Draft leaflets found for this branch and sponsor bank.',
+ 'Please register a DD inventory series first.'
+ ]);
+ return;
+ }
+
+ const selectedBank = this.sponsorBanks.find((b: any) => b.id === row.sponsorBankId);
+ const branchName = this.branchNameFor(row.br) || 'Branch ' + row.br;
+
+ const dialogRef = this.dialog.open(CashierIssueDdDialogComponent, {
+ width: '600px',
+ disableClose: true,
+ data: {
+ leaflet,
+ branchName,
+ sponsorBankName: selectedBank?.name || 'Sponsor Bank',
+ faceValue,
+ chargesAmount,
+ gstPercentage: this.ddGstPercentage,
+ gstAmount,
+ totalCashPayable,
+ paymentMethod: accountDebit ? ('ACCOUNT_DEBIT' as const) : ('CASH' as const),
+ accountNo: accountDebit ? row.acNo : undefined,
+ accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined
+ }
+ });
+
+ dialogRef.afterClosed().subscribe((result) => {
+ if (!result) return;
+ if (accountDebit) {
+ this.submitAccountDebitDd(row, leaflet, result, fundingAccountId!, fundingClientId!);
+ } else {
+ this.submitWalkinCashDd(row, leaflet, result);
+ }
+ });
+ },
+ error: (err) => {
+ this.saving = false;
+ this.showAlert('error', 'Error Auto-Selecting DD Leaflet', [err.error?.message || err.message]);
+ }
+ });
+ }
+
+ /**
+ * Raises a Demand Draft funded by debiting a member's savings or current account.
+ *
+ * Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The member's
+ * account is only touched when a checker approves, which is also when the balance is re-checked —
+ * so a draft raised against a sufficient balance can still be refused if the balance has since
+ * gone. No denominations are sent, because no cash is taken.
+ *
+ * The account goes in savingsAccountId or currentAccountId, never both: the server insists on
+ * exactly one, since which table the account lives in decides which control GL is debited.
+ */
+ private submitAccountDebitDd(
+ row: TxnRow,
+ leaflet: any,
+ beneficiaryData: any,
+ fundingAccountId: number,
+ fundingClientId: number
+ ): void {
+ const d = new Date();
+ const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+ const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
+
+ const payload = {
+ clientId: fundingClientId,
+ beneficiaryName: beneficiaryData.beneficiaryName,
+ payableAtBranch: beneficiaryData.payableAtBranch,
+ payableAtCity: beneficiaryData.payableAtCity,
+ faceValue: Number(row.amount),
+ chargesAmount: Number(row.ddCharges) || 0,
+ issuingBranchId: row.br,
+ issuedDate,
+ paymentMethod: 'ACCOUNT_DEBIT' as const,
+ savingsAccountId: isCurrent ? undefined : fundingAccountId,
+ currentAccountId: isCurrent ? fundingAccountId : undefined
+ };
+
+ this.saving = true;
+ this.sponsorDDService.submitIssueRequest(leaflet.id, payload).subscribe({
+ next: () => {
+ this.saving = false;
+ this.showAlert('success', 'DD Issuance Request Submitted', [
+ `Demand Draft request for DD No. ${leaflet.ddNumber} (Face Value: ₹${row.amount}) submitted successfully!`,
+ `₹${this.ddTotalPayableFor(row)} will be debited from ${row.acNo} on approval.`,
+ 'Sent to Manager / Head Cashier for maker-checker approval.'
+ ]);
+ row.amount = null;
+ row.ddCharges = null;
+ row.description = '';
+ },
+ error: (err) => {
+ this.saving = false;
+ this.showAlert('error', 'DD Submission Failed', [err.error?.message || err.message]);
+ }
+ });
+ }
+
+ private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void {
+ // Read through countFor, which knows the map is keyed by kind:value. Indexing by d.id looked
+ // right but matched nothing, so every cash DD went in with no breakdown at all and its money
+ // vanished from the closing report -- the notes were counted on screen and then dropped.
+ const denominationMap: { [key: string]: number } = {};
+ for (const d of [
+ ...this.notes,
+ ...this.coins
+ ]) {
+ const count = this.countFor(d);
+ if (count > 0) {
+ denominationMap[this.denomKey(d)] = count;
+ }
+ }
+
+ // The breakdown is the only record of which notes were taken, and the server insists it add up
+ // to the cash collected. Checked here so the cashier is told which figure is wrong while the
+ // counts are still on screen, rather than getting a rejection after the fact.
+ //
+ // ddTotalPayableFor rounds up to the whole rupee actually collected, which is what the notes
+ // can add to; the exact figure with paise is accepted too, for a draft that lands on a rupee.
+ const totalCollected = Number(this.ddTotalPayableFor(row));
+ const exactPayable = Number(row.amount || 0) + (Number(row.ddCharges) || 0) + this.ddGstAmountFor(row);
+ const countedTotal = Object.entries(denominationMap).reduce(
+ (sum, [
+ key,
+ count
+ ]) => sum + Number(key.split(':')[1]) * count,
+ 0
+ );
+ if (countedTotal !== totalCollected && countedTotal !== exactPayable) {
+ this.showAlert('error', 'Denomination Entry Does Not Match', [
+ countedTotal === 0
+ ? 'Enter the notes and coins taken from the purchaser before submitting.'
+ : `The notes and coins entered come to ₹${countedTotal.toFixed(2)}, but ₹${totalCollected.toFixed(2)} is collected (face value + charges + GST).`,
+ 'The breakdown must equal the cash taken — it is what the closing report and the drawer are checked against.'
+ ]);
+ return;
+ }
+
+ const d = new Date();
+ const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+
+ const payload = {
+ walkinPurchaserName: purchaserData.walkinPurchaserName,
+ walkinPurchaserAddress: purchaserData.walkinPurchaserAddress,
+ walkinPurchaserAadhaar: purchaserData.walkinPurchaserAadhaar,
+ beneficiaryName: purchaserData.beneficiaryName,
+ payableAtBranch: purchaserData.payableAtBranch,
+ payableAtCity: purchaserData.payableAtCity,
+ faceValue: Number(row.amount),
+ chargesAmount: Number(row.ddCharges) || 0,
+ issuingBranchId: row.br,
+ issuedDate,
+ paymentMethod: 'CASH' as const,
+ denominationData: Object.keys(denominationMap).length > 0 ? JSON.stringify(denominationMap) : undefined
+ };
+
+ this.saving = true;
+ this.sponsorDDService.submitIssueRequest(leaflet.id, payload).subscribe({
+ next: () => {
+ this.saving = false;
+ this.showAlert('success', 'DD Issuance Request Submitted', [
+ `Demand Draft request for DD No. ${leaflet.ddNumber} (Face Value: ₹${row.amount}, Total Cash: ₹${this.ddTotalPayableFor(row)}) submitted successfully!`,
+ 'Sent to Manager / Head Cashier for maker-checker approval.'
+ ]);
+ row.amount = null;
+ row.ddCharges = null;
+ row.description = '';
+ this.clearDenominations();
+ },
+ error: (err) => {
+ this.saving = false;
+ this.showAlert('error', 'DD Submission Failed', [err.error?.message || err.message]);
+ }
+ });
+ }
+
/**
* Submits a clerk double-entry request (maker-checker, PENDING until manager approves).
* Disbursement: 1 DR [GL] source → N CR savings/current/GL destinations.
@@ -2811,16 +3198,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const f = cfg.matchField;
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
- // FULL unfiltered list rather than a server-side-narrowed one, so a partial match can't be
- // gated on "exactly one row came back" — it never would be for those types.
- const exactMatch = allItems.find((x: any) => x[f] === acNo);
+ const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
const partialMatch = !exactMatch
- ? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo)))
+ ? (allItems.find((x: any) => String(x[f] ?? '').endsWith(acNo)) ??
+ allItems.find((x: any) => String(x[f] ?? '').includes(acNo)))
: null;
- const match = exactMatch ?? partialMatch ?? null;
+ const idMatch =
+ !exactMatch && !partialMatch && acTyp === 'GL'
+ ? allItems.find((x: any) => String(x.id) === String(acNo))
+ : null;
+ const match = exactMatch ?? partialMatch ?? idMatch ?? null;
if (match?.id) {
+ const matchedGlCode = match[f] ?? acNo;
+ if (this.selectedRow && this.selectedRow.typ === 'DD' && match.glCode) {
+ this.selectedRow.acNo = match.glCode;
+ }
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
- this.fetchAllDetails(match.id, match[f] ?? acNo, cfg, token, acTyp, clientIdFromSearch);
+ this.fetchAllDetails(match.id, matchedGlCode, cfg, token, acTyp, clientIdFromSearch);
return;
}
this.loadingAccount = false;
@@ -3048,7 +3442,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return false;
}
const returnedId = acc.glCode ?? acc.accountNo;
- if (returnedId && returnedId !== acNo) {
+ if (returnedId && returnedId !== acNo && String(acc.id) !== acNo) {
this.loadingAccount = false;
this.accountError = 'Account mismatch. Please re-enter account number and try again.';
return false;
@@ -3294,7 +3688,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
{ label: 'NEFT Facility', active: !!f.neftFacility },
{ label: 'RTGS Facility', active: !!f.rtgsFacility },
{ label: 'Inter-Bank Cap', active: !!f.interBankTotalCapFacility },
- { label: 'Withdrawal', active: !!f.withdrawalFacility },
{ label: 'Deposit (KYC)', active: !!f.depositFacilityKyc },
{ label: 'Virtual Debit Card', active: !!f.virtualDebitCard },
{ label: 'OD', active: !!this.accountDetails?.allowOverdraft }
@@ -4579,6 +4972,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
confirmSubmit(): void {
this.showConfirmDialog = false;
const row = this.selectedRow!;
+ if (row.typ === 'DD') {
+ this.openIssueWalkinDd(row);
+ return;
+ }
const amount = this.isDdCashGlRow(row) ? this.ddTotalPayableFor(row) : (row.amount ?? this.denominationTotal);
const isDeposit = row.dbCr === 'Cr';
const command = isDeposit ? 'deposit' : 'withdrawal';
diff --git a/src/app/cashier/voucher-exceptions/voucher-exceptions.component.html b/src/app/cashier/voucher-exceptions/voucher-exceptions.component.html
deleted file mode 100644
index 6185d289f..000000000
--- a/src/app/cashier/voucher-exceptions/voucher-exceptions.component.html
+++ /dev/null
@@ -1,183 +0,0 @@
-
-
-
-
-
-
CASHIER · EXCEPTIONS
-
Voucher Exceptions
-
- Vouchers flagged at the counter — where the slip and the system disagree, or something else about the entry is
- wrong. A clerk or manager reviews each one with the cashier, records what was found, and attaches the supporting
- document.
-
-
-
-
-
-
-
-
- @if (errorMsg) {
-
{{ errorMsg }}
- }
-
- @if (!canResolve && loaded) {
-
- info
- You can see these exceptions but not close them — a Clerk, Head Cashier or Bank Manager records the conclusion, so
- that whoever raised the flag is not the one who rules on it.
-
+ {{ unclassifiedCount }} voucher{{ unclassifiedCount === 1 ? '' : 's' }} in this period
+ {{ unclassifiedCount === 1 ? 'carries' : 'carry' }} no CR or DR prefix, so
+ {{ unclassifiedCount === 1 ? 'it appears' : 'they appear' }} on neither the Received nor the Payment report.
+
+ }
+
+
+ @if (uncountedCount > 0) {
+
+ {{ uncountedCount }} of these {{ noun }}s {{ uncountedCount === 1 ? 'has' : 'have' }} no denomination breakdown
+ recorded, totalling ₹{{ uncountedTotal | number: '1.2-2' }}. Some registers pay out without counting notes
+ against the voucher, so these are still cash — ₹{{ countedTotal | number: '1.2-2' }} of the total has a
+ breakdown behind it.
+
+ }
+
+
+ @if (missingSidesCount > 0) {
+
+ {{ missingSidesCount }} of these {{ noun }}s {{ missingSidesCount === 1 ? 'has' : 'have' }} no debit or credit
+ recorded. The sides are attached when a request is approved, so vouchers raised before that step was added keep
+ their number and amount but show no accounts. Newly raised {{ noun }}s carry both sides.
+
+ }
+
+ @if (count === 0) {
+
No {{ noun }}s were raised in this period.
} @else {
@@ -129,7 +160,7 @@
-
Vouchers
+
{{ isReceived ? 'Receipts' : 'Payments' }}
@@ -143,33 +174,39 @@
- @for (row of report.vouchers; track row.voucherNo) {
+ @for (row of rows; track row.voucherNo) {
diff --git a/src/app/cashier/voucher-report/voucher-report.component.scss b/src/app/cashier/voucher-report/voucher-report.component.scss
index 00aeecb1c..690eb1893 100644
--- a/src/app/cashier/voucher-report/voucher-report.component.scss
+++ b/src/app/cashier/voucher-report/voucher-report.component.scss
@@ -36,16 +36,6 @@
}
.vr-rows {
- .vr-link {
- color: #1d4ed8;
- text-decoration: none;
- font-weight: 500;
-
- &:hover {
- text-decoration: underline;
- }
- }
-
.vr-flag {
margin-left: 4px;
font-size: 15px;
@@ -65,6 +55,13 @@
font-size: 11px;
color: #6b7280;
}
+
+ // Spans the Debit and Credit cells for a voucher raised before its sides were recorded.
+ .vr-nosides {
+ font-size: 11px;
+ font-style: italic;
+ color: #9ca3af;
+ }
}
/* Printing a report should put the register on paper — the filters are how you reached it. */
@@ -72,9 +69,4 @@
.vr-no-print {
display: none !important;
}
-
- .vr-link {
- color: inherit;
- text-decoration: none;
- }
}
diff --git a/src/app/cashier/voucher-report/voucher-report.component.ts b/src/app/cashier/voucher-report/voucher-report.component.ts
index e80bc59c6..1d34966fa 100644
--- a/src/app/cashier/voucher-report/voucher-report.component.ts
+++ b/src/app/cashier/voucher-report/voucher-report.component.ts
@@ -9,11 +9,19 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
-import { RouterLink } from '@angular/router';
+import { ActivatedRoute } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
+/**
+ * Which side of the register a screen shows.
+ *
+ * 'CR' is the Received report (money in), 'DR' the Payment report (money out). Set from the route,
+ * so the two screens are the same component pointed at opposite sides.
+ */
+export type VoucherSide = 'CR' | 'DR';
+
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
interface VoucherRow {
voucherNo: string;
@@ -30,6 +38,13 @@ interface VoucherRow {
creditAccount: string | null;
/** Sides beyond the one debit and one credit shown — a DD's charges, a compound journal. */
extraLines: number;
+ /**
+ * Whether a denomination breakdown was recorded against this voucher.
+ *
+ * True proves cash was counted. False does not prove the opposite: several registers pay real
+ * money out without recording a breakdown, so this marks a row as unconfirmed, not as non-cash.
+ */
+ denominationCounted: boolean;
}
interface VoucherReport {
@@ -38,6 +53,8 @@ interface VoucherReport {
username: string | null;
/** Set by the server from the caller's roles; a cashier is never offered the user filter. */
canSeeAllUsers: boolean;
+ /** Whether the server restricted the register to cash movements. */
+ cashOnly: boolean;
vouchers: VoucherRow[];
count: number;
totalAmount: number;
@@ -60,12 +77,12 @@ interface VoucherReport {
imports: [
CommonModule,
FormsModule,
- RouterLink,
MatIconModule
]
})
export class VoucherReportComponent implements OnInit {
private service = inject(CashierService);
+ private route = inject(ActivatedRoute);
/** Day is the working default; a month is what you ask for at a close. */
period: 'DAY' | 'MONTH' = 'DAY';
@@ -74,21 +91,67 @@ export class VoucherReportComponent implements OnInit {
usernameFilter = '';
typeFilter = '';
+ /** Which side this screen is: Received (CR) or Payment (DR). Comes from the route's data. */
+ side: VoucherSide = 'CR';
+
+ /**
+ * Whether this screen is the cash register rather than the whole voucher register.
+ *
+ * A cashier handles notes and coins, so entries that cannot be cash — an interest posting, a
+ * clearing settlement, a cheque book fee — are left out, and everything that could have crossed
+ * the counter stays in. The filter errs towards keeping a row: a book entry on the register can
+ * be seen and recognised, whereas cash that never appears is invisible until it turns up as an
+ * unexplained difference at the end of the day.
+ */
+ cashOnly = false;
+
report: VoucherReport | null = null;
loading = false;
error: string | null = null;
ngOnInit(): void {
+ this.side = (this.route.snapshot.data['side'] as VoucherSide) ?? 'CR';
+ this.cashOnly = this.route.snapshot.data['cashOnly'] === true;
this.load();
}
+ get isReceived(): boolean {
+ return this.side === 'CR';
+ }
+
+ get title(): string {
+ return this.isReceived ? 'Received Report' : 'Payment Report';
+ }
+
+ get subtitle(): string {
+ if (this.cashOnly) {
+ return this.isReceived
+ ? 'Cash taken in over a day or a month, and who took it — book entries left out.'
+ : 'Cash paid out over a day or a month, and who paid it — book entries left out.';
+ }
+ return this.isReceived
+ ? 'Every receipt voucher raised over a day or a month — money taken in, and who took it.'
+ : 'Every payment voucher raised over a day or a month — money paid out, and who paid it.';
+ }
+
+ /** The word for one row on this side, for counts and the empty state. */
+ get noun(): string {
+ return this.isReceived ? 'receipt' : 'payment';
+ }
+
load(): void {
this.loading = true;
this.error = null;
const { from, to } = this.range();
this.service
- .voucherReport(from, to, this.usernameFilter.trim() || undefined, this.typeFilter.trim() || undefined)
+ .voucherReport(
+ from,
+ to,
+ this.usernameFilter.trim() || undefined,
+ this.typeFilter.trim() || undefined,
+ this.cashOnly
+ )
.pipe(
catchError((err: any) => {
this.error =
@@ -125,9 +188,95 @@ export class VoucherReportComponent implements OnInit {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
+ /**
+ * The rows belonging to this screen's side.
+ *
+ * The side is read off the first segment of the voucher number — `CR/001/2608/0003` — which the
+ * server stamps when it raises the voucher: a deposit credits the member and gets CR, everything
+ * that takes money out gets DR. That prefix is the authoritative record of the side, so it is
+ * matched directly rather than re-deriving the side from the voucher type here — a new type added
+ * on the server then lands on the correct report with no change needed on this screen.
+ */
+ get rows(): VoucherRow[] {
+ const all = this.report?.vouchers ?? [];
+ return all.filter((row) => this.sideOf(row) === this.side);
+ }
+
+ /**
+ * Whether a voucher has its two sides recorded against it.
+ *
+ * The registers raise a voucher when the request is made and only attach its sides once it is
+ * approved and posted, so a voucher raised before that step existed carries a number but no
+ * lines. Those rows are labelled rather than shown as an empty dash, which reads like the money
+ * went nowhere.
+ */
+ hasSides(row: VoucherRow): boolean {
+ return !!(row.debitParty || row.creditParty);
+ }
+
+ /** How many rows on this side are missing their debit/credit lines. */
+ get missingSidesCount(): number {
+ return this.rows.filter((row) => !this.hasSides(row)).length;
+ }
+
+ /** A voucher's side, from its number's prefix. Null when the number is missing or unrecognised. */
+ private sideOf(row: VoucherRow): VoucherSide | null {
+ const no = (row.voucherNo ?? '').toUpperCase();
+ if (no.startsWith('CR/')) {
+ return 'CR';
+ }
+ if (no.startsWith('DR/')) {
+ return 'DR';
+ }
+ return null;
+ }
+
+ /** How many rows on this side — not the server's count, which spans both. */
+ get count(): number {
+ return this.rows.length;
+ }
+
+ /** Total for this side only. */
+ get totalAmount(): number {
+ return this.rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
+ }
+
+ /**
+ * The part of this side's total whose notes and coins were actually counted.
+ *
+ * Always at most `totalAmount`: a voucher can be real cash and still be missing its breakdown,
+ * because the register that raised it records none. So this is the confirmed part of the cash,
+ * not the whole of it, and the gap is reported rather than treated as book entries.
+ */
+ get countedTotal(): number {
+ return this.rows.filter((row) => row.denominationCounted).reduce((sum, row) => sum + (row.amount ?? 0), 0);
+ }
+
+ /** Rows carrying no denomination breakdown — unconfirmed against the drawer, not non-cash. */
+ get uncountedCount(): number {
+ return this.rows.filter((row) => !row.denominationCounted).length;
+ }
+
+ /** How much of this side's total has no note-and-coin breakdown behind it. */
+ get uncountedTotal(): number {
+ return this.totalAmount - this.countedTotal;
+ }
+
+ /**
+ * Vouchers whose number carries neither prefix, so they appear on neither report.
+ *
+ * Counted and shown rather than silently dropped: a voucher that vanishes from both sides of the
+ * register is exactly the kind of thing a cashier must be told about, not left to discover when
+ * the two reports do not add up to the day.
+ */
+ get unclassifiedCount(): number {
+ const all = this.report?.vouchers ?? [];
+ return all.filter((row) => this.sideOf(row) === null).length;
+ }
+
/** Totals per user, for the summary a manager reads before the rows. */
get byUser(): { username: string; count: number; total: number }[] {
- const rows = this.report?.vouchers ?? [];
+ const rows = this.rows;
const tally = new Map();
for (const row of rows) {
const key = row.username ?? '—';
@@ -141,7 +290,7 @@ export class VoucherReportComponent implements OnInit {
/** Totals per voucher type — deposits against withdrawals against journals. */
get byType(): { type: string; count: number; total: number }[] {
- const rows = this.report?.vouchers ?? [];
+ const rows = this.rows;
const tally = new Map();
for (const row of rows) {
const key = row.voucherType ?? '—';
diff --git a/src/app/cashier/voucher-view/voucher-view.component.html b/src/app/cashier/voucher-view/voucher-view.component.html
deleted file mode 100644
index 64c2f7d9c..000000000
--- a/src/app/cashier/voucher-view/voucher-view.component.html
+++ /dev/null
@@ -1,144 +0,0 @@
-
-
-
-
-
-
CASHIER · VOUCHER
-
Voucher
-
- Look up a voucher by its number to see the entry behind it — which accounts were debited, which were credited,
- and for how much.
-
- This voucher does not balance — debits and credits differ. Refer it to accounts before relying on it.
-
- }
- }
-
- }
-
diff --git a/src/app/cashier/voucher-view/voucher-view.component.scss b/src/app/cashier/voucher-view/voucher-view.component.scss
deleted file mode 100644
index 1cd92f93c..000000000
--- a/src/app/cashier/voucher-view/voucher-view.component.scss
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * 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/.
- */
-
-/* Page chrome and the table come from cashier-shared.scss. Only the slip itself is defined here. */
-
-.vv-slip {
- max-width: 860px;
-}
-
-.vv-slip-head {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 16px;
- padding-bottom: 14px;
- margin-bottom: 14px;
- border-bottom: 1px solid #e5e7eb;
-}
-
-.vv-slip-head-right {
- text-align: right;
-}
-
-.vv-no {
- font-size: 18px;
- font-weight: 600;
- letter-spacing: 0.4px;
- color: #111827;
-}
-
-.vv-amount {
- font-size: 18px;
- font-weight: 600;
- color: #111827;
-}
-
-.vv-meta {
- margin-top: 2px;
- font-size: 12px;
- color: #6b7280;
-}
-
-.vv-status {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 12px;
- margin-bottom: 14px;
- background: #ecfdf5;
- border: 1px solid #a7f3d0;
- border-radius: 8px;
- color: #065f46;
- font-size: 12px;
-
- mat-icon {
- font-size: 18px;
- width: 18px;
- height: 18px;
- }
-
- /* An exception is a warning about this voucher, not a state of it — it has to stop the eye. */
- &.vv-status--exception {
- background: #fef2f2;
- border-color: #fecaca;
- color: #991b1b;
- }
-}
-
-.vv-lines {
- /* Credits sit under their debits the way they do on a written slip, so the indent carries the
- relationship that the DR/CR columns alone would leave to the reader to work out. */
- .vv-indent {
- padding-left: 28px;
- }
-
- .vv-party {
- color: #111827;
- }
-
- .vv-desc {
- margin-top: 2px;
- font-size: 11px;
- color: #6b7280;
- }
-
- tfoot td {
- font-weight: 600;
- border-top: 2px solid #e5e7eb;
- }
-}
-
-/* Printing a voucher should put the slip on paper and nothing else — the search box and buttons are
- how you reached it, not part of it. */
-@media print {
- .vv-no-print {
- display: none !important;
- }
-
- .vv-slip {
- max-width: none;
- border: none;
- box-shadow: none;
- }
-}
diff --git a/src/app/cashier/voucher-view/voucher-view.component.ts b/src/app/cashier/voucher-view/voucher-view.component.ts
deleted file mode 100644
index 53cb33701..000000000
--- a/src/app/cashier/voucher-view/voucher-view.component.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-/**
- * 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 { CommonModule } from '@angular/common';
-import { FormsModule } from '@angular/forms';
-import { ActivatedRoute, Router } from '@angular/router';
-import { MatIconModule } from '@angular/material/icon';
-import { catchError, of } from 'rxjs';
-import { CashierService } from '../cashier.service';
-
-/** One side of a voucher: a debit or a credit against a single account. */
-interface VoucherLine {
- lineNo: number;
- entryType: 'DR' | 'CR';
- glAccountId: number | null;
- savingsAccountId: number | null;
- loanAccountId: number | null;
- partyName: string | null;
- accountNo: string | null;
- amount: number;
- description: string | null;
-}
-
-interface Voucher {
- voucherNo: string;
- voucherType: string | null;
- referenceType: string | null;
- referenceId: number | null;
- amount: number | null;
- branchOfficeId: number | null;
- branchName: string | null;
- createdBy: string | null;
- createdAt: string | null;
- verificationStatus: string | null;
- lines: VoucherLine[];
- totalDebit: number;
- totalCredit: number;
- balanced: boolean;
-}
-
-/**
- * One voucher, as a slip: which accounts were debited, which credited, and for how much.
- *
- * Looked up by voucher number rather than listed, because that is how a voucher is reached — from a
- * paper slip in someone's hand, or from a number quoted on a statement.
- */
-@Component({
- selector: 'mifosx-voucher-view',
- templateUrl: './voucher-view.component.html',
- styleUrls: [
- '../cashier-shared.scss',
- './voucher-view.component.scss'
- ],
- standalone: true,
- imports: [
- CommonModule,
- FormsModule,
- MatIconModule
- ]
-})
-export class VoucherViewComponent {
- private service = inject(CashierService);
- private route = inject(ActivatedRoute);
- private router = inject(Router);
-
- voucherNo = '';
- voucher: Voucher | null = null;
- loading = false;
- /** Shown in place of the slip — a bad number and a server error read differently to the user. */
- error: string | null = null;
-
- constructor() {
- // A voucher number in the URL means someone arrived from a link or a statement rather than by
- // typing, so the slip is fetched straight away instead of showing them an empty search box.
- const fromUrl = this.route.snapshot.queryParamMap.get('voucherNo');
- if (fromUrl) {
- this.voucherNo = fromUrl;
- this.search();
- }
- }
-
- search(): void {
- const trimmed = (this.voucherNo || '').trim();
- if (!trimmed) {
- this.error = 'Enter a voucher number.';
- this.voucher = null;
- return;
- }
-
- this.loading = true;
- this.error = null;
-
- // Kept in the URL so the slip can be linked to and survives a refresh.
- this.router.navigate([], {
- relativeTo: this.route,
- queryParams: { voucherNo: trimmed },
- replaceUrl: true
- });
-
- this.service
- .voucherDetail(trimmed)
- .pipe(
- catchError((err: any) => {
- this.error =
- err?.error?.errors?.[0]?.defaultUserMessage ??
- err?.error?.developerMessage ??
- `Voucher ${trimmed} could not be loaded.`;
- return of(null);
- })
- )
- .subscribe((res: Voucher | null) => {
- this.loading = false;
- this.voucher = res;
- });
- }
-
- /** Debits first, then credits — the order a slip is read in. */
- get debitLines(): VoucherLine[] {
- return (this.voucher?.lines ?? []).filter((l) => l.entryType === 'DR');
- }
-
- get creditLines(): VoucherLine[] {
- return (this.voucher?.lines ?? []).filter((l) => l.entryType === 'CR');
- }
-
- /**
- * A voucher raised before its sides were recorded. The voucher is real and its number valid — only
- * the posting behind it was never captured — so this reads as an older record, not a broken one.
- */
- get hasNoLines(): boolean {
- return !!this.voucher && this.voucher.lines.length === 0;
- }
-
- print(): void {
- window.print();
- }
-}
diff --git a/src/app/clerk/dividend-dashboard.service.ts b/src/app/clerk/dividend-dashboard.service.ts
index dbd093ce5..9493f5632 100644
--- a/src/app/clerk/dividend-dashboard.service.ts
+++ b/src/app/clerk/dividend-dashboard.service.ts
@@ -39,5 +39,4 @@ export class DividendDashboardService {
const params = status && status !== 'ALL' ? { status } : {};
return this.http.get('/clerk/dividend-dashboard/locks', { params });
}
-
}
diff --git a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss
index e59af736e..a452f8856 100644
--- a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss
+++ b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.scss
@@ -350,6 +350,7 @@
flex: 0 0 auto;
padding: 9px 14px;
border: 0;
+
/* The active marker is this underline, so every tab reserves the space for it — otherwise the
strip would shift by 2px each time the selection moved. */
border-bottom: 2px solid transparent;
diff --git a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts
index b486325d0..1c5ea3642 100644
--- a/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts
+++ b/src/app/clerk/dividend-dashboard/dividend-dashboard.component.ts
@@ -102,7 +102,6 @@ export class DividendDashboardComponent implements OnInit {
lockYears: string[] = [];
lockYearFilter = 'ALL';
-
ngOnInit(): void {
this.loadSummary();
this.loadDeclarations();
diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts
index 18a2fef29..f6f8db064 100644
--- a/src/app/clients/clients.service.ts
+++ b/src/app/clients/clients.service.ts
@@ -1043,4 +1043,56 @@ export class ClientsService {
getKycContactLog(clientId: string | number): Observable {
return this.http.get(`/kyc/exceptions/${clientId}/contact-log`);
}
+
+ /**
+ * Clients whose periodic re-KYC due date has passed, oldest first.
+ *
+ * Drives the expiry worklist. A client drops out of this list the moment their re-KYC is
+ * completed, because renewing pushes the expiry date into the future.
+ */
+ getLapsedKyc(): Observable {
+ return this.http.get('/kyc/exceptions/lapsed');
+ }
+
+ /**
+ * Savings accounts the dormancy job has marked.
+ *
+ * @param subStatus 100 inactive, 200 dormant, 300 escheat; omit for all three
+ */
+ getDormantAccounts(subStatus?: number): Observable {
+ const params = subStatus ? new HttpParams().set('subStatus', String(subStatus)) : undefined;
+ return this.http.get('/savingsaccounts/dormant', { params });
+ }
+
+ /**
+ * Records that the escheated balances of the given accounts were remitted to RBI's DEAF.
+ *
+ * The transfer itself happens outside this system; this only writes down that it was made, which
+ * is what lets the escheat report tell remitted money from money still sitting with the bank.
+ * Accounts that are not escheated, or already recorded, are skipped server-side — the result
+ * carries `recorded` and `skipped` counts.
+ */
+ recordDeafRemittance(data: {
+ savingsAccountIds: number[];
+ remittedOn: string;
+ deafReference?: string;
+ remarks?: string;
+ locale?: string;
+ dateFormat?: string;
+ }): Observable {
+ return this.http.post('/savingsaccounts/deaf-remittance', {
+ locale: 'en',
+ dateFormat: 'yyyy-MM-dd',
+ ...data
+ });
+ }
+
+ /** Records that a customer claimed a remitted balance back and the bank recovered it from DEAF. */
+ refundDeafRemittance(savingsAccountId: number, refundedOn: string): Observable {
+ return this.http.post(`/savingsaccounts/deaf-remittance/${savingsAccountId}/refund`, {
+ locale: 'en',
+ dateFormat: 'yyyy-MM-dd',
+ refundedOn
+ });
+ }
}
diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html
index 76cc5daa2..7338b6eb3 100644
--- a/src/app/core/shell/shell.component.html
+++ b/src/app/core/shell/shell.component.html
@@ -184,47 +184,6 @@
Today's Activity
}
-
-
-
- Exceptions
-
-
-
- Voucher
-
-
-
- Voucher Report
- ESC Show Header
} @else {
@@ -288,27 +247,17 @@
- Day Book
+ Received
- Exceptions
-
-
- Voucher
-
-
- Voucher Report
+ Payment
Risk Review Report
+
+
+
-
- Individual item-wise figures for this schedule are not broken
- out here — only the audited total is shown, to avoid presenting a sub-item split that could not be
- independently verified against the source statement.
+ Individual item-wise figures for this schedule are not broken out
+ here — only the audited total is shown, to avoid presenting a sub-item split that could not be independently
+ verified against the source statement.
diff --git a/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.ts b/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.ts
index 1d9f12a9a..eddee214a 100644
--- a/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.ts
+++ b/src/app/home/dashboard/accounting-reports-dialog/accounting-reports-dialog.component.ts
@@ -150,7 +150,13 @@ export class AccountingReportsDialogComponent {
// -------------------- P&L Data (₹ in Lakhs) — FY 2025-26 audited figures --------------------
pnl: PnlLine[] = [
- { code: 'I-01', group: 'INCOME', subgroup: 'Interest Earned', label: 'Interest on Advances & Bills', amount: 102641.73 },
+ {
+ code: 'I-01',
+ group: 'INCOME',
+ subgroup: 'Interest Earned',
+ label: 'Interest on Advances & Bills',
+ amount: 102641.73
+ },
{ code: 'I-02', group: 'INCOME', subgroup: 'Interest Earned', label: 'Income on Investments', amount: 41936.75 },
{
code: 'I-03',
@@ -167,7 +173,13 @@ export class AccountingReportsDialogComponent {
label: 'Net Income on Sale of Fixed Assets',
amount: 0.3
},
- { code: 'I-06', group: 'INCOME', subgroup: 'Other Income', label: 'Other Income (ATM/POS/UPI/Rental/Misc)', amount: 280 },
+ {
+ code: 'I-06',
+ group: 'INCOME',
+ subgroup: 'Other Income',
+ label: 'Other Income (ATM/POS/UPI/Rental/Misc)',
+ amount: 280
+ },
{
code: 'E-01',
group: 'EXPENDITURE',
diff --git a/src/app/home/dashboard/cims-xbrl-dialog/cims-xbrl-dialog.component.ts b/src/app/home/dashboard/cims-xbrl-dialog/cims-xbrl-dialog.component.ts
index 85a72408d..9bcfe90ce 100644
--- a/src/app/home/dashboard/cims-xbrl-dialog/cims-xbrl-dialog.component.ts
+++ b/src/app/home/dashboard/cims-xbrl-dialog/cims-xbrl-dialog.component.ts
@@ -659,7 +659,7 @@ export class CimsXbrlDialogComponent {
name: 'OSS-4',
fullName: 'Connected Lending & Large Exposure Statement',
description:
- 'Quarterly statement of loans to directors/relatives, senior officers, and exposures exceeding prudential thresholds. Illustrative figures — no source filing was provided for this return; values are modelled on this bank\'s own capital/advances scale for consistency.',
+ "Quarterly statement of loans to directors/relatives, senior officers, and exposures exceeding prudential thresholds. Illustrative figures — no source filing was provided for this return; values are modelled on this bank's own capital/advances scale for consistency.",
cimsCode: 'CIMS_OSS_004',
taxonomy: 'rbi-cims-oss4-2024-04-01',
periodicity: 'Quarterly',
@@ -729,7 +729,7 @@ export class CimsXbrlDialogComponent {
name: 'OSS-5',
fullName: 'Institutional & Membership Profile',
description:
- 'Annual statement of branch network, staff strength, membership and depositor/borrower base. Illustrative figures — no source filing was provided for this return; membership share capital is aligned to this bank\'s real paid-up capital figure.',
+ "Annual statement of branch network, staff strength, membership and depositor/borrower base. Illustrative figures — no source filing was provided for this return; membership share capital is aligned to this bank's real paid-up capital figure.",
cimsCode: 'CIMS_OSS_005',
taxonomy: 'rbi-cims-oss5-2024-04-01',
periodicity: 'Annual',
diff --git a/src/app/home/dashboard/fd-rd-entry-dialog/fd-rd-entry-dialog.component.ts b/src/app/home/dashboard/fd-rd-entry-dialog/fd-rd-entry-dialog.component.ts
index 3eed23f0b..380d44cf6 100644
--- a/src/app/home/dashboard/fd-rd-entry-dialog/fd-rd-entry-dialog.component.ts
+++ b/src/app/home/dashboard/fd-rd-entry-dialog/fd-rd-entry-dialog.component.ts
@@ -27,7 +27,15 @@ interface DemoClient {
standalone: true,
templateUrl: './fd-rd-entry-dialog.component.html',
styleUrls: ['./fd-rd-entry-dialog.component.scss'],
- imports: [CommonModule, DecimalPipe, FormsModule, MatDialogModule, MatButtonModule, MatTooltipModule, FaIconComponent]
+ imports: [
+ CommonModule,
+ DecimalPipe,
+ FormsModule,
+ MatDialogModule,
+ MatButtonModule,
+ MatTooltipModule,
+ FaIconComponent
+ ]
})
export class FdRdEntryDialogComponent {
private dialogRef = inject(MatDialogRef);
@@ -53,8 +61,16 @@ export class FdRdEntryDialogComponent {
{ id: 'C-10689', name: 'Vikram Traders' }
];
- payModes = ['Cash', 'Cheque', 'Transfer from Savings A/c'];
- interestPayoutOptions = ['Cumulative (Paid on Maturity)', 'Monthly Payout', 'Quarterly Payout'];
+ payModes = [
+ 'Cash',
+ 'Cheque',
+ 'Transfer from Savings A/c'
+ ];
+ interestPayoutOptions = [
+ 'Cumulative (Paid on Maturity)',
+ 'Monthly Payout',
+ 'Quarterly Payout'
+ ];
maturityInstructions = [
'Renew Principal & Interest',
'Renew Principal Only, Payout Interest',
diff --git a/src/app/interest-report/interest-report.component.html b/src/app/interest-report/interest-report.component.html
new file mode 100644
index 000000000..3d7b36957
--- /dev/null
+++ b/src/app/interest-report/interest-report.component.html
@@ -0,0 +1,205 @@
+
+
+
+
+
+
+
Savings Interest Posted
+
+ Interest actually credited to members, grouped by the rhythm each product posts on. An account appears under its
+ own product's posting period and nowhere else.
+
+
+
+
+
+
+
+
+ @if (errorMessage) {
+
{{ errorMessage }}
+ }
+
+
+ @if (rhythmWarnings.length > 0) {
+
+
+
+ Postings do not follow the product's rhythm.
+ @for (w of rhythmWarnings; track w.product) {
+
+ {{ w.product }} is configured {{ w.label }} ({{ w.expected }}) but has paid on
+ {{ w.dates }} different dates.
+
+ }
+
+ These were written by the platform's own savings interest job, which posts a day at a time regardless of the
+ product's period. Every posting below is real money credited to a member — the figures are correct, but a
+ period total will not match the rhythm the product is set to.
+
+ No {{ activeMeta.label.toLowerCase() }} interest posted in this period.
+ @if (activePeriod !== 1) {
+ A cycle that has not ended yet has nothing to show.
+ }
+
diff --git a/src/app/interest-report/interest-report.component.scss b/src/app/interest-report/interest-report.component.scss
new file mode 100644
index 000000000..0f7994039
--- /dev/null
+++ b/src/app/interest-report/interest-report.component.scss
@@ -0,0 +1,470 @@
+/**
+ * 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/.
+ */
+
+:host {
+ --i-navy: #1a237e;
+ --i-blue: #1d4ed8;
+ --i-blue-l: #eff6ff;
+ --i-green: #15803d;
+ --i-green-l: #dcfce7;
+ --i-amber: #b45309;
+ --i-amber-l: #fef3c7;
+ --i-red: #c0392b;
+ --i-red-l: #fdecea;
+ --i-g50: #f8fafc;
+ --i-g100: #f1f5f9;
+ --i-g200: #e2e8f0;
+ --i-g300: #cbd5e1;
+ --i-g500: #64748b;
+ --i-g700: #334155;
+ --i-g900: #0f172a;
+
+ display: block;
+ font-family: 'DM Sans', system-ui, sans-serif;
+ color: var(--i-g900);
+ background: var(--i-g50);
+ min-height: 100%;
+ padding: 16px;
+}
+
+.mono {
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
+ font-size: 11.5px;
+}
+
+.num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+
+.strong {
+ font-weight: 700;
+}
+
+/* ── Header ─────────────────────────────────────────────────────────────── */
+
+.ir-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 12px;
+}
+
+.ir-head-t {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--i-navy);
+}
+
+.ir-head-s {
+ margin-top: 3px;
+ font-size: 12px;
+ color: var(--i-g500);
+ max-width: 760px;
+ line-height: 1.5;
+}
+
+.ir-head-a {
+ display: flex;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+.ir-alert {
+ padding: 10px 14px;
+ margin-bottom: 12px;
+ background: var(--i-red-l);
+ border: 1px solid #f5b7b1;
+ border-radius: 7px;
+ font-size: 12px;
+ color: var(--i-red);
+}
+
+/* ── Filters ────────────────────────────────────────────────────────────── */
+
+.ir-filters {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 12px;
+ padding: 13px 14px;
+ margin-bottom: 14px;
+ background: #fff;
+ border: 1px solid var(--i-g200);
+ border-radius: 7px;
+}
+
+.ir-field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 150px;
+
+ label {
+ font-size: 10.5px;
+ font-weight: 700;
+ color: var(--i-g500);
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ }
+
+ &--wide {
+ flex: 1;
+ min-width: 240px;
+ }
+
+ &--btn {
+ min-width: 0;
+ }
+}
+
+.ir-inp {
+ padding: 7px 10px;
+ border: 1px solid var(--i-g300);
+ border-radius: 5px;
+ font: inherit;
+ font-size: 12px;
+ background: #fff;
+}
+
+/* ── Buttons ────────────────────────────────────────────────────────────── */
+
+.ir-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 13px;
+ border: 1px solid var(--i-g300);
+ border-radius: 5px;
+ background: #fff;
+ font: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--i-g700);
+ cursor: pointer;
+
+ &:hover:not(:disabled) {
+ border-color: var(--i-blue);
+ color: var(--i-blue);
+ }
+
+ &:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+ }
+
+ &--slim {
+ padding: 6px 11px;
+ font-size: 11.5px;
+ }
+
+ &--primary {
+ background: var(--i-blue);
+ border-color: var(--i-blue);
+ color: #fff;
+
+ &:hover:not(:disabled) {
+ background: #1e40af;
+ color: #fff;
+ }
+ }
+}
+
+.ir-mini {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 9px;
+ border: 1px solid var(--i-g300);
+ border-radius: 5px;
+ background: #fff;
+ font: inherit;
+ font-size: 10.5px;
+ font-weight: 600;
+ color: var(--i-g700);
+ cursor: pointer;
+
+ &:hover:not(:disabled) {
+ border-color: var(--i-blue);
+ color: var(--i-blue);
+ }
+
+ &:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+}
+
+/* ── One card per posting rhythm ────────────────────────────────────────── */
+
+.ir-tabs {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 10px;
+ margin-bottom: 12px;
+}
+
+.ir-tab {
+ text-align: left;
+ padding: 11px 13px;
+ border: 1px solid var(--i-g200);
+ border-top: 3px solid var(--i-g300);
+ border-radius: 7px;
+ background: #fff;
+ font: inherit;
+ cursor: pointer;
+ transition: box-shadow 0.12s;
+
+ &:hover {
+ box-shadow: 0 2px 8px rgb(15 23 42 / 9%);
+ }
+
+ /* The rhythm on display stays obvious while its rows are being read. */
+ &--on {
+ background: var(--i-blue-l);
+ border-top-color: var(--i-blue);
+ box-shadow: inset 0 0 0 1px #bfdbfe;
+ }
+}
+
+.ir-tab-h {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.ir-tab-l {
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.ir-tab-n {
+ font-size: 19px;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.ir-tab-d {
+ margin-top: 2px;
+ font-size: 10px;
+ color: var(--i-g500);
+}
+
+.ir-tab-t {
+ margin-top: 6px;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--i-green);
+}
+
+/* ── Context strip ──────────────────────────────────────────────────────── */
+
+.ir-context {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 10px 14px;
+ margin-bottom: 12px;
+ background: var(--i-blue-l);
+ border: 1px solid #bfdbfe;
+ border-radius: 7px;
+ font-size: 11.5px;
+ line-height: 1.55;
+ color: var(--i-g700);
+}
+
+.ir-context-i {
+ display: flex;
+ gap: 9px;
+
+ fa-icon {
+ color: var(--i-blue);
+ margin-top: 2px;
+ }
+}
+
+.ir-context-t {
+ white-space: nowrap;
+ color: var(--i-g500);
+}
+
+.ir-context-c {
+ font-weight: 700;
+ color: var(--i-g900);
+}
+
+/* ── One group per posting date ─────────────────────────────────────────── */
+
+.ir-group {
+ margin-bottom: 14px;
+ background: #fff;
+ border: 1px solid var(--i-g200);
+ border-radius: 7px;
+ overflow: hidden;
+}
+
+/* The date is the unit an officer reconciles against: a run pays a whole product on one day. */
+.ir-group-h {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 9px 14px;
+ background: var(--i-g100);
+ border-bottom: 1px solid var(--i-g200);
+}
+
+.ir-group-d {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ font-size: 12.5px;
+ font-weight: 700;
+ color: var(--i-navy);
+}
+
+.ir-group-m {
+ font-size: 11.5px;
+ color: var(--i-g500);
+
+ strong {
+ color: var(--i-green);
+ }
+}
+
+/* ── Table ──────────────────────────────────────────────────────────────── */
+
+.ir-tablewrap {
+ overflow-x: auto;
+}
+
+.ir-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+
+ th {
+ text-align: left;
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--i-g200);
+ font-size: 10.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ color: var(--i-g500);
+ white-space: nowrap;
+
+ &.num {
+ text-align: right;
+ }
+ }
+
+ td {
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--i-g100);
+ }
+
+ tbody tr:hover {
+ background: var(--i-g50);
+ }
+
+ tbody tr:last-child td {
+ border-bottom: 0;
+ }
+}
+
+.ir-prod {
+ max-width: 210px;
+ font-size: 11.5px;
+}
+
+/* The figure the report exists for. */
+.ir-amt {
+ color: var(--i-green);
+}
+
+.ir-empty {
+ padding: 34px;
+ text-align: center;
+ font-size: 12.5px;
+ color: var(--i-g500);
+ background: #fff;
+ border: 1px solid var(--i-g200);
+ border-radius: 7px;
+}
+
+/* ── Per-rhythm period control ──────────────────────────────────────────── */
+
+.ir-pick {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ padding: 9px 14px;
+ margin-bottom: 10px;
+ background: #fff;
+ border: 1px solid var(--i-g200);
+ border-radius: 7px;
+}
+
+.ir-pick-l {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ color: var(--i-g500);
+}
+
+.ir-spacer {
+ flex: 1;
+}
+
+.ir-pick-hint {
+ font-size: 11px;
+ color: var(--i-g500);
+}
+
+/* The dropdown carries a date, a count and a total, so it needs room. */
+.ir-inp--pick {
+ min-width: 20rem;
+}
+
+/* ── Rhythm mismatch warning ────────────────────────────────────────────── */
+
+.ir-warn {
+ display: flex;
+ gap: 10px;
+ padding: 11px 14px;
+ margin-bottom: 12px;
+ background: var(--i-amber-l);
+ border: 1px solid #fcd34d;
+ border-left: 4px solid var(--i-amber);
+ border-radius: 7px;
+ font-size: 12px;
+ line-height: 1.55;
+ color: var(--i-g700);
+
+ fa-icon {
+ color: var(--i-amber);
+ margin-top: 2px;
+ }
+}
+
+.ir-warn-row {
+ margin-top: 4px;
+}
+
+.ir-warn-note {
+ margin-top: 6px;
+ font-size: 11px;
+ color: var(--i-g500);
+}
diff --git a/src/app/interest-report/interest-report.component.ts b/src/app/interest-report/interest-report.component.ts
new file mode 100644
index 000000000..948dff9bd
--- /dev/null
+++ b/src/app/interest-report/interest-report.component.ts
@@ -0,0 +1,445 @@
+/**
+ * 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/.
+ */
+
+/** Angular Imports */
+import { Component, OnInit, inject } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+
+/** Custom Modules */
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+
+/** Custom Services */
+import {
+ SavingsDailyInterestService,
+ SavingsInterestPostingRow
+} from '../savings-daily-interest/savings-daily-interest.service';
+
+/** Shared report helpers */
+import { exportWorkbook, ExportSheetSpec } from '../cashier/cashier-export';
+
+/**
+ * One posting rhythm, as the product configures it.
+ *
+ * The enum values are the product's own {@code interest_posting_period_enum}; they are not
+ * consecutive, and 2 and 3 are unused in this platform.
+ */
+interface PostingPeriod {
+ enumValue: number;
+ key: string;
+ label: string;
+ /** What "the end of the cycle" means for this rhythm, in the officer's words. */
+ cadence: string;
+ /** What the period control is asking for, in the terms this rhythm is read in. */
+ pickLabel: string;
+ /** The unit the control counts in, for the "N with postings" hint. */
+ pickUnit: string;
+}
+
+@Component({
+ selector: 'mifosx-interest-report',
+ templateUrl: './interest-report.component.html',
+ styleUrls: ['./interest-report.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ FormsModule,
+ FaIconComponent
+ ]
+})
+export class InterestReportComponent implements OnInit {
+ private service = inject(SavingsDailyInterestService);
+ private router = inject(Router);
+
+ /**
+ * Each rhythm is its own report.
+ *
+ * A monthly product's month-end posting and a quarterly product's quarter-end posting are
+ * different events on different dates; listing them together reads as one uneven series and
+ * makes a month's total impossible to state. So an account appears under the rhythm its own
+ * product posts on, and nowhere else.
+ */
+ readonly periods: PostingPeriod[] = [
+ {
+ enumValue: 1,
+ key: 'daily',
+ label: 'Daily',
+ cadence: 'Posted every day',
+ pickLabel: 'Posted on',
+ pickUnit: 'day'
+ },
+ {
+ enumValue: 4,
+ key: 'monthly',
+ label: 'Monthly',
+ cadence: 'Posted at each month end',
+ pickLabel: 'Month end',
+ pickUnit: 'month end'
+ },
+ {
+ enumValue: 5,
+ key: 'quarterly',
+ label: 'Quarterly',
+ cadence: 'Posted at each quarter end',
+ pickLabel: 'Quarter end',
+ pickUnit: 'quarter end'
+ },
+ {
+ enumValue: 6,
+ key: 'halfYearly',
+ label: 'Half-yearly',
+ cadence: 'Posted every six months',
+ pickLabel: 'Half-year end',
+ pickUnit: 'half-year end'
+ },
+ {
+ enumValue: 7,
+ key: 'yearly',
+ label: 'Yearly',
+ cadence: 'Posted at each year end',
+ pickLabel: 'Year end',
+ pickUnit: 'year end'
+ }
+ ];
+
+ activePeriod = 4;
+
+ /** Every posting, across all rhythms — the tabs read from this. */
+ rows: SavingsInterestPostingRow[] = [];
+ loading = false;
+ errorMessage = '';
+
+ /** Filters, shared by every rhythm. */
+ fromDate = '';
+ toDate = '';
+ search = '';
+
+ ngOnInit(): void {
+ this.load();
+ }
+
+ /**
+ * Loads every posting once and splits it by rhythm in the browser.
+ *
+ * One request rather than five: the tab counts have to be right before a tab is opened, and
+ * asking per rhythm would leave them blank until each was visited.
+ */
+ load(): void {
+ this.loading = true;
+ this.errorMessage = '';
+
+ this.service
+ .getPostings({
+ fromDate: this.fromDate || undefined,
+ toDate: this.toDate || undefined
+ })
+ .subscribe({
+ next: (data) => {
+ this.rows = data ?? [];
+ this.loading = false;
+ },
+ error: (err: any) => {
+ this.rows = [];
+ this.loading = false;
+ this.errorMessage =
+ err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the interest postings.';
+ }
+ });
+ }
+
+ // ── Per-rhythm views ───────────────────────────────────────────────────────
+
+ /**
+ * The period each rhythm is narrowed to, keyed by its enum.
+ *
+ * Each rhythm carries its own, because the unit an officer picks differs: a daily report is read
+ * one day at a time, a monthly one a month at a time. A single shared control would force the
+ * quarterly report to be filtered by a day, which selects one posting date and hides the rest of
+ * the quarter.
+ */
+ periodPick: Record = {};
+
+ private matchesPick(row: SavingsInterestPostingRow, periodEnum: number): boolean {
+ const pick = this.periodPick[periodEnum];
+ return !pick || row.posting_date === pick;
+ }
+
+ /**
+ * The cycle ends a rhythm actually posted on, each with how many accounts were paid.
+ *
+ * A dropdown of real posting dates rather than a free date picker: a rhythm posts on a handful of
+ * dates a year, and a calendar would offer three hundred and sixty-odd days that hold nothing.
+ * The account count sits in the option itself, so the answer to "how many accounts were paid at
+ * this quarter end" is visible before the filter is even applied.
+ */
+ availablePicks(periodEnum: number): { date: string; accounts: number; total: number }[] {
+ const byDate = new Map();
+ for (const row of this.rows) {
+ if (row.posting_period_enum !== periodEnum || !row.posting_date) {
+ continue;
+ }
+ const list = byDate.get(row.posting_date);
+ if (list) {
+ list.push(row);
+ } else {
+ byDate.set(row.posting_date, [row]);
+ }
+ }
+
+ return [...byDate.entries()]
+ .sort((a, b) => b[0].localeCompare(a[0]))
+ .map(
+ ([
+ date,
+ rows
+ ]) => ({
+ date,
+ accounts: new Set(rows.map((r) => r.savings_account_id)).size,
+ total: rows.reduce((s, r) => s + Number(r.interest_amount ?? 0), 0)
+ })
+ );
+ }
+
+ clearPick(periodEnum: number): void {
+ delete this.periodPick[periodEnum];
+ }
+
+ /**
+ * Products whose postings do not follow the rhythm they are configured for.
+ *
+ * A quarterly product posts four times a year. Where one shows a hundred and thirty-five posting
+ * dates, something other than this ledger has paid it — stock Fineract's own savings interest job
+ * writes backdated postings a day at a time and pays no attention to the product's posting
+ * period. The report cannot hide those: the money reached the members and the transactions are
+ * real. What it can do is say so, so a total read off this screen is not mistaken for the rhythm
+ * the product is meant to keep.
+ *
+ * The threshold is generous — a rhythm is only called out at more than double the dates it could
+ * legitimately produce across the range on display, so a backdated correction or a re-run does
+ * not raise it.
+ */
+ get rhythmWarnings(): { product: string; label: string; dates: number; expected: string }[] {
+ const expectedMax: Record = { 1: 100000, 4: 24, 5: 8, 6: 4, 7: 2 };
+ const expectedWord: Record = {
+ 1: 'daily',
+ 4: 'about one a month',
+ 5: 'about four a year',
+ 6: 'two a year',
+ 7: 'one a year'
+ };
+
+ const byProduct = new Map }>();
+ for (const row of this.rows) {
+ const entry = byProduct.get(row.product_name);
+ if (entry) {
+ entry.dates.add(row.posting_date);
+ } else {
+ byProduct.set(row.product_name, {
+ label: this.periods.find((p) => p.enumValue === row.posting_period_enum)?.label ?? '—',
+ period: row.posting_period_enum,
+ dates: new Set([row.posting_date])
+ });
+ }
+ }
+
+ return [...byProduct.entries()]
+ .filter(
+ ([
+ ,
+ v
+ ]) => v.dates.size > (expectedMax[v.period] ?? 100000)
+ )
+ .map(
+ ([
+ product,
+ v
+ ]) => ({
+ product,
+ label: v.label,
+ dates: v.dates.size,
+ expected: expectedWord[v.period] ?? '—'
+ })
+ );
+ }
+
+ private rowsFor(periodEnum: number): SavingsInterestPostingRow[] {
+ const needle = this.search.trim().toLowerCase();
+ return this.rows.filter((r) => {
+ if (r.posting_period_enum !== periodEnum) {
+ return false;
+ }
+ if (!this.matchesPick(r, periodEnum)) {
+ return false;
+ }
+ if (needle) {
+ const hay = `${r.account_no} ${r.client_name ?? ''} ${r.product_name}`.toLowerCase();
+ if (!hay.includes(needle)) {
+ return false;
+ }
+ }
+ return true;
+ });
+ }
+
+ get visibleRows(): SavingsInterestPostingRow[] {
+ return this.rowsFor(this.activePeriod);
+ }
+
+ countFor(periodEnum: number): number {
+ return this.rowsFor(periodEnum).length;
+ }
+
+ totalFor(periodEnum: number): number {
+ return this.rowsFor(periodEnum).reduce((s, r) => s + Number(r.interest_amount ?? 0), 0);
+ }
+
+ get visibleTotal(): number {
+ return this.totalFor(this.activePeriod);
+ }
+
+ /** Distinct accounts paid on the tab in view — a count of rows would double-count re-postings. */
+ get visibleAccounts(): number {
+ return new Set(this.visibleRows.map((r) => r.savings_account_id)).size;
+ }
+
+ get activeMeta(): PostingPeriod {
+ return this.periods.find((p) => p.enumValue === this.activePeriod) ?? this.periods[1];
+ }
+
+ /**
+ * The postings on the tab in view, grouped by the day they were paid.
+ *
+ * A posting run pays a whole product on one date, so the date is the unit an officer reconciles
+ * against — "what went out at this month end" — rather than the individual row.
+ */
+ get groupedByDate(): { date: string; rows: SavingsInterestPostingRow[]; total: number; accounts: number }[] {
+ const byDate = new Map();
+ for (const row of this.visibleRows) {
+ const key = row.posting_date ?? '';
+ const list = byDate.get(key);
+ if (list) {
+ list.push(row);
+ } else {
+ byDate.set(key, [row]);
+ }
+ }
+
+ return [...byDate.entries()]
+ .sort((a, b) => b[0].localeCompare(a[0]))
+ .map(
+ ([
+ date,
+ rows
+ ]) => ({
+ date,
+ rows,
+ total: rows.reduce((s, r) => s + Number(r.interest_amount ?? 0), 0),
+ accounts: new Set(rows.map((r) => r.savings_account_id)).size
+ })
+ );
+ }
+
+ showPeriod(periodEnum: number): void {
+ this.activePeriod = periodEnum;
+ }
+
+ resetFilters(): void {
+ this.fromDate = '';
+ this.toDate = '';
+ this.search = '';
+ this.periodPick = {};
+ this.load();
+ }
+
+ // ── Presentation ───────────────────────────────────────────────────────────
+
+ /** Posting dates arrive as ISO yyyy-MM-dd. */
+ formatDate(value: string | null | undefined): string {
+ if (!value) {
+ return '—';
+ }
+ const [
+ y,
+ m,
+ d
+ ] = value.split('-').map(Number);
+ if (!y || !m || !d) {
+ return value;
+ }
+ return new Date(y, m - 1, d).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' });
+ }
+
+ openAccount(row: SavingsInterestPostingRow): void {
+ if (row.client_id == null) {
+ return;
+ }
+ this.router.navigate([
+ '/clients',
+ row.client_id,
+ 'savings-accounts',
+ row.savings_account_id,
+ 'transactions'
+ ]);
+ }
+
+ // ── Export ─────────────────────────────────────────────────────────────────
+
+ /**
+ * One sheet per rhythm, so the workbook mirrors the screen.
+ *
+ * A rhythm with nothing posted still gets its sheet: an empty Quarterly sheet says the quarter
+ * has not ended yet, which a missing sheet would leave the reader to guess at.
+ */
+ exportExcel(): void {
+ const stamp = `${this.fromDate || 'from inception'} to ${this.toDate || 'date'}`;
+
+ const sheets: ExportSheetSpec[] = this.periods.map((p) => {
+ const rows = this.rowsFor(p.enumValue);
+ return {
+ sheetName: p.label,
+ title: `Savings Interest Posted — ${p.label}`,
+ subtitle: `${p.cadence} · ${stamp} · ${rows.length} posting(s), total ${this.totalFor(p.enumValue).toFixed(2)}`,
+ headers: [
+ 'Posting Date',
+ 'Account No',
+ 'Customer',
+ 'Product',
+ 'Rate %',
+ 'Balance Before',
+ 'Interest Posted',
+ 'Balance After',
+ 'Branch'
+ ],
+ widths: [
+ 14,
+ 20,
+ 28,
+ 26,
+ 9,
+ 16,
+ 16,
+ 16,
+ 22
+ ],
+ rows: rows.map((r) => [
+ this.formatDate(r.posting_date),
+ r.account_no,
+ r.client_name || '—',
+ r.product_name,
+ Number(r.interest_rate ?? 0),
+ Number(r.balance_before ?? 0),
+ Number(r.interest_amount ?? 0),
+ Number(r.balance_after ?? 0),
+ r.office_name || '—'
+ ])
+ };
+ });
+
+ exportWorkbook(sheets, `savings-interest-posted-${new Date().toISOString().slice(0, 10)}`);
+ }
+}
diff --git a/src/app/kyc-expiry/kyc-expiry.component.html b/src/app/kyc-expiry/kyc-expiry.component.html
new file mode 100644
index 000000000..5879612c5
--- /dev/null
+++ b/src/app/kyc-expiry/kyc-expiry.component.html
@@ -0,0 +1,430 @@
+
+
+
+
+
+
+
Expired KYC & Customer Outreach
+
+ Customers whose periodic re-KYC has lapsed: what each restriction stops, and the record of every attempt to
+ reach them. A customer drops off this list the moment their re-KYC is completed.
+
+
+
+
+
+
+
+
+
+ RBI Master Direction on KYC. An account is not closed or restricted merely because periodic
+ re-KYC is pending. Nothing stops for the first {{ policy.graceDays }} days past the due date;
+ debits stop after that, and the account is marked inoperative after
+ {{ policy.inoperativeDays }} days. Restrictions are applied to the customer's savings accounts
+ overnight and lift as soon as the re-KYC is completed. These credits are never held up:
+ {{ alwaysAllowed.join(' · ') }}
+
+ @if (contactDestinationMissing) {
+
+ No destination on file for this channel — the attempt will be recorded but nothing is sent.
+ } @else {
+
+ @if (contactChannelTransmits) {
+ Will be sent to {{ contactDestination }}
+ } @else {
+ Recorded as made {{ contactDestination }}
+ }
+ }
+
+
+ This records that the customer was reached and told what is required. It does not complete the re-KYC — the
+ restriction ladder keeps running until the KYC record itself is renewed.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+}
+
+@if (toastVisible) {
+
{{ toastMsg }}
+}
diff --git a/src/app/kyc-expiry/kyc-expiry.component.scss b/src/app/kyc-expiry/kyc-expiry.component.scss
new file mode 100644
index 000000000..45a37f6d6
--- /dev/null
+++ b/src/app/kyc-expiry/kyc-expiry.component.scss
@@ -0,0 +1,814 @@
+/**
+ * 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/.
+ */
+
+:host {
+ --k-navy: #1a237e;
+ --k-blue: #1d4ed8;
+ --k-blue-l: #eff6ff;
+ --k-green: #15803d;
+ --k-green-l: #dcfce7;
+ --k-amber: #b45309;
+ --k-amber-l: #fef3c7;
+ --k-red: #c0392b;
+ --k-red-l: #fdecea;
+ --k-crit: #7f1d1d;
+ --k-crit-l: #fee2e2;
+ --k-g50: #f8fafc;
+ --k-g100: #f1f5f9;
+ --k-g200: #e2e8f0;
+ --k-g300: #cbd5e1;
+ --k-g500: #64748b;
+ --k-g700: #334155;
+ --k-g900: #0f172a;
+
+ display: block;
+ font-family: 'DM Sans', system-ui, sans-serif;
+ color: var(--k-g900);
+ background: var(--k-g50);
+ min-height: 100%;
+ padding: 16px;
+}
+
+.mono {
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
+ font-size: 11.5px;
+}
+
+/* ── Header ─────────────────────────────────────────────────────────────── */
+
+.ke-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 12px;
+}
+
+.ke-head-t {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--k-navy);
+}
+
+.ke-head-s {
+ margin-top: 3px;
+ font-size: 12px;
+ color: var(--k-g500);
+ max-width: 760px;
+}
+
+/* The rule the ladder implements, stated once at the top. */
+.ke-policy {
+ display: flex;
+ gap: 10px;
+ padding: 11px 14px;
+ margin-bottom: 14px;
+ background: var(--k-blue-l);
+ border: 1px solid #bfdbfe;
+ border-radius: 7px;
+ font-size: 12px;
+ line-height: 1.55;
+ color: var(--k-g700);
+
+ fa-icon {
+ color: var(--k-blue);
+ margin-top: 2px;
+ }
+}
+
+.ke-allow-list {
+ font-weight: 600;
+ color: var(--k-green);
+}
+
+.ke-warnbar {
+ padding: 9px 13px;
+ margin-bottom: 12px;
+ background: var(--k-amber-l);
+ border: 1px solid #fcd34d;
+ border-radius: 6px;
+ font-size: 12px;
+ color: var(--k-amber);
+}
+
+/* ── Ladder cards ───────────────────────────────────────────────────────── */
+
+.ke-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(215px, 1fr));
+ gap: 10px;
+ margin-bottom: 14px;
+}
+
+.ke-stat {
+ text-align: left;
+ padding: 12px 14px;
+ border: 1px solid var(--k-g200);
+ border-left: 4px solid var(--k-g300);
+ border-radius: 7px;
+ background: #fff;
+ font: inherit;
+ cursor: pointer;
+ transition: box-shadow 0.12s;
+
+ &:hover {
+ box-shadow: 0 2px 8px rgb(15 23 42 / 9%);
+ }
+
+ &--info {
+ border-left-color: var(--k-blue);
+ }
+
+ &--warn {
+ border-left-color: var(--k-amber);
+ }
+
+ &--danger {
+ border-left-color: var(--k-red);
+ }
+
+ &--critical {
+ border-left-color: var(--k-crit);
+ }
+
+ /* The selected rung stays obvious while its rows are being worked. */
+ &--on {
+ background: var(--k-g100);
+ box-shadow: inset 0 0 0 1px var(--k-g300);
+ }
+}
+
+.ke-stat-n {
+ font-size: 22px;
+ font-weight: 700;
+ line-height: 1.1;
+}
+
+.ke-stat-l {
+ font-size: 12px;
+ font-weight: 700;
+ margin-top: 1px;
+}
+
+.ke-stat-w {
+ font-size: 10.5px;
+ color: var(--k-g500);
+ margin-top: 4px;
+ line-height: 1.45;
+}
+
+/* ── Filters ────────────────────────────────────────────────────────────── */
+
+.ke-filters {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 10px 12px;
+ margin-bottom: 12px;
+ background: #fff;
+ border: 1px solid var(--k-g200);
+ border-radius: 7px;
+}
+
+.ke-inp,
+.ke-sel {
+ padding: 6px 10px;
+ border: 1px solid var(--k-g300);
+ border-radius: 5px;
+ font: inherit;
+ font-size: 12px;
+ background: #fff;
+
+ &--full {
+ width: 100%;
+ }
+}
+
+.ke-inp {
+ min-width: 240px;
+}
+
+.ke-chk {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 12px;
+ color: var(--k-g700);
+ cursor: pointer;
+}
+
+.ke-badge {
+ padding: 1px 7px;
+ border-radius: 999px;
+ background: var(--k-red-l);
+ color: var(--k-red);
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.ke-spacer {
+ flex: 1;
+}
+
+.ke-count {
+ font-size: 11.5px;
+ color: var(--k-g500);
+}
+
+/* ── Buttons ────────────────────────────────────────────────────────────── */
+
+.ke-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 13px;
+ border: 1px solid var(--k-g300);
+ border-radius: 5px;
+ background: #fff;
+ font: inherit;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--k-g700);
+ cursor: pointer;
+
+ &:hover:not(:disabled) {
+ border-color: var(--k-blue);
+ color: var(--k-blue);
+ }
+
+ &:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+ }
+
+ &--slim {
+ padding: 5px 11px;
+ font-size: 11.5px;
+ }
+
+ &--primary {
+ background: var(--k-blue);
+ border-color: var(--k-blue);
+ color: #fff;
+
+ &:hover:not(:disabled) {
+ background: #1e40af;
+ color: #fff;
+ }
+ }
+
+ &--ghost {
+ background: transparent;
+ }
+}
+
+/* ── Table ──────────────────────────────────────────────────────────────── */
+
+.ke-tablewrap {
+ overflow-x: auto;
+ background: #fff;
+ border: 1px solid var(--k-g200);
+ border-radius: 7px;
+}
+
+.ke-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+
+ th {
+ text-align: left;
+ padding: 9px 10px;
+ background: var(--k-g100);
+ border-bottom: 1px solid var(--k-g200);
+ font-size: 10.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.4px;
+ color: var(--k-g500);
+ white-space: nowrap;
+ }
+
+ td {
+ padding: 9px 10px;
+ border-bottom: 1px solid var(--k-g100);
+ vertical-align: top;
+ }
+}
+
+.ke-row {
+ cursor: pointer;
+
+ &:hover {
+ background: var(--k-g50);
+ }
+
+ &.open {
+ background: var(--k-blue-l);
+ }
+}
+
+.ke-sub {
+ font-size: 10.5px;
+ color: var(--k-g500);
+ margin-top: 2px;
+
+ &--red {
+ color: var(--k-red);
+ font-weight: 700;
+ }
+}
+
+/* ── Badges ─────────────────────────────────────────────────────────────── */
+
+.ke-stage {
+ display: inline-block;
+ padding: 2px 9px;
+ border-radius: 999px;
+ font-size: 10.5px;
+ font-weight: 700;
+ white-space: nowrap;
+
+ &--info {
+ background: var(--k-blue-l);
+ color: var(--k-blue);
+ }
+
+ &--warn {
+ background: var(--k-amber-l);
+ color: var(--k-amber);
+ }
+
+ &--danger {
+ background: var(--k-red-l);
+ color: var(--k-red);
+ }
+
+ &--critical {
+ background: var(--k-crit-l);
+ color: var(--k-crit);
+ }
+}
+
+.ke-risk {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 999px;
+ font-size: 10.5px;
+ font-weight: 700;
+
+ &--low {
+ background: var(--k-green-l);
+ color: var(--k-green);
+ }
+
+ &--mid {
+ background: var(--k-amber-l);
+ color: var(--k-amber);
+ }
+
+ &--high {
+ background: var(--k-red-l);
+ color: var(--k-red);
+ }
+}
+
+.ke-block-cell {
+ max-width: 230px;
+}
+
+.ke-blocked {
+ display: inline-block;
+ margin: 0 3px 3px 0;
+ padding: 1px 7px;
+ border-radius: 4px;
+ background: var(--k-red-l);
+ color: var(--k-red);
+ font-size: 10px;
+ font-weight: 600;
+}
+
+/* Nothing restricted is good news, not an absence of information. */
+.ke-nothing {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--k-green);
+}
+
+.ke-next {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--k-g700);
+}
+
+.ke-none {
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--k-red);
+}
+
+.ke-times {
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.ke-ack {
+ margin-top: 3px;
+ font-size: 10px;
+ font-weight: 700;
+ color: var(--k-green);
+}
+
+/* ── Row actions ────────────────────────────────────────────────────────── */
+
+.ke-act-cell {
+ white-space: nowrap;
+}
+
+.ke-acts {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 4px;
+}
+
+.ke-ico {
+ width: 26px;
+ height: 26px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--k-g300);
+ border-radius: 5px;
+ background: #fff;
+ color: var(--k-g700);
+ font-size: 11px;
+ cursor: pointer;
+
+ &:hover:not(:disabled) {
+ border-color: var(--k-blue);
+ color: var(--k-blue);
+ background: var(--k-blue-l);
+ }
+
+ /* A channel with no destination on file cannot be attempted. */
+ &:disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+ }
+}
+
+.ke-mini {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 8px;
+ border: 1px solid var(--k-g300);
+ border-radius: 5px;
+ background: #fff;
+ font: inherit;
+ font-size: 10.5px;
+ font-weight: 600;
+ color: var(--k-g700);
+ cursor: pointer;
+
+ &:hover:not(:disabled) {
+ border-color: var(--k-blue);
+ color: var(--k-blue);
+ }
+
+ &:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+ }
+
+ &--go {
+ border-color: var(--k-blue);
+ color: var(--k-blue);
+ }
+}
+
+/* ── Expanded detail ────────────────────────────────────────────────────── */
+
+.ke-detail {
+ background: var(--k-g50);
+
+ > td {
+ padding: 14px 16px;
+ }
+}
+
+.ke-detail-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+.ke-panel {
+ padding: 11px 13px;
+ background: #fff;
+ border: 1px solid var(--k-g200);
+ border-radius: 6px;
+
+ h4 {
+ margin: 0 0 7px;
+ font-size: 11.5px;
+ font-weight: 700;
+ color: var(--k-g700);
+ }
+
+ ul {
+ margin: 0;
+ padding-left: 17px;
+ font-size: 11.5px;
+ line-height: 1.7;
+ }
+}
+
+.ke-panel-note {
+ margin-top: 7px;
+ font-size: 10.5px;
+ color: var(--k-g500);
+ line-height: 1.5;
+
+ &--allow {
+ color: var(--k-green);
+ font-weight: 600;
+ }
+}
+
+.ke-kv {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 3px 0;
+ font-size: 11.5px;
+
+ span {
+ color: var(--k-g500);
+ }
+}
+
+.ke-ack-box {
+ margin-top: 8px;
+ padding: 7px 9px;
+ background: var(--k-green-l);
+ border-radius: 5px;
+ font-size: 11px;
+ color: var(--k-green);
+}
+
+.ke-hist-h {
+ margin: 0 0 8px;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--k-g700);
+}
+
+.ke-subtable {
+ width: 100%;
+ border-collapse: collapse;
+ background: #fff;
+ border: 1px solid var(--k-g200);
+ border-radius: 6px;
+ font-size: 11.5px;
+
+ th {
+ text-align: left;
+ padding: 7px 9px;
+ background: var(--k-g100);
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ color: var(--k-g500);
+ }
+
+ td {
+ padding: 7px 9px;
+ border-top: 1px solid var(--k-g100);
+ vertical-align: top;
+ }
+}
+
+.ke-notes {
+ max-width: 380px;
+ line-height: 1.5;
+}
+
+.ke-dlv {
+ display: inline-block;
+ padding: 1px 7px;
+ border-radius: 4px;
+ font-size: 10px;
+ font-weight: 700;
+
+ &--ok {
+ background: var(--k-green-l);
+ color: var(--k-green);
+ }
+
+ /* Anything that is not SENT means the customer was not reached — it must not read as success. */
+ &--bad {
+ background: var(--k-amber-l);
+ color: var(--k-amber);
+ }
+}
+
+.ke-empty {
+ padding: 34px;
+ text-align: center;
+ font-size: 12.5px;
+ color: var(--k-g500);
+ background: #fff;
+ border: 1px solid var(--k-g200);
+ border-radius: 7px;
+}
+
+/* ── Modal ──────────────────────────────────────────────────────────────── */
+
+.ke-modal-bg {
+ position: fixed;
+ inset: 0;
+ background: rgb(15 23 42 / 45%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ padding: 20px;
+}
+
+.ke-modal {
+ width: 100%;
+ max-width: 560px;
+ max-height: 90vh;
+ overflow-y: auto;
+ background: #fff;
+ border-radius: 9px;
+ box-shadow: 0 18px 50px rgb(15 23 42 / 26%);
+
+ &--sm {
+ max-width: 460px;
+ }
+}
+
+.ke-modal-h {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 13px 17px;
+ border-bottom: 1px solid var(--k-g200);
+
+ h3 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--k-navy);
+ }
+}
+
+.ke-x {
+ border: 0;
+ background: transparent;
+ font-size: 22px;
+ line-height: 1;
+ color: var(--k-g500);
+ cursor: pointer;
+}
+
+.ke-modal-b {
+ padding: 15px 17px;
+}
+
+.ke-modal-f {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding: 12px 17px;
+ border-top: 1px solid var(--k-g200);
+}
+
+.ke-modal-who {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ flex-wrap: wrap;
+ padding-bottom: 11px;
+ margin-bottom: 11px;
+ border-bottom: 1px solid var(--k-g100);
+ font-size: 12.5px;
+}
+
+.ke-lbl {
+ display: block;
+ margin: 11px 0 5px;
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--k-g700);
+}
+
+.ke-req {
+ color: var(--k-red);
+}
+
+.ke-chan {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.ke-chan-b {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 5px 11px;
+ border: 1px solid var(--k-g300);
+ border-radius: 999px;
+ background: #fff;
+ font: inherit;
+ font-size: 11.5px;
+ font-weight: 600;
+ color: var(--k-g700);
+ cursor: pointer;
+
+ &:hover {
+ border-color: var(--k-blue);
+ }
+
+ &.active {
+ background: var(--k-blue);
+ border-color: var(--k-blue);
+ color: #fff;
+ }
+}
+
+/* Says plainly whether anything will actually be sent. */
+.ke-dest {
+ margin-top: 9px;
+ padding: 7px 10px;
+ background: var(--k-green-l);
+ border-radius: 5px;
+ font-size: 11px;
+ color: var(--k-green);
+
+ &--missing {
+ background: var(--k-amber-l);
+ color: var(--k-amber);
+ }
+}
+
+.ke-ta {
+ width: 100%;
+ padding: 8px 10px;
+ border: 1px solid var(--k-g300);
+ border-radius: 5px;
+ font: inherit;
+ font-size: 12px;
+ resize: vertical;
+}
+
+.ke-note-box {
+ display: flex;
+ gap: 8px;
+ padding: 9px 11px;
+ background: var(--k-blue-l);
+ border-radius: 5px;
+ font-size: 11px;
+ line-height: 1.55;
+ color: var(--k-g700);
+
+ fa-icon {
+ color: var(--k-blue);
+ margin-top: 1px;
+ }
+}
+
+.ke-err {
+ margin-top: 10px;
+ padding: 7px 10px;
+ background: var(--k-red-l);
+ border-radius: 5px;
+ font-size: 11.5px;
+ color: var(--k-red);
+}
+
+.ke-toast {
+ position: fixed;
+ bottom: 22px;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 10px 20px;
+ background: var(--k-g900);
+ color: #fff;
+ border-radius: 6px;
+ font-size: 12.5px;
+ box-shadow: 0 8px 22px rgb(15 23 42 / 30%);
+ z-index: 1100;
+}
diff --git a/src/app/kyc-expiry/kyc-expiry.component.ts b/src/app/kyc-expiry/kyc-expiry.component.ts
new file mode 100644
index 000000000..bc95be300
--- /dev/null
+++ b/src/app/kyc-expiry/kyc-expiry.component.ts
@@ -0,0 +1,431 @@
+/**
+ * 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/.
+ */
+
+/** Angular Imports */
+import { Component, OnInit, inject } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { forkJoin, of } from 'rxjs';
+import { catchError, map } from 'rxjs/operators';
+
+/** Custom Modules */
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+
+/** Custom Services */
+import { ClientsService } from '../clients/clients.service';
+
+/** Report data + computation */
+import {
+ ALWAYS_ALLOWED,
+ CONTACT_CHANNELS,
+ KYC_EXPIRY_POLICY,
+ KYC_EXPIRY_STAGES,
+ KycContactRow,
+ KycExpiryRow,
+ KycExpiryStage,
+ KycExpirySummary,
+ buildExpiryRow,
+ channelLabel,
+ expirySummary,
+ sortWorklist
+} from './kyc-expiry.compute';
+
+/**
+ * The re-KYC expiry worklist.
+ *
+ * Answers two questions an officer has about an overdue customer: what has stopped working on the
+ * account, and what has been done to reach them. The restriction ladder is read-only — it is
+ * derived from the expiry date, not stored — so this screen never has to be kept in step with the
+ * account's real state; it says what the policy implies and leaves the account alone.
+ *
+ * Recording an outreach attempt does write: it posts to the contact-log API the KYC exceptions
+ * screen already uses, so both screens read the same history.
+ */
+@Component({
+ selector: 'mifosx-kyc-expiry',
+ templateUrl: './kyc-expiry.component.html',
+ styleUrls: ['./kyc-expiry.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ FormsModule,
+ FaIconComponent
+ ]
+})
+export class KycExpiryComponent implements OnInit {
+ private clientsService = inject(ClientsService);
+ private router = inject(Router);
+
+ loading = false;
+ errorMessage = '';
+
+ rows: KycExpiryRow[] = [];
+ expanded: number | null = null;
+
+ /** Filters */
+ stageFilter: KycExpiryStage | '' = '';
+ riskFilter = 0;
+ search = '';
+ notContactedOnly = false;
+
+ /** Contact dialog */
+ contactOpen = false;
+ contactRow: KycExpiryRow | null = null;
+ contactChannel = 'EMAIL';
+ contactNotes = '';
+ contactOfficer = '';
+ contactSaving = false;
+ contactError = '';
+
+ /** Acknowledge dialog */
+ ackOpen = false;
+ ackRow: KycExpiryRow | null = null;
+ ackNote = '';
+ ackOfficer = '';
+
+ toastMsg = '';
+ toastVisible = false;
+ private toastTimer: any;
+
+ readonly channels = CONTACT_CHANNELS;
+ readonly policy = KYC_EXPIRY_POLICY;
+ readonly alwaysAllowed = ALWAYS_ALLOWED;
+ readonly stageMeta = KYC_EXPIRY_STAGES;
+ /** DUE_SOON is absent by design: nothing on this screen has an unexpired KYC. */
+ readonly stageKeys: KycExpiryStage[] = [
+ 'INOPERATIVE',
+ 'PARTIAL_FREEZE',
+ 'GRACE'
+ ];
+
+ ngOnInit(): void {
+ this.load();
+ }
+
+ /**
+ * Loads the customers whose re-KYC has lapsed, then folds in each one's outreach history.
+ *
+ * The server already restricts the list to lapsed records, so nothing is filtered by date here —
+ * a customer whose renewal is still ahead never reaches this screen, and one who renews drops out
+ * of it on the next load.
+ */
+ load(): void {
+ this.loading = true;
+ this.errorMessage = '';
+ const today = new Date().toISOString().slice(0, 10);
+
+ this.clientsService.getLapsedKyc().subscribe({
+ next: (records: any[]) => {
+ const clients = (records || []).map((r) => this.mapLapsed(r)).filter((c) => !!c.expiryDate);
+
+ if (clients.length === 0) {
+ this.rows = [];
+ this.loading = false;
+ return;
+ }
+
+ forkJoin(
+ clients.map((c) =>
+ this.clientsService.getKycContactLog(c.clientId).pipe(
+ map((logs: any[]) => (logs || []).map((l) => this.mapContact(l))),
+ // One customer's history failing must not blank the whole worklist — an overdue
+ // account the branch cannot see is worse than one shown without its outreach.
+ catchError(() => of([] as KycContactRow[]))
+ )
+ )
+ ).subscribe({
+ next: (histories) => {
+ this.rows = sortWorklist(clients.map((c, i) => buildExpiryRow(c, histories[i], today)));
+ this.loading = false;
+ },
+ error: () => {
+ this.rows = sortWorklist(clients.map((c) => buildExpiryRow(c, [], today)));
+ this.loading = false;
+ this.errorMessage = 'Contact history could not be loaded. The worklist below is still accurate.';
+ }
+ });
+ },
+ error: (err: any) => {
+ this.rows = [];
+ this.loading = false;
+ this.errorMessage =
+ err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the lapsed KYC worklist.';
+ }
+ });
+ }
+
+ /** Maps one ClientKycData record onto what the ladder needs. */
+ private mapLapsed(r: any): {
+ clientId: number;
+ clientName: string;
+ accountNo: string;
+ branchCode: string;
+ riskCategory: number;
+ expiryDate: string;
+ mobile: string;
+ email: string;
+ } {
+ return {
+ clientId: r?.clientId,
+ clientName: r?.clientDisplayName || '—',
+ accountNo: r?.clientAccountNo || '',
+ branchCode: r?.clientOfficeName || '',
+ riskCategory: r?.riskCategory ?? 1,
+ expiryDate: (r?.expiryDate || '').toString().slice(0, 10),
+ // Only the masked forms come back from the server, which is what the dialog shows and all it
+ // needs — the destination itself is resolved server-side when a notice is sent.
+ mobile: r?.clientMobileMasked || (r?.hasMobile ? 'On file' : ''),
+ email: r?.clientEmailMasked || (r?.hasEmail ? 'On file' : '')
+ };
+ }
+
+ private mapContact(log: any): KycContactRow {
+ const status = log?.deliveryStatus || 'NOT_APPLICABLE';
+ return {
+ date: (log?.contactedOn || log?.createdOn || '').toString().slice(0, 10),
+ channel: log?.channel || '',
+ channelLabel: channelLabel(log?.channel || ''),
+ notes: log?.notes || '',
+ officer: log?.officerName || log?.loggedByUsername || '—',
+ deliveryStatus: status,
+ delivered: status === 'SENT',
+ destinationMasked: log?.destinationMasked || ''
+ };
+ }
+
+ // ── Filtering ──────────────────────────────────────────────────────────────
+
+ get visibleRows(): KycExpiryRow[] {
+ const needle = this.search.trim().toLowerCase();
+ return this.rows.filter((r) => {
+ if (this.stageFilter && r.stage !== this.stageFilter) {
+ return false;
+ }
+ if (this.riskFilter && r.riskCategory !== this.riskFilter) {
+ return false;
+ }
+ if (this.notContactedOnly && r.contactCount > 0) {
+ return false;
+ }
+ if (needle) {
+ const hay = `${r.clientName} ${r.clientId} ${r.accountNo}`.toLowerCase();
+ if (!hay.includes(needle)) {
+ return false;
+ }
+ }
+ return true;
+ });
+ }
+
+ get summary(): KycExpirySummary {
+ return expirySummary(this.rows);
+ }
+
+ resetFilters(): void {
+ this.stageFilter = '';
+ this.riskFilter = 0;
+ this.search = '';
+ this.notContactedOnly = false;
+ }
+
+ toggleStage(stage: KycExpiryStage): void {
+ this.stageFilter = this.stageFilter === stage ? '' : stage;
+ this.expanded = null;
+ }
+
+ toggle(clientId: number): void {
+ this.expanded = this.expanded === clientId ? null : clientId;
+ }
+
+ // ── Outreach ───────────────────────────────────────────────────────────────
+
+ /**
+ * Opens the contact dialog on a sensible channel.
+ *
+ * Email is the default only where the customer has one — offering a channel with no destination
+ * behind it produces a log entry that looks like an attempt but reached nobody.
+ */
+ openContact(row: KycExpiryRow, channel: string, event: Event): void {
+ event.stopPropagation();
+ this.contactRow = row;
+ this.contactChannel = channel || (row.email ? 'EMAIL' : row.mobile ? 'SMS' : 'PHONE_CALL');
+ this.contactNotes = '';
+ this.contactOfficer = '';
+ this.contactError = '';
+ this.contactOpen = true;
+ }
+
+ /** Whether the chosen channel has a destination on file. */
+ get contactDestination(): string {
+ if (!this.contactRow) {
+ return '';
+ }
+ const ch = this.contactChannel;
+ if (ch === 'EMAIL') {
+ return this.contactRow.email;
+ }
+ if (ch === 'SMS' || ch === 'WHATSAPP' || ch === 'PHONE_CALL') {
+ return this.contactRow.mobile;
+ }
+ // A visit is made in person, so there is no destination to check.
+ return 'in person';
+ }
+
+ get contactDestinationMissing(): boolean {
+ return !this.contactDestination;
+ }
+
+ get contactChannelTransmits(): boolean {
+ return !!CONTACT_CHANNELS.find((c) => c.key === this.contactChannel)?.transmits;
+ }
+
+ saveContact(): void {
+ if (!this.contactRow || !this.contactNotes.trim()) {
+ this.contactError = 'Enter what was said or attempted — the note is the record.';
+ return;
+ }
+
+ this.contactSaving = true;
+ this.contactError = '';
+ const row = this.contactRow;
+
+ this.clientsService
+ .saveKycContactLog(row.clientId, {
+ channel: this.contactChannel,
+ notes: this.contactNotes.trim(),
+ officerName: this.contactOfficer.trim() || undefined
+ })
+ .subscribe({
+ next: (res: any) => {
+ this.contactSaving = false;
+ this.contactOpen = false;
+ // The log row is written whether or not the message left the gateway, so the officer is
+ // told which of the two happened rather than being left to assume it was delivered.
+ const label = channelLabel(this.contactChannel);
+ const status = res?.deliveryStatus;
+ this.toast(
+ status && status !== 'SENT' && status !== 'NOT_APPLICABLE'
+ ? `${label} recorded for ${row.clientName}, but it was not delivered — see the history.`
+ : `${label} recorded for ${row.clientName}`
+ );
+ this.load();
+ },
+ error: (err: any) => {
+ this.contactSaving = false;
+ this.contactError =
+ err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not record the contact attempt.';
+ }
+ });
+ }
+
+ // ── Acknowledgement ────────────────────────────────────────────────────────
+
+ /**
+ * Marks that the customer has been reached and understands what is required.
+ *
+ * This is a note on the outreach, not a substitute for the re-KYC: the ladder keeps running, and
+ * the account is only released once the KYC record itself is renewed on the KYC screen.
+ */
+ openAck(row: KycExpiryRow, event: Event): void {
+ event.stopPropagation();
+ this.ackRow = row;
+ this.ackNote = '';
+ this.ackOfficer = '';
+ this.ackOpen = true;
+ }
+
+ saveAck(): void {
+ if (!this.ackRow) {
+ return;
+ }
+ const row = this.ackRow;
+ // Recorded through the same contact log so the acknowledgement sits in one history with the
+ // attempts that led to it, rather than in a store of its own.
+ this.clientsService
+ .saveKycContactLog(row.clientId, {
+ channel: 'PHONE_CALL',
+ notes: `[ACKNOWLEDGED] ${this.ackNote.trim() || 'Customer reached and informed of re-KYC requirement.'}`,
+ officerName: this.ackOfficer.trim() || undefined
+ })
+ .subscribe({
+ next: () => {
+ row.acknowledged = true;
+ row.acknowledgedOn = new Date().toISOString().slice(0, 10);
+ row.acknowledgedBy = this.ackOfficer.trim() || 'Current user';
+ row.acknowledgedNote = this.ackNote.trim();
+ this.ackOpen = false;
+ this.toast(`${row.clientName} marked as contacted`);
+ this.load();
+ },
+ error: () => {
+ this.ackOpen = false;
+ this.toast('Could not save the acknowledgement.');
+ }
+ });
+ }
+
+ // ── Navigation ─────────────────────────────────────────────────────────────
+
+ /** The KYC screen is where the record is actually renewed. */
+ openKyc(row: KycExpiryRow, event: Event): void {
+ event.stopPropagation();
+ this.router.navigate([
+ '/clients',
+ row.clientId,
+ 'kyc'
+ ]);
+ }
+
+ // ── Presentation ───────────────────────────────────────────────────────────
+
+ stageClass(stage: KycExpiryStage): string {
+ return `ke-stage ke-stage--${KYC_EXPIRY_STAGES[stage].tone}`;
+ }
+
+ riskClass(category: number): string {
+ const key = category === 3 ? 'high' : category === 2 ? 'mid' : 'low';
+ return `ke-risk ke-risk--${key}`;
+ }
+
+ /** "214 days overdue" / "in 17 days", as an officer would say it. */
+ overdueText(row: KycExpiryRow): string {
+ if (row.daysOverdue > 0) {
+ return `${row.daysOverdue} days overdue`;
+ }
+ if (row.daysOverdue === 0) {
+ return 'Due today';
+ }
+ return `Due in ${Math.abs(row.daysOverdue)} days`;
+ }
+
+ deliveryClass(status: string): string {
+ return status === 'SENT' ? 'ke-dlv ke-dlv--ok' : 'ke-dlv ke-dlv--bad';
+ }
+
+ deliveryText(c: KycContactRow): string {
+ switch (c.deliveryStatus) {
+ case 'SENT':
+ return 'Delivered';
+ case 'FAILED':
+ return 'Failed';
+ case 'SKIPPED_NO_DESTINATION':
+ return 'No destination on file';
+ case 'PROVIDER_NOT_CONFIGURED':
+ return 'Gateway not configured';
+ default:
+ return 'Recorded offline';
+ }
+ }
+
+ toast(msg: string): void {
+ this.toastMsg = msg;
+ this.toastVisible = true;
+ clearTimeout(this.toastTimer);
+ this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
+ }
+}
diff --git a/src/app/kyc-expiry/kyc-expiry.compute.ts b/src/app/kyc-expiry/kyc-expiry.compute.ts
new file mode 100644
index 000000000..d92ec8cfc
--- /dev/null
+++ b/src/app/kyc-expiry/kyc-expiry.compute.ts
@@ -0,0 +1,328 @@
+/**
+ * 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/.
+ */
+
+/**
+ * What happens to an account once its periodic re-KYC falls due.
+ *
+ * RBI's Master Direction on KYC does not treat an overdue re-KYC as a reason to shut a customer
+ * out. The 2021 amendment (DOR.AML.REC.13/14.01.001/2021-22) is explicit that an account is not
+ * to be closed merely because periodic KYC is pending, and that restrictions come only after the
+ * customer has been given notice and time to respond. So the ladder below escalates slowly, and
+ * every stage before PARTIAL_FREEZE leaves the account fully operative.
+ *
+ * The day counts are the bank's own policy, not RBI's — the Direction requires "adequate notice"
+ * without fixing a number — so they live in one place here and can be moved without touching the
+ * logic that reads them.
+ */
+
+/** Where an overdue customer currently sits on the restriction ladder. */
+export type KycExpiryStage =
+ /** Due date approaching; nothing is restricted and notices are going out. */
+ | 'DUE_SOON'
+ /** Past the due date but inside the grace window. Still fully operative. */
+ | 'GRACE'
+ /** Debits stopped, credits still accepted. The first actual restriction. */
+ | 'PARTIAL_FREEZE'
+ /** Both directions restricted, barring the always-allowed credits. */
+ | 'INOPERATIVE';
+
+/** Policy day counts, measured forward from the KYC expiry date. */
+export const KYC_EXPIRY_POLICY = {
+ /** Days past expiry before debits are stopped. */
+ graceDays: 90,
+ /** Days past expiry before the account is marked inoperative. */
+ inoperativeDays: 180
+};
+
+/**
+ * Credits that must keep landing whatever the KYC position.
+ *
+ * Government benefit transfers are the one category RBI has specifically directed banks not to
+ * hold up, and interest and the bank's own loan recoveries are the account working rather than
+ * the customer transacting on it.
+ */
+export const ALWAYS_ALLOWED = [
+ 'DBT / government subsidy credit',
+ 'Interest credit',
+ 'Own loan EMI auto-debit',
+ 'Reversal of a bank-initiated entry'
+];
+
+export interface KycExpiryStageMeta {
+ stage: KycExpiryStage;
+ label: string;
+ /** Colour key the template maps to a badge class. */
+ tone: 'info' | 'warn' | 'danger' | 'critical';
+ /** What the customer can still do, in the officer's words. */
+ allowed: string[];
+ /** What has stopped. Empty while the account is still fully operative. */
+ blocked: string[];
+ /** One line telling the officer what this stage means. */
+ summary: string;
+}
+
+export const KYC_EXPIRY_STAGES: Record = {
+ DUE_SOON: {
+ stage: 'DUE_SOON',
+ label: 'Re-KYC due soon',
+ tone: 'info',
+ allowed: [
+ 'All debits',
+ 'All credits',
+ 'Cheque book & channels'
+ ],
+ blocked: [],
+ summary: 'Nothing is restricted. Notices are going out so the customer can complete re-KYC in time.'
+ },
+ GRACE: {
+ stage: 'GRACE',
+ label: 'Overdue — grace period',
+ tone: 'warn',
+ allowed: [
+ 'All debits',
+ 'All credits',
+ 'Cheque book & channels'
+ ],
+ blocked: [],
+ summary: 'Past the due date but still fully operative. Restrictions only follow if the customer does not respond.'
+ },
+ PARTIAL_FREEZE: {
+ stage: 'PARTIAL_FREEZE',
+ label: 'Debit freeze',
+ tone: 'danger',
+ allowed: [
+ 'All credits',
+ 'DBT / subsidy',
+ 'Interest credit'
+ ],
+ blocked: [
+ 'Cash withdrawal',
+ 'Transfers & NEFT / RTGS / UPI out',
+ 'Cheque issue & clearing debits',
+ 'New standing instructions'
+ ],
+ summary: 'Debits are stopped; money can still come in. The customer must visit the branch to have this lifted.'
+ },
+ INOPERATIVE: {
+ stage: 'INOPERATIVE',
+ label: 'Inoperative',
+ tone: 'critical',
+ allowed: [
+ 'DBT / subsidy',
+ 'Interest credit'
+ ],
+ blocked: [
+ 'All debits',
+ 'Ordinary credits',
+ 'Cheque book issue',
+ 'All digital channels',
+ 'New products & renewals'
+ ],
+ summary: 'Both directions restricted. Only the credits RBI requires be let through are still accepted.'
+ }
+};
+
+/** A customer whose re-KYC is due or overdue, with the ladder worked out. */
+export interface KycExpiryRow {
+ clientId: number;
+ clientName: string;
+ accountNo: string;
+ branchCode: string;
+ /** 1 Low / 2 Medium / 3 High. */
+ riskCategory: number;
+ riskLabel: string;
+ expiryDate: string;
+ /** Negative while the due date is still ahead. */
+ daysOverdue: number;
+ stage: KycExpiryStage;
+ meta: KycExpiryStageMeta;
+ mobile: string;
+ email: string;
+ /** Outreach already recorded against this customer. */
+ contacts: KycContactRow[];
+ contactCount: number;
+ lastContactDate: string;
+ /** True once an officer has confirmed the customer was reached and the case can rest. */
+ acknowledged: boolean;
+ acknowledgedOn: string;
+ acknowledgedBy: string;
+ acknowledgedNote: string;
+ /** The day the next restriction bites, so the officer can warn the customer precisely. */
+ nextActionDate: string;
+ nextActionLabel: string;
+}
+
+/** One recorded attempt to reach the customer. */
+export interface KycContactRow {
+ date: string;
+ channel: string;
+ channelLabel: string;
+ notes: string;
+ officer: string;
+ /** SENT means it actually left the gateway; anything else means the customer was not reached. */
+ deliveryStatus: string;
+ delivered: boolean;
+ destinationMasked: string;
+}
+
+/** Channels an officer can record, matching the backend's KycContactChannel. */
+export const CONTACT_CHANNELS = [
+ { key: 'EMAIL', label: 'Email', icon: 'envelope', transmits: true },
+ { key: 'SMS', label: 'SMS', icon: 'comment-dots', transmits: true },
+ { key: 'WHATSAPP', label: 'WhatsApp', icon: 'comment-dots', transmits: true },
+ { key: 'PHONE_CALL', label: 'Phone Call', icon: 'phone', transmits: false },
+ { key: 'BRANCH_VISIT', label: 'Branch Visit', icon: 'building-columns', transmits: false },
+ { key: 'FIELD_VISIT', label: 'Field Visit', icon: 'user-check', transmits: false }
+];
+
+export function channelLabel(key: string): string {
+ return CONTACT_CHANNELS.find((c) => c.key === key)?.label ?? key;
+}
+
+/** Days between two ISO dates, positive when `later` is after `earlier`. */
+function daysBetween(earlier: string, later: string): number {
+ const a = new Date(`${earlier}T00:00:00Z`).getTime();
+ const b = new Date(`${later}T00:00:00Z`).getTime();
+ return Math.round((b - a) / 86400000);
+}
+
+function addDays(date: string, days: number): string {
+ const d = new Date(`${date}T00:00:00Z`);
+ return new Date(d.getTime() + days * 86400000).toISOString().slice(0, 10);
+}
+
+/**
+ * Which rung of the ladder a customer is on, from how far past the due date they are.
+ *
+ * An acknowledged case is deliberately not exempt: acknowledging records that the customer was
+ * reached, which is not the same as the re-KYC being done, and letting it hold off a freeze would
+ * turn a contact note into a way of parking an overdue account indefinitely.
+ */
+export function expiryStage(daysOverdue: number): KycExpiryStage {
+ if (daysOverdue >= KYC_EXPIRY_POLICY.inoperativeDays) {
+ return 'INOPERATIVE';
+ }
+ if (daysOverdue >= KYC_EXPIRY_POLICY.graceDays) {
+ return 'PARTIAL_FREEZE';
+ }
+ if (daysOverdue >= 0) {
+ return 'GRACE';
+ }
+ return 'DUE_SOON';
+}
+
+/** The next restriction and the day it lands, worded for the customer. */
+export function nextAction(expiryDate: string, stage: KycExpiryStage): { date: string; label: string } {
+ switch (stage) {
+ case 'DUE_SOON':
+ return { date: expiryDate, label: 'Re-KYC falls due' };
+ case 'GRACE':
+ return {
+ date: addDays(expiryDate, KYC_EXPIRY_POLICY.graceDays),
+ label: 'Debit freeze applies'
+ };
+ case 'PARTIAL_FREEZE':
+ return {
+ date: addDays(expiryDate, KYC_EXPIRY_POLICY.inoperativeDays),
+ label: 'Account marked inoperative'
+ };
+ default:
+ // Nothing further is applied automatically — closure is a manual decision with its own notice.
+ return { date: '', label: 'No further automatic restriction' };
+ }
+}
+
+export const RISK_LABELS: Record = { 1: 'Low Risk', 2: 'Medium Risk', 3: 'High Risk' };
+
+/** Builds one worklist row from a KYC record and its contact history. */
+export function buildExpiryRow(
+ kyc: {
+ clientId: number;
+ clientName: string;
+ accountNo?: string;
+ branchCode?: string;
+ riskCategory?: number;
+ expiryDate: string;
+ mobile?: string;
+ email?: string;
+ acknowledged?: boolean;
+ acknowledgedOn?: string;
+ acknowledgedBy?: string;
+ acknowledgedNote?: string;
+ },
+ contacts: KycContactRow[],
+ today: string
+): KycExpiryRow {
+ const daysOverdue = daysBetween(kyc.expiryDate, today);
+ const stage = expiryStage(daysOverdue);
+ const next = nextAction(kyc.expiryDate, stage);
+ const risk = kyc.riskCategory ?? 1;
+ const sorted = [...contacts].sort((a, b) => b.date.localeCompare(a.date));
+
+ return {
+ clientId: kyc.clientId,
+ clientName: kyc.clientName,
+ accountNo: kyc.accountNo || '',
+ branchCode: kyc.branchCode || '',
+ riskCategory: risk,
+ riskLabel: RISK_LABELS[risk] ?? 'Low Risk',
+ expiryDate: kyc.expiryDate,
+ daysOverdue,
+ stage,
+ meta: KYC_EXPIRY_STAGES[stage],
+ mobile: kyc.mobile || '',
+ email: kyc.email || '',
+ contacts: sorted,
+ contactCount: sorted.length,
+ lastContactDate: sorted.length ? sorted[0].date : '',
+ acknowledged: !!kyc.acknowledged,
+ acknowledgedOn: kyc.acknowledgedOn || '',
+ acknowledgedBy: kyc.acknowledgedBy || '',
+ acknowledgedNote: kyc.acknowledgedNote || '',
+ nextActionDate: next.date,
+ nextActionLabel: next.label
+ };
+}
+
+export interface KycExpirySummary {
+ total: number;
+ grace: number;
+ partialFreeze: number;
+ inoperative: number;
+ /** Lapsed customers nobody has reached out to yet — the real backlog. */
+ notContacted: number;
+ acknowledged: number;
+}
+
+export function expirySummary(rows: KycExpiryRow[]): KycExpirySummary {
+ return {
+ total: rows.length,
+ grace: rows.filter((r) => r.stage === 'GRACE').length,
+ partialFreeze: rows.filter((r) => r.stage === 'PARTIAL_FREEZE').length,
+ inoperative: rows.filter((r) => r.stage === 'INOPERATIVE').length,
+ notContacted: rows.filter((r) => r.contactCount === 0).length,
+ acknowledged: rows.filter((r) => r.acknowledged).length
+ };
+}
+
+/** Sorts the worklist so the most restricted, least contacted cases come first. */
+export function sortWorklist(rows: KycExpiryRow[]): KycExpiryRow[] {
+ const rank: Record = {
+ INOPERATIVE: 0,
+ PARTIAL_FREEZE: 1,
+ GRACE: 2,
+ DUE_SOON: 3
+ };
+ return [...rows].sort(
+ (a, b) =>
+ rank[a.stage] - rank[b.stage] ||
+ Number(a.acknowledged) - Number(b.acknowledged) ||
+ a.contactCount - b.contactCount ||
+ b.daysOverdue - a.daysOverdue
+ );
+}
diff --git a/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.html b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.html
index bc34a7490..949239f5c 100644
--- a/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.html
+++ b/src/app/letter-mgmt/certificates/form-15gh/real-form15gh-view/real-form15gh-view.component.html
@@ -8,12 +8,12 @@
@if (loading) {
-
+
Loading live declarations and accounts from Fineract…
} @else if (loadError) {
-
-
+
+
Could not load real data from Fineract. Check connection.
} @else {
@@ -139,7 +139,9 @@
{{ row.row.record.customerName }}
-
{{ row.row.record.customerId }} · PAN {{ row.row.record.customerPan || '—' }}
- ₹{{ row.row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of
- the declaration — reportable in the quarterly {{ statute.statement }} return.
+ ₹{{ row.row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength
+ of the declaration — reportable in the quarterly {{ statute.statement }} return.
diff --git a/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts
index 6add60514..b3aac19d9 100644
--- a/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts
+++ b/src/app/savings/savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component.ts
@@ -24,7 +24,6 @@ const DEFAULT_FACILITY_CONFIG = {
neftFacility: false,
rtgsFacility: false,
interBankTotalCapFacility: false,
- withdrawalFacility: false,
depositFacilityKyc: false,
virtualDebitCard: false
};
diff --git a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts
index 32b227bcd..880e51d90 100644
--- a/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts
+++ b/src/app/savings/savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component.ts
@@ -73,7 +73,6 @@ export class SavingsAccountPreviewStepComponent implements OnChanges {
{ key: 'neftFacility', label: 'NEFT Facility' },
{ key: 'rtgsFacility', label: 'RTGS Facility' },
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap' },
- { key: 'withdrawalFacility', label: 'Withdrawal Facility' },
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
];
diff --git a/src/app/shared/charges/facility-charge-filter.service.ts b/src/app/shared/charges/facility-charge-filter.service.ts
new file mode 100644
index 000000000..d9da72250
--- /dev/null
+++ b/src/app/shared/charges/facility-charge-filter.service.ts
@@ -0,0 +1,66 @@
+/**
+ * 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/.
+ */
+
+/** Angular Imports */
+import { Injectable, inject } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+
+/** rxjs Imports */
+import { Observable, of } from 'rxjs';
+import { catchError, map, shareReplay } from 'rxjs/operators';
+
+/**
+ * Charges that are mapped to an account facility (ATM, UPI, Cheque Book, ...) are priced by the
+ * Facility Charge Mapping screen and levied automatically when that facility is enabled. Offering
+ * them again in a product/account charge picker would attach the same charge a second time and
+ * double-charge the customer, so they are filtered out of those pickers.
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class FacilityChargeFilterService {
+ private http = inject(HttpClient);
+
+ /** Facility mappings rarely change within a session, so the lookup is fetched once and shared. */
+ private mappedChargeIds$: Observable>;
+
+ /**
+ * Ids of every charge currently mapped to a facility.
+ * Falls back to an empty set if the lookup fails, so a charge picker still renders.
+ */
+ getFacilityMappedChargeIds(): Observable> {
+ if (!this.mappedChargeIds$) {
+ this.mappedChargeIds$ = this.http.get('/facilitychargemappings').pipe(
+ map(
+ (mappings: any) =>
+ new Set(
+ ((mappings as any[]) || [])
+ .map((mapping) => Number(mapping?.chargeId))
+ .filter((chargeId) => Number.isFinite(chargeId))
+ )
+ ),
+ catchError(() => of(new Set())),
+ shareReplay({ bufferSize: 1, refCount: false })
+ );
+ }
+ return this.mappedChargeIds$;
+ }
+
+ /**
+ * Removes facility-mapped charges from a charge option list.
+ * @param charges Charge options straight off a product/account template.
+ * @returns The same list without any charge priced by a facility mapping.
+ */
+ excludeFacilityCharges(charges: any[]): Observable {
+ return this.getFacilityMappedChargeIds().pipe(
+ map((mappedChargeIds) =>
+ (charges || []).filter((charge) => !mappedChargeIds.has(Number(charge?.id ?? charge?.chargeId)))
+ )
+ );
+ }
+}
diff --git a/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.html b/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.html
index c85e9182c..500edea7e 100644
--- a/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.html
+++ b/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.html
@@ -12,7 +12,9 @@
-
+
+
diff --git a/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.ts b/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.ts
index 4a0e7f944..35663d419 100644
--- a/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.ts
+++ b/src/app/shared/dormancy-alert-panel/dormancy-alert-panel.component.ts
@@ -6,7 +6,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
-import { Component, Input, OnInit } from '@angular/core';
+import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export type DormancyRisk = 'safe' | 'low' | 'medium' | 'high' | 'critical' | 'escheat';
@@ -17,7 +17,7 @@ export type DormancyRisk = 'safe' | 'low' | 'medium' | 'high' | 'critical' | 'es
styleUrls: ['./dormancy-alert-panel.component.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
-export class DormancyAlertPanelComponent implements OnInit {
+export class DormancyAlertPanelComponent implements OnInit, OnChanges {
/** Pass full account data — for ACCOUNT VIEW (general tab) */
@Input() accountData: any;
/** Pass product template — for ACCOUNT CREATION (currency step) */
@@ -48,13 +48,40 @@ export class DormancyAlertPanelComponent implements OnInit {
}
ngOnInit(): void {
+ this.refresh();
+ }
+
+ /**
+ * The stepper reuses one panel instance while the user switches products, so the thresholds have
+ * to be recomputed on every input change — without this the panel keeps showing the previously
+ * selected product's days.
+ */
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes['productTemplate'] || changes['accountData']) {
+ this.refresh();
+ }
+ }
+
+ private refresh(): void {
if (this.productTemplate && !this.accountData) {
this.initFromProductTemplate();
} else if (this.accountData) {
this.initFromAccountData();
+ } else {
+ this.resetThresholds();
}
}
+ /** Clears stale thresholds when neither a product nor an account is bound. */
+ private resetThresholds(): void {
+ this.daysToInactive = null;
+ this.daysToDormancy = null;
+ this.daysToEscheat = null;
+ this.isMonitoringActive = false;
+ this.isAlertEnabled = false;
+ this.shouldShow = false;
+ }
+
// ── Product Template Mode (Currency Step) ─────────────────────
private initFromProductTemplate(): void {
const pt = this.productTemplate;
@@ -113,10 +140,15 @@ export class DormancyAlertPanelComponent implements OnInit {
}
this.computeRiskFromAccount();
- // Only surface this panel once the account has actually left "Active" —
- // i.e. subStatus is Inactive/Dormant/Escheat. A healthy active account
- // (statusId === 0) should never show a danger-colored dormancy alert.
- this.shouldShow = this.statusId > 0;
+ // Shown wherever dormancy monitoring applies, so a branch can read the thresholds and how far
+ // the account is from each of them. The template drops the risk timeline while the account is
+ // still healthy, where there is no risk to show.
+ this.shouldShow = this.statusId > 0 || this.isMonitoringActive;
+ }
+
+ /** True when the account has not left "Active", so there is no risk timeline worth showing. */
+ get isHealthy(): boolean {
+ return this.statusId === 0;
}
private computeRiskFromAccount(): void {
diff --git a/src/app/sponsor-dd/dd-list/dd-list.component.html b/src/app/sponsor-dd/dd-list/dd-list.component.html
index 25bd2f873..a7b1fa4ff 100644
--- a/src/app/sponsor-dd/dd-list/dd-list.component.html
+++ b/src/app/sponsor-dd/dd-list/dd-list.component.html
@@ -122,18 +122,40 @@
Actions
+
- Issue Draft
+ Draw for Dividend
Pending Approval (Req #{{ element.pendingIssueRequestId }})
+
+
+ Edit
+
+
+ Amendment Pending (Req #{{ element.pendingAmendmentRequestId }})
+ {
+ if (result) {
+ this.loadLeaflets();
+ }
+ });
+ }
+
onStopPayment(leaflet: any): void {
if (confirm(`Are you sure you want to place a Stop Payment on DD No. ${leaflet.ddNumber}?`)) {
this.sponsorDDService
@@ -446,8 +466,7 @@ export class DdListComponent implements OnInit, AfterViewInit {
providers: [DatePipe],
template: `
- GST Percentage (%)
-
- Required
-
- Must be between 0 and 100
-
+ Sponsor DD Payable GL Account *
+
+ {{ acc.glCode }} · {{ acc.name }}
+
+ Credited for the DD Face Value
+ Required
+
- DD Limit Amount
-
-
- Maximum face value for a single DD, and the maximum combined face value of all DDs issued to the same
- beneficiary on the same day. Leave blank for no limit.
-
- Cannot be negative
+ DD Charges / Commission Income GL *
+
+ {{ acc.glCode }} · {{ acc.name }}
+
+ Credited for DD Issuance Charges/Commission
+ Required
-
@if (data.emailSent) {
-
- Sent
-
+ Sent
} @else {
-
- Not sent
-
+ Not sent
}
@if (data.emailOpenedAt) {
-
- Seen
-
+ Seen
} @else {
Not yet seen
@@ -34,16 +28,15 @@