297 lines
8.8 KiB
TypeScript
297 lines
8.8 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 { GL_PERIOD } from '../gl-reports/gl-reports-data';
|
|
import {
|
|
FORM_THRESHOLD,
|
|
FormDeclarant,
|
|
FormDeclarantTxn,
|
|
FormType,
|
|
formDeclarants,
|
|
formDeclarantTxns
|
|
} from './form-declaration-data';
|
|
|
|
export interface DateRange {
|
|
from: string;
|
|
to: string;
|
|
}
|
|
|
|
export interface FormReportFilter {
|
|
range: DateRange;
|
|
/** '' means every branch. */
|
|
branchCode: string;
|
|
/** '' means both declaration types. */
|
|
formType: FormType | '';
|
|
/** '' means every transaction category (Cash, Clearing, Loan, …). */
|
|
group: string;
|
|
/** Free text over name, account number and acknowledgement number. */
|
|
search: string;
|
|
/** Show only rows a compliance officer would need to look at. */
|
|
flaggedOnly: boolean;
|
|
}
|
|
|
|
/** One transaction, joined to the declarant who filed for it. */
|
|
export interface FormTxnRow extends FormDeclarantTxn {
|
|
declarantName: string;
|
|
formType: FormType;
|
|
fatherName: string;
|
|
declaredOn: string;
|
|
reasonForNoPan: string;
|
|
estimatedIncome: number;
|
|
/** Counter or ATM cash, as opposed to a book or electronic entry. */
|
|
isCash: boolean;
|
|
/**
|
|
* The transaction is one of the categories in Rule 114B, so PAN — and in its
|
|
* absence the Form 97/98 declaration — was mandatory for it.
|
|
*/
|
|
declarationRequired: boolean;
|
|
/** Cash at or above the CTR limit — also lands in the monthly CTR. */
|
|
ctrReportable: boolean;
|
|
}
|
|
|
|
/** A declarant with their totals for the period. */
|
|
export interface FormPersonRow {
|
|
declarant: FormDeclarant;
|
|
txns: FormTxnRow[];
|
|
txnCount: number;
|
|
cashCount: number;
|
|
totalCredit: number;
|
|
totalDebit: number;
|
|
cashTotal: number;
|
|
/** Largest single transaction, used to sort the riskiest to the top. */
|
|
maxAmount: number;
|
|
/** How many of the period's transactions needed the declaration. */
|
|
declaredCount: number;
|
|
ctrCount: number;
|
|
/** Distinct categories the customer transacted in during the period. */
|
|
groups: string[];
|
|
/**
|
|
* Aggregate cash for the period against the income the customer declared.
|
|
* A ratio well above 1 is the classic "turnover inconsistent with declared
|
|
* income" flag, which is why the declaration is collected in the first place.
|
|
*/
|
|
incomeRatio: number;
|
|
}
|
|
|
|
export interface FormReportSummary {
|
|
persons: number;
|
|
form60Persons: number;
|
|
form61Persons: number;
|
|
txnCount: number;
|
|
cashTotal: number;
|
|
cashCount: number;
|
|
totalCredit: number;
|
|
totalDebit: number;
|
|
/** Transactions falling in a Rule 114B category. */
|
|
declarationRequired: number;
|
|
ctrReportable: number;
|
|
/** Transaction count by category, largest first — drives the mix panel. */
|
|
byGroup: { group: string; count: number; amount: number }[];
|
|
}
|
|
|
|
function clamp(range: DateRange): DateRange {
|
|
const from = range.from < GL_PERIOD.fyStart ? GL_PERIOD.fyStart : range.from;
|
|
const to = range.to > GL_PERIOD.fyEnd ? GL_PERIOD.fyEnd : range.to;
|
|
// A backwards range would silently return nothing; collapse it to a single day.
|
|
return from > to ? { from: to, to: to } : { from, to };
|
|
}
|
|
|
|
/** Monday-to-Saturday banking week containing the given date. */
|
|
export function weekRange(anchor: string): DateRange {
|
|
const d = new Date(`${anchor}T00:00:00Z`);
|
|
const dow = d.getUTCDay(); // 0 = Sunday
|
|
const monday = new Date(d.getTime());
|
|
monday.setUTCDate(d.getUTCDate() - (dow === 0 ? 6 : dow - 1));
|
|
const saturday = new Date(monday.getTime());
|
|
saturday.setUTCDate(monday.getUTCDate() + 5);
|
|
const iso = (x: Date) => x.toISOString().slice(0, 10);
|
|
return { from: iso(monday), to: iso(saturday) };
|
|
}
|
|
|
|
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}-${`${last}`.padStart(2, '0')}` };
|
|
}
|
|
|
|
/**
|
|
* Transactions matching the filter, each joined to its declarant.
|
|
*
|
|
* Memoised on the filter signature: the getters that feed the template are read
|
|
* many times per change-detection pass, and re-scanning several hundred rows on
|
|
* every read is what made the GL reports stall.
|
|
*/
|
|
let rowCacheKey = '';
|
|
let rowCache: FormTxnRow[] = [];
|
|
|
|
export function formTxnRows(filter: FormReportFilter): FormTxnRow[] {
|
|
const range = clamp(filter.range);
|
|
const key = [
|
|
range.from,
|
|
range.to,
|
|
filter.branchCode,
|
|
filter.formType,
|
|
filter.group,
|
|
filter.search.trim().toLowerCase(),
|
|
filter.flaggedOnly ? '1' : '0'
|
|
].join('|');
|
|
|
|
if (key === rowCacheKey) {
|
|
return rowCache;
|
|
}
|
|
|
|
const byAck = new Map(
|
|
formDeclarants().map((d) => [
|
|
d.ackNo,
|
|
d
|
|
])
|
|
);
|
|
const needle = filter.search.trim().toLowerCase();
|
|
|
|
const rows: FormTxnRow[] = [];
|
|
for (const t of formDeclarantTxns()) {
|
|
if (t.date < range.from || t.date > range.to) {
|
|
continue;
|
|
}
|
|
if (filter.branchCode && t.branchCode !== filter.branchCode) {
|
|
continue;
|
|
}
|
|
if (filter.group && t.group !== filter.group) {
|
|
continue;
|
|
}
|
|
const d = byAck.get(t.ackNo);
|
|
if (!d) {
|
|
continue;
|
|
}
|
|
if (filter.formType && d.formType !== filter.formType) {
|
|
continue;
|
|
}
|
|
if (
|
|
needle &&
|
|
!d.name.toLowerCase().includes(needle) &&
|
|
!t.accountNo.toLowerCase().includes(needle) &&
|
|
!d.ackNo.toLowerCase().includes(needle)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
// ATM dispensing is cash in the customer's hands, so it counts towards the
|
|
// cash view even though it never crossed the counter.
|
|
const isCash = t.mode === 'CASH' || t.mode === 'ATM';
|
|
const ctrReportable = isCash && t.amount >= FORM_THRESHOLD.CTR;
|
|
|
|
if (filter.flaggedOnly && !ctrReportable) {
|
|
continue;
|
|
}
|
|
|
|
rows.push({
|
|
...t,
|
|
declarantName: d.name,
|
|
formType: d.formType,
|
|
fatherName: d.fatherName,
|
|
declaredOn: d.declaredOn,
|
|
reasonForNoPan: d.reasonForNoPan,
|
|
estimatedIncome: d.estimatedIncome,
|
|
isCash,
|
|
declarationRequired: t.rule114B,
|
|
ctrReportable
|
|
});
|
|
}
|
|
|
|
rowCacheKey = key;
|
|
rowCache = rows;
|
|
return rows;
|
|
}
|
|
|
|
/** The same rows grouped by declarant, heaviest customer first. */
|
|
export function formPersonRows(filter: FormReportFilter): FormPersonRow[] {
|
|
const byAck = new Map(
|
|
formDeclarants().map((d) => [
|
|
d.ackNo,
|
|
d
|
|
])
|
|
);
|
|
const grouped = new Map<string, FormTxnRow[]>();
|
|
|
|
for (const r of formTxnRows(filter)) {
|
|
const list = grouped.get(r.ackNo);
|
|
if (list) {
|
|
list.push(r);
|
|
} else {
|
|
grouped.set(r.ackNo, [r]);
|
|
}
|
|
}
|
|
|
|
const out: FormPersonRow[] = [];
|
|
for (const [
|
|
ackNo,
|
|
txns
|
|
] of grouped) {
|
|
const declarant = byAck.get(ackNo);
|
|
if (!declarant) {
|
|
continue;
|
|
}
|
|
const cash = txns.filter((t) => t.isCash);
|
|
const cashTotal = cash.reduce((s, t) => s + t.amount, 0);
|
|
out.push({
|
|
declarant,
|
|
txns,
|
|
txnCount: txns.length,
|
|
cashCount: cash.length,
|
|
totalCredit: txns.filter((t) => t.drCr === 'CR').reduce((s, t) => s + t.amount, 0),
|
|
totalDebit: txns.filter((t) => t.drCr === 'DR').reduce((s, t) => s + t.amount, 0),
|
|
cashTotal,
|
|
maxAmount: txns.reduce((m, t) => Math.max(m, t.amount), 0),
|
|
declaredCount: txns.filter((t) => t.declarationRequired).length,
|
|
ctrCount: txns.filter((t) => t.ctrReportable).length,
|
|
groups: [...new Set(txns.map((t) => t.group))].sort(),
|
|
incomeRatio: declarant.estimatedIncome > 0 ? cashTotal / declarant.estimatedIncome : 0
|
|
});
|
|
}
|
|
|
|
out.sort((a, b) => b.cashTotal - a.cashTotal);
|
|
return out;
|
|
}
|
|
|
|
export function formReportSummary(filter: FormReportFilter): FormReportSummary {
|
|
const rows = formTxnRows(filter);
|
|
const acks = new Set(rows.map((r) => r.ackNo));
|
|
const form61 = new Set(rows.filter((r) => r.formType === 'FORM_61').map((r) => r.ackNo));
|
|
const form60 = new Set(rows.filter((r) => r.formType === 'FORM_60').map((r) => r.ackNo));
|
|
|
|
const groupMap = new Map<string, { group: string; count: number; amount: number }>();
|
|
for (const r of rows) {
|
|
const g = groupMap.get(r.group);
|
|
if (g) {
|
|
g.count++;
|
|
g.amount += r.amount;
|
|
} else {
|
|
groupMap.set(r.group, { group: r.group, count: 1, amount: r.amount });
|
|
}
|
|
}
|
|
|
|
const cash = rows.filter((r) => r.isCash);
|
|
|
|
return {
|
|
persons: acks.size,
|
|
form60Persons: form60.size,
|
|
form61Persons: form61.size,
|
|
txnCount: rows.length,
|
|
cashTotal: cash.reduce((s, r) => s + r.amount, 0),
|
|
cashCount: cash.length,
|
|
totalCredit: rows.filter((r) => r.drCr === 'CR').reduce((s, r) => s + r.amount, 0),
|
|
totalDebit: rows.filter((r) => r.drCr === 'DR').reduce((s, r) => s + r.amount, 0),
|
|
declarationRequired: rows.filter((r) => r.declarationRequired).length,
|
|
ctrReportable: rows.filter((r) => r.ctrReportable).length,
|
|
byGroup: [...groupMap.values()].sort((a, b) => b.count - a.count)
|
|
};
|
|
}
|