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