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 @@
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
};