569 lines
20 KiB
TypeScript
569 lines
20 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/.
|
|
*/
|
|
|
|
import {
|
|
AmlRuleMeta,
|
|
CTR_THRESHOLD,
|
|
MATERIALITY,
|
|
isNonMitigableRule,
|
|
REVIEW_STATUS_META,
|
|
RISK_LABEL,
|
|
RiskAlertRow,
|
|
RiskLevel,
|
|
RiskTxn,
|
|
ReviewStatus,
|
|
amlRuleMeta,
|
|
riskRank
|
|
} from './risk-review-data';
|
|
|
|
/** Which way the review says the rating should move. */
|
|
export type RatingDirection = 'up' | 'down' | 'same';
|
|
|
|
const RANK_TO_LEVEL: Record<number, RiskLevel> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH' };
|
|
|
|
export interface DateRange {
|
|
from: string;
|
|
to: string;
|
|
}
|
|
|
|
export interface RiskReviewFilter {
|
|
range: DateRange;
|
|
/** '' means every branch. */
|
|
branchCode: string;
|
|
/** '' means every rating. */
|
|
currentRisk: RiskLevel | '';
|
|
/** '' means every rule. */
|
|
ruleCode: string;
|
|
/** '' means every review status. */
|
|
status: ReviewStatus | '';
|
|
/** '' means every customer, whichever way their rating should move. */
|
|
direction: RatingDirection | '';
|
|
/** Free text over customer name, customer ID, account number and alert ID. */
|
|
search: string;
|
|
/** Only cases a reviewer still has to act on. */
|
|
openOnly: boolean;
|
|
}
|
|
|
|
/** An alert joined to its rule catalogue entry, ready to render. */
|
|
export interface RiskAlertView extends RiskAlertRow {
|
|
rule: AmlRuleMeta;
|
|
}
|
|
|
|
/**
|
|
* One transaction carrying the alert and rule it belongs to.
|
|
*
|
|
* This is the row that answers "which AML rule did this transaction trip" —
|
|
* the alert is flattened onto each of its entries so the same transaction can
|
|
* be read on its own, without expanding a customer first.
|
|
*/
|
|
export interface RiskTxnView extends RiskTxn {
|
|
alertId: string;
|
|
alertDate: string;
|
|
clientId: number;
|
|
customerName: string;
|
|
accountNo: string;
|
|
branchCode: string;
|
|
currentRisk: RiskLevel | null;
|
|
status: ReviewStatus;
|
|
remarks: string;
|
|
rule: AmlRuleMeta;
|
|
/** Position of this entry within its alert, e.g. "2 of 7" for a series. */
|
|
legIndex: number;
|
|
legCount: number;
|
|
}
|
|
|
|
/** All of one customer's alerts in the period, rolled up. */
|
|
export interface RiskCustomerRow {
|
|
clientId: number;
|
|
customerName: string;
|
|
accountNo: string;
|
|
branchCode: string;
|
|
currentRisk: RiskLevel | null;
|
|
alerts: RiskAlertView[];
|
|
alertCount: number;
|
|
/** Sum of the amounts behind the alerts — the exposure under review. */
|
|
totalAmount: number;
|
|
largestAmount: number;
|
|
/** Highest risk any of the triggered rules points at, before materiality. */
|
|
proposedRisk: RiskLevel;
|
|
/** What the review concludes the rating should be, after materiality. */
|
|
recommendedRisk: RiskLevel;
|
|
/** Whether that is above, below or the same as the rating on record. */
|
|
direction: RatingDirection;
|
|
/** One line explaining the recommendation, shown under the verdict. */
|
|
directionReason: string;
|
|
/** Distinct rule labels, for the "AML Rule Triggered" cell. */
|
|
ruleLabels: string[];
|
|
/** Earliest and latest alert in the period. */
|
|
firstAlertDate: string;
|
|
lastAlertDate: string;
|
|
openCount: number;
|
|
/** True when any alert on the customer has gone to the Principal Officer. */
|
|
escalated: boolean;
|
|
/** True when the rules point above the rating currently on the KYC record. */
|
|
needsUpgrade: boolean;
|
|
/** True when the rating on record is higher than the activity warrants. */
|
|
needsDowngrade: boolean;
|
|
/** The review period the recommendation was drawn from, where the nightly job has computed one. */
|
|
behaviourWindow?: BehaviourWindowByClient[number] | null;
|
|
}
|
|
|
|
export interface RiskReviewSummary {
|
|
customers: number;
|
|
alerts: number;
|
|
/** Individual entries behind those alerts. */
|
|
txns: number;
|
|
/** Cash entries at or above the CTR limit on their own. */
|
|
ctrReportable: number;
|
|
openAlerts: number;
|
|
escalatedCustomers: number;
|
|
upgradeCandidates: number;
|
|
downgradeCandidates: number;
|
|
totalAmount: number;
|
|
byRisk: Record<'LOW' | 'MEDIUM' | 'HIGH' | 'UNRATED', number>;
|
|
/** Rule mix across the filtered set, most-triggered first. */
|
|
byRule: { code: string; label: string; count: number }[];
|
|
byStatus: { status: ReviewStatus; label: string; count: number }[];
|
|
}
|
|
|
|
function inRange(date: string, range: DateRange): boolean {
|
|
return date >= range.from && date <= range.to;
|
|
}
|
|
|
|
function inr(n: number): string {
|
|
return `₹${Math.round(n).toLocaleString('en-IN')}`;
|
|
}
|
|
|
|
/**
|
|
* Works out what the rating ought to be once the size of the activity is taken
|
|
* into account, and why.
|
|
*
|
|
* The rules give an upper bound — what the behaviour could mean at worst. The
|
|
* amounts then pull that bound down: a customer sitting at High who has only put
|
|
* a handful of small transfers through has not behaved like a High-risk customer
|
|
* this period, and the review should say so. Two things stop the pull-down: a
|
|
* case already referred for an STR, and rules whose seriousness has nothing to
|
|
* do with value — a sanctions match or a structuring pattern stays grave at any
|
|
* amount.
|
|
*/
|
|
function recommendRating(
|
|
impliedRank: number,
|
|
currentRisk: RiskLevel | null,
|
|
largestAmount: number,
|
|
totalAmount: number,
|
|
escalated: boolean,
|
|
ruleCodes: string[]
|
|
): { risk: RiskLevel; reason: string } {
|
|
const implied = RANK_TO_LEVEL[impliedRank] ?? 'LOW';
|
|
const grave = ruleCodes.find((c) => isNonMitigableRule(c));
|
|
const material = largestAmount >= CTR_THRESHOLD || totalAmount >= MATERIALITY.highTotal;
|
|
const small = largestAmount < MATERIALITY.smallTxn && totalAmount < MATERIALITY.smallTotal;
|
|
|
|
// What the rating should be. Amounts only ever pull the rules' bound down, and
|
|
// only where nothing weightier holds it up.
|
|
let risk: RiskLevel;
|
|
let basis: 'escalated' | 'grave' | 'material' | 'small' | 'moderate';
|
|
if (escalated) {
|
|
risk = 'HIGH';
|
|
basis = 'escalated';
|
|
} else if (grave) {
|
|
risk = implied;
|
|
basis = 'grave';
|
|
} else if (material) {
|
|
risk = implied;
|
|
basis = 'material';
|
|
} else {
|
|
risk = RANK_TO_LEVEL[Math.min(impliedRank, small ? 1 : 2)] ?? 'LOW';
|
|
basis = small ? 'small' : 'moderate';
|
|
}
|
|
|
|
// The wording is worked out from the conclusion, not the branch, so a customer
|
|
// being brought down is never told the activity supports where they are.
|
|
const sizes = `largest entry ${inr(largestAmount)}, ${inr(totalAmount)} in total`;
|
|
const rated = currentRisk ? `Rated ${RISK_LABEL[currentRisk]} on record` : 'No rating on record';
|
|
const delta = currentRisk ? riskRank(risk) - riskRank(currentRisk) : 0;
|
|
|
|
if (basis === 'escalated') {
|
|
return { risk, reason: 'Case referred to the Principal Officer — rating held at High pending the outcome.' };
|
|
}
|
|
|
|
if (delta < 0) {
|
|
if (basis === 'small') {
|
|
return { risk, reason: `Only small-value activity — ${sizes}. ${rated}; not supported by this period.` };
|
|
}
|
|
if (basis === 'moderate') {
|
|
return { risk, reason: `Moderate activity — ${sizes}. ${rated}; one step down is proportionate.` };
|
|
}
|
|
// Material or grave, yet still below where the customer sits: the size of
|
|
// the activity is not the reason, the rules that fired are.
|
|
return {
|
|
risk,
|
|
reason:
|
|
`Activity of ${inr(totalAmount)} is material, but the rules triggered point only at ${RISK_LABEL[risk]}. ` +
|
|
`${rated}.`
|
|
};
|
|
}
|
|
|
|
if (delta > 0) {
|
|
return { risk, reason: `Triggered rules point at ${RISK_LABEL[risk]}, above the rating on record.` };
|
|
}
|
|
|
|
if (basis === 'grave') {
|
|
return {
|
|
risk,
|
|
reason: `${amlRuleMeta(grave as string).label} carries its own weight — the rating does not come down on amount alone.`
|
|
};
|
|
}
|
|
|
|
// The rating on file is where the activity puts it. Saying so is the verdict — leaving it blank
|
|
// read as "not assessed yet" on screen, which is the opposite of what the review concluded.
|
|
return {
|
|
risk,
|
|
reason: currentRisk
|
|
? `${sizes}. Consistent with ${RISK_LABEL[risk]} — the rating on file stands.`
|
|
: `${sizes}. Activity points at ${RISK_LABEL[risk]}; no rating on record to compare against.`
|
|
};
|
|
}
|
|
|
|
/** Applies every filter and joins each alert to its rule. */
|
|
export function riskAlertViews(alerts: RiskAlertRow[], filter: RiskReviewFilter): RiskAlertView[] {
|
|
const needle = filter.search.trim().toLowerCase();
|
|
|
|
return alerts
|
|
.filter((a) => {
|
|
if (!inRange(a.alertDate, filter.range)) {
|
|
return false;
|
|
}
|
|
// A live alert carries no branch; hiding it under a branch filter would
|
|
// silently drop real compliance data, so it stays visible.
|
|
if (filter.branchCode && a.branchCode && a.branchCode !== filter.branchCode) {
|
|
return false;
|
|
}
|
|
if (filter.currentRisk && a.currentRisk !== filter.currentRisk) {
|
|
return false;
|
|
}
|
|
if (filter.ruleCode && a.ruleCode !== filter.ruleCode) {
|
|
return false;
|
|
}
|
|
if (filter.status && a.status !== filter.status) {
|
|
return false;
|
|
}
|
|
if (filter.openOnly && !REVIEW_STATUS_META[a.status].open) {
|
|
return false;
|
|
}
|
|
if (needle) {
|
|
const hay = `${a.customerName} ${a.clientId} ${a.accountNo} ${a.alertId} ${a.ruleCode}`.toLowerCase();
|
|
if (!hay.includes(needle)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
})
|
|
.map((a) => ({ ...a, rule: amlRuleMeta(a.ruleCode, a.ruleDescription) }))
|
|
.sort((a, b) => b.alertDate.localeCompare(a.alertDate) || a.customerName.localeCompare(b.customerName));
|
|
}
|
|
|
|
/**
|
|
* Rolls the alerts up per customer. Sorted so the cases needing attention —
|
|
* escalated, then rated below what the rules point at, then most alerts —
|
|
* come to the top of the worklist.
|
|
*/
|
|
/**
|
|
* The behaviour window the nightly job computed for one customer, where there is one.
|
|
*
|
|
* Keyed on client id. A customer with no window - one the job has not seen yet, or a tenant where the job has never
|
|
* run - falls back to the per-alert reckoning below, so the report renders either way.
|
|
*/
|
|
export interface BehaviourWindowByClient {
|
|
[clientId: number]: {
|
|
clientId: number;
|
|
clientName: string | null;
|
|
accountNo: string | null;
|
|
officeName: string | null;
|
|
riskCategoryAtReview: number | null;
|
|
txnCount: number | null;
|
|
distinctMonths: number | null;
|
|
windowFrom: any;
|
|
windowTo: any;
|
|
creditTotal: number | null;
|
|
debitTotal: number | null;
|
|
largestTxn: number | null;
|
|
cashTotal: number | null;
|
|
alertCount: number | null;
|
|
strCaseCount: number | null;
|
|
suggestedDirection: string | null;
|
|
suggestedCategory: number | null;
|
|
suggestionReason: string | null;
|
|
};
|
|
}
|
|
|
|
export function riskCustomerRows(views: RiskAlertView[], windows: BehaviourWindowByClient = {}): RiskCustomerRow[] {
|
|
const byClient = new Map<number, RiskAlertView[]>();
|
|
for (const v of views) {
|
|
const list = byClient.get(v.clientId);
|
|
if (list) {
|
|
list.push(v);
|
|
} else {
|
|
byClient.set(v.clientId, [v]);
|
|
}
|
|
}
|
|
|
|
const rows: RiskCustomerRow[] = [];
|
|
for (const [
|
|
clientId,
|
|
alerts
|
|
] of byClient) {
|
|
const dates = alerts.map((a) => a.alertDate).sort();
|
|
const proposedRank = Math.max(...alerts.map((a) => riskRank(a.rule.impliedRisk)));
|
|
const proposedRisk: RiskLevel = RANK_TO_LEVEL[proposedRank] ?? 'LOW';
|
|
const head = alerts[0];
|
|
|
|
const currentRisk = alerts.find((a) => a.currentRisk)?.currentRisk ?? null;
|
|
const totalAmount = alerts.reduce((s, a) => s + a.amount, 0);
|
|
const largestAmount = Math.max(...alerts.map((a) => a.amount));
|
|
const escalated = alerts.some((a) => a.status === 'ESCALATED_STR' || a.status === 'STR_FILED');
|
|
|
|
// The server's own reckoning, where the nightly job has produced one.
|
|
//
|
|
// It is the better answer, and not because it is newer: it is drawn from a review period rather than from the
|
|
// alerts on this screen. A customer appears here only once a rule has fired, so judging them on those alerts
|
|
// alone describes the exceptions and never the ordinary months around them - which is exactly what a rating is
|
|
// supposed to reflect. The job also tests the things this screen cannot see at all: how many separate months the
|
|
// activity falls in, how long the customer has held the rating, whether an STR inside the block window forbids
|
|
// lowering it.
|
|
//
|
|
// Without a window - a tenant where the job has not run, or a customer it has not reached - the per-alert
|
|
// reckoning below still stands, so the report never renders blank while the two are in step.
|
|
// A window is only good for as long as the rating it was computed against. The job takes a snapshot each night
|
|
// and records the rating the customer held at the time; once an officer moves that rating the snapshot describes
|
|
// a customer who no longer exists, and the screen would go on asking for a change that has already been applied
|
|
// and approved. Dropping it here falls back to the per-alert reckoning until tonight's job catches up.
|
|
const raw = windows[clientId];
|
|
const staleWindow =
|
|
raw?.riskCategoryAtReview != null && currentRisk != null && RANK_TO_LEVEL[raw.riskCategoryAtReview] !== currentRisk;
|
|
const window = staleWindow ? undefined : raw;
|
|
const recommendation = window?.suggestedDirection
|
|
? {
|
|
risk: RANK_TO_LEVEL[window.suggestedCategory ?? 0] ?? currentRisk ?? 'LOW',
|
|
reason: window.suggestionReason ?? ''
|
|
}
|
|
: recommendRating(
|
|
proposedRank,
|
|
currentRisk,
|
|
largestAmount,
|
|
totalAmount,
|
|
escalated,
|
|
alerts.map((a) => a.ruleCode)
|
|
);
|
|
|
|
// Where the customer carries no rating there is nothing to move, so an
|
|
// unrated customer is never counted as an upgrade or a downgrade.
|
|
const delta = currentRisk ? riskRank(recommendation.risk) - riskRank(currentRisk) : 0;
|
|
const direction: RatingDirection = window?.suggestedDirection
|
|
? window.suggestedDirection === 'UP'
|
|
? 'up'
|
|
: window.suggestedDirection === 'DOWN'
|
|
? 'down'
|
|
: 'same'
|
|
: delta > 0
|
|
? 'up'
|
|
: delta < 0
|
|
? 'down'
|
|
: 'same';
|
|
|
|
rows.push({
|
|
clientId,
|
|
customerName: head.customerName,
|
|
// Take the first non-blank — live alerts leave these empty.
|
|
accountNo: alerts.find((a) => a.accountNo)?.accountNo || '',
|
|
branchCode: alerts.find((a) => a.branchCode)?.branchCode || '',
|
|
currentRisk,
|
|
alerts,
|
|
alertCount: alerts.length,
|
|
totalAmount,
|
|
largestAmount,
|
|
proposedRisk,
|
|
recommendedRisk: recommendation.risk,
|
|
direction,
|
|
directionReason: recommendation.reason,
|
|
ruleLabels: [...new Set(alerts.map((a) => a.rule.label))],
|
|
firstAlertDate: dates[0],
|
|
lastAlertDate: dates[dates.length - 1],
|
|
openCount: alerts.filter((a) => REVIEW_STATUS_META[a.status].open).length,
|
|
escalated,
|
|
needsUpgrade: direction === 'up',
|
|
needsDowngrade: direction === 'down',
|
|
// Carried through so the screen can show the period a recommendation was drawn from. A rating an officer is
|
|
// asked to approve has to show its working, and the alerts on this row are not it.
|
|
behaviourWindow: window ?? null
|
|
});
|
|
}
|
|
|
|
// Escalations first, then ratings that need moving up, then down, then volume.
|
|
return rows.sort(
|
|
(a, b) =>
|
|
Number(b.escalated) - Number(a.escalated) ||
|
|
Number(b.needsUpgrade) - Number(a.needsUpgrade) ||
|
|
Number(b.needsDowngrade) - Number(a.needsDowngrade) ||
|
|
b.alertCount - a.alertCount ||
|
|
b.totalAmount - a.totalAmount
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Restricts the alerts to customers whose rating should move a given way.
|
|
*
|
|
* Direction is a property of the customer, not of a single alert, so it can only
|
|
* be applied once the rollup has run — the alert and transaction views are then
|
|
* narrowed to the same customers so all three views agree.
|
|
*/
|
|
export function applyDirectionFilter(
|
|
views: RiskAlertView[],
|
|
customers: RiskCustomerRow[],
|
|
direction: RatingDirection | ''
|
|
): { views: RiskAlertView[]; customers: RiskCustomerRow[] } {
|
|
if (!direction) {
|
|
return { views, customers };
|
|
}
|
|
const keep = customers.filter((c) => c.direction === direction);
|
|
const ids = new Set(keep.map((c) => c.clientId));
|
|
return { views: views.filter((v) => ids.has(v.clientId)), customers: keep };
|
|
}
|
|
|
|
/**
|
|
* Flattens every alert onto its transactions, newest entry first, so each row
|
|
* names the rule that transaction tripped.
|
|
*/
|
|
export function riskTxnRows(views: RiskAlertView[]): RiskTxnView[] {
|
|
const rows: RiskTxnView[] = [];
|
|
|
|
for (const v of views) {
|
|
v.txns.forEach((t, i) => {
|
|
rows.push({
|
|
...t,
|
|
alertId: v.alertId,
|
|
alertDate: v.alertDate,
|
|
clientId: v.clientId,
|
|
customerName: v.customerName,
|
|
accountNo: v.accountNo,
|
|
branchCode: v.branchCode,
|
|
currentRisk: v.currentRisk,
|
|
status: v.status,
|
|
remarks: v.remarks,
|
|
rule: v.rule,
|
|
legIndex: i + 1,
|
|
legCount: v.txns.length
|
|
});
|
|
});
|
|
}
|
|
|
|
return rows.sort((a, b) => b.date.localeCompare(a.date) || a.txnId.localeCompare(b.txnId));
|
|
}
|
|
|
|
export function riskReviewSummary(views: RiskAlertView[], customers: RiskCustomerRow[]): RiskReviewSummary {
|
|
const byRisk: RiskReviewSummary['byRisk'] = { LOW: 0, MEDIUM: 0, HIGH: 0, UNRATED: 0 };
|
|
for (const c of customers) {
|
|
byRisk[c.currentRisk ?? 'UNRATED']++;
|
|
}
|
|
|
|
const ruleCounts = new Map<string, { label: string; count: number }>();
|
|
for (const v of views) {
|
|
const entry = ruleCounts.get(v.ruleCode);
|
|
if (entry) {
|
|
entry.count++;
|
|
} else {
|
|
ruleCounts.set(v.ruleCode, { label: v.rule.label, count: 1 });
|
|
}
|
|
}
|
|
|
|
const statusCounts = new Map<ReviewStatus, number>();
|
|
for (const v of views) {
|
|
statusCounts.set(v.status, (statusCounts.get(v.status) || 0) + 1);
|
|
}
|
|
|
|
return {
|
|
customers: customers.length,
|
|
alerts: views.length,
|
|
txns: views.reduce((s, v) => s + v.txns.length, 0),
|
|
ctrReportable: views.reduce((s, v) => s + v.txns.filter((t) => t.ctrReportable).length, 0),
|
|
openAlerts: views.filter((v) => REVIEW_STATUS_META[v.status].open).length,
|
|
escalatedCustomers: customers.filter((c) => c.escalated).length,
|
|
upgradeCandidates: customers.filter((c) => c.needsUpgrade).length,
|
|
downgradeCandidates: customers.filter((c) => c.needsDowngrade).length,
|
|
totalAmount: views.reduce((s, v) => s + v.amount, 0),
|
|
byRisk,
|
|
byRule: [...ruleCounts.entries()]
|
|
.map(
|
|
([
|
|
code,
|
|
v
|
|
]) => ({ code, label: v.label, count: v.count })
|
|
)
|
|
.sort((a, b) => b.count - a.count),
|
|
byStatus: [...statusCounts.entries()]
|
|
.map(
|
|
([
|
|
status,
|
|
count
|
|
]) => ({ status, label: REVIEW_STATUS_META[status].label, count })
|
|
)
|
|
.sort((a, b) => b.count - a.count)
|
|
};
|
|
}
|
|
|
|
/** Everything the screen renders, computed in one pass. */
|
|
export interface RiskReviewModel {
|
|
alerts: RiskAlertView[];
|
|
customers: RiskCustomerRow[];
|
|
txns: RiskTxnView[];
|
|
summary: RiskReviewSummary;
|
|
}
|
|
|
|
/**
|
|
* Single entry point for the screen. Filtering, rollup, direction narrowing and
|
|
* the summary all run once per filter change rather than once per template
|
|
* binding, which matters because every view reads from the same result.
|
|
*/
|
|
export function buildRiskReview(
|
|
alerts: RiskAlertRow[],
|
|
filter: RiskReviewFilter,
|
|
windows: BehaviourWindowByClient = {}
|
|
): RiskReviewModel {
|
|
const base = riskAlertViews(alerts, filter);
|
|
const narrowed = applyDirectionFilter(base, riskCustomerRows(base, windows), filter.direction);
|
|
|
|
return {
|
|
alerts: narrowed.views,
|
|
customers: narrowed.customers,
|
|
txns: riskTxnRows(narrowed.views),
|
|
summary: riskReviewSummary(narrowed.views, narrowed.customers)
|
|
};
|
|
}
|
|
|
|
// ── Period helpers (shared with the other period-filtered reports) ───────────
|
|
|
|
/** Monday to Saturday containing the given date. */
|
|
export function weekRange(anyDateInWeek: string): DateRange {
|
|
const d = new Date(`${anyDateInWeek}T00:00:00Z`);
|
|
const dow = d.getUTCDay();
|
|
const backToMonday = dow === 0 ? 6 : dow - 1;
|
|
const monday = new Date(d.getTime() - backToMonday * 86400000);
|
|
const saturday = new Date(monday.getTime() + 5 * 86400000);
|
|
return { from: monday.toISOString().slice(0, 10), to: saturday.toISOString().slice(0, 10) };
|
|
}
|
|
|
|
/** First to last day of the given yyyy-MM. */
|
|
export function monthRange(month: string): DateRange {
|
|
const [
|
|
y,
|
|
m
|
|
] = month.split('-').map(Number);
|
|
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
|
return { from: `${month}-01`, to: `${month}-${String(last).padStart(2, '0')}` };
|
|
}
|