feat: update kyc tab, branch day register, and dormancy reports
This commit is contained in:
@@ -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 — 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>
|
||||||
|
@if (blocksSameDayClose) {
|
||||||
<span class="db-muted"
|
<span class="db-muted"
|
||||||
>Each cashier must reconcile their cash and settle it back to the vault before the branch day can be
|
>Each cashier must count their drawer, reconcile it, and settle the cash back to the vault. Then this
|
||||||
closed.</span
|
day can be closed.</span
|
||||||
>
|
>
|
||||||
|
} @else {
|
||||||
|
<span class="db-muted"
|
||||||
|
>The cash cannot be counted for a date that has passed. Record why above — 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;
|
||||||
|
|||||||
+39
-8
@@ -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';
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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[]) => {
|
||||||
|
// 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(
|
this.rows = sortByIdle(
|
||||||
(accounts || []).map((a) => this.mapAccount(a)).filter((r): r is DormancyRow => r !== null)
|
(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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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'] = '';
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user