/** * 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'; }