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>
</div>
@if (isLateClose) {
@if (needsForceReason) {
<p class="db-panel__warn">
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.
@@ -592,10 +592,25 @@
</p>
@if (hasUnreconciledOrUnsettledCashiers) {
<div class="pg-alert pg-alert--error bd-reconcile-block">
<mat-icon>block</mat-icon>
<!-- A wall on the day itself, a warning on a past one. Today the cashier is still here and
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>
<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">
@for (detail of unreconciledOrUnsettledCashierDetails; track detail.name) {
<li>
@@ -610,17 +625,24 @@
</li>
}
</ul>
<span class="db-muted"
>Each cashier must reconcile their cash and settle it back to the vault before the branch day can be
closed.</span
>
@if (blocksSameDayClose) {
<span class="db-muted"
>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 class="db-panel__actions">
<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 type="button" class="pg-btn" (click)="cancelClose()">Cancel</button>
</div>
@@ -328,3 +328,17 @@
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;
}
/**
* 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 {
const hasFigure = this.closingCash != null || this.closeDenomTotal > 0;
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' }
];
/**
* 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. */
riskCategoryLabel(category: number): string {
if (category === 3) {
@@ -2518,17 +2529,32 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
// Evaluate rules from DB (ordered by orderPosition ASC)
if (this.riskRules && this.riskRules.length > 0) {
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 (
rule.ruleKind == null &&
rule.legalFormId == null &&
rule.occupationCategoryCvId == null &&
rule.occupationCvId == null &&
rule.minMonthlyIncome == null &&
rule.maxMonthlyIncome == null &&
rule.minAnnualIncome == null &&
rule.maxAnnualIncome == null &&
rule.minAnnualTurnover == null &&
rule.maxAnnualTurnover == null
!ClientGeneralStepComponent.isConfigured(rule.minMonthlyIncome) &&
!ClientGeneralStepComponent.isConfigured(rule.maxMonthlyIncome) &&
!ClientGeneralStepComponent.isConfigured(rule.minAnnualIncome) &&
!ClientGeneralStepComponent.isConfigured(rule.maxAnnualIncome) &&
!ClientGeneralStepComponent.isConfigured(rule.minAnnualTurnover) &&
!ClientGeneralStepComponent.isConfigured(rule.maxAnnualTurnover)
) {
continue;
}
@@ -2576,7 +2602,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
return {
category: rule.riskCategory,
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',
willfulDefaulter: rule.willfulDefaulter || 'NO',
ecgcSalFlag: rule.ecgcSalFlag || 'NO',
@@ -280,6 +280,82 @@
</span>
</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
only reason Aadhaar carries no KYC exception.
@@ -565,73 +641,6 @@
</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) {
<div class="kyc-error-msg">
<mat-icon class="kyc-error-icon">error_outline</mat-icon> {{ verificationError.aadhaar }}
@@ -1128,6 +1137,69 @@
</mat-select>
</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 -->
<div
class="full-span exception-controls-row"
@@ -856,6 +856,18 @@
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 {
display: flex;
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,
[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: [
'',
[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.
aadhaarNumber: '',
gstinNumber: data.gstinNumber || '',
kycDocumentName: data.kycDocumentName || '',
kycDocumentNumber: data.kycDocumentNumber || '',
kycDocIssueDate: this.parseApiDate(data.kycDocIssueDate),
kycDocExpiryDate: this.parseApiDate(data.kycDocExpiryDate),
remarks: data.remarks || '',
isException: data.isException || false,
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
* 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() {
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === 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,
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
dateFormat: 'dd MMMM yyyy',
...this.buildDocumentFields(),
...this.buildExceptionFields()
};
@@ -478,11 +524,12 @@ export class KycTabComponent implements OnInit, OnDestroy {
if (response?.changes?.panVerified) {
this.verificationStatus.pan = 'VERIFIED';
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) {
this.writePanDeclaration(null);
} else {
this.loadKycData();
this.syncExceptionAfterVerify();
}
} else {
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.
*
@@ -786,6 +887,12 @@ export class KycTabComponent implements OnInit, OnDestroy {
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 */
sendAadhaarOtp(): void {
const aadhaar = this.isEditMode ? this.kycForm?.get('aadhaarNumber')?.value : this.aadhaarFullNumber;
@@ -855,7 +962,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
if (this.aadhaarReceiptTaken) {
this.writeAadhaarReceipt(null);
} else {
this.loadKycData();
this.syncExceptionAfterVerify();
}
} else {
this.verificationStatus.aadhaar = 'FAILED';
+11
View File
@@ -536,6 +536,17 @@ export class ClientsService {
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) {
return this.http.delete(`/clients/${clientId}/identifiers/${identifierId}`);
}
@@ -1079,12 +1079,15 @@ export class EditClientComponent {
*/
private saveKycDocProof(clientId: string) {
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 uploadFiles = (identifierId: string) => {
for (const entry of payload.files) {
for (const entry of payload.files || []) {
const formData = new FormData();
formData.append('name', `${proofName} — ${entry.slot}`);
formData.append('file', entry.file);
@@ -1092,8 +1095,26 @@ export class EditClientComponent {
}
};
if (this.clientKycStep?.existingKycIdentifierId) {
uploadFiles(this.clientKycStep.existingKycIdentifierId);
// A document already on file is updated in place, not posted again.
//
// 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;
}
@@ -230,6 +230,23 @@ describe('deriveKycExceptionFields', () => {
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
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', () => {
+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,
stageFromSubStatus
} from './dormancy-report.compute';
import { toIsoDate } from 'app/core/utils/api-date';
/**
* The three reports on accounts that have gone quiet.
@@ -109,9 +110,20 @@ export class DormancyReportComponent implements OnInit {
this.clientsService.getDormantAccounts().subscribe({
next: (accounts: any[]) => {
this.rows = sortByIdle(
(accounts || []).map((a) => this.mapAccount(a)).filter((r): r is DormancyRow => r !== null)
);
// Mapping is wrapped because it did once throw on a date the API returned in a shape this
// 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;
},
error: (err: any) => {
@@ -300,7 +312,7 @@ export class DormancyReportComponent implements OnInit {
return null;
}
const lastTxn = (a?.lastCustomerTransactionDate || '').toString().slice(0, 10);
const lastTxn = toIsoDate(a?.lastCustomerTransactionDate);
const next = lastTxn ? nextStage(lastTxn, stage) : { date: '', label: '' };
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
// customer's account but is not yet accounted for as having reached RBI.
remittance: stage === 'ESCHEAT' ? (a?.deafRemittedOn ? 'REMITTED' : 'PENDING') : undefined,
escheatedOn: (a?.closedOnDate || '').toString().slice(0, 10) || undefined,
remittedOn: (a?.deafRemittedOn || '').toString().slice(0, 10) || undefined,
escheatedOn: toIsoDate(a?.closedOnDate) || undefined,
remittedOn: toIsoDate(a?.deafRemittedOn) || undefined,
deafReference: a?.deafReference || undefined,
refundedOn: (a?.deafRefundedOn || '').toString().slice(0, 10) || undefined,
refundedOn: toIsoDate(a?.deafRefundedOn) || undefined,
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 ───────────────────────────────────────────────────────────
/** 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 {
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);
}
+3 -2
View File
@@ -19,6 +19,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/** Custom Services */
import { ClientsService } from '../clients/clients.service';
import { toIsoDate } from 'app/core/utils/api-date';
/** Report data + computation */
import {
@@ -176,7 +177,7 @@ export class KycExpiryComponent implements OnInit {
accountNo: r?.clientAccountNo || '',
branchCode: r?.clientOfficeName || '',
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
// needs — the destination itself is resolved server-side when a notice is sent.
mobile: r?.clientMobileMasked || (r?.hasMobile ? 'On file' : ''),
@@ -187,7 +188,7 @@ export class KycExpiryComponent implements OnInit {
private mapContact(log: any): KycContactRow {
const status = log?.deliveryStatus || 'NOT_APPLICABLE';
return {
date: (log?.contactedOn || log?.createdOn || '').toString().slice(0, 10),
date: toIsoDate(log?.contactedOn || log?.createdOn),
channel: log?.channel || '',
channelLabel: channelLabel(log?.channel || ''),
notes: log?.notes || '',
+5 -2
View File
@@ -17,8 +17,11 @@
window["env"]["apiVersion"] = '';
window["env"]["apiActuator"] = '';
window["env"]["fineractPlatformTenantId"] = 'abc_bank';
window["env"]["fineractPlatformTenantIds"] = 'default,abc_bank,socity_bank';
// Tenants the login screen offers, and which one it starts on. Every identifier here has to exist
// 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']['tenantLogoUrlDark'] = '';
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260909',
'hash': '03bc2ff1'
'version': '260910',
'hash': 'f0c06988'
},
'allow_switching_backend_instance': true
};