feat: KYC document update, FIU & AML recurring alerts integration, Sponsor DD revoke workflow, and TDS reports

This commit is contained in:
2026-09-14 11:43:26 +05:30
parent 7afa9c7a50
commit 2124bb4b76
40 changed files with 3028 additions and 929 deletions
@@ -715,7 +715,10 @@
></mat-row> ></mat-row>
</mat-table> </mat-table>
@if (totalEntries === 0) { <!-- Keyed off the rows actually rendered, not totalEntries: that count comes from the ledger
summary, which only reports a figure once a GL account is chosen, so the search view would
otherwise show "No entries found" above a full table. -->
@if (((dataSource?.entries$ | async) || []).length === 0) {
<div class="gl-empty"> <div class="gl-empty">
<div class="gl-empty__icon"><fa-icon icon="search"></fa-icon></div> <div class="gl-empty__icon"><fa-icon icon="search"></fa-icon></div>
<div class="gl-empty__title">No entries found</div> <div class="gl-empty__title">No entries found</div>
@@ -724,7 +727,11 @@
} }
<div class="gl-pager-wrap"> <div class="gl-pager-wrap">
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons></mat-paginator> <mat-paginator
[length]="(dataSource?.records$ | async) || 0"
[pageSizeOptions]="[10, 25, 50, 100]"
showFirstLastButtons
></mat-paginator>
</div> </div>
</div> </div>
</div> </div>
+9
View File
@@ -122,6 +122,15 @@ export class AmlService {
return this.http.get<any[]>('/aml/ctr-reports'); return this.http.get<any[]>('/aml/ctr-reports');
} }
/**
* The reportable lines behind the generated CTRs, each resolved to the account, customer, branch
* and date a FIU-IND filing names. Omit `reportId` for every generated month.
*/
getCtrReportLines(reportId?: number): Observable<any[]> {
const params = reportId ? new HttpParams().set('reportId', String(reportId)) : undefined;
return this.http.get<any[]>('/aml/ctr-reports/lines', { params });
}
/** month format: yyyy-MM */ /** month format: yyyy-MM */
generateCtrReport(month: string): Observable<any> { generateCtrReport(month: string): Observable<any> {
return this.http.post('/aml/ctr-reports/generate', { month }); return this.http.post('/aml/ctr-reports/generate', { month });
@@ -71,8 +71,11 @@ export interface CashierIssueDdDialogData {
<mat-dialog-content class="dd-dialog-body"> <mat-dialog-content class="dd-dialog-body">
<!-- Draft & Financials Summary Card --> <!-- Draft & Financials Summary Card -->
<div class="dd-summary-card"> <div class="dd-summary-card">
<!-- The number the customer will actually see printed on the instrument. "Auto-Selected
Leaflet" named the stock item rather than the draft, so it was not obvious that this
is the DD number being issued. -->
<div class="summary-col"> <div class="summary-col">
<span class="label">Auto-Selected Leaflet</span> <span class="label">DD Number Being Issued</span>
<strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong> <strong class="dd-number-highlight">{{ data.leaflet.ddNumber }}</strong>
<span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span> <span class="sublabel">{{ data.sponsorBankName || 'Sponsor Bank' }} · {{ data.branchName }}</span>
</div> </div>
@@ -234,7 +237,7 @@ export interface CashierIssueDdDialogData {
<mat-dialog-actions class="dd-dialog-actions" align="end"> <mat-dialog-actions class="dd-dialog-actions" align="end">
<button mat-button type="button" (click)="onClose()">Cancel</button> <button mat-button type="button" (click)="onClose()">Cancel</button>
<button mat-flat-button color="primary" [disabled]="form.invalid" (click)="onSubmit()"> <button mat-flat-button color="primary" [disabled]="form.invalid" (click)="onSubmit()">
Submit for Manager Approval Save DD Details
</button> </button>
</mat-dialog-actions> </mat-dialog-actions>
</div> </div>
@@ -178,7 +178,7 @@
(row.dbCr = 'Cr') (row.dbCr = 'Cr')
" "
> >
@for (a of acTypOptions; track a) { @for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option> <option [value]="a">{{ a }}</option>
} }
</select> </select>
@@ -432,8 +432,14 @@
: 'Auto-selects next available leaflet and opens walk-in purchaser 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> <mat-icon style="font-size: 18px; width: 18px; height: 18px">{{
<span>Fill DD Details &amp; Send for Approval</span> row.ddDetails ? 'edit_note' : 'post_add'
}}</mat-icon>
<!-- Names the leaflet once details are captured, so the clerk can see at a
glance which rows are ready before pressing Save. -->
<span>{{
row.ddDetails ? 'DD ' + row.ddDetails.ddNumber + ' · Edit Details' : 'Fill DD Details'
}}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -1529,7 +1535,7 @@
(row.dbCr = 'Cr') (row.dbCr = 'Cr')
" "
> >
@for (a of acTypOptions; track a) { @for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option> <option [value]="a">{{ a }}</option>
} }
</select> </select>
@@ -1756,7 +1762,7 @@
" "
> >
<mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon> <mat-icon style="font-size: 18px; width: 18px; height: 18px">post_add</mat-icon>
<span>Fill DD Details &amp; Send for Approval</span> <span>Fill DD Details</span>
</button> </button>
</div> </div>
</div> </div>
@@ -57,6 +57,27 @@ interface TxnRow {
* account — governs the share redemption (see submitShareRedemptionTransfer()). */ * account — governs the share redemption (see submitShareRedemptionTransfer()). */
resolutionNumber?: string; resolutionNumber?: string;
meetingDate?: string; meetingDate?: string;
/**
* Beneficiary and leaflet captured in the DD dialog, held on the row until Save.
*
* The dialog used to submit the request itself, which left the worksheet's own Save button doing
* nothing for a DD row — the clerk filled the dialog, the request was already gone, and pressing
* Save afterwards had no effect. Filling in details and committing them are two different
* actions, so the dialog now only records what was entered and Save is what sends it.
*/
ddDetails?: {
leafletId: number;
ddNumber: string;
beneficiaryName: string;
payableAtBranch: string;
payableAtCity: string;
/** Present only for a walk-in cash draft. */
purchaser?: { name: string; address: string; aadhaar: string };
/** Note-and-coin breakdown, cash drafts only. */
denominations?: { [key: string]: number };
fundingAccountId?: number;
fundingClientId?: number;
};
} }
interface Denomination { interface Denomination {
@@ -1671,6 +1692,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
'GL', 'GL',
'LA' 'LA'
]; ];
/**
* Account types a clerk may fund a demand draft from.
*
* A clerk raises a draft against a member's own balance, so the only sensible sources are the
* accounts a member actually holds money in. GL was offered here too, and picking it quietly
* switched the row onto the cash path — the one that asks for a walk-in purchaser's name and a
* note-and-coin count, neither of which a clerk debiting an account has. The option is removed
* rather than validated afterwards, so the wrong path cannot be reached at all.
*/
private static readonly DD_CLERK_ACTYPS = [
'SD',
'CA'
];
/**
* The account types this particular row may use.
*
* Every other row keeps the full list; only a clerk's DD row is narrowed, because that is the
* only row where the account type silently decides which funding flow runs.
*/
acTypOptionsFor(row: TxnRow): string[] {
if (this.isClerk && row.typ === 'DD') {
return TransactionEntryComponent.DD_CLERK_ACTYPS;
}
return this.acTypOptions;
}
dbCrOptions = [ dbCrOptions = [
'Dr', 'Dr',
'Cr' 'Cr'
@@ -2230,12 +2278,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// panel never appears and the row would post as an ordinary entry with the charges silently // panel never appears and the row would post as an ordinary entry with the charges silently
// dropped. // dropped.
// //
// A clerk funds a draft through an account or an instrument instead, and their TXN TP. list no // A clerk funds a draft by debiting a member's own account, so their row opens on savings and
// longer offers cash, so defaulting to it would open the field on a value it cannot show. // is debited, and GL is not on their AC.TP list at all (see acTypOptionsFor). Opening them on
// isDdCashGlRow() still holds for them through AC.TP = GL. // GL put the row on the cash path, which asks for a walk-in purchaser and a note-and-coin
// count that a clerk debiting an account does not have.
case 'DD': case 'DD':
row.dbCr = 'Cr'; row.dbCr = this.isClerk ? 'Dr' : 'Cr';
row.acTyp = 'GL'; row.acTyp = this.isClerk ? 'SD' : 'GL';
row.paymentTypeId = this.isClerk ? this.findTransferPaymentTypeId() : this.findCashPaymentTypeId(); row.paymentTypeId = this.isClerk ? this.findTransferPaymentTypeId() : this.findCashPaymentTypeId();
row.chequeNo = ''; row.chequeNo = '';
row.cheqDate = ''; row.cheqDate = '';
@@ -2416,13 +2465,34 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
} }
}); });
// Recorded on the row, not sent. Save is what commits the worksheet, and a DD row that
// submitted itself here left that button with nothing to do — so the clerk had no way to
// review the entered details, and no way to abandon them either.
dialogRef.afterClosed().subscribe((result) => { dialogRef.afterClosed().subscribe((result) => {
if (!result) return; if (!result) return;
if (accountDebit) { row.ddDetails = {
this.submitAccountDebitDd(row, leaflet, result, fundingAccountId!, fundingClientId!); leafletId: leaflet.id,
} else { ddNumber: leaflet.ddNumber,
this.submitWalkinCashDd(row, leaflet, result); beneficiaryName: result.beneficiaryName,
} payableAtBranch: result.payableAtBranch,
payableAtCity: result.payableAtCity,
purchaser: accountDebit
? undefined
: {
name: result.walkinPurchaserName,
address: result.walkinPurchaserAddress,
aadhaar: result.walkinPurchaserAadhaar
},
fundingAccountId: accountDebit ? fundingAccountId! : undefined,
fundingClientId: accountDebit ? fundingClientId! : undefined
};
this.showAlert('success', 'DD Details Captured', [
`DD No. ${leaflet.ddNumber} · in favour of ${result.beneficiaryName}.`,
accountDebit
? `₹${this.ddTotalPayableFor(row)} will be debited from ${row.acNo} once a checker approves.`
: `₹${this.ddTotalPayableFor(row)} to be collected at the counter.`,
'Press Save to send this draft for approval.'
]);
}); });
}, },
error: (err) => { error: (err) => {
@@ -2480,6 +2550,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
row.amount = null; row.amount = null;
row.ddCharges = null; row.ddCharges = null;
row.description = ''; row.description = '';
// Cleared with the row: leaving the captured details behind would let a second Save raise
// the same draft again against a leaflet that has already been used.
row.ddDetails = undefined;
}, },
error: (err) => { error: (err) => {
this.saving = false; this.saving = false;
@@ -2488,6 +2561,53 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}); });
} }
/**
* Sends the drafts on the worksheet for approval.
*
* Each row carries the beneficiary details the dialog captured; a row that was never filled in is
* refused by name rather than skipped, because a clerk who pressed Save expecting one draft and
* got none has no way of telling which row was missing.
*/
private saveDdRows(ddRows: TxnRow[]): void {
const unfilled = ddRows.filter((r) => !r.ddDetails);
if (unfilled.length > 0) {
this.showAlert('error', 'DD Details Not Entered', [
unfilled.length === 1
? 'One DD row has no beneficiary details yet.'
: `${unfilled.length} DD rows have no beneficiary details yet.`,
'Use "Fill DD Details & Send for Approval" on each DD row first, then press Save.'
]);
return;
}
for (const row of ddRows) {
const details = row.ddDetails!;
const leaflet = { id: details.leafletId, ddNumber: details.ddNumber };
if (this.isDdAccountDebitRow(row)) {
this.submitAccountDebitDd(
row,
leaflet,
{
beneficiaryName: details.beneficiaryName,
payableAtBranch: details.payableAtBranch,
payableAtCity: details.payableAtCity
},
details.fundingAccountId!,
details.fundingClientId!
);
} else {
this.submitWalkinCashDd(row, leaflet, {
beneficiaryName: details.beneficiaryName,
payableAtBranch: details.payableAtBranch,
payableAtCity: details.payableAtCity,
walkinPurchaserName: details.purchaser?.name,
walkinPurchaserAddress: details.purchaser?.address,
walkinPurchaserAadhaar: details.purchaser?.aadhaar
});
}
}
}
private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void { 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 // 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 // right but matched nothing, so every cash DD went in with no breakdown at all and its money
@@ -2557,6 +2677,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
row.amount = null; row.amount = null;
row.ddCharges = null; row.ddCharges = null;
row.description = ''; row.description = '';
// See the account-debit path: a second Save must not re-raise a draft already issued.
row.ddDetails = undefined;
this.clearDenominations(); this.clearDenominations();
}, },
error: (err) => { error: (err) => {
@@ -4361,6 +4483,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
} }
onSave(): void { onSave(): void {
// Demand drafts commit here like everything else on the worksheet. Checked first because a DD
// row shares its shape with the ordinary entries below — without this it would fall through and
// post as a plain transaction, with the draft never raised and the charges silently dropped.
const ddRows = this.rows.filter((r) => this.isDdRow(r));
if (ddRows.length > 0) {
this.saveDdRows(ddRows);
return;
}
// Clerk bank transfer — N DR savings/CA + N CR savings/CA, immediate execution. 'Fund Transfer' // Clerk bank transfer — N DR savings/CA + N CR savings/CA, immediate execution. 'Fund Transfer'
// is the same flow under a second TYPE label — onTypChange() already defaults it identically to // is the same flow under a second TYPE label — onTypChange() already defaults it identically to
// 'Enter Bank Transfer', so submission has to recognise both or a share redemption or bank // 'Enter Bank Transfer', so submission has to recognise both or a share redemption or bank
@@ -82,12 +82,13 @@
<strong class="kyc-doc-alert__title">{{ kycDocumentAlertTitle }}</strong> <strong class="kyc-doc-alert__title">{{ kycDocumentAlertTitle }}</strong>
<p class="kyc-doc-alert__text">{{ kycDocumentAlertMessage }}</p> <p class="kyc-doc-alert__text">{{ kycDocumentAlertMessage }}</p>
</div> </div>
<!-- Same dialog as the document's own row: the banner is asking for exactly that. -->
<button <button
mat-flat-button mat-flat-button
color="primary" color="primary"
type="button" type="button"
class="kyc-doc-alert__action" class="kyc-doc-alert__action"
(click)="toggleEditMode()" (click)="updateKycDocument()"
> >
Update Document Update Document
</button> </button>
@@ -470,7 +471,11 @@
} }
</span> </span>
<span class="kv-table__col kv-table__col--action"> <span class="kv-table__col kv-table__col--action">
<button mat-stroked-button class="kv-action-btn" type="button" (click)="toggleEditMode()">Update</button> <!-- The document is re-collected in its own dialog, the way PAN and Aadhaar are dealt
with on their rows, rather than by opening the whole KYC form for editing. -->
<button mat-stroked-button class="kv-action-btn" type="button" (click)="updateKycDocument()">
Update
</button>
</span> </span>
</div> </div>
} }
@@ -1188,15 +1193,83 @@
</mat-form-field> </mat-form-field>
</div> </div>
<!-- The scans live with the document itself, on the identifier the customer's <!-- The scan, where the document it belongs to is. Re-collecting an expiring
original was attached to. Re-scanning is done there rather than duplicated document is what this panel is for, so sending the officer to the client
here, so both sides, the cropper and the replace-in-place all behave the edit screen to change the image made them leave the record they were
same way they did when the document was first collected. --> working on and find the same document again. -->
<a class="kyc-doc-scans-link" [routerLink]="['/clients', clientId, 'edit']"> <div class="kyc-doc-scans">
<mat-icon>photo_library</mat-icon> <div class="kyc-doc-scans__head">
<span>Replace the scanned front / back of this document</span> <mat-icon>photo_library</mat-icon>
<mat-icon class="kyc-doc-scans-link__go">chevron_right</mat-icon> <span>Scanned document</span>
</a> @if (docScansLoading) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
}
</div>
@if (!docScansLoading && !docScans.front && !docScans.back) {
<div class="kyc-doc-scans__empty">
No scan is on file for this document. Upload one below.
</div>
}
<div class="kyc-doc-scans__sides">
@for (slot of docScanSlots; track slot) {
<div class="kyc-doc-side">
<div class="kyc-doc-side__label">{{ slot === 'front' ? 'Front' : 'Back' }}</div>
<div class="kyc-doc-side__frame">
@if (docScanOf(slot)?.previewUrl) {
<img
[src]="docScanOf(slot)!.previewUrl"
[alt]="slot === 'front' ? 'Front of document' : 'Back of document'"
/>
} @else if (docScanOf(slot)) {
<!-- On file but not an image (a PDF, say) — named rather than shown. -->
<div class="kyc-doc-side__file">
<mat-icon>description</mat-icon>
<span>{{ docScanOf(slot)!.fileName }}</span>
</div>
} @else {
<div class="kyc-doc-side__none">Not on file</div>
}
</div>
<button
type="button"
mat-stroked-button
class="kyc-doc-side__btn"
[disabled]="!!docScanUploading || docScansLoading"
(click)="scanInput.click()"
>
@if (docScanUploading === slot) {
<mat-progress-spinner diameter="16" mode="indeterminate"></mat-progress-spinner>
Uploading…
} @else {
{{ docScanOf(slot) ? 'Replace' : 'Upload' }}
}
</button>
<input
#scanInput
type="file"
hidden
accept="image/*,application/pdf"
(change)="onDocScanSelected($event, slot)"
/>
</div>
}
</div>
@if (docScanError) {
<div class="kyc-doc-scans__msg kyc-doc-scans__msg--err">
<mat-icon>error_outline</mat-icon><span>{{ docScanError }}</span>
</div>
}
@if (docScanSuccess) {
<div class="kyc-doc-scans__msg kyc-doc-scans__msg--ok">
<mat-icon>check_circle</mat-icon><span>{{ docScanSuccess }}</span>
</div>
}
</div>
</div> </div>
} }
@@ -1681,3 +1681,129 @@
.kyc-doc-scans-link__go { .kyc-doc-scans-link__go {
margin-left: auto; margin-left: auto;
} }
/* The scan itself, beside the number and dates it belongs to. */
.kyc-doc-scans {
margin-top: 10px;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.kyc-doc-scans__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
color: #475569;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #64748b;
}
}
.kyc-doc-scans__empty {
margin-bottom: 10px;
font-size: 12.5px;
color: #64748b;
}
.kyc-doc-scans__sides {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.kyc-doc-side {
display: flex;
flex-direction: column;
gap: 6px;
width: 190px;
}
.kyc-doc-side__label {
font-size: 11.5px;
font-weight: 700;
color: #334155;
}
/* Fixed height so front and back line up whatever the scan's aspect ratio is. */
.kyc-doc-side__frame {
display: flex;
align-items: center;
justify-content: center;
height: 118px;
padding: 6px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
overflow: hidden;
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 3px;
}
}
.kyc-doc-side__none {
font-size: 12px;
color: #94a3b8;
}
.kyc-doc-side__file {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 0 6px;
font-size: 11px;
color: #475569;
text-align: center;
word-break: break-word;
mat-icon {
color: #64748b;
}
}
.kyc-doc-side__btn {
font-size: 12px;
mat-progress-spinner {
display: inline-block;
margin-right: 6px;
vertical-align: middle;
}
}
.kyc-doc-scans__msg {
display: flex;
align-items: center;
gap: 6px;
margin-top: 10px;
font-size: 12.5px;
mat-icon {
font-size: 17px;
width: 17px;
height: 17px;
}
&--err {
color: #b91c1c;
}
&--ok {
color: #15803d;
}
}
@@ -11,7 +11,7 @@ import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms'; import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { catchError, of, Subject, takeUntil } from 'rxjs'; import { catchError, forkJoin, map, of, Subject, takeUntil } from 'rxjs';
/** Custom Services */ /** Custom Services */
import { ClientsService } from '../../clients.service'; import { ClientsService } from '../../clients.service';
@@ -30,6 +30,11 @@ import {
AadhaarReceiptDialogComponent, AadhaarReceiptDialogComponent,
AadhaarReceiptResult AadhaarReceiptResult
} from '../../shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component'; } from '../../shared/aadhaar-receipt-dialog/aadhaar-receipt-dialog.component';
import {
KycDocumentUpdateDialogComponent,
KycDocumentUpdateData,
KycDocumentUpdateResult
} from '../../shared/kyc-document-update-dialog/kyc-document-update-dialog.component';
import { Dates } from 'app/core/utils/dates'; import { Dates } from 'app/core/utils/dates';
import { jsPDF } from 'jspdf'; import { jsPDF } from 'jspdf';
import { MatIcon } from '@angular/material/icon'; import { MatIcon } from '@angular/material/icon';
@@ -38,6 +43,15 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** One side of the scanned KYC document, with the preview once it has been fetched. */
interface DocScanSide {
documentId: string;
/** The identifier row this scan hangs off; the two sides can sit on different rows. */
identifierId: string;
fileName: string;
previewUrl: string | null;
}
/** /**
* KYC Tab Component. * KYC Tab Component.
* Displays and manages KYC (Know Your Customer) details for an existing client. * Displays and manages KYC (Know Your Customer) details for an existing client.
@@ -149,9 +163,39 @@ export class KycTabComponent implements OnInit, OnDestroy {
exceptionReason: [[]] exceptionReason: [[]]
}); });
// ── Officially valid document scans ────────────────────────────────────────
/**
* The scans of the document named above, by side.
*
* The screen used to show the document's number and dates but not the image behind them, and
* sent the officer to the client edit screen to change it. Re-collecting an expiring document is
* the one thing this panel exists for, so the scan is shown and replaced here.
*/
docScans: { front: DocScanSide | null; back: DocScanSide | null } = { front: null, back: null };
/** The two sides, in display order, so the template loops rather than repeating itself. */
readonly docScanSlots: ('front' | 'back')[] = ['front', 'back'];
docScanOf(slot: 'front' | 'back') {
return this.docScans[slot];
}
/** Identifier row replacements are filed against. Null when the document has no identifier. */
private docIdentifierId: string | null = null;
/** Every identifier row carrying this document, since the sides can be split across them. */
private docIdentifierIds: string[] = [];
docScansLoading = false;
/** Which side is mid-upload, so only that button shows a spinner. */
docScanUploading: 'front' | 'back' | null = null;
docScanError: string | null = null;
docScanSuccess: string | null = null;
ngOnInit(): void { ngOnInit(): void {
this.route.parent.params.subscribe((params: any) => { this.route.parent.params.subscribe((params: any) => {
this.clientId = params['clientId']; this.clientId = params['clientId'];
// Scans are matched against the document number on the KYC record, so they are loaded once
// that record has arrived rather than alongside it.
this.loadKycData(); this.loadKycData();
this.loadClientDateOfBirth(); this.loadClientDateOfBirth();
}); });
@@ -190,6 +234,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
isException: data.isException || false, isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : [] exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
}); });
// Now that the document number is known, the scans filed under it can be found.
this.loadDocumentScans();
} }
}); });
} }
@@ -1307,6 +1353,297 @@ I further confirm that:
doc.save(`Self_Declaration_${cifNumber}_${clientName.replace(/\s/g, '_')}.pdf`); doc.save(`Self_Declaration_${cifNumber}_${clientName.replace(/\s/g, '_')}.pdf`);
} }
// ── Document scans ─────────────────────────────────────────────────────────
/**
* Finds the identifier the KYC document was filed under and loads its scans.
*
* The KYC record keeps the document's name and number but not the identifier id, so the two are
* matched on the number — that is what makes them the same document, and it survives the client
* holding several identifiers of the same type.
*/
private loadDocumentScans(): void {
if (!this.clientId) {
return;
}
this.docScansLoading = true;
this.clientsService
.getClientIdentifiers(this.clientId)
.pipe(catchError(() => of([] as any[])))
.subscribe((identifiers: any[]) => {
const wantedKey = (this.kycData?.kycDocumentNumber || '').toString().trim();
const wantedName = (this.kycData?.kycDocumentName || '').toString().trim().toLowerCase();
const norm = (s: any) => (s || '').toString().trim().toLowerCase();
// Every identifier carrying this document number, not just the first. The onboarding step
// files each side against its own identifier row, so this client's Aadhaar front sits on
// one row and the back on another under the same number - taking only the first match
// showed the front and reported the back as missing.
let matches = (identifiers || []).filter((i) => wantedKey && norm(i.documentKey) === norm(wantedKey));
if (!matches.length) {
matches = (identifiers || []).filter((i) => wantedName && norm(i.documentType?.name) === wantedName);
}
if (!matches.length) {
this.docScansLoading = false;
this.docIdentifierId = null;
this.docIdentifierIds = [];
this.docScans = { front: null, back: null };
return;
}
// Replacements are filed against the first row, so the sides stay together from here on.
this.docIdentifierId = String(matches[0].id);
this.docIdentifierIds = matches.map((m: any) => String(m.id));
this.fetchScansFor(this.docIdentifierIds);
});
}
/**
* Sides are read off the document name, which is how they were written at upload
* ("<Proof> — Front" / "— Back"). Where the name says nothing, upload order stands in, front
* first, rather than leaving a scan the officer cannot see.
*/
private fetchScansFor(identifierIds: string[]): void {
forkJoin(
identifierIds.map((id) =>
this.clientsService.getClientIdentificationDocuments(id).pipe(
// Tagged with the row it came from, because the download call needs the parent id and
// the sides can be spread across more than one.
map((docs: any) => ((docs as any[]) || []).map((d) => ({ ...d, identifierId: id }))),
catchError(() => of([] as any[]))
)
)
).subscribe((perIdentifier: any[][]) => {
this.docScansLoading = false;
// This runs again after every replace, so the previews being thrown away are revoked
// rather than left holding their blobs.
for (const old of [this.docScans.front, this.docScans.back]) {
if (old?.previewUrl) {
URL.revokeObjectURL(old.previewUrl);
}
}
// Oldest first by document id, so the newest upload for a side is the last one written and
// therefore the one that stands. Identifier order cannot do this job: a replacement is filed
// against the first row while the original sits on the second, so reading the rows in order
// let a document uploaded months ago overwrite the scan collected a moment ago - the officer
// pressed Replace, saw "replaced", and the old image came straight back.
const docs = perIdentifier.flat().sort((a, b) => Number(a.id) - Number(b.id));
const sides: typeof this.docScans = { front: null, back: null };
const unlabelled: any[] = [];
const entryOf = (d: any): DocScanSide => ({
documentId: String(d.id),
identifierId: String(d.identifierId),
fileName: d.fileName || d.name,
previewUrl: null
});
for (const d of docs) {
const text = `${d.name || ''} ${d.fileName || ''}`.toLowerCase();
// Later uploads win, so a replaced side shows the new scan rather than the original.
if (text.includes('back')) {
sides.back = entryOf(d);
} else if (text.includes('front')) {
sides.front = entryOf(d);
} else {
unlabelled.push(d);
}
}
for (const d of unlabelled) {
if (!sides.front) {
sides.front = entryOf(d);
} else if (!sides.back) {
sides.back = entryOf(d);
}
}
this.docScans = sides;
for (const key of ['front', 'back'] as const) {
if (sides[key]) {
this.loadScanPreview(sides[key]!);
}
}
});
}
private loadScanPreview(side: DocScanSide): void {
this.clientsService
.downloadClientIdentificationDocument(side.identifierId, side.documentId)
.pipe(catchError(() => of(null)))
.subscribe((blob: Blob | null) => {
if (!blob || !blob.type?.startsWith('image/')) {
return;
}
// Revoked in ngOnDestroy; a preview left behind holds the blob in memory for the life of
// the tab.
side.previewUrl = URL.createObjectURL(blob);
});
}
/**
* Replaces one side with a freshly collected scan.
*
* The new file is uploaded under the same side name, which is what the readers here and on the
* client screen key on, so the latest upload becomes the scan on show. The old file is left in
* place deliberately: a KYC document that has been re-collected is a record of what was held
* before and when it changed, and destroying it would leave the audit trail with a gap.
*/
onDocScanSelected(event: Event, slot: 'front' | 'back'): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
// Cleared so choosing the same filename twice still fires a change event.
input.value = '';
if (!file || !this.docIdentifierId) {
return;
}
this.docScanError = null;
this.docScanSuccess = null;
this.docScanUploading = slot;
const label = slot === 'front' ? 'Front' : 'Back';
const docName = this.kycForm.get('kycDocumentName')?.value || this.kycData?.kycDocumentName || 'KYC document';
const formData = new FormData();
formData.append('file', file);
formData.append('name', `${docName} — ${label}`);
formData.append('description', `Re-collected on ${new Date().toLocaleDateString('en-IN')}`);
this.clientsService.uploadClientIdentifierDocument(this.docIdentifierId, formData).subscribe({
next: () => {
this.docScanUploading = null;
this.docScanSuccess = `${label} side replaced.`;
// Re-read every row, not just the one written to: the other side still lives on its own.
this.fetchScansFor(this.docIdentifierIds);
},
error: (err: any) => {
this.docScanUploading = null;
this.docScanError =
err?.error?.defaultUserMessage || err?.error?.developerMessage || `Could not replace the ${label.toLowerCase()} side.`;
}
});
}
/**
* A verified record only accepts an amendment with a reason on it, so the dialog has to ask for
* one. 300 is VERIFIED; the code form is checked too, since either can arrive on the payload.
*/
private get isKycVerified(): boolean {
return this.kycData?.kycStatus === 300 || this.kycData?.kycStatusCode === 'clientKycStatus.verified';
}
/**
* Re-collects the officially valid document from its own row.
*
* PAN and Aadhaar are each handled on their row; this one sent the officer into edit mode on the
* whole KYC form to do the same job. The dialog asks for the number, the dates and each side of
* the scan together, which is what re-collecting a document actually is.
*/
updateKycDocument(): void {
if (!this.kycId) {
this.docScanError = 'KYC record not found. Save KYC details first.';
return;
}
this.dialog
.open(KycDocumentUpdateDialogComponent, {
width: '640px',
maxWidth: '95vw',
disableClose: true,
data: {
documentName: this.kycData?.kycDocumentName || 'KYC document',
documentNumber: this.kycData?.kycDocumentNumber || '',
issueDate: this.parseApiDate(this.kycData?.kycDocIssueDate),
expiryDate: this.parseApiDate(this.kycData?.kycDocExpiryDate),
reason: this.showKycDocumentAlert ? this.kycDocumentAlertMessage : '',
currentFront: this.docScans.front?.previewUrl || null,
currentBack: this.docScans.back?.previewUrl || null,
requiresRemarks: this.isKycVerified
} as KycDocumentUpdateData
})
.afterClosed()
.subscribe((result: KycDocumentUpdateResult | undefined) => {
if (result) {
this.persistKycDocument(result);
}
});
}
/**
* Saves the corrected details, then uploads whichever sides were re-scanned.
*
* The details go first: they are the record, and a scan attached to a document whose number was
* never corrected would file the new image under the old identity.
*/
private persistKycDocument(result: KycDocumentUpdateResult): void {
this.saving = true;
this.docScanError = null;
this.docScanSuccess = null;
const dateFormat = 'dd MMMM yyyy';
const payload: any = {
locale: 'en',
dateFormat,
kycDocumentName: this.kycData?.kycDocumentName || null,
kycDocumentNumber: result.documentNumber || null,
kycDocIssueDate: result.issueDate ? this.dateUtils.formatDate(result.issueDate, dateFormat) : null,
kycDocExpiryDate: result.expiryDate ? this.dateUtils.formatDate(result.expiryDate, dateFormat) : null
};
// The server refuses an amendment to a verified record without one, and records it as the
// reason the document changed.
if (result.remarks) {
payload.remarks = result.remarks;
}
this.clientsService.updateClientKyc(this.clientId, this.kycId!, payload).subscribe({
next: () => {
this.saving = false;
this.docScanSuccess = 'Document updated.';
this.loadKycData();
this.uploadReplacedSides(result);
},
error: (err: any) => {
this.saving = false;
this.docScanError =
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not update the document.';
}
});
}
/** Sends each re-scanned side through the same upload path the panel below uses. */
private uploadReplacedSides(result: KycDocumentUpdateResult): void {
if (!this.docIdentifierId) {
return;
}
const sides: { slot: 'front' | 'back'; file: File }[] = [];
if (result.front) {
sides.push({ slot: 'front', file: result.front });
}
if (result.back) {
sides.push({ slot: 'back', file: result.back });
}
if (!sides.length) {
return;
}
const docName = this.kycData?.kycDocumentName || 'KYC document';
forkJoin(
sides.map(({ slot, file }) => {
const formData = new FormData();
formData.append('file', file);
formData.append('name', `${docName} — ${slot === 'front' ? 'Front' : 'Back'}`);
formData.append('description', `Re-collected on ${new Date().toLocaleDateString('en-IN')}`);
return this.clientsService
.uploadClientIdentifierDocument(this.docIdentifierId!, formData)
.pipe(catchError(() => of(null)));
})
).subscribe(() => {
this.docScanSuccess = 'Document and scan updated.';
this.fetchScansFor(this.docIdentifierIds);
});
}
/** Handle file selection for signed declaration upload */ /** Handle file selection for signed declaration upload */
onDeclarationFileSelected(event: Event): void { onDeclarationFileSelected(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
@@ -1365,5 +1702,11 @@ I further confirm that:
ngOnDestroy(): void { ngOnDestroy(): void {
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); this.destroy$.complete();
// Object URLs are held by the browser until revoked, so the scan blobs would outlive the tab.
for (const side of [this.docScans.front, this.docScans.back]) {
if (side?.previewUrl) {
URL.revokeObjectURL(side.previewUrl);
}
}
} }
} }
@@ -0,0 +1,137 @@
<!--
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/.
-->
<h2 mat-dialog-title class="kdu-title">
<mat-icon>description</mat-icon>
Update {{ data.documentName }}
</h2>
<mat-dialog-content class="kdu-body">
<!-- Why this is being asked for, in the words the alert used. -->
@if (data.reason) {
<div class="kdu-reason">
<mat-icon>schedule</mat-icon>
<span>{{ data.reason }}</span>
</div>
}
<form [formGroup]="form" class="kdu-form">
<mat-form-field appearance="outline" class="kdu-field kdu-field--wide">
<mat-label>Document Number</mat-label>
<input matInput formControlName="documentNumber" autocomplete="off" />
@if (form.get('documentNumber')?.hasError('required') && form.get('documentNumber')?.touched) {
<mat-error>Document number is required</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="kdu-field">
<mat-label>Issued On</mat-label>
<input matInput [matDatepicker]="issuePicker" formControlName="issueDate" />
<mat-datepicker-toggle matSuffix [for]="issuePicker"></mat-datepicker-toggle>
<mat-datepicker #issuePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="kdu-field">
<mat-label>Valid Till</mat-label>
<input matInput [matDatepicker]="expiryPicker" formControlName="expiryDate" />
<mat-datepicker-toggle matSuffix [for]="expiryPicker"></mat-datepicker-toggle>
<mat-datepicker #expiryPicker></mat-datepicker>
<mat-hint>Blank if it does not expire</mat-hint>
</mat-form-field>
<!-- Only where the KYC record is already verified. Changing what a verified record holds is an
amendment, and the reason goes on the record with it. -->
@if (data.requiresRemarks) {
<mat-form-field appearance="outline" class="kdu-field kdu-field--wide">
<mat-label>Reason for update</mat-label>
<textarea
matInput
formControlName="remarks"
rows="2"
placeholder="e.g. Document expired — fresh copy collected from the customer"
></textarea>
<mat-hint>Recorded against the verified KYC record</mat-hint>
@if (form.get('remarks')?.hasError('required') && form.get('remarks')?.touched) {
<mat-error>A reason is required to change a verified KYC record</mat-error>
}
</mat-form-field>
}
</form>
<div class="kdu-scans">
<div class="kdu-scans__head">Scanned copy</div>
<p class="kdu-scans__hint">
Leave a side untouched to keep the scan already on file. The previous copy is kept either way.
</p>
<div class="kdu-sides">
@for (slot of slots; track slot) {
<div class="kdu-side">
<div class="kdu-side__label">
{{ slot === 'front' ? 'Front' : 'Back' }}
@if (isReplaced(slot)) {
<span class="kdu-side__new">New</span>
}
</div>
<div class="kdu-side__frame" [class.kdu-side__frame--new]="isReplaced(slot)">
@if (previewOf(slot)) {
<img [src]="previewOf(slot)" [alt]="slot === 'front' ? 'Front of document' : 'Back of document'" />
} @else if (isReplaced(slot)) {
<!-- Picked, but not an image (a PDF, say) — named rather than shown. -->
<div class="kdu-side__file">
<mat-icon>insert_drive_file</mat-icon>
<span>{{ pickedName(slot) }}</span>
</div>
} @else {
<div class="kdu-side__none">Not on file</div>
}
</div>
<div class="kdu-side__actions">
<button type="button" mat-stroked-button class="kdu-side__btn" (click)="sideInput.click()">
{{ previewOf(slot) || isReplaced(slot) ? 'Choose new scan' : 'Upload scan' }}
</button>
@if (isReplaced(slot)) {
<button
type="button"
mat-icon-button
class="kdu-side__undo"
matTooltip="Keep the scan already on file"
(click)="clearPick(slot)"
>
<mat-icon>undo</mat-icon>
</button>
}
</div>
<input
#sideInput
type="file"
hidden
accept="image/*,application/pdf"
(change)="onFilePicked($event, slot)"
/>
</div>
}
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end" class="kdu-actions">
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button
mat-flat-button
color="primary"
type="button"
[disabled]="form.invalid || !hasChanges"
[matTooltip]="!hasChanges ? 'Change a detail or add a scan first' : ''"
(click)="submit()"
>
Save Document
</button>
</mat-dialog-actions>
@@ -0,0 +1,172 @@
/**
* 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/.
*/
.kdu-title {
display: flex;
align-items: center;
gap: 8px;
mat-icon {
color: #64748b;
}
}
.kdu-body {
padding-top: 4px;
}
.kdu-reason {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 16px;
padding: 10px 12px;
border: 1px solid #fde68a;
border-radius: 6px;
background: #fffbeb;
color: #92400e;
font-size: 13px;
line-height: 1.5;
mat-icon {
flex: 0 0 auto;
font-size: 18px;
width: 18px;
height: 18px;
color: #d97706;
}
}
.kdu-form {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.kdu-field {
flex: 1 1 180px;
}
.kdu-field--wide {
flex: 1 1 100%;
}
.kdu-scans {
margin-top: 8px;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.kdu-scans__head {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
color: #475569;
}
.kdu-scans__hint {
margin: 4px 0 12px;
font-size: 12px;
color: #64748b;
}
.kdu-sides {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.kdu-side {
display: flex;
flex-direction: column;
gap: 6px;
width: 210px;
}
.kdu-side__label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: #334155;
}
/* Marks the side the officer has just re-scanned, so it is obvious what will change on save. */
.kdu-side__new {
padding: 1px 7px;
border-radius: 999px;
background: #dcfce7;
color: #15803d;
font-size: 10.5px;
font-weight: 700;
}
.kdu-side__frame {
display: flex;
align-items: center;
justify-content: center;
height: 132px;
padding: 6px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
overflow: hidden;
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 3px;
}
&--new {
border-color: #86efac;
box-shadow: 0 0 0 1px #86efac;
}
}
.kdu-side__none {
font-size: 12px;
color: #94a3b8;
}
.kdu-side__file {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 0 6px;
font-size: 11px;
color: #475569;
text-align: center;
word-break: break-word;
mat-icon {
color: #64748b;
}
}
.kdu-side__actions {
display: flex;
align-items: center;
gap: 4px;
}
.kdu-side__btn {
flex: 1;
font-size: 12px;
}
.kdu-actions {
padding: 8px 24px 20px;
gap: 8px;
}
@@ -0,0 +1,211 @@
/**
* 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, Inject, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UntypedFormBuilder, UntypedFormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** What the caller hands in: the document as it stands, and the scans already on file. */
export interface KycDocumentUpdateData {
documentName: string;
documentNumber: string;
issueDate: Date | string | null;
expiryDate: Date | string | null;
/** Why the document is being asked for, shown at the top so the officer sees the reason. */
reason: string;
/** Previews of what is currently held, so the officer can see what they are replacing. */
currentFront: string | null;
currentBack: string | null;
/**
* True when the KYC record is already verified. Changing data on one of those needs a reason on
* the record, which the server enforces - so the dialog asks for it rather than letting the
* officer fill the whole form in and be refused on submit.
*/
requiresRemarks: boolean;
}
/** What the caller gets back: the corrected details, plus whichever sides were re-scanned. */
export interface KycDocumentUpdateResult {
documentNumber: string;
issueDate: Date | null;
expiryDate: Date | null;
front: File | null;
back: File | null;
/** Why the document was re-collected. Blank unless the record was already verified. */
remarks: string;
}
/**
* Re-collects an officially valid document without leaving the KYC record.
*
* PAN and Aadhaar are each dealt with on their own row of the verification table. The OVD was the
* exception: its Update button put the whole KYC form into edit mode, so re-collecting one expiring
* document meant scrolling a form of unrelated fields and finding the same document again. The work
* is the same either way - a new number, new dates, a fresh scan of each side - so it is asked for
* in one place.
*/
@Component({
selector: 'mifosx-kyc-document-update-dialog',
templateUrl: './kyc-document-update-dialog.component.html',
styleUrls: ['./kyc-document-update-dialog.component.scss'],
standalone: true,
// The shared set carries the date adapter the two datepickers below need; the sibling dialogs
// pull it in the same way.
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
ReactiveFormsModule,
MatDialogModule,
MatButtonModule,
MatDatepickerModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatTooltipModule
]
})
export class KycDocumentUpdateDialogComponent implements OnDestroy {
private formBuilder = inject(UntypedFormBuilder);
private dialogRef = inject<MatDialogRef<KycDocumentUpdateDialogComponent>>(MatDialogRef);
form: UntypedFormGroup;
/** Newly chosen files, with a local preview so the officer sees what they picked. */
picked: { front: File | null; back: File | null } = { front: null, back: null };
pickedPreview: { front: string | null; back: string | null } = { front: null, back: null };
readonly slots: ('front' | 'back')[] = ['front', 'back'];
constructor(@Inject(MAT_DIALOG_DATA) public data: KycDocumentUpdateData) {
this.form = this.formBuilder.group({
documentNumber: [
data.documentNumber || '',
[Validators.required]
],
issueDate: [this.toDate(data.issueDate)],
// Left free rather than required: a passport expires, a PAN card does not.
expiryDate: [this.toDate(data.expiryDate)],
// Required only where the server requires it, so completing a record that was never verified
// does not demand a justification for filling in blanks.
remarks: [
'',
data.requiresRemarks ? [Validators.required] : []
]
});
}
private toDate(value: Date | string | null): Date | null {
if (!value) {
return null;
}
const d = value instanceof Date ? value : new Date(value);
return isNaN(d.getTime()) ? null : d;
}
previewOf(slot: 'front' | 'back'): string | null {
return this.pickedPreview[slot] || (slot === 'front' ? this.data.currentFront : this.data.currentBack);
}
/** True once this side has been re-scanned in this dialog, so the label can say so. */
isReplaced(slot: 'front' | 'back'): boolean {
return !!this.picked[slot];
}
pickedName(slot: 'front' | 'back'): string {
return this.picked[slot]?.name || '';
}
onFilePicked(event: Event, slot: 'front' | 'back'): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0] || null;
// Cleared so re-picking the same filename still fires a change event.
input.value = '';
if (!file) {
return;
}
if (this.pickedPreview[slot]) {
URL.revokeObjectURL(this.pickedPreview[slot]!);
}
this.picked[slot] = file;
this.pickedPreview[slot] = file.type.startsWith('image/') ? URL.createObjectURL(file) : null;
}
clearPick(slot: 'front' | 'back'): void {
if (this.pickedPreview[slot]) {
URL.revokeObjectURL(this.pickedPreview[slot]!);
}
this.picked[slot] = null;
this.pickedPreview[slot] = null;
}
/**
* Something has to change for the update to mean anything - either a corrected detail or a fresh
* scan. Submitting an untouched form would reopen a verified KYC record for no reason.
*/
get hasChanges(): boolean {
if (this.picked.front || this.picked.back) {
return true;
}
const v = this.form.value;
const sameNumber = (v.documentNumber || '').trim() === (this.data.documentNumber || '').trim();
const sameIssue = this.sameDay(v.issueDate, this.toDate(this.data.issueDate));
const sameExpiry = this.sameDay(v.expiryDate, this.toDate(this.data.expiryDate));
return !(sameNumber && sameIssue && sameExpiry);
}
private sameDay(a: Date | null, b: Date | null): boolean {
if (!a && !b) {
return true;
}
if (!a || !b) {
return false;
}
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
submit(): void {
if (this.form.invalid || !this.hasChanges) {
this.form.markAllAsTouched();
return;
}
const v = this.form.value;
const result: KycDocumentUpdateResult = {
documentNumber: (v.documentNumber || '').trim(),
issueDate: v.issueDate || null,
expiryDate: v.expiryDate || null,
front: this.picked.front,
back: this.picked.back,
remarks: (v.remarks || '').trim()
};
this.dialogRef.close(result);
}
cancel(): void {
this.dialogRef.close();
}
ngOnDestroy(): void {
// Only the previews this dialog created; the caller owns the ones it passed in.
for (const slot of this.slots) {
if (this.pickedPreview[slot]) {
URL.revokeObjectURL(this.pickedPreview[slot]!);
}
}
}
}
+29
View File
@@ -35,3 +35,32 @@ export function toIsoDate(value: any): string {
} }
return String(value).slice(0, 10); return String(value).slice(0, 10);
} }
/**
* A Fineract timestamp as a real Date, so Angular's `date` pipe can format it.
*
* LocalDateTime arrives as [y, M, d, H, m, s] — an array the pipe renders as nothing at all, which
* is how a "Raised On" column came to sit empty next to an alert that plainly had been raised.
* Month is 1-based on the wire and 0-based in Date, hence the shift.
*/
export function toApiDate(value: any): Date | null {
if (!value) {
return null;
}
if (Array.isArray(value)) {
const [
year,
month,
day,
hour = 0,
minute = 0,
second = 0
] = value;
if (!year || !month || !day) {
return null;
}
return new Date(year, month - 1, day, hour, minute, second);
}
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
+16
View File
@@ -65,6 +65,22 @@ export interface CtrTransaction {
evidenceDocType?: string; evidenceDocType?: string;
evidenceReference?: string; evidenceReference?: string;
evidencedOn?: string; evidencedOn?: string;
/**
* Account and customer facts carried alongside the transaction, read off the books when the CTR
* line is loaded.
*
* Account_Detail and Account_Person_Relation are derived from these rows, and they used to fill
* these columns with constants — every account opened on 20/05/2018, every account ACTIVE, every
* customer reference '0'. Those are statements about a customer's account in a filing, so they
* come from the record rather than from a placeholder.
*/
accountOpenedOn?: string;
accountClosedOn?: string;
accountStatus?: string;
ckycNumber?: string;
clientExternalId?: string;
individual?: boolean;
} }
/** Outcome of the Principal Officer's investigation into an STR case */ /** Outcome of the Principal Officer's investigation into an STR case */
+33 -301
View File
@@ -14,8 +14,8 @@
<div> <div>
<h2 class="fiu-head-t">Financial Intelligence Unit – India (FIU-IND)</h2> <h2 class="fiu-head-t">Financial Intelligence Unit – India (FIU-IND)</h2>
<div class="fiu-head-s"> <div class="fiu-head-s">
{{ bank.name }} &middot; {{ scopeName }} &middot; Reporting Month <strong>{{ bank.reportingMonth }}</strong> {{ bank.name }} &middot; {{ scopeName }} &middot; Reporting Month
&middot; PMLA 2002 / FINnet 2.0 <strong>{{ reportingMonthLabel }}</strong> &middot; PMLA 2002 / FINnet 2.0
</div> </div>
</div> </div>
</div> </div>
@@ -33,12 +33,13 @@
<span class="fiu-meta-v">{{ scopeName }}</span> <span class="fiu-meta-v">{{ scopeName }}</span>
} }
</div> </div>
<div> <!-- Named only when the person at the screen actually holds the role. A filing screen that
<span class="fiu-meta-l">Principal Officer</span><span class="fiu-meta-v">{{ bank.principalOfficer }}</span> names an officer who is not the one working it misstates who signed the return. -->
</div> @if (principalOfficerName) {
<div> <div>
<span class="fiu-meta-l">REBANK BUID</span><span class="fiu-meta-v mono">{{ bank.reBuid }}</span> <span class="fiu-meta-l">Principal Officer</span><span class="fiu-meta-v">{{ principalOfficerName }}</span>
</div> </div>
}
</div> </div>
</div> </div>
@@ -164,7 +165,9 @@
<div class="dc-meta"> <div class="dc-meta">
Threshold: <strong>&#8377;{{ bank.cashThreshold | number }}</strong> per single txn / aggregate per month Threshold: <strong>&#8377;{{ bank.cashThreshold | number }}</strong> per single txn / aggregate per month
</div> </div>
<div class="dc-meta">Due: <strong>15 Feb 2026</strong></div> <!-- The 15th of the month after the one being reported, worked out rather than fixed at
15 Feb 2026 — a due date that never moves stops being a due date. -->
<div class="dc-meta">Due: <strong>{{ ctrDueDateLabel }}</strong></div>
<button class="dc-btn" (click)="showTab('ctr')"><fa-icon icon="external-link-alt"></fa-icon> Open</button> <button class="dc-btn" (click)="showTab('ctr')"><fa-icon icon="external-link-alt"></fa-icon> Open</button>
</div> </div>
<div class="dc-card"> <div class="dc-card">
@@ -206,11 +209,29 @@
with the same bundle and are worked on the Customer Cards tab. with the same bundle and are worked on the Customer Cards tab.
</p> </p>
</div> </div>
<button class="fiu-btn fiu-btn-xls" (click)="exportAll()"> <button class="fiu-btn fiu-btn-xls" [disabled]="!ctrTxns.length" (click)="exportAll()">
<fa-icon icon="file-csv"></fa-icon> Download full bundle <fa-icon icon="file-csv"></fa-icon> Download full bundle
</button> </button>
</div> </div>
<!-- The grids are built from the reports the bank generated. Until one exists there is
nothing to file, and saying so is better than a screen of plausible-looking rows
nobody can submit. -->
@if (ctrLinesLoading) {
<div class="fiu-note">Loading the generated CTR lines…</div>
} @else if (ctrLinesError) {
<div class="fiu-note fiu-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}
</div>
} @else if (!ctrTxns.length) {
<div class="fiu-note fiu-note--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
No CTR has been generated yet. Pick a month under “Generate CTR” below and run it — every cash transaction
of ₹10,00,000 or more, and every customer whose smaller cash transactions add up to that within the month,
will be picked up from the books.
</div>
}
<!-- Scope: these controls decide which transactions the six files carry. --> <!-- Scope: these controls decide which transactions the six files carry. -->
<div class="fiu-filters"> <div class="fiu-filters">
<div class="fiu-filter"> <div class="fiu-filter">
@@ -450,7 +471,7 @@
<td>{{ alert.transactionType }}</td> <td>{{ alert.transactionType }}</td>
<td>{{ alert.ruleCode }} &mdash; {{ alert.ruleDescription }}</td> <td>{{ alert.ruleCode }} &mdash; {{ alert.ruleDescription }}</td>
<td class="ta-r mono">&#8377;{{ alert.amount | number: '1.0-0' }}</td> <td class="ta-r mono">&#8377;{{ alert.amount | number: '1.0-0' }}</td>
<td>{{ alert.raisedOn | date: 'medium' }}</td> <td>{{ asDate(alert.raisedOn) | date: 'medium' }}</td>
<td> <td>
<span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span> <span [class]="amlStatusClass(alert.status)">{{ alert.status }}</span>
</td> </td>
@@ -523,7 +544,7 @@
<td>{{ alert.transactionType }}</td> <td>{{ alert.transactionType }}</td>
<td>{{ alert.ruleCode }} &mdash; {{ alert.ruleDescription }}</td> <td>{{ alert.ruleCode }} &mdash; {{ alert.ruleDescription }}</td>
<td class="ta-r mono">&#8377;{{ alert.amount | number: '1.0-0' }}</td> <td class="ta-r mono">&#8377;{{ alert.amount | number: '1.0-0' }}</td>
<td>{{ alert.raisedOn | date: 'medium' }}</td> <td>{{ asDate(alert.raisedOn) | date: 'medium' }}</td>
<td>{{ alert.reviewedByUsername || '—' }}</td> <td>{{ alert.reviewedByUsername || '—' }}</td>
<td> <td>
<span class="muted">{{ alert.reviewNotes || '—' }}</span> <span class="muted">{{ alert.reviewNotes || '—' }}</span>
@@ -744,295 +765,6 @@
<fa-icon icon="file-alt"></fa-icon> Download STR XML <fa-icon icon="file-alt"></fa-icon> Download STR XML
</button> </button>
</div> </div>
<div class="fiu-filters">
<div class="fiu-filter">
<label>Status</label>
<select class="fiu-inp" [(ngModel)]="strFilterStatus">
<option value="">All</option>
<option value="draft">Draft</option>
<option value="po-review">PO Review</option>
<option value="awaiting-filing">Awaiting Filing</option>
<option value="filed">Filed</option>
<option value="rejected">Closed — Clean</option>
</select>
</div>
<div class="fiu-filter">
<label>Risk Category</label>
<select class="fiu-inp" [(ngModel)]="strFilterRisk">
<option value="">All</option>
<option value="structuring">Structuring</option>
<option value="dormant-revival">Dormant Revival</option>
<option value="high-risk-country">High-Risk Country</option>
<option value="unusual-pattern">Unusual Pattern</option>
<option value="pep-related">PEP Related</option>
<option value="sanctions-hit">Sanctions Hit</option>
</select>
</div>
<button class="fiu-btn fiu-btn-g" (click)="resetStrFilters()">Reset</button>
</div>
<div class="fiu-str-list">
@for (s of filteredStr; track s.reportRefNo) {
<div
class="str-card"
[id]="'str-card-' + s.reportRefNo"
[class.str-card--filed]="s.status === 'filed'"
[class.str-card--highlight]="highlightedStrRef === s.reportRefNo"
>
<div class="str-card-h">
<div class="str-card-id">
<span class="str-ref">STR-{{ s.reportRefNo }}</span>
<span class="status-pill" [class]="'st-' + s.status">{{ statusLabel(s.status) }}</span>
<span class="risk-pill" [class]="'rk-' + s.riskCategory">{{ riskLabel(s.riskCategory) }}</span>
</div>
<div class="str-card-amt">
<span class="muted">{{ s.depositOrWithdrawal }}</span>
<strong>&#8377;{{ s.txnAmount | number: '1.0-0' }}</strong>
</div>
</div>
<div class="str-card-body">
<div class="str-meta">
<div>
<label>Customer</label><span>{{ s.customerName }}</span>
</div>
<div>
<label>Account</label><span class="mono">{{ s.accountNumber }}</span>
</div>
<div>
<label>Txn Date</label><span>{{ s.txnDate }} {{ s.txnTime }}</span>
</div>
<div>
<label>Detected</label><span>{{ s.detectedOn }}</span>
</div>
<div>
<label>Flagged By</label><span>{{ s.flaggedBy }}</span>
</div>
<div>
<label>Instrument</label><span>{{ s.instrumentType }} {{ s.instrumentId }}</span>
</div>
<div>
<label>Branch</label><span class="mono">{{ s.branchCode }} {{ branchNameFor(s.branchCode) }}</span>
</div>
</div>
<div class="str-narrative">
<label>Narrative</label>
<p>{{ s.narrative }}</p>
</div>
@if (s.poRemarks) {
<div class="str-po-remarks">
<label><fa-icon icon="check-circle"></fa-icon> PO Remarks</label>
<p>{{ s.poRemarks }}</p>
</div>
}
@if (s.poFinding) {
<div class="str-finding" [class.str-finding--suspicious]="s.poFinding === 'SUSPICIOUS'">
<label>
<fa-icon [icon]="s.poFinding === 'CLEAN' ? 'check-circle' : 'exclamation-triangle'"></fa-icon>
Principal Officer Finding
</label>
<p>{{ poFindingLabel(s.poFinding) }}</p>
</div>
}
<!-- Concluded suspicious but not yet submitted to FIU-IND -->
@if (s.status === 'awaiting-filing') {
<div class="str-filing" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<label>
<fa-icon icon="exclamation-triangle"></fa-icon> Awaiting submission to FIU-IND — due within 7 days
of {{ s.detectedOn }}
</label>
<p class="filing-hint">
Generate the FINnet STR XML, upload it on the FIU-IND portal under
{{ bank.principalOfficer }}'s digital signature, then record the acknowledgement number here.
</p>
<div class="filing-row">
<button class="fiu-btn" (click)="exportSingleStrXml(s)">
<fa-icon icon="file-alt"></fa-icon> Generate STR XML
</button>
<input
class="fiu-inp"
type="text"
placeholder="FIU-IND acknowledgement no."
[(ngModel)]="localFileRefs[s.reportRefNo]"
/>
<button class="fiu-btn fiu-btn-xml" (click)="fileLocalStr(s)">Mark STR Filed</button>
</div>
</div>
}
@if (s.status === 'filed' && s.fiuReferenceNo) {
<div class="str-filed-ack">
<fa-icon icon="check-circle"></fa-icon>
<span>
Filed with FIU-IND on <strong>{{ s.filedOn }}</strong> &middot; acknowledgement
<strong class="mono">{{ s.fiuReferenceNo }}</strong>
</span>
</div>
}
@if (!s.poFinding && investigatingStr?.reportRefNo !== s.reportRefNo) {
<div class="str-po-action" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<label>Principal Officer investigation — is the source of funds legitimate?</label>
<button class="fiu-btn fiu-btn-xml" (click)="investigateLocalStr(s)">
<fa-icon icon="search"></fa-icon> Investigate this case
</button>
</div>
}
<!-- Investigation workspace for this case -->
@if (investigatingStr?.reportRefNo === s.reportRefNo) {
<div class="str-investigation" *mifosxHasPermission="'CONCLUDE_AML_STR'">
<div class="inv-head">
<span>Principal Officer investigation — STR-{{ s.reportRefNo }}</span>
<button class="fiu-link-btn fiu-link-btn--muted" (click)="closeLocalStrInvestigation()">
Cancel
</button>
</div>
<!-- Step 1 — call for evidence -->
<div class="inv-step">
<div class="inv-step-t"><span class="inv-num">1</span> Documentary evidence called for</div>
<div class="inv-grid">
<div class="form-row">
<label for="invDocType">Document requested from the customer</label>
<select id="invDocType" class="fiu-inp" [(ngModel)]="evidenceDocType">
<option value="">Select document…</option>
@for (d of evidenceDocTypes; track d) {
<option [value]="d">{{ d }}</option>
}
</select>
</div>
<div class="form-row">
<label for="invReceived">Was it produced?</label>
<select id="invReceived" class="fiu-inp" [(ngModel)]="evidenceReceived">
<option value="">Select…</option>
<option value="YES">Yes — complete</option>
<option value="PARTIAL">Partially — incomplete</option>
<option value="NO">No — not produced</option>
</select>
</div>
<div class="form-row">
<label for="invRef">Document / reference number</label>
<input id="invRef" class="fiu-inp" type="text" [(ngModel)]="evidenceReference" />
</div>
</div>
</div>
<!-- Step 2 — customer's account of the funds -->
<div class="inv-step">
<div class="inv-step-t"><span class="inv-num">2</span> Customer’s explanation of the source</div>
<textarea
rows="2"
[(ngModel)]="customerExplanation"
placeholder="e.g. Proceeds of a flat sold in June; sale deed produced and matched to the deposit amount and date."
></textarea>
</div>
<!-- Step 3 — verification checklist -->
<div class="inv-step">
<div class="inv-step-t">
<span class="inv-num">3</span> Verification checklist
<span class="checklist-progress" [class.done]="allAnswered">
{{ answeredCount }} / {{ strChecklist.length }} answered
</span>
</div>
<table class="checklist-tbl">
<thead>
<tr>
<th>Verification point</th>
<th class="ta-c col-yes">Genuine</th>
<th class="ta-c col-no">Not satisfied</th>
</tr>
</thead>
<tbody>
@for (item of strChecklist; track item.key) {
<tr
[class.row-yes]="strFindings[item.key] === true"
[class.row-no]="strFindings[item.key] === false"
[class.row-pending]="strFindings[item.key] === null"
>
<td>
<div class="chk-label">{{ item.label }}</div>
<div class="chk-hint">{{ item.hint }}</div>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--yes"
[class.on]="strFindings[item.key] === true"
[attr.aria-label]="'Mark ' + item.label + ' as genuine'"
(click)="setFinding(item.key, true)"
>
<fa-icon icon="check-circle"></fa-icon>
</button>
</td>
<td class="ta-c">
<button
type="button"
class="chk-box chk-box--no"
[class.on]="strFindings[item.key] === false"
[attr.aria-label]="'Mark ' + item.label + ' as not satisfied'"
(click)="setFinding(item.key, false)"
>
<fa-icon icon="exclamation-triangle"></fa-icon>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Derived outcome -->
<div
class="verdict"
[class.verdict--clear]="canConcludeLocal && derivedConclusion === 'NOT_SUSPICIOUS'"
[class.verdict--suspicious]="canConcludeLocal && derivedConclusion === 'SUSPICIOUS'"
>
@if (!canConcludeLocal) {
<fa-icon icon="info-circle"></fa-icon>
<span>
Complete all three steps to determine the outcome.
@if (!evidenceRecorded) {
<span> Evidence not yet recorded.</span>
}
@if (!allAnswered) {
<span> {{ strChecklist.length - answeredCount }} verification point(s) pending.</span>
}
</span>
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
<fa-icon icon="check-circle"></fa-icon>
<span>
<strong>Source of funds established.</strong> Case closes as clean — the transaction stays in
the CTR report and no STR is filed with FIU-IND.
</span>
} @else {
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
<strong>Source of funds not established.</strong> {{ failedFindings.length }} point(s)
unsatisfied — an STR will be filed with FIU-IND in addition to the CTR.
</span>
}
</div>
<button class="fiu-btn fiu-btn-xml" [disabled]="!canConcludeLocal" (click)="concludeLocalStr()">
{{
canConcludeLocal
? derivedConclusion === 'SUSPICIOUS'
? 'Conclude — file STR with FIU-IND'
: 'Conclude — close as clean (CTR only)'
: 'Conclude'
}}
</button>
</div>
}
</div>
</div>
} @empty {
<div class="fiu-empty-card">No STR records match the filters.</div>
}
</div>
</div> </div>
} }
+11
View File
@@ -1086,6 +1086,17 @@
flex-shrink: 0; flex-shrink: 0;
margin-top: 2px; margin-top: 2px;
} }
/* Nothing to file yet, or the lines could not be read — not the neutral blue of an explanation. */
&--warn {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
fa-icon {
color: #d97706;
}
}
} }
/* TOAST */ /* TOAST */
+253 -32
View File
@@ -13,17 +13,14 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { AmlService } from 'app/aml/aml.service'; import { AmlService } from 'app/aml/aml.service';
import { SanctionsLogsService } from 'app/reports/sanctions-logs/sanctions-logs.service'; import { SanctionsLogsService } from 'app/reports/sanctions-logs/sanctions-logs.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service'; import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { toApiDate, toIsoDate } from 'app/core/utils/api-date';
import { import {
BRANCHES, BRANCHES,
BranchMaster, BranchMaster,
CardTransaction, CardTransaction,
CtrTransaction, CtrTransaction,
CTR_TXNS,
TC1_TXNS,
FIU_BANK, FIU_BANK,
FIU_BRANCH_CODE_TO_CANONICAL, FIU_BRANCH_CODE_TO_CANONICAL,
KC1_INDIVIDUALS,
KC2_ENTITIES,
KycEntity, KycEntity,
KycIndividual, KycIndividual,
PoFinding, PoFinding,
@@ -127,7 +124,7 @@ export const FIU_OBLIGATIONS: FiuObligation[] = [
] ]
}) })
export class FiuComponent implements OnInit { export class FiuComponent implements OnInit {
/** Bump when CTR_TXNS / STR_TXNS change so cached rows are re-seeded. */ /** Bump when the seeded STR set changes so cached rows are re-seeded. */
private static readonly SEED_VERSION = '2026-01-cash-threshold'; private static readonly SEED_VERSION = '2026-01-cash-threshold';
private amlService = inject(AmlService); private amlService = inject(AmlService);
@@ -140,11 +137,33 @@ export class FiuComponent implements OnInit {
readonly obligations = FIU_OBLIGATIONS; readonly obligations = FIU_OBLIGATIONS;
bank = FIU_BANK; bank = FIU_BANK;
ctrTxns: CtrTransaction[] = CTR_TXNS; /**
tc1Txns: CardTransaction[] = TC1_TXNS; * The CTR lines the bank has actually generated. Empty until the engine has produced a report —
* a filing screen that shows invented accounts is worse than one that shows none, because every
* count, total and CSV on it is then something nobody can submit.
*/
ctrTxns: CtrTransaction[] = [];
ctrLinesLoading = false;
ctrLinesError = '';
/**
* ATM / card-rail cash, which feeds the same CTR as the counter transactions in TC2.
*
* Empty: the sample rows that used to sit here belonged to the sample accounts in TC2, and with
* those gone they referenced accounts this bank does not hold — twelve rows claiming to reconcile
* against a file that had none. Card transactions are not yet sourced from the books, so the file
* is honestly empty rather than populated with rows that cannot be filed.
*/
tc1Txns: CardTransaction[] = [];
strTxns: StrTransaction[] = STR_TXNS; strTxns: StrTransaction[] = STR_TXNS;
kc1: KycIndividual[] = KC1_INDIVIDUALS; /**
kc2: KycEntity[] = KC2_ENTITIES; * KC1 / KC2 — the customer cards that travel with the CTR bundle.
*
* Empty for the same reason as TC1: these described sample customers of sample accounts. A card
* naming a person this bank has never onboarded cannot be filed, and leaving them in would put
* invented customers into a bundle an officer might submit.
*/
kc1: KycIndividual[] = [];
kc2: KycEntity[] = [];
// -------------------- Head Office vs Branch scope -------------------- // -------------------- Head Office vs Branch scope --------------------
// CTR/STR filing itself is a bank-wide Principal-Officer function (one filing for the whole // CTR/STR filing itself is a bank-wide Principal-Officer function (one filing for the whole
@@ -155,6 +174,12 @@ export class FiuComponent implements OnInit {
readonly branches = BRANCHES; readonly branches = BRANCHES;
isHeadOffice = false; isHeadOffice = false;
currentOfficeName = 'Branch Office'; currentOfficeName = 'Branch Office';
/** The month being filed, from the clock rather than a constant in the data file. */
reportingMonthLabel = '';
/** Blank unless the signed-in user actually holds the Principal Officer role. */
principalOfficerName = '';
/** CTR is due by the 15th of the month after the one being reported. */
ctrDueDateLabel = '';
private ownBranchCode: string | null = null; private ownBranchCode: string | null = null;
/** '' = All Branches (Head Office consolidated view); otherwise a BranchMaster.code. */ /** '' = All Branches (Head Office consolidated view); otherwise a BranchMaster.code. */
branchFilter = ''; branchFilter = '';
@@ -372,6 +397,21 @@ export class FiuComponent implements OnInit {
const creds = this.authenticationService.getCredentials(); const creds = this.authenticationService.getCredentials();
this.isHeadOffice = this.authenticationService.isHeadOfficeUser(); this.isHeadOffice = this.authenticationService.isHeadOfficeUser();
this.currentOfficeName = creds?.officeName || 'Branch Office'; this.currentOfficeName = creds?.officeName || 'Branch Office';
// The header named a hard-coded officer and a hard-coded month — "Karthik Kamath" and
// "January 2026" — on a screen whose whole purpose is to say which month is being filed and
// by whom. Both now come from the session and the clock.
const today = new Date();
this.reportingMonthLabel = today.toLocaleDateString('en-IN', { month: 'long', year: 'numeric' });
// The 15th of the following month, which is the PMLA deadline for the month being reported.
this.ctrDueDateLabel = new Date(today.getFullYear(), today.getMonth() + 1, 15).toLocaleDateString('en-IN', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
this.principalOfficerName = this.authenticationService.isPrincipalOfficerUser()
? creds?.staffDisplayName || creds?.username || 'Principal Officer'
: '';
if (this.isHeadOffice) { if (this.isHeadOffice) {
this.branchFilter = ''; this.branchFilter = '';
} else { } else {
@@ -384,22 +424,17 @@ export class FiuComponent implements OnInit {
this.loadAmlAlerts(); this.loadAmlAlerts();
this.loadAmlCtrReports(); this.loadAmlCtrReports();
this.loadAmlStrCases(); this.loadAmlStrCases();
// The CTR grids are built from the reports the bank generated, not from a sample set. Loaded
// after the cached rows below so a fresh read always wins over whatever was stored last.
this.loadCtrLines();
// Bumping SEED_VERSION discards previously cached rows so a new data set takes effect. // Bumping SEED_VERSION discards previously cached rows so a new data set takes effect.
if (localStorage.getItem('fiu_seed_version') !== FiuComponent.SEED_VERSION) { if (localStorage.getItem('fiu_seed_version') !== FiuComponent.SEED_VERSION) {
localStorage.setItem('fiu_seed_version', FiuComponent.SEED_VERSION); localStorage.setItem('fiu_seed_version', FiuComponent.SEED_VERSION);
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns)); localStorage.setItem('fiu_str_cases', JSON.stringify(this.strTxns));
return; return;
} }
const storedCtr = localStorage.getItem('fiu_ctr_txns');
if (storedCtr) {
this.ctrTxns = JSON.parse(storedCtr);
} else {
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns));
}
const storedStr = localStorage.getItem('fiu_str_cases'); const storedStr = localStorage.getItem('fiu_str_cases');
if (storedStr) { if (storedStr) {
this.strTxns = JSON.parse(storedStr); this.strTxns = JSON.parse(storedStr);
@@ -442,7 +477,7 @@ export class FiuComponent implements OnInit {
txn.evidenceDocType = this.ctrEvidenceDocType; txn.evidenceDocType = this.ctrEvidenceDocType;
txn.evidenceReference = this.ctrEvidenceRef.trim(); txn.evidenceReference = this.ctrEvidenceRef.trim();
txn.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-'); txn.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); this.storeCtrCompliance();
this.evidencingTxn = null; this.evidencingTxn = null;
this.toast(`${txn.customerName} — evidence recorded. Reported in CTR only, no STR referral.`); this.toast(`${txn.customerName} — evidence recorded. Reported in CTR only, no STR referral.`);
} }
@@ -453,7 +488,7 @@ export class FiuComponent implements OnInit {
*/ */
referCtrTxnToStr(txn: CtrTransaction): void { referCtrTxnToStr(txn: CtrTransaction): void {
txn.complianceStatus = 'REFERRED'; txn.complianceStatus = 'REFERRED';
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); this.storeCtrCompliance();
const newRef = Math.max(100, ...this.strTxns.map((s) => s.reportRefNo)) + 1; const newRef = Math.max(100, ...this.strTxns.map((s) => s.reportRefNo)) + 1;
const newStrCase: StrTransaction = { const newStrCase: StrTransaction = {
@@ -521,7 +556,7 @@ export class FiuComponent implements OnInit {
origin.evidenceDocType = this.evidenceDocType; origin.evidenceDocType = this.evidenceDocType;
origin.evidenceReference = this.evidenceReference.trim(); origin.evidenceReference = this.evidenceReference.trim();
origin.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-'); origin.evidencedOn = new Date().toLocaleDateString('en-GB').replace(/\//g, '-');
localStorage.setItem('fiu_ctr_txns', JSON.stringify(this.ctrTxns)); this.storeCtrCompliance();
} }
this.toast(`STR-${rec.reportRefNo} closed as clean — CTR only, no STR filed`); this.toast(`STR-${rec.reportRefNo} closed as clean — CTR only, no STR filed`);
} else { } else {
@@ -598,6 +633,129 @@ export class FiuComponent implements OnInit {
this.amlService.getStrCases().subscribe((data) => (this.amlStrCases = data || [])); this.amlService.getStrCases().subscribe((data) => (this.amlStrCases = data || []));
} }
/**
* Fineract sends timestamps as [y, M, d, H, m, s]. Angular's date pipe renders an array as
* nothing, which left "Raised On" blank beside alerts that had obviously been raised.
*/
asDate(value: any): Date | null {
return toApiDate(value);
}
/**
* Loads the generated CTR lines and shapes them into the TC2 rows every grid on this tab derives
* from — Account_Detail, Account_Person_Relation and TC1 are all computed off `ctrTxns`, so the
* one conversion here puts real data behind all four files.
*/
loadCtrLines(): void {
this.ctrLinesLoading = true;
this.ctrLinesError = '';
this.amlService.getCtrReportLines().subscribe({
next: (lines) => {
this.ctrLinesLoading = false;
this.ctrTxns = (lines || []).map((l: any, i: number) => this.toCtrTransaction(l, i + 1));
// Cached so the compliance flags an officer sets below survive a reload, keyed on the rows
// that actually came back rather than on a seeded set.
this.applyStoredCtrCompliance();
},
error: (err: any) => {
this.ctrLinesLoading = false;
this.ctrTxns = [];
this.ctrLinesError =
err?.error?.defaultUserMessage || 'Could not load the generated CTR lines. Generate a report for the month first.';
}
});
}
/**
* One reportable line as a TC2 row.
*
* A connected-group line is a month of smaller cash movements by one customer that together cross
* the threshold, so it has no single transaction, account or date behind it. It is still
* reportable, and is shown as the aggregate it is rather than dropped.
*/
private toCtrTransaction(line: any, refNo: number): CtrTransaction {
const branchCode = this.branchCodeOfName(line.branchName) || '';
return {
reportType: 'CTR',
reportRefNo: refNo,
branchCode,
accountNumber: line.accountNo || '',
txnBranchCode: branchCode,
depositOrWithdrawal: line.direction === 'Withdrawal' ? 'Withdrawal' : 'Deposit',
txnId: line.transactionId || line.id,
txnDate: toIsoDate(line.transactionDate),
txnTime: '',
txnAmount: Number(line.amount) || 0,
instrumentType: 'Cash',
instrumentId: 'CASH',
nonCustomerRef: '',
relationshipFlag: 'Customer',
thirdPartyPan: '',
declaration: line.connectedGroup ? 'Connected transactions aggregated for the month' : '',
customerName: line.clientName || '',
complianceStatus: 'PENDING',
accountOpenedOn: toIsoDate(line.accountOpenedOn),
accountClosedOn: toIsoDate(line.accountClosedOn),
accountStatus: line.accountStatus || '',
ckycNumber: line.ckycNumber || '',
clientExternalId: line.clientExternalId || '',
individual: line.individual !== false
};
}
/** Branch master code for an office name, so the branch filter on this tab still works. */
private branchCodeOfName(name: string | null): string | null {
if (!name) {
return null;
}
const wanted = name.trim().toLowerCase();
return this.branches.find((b: BranchMaster) => wanted.includes(b.name.toLowerCase()))?.code || null;
}
/**
* Re-applies the evidence an officer recorded against these transactions.
*
* The rows themselves come from the server every load; only the counter-side compliance fields
* are held locally, so they are matched back on transaction id rather than by replacing the row.
*/
private applyStoredCtrCompliance(): void {
const stored = localStorage.getItem('fiu_ctr_compliance');
if (!stored) {
return;
}
try {
const byTxnId: Record<string, any> = JSON.parse(stored);
for (const t of this.ctrTxns) {
const saved = byTxnId[String(t.txnId)];
if (saved) {
t.complianceStatus = saved.complianceStatus;
t.evidenceDocType = saved.evidenceDocType;
t.evidenceReference = saved.evidenceReference;
t.evidencedOn = saved.evidencedOn;
}
}
} catch {
// A corrupted cache must not take the filing screen down with it.
localStorage.removeItem('fiu_ctr_compliance');
}
}
/** Persists only the compliance fields, keyed on transaction id. */
private storeCtrCompliance(): void {
const byTxnId: Record<string, any> = {};
for (const t of this.ctrTxns) {
if (t.complianceStatus && t.complianceStatus !== 'PENDING') {
byTxnId[String(t.txnId)] = {
complianceStatus: t.complianceStatus,
evidenceDocType: t.evidenceDocType,
evidenceReference: t.evidenceReference,
evidencedOn: t.evidencedOn
};
}
}
localStorage.setItem('fiu_ctr_compliance', JSON.stringify(byTxnId));
}
/** Surfaces a failed backend call — without this a rejected request looks like a dead button. */ /** Surfaces a failed backend call — without this a rejected request looks like a dead button. */
private apiError(action: string) { private apiError(action: string) {
return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`); return (err: any) => this.toast(err?.error?.defaultUserMessage || `${action} failed — please retry`);
@@ -654,6 +812,9 @@ export class FiuComponent implements OnInit {
next: () => { next: () => {
this.toast(`CTR report generated for ${this.ctrGenerateMonth}`); this.toast(`CTR report generated for ${this.ctrGenerateMonth}`);
this.loadAmlCtrReports(); this.loadAmlCtrReports();
// The grids above are built from these lines, so they are re-read rather than left showing
// the state before the run.
this.loadCtrLines();
}, },
error: this.apiError('CTR report generation') error: this.apiError('CTR report generation')
}); });
@@ -1016,7 +1177,9 @@ export class FiuComponent implements OnInit {
t.txnBranchCode, t.txnBranchCode,
t.depositOrWithdrawal, t.depositOrWithdrawal,
t.txnId.toString(), t.txnId.toString(),
t.txnDate.replace(/-/g, '/'), // dd/MM/yyyy, as FINnet prints it. A straight slash-swap on an ISO date gave 2026/09/11,
// which reads as a different date to anyone expecting the prescribed format.
this.toFinnetDate(t.txnDate),
t.txnTime, t.txnTime,
t.txnAmount.toString(), t.txnAmount.toString(),
t.instrumentType, t.instrumentType,
@@ -1254,6 +1417,19 @@ export class FiuComponent implements OnInit {
return { headers, rows }; return { headers, rows };
} }
/** FINnet prints dates as dd/MM/yyyy; the API sends them as yyyy-MM-dd. Blank stays blank. */
private toFinnetDate(iso: string | undefined): string {
if (!iso) {
return '';
}
const [
y,
m,
d
] = iso.split('-');
return y && m && d ? `${d}/${m}/${y}` : '';
}
/** Account_Detail — per-report account aggregates (12-month) */ /** Account_Detail — per-report account aggregates (12-month) */
private buildAccountDetail(): FinnetTable { private buildAccountDetail(): FinnetTable {
const headers = [ const headers = [
@@ -1290,12 +1466,15 @@ export class FiuComponent implements OnInit {
rows.push([ rows.push([
t.reportType, t.reportType,
t.reportRefNo.toString(), t.reportRefNo.toString(),
// 212 is FINnet's savings-account code; every CTR line this module produces is a savings
// transaction, and a loan line would need its own code before it could be filed.
'212', '212',
t.accountNumber, t.accountNumber,
t.branchCode, t.branchCode,
'20/05/2018', // Read off the account, not assumed. Every row used to carry 20/05/2018 and ACTIVE.
'', this.toFinnetDate(t.accountOpenedOn),
'ACTIVE', this.toFinnetDate(t.accountClosedOn),
t.accountStatus || '',
'', '',
debits.length.toString(), debits.length.toString(),
debits.reduce((s, x) => s + x.txnAmount, 0).toString(), debits.reduce((s, x) => s + x.txnAmount, 0).toString(),
@@ -1333,16 +1512,16 @@ export class FiuComponent implements OnInit {
const key = `${t.reportRefNo}|${t.accountNumber}`; const key = `${t.reportRefNo}|${t.accountNumber}`;
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
// Find matching KC1 by reportRefNo (best-effort dummy mapping)
const k = this.kc1.find((x) => x.reportRefNo === t.reportRefNo);
rows.push([ rows.push([
t.reportType, t.reportType,
t.reportRefNo.toString(), t.reportRefNo.toString(),
t.branchCode, t.branchCode,
t.accountNumber, t.accountNumber,
'1', '1',
k ? k.ucic : '0', // The customer's own reference: their CKYC number where one is on file, else the external
'Individual', // id the bank knows them by. This used to read '0' for every row, which identified nobody.
t.ckycNumber || t.clientExternalId || '',
t.individual === false ? 'Non-individual' : 'Individual',
t.relationshipFlag === 'Non-Customer' ? t.customerName || '' : '' t.relationshipFlag === 'Non-Customer' ? t.customerName || '' : ''
]); ]);
} }
@@ -1488,14 +1667,56 @@ export class FiuComponent implements OnInit {
} }
/** STR XML stub (FINnet 2.0 STR XML) */ /** STR XML stub (FINnet 2.0 STR XML) */
/**
* Writes the STR XML from the cases the Principal Officer has actually concluded as suspicious.
*
* It used to be built from the sample set, so the file named accounts and customers this bank
* does not hold — a return that could not be filed and should not have been offered.
*/
exportStrXml(): void { exportStrXml(): void {
const suspects = this.strSuspects; const suspects = (this.amlStrCases || []).filter(
(c: any) => c.conclusion === 'SUSPICIOUS' || c.status === 'CONCLUDED_SUSPICIOUS' || c.status === 'FILED'
);
if (!suspects.length) { if (!suspects.length) {
this.toast('No suspect cases to export — nothing matches the current filters'); this.toast('No case has been concluded suspicious yet — nothing to file');
return; return;
} }
this.writeStrXml(suspects, `STR-${this.bank.reportingMonth.replace(/\s+/g, '-')}.xml`); this.writeLiveStrXml(suspects, `STR-${this.reportingMonthLabel.replace(/\s+/g, '-')}.xml`);
this.toast(`STR XML exported (${suspects.length} suspects)`); this.toast(`STR XML exported (${suspects.length} case${suspects.length === 1 ? '' : 's'})`);
}
/** Serialises the concluded cases as FINnet 2.0 STR XML and downloads it. */
private writeLiveStrXml(cases: any[], filename: string): void {
const xml: string[] = [];
xml.push('<?xml version="1.0" encoding="UTF-8"?>');
xml.push(`<STR xmlns="http://fiuind.gov.in/finnet2/str" reportingMonth="${this.escapeXml(this.reportingMonthLabel)}">`);
xml.push(` <REBank name="${this.escapeXml(this.bank.name)}" />`);
if (this.principalOfficerName) {
xml.push(` <PrincipalOfficer name="${this.escapeXml(this.principalOfficerName)}" />`);
}
for (const c of cases) {
xml.push(` <Suspect ref="${c.id}" status="${this.escapeXml(c.status || '')}">`);
xml.push(` <Customer id="${c.clientId}">${this.escapeXml(c.clientDisplayName || '')}</Customer>`);
xml.push(` <Conclusion>${this.escapeXml(c.conclusion || '')}</Conclusion>`);
if (c.investigationNotes) {
xml.push(` <Narrative>${this.escapeXml(c.investigationNotes)}</Narrative>`);
}
if (c.sourceOfFundsNotes) {
xml.push(` <SourceOfFunds>${this.escapeXml(c.sourceOfFundsNotes)}</SourceOfFunds>`);
}
if (c.fiuReferenceNo) {
xml.push(` <Acknowledgement ref="${this.escapeXml(c.fiuReferenceNo)}" filedOn="${c.filedOn ?? ''}" />`);
}
xml.push(' </Suspect>');
}
xml.push('</STR>');
const blob = new Blob([xml.join('\n')], { type: 'application/xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} }
/** Serialises a set of suspect cases as FINnet 2.0 STR XML and downloads it. */ /** Serialises a set of suspect cases as FINnet 2.0 STR XML and downloads it. */
@@ -51,15 +51,6 @@
<span>Could not load the review queue.</span> <span>Could not load the review queue.</span>
</div> </div>
<!-- Named on screen, because sample rows must never be mistaken for engine output on an AML queue -->
<div *ngIf="usingDemoData && rows.data.length > 0" class="ra-demo">
<mat-icon>science</mat-icon>
<span>
Showing <strong>sample data</strong> — the AML engine returned no recurring alerts for this threshold and window.
These rows are for demonstration only and are not reportable.
</span>
</div>
<table mat-table [dataSource]="rows" class="ra-table mat-elevation-z1" multiTemplateDataRows> <table mat-table [dataSource]="rows" class="ra-table mat-elevation-z1" multiTemplateDataRows>
<ng-container matColumnDef="customer"> <ng-container matColumnDef="customer">
<th mat-header-cell *matHeaderCellDef>Customer</th> <th mat-header-cell *matHeaderCellDef>Customer</th>
@@ -79,27 +79,6 @@
font-size: 13px; font-size: 13px;
} }
.ra-demo {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
margin-bottom: 12px;
border: 1px solid #fde68a;
border-radius: 6px;
background: #fffbeb;
color: #92400e;
font-size: 12.5px;
line-height: 1.5;
mat-icon {
flex: 0 0 auto;
font-size: 18px;
width: 18px;
height: 18px;
}
}
.ra-table { .ra-table {
width: 100%; width: 100%;
border-collapse: separate; border-collapse: separate;
@@ -25,7 +25,6 @@ import { AmlService } from '../../../aml/aml.service';
import { AlertService } from '../../../core/alert/alert.service'; import { AlertService } from '../../../core/alert/alert.service';
import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive'; import { HasPermissionDirective } from '../../../directives/has-permission/has-permission.directive';
import { AmlRecurringReviewDialogComponent } from './aml-recurring-review-dialog.component'; import { AmlRecurringReviewDialogComponent } from './aml-recurring-review-dialog.component';
import { amlRecurringAlertsDemoData } from './aml-recurring-alerts.demo-data';
/** /**
* Customers whose transactions keep tripping the AML engine, queued for an employee to review why. * Customers whose transactions keep tripping the AML engine, queued for an employee to review why.
@@ -95,8 +94,6 @@ export class AmlRecurringAlertsComponent implements OnInit {
loading = false; loading = false;
loadFailed = false; loadFailed = false;
/** Set when the rows on screen are the sample set rather than engine output. */
usingDemoData = false;
/** Which customer's alert detail is expanded inline. */ /** Which customer's alert detail is expanded inline. */
expandedClientId: number | null = null; expandedClientId: number | null = null;
@@ -111,20 +108,20 @@ export class AmlRecurringAlertsComponent implements OnInit {
this.amlService.getRecurringAlerts(this.minAlerts, this.windowDays, this.onlyNeedingReview).subscribe({ this.amlService.getRecurringAlerts(this.minAlerts, this.windowDays, this.onlyNeedingReview).subscribe({
next: (data) => { next: (data) => {
this.loading = false; this.loading = false;
const rows = data || []; this.rows.data = this.mapRows(data || []);
this.usingDemoData = rows.length === 0;
this.rows.data = this.mapRows(this.usingDemoData ? this.demoRows() : rows);
}, },
error: () => { error: () => {
this.loading = false; this.loading = false;
this.loadFailed = true; this.loadFailed = true;
this.usingDemoData = true; // An AML review queue shows what the engine found or nothing at all. Standing in sample
this.rows.data = this.mapRows(this.demoRows()); // customers when the call fails invents suspicious activity against names that are not
// the bank's, which is worse than an empty table saying the load failed.
this.rows.data = [];
} }
}); });
} }
/** Parses the date fields the template renders. Shared by engine rows and the sample set. */ /** Parses the date fields the template renders. */
private mapRows(rows: any[]): any[] { private mapRows(rows: any[]): any[] {
return rows.map((r: any) => ({ return rows.map((r: any) => ({
...r, ...r,
@@ -135,36 +132,6 @@ export class AmlRecurringAlertsComponent implements OnInit {
})); }));
} }
/**
* The sample set narrowed by the on-screen controls, so the threshold and window selectors behave
* the way they would against the engine instead of returning the same three rows regardless.
*/
private demoRows(): any[] {
const cutoff = Date.now() - this.windowDays * 24 * 60 * 60 * 1000;
return amlRecurringAlertsDemoData()
.map((row: any) => {
const alerts = (row.alerts || []).filter((a: any) => new Date(a.raisedOn).getTime() >= cutoff);
const ruleCodes = Array.from(new Set(alerts.map((a: any) => a.ruleCode)));
return {
...row,
alerts,
alertCount: alerts.length,
openCount: alerts.filter((a: any) => a.status === 'OPEN').length,
escalatedCount: alerts.filter((a: any) => a.status === 'ESCALATED').length,
// Severity sits on the alert server-side; the sample rows carry a row total, so it is
// capped at what survived the window rather than recomputed.
highRiskCount: Math.min(row.highRiskCount, alerts.length),
distinctRules: ruleCodes.length,
ruleCodes,
totalAmount: alerts.reduce((sum: number, a: any) => sum + (Number(a.amount) || 0), 0),
firstAlertOn: alerts.length ? alerts[0].raisedOn : null,
lastAlertOn: alerts.length ? alerts[alerts.length - 1].raisedOn : null
};
})
.filter((row: any) => row.alertCount >= this.minAlerts)
.filter((row: any) => !this.onlyNeedingReview || row.needsReview);
}
toggleExpanded(row: any): void { toggleExpanded(row: any): void {
this.expandedClientId = this.expandedClientId === row.clientId ? null : row.clientId; this.expandedClientId = this.expandedClientId === row.clientId ? null : row.clientId;
} }
@@ -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/.
*/
/**
* Sample recurring-alert rows for the AML review queue, used only when the engine returns nothing
* for the selected window so the screen can be demonstrated and reviewed.
*
* Shaped exactly like the `/aml/recurring-alerts` response — dates as ISO strings, alerts nested —
* so the rows travel through the component's normal mapping rather than a second code path.
*
* Dates are generated relative to today rather than hard-coded, otherwise every row would fall
* outside the "last 90 days" window as soon as the file aged.
*/
/** ISO timestamp `days` before now, at the given hour and minute. */
function daysAgo(days: number, hour = 11, minute = 20): string {
const d = new Date();
d.setDate(d.getDate() - days);
d.setHours(hour, minute, 0, 0);
return d.toISOString();
}
/**
* Three customers covering the three review states the table renders: never reviewed, reviewed but
* flagged again since, and reviewed with an outcome recorded.
*/
export function amlRecurringAlertsDemoData(): any[] {
return [
// Never reviewed — the row the queue exists to surface.
{
clientId: 10078,
clientDisplayName: 'Ravi Kumar Sharma',
accountNo: '000000010078',
clientRiskCategory: 3,
alertCount: 5,
highRiskCount: 3,
openCount: 4,
escalatedCount: 1,
distinctRules: 3,
ruleCodes: [
'CASH_TXN_10L',
'STRUCTURING',
'RAPID_MOVEMENT'
],
totalAmount: 4785000,
firstAlertOn: daysAgo(62, 10, 5),
lastAlertOn: daysAgo(3, 15, 42),
needsReview: true,
lastReviewedOn: null,
alertCountAtLastReview: null,
lastReviewOutcome: null,
lastReviewedByUsername: null,
lastReviewNotes: null,
alerts: [
{
alertId: 90121,
raisedOn: daysAgo(62, 10, 5),
transactionType: 'Savings Deposit',
transactionId: 552301,
ruleCode: 'CASH_TXN_10L',
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
amount: 1200000,
status: 'OPEN',
reviewNotes: null
},
{
alertId: 90144,
raisedOn: daysAgo(48, 12, 18),
transactionType: 'Savings Deposit',
transactionId: 553980,
ruleCode: 'STRUCTURING',
ruleDescription: 'Multiple cash deposits just below the reporting threshold within 48 hours',
amount: 890000,
status: 'OPEN',
reviewNotes: null
},
{
alertId: 90190,
raisedOn: daysAgo(29, 9, 55),
transactionType: 'Savings Withdrawal',
transactionId: 556114,
ruleCode: 'RAPID_MOVEMENT',
ruleDescription: 'Funds withdrawn within 24 hours of a large credit',
amount: 1150000,
status: 'ESCALATED',
reviewNotes: 'Escalated to Principal Officer — source of funds not established.'
},
{
alertId: 90233,
raisedOn: daysAgo(14, 16, 30),
transactionType: 'Savings Deposit',
transactionId: 558702,
ruleCode: 'CASH_TXN_10L',
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
amount: 1045000,
status: 'OPEN',
reviewNotes: null
},
{
alertId: 90275,
raisedOn: daysAgo(3, 15, 42),
transactionType: 'Savings Withdrawal',
transactionId: 560488,
ruleCode: 'STRUCTURING',
ruleDescription: 'Multiple cash deposits just below the reporting threshold within 48 hours',
amount: 500000,
status: 'OPEN',
reviewNotes: null
}
]
},
// Reviewed at 2 alerts, flagged twice since — returns to the queue as "New activity".
{
clientId: 10079,
clientDisplayName: 'Priya Patel',
accountNo: '000000010079',
clientRiskCategory: 2,
alertCount: 4,
highRiskCount: 1,
openCount: 2,
escalatedCount: 0,
distinctRules: 2,
ruleCodes: [
'HIGH_VALUE_TRANSFER',
'ROUND_TRIPPING'
],
totalAmount: 2130000,
firstAlertOn: daysAgo(71, 14, 10),
lastAlertOn: daysAgo(6, 11, 5),
needsReview: true,
lastReviewedOn: daysAgo(34, 17, 0),
alertCountAtLastReview: 2,
lastReviewOutcome: 'MONITOR',
lastReviewedByUsername: 'anjali.mehta',
lastReviewNotes: 'Textile trading receipts, invoices seen. Keep under watch for one more quarter.',
alerts: [
{
alertId: 90102,
raisedOn: daysAgo(71, 14, 10),
transactionType: 'Account Transfer',
transactionId: 551002,
ruleCode: 'HIGH_VALUE_TRANSFER',
ruleDescription: 'Transfer above ₹5,00,000 to an unrelated account',
amount: 620000,
status: 'WHITELISTED',
reviewNotes: 'Supplier payment, invoice verified.'
},
{
alertId: 90158,
raisedOn: daysAgo(41, 10, 45),
transactionType: 'Account Transfer',
transactionId: 554330,
ruleCode: 'ROUND_TRIPPING',
ruleDescription: 'Funds returned to the originating account within seven days',
amount: 450000,
status: 'WHITELISTED',
reviewNotes: 'Advance refunded after order cancellation.'
},
{
alertId: 90241,
raisedOn: daysAgo(19, 13, 25),
transactionType: 'Account Transfer',
transactionId: 559011,
ruleCode: 'HIGH_VALUE_TRANSFER',
ruleDescription: 'Transfer above ₹5,00,000 to an unrelated account',
amount: 540000,
status: 'OPEN',
reviewNotes: null
},
{
alertId: 90268,
raisedOn: daysAgo(6, 11, 5),
transactionType: 'Account Transfer',
transactionId: 560190,
ruleCode: 'ROUND_TRIPPING',
ruleDescription: 'Funds returned to the originating account within seven days',
amount: 520000,
status: 'OPEN',
reviewNotes: null
}
]
},
// Reviewed with an outcome and no activity since — shows the settled state.
{
clientId: 10080,
clientDisplayName: 'Arjun Deshmukh',
accountNo: '000000010080',
clientRiskCategory: 1,
alertCount: 3,
highRiskCount: 0,
openCount: 0,
escalatedCount: 0,
distinctRules: 1,
ruleCodes: ['CASH_TXN_10L'],
totalAmount: 3420000,
firstAlertOn: daysAgo(80, 9, 30),
lastAlertOn: daysAgo(23, 12, 15),
needsReview: false,
lastReviewedOn: daysAgo(9, 15, 20),
alertCountAtLastReview: 3,
lastReviewOutcome: 'CLEARED',
lastReviewedByUsername: 'vikram.singh',
lastReviewNotes: 'Sugarcane crushing-season proceeds. Mill settlement statements filed with the branch.',
alerts: [
{
alertId: 90088,
raisedOn: daysAgo(80, 9, 30),
transactionType: 'Savings Deposit',
transactionId: 550210,
ruleCode: 'CASH_TXN_10L',
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
amount: 1180000,
status: 'WHITELISTED',
reviewNotes: 'Mill settlement for the 2025-26 crushing season.'
},
{
alertId: 90165,
raisedOn: daysAgo(52, 11, 0),
transactionType: 'Savings Deposit',
transactionId: 554901,
ruleCode: 'CASH_TXN_10L',
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
amount: 1090000,
status: 'WHITELISTED',
reviewNotes: 'Second instalment, statement on record.'
},
{
alertId: 90212,
raisedOn: daysAgo(23, 12, 15),
transactionType: 'Savings Deposit',
transactionId: 557640,
ruleCode: 'CASH_TXN_10L',
ruleDescription: 'Cash deposit at or above ₹10,00,000 in a single transaction',
amount: 1150000,
status: 'WHITELISTED',
reviewNotes: 'Final instalment for the season.'
}
]
}
];
}
+12 -5
View File
@@ -7,6 +7,7 @@
*/ */
import { GL_BRANCHES, GL_PERIOD } from '../gl-reports/gl-reports-data'; import { GL_BRANCHES, GL_PERIOD } from '../gl-reports/gl-reports-data';
import { toIsoDate } from '../core/utils/api-date';
/** /**
* Data layer for the Risk Review Report. * Data layer for the Risk Review Report.
@@ -390,17 +391,23 @@ export interface LiveAmlAlert {
ruleDescription: string; ruleDescription: string;
amount: number; amount: number;
currencyCode: string; currencyCode: string;
raisedOn: string; /** Fineract sends LocalDateTime as [y,m,d,h,mi,s], not a string. */
raisedOn: number[] | string;
status: string; status: string;
reviewedByUsername: string | null; reviewedByUsername: string | null;
reviewedOn: string | null; reviewedOn: number[] | string | null;
reviewNotes: string | null; reviewNotes: string | null;
disposition: string | null; disposition: string | null;
} }
/** Trims a LocalDateTime such as 2026-07-14T11:04:22 down to its date part. */ /**
function isoDayOf(value: string | null | undefined): string { * Fineract serialises LocalDateTime as an array — raisedOn arrives as
return (value || '').slice(0, 10); * [2026,9,11,0,0,0], not a string. Slicing that gave back an array rather than
* a date, so every live row carried an unusable alertDate and the period filter
* discarded it: the report read empty while the engine held alerts.
*/
function isoDayOf(value: unknown): string {
return toIsoDate(value);
} }
/** /**
@@ -150,33 +150,62 @@
} @else { } @else {
<!-- SUMMARY --> <!-- SUMMARY -->
<div class="rr-kpis"> <div class="rr-kpis">
<div class="rr-kpi"> <!-- Each tile reads as a question, so each one answers it by narrowing the table to the rows
it counted. They were plain text before, which invited a click that did nothing and left
the reviewer to rebuild the same filter by hand from the dropdowns below. -->
<button type="button" class="rr-kpi" (click)="focusAllCustomers()" title="Show every customer in the period">
<div class="rr-kpi-n">{{ summary.customers }}</div> <div class="rr-kpi-n">{{ summary.customers }}</div>
<div class="rr-kpi-l">Customers for review</div> <div class="rr-kpi-l">Customers for review</div>
<div class="rr-kpi-sub"> <div class="rr-kpi-sub">
{{ summary.byRisk.LOW }} Low · {{ summary.byRisk.MEDIUM }} Medium · {{ summary.byRisk.HIGH }} High {{ summary.byRisk.LOW }} Low · {{ summary.byRisk.MEDIUM }} Medium · {{ summary.byRisk.HIGH }} High
</div> </div>
</div> </button>
<div class="rr-kpi rr-kpi--blue"> <button
type="button"
class="rr-kpi rr-kpi--blue"
[class.rr-kpi--on]="openOnly"
(click)="focusOpenAlerts()"
title="Show only the cases still awaiting a decision"
>
<div class="rr-kpi-n">{{ summary.alerts }}</div> <div class="rr-kpi-n">{{ summary.alerts }}</div>
<div class="rr-kpi-l">AML alerts triggered</div> <div class="rr-kpi-l">AML alerts triggered</div>
<div class="rr-kpi-sub">on {{ summary.txns }} transactions · {{ summary.openAlerts }} awaiting a decision</div> <div class="rr-kpi-sub">on {{ summary.txns }} transactions · {{ summary.openAlerts }} awaiting a decision</div>
</div> </button>
<div <button
type="button"
class="rr-kpi rr-kpi--amber" class="rr-kpi rr-kpi--amber"
[class.rr-kpi--dim]="summary.upgradeCandidates + summary.downgradeCandidates === 0" [class.rr-kpi--dim]="summary.upgradeCandidates + summary.downgradeCandidates === 0"
[class.rr-kpi--on]="direction !== ''"
(click)="focusRatingsToMove()"
[title]="
summary.upgradeCandidates + summary.downgradeCandidates === 0
? 'No rating needs moving in this period'
: 'Show the customers whose rating should move'
"
> >
<div class="rr-kpi-n">{{ summary.upgradeCandidates + summary.downgradeCandidates }}</div> <div class="rr-kpi-n">{{ summary.upgradeCandidates + summary.downgradeCandidates }}</div>
<div class="rr-kpi-l">Ratings to be moved</div> <div class="rr-kpi-l">Ratings to be moved</div>
<div class="rr-kpi-sub"> <div class="rr-kpi-sub">
{{ summary.upgradeCandidates }} upward · {{ summary.downgradeCandidates }} downward {{ summary.upgradeCandidates }} upward · {{ summary.downgradeCandidates }} downward
</div> </div>
</div> </button>
<div class="rr-kpi" [class.rr-kpi--red]="summary.escalatedCustomers > 0"> <button
type="button"
class="rr-kpi"
[class.rr-kpi--red]="summary.escalatedCustomers > 0"
[class.rr-kpi--dim]="summary.escalatedCustomers === 0"
[class.rr-kpi--on]="status === 'ESCALATED_STR'"
(click)="focusEscalated()"
[title]="
summary.escalatedCustomers === 0
? 'No case has gone to the Principal Officer in this period'
: 'Show only the cases with the Principal Officer'
"
>
<div class="rr-kpi-n">{{ summary.escalatedCustomers }}</div> <div class="rr-kpi-n">{{ summary.escalatedCustomers }}</div>
<div class="rr-kpi-l">Escalated to STR</div> <div class="rr-kpi-l">Escalated to STR</div>
<div class="rr-kpi-sub">₹{{ summary.totalAmount | number: '1.0-0' }} under review</div> <div class="rr-kpi-sub">₹{{ summary.totalAmount | number: '1.0-0' }} under review</div>
</div> </button>
</div> </div>
<!-- RULE MIX — doubles as a one-click filter --> <!-- RULE MIX — doubles as a one-click filter -->
@@ -220,7 +249,7 @@
row does not have to repeat it in a Move To and a Position column. --> row does not have to repeat it in a Move To and a Position column. -->
@for (g of customerGroups; track g.key) { @for (g of customerGroups; track g.key) {
<div class="rr-group" [class]="'rr-group--' + g.tone"> <div class="rr-group" [class]="'rr-group--' + g.tone">
<button class="rr-group-head" (click)="toggleGroup(g.key)"> <button type="button" class="rr-group-head" (click)="toggleGroup(g.key)">
<fa-icon [icon]="collapsedGroups[g.key] ? 'caret-right' : 'caret-down'"></fa-icon> <fa-icon [icon]="collapsedGroups[g.key] ? 'caret-right' : 'caret-down'"></fa-icon>
<span class="rr-group-t">{{ g.title }}</span> <span class="rr-group-t">{{ g.title }}</span>
<span class="rr-group-n">{{ g.rows.length }}</span> <span class="rr-group-n">{{ g.rows.length }}</span>
@@ -238,6 +267,7 @@
<th>Account Number</th> <th>Account Number</th>
<th>Branch</th> <th>Branch</th>
<th>Rating on file</th> <th>Rating on file</th>
<th>Review says</th>
<th class="num">Alerts</th> <th class="num">Alerts</th>
<th>AML Rules Triggered</th> <th>AML Rules Triggered</th>
<th class="num">Amount under review</th> <th class="num">Amount under review</th>
@@ -268,6 +298,20 @@
<td> <td>
<span [class]="riskClass(c.currentRisk)">{{ riskText(c.currentRisk) }}</span> <span [class]="riskClass(c.currentRisk)">{{ riskText(c.currentRisk) }}</span>
</td> </td>
<!-- The verdict itself, on the row. The section heading says it once for the
whole group, but a reviewer reading a single row - or an exported sheet -
could not tell whether the rating was to be raised, lowered or left. The
reason sits under it rather than under the button, because it explains
this conclusion and not the act of opening the KYC screen. -->
<td class="rr-move-cell">
<span class="rr-move" [class]="'rr-move--' + c.direction">
<fa-icon [icon]="directionIcon(c)"></fa-icon>
{{ directionText(c) }}
</span>
@if (c.directionReason) {
<div class="rr-verdict-why">{{ c.directionReason }}</div>
}
</td>
<td class="num">{{ c.alertCount }}</td> <td class="num">{{ c.alertCount }}</td>
<td class="rr-rules-cell"> <td class="rr-rules-cell">
@for (label of c.ruleLabels; track label) { @for (label of c.ruleLabels; track label) {
@@ -276,10 +320,11 @@
</td> </td>
<td class="num strong">{{ c.totalAmount | number: '1.0-0' }}</td> <td class="num strong">{{ c.totalAmount | number: '1.0-0' }}</td>
<td class="mono">{{ c.lastAlertDate }}</td> <td class="mono">{{ c.lastAlertDate }}</td>
<!-- The verdict is the section this row sits in, so the cell only carries the <!-- Only the control. The verdict and its reason moved to their own column,
button that acts on it, and the reason behind it. --> so this no longer wraps to two lines under the button. -->
<td class="rr-verdict-cell"> <td class="rr-action-cell">
<button <button
type="button"
class="rr-kyc-link" class="rr-kyc-link"
[class.rr-kyc-link--act]="c.direction !== 'same' && !c.escalated" [class.rr-kyc-link--act]="c.direction !== 'same' && !c.escalated"
(click)="openKyc(c, $event)" (click)="openKyc(c, $event)"
@@ -287,15 +332,12 @@
> >
<fa-icon icon="id-card"></fa-icon> {{ kycActionText(c) }} <fa-icon icon="id-card"></fa-icon> {{ kycActionText(c) }}
</button> </button>
@if (c.directionReason) {
<div class="rr-verdict-why">{{ c.directionReason }}</div>
}
</td> </td>
</tr> </tr>
@if (expanded === c.clientId) { @if (expanded === c.clientId) {
<tr class="rr-detail"> <tr class="rr-detail">
<td colspan="11"> <td colspan="12">
<!-- One block per alert: what the rule is, then the entries it fired on. --> <!-- One block per alert: what the rule is, then the entries it fired on. -->
@for (a of c.alerts; track a.alertId) { @for (a of c.alerts; track a.alertId) {
<div class="rr-case"> <div class="rr-case">
@@ -286,12 +286,61 @@
margin-bottom: 14px; margin-bottom: 14px;
} }
/* The verdict, on the row. Colour carries the same meaning as the section it sits under, so a
reviewer scanning the middle of a long table does not have to look back up to the heading. */
.rr-move {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 11.5px;
font-weight: 700;
white-space: nowrap;
&--up {
background: #fef3c7;
color: #92400e;
}
&--down {
background: #dbeafe;
color: #1e40af;
}
&--same {
background: #f1f5f9;
color: var(--r-g500);
}
}
/* A button, so the tile is reachable by keyboard and reads as the control it is. The type
defaults that come with that — centred text, the button font, the UA border — are undone
here so it still looks like the panel it replaced. */
.rr-kpi { .rr-kpi {
display: block;
width: 100%;
background: #fff; background: #fff;
border: 1px solid var(--r-g200); border: 1px solid var(--r-g200);
border-left: 4px solid var(--r-navy); border-left: 4px solid var(--r-navy);
border-radius: 11px; border-radius: 11px;
padding: 12px 16px; padding: 12px 16px;
text-align: left;
font: inherit;
color: inherit;
cursor: pointer;
transition:
box-shadow 0.15s ease,
border-color 0.15s ease;
&:hover {
box-shadow: 0 1px 6px rgb(15 23 42 / 12%);
}
&:focus-visible {
outline: 2px solid var(--r-blue);
outline-offset: 2px;
}
&--blue { &--blue {
border-left-color: var(--r-blue); border-left-color: var(--r-blue);
@@ -307,6 +356,18 @@
&--dim { &--dim {
border-left-color: var(--r-g300); border-left-color: var(--r-g300);
/* Nothing to narrow to, so the tile says so rather than accepting a click that does nothing. */
cursor: default;
&:hover {
box-shadow: none;
}
}
/* The filter this tile applies is currently on. */
&--on {
border-color: var(--r-navy);
box-shadow: inset 0 0 0 1px var(--r-navy);
} }
} }
@@ -502,9 +563,11 @@
} }
td { td {
padding: 8px 10px; padding: 11px 10px;
border-bottom: 1px solid var(--r-g100); border-bottom: 1px solid var(--r-g100);
vertical-align: top; /* Middle rather than top: one cell in the row carries a two-line reason, and top alignment
left every other value floating against the row above it. */
vertical-align: middle;
} }
.num { .num {
@@ -538,6 +601,8 @@
td { td {
padding: 12px 16px 16px; padding: 12px 16px 16px;
/* The expanded block is a panel, not a value, so it starts at the top of its cell. */
vertical-align: top;
} }
} }
@@ -789,6 +854,19 @@
} }
/* What the report says should happen to the rating */ /* What the report says should happen to the rating */
/* The verdict and the sentence behind it. Wide enough that the reason wraps to two readable
lines instead of a narrow ragged column. */
.rr-move-cell {
min-width: 250px;
max-width: 330px;
}
/* Just the button, so the column is only as wide as the control it holds. */
.rr-action-cell {
width: 1%;
white-space: nowrap;
}
.rr-verdict-cell { .rr-verdict-cell {
min-width: 210px; min-width: 210px;
max-width: 300px; max-width: 300px;
@@ -817,9 +895,11 @@
} }
.rr-verdict-why { .rr-verdict-why {
font-size: 10.5px; font-size: 11px;
line-height: 1.45;
color: var(--r-g500); color: var(--r-g500);
margin-top: 2px; margin-top: 4px;
white-space: normal;
white-space: normal; white-space: normal;
} }
@@ -224,21 +224,35 @@ export class RiskReviewReportComponent implements OnInit {
return; return;
} }
// The alert carries the client but not their account number or branch, so both columns read
// as an em dash on every live row. Both sit on the client record the KYC call already needs,
// so they are fetched alongside it rather than left blank on a compliance report.
forkJoin( forkJoin(
clientIds.map((id) => clientIds.map((id) =>
this.clients.getClientKyc(String(id), true).pipe( forkJoin({
map((kyc: any) => ({ id, risk: riskFromCategory(kyc?.riskCategory) })), kyc: this.clients.getClientKyc(String(id), true).pipe(catchError(() => of(null))),
catchError(() => of({ id, risk: null as RiskLevel | null })) client: this.clients.getClientData(String(id), true).pipe(catchError(() => of(null)))
}).pipe(
map((r: any) => ({
id,
risk: riskFromCategory(r.kyc?.riskCategory),
accountNo: r.client?.accountNo || '',
branch: r.client?.officeName || ''
})),
catchError(() => of({ id, risk: null as RiskLevel | null, accountNo: '', branch: '' }))
) )
) )
).subscribe((results) => { ).subscribe((results) => {
const byClient = new Map( const byClient = new Map(results.map((r) => [r.id, r]));
results.map((r) => [ this.liveAlerts = rows.map((r) => {
r.id, const info = byClient.get(r.clientId);
r.risk return {
]) ...r,
); currentRisk: info?.risk ?? null,
this.liveAlerts = rows.map((r) => ({ ...r, currentRisk: byClient.get(r.clientId) ?? null })); accountNo: r.accountNo || info?.accountNo || '',
branchCode: r.branchCode || info?.branch || ''
};
});
this.compose(); this.compose();
this.loading = false; this.loading = false;
}); });
@@ -389,8 +403,62 @@ export class RiskReviewReportComponent implements OnInit {
return groups.filter((g) => g.rows.length > 0); 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 }; * Collapsed sections, by group key. Every group starts open.
*
* 'No change needed' used to start closed on the reasoning that there was nothing to act on. On a
* report that often holds one group, that meant opening the page to a heading and a count and no
* rows — the customer was there, but reading the report took a click to discover it. The toggle
* stays for folding a long section away.
*/
collapsedGroups: Record<string, boolean> = {};
/**
* The summary tiles read as a question each — "which customers?", "which alerts still need a
* decision?" — so each one narrows the table to the rows it counted. They were plain text
* before, which invited a click that did nothing and left the reviewer to rebuild the same
* filter by hand from the dropdowns.
*
* Clicking an active tile clears it again, so a tile is never a one-way trip into a filter the
* reviewer then has to hunt for.
*/
focusAllCustomers(): void {
this.currentRisk = '';
this.direction = '';
this.openOnly = false;
this.expanded = null;
this.collapsedGroups = {};
}
focusOpenAlerts(): void {
this.openOnly = !this.openOnly;
this.direction = '';
this.expanded = null;
this.collapsedGroups = {};
}
/** Only the customers whose rating the review says should move, either way. */
focusRatingsToMove(): void {
// Nothing to narrow to when no rating needs moving; leaving the filter alone keeps the
// table showing what is actually there rather than emptying it.
if (this.summary.upgradeCandidates + this.summary.downgradeCandidates === 0) {
return;
}
this.direction = this.direction === 'up' ? 'down' : this.direction === 'down' ? '' : 'up';
this.openOnly = false;
this.expanded = null;
this.collapsedGroups = {};
}
focusEscalated(): void {
if (this.summary.escalatedCustomers === 0) {
return;
}
this.status = this.status === 'ESCALATED_STR' ? '' : 'ESCALATED_STR';
this.openOnly = false;
this.expanded = null;
this.collapsedGroups = {};
}
toggleGroup(key: string): void { toggleGroup(key: string): void {
this.collapsedGroups[key] = !this.collapsedGroups[key]; this.collapsedGroups[key] = !this.collapsedGroups[key];
@@ -513,6 +581,47 @@ export class RiskReviewReportComponent implements OnInit {
* An escalated case is worded as a review rather than a move: the rating is held * 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. * pending the Principal Officer's conclusion, so there is nothing to apply yet.
*/ */
/**
* The verdict in the words a reviewer needs: which way the rating moves, and to what.
*
* An escalated case is deliberately not given a direction — the rating is held where it is
* until the Principal Officer reports back, so showing "raise to High" there would invite a
* change nobody is authorised to make yet.
*/
directionText(c: RiskCustomerRow): string {
if (c.escalated) {
return 'Held — with Principal Officer';
}
if (c.direction === 'up') {
return `Raise to ${RISK_LABEL[c.recommendedRisk]}`;
}
if (c.direction === 'down') {
return `Lower to ${RISK_LABEL[c.recommendedRisk]}`;
}
return c.currentRisk ? `Keep at ${RISK_LABEL[c.currentRisk]}` : 'No rating on record';
}
directionIcon(c: RiskCustomerRow): string {
if (c.escalated) {
return 'lock';
}
// 'check' rather than an equals sign: faEquals is not in the shared library, and an
// unregistered icon throws at render and takes the whole row down with it.
return c.direction === 'up' ? 'arrow-up' : c.direction === 'down' ? 'arrow-down' : 'check';
}
/**
* A live row carries the office name straight off the client record, not a GL branch code, so
* it is already the label to print. Only a coded row needs the lookup.
*/
branchLabelOf(branch: string): string {
if (!branch) {
return '—';
}
const named = glBranchName(branch);
return named && named !== branch ? named : branch;
}
kycActionText(c: RiskCustomerRow): string { kycActionText(c: RiskCustomerRow): string {
if (c.escalated) { if (c.escalated) {
return 'Open KYC'; return 'Open KYC';
@@ -689,7 +798,7 @@ export class RiskReviewReportComponent implements OnInit {
String(c.clientId), String(c.clientId),
c.customerName, c.customerName,
c.accountNo || '—', c.accountNo || '—',
c.branchCode ? glBranchName(c.branchCode) : '—', this.branchLabelOf(c.branchCode),
this.riskText(c.currentRisk), this.riskText(c.currentRisk),
c.alertCount, c.alertCount,
c.ruleLabels.join('; '), c.ruleLabels.join('; '),
@@ -749,7 +858,7 @@ export class RiskReviewReportComponent implements OnInit {
String(a.clientId), String(a.clientId),
a.customerName, a.customerName,
a.accountNo || '—', a.accountNo || '—',
a.branchCode ? glBranchName(a.branchCode) : '—', this.branchLabelOf(a.branchCode),
this.riskText(a.currentRisk), this.riskText(a.currentRisk),
this.txnDetail(a), this.txnDetail(a),
a.rule.label, a.rule.label,
@@ -815,7 +924,7 @@ export class RiskReviewReportComponent implements OnInit {
String(t.clientId), String(t.clientId),
t.customerName, t.customerName,
t.accountNo || '—', t.accountNo || '—',
t.branchCode ? glBranchName(t.branchCode) : '—', this.branchLabelOf(t.branchCode),
this.riskText(t.currentRisk), this.riskText(t.currentRisk),
channelLabel(t.channel), channelLabel(t.channel),
t.drCr, t.drCr,
+8 -1
View File
@@ -218,7 +218,14 @@ function recommendRating(
}; };
} }
return { risk, reason: '' }; // The rating on file is where the activity puts it. Saying so is the verdict — leaving it blank
// read as "not assessed yet" on screen, which is the opposite of what the review concluded.
return {
risk,
reason: currentRisk
? `${sizes}. Consistent with ${RISK_LABEL[risk]} — the rating on file stands.`
: `${sizes}. Activity points at ${RISK_LABEL[risk]}; no rating on record to compare against.`
};
} }
/** Applies every filter and joins each alert to its rule. */ /** Applies every filter and joins each alert to its rule. */
@@ -240,45 +240,83 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
this.approveBatch(request); this.approveBatch(request);
return; return;
} }
if ( this.dialog
!confirm( .open(ApproveIssueRequestDialogComponent, {
`Approve DD issuance for DD No. ${request.ddNumber} (₹${request.faceValue})? This will debit the account (or clear the cheque) and issue the draft.` width: '440px',
) maxWidth: '95vw',
) { data: {
return; isBatch: false,
ddNumber: request.ddNumber,
beneficiaryName: request.beneficiaryName,
clientName: request.clientName,
totalFaceValue: Number(request.faceValue) || 0,
// Named for how this particular draft is funded, rather than offering the approver every
// possibility and leaving them to work out which one applies.
effect: this.effectTextFor(request)
}
})
.afterClosed()
.subscribe((confirmed) => {
if (!confirmed) {
return;
}
this.sponsorDDService.approveIssueRequest(request.id).subscribe({
next: () => {
this.snackBar.open('DD issuance approved and posted successfully', 'Close', { duration: 3000 });
this.loadRequests();
},
error: (err) => {
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 });
}
});
});
}
/** What approving this request actually does, in the words of its own payment method. */
private effectTextFor(request: any, count = 1): string {
const drafts = count > 1 ? `${count} drafts` : 'the draft';
const once = count > 1 ? ' once for the combined total' : '';
switch ((request.paymentMethod || '').toUpperCase()) {
case 'ACCOUNT_DEBIT':
return `The customer's account is debited${once} and ${drafts} ${count > 1 ? 'are' : 'is'} issued. This cannot be undone — a draft issued in error has to be cancelled separately.`;
case 'CHEQUE':
return `The cheque is marked cleared${once} and ${drafts} ${count > 1 ? 'are' : 'is'} issued. This cannot be undone — a draft issued in error has to be cancelled separately.`;
default:
return `The cash collected is posted${once} and ${drafts} ${count > 1 ? 'are' : 'is'} issued. This cannot be undone — a draft issued in error has to be cancelled separately.`;
} }
this.sponsorDDService.approveIssueRequest(request.id).subscribe({
next: () => {
this.snackBar.open('DD issuance approved and posted successfully', 'Close', { duration: 3000 });
this.loadRequests();
},
error: (err) => {
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 });
}
});
} }
private approveBatch(request: any): void { private approveBatch(request: any): void {
const siblings = this.batchSiblings(request); const siblings = this.batchSiblings(request);
const lines = siblings.map((r) => ` • ${r.beneficiaryName} — ₹${r.faceValue}`).join('\n');
const total = siblings.reduce((sum, r) => sum + (Number(r.faceValue) || 0), 0); const total = siblings.reduce((sum, r) => sum + (Number(r.faceValue) || 0), 0);
if (
!confirm( this.dialog
`Approve this DD slip - ${siblings.length} drafts, combined face value ₹${total}?\n${lines}\n\n` + .open(ApproveIssueRequestDialogComponent, {
'This debits the account (or clears the cheque) ONCE for the combined total and issues every draft in the slip.' width: '460px',
) maxWidth: '95vw',
) { data: {
return; isBatch: true,
} clientName: request.clientName,
this.sponsorDDService.approveIssueRequestBatch(request.batchId).subscribe({ totalFaceValue: total,
next: () => { lines: siblings.map((r) => ({ beneficiaryName: r.beneficiaryName, faceValue: Number(r.faceValue) || 0 })),
this.snackBar.open('DD slip approved and posted successfully', 'Close', { duration: 3000 }); effect: this.effectTextFor(request, siblings.length)
this.loadRequests(); }
}, })
error: (err) => { .afterClosed()
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 }); .subscribe((confirmed) => {
} if (!confirmed) {
}); return;
}
this.sponsorDDService.approveIssueRequestBatch(request.batchId).subscribe({
next: () => {
this.snackBar.open('DD slip approved and posted successfully', 'Close', { duration: 3000 });
this.loadRequests();
},
error: (err) => {
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 });
}
});
});
} }
openRejectDialog(request: any): void { openRejectDialog(request: any): void {
@@ -302,6 +340,123 @@ export class DdIssueApprovalsComponent implements OnInit, AfterViewInit {
} }
} }
/**
* What the approver is about to commit, laid out rather than run together in a sentence.
*
* This was a browser `confirm()`, which the browser renders with its own "localhost:4200 says"
* chrome and no formatting — so the one figure that matters, the amount leaving the customer's
* account, sat mid-paragraph beside a parenthetical about cheques that does not apply to most
* drafts. An approval is the point of no return on someone else's money; it should read like one.
*/
@Component({
selector: 'mifosx-approve-issue-request-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule
],
template: `
<h2 mat-dialog-title class="approve-title">
{{ data.isBatch ? 'Approve DD Slip' : 'Approve DD Issuance' }}
</h2>
<mat-dialog-content>
<div class="approve-summary">
<div class="approve-row">
<span class="approve-label">{{ data.isBatch ? 'Drafts' : 'DD Number' }}</span>
<strong class="approve-value">{{ data.isBatch ? data.lines.length + ' drafts' : data.ddNumber }}</strong>
</div>
<div class="approve-row" *ngIf="!data.isBatch">
<span class="approve-label">In favour of</span>
<strong class="approve-value">{{ data.beneficiaryName || '—' }}</strong>
</div>
<div class="approve-row" *ngIf="data.clientName">
<span class="approve-label">Purchaser</span>
<strong class="approve-value">{{ data.clientName }}</strong>
</div>
<div class="approve-row approve-row--total">
<span class="approve-label">{{ data.isBatch ? 'Combined face value' : 'Face value' }}</span>
<strong class="approve-amount">₹{{ data.totalFaceValue | number: '1.2-2' }}</strong>
</div>
</div>
<!-- A slip is one debit for every draft on it, which is the part an approver is most likely
to get wrong if it is not said plainly. -->
<ul class="approve-lines" *ngIf="data.isBatch">
<li *ngFor="let l of data.lines">{{ l.beneficiaryName }} — ₹{{ l.faceValue | number: '1.2-2' }}</li>
</ul>
<p class="approve-effect">
{{ data.effect }}
</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
<button mat-flat-button color="primary" type="button" (click)="dialogRef.close(true)">
{{ data.isBatch ? 'Approve Slip' : 'Approve &amp; Issue' }}
</button>
</mat-dialog-actions>
`,
styles: [
`
.approve-title {
margin-bottom: 4px;
}
.approve-summary {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 4px 14px;
margin-bottom: 12px;
background: #f8fafc;
}
.approve-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
padding: 8px 0;
border-bottom: 1px solid #eef2f7;
font-size: 13px;
}
.approve-row:last-child {
border-bottom: none;
}
.approve-label {
color: #64748b;
}
.approve-value {
text-align: right;
}
.approve-amount {
font-size: 17px;
font-weight: 700;
}
.approve-lines {
margin: 0 0 12px;
padding-left: 20px;
font-size: 12.5px;
color: #334155;
max-height: 160px;
overflow-y: auto;
}
.approve-effect {
margin: 0;
padding: 10px 12px;
border-radius: 6px;
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
font-size: 12.5px;
line-height: 1.5;
}
`
]
})
export class ApproveIssueRequestDialogComponent {
dialogRef = inject(MatDialogRef<ApproveIssueRequestDialogComponent>);
data = inject(MAT_DIALOG_DATA);
}
// ── REJECT DIALOG COMPONENT ── // ── REJECT DIALOG COMPONENT ──
@Component({ @Component({
selector: 'mifosx-reject-issue-request-dialog', selector: 'mifosx-reject-issue-request-dialog',
@@ -209,6 +209,31 @@
<span class="pending-badge" *ngIf="element.status === 'ISSUED' && element.pendingReprintRequestId"> <span class="pending-badge" *ngIf="element.status === 'ISSUED' && element.pendingReprintRequestId">
Reprint Pending Approval (Req #{{ element.pendingReprintRequestId }}) Reprint Pending Approval (Req #{{ element.pendingReprintRequestId }})
</span> </span>
<!-- The leaflet is ruined and cannot be handed over. Offered beside Request Reprint
because the two look alike from the counter: reprint when the paper survived, revoke
when it did not. -->
<button
mat-stroked-button
color="warn"
size="small"
*ngIf="element.status === 'ISSUED' && !element.pendingRevokeRequestId"
(click)="openRevokeDialog(element)"
>
Revoke
</button>
<span class="pending-badge" *ngIf="element.status === 'ISSUED' && element.pendingRevokeRequestId">
Revoke Pending Approval (Req #{{ element.pendingRevokeRequestId }})
</span>
<!-- Both ends of a replacement, so the pad reconciles: a revoked leaflet says where its
draft went, and the blank that took it says what it stands in for. -->
<span class="pending-badge" *ngIf="element.revokedReplacedByDdNumber">
Revoked → replaced by DD {{ element.revokedReplacedByDdNumber }}
</span>
<span class="pending-badge" *ngIf="element.revokedReplacementOfDdNumber">
Replaces spoiled DD {{ element.revokedReplacementOfDdNumber }}
</span>
</div> </div>
</td> </td>
</ng-container> </ng-container>
+135 -1
View File
@@ -142,6 +142,12 @@ import { DividendClaimRequestService } from '../../clerk/dividend-claim-request.
background-color: #fef3c7; background-color: #fef3c7;
color: #b45309; color: #b45309;
} }
/* Spoiled stock. Grey rather than red: nothing went wrong with the customer's draft, only
with the paper it was printed on, and the draft itself is alive on another leaflet. */
.status-badge.revoked {
background-color: #e2e8f0;
color: #475569;
}
.action-btn-group { .action-btn-group {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -212,7 +218,10 @@ export class DdListComponent implements OnInit, AfterViewInit {
'ISSUED', 'ISSUED',
'CANCELLED', 'CANCELLED',
'STOPPED', 'STOPPED',
'CLEARED' 'CLEARED',
// Spoiled at the printer. Filterable on its own because it is the count that explains a pad
// whose issued drafts do not add up to the numbers consumed.
'REVOKED'
]; ];
selectedStatus = 'ALL'; selectedStatus = 'ALL';
searchText = ''; searchText = '';
@@ -444,6 +453,23 @@ export class DdListComponent implements OnInit, AfterViewInit {
} }
}); });
} }
/** The leaflet was ruined at the printer and its draft has to move onto a fresh blank. */
openRevokeDialog(leaflet: any): void {
this.dialog
.open(RevokeDdDialogComponent, {
width: '480px',
maxWidth: '95vw',
data: { leaflet },
disableClose: true
})
.afterClosed()
.subscribe((result) => {
if (result) {
this.loadLeaflets();
}
});
}
} }
// ── ISSUE DRAFT DIALOG COMPONENT ── // ── ISSUE DRAFT DIALOG COMPONENT ──
@@ -1655,6 +1681,114 @@ export class RequestReprintDialogComponent implements OnInit {
} }
} }
/**
* Replacing a leaflet ruined at the printer.
*
* Not a reprint: a reprint puts the same draft back through the printer on the same paper, which is
* right when the print jammed but the leaflet survived. Here the leaflet itself is spoiled — its
* printed number can never be handed to anyone — so the draft moves onto the next blank and both
* leaflets stay on the register, or the pad comes up short against the sponsor bank's range.
*/
@Component({
selector: 'mifosx-revoke-dd-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule
],
template: `
<h2 mat-dialog-title>Revoke Spoiled DD (DD No: {{ data.leaflet.ddNumber }})</h2>
<mat-dialog-content>
<p class="hint-text">
Use this when the leaflet itself is ruined — misprinted, torn or jammed — and cannot be given to the customer.
On approval this leaflet is marked <strong>REVOKED</strong> and the draft is re-issued on the next available
blank.
</p>
<p class="hint-text">
No money moves. {{ data.leaflet.clientName || data.leaflet.walkinPurchaserName || 'The purchaser' }} has already
paid for this draft and is still owed the same one.
</p>
<form [formGroup]="revokeForm" class="dialog-form">
<mat-form-field appearance="outline" class="full-width">
<mat-label>What went wrong with this leaflet?</mat-label>
<textarea matInput formControlName="reason" rows="3"></textarea>
<mat-error *ngIf="revokeForm.get('reason')?.hasError('required')">Reason is required</mat-error>
</mat-form-field>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="onCancel()">Cancel</button>
<button mat-flat-button color="warn" [disabled]="revokeForm.invalid || loading" (click)="onSubmit()">
Submit for Approval
</button>
</mat-dialog-actions>
`,
styles: [
`
.dialog-form {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 8px;
}
.full-width {
width: 100%;
}
.hint-text {
color: #64748b;
font-size: 13px;
margin: 0 0 10px 0;
line-height: 1.5;
}
`
]
})
export class RevokeDdDialogComponent implements OnInit {
private fb = inject(FormBuilder);
private sponsorDDService = inject(SponsorDDService);
private dialogRef = inject(MatDialogRef<RevokeDdDialogComponent>);
private snackBar = inject(MatSnackBar);
protected data = inject(MAT_DIALOG_DATA);
revokeForm!: FormGroup;
loading = false;
ngOnInit(): void {
this.revokeForm = this.fb.group({
reason: [
'',
Validators.required
]
});
}
onCancel(): void {
this.dialogRef.close(false);
}
onSubmit(): void {
if (this.revokeForm.invalid) {
return;
}
this.loading = true;
this.sponsorDDService.requestRevoke(this.data.leaflet.id, this.revokeForm.value.reason).subscribe({
next: () => {
this.snackBar.open('Revoke request submitted for approval', 'Close', { duration: 3000 });
this.dialogRef.close(true);
},
error: (err) => {
this.loading = false;
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 });
}
});
}
}
// ── PRINT DRAFT DIALOG COMPONENT ── // ── PRINT DRAFT DIALOG COMPONENT ──
@Component({ @Component({
selector: 'mifosx-print-draft-dialog', selector: 'mifosx-print-draft-dialog',
@@ -0,0 +1,130 @@
<!--
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="approvals-container">
<div class="header-row">
<div class="title-area">
<h2>DD Revoke Approvals</h2>
<p>
A leaflet ruined at the printer cannot be handed over, and its number can never be given to anyone else.
Approving moves the same draft onto the next available blank — this series first, then this sponsor bank, then
any other. No money moves: the customer was already debited.
</p>
</div>
</div>
<mat-form-field appearance="outline" class="search-field">
<mat-label>Search by DD No. or customer</mat-label>
<input matInput [value]="searchText" (input)="onSearchChange($any($event.target).value)" placeholder="e.g. 3005" />
</mat-form-field>
<div class="status-filter-row">
<button
mat-stroked-button
*ngFor="let status of statusOptions"
[color]="selectedStatus === status ? 'primary' : undefined"
[class.filter-active]="selectedStatus === status"
(click)="filterByStatus(status)"
>
{{ status === 'ALL' ? 'All' : status }}
<span class="filter-count">({{ statusCount(status) }})</span>
</button>
</div>
<mat-card>
<table mat-table [dataSource]="dataSource">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>Req. ID</th>
<td mat-cell *matCellDef="let element">{{ element.id }}</td>
</ng-container>
<ng-container matColumnDef="ddNumber">
<th mat-header-cell *matHeaderCellDef>Spoiled DD</th>
<td mat-cell *matCellDef="let element">
<strong>{{ element.ddNumber }}</strong>
</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef>Purchaser</th>
<td mat-cell *matCellDef="let element">{{ element.clientName || '—' }}</td>
</ng-container>
<ng-container matColumnDef="beneficiaryName">
<th mat-header-cell *matHeaderCellDef>Beneficiary</th>
<td mat-cell *matCellDef="let element">{{ element.beneficiaryName || '—' }}</td>
</ng-container>
<ng-container matColumnDef="faceValue">
<th mat-header-cell *matHeaderCellDef>Face Value</th>
<td mat-cell *matCellDef="let element">{{ element.faceValue | number: '1.2-2' }}</td>
</ng-container>
<ng-container matColumnDef="reason">
<th mat-header-cell *matHeaderCellDef>Reason</th>
<td mat-cell *matCellDef="let element">{{ element.reason }}</td>
</ng-container>
<!-- Empty until approval: no blank is consumed while the request is still pending. -->
<ng-container matColumnDef="replacementDdNumber">
<th mat-header-cell *matHeaderCellDef>Replaced By</th>
<td mat-cell *matCellDef="let element">
<span class="replacement-no" *ngIf="element.replacementDdNumber">{{ element.replacementDdNumber }}</span>
<span class="muted" *ngIf="!element.replacementDdNumber">—</span>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let element">
<span class="status-badge" [ngClass]="element.status.toLowerCase()">
{{ element.status }}
</span>
</td>
</ng-container>
<ng-container matColumnDef="makerUsername">
<th mat-header-cell *matHeaderCellDef>Requested By</th>
<td mat-cell *matCellDef="let element">{{ element.makerUsername }}</td>
</ng-container>
<ng-container matColumnDef="createdDate">
<th mat-header-cell *matHeaderCellDef>Requested On</th>
<td mat-cell *matCellDef="let element">{{ element.createdDate | date: 'dd MMM yyyy, HH:mm' }}</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let element">
<div class="action-btn-group" *ngIf="element.status === 'PENDING'">
<button mat-flat-button color="warn" (click)="approve(element)">Approve</button>
<button mat-stroked-button color="warn" (click)="openRejectDialog(element)">Reject</button>
</div>
<span *ngIf="element.status === 'REJECTED'" style="color: #64748b; font-size: 12px">{{
element.rejectionReason
}}</span>
<div *ngIf="element.status === 'APPROVED'" style="color: #64748b; font-size: 12px; line-height: 1.6">
<div>by {{ element.checkerUsername }}, {{ element.checkerDate | date: 'dd MMM, HH:mm' }}</div>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<div
*ngIf="dataSource.filteredData.length === 0"
class="no-records-row"
style="text-align: center; padding: 32px; color: #64748b"
>
No DD revoke requests match the selected filter.
</div>
<mat-paginator [pageSizeOptions]="[25, 50, 100]" [pageSize]="25" showFirstLastButtons></mat-paginator>
</mat-card>
</div>
@@ -0,0 +1,460 @@
/**
* 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, AfterViewInit, ViewChild, inject, Inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { MatPaginatorModule, MatPaginator } from '@angular/material/paginator';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog, MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { SponsorDDService } from '../sponsor-dd.service';
/**
* The queue for drafts spoiled at the printer.
*
* Kept apart from reprint approvals because the two decisions are not alike. A reprint puts the
* same draft back through the printer on the same leaflet and consumes nothing. Approving a revoke
* spends a blank from a limited pad the sponsor bank sent — so the checker is told which customer's
* draft it is, for how much, and that a fresh leaflet will be taken.
*/
@Component({
selector: 'mifosx-dd-revoke-approvals',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatCardModule,
MatTableModule,
MatPaginatorModule,
MatButtonModule,
MatDialogModule,
MatSnackBarModule,
MatFormFieldModule,
MatInputModule
],
templateUrl: './dd-revoke-approvals.component.html',
styles: [
`
.approvals-container {
padding: 24px;
max-width: 1240px;
margin: 0 auto;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
}
.title-area h2 {
margin: 0 0 4px 0;
font-size: 24px;
font-weight: 600;
color: #1e293b;
}
.title-area p {
margin: 0;
color: #64748b;
font-size: 14px;
}
.search-field {
width: 320px;
display: block;
margin-bottom: 8px;
}
.mat-mdc-card {
border-radius: 12px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
table {
width: 100%;
}
th {
font-weight: 600;
color: #475569;
background-color: #f8fafc;
}
td {
color: #334155;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.status-badge.pending {
background-color: #fef3c7;
color: #b45309;
}
.status-badge.approved {
background-color: #dcfce7;
color: #15803d;
}
.status-badge.rejected {
background-color: #fee2e2;
color: #b91c1c;
}
.action-btn-group {
display: flex;
gap: 8px;
}
.status-filter-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.status-filter-row button.filter-active {
background-color: #ede9fe;
}
.filter-count {
color: #64748b;
margin-left: 4px;
}
/* The replacement is the whole point of the decision, so it reads as a number rather than
sitting in the row as ordinary text. */
.replacement-no {
font-weight: 600;
color: #15803d;
}
.muted {
color: #94a3b8;
}
`
]
})
export class DdRevokeApprovalsComponent implements OnInit, AfterViewInit {
private sponsorDDService = inject(SponsorDDService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
@ViewChild(MatPaginator) paginator!: MatPaginator;
displayedColumns: string[] = [
'id',
'ddNumber',
'clientName',
'beneficiaryName',
'faceValue',
'reason',
'replacementDdNumber',
'status',
'makerUsername',
'createdDate',
'actions'
];
dataSource = new MatTableDataSource<any>([]);
readonly statusOptions: string[] = [
'PENDING',
'APPROVED',
'REJECTED',
'ALL'
];
selectedStatus = 'PENDING';
searchText = '';
ngOnInit(): void {
this.dataSource.filterPredicate = (data) => {
const statusMatches = this.selectedStatus === 'ALL' || data.status === this.selectedStatus;
const needle = this.searchText.trim().toLowerCase();
const searchMatches =
!needle ||
(data.ddNumber || '').toLowerCase().includes(needle) ||
(data.clientName || '').toLowerCase().includes(needle);
return statusMatches && searchMatches;
};
this.loadRequests();
}
ngAfterViewInit(): void {
this.dataSource.paginator = this.paginator;
}
loadRequests(): void {
this.sponsorDDService.getAllRevokeRequests().subscribe({
next: (data) => {
this.dataSource.data = data || [];
this.applyFilter();
},
error: (err) => {
this.snackBar.open('Error loading revoke requests: ' + (err.error?.message || err.message), 'Close', {
duration: 5000
});
}
});
}
statusCount(status: string): number {
const all = this.dataSource.data;
return status === 'ALL' ? all.length : all.filter((r) => r.status === status).length;
}
filterByStatus(status: string): void {
this.selectedStatus = status;
this.applyFilter();
}
onSearchChange(value: string): void {
this.searchText = value;
this.applyFilter();
}
private applyFilter(): void {
this.dataSource.filter = `${this.selectedStatus}|${this.searchText}`;
if (this.dataSource.paginator) {
this.dataSource.paginator.firstPage();
}
}
approve(request: any): void {
this.dialog
.open(ApproveRevokeRequestDialogComponent, {
width: '460px',
maxWidth: '95vw',
data: { request },
disableClose: true
})
.afterClosed()
.subscribe((confirmed) => {
if (!confirmed) {
return;
}
this.sponsorDDService.approveRevokeRequest(request.id).subscribe({
next: (res: any) => {
// The replacement number is what the clerk has to write on and hand over, so it is said
// plainly rather than left for them to find by reopening the register.
const replacement = res?.subResourceId ? ` Replacement DD issued — leaflet #${res.subResourceId}.` : '';
this.snackBar.open(`DD ${request.ddNumber} revoked.${replacement}`, 'Close', { duration: 6000 });
this.loadRequests();
},
error: (err) => {
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 7000 });
}
});
});
}
openRejectDialog(request: any): void {
this.dialog
.open(RejectRevokeRequestDialogComponent, {
width: '450px',
data: { request },
disableClose: true
})
.afterClosed()
.subscribe((result) => {
if (result) {
this.loadRequests();
}
});
}
}
/**
* What approving actually costs, laid out before it is done.
*
* A browser `confirm()` would run the customer, the amount and the fact that a blank is consumed
* together in one sentence under the browser's own "localhost says" chrome. This is a decision
* about someone else's draft and about stock that cannot be replaced, so it reads like one.
*/
@Component({
selector: 'mifosx-approve-revoke-request-dialog',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatButtonModule
],
template: `
<h2 mat-dialog-title>Approve DD Revoke</h2>
<mat-dialog-content>
<div class="rv-summary">
<div class="rv-row">
<span class="rv-label">Spoiled DD</span>
<strong class="rv-value">{{ data.request.ddNumber }}</strong>
</div>
<div class="rv-row" *ngIf="data.request.clientName">
<span class="rv-label">Purchaser</span>
<strong class="rv-value">{{ data.request.clientName }}</strong>
</div>
<div class="rv-row" *ngIf="data.request.beneficiaryName">
<span class="rv-label">In favour of</span>
<strong class="rv-value">{{ data.request.beneficiaryName }}</strong>
</div>
<div class="rv-row rv-row--total">
<span class="rv-label">Face value</span>
<strong class="rv-amount">₹{{ data.request.faceValue | number: '1.2-2' }}</strong>
</div>
</div>
<p class="rv-reason"><span class="rv-label">Reason given</span><br />{{ data.request.reason }}</p>
<p class="rv-effect">
This marks {{ data.request.ddNumber }} as REVOKED and re-issues the same draft on the next available blank —
preferring this series, then this sponsor bank, then any other. No money moves: the customer was already
debited and is still owed this one draft.
</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
<button mat-flat-button color="warn" type="button" (click)="dialogRef.close(true)">Revoke &amp; Re-issue</button>
</mat-dialog-actions>
`,
styles: [
`
.rv-summary {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 14px;
background: #f8fafc;
}
.rv-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
padding: 4px 0;
}
.rv-row--total {
border-top: 1px solid #e2e8f0;
margin-top: 6px;
padding-top: 8px;
}
.rv-label {
color: #64748b;
font-size: 12.5px;
}
.rv-value {
color: #1e293b;
font-size: 13.5px;
text-align: right;
}
.rv-amount {
color: #1e293b;
font-size: 17px;
font-weight: 700;
}
.rv-reason {
font-size: 13px;
color: #334155;
margin: 0 0 12px;
}
.rv-effect {
font-size: 12.5px;
line-height: 1.55;
color: #92400e;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 6px;
padding: 10px 12px;
margin: 0;
}
`
]
})
export class ApproveRevokeRequestDialogComponent {
dialogRef = inject(MatDialogRef<ApproveRevokeRequestDialogComponent>);
constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) {}
}
// ── REJECT DIALOG ──
@Component({
selector: 'mifosx-reject-revoke-request-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule
],
template: `
<h2 mat-dialog-title>Reject DD Revoke (DD No: {{ data.request.ddNumber }})</h2>
<mat-dialog-content>
<p class="hint-text">
The leaflet stays issued and no replacement is taken. The clerk keeps a draft they say they cannot hand over,
so the reason should tell them what to do instead.
</p>
<form [formGroup]="rejectForm" class="dialog-form">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Rejection Reason</mat-label>
<textarea matInput formControlName="rejectionReason" rows="3"></textarea>
<mat-error *ngIf="rejectForm.get('rejectionReason')?.hasError('required')">A reason is required</mat-error>
</mat-form-field>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
<button mat-flat-button color="warn" type="button" [disabled]="rejectForm.invalid || loading" (click)="onSubmit()">
Reject
</button>
</mat-dialog-actions>
`,
styles: [
`
.full-width {
width: 100%;
}
.hint-text {
font-size: 13px;
color: #64748b;
margin: 0 0 12px;
line-height: 1.5;
}
`
]
})
export class RejectRevokeRequestDialogComponent {
private fb = inject(FormBuilder);
private sponsorDDService = inject(SponsorDDService);
private snackBar = inject(MatSnackBar);
dialogRef = inject(MatDialogRef<RejectRevokeRequestDialogComponent>);
rejectForm: FormGroup;
loading = false;
constructor(@Inject(MAT_DIALOG_DATA) public data: { request: any }) {
this.rejectForm = this.fb.group({
rejectionReason: ['', Validators.required]
});
}
onSubmit(): void {
if (this.rejectForm.invalid) {
return;
}
this.loading = true;
this.sponsorDDService.rejectRevokeRequest(this.data.request.id, this.rejectForm.value.rejectionReason).subscribe({
next: () => {
this.loading = false;
this.snackBar.open('Revoke request rejected', 'Close', { duration: 3000 });
this.dialogRef.close(true);
},
error: (err) => {
this.loading = false;
this.snackBar.open('Error: ' + (err.error?.message || err.message), 'Close', { duration: 6000 });
}
});
}
}
@@ -13,6 +13,7 @@ import { DdInventoryListComponent } from './dd-inventory-list/dd-inventory-list.
import { DdListComponent } from './dd-list/dd-list.component'; import { DdListComponent } from './dd-list/dd-list.component';
import { DdIssueApprovalsComponent } from './dd-issue-approvals/dd-issue-approvals.component'; import { DdIssueApprovalsComponent } from './dd-issue-approvals/dd-issue-approvals.component';
import { DdReprintApprovalsComponent } from './dd-reprint-approvals/dd-reprint-approvals.component'; import { DdReprintApprovalsComponent } from './dd-reprint-approvals/dd-reprint-approvals.component';
import { DdRevokeApprovalsComponent } from './dd-revoke-approvals/dd-revoke-approvals.component';
const routes: Routes = [ const routes: Routes = [
Route.withShell([ Route.withShell([
@@ -44,6 +45,11 @@ const routes: Routes = [
path: 'reprint-approvals', path: 'reprint-approvals',
component: DdReprintApprovalsComponent, component: DdReprintApprovalsComponent,
data: { title: 'DD Reprint Approvals', breadcrumb: 'Reprint Approvals' } data: { title: 'DD Reprint Approvals', breadcrumb: 'Reprint Approvals' }
},
{
path: 'revoke-approvals',
component: DdRevokeApprovalsComponent,
data: { title: 'DD Revoke Approvals', breadcrumb: 'Revoke Approvals' }
} }
] ]
} }
+21
View File
@@ -218,6 +218,27 @@ export class SponsorDDService {
return this.http.post<any>(`/sponsordd/reprint-requests/${id}/reject`, { rejectionReason }); return this.http.post<any>(`/sponsordd/reprint-requests/${id}/reject`, { rejectionReason });
} }
// ── DD revoke requests (maker-checker) ──
// A leaflet ruined at the printer. Its number can never be handed to anyone, so the draft moves
// onto the next available blank and both leaflets stay on the register — one spoiled, one
// carrying the draft. Distinct from a reprint, which re-prints the same leaflet.
requestRevoke(leafletId: number, reason: string): Observable<any> {
return this.http.post<any>(`/sponsordd/leaflets/${leafletId}/revoke-request`, { reason });
}
getAllRevokeRequests(status?: string): Observable<any[]> {
const params = status ? { status } : {};
return this.http.get<any[]>('/sponsordd/revoke-requests', { params });
}
approveRevokeRequest(id: number): Observable<any> {
return this.http.post<any>(`/sponsordd/revoke-requests/${id}/approve`, {});
}
rejectRevokeRequest(id: number, rejectionReason: string): Observable<any> {
return this.http.post<any>(`/sponsordd/revoke-requests/${id}/reject`, { rejectionReason });
}
// ── DD amendment requests (maker-checker) ── // ── DD amendment requests (maker-checker) ──
// Correcting an issued draft's beneficiary, or the branch and city it is payable at. Nothing else // Correcting an issued draft's beneficiary, or the branch and city it is payable at. Nothing else
// is amendable: the money behind the face value has already moved, so a draft wrong in that way // is amendable: the money behind the face value has already moved, so a draft wrong in that way
@@ -226,6 +226,12 @@
<fa-icon icon="print" class="ac-tab__ic"></fa-icon> <fa-icon icon="print" class="ac-tab__ic"></fa-icon>
<span>DD Reprint Requests</span> <span>DD Reprint Requests</span>
</a> </a>
<!-- Beside the reprint queue because the two arrive together from the counter, but they are
different decisions: a reprint re-prints the same leaflet, a revoke spends a fresh one. -->
<a class="ac-tab" routerLink="./dd-revoke-approvals" routerLinkActive="ac-tab--active">
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
<span>DD Revoke Requests</span>
</a>
</nav> </nav>
<div class="ac-tab-panel"> <div class="ac-tab-panel">
+8
View File
@@ -235,6 +235,14 @@ const routes: Routes = [
(m) => m.DdReprintApprovalsComponent (m) => m.DdReprintApprovalsComponent
), ),
data: { title: 'DD Reprint Approvals' } data: { title: 'DD Reprint Approvals' }
},
{
path: 'dd-revoke-approvals',
loadComponent: () =>
import('../sponsor-dd/dd-revoke-approvals/dd-revoke-approvals.component').then(
(m) => m.DdRevokeApprovalsComponent
),
data: { title: 'DD Revoke Approvals' }
} }
] ]
}, },
@@ -80,7 +80,7 @@
<div class="tdsr__filters"> <div class="tdsr__filters">
<label class="tdsr__filter"> <label class="tdsr__filter">
<span>Financial year</span> <span>Financial year</span>
<select [(ngModel)]="filter.fy" (ngModelChange)="onFilterChanged()"> <select [(ngModel)]="filter.fy" (ngModelChange)="onYearChanged()">
@for (year of financialYears; track year) { @for (year of financialYears; track year) {
<option [value]="year">FY {{ year }}</option> <option [value]="year">FY {{ year }}</option>
} }
@@ -15,7 +15,8 @@ import * as ExcelJS from 'exceljs';
import { BranchScopeService } from '../core/branch-scope.service'; import { BranchScopeService } from '../core/branch-scope.service';
import { GL_BRANCHES, glBranchName } from '../gl-reports/gl-reports-data'; import { GL_BRANCHES, glBranchName } from '../gl-reports/gl-reports-data';
import { FY_TAX_CONFIG, Quarter, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; import { FY_TAX_CONFIG, Quarter, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TDS_FYS } from './tds-reports-data'; import { setTdsDeductees, TDS_FYS } from './tds-reports-data';
import { TdsReportsService } from './tds-reports.service';
import { import {
NEAR_THRESHOLD_BAND, NEAR_THRESHOLD_BAND,
TdsInterestRow, TdsInterestRow,
@@ -37,6 +38,7 @@ import {
}) })
export class TdsInterestReportComponent implements OnInit { export class TdsInterestReportComponent implements OnInit {
private scope = inject(BranchScopeService); private scope = inject(BranchScopeService);
private tdsService = inject(TdsReportsService);
readonly branches = GL_BRANCHES.filter((b) => b.code !== 'HO'); readonly branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
readonly financialYears = TDS_FYS; readonly financialYears = TDS_FYS;
@@ -87,12 +89,54 @@ export class TdsInterestReportComponent implements OnInit {
private summaryCacheKey = ''; private summaryCacheKey = '';
private cachedSummary: TdsInterestSummary | null = null; private cachedSummary: TdsInterestSummary | null = null;
loading = false;
loadError = '';
ngOnInit(): void { ngOnInit(): void {
// A branch login sees only its own book and cannot widen the scope. // A branch login sees only its own book and cannot widen the scope.
const own = this.scope.ownBranchCode; const own = this.scope.ownBranchCode;
if (own) { if (own) {
this.filter.branchCode = own; this.filter.branchCode = own;
} }
this.loadDeductees();
}
/**
* Loads the interest the bank actually credited for the selected year.
*
* Everything below computes on whatever `setTdsDeductees` holds, so the load is the only place
* that knows where the figures come from.
*/
loadDeductees(): void {
this.loading = true;
this.loadError = '';
// 'FY 2026-27' → 2026, the opening calendar year the platform filters on.
const openingYear = Number((this.filter.fy.match(/(\d{4})/) || [])[1]) || undefined;
this.tdsService.getDepositInterest(openingYear).subscribe({
next: (deductees) => {
this.loading = false;
setTdsDeductees(deductees);
this.summaryCacheKey = '';
this.cachedSummary = null;
},
error: (err: any) => {
this.loading = false;
setTdsDeductees([]);
this.summaryCacheKey = '';
this.cachedSummary = null;
this.loadError =
err?.error?.defaultUserMessage || 'Could not load deposit interest. The figures below cannot be shown.';
}
});
}
/**
* A different year needs a different set of interest credits, so the year re-reads from the
* platform rather than re-filtering the set already in hand.
*/
onYearChanged(): void {
this.onFilterChanged();
this.loadDeductees();
} }
/** /**
+21 -166
View File
@@ -145,178 +145,33 @@ function makePan(rand: () => number, surname: string): string {
return `${letter()}${letter()}${letter()}P${surname[0].toUpperCase()}${digits}${letter()}`; return `${letter()}${letter()}${letter()}P${surname[0].toUpperCase()}${digits}${letter()}`;
} }
let deducteeCache: TdsDeductee[] | null = null;
/** /**
* The bank's deductees. * The deductees the reports run on, loaded from the platform.
* *
* The spread is deliberate so both reports have every case in them: customers * This used to be sixty-four customers generated from a fixed seed — names, PANs, deposits and all.
* comfortably under the trigger, customers sitting just below it, customers well * The computation over them is the real section 194A calculation, so the figures looked like a
* over, senior citizens on the higher threshold, a few with no PAN on record * filing and were not one: not a rupee of it came from the bank's own books.
* (deducted at the 206AA rate), some joint deposits held as second holder, and *
* some declarations that only came in mid-year. * It is set once by whoever loads the screen and read by the pure computation below, which is why
* this is a module-level holder rather than a service the compute would have to inject.
*/ */
export function tdsDeductees(): TdsDeductee[] { let liveDeductees: TdsDeductee[] = [];
if (deducteeCache) {
return deducteeCache;
}
const rand = lcg(20260801);
const branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
const out: TdsDeductee[] = [];
for (let i = 0; i < 64; i++) { export function setTdsDeductees(deductees: TdsDeductee[]): void {
const branch = branches[i % branches.length]; liveDeductees = deductees || [];
const first = pick(rand, FIRST_NAMES); // The row cache in the compute is keyed on the financial year alone, so it has to be told that
const last = pick(rand, SURNAMES); // the underlying set changed or it would serve rows built from the previous load.
const clientId = 5000 + i; tdsDeducteesVersion++;
// A third of the book is senior citizens, which is about right for a
// co-operative bank's deposit base.
const senior = rand() < 0.34;
const birthYear = senior ? 1948 + Math.floor(rand() * 15) : 1972 + Math.floor(rand() * 22);
const dob = `${birthYear}-${`${1 + Math.floor(rand() * 12)}`.padStart(2, '0')}-${`${1 + Math.floor(rand() * 28)}`.padStart(2, '0')}`;
// Every seventeenth customer has no PAN on record, so tax comes off at the
// higher section 206AA rate. Fixed by index rather than drawn at random, so
// the case is always present.
const hasPan = i % 17 !== 3;
// Aim each customer's annual time-deposit interest at a band, so the report
// has a proper spread either side of the trigger.
const draw = rand();
let annualInterest: number;
const limit = senior ? 100_000 : 50_000;
if (draw < 0.3) {
annualInterest = 4_000 + Math.floor(rand() * (limit - 10_000)); // comfortably under
} else if (draw < 0.45) {
annualInterest = limit - 6_000 + Math.floor(rand() * 6_000); // just below the threshold
} else if (draw < 0.8) {
annualInterest = limit + 2_000 + Math.floor(rand() * 40_000); // over
} else {
annualInterest = 110_000 + Math.floor(rand() * 320_000); // well over
}
// Push two of the no-PAN customers over the trigger. Left to chance they can
// all land under it, and then the 20% line of the return summary comes out
// empty — a report line that is never exercised is one nobody has checked.
if (!hasPan && (i === 3 || i === 37)) {
annualInterest = 70_000 + i * 400;
}
const accounts: TdsAccountInput[] = [];
const acctBase = 41_00_000 + i * 137;
// One or two time deposits, split across the year's quarter ends.
const depositCount = rand() < 0.4 ? 2 : 1;
for (let d = 0; d < depositCount; d++) {
const isRd = rand() < 0.3;
const share = depositCount === 1 ? 1 : d === 0 ? 0.6 : 0.4;
const credits: InterestCredit[] = [];
for (const fy of TDS_FYS) {
// The current year is only part-run, so later quarters have no postings.
const ends = quarterEnds(fy).filter((date) => date <= TDS_AS_AT);
if (!ends.length) {
continue;
}
// The prior year ran in full; scale the current year to what has posted.
const perQuarter = Math.round((annualInterest * share) / 4);
for (const date of ends) {
credits.push({ date, amount: perQuarter });
}
}
accounts.push({
accountNo: `${branch.code}${`${acctBase + d}`.padStart(8, '0')}`,
depositTypeCode: isRd ? 'depositAccountType.recurringDeposit' : 'depositAccountType.fixedDeposit',
accountStatus: 'SINGLE',
clientId,
interestPostingPeriodType: {
// A fifth of the book is on annual posting, so tax on those settles
// once at year end rather than quarterly.
code: rand() < 0.2 ? 'savingsPostingInterestPeriodType.annual' : 'savingsPostingInterestPeriodType.quarterly'
},
credits
});
}
// Most customers also hold a savings account. Its interest must never reach
// the deduction aggregate — that is the point of including it here.
if (rand() < 0.75) {
const sbCredits: InterestCredit[] = [];
for (const fy of TDS_FYS) {
for (const date of quarterEnds(fy).filter((x) => x <= TDS_AS_AT)) {
sbCredits.push({ date, amount: 900 + Math.floor(rand() * 2_600) });
}
}
accounts.push({
accountNo: `${branch.code}${`${acctBase + 50}`.padStart(8, '0')}`,
depositTypeCode: 'depositAccountType.savingsDeposit',
accountStatus: 'SINGLE',
clientId,
interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.quarterly' },
credits: sbCredits
});
}
// One in eight is the second holder on somebody else's deposit. Tax there is
// deducted against the first-named holder, so it must not land on this
// customer's aggregate.
if (rand() < 0.12) {
const jointCredits: InterestCredit[] = [];
for (const fy of TDS_FYS) {
for (const date of quarterEnds(fy).filter((x) => x <= TDS_AS_AT)) {
jointCredits.push({ date, amount: 16_000 });
}
}
accounts.push({
accountNo: `${branch.code}${`${acctBase + 70}`.padStart(8, '0')}`,
depositTypeCode: 'depositAccountType.fixedDeposit',
accountStatus: 'JOINT',
// Somebody else is first-named.
primaryClientId: clientId + 1,
clientId: clientId + 1,
jointHolders: [{ clientId }],
interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.quarterly' },
credits: jointCredits
});
}
// Declarations. Only worth filing where tax would otherwise be deducted, so
// they cluster on the customers over the trigger.
const declarations: Record<string, Declaration> = {};
if (annualInterest > (senior ? 100_000 : 50_000) && hasPan && rand() < 0.45) {
const kind: Form15Kind = senior ? '15H' : '15G';
for (const fy of TDS_FYS) {
const y = Number(fy.slice(0, 4));
// A quarter of them only came in mid-year, which leaves tax already
// deducted that the branch cannot reverse.
const midYear = rand() < 0.25;
declarations[fy] = {
kind,
acceptedOn: midYear ? `${y}-10-01` : `${y}-04-05`,
fy,
uin: `${branch.code}${kind}${`${i + 1}`.padStart(4, '0')}`
};
}
}
out.push({
clientId,
customerId: `CIF${`${clientId}`.padStart(8, '0')}`,
name: `${first} ${last}`,
pan: hasPan ? makePan(rand, last) : '',
dob,
branchCode: branch.code,
declarations,
accounts
});
}
deducteeCache = out;
return out;
} }
/** Bumped on every load, so memoised rows can tell one data set from the next. */
export let tdsDeducteesVersion = 0;
export function tdsDeductees(): TdsDeductee[] {
return liveDeductees;
}
/** /**
* Statutory date by which tax deducted in a given month must be deposited. * Statutory date by which tax deducted in a given month must be deposited.
* *
+6 -3
View File
@@ -35,7 +35,7 @@ import {
tdsDeducteeOf, tdsDeducteeOf,
tdsModeFor tdsModeFor
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TdsChallan, TdsDeductee, tdsChallans, tdsDeductees } from './tds-reports-data'; import { TdsChallan, TdsDeductee, tdsChallans, tdsDeductees, tdsDeducteesVersion } from './tds-reports-data';
// ─────────────────────────────── Filtering ─────────────────────────────── // ─────────────────────────────── Filtering ───────────────────────────────
@@ -116,7 +116,10 @@ let rowCacheKey = '';
let rowCache: TdsInterestRow[] = []; let rowCache: TdsInterestRow[] = [];
export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] { export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
if (rowCacheKey === fy) { // Keyed on the data set as well as the year: the rows are memoised, and a fresh load would
// otherwise keep serving the previous customer's figures under the same financial year.
const key = fy + '|' + tdsDeducteesVersion;
if (rowCacheKey === key) {
return rowCache; return rowCache;
} }
@@ -190,7 +193,7 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
} }
rows.sort((a, b) => b.tdsApplicableInterest - a.tdsApplicableInterest); rows.sort((a, b) => b.tdsApplicableInterest - a.tdsApplicableInterest);
rowCacheKey = fy; rowCacheKey = key;
rowCache = rows; rowCache = rows;
return rows; return rows;
} }
@@ -0,0 +1,91 @@
/**
* 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, HttpParams } from '@angular/common/http';
import { Observable, map } from 'rxjs';
/** Custom Helpers */
import { toIsoDate } from '../core/utils/api-date';
import { TdsDeductee } from './tds-reports-data';
/**
* Deposit interest for the TDS reports.
*
* The platform returns the interest it actually credited — nothing else. A deposit is the
* customer's own money arriving and a fee is a charge, so neither is income and neither is sent.
* Working the figure out here from a balance and a rate would produce a number the passbook does
* not show.
*/
@Injectable({ providedIn: 'root' })
export class TdsReportsService {
private http = inject(HttpClient);
/**
* @param financialYear the opening calendar year — 2026 means 1 Apr 2026 to 31 Mar 2027.
* Omit to let the platform use the year the business date falls in.
*/
getDepositInterest(financialYear?: number): Observable<TdsDeductee[]> {
const params = financialYear ? new HttpParams().set('financialYear', String(financialYear)) : undefined;
return this.http
.get<any[]>('/tds/deposit-interest', { params })
.pipe(map((rows) => (rows || []).map((r) => this.toDeductee(r))));
}
/**
* Shapes one platform row into what the section 194A computation expects.
*
* Dates arrive as [y, M, d] arrays and are flattened to ISO here, because the computation
* compares them as strings when deciding which financial year a credit falls in.
*/
private toDeductee(row: any): TdsDeductee {
return {
clientId: row.clientId,
customerId: row.customerId || '',
name: row.name || '',
pan: row.pan || '',
dob: toIsoDate(row.dateOfBirth),
branchCode: row.branchName || '',
// A 15G/15H on the customer's KYC record covers the year it was accepted for. The platform
// carries the kind but not the acceptance date, so the year's start stands in — a
// declaration is operative for the year it was given, which is what the computation needs.
declarations: this.declarationsOf(row),
accounts: (row.accounts || []).map((a: any) => ({
id: a.id,
accountNo: a.accountNo || '',
depositTypeCode: a.depositTypeCode || '',
accountStatus: a.accountStatus || undefined,
clientId: a.clientId ?? row.clientId,
credits: (a.credits || []).map((c: any) => ({
date: toIsoDate(c.date),
amount: Number(c.amount) || 0,
accountNo: c.accountNo || a.accountNo || ''
}))
}))
};
}
private declarationsOf(row: any): Record<string, any> {
const kind = (row.form15Type || '').trim().toUpperCase();
if (kind !== '15G' && kind !== '15H') {
return {};
}
const out: Record<string, any> = {};
// Applied to the years the reports can show. Without an acceptance date on the record the
// declaration is treated as operative from the start of each year rather than mid-year.
for (const year of [
2025,
2026
]) {
const fy = `FY ${year}-${String(year + 1).slice(2)}`;
out[fy] = { kind, acceptedOn: `${year}-04-01`, fy };
}
return out;
}
}
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */ /* tslint:disable */
export default { export default {
'mifos_x': { 'mifos_x': {
'version': '260911', 'version': '260914',
'hash': 'fd5fe6e3' 'hash': '7afa9c7a'
}, },
'allow_switching_backend_instance': true 'allow_switching_backend_instance': true
}; };