feat: cash-only registers, day book sections and DD amendments
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-09-03 18:26:22 +05:30
parent 6442562df0
commit fbce73fd17
111 changed files with 9076 additions and 3507 deletions
+15 -14
View File
@@ -20,8 +20,6 @@ import { CashierPositionComponent } from './cashier-position/cashier-position.co
import { ReconciliationComponent } from './reconciliation/reconciliation.component';
import { CashHistoryComponent } from './cash-history/cash-history.component';
import { DayBookComponent } from './day-book/day-book.component';
import { VoucherExceptionsComponent } from './voucher-exceptions/voucher-exceptions.component';
import { VoucherViewComponent } from './voucher-view/voucher-view.component';
import { VoucherReportComponent } from './voucher-report/voucher-report.component';
import { BranchDayComponent } from './branch-day/branch-day.component';
import { VaultPositionComponent } from './vault-position/vault-position.component';
@@ -226,20 +224,23 @@ const routes: Routes = [
component: DayBookComponent,
data: { title: 'Day Book', breadcrumb: 'Day Book' }
},
// The register is read one side at a time: Received is the CR side, Payment the DR side.
// Same component both times — the side it shows comes from `data.side`.
//
// `cashOnly` is what makes these the cashier's own registers rather than the whole voucher
// book: a cashier deals in notes and coins, so only vouchers that actually moved cash
// belong here, and the two reports then reconcile against the drawer and the closing
// report. A back-office screen wanting every voucher points at the same component without
// the flag.
{
path: 'voucher-exceptions',
component: VoucherExceptionsComponent,
data: { title: 'Voucher Exceptions', breadcrumb: 'Voucher Exceptions' }
},
{
path: 'voucher',
component: VoucherViewComponent,
data: { title: 'Voucher', breadcrumb: 'Voucher' }
},
{
path: 'voucher-report',
path: 'received-report',
component: VoucherReportComponent,
data: { title: 'Voucher Report', breadcrumb: 'Voucher Report' }
data: { title: 'Received Report', breadcrumb: 'Received Report', side: 'CR', cashOnly: true }
},
{
path: 'payment-report',
component: VoucherReportComponent,
data: { title: 'Payment Report', breadcrumb: 'Payment Report', side: 'DR', cashOnly: true }
},
{
path: 'closing-report',
+41
View File
@@ -312,6 +312,47 @@
font-variant-numeric: tabular-nums;
color: #0f172a;
}
// Names the part of a total that has no row in the table below, so the figures do not read as
// failing to add up.
&__note {
margin-top: 2px;
font-size: 10px;
line-height: 1.4;
color: #64748b;
}
// The drawer strip sits under the counter totals and is the figure a physical count is checked
// against, so it is marked out as the one to read rather than a second row of the same thing.
&--balance {
margin-top: 10px;
border-color: #cbd5e1;
box-shadow: 0 1px 3px rgb(15 23 42 / 8%);
}
}
.closing-note {
margin: 8px 0 0;
font-size: 11px;
line-height: 1.5;
color: #64748b;
}
// Says what a row without a denomination is, next to its chip.
.closing-row__hint {
margin-left: 6px;
font-size: 11px;
color: #64748b;
}
// A totals row, marked off from the rows it sums. Defined here rather than only in the day book's
// own stylesheet, which the other cashier screens do not load.
.pg-table tfoot td {
background: #f8fafc;
font-weight: 800;
font-size: 12px;
color: #334155;
border-top: 1px solid #e2e8f0;
}
.pg-step {
+12 -77
View File
@@ -126,66 +126,6 @@ export class CashierService {
return this.http.get('/cashreports/day-book', { params });
}
// ── Voucher physical verification & attachments ──
// The voucher number always travels in the body or a query parameter, never as a path segment:
// numbers are formatted CR/001/2108/0001 and Spring Security's strict firewall rejects the
// encoded slashes with a 400 before the request reaches the resource.
/** Records the outcome of checking a voucher against the paper slip. */
verifyVoucher(
voucherNo: string,
status: 'VERIFIED' | 'EXCEPTION',
slipAmount?: number,
remarks?: string
): Observable<any> {
return this.http.post('/vouchers/verify', {
voucherNo,
status,
slipAmount: slipAmount != null ? String(slipAmount) : null,
remarks: remarks ?? null,
locale: 'en'
});
}
uploadVoucherAttachment(
voucherNo: string,
fileName: string,
contentType: string,
fileData: string,
description?: string
): Observable<any> {
return this.http.post('/vouchers/attachments', {
voucherNo,
fileName,
contentType,
fileData,
description: description ?? null
});
}
listVoucherAttachments(voucherNo: string): Observable<any> {
const params = new HttpParams().set('voucherNo', voucherNo);
return this.http.get('/vouchers/attachments', { params });
}
deleteVoucherAttachment(id: number): Observable<any> {
return this.http.delete(`/vouchers/attachments/${id}`);
}
/** Fetched as a blob through HttpClient so the interceptor's auth header travels with it. */
downloadVoucherAttachment(id: number): Observable<Blob> {
return this.http.get(`/vouchers/attachments/${id}/download`, { responseType: 'blob' });
}
/**
* Flagged vouchers awaiting (or carrying) a conclusion. Not scoped to a date — an exception
* raised on Friday still needs answering on Monday.
*/
voucherExceptions(status: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN'): Observable<any> {
const params = new HttpParams().set('status', status);
return this.http.get('/vouchers/exceptions', { params });
}
/**
* The vouchers raised over a period, one row each, with what was debited and what credited.
*
@@ -193,7 +133,13 @@ export class CashierService {
* own work whatever they ask for — so `username` is a filter for those allowed to use it, not a
* way to choose whose report this is.
*/
voucherReport(from: string, to: string, username?: string, voucherType?: string): Observable<any> {
voucherReport(
from: string,
to: string,
username?: string,
voucherType?: string,
cashOnly?: boolean
): Observable<any> {
let params = new HttpParams().set('from', from).set('to', to);
if (username) {
params = params.set('username', username);
@@ -201,25 +147,14 @@ export class CashierService {
if (voucherType) {
params = params.set('voucherType', voucherType);
}
// Only sent when asked for: the server treats a missing flag as the full register, so the
// cashier screens opt in to cash and the back-office ones keep seeing everything.
if (cashOnly) {
params = params.set('cashOnly', 'true');
}
return this.http.get('/vouchers/report', { params });
}
/**
* One voucher and the debit and credit sides it is made of — what a printed voucher shows.
*
* The voucher number travels as a query parameter, not a path segment: numbers are formatted
* CR/2708/0001 and the server rejects the encoded slashes in a path.
*/
voucherDetail(voucherNo: string): Observable<any> {
const params = new HttpParams().set('voucherNo', voucherNo);
return this.http.get('/vouchers/detail', { params });
}
/** Closes an exception. Manager / head cashier only, and a supporting document must exist. */
resolveVoucherException(voucherNo: string, resolutionStatus: string, resolutionNotes: string): Observable<any> {
return this.http.post('/vouchers/exceptions/resolve', { voucherNo, resolutionStatus, resolutionNotes });
}
/**
* Account register for one date: what opened and closed that day, each account's balance as it
* stood then, and what it holds now.
@@ -27,12 +27,23 @@
</div>
</header>
@if (!showGl && rows.length > 0) {
<!-- Shown whenever the day has any cash at all, not only when a denomination breakdown exists:
a cash Demand Draft is recorded without one, and gating on rows hid its money entirely. -->
@if (!showGl && (rows.length > 0 || ddCashTotal > 0)) {
<!-- Summary totals bar -->
<div class="closing-summary">
<div class="closing-summary__item closing-summary__item--in">
<span class="closing-summary__label">Total Deposits (IN)</span>
<span class="closing-summary__value">₹{{ depositTotal | number: '1.2-2' }}</span>
<span class="closing-summary__value">₹{{ depositTotalWithDd | number: '1.2-2' }}</span>
@if (ddCashTotal > 0) {
<!-- Named on the tile as well as in the table: this part of the total carries no
denomination, so without saying so the figures read as failing to add up. -->
<span class="closing-summary__note">
incl. ₹{{ ddCashTotal | number: '1.2-2' }} not itemised ({{ ddCashCount }} cash DD{{
ddCashCount === 1 ? '' : 's'
}})
</span>
}
</div>
<div class="closing-summary__sep">−</div>
<div class="closing-summary__item closing-summary__item--out">
@@ -45,15 +56,57 @@
[class.closing-summary__item--net-pos]="netTotal >= 0"
[class.closing-summary__item--net-neg]="netTotal < 0"
>
<span class="closing-summary__label">Net Closing Cash</span>
<!-- Named for what it is: the counter's own movement. It was labelled "Net Closing Cash",
which it is not -- it carries nothing forward from yesterday and leaves out vault and
inter-cashier legs, so it can differ from the drawer by a large amount. The real
closing figure is in the balance strip below. -->
<span class="closing-summary__label">Net Counter Movement</span>
<span class="closing-summary__value">₹{{ netTotal | number: '1.2-2' }}</span>
</div>
</div>
}
<!-- Opening → closing, the figure a physical count is checked against. Shown only for a
cashier-scoped report; a branch-wide one has no single drawer to open and close. -->
@if (!showGl && scopedToCashier && closingBalance !== null) {
<div class="closing-summary closing-summary--balance">
<div class="closing-summary__item">
<span class="closing-summary__label">Opening Balance</span>
<span class="closing-summary__value">₹{{ openingBalance | number: '1.2-2' }}</span>
</div>
<div class="closing-summary__sep">+</div>
<div
class="closing-summary__item"
[class.closing-summary__item--in]="(cashMovement ?? 0) >= 0"
[class.closing-summary__item--out]="(cashMovement ?? 0) < 0"
>
<span class="closing-summary__label">Cash Movement</span>
<span class="closing-summary__value">₹{{ cashMovement | number: '1.2-2' }}</span>
</div>
<div class="closing-summary__sep">=</div>
<div
class="closing-summary__item"
[class.closing-summary__item--net-pos]="(closingBalance ?? 0) >= 0"
[class.closing-summary__item--net-neg]="(closingBalance ?? 0) < 0"
>
<span class="closing-summary__label">Closing Balance</span>
<span class="closing-summary__value">₹{{ closingBalance | number: '1.2-2' }}</span>
</div>
</div>
@if (cashMovement !== null && netTotal !== cashMovement) {
<!-- The gap between the two is not an error: it is the vault, inter-cashier and exchange
traffic that never crossed the counter. Said plainly, because two different "net"
figures on one screen otherwise read as a bug. -->
<p class="closing-note">
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.
</p>
}
}
@if (!showGl) {
<div class="pg-card">
@if (noteRows.length > 0) {
@if (noteRows.length > 0 || ddCashTotal > 0) {
<div class="pos-section-label">Notes</div>
<table class="pg-table">
<thead>
@@ -93,7 +146,42 @@
</td>
</tr>
}
<!-- Cash taken for a Demand Draft, on the deposit side where it belongs. It has no
denomination to sit against -- the notes were never itemised -- so the piece
counts show a dash rather than a zero, which would read as "no notes". -->
@if (ddCashTotal > 0) {
<tr>
<td>
<span class="pg-chip">Not itemised</span>
<span class="closing-row__hint"> {{ ddCashCount }} cash DD{{ ddCashCount === 1 ? '' : 's' }} </span>
</td>
<td class="num col-in">—</td>
<td class="num col-in">₹{{ ddCashTotal | number: '1.2-2' }}</td>
<td class="num col-out">—</td>
<td class="num col-out">₹0.00</td>
<td class="num col-net">—</td>
<td class="num col-net" style="color: #16a34a">+₹{{ ddCashTotal | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<!-- Totals, so the table reconciles against the summary above rather than having to be
added up by hand — and so the deposit column visibly accounts for the DD row. -->
<tfoot>
<tr>
<td>Total</td>
<td class="num col-in">{{ noteDepositCount }}</td>
<td class="num col-in">₹{{ noteDepositAmount | number: '1.2-2' }}</td>
<td class="num col-out">{{ noteWithdrawalCount }}</td>
<td class="num col-out">₹{{ noteWithdrawalAmount | number: '1.2-2' }}</td>
<td class="num col-net"></td>
<td class="num col-net">
{{ noteNetAmount >= 0 ? '+' : '−' }}₹{{
(noteNetAmount < 0 ? -noteNetAmount : noteNetAmount) | number: '1.2-2'
}}
</td>
</tr>
</tfoot>
</table>
}
@@ -141,8 +229,20 @@
</table>
}
@if (loaded && rows.length === 0 && exchangeRows.length === 0) {
<div class="pg-empty">No cash denomination data for this date.</div>
<!-- Not shown when a Demand Draft row is on the table: there is something to read then, and
an "empty" notice under it contradicts it. -->
@if (loaded && rows.length === 0 && exchangeRows.length === 0 && ddCashTotal === 0) {
<!-- A day with no counter work is not an empty report: the cashier is still holding
whatever they held yesterday, and that balance is shown in the strip above. Saying
only "no data" here read as though the drawer were empty. -->
<div class="pg-empty">
@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.
}
</div>
}
</div>
}
@@ -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;
}
+74 -275
View File
@@ -65,42 +65,6 @@
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
<!-- Exceptions surface at the top of the day, not only as a badge buried in their own row: a
flag raised for investigation is useless if someone has to scroll to find it. -->
@if (!loading && exceptionRows.length > 0) {
<div class="db-exceptions">
<div class="db-exceptions__head">
<mat-icon>warning</mat-icon>
<strong>
{{ exceptionRows.length }} voucher{{ exceptionRows.length === 1 ? '' : 's' }} awaiting investigation
</strong>
@if (resolvedExceptionCount > 0) {
<span class="db-muted">({{ resolvedExceptionCount }} already closed)</span>
}
<button type="button" class="pg-btn pg-btn--warn" (click)="toggleExceptionsOnly()">
{{ exceptionsOnly ? 'Show all vouchers' : 'Show only these' }}
</button>
</div>
@for (e of exceptionRows; track e.voucherNo) {
<div class="db-exceptions__item">
<span class="pg-chip">{{ e.voucherNo }}</span>
<span class="db-exceptions__amt">
System ₹{{ e.amount | number: '1.2-2' }}
@if (e.slipAmount != null && e.slipAmount !== e.amount) {
· Slip ₹{{ e.slipAmount | number: '1.2-2' }}
<strong class="db-exceptions__diff"
>(diff ₹{{ e.slipAmount - (e.amount ?? 0) | number: '1.2-2' }})</strong
>
}
</span>
<span class="db-exceptions__why">{{ e.verifyRemarks }}</span>
<span class="db-muted">— {{ e.verifiedByName }}, {{ timeOf(e.verifiedAt) }}</span>
</div>
}
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@@ -139,252 +103,87 @@
Totals count approved vouchers only — pending and rejected rows are listed but never move the money.
</p>
<div class="pg-card">
<table class="pg-table">
<thead>
<tr>
<th>Voucher No</th>
<th>Dr/Cr</th>
<th>Type</th>
<th>Account</th>
<th>Party</th>
<th class="num">Amount</th>
<th>Payment</th>
<th>Status</th>
<th>By</th>
<th>Approved By</th>
<th>Slip Check</th>
<th>Time</th>
<th></th>
</tr>
</thead>
<tbody>
@for (r of filteredRows; track r.voucherNo ?? r.submittedAt) {
<tr [class.db-row--cr]="r.side === 'CR'" [class.db-row--dr]="r.side === 'DR'">
<td>
<span class="pg-chip">{{ r.voucherNo ?? '—' }}</span>
</td>
<td>
<span class="db-side" [class.db-side--cr]="r.side === 'CR'" [class.db-side--dr]="r.side === 'DR'">
{{ r.side }}
</span>
</td>
<td>{{ r.kind }}</td>
<td>
{{ r.accountNo ?? '—' }}
@if (r.accountType) {
<span class="db-muted">· {{ r.accountType }}</span>
}
</td>
<td>{{ r.party ?? '—' }}</td>
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
<td>{{ r.paymentTypeName ?? '—' }}</td>
<td>
<span [class]="statusClass(r.status)">{{ r.status }}</span>
</td>
<td>{{ r.handledBy ?? '—' }}</td>
<td>
{{ r.approvedBy ?? '—' }}
@if (r.approvedBy && r.actionedAt) {
<span class="db-muted">· {{ timeOf(r.actionedAt) }}</span>
}
</td>
<td>
@if (r.verifyStatus === 'VERIFIED') {
<span class="db-badge db-badge--ok" [title]="'Verified by ' + (r.verifiedByName ?? '')"
>✓ MATCHED</span
>
} @else if (r.verifyStatus === 'EXCEPTION' && r.resolutionStatus) {
<!-- Closed: the flag is history now, so it reads as settled rather than pending. -->
<span
class="db-badge db-badge--ok"
[title]="resolutionLabel(r.resolutionStatus) + ' — ' + (r.resolutionNotes ?? '')"
>
✓ RESOLVED
</span>
} @else if (r.verifyStatus === 'EXCEPTION') {
<span
class="db-badge db-badge--rejected"
[title]="'Slip ₹' + r.slipAmount + ' vs system ₹' + r.amount + ' — ' + (r.verifyRemarks ?? '')"
>
⚠ EXCEPTION
</span>
} @else {
<span class="db-muted">Not checked</span>
}
</td>
<td>{{ timeOf(r.submittedAt) }}</td>
<td class="db-actions">
<button type="button" class="db-icon-btn" (click)="openVerify(r)" title="Check against the paper slip">
<mat-icon>fact_check</mat-icon>
</button>
<button type="button" class="db-icon-btn" (click)="openAttach(r)" title="Attach or view scanned slip">
<mat-icon>attach_file</mat-icon>
@if (r.attachmentCount > 0) {
<span class="db-icon-btn__count">{{ r.attachmentCount }}</span>
}
</button>
</td>
<!-- One table per source register rather than one long list. The two read differently: the
cashier's rows are cash that actually crossed the counter, the clerk's are requests nobody
has actioned yet. Mixed together they invited the wrong sum. -->
@for (s of sections; track s.key) {
<div class="pg-card db-section">
<div class="db-section__head">
<strong>{{ s.label }}</strong>
<span class="db-muted">{{ s.hint }}</span>
<span class="db-section__count">{{ s.rows.length }} row{{ s.rows.length === 1 ? '' : 's' }}</span>
</div>
<table class="pg-table">
<thead>
<tr>
<th>Voucher No</th>
<th>Dr/Cr</th>
<th>Type</th>
<th>Account</th>
<th>Party</th>
<th class="num">Amount</th>
<th>Payment</th>
<th>Status</th>
<th>By</th>
<th>Approved By</th>
<th>Time</th>
</tr>
<!-- A flagged voucher spells out its reason in the table itself. A tooltip is not
enough: it cannot be printed, exported, or read without a mouse. -->
@if (r.verifyStatus === 'EXCEPTION' && verifyRow !== r) {
<tr class="db-exc-row" [class.db-exc-row--resolved]="r.resolutionStatus">
<td colspan="13">
<mat-icon>{{ r.resolutionStatus ? 'task_alt' : 'warning' }}</mat-icon>
<strong>Exception</strong>
@if (r.slipAmount != null && r.slipAmount !== r.amount) {
— slip ₹{{ r.slipAmount | number: '1.2-2' }} vs system ₹{{ r.amount | number: '1.2-2' }} (difference
₹{{ r.slipAmount - (r.amount ?? 0) | number: '1.2-2' }})
}
— {{ r.verifyRemarks }}
<span class="db-muted">· raised by {{ r.verifiedByName }} at {{ timeOf(r.verifiedAt) }}</span>
<!-- Both halves of the record: what the cashier found, and how it was settled. -->
@if (r.resolutionStatus) {
<div class="db-exc-row__resolution">
<strong>Closed — {{ resolutionLabel(r.resolutionStatus) }}:</strong>
{{ r.resolutionNotes }}
<span class="db-muted">· by {{ r.resolvedByName }} at {{ timeOf(r.resolvedAt) }}</span>
</div>
</thead>
<tbody>
@for (r of s.rows; track r.voucherNo ?? r.section + r.txnId) {
<tr [class.db-row--cr]="r.side === 'CR'" [class.db-row--dr]="r.side === 'DR'">
<td>
<span class="pg-chip">{{ r.voucherNo ?? '—' }}</span>
</td>
<td>
<span class="db-side" [class.db-side--cr]="r.side === 'CR'" [class.db-side--dr]="r.side === 'DR'">
{{ r.side }}
</span>
</td>
<td>{{ r.kind }}</td>
<td>
{{ r.accountNo ?? '—' }}
@if (r.accountType) {
<span class="db-muted">· {{ r.accountType }}</span>
}
</td>
</tr>
}
<!-- Verification panel, opened inline under its own voucher so the two amounts sit
next to the row they describe. -->
@if (verifyRow === r) {
<tr class="db-panel-row">
<td colspan="13">
<div class="db-panel">
<div class="db-panel__head">
<strong>Slip check — {{ r.voucherNo }}</strong>
<button type="button" class="db-icon-btn" (click)="closeVerify()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="db-panel__body">
<div class="db-cmp">
<span class="db-cmp__label">System recorded</span>
<span class="db-cmp__value">₹{{ r.amount | number: '1.2-2' }}</span>
</div>
<label class="pg-field">
<span>Amount written on slip</span>
<input type="number" step="0.01" [(ngModel)]="verifySlipAmount" />
</label>
@if (verifySlipBlank) {
<div class="db-cmp">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">—</span>
</div>
} @else if (verifyMismatch) {
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">₹{{ verifyDifference | number: '1.2-2' }}</span>
</div>
} @else {
<div class="db-cmp db-cmp--good">
<span class="db-cmp__label">Match</span>
<span class="db-cmp__value">Amounts agree</span>
</div>
}
</div>
<label class="pg-field db-panel__remarks">
<span>{{ verifyMismatch ? 'What did you find? (required)' : 'Remarks (optional)' }}</span>
<input
type="text"
[(ngModel)]="verifyRemarks"
placeholder="e.g. slip written as 500, system posted 400 — teller keyed it wrong"
/>
</label>
@if (verifyMismatch) {
<p class="db-panel__warn">
Saving this marks the voucher as an <strong>exception</strong> for investigation — it does not
change the posted amount.
</p>
}
<div class="db-panel__actions">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="saveVerify()"
[disabled]="verifySaving || verifySlipBlank"
>
{{ verifyMismatch ? 'Mark Exception' : 'Mark Verified' }}
</button>
<!-- Raising an exception on an already-matched voucher stays available: a
slip cleared in haste, or a difference noticed later, has to be
recordable without erasing the earlier verdict. -->
@if (!verifyMismatch) {
<button
type="button"
class="pg-btn pg-btn--warn"
(click)="raiseException()"
[disabled]="verifySaving"
>
Raise Exception
</button>
}
<button type="button" class="pg-btn" (click)="closeVerify()">Cancel</button>
</div>
</div>
<td>{{ r.party ?? '—' }}</td>
<td class="num">₹{{ r.amount | number: '1.2-2' }}</td>
<td>{{ r.paymentTypeName ?? '—' }}</td>
<td>
<span [class]="statusClass(r.status)">{{ r.status }}</span>
</td>
</tr>
}
<!-- Attachment panel for this voucher. -->
@if (attachRow === r) {
<tr class="db-panel-row">
<td colspan="13">
<div class="db-panel">
<div class="db-panel__head">
<strong>Attachments — {{ r.voucherNo }}</strong>
<button type="button" class="db-icon-btn" (click)="closeAttach()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<label class="db-upload">
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
<span class="db-muted">Scanned slip, cheque image or ID proof · max 10 MB</span>
</label>
@if (attachUploading) {
<p class="db-muted">Uploading…</p>
}
@if (attachLoading) {
<p class="db-muted">Loading…</p>
}
@if (!attachLoading && attachments.length === 0) {
<p class="db-muted">Nothing attached to this voucher yet.</p>
}
@for (a of attachments; track a.id) {
<div class="db-file">
<mat-icon>description</mat-icon>
<span class="db-file__name">{{ a.fileName }}</span>
<span class="db-muted">{{ a.fileSize }} bytes · {{ a.uploadedByName }}</span>
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
<mat-icon>download</mat-icon>
</button>
<button type="button" class="db-icon-btn" (click)="deleteAttachment(a)" title="Delete">
<mat-icon>delete</mat-icon>
</button>
</div>
}
</div>
<td>{{ r.handledBy ?? '—' }}</td>
<td>
{{ r.approvedBy ?? '—' }}
@if (r.approvedBy && r.actionedAt) {
<span class="db-muted">· {{ timeOf(r.actionedAt) }}</span>
}
</td>
<td>{{ timeOf(r.submittedAt) }}</td>
</tr>
}
}
</tbody>
<tfoot>
<tr>
<td colspan="5">Showing {{ filteredRows.length }} of {{ rows.length }} voucher(s)</td>
<td class="num">₹{{ shownCredit - shownDebit | number: '1.2-2' }}</td>
<td colspan="7"></td>
</tr>
</tfoot>
</table>
</div>
</tbody>
<tfoot>
<tr>
<td colspan="5">{{ s.label }} subtotal</td>
<td class="num">
@if (s.credit > 0 && s.debit > 0) {
₹{{ s.credit | number: '1.2-2' }} CR · ₹{{ s.debit | number: '1.2-2' }} DR
} @else if (s.credit > 0) {
₹{{ s.credit | number: '1.2-2' }} CR
} @else if (s.debit > 0) {
₹{{ s.debit | number: '1.2-2' }} DR
} @else {
—
}
</td>
<td colspan="5"></td>
</tr>
</tfoot>
</table>
</div>
}
}
@if (!loading && loaded && rows.length > 0 && filteredRows.length === 0) {
@@ -32,6 +32,34 @@
color: #94a3b8;
}
/* One card per source register. The heading says which register, and why its rows read differently
from the others, so the three are not added up as though they were one list. */
.db-section {
margin-bottom: 14px;
&__head {
display: flex;
align-items: baseline;
gap: 10px;
padding: 10px 14px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
font-size: 12px;
strong {
font-size: 13px;
color: #0d1b2a;
}
}
&__count {
margin-left: auto;
font-size: 11px;
font-weight: 700;
color: #64748b;
}
}
/* Dr/Cr marker — the one thing the eye should catch when scanning a day's vouchers. */
.db-side {
display: inline-block;
@@ -169,41 +197,6 @@
}
}
/* The reason spelled out under its own flagged row. */
.db-exc-row td {
background: #fef2f2 !important;
border-bottom: 1px solid #fecaca !important;
color: #7f1d1d;
font-size: 12px;
padding: 8px 14px !important;
mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
vertical-align: -3px;
margin-right: 4px;
}
}
/* A closed exception stays on the row as history, but drops the alarm colouring — it is no longer
something anyone has to act on. */
.db-exc-row--resolved td {
background: #f0fdf4 !important;
border-bottom: 1px solid #bbf7d0 !important;
color: #14532d;
}
.db-exc-row__resolution {
margin-top: 4px;
padding-top: 4px;
border-top: 1px dotted #bbf7d0;
}
.db-actions {
white-space: nowrap;
}
/* Raising an exception is a deliberate, consequential act — styled to read that way without
looking destructive, since nothing is deleted. */
.pg-btn--warn {
@@ -352,38 +345,6 @@
}
}
.db-upload {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.db-file {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
margin-bottom: 6px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #64748b;
}
&__name {
font-weight: 700;
color: #0d1b2a;
}
}
.pg-table tfoot td {
background: #f8fafc;
font-weight: 800;
+47 -292
View File
@@ -13,8 +13,18 @@ import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
/**
* Which part of the day's work a row came from. Set by the server, one per source register.
*
* Only counter business appears in the day book. Cash moved between the vault and the drawers is
* the bank shifting its own money, not a customer transaction, and it is read on the Cashier
* Position and Closing Cash Report where the drawer itself is the subject.
*/
type DayBookSection = 'CASHIER' | 'CLERK';
interface DayBookRow {
voucherNo: string | null;
section: DayBookSection;
side: 'CR' | 'DR';
kind: string;
accountNo: string | null;
@@ -29,18 +39,6 @@ interface DayBookRow {
txnId: string | null;
submittedAt: string | null;
actionedAt: string | null;
/** null until someone has physically checked the slip. */
verifyStatus: 'VERIFIED' | 'EXCEPTION' | null;
slipAmount: number | null;
verifyRemarks: string | null;
verifiedByName: string | null;
verifiedAt: string | null;
/** Set once a clerk or manager has closed the exception; null while it is still open. */
resolutionStatus: string | null;
resolutionNotes: string | null;
resolvedByName: string | null;
resolvedAt: string | null;
attachmentCount: number;
}
/**
@@ -128,48 +126,47 @@ export class DayBookComponent implements OnInit {
this.load();
}
/** Show only the flagged vouchers — set from the exception banner. */
exceptionsOnly = false;
/**
* Vouchers still awaiting a conclusion. A closed exception drops out of the banner — it stays
* visible on its own row as history, but it is no longer something anyone has to act on.
* The day's rows split by where they came from, in the order they are read.
*
* Cashier work first — that is the counter — then the clerk's outstanding requests. An empty
* section is dropped rather than shown as a heading over nothing.
*
* A section is not "who typed it": a clerk raises a withdrawal that a cashier pays out, so the
* heading says which register the entry belongs to, and the By column still names the person.
*/
get exceptionRows(): DayBookRow[] {
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && !r.resolutionStatus);
}
/** Closed exceptions, kept for the record rather than for action. */
get resolvedExceptionCount(): number {
return this.rows.filter((r) => r.verifyStatus === 'EXCEPTION' && r.resolutionStatus).length;
}
/** Human label for a resolution code. */
resolutionLabel(status: string | null): string {
switch (status) {
case 'RESOLVED_SYSTEM_ERROR':
return 'System entry was wrong';
case 'RESOLVED_SLIP_ERROR':
return 'Slip was written wrong';
case 'RESOLVED_NO_ACTION':
return 'No action needed';
default:
return status ?? '';
}
}
/** Vouchers nobody has checked against a slip yet. */
get uncheckedCount(): number {
return this.rows.filter((r) => !r.verifyStatus).length;
}
toggleExceptionsOnly(): void {
this.exceptionsOnly = !this.exceptionsOnly;
get sections(): {
key: DayBookSection;
label: string;
hint: string;
rows: DayBookRow[];
credit: number;
debit: number;
}[] {
const order: { key: DayBookSection; label: string; hint: string }[] = [
{ key: 'CASHIER', label: 'Cashier', hint: 'Cash actually taken in and paid out over the counter' },
{ key: 'CLERK', label: 'Clerk', hint: 'Requests raised but not yet actioned — no cash has moved' }
];
return order
.map((s) => {
const rows = this.filteredRows.filter((r) => r.section === s.key);
return {
...s,
rows,
credit: rows
.filter((r) => r.side === 'CR' && this.countsAsMoney(r))
.reduce((t, r) => t + Number(r.amount ?? 0), 0),
debit: rows
.filter((r) => r.side === 'DR' && this.countsAsMoney(r))
.reduce((t, r) => t + Number(r.amount ?? 0), 0)
};
})
.filter((s) => s.rows.length > 0);
}
/** Free-text narrowing over the rows already fetched — no extra round trip per keystroke. */
get filteredRows(): DayBookRow[] {
const base = this.exceptionsOnly ? this.exceptionRows : this.rows;
const base = this.rows;
const q = this.search.trim().toLowerCase();
if (!q) return base;
return base.filter((r) =>
@@ -231,236 +228,9 @@ export class DayBookComponent implements OnInit {
return t ? t.slice(0, 5) : '—';
}
// ── Physical slip verification ────────────────────────────────────────────
/** Row whose verification panel is open, or null when none is. */
verifyRow: DayBookRow | null = null;
verifySlipAmount: number | null = null;
verifyRemarks = '';
verifySaving = false;
openVerify(r: DayBookRow): void {
this.verifyRow = r;
// Pre-filled with the system's own figure: the common case is that the two agree, and when
// they don't the cashier only has to change the digits that actually differ.
this.verifySlipAmount = Number(r.amount ?? 0);
this.verifyRemarks = r.verifyRemarks ?? '';
this.attachRow = null;
}
closeVerify(): void {
this.verifyRow = null;
this.verifySlipAmount = null;
this.verifyRemarks = '';
}
/** True when the amount typed off the slip differs from what the system recorded. */
get verifyMismatch(): boolean {
if (!this.verifyRow || this.verifySlipAmount == null) return false;
return Number(this.verifySlipAmount) !== Number(this.verifyRow.amount ?? 0);
}
/**
* True while the slip box is empty. Clearing the field must not read as "amounts agree" — an
* unanswered question is not a match, so the panel shows neither verdict until a figure is typed.
*/
get verifySlipBlank(): boolean {
return this.verifySlipAmount == null || String(this.verifySlipAmount).trim() === '';
}
get verifyDifference(): number {
if (!this.verifyRow || this.verifySlipAmount == null) return 0;
return Number(this.verifySlipAmount) - Number(this.verifyRow.amount ?? 0);
}
/**
* Saves the verdict. The status is derived from the figures rather than picked by the user —
* if the slip and the system disagree it is an exception, whatever anyone intended to click.
*/
saveVerify(): void {
const row = this.verifyRow;
if (!row?.voucherNo) return;
const isException = this.verifyMismatch;
if (isException && !this.verifyRemarks.trim()) {
this.errorMsg = 'The amounts differ — add a remark saying what you found before saving.';
return;
}
this.verifySaving = true;
this.errorMsg = '';
this.service
.verifyVoucher(
row.voucherNo,
isException ? 'EXCEPTION' : 'VERIFIED',
isException ? Number(this.verifySlipAmount) : undefined,
this.verifyRemarks.trim() || undefined
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not save the verification.';
return of(null);
})
)
.subscribe((res: any) => {
this.verifySaving = false;
if (res) {
this.closeVerify();
this.load();
}
});
}
/**
* Flags a voucher whose amounts agree — the problem is something other than the figure: a
* missing signature, a wrong account, a slip the member disputes. Deliberately not a "clear the
* check" action: erasing a verdict would erase the evidence that anything was ever wrong, which
* is exactly what an investigation needs.
*/
raiseException(): void {
const row = this.verifyRow;
if (!row?.voucherNo) return;
if (!this.verifyRemarks.trim()) {
this.errorMsg = 'Say what the problem is before raising an exception.';
return;
}
this.verifySaving = true;
this.errorMsg = '';
this.service
// The slip amount is sent as-is: the figures match, so it doubles as the record of what
// both the slip and the system said at the time the exception was raised.
.verifyVoucher(
row.voucherNo,
'EXCEPTION',
Number(this.verifySlipAmount ?? row.amount ?? 0),
this.verifyRemarks.trim()
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not raise the exception.';
return of(null);
})
)
.subscribe((res: any) => {
this.verifySaving = false;
if (res) {
this.closeVerify();
this.load();
}
});
}
// ── Attachments ───────────────────────────────────────────────────────────
attachRow: DayBookRow | null = null;
attachments: any[] = [];
attachLoading = false;
attachUploading = false;
openAttach(r: DayBookRow): void {
this.attachRow = r;
this.verifyRow = null;
this.attachments = [];
if (!r.voucherNo) return;
this.attachLoading = true;
this.service
.listVoucherAttachments(r.voucherNo)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.attachLoading = false;
this.attachments = res?.attachments ?? [];
});
}
closeAttach(): void {
this.attachRow = null;
this.attachments = [];
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
const row = this.attachRow;
if (!file || !row?.voucherNo) return;
// 10 MB, matching the backend's own ceiling — rejecting here saves uploading the whole file
// only to have it refused.
if (file.size > 10 * 1024 * 1024) {
this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.';
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
this.attachUploading = true;
this.errorMsg = '';
this.service
.uploadVoucherAttachment(
row.voucherNo!,
file.name,
file.type || 'application/octet-stream',
String(reader.result)
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.';
return of(null);
})
)
.subscribe((res: any) => {
this.attachUploading = false;
input.value = '';
if (res) {
this.openAttach(row);
this.load();
}
});
};
reader.readAsDataURL(file);
}
/**
* Fetched through HttpClient rather than window.open: the download endpoint needs the auth
* header the interceptor adds, which a bare browser navigation would not carry. The blob is
* then handed to the browser as a normal save.
*/
downloadAttachment(a: any): void {
this.service
.downloadVoucherAttachment(a.id)
.pipe(catchError(() => of(null)))
.subscribe((blob: Blob | null) => {
if (!blob) {
this.errorMsg = 'Could not download that file.';
return;
}
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = a.fileName ?? 'attachment';
link.click();
URL.revokeObjectURL(url);
});
}
deleteAttachment(a: any): void {
const row = this.attachRow;
this.service
.deleteVoucherAttachment(a.id)
.pipe(catchError(() => of(null)))
.subscribe(() => {
if (row) this.openAttach(row);
this.load();
});
}
exportCsv(): void {
const header = [
'Section',
'Voucher No',
'Side',
'Type',
@@ -471,18 +241,11 @@ export class DayBookComponent implements OnInit {
'Status',
'Handled By',
'Approved By',
'Slip Check',
'Slip Amount',
'Remarks',
'Checked By',
'Resolution',
'Resolution Notes',
'Closed By',
'Attachments',
'Time'
];
const lines = this.filteredRows.map((r) =>
[
r.section,
r.voucherNo ?? '',
r.side,
r.kind,
@@ -493,14 +256,6 @@ export class DayBookComponent implements OnInit {
r.status,
r.handledBy ?? '',
r.approvedBy ?? '',
r.verifyStatus ?? 'NOT CHECKED',
r.slipAmount != null ? String(r.slipAmount) : '',
r.verifyRemarks ?? '',
r.verifiedByName ?? '',
r.resolutionStatus ? this.resolutionLabel(r.resolutionStatus) : '',
r.resolutionNotes ?? '',
r.resolvedByName ?? '',
String(r.attachmentCount ?? 0),
this.timeOf(r.submittedAt)
]
// Quote every field and double any embedded quote: names and narrations routinely
@@ -0,0 +1,497 @@
/**
* 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, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
export interface CashierIssueDdDialogData {
leaflet: any;
branchName: string;
sponsorBankName: string;
faceValue: number;
chargesAmount: number;
gstPercentage: number;
gstAmount: number;
totalCashPayable: number;
/**
* How the draft is being paid for. Cash is a walk-in purchaser the bank may know nothing about,
* so their name, address and Aadhaar have to be captured here. An account debit is a member whose
* account is already on screen — those fields are not asked for, and must not be sent, because
* the identity is the account holder's rather than a walk-in's.
*/
paymentMethod?: 'CASH' | 'ACCOUNT_DEBIT';
/** The account being debited, shown so the clerk can see what is funding the draft. */
accountNo?: string;
/** The account holder, for the same reason. */
accountHolderName?: string;
}
@Component({
selector: 'mifosx-cashier-issue-dd-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule,
MatIconModule
],
template: `
<div class="dd-dialog-shell">
<div class="dd-dialog-header">
<div class="dd-dialog-header__title">
<mat-icon class="dd-icon">receipt_long</mat-icon>
<div>
<h2>{{ isAccountDebit ? 'Account-Debit Demand Draft (DD)' : 'Walk-in Cash Demand Draft (DD)' }}</h2>
<p>
Next available DD leaflet auto-selected ·
{{ isAccountDebit ? 'Enter beneficiary details' : 'Enter purchaser &amp; beneficiary details' }}
</p>
</div>
</div>
<button mat-icon-button type="button" (click)="onClose()" class="close-btn">
<mat-icon>close</mat-icon>
</button>
</div>
<mat-dialog-content class="dd-dialog-body">
<!-- Draft & Financials Summary Card -->
<div class="dd-summary-card">
<div class="summary-col">
<span class="label">Auto-Selected Leaflet</span>
<strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong>
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">Face Value</span>
<strong class="amount">₹{{ data.faceValue | number: '1.2-2' }}</strong>
<span class="sublabel">+ Charges ₹{{ data.chargesAmount | number: '1.2-2' }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col">
<span class="label">GST ({{ data.gstPercentage }}%)</span>
<strong class="amount">₹{{ data.gstAmount | number: '1.2-2' }}</strong>
<span class="sublabel">on charges only</span>
</div>
<div class="summary-divider"></div>
<div class="summary-col highlight-col">
<span class="label">{{ isAccountDebit ? 'Total Debited' : 'Total Cash Collected' }}</span>
<strong class="total-amount">₹{{ data.totalCashPayable | number: '1.2-2' }}</strong>
<span class="sublabel">
@if (isAccountDebit) {
{{ data.accountNo || 'Account' }}
} @else {
Denominations verified
}
</span>
</div>
</div>
<!--
The account funding the draft, stated before the form.
A clerk reaching this dialog has an account loaded on the workstation behind it, and the
debit is irreversible once a checker approves. Naming the account and its holder here is
what makes a wrong account visible while it can still be cancelled.
-->
@if (isAccountDebit) {
<div class="dd-funding-note">
<mat-icon>account_balance_wallet</mat-icon>
<div>
<strong>Debiting {{ data.accountNo }}</strong>
@if (data.accountHolderName) {
· {{ data.accountHolderName }}
}
<div class="dd-funding-sub">
₹{{ data.totalCashPayable | number: '1.2-2' }} will be debited from this account when a checker
approves. No cash is taken at the counter.
</div>
</div>
</div>
}
<form [formGroup]="form" class="dd-form">
<!--
Purchaser identity is asked for only when the bank has none: a walk-in paying cash. On an
account debit the purchaser IS the account holder, already identified and KYC'd, and
re-capturing a name and Aadhaar here would invite a mismatch against the account on record.
-->
@if (!isAccountDebit) {
<div class="form-section-title">Walk-in Purchaser Details</div>
<div class="form-row">
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Walk-in Purchaser Name *</mat-label>
<input
matInput
formControlName="walkinPurchaserName"
placeholder="e.g. RAMESH CHANDRA"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('required')"
>Purchaser name is required</mat-error
>
<mat-error *ngIf="form.get('walkinPurchaserName')?.hasError('minlength')"
>Must be at least 3 characters</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>12-Digit Aadhaar Number *</mat-label>
<input
matInput
formControlName="walkinPurchaserAadhaar"
placeholder="12-digit Aadhaar"
maxlength="12"
/>
<mat-hint>Masked to last 4 digits before storage</mat-hint>
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('required')"
>Aadhaar number is required</mat-error
>
<mat-error *ngIf="form.get('walkinPurchaserAadhaar')?.hasError('pattern')"
>Must be exactly 12 numeric digits</mat-error
>
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Purchaser Address / Location *</mat-label>
<input
matInput
formControlName="walkinPurchaserAddress"
placeholder="e.g. 14, SECTOR 5, MAIN ROAD, MUMBAI"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('walkinPurchaserAddress')?.hasError('required')"
>Address is required</mat-error
>
</mat-form-field>
}
<!-- Beneficiary Section -->
<div class="form-section-title">Beneficiary & Payment Details</div>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Beneficiary Name (In Favour Of) *</mat-label>
<input
matInput
formControlName="beneficiaryName"
placeholder="e.g. UNIVERSITY OF MUMBAI"
maxlength="120"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('required')"
>Beneficiary name is required</mat-error
>
<mat-error *ngIf="form.get('beneficiaryName')?.hasError('maxlength')">Max 120 characters</mat-error>
</mat-form-field>
<div class="form-row">
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Payable At - Branch *</mat-label>
<input
matInput
formControlName="payableAtBranch"
placeholder="e.g. FORT BRANCH"
maxlength="150"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('payableAtBranch')?.hasError('required')"
>Payable Branch is required</mat-error
>
</mat-form-field>
<mat-form-field appearance="outline" class="form-field-flex">
<mat-label>Payable At - City *</mat-label>
<input
matInput
formControlName="payableAtCity"
placeholder="e.g. MUMBAI"
maxlength="100"
style="text-transform: uppercase;"
/>
<mat-error *ngIf="form.get('payableAtCity')?.hasError('required')">Payable City is required</mat-error>
</mat-form-field>
</div>
</form>
</mat-dialog-content>
<mat-dialog-actions class="dd-dialog-actions" align="end">
<button mat-button type="button" (click)="onClose()">Cancel</button>
<button mat-flat-button color="primary" [disabled]="form.invalid" (click)="onSubmit()">
Submit for Manager Approval
</button>
</mat-dialog-actions>
</div>
`,
styles: [
`
.dd-dialog-shell {
display: flex;
flex-direction: column;
max-height: 90vh;
}
.dd-dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: #0f172a;
color: #ffffff;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.dd-dialog-header__title {
display: flex;
align-items: center;
gap: 12px;
}
.dd-dialog-header__title h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #ffffff;
}
.dd-dialog-header__title p {
margin: 2px 0 0 0;
font-size: 12px;
color: #94a3b8;
}
.dd-icon {
color: #38bdf8;
font-size: 28px;
width: 28px;
height: 28px;
}
.close-btn {
color: #94a3b8;
}
.close-btn:hover {
color: #ffffff;
}
.dd-dialog-body {
padding: 20px 24px;
overflow-y: auto;
}
.dd-summary-card {
display: flex;
align-items: center;
justify-content: space-between;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 14px 18px;
margin-bottom: 20px;
}
.summary-col {
display: flex;
flex-direction: column;
gap: 2px;
}
.summary-col.highlight-col {
background: #ecfdf5;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid #a7f3d0;
}
.summary-divider {
width: 1px;
height: 36px;
background: #e2e8f0;
}
.summary-col .label {
font-size: 11px;
text-transform: uppercase;
color: #64748b;
font-weight: 600;
}
.dd-number-highlight {
font-size: 16px;
color: #0369a1;
font-family: monospace;
}
.summary-col .amount {
font-size: 15px;
color: #1e293b;
}
.summary-col .total-amount {
font-size: 16px;
color: #065f46;
}
.summary-col .sublabel {
font-size: 11px;
color: #94a3b8;
}
.form-section-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
color: #475569;
margin: 16px 0 10px 0;
border-bottom: 1px solid #f1f5f9;
padding-bottom: 4px;
}
.form-section-title:first-child {
margin-top: 0;
}
.dd-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.full-width {
width: 100%;
}
.form-row {
display: flex;
gap: 16px;
}
.form-field-flex {
flex: 1;
}
.dd-dialog-actions {
padding: 12px 24px;
border-top: 1px solid #e2e8f0;
}
/* The account about to be debited — read before the form, so a wrong one is caught here. */
.dd-funding-note {
display: flex;
gap: 10px;
align-items: flex-start;
margin: 14px 0 4px;
padding: 11px 14px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-left: 3px solid #1d4ed8;
border-radius: 6px;
font-size: 13px;
color: #334155;
}
.dd-funding-note mat-icon {
color: #1d4ed8;
flex-shrink: 0;
}
.dd-funding-sub {
margin-top: 3px;
font-size: 11.5px;
line-height: 1.5;
color: #64748b;
}
`
]
})
export class CashierIssueDdDialogComponent implements OnInit {
private fb = inject(FormBuilder);
private dialogRef = inject(MatDialogRef<CashierIssueDdDialogComponent>);
public data: CashierIssueDdDialogData = inject(MAT_DIALOG_DATA);
form!: FormGroup;
/** Whether the draft is funded by debiting a member's account rather than by cash at the counter. */
get isAccountDebit(): boolean {
return this.data.paymentMethod === 'ACCOUNT_DEBIT';
}
ngOnInit(): void {
// Walk-in identity is required for cash and rejected outright for an account debit — the server
// refuses a walk-in name alongside an account — so the controls are only built when they apply.
// Registering them unconditionally and merely hiding them would hold the form invalid on a
// required field the clerk cannot see.
this.form = this.fb.group(
this.isAccountDebit
? {
beneficiaryName: [
'',
[
Validators.required,
Validators.maxLength(120)
]
],
payableAtBranch: [
'',
[Validators.required]
],
payableAtCity: [
'',
[Validators.required]
]
}
: {
walkinPurchaserName: [
'',
[
Validators.required,
Validators.minLength(3)
]
],
walkinPurchaserAddress: [
'',
[Validators.required]
],
walkinPurchaserAadhaar: [
'',
[
Validators.required,
Validators.pattern(/^[0-9]{12}$/)
]
],
beneficiaryName: [
'',
[
Validators.required,
Validators.maxLength(120)
]
],
payableAtBranch: [
'',
[Validators.required]
],
payableAtCity: [
'',
[Validators.required]
]
}
);
}
onClose(): void {
this.dialogRef.close();
}
onSubmit(): void {
if (this.form.invalid) return;
const val = this.form.value;
const beneficiary = {
beneficiaryName: (val.beneficiaryName || '').trim().toUpperCase(),
payableAtBranch: (val.payableAtBranch || '').trim().toUpperCase(),
payableAtCity: (val.payableAtCity || '').trim().toUpperCase()
};
// Walk-in fields are left off entirely on an account debit rather than sent empty: the server
// rejects a purchaser name arriving alongside an account, and '' would be sent as one.
this.dialogRef.close(
this.isAccountDebit
? beneficiary
: {
walkinPurchaserName: (val.walkinPurchaserName || '').trim().toUpperCase(),
walkinPurchaserAddress: (val.walkinPurchaserAddress || '').trim().toUpperCase(),
walkinPurchaserAadhaar: (val.walkinPurchaserAadhaar || '').trim(),
...beneficiary
}
);
}
}
@@ -134,6 +134,23 @@
<option [ngValue]="27">027 · Industrial</option>
</select>
</div>
@if (row.typ === 'DD') {
<div class="ws-f ws-f--branch">
<label class="ws-f__lbl ws-f__lbl--accent" title="Sponsor Bank">SPONSOR BANK</label>
<select
class="ws-f__ctl ws-f__ctl--accent"
[(ngModel)]="row.sponsorBankId"
(click)="$event.stopPropagation()"
>
@if (sponsorBanks.length === 0) {
<option [ngValue]="null">-- Select Bank --</option>
}
@for (sb of sponsorBanks; track sb.id) {
<option [ngValue]="sb.id">{{ sb.name }}</option>
}
</select>
</div>
}
<div class="ws-f ws-f--paytype">
<label class="ws-f__lbl ws-f__lbl--accent" title="Transaction Type">TXN TP.</label>
<select
@@ -182,7 +199,22 @@
(input)="onAcNoInput(row, i, $any($event.target).value)"
(click)="$event.stopPropagation()"
(blur)="onAcNoBlur(row, i)"
placeholder="Account number"
[placeholder]="
isDdAccountDebitRow(row)
? 'Account to debit'
: row.typ === 'DD'
? ddGlMapping?.ddPayableGlCode || 'Auto GL'
: 'Account number'
"
[readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
[class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
[title]="
isDdAccountDebitRow(row)
? 'Member account the draft is funded from'
: row.typ === 'DD'
? 'Configured Sponsor DD Payable GL (Auto-selected)'
: 'Account number'
"
/>
</div>
<div class="ws-f ws-f--ecsno">
@@ -340,7 +372,7 @@
</div>
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
@if (isDdCashGlRow(row)) {
@if (isDdRow(row)) {
<div class="ws-group-row ws-group-row--mt">
<div class="ws-group ws-group--flex">
<span class="ws-caption">DD Charges &amp; GST — Admin Rate {{ ddGstPercentage }}%</span>
@@ -375,7 +407,7 @@
</div>
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
TOTAL TO COLLECT
{{ isDdAccountDebitRow(row) ? 'TOTAL TO DEBIT' : 'TOTAL TO COLLECT' }}
</label>
<div class="ws-amt-wrap ws-amt-wrap--locked">
<span class="ws-amt-prefix">₹</span>
@@ -387,6 +419,23 @@
/>
</div>
</div>
<div class="ws-f" style="margin-left: auto; align-self: flex-end">
<button
type="button"
class="te-dd-issue-btn"
[disabled]="saving"
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
[title]="
isDdAccountDebitRow(row)
? 'Auto-selects next available leaflet and opens the beneficiary form'
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
"
>
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon>
<span>Fill DD Details &amp; Send for Approval</span>
</button>
</div>
</div>
</div>
</div>
@@ -398,6 +447,18 @@
</div>
}
<!-- ── DD GL UNCONFIGURED WARNING BANNER ────────────────────────── -->
@if (selectedRow?.typ === 'DD' && !isDdGlMappingConfigured) {
<div class="ws-dd-unconfigured-banner">
<mat-icon>warning</mat-icon>
<div class="ws-dd-unconfigured-banner__text">
<strong>DD GL Mapping Required:</strong> Demand Draft GL accounts have not been configured yet. Please ask an
administrator to set up the mapping under <em>Admin &gt; Sponsor DD GST / GL Settings</em> before issuing
drafts.
</div>
</div>
}
<!-- ── APPROVED WITHDRAWAL BANNER (cashier disburse flow) ─────────── -->
@if (!isClerk && loadingWithdrawal) {
<div class="ws-withdrawal-banner ws-withdrawal-banner--loading">
@@ -1404,7 +1465,12 @@
<div class="ws-group__fields">
<div class="ws-f ws-f--type">
<label class="ws-f__lbl">TYPE</label>
<select class="ws-f__ctl" [(ngModel)]="row.typ">
<select
class="ws-f__ctl"
[(ngModel)]="row.typ"
(click)="$event.stopPropagation()"
(change)="onTypChange(row)"
>
@for (t of typOptions; track t.value) {
<option [value]="t.value">{{ t.label }}</option>
}
@@ -1412,18 +1478,36 @@
</div>
<div class="ws-f ws-f--branch">
<label class="ws-f__lbl">BRANCH</label>
<select class="ws-f__ctl" [(ngModel)]="row.br">
<select class="ws-f__ctl" [(ngModel)]="row.br" (click)="$event.stopPropagation()">
<option [ngValue]="1">001 · Main</option>
<option [ngValue]="2">002 · M.G. Road</option>
<option [ngValue]="14">014 · Station Rd</option>
<option [ngValue]="27">027 · Industrial</option>
</select>
</div>
@if (row.typ === 'DD') {
<div class="ws-f ws-f--branch">
<label class="ws-f__lbl ws-f__lbl--accent" title="Sponsor Bank">SPONSOR BANK</label>
<select
class="ws-f__ctl ws-f__ctl--accent"
[(ngModel)]="row.sponsorBankId"
(click)="$event.stopPropagation()"
>
@if (sponsorBanks.length === 0) {
<option [ngValue]="null">-- Select Bank --</option>
}
@for (sb of sponsorBanks; track sb.id) {
<option [ngValue]="sb.id">{{ sb.name }}</option>
}
</select>
</div>
}
<div class="ws-f ws-f--paytype">
<label class="ws-f__lbl ws-f__lbl--accent">TXN TP.</label>
<select
class="ws-f__ctl ws-f__ctl--accent"
[(ngModel)]="row.paymentTypeId"
(click)="$event.stopPropagation()"
[class.ws-f__ctl--req]="!row.paymentTypeId"
>
@for (pt of paymentTypesForRow(row); track pt.id) {
@@ -1436,6 +1520,7 @@
<select
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
@@ -1462,7 +1547,14 @@
[value]="row.acNo"
(input)="onAcNoInput(row, i, $any($event.target).value)"
(blur)="onAcNoBlur(row, i)"
placeholder="Account number"
[placeholder]="
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
"
[readonly]="row.typ === 'DD'"
[class.ws-f__ctl--readonly]="row.typ === 'DD'"
[title]="
row.typ === 'DD' ? 'Configured Sponsor DD Payable GL (Auto-selected)' : 'Account number'
"
/>
</div>
<div class="ws-f ws-f--ecsno">
@@ -1591,14 +1683,19 @@
<div class="ws-group__fields">
<div class="ws-f ws-f--desc">
<label class="ws-f__lbl">DESCRIPTION</label>
<input class="ws-f__ctl" [(ngModel)]="row.description" placeholder="Narration / remarks" />
<input
class="ws-f__ctl"
[(ngModel)]="row.description"
(click)="$event.stopPropagation()"
placeholder="Narration / remarks"
/>
</div>
</div>
</div>
</div>
<!-- ── Row 3: DD Charges & GST — only for TYPE=DD, TXN TP=Cash, AC.TP=GL ── -->
@if (isDdCashGlRow(row)) {
@if (isDdRow(row)) {
<div class="ws-group-row ws-group-row--mt">
<div class="ws-group ws-group--flex">
<span class="ws-caption">DD Charges &amp; GST — Admin Rate {{ ddGstPercentage }}%</span>
@@ -1612,6 +1709,7 @@
type="number"
[value]="row.ddCharges ?? ''"
(input)="row.ddCharges = +$any($event.target).value"
(click)="$event.stopPropagation()"
placeholder="0.00"
/>
</div>
@@ -1632,7 +1730,7 @@
</div>
<div class="ws-f ws-f--amt">
<label class="ws-f__lbl" title="Amount + Charges + GST, rounded up to the next rupee">
TOTAL TO COLLECT
{{ isDdAccountDebitRow(row) ? 'TOTAL TO DEBIT' : 'TOTAL TO COLLECT' }}
</label>
<div class="ws-amt-wrap ws-amt-wrap--locked">
<span class="ws-amt-prefix">₹</span>
@@ -1644,6 +1742,23 @@
/>
</div>
</div>
<div class="ws-f" style="margin-left: auto; align-self: flex-end">
<button
type="button"
class="te-dd-issue-btn"
[disabled]="saving"
(click)="openIssueWalkinDd(row); $event.stopPropagation()"
[title]="
isDdAccountDebitRow(row)
? 'Auto-selects next available leaflet and opens the beneficiary form'
: 'Auto-selects next available leaflet and opens walk-in purchaser form'
"
>
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon>
<span>Fill DD Details &amp; Send for Approval</span>
</button>
</div>
</div>
</div>
</div>
@@ -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;
}
}
@@ -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<any>(`/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<any>(`/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<number, string> = {
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';
@@ -1,183 +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/.
-->
<div class="pg">
<header class="pg-head">
<div>
<div class="pg-eyebrow">CASHIER · EXCEPTIONS</div>
<h1 class="pg-title">Voucher Exceptions</h1>
<p class="pg-sub">
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.
</p>
</div>
<div class="pg-toolbar">
<label class="pg-field">
<span>Show</span>
<select [(ngModel)]="filter" (change)="load()">
<option value="OPEN">Open</option>
<option value="RESOLVED">Resolved</option>
<option value="ALL">All</option>
</select>
</label>
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Reload
</button>
</div>
</header>
@if (errorMsg) {
<div class="pg-alert pg-alert--error">{{ errorMsg }}</div>
}
@if (!canResolve && loaded) {
<div class="ve-note">
<mat-icon>info</mat-icon>
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.
</div>
}
@if (loading) {
<div class="pg-empty">Loading…</div>
}
@if (!loading && loaded && exceptions.length === 0) {
<div class="pg-empty">
<mat-icon>check_circle</mat-icon>
<p>{{ filter === 'OPEN' ? 'No open exceptions. Everything checked so far agrees.' : 'Nothing to show.' }}</p>
</div>
}
@for (e of exceptions; track e.voucherNo) {
<div class="ve-card" [class.ve-card--resolved]="e.resolutionStatus">
<div class="ve-card__head">
<span class="pg-chip">{{ e.voucherNo }}</span>
@if (e.resolutionStatus) {
<span class="db-badge db-badge--ok">RESOLVED</span>
} @else {
<span class="db-badge db-badge--rejected">⚠ OPEN</span>
}
<span class="db-muted">
{{ e.accountNo }} · {{ e.party }} · raised by {{ e.raisedByName }} on {{ stamp(e.raisedAt) }}
</span>
@if (!e.resolutionStatus && canResolve) {
<button type="button" class="pg-btn pg-btn--primary ve-card__act" (click)="openResolve(e)">
Review &amp; Close
</button>
}
</div>
<div class="ve-card__amts">
<div class="db-cmp">
<span class="db-cmp__label">System recorded</span>
<span class="db-cmp__value">₹{{ e.systemAmount | number: '1.2-2' }}</span>
</div>
@if (!amountsAgree(e)) {
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Written on slip</span>
<span class="db-cmp__value">₹{{ e.slipAmount | number: '1.2-2' }}</span>
</div>
<div class="db-cmp db-cmp--bad">
<span class="db-cmp__label">Difference</span>
<span class="db-cmp__value">₹{{ difference(e) | number: '1.2-2' }}</span>
</div>
} @else {
<div class="db-cmp">
<span class="db-cmp__label">Amounts</span>
<span class="db-cmp__value">Agree — flagged for another reason</span>
</div>
}
</div>
<p class="ve-card__why"><strong>Cashier reported:</strong> {{ e.remarks }}</p>
@if (e.resolutionStatus) {
<div class="ve-card__resolution">
<strong>{{ resolutionLabel(e.resolutionStatus) }}</strong> — {{ e.resolutionNotes }}
<span class="db-muted">· closed by {{ e.resolvedByName }} on {{ stamp(e.resolvedAt) }}</span>
</div>
}
<!-- Resolution panel, opened under the exception it closes. -->
@if (resolveRow === e) {
<div class="db-panel ve-panel">
<div class="db-panel__head">
<strong>Close exception — {{ e.voucherNo }}</strong>
<button type="button" class="db-icon-btn" (click)="closeResolve()" title="Close">
<mat-icon>close</mat-icon>
</button>
</div>
<label class="pg-field ve-panel__field">
<span>What was wrong?</span>
<select [(ngModel)]="resolutionStatus">
<option value="">— Select —</option>
@for (o of resolutionOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
</label>
<label class="pg-field ve-panel__field">
<span>What was found, and what was done about it?</span>
<input
type="text"
[(ngModel)]="resolutionNotes"
placeholder="e.g. teller keyed 700 instead of 500 — reversing entry passed, member informed"
/>
</label>
<div class="ve-panel__docs">
<strong>Supporting document</strong>
<p class="db-muted">
An exception cannot be closed on someone's word alone — attach the corrected slip, a signed note, or
whatever the decision rests on.
</p>
<label class="db-upload">
<input type="file" (change)="onFileSelected($event)" [disabled]="attachUploading" />
<span class="db-muted">Max 10 MB</span>
</label>
@if (attachUploading) {
<p class="db-muted">Uploading…</p>
}
@if (attachLoading) {
<p class="db-muted">Loading…</p>
}
@if (!attachLoading && attachments.length === 0) {
<p class="ve-panel__warn">No document attached yet — required before closing.</p>
}
@for (a of attachments; track a.id) {
<div class="db-file">
<mat-icon>description</mat-icon>
<span class="db-file__name">{{ a.fileName }}</span>
<span class="db-muted">{{ a.uploadedByName }}</span>
<button type="button" class="db-icon-btn" (click)="downloadAttachment(a)" title="Download">
<mat-icon>download</mat-icon>
</button>
</div>
}
</div>
<div class="db-panel__actions">
<button
type="button"
class="pg-btn pg-btn--primary"
(click)="saveResolution()"
[disabled]="!canSubmitResolution"
>
Close Exception
</button>
<button type="button" class="pg-btn" (click)="closeResolve()">Cancel</button>
</div>
</div>
}
</div>
}
</div>
@@ -1,120 +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 comes from cashier-shared.scss, and the comparison tiles, panel and file rows are
shared with the day book. Only the exception card itself is defined here. */
.ve-note {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin-bottom: 14px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
color: #1e40af;
font-size: 12px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
/* One card per flagged voucher. Open ones carry a red edge; closed ones go green, so a queue
reads at a glance without opening anything. */
.ve-card {
padding: 14px 16px;
margin-bottom: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #dc2626;
border-radius: 8px;
&--resolved {
border-left-color: #16a34a;
background: #fafffb;
}
&__head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
}
/* Pushed right so the identity of the voucher reads first. */
&__act {
margin-left: auto;
height: 32px;
font-size: 12px;
}
&__amts {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
&__why {
margin: 0;
padding: 8px 12px;
background: #f8fafc;
border-radius: 6px;
font-size: 12px;
color: #0d1b2a;
}
&__resolution {
margin-top: 10px;
padding: 8px 12px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 6px;
font-size: 12px;
color: #14532d;
}
}
.ve-panel {
margin-top: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
&__field {
display: block;
margin-bottom: 12px;
max-width: 640px;
}
&__docs {
padding: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
p {
margin: 4px 0 10px;
}
}
&__warn {
padding: 6px 10px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 4px;
color: #92400e;
}
}
@@ -1,248 +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, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
interface VoucherException {
voucherNo: string;
systemAmount: number | null;
slipAmount: number | null;
remarks: string | null;
raisedByName: string | null;
raisedAt: string | null;
accountNo: string | null;
party: string | null;
raisedAgainst: string | null;
attachmentCount: number;
resolutionStatus: string | null;
resolutionNotes: string | null;
resolvedByName: string | null;
resolvedAt: string | null;
}
/**
* Voucher exceptions raised at the counter, and the conclusion recorded on each.
*
* The cashier who spots a difference cannot close it — this screen is where a clerk, head cashier
* or manager sits down with them, records what was actually wrong, and attaches the paper that
* supports the decision.
*/
@Component({
selector: 'mifosx-voucher-exceptions',
templateUrl: './voucher-exceptions.component.html',
styleUrls: [
'../cashier-shared.scss',
'../day-book/day-book.component.scss',
'./voucher-exceptions.component.scss'
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class VoucherExceptionsComponent implements OnInit {
private service = inject(CashierService);
filter: 'OPEN' | 'RESOLVED' | 'ALL' = 'OPEN';
exceptions: VoucherException[] = [];
/** Set by the backend from the caller's own roles — the cashier who raised a flag cannot close it. */
canResolve = false;
loading = false;
loaded = false;
errorMsg = '';
// Resolution panel
resolveRow: VoucherException | null = null;
resolutionStatus = '';
resolutionNotes = '';
resolveSaving = false;
// Attachments for the voucher being resolved
attachments: any[] = [];
attachLoading = false;
attachUploading = false;
readonly resolutionOptions = [
{ value: 'RESOLVED_SYSTEM_ERROR', label: 'System entry was wrong' },
{ value: 'RESOLVED_SLIP_ERROR', label: 'Slip was written wrong' },
{ value: 'RESOLVED_NO_ACTION', label: 'No action needed' }
];
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
this.service
.voucherExceptions(this.filter)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load voucher exceptions.';
return of(null);
})
)
.subscribe((res: any) => {
this.loading = false;
this.loaded = true;
this.exceptions = res?.exceptions ?? [];
this.canResolve = !!res?.canResolve;
});
}
difference(e: VoucherException): number {
if (e.slipAmount == null) return 0;
return Number(e.slipAmount) - Number(e.systemAmount ?? 0);
}
/** True when the flag was about something other than the amount. */
amountsAgree(e: VoucherException): boolean {
return e.slipAmount == null || Number(e.slipAmount) === Number(e.systemAmount ?? 0);
}
openResolve(e: VoucherException): void {
this.resolveRow = e;
this.resolutionStatus = '';
this.resolutionNotes = '';
this.loadAttachments(e);
}
closeResolve(): void {
this.resolveRow = null;
this.attachments = [];
}
private loadAttachments(e: VoucherException): void {
this.attachments = [];
this.attachLoading = true;
this.service
.listVoucherAttachments(e.voucherNo)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.attachLoading = false;
this.attachments = res?.attachments ?? [];
});
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
const row = this.resolveRow;
if (!file || !row) return;
// 10 MB, matching the backend — refusing here saves uploading the whole file to be rejected.
if (file.size > 10 * 1024 * 1024) {
this.errorMsg = 'That file is larger than 10 MB. Please scan it at a lower resolution.';
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
this.attachUploading = true;
this.errorMsg = '';
this.service
.uploadVoucherAttachment(
row.voucherNo,
file.name,
file.type || 'application/octet-stream',
String(reader.result),
'Supporting document for exception resolution'
)
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Upload failed. Please try again.';
return of(null);
})
)
.subscribe((res: any) => {
this.attachUploading = false;
input.value = '';
if (res) {
this.loadAttachments(row);
this.load();
}
});
};
reader.readAsDataURL(file);
}
downloadAttachment(a: any): void {
this.service
.downloadVoucherAttachment(a.id)
.pipe(catchError(() => of(null)))
.subscribe((blob: Blob | null) => {
if (!blob) {
this.errorMsg = 'Could not download that file.';
return;
}
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = a.fileName ?? 'attachment';
link.click();
URL.revokeObjectURL(url);
});
}
/** Mirrors the backend's own rule, so the button explains itself before it is pressed. */
get canSubmitResolution(): boolean {
return (
!!this.resolutionStatus && !!this.resolutionNotes.trim() && this.attachments.length > 0 && !this.resolveSaving
);
}
saveResolution(): void {
const row = this.resolveRow;
if (!row) return;
this.resolveSaving = true;
this.errorMsg = '';
this.service
.resolveVoucherException(row.voucherNo, this.resolutionStatus, this.resolutionNotes.trim())
.pipe(
catchError((err: any) => {
this.errorMsg =
err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the exception.';
return of(null);
})
)
.subscribe((res: any) => {
this.resolveSaving = false;
if (res) {
this.closeResolve();
this.load();
}
});
}
resolutionLabel(status: string | null): string {
if (!status) return '';
return this.resolutionOptions.find((o) => o.value === status)?.label ?? status;
}
/** Date and time — an exception queue spans days, so the day matters here. */
stamp(iso: string | null): string {
if (!iso) return '—';
const [
d,
t
] = iso.split('T');
return t ? `${d} ${t.slice(0, 5)}` : d;
}
}
@@ -10,10 +10,8 @@
<header class="pg-head vr-no-print">
<div>
<div class="pg-eyebrow">CASHIER · REPORT</div>
<h1 class="pg-title">Voucher Report</h1>
<p class="pg-sub">
Every voucher raised over a day or a month — the number, what was debited, what was credited, and who raised it.
</p>
<h1 class="pg-title">{{ title }}</h1>
<p class="pg-sub">{{ subtitle }}</p>
</div>
<div class="pg-toolbar">
<label class="pg-field">
@@ -42,10 +40,8 @@
<button type="button" class="pg-btn pg-btn--primary" (click)="load()" [disabled]="loading">
<mat-icon>refresh</mat-icon> Run
</button>
@if (report && report.count > 0) {
<button type="button" class="pg-btn" (click)="print()">
<mat-icon>print</mat-icon> Print
</button>
@if (report && count > 0) {
<button type="button" class="pg-btn" (click)="print()"><mat-icon>print</mat-icon> Print</button>
}
</div>
</header>
@@ -64,8 +60,7 @@
@if (report.from !== report.to) {
— {{ report.to | date: 'dd MMM yyyy' }}
}
· {{ report.count }} voucher{{ report.count === 1 ? '' : 's' }}
· ₹{{ report.totalAmount | number: '1.2-2' }}
· {{ count }} {{ noun }}{{ count === 1 ? '' : 's' }} · ₹{{ totalAmount | number: '1.2-2' }}
@if (report.username) {
· {{ report.username }}
}
@@ -74,10 +69,46 @@
@if (!report.canSeeAllUsers) {
<span class="vr-scope-note">Your own vouchers</span>
}
<!-- The total on a cash register means something narrower than the whole voucher book, so the
screen says which one it is instead of leaving the figure ambiguous. -->
@if (cashOnly) {
<span class="vr-scope-note">Cash only</span>
}
</div>
@if (report.count === 0) {
<div class="pg-empty">No vouchers were raised in this period.</div>
<!-- A voucher with no CR/DR prefix belongs to neither report. Say so, so the two reports not
adding up to the day's total is explained rather than left to be puzzled over. -->
@if (unclassifiedCount > 0) {
<div class="pg-alert vr-no-print">
{{ 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.
</div>
}
<!-- Which part of the total has notes and coins counted behind it. Shown on the cash register
as well as the full one: a voucher with no breakdown is still real money, so it stays in
the total, and what the drawer can be checked against is stated instead of assumed. -->
@if (uncountedCount > 0) {
<div class="pg-alert vr-no-print">
{{ 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.
</div>
}
<!-- Explains the blank Debit/Credit cells instead of leaving them looking like lost data. -->
@if (missingSidesCount > 0) {
<div class="pg-alert vr-no-print">
{{ 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.
</div>
}
@if (count === 0) {
<div class="pg-empty">No {{ noun }}s were raised in this period.</div>
} @else {
<div class="vr-summaries">
<div class="pg-card">
@@ -129,7 +160,7 @@
</div>
<div class="pg-card">
<div class="pos-section-label">Vouchers</div>
<div class="pos-section-label">{{ isReceived ? 'Receipts' : 'Payments' }}</div>
<table class="pg-table vr-rows">
<thead>
<tr>
@@ -143,33 +174,39 @@
</tr>
</thead>
<tbody>
@for (row of report.vouchers; track row.voucherNo) {
@for (row of rows; track row.voucherNo) {
<tr>
<td>
<a class="vr-link" [routerLink]="['/cashier/voucher']" [queryParams]="{ voucherNo: row.voucherNo }">
{{ row.voucherNo }}
</a>
{{ row.voucherNo }}
@if (row.verificationStatus === 'EXCEPTION') {
<mat-icon class="vr-flag" title="An exception was raised against this voucher">error_outline</mat-icon>
<mat-icon class="vr-flag" title="An exception was raised against this voucher"
>error_outline</mat-icon
>
}
</td>
<td>{{ row.voucherType || '—' }}</td>
<td>
<div class="vr-party">{{ row.debitParty || '—' }}</div>
@if (row.debitAccount) {
<div class="vr-acct">{{ row.debitAccount }}</div>
}
</td>
<td>
<div class="vr-party">{{ row.creditParty || '—' }}</div>
@if (row.creditAccount) {
<div class="vr-acct">{{ row.creditAccount }}</div>
}
<!-- A DD with charges, or a compound journal, has sides this row does not show. -->
@if (row.extraLines > 0) {
<div class="vr-more">+{{ row.extraLines }} more on voucher</div>
}
</td>
<!-- A voucher raised before its sides were recorded has neither. Say that once,
across both cells, rather than showing two dashes that read as "nowhere". -->
@if (!hasSides(row)) {
<td colspan="2" class="vr-nosides">Sides not recorded</td>
} @else {
<td>
<div class="vr-party">{{ row.debitParty || '—' }}</div>
@if (row.debitAccount) {
<div class="vr-acct">{{ row.debitAccount }}</div>
}
</td>
<td>
<div class="vr-party">{{ row.creditParty || '—' }}</div>
@if (row.creditAccount) {
<div class="vr-acct">{{ row.creditAccount }}</div>
}
<!-- A DD with charges, or a compound journal, has sides this row does not show. -->
@if (row.extraLines > 0) {
<div class="vr-more">+{{ row.extraLines }} more on voucher</div>
}
</td>
}
<td class="num">{{ row.amount | number: '1.2-2' }}</td>
<td>{{ row.username || '—' }}</td>
<td>{{ row.createdAt | date: 'dd MMM, HH:mm' }}</td>
@@ -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;
}
}
@@ -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<string, { username: string; count: number; total: number }>();
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<string, { type: string; count: number; total: number }>();
for (const row of rows) {
const key = row.voucherType ?? '—';
@@ -1,144 +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/.
-->
<div class="pg">
<header class="pg-head vv-no-print">
<div>
<div class="pg-eyebrow">CASHIER · VOUCHER</div>
<h1 class="pg-title">Voucher</h1>
<p class="pg-sub">
Look up a voucher by its number to see the entry behind it — which accounts were debited, which were credited,
and for how much.
</p>
</div>
<div class="pg-toolbar">
<label class="pg-field">
<span>Voucher No</span>
<input
type="text"
[(ngModel)]="voucherNo"
(keyup.enter)="search()"
placeholder="CR/2708/0001"
autocomplete="off"
/>
</label>
<button type="button" class="pg-btn pg-btn--primary" (click)="search()" [disabled]="loading">
<mat-icon>search</mat-icon> Find
</button>
@if (voucher) {
<button type="button" class="pg-btn" (click)="print()">
<mat-icon>print</mat-icon> Print
</button>
}
</div>
</header>
@if (error) {
<div class="pg-alert pg-alert--error vv-no-print">{{ error }}</div>
}
@if (loading) {
<div class="pg-empty vv-no-print">Loading…</div>
}
@if (voucher && !loading) {
<div class="pg-card vv-slip">
<div class="vv-slip-head">
<div>
<div class="vv-no">{{ voucher.voucherNo }}</div>
<div class="vv-meta">
{{ voucher.voucherType || '—' }}
@if (voucher.branchName) {
· {{ voucher.branchName }}
}
</div>
</div>
<div class="vv-slip-head-right">
<div class="vv-amount">₹{{ voucher.amount | number: '1.2-2' }}</div>
<div class="vv-meta">
@if (voucher.createdAt) {
{{ voucher.createdAt | date: 'dd MMM yyyy, HH:mm' }}
}
@if (voucher.createdBy) {
· {{ voucher.createdBy }}
}
</div>
</div>
</div>
@if (voucher.verificationStatus) {
<div class="vv-status" [class.vv-status--exception]="voucher.verificationStatus === 'EXCEPTION'">
<mat-icon>{{ voucher.verificationStatus === 'EXCEPTION' ? 'error_outline' : 'verified' }}</mat-icon>
{{ voucher.verificationStatus === 'EXCEPTION' ? 'Exception raised against this voucher' : 'Verified against the slip' }}
</div>
}
<!-- A voucher raised before its sides were recorded: the number is valid, the posting behind it
simply was never captured, so it is shown as an older record rather than as an error. -->
@if (hasNoLines) {
<div class="pg-empty">
No debit or credit detail was recorded for this voucher. It was raised before the entry sides were kept.
</div>
} @else {
<table class="pg-table vv-lines">
<thead>
<tr>
<th>Particulars</th>
<th>Account</th>
<th class="num">Debit</th>
<th class="num">Credit</th>
</tr>
</thead>
<tbody>
@for (line of debitLines; track line.lineNo) {
<tr>
<td>
<div class="vv-party">{{ line.partyName || '—' }}</div>
@if (line.description) {
<div class="vv-desc">{{ line.description }}</div>
}
</td>
<td>{{ line.accountNo || '—' }}</td>
<td class="num">{{ line.amount | number: '1.2-2' }}</td>
<td class="num">—</td>
</tr>
}
@for (line of creditLines; track line.lineNo) {
<tr>
<td class="vv-indent">
<div class="vv-party">{{ line.partyName || '—' }}</div>
@if (line.description) {
<div class="vv-desc">{{ line.description }}</div>
}
</td>
<td>{{ line.accountNo || '—' }}</td>
<td class="num">—</td>
<td class="num">{{ line.amount | number: '1.2-2' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="2">Total</td>
<td class="num">{{ voucher.totalDebit | number: '1.2-2' }}</td>
<td class="num">{{ voucher.totalCredit | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
<!-- Stated rather than assumed: a voucher whose sides were written before the balance check
existed, or repaired by hand, should read as unbalanced rather than pass as a valid slip. -->
@if (!voucher.balanced) {
<div class="pg-alert pg-alert--error">
This voucher does not balance — debits and credits differ. Refer it to accounts before relying on it.
</div>
}
}
</div>
}
</div>
@@ -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;
}
}
@@ -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();
}
}
@@ -39,5 +39,4 @@ export class DividendDashboardService {
const params = status && status !== 'ALL' ? { status } : {};
return this.http.get('/clerk/dividend-dashboard/locks', { params });
}
}
@@ -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;
@@ -102,7 +102,6 @@ export class DividendDashboardComponent implements OnInit {
lockYears: string[] = [];
lockYearFilter = 'ALL';
ngOnInit(): void {
this.loadSummary();
this.loadDeclarations();
+52
View File
@@ -1043,4 +1043,56 @@ export class ClientsService {
getKycContactLog(clientId: string | number): Observable<any[]> {
return this.http.get<any[]>(`/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<any[]> {
return this.http.get<any[]>('/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<any[]> {
const params = subStatus ? new HttpParams().set('subStatus', String(subStatus)) : undefined;
return this.http.get<any[]>('/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<any> {
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<any> {
return this.http.post(`/savingsaccounts/deaf-remittance/${savingsAccountId}/refund`, {
locale: 'en',
dateFormat: 'yyyy-MM-dd',
refundedOn
});
}
}
+13 -79
View File
@@ -184,47 +184,6 @@
Today's Activity
</a>
}
<!-- Outside the clerkAccountLoaded block on purpose: an open exception has to be
reachable whether or not the clerk currently has an account loaded. -->
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/cashier/voucher-exceptions')"
[routerLink]="['/cashier/voucher-exceptions']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
Exceptions
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.split('?')[0] === '/cashier/voucher'"
[routerLink]="['/cashier/voucher']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1z" />
<line x1="8" y1="9" x2="16" y2="9" />
<line x1="8" y1="13" x2="14" y2="13" />
</svg>
Voucher
</a>
<a
class="cashier-nav__item cashier-nav__item--sec"
[class.cashier-nav__item--active]="currentUrl.includes('/cashier/voucher-report')"
[routerLink]="['/cashier/voucher-report']"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
Voucher Report
</a>
<span class="cashier-nav__esc-hint" (click)="onEscapeKey()"> <kbd>ESC</kbd> Show Header </span>
</nav>
} @else {
@@ -288,27 +247,17 @@
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/day-book']"
[routerLink]="['/cashier/received-report']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="book"></fa-icon> Day Book
<fa-icon icon="list"></fa-icon> Received
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/voucher-exceptions']"
[routerLink]="['/cashier/payment-report']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="exclamation-triangle"></fa-icon> Exceptions
</a>
<a class="cashier-nav__item" [routerLink]="['/cashier/voucher']" routerLinkActive="cashier-nav__item--active">
<fa-icon icon="receipt"></fa-icon> Voucher
</a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/voucher-report']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="list"></fa-icon> Voucher Report
<fa-icon icon="list"></fa-icon> Payment
</a>
<a
class="cashier-nav__item"
@@ -685,6 +634,15 @@
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
<button mat-menu-item routerLink="/reports/kyc-expiry" tabindex="0">
<i class="fa fa-id-card menu-item-icon"></i> Re-KYC Expiry &amp; Outreach
</button>
<button mat-menu-item routerLink="/reports/dormancy" tabindex="0">
<i class="fa fa-hourglass-half menu-item-icon"></i> Inactive, Dormant &amp; DEAF
</button>
<button mat-menu-item routerLink="/reports/savings-interest" tabindex="0">
<i class="fa fa-percentage menu-item-icon"></i> Savings Interest Posted
</button>
<!--
The Principal Officer clears flagged UNSC screenings here. It is not a runnable report,
so it never shows up under the report categories above and had no nav entry at all.
@@ -1092,15 +1050,6 @@
<button mat-menu-item tabindex="0" routerLink="/clerk/dashboard" (click)="hideShellLayout = true">
<fa-icon icon="id-card" class="menu-item-icon"></fa-icon> Clerk Dashboard
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-exceptions']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Voucher Exceptions
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Voucher Lookup
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-report']">
<fa-icon icon="list" class="menu-item-icon"></fa-icon> Voucher Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
@@ -1152,15 +1101,6 @@
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/day-book']">
<fa-icon icon="book" class="menu-item-icon"></fa-icon> Day Book
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-exceptions']">
<fa-icon icon="exclamation-triangle" class="menu-item-icon"></fa-icon> Voucher Exceptions
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher']">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Voucher Lookup
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/voucher-report']">
<fa-icon icon="list" class="menu-item-icon"></fa-icon> Voucher Report
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button>
@@ -1253,12 +1193,6 @@
<button mat-menu-item [routerLink]="['/sponsor-dd/inventory']" tabindex="0">
<fa-icon icon="receipt" class="menu-item-icon"></fa-icon> Sponsor DD Inventory
</button>
<button mat-menu-item [routerLink]="['/sponsor-dd/issue-approvals']" tabindex="0">
<fa-icon icon="check-double" class="menu-item-icon"></fa-icon> DD Issuance Approvals
</button>
<button mat-menu-item [routerLink]="['/sponsor-dd/reprint-approvals']" tabindex="0">
<fa-icon icon="print" class="menu-item-icon"></fa-icon> DD Reprint Approvals
</button>
</mat-menu>
<mat-menu #hrmMenu="matMenu" class="hrm-mega-panel" [overlapTrigger]="false">
@@ -367,15 +367,18 @@
}
<!-- Compliance / AML Action Triage -->
<div class="compliance-triage" style="margin-top: 16px; padding: 12px; background: #fff8f8; border: 1px dashed #f5c2c2; border-radius: 8px;">
<h4 style="margin: 0 0 8px; font-weight: 600; color: #b91c1c; display: flex; align-items: center; gap: 6px;">
<div
class="compliance-triage"
style="margin-top: 16px; padding: 12px; background: #fff8f8; border: 1px dashed #f5c2c2; border-radius: 8px"
>
<h4 style="margin: 0 0 8px; font-weight: 600; color: #b91c1c; display: flex; align-items: center; gap: 6px">
Compliance & AML Triage
</h4>
<div style="display: flex; flex-direction: column; gap: 8px;">
<mat-checkbox [(ngModel)]="flagAsSuspicious" [ngModelOptions]="{standalone: true}">
<div style="display: flex; flex-direction: column; gap: 8px">
<mat-checkbox [(ngModel)]="flagAsSuspicious" [ngModelOptions]="{ standalone: true }">
Flag as suspicious (escalate to STR cases under RBI compliance)
</mat-checkbox>
<mat-checkbox [(ngModel)]="markAsWhitelisted" [ngModelOptions]="{standalone: true}">
<mat-checkbox [(ngModel)]="markAsWhitelisted" [ngModelOptions]="{ standalone: true }">
Mark as whitelisted (prevent future AML alerts for this account)
</mat-checkbox>
</div>
@@ -145,7 +145,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
cases = [];
}
}
const newRef = 101 + cases.length;
const newCase: any = {
reportType: 'STR',
@@ -171,10 +171,10 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
narrative: `CTS Cheque #${this.item.chequeNumber} for amount ₹${this.item.amount} flagged as suspicious by Clearing Officer during inward presentment review. Action: ${this.mode}. Reason/Notes: ${this.mode === 'RETURN' ? this.returnForm.value.reason : this.passForm.value.note || 'Manual compliance escalation'}`,
status: 'po-review'
};
cases.unshift(newCase);
localStorage.setItem('fiu_str_cases', JSON.stringify(cases));
const storedCtr = localStorage.getItem('fiu_ctr_txns');
if (storedCtr) {
try {
@@ -201,7 +201,7 @@ export class ItemDecisionDialogComponent implements OnInit, OnDestroy {
whitelisted.push(account.toString());
localStorage.setItem('compliance_whitelisted_accounts', JSON.stringify(whitelisted));
}
const storedCtr = localStorage.getItem('fiu_ctr_txns');
if (storedCtr) {
try {
@@ -21,6 +21,7 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router';
import { CurrentAccountsService } from '../../current-accounts.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -40,6 +41,7 @@ export class AddChargeCurrentAccountComponent implements OnInit {
private router = inject(Router);
private dateUtils = inject(Dates);
private currentAccountsService = inject(CurrentAccountsService);
private facilityChargeFilter = inject(FacilityChargeFilterService);
private settingsService = inject(SettingsService);
/** Minimum Due Date allowed. */
@@ -66,7 +68,9 @@ export class AddChargeCurrentAccountComponent implements OnInit {
*/
constructor() {
this.route.data.subscribe((data: { currentAccountActionData: any }) => {
this.currentAccountChargeOptions = data.currentAccountActionData.chargeOptions;
this.facilityChargeFilter
.excludeFacilityCharges(data.currentAccountActionData.chargeOptions)
.subscribe((charges: any[]) => (this.currentAccountChargeOptions = charges));
});
this.currentAccountId = this.route.snapshot.params['currentAccountId'];
}
@@ -36,6 +36,7 @@ import { TranslateService } from '@ngx-translate/core';
import { MatButton, MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service';
import { ChargesFilterPipe } from '../../../pipes/charges-filter.pipe';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
@@ -73,6 +74,7 @@ export class CurrentAccountChargesStepComponent implements OnInit {
private dialog = inject(MatDialog);
private dateUtils = inject(Dates);
private translateService = inject(TranslateService);
private facilityChargeFilter = inject(FacilityChargeFilterService);
/** Current Account Product Template */
private _currentAccountProductTemplate: any;
@@ -80,7 +82,9 @@ export class CurrentAccountChargesStepComponent implements OnInit {
set currentAccountProductTemplate(value: any) {
this._currentAccountProductTemplate = value;
if (value) {
this.chargeData = value.chargeOptions || [];
this.facilityChargeFilter
.excludeFacilityCharges(value.chargeOptions)
.subscribe((charges: any[]) => (this.chargeData = charges));
// Only pre-populate from product template for new accounts
if (!this.currentAccountTemplate?.id) {
this.chargesDataSource = (value.charges || []).map((charge: any) => ({ ...charge, id: charge.chargeId }));
@@ -174,18 +174,6 @@
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.withdrawalFacility">
<div class="dig-card__icon"><i class="fas fa-arrow-right-arrow-left"></i></div>
<div class="dig-card__body">
<span class="dig-card__label">Withdrawal Facility</span>
<span class="dig-card__status">{{ facilityConfig.withdrawalFacility ? 'Enabled' : 'Disabled' }}</span>
</div>
<label class="dig-toggle">
<input type="checkbox" [(ngModel)]="facilityConfig.withdrawalFacility" />
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.depositFacilityKyc">
<div class="dig-card__icon"><i class="fas fa-id-card"></i></div>
<div class="dig-card__body">
@@ -24,7 +24,6 @@ const DEFAULT_FACILITY_CONFIG = {
neftFacility: false,
rtgsFacility: false,
interBankTotalCapFacility: false,
withdrawalFacility: false,
depositFacilityKyc: false,
virtualDebitCard: false,
internetBanking: false,
@@ -76,7 +76,6 @@ export class CurrentAccountPreviewStepComponent 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' },
{ key: 'internetBanking', label: 'Internet Banking' },
@@ -43,7 +43,6 @@ const DEFAULT_FACILITY_CONFIG: Record<string, boolean> = {
neftFacility: false,
rtgsFacility: false,
interBankTotalCapFacility: false,
withdrawalFacility: false,
depositFacilityKyc: false,
virtualDebitCard: false,
internetBanking: false,
@@ -66,7 +65,6 @@ const FACILITY_TOGGLES: { key: string; label: string; icon: string }[] = [
{ key: 'neftFacility', label: 'NEFT Facility', icon: 'fa-money-check-alt' },
{ key: 'rtgsFacility', label: 'RTGS Facility', icon: 'fa-money-check' },
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap', icon: 'fa-balance-scale' },
{ key: 'withdrawalFacility', label: 'Withdrawal Facility', icon: 'fa-arrow-right-arrow-left' },
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)', icon: 'fa-id-card' },
{ key: 'virtualDebitCard', label: 'Virtual Debit Card', icon: 'fa-credit-card' },
{ key: 'internetBanking', label: 'Internet Banking', icon: 'fa-globe' },
@@ -0,0 +1,398 @@
<!--
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/.
-->
<div class="dr">
<!-- Header -->
<div class="dr-head">
<div>
<h2 class="dr-head-t"><fa-icon icon="clock"></fa-icon> Inactive, Dormant &amp; DEAF Reports</h2>
<div class="dr-head-s">
Accounts with no customer-induced transaction, at each of the three marks RBI sets. Interest the bank posts does
not reset the clock — only a deposit or withdrawal the customer makes.
</div>
</div>
<div class="dr-head-a">
<button class="dr-btn dr-btn--slim" (click)="load()" [disabled]="loading">
<fa-icon icon="undo"></fa-icon> Reload
</button>
<button class="dr-btn dr-btn--primary" (click)="exportExcel()" [disabled]="loading">
<fa-icon icon="file-excel"></fa-icon> Export all three
</button>
</div>
</div>
@if (errorMessage) {
<div class="dr-alert">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ errorMessage }}</div>
</div>
}
<!-- The money that has left customers' accounts but not reached RBI -->
@if (summary.pendingRemittanceCount > 0) {
<div class="dr-alert">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>₹{{ summary.pendingRemittance | number: '1.0-0' }}</strong> from
<strong>{{ summary.pendingRemittanceCount }}</strong> escheated
{{ summary.pendingRemittanceCount === 1 ? 'account has' : 'accounts have' }} not been recorded as remitted to
RBI's DEAF. The balance has already left the customer's account, so until the remittance is recorded the money
sits with the bank against no fund.
<button class="dr-link" (click)="showTab('ESCHEAT'); pendingRemittanceOnly = true">Show these accounts</button>
</div>
</div>
}
<!-- Entry: filters that apply to all three reports -->
<div class="dr-entry">
<div class="dr-entry-h"><fa-icon icon="filter"></fa-icon> Report criteria</div>
<div class="dr-entry-b">
<div class="dr-field">
<label>As on</label>
<!-- The idle period is worked out server-side against the business date, so this states the
day the figures are for rather than offering to recompute them for another. -->
<input class="dr-inp dr-inp--ro" type="date" [value]="today" disabled />
</div>
<div class="dr-field">
<label>Branch</label>
<select class="dr-inp" [(ngModel)]="branchCode">
<option value="">All branches</option>
@for (b of branches; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</div>
<div class="dr-field">
<label>Minimum balance</label>
<input class="dr-inp" type="number" [(ngModel)]="minBalance" placeholder="Any" min="0" />
</div>
<div class="dr-field dr-field--wide">
<label>Search</label>
<input class="dr-inp" type="text" [(ngModel)]="search" placeholder="Customer name, ID or account number" />
</div>
<div class="dr-field dr-field--btn">
<button class="dr-btn dr-btn--slim" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
</div>
</div>
</div>
<!-- The three reports, side by side -->
<div class="dr-tabs">
@for (t of tabs; track t) {
<button
class="dr-tab"
[class]="'dr-tab dr-tab--' + stageMeta[t].tone"
[class.dr-tab--on]="tab === t"
(click)="showTab(t)"
>
<div class="dr-tab-h">
<span class="dr-tab-l">{{ stageMeta[t].label }}</span>
<span class="dr-tab-n">{{ countFor(t) }}</span>
</div>
<div class="dr-tab-d">After {{ stageMeta[t].afterDays }} days idle</div>
<div class="dr-tab-e">{{ stageMeta[t].effect }}</div>
</button>
}
</div>
<!-- What this stage means, and how an account comes back from it -->
<div class="dr-context">
<div class="dr-context-i">
<fa-icon icon="info-circle"></fa-icon>
<div>
<strong>{{ stageMeta[tab].label }}.</strong> {{ stageMeta[tab].effect }}
<div class="dr-context-r"><strong>Revival:</strong> {{ stageMeta[tab].revival }}</div>
</div>
</div>
<div class="dr-context-t">
<span class="dr-context-c">{{ visibleRows.length }}</span> accounts ·
<span class="dr-context-c">₹{{ visibleBalance | number: '1.0-0' }}</span> total balance
</div>
</div>
@if (tab === 'ESCHEAT') {
<div class="dr-bar">
<label class="dr-chk">
<input type="checkbox" [(ngModel)]="pendingRemittanceOnly" /> Not yet remitted to RBI
</label>
<span class="dr-money">
<span class="dr-money-i dr-money-i--bad">
₹{{ summary.pendingRemittance | number: '1.0-0' }} not remitted
</span>
<span class="dr-money-i dr-money-i--ok"> ₹{{ summary.remittedBalance | number: '1.0-0' }} with the fund </span>
@if (summary.claimedBackCount > 0) {
<span class="dr-money-i"> ₹{{ summary.claimedBackBalance | number: '1.0-0' }} claimed back </span>
}
</span>
<span class="dr-spacer"></span>
@if (selected.size > 0) {
<span class="dr-selinfo"> {{ selected.size }} selected · ₹{{ selectedTotal | number: '1.0-0' }} </span>
<button class="dr-btn dr-btn--slim" (click)="clearSelection()">Clear</button>
}
<button class="dr-btn dr-btn--primary" (click)="openRemit()" [disabled]="selected.size === 0">
<fa-icon icon="check"></fa-icon> Record remittance to RBI
</button>
</div>
}
<!-- Table -->
@if (loading) {
<div class="dr-empty"><fa-icon icon="spinner" [spin]="true"></fa-icon> Loading…</div>
} @else if (visibleRows.length === 0) {
<div class="dr-empty">No accounts at this stage match the criteria above.</div>
} @else {
<div class="dr-tablewrap">
<table class="dr-table">
<thead>
<tr>
@if (tab === 'ESCHEAT') {
<th class="dr-tick">
<input
type="checkbox"
[checked]="allSelected"
(change)="toggleSelectAll()"
title="Select every account not yet remitted"
/>
</th>
}
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Product</th>
<th class="num">{{ tab === 'ESCHEAT' ? 'Balance Escheated' : 'Balance' }}</th>
<th>Last Customer Txn</th>
<th>Idle For</th>
@if (tab === 'ESCHEAT') {
<th>Escheated On</th>
<th>Remittance to RBI</th>
} @else {
<th>Next Stage</th>
}
<th></th>
</tr>
</thead>
<tbody>
@for (r of visibleRows; track r.accountId) {
<tr [class.dr-row--warn]="isNearingEscheat(r)" [class.dr-row--sel]="selected.has(r.accountId)">
@if (tab === 'ESCHEAT') {
<td class="dr-tick">
<!-- An already-remitted balance cannot be remitted again. -->
<input
type="checkbox"
[checked]="selected.has(r.accountId)"
[disabled]="r.remittance === 'REMITTED'"
(click)="toggleSelect(r, $event)"
/>
</td>
}
<td class="mono">{{ r.accountNo }}</td>
<td>
<strong>{{ r.clientName }}</strong>
<div class="dr-sub mono">{{ r.clientId }} · {{ r.mobile }}</div>
</td>
<td>{{ branchName(r.branchCode) }}</td>
<td class="dr-prod">{{ r.productName }}</td>
<td class="num strong">{{ r.balance | number: '1.0-0' }}</td>
<td class="mono">{{ r.lastCustomerTxnDate }}</td>
<td>
<span [class]="toneClass(r.stage)">{{ idleText(r.daysIdle) }}</span>
</td>
@if (tab === 'ESCHEAT') {
<td class="mono">{{ r.escheatedOn || '—' }}</td>
<td>
<span [class]="remittanceClass(r.remittance)">
{{ r.remittance === 'REMITTED' ? 'Remitted' : 'Not remitted' }}
</span>
@if (r.remittedOn) {
<div class="dr-sub mono">{{ r.remittedOn }}</div>
}
@if (r.deafReference) {
<div class="dr-sub mono">{{ r.deafReference }}</div>
}
@if (r.refundedOn) {
<div class="dr-claimed"><fa-icon icon="undo"></fa-icon> Claimed back {{ r.refundedOn }}</div>
}
</td>
} @else {
<td>
<div class="dr-next">{{ r.nextStageLabel }}</div>
<div class="dr-sub mono" [class.dr-sub--red]="isNearingEscheat(r)">
{{ r.nextStageDate }}
@if (isNearingEscheat(r)) {
· {{ daysToNext(r) }} days
}
</div>
</td>
}
<td class="dr-act">
<button class="dr-mini" (click)="openClient(r, $event)">
<fa-icon icon="user"></fa-icon> Customer
</button>
<!-- A remitted balance can still be claimed, so long as it has not been already. -->
@if (tab === 'ESCHEAT' && r.remittance === 'REMITTED' && !r.refundedOn) {
<button
class="dr-mini"
title="The customer claimed this balance back — the bank paid them and recovers it from DEAF"
(click)="openRefund(r, $event)"
>
<fa-icon icon="undo"></fa-icon> Claimed back
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- Where the money in the escheat report has to go -->
@if (tab === 'ESCHEAT') {
<div class="dr-foot">
<fa-icon icon="gavel"></fa-icon>
<div>
<strong>Depositor Education and Awareness Fund.</strong> A balance unclaimed for ten years is transferred to
RBI's DEAF. The transfer itself is made outside this system, so an account shows as escheated here from the day
the balance leaves it — the remittance column records that the money actually reached RBI, and stays "not
remitted" until it does. A customer can still claim the amount at any time; the bank pays them and recovers it
from the fund, which shows here as claimed back.
</div>
</div>
}
</div>
<!-- Record that money was sent to RBI -->
@if (remitOpen) {
<div class="dr-modal-bg" (click)="remitOpen = false">
<div class="dr-modal" (click)="$event.stopPropagation()">
<div class="dr-modal-h">
<h3>Record remittance to RBI's DEAF</h3>
<button class="dr-x" (click)="remitOpen = false">&times;</button>
</div>
<div class="dr-modal-b">
<div class="dr-modal-sum">
<div>
<span class="dr-modal-n">{{ selected.size }}</span>
<span class="dr-modal-l">accounts</span>
</div>
<div>
<span class="dr-modal-n">₹{{ selectedTotal | number: '1.0-0' }}</span>
<span class="dr-modal-l">total remitted</span>
</div>
</div>
<div class="dr-note-box">
<fa-icon icon="info-circle"></fa-icon>
This records a transfer that has already been made to RBI. It does not move any money — it marks these
balances as accounted for, so they stop showing as outstanding.
</div>
<label class="dr-lbl">Date sent to RBI <span class="dr-req">*</span></label>
<input class="dr-inp dr-inp--full" type="date" [(ngModel)]="remitDate" />
<label class="dr-lbl">RBI / DEAF reference</label>
<input
class="dr-inp dr-inp--full"
type="text"
[(ngModel)]="remitReference"
placeholder="e.g. DEAF/2026/Q2/004913 — as printed on RBI's advice"
/>
<label class="dr-lbl">Remarks</label>
<textarea class="dr-ta" rows="3" [(ngModel)]="remitRemarks" placeholder="Optional"></textarea>
@if (remitError) {
<div class="dr-err"><fa-icon icon="exclamation-triangle"></fa-icon> {{ remitError }}</div>
}
</div>
<div class="dr-modal-f">
<button class="dr-btn" (click)="remitOpen = false">Cancel</button>
<button class="dr-btn dr-btn--primary" (click)="saveRemittance()" [disabled]="remitSaving">
@if (remitSaving) {
<fa-icon icon="spinner" [spin]="true"></fa-icon> Saving…
} @else {
Record remittance
}
</button>
</div>
</div>
</div>
}
<!-- Customer claimed a remitted balance back -->
@if (refundOpen && refundRow) {
<div class="dr-modal-bg" (click)="refundOpen = false">
<div class="dr-modal dr-modal--sm" (click)="$event.stopPropagation()">
<div class="dr-modal-h">
<h3>Record a claim against DEAF</h3>
<button class="dr-x" (click)="refundOpen = false">&times;</button>
</div>
<div class="dr-modal-b">
<div class="dr-modal-who">
<strong>{{ refundRow.clientName }}</strong>
<span class="mono">{{ refundRow.accountNo }}</span>
</div>
<div class="dr-modal-sum">
<div>
<span class="dr-modal-n">₹{{ refundRow.balance | number: '1.0-0' }}</span>
<span class="dr-modal-l">claimed</span>
</div>
<div>
<span class="dr-modal-n mono">{{ refundRow.remittedOn }}</span>
<span class="dr-modal-l">remitted to RBI</span>
</div>
</div>
<div class="dr-note-box">
<fa-icon icon="info-circle"></fa-icon>
A depositor can claim an unclaimed balance at any time, however long it has been with the fund. The bank pays
the customer and recovers the amount from RBI. This records that, so the money is no longer counted as sitting
with the fund.
</div>
<label class="dr-lbl">Date the customer was paid <span class="dr-req">*</span></label>
<input class="dr-inp dr-inp--full" type="date" [(ngModel)]="refundDate" />
@if (refundError) {
<div class="dr-err"><fa-icon icon="exclamation-triangle"></fa-icon> {{ refundError }}</div>
}
</div>
<div class="dr-modal-f">
<button class="dr-btn" (click)="refundOpen = false">Cancel</button>
<button class="dr-btn dr-btn--primary" (click)="saveRefund()" [disabled]="refundSaving">
@if (refundSaving) {
<fa-icon icon="spinner" [spin]="true"></fa-icon> Saving…
} @else {
Record claim
}
</button>
</div>
</div>
</div>
}
@if (toastVisible) {
<div class="dr-toast">{{ toastMsg }}</div>
}
@@ -0,0 +1,778 @@
/**
* 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 {
--d-navy: #1a237e;
--d-blue: #1d4ed8;
--d-blue-l: #eff6ff;
--d-green: #15803d;
--d-green-l: #dcfce7;
--d-amber: #b45309;
--d-amber-l: #fef3c7;
--d-red: #c0392b;
--d-red-l: #fdecea;
--d-crit: #7f1d1d;
--d-crit-l: #fee2e2;
--d-g50: #f8fafc;
--d-g100: #f1f5f9;
--d-g200: #e2e8f0;
--d-g300: #cbd5e1;
--d-g500: #64748b;
--d-g700: #334155;
--d-g900: #0f172a;
display: block;
font-family: 'DM Sans', system-ui, sans-serif;
color: var(--d-g900);
background: var(--d-g50);
min-height: 100%;
padding: 16px;
}
.mono {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11.5px;
}
.num {
text-align: right;
}
.strong {
font-weight: 700;
}
/* ── Header ─────────────────────────────────────────────────────────────── */
.dr-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.dr-head-t {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--d-navy);
}
.dr-head-s {
margin-top: 3px;
font-size: 12px;
color: var(--d-g500);
max-width: 780px;
line-height: 1.5;
}
.dr-head-a {
display: flex;
gap: 8px;
flex-shrink: 0;
}
/* The unremitted-money warning: the one number on this screen that is a live exposure. */
.dr-alert {
display: flex;
gap: 10px;
padding: 11px 14px;
margin-bottom: 14px;
background: var(--d-red-l);
border: 1px solid #f5b7b1;
border-left: 4px solid var(--d-red);
border-radius: 7px;
font-size: 12px;
line-height: 1.55;
color: var(--d-g700);
fa-icon {
color: var(--d-red);
margin-top: 2px;
}
}
.dr-link {
display: inline;
margin-left: 4px;
padding: 0;
border: 0;
background: none;
font: inherit;
font-weight: 700;
color: var(--d-red);
text-decoration: underline;
cursor: pointer;
}
/* ── Entry / criteria ───────────────────────────────────────────────────── */
.dr-entry {
margin-bottom: 14px;
background: #fff;
border: 1px solid var(--d-g200);
border-radius: 7px;
overflow: hidden;
}
.dr-entry-h {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
background: var(--d-g100);
border-bottom: 1px solid var(--d-g200);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--d-g500);
}
.dr-entry-b {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
padding: 13px 14px;
}
.dr-field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 160px;
label {
font-size: 10.5px;
font-weight: 700;
color: var(--d-g500);
text-transform: uppercase;
letter-spacing: 0.3px;
}
&--wide {
flex: 1;
min-width: 240px;
}
&--btn {
min-width: 0;
}
}
.dr-inp {
padding: 7px 10px;
border: 1px solid var(--d-g300);
border-radius: 5px;
font: inherit;
font-size: 12px;
background: #fff;
}
.dr-chk {
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
font-size: 12px;
color: var(--d-g700);
cursor: pointer;
}
/* ── The three reports, side by side ────────────────────────────────────── */
.dr-tabs {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(255px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.dr-tab {
text-align: left;
padding: 12px 14px;
border: 1px solid var(--d-g200);
border-top: 3px solid var(--d-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-top-color: var(--d-blue);
}
&--warn {
border-top-color: var(--d-amber);
}
&--critical {
border-top-color: var(--d-crit);
}
/* The report on display stays obvious while its rows are being read. */
&--on {
background: var(--d-g100);
box-shadow: inset 0 0 0 1px var(--d-g300);
}
}
.dr-tab-h {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.dr-tab-l {
font-size: 13px;
font-weight: 700;
}
.dr-tab-n {
font-size: 20px;
font-weight: 700;
line-height: 1;
}
.dr-tab-d {
margin-top: 2px;
font-size: 10.5px;
font-weight: 600;
color: var(--d-g500);
}
.dr-tab-e {
margin-top: 5px;
font-size: 10.5px;
color: var(--d-g500);
line-height: 1.45;
}
/* ── Context strip ──────────────────────────────────────────────────────── */
.dr-context {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 14px;
margin-bottom: 10px;
background: var(--d-blue-l);
border: 1px solid #bfdbfe;
border-radius: 7px;
font-size: 11.5px;
line-height: 1.55;
color: var(--d-g700);
}
.dr-context-i {
display: flex;
gap: 9px;
fa-icon {
color: var(--d-blue);
margin-top: 2px;
}
}
.dr-context-r {
margin-top: 3px;
color: var(--d-g500);
}
.dr-context-t {
white-space: nowrap;
font-size: 11.5px;
color: var(--d-g500);
}
.dr-context-c {
font-weight: 700;
color: var(--d-g900);
}
/* ── Buttons ────────────────────────────────────────────────────────────── */
.dr-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 13px;
border: 1px solid var(--d-g300);
border-radius: 5px;
background: #fff;
font: inherit;
font-size: 12px;
font-weight: 600;
color: var(--d-g700);
cursor: pointer;
&:hover:not(:disabled) {
border-color: var(--d-blue);
color: var(--d-blue);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&--slim {
padding: 6px 11px;
font-size: 11.5px;
}
&--primary {
background: var(--d-blue);
border-color: var(--d-blue);
color: #fff;
&:hover:not(:disabled) {
background: #1e40af;
color: #fff;
}
}
}
.dr-mini {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
border: 1px solid var(--d-g300);
border-radius: 5px;
background: #fff;
font: inherit;
font-size: 10.5px;
font-weight: 600;
color: var(--d-g700);
cursor: pointer;
&:hover {
border-color: var(--d-blue);
color: var(--d-blue);
}
}
/* ── Table ──────────────────────────────────────────────────────────────── */
.dr-tablewrap {
overflow-x: auto;
background: #fff;
border: 1px solid var(--d-g200);
border-radius: 7px;
}
.dr-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
th {
text-align: left;
padding: 9px 10px;
background: var(--d-g100);
border-bottom: 1px solid var(--d-g200);
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--d-g500);
white-space: nowrap;
&.num {
text-align: right;
}
}
td {
padding: 9px 10px;
border-bottom: 1px solid var(--d-g100);
vertical-align: top;
}
tbody tr:hover {
background: var(--d-g50);
}
}
/* A dormant account inside a year of the DEAF mark is the one to act on now. */
.dr-row--warn {
background: var(--d-amber-l);
&:hover {
background: #fde68a;
}
}
.dr-sub {
font-size: 10.5px;
color: var(--d-g500);
margin-top: 2px;
&--red {
color: var(--d-amber);
font-weight: 700;
}
}
.dr-prod {
max-width: 220px;
font-size: 11.5px;
}
.dr-next {
font-size: 11px;
font-weight: 600;
color: var(--d-g700);
}
.dr-ref {
max-width: 200px;
}
.dr-badge {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
&--info {
background: var(--d-blue-l);
color: var(--d-blue);
}
&--warn {
background: var(--d-amber-l);
color: var(--d-amber);
}
&--critical {
background: var(--d-crit-l);
color: var(--d-crit);
}
}
.dr-rem {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
&--ok {
background: var(--d-green-l);
color: var(--d-green);
}
/* Money out of the customer's account but not yet with RBI — never shown as settled. */
&--pending {
background: var(--d-red-l);
color: var(--d-red);
}
}
.dr-empty {
padding: 34px;
text-align: center;
font-size: 12.5px;
color: var(--d-g500);
background: #fff;
border: 1px solid var(--d-g200);
border-radius: 7px;
}
.dr-foot {
display: flex;
gap: 10px;
padding: 11px 14px;
margin-top: 12px;
background: #fff;
border: 1px solid var(--d-g200);
border-radius: 7px;
font-size: 11.5px;
line-height: 1.6;
color: var(--d-g500);
fa-icon {
color: var(--d-g500);
margin-top: 2px;
}
}
/* ── Selection & remittance bar ─────────────────────────────────────────── */
.dr-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.dr-spacer {
flex: 1;
}
.dr-selinfo {
font-size: 11.5px;
font-weight: 700;
color: var(--d-g700);
}
.dr-tick {
width: 34px;
text-align: center;
input {
cursor: pointer;
}
/* An already-remitted row cannot be picked again; the cursor should say so. */
input:disabled {
cursor: not-allowed;
opacity: 0.4;
}
}
.dr-row--sel {
background: var(--d-blue-l);
}
.dr-act {
white-space: nowrap;
.dr-mini + .dr-mini {
margin-left: 4px;
}
}
/* ── Modal ──────────────────────────────────────────────────────────────── */
.dr-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;
}
.dr-modal {
width: 100%;
max-width: 520px;
max-height: 90vh;
overflow-y: auto;
background: #fff;
border-radius: 9px;
box-shadow: 0 18px 50px rgb(15 23 42 / 26%);
&--sm {
max-width: 450px;
}
}
.dr-modal-h {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 17px;
border-bottom: 1px solid var(--d-g200);
h3 {
margin: 0;
font-size: 14px;
font-weight: 700;
color: var(--d-navy);
}
}
.dr-x {
border: 0;
background: transparent;
font-size: 22px;
line-height: 1;
color: var(--d-g500);
cursor: pointer;
}
.dr-modal-b {
padding: 15px 17px;
}
.dr-modal-f {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 17px;
border-top: 1px solid var(--d-g200);
}
.dr-modal-sum {
display: flex;
gap: 26px;
padding-bottom: 12px;
margin-bottom: 12px;
border-bottom: 1px solid var(--d-g100);
}
.dr-modal-n {
display: block;
font-size: 20px;
font-weight: 700;
line-height: 1.1;
}
.dr-modal-l {
font-size: 10.5px;
color: var(--d-g500);
}
.dr-modal-who {
display: flex;
align-items: center;
gap: 9px;
flex-wrap: wrap;
padding-bottom: 11px;
margin-bottom: 11px;
border-bottom: 1px solid var(--d-g100);
font-size: 12.5px;
}
.dr-modal-p {
margin: 12px 0 5px;
font-size: 11.5px;
font-weight: 700;
color: var(--d-g700);
}
.dr-modal-ul {
margin: 0;
padding-left: 18px;
font-size: 11.5px;
line-height: 1.75;
color: var(--d-g700);
}
/* Marking an account exempt is a decision on record, not a convenience. */
.dr-modal-warn {
margin: 12px 0 0;
padding: 8px 11px;
background: var(--d-amber-l);
border-radius: 5px;
font-size: 11px;
line-height: 1.55;
color: var(--d-amber);
}
.dr-lbl {
display: block;
margin: 12px 0 5px;
font-size: 11px;
font-weight: 700;
color: var(--d-g700);
}
.dr-req {
color: var(--d-red);
}
.dr-inp--full {
width: 100%;
}
.dr-ta {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--d-g300);
border-radius: 5px;
font: inherit;
font-size: 12px;
resize: vertical;
}
.dr-note-box {
display: flex;
gap: 8px;
padding: 9px 11px;
background: var(--d-blue-l);
border-radius: 5px;
font-size: 11px;
line-height: 1.55;
color: var(--d-g700);
fa-icon {
color: var(--d-blue);
margin-top: 1px;
}
}
.dr-err {
margin-top: 10px;
padding: 7px 10px;
background: var(--d-red-l);
border-radius: 5px;
font-size: 11.5px;
color: var(--d-red);
}
.dr-toast {
position: fixed;
bottom: 22px;
left: 50%;
transform: translateX(-50%);
padding: 10px 20px;
background: var(--d-g900);
color: #fff;
border-radius: 6px;
font-size: 12.5px;
box-shadow: 0 8px 22px rgb(15 23 42 / 30%);
z-index: 1100;
}
/* Where the escheated money currently stands, read at a glance above the table. */
.dr-money {
display: inline-flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-left: 14px;
}
.dr-money-i {
font-size: 11px;
font-weight: 700;
color: var(--d-g500);
&--bad {
color: var(--d-red);
}
&--ok {
color: var(--d-green);
}
}
/* A balance the customer has taken back is no longer with the fund. */
.dr-claimed {
margin-top: 3px;
font-size: 10px;
font-weight: 700;
color: var(--d-blue);
}
@@ -0,0 +1,585 @@
/**
* 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 { ClientsService } from '../clients/clients.service';
/** Shared report helpers */
import { exportWorkbook } from '../cashier/cashier-export';
/** Report data + computation */
import {
DORMANCY_POLICY,
DORMANCY_STAGES,
DormancyRow,
DormancyStage,
DormancySummary,
daysBetween,
dormancySummary,
nextStage,
sortByIdle,
stageFromSubStatus
} from './dormancy-report.compute';
/**
* The three reports on accounts that have gone quiet.
*
* They are one screen rather than three because they are one progression: an account moves from
* inactive to dormant to escheat as the same silence lengthens, and an officer reading one usually
* wants to see what is coming behind it. The filters at the top therefore apply to all three, and
* the tabs only choose which stage is on display.
*
* The escheat tab carries the figure that matters most — money taken out of customers' accounts
* that has not yet reached RBI's DEAF. Nothing in this system performs that remittance, so without
* a report on it the gap is invisible.
*/
@Component({
selector: 'mifosx-dormancy-report',
templateUrl: './dormancy-report.component.html',
styleUrls: ['./dormancy-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class DormancyReportComponent implements OnInit {
private router = inject(Router);
private clientsService = inject(ClientsService);
loading = false;
errorMessage = '';
rows: DormancyRow[] = [];
today = new Date().toISOString().slice(0, 10);
/** Which of the three reports is on display. */
tab: DormancyStage = 'DORMANT';
/** Filters, shared by all three tabs. */
branchCode = '';
search = '';
minBalance: number | null = null;
pendingRemittanceOnly = false;
/**
* Branch options come from the rows themselves rather than a fixed list: the server returns the
* office name it holds, and a dropdown built from anywhere else would offer branches that match
* nothing and omit ones that do.
*/
get branches(): string[] {
return [...new Set(this.rows.map((r) => r.branchCode).filter((b) => !!b))].sort();
}
readonly policy = DORMANCY_POLICY;
readonly stageMeta = DORMANCY_STAGES;
readonly tabs: DormancyStage[] = [
'INACTIVE',
'DORMANT',
'ESCHEAT'
];
ngOnInit(): void {
this.load();
}
/**
* Loads the accounts the dormancy job has marked.
*
* The stage is taken from the sub-status the job wrote rather than recomputed from the idle
* period. The sub-status is what actually restricts the account, so a report that worked it out
* independently could disagree with the account's real state and send officers after the wrong
* ones.
*/
load(): void {
this.loading = true;
this.errorMessage = '';
this.clientsService.getDormantAccounts().subscribe({
next: (accounts: any[]) => {
this.rows = sortByIdle(
(accounts || []).map((a) => this.mapAccount(a)).filter((r): r is DormancyRow => r !== null)
);
this.loading = false;
},
error: (err: any) => {
this.rows = [];
this.loading = false;
this.errorMessage =
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the dormancy reports.';
}
});
}
// ── Recording a DEAF remittance ────────────────────────────────────────────
/** Accounts ticked on the escheat tab, by account id. */
selected = new Set<number>();
remitOpen = false;
remitDate = new Date().toISOString().slice(0, 10);
remitReference = '';
remitRemarks = '';
remitSaving = false;
remitError = '';
toggleSelect(row: DormancyRow, event: Event): void {
event.stopPropagation();
if (this.selected.has(row.accountId)) {
this.selected.delete(row.accountId);
} else {
this.selected.add(row.accountId);
}
}
/** Ticks every unremitted row on display, or clears them if all are already ticked. */
toggleSelectAll(): void {
const selectable = this.visibleRows.filter((r) => r.remittance !== 'REMITTED');
if (selectable.every((r) => this.selected.has(r.accountId))) {
selectable.forEach((r) => this.selected.delete(r.accountId));
} else {
selectable.forEach((r) => this.selected.add(r.accountId));
}
}
/**
* Drops the whole selection.
*
* A method rather than `selected.clear()` in the template — Angular's expression syntax does not
* allow calling built-in methods on a Set.
*/
clearSelection(): void {
this.selected.clear();
}
get allSelected(): boolean {
const selectable = this.visibleRows.filter((r) => r.remittance !== 'REMITTED');
return selectable.length > 0 && selectable.every((r) => this.selected.has(r.accountId));
}
get selectedTotal(): number {
return this.rows.filter((r) => this.selected.has(r.accountId)).reduce((s, r) => s + r.balance, 0);
}
openRemit(): void {
this.remitDate = new Date().toISOString().slice(0, 10);
this.remitReference = '';
this.remitRemarks = '';
this.remitError = '';
this.remitOpen = true;
}
/**
* Writes the remittance against every ticked account.
*
* The server skips anything not escheated or already recorded, so the result is reported back as
* counts rather than assumed — a batch that silently recorded fewer rows than were ticked would
* leave the officer believing money was accounted for when it was not.
*/
saveRemittance(): void {
if (this.selected.size === 0) {
this.remitError = 'Select at least one account.';
return;
}
if (!this.remitDate) {
this.remitError = 'Enter the date the money was sent to RBI.';
return;
}
this.remitSaving = true;
this.remitError = '';
this.clientsService
.recordDeafRemittance({
savingsAccountIds: [...this.selected],
remittedOn: this.remitDate,
deafReference: this.remitReference.trim() || undefined,
remarks: this.remitRemarks.trim() || undefined
})
.subscribe({
next: (res: any) => {
this.remitSaving = false;
this.remitOpen = false;
const recorded = res?.changes?.recorded ?? res?.recorded ?? this.selected.size;
const skipped = res?.changes?.skipped ?? res?.skipped ?? 0;
this.selected.clear();
this.toast(
skipped > 0
? `${recorded} remittance(s) recorded, ${skipped} skipped — already recorded or not escheated.`
: `${recorded} remittance(s) recorded.`
);
this.load();
},
error: (err: any) => {
this.remitSaving = false;
this.remitError =
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not record the remittance.';
}
});
}
// ── Customer claiming a remitted balance back ──────────────────────────────
refundOpen = false;
refundRow: DormancyRow | null = null;
refundDate = new Date().toISOString().slice(0, 10);
refundSaving = false;
refundError = '';
/**
* Opens the claim dialog for a balance already sent to the fund.
*
* A customer can claim an unclaimed deposit at any time, however long it has been with DEAF. The
* bank pays them and recovers the amount from RBI, so recording it here keeps the remitted total
* honest rather than leaving money counted as still sitting in the fund.
*/
openRefund(row: DormancyRow, event: Event): void {
event.stopPropagation();
this.refundRow = row;
this.refundDate = new Date().toISOString().slice(0, 10);
this.refundError = '';
this.refundOpen = true;
}
saveRefund(): void {
if (!this.refundRow) {
return;
}
if (!this.refundDate) {
this.refundError = 'Enter the date the customer was paid.';
return;
}
const row = this.refundRow;
this.refundSaving = true;
this.refundError = '';
this.clientsService.refundDeafRemittance(row.accountId, this.refundDate).subscribe({
next: () => {
this.refundSaving = false;
this.refundOpen = false;
this.toast(`Claim recorded for ${row.clientName}.`);
this.load();
},
error: (err: any) => {
this.refundSaving = false;
this.refundError =
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not record the claim.';
}
});
}
toastMsg = '';
toastVisible = false;
private toastTimer: any;
toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => (this.toastVisible = false), 4000);
}
/** Maps one DormantAccountData row onto what the three reports render. */
private mapAccount(a: any): DormancyRow | null {
const stage = stageFromSubStatus(Number(a?.subStatus));
if (!stage) {
// Only the three dormancy states belong here; anything else is not on this ladder.
return null;
}
const lastTxn = (a?.lastCustomerTransactionDate || '').toString().slice(0, 10);
const next = lastTxn ? nextStage(lastTxn, stage) : { date: '', label: '' };
return {
accountId: a?.accountId,
accountNo: a?.accountNo || '—',
clientId: a?.clientId,
clientName: a?.clientName || '—',
branchCode: a?.officeName || '',
productName: a?.productName || '—',
balance: Number(a?.balance ?? 0),
lastCustomerTxnDate: lastTxn,
daysIdle: Number(a?.daysIdle ?? 0),
stage,
subStatus: Number(a?.subStatus ?? 0),
nextStageDate: next.date,
nextStageLabel: next.label,
// An escheated account counts as remitted only when a remittance has actually been recorded
// against it. Absence of that row is the whole point of the report: the money has left the
// customer's account but is not yet accounted for as having reached RBI.
remittance: stage === 'ESCHEAT' ? (a?.deafRemittedOn ? 'REMITTED' : 'PENDING') : undefined,
escheatedOn: (a?.closedOnDate || '').toString().slice(0, 10) || undefined,
remittedOn: (a?.deafRemittedOn || '').toString().slice(0, 10) || undefined,
deafReference: a?.deafReference || undefined,
refundedOn: (a?.deafRefundedOn || '').toString().slice(0, 10) || undefined,
mobile: a?.mobileNo || ''
};
}
// ── Filtering ──────────────────────────────────────────────────────────────
/** Rows on the tab in view, after the shared filters. */
get visibleRows(): DormancyRow[] {
return this.rowsForStage(this.tab);
}
private rowsForStage(stage: DormancyStage): DormancyRow[] {
const needle = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (r.stage !== stage) {
return false;
}
if (this.branchCode && r.branchCode !== this.branchCode) {
return false;
}
if (this.minBalance != null && r.balance < this.minBalance) {
return false;
}
// Only meaningful on the escheat tab, where a row can be awaiting remittance.
if (this.pendingRemittanceOnly && r.remittance !== 'PENDING') {
return false;
}
if (needle) {
const hay = `${r.clientName} ${r.clientId} ${r.accountNo}`.toLowerCase();
if (!hay.includes(needle)) {
return false;
}
}
return true;
});
}
/** Tab counts respect the filters, so the badge matches what the tab will show. */
countFor(stage: DormancyStage): number {
return this.rowsForStage(stage).length;
}
get summary(): DormancySummary {
return dormancySummary(this.rows, this.today);
}
get visibleBalance(): number {
return this.visibleRows.reduce((s, r) => s + r.balance, 0);
}
resetFilters(): void {
this.branchCode = '';
this.search = '';
this.minBalance = null;
this.pendingRemittanceOnly = false;
}
showTab(stage: DormancyStage): void {
this.tab = stage;
// The remittance filter only exists on the escheat tab; carrying it elsewhere would silently
// empty a tab that has no remittance state at all.
if (stage !== 'ESCHEAT') {
this.pendingRemittanceOnly = false;
}
}
// ── Presentation ───────────────────────────────────────────────────────────
/** The server already returns the office by name, so there is nothing to look up. */
branchName(office: string): string {
return office || '—';
}
toneClass(stage: DormancyStage): string {
return `dr-badge dr-badge--${DORMANCY_STAGES[stage].tone}`;
}
/** "3 years 2 months" reads better than a four-digit day count. */
idleText(days: number): string {
const years = Math.floor(days / 365);
const months = Math.floor((days % 365) / 30);
if (years === 0) {
return `${months} month${months === 1 ? '' : 's'}`;
}
if (months === 0) {
return `${years} year${years === 1 ? '' : 's'}`;
}
return `${years}y ${months}m`;
}
/** Days until the next mark, for the accounts approaching one. */
daysToNext(row: DormancyRow): number | null {
return row.nextStageDate ? daysBetween(this.today, row.nextStageDate) : null;
}
/** Flags a dormant account within a year of the ten-year mark. */
isNearingEscheat(row: DormancyRow): boolean {
const days = this.daysToNext(row);
return row.stage === 'DORMANT' && days !== null && days <= 365;
}
remittanceClass(state?: string): string {
return state === 'REMITTED' ? 'dr-rem dr-rem--ok' : 'dr-rem dr-rem--pending';
}
openClient(row: DormancyRow, event: Event): void {
event.stopPropagation();
this.router.navigate([
'/clients',
row.clientId,
'general'
]);
}
// ── Export ─────────────────────────────────────────────────────────────────
/**
* Exports all three reports, not just the tab in view.
*
* They are read together — an auditor asking about escheat invariably asks what is behind it —
* and a workbook holding one of the three would only have to be produced three times.
*/
exportExcel(): void {
const stamp = `As on ${this.today}${this.branchCode ? ` · ${this.branchName(this.branchCode)}` : ' · All branches'}`;
exportWorkbook(
[
{
sheetName: 'Inactive',
title: `Inactive Accounts — idle ${this.policy.inactiveDays}+ days`,
subtitle: stamp,
headers: [
'Account No',
'Customer',
'Customer ID',
'Branch',
'Product',
'Balance',
'Last Customer Txn',
'Idle',
'Becomes Dormant On'
],
widths: [
18,
28,
12,
22,
30,
14,
18,
12,
20
],
rows: this.rowsForStage('INACTIVE').map((r) => [
r.accountNo,
r.clientName,
String(r.clientId),
this.branchName(r.branchCode),
r.productName,
r.balance,
r.lastCustomerTxnDate,
this.idleText(r.daysIdle),
r.nextStageDate
])
},
{
sheetName: 'Dormant',
title: `Dormant / Inoperative Accounts — idle ${this.policy.dormantDays}+ days`,
subtitle: stamp,
headers: [
'Account No',
'Customer',
'Customer ID',
'Branch',
'Product',
'Balance',
'Last Customer Txn',
'Idle',
'Due to DEAF On',
'Nearing DEAF'
],
widths: [
18,
28,
12,
22,
30,
14,
18,
12,
18,
14
],
rows: this.rowsForStage('DORMANT').map((r) => [
r.accountNo,
r.clientName,
String(r.clientId),
this.branchName(r.branchCode),
r.productName,
r.balance,
r.lastCustomerTxnDate,
this.idleText(r.daysIdle),
r.nextStageDate,
this.isNearingEscheat(r) ? 'Within 1 year' : ''
])
},
{
sheetName: 'Escheat & DEAF',
title: `Escheated Accounts — idle ${this.policy.escheatDays}+ days`,
subtitle: `${stamp} · Not yet remitted to RBI: ₹${this.summary.pendingRemittance.toLocaleString('en-IN')}`,
headers: [
'Account No',
'Customer',
'Customer ID',
'Branch',
'Balance Escheated',
'Last Customer Txn',
'Idle',
'Escheated On',
'Remittance to RBI',
'Remitted On',
'DEAF Reference',
'Claimed Back On'
],
widths: [
18,
28,
12,
22,
18,
18,
12,
14,
16,
14,
24,
16
],
rows: this.rowsForStage('ESCHEAT').map((r) => [
r.accountNo,
r.clientName,
String(r.clientId),
this.branchName(r.branchCode),
r.balance,
r.lastCustomerTxnDate,
this.idleText(r.daysIdle),
r.escheatedOn || '',
r.remittance === 'REMITTED' ? 'Remitted' : 'Not remitted',
r.remittedOn || '',
r.deafReference || '',
r.refundedOn || ''
])
}
],
`dormancy-reports-${this.today}`
);
}
}
@@ -0,0 +1,218 @@
/**
* 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/.
*/
/**
* Accounts that have gone quiet, and what has happened to them.
*
* RBI counts the silence from the last *customer-induced* transaction — a deposit or a withdrawal
* the customer made. Interest the bank posts and charges it recovers are the bank operating the
* account, not the customer using it, so they do not reset the clock. An account crosses three
* marks: inactive at a year, inoperative at two, and due to the Depositor Education and Awareness
* Fund at ten.
*
* The escheat stage is the one that needs watching. Sending the balance to DEAF is a transfer to
* RBI that happens outside this system, so an account can sit closed here with its money not yet
* remitted. That gap is what the third report exists to show.
*/
/** The three marks an idle account passes. */
export type DormancyStage = 'INACTIVE' | 'DORMANT' | 'ESCHEAT';
/** Sub-status codes as stored on the savings account. */
export const SUB_STATUS = {
NONE: 0,
INACTIVE: 100,
DORMANT: 200,
ESCHEAT: 300
};
/** RBI periods, in days from the last customer transaction. */
export const DORMANCY_POLICY = {
inactiveDays: 365,
dormantDays: 730,
escheatDays: 3650
};
export interface DormancyStageMeta {
stage: DormancyStage;
label: string;
tone: 'info' | 'warn' | 'critical';
afterDays: number;
/** What the customer can still do. */
effect: string;
/** How the account comes back, in the officer's words. */
revival: string;
}
export const DORMANCY_STAGES: Record<DormancyStage, DormancyStageMeta> = {
INACTIVE: {
stage: 'INACTIVE',
label: 'Inactive',
tone: 'info',
afterDays: DORMANCY_POLICY.inactiveDays,
effect: 'Nothing is restricted. The account is flagged internally so the branch can reach out.',
revival: 'Any customer deposit or withdrawal clears the flag at once.'
},
DORMANT: {
stage: 'DORMANT',
label: 'Dormant / Inoperative',
tone: 'warn',
afterDays: DORMANCY_POLICY.dormantDays,
effect: 'Debits are stopped. Credits still land, so pension and benefit transfers are unaffected.',
revival: 'The customer completes KYC at the branch and makes one transaction — no reactivation charge.'
},
ESCHEAT: {
stage: 'ESCHEAT',
label: 'Escheat — due to DEAF',
tone: 'critical',
afterDays: DORMANCY_POLICY.escheatDays,
effect: 'The account is closed and the balance is taken out, to be remitted to RBI’s DEAF.',
revival: 'The customer can still claim the money from the bank, which then recovers it from DEAF.'
}
};
/** Whether the balance taken out at escheat has actually reached RBI. */
export type DeafRemittanceState = 'PENDING' | 'REMITTED';
export interface DormancyRow {
accountId: number;
accountNo: string;
clientId: number;
clientName: string;
branchCode: string;
productName: string;
balance: number;
/** Last transaction the customer themselves made. */
lastCustomerTxnDate: string;
daysIdle: number;
stage: DormancyStage;
/** Sub-status currently on the account. */
subStatus: number;
/** The day the next mark is crossed; blank once escheated. */
nextStageDate: string;
nextStageLabel: string;
/** Escheat only — whether the money has gone to RBI. */
remittance?: DeafRemittanceState;
/** Escheat only — the day the balance was taken out of the account. */
escheatedOn?: string;
/** Escheat only — the day it was remitted, once it has been. */
remittedOn?: string;
/** Escheat only — RBI's reference for the remittance. */
deafReference?: string;
/** Escheat only — set if the customer claimed the balance back after it went to the fund. */
refundedOn?: string;
mobile: string;
}
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);
}
export 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);
}
/**
* The stage an account is at, from the sub-status the dormancy job wrote on it.
*
* Preferred over deriving it from the idle period, because the sub-status is what actually
* restricts the account. Anything outside the three dormancy codes — a manual block, an active
* account — is not on this ladder and returns null.
*/
export function stageFromSubStatus(subStatus: number): DormancyStage | null {
if (subStatus === SUB_STATUS.INACTIVE) {
return 'INACTIVE';
}
if (subStatus === SUB_STATUS.DORMANT) {
return 'DORMANT';
}
if (subStatus === SUB_STATUS.ESCHEAT) {
return 'ESCHEAT';
}
return null;
}
/** Which mark an account has passed, from how long it has been idle. */
export function dormancyStage(daysIdle: number): DormancyStage | null {
if (daysIdle >= DORMANCY_POLICY.escheatDays) {
return 'ESCHEAT';
}
if (daysIdle >= DORMANCY_POLICY.dormantDays) {
return 'DORMANT';
}
if (daysIdle >= DORMANCY_POLICY.inactiveDays) {
return 'INACTIVE';
}
return null;
}
/** The next mark and the day it falls due. */
export function nextStage(lastTxnDate: string, stage: DormancyStage): { date: string; label: string } {
if (stage === 'INACTIVE') {
return { date: addDays(lastTxnDate, DORMANCY_POLICY.dormantDays), label: 'Becomes dormant' };
}
if (stage === 'DORMANT') {
return { date: addDays(lastTxnDate, DORMANCY_POLICY.escheatDays), label: 'Balance due to DEAF' };
}
// Escheat is the last mark — nothing further happens to the account on its own.
return { date: '', label: 'Awaiting DEAF remittance' };
}
export interface DormancySummary {
inactive: number;
dormant: number;
escheat: number;
inactiveBalance: number;
dormantBalance: number;
escheatBalance: number;
/** Escheated money not yet sent to RBI — the figure that must not be left to drift. */
pendingRemittance: number;
pendingRemittanceCount: number;
/** Balances that reached the fund and are still with it. */
remittedBalance: number;
/** Balances since claimed back by the customer and recovered from the fund. */
claimedBackBalance: number;
claimedBackCount: number;
/** Dormant accounts approaching the ten-year mark within a year. */
nearingEscheat: number;
}
export function dormancySummary(rows: DormancyRow[], today: string): DormancySummary {
const inactive = rows.filter((r) => r.stage === 'INACTIVE');
const dormant = rows.filter((r) => r.stage === 'DORMANT');
const escheat = rows.filter((r) => r.stage === 'ESCHEAT');
const pending = escheat.filter((r) => r.remittance === 'PENDING');
const claimedBack = escheat.filter((r) => r.refundedOn);
// A balance the customer has since claimed back is no longer with the fund, so it is counted
// separately rather than left in the remitted total.
const stillWithFund = escheat.filter((r) => r.remittance === 'REMITTED' && !r.refundedOn);
const sum = (list: DormancyRow[]) => list.reduce((s, r) => s + r.balance, 0);
return {
inactive: inactive.length,
dormant: dormant.length,
escheat: escheat.length,
inactiveBalance: sum(inactive),
dormantBalance: sum(dormant),
escheatBalance: sum(escheat),
pendingRemittance: sum(pending),
pendingRemittanceCount: pending.length,
remittedBalance: sum(stillWithFund),
claimedBackBalance: sum(claimedBack),
claimedBackCount: claimedBack.length,
nearingEscheat: dormant.filter((r) => r.nextStageDate && daysBetween(today, r.nextStageDate) <= 365).length
};
}
/** Idle longest first — the accounts closest to the next mark need attention first. */
export function sortByIdle(rows: DormancyRow[]): DormancyRow[] {
return [...rows].sort((a, b) => b.daysIdle - a.daysIdle || b.balance - a.balance);
}
@@ -441,16 +441,18 @@
<tfoot>
<tr class="row-total expense">
<td colspan="2">
<strong>Total Contingent Liabilities: &#8377; {{ contingentLiab.current | number: '1.2-2' }} Lakh</strong>
<strong
>Total Contingent Liabilities: &#8377; {{ contingentLiab.current | number: '1.2-2' }} Lakh</strong
>
</td>
</tr>
</tfoot>
</table>
</div>
<p class="acr-note">
<fa-icon icon="info-circle"></fa-icon>&nbsp; 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.
<fa-icon icon="info-circle"></fa-icon>&nbsp; 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.
</p>
</div>
</ng-container>
@@ -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',
@@ -659,7 +659,7 @@ export class CimsXbrlDialogComponent {
name: 'OSS-4',
fullName: 'Connected Lending &amp; 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 &amp; 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',
@@ -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<FdRdEntryDialogComponent>);
@@ -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',
@@ -0,0 +1,205 @@
<!--
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/.
-->
<div class="ir">
<!-- Header -->
<div class="ir-head">
<div>
<h2 class="ir-head-t"><fa-icon icon="percentage"></fa-icon> Savings Interest Posted</h2>
<div class="ir-head-s">
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.
</div>
</div>
<div class="ir-head-a">
<button class="ir-btn ir-btn--slim" (click)="load()" [disabled]="loading">
<fa-icon icon="undo"></fa-icon> Reload
</button>
<button class="ir-btn ir-btn--primary" (click)="exportExcel()" [disabled]="loading">
<fa-icon icon="file-excel"></fa-icon> Export all periods
</button>
</div>
</div>
@if (errorMessage) {
<div class="ir-alert"><fa-icon icon="exclamation-triangle"></fa-icon> {{ errorMessage }}</div>
}
<!--
A product paid on more dates than its rhythm allows was paid by something other than this
ledger — stock Fineract's savings interest job writes backdated postings a day at a time and
ignores the product's posting period. The postings are real and stay on the report; this says
so, so a total is not read as the rhythm the product is meant to keep.
-->
@if (rhythmWarnings.length > 0) {
<div class="ir-warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>Postings do not follow the product's rhythm.</strong>
@for (w of rhythmWarnings; track w.product) {
<div class="ir-warn-row">
<strong>{{ w.product }}</strong> is configured {{ w.label }} ({{ w.expected }}) but has paid on
<strong>{{ w.dates }} different dates</strong>.
</div>
}
<div class="ir-warn-note">
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.
</div>
</div>
</div>
}
<!-- Filters, shared by every period -->
<div class="ir-filters">
<div class="ir-field">
<label>From</label>
<input class="ir-inp" type="date" [(ngModel)]="fromDate" (ngModelChange)="load()" />
</div>
<div class="ir-field">
<label>To</label>
<input class="ir-inp" type="date" [(ngModel)]="toDate" (ngModelChange)="load()" />
</div>
<div class="ir-field ir-field--wide">
<label>Search</label>
<input class="ir-inp" type="text" [(ngModel)]="search" placeholder="Account number, customer or product" />
</div>
<div class="ir-field ir-field--btn">
<button class="ir-btn ir-btn--slim" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
</div>
</div>
<!-- One card per posting rhythm; each is its own report -->
<div class="ir-tabs">
@for (p of periods; track p.enumValue) {
<button class="ir-tab" [class.ir-tab--on]="activePeriod === p.enumValue" (click)="showPeriod(p.enumValue)">
<div class="ir-tab-h">
<span class="ir-tab-l">{{ p.label }}</span>
<span class="ir-tab-n">{{ countFor(p.enumValue) }}</span>
</div>
<div class="ir-tab-d">{{ p.cadence }}</div>
<div class="ir-tab-t">₹{{ totalFor(p.enumValue) | number: '1.2-2' }}</div>
</button>
}
</div>
<!--
The period control for the rhythm in view.
Each rhythm gets its own, at its own granularity: a day for Daily, a month for the rest. One
shared control would make the quarterly report filter by a single date and hide the rest of the
quarter.
-->
<div class="ir-pick">
<label class="ir-pick-l">{{ activeMeta.pickLabel }}</label>
<!--
A dropdown of the dates this rhythm actually posted on, not a calendar: a quarterly product
posts four times a year, and a date picker would offer every other day of it holding nothing.
The account count rides in the option, so "how many accounts at this quarter end" is answered
before the filter is applied.
-->
<select class="ir-inp ir-inp--pick" [(ngModel)]="periodPick[activePeriod]">
<option value="">All {{ activeMeta.pickUnit }}s</option>
@for (p of availablePicks(activePeriod); track p.date) {
<option [value]="p.date">
{{ formatDate(p.date) }} — {{ p.accounts }} account{{ p.accounts === 1 ? '' : 's' }}, ₹{{
p.total | number: '1.2-2'
}}
</option>
}
</select>
@if (periodPick[activePeriod]) {
<button class="ir-btn ir-btn--slim" (click)="clearPick(activePeriod)">
<fa-icon icon="undo"></fa-icon> Show all
</button>
}
<span class="ir-spacer"></span>
<span class="ir-pick-hint">
{{ availablePicks(activePeriod).length }} {{ activeMeta.pickUnit
}}{{ availablePicks(activePeriod).length === 1 ? '' : 's' }} with postings
</span>
</div>
<!-- What the tab in view holds -->
<div class="ir-context">
<div class="ir-context-i">
<fa-icon icon="info-circle"></fa-icon>
<div>
<strong>{{ activeMeta.label }}.</strong> {{ activeMeta.cadence }} — every account on a product configured to
post {{ activeMeta.label.toLowerCase() }}.
</div>
</div>
<div class="ir-context-t">
<span class="ir-context-c">{{ visibleAccounts }}</span> accounts ·
<span class="ir-context-c">{{ visibleRows.length }}</span> postings ·
<span class="ir-context-c">₹{{ visibleTotal | number: '1.2-2' }}</span> paid
</div>
</div>
<!-- Postings, grouped by the day they were paid -->
@if (loading) {
<div class="ir-empty"><fa-icon icon="spinner" [spin]="true"></fa-icon> Loading…</div>
} @else if (visibleRows.length === 0) {
<div class="ir-empty">
No {{ activeMeta.label.toLowerCase() }} interest posted in this period.
@if (activePeriod !== 1) {
A cycle that has not ended yet has nothing to show.
}
</div>
} @else {
@for (group of groupedByDate; track group.date) {
<div class="ir-group">
<div class="ir-group-h">
<span class="ir-group-d"><fa-icon icon="calendar"></fa-icon> {{ formatDate(group.date) }}</span>
<span class="ir-group-m">
{{ group.accounts }} account{{ group.accounts === 1 ? '' : 's' }} ·
<strong>₹{{ group.total | number: '1.2-2' }}</strong>
</span>
</div>
<div class="ir-tablewrap">
<table class="ir-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Product</th>
<th class="num">Rate</th>
<th class="num">Balance Before</th>
<th class="num">Interest Posted</th>
<th class="num">Balance After</th>
<th>Branch</th>
<th></th>
</tr>
</thead>
<tbody>
@for (r of group.rows; track r.transaction_id) {
<tr>
<td class="mono">{{ r.account_no }}</td>
<td>{{ r.client_name || '—' }}</td>
<td class="ir-prod">{{ r.product_name }}</td>
<td class="num">{{ r.interest_rate | number: '1.2-2' }}%</td>
<td class="num">{{ r.balance_before | number: '1.2-2' }}</td>
<td class="num strong ir-amt">{{ r.interest_amount | number: '1.2-2' }}</td>
<td class="num">{{ r.balance_after | number: '1.2-2' }}</td>
<td>{{ r.office_name || '—' }}</td>
<td>
<button class="ir-mini" (click)="openAccount(r)" [disabled]="r.client_id == null">
<fa-icon icon="list"></fa-icon> Transactions
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
}
</div>
@@ -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);
}
@@ -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<number, string> = {};
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<string, SavingsInterestPostingRow[]>();
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<number, number> = { 1: 100000, 4: 24, 5: 8, 6: 4, 7: 2 };
const expectedWord: Record<number, string> = {
1: 'daily',
4: 'about one a month',
5: 'about four a year',
6: 'two a year',
7: 'one a year'
};
const byProduct = new Map<string, { label: string; period: number; dates: Set<string> }>();
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<string, SavingsInterestPostingRow[]>();
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)}`);
}
}
@@ -0,0 +1,430 @@
<!--
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/.
-->
<div class="ke">
<!-- Header -->
<div class="ke-head">
<div>
<h2 class="ke-head-t"><fa-icon icon="id-card"></fa-icon> Expired KYC &amp; Customer Outreach</h2>
<div class="ke-head-s">
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.
</div>
</div>
<button class="ke-btn ke-btn--slim" (click)="load()" [disabled]="loading">
<fa-icon icon="undo"></fa-icon> Reload
</button>
</div>
<!-- The rule the screen runs on -->
<div class="ke-policy">
<fa-icon icon="gavel"></fa-icon>
<div>
<strong>RBI Master Direction on KYC.</strong> An account is not closed or restricted merely because periodic
re-KYC is pending. Nothing stops for the first <strong>{{ policy.graceDays }} days</strong> past the due date;
debits stop after that, and the account is marked inoperative after
<strong>{{ policy.inoperativeDays }} days</strong>. 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:
<span class="ke-allow-list">{{ alwaysAllowed.join(' · ') }}</span>
</div>
</div>
@if (errorMessage) {
<div class="ke-warnbar"><fa-icon icon="exclamation-triangle"></fa-icon> {{ errorMessage }}</div>
}
<!-- Ladder summary; each card filters the worklist -->
<div class="ke-stats">
@for (key of stageKeys; track key) {
<button
class="ke-stat"
[class.active]="stageFilter === key"
[class]="'ke-stat ke-stat--' + stageMeta[key].tone"
[class.ke-stat--on]="stageFilter === key"
(click)="toggleStage(key)"
>
<div class="ke-stat-n">
@switch (key) {
@case ('INOPERATIVE') {
{{ summary.inoperative }}
}
@case ('PARTIAL_FREEZE') {
{{ summary.partialFreeze }}
}
@default {
{{ summary.grace }}
}
}
</div>
<div class="ke-stat-l">{{ stageMeta[key].label }}</div>
<div class="ke-stat-w">{{ stageMeta[key].summary }}</div>
</button>
}
</div>
<!-- Filters -->
<div class="ke-filters">
<input class="ke-inp" type="text" placeholder="Search name, customer ID or account" [(ngModel)]="search" />
<select class="ke-sel" [(ngModel)]="riskFilter">
<option [ngValue]="0">All risk categories</option>
<option [ngValue]="1">Low Risk</option>
<option [ngValue]="2">Medium Risk</option>
<option [ngValue]="3">High Risk</option>
</select>
<label class="ke-chk">
<input type="checkbox" [(ngModel)]="notContactedOnly" /> Not yet contacted
@if (summary.notContacted > 0) {
<span class="ke-badge">{{ summary.notContacted }}</span>
}
</label>
<button class="ke-btn ke-btn--slim" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
<span class="ke-spacer"></span>
<span class="ke-count">{{ visibleRows.length }} of {{ summary.total }} customers</span>
</div>
<!-- Worklist -->
@if (loading) {
<div class="ke-empty"><fa-icon icon="spinner" [spin]="true"></fa-icon> Loading worklist…</div>
} @else if (visibleRows.length === 0) {
<div class="ke-empty">No customers match these filters.</div>
} @else {
<div class="ke-tablewrap">
<table class="ke-table">
<thead>
<tr>
<th></th>
<th>Customer</th>
<th>Account</th>
<th>Risk</th>
<th>KYC Expiry</th>
<th>Position</th>
<th>What is blocked</th>
<th>Next step</th>
<th>Outreach</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (r of visibleRows; track r.clientId) {
<tr class="ke-row" [class.open]="expanded === r.clientId" (click)="toggle(r.clientId)">
<td>
<fa-icon [icon]="expanded === r.clientId ? 'caret-down' : 'caret-right'"></fa-icon>
</td>
<td>
<strong>{{ r.clientName }}</strong>
<div class="ke-sub mono">{{ r.clientId }}</div>
</td>
<td>
<span class="mono">{{ r.accountNo || '—' }}</span>
<div class="ke-sub">Branch {{ r.branchCode || '—' }}</div>
</td>
<td>
<span [class]="riskClass(r.riskCategory)">{{ r.riskLabel }}</span>
</td>
<td>
<span class="mono">{{ r.expiryDate }}</span>
<div class="ke-sub" [class.ke-sub--red]="r.daysOverdue > 0">{{ overdueText(r) }}</div>
</td>
<td>
<span [class]="stageClass(r.stage)">{{ r.meta.label }}</span>
</td>
<td class="ke-block-cell">
@if (r.meta.blocked.length === 0) {
<span class="ke-nothing">Nothing blocked — fully operative</span>
} @else {
@for (b of r.meta.blocked; track b) {
<span class="ke-blocked">{{ b }}</span>
}
}
</td>
<td>
@if (r.nextActionDate) {
<div class="ke-next">{{ r.nextActionLabel }}</div>
<div class="ke-sub mono">{{ r.nextActionDate }}</div>
} @else {
<span class="ke-sub">{{ r.nextActionLabel }}</span>
}
</td>
<td>
@if (r.contactCount === 0) {
<span class="ke-none">Not contacted</span>
} @else {
<span class="ke-times">{{ r.contactCount }} attempt{{ r.contactCount > 1 ? 's' : '' }}</span>
<div class="ke-sub mono">last {{ r.lastContactDate }}</div>
}
@if (r.acknowledged) {
<div class="ke-ack"><fa-icon icon="check"></fa-icon> Customer reached</div>
}
</td>
<td class="ke-act-cell">
<div class="ke-acts">
<button
class="ke-ico"
title="Record an email to the customer"
[disabled]="!r.email"
(click)="openContact(r, 'EMAIL', $event)"
>
<fa-icon icon="envelope"></fa-icon>
</button>
<button
class="ke-ico"
title="Record an SMS"
[disabled]="!r.mobile"
(click)="openContact(r, 'SMS', $event)"
>
<fa-icon icon="comment-dots"></fa-icon>
</button>
<button
class="ke-ico"
title="Record a phone call"
[disabled]="!r.mobile"
(click)="openContact(r, 'PHONE_CALL', $event)"
>
<fa-icon icon="phone"></fa-icon>
</button>
<button class="ke-ico" title="Record a visit" (click)="openContact(r, 'FIELD_VISIT', $event)">
<fa-icon icon="user-check"></fa-icon>
</button>
</div>
<div class="ke-acts">
<button class="ke-mini" (click)="openAck(r, $event)" [disabled]="r.acknowledged">
<fa-icon icon="check"></fa-icon> Mark contacted
</button>
<button
class="ke-mini ke-mini--go"
title="Complete re-KYC on the customer's KYC screen. Once it is renewed the restrictions lift and this customer drops off the list."
(click)="openKyc(r, $event)"
>
<fa-icon icon="id-card"></fa-icon> Renew KYC
</button>
</div>
</td>
</tr>
<!-- Expanded: what is allowed, and the full outreach history -->
@if (expanded === r.clientId) {
<tr class="ke-detail">
<td colspan="10">
<div class="ke-detail-grid">
<div class="ke-panel">
<h4><fa-icon icon="check-circle"></fa-icon> Still allowed</h4>
<ul>
@for (a of r.meta.allowed; track a) {
<li>{{ a }}</li>
}
</ul>
<div class="ke-panel-note">{{ r.meta.summary }}</div>
</div>
<div class="ke-panel">
<h4><fa-icon icon="ban"></fa-icon> Blocked</h4>
@if (r.meta.blocked.length === 0) {
<div class="ke-panel-note">
Nothing is restricted at this stage. The account operates normally while the customer is given
time to complete re-KYC.
</div>
} @else {
<ul>
@for (b of r.meta.blocked; track b) {
<li>{{ b }}</li>
}
</ul>
}
<div class="ke-panel-note ke-panel-note--allow">
Never held up: {{ alwaysAllowed.join(' · ') }}
</div>
</div>
<div class="ke-panel">
<h4><fa-icon icon="address-card"></fa-icon> Contact on file</h4>
<div class="ke-kv">
<span>Mobile</span><strong class="mono">{{ r.mobile || 'Not on file' }}</strong>
</div>
<div class="ke-kv">
<span>Email</span><strong class="mono">{{ r.email || 'Not on file' }}</strong>
</div>
@if (r.acknowledged) {
<div class="ke-ack-box">
<strong>Customer reached</strong> on {{ r.acknowledgedOn }} by {{ r.acknowledgedBy }}.
@if (r.acknowledgedNote) {
<div class="ke-sub">{{ r.acknowledgedNote }}</div>
}
</div>
}
</div>
</div>
<h4 class="ke-hist-h"><fa-icon icon="history"></fa-icon> Outreach history</h4>
@if (r.contacts.length === 0) {
<div class="ke-panel-note">
No attempt recorded yet. Use the actions on the row to log an email, SMS, call or visit.
</div>
} @else {
<table class="ke-subtable">
<thead>
<tr>
<th>Date</th>
<th>Channel</th>
<th>Destination</th>
<th>Outcome</th>
<th>Officer</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
@for (c of r.contacts; track c.date + c.channel + c.notes) {
<tr>
<td class="mono">{{ c.date }}</td>
<td>{{ c.channelLabel }}</td>
<td class="mono">{{ c.destinationMasked || '—' }}</td>
<td>
<span [class]="deliveryClass(c.deliveryStatus)">{{ deliveryText(c) }}</span>
</td>
<td>{{ c.officer }}</td>
<td class="ke-notes">{{ c.notes }}</td>
</tr>
}
</tbody>
</table>
}
</td>
</tr>
}
}
</tbody>
</table>
</div>
}
</div>
<!-- Record an outreach attempt -->
@if (contactOpen && contactRow) {
<div class="ke-modal-bg" (click)="contactOpen = false">
<div class="ke-modal" (click)="$event.stopPropagation()">
<div class="ke-modal-h">
<h3>Record customer contact</h3>
<button class="ke-x" (click)="contactOpen = false">&times;</button>
</div>
<div class="ke-modal-b">
<div class="ke-modal-who">
<strong>{{ contactRow.clientName }}</strong>
<span class="mono">{{ contactRow.accountNo }}</span>
<span [class]="stageClass(contactRow.stage)">{{ contactRow.meta.label }}</span>
</div>
<label class="ke-lbl">Channel</label>
<div class="ke-chan">
@for (c of channels; track c.key) {
<button class="ke-chan-b" [class.active]="contactChannel === c.key" (click)="contactChannel = c.key">
<fa-icon [icon]="c.icon"></fa-icon> {{ c.label }}
</button>
}
</div>
<div class="ke-dest" [class.ke-dest--missing]="contactDestinationMissing">
@if (contactDestinationMissing) {
<fa-icon icon="exclamation-triangle"></fa-icon>
No destination on file for this channel — the attempt will be recorded but nothing is sent.
} @else {
<fa-icon icon="paper-plane"></fa-icon>
@if (contactChannelTransmits) {
Will be sent to <strong>{{ contactDestination }}</strong>
} @else {
Recorded as made <strong>{{ contactDestination }}</strong>
}
}
</div>
<label class="ke-lbl">What was said or attempted <span class="ke-req">*</span></label>
<textarea
class="ke-ta"
rows="4"
[(ngModel)]="contactNotes"
placeholder="e.g. Called on registered mobile, customer confirmed they will visit the branch with PAN and address proof next week."
></textarea>
<label class="ke-lbl">Officer (optional)</label>
<input class="ke-inp ke-inp--full" type="text" [(ngModel)]="contactOfficer" placeholder="Name of the officer" />
@if (contactError) {
<div class="ke-err"><fa-icon icon="exclamation-triangle"></fa-icon> {{ contactError }}</div>
}
</div>
<div class="ke-modal-f">
<button class="ke-btn ke-btn--ghost" (click)="contactOpen = false">Cancel</button>
<button class="ke-btn ke-btn--primary" (click)="saveContact()" [disabled]="contactSaving">
@if (contactSaving) {
<fa-icon icon="spinner" [spin]="true"></fa-icon> Saving…
} @else {
Record contact
}
</button>
</div>
</div>
</div>
}
<!-- Mark the customer as reached -->
@if (ackOpen && ackRow) {
<div class="ke-modal-bg" (click)="ackOpen = false">
<div class="ke-modal ke-modal--sm" (click)="$event.stopPropagation()">
<div class="ke-modal-h">
<h3>Mark customer as contacted</h3>
<button class="ke-x" (click)="ackOpen = false">&times;</button>
</div>
<div class="ke-modal-b">
<div class="ke-modal-who">
<strong>{{ ackRow.clientName }}</strong>
<span class="mono">{{ ackRow.accountNo }}</span>
</div>
<div class="ke-note-box">
<fa-icon icon="info-circle"></fa-icon>
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.
</div>
<label class="ke-lbl">What the customer said</label>
<textarea
class="ke-ta"
rows="3"
[(ngModel)]="ackNote"
placeholder="e.g. Customer acknowledged the debit freeze and will visit the branch on 12 Sep with documents."
></textarea>
<label class="ke-lbl">Officer (optional)</label>
<input class="ke-inp ke-inp--full" type="text" [(ngModel)]="ackOfficer" placeholder="Name of the officer" />
</div>
<div class="ke-modal-f">
<button class="ke-btn ke-btn--ghost" (click)="ackOpen = false">Cancel</button>
<button class="ke-btn ke-btn--primary" (click)="saveAck()">Mark contacted</button>
</div>
</div>
</div>
}
@if (toastVisible) {
<div class="ke-toast">{{ toastMsg }}</div>
}
@@ -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;
}
+431
View File
@@ -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);
}
}
+328
View File
@@ -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<KycExpiryStage, KycExpiryStageMeta> = {
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<number, string> = { 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<KycExpiryStage, number> = {
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
);
}
@@ -8,12 +8,12 @@
@if (loading) {
<div class="cert-empty">
<fa-icon icon="spinner" [spin]="true" size="2x" style="color: #7c3aed; margin-bottom: 12px;"></fa-icon>
<fa-icon icon="spinner" [spin]="true" size="2x" style="color: #7c3aed; margin-bottom: 12px"></fa-icon>
<div>Loading live declarations and accounts from Fineract…</div>
</div>
} @else if (loadError) {
<div class="cert-empty" style="color: #ef4444;">
<fa-icon icon="exclamation-circle" size="2x" style="margin-bottom: 12px;"></fa-icon>
<div class="cert-empty" style="color: #ef4444">
<fa-icon icon="exclamation-circle" size="2x" style="margin-bottom: 12px"></fa-icon>
<div>Could not load real data from Fineract. Check connection.</div>
</div>
} @else {
@@ -139,7 +139,9 @@
<tr [class.cert-row--warn]="!row.row.eligibility.eligible">
<td>
<div class="cert-cust-name">{{ row.row.record.customerName }}</div>
<div class="mono small muted">{{ row.row.record.customerId }} · PAN {{ row.row.record.customerPan || '—' }}</div>
<div class="mono small muted">
{{ row.row.record.customerId }} · PAN {{ row.row.record.customerPan || '—' }}
</div>
<div class="small muted">DOB {{ row.row.record.dob | date: 'd MMM y' }}</div>
</td>
<td>
@@ -306,8 +308,8 @@
@if (row.row.schedule.shieldedInterest > 0) {
<div class="small muted">
₹{{ 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.
</div>
}
</td>
@@ -64,7 +64,11 @@ export interface RealForm15ghRow {
/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */
function toIso(value: unknown): string | null {
if (Array.isArray(value) && value.length >= 3) {
const [y, m, d] = value as number[];
const [
y,
m,
d
] = value as number[];
return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`;
}
if (typeof value === 'string' && value.trim()) {
@@ -118,7 +122,10 @@ export class RealForm15ghViewComponent implements OnInit, OnDestroy {
private clientKycCache = new Map<string, Observable<any>>();
private clientAccountsCache = new Map<string, Observable<any>>();
readonly availableFys = ['FY 2026-27', 'FY 2025-26'];
readonly availableFys = [
'FY 2026-27',
'FY 2025-26'
];
ngOnInit(): void {
this.loadRealData();
@@ -311,9 +318,11 @@ export class RealForm15ghViewComponent implements OnInit, OnDestroy {
depositPrincipal,
fy: this.selectedFy,
status,
filedOn: hasDeclaration ? toIso(kyc?.submittedOnDate) ?? `${fy.slice(0, 4)}-04-01` : undefined,
filedOn: hasDeclaration ? (toIso(kyc?.submittedOnDate) ?? `${fy.slice(0, 4)}-04-01`) : undefined,
assessed: true,
interestPostingPeriodType: detailsList.find(d => d.depositType?.code?.includes('fixedDeposit'))?.interestPostingPeriodType?.code || 'savingsPostingInterestPeriodType.quarterly'
interestPostingPeriodType:
detailsList.find((d) => d.depositType?.code?.includes('fixedDeposit'))?.interestPostingPeriodType?.code ||
'savingsPostingInterestPeriodType.quarterly'
};
}
@@ -226,7 +226,11 @@ export function uinTaxYearPart(fy: string): string {
*/
export function buildUin(sequence: number, fy: string, tan: string): string {
const digits = `${Math.max(0, Math.floor(sequence))}`.slice(-9).padStart(9, '0');
const normalisedTan = (tan || '').toUpperCase().replace(/[^A-Z0-9]/g, '').padEnd(10, 'X').slice(0, 10);
const normalisedTan = (tan || '')
.toUpperCase()
.replace(/[^A-Z0-9]/g, '')
.padEnd(10, 'X')
.slice(0, 10);
return `D${digits}${uinTaxYearPart(fy)}${normalisedTan}`;
}
@@ -12,9 +12,7 @@
<fa-icon icon="book" class="cert-head-ic"></fa-icon>
<div>
<h2 class="cert-head-t">Membership Register</h2>
<div class="cert-head-s">
Statutory register of members — one line per member, in order of admission.
</div>
<div class="cert-head-s">Statutory register of members — one line per member, in order of admission.</div>
</div>
</div>
<div class="cert-head-r">
@@ -98,9 +96,7 @@
<input type="date" [(ngModel)]="toDate" />
</div>
<button class="cert-clear" (click)="clearFilters()">
<fa-icon icon="sync"></fa-icon> Clear
</button>
<button class="cert-clear" (click)="clearFilters()"><fa-icon icon="sync"></fa-icon> Clear</button>
</div>
<!-- Register -->
@@ -109,9 +109,7 @@
<input type="date" [(ngModel)]="toDate" />
</div>
<button class="cert-clear" (click)="clearFilters()">
<fa-icon icon="sync"></fa-icon> Clear
</button>
<button class="cert-clear" (click)="clearFilters()"><fa-icon icon="sync"></fa-icon> Clear</button>
</div>
<!-- Report -->
@@ -191,7 +189,9 @@
<span class="cert-pill" [class]="'sar-pill--' + row.status.toLowerCase()">{{ row.statusLabel }}</span>
</td>
<td>
<span class="cert-pill" [class]="'sar-pay--' + row.status.toLowerCase()">{{ row.paymentStatus }}</span>
<span class="cert-pill" [class]="'sar-pay--' + row.status.toLowerCase()">{{
row.paymentStatus
}}</span>
</td>
<td class="cert-row-actions">
<fa-icon icon="chevron-right" class="sar-chevron"></fa-icon>
@@ -105,9 +105,7 @@ export class ShareAllotmentReportComponent implements OnInit {
if (!items.length) {
return of([] as AllotmentRow[]);
}
return forkJoin(items.map((item) => this.buildAllotments(item))).pipe(
map((perAccount) => perAccount.flat())
);
return forkJoin(items.map((item) => this.buildAllotments(item))).pipe(map((perAccount) => perAccount.flat()));
}),
catchError(() => {
this.loadError = true;
@@ -100,9 +100,7 @@
</select>
</div>
<button class="cert-clear" (click)="clearFilters()">
<fa-icon icon="sync"></fa-icon> Clear
</button>
<button class="cert-clear" (click)="clearFilters()"><fa-icon icon="sync"></fa-icon> Clear</button>
</div>
<!-- Register -->
@@ -26,6 +26,7 @@ import {
MatRowDef,
MatRow
} from '@angular/material/table';
import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service';
import { ChargesFilterPipe } from '../../../../pipes/charges-filter.pipe';
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -55,6 +56,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class CurrentProductChargesStepComponent implements OnInit {
dialog = inject(MatDialog);
private translateService = inject(TranslateService);
private facilityChargeFilter = inject(FacilityChargeFilterService);
@Input() CurrentProductsTemplate: any;
@Input() currencyCode: UntypedFormControl;
@@ -73,7 +75,9 @@ export class CurrentProductChargesStepComponent implements OnInit {
pristine = true;
ngOnInit() {
this.chargeData = this.CurrentProductsTemplate.chargeOptions;
this.facilityChargeFilter
.excludeFacilityCharges(this.CurrentProductsTemplate.chargeOptions)
.subscribe((charges: any[]) => (this.chargeData = charges));
this.chargesDataSource = this.CurrentProductsTemplate.charges || [];
this.pristine = true;
@@ -174,21 +174,6 @@
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-arrow-right-arrow-left"></i>
Withdrawal Facility
</span>
<select
class="spl-select"
[(ngModel)]="accountFacilityConfig.withdrawalFacility"
(ngModelChange)="markAsDirty()"
>
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-id-card"></i>
@@ -24,7 +24,6 @@ type BooleanFeatureKey =
| 'neftFacility'
| 'rtgsFacility'
| 'interBankTotalCapFacility'
| 'withdrawalFacility'
| 'depositFacilityKyc'
| 'virtualDebitCard';
@@ -76,7 +75,6 @@ export class CurrentProductLimitsStepComponent implements OnChanges {
neftFacility: true,
rtgsFacility: true,
interBankTotalCapFacility: true,
withdrawalFacility: true,
depositFacilityKyc: true,
virtualDebitCard: false
};
@@ -94,7 +94,6 @@ export class CurrentProductPreviewStepComponent implements OnInit, OnChanges {
{ key: 'neftFacility', label: 'NEFT Facility' },
{ key: 'rtgsFacility', label: 'RTGS Facility' },
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
];
@@ -11,6 +11,7 @@ import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DORMANCY_DEFAULTS } from '../../../dormancy-defaults';
@Component({
selector: 'mifosx-current-product-settings-step',
@@ -62,9 +63,15 @@ export class CurrentProductSettingsStepComponent implements OnInit {
? (this.CurrentProductsTemplate.accountClosedZeroBalanceInDayClosing ?? false)
: false,
isDormancyTrackingActive: isEditing ? (this.CurrentProductsTemplate.isDormancyTrackingActive ?? true) : true,
daysToInactive: isEditing ? this.CurrentProductsTemplate.daysToInactive || 30 : 30,
daysToDormancy: isEditing ? this.CurrentProductsTemplate.daysToDormancy || 60 : 60,
daysToEscheat: isEditing ? this.CurrentProductsTemplate.daysToEscheat || 90 : 90,
daysToInactive: isEditing
? this.CurrentProductsTemplate.daysToInactive || DORMANCY_DEFAULTS.daysToInactive
: DORMANCY_DEFAULTS.daysToInactive,
daysToDormancy: isEditing
? this.CurrentProductsTemplate.daysToDormancy || DORMANCY_DEFAULTS.daysToDormancy
: DORMANCY_DEFAULTS.daysToDormancy,
daysToEscheat: isEditing
? this.CurrentProductsTemplate.daysToEscheat || DORMANCY_DEFAULTS.daysToEscheat
: DORMANCY_DEFAULTS.daysToEscheat,
// Overdraft fields
allowOverdraft: isEditing ? (this.CurrentProductsTemplate.allowOverdraft ?? false) : false,
overdraftLimit: isEditing ? this.CurrentProductsTemplate.overdraftLimit : '',
@@ -113,9 +120,9 @@ export class CurrentProductSettingsStepComponent implements OnInit {
accountClosedZeroBalanceInDayClosing: [false],
// Dormancy (used internally / by accounting step)
isDormancyTrackingActive: [true],
daysToInactive: [30],
daysToDormancy: [60],
daysToEscheat: [90],
daysToInactive: [DORMANCY_DEFAULTS.daysToInactive],
daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy],
daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat],
// Overdraft (OD) Facility
allowOverdraft: [false],
overdraftLimit: [
@@ -89,7 +89,6 @@ export class CurrentProductGeneralTabComponent {
{ key: 'neftFacility', label: 'NEFT Facility' },
{ key: 'rtgsFacility', label: 'RTGS Facility' },
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
{ key: 'depositFacilityKyc', label: 'Deposit Facility KYC' },
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
];
+32
View File
@@ -0,0 +1,32 @@
/**
* 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/.
*/
/**
* Default dormancy thresholds for a new product, in days from the last customer transaction.
*
* RBI's inoperative-accounts rules count the silence from the last *customer-induced* transaction —
* a deposit or withdrawal the customer made, not interest the bank posts — and set the marks at a
* year, two years and ten:
*
* 365 → inactive (an internal flag; nothing is restricted)
* 730 → inoperative (two years; debits stop, reactivation needs the branch)
* 3650 → escheat (ten years; the balance is due to RBI's DEAF)
*
* These are the values every product stepper starts a new product on, and the backend seeds the
* same numbers (see the 0521 and 0524 changelogs). They live here rather than being repeated in
* each stepper so a bank that revises its policy changes one file, not four.
*
* A product can still be configured away from these — they are defaults, not a ceiling — but a
* materially shorter period is worth questioning: it would make an ordinary account, dormant for a
* few weeks, show as overdue for inactivity.
*/
export const DORMANCY_DEFAULTS = {
daysToInactive: 365,
daysToDormancy: 730,
daysToEscheat: 3650
};
@@ -11,6 +11,7 @@ import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms
import { MatTooltip } from '@angular/material/tooltip';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DORMANCY_DEFAULTS } from '../../../dormancy-defaults';
@Component({
selector: 'mifosx-fixed-deposit-product-currency-step',
@@ -54,9 +55,9 @@ export class FixedDepositProductCurrencyStepComponent implements OnInit {
2,
dormancyAlert: this.fixedDepositProductsTemplate.dormancyAlert ?? false,
isDormancyTrackingActive: this.fixedDepositProductsTemplate.isDormancyTrackingActive ?? false,
daysToInactive: this.fixedDepositProductsTemplate.daysToInactive ?? 30,
daysToDormancy: this.fixedDepositProductsTemplate.daysToDormancy ?? 60,
daysToEscheat: this.fixedDepositProductsTemplate.daysToEscheat ?? 90,
daysToInactive: this.fixedDepositProductsTemplate.daysToInactive ?? DORMANCY_DEFAULTS.daysToInactive,
daysToDormancy: this.fixedDepositProductsTemplate.daysToDormancy ?? DORMANCY_DEFAULTS.daysToDormancy,
daysToEscheat: this.fixedDepositProductsTemplate.daysToEscheat ?? DORMANCY_DEFAULTS.daysToEscheat,
nominationFacility: this.fixedDepositProductsTemplate.nominationFacility ?? false,
autoDebitFacility: this.fixedDepositProductsTemplate.autoDebitFacility ?? false
});
@@ -77,9 +78,9 @@ export class FixedDepositProductCurrencyStepComponent implements OnInit {
],
dormancyAlert: [false],
isDormancyTrackingActive: [false],
daysToInactive: [30],
daysToDormancy: [60],
daysToEscheat: [90],
daysToInactive: [DORMANCY_DEFAULTS.daysToInactive],
daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy],
daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat],
nominationFacility: [false],
autoDebitFacility: [false]
});
+11
View File
@@ -74,6 +74,17 @@ export class ProductsService {
return this.http.put(`/savingsproducts/${savingProductId}`, savingProduct);
}
/**
* Deletes a savings product.
*
* The server refuses this once any account has been opened on the product — a product with a
* history behind it cannot be removed without taking those accounts' terms with it — so this is
* really only usable on one created by mistake or for a trial.
*/
deleteSavingProduct(savingProductId: string): Observable<any> {
return this.http.delete(`/savingsproducts/${savingProductId}`);
}
/**
* Submitting a savings product update (above) puts it in the pending-approval temp table instead of
* applying it directly. These calls drive the Approvals -> Pending Savings Product Edits screen.
@@ -11,6 +11,7 @@ import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule }
import { MatTooltip } from '@angular/material/tooltip';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DORMANCY_DEFAULTS } from '../../../dormancy-defaults';
@Component({
selector: 'mifosx-recurring-deposit-product-currency-step',
@@ -54,9 +55,9 @@ export class RecurringDepositProductCurrencyStepComponent implements OnInit {
2,
dormancyAlert: this.recurringDepositProductsTemplate.dormancyAlert ?? false,
isDormancyTrackingActive: this.recurringDepositProductsTemplate.isDormancyTrackingActive ?? false,
daysToInactive: this.recurringDepositProductsTemplate.daysToInactive ?? 30,
daysToDormancy: this.recurringDepositProductsTemplate.daysToDormancy ?? 60,
daysToEscheat: this.recurringDepositProductsTemplate.daysToEscheat ?? 90,
daysToInactive: this.recurringDepositProductsTemplate.daysToInactive ?? DORMANCY_DEFAULTS.daysToInactive,
daysToDormancy: this.recurringDepositProductsTemplate.daysToDormancy ?? DORMANCY_DEFAULTS.daysToDormancy,
daysToEscheat: this.recurringDepositProductsTemplate.daysToEscheat ?? DORMANCY_DEFAULTS.daysToEscheat,
nominationFacility: this.recurringDepositProductsTemplate.nominationFacility ?? false,
autoDebitFacility: this.recurringDepositProductsTemplate.autoDebitFacility ?? false
});
@@ -77,9 +78,9 @@ export class RecurringDepositProductCurrencyStepComponent implements OnInit {
],
dormancyAlert: [false],
isDormancyTrackingActive: [false],
daysToInactive: [30],
daysToDormancy: [60],
daysToEscheat: [90],
daysToInactive: [DORMANCY_DEFAULTS.daysToInactive],
daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy],
daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat],
nominationFacility: [false],
autoDebitFacility: [false]
});
@@ -100,7 +100,7 @@
<mifosx-saving-product-accounting-step
[savingProductsTemplate]="savingProductsTemplate"
[accountingRuleData]="accountingRuleData"
[isDormancyTrackingActive]="savingProductSettingsForm.get('isDormancyTrackingActive')"
[isDormancyTrackingActive]="savingProductCurrencyForm.get('dormancyAlert')"
[savingProductFormValid]="savingProductFormValid"
[allowOverdraft]="savingProductSettingsForm.get('allowOverdraft')"
></mifosx-saving-product-accounting-step>
@@ -100,7 +100,7 @@
<mifosx-saving-product-accounting-step
[savingProductsTemplate]="savingProductAndTemplate"
[accountingRuleData]="accountingRuleData"
[isDormancyTrackingActive]="savingProductSettingsForm.get('isDormancyTrackingActive')"
[isDormancyTrackingActive]="savingProductCurrencyForm.get('dormancyAlert')"
[savingProductFormValid]="savingProductFormValidAndNotPristine"
[allowOverdraft]="savingProductSettingsForm.get('allowOverdraft')"
></mifosx-saving-product-accounting-step>
@@ -24,6 +24,12 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
type AccountingSectionKey = 'assets' | 'liabilities' | 'expenses' | 'income';
/** Reserved otherGlMappings account names backing the two dedicated savings-interest GL rows.
* They round trip through otherGlMappings because the backend persists only the core mappings
* under CASH_BASED accounting, which is the only rule this screen offers. */
const SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME = 'Savings Interest Payable';
const SAVINGS_INTEREST_PAID_ACCOUNT_NAME = 'Savings Interest Paid';
type AccountingRowConfig = {
key: string;
label: string;
@@ -180,13 +186,9 @@ export class SavingProductAccountingStepComponent implements OnInit {
this.savingProductAccountingForm.patchValue({
savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '',
savingsControlAccountId: mappings.savingsControlAccount?.id || '',
transfersInSuspenseAccountId: mappings.transfersInSuspenseAccount?.id || '',
interestOnSavingsAccountId: mappings.interestOnSavingsAccount?.id || '',
incomeFromFeeAccountId: mappings.incomeFromFeeAccount?.id || '',
incomeFromPenaltyAccountId: mappings.incomeFromPenaltyAccount?.id || '',
incomeFromInterestId: mappings.incomeFromInterest?.id || '',
overdraftPortfolioControlId: mappings.overdraftPortfolioControl?.id || '',
writeOffAccountId: mappings.writeOffAccount?.id || '',
feesReceivableAccountId: mappings.feeReceivableAccount?.id || '',
penaltiesReceivableAccountId: mappings.penaltyReceivableAccount?.id || '',
interestPayableAccountId: mappings.interestPayableAccount?.id || '',
@@ -200,10 +202,24 @@ export class SavingProductAccountingStepComponent implements OnInit {
this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions);
this.setOtherGlMappings(
'liabilitiesOtherGlMappings',
glMappings?.liabilities || [],
this.extractNamedGlMapping(
glMappings?.liabilities || [],
SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME,
'savingsInterestPayableAccountId',
this.liabilityAccountOptions
),
this.liabilityAccountOptions
);
this.setOtherGlMappings('expensesOtherGlMappings', glMappings?.expenses || [], this.expenseAccountOptions);
this.setOtherGlMappings(
'expensesOtherGlMappings',
this.extractNamedGlMapping(
glMappings?.expenses || [],
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
'savingsInterestPaidAccountId',
this.expenseAccountOptions
),
this.expenseAccountOptions
);
this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions);
}
}
@@ -225,6 +241,22 @@ export class SavingProductAccountingStepComponent implements OnInit {
return this.savingProductAccountingForm.get(controlName) as UntypedFormArray;
}
/** Pulls a reserved entry out of a saved otherGlMappings list into its own dedicated control and
* returns the remaining entries, so it renders as a named row rather than a free-form "Other" row. */
private extractNamedGlMapping(
otherGlMappings: any[],
accountName: string,
controlName: string,
options: GLAccount[]
): any[] {
const match = (otherGlMappings || []).find((mapping) => mapping?.accountName === accountName);
if (match) {
const glAccountId = match.glAccountId || options.find((option) => option.glCode === match.glCode)?.id || '';
this.savingProductAccountingForm.get(controlName)?.patchValue(glAccountId);
}
return (otherGlMappings || []).filter((mapping) => mapping?.accountName !== accountName);
}
private setOtherGlMappings(controlName: string, otherGlMappings: any[], options: GLAccount[]) {
const otherGlMappingsArray = this.getOtherGlMappingsArray(controlName);
otherGlMappingsArray.clear();
@@ -282,10 +314,35 @@ export class SavingProductAccountingStepComponent implements OnInit {
}))
.filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode);
/** The two named-but-not-backend-persisted rows are emitted as reserved otherGlMappings entries so
* they survive a save/reload round trip under Cash accounting. */
const namedAsOtherGlMapping = (controlName: string, accountName: string, options: GLAccount[]) => {
const glAccountId = this.savingProductAccountingForm.get(controlName)?.value;
if (!glAccountId) {
return [];
}
const glCode = options.find((option) => option.id === glAccountId)?.glCode || '';
return glCode ? [{ accountName, glCode }] : [];
};
const otherGlMappings = {
assets: mapOtherGlMappings('assetsOtherGlMappings', this.assetAccountOptions),
liabilities: mapOtherGlMappings('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
expenses: mapOtherGlMappings('expensesOtherGlMappings', this.expenseAccountOptions),
liabilities: [
...namedAsOtherGlMapping(
'savingsInterestPayableAccountId',
SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME,
this.liabilityAccountOptions
),
...mapOtherGlMappings('liabilitiesOtherGlMappings', this.liabilityAccountOptions)
],
expenses: [
...namedAsOtherGlMapping(
'savingsInterestPaidAccountId',
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
this.expenseAccountOptions
),
...mapOtherGlMappings('expensesOtherGlMappings', this.expenseAccountOptions)
],
income: mapOtherGlMappings('incomeOtherGlMappings', this.incomeAccountOptions)
};
@@ -320,13 +377,21 @@ export class SavingProductAccountingStepComponent implements OnInit {
if (this.accounting.isCashOrAccrualAccountingRuleId(accountingRule)) {
addControl('savingsReferenceAccountId', true);
addControl('savingsControlAccountId', true);
addControl('transfersInSuspenseAccountId', true);
addControl('interestOnSavingsAccountId', true);
addControl('incomeFromFeeAccountId', true);
addControl('incomeFromPenaltyAccountId', true);
addControl('incomeFromInterestId', true);
addControl('writeOffAccountId', true);
addControl('overdraftPortfolioControlId', false);
// Savings Interest Payable / Paid are offered under Cash too, but the backend only persists the
// core mappings for CASH_BASED, so these two ride along in otherGlMappings (see getOtherGlMappings)
// rather than as named accounting params, which would be silently dropped. Optional by design.
addControl('savingsInterestPayableAccountId', false);
addControl('savingsInterestPaidAccountId', false);
// Transfers In Suspense / Income From Interest / Write Off / Overdraft Portfolio Control are
// intentionally not offered on this screen, so their controls are never added — leaving them in
// with required validators would keep the form invalid with no visible field to fix.
removeControl('transfersInSuspenseAccountId');
removeControl('incomeFromInterestId');
removeControl('writeOffAccountId');
removeControl('overdraftPortfolioControlId');
if (this.accounting.isAccrualAccountingRuleId(accountingRule)) {
addControl('feesReceivableAccountId', true);
@@ -360,6 +425,8 @@ export class SavingProductAccountingStepComponent implements OnInit {
removeControl('interestPayableAccountId');
removeControl('interestReceivableAccountId');
removeControl('escheatLiabilityId');
removeControl('savingsInterestPayableAccountId');
removeControl('savingsInterestPaidAccountId');
this.clearOtherGlMappings();
}
}
@@ -370,6 +437,9 @@ export class SavingProductAccountingStepComponent implements OnInit {
liabilitiesOtherGlMappings,
expensesOtherGlMappings,
incomeOtherGlMappings,
// Carried inside otherGlMappings instead — the backend rejects unknown top-level accounting params.
savingsInterestPayableAccountId,
savingsInterestPaidAccountId,
...savingProductAccounting
} = this.savingProductAccountingForm.value;
const otherGlMappings = this.getOtherGlMappings();
@@ -404,13 +474,6 @@ export class SavingProductAccountingStepComponent implements OnInit {
options: this.assetAccountOptions,
required: true
},
{
key: 'overdraftPortfolioControlId',
label: 'Overdraft Portfolio Control',
inputLabel: 'Overdraft portfolio control',
controlName: 'overdraftPortfolioControlId',
options: this.assetAccountOptions
},
{
key: 'interestReceivableAccountId',
label: 'Interest Receivable Account',
@@ -454,12 +517,11 @@ export class SavingProductAccountingStepComponent implements OnInit {
required: true
},
{
key: 'transfersInSuspenseAccountId',
label: 'Transfers In Suspense Account',
inputLabel: 'Transfers in suspense account',
controlName: 'transfersInSuspenseAccountId',
options: this.liabilityAccountOptions,
required: true
key: 'savingsInterestPayableAccountId',
label: 'Savings Interest Payable',
inputLabel: 'Savings interest payable',
controlName: 'savingsInterestPayableAccountId',
options: this.liabilityAccountOptions
},
{
key: 'interestPayableAccountId',
@@ -496,12 +558,11 @@ export class SavingProductAccountingStepComponent implements OnInit {
required: true
},
{
key: 'writeOffAccountId',
label: 'Write Off Account',
inputLabel: 'Write off account',
controlName: 'writeOffAccountId',
options: this.expenseAccountOptions,
required: true
key: 'savingsInterestPaidAccountId',
label: 'Savings Interest Paid',
inputLabel: 'Savings interest paid',
controlName: 'savingsInterestPaidAccountId',
options: this.expenseAccountOptions
}
],
otherMappingsControlName: 'expensesOtherGlMappings',
@@ -528,14 +589,6 @@ export class SavingProductAccountingStepComponent implements OnInit {
controlName: 'incomeFromPenaltyAccountId',
options: this.incomeAccountOptions,
required: true
},
{
key: 'incomeFromInterestId',
label: 'Income From Interest',
inputLabel: 'Income from interest',
controlName: 'incomeFromInterestId',
options: this.incomeAccountOptions,
required: true
}
],
otherMappingsControlName: 'incomeOtherGlMappings',
@@ -26,6 +26,7 @@ import {
MatRowDef,
MatRow
} from '@angular/material/table';
import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service';
import { ChargesFilterPipe } from '../../../../pipes/charges-filter.pipe';
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -55,6 +56,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class SavingProductChargesStepComponent implements OnInit {
dialog = inject(MatDialog);
private translateService = inject(TranslateService);
private facilityChargeFilter = inject(FacilityChargeFilterService);
@Input() savingProductsTemplate: any;
@Input() currencyCode: UntypedFormControl;
@@ -73,8 +75,14 @@ export class SavingProductChargesStepComponent implements OnInit {
pristine = true;
ngOnInit() {
this.chargeData = this.savingProductsTemplate.chargeOptions;
// Facility-priced charges (Cheque Book, ATM, UPI, ...) are levied by the facility mapping itself,
// so they must not be offered here as well — see FacilityChargeFilterService.
this.facilityChargeFilter
.excludeFacilityCharges(this.savingProductsTemplate.chargeOptions)
.subscribe((charges: any[]) => (this.chargeData = charges));
// Already-attached charges are shown as-is: filtering them here would silently drop them
// from the payload on save.
this.chargesDataSource = this.savingProductsTemplate.charges || [];
this.pristine = true;
@@ -63,6 +63,97 @@
</mat-radio-group>
</div>
<!--
The periods that will apply to every account opened on this product, shown as soon as
tracking is switched on. Read-only until asked for: these are the RBI periods, and a product
should get them unless somebody deliberately departs from them.
-->
@if (dormancyTrackingOn) {
<div class="spc-dorm">
<div class="spc-dorm__head">
<div>
<h4>Dormancy Periods</h4>
<p>
Counted from the last customer transaction — a deposit or withdrawal the customer makes. Interest the
bank posts does not reset the clock.
</p>
</div>
<div class="spc-dorm__actions">
@if (editingDormancyDays) {
<button type="button" class="spc-dorm__btn" (click)="resetDormancyDays()">
<fa-icon icon="undo"></fa-icon> RBI defaults
</button>
}
<button type="button" class="spc-dorm__btn" (click)="toggleDormancyDaysEdit()">
<fa-icon [icon]="editingDormancyDays ? 'times' : 'edit'"></fa-icon>
{{ editingDormancyDays ? 'Cancel' : 'Edit' }}
</button>
</div>
</div>
<div class="spc-dorm__grid">
<div class="spc-dorm__item">
<span class="spc-dorm__label">Inactive after</span>
@if (editingDormancyDays) {
<mat-form-field appearance="outline" class="spc-dorm__field">
<input matInput type="number" min="1" formControlName="daysToInactive" />
<span matSuffix>days</span>
</mat-form-field>
} @else {
<span class="spc-dorm__value">
{{ savingProductCurrencyForm.value.daysToInactive }} <small>days</small>
</span>
}
<span class="spc-dorm__note">Flagged internally. Nothing is restricted.</span>
</div>
<div class="spc-dorm__item">
<span class="spc-dorm__label">Dormant after</span>
@if (editingDormancyDays) {
<mat-form-field appearance="outline" class="spc-dorm__field">
<input matInput type="number" min="1" formControlName="daysToDormancy" />
<span matSuffix>days</span>
</mat-form-field>
} @else {
<span class="spc-dorm__value">
{{ savingProductCurrencyForm.value.daysToDormancy }} <small>days</small>
</span>
}
<span class="spc-dorm__note">Debits stop. Credits still land.</span>
</div>
<div class="spc-dorm__item">
<span class="spc-dorm__label">Escheat after</span>
@if (editingDormancyDays) {
<mat-form-field appearance="outline" class="spc-dorm__field">
<input matInput type="number" min="1" formControlName="daysToEscheat" />
<span matSuffix>days</span>
</mat-form-field>
} @else {
<span class="spc-dorm__value">
{{ savingProductCurrencyForm.value.daysToEscheat }} <small>days</small>
</span>
}
<span class="spc-dorm__note">Balance becomes due to RBI's DEAF.</span>
</div>
</div>
@if (dormancyDaysOutOfOrder) {
<div class="spc-dorm__error">
<fa-icon icon="exclamation-triangle"></fa-icon>
The periods have to run in order: dormant later than inactive, escheat later than dormant. An account
cannot reach a later stage before an earlier one.
</div>
}
<div class="spc-dorm__foot">
<fa-icon icon="info-circle"></fa-icon>
Accounts opened on this product inherit these periods. An account that receives government benefit credits
can be exempted individually, and is then never restricted for inactivity.
</div>
</div>
}
<div class="spc-alert">
<div class="spc-alert__content">
<h4>Nomination Facility</h4>
@@ -112,3 +112,133 @@ $gray-900: #202124;
flex-wrap: wrap;
}
}
/* ── Dormancy periods, shown once tracking is switched on ─────────────────── */
.spc-dorm {
margin-top: 1rem;
padding: 1rem 1.15rem;
border: 1px solid rgb(0 0 0 / 12%);
border-left: 3px solid #1565c0;
border-radius: 6px;
background: rgb(21 101 192 / 3%);
}
.spc-dorm__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
h4 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
}
p {
margin: 0.2rem 0 0;
font-size: 0.78rem;
opacity: 0.7;
max-width: 34rem;
line-height: 1.45;
}
}
.spc-dorm__actions {
display: flex;
gap: 0.4rem;
flex-shrink: 0;
}
.spc-dorm__btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.7rem;
border: 1px solid rgb(0 0 0 / 20%);
border-radius: 4px;
background: #fff;
font: inherit;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
&:hover {
border-color: #1565c0;
color: #1565c0;
}
}
.spc-dorm__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 0.9rem;
margin-top: 0.9rem;
}
.spc-dorm__item {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.spc-dorm__label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
opacity: 0.6;
}
.spc-dorm__value {
font-size: 1.35rem;
font-weight: 700;
line-height: 1.2;
small {
font-size: 0.75rem;
font-weight: 500;
opacity: 0.6;
}
}
.spc-dorm__field {
width: 100%;
/* The stepper's fields are roomier than this panel needs. */
::ng-deep .mat-mdc-form-field-subscript-wrapper {
display: none;
}
}
.spc-dorm__note {
font-size: 0.72rem;
opacity: 0.65;
line-height: 1.35;
}
/* Periods that would have an account reach a later stage before an earlier one. */
.spc-dorm__error {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
padding: 0.5rem 0.7rem;
border-radius: 4px;
background: rgb(192 57 43 / 9%);
color: #c0392b;
font-size: 0.78rem;
line-height: 1.45;
}
.spc-dorm__foot {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.6rem;
border-top: 1px solid rgb(0 0 0 / 8%);
font-size: 0.75rem;
opacity: 0.7;
line-height: 1.45;
}
@@ -12,6 +12,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DORMANCY_DEFAULTS } from '../../../dormancy-defaults';
@Component({
selector: 'mifosx-saving-product-currency-step',
@@ -50,10 +51,68 @@ export class SavingProductCurrencyStepComponent implements OnInit {
currencyCode: this.savingProductsTemplate.currency?.code || this.currencyData?.[0]?.code || '',
digitsAfterDecimal: this.savingProductsTemplate.digitsAfterDecimal ?? 2,
dormancyAlert: this.savingProductsTemplate.isDormancyTrackingActive ?? false,
nominationFacility: this.savingProductsTemplate.nominationFacility ?? false
nominationFacility: this.savingProductsTemplate.nominationFacility ?? false,
daysToInactive: this.savingProductsTemplate.daysToInactive ?? DORMANCY_DEFAULTS.daysToInactive,
daysToDormancy: this.savingProductsTemplate.daysToDormancy ?? DORMANCY_DEFAULTS.daysToDormancy,
daysToEscheat: this.savingProductsTemplate.daysToEscheat ?? DORMANCY_DEFAULTS.daysToEscheat
});
}
/**
* Whether the periods are open for editing.
*
* They start read-only and showing the RBI defaults. A bank that wants its own numbers has to ask
* for them, which keeps the regulatory periods as what a product gets unless somebody deliberately
* departs from them.
*/
editingDormancyDays = false;
get dormancyTrackingOn(): boolean {
return !!this.savingProductCurrencyForm.get('dormancyAlert')?.value;
}
toggleDormancyDaysEdit(): void {
this.editingDormancyDays = !this.editingDormancyDays;
if (!this.editingDormancyDays) {
// Leaving edit mode puts back whatever the product had, so a half-typed number is not kept.
this.savingProductCurrencyForm.patchValue({
daysToInactive: this.savingProductsTemplate.daysToInactive ?? DORMANCY_DEFAULTS.daysToInactive,
daysToDormancy: this.savingProductsTemplate.daysToDormancy ?? DORMANCY_DEFAULTS.daysToDormancy,
daysToEscheat: this.savingProductsTemplate.daysToEscheat ?? DORMANCY_DEFAULTS.daysToEscheat
});
}
}
/** Puts the RBI periods back, for a product that was edited away from them. */
resetDormancyDays(): void {
this.savingProductCurrencyForm.patchValue({
daysToInactive: DORMANCY_DEFAULTS.daysToInactive,
daysToDormancy: DORMANCY_DEFAULTS.daysToDormancy,
daysToEscheat: DORMANCY_DEFAULTS.daysToEscheat
});
}
/**
* True when the periods no longer run inactive → dormant → escheat in order.
*
* A dormant threshold at or before the inactive one would have an account reach the later stage
* first, which the job cannot act on sensibly, so the step says so rather than saving it.
*/
get dormancyDaysOutOfOrder(): boolean {
const v = this.savingProductCurrencyForm.value;
const inactive = Number(v.daysToInactive);
const dormant = Number(v.daysToDormancy);
const escheat = Number(v.daysToEscheat);
if ([
inactive,
dormant,
escheat
].some((n) => !Number.isFinite(n) || n <= 0)) {
return false;
}
return dormant <= inactive || escheat <= dormant;
}
createSavingProductCurrencyForm() {
this.savingProductCurrencyForm = this.formBuilder.group({
currencyCode: [
@@ -68,7 +127,21 @@ export class SavingProductCurrencyStepComponent implements OnInit {
]
],
dormancyAlert: [false],
nominationFacility: [false]
nominationFacility: [false],
// The RBI periods, counted from the last customer transaction. Editable, but a product gets
// these unless somebody deliberately changes them.
daysToInactive: [
DORMANCY_DEFAULTS.daysToInactive,
Validators.min(1)
],
daysToDormancy: [
DORMANCY_DEFAULTS.daysToDormancy,
Validators.min(1)
],
daysToEscheat: [
DORMANCY_DEFAULTS.daysToEscheat,
Validators.min(1)
]
});
}
@@ -82,6 +155,14 @@ export class SavingProductCurrencyStepComponent implements OnInit {
nominationFacility: formValue.nominationFacility
};
// The periods only mean anything while tracking is on; sending them with it off would store
// thresholds against a product nothing acts on.
if (formValue.dormancyAlert) {
result.daysToInactive = formValue.daysToInactive;
result.daysToDormancy = formValue.daysToDormancy;
result.daysToEscheat = formValue.daysToEscheat;
}
return result;
}
}
@@ -174,21 +174,6 @@
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-arrow-right-arrow-left"></i>
Withdrawal Facility
</span>
<select
class="spl-select"
[(ngModel)]="accountFacilityConfig.withdrawalFacility"
(ngModelChange)="markAsDirty()"
>
<option [ngValue]="false">No</option>
<option [ngValue]="true">Yes</option>
</select>
</label>
<label class="spl-feature">
<span class="spl-feature__label">
<i class="fas fa-id-card"></i>
@@ -24,7 +24,6 @@ type BooleanFeatureKey =
| 'neftFacility'
| 'rtgsFacility'
| 'interBankTotalCapFacility'
| 'withdrawalFacility'
| 'depositFacilityKyc'
| 'virtualDebitCard';
@@ -76,7 +75,6 @@ export class SavingProductLimitsStepComponent implements OnChanges {
neftFacility: true,
rtgsFacility: true,
interBankTotalCapFacility: true,
withdrawalFacility: true,
depositFacilityKyc: true,
virtualDebitCard: false
};
@@ -475,42 +475,35 @@
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Overdraft portfolio control</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.overdraftPortfolioControlId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Interest receivable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.interestReceivableAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Fees receivable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.feesReceivableAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Penalties receivable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.penaltiesReceivableAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
@if (isAccrualAccounting()) {
<div class="spa-gl-row">
<span class="spa-gl-row__label">Interest receivable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.interestReceivableAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Fees receivable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.feesReceivableAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Penalties receivable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.penaltiesReceivableAccountId,
savingProductsTemplate?.accountingMappingOptions.assetAccountOptions
)
}}</span>
</div>
}
</div>
<div class="spa-gl-section">
@@ -524,24 +517,17 @@
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Transfers in suspense account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.transfersInSuspenseAccountId,
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Interest payable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.interestPayableAccountId,
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
)
}}</span>
</div>
@if (isAccrualAccounting()) {
<div class="spa-gl-row">
<span class="spa-gl-row__label">Interest payable account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.interestPayableAccountId,
savingProductsTemplate?.accountingMappingOptions.liabilityAccountOptions
)
}}</span>
</div>
}
@if (savingProduct.isDormancyTrackingActive) {
<div class="spa-gl-row">
<span class="spa-gl-row__label">Escheat liability account</span>
@@ -566,15 +552,6 @@
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Write off account</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.writeOffAccountId,
savingProductsTemplate?.accountingMappingOptions.expenseAccountOptions
)
}}</span>
</div>
</div>
<div class="spa-gl-section">
@@ -597,15 +574,6 @@
)
}}</span>
</div>
<div class="spa-gl-row">
<span class="spa-gl-row__label">Income from interest</span>
<span class="spa-gl-row__value">{{
formatGlAccountById(
savingProduct.incomeFromInterestId,
savingProductsTemplate?.accountingMappingOptions.incomeAccountOptions
)
}}</span>
</div>
</div>
</div>
}
@@ -94,7 +94,6 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
{ key: 'neftFacility', label: 'NEFT Facility' },
{ key: 'rtgsFacility', label: 'RTGS Facility' },
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
];
@@ -132,23 +131,14 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
this.savingProduct.savingsReferenceAccountId,
assetAccountData
),
overdraftPortfolioControl: this.accounting.glAccountLookUp(
this.savingProduct.overdraftPortfolioControlId,
assetAccountData
),
savingsControlAccount: this.accounting.glAccountLookUp(
this.savingProduct.savingsControlAccountId,
liabilityAccountData
),
transfersInSuspenseAccount: this.accounting.glAccountLookUp(
this.savingProduct.transfersInSuspenseAccountId,
liabilityAccountData
),
interestOnSavingsAccount: this.accounting.glAccountLookUp(
this.savingProduct.interestOnSavingsAccountId,
expenseAccountData
),
writeOffAccount: this.accounting.glAccountLookUp(this.savingProduct.writeOffAccountId, expenseAccountData),
incomeFromFeeAccount: this.accounting.glAccountLookUp(
this.savingProduct.incomeFromFeeAccountId,
incomeAccountData
@@ -156,8 +146,7 @@ export class SavingProductPreviewStepComponent implements OnInit, OnChanges {
incomeFromPenaltyAccount: this.accounting.glAccountLookUp(
this.savingProduct.incomeFromPenaltyAccountId,
incomeAccountData
),
incomeFromInterest: this.accounting.glAccountLookUp(this.savingProduct.incomeFromInterestId, incomeAccountData)
)
};
if (this.isAccrualAccounting()) {
@@ -70,11 +70,7 @@ export class SavingProductSettingsStepComponent implements OnInit {
: this.savingProductsTemplate.lockinPeriodFrequency && this.savingProductsTemplate.lockinPeriodFrequency > 0,
accountClosedZeroBalanceInDayClosing: isEditing
? (this.savingProductsTemplate.accountClosedZeroBalanceInDayClosing ?? false)
: false,
isDormancyTrackingActive: isEditing ? (this.savingProductsTemplate.isDormancyTrackingActive ?? true) : true,
daysToInactive: isEditing ? this.savingProductsTemplate.daysToInactive || 30 : 30,
daysToDormancy: isEditing ? this.savingProductsTemplate.daysToDormancy || 60 : 60,
daysToEscheat: isEditing ? this.savingProductsTemplate.daysToEscheat || 90 : 90
: false
});
if (
@@ -115,12 +111,11 @@ export class SavingProductSettingsStepComponent implements OnInit {
enableLockinPeriod: [false],
lockinPeriodFrequency: [''],
lockinPeriodFrequencyType: [''],
accountClosedZeroBalanceInDayClosing: [false],
// Dormancy tracking fields (required when isDormancyTrackingActive is true)
isDormancyTrackingActive: [true],
daysToInactive: [30],
daysToDormancy: [60],
daysToEscheat: [90]
accountClosedZeroBalanceInDayClosing: [false]
// Dormancy is deliberately absent: it belongs to the Currency step, which is where it is shown
// and edited. It was declared here as well, and because this step is spread into the payload
// after that one, the numbers an officer typed were silently replaced by these untouched
// defaults on the way to the server.
});
}
@@ -86,7 +86,6 @@ export class SavingProductGeneralTabComponent {
{ key: 'neftFacility', label: 'NEFT Facility' },
{ key: 'rtgsFacility', label: 'RTGS Facility' },
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap Facility' },
{ key: 'withdrawalFacility', label: 'Withdrawal Facility' },
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)' },
{ key: 'virtualDebitCard', label: 'Virtual Debit Card' }
];
@@ -76,9 +76,7 @@
<input matInput type="number" step="0.01" min="0.01" max="100" formControlName="ratePercentage" />
<span matSuffix>&nbsp;%&nbsp;</span>
</mat-form-field>
<p class="dd-hint">
Applied to each member's own share investment, pro-rated for how long they held it.
</p>
<p class="dd-hint">Applied to each member's own share investment, pro-rated for how long they held it.</p>
</div>
@if (loadingPreview) {
@@ -248,27 +248,67 @@ export class ViewDividendComponent implements OnInit {
ws.getCell('A1').alignment = { horizontal: 'center' };
const heading: [string, any][] = [
['Share Product', h?.productName ?? ''],
['Financial Year', fy],
['Dividend Period', h ? `${h.periodStartDate} to ${h.periodEndDate}` : ''],
['Dividend Rate (% per annum)', h?.ratePercentage != null ? Number(h.ratePercentage) : 'Not recorded'],
['Declared Amount', h?.declaredAmount != null ? Number(h.declaredAmount) : ''],
['Distributed to Members', h?.allocatedAmount != null ? Number(h.allocatedAmount) : ''],
['Payment Date', h?.paymentDate ? String(h.paymentDate).slice(0, 10) : 'Not yet approved'],
['Status', h?.status === 'APPROVED' ? 'Approved' : 'Awaiting Approval'],
['Members Paid', h?.paidMembers ?? ''],
['Members Below Minimum', h?.unpaidMembers ?? '']
[
'Share Product',
h?.productName ?? ''
],
[
'Financial Year',
fy
],
[
'Dividend Period',
h ? `${h.periodStartDate} to ${h.periodEndDate}` : ''
],
[
'Dividend Rate (% per annum)',
h?.ratePercentage != null ? Number(h.ratePercentage) : 'Not recorded'
],
[
'Declared Amount',
h?.declaredAmount != null ? Number(h.declaredAmount) : ''
],
[
'Distributed to Members',
h?.allocatedAmount != null ? Number(h.allocatedAmount) : ''
],
[
'Payment Date',
h?.paymentDate ? String(h.paymentDate).slice(0, 10) : 'Not yet approved'
],
[
'Status',
h?.status === 'APPROVED' ? 'Approved' : 'Awaiting Approval'
],
[
'Members Paid',
h?.paidMembers ?? ''
],
[
'Members Below Minimum',
h?.unpaidMembers ?? ''
]
];
heading.forEach(([label, value], i) => {
const row = ws.getRow(i + 3);
row.getCell(1).value = label;
row.getCell(1).font = { bold: true };
row.getCell(2).value = value;
});
heading.forEach(
([
label,
value
], i) => {
const row = ws.getRow(i + 3);
row.getCell(1).value = label;
row.getCell(1).font = { bold: true };
row.getCell(2).value = value;
}
);
const tableStart = heading.length + 5;
const header = ws.getRow(tableStart);
['Client Name', 'Share Account', 'Dividend Amount', 'Status'].forEach((t, i) => {
[
'Client Name',
'Share Account',
'Dividend Amount',
'Status'
].forEach((t, i) => {
header.getCell(i + 1).value = t;
header.getCell(i + 1).font = { bold: true };
header.getCell(i + 1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE8ECF0' } };
@@ -286,7 +326,10 @@ export class ViewDividendComponent implements OnInit {
const totalRow = ws.getRow(tableStart + 1 + (this.dataSource?.data?.length ?? 0));
totalRow.getCell(2).value = 'Total';
totalRow.getCell(2).font = { bold: true };
totalRow.getCell(3).value = (this.dataSource?.data ?? []).reduce((s: number, d: any) => s + (Number(d?.amount) || 0), 0);
totalRow.getCell(3).value = (this.dataSource?.data ?? []).reduce(
(s: number, d: any) => s + (Number(d?.amount) || 0),
0
);
totalRow.getCell(3).numFmt = '#,##0.00';
totalRow.getCell(3).font = { bold: true };
@@ -13,9 +13,9 @@
<!-- What the return is for, in the words a supervisor would use. A page titled only with a
form number tells a clerk nothing about what they are about to file. -->
<p>
Membership and share capital as reported to the RBI, per share type — how many members hold
each type, how much capital they have subscribed, and how much of the authorised issue
remains unsold, with the previous financial year alongside for comparison.
Membership and share capital as reported to the RBI, per share type — how many members hold each type, how much
capital they have subscribed, and how much of the authorised issue remains unsold, with the previous financial
year alongside for comparison.
</p>
</div>
<div class="scr-header__actions">
@@ -31,7 +31,12 @@
<fa-icon icon="sync"></fa-icon>
Refresh
</button>
<button type="button" class="scr-btn scr-btn--primary" [disabled]="exporting || !report" (click)="exportToExcel()">
<button
type="button"
class="scr-btn scr-btn--primary"
[disabled]="exporting || !report"
(click)="exportToExcel()"
>
<fa-icon icon="file-excel"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export to Excel' }}
</button>
@@ -46,8 +51,8 @@
<div class="scr-note">Loading the share register…</div>
} @else if (report) {
<div class="scr-period">
Reporting period <strong>{{ report.periodStartDate }}</strong> to
<strong>{{ report.periodEndDate }}</strong> · compared with FY {{ report.previousFinancialYear }}
Reporting period <strong>{{ report.periodStartDate }}</strong> to <strong>{{ report.periodEndDate }}</strong> ·
compared with FY {{ report.previousFinancialYear }}
</div>
<!-- The bottom line first: a supervisor checks the society's totals before reading any single
@@ -154,7 +154,8 @@ export class ShareCapitalReturnComponent implements OnInit {
ws.getCell('A1').alignment = { horizontal: 'center' };
ws.mergeCells('A2:L2');
ws.getCell('A2').value = `Financial Year ${fy} (compared with ${prev}) · Period ${this.report.periodStartDate} to ${this.report.periodEndDate}`;
ws.getCell('A2').value =
`Financial Year ${fy} (compared with ${prev}) · Period ${this.report.periodStartDate} to ${this.report.periodEndDate}`;
ws.getCell('A2').alignment = { horizontal: 'center' };
const headers = [
@@ -215,7 +216,10 @@ export class ShareCapitalReturnComponent implements OnInit {
totalRow.getCell(9).value = t.membersClosingPrev;
totalRow.getCell(12).value = t.capitalClosing;
totalRow.getCell(13).value = t.capitalClosingPrev;
[12, 13].forEach((c) => (totalRow.getCell(c).numFmt = '#,##0.00'));
[
12,
13
].forEach((c) => (totalRow.getCell(c).numFmt = '#,##0.00'));
for (let c = 1; c <= 13; c++) {
totalRow.getCell(c).font = { bold: true };
}
+17
View File
@@ -64,6 +64,23 @@ const routes: Routes = [
loadComponent: () =>
import('../risk-review/risk-review-report.component').then((m) => m.RiskReviewReportComponent)
},
{
path: 'kyc-expiry',
data: { title: 'Re-KYC Expiry & Outreach', breadcrumb: 'Re-KYC Expiry & Outreach' },
loadComponent: () => import('../kyc-expiry/kyc-expiry.component').then((m) => m.KycExpiryComponent)
},
{
path: 'savings-interest',
data: { title: 'Savings Interest Posted', breadcrumb: 'Savings Interest Posted' },
loadComponent: () =>
import('../interest-report/interest-report.component').then((m) => m.InterestReportComponent)
},
{
path: 'dormancy',
data: { title: 'Inactive, Dormant & DEAF', breadcrumb: 'Inactive, Dormant & DEAF' },
loadComponent: () =>
import('../dormancy-report/dormancy-report.component').then((m) => m.DormancyReportComponent)
},
{
path: 'tds-interest',
data: { title: 'Deposit Interest & TDS', breadcrumb: 'Deposit Interest & TDS' },
File diff suppressed because it is too large Load Diff
@@ -27,18 +27,6 @@
</div>
</div>
@if (sampleCount) {
<div class="rr-banner rr-banner--sample">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
<strong>{{ sampleCount }} demonstration rows</strong> are included alongside
{{ hasLive ? 'the live alerts' : 'the live feed, which has raised nothing' }}. Sample rows carry a
<span class="rr-tag">Sample</span> tag in the table and must not be acted on as compliance data.
</span>
<button class="rr-btn rr-btn--slim" (click)="load()"><fa-icon icon="undo"></fa-icon> Reload live feed</button>
</div>
}
<!-- FILTERS -->
<div class="rr-filters">
<div class="rr-filter-row">
@@ -228,148 +216,173 @@
<!-- CUSTOMER WISE -->
@if (view === 'customer') {
<div class="rr-table-wrap">
<table class="rr-table">
<thead>
<tr>
<th></th>
<th>Customer ID</th>
<th>Customer Name</th>
<th>Account Number</th>
<th>Branch</th>
<th>Current Risk</th>
<th class="num">Alerts</th>
<th>AML Rules Triggered</th>
<th class="num">Amount under review</th>
<th>Last Alert</th>
<th>Position</th>
</tr>
</thead>
<tbody>
@for (c of customerRows; track c.clientId) {
<tr
class="rr-cust"
[class.open]="expanded === c.clientId"
[class.rr-row--escalated]="c.escalated"
(click)="toggle(c.clientId)"
>
<td>
<fa-icon [icon]="expanded === c.clientId ? 'caret-down' : 'caret-right'"></fa-icon>
</td>
<td class="mono">{{ c.clientId }}</td>
<td>
<strong>{{ c.customerName }}</strong>
@if (c.openCount > 0) {
<span class="rr-tag">{{ c.openCount }} open</span>
}
</td>
<td class="mono">{{ c.accountNo || '—' }}</td>
<td>{{ c.branchCode || '—' }}</td>
<td>
<span [class]="riskClass(c.currentRisk)">{{ riskText(c.currentRisk) }}</span>
</td>
<td class="num">{{ c.alertCount }}</td>
<td class="rr-rules-cell">
@for (label of c.ruleLabels; track label) {
<span class="rr-rule">{{ label }}</span>
}
</td>
<td class="num strong">{{ c.totalAmount | number: '1.0-0' }}</td>
<td class="mono">{{ c.lastAlertDate }}</td>
<td class="rr-verdict-cell">
<span [class]="verdictClass(c)">{{ verdictText(c) }}</span>
@if (c.directionReason) {
<div class="rr-verdict-why">{{ c.directionReason }}</div>
}
</td>
</tr>
<!-- Grouped by verdict: the section heading answers "what do I do about these?" once, so the
row does not have to repeat it in a Move To and a Position column. -->
@for (g of customerGroups; track g.key) {
<div class="rr-group" [class]="'rr-group--' + g.tone">
<button class="rr-group-head" (click)="toggleGroup(g.key)">
<fa-icon [icon]="collapsedGroups[g.key] ? 'caret-right' : 'caret-down'"></fa-icon>
<span class="rr-group-t">{{ g.title }}</span>
<span class="rr-group-n">{{ g.rows.length }}</span>
<span class="rr-group-h">{{ g.hint }}</span>
</button>
@if (expanded === c.clientId) {
<tr class="rr-detail">
<td colspan="11">
<!-- One block per alert: what the rule is, then the entries it fired on. -->
@for (a of c.alerts; track a.alertId) {
<div class="rr-case">
<div class="rr-case-head">
<span class="rr-rule">{{ a.rule.label }}</span>
<span class="rr-rule-cat">{{ a.rule.category }}</span>
<span class="rr-case-id mono">{{ a.alertId }} · raised {{ a.alertDate }}</span>
<span class="rr-spacer"></span>
<span class="rr-case-amt mono">₹{{ a.amount | number: '1.0-0' }}</span>
<span [class]="statusClass(a.status)">{{ statusMeta[a.status].label }}</span>
</div>
<div class="rr-case-why">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span><strong>Why it triggered:</strong> {{ a.rule.description }} — {{ txnDetail(a) }}</span>
</div>
@if (!collapsedGroups[g.key]) {
<div class="rr-table-wrap">
<table class="rr-table">
<thead>
<tr>
<th></th>
<th>Customer ID</th>
<th>Customer Name</th>
<th>Account Number</th>
<th>Branch</th>
<th>Rating on file</th>
<th class="num">Alerts</th>
<th>AML Rules Triggered</th>
<th class="num">Amount under review</th>
<th>Last Alert</th>
<th>Action</th>
</tr>
</thead>
<tbody>
@for (c of g.rows; track c.clientId) {
<tr
class="rr-cust"
[class.open]="expanded === c.clientId"
[class.rr-row--escalated]="c.escalated"
(click)="toggle(c.clientId)"
>
<td>
<fa-icon [icon]="expanded === c.clientId ? 'caret-down' : 'caret-right'"></fa-icon>
</td>
<td class="mono">{{ c.clientId }}</td>
<td>
<strong>{{ c.customerName }}</strong>
@if (c.openCount > 0) {
<span class="rr-tag">{{ c.openCount }} open</span>
}
</td>
<td class="mono">{{ c.accountNo || '—' }}</td>
<td>{{ c.branchCode || '—' }}</td>
<td>
<span [class]="riskClass(c.currentRisk)">{{ riskText(c.currentRisk) }}</span>
</td>
<td class="num">{{ c.alertCount }}</td>
<td class="rr-rules-cell">
@for (label of c.ruleLabels; track label) {
<span class="rr-rule">{{ label }}</span>
}
</td>
<td class="num strong">{{ c.totalAmount | number: '1.0-0' }}</td>
<td class="mono">{{ c.lastAlertDate }}</td>
<!-- The verdict is the section this row sits in, so the cell only carries the
button that acts on it, and the reason behind it. -->
<td class="rr-verdict-cell">
<button
class="rr-kyc-link"
[class.rr-kyc-link--act]="c.direction !== 'same' && !c.escalated"
(click)="openKyc(c, $event)"
[attr.aria-label]="kycActionText(c) + ' for ' + c.customerName"
>
<fa-icon icon="id-card"></fa-icon> {{ kycActionText(c) }}
</button>
@if (c.directionReason) {
<div class="rr-verdict-why">{{ c.directionReason }}</div>
}
</td>
</tr>
<table class="rr-subtable">
<thead>
<tr>
<th>Txn Date</th>
<th>Txn ID</th>
<th>Channel</th>
<th>Dr/Cr</th>
<th class="num">Amount</th>
<th class="num">Balance After</th>
<th>Narration</th>
<th>Counterparty</th>
<th>AML Rule Triggered</th>
</tr>
</thead>
<tbody>
@for (t of a.txns; track t.txnId) {
<tr [class.rr-leg--ctr]="t.ctrReportable">
<td class="mono">{{ t.date }}</td>
<td class="mono">{{ t.txnId }}</td>
<td>
<span class="rr-chan">{{ channelLabel(t.channel) }}</span>
</td>
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
<td class="num strong">
{{ t.amount | number: '1.0-0' }}
@if (t.ctrReportable) {
<span class="rr-tag rr-tag--ctr">CTR</span>
@if (expanded === c.clientId) {
<tr class="rr-detail">
<td colspan="11">
<!-- One block per alert: what the rule is, then the entries it fired on. -->
@for (a of c.alerts; track a.alertId) {
<div class="rr-case">
<div class="rr-case-head">
<span class="rr-rule">{{ a.rule.label }}</span>
<span class="rr-rule-cat">{{ a.rule.category }}</span>
<span class="rr-case-id mono">{{ a.alertId }} · raised {{ a.alertDate }}</span>
<span class="rr-spacer"></span>
<span class="rr-case-amt mono">₹{{ a.amount | number: '1.0-0' }}</span>
<span [class]="statusClass(a.status)">{{ statusMeta[a.status].label }}</span>
</div>
<div class="rr-case-why">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span
><strong>Why it triggered:</strong> {{ a.rule.description }} —
{{ txnDetail(a) }}</span
>
</div>
<table class="rr-subtable">
<thead>
<tr>
<th>Txn Date</th>
<th>Txn ID</th>
<th>Channel</th>
<th>Dr/Cr</th>
<th class="num">Amount</th>
<th class="num">Balance After</th>
<th>Narration</th>
<th>Counterparty</th>
<th>AML Rule Triggered</th>
</tr>
</thead>
<tbody>
@for (t of a.txns; track t.txnId) {
<tr [class.rr-leg--ctr]="t.ctrReportable">
<td class="mono">{{ t.date }}</td>
<td class="mono">{{ t.txnId }}</td>
<td>
<span class="rr-chan">{{ channelLabel(t.channel) }}</span>
</td>
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
<td class="num strong">
{{ t.amount | number: '1.0-0' }}
@if (t.ctrReportable) {
<span class="rr-tag rr-tag--ctr">CTR</span>
}
</td>
<td class="num">
{{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}
</td>
<td>{{ t.narration || '—' }}</td>
<td>{{ t.counterparty || '—' }}</td>
<td>
<span class="rr-rule">{{ a.rule.label }}</span>
@if (a.txns.length > 1) {
<span class="rr-leg">leg {{ $index + 1 }} of {{ a.txns.length }}</span>
}
</td>
</tr>
}
</td>
<td class="num">{{ t.balanceAfter ? (t.balanceAfter | number: '1.0-0') : '—' }}</td>
<td>{{ t.narration || '—' }}</td>
<td>{{ t.counterparty || '—' }}</td>
<td>
<span class="rr-rule">{{ a.rule.label }}</span>
@if (a.txns.length > 1) {
<span class="rr-leg">leg {{ $index + 1 }} of {{ a.txns.length }}</span>
}
</td>
</tr>
}
</tbody>
</table>
</tbody>
</table>
<div class="rr-case-foot">
<div class="rr-suggest">
<fa-icon icon="clipboard-check"></fa-icon> {{ a.rule.suggestedAction }}
</div>
<div class="rr-case-remark">
<strong>Reviewer:</strong> {{ a.reviewedBy || 'not yet assigned' }} —
{{ a.remarks || 'no remarks recorded' }}
</div>
</div>
</div>
<div class="rr-case-foot">
<div class="rr-suggest">
<fa-icon icon="clipboard-check"></fa-icon> {{ a.rule.suggestedAction }}
</div>
<div class="rr-case-remark">
<strong>Reviewer:</strong> {{ a.reviewedBy || 'not yet assigned' }} —
{{ a.remarks || 'no remarks recorded' }}
</div>
</div>
</div>
}
</td>
</tr>
}
</td>
</tr>
}
} @empty {
<tr>
<td colspan="11" class="rr-empty">
No customer tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.
</td>
</tr>
}
</tbody>
</table>
</div>
}
</tbody>
</table>
</div>
}
</div>
} @empty {
<div class="rr-empty">No customer tripped an AML rule for {{ rangeLabel }} at {{ branchLabel }}.</div>
}
}
<!-- ALERT WISE -->
@@ -400,9 +413,6 @@
<td class="mono">{{ a.clientId }}</td>
<td>
<strong>{{ a.customerName }}</strong>
@if (!a.live) {
<span class="rr-tag">Sample</span>
}
</td>
<td class="mono">{{ a.accountNo || '—' }}</td>
<td>{{ a.branchCode || '—' }}</td>
@@ -139,12 +139,6 @@
span {
flex: 1;
}
&--sample {
background: var(--r-amber-l);
border: 1px solid #fcd34d;
color: #78350f;
}
}
/* FILTERS */
@@ -403,6 +397,84 @@
font-weight: 600;
}
/* VERDICT GROUPS — the section heading carries the verdict, so rows need not repeat it. */
.rr-group {
margin-bottom: 14px;
}
.rr-group-head {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 14px;
background: #fff;
border: 1px solid var(--r-g200);
border-left: 4px solid var(--r-g300);
border-radius: 10px;
cursor: pointer;
text-align: left;
font: inherit;
}
.rr-group-t {
font-size: 14px;
font-weight: 700;
}
/* The count sits next to the title so a reviewer sees the size of each pile at a glance. */
.rr-group-n {
min-width: 24px;
padding: 1px 8px;
border-radius: 999px;
background: var(--r-g100, #f1f5f9);
font-size: 12px;
font-weight: 700;
text-align: center;
}
.rr-group-h {
flex: 1;
font-size: 12px;
color: var(--r-g500);
}
/* One colour per verdict, matched to the arrows already used in the rows. */
.rr-group--str .rr-group-head {
border-left-color: #dc2626;
}
.rr-group--str .rr-group-n {
background: #fee2e2;
color: #991b1b;
}
.rr-group--up .rr-group-head {
border-left-color: #ea580c;
}
.rr-group--up .rr-group-n {
background: #ffedd5;
color: #9a3412;
}
.rr-group--down .rr-group-head {
border-left-color: #16a34a;
}
.rr-group--down .rr-group-n {
background: #dcfce7;
color: #166534;
}
.rr-group--hold .rr-group-head {
border-left-color: var(--r-g300);
}
.rr-group .rr-table-wrap {
margin-top: 6px;
}
/* TABLE */
.rr-table-wrap {
background: #fff;
@@ -751,6 +823,75 @@
white-space: normal;
}
/* Where the rating should land, read straight off the Current Risk column beside it. */
.rr-move-cell {
min-width: 150px;
max-width: 190px;
}
.rr-move {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
&--up {
background: var(--r-red-l);
color: var(--r-red);
}
/* Coming down is a relief, not an alarm — same green the verdict uses. */
&--down {
background: var(--r-green-l);
color: var(--r-green);
}
&--hold {
background: var(--r-g100);
color: var(--r-g500);
}
}
.rr-move-why {
font-size: 10px;
color: var(--r-g500);
margin-top: 3px;
white-space: normal;
}
/* Drill from the verdict into the KYC screen, where the rating is actually moved. */
.rr-kyc-link {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 6px;
padding: 3px 9px;
border: 1px solid var(--r-g300);
border-radius: 5px;
background: #fff;
font: inherit;
font-size: 10.5px;
font-weight: 600;
color: var(--r-g500);
white-space: nowrap;
cursor: pointer;
&:hover {
border-color: var(--r-blue);
color: var(--r-blue);
}
/* A rating that has to move gets the emphasis; a settled row stays quiet. */
&--act {
border-color: var(--r-blue);
color: var(--r-blue);
}
}
.rr-tag {
display: inline-block;
margin-left: 6px;
@@ -9,6 +9,7 @@
/** 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';
@@ -37,8 +38,7 @@ import {
RiskLevel,
channelLabel,
mapLiveAlert,
riskFromCategory,
sampleRiskAlerts
riskFromCategory
} from './risk-review-data';
import {
DateRange,
@@ -57,6 +57,15 @@ import {
type PeriodMode = 'day' | 'week' | 'month' | 'range';
type ViewMode = 'customer' | 'alert' | 'txn';
/** One block of the customer view: the customers that share a single verdict. */
interface RiskCustomerGroup {
key: string;
title: string;
hint: string;
tone: 'str' | 'up' | 'down' | 'hold';
rows: RiskCustomerRow[];
}
/** Starting value for the memo, used before the first filter pass runs. */
const EMPTY_SUMMARY: RiskReviewSummary = {
customers: 0,
@@ -82,9 +91,8 @@ const EMPTY_SUMMARY: RiskReviewSummary = {
* rating, move it, refresh KYC, or escalate to the Principal Officer for an
* STR — which is the vocabulary in `REVIEW_STATUS_META`.
*
* Rows come from the live rule engine. If the engine has raised nothing (or is
* unreachable) the report falls back to a deterministic sample set and says so
* on screen and in the workbook, so sample rows are never taken for live data.
* Rows come from the live rule engine only. If the engine has raised nothing
* (or is unreachable) the report shows as empty rather than padding itself.
*/
@Component({
selector: 'mifosx-risk-review-report',
@@ -100,6 +108,7 @@ export class RiskReviewReportComponent implements OnInit {
private aml = inject(AmlService);
private clients = inject(ClientsService);
private scope = inject(BranchScopeService);
private router = inject(Router);
readonly period = GL_PERIOD;
readonly branches = GL_BRANCHES;
@@ -122,22 +131,13 @@ export class RiskReviewReportComponent implements OnInit {
private liveAlerts: RiskAlertRow[] = [];
loading = true;
/**
* Live alerts and the demonstration set, in one worklist.
*
* Every row keeps its `live` flag and sample rows are tagged in the table, so
* a reviewer can still tell them apart at a glance.
*/
/** The worklist — live alerts from the rule engine. */
private alerts: RiskAlertRow[] = [];
get hasLive(): boolean {
return this.liveAlerts.length > 0;
}
get sampleCount(): number {
return this.alerts.filter((a) => !a.live).length;
}
// ── Filter state ───────────────────────────────────────────────────────────
/**
@@ -198,12 +198,16 @@ export class RiskReviewReportComponent implements OnInit {
});
}
/** Live alerts first, then the demonstration set behind them. */
/**
* Only what the AML engine actually raised.
*
* The demonstration set that used to be appended here is gone: a compliance report that mixes
* invented customers into live alerts is worse than an empty one, because every count, total and
* rule tally on the screen is then a number nobody can act on. An empty period now reads as an
* empty period.
*/
private compose(): void {
this.alerts = [
...this.liveAlerts,
...sampleRiskAlerts()
];
this.alerts = [...this.liveAlerts];
this.memoKey = '';
}
@@ -334,6 +338,64 @@ export class RiskReviewReportComponent implements OnInit {
return this.model.customers;
}
/**
* The customers split by what the reviewer actually has to do about them.
*
* The report's whole question is "does the rating on file still hold?", and there are only four
* answers. Grouping by the answer puts that question in the section heading, where it is read
* once, instead of repeating it on every row across a Move To and a Position column that said the
* same thing in different words.
*
* Ordered by what a reviewer must deal with first: an STR sits with the Principal Officer against
* a regulatory clock, an under-rated customer is the real compliance gap, an over-rated one is
* only a correction, and matching ratings need nothing at all.
*/
get customerGroups(): RiskCustomerGroup[] {
const rows = this.customerRows;
const escalated = rows.filter((c) => c.escalated);
const rest = rows.filter((c) => !c.escalated);
const groups: RiskCustomerGroup[] = [
{
key: 'escalated',
title: 'With the Principal Officer',
hint: 'An STR has been filed. The rating is held where it is until the outcome — nothing to apply here.',
tone: 'str',
rows: escalated
},
{
key: 'up',
title: 'Rating needs raising',
hint: 'The rules point higher than the rating on file. These are under-rated customers — the real gap.',
tone: 'up',
rows: rest.filter((c) => c.direction === 'up')
},
{
key: 'down',
title: 'Rating can be lowered',
hint: 'Activity no longer supports the rating on file.',
tone: 'down',
rows: rest.filter((c) => c.direction === 'down')
},
{
key: 'same',
title: 'No change needed',
hint: 'The rating on file already matches the behaviour. Listed for the record.',
tone: 'hold',
rows: rest.filter((c) => c.direction === 'same')
}
];
return groups.filter((g) => g.rows.length > 0);
}
/** Collapsed sections, by group key. 'No change needed' starts closed — nothing to act on. */
collapsedGroups: Record<string, boolean> = { same: true };
toggleGroup(key: string): void {
this.collapsedGroups[key] = !this.collapsedGroups[key];
}
get txnRows(): RiskTxnView[] {
return this.model.txns;
}
@@ -393,6 +455,74 @@ export class RiskReviewReportComponent implements OnInit {
return 'rr-verdict';
}
/** Colours the target rating by which way it moves. */
moveClass(c: RiskCustomerRow): string {
if (c.direction === 'up') {
return 'rr-move rr-move--up';
}
if (c.direction === 'down') {
return 'rr-move rr-move--down';
}
return 'rr-move rr-move--hold';
}
/**
* The short "from → to" line under the target rating.
*
* The reason column already carries the full argument; this only has to say what is
* happening to the rating, so a reviewer scanning the column never has to read a
* paragraph to find out whether a customer is moving.
*/
moveHint(c: RiskCustomerRow): string {
const from = this.riskText(c.currentRisk);
const to = this.riskText(c.recommendedRisk);
if (c.escalated) {
return `${from} → hold at ${to} till STR outcome`;
}
if (c.direction === 'up') {
return `${from} → ${to} as per AML rules`;
}
if (c.direction === 'down') {
return `${from} → ${to}, activity does not support ${from}`;
}
// Nothing to move: say why it is staying rather than leaving the cell mute.
return c.currentRisk ? `Behaviour matches ${from} — no change` : 'No rating on record yet';
}
/**
* Opens the customer's KYC tab so the reviewer can act on the verdict.
*
* The report only ever recommends — nothing here writes the rating. Moving a customer
* is a KYC amendment and stays on the KYC screen, where the override checkbox, the
* remarks field and the review-date recalculation all live together.
*/
openKyc(c: RiskCustomerRow, event: Event): void {
// The row itself expands on click; the button must not do both.
event.stopPropagation();
this.router.navigate([
'/clients',
c.clientId,
'kyc'
]);
}
/**
* What the reviewer is going to the KYC screen to do.
*
* An escalated case is worded as a review rather than a move: the rating is held
* pending the Principal Officer's conclusion, so there is nothing to apply yet.
*/
kycActionText(c: RiskCustomerRow): string {
if (c.escalated) {
return 'Open KYC';
}
if (c.direction === 'up' || c.direction === 'down') {
return `Set ${RISK_LABEL[c.recommendedRisk]} in KYC`;
}
return 'Open KYC';
}
/** Transaction detail as the reviewer would read it in one line. */
txnDetail(a: RiskAlertView): string {
const amount = `₹${a.amount.toLocaleString('en-IN')}`;
@@ -409,9 +539,7 @@ export class RiskReviewReportComponent implements OnInit {
try {
const s = this.summary;
const subtitle =
`Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` +
`Source: live AML monitoring engine` +
(this.sampleCount ? ` + ${this.sampleCount} DEMONSTRATION rows (not compliance data)` : '');
`Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · ` + `Source: live AML monitoring engine`;
await exportWorkbook(
[
@@ -533,6 +661,7 @@ export class RiskReviewReportComponent implements OnInit {
'Last Alert',
'Rules Point At',
'Recommended Risk',
'Move To',
'Open Cases',
'Position',
'Basis'
@@ -551,6 +680,7 @@ export class RiskReviewReportComponent implements OnInit {
12,
14,
16,
38,
11,
24,
70
@@ -569,6 +699,7 @@ export class RiskReviewReportComponent implements OnInit {
c.lastAlertDate,
RISK_LABEL[c.proposedRisk],
RISK_LABEL[c.recommendedRisk],
this.moveHint(c),
c.openCount,
this.verdictText(c),
c.directionReason || '—'
@@ -24,6 +24,7 @@
<div class="sdi-tabs">
<button [class.active]="activeTab === 'products'" (click)="backToProducts()">Products</button>
<button [class.active]="activeTab === 'summary'" (click)="loadSummary()">All Accounts</button>
@if (selectedProduct) {
<button [class.active]="activeTab === 'accounts'" (click)="backToAccounts()">
{{ selectedProduct.name }}
@@ -41,6 +42,89 @@
<div class="sdi-loading">Loading…</div>
}
<!--
All Accounts: one line per savings account, across every product.
The ledger behind this holds a row per account per day — interest is worked out on the balance
each day carried, and a reversed backdated entry means every day after it is recomputed. This
reads the top of that ledger, so the whole book is one row each.
-->
@if (activeTab === 'summary' && !loading) {
<div class="sdi-summary-bar">
<input
class="sdi-search"
type="text"
[(ngModel)]="summarySearch"
placeholder="Search account number, customer or product"
/>
<span class="sdi-spacer"></span>
<span class="sdi-total">
{{ filteredSummary.length }} accounts · accrued this cycle
<strong>{{ summaryAccruedTotal | number: '1.2-2' }}</strong> · posted to date
<strong>{{ summaryPostedTotal | number: '1.2-2' }}</strong>
</span>
</div>
@if (filteredSummary.length === 0) {
<div class="sdi-loading">
No accounts in the ledger yet. Run the accrual, or use Backfill to rebuild it from activation.
</div>
} @else {
<table class="sdi-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Product</th>
<th>As On</th>
<th class="num">Balance</th>
<th class="num">Rate</th>
<th class="num">Interest Today</th>
<th>Cycle</th>
<th class="num">Accrued This Cycle</th>
<th>Next Posting</th>
<th class="num">Posted To Date</th>
<th></th>
</tr>
</thead>
<tbody>
@for (row of filteredSummary; track row.savings_account_id) {
<tr [class.sdi-row--posting]="row.is_posting_day" [class.sdi-row--below]="row.below_min_balance">
<td class="mono">{{ row.account_no }}</td>
<td>{{ row.client_name || '—' }}</td>
<td>
{{ row.product_name }}
<div class="sdi-sub">{{ postingPeriodLabel(row.posting_period_enum ?? 0) }} posting</div>
</td>
<td class="mono">{{ formatDate(row.as_on_date) }}</td>
<td class="num">{{ row.closing_balance | number: '1.2-2' }}</td>
<td class="num">{{ row.interest_rate | number: '1.2-2' }}%</td>
<td class="num">{{ row.interest_today | number: '1.4-4' }}</td>
<td>
#{{ row.cycle_number }}
<div class="sdi-sub mono">{{ formatDate(row.cycle_start_date) }}</div>
</td>
<td class="num strong">{{ row.interest_this_cycle | number: '1.2-2' }}</td>
<td>
<span class="mono">{{ formatDate(row.cycle_end_date) }}</span>
@if (row.is_posting_day) {
<div class="sdi-sub sdi-sub--due">posting today</div>
} @else {
<div class="sdi-sub">in {{ row.days_to_posting }} days</div>
}
</td>
<td class="num">{{ row.interest_posted_total | number: '1.2-2' }}</td>
<td>
<!-- The day-by-day working, for the account whose figure is being questioned. -->
<button class="sdi-link" (click)="openLedgerFor(row)">Days</button>
</td>
</tr>
}
</tbody>
</table>
}
}
<!-- Products: one row per savings product, with where it stands in its timetable. -->
@if (activeTab === 'products' && !loading) {
<table class="sdi-table">
@@ -71,8 +155,11 @@
<td>{{ formatDate(product.next_posting_date) }}</td>
<td>
@if (product.last_run_status) {
<span class="sdi-chip" [class.ok]="product.last_run_status === 'COMPLETED'"
[class.bad]="product.last_run_status === 'FAILED'">
<span
class="sdi-chip"
[class.ok]="product.last_run_status === 'COMPLETED'"
[class.bad]="product.last_run_status === 'FAILED'"
>
{{ product.last_run_status }}
</span>
} @else {
@@ -81,7 +168,9 @@
</td>
</tr>
} @empty {
<tr><td colspan="8" class="sdi-empty">No savings products carry a ledger yet.</td></tr>
<tr>
<td colspan="8" class="sdi-empty">No savings products carry a ledger yet.</td>
</tr>
}
</tbody>
</table>
@@ -106,7 +195,9 @@
<tbody>
@for (account of accounts; track account.savings_account_id) {
<tr class="clickable" (click)="openAccount(account)">
<td><strong>{{ account.account_no }}</strong></td>
<td>
<strong>{{ account.account_no }}</strong>
</td>
<td>{{ account.client_name || '—' }}</td>
<td>{{ formatDate(account.balance_date) }}</td>
<td class="num">{{ account.opening_balance | number: '1.2-2' }}</td>
@@ -114,7 +205,9 @@
<td class="num" [class.zero]="account.below_min_balance">
{{ account.daily_interest | number: '1.4-4' }}
@if (account.below_min_balance) {
<span class="sdi-flag" title="Closed below the minimum balance, so this day earned nothing">below min</span>
<span class="sdi-flag" title="Closed below the minimum balance, so this day earned nothing"
>below min</span
>
}
</td>
<td class="num accent">{{ account.cycle_cumulative_interest | number: '1.2-2' }}</td>
@@ -122,7 +215,9 @@
<td>{{ formatDate(account.cycle_end_date) }}</td>
</tr>
} @empty {
<tr><td colspan="9" class="sdi-empty">No ledger rows for this product yet.</td></tr>
<tr>
<td colspan="9" class="sdi-empty">No ledger rows for this product yet.</td>
</tr>
}
</tbody>
</table>
@@ -156,7 +251,9 @@
<td class="num" [class.zero]="day.below_min_balance">
{{ day.daily_interest | number: '1.4-4' }}
@if (day.below_min_balance) {
<span class="sdi-flag" title="Closed below the minimum balance, so this day earned nothing">below min</span>
<span class="sdi-flag" title="Closed below the minimum balance, so this day earned nothing"
>below min</span
>
}
</td>
<td class="num accent">{{ day.cycle_cumulative_interest | number: '1.4-4' }}</td>
@@ -170,7 +267,9 @@
</td>
</tr>
} @empty {
<tr><td colspan="7" class="sdi-empty">No days recorded for this account.</td></tr>
<tr>
<td colspan="7" class="sdi-empty">No days recorded for this account.</td>
</tr>
}
</tbody>
</table>
@@ -200,15 +299,18 @@
<td class="num">{{ run.distributed_amount | number: '1.2-2' }}</td>
<td class="num">{{ run.rounding_residual | number: '1.2-2' }}</td>
<td>
<span class="sdi-chip" [class.ok]="run.status === 'COMPLETED'"
[class.bad]="run.status === 'FAILED'">{{ run.status }}</span>
<span class="sdi-chip" [class.ok]="run.status === 'COMPLETED'" [class.bad]="run.status === 'FAILED'">{{
run.status
}}</span>
@if (run.failure_reason) {
<div class="sdi-sub">{{ run.failure_reason }}</div>
}
</td>
</tr>
} @empty {
<tr><td colspan="7" class="sdi-empty">No posting runs yet.</td></tr>
<tr>
<td colspan="7" class="sdi-empty">No posting runs yet.</td>
</tr>
}
</tbody>
</table>
@@ -50,7 +50,7 @@
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
border-bottom: 1px solid rgb(0 0 0 / 12%);
margin-bottom: 1rem;
button {
@@ -87,12 +87,12 @@
td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
border-bottom: 1px solid rgb(0 0 0 / 8%);
}
th {
font-weight: 600;
background: rgba(0, 0, 0, 0.03);
background: rgb(0 0 0 / 3%);
position: sticky;
top: 0;
}
@@ -106,13 +106,13 @@
cursor: pointer;
&:hover {
background: rgba(21, 101, 192, 0.06);
background: rgb(21 101 192 / 6%);
}
}
// The day the cycle is paid out and the sum goes back to zero.
tr.posting-row {
background: rgba(46, 125, 50, 0.08);
background: rgb(46 125 50 / 8%);
font-weight: 600;
}
}
@@ -144,15 +144,15 @@
padding: 0.15rem 0.5rem;
border-radius: 10px;
font-size: 0.75rem;
background: rgba(0, 0, 0, 0.08);
background: rgb(0 0 0 / 8%);
&.ok {
background: rgba(46, 125, 50, 0.15);
background: rgb(46 125 50 / 15%);
color: #2e7d32;
}
&.bad {
background: rgba(198, 40, 40, 0.15);
background: rgb(198 40 40 / 15%);
color: #c62828;
}
}
@@ -164,10 +164,74 @@
border-radius: 3px;
font-size: 0.7rem;
font-weight: 500;
background: rgba(0, 0, 0, 0.08);
background: rgb(0 0 0 / 8%);
&.paid {
background: rgba(46, 125, 50, 0.2);
background: rgb(46 125 50 / 20%);
color: #2e7d32;
}
}
// ── All Accounts: one line per account ───────────────────────────────────────
.sdi-summary-bar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.sdi-search {
min-width: 20rem;
padding: 0.45rem 0.7rem;
border: 1px solid rgb(0 0 0 / 20%);
border-radius: 4px;
font: inherit;
font-size: 0.85rem;
}
.sdi-spacer {
flex: 1;
}
.sdi-total {
font-size: 0.8rem;
opacity: 0.75;
}
.mono {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.8rem;
}
.strong {
font-weight: 700;
}
// The day the cycle is paid out — the accrued figure goes back to zero after it.
tr.sdi-row--posting {
background: rgb(46 125 50 / 8%);
}
// Under the product minimum, so this day earns nothing at all.
tr.sdi-row--below {
opacity: 0.6;
}
.sdi-sub--due {
color: #2e7d32;
font-weight: 700;
opacity: 1;
}
.sdi-link {
border: 0;
background: none;
padding: 0;
font: inherit;
font-size: 0.8rem;
color: #1565c0;
text-decoration: underline;
cursor: pointer;
}
@@ -14,7 +14,8 @@ import {
SavingsInterestAccount,
SavingsInterestDay,
SavingsInterestProduct,
SavingsInterestRun
SavingsInterestRun,
SavingsInterestSummaryRow
} from './savings-daily-interest.service';
/**
@@ -37,13 +38,17 @@ import {
export class SavingsDailyInterestComponent implements OnInit {
private service = inject(SavingsDailyInterestService);
activeTab: 'products' | 'accounts' | 'ledger' | 'runs' = 'products';
activeTab: 'products' | 'summary' | 'accounts' | 'ledger' | 'runs' = 'products';
products: SavingsInterestProduct[] = [];
accounts: SavingsInterestAccount[] = [];
ledger: SavingsInterestDay[] = [];
runs: SavingsInterestRun[] = [];
/** One row per account across every product. */
summary: SavingsInterestSummaryRow[] = [];
summarySearch = '';
selectedProduct: SavingsInterestProduct | null = null;
selectedAccount: SavingsInterestAccount | null = null;
@@ -95,6 +100,39 @@ export class SavingsDailyInterestComponent implements OnInit {
});
}
/**
* Opens the day-by-day working for a row on the All Accounts list.
*
* The summary row carries what the ledger tab needs to caption itself, so it is mapped across
* rather than the account being re-fetched: the two views read the same ledger, and a second call
* would only be able to disagree with the row the user just clicked.
*/
openLedgerFor(row: SavingsInterestSummaryRow): void {
this.selectedAccount = {
savings_account_id: row.savings_account_id,
account_no: row.account_no,
client_name: row.client_name ?? '',
balance_date: row.as_on_date,
opening_balance: row.opening_balance,
closing_balance: row.closing_balance,
daily_interest: row.interest_today,
cycle_cumulative_interest: row.interest_this_cycle,
cycle_number: row.cycle_number,
cycle_start_date: row.cycle_start_date,
cycle_end_date: row.cycle_end_date,
below_min_balance: row.below_min_balance
};
this.activeTab = 'ledger';
this.loading = true;
this.service.getAccountLedger(row.savings_account_id).subscribe({
next: (data) => {
this.ledger = data ?? [];
this.loading = false;
},
error: (err) => this.fail(err)
});
}
loadRuns(): void {
this.activeTab = 'runs';
this.loading = true;
@@ -107,6 +145,43 @@ export class SavingsDailyInterestComponent implements OnInit {
});
}
/**
* Every account on one line: today's balance and interest, the cycle it is in, and what it has
* been paid.
*
* Loaded unfiltered and narrowed in the browser. The whole book is one row per account, which is
* small enough to hold, and filtering locally means typing in the search box does not re-query.
*/
loadSummary(): void {
this.activeTab = 'summary';
this.loading = true;
this.service.getSummary().subscribe({
next: (data) => {
this.summary = data ?? [];
this.loading = false;
},
error: (err) => this.fail(err)
});
}
get filteredSummary(): SavingsInterestSummaryRow[] {
const needle = this.summarySearch.trim().toLowerCase();
if (!needle) {
return this.summary;
}
return this.summary.filter((r) =>
`${r.account_no} ${r.client_name ?? ''} ${r.product_name}`.toLowerCase().includes(needle)
);
}
get summaryAccruedTotal(): number {
return this.filteredSummary.reduce((s, r) => s + Number(r.interest_this_cycle ?? 0), 0);
}
get summaryPostedTotal(): number {
return this.filteredSummary.reduce((s, r) => s + Number(r.interest_posted_total ?? 0), 0);
}
// -------------------- actions --------------------
/**
@@ -160,7 +235,11 @@ export class SavingsDailyInterestComponent implements OnInit {
if (!value || value.length < 3) {
return '—';
}
const [year, month, day] = value;
const [
year,
month,
day
] = value;
return `${String(day).padStart(2, '0')}/${String(month).padStart(2, '0')}/${year}`;
}
@@ -42,6 +42,85 @@ export interface SavingsInterestAccount {
below_min_balance: boolean;
}
/**
* One row per savings account: where that account stands right now.
*
* The ledger behind this holds a row per account per day, because interest is worked out on the
* balance each day carried and a reversed backdated entry means every day after it is recomputed.
* This is the current position read off the top of that ledger — today's figures, the cycle the
* account is in, and what it has been paid so far — so a screen can show one line per account
* without reading a month of rows to find it.
*/
export interface SavingsInterestSummaryRow {
savings_account_id: number;
account_no: string;
client_id: number | null;
client_name: string | null;
office_name: string | null;
savings_product_id: number;
product_name: string;
currency_code: string;
/** The latest day the ledger has accrued for. */
as_on_date: number[];
opening_balance: number;
closing_balance: number;
interest_today: number;
cycle_number: number;
cycle_start_date: number[];
cycle_end_date: number[];
/** Accrued since the cycle began; resets to the day's interest after a posting. */
interest_this_cycle: number;
days_to_posting: number;
is_posting_day: boolean;
posting_date: number[] | null;
interest_rate: number;
days_in_year: number;
compounding_period_enum: number | null;
posting_period_enum: number | null;
calculation_type_enum: number | null;
min_balance_for_interest: number | null;
below_min_balance: boolean;
/** Everything ever posted to the account from this ledger. */
interest_posted_total: number;
last_posted_on: number[] | null;
/** How many days the ledger holds for the account. */
days_accrued: number;
last_recomputed_on: string | null;
}
/**
* One interest posting: what a member was actually paid, and on what.
*
* Read from the savings transaction rather than the ledger — the ledger clears its posted amount
* when the next cycle opens, so it cannot answer what was paid last quarter. The transaction is
* the payment and stays.
*/
export interface SavingsInterestPostingRow {
transaction_id: number;
savings_account_id: number;
account_no: string;
client_id: number | null;
client_name: string | null;
office_name: string | null;
savings_product_id: number;
product_name: string;
/** The product's own rhythm: 1 daily, 4 monthly, 5 quarterly, 6 half-yearly, 7 annual. */
posting_period_enum: number;
interest_rate: number;
currency_code: string;
/** ISO yyyy-MM-dd — formatted server-side, because this endpoint serialises with a plain Gson. */
posting_date: string;
interest_amount: number;
/** Balance the interest was credited onto, and what it became. */
balance_before: number;
balance_after: number;
recorded_on: string | null;
}
/** One day of one account: the working behind that day's interest. */
export interface SavingsInterestDay {
balance_date: number[];
@@ -100,6 +179,61 @@ export class SavingsDailyInterestService {
return this.http.get<SavingsInterestAccount[]>(`${this.base}/products/${productId}/accounts`);
}
/**
* One row per account, across every product — the whole book's current interest position.
*
* Narrow it with any of the filters; all three are optional and combine.
*/
getSummary(filters?: {
productId?: number;
officeName?: string;
accountNo?: string;
}): Observable<SavingsInterestSummaryRow[]> {
let params = new HttpParams();
if (filters?.productId) {
params = params.set('productId', filters.productId);
}
if (filters?.officeName) {
params = params.set('officeName', filters.officeName);
}
if (filters?.accountNo) {
params = params.set('accountNo', filters.accountNo);
}
return this.http.get<SavingsInterestSummaryRow[]>(`${this.base}/summary`, { params });
}
/**
* Interest actually paid to members, one row per posting.
*
* `period` narrows to one posting rhythm (1 daily, 4 monthly, 5 quarterly, 6 half-yearly,
* 7 annual), matching the product's own posting period.
*/
getPostings(filters?: {
period?: number;
productId?: number;
fromDate?: string;
toDate?: string;
officeName?: string;
}): Observable<SavingsInterestPostingRow[]> {
let params = new HttpParams();
if (filters?.period) {
params = params.set('period', filters.period);
}
if (filters?.productId) {
params = params.set('productId', filters.productId);
}
if (filters?.fromDate) {
params = params.set('fromDate', filters.fromDate);
}
if (filters?.toDate) {
params = params.set('toDate', filters.toDate);
}
if (filters?.officeName) {
params = params.set('officeName', filters.officeName);
}
return this.http.get<SavingsInterestPostingRow[]>(`${this.base}/postings`, { params });
}
getAccountLedger(accountId: number, limit = 400): Observable<SavingsInterestDay[]> {
const params = new HttpParams().set('limit', limit);
return this.http.get<SavingsInterestDay[]>(`${this.base}/accounts/${accountId}`, { params });
@@ -43,7 +43,6 @@ const DEFAULT_FACILITY_CONFIG: Record<string, boolean> = {
neftFacility: false,
rtgsFacility: false,
interBankTotalCapFacility: false,
withdrawalFacility: false,
depositFacilityKyc: false,
virtualDebitCard: false
};
@@ -64,7 +63,6 @@ const FACILITY_TOGGLES: { key: string; label: string; icon: string }[] = [
{ key: 'neftFacility', label: 'NEFT Facility', icon: 'fa-money-check-alt' },
{ key: 'rtgsFacility', label: 'RTGS Facility', icon: 'fa-money-check' },
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap', icon: 'fa-balance-scale' },
{ key: 'withdrawalFacility', label: 'Withdrawal Facility', icon: 'fa-arrow-right-arrow-left' },
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)', icon: 'fa-id-card' },
{ key: 'virtualDebitCard', label: 'Virtual Debit Card', icon: 'fa-credit-card' }
];
@@ -21,6 +21,7 @@ import { Router, ActivatedRoute, RouterLink } from '@angular/router';
import { SavingsService } from '../../savings.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -43,6 +44,7 @@ export class AddChargeSavingsAccountComponent implements OnInit {
private dateUtils = inject(Dates);
private savingsService = inject(SavingsService);
private settingsService = inject(SettingsService);
private facilityChargeFilter = inject(FacilityChargeFilterService);
/** Minimum Due Date allowed. */
minDate = new Date(2000, 0, 1);
@@ -68,7 +70,9 @@ export class AddChargeSavingsAccountComponent implements OnInit {
*/
constructor() {
this.route.data.subscribe((data: { savingsAccountActionData: any }) => {
this.savingsChargeOptions = data.savingsAccountActionData.chargeOptions;
this.facilityChargeFilter
.excludeFacilityCharges(data.savingsAccountActionData.chargeOptions)
.subscribe((charges: any[]) => (this.savingsChargeOptions = charges));
});
this.savingAccountId = this.route.snapshot.params['savingAccountId'];
}
@@ -36,6 +36,7 @@ import { TranslateService } from '@ngx-translate/core';
import { MatButton, MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
import { FacilityChargeFilterService } from 'app/shared/charges/facility-charge-filter.service';
import { ChargesFilterPipe } from '../../../pipes/charges-filter.pipe';
import { DateFormatPipe } from '../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
@@ -73,6 +74,7 @@ export class SavingsAccountChargesStepComponent implements OnInit {
private dialog = inject(MatDialog);
private dateUtils = inject(Dates);
private translateService = inject(TranslateService);
private facilityChargeFilter = inject(FacilityChargeFilterService);
private _savingsAccountProductTemplate: any;
@@ -81,7 +83,9 @@ export class SavingsAccountChargesStepComponent implements OnInit {
set savingsAccountProductTemplate(value: any) {
this._savingsAccountProductTemplate = value;
if (value) {
this.chargeData = value.chargeOptions || [];
this.facilityChargeFilter
.excludeFacilityCharges(value.chargeOptions)
.subscribe((charges: any[]) => (this.chargeData = charges));
this.chargesDataSource = (value.charges || []).map((charge: any) => ({ ...charge, id: charge.chargeId }));
}
}
@@ -174,18 +174,6 @@
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.withdrawalFacility">
<div class="dig-card__icon"><i class="fas fa-arrow-right-arrow-left"></i></div>
<div class="dig-card__body">
<span class="dig-card__label">Withdrawal Facility</span>
<span class="dig-card__status">{{ facilityConfig.withdrawalFacility ? 'Enabled' : 'Disabled' }}</span>
</div>
<label class="dig-toggle">
<input type="checkbox" [(ngModel)]="facilityConfig.withdrawalFacility" />
<span class="dig-toggle__track"><span class="dig-toggle__thumb"></span></span>
</label>
</div>
<div class="dig-card" [class.dig-card--on]="facilityConfig.depositFacilityKyc">
<div class="dig-card__icon"><i class="fas fa-id-card"></i></div>
<div class="dig-card__body">
@@ -24,7 +24,6 @@ const DEFAULT_FACILITY_CONFIG = {
neftFacility: false,
rtgsFacility: false,
interBankTotalCapFacility: false,
withdrawalFacility: false,
depositFacilityKyc: false,
virtualDebitCard: false
};
@@ -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' }
];
@@ -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<Set<number>>;
/**
* 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<Set<number>> {
if (!this.mappedChargeIds$) {
this.mappedChargeIds$ = this.http.get('/facilitychargemappings').pipe(
map(
(mappings: any) =>
new Set<number>(
((mappings as any[]) || [])
.map((mapping) => Number(mapping?.chargeId))
.filter((chargeId) => Number.isFinite(chargeId))
)
),
catchError(() => of(new Set<number>())),
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<any[]> {
return this.getFacilityMappedChargeIds().pipe(
map((mappedChargeIds) =>
(charges || []).filter((charge) => !mappedChargeIds.has(Number(charge?.id ?? charge?.chargeId)))
)
);
}
}
@@ -12,7 +12,9 @@
<div class="dap__header">
<div class="dap__header-left">
<div class="dap__icon-wrap">
<i [class]="isProductMode ? 'fas fa-shield-alt' : 'fas fa-exclamation-triangle'"></i>
<!-- A healthy account gets the shield, not the warning triangle: the panel is showing it
its thresholds and the benefit exemption, not raising an alarm. -->
<i [class]="isProductMode || isHealthy ? 'fas fa-shield-alt' : 'fas fa-exclamation-triangle'"></i>
</div>
<div>
<div class="dap__title">

Some files were not shown because too many files have changed in this diff Show More