153 lines
5.9 KiB
TypeScript
153 lines
5.9 KiB
TypeScript
/**
|
|
* 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/.
|
|
*/
|
|
|
|
/**
|
|
* Decides when the officially valid document (OVD) captured at KYC needs updating.
|
|
*
|
|
* The KYC step records a document type, its number, an issue date and — for documents that carry
|
|
* one — an expiry date. Nothing has ever read those dates back, so a client onboarded on a
|
|
* ten-year-old driving licence looked exactly like one onboarded yesterday. This module turns the
|
|
* two dates into a single answer to "does this document need updating?", so the KYC tab can say so
|
|
* rather than leaving staff to work it out from a date on a form.
|
|
*
|
|
* Two separate clocks run, and they warn differently because they mean different things:
|
|
*
|
|
* - **Age.** Five years after issue the document is stale and must be re-collected. It is still a
|
|
* valid document, so there is nothing to pre-empt — the alert appears once the date passes.
|
|
* - **Expiry.** A printed expiry date is a hard stop: the day after, the document is not a valid
|
|
* OVD at all and the account is out of compliance. That one is worth heading off, so it warns
|
|
* seven days ahead while the customer can still be asked to bring the renewed document in.
|
|
*/
|
|
|
|
/** A document is treated as due for refresh five years after it was issued. */
|
|
export const KYC_DOCUMENT_RENEWAL_YEARS = 5;
|
|
|
|
/** How many days before a printed expiry date the "expires soon" warning starts. */
|
|
export const KYC_DOCUMENT_EXPIRY_WARNING_DAYS = 7;
|
|
|
|
export type KycDocumentRenewalState =
|
|
/** No dates captured — nothing can be said about this document. */
|
|
| 'UNKNOWN'
|
|
/** Within its life, and not close to either threshold. */
|
|
| 'OK'
|
|
/** Printed expiry date falls within the next seven days. */
|
|
| 'EXPIRING_SOON'
|
|
/** Past its printed expiry date: no longer a valid OVD. */
|
|
| 'EXPIRED'
|
|
/** Past the five-year mark: still a valid document, but must be re-collected. */
|
|
| 'DUE';
|
|
|
|
/** Which of the two clocks decided the outcome. */
|
|
export type KycDocumentRenewalReason = 'EXPIRY' | 'AGE' | null;
|
|
|
|
export interface KycDocumentRenewalInput {
|
|
/** Issue date printed on the document. */
|
|
issueDate: Date | null;
|
|
/** Expiry date printed on the document — only some carry one (passport, DL, visa). */
|
|
expiryDate: Date | null;
|
|
/** Today, injected so the calculation is testable and matches the tenant's date. */
|
|
today: Date;
|
|
}
|
|
|
|
export interface KycDocumentRenewalStatus {
|
|
state: KycDocumentRenewalState;
|
|
/** The date the document falls due — the earlier of its expiry and its fifth anniversary. */
|
|
dueOn: Date | null;
|
|
/** Days until `dueOn`; negative once it has passed. Null when nothing can be judged. */
|
|
daysRemaining: number | null;
|
|
reason: KycDocumentRenewalReason;
|
|
}
|
|
|
|
/** Midnight-aligned, so a document due today is not counted as a fraction of a day overdue. */
|
|
function startOfDay(date: Date): Date {
|
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
}
|
|
|
|
/**
|
|
* Adds whole years, clamping to the end of the month.
|
|
*
|
|
* A 29 February issue date has no fifth anniversary. Letting the Date constructor roll it into
|
|
* 1 March would push the document into the wrong month, so it is pinned to 28 February instead.
|
|
*/
|
|
function addYears(date: Date, years: number): Date {
|
|
const target = new Date(date.getFullYear() + years, date.getMonth(), date.getDate());
|
|
if (target.getMonth() !== date.getMonth()) {
|
|
return new Date(target.getFullYear(), target.getMonth(), 0);
|
|
}
|
|
return target;
|
|
}
|
|
|
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
|
|
function daysBetween(from: Date, to: Date): number {
|
|
return Math.round((to.getTime() - from.getTime()) / MS_PER_DAY);
|
|
}
|
|
|
|
/**
|
|
* Works out whether the captured document needs updating.
|
|
*
|
|
* The two dates are not alternatives — whichever falls first governs. A passport issued last year
|
|
* but expiring in six months is due in six months, not in four years: treating the five-year rule
|
|
* as the only clock would leave an expired passport on file looking current. A document with no
|
|
* printed expiry is judged purely on age.
|
|
*/
|
|
export function deriveKycDocumentRenewal(input: KycDocumentRenewalInput): KycDocumentRenewalStatus {
|
|
const today = startOfDay(input.today);
|
|
const issue = input.issueDate ? startOfDay(input.issueDate) : null;
|
|
const expiry = input.expiryDate ? startOfDay(input.expiryDate) : null;
|
|
|
|
if (!issue && !expiry) {
|
|
return { state: 'UNKNOWN', dueOn: null, daysRemaining: null, reason: null };
|
|
}
|
|
|
|
const ageDue = issue ? addYears(issue, KYC_DOCUMENT_RENEWAL_YEARS) : null;
|
|
|
|
// Whichever comes first governs — see the doc comment above.
|
|
let dueOn: Date;
|
|
let reason: KycDocumentRenewalReason;
|
|
if (expiry && ageDue) {
|
|
if (expiry.getTime() <= ageDue.getTime()) {
|
|
dueOn = expiry;
|
|
reason = 'EXPIRY';
|
|
} else {
|
|
dueOn = ageDue;
|
|
reason = 'AGE';
|
|
}
|
|
} else if (expiry) {
|
|
dueOn = expiry;
|
|
reason = 'EXPIRY';
|
|
} else {
|
|
dueOn = ageDue as Date;
|
|
reason = 'AGE';
|
|
}
|
|
|
|
const daysRemaining = daysBetween(today, dueOn);
|
|
|
|
let state: KycDocumentRenewalState;
|
|
if (reason === 'EXPIRY') {
|
|
if (daysRemaining < 0) {
|
|
state = 'EXPIRED';
|
|
} else if (daysRemaining <= KYC_DOCUMENT_EXPIRY_WARNING_DAYS) {
|
|
state = 'EXPIRING_SOON';
|
|
} else {
|
|
state = 'OK';
|
|
}
|
|
} else {
|
|
// Age alone: a document one day short of five years is entirely fine, so there is nothing to
|
|
// warn about in advance. It becomes due the moment the anniversary passes.
|
|
state = daysRemaining < 0 ? 'DUE' : 'OK';
|
|
}
|
|
|
|
return { state, dueOn, daysRemaining, reason };
|
|
}
|
|
|
|
/** Whether this state warrants surfacing an alert to staff at all. */
|
|
export function kycDocumentNeedsAttention(state: KycDocumentRenewalState): boolean {
|
|
return state === 'EXPIRING_SOON' || state === 'EXPIRED' || state === 'DUE';
|
|
}
|