feat: update kyc tab, branch day register, and dormancy reports
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s

This commit is contained in:
2026-09-10 18:29:33 +05:30
parent f0c06988c0
commit fd5fe6e3b2
16 changed files with 595 additions and 104 deletions
@@ -283,7 +283,7 @@
</button> </button>
</div> </div>
@if (isLateClose) { @if (needsForceReason) {
<p class="db-panel__warn"> <p class="db-panel__warn">
This day is being closed after its own date. It will be recorded as a This day is being closed after its own date. It will be recorded as a
<strong>force close</strong>, with the reason below kept permanently against it. <strong>force close</strong>, with the reason below kept permanently against it.
@@ -592,10 +592,25 @@
</p> </p>
@if (hasUnreconciledOrUnsettledCashiers) { @if (hasUnreconciledOrUnsettledCashiers) {
<div class="pg-alert pg-alert--error bd-reconcile-block"> <!-- A wall on the day itself, a warning on a past one. Today the cashier is still here and
<mat-icon>block</mat-icon> the cash can be counted, so nothing excuses closing over it. On a past date it cannot be
counted for that date at all, and blocking would leave a day nobody could ever close —
so it goes through on the reason a late close already requires, with the names recorded
against the day. -->
<div
class="pg-alert bd-reconcile-block"
[class.pg-alert--error]="blocksSameDayClose"
[class.bd-reconcile-block--warn]="!blocksSameDayClose"
>
<mat-icon>{{ blocksSameDayClose ? 'block' : 'warning' }}</mat-icon>
<div> <div>
<strong>Cannot close the day — cashiers still holding cash have not been reconciled or settled.</strong> @if (blocksSameDayClose) {
<strong
>Cannot close today &mdash; cashiers still holding cash have not been reconciled or settled.</strong
>
} @else {
<strong>This day's cash was never reconciled or settled.</strong>
}
<ul class="bd-reconcile-block__list"> <ul class="bd-reconcile-block__list">
@for (detail of unreconciledOrUnsettledCashierDetails; track detail.name) { @for (detail of unreconciledOrUnsettledCashierDetails; track detail.name) {
<li> <li>
@@ -610,17 +625,24 @@
</li> </li>
} }
</ul> </ul>
<span class="db-muted" @if (blocksSameDayClose) {
>Each cashier must reconcile their cash and settle it back to the vault before the branch day can be <span class="db-muted"
closed.</span >Each cashier must count their drawer, reconcile it, and settle the cash back to the vault. Then this
> day can be closed.</span
>
} @else {
<span class="db-muted"
>The cash cannot be counted for a date that has passed. Record why above &mdash; their names are kept
against the record.</span
>
}
</div> </div>
</div> </div>
} }
<div class="db-panel__actions"> <div class="db-panel__actions">
<button type="button" class="pg-btn pg-btn--primary" (click)="submitClose()" [disabled]="!canSubmitClose"> <button type="button" class="pg-btn pg-btn--primary" (click)="submitClose()" [disabled]="!canSubmitClose">
{{ isLateClose ? 'Force Close Day' : 'Close Day' }} {{ needsForceReason ? 'Force Close Day' : 'Close Day' }}
</button> </button>
<button type="button" class="pg-btn" (click)="cancelClose()">Cancel</button> <button type="button" class="pg-btn" (click)="cancelClose()">Cancel</button>
</div> </div>
@@ -328,3 +328,17 @@
line-height: 1.6; line-height: 1.6;
} }
} }
/* Amber, not red: an unreconciled cashier is now a reason to record why the day is being closed
rather than a refusal to close it at all. Red said "you cannot", which was true when the button
was disabled and misleading once a force close became possible. */
.bd-reconcile-block--warn {
background: #fffbeb;
border-color: #fde68a;
border-left-color: #d97706;
color: #92400e;
mat-icon {
color: #d97706;
}
}
@@ -571,6 +571,16 @@ export class BranchDayComponent implements OnInit {
return result; return result;
} }
/**
* Whether a reason has to be given before the day can close.
*
* Only for a late close. Cash still sitting with a cashier is not something a reason excuses —
* see canSubmitClose.
*/
get needsForceReason(): boolean {
return this.isLateClose;
}
get canSubmitClose(): boolean { get canSubmitClose(): boolean {
const hasFigure = this.closingCash != null || this.closeDenomTotal > 0; const hasFigure = this.closingCash != null || this.closeDenomTotal > 0;
if (!this.closeTarget || !hasFigure || this.closing) return false; if (!this.closeTarget || !hasFigure || this.closing) return false;
@@ -2432,6 +2432,17 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
{ upTo: null, category: 3, label: 'above ₹10 crore' } { upTo: null, category: 3, label: 'above ₹10 crore' }
]; ];
/**
* A numeric rule bound counts as configured only when it is present and above zero.
*
* Migration 0448 replaced every unset income/turnover NULL with 0.0, so "absent" now arrives as
* either. Every comparison in calculatedRisk already pairs its check with `> 0` for that reason;
* this keeps the criteria-less test reading the columns the same way.
*/
private static isConfigured(bound: number | string | null | undefined): boolean {
return bound != null && parseFloat(String(bound)) > 0;
}
/** "Low Risk" / "Medium Risk" / "High Risk" for a category id. */ /** "Low Risk" / "Medium Risk" / "High Risk" for a category id. */
riskCategoryLabel(category: number): string { riskCategoryLabel(category: number): string {
if (category === 3) { if (category === 3) {
@@ -2518,17 +2529,32 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// Evaluate rules from DB (ordered by orderPosition ASC) // Evaluate rules from DB (ordered by orderPosition ASC)
if (this.riskRules && this.riskRules.length > 0) { if (this.riskRules && this.riskRules.length > 0) {
for (const rule of this.riskRules) { for (const rule of this.riskRules) {
// Skip transaction/AML rules that have no KYC criteria // Only KYC profile rules categorise a customer at onboarding. The same table also holds
// ~221 AML transaction-monitoring scenarios ("Large cash transaction", "Additional RFIs
// Rule - 91", ...) which describe behaviour and may only fire against posted transactions,
// as AML alerts — RBI Master Direction on KYC separates risk categorisation (Section 12)
// from transaction monitoring (Section 13). ruleKind, set on the row, is what tells them
// apart; it replaced an inference from "this rule has no criteria set", which migration
// 0448 silently broke by writing 0.0 over those NULLs. That let rule 91 match a salaried
// client on an income band it was never meant to carry and pre-flag them for AML.
if (rule.ruleKind === 'AML') {
continue;
}
// Backstop for a rule row predating rule_kind: a rule naming no occupation, no legal form
// and no income or turnover band constrains nothing, so it would match every customer.
// Treat 0 as unset — that is how the comparisons below read it, and what 0448 left behind.
if ( if (
rule.ruleKind == null &&
rule.legalFormId == null && rule.legalFormId == null &&
rule.occupationCategoryCvId == null && rule.occupationCategoryCvId == null &&
rule.occupationCvId == null && rule.occupationCvId == null &&
rule.minMonthlyIncome == null && !ClientGeneralStepComponent.isConfigured(rule.minMonthlyIncome) &&
rule.maxMonthlyIncome == null && !ClientGeneralStepComponent.isConfigured(rule.maxMonthlyIncome) &&
rule.minAnnualIncome == null && !ClientGeneralStepComponent.isConfigured(rule.minAnnualIncome) &&
rule.maxAnnualIncome == null && !ClientGeneralStepComponent.isConfigured(rule.maxAnnualIncome) &&
rule.minAnnualTurnover == null && !ClientGeneralStepComponent.isConfigured(rule.minAnnualTurnover) &&
rule.maxAnnualTurnover == null !ClientGeneralStepComponent.isConfigured(rule.maxAnnualTurnover)
) { ) {
continue; continue;
} }
@@ -2576,7 +2602,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
return { return {
category: rule.riskCategory, category: rule.riskCategory,
reason: rule.reason, reason: rule.reason,
amlCheckStatus: rule.amlCheckStatus || (rule.riskCategory === 3 ? 'FLAGGED' : 'CLEAR'), // A profile rule sets the risk category; it does not raise an AML flag. High Risk means
// enhanced due diligence and a 2-yearly KYC refresh (RBI MD Sections 12 and 38), not
// suspicion — an alert belongs to observed transaction behaviour (Section 13), so a
// customer who has transacted nothing yet stays CLEAR. A bank that genuinely wants a
// rule to flag on match can still say so explicitly via the rule's own amlCheckStatus.
amlCheckStatus: rule.amlCheckStatus || 'CLEAR',
bannedEntityMatch: rule.bannedEntityMatch || 'NO', bannedEntityMatch: rule.bannedEntityMatch || 'NO',
willfulDefaulter: rule.willfulDefaulter || 'NO', willfulDefaulter: rule.willfulDefaulter || 'NO',
ecgcSalFlag: rule.ecgcSalFlag || 'NO', ecgcSalFlag: rule.ecgcSalFlag || 'NO',
@@ -280,6 +280,82 @@
</span> </span>
</div> </div>
<!--
The OTP exchange, directly under the row that started it.
It used to sit at the very bottom of the tab, past the PAN-Aadhaar seeding panel: clicking
Re-Verify appeared to do nothing, because the input it opened was off-screen. Keeping it
against the Aadhaar row means the field is where the officer is already looking.
-->
@if (showAadhaarReVerify && !aadhaarOtpSent) {
<div class="kv-otp-bar kv-otp-bar--inline">
<mat-form-field appearance="outline" class="kv-otp-input">
<mat-label>Enter 12-digit Aadhaar</mat-label>
<input
matInput
[(ngModel)]="aadhaarFullNumber"
[ngModelOptions]="{ standalone: true }"
maxlength="12"
type="password"
/>
</mat-form-field>
<button
mat-raised-button
color="primary"
type="button"
class="kv-otp-btn"
[disabled]="!aadhaarFullNumber || aadhaarFullNumber.length !== 12 || verifying.aadhaar"
(click)="sendAadhaarOtp()"
>
@if (verifying.aadhaar) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Send OTP
}
</button>
<button mat-button type="button" class="kv-otp-btn" (click)="cancelAadhaarReVerify()">Cancel</button>
</div>
}
@if (aadhaarOtpSent) {
<div class="kv-otp-bar kv-otp-bar--inline">
<span class="kv-otp-label"><mat-icon>sms</mat-icon> OTP sent</span>
<mat-form-field appearance="outline" class="kv-otp-input">
<mat-label>Enter OTP</mat-label>
<input
matInput
[(ngModel)]="aadhaarOtp"
[ngModelOptions]="{ standalone: true }"
maxlength="6"
autocomplete="one-time-code"
/>
</mat-form-field>
<button
mat-raised-button
color="primary"
type="button"
class="kv-otp-btn"
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
(click)="verifyAadhaarOtp()"
>
@if (aadhaarOtpVerifying) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Verify
}
</button>
<button
mat-stroked-button
type="button"
class="kv-otp-btn"
[disabled]="verifying.aadhaar"
(click)="sendAadhaarOtp()"
>
Resend
</button>
<button mat-button type="button" class="kv-otp-btn" (click)="cancelAadhaarReVerify()">Cancel</button>
</div>
}
<!-- <!--
Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the Aadhaar is unverified but an enrolment receipt is on file. Shown in full because it is the
only reason Aadhaar carries no KYC exception. only reason Aadhaar carries no KYC exception.
@@ -565,73 +641,6 @@
</div> </div>
} }
<!-- Aadhaar OTP inline (shown below table when re-verifying) -->
@if (showAadhaarReVerify && !aadhaarOtpSent) {
<div class="kv-otp-bar">
<mat-form-field appearance="outline" class="kv-otp-input">
<mat-label>Enter 12-digit Aadhaar</mat-label>
<input
matInput
[(ngModel)]="aadhaarFullNumber"
[ngModelOptions]="{ standalone: true }"
maxlength="12"
type="password"
/>
</mat-form-field>
<button
mat-raised-button
color="primary"
type="button"
class="kv-otp-btn"
[disabled]="!aadhaarFullNumber || aadhaarFullNumber.length !== 12 || verifying.aadhaar"
(click)="sendAadhaarOtp()"
>
@if (verifying.aadhaar) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Send OTP
}
</button>
</div>
}
@if (aadhaarOtpSent) {
<div class="kv-otp-bar">
<span class="kv-otp-label"><mat-icon>sms</mat-icon> OTP sent</span>
<mat-form-field appearance="outline" class="kv-otp-input">
<mat-label>Enter OTP</mat-label>
<input
matInput
[(ngModel)]="aadhaarOtp"
[ngModelOptions]="{ standalone: true }"
maxlength="6"
autocomplete="one-time-code"
/>
</mat-form-field>
<button
mat-raised-button
color="primary"
type="button"
class="kv-otp-btn"
[disabled]="!aadhaarOtp || aadhaarOtp.length < 4 || aadhaarOtpVerifying"
(click)="verifyAadhaarOtp()"
>
@if (aadhaarOtpVerifying) {
<mat-progress-spinner diameter="14" mode="indeterminate"></mat-progress-spinner>
} @else {
Verify
}
</button>
<button
mat-stroked-button
type="button"
class="kv-otp-btn"
[disabled]="verifying.aadhaar"
(click)="sendAadhaarOtp()"
>
Resend
</button>
</div>
}
@if (verificationError.aadhaar) { @if (verificationError.aadhaar) {
<div class="kyc-error-msg"> <div class="kyc-error-msg">
<mat-icon class="kyc-error-icon">error_outline</mat-icon> {{ verificationError.aadhaar }} <mat-icon class="kyc-error-icon">error_outline</mat-icon> {{ verificationError.aadhaar }}
@@ -1128,6 +1137,69 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<!-- Officially Valid Document. Shown when one is on file or the alert is asking for
it, so re-collecting an expired document is done on this form rather than
sending the officer somewhere else. -->
@if (kycData?.kycDocumentName || kycDocumentNeedsUpdate) {
<div class="full-span kyc-doc-fields">
<div class="kyc-doc-fields__head">
<h4 class="kyc-doc-fields__title">Officially Valid Document</h4>
@if (kycDocumentNeedsUpdate) {
<span class="kyc-doc-fields__flag">Re-collect from the customer</span>
}
</div>
<div class="kyc-doc-fields__grid">
<mat-form-field appearance="outline">
<mat-label>Document</mat-label>
<mat-select formControlName="kycDocumentName">
<mat-option value="Aadhaar Card">Aadhaar Card</mat-option>
<mat-option value="Passport">Passport</mat-option>
<mat-option value="Driving Licence">Driving Licence</mat-option>
<mat-option value="Voter ID">Voter ID</mat-option>
<mat-option value="NREGA Job Card">NREGA Job Card</mat-option>
<mat-option value="Letter from National Population Register">Letter from NPR</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Document Number</mat-label>
<input matInput formControlName="kycDocumentNumber" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Issued On</mat-label>
<input
matInput
[matDatepicker]="docIssuePicker"
[max]="maxDate"
formControlName="kycDocIssueDate"
/>
<mat-datepicker-toggle matSuffix [for]="docIssuePicker"></mat-datepicker-toggle>
<mat-datepicker #docIssuePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Valid Till</mat-label>
<input matInput [matDatepicker]="docExpiryPicker" formControlName="kycDocExpiryDate" />
<mat-datepicker-toggle matSuffix [for]="docExpiryPicker"></mat-datepicker-toggle>
<mat-datepicker #docExpiryPicker></mat-datepicker>
<mat-hint>Blank if it does not expire</mat-hint>
</mat-form-field>
</div>
<!-- The scans live with the document itself, on the identifier the customer's
original was attached to. Re-scanning is done there rather than duplicated
here, so both sides, the cropper and the replace-in-place all behave the
same way they did when the document was first collected. -->
<a class="kyc-doc-scans-link" [routerLink]="['/clients', clientId, 'edit']">
<mat-icon>photo_library</mat-icon>
<span>Replace the scanned front / back of this document</span>
<mat-icon class="kyc-doc-scans-link__go">chevron_right</mat-icon>
</a>
</div>
}
<!-- KYC Exception Flag --> <!-- KYC Exception Flag -->
<div <div
class="full-span exception-controls-row" class="full-span exception-controls-row"
@@ -856,6 +856,18 @@
margin-bottom: 12px; margin-bottom: 12px;
} }
// Sitting inside the table, directly under the Aadhaar row it belongs to. Indented and tinted so
// it reads as that row opening up rather than as a new section, and the flat top edge joins it to
// the row above.
.kv-otp-bar--inline {
margin: 0 0 8px 28px;
background: #eff6ff;
border-color: #bfdbfe;
border-top-left-radius: 0;
border-top-right-radius: 0;
flex-wrap: wrap;
}
.kv-otp-label { .kv-otp-label {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1576,3 +1588,96 @@
} }
} }
} }
// ── Officially valid document, on the edit form ────────────────────────────
// Set apart from the identity fields above it because it is one document with its own
// provenance — a type, a number, and the two dates that decide whether it is still valid —
// rather than four unrelated inputs.
.kyc-doc-fields {
margin: 4px 0 20px;
padding: 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.kyc-doc-fields__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.kyc-doc-fields__title {
margin: 0;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: #475569;
}
.kyc-doc-fields__flag {
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: #92400e;
background: #fef3c7;
}
// Four across on a wide screen, folding to two and then one. The dates sit next to each other
// at every width, since it is the pair that has to be read together.
.kyc-doc-fields__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
@media (width <= 1280px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (width <= 640px) {
grid-template-columns: minmax(0, 1fr);
}
mat-form-field {
width: 100%;
}
}
.kyc-doc-scans-link {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
padding: 10px 12px;
border: 1px dashed #cbd5e1;
border-radius: 6px;
font-size: 13px;
color: #334155;
text-decoration: none;
background: #fff;
transition:
border-color 0.15s,
background 0.15s;
&:hover {
border-color: #94a3b8;
background: #f1f5f9;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #64748b;
}
}
.kyc-doc-scans-link__go {
margin-left: auto;
}
@@ -126,6 +126,12 @@ export class KycTabComponent implements OnInit, OnDestroy {
1, 1,
[Validators.required] [Validators.required]
], ],
// The officially valid document, so a lapsed one can be re-collected on this form. The
// backend has always accepted these four; only the fields were missing.
kycDocumentName: [''],
kycDocumentNumber: [''],
kycDocIssueDate: [null],
kycDocExpiryDate: [null],
panNumber: [ panNumber: [
'', '',
[Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)] [Validators.pattern(/^[A-Z]{5}[0-9]{4}[A-Z]{1}$/)]
@@ -176,6 +182,10 @@ export class KycTabComponent implements OnInit, OnDestroy {
// the full 12-digit number to re-verify, so don't pre-fill. // the full 12-digit number to re-verify, so don't pre-fill.
aadhaarNumber: '', aadhaarNumber: '',
gstinNumber: data.gstinNumber || '', gstinNumber: data.gstinNumber || '',
kycDocumentName: data.kycDocumentName || '',
kycDocumentNumber: data.kycDocumentNumber || '',
kycDocIssueDate: this.parseApiDate(data.kycDocIssueDate),
kycDocExpiryDate: this.parseApiDate(data.kycDocExpiryDate),
remarks: data.remarks || '', remarks: data.remarks || '',
isException: data.isException || false, isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : [] exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
@@ -355,6 +365,41 @@ export class KycTabComponent implements OnInit, OnDestroy {
* stored record; either way the customer has cleared that check. Verifying one of two * stored record; either way the customer has cleared that check. Verifying one of two
* outstanding items narrows the exception to the remaining one rather than closing it. * outstanding items narrows the exception to the remaining one rather than closing it.
*/ */
/**
* The officially valid document, for the update payload.
*
* Only sent when a document is named, so an officer editing the risk category on a client who
* never had an OVD recorded does not silently blank the other three columns.
*/
private buildDocumentFields() {
const name = (this.kycForm.value.kycDocumentName || '').trim();
if (!name) {
return {};
}
const dateFormat = 'dd MMMM yyyy';
return {
kycDocumentName: name,
kycDocumentNumber: (this.kycForm.value.kycDocumentNumber || '').trim() || null,
kycDocIssueDate: this.kycForm.value.kycDocIssueDate
? this.dateUtils.formatDate(this.kycForm.value.kycDocIssueDate, dateFormat)
: null,
// Blank is meaningful: a passport expires, a PAN card does not.
kycDocExpiryDate: this.kycForm.value.kycDocExpiryDate
? this.dateUtils.formatDate(this.kycForm.value.kycDocExpiryDate, dateFormat)
: null
};
}
/** True while the banner is asking for a document, so the form shows the fields to supply it. */
get kycDocumentNeedsUpdate(): boolean {
return this.showKycDocumentAlert;
}
/** Today, as the latest an existing document can have been issued. */
get maxDate(): Date {
return new Date();
}
private buildExceptionFields() { private buildExceptionFields() {
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true; const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true; const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
@@ -392,6 +437,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
remarks: this.kycForm.value.remarks || null, remarks: this.kycForm.value.remarks || null,
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes. // dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
dateFormat: 'dd MMMM yyyy', dateFormat: 'dd MMMM yyyy',
...this.buildDocumentFields(),
...this.buildExceptionFields() ...this.buildExceptionFields()
}; };
@@ -478,11 +524,12 @@ export class KycTabComponent implements OnInit, OnDestroy {
if (response?.changes?.panVerified) { if (response?.changes?.panVerified) {
this.verificationStatus.pan = 'VERIFIED'; this.verificationStatus.pan = 'VERIFIED';
this.verificationDetails.pan = response.changes; this.verificationDetails.pan = response.changes;
// A PAN that now verifies makes any earlier declaration moot. // A PAN that now verifies makes any earlier declaration moot. That write already
// re-derives the exception columns, so it settles the exception too.
if (this.panDeclarationTaken) { if (this.panDeclarationTaken) {
this.writePanDeclaration(null); this.writePanDeclaration(null);
} else { } else {
this.loadKycData(); this.syncExceptionAfterVerify();
} }
} else { } else {
this.verificationStatus.pan = 'FAILED'; this.verificationStatus.pan = 'FAILED';
@@ -660,6 +707,60 @@ export class KycTabComponent implements OnInit, OnDestroy {
}); });
} }
/**
* Re-derives the exception columns once a verification has actually passed.
*
* `saveKycBeforeVerify()` writes the exception state *before* the vendor is called, when the
* check is by definition still outstanding — so a check that then succeeds leaves the record
* carrying an exception nobody closes. The KYC tab reads `panVerified` and shows "Verified"
* while the exceptions register keeps reading `isException`, which is why a client could sit in
* the Pending tab with every document verified.
*
* Called on each verification success. `buildExceptionFields()` already prefers the in-session
* `verificationStatus` over the stored record, so the freshly passed check counts even though
* `kycData` has not been reloaded yet; `hadException` still reads the stored value, which is
* what tells it to close the entry rather than leave every flag off.
*
* Verifying one of two outstanding items narrows the exception instead of closing it — that
* falls out of the derivation, so this needs no special case for it.
*/
private syncExceptionAfterVerify(): void {
if (!this.kycId) {
this.loadKycData();
return;
}
const exceptionFields = this.buildExceptionFields();
// Nothing recorded and nothing outstanding — no write would change anything.
if (this.kycData?.isException !== true && !exceptionFields.isException) {
this.loadKycData();
return;
}
this.clientsService
.updateClientKyc(this.clientId, this.kycId, {
locale: 'en',
dateFormat: 'dd MMMM yyyy',
riskCategory: this.kycForm.value.riskCategory || 1,
...exceptionFields
})
.pipe(takeUntil(this.destroy$))
.subscribe({
// The verification itself succeeded either way, so a failure here must not read as a
// failed check — surface it as the save problem it is and still refresh.
next: () => this.loadKycData(),
error: (err: any) => {
this.errorMessage =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Verification succeeded but the KYC exception could not be updated.';
this.loadKycData();
}
});
}
/** /**
* Records the UIDAI enrolment receipt accepted in place of a verified Aadhaar. * Records the UIDAI enrolment receipt accepted in place of a verified Aadhaar.
* *
@@ -786,6 +887,12 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.verificationError.aadhaar = null; this.verificationError.aadhaar = null;
} }
/** Abandons the OTP exchange, so a mis-click can be backed out of without reloading the tab. */
cancelAadhaarReVerify(): void {
this.resetOtpState();
this.verificationError.aadhaar = null;
}
/** Step 1: Send OTP to Aadhaar-linked mobile */ /** Step 1: Send OTP to Aadhaar-linked mobile */
sendAadhaarOtp(): void { sendAadhaarOtp(): void {
const aadhaar = this.isEditMode ? this.kycForm?.get('aadhaarNumber')?.value : this.aadhaarFullNumber; const aadhaar = this.isEditMode ? this.kycForm?.get('aadhaarNumber')?.value : this.aadhaarFullNumber;
@@ -855,7 +962,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
if (this.aadhaarReceiptTaken) { if (this.aadhaarReceiptTaken) {
this.writeAadhaarReceipt(null); this.writeAadhaarReceipt(null);
} else { } else {
this.loadKycData(); this.syncExceptionAfterVerify();
} }
} else { } else {
this.verificationStatus.aadhaar = 'FAILED'; this.verificationStatus.aadhaar = 'FAILED';
+11
View File
@@ -536,6 +536,17 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/identifiers`, identifierData); return this.http.post(`/clients/${clientId}/identifiers`, identifierData);
} }
/**
* Updates an identifier in place — the document number, its type, or the description.
*
* Without this the only way to record a re-collected document was to post a new identifier, so
* a customer whose Aadhaar was re-verified ended up with two rows carrying the same number and
* the same scans, and nothing saying which was current.
*/
updateClientIdentifier(clientId: string, identifierId: string, identifierData: any) {
return this.http.put(`/clients/${clientId}/identifiers/${identifierId}`, identifierData);
}
deleteClientIdentifier(clientId: string, identifierId: string) { deleteClientIdentifier(clientId: string, identifierId: string) {
return this.http.delete(`/clients/${clientId}/identifiers/${identifierId}`); return this.http.delete(`/clients/${clientId}/identifiers/${identifierId}`);
} }
@@ -1079,12 +1079,15 @@ export class EditClientComponent {
*/ */
private saveKycDocProof(clientId: string) { private saveKycDocProof(clientId: string) {
const payload = this.clientKycStep?.kycDocPayload; const payload = this.clientKycStep?.kycDocPayload;
if (!payload?.proofId || !payload?.files?.length) return; // A document number corrected without re-scanning the document is still a change worth
// saving, so this no longer requires files — it used to return here and the new number was
// silently dropped.
if (!payload?.proofId) return;
const proofName = payload.proof?.name || 'KYC Document'; const proofName = payload.proof?.name || 'KYC Document';
const uploadFiles = (identifierId: string) => { const uploadFiles = (identifierId: string) => {
for (const entry of payload.files) { for (const entry of payload.files || []) {
const formData = new FormData(); const formData = new FormData();
formData.append('name', `${proofName} — ${entry.slot}`); formData.append('name', `${proofName} — ${entry.slot}`);
formData.append('file', entry.file); formData.append('file', entry.file);
@@ -1092,8 +1095,26 @@ export class EditClientComponent {
} }
}; };
if (this.clientKycStep?.existingKycIdentifierId) { // A document already on file is updated in place, not posted again.
uploadFiles(this.clientKycStep.existingKycIdentifierId); //
// Re-collecting a document used to create a second identifier carrying the same number and
// the same scans, with nothing to say which of the two was current — the client in front of
// this was left with two identical Aadhaar rows. The document the customer renewed is the
// one already recorded, so its number and type are written back to that row and any new
// scans are attached to it.
const existingId = this.clientKycStep?.existingKycIdentifierId;
if (existingId) {
this.resolveDocumentTypeId(payload.proofId, proofName).subscribe((documentTypeId: number | null) => {
const update: any = { documentKey: payload.documentKey || `${proofName} - ${clientId}`, status: 'Active' };
if (documentTypeId != null) {
update.documentTypeId = documentTypeId;
}
this.clientsService.updateClientIdentifier(clientId, existingId, update).subscribe({
// The scans go up either way: failing to rewrite the number must not cost the images.
next: () => uploadFiles(existingId),
error: () => uploadFiles(existingId)
});
});
return; return;
} }
@@ -230,6 +230,23 @@ describe('deriveKycExceptionFields', () => {
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`; const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
expect(deriveKycExceptionFields(existingException(false, true, both)).exceptionReason).toBe(KYC_EXCEPTION_PAN); expect(deriveKycExceptionFields(existingException(false, true, both)).exceptionReason).toBe(KYC_EXCEPTION_PAN);
}); });
it('closes a record left open by a verification that passed after the exception was written', () => {
// A verification writes the exception before calling the vendor, so a check that then passes
// leaves `isException` set with the stale reason still loaded into the form's reason list.
// Saving such a record has to close it, not re-raise it off its own stale reason.
expect(
deriveKycExceptionFields({
...existingException(true, true, KYC_EXCEPTION_PAN),
manualReasons: [KYC_EXCEPTION_PAN]
})
).toEqual({
isException: false,
exceptionReason: KYC_EXCEPTION_PAN,
exceptionResolved: true,
exceptionResolvedOn: TODAY
});
});
}); });
describe('re-opening', () => { describe('re-opening', () => {
+37
View File
@@ -0,0 +1,37 @@
/**
* 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/.
*/
/**
* A date from the Fineract API as `yyyy-mm-dd`, or '' when there is none.
*
* Fineract serialises dates as `[year, month, day]` arrays, not strings, and reading one as a
* string gives `"2026,6,12"` — which renders literally on screen, and which `new Date()` turns
* into an Invalid Date, so every day-count off it comes out NaN. Two reports have already shipped
* that bug: the dormancy report sat on "Loading..." forever when `toISOString()` threw inside a
* subscribe callback, and the re-KYC report showed "2026,6,12 · Due in NaN days" with all three
* stage counters stuck on zero.
*
* Strings are passed through, since some endpoints are configured to return them.
*/
export function toIsoDate(value: any): string {
if (!value) {
return '';
}
if (Array.isArray(value)) {
const [
year,
month,
day
] = value;
if (!year || !month || !day) {
return '';
}
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
return String(value).slice(0, 10);
}
@@ -34,6 +34,7 @@ import {
sortByIdle, sortByIdle,
stageFromSubStatus stageFromSubStatus
} from './dormancy-report.compute'; } from './dormancy-report.compute';
import { toIsoDate } from 'app/core/utils/api-date';
/** /**
* The three reports on accounts that have gone quiet. * The three reports on accounts that have gone quiet.
@@ -109,9 +110,20 @@ export class DormancyReportComponent implements OnInit {
this.clientsService.getDormantAccounts().subscribe({ this.clientsService.getDormantAccounts().subscribe({
next: (accounts: any[]) => { next: (accounts: any[]) => {
this.rows = sortByIdle( // Mapping is wrapped because it did once throw on a date the API returned in a shape this
(accounts || []).map((a) => this.mapAccount(a)).filter((r): r is DormancyRow => r !== null) // screen did not expect. The throw escaped the callback, so `loading` was never cleared and
); // the report sat on "Loading..." with every control dead and nothing in the network tab to
// explain it. A bad row should cost the row, not the screen.
try {
this.rows = sortByIdle(
(accounts || []).map((a) => this.mapAccount(a)).filter((r): r is DormancyRow => r !== null)
);
} catch (e) {
this.rows = [];
this.errorMessage = 'Could not read the dormancy reports.';
console.error('Dormancy report: could not map the accounts returned by the server', e);
}
this.openFirstStageWithRows();
this.loading = false; this.loading = false;
}, },
error: (err: any) => { error: (err: any) => {
@@ -300,7 +312,7 @@ export class DormancyReportComponent implements OnInit {
return null; return null;
} }
const lastTxn = (a?.lastCustomerTransactionDate || '').toString().slice(0, 10); const lastTxn = toIsoDate(a?.lastCustomerTransactionDate);
const next = lastTxn ? nextStage(lastTxn, stage) : { date: '', label: '' }; const next = lastTxn ? nextStage(lastTxn, stage) : { date: '', label: '' };
return { return {
@@ -321,10 +333,10 @@ export class DormancyReportComponent implements OnInit {
// against it. Absence of that row is the whole point of the report: the money has left the // against it. Absence of that row is the whole point of the report: the money has left the
// customer's account but is not yet accounted for as having reached RBI. // customer's account but is not yet accounted for as having reached RBI.
remittance: stage === 'ESCHEAT' ? (a?.deafRemittedOn ? 'REMITTED' : 'PENDING') : undefined, remittance: stage === 'ESCHEAT' ? (a?.deafRemittedOn ? 'REMITTED' : 'PENDING') : undefined,
escheatedOn: (a?.closedOnDate || '').toString().slice(0, 10) || undefined, escheatedOn: toIsoDate(a?.closedOnDate) || undefined,
remittedOn: (a?.deafRemittedOn || '').toString().slice(0, 10) || undefined, remittedOn: toIsoDate(a?.deafRemittedOn) || undefined,
deafReference: a?.deafReference || undefined, deafReference: a?.deafReference || undefined,
refundedOn: (a?.deafRefundedOn || '').toString().slice(0, 10) || undefined, refundedOn: toIsoDate(a?.deafRefundedOn) || undefined,
mobile: a?.mobileNo || '' mobile: a?.mobileNo || ''
}; };
} }
@@ -391,6 +403,30 @@ export class DormancyReportComponent implements OnInit {
} }
} }
/**
* Opens the first stage that actually has accounts on it.
*
* The tab that happens to be selected is not necessarily the one holding the data: the report
* opens on Dormant, and an account that has only reached Inactive — or that fell back to it
* because a customer transaction reset its clock — leaves the reader looking at "No accounts at
* this stage" beside a card counting 1. Only moves off a tab that is empty, so a reader who has
* deliberately opened a stage is not thrown off it when the data reloads.
*/
private openFirstStageWithRows(): void {
if (this.rowsForStage(this.tab).length > 0) {
return;
}
const ladder: DormancyStage[] = [
'INACTIVE',
'DORMANT',
'ESCHEAT'
];
const populated = ladder.find((stage) => this.rowsForStage(stage).length > 0);
if (populated) {
this.showTab(populated);
}
}
// ── Presentation ─────────────────────────────────────────────────────────── // ── Presentation ───────────────────────────────────────────────────────────
/** The server already returns the office by name, so there is nothing to look up. */ /** The server already returns the office by name, so there is nothing to look up. */
@@ -111,6 +111,10 @@ export interface DormancyRow {
function addDays(date: string, days: number): string { function addDays(date: string, days: number): string {
const d = new Date(`${date}T00:00:00Z`); const d = new Date(`${date}T00:00:00Z`);
if (Number.isNaN(d.getTime())) {
// Never let an unparseable date take the whole report down with it.
return '';
}
return new Date(d.getTime() + days * 86400000).toISOString().slice(0, 10); return new Date(d.getTime() + days * 86400000).toISOString().slice(0, 10);
} }
+3 -2
View File
@@ -19,6 +19,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/** Custom Services */ /** Custom Services */
import { ClientsService } from '../clients/clients.service'; import { ClientsService } from '../clients/clients.service';
import { toIsoDate } from 'app/core/utils/api-date';
/** Report data + computation */ /** Report data + computation */
import { import {
@@ -176,7 +177,7 @@ export class KycExpiryComponent implements OnInit {
accountNo: r?.clientAccountNo || '', accountNo: r?.clientAccountNo || '',
branchCode: r?.clientOfficeName || '', branchCode: r?.clientOfficeName || '',
riskCategory: r?.riskCategory ?? 1, riskCategory: r?.riskCategory ?? 1,
expiryDate: (r?.expiryDate || '').toString().slice(0, 10), expiryDate: toIsoDate(r?.expiryDate),
// Only the masked forms come back from the server, which is what the dialog shows and all it // Only the masked forms come back from the server, which is what the dialog shows and all it
// needs — the destination itself is resolved server-side when a notice is sent. // needs — the destination itself is resolved server-side when a notice is sent.
mobile: r?.clientMobileMasked || (r?.hasMobile ? 'On file' : ''), mobile: r?.clientMobileMasked || (r?.hasMobile ? 'On file' : ''),
@@ -187,7 +188,7 @@ export class KycExpiryComponent implements OnInit {
private mapContact(log: any): KycContactRow { private mapContact(log: any): KycContactRow {
const status = log?.deliveryStatus || 'NOT_APPLICABLE'; const status = log?.deliveryStatus || 'NOT_APPLICABLE';
return { return {
date: (log?.contactedOn || log?.createdOn || '').toString().slice(0, 10), date: toIsoDate(log?.contactedOn || log?.createdOn),
channel: log?.channel || '', channel: log?.channel || '',
channelLabel: channelLabel(log?.channel || ''), channelLabel: channelLabel(log?.channel || ''),
notes: log?.notes || '', notes: log?.notes || '',
+5 -2
View File
@@ -17,8 +17,11 @@
window["env"]["apiVersion"] = ''; window["env"]["apiVersion"] = '';
window["env"]["apiActuator"] = ''; window["env"]["apiActuator"] = '';
window["env"]["fineractPlatformTenantId"] = 'abc_bank'; // Tenants the login screen offers, and which one it starts on. Every identifier here has to exist
window["env"]["fineractPlatformTenantIds"] = 'default,abc_bank,socity_bank'; // in the tenants table of the tenant-store database, or the platform rejects the request as an
// unknown tenant — 'socity_bank' was listed with no such row behind it.
window["env"]["fineractPlatformTenantId"] = 'default';
window["env"]["fineractPlatformTenantIds"] = 'default,abc_bank';
window['env']['tenantLogoUrl'] = ''; window['env']['tenantLogoUrl'] = '';
window['env']['tenantLogoUrlDark'] = ''; window['env']['tenantLogoUrlDark'] = '';
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */ /* tslint:disable */
export default { export default {
'mifos_x': { 'mifos_x': {
'version': '260909', 'version': '260910',
'hash': '03bc2ff1' 'hash': 'f0c06988'
}, },
'allow_switching_backend_instance': true 'allow_switching_backend_instance': true
}; };