From fd5fe6e3b28956f249a48490c721ef9d8dbd4aeb Mon Sep 17 00:00:00 2001 From: keshav Date: Thu, 10 Sep 2026 18:29:33 +0530 Subject: [PATCH] feat: update kyc tab, branch day register, and dormancy reports --- .../branch-day/branch-day.component.html | 40 +++- .../branch-day/branch-day.component.scss | 14 ++ .../branch-day/branch-day.component.ts | 10 + .../client-general-step.component.ts | 47 +++- .../kyc-tab/kyc-tab.component.html | 206 ++++++++++++------ .../kyc-tab/kyc-tab.component.scss | 105 +++++++++ .../clients-view/kyc-tab/kyc-tab.component.ts | 113 +++++++++- src/app/clients/clients.service.ts | 11 + .../edit-client/edit-client.component.ts | 29 ++- src/app/clients/kyc-exception.util.spec.ts | 17 ++ src/app/core/utils/api-date.ts | 37 ++++ .../dormancy-report.component.ts | 50 ++++- .../dormancy-report.compute.ts | 4 + src/app/kyc-expiry/kyc-expiry.component.ts | 5 +- src/assets/env.js | 7 +- src/environments/.env.ts | 4 +- 16 files changed, 595 insertions(+), 104 deletions(-) create mode 100644 src/app/core/utils/api-date.ts diff --git a/src/app/cashier/branch-day/branch-day.component.html b/src/app/cashier/branch-day/branch-day.component.html index a2fd93d0a..e5db06861 100644 --- a/src/app/cashier/branch-day/branch-day.component.html +++ b/src/app/cashier/branch-day/branch-day.component.html @@ -283,7 +283,7 @@ - @if (isLateClose) { + @if (needsForceReason) {

This day is being closed after its own date. It will be recorded as a force close, with the reason below kept permanently against it. @@ -592,10 +592,25 @@

@if (hasUnreconciledOrUnsettledCashiers) { -
- block + +
+ {{ blocksSameDayClose ? 'block' : 'warning' }}
- Cannot close the day — cashiers still holding cash have not been reconciled or settled. + @if (blocksSameDayClose) { + Cannot close today — cashiers still holding cash have not been reconciled or settled. + } @else { + This day's cash was never reconciled or settled. + }
    @for (detail of unreconciledOrUnsettledCashierDetails; track detail.name) {
  • @@ -610,17 +625,24 @@
  • }
- Each cashier must reconcile their cash and settle it back to the vault before the branch day can be - closed. + @if (blocksSameDayClose) { + Each cashier must count their drawer, reconcile it, and settle the cash back to the vault. Then this + day can be closed. + } @else { + The cash cannot be counted for a date that has passed. Record why above — their names are kept + against the record. + }
}
diff --git a/src/app/cashier/branch-day/branch-day.component.scss b/src/app/cashier/branch-day/branch-day.component.scss index 1db8d68d2..fa49ed69d 100644 --- a/src/app/cashier/branch-day/branch-day.component.scss +++ b/src/app/cashier/branch-day/branch-day.component.scss @@ -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; + } +} diff --git a/src/app/cashier/branch-day/branch-day.component.ts b/src/app/cashier/branch-day/branch-day.component.ts index 7235a4ea4..579a3364a 100644 --- a/src/app/cashier/branch-day/branch-day.component.ts +++ b/src/app/cashier/branch-day/branch-day.component.ts @@ -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; diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts index 7f1cab462..5fafd6717 100644 --- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts +++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts @@ -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', diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html index c91dfc132..57311257a 100644 --- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html +++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.html @@ -280,6 +280,82 @@
+ + @if (showAadhaarReVerify && !aadhaarOtpSent) { +
+ + Enter 12-digit Aadhaar + + + + +
+ } + @if (aadhaarOtpSent) { +
+ sms OTP sent + + Enter OTP + + + + + +
+ } + - @if (showAadhaarReVerify && !aadhaarOtpSent) { -
- - Enter 12-digit Aadhaar - - - -
- } - @if (aadhaarOtpSent) { -
- sms OTP sent - - Enter OTP - - - - -
- } @if (verificationError.aadhaar) {
error_outline {{ verificationError.aadhaar }} @@ -1128,6 +1137,69 @@ + + @if (kycData?.kycDocumentName || kycDocumentNeedsUpdate) { +
+
+

Officially Valid Document

+ @if (kycDocumentNeedsUpdate) { + Re-collect from the customer + } +
+ +
+ + Document + + Aadhaar Card + Passport + Driving Licence + Voter ID + NREGA Job Card + Letter from NPR + + + + + Document Number + + + + + Issued On + + + + + + + Valid Till + + + + Blank if it does not expire + +
+ + + + photo_library + Replace the scanned front / back of this document + chevron_right + +
+ } +
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'; diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts index f6f8db064..7220fbe47 100644 --- a/src/app/clients/clients.service.ts +++ b/src/app/clients/clients.service.ts @@ -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}`); } diff --git a/src/app/clients/edit-client/edit-client.component.ts b/src/app/clients/edit-client/edit-client.component.ts index c55beef28..0f300376a 100644 --- a/src/app/clients/edit-client/edit-client.component.ts +++ b/src/app/clients/edit-client/edit-client.component.ts @@ -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; } diff --git a/src/app/clients/kyc-exception.util.spec.ts b/src/app/clients/kyc-exception.util.spec.ts index 3f77c8e59..c65736fe7 100644 --- a/src/app/clients/kyc-exception.util.spec.ts +++ b/src/app/clients/kyc-exception.util.spec.ts @@ -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', () => { diff --git a/src/app/core/utils/api-date.ts b/src/app/core/utils/api-date.ts new file mode 100644 index 000000000..25c64dc54 --- /dev/null +++ b/src/app/core/utils/api-date.ts @@ -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); +} diff --git a/src/app/dormancy-report/dormancy-report.component.ts b/src/app/dormancy-report/dormancy-report.component.ts index 7e6404121..39c794881 100644 --- a/src/app/dormancy-report/dormancy-report.component.ts +++ b/src/app/dormancy-report/dormancy-report.component.ts @@ -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. */ diff --git a/src/app/dormancy-report/dormancy-report.compute.ts b/src/app/dormancy-report/dormancy-report.compute.ts index 8b6f09100..bd491edb2 100644 --- a/src/app/dormancy-report/dormancy-report.compute.ts +++ b/src/app/dormancy-report/dormancy-report.compute.ts @@ -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); } diff --git a/src/app/kyc-expiry/kyc-expiry.component.ts b/src/app/kyc-expiry/kyc-expiry.component.ts index bc95be300..70054c079 100644 --- a/src/app/kyc-expiry/kyc-expiry.component.ts +++ b/src/app/kyc-expiry/kyc-expiry.component.ts @@ -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 || '', diff --git a/src/assets/env.js b/src/assets/env.js index db4201002..3d4e606c0 100644 --- a/src/assets/env.js +++ b/src/assets/env.js @@ -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'] = ''; diff --git a/src/environments/.env.ts b/src/environments/.env.ts index db38cab6f..84cda5934 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '260909', - 'hash': '03bc2ff1' + 'version': '260910', + 'hash': 'f0c06988' }, 'allow_switching_backend_instance': true };