/** * 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_BRANCHES, GL_PERIOD } from '../gl-reports/gl-reports-data'; /** * Transactions of customers who banked on a Form 97 / Form 98 declaration. * * Rule 114B of the Income-tax Rules lists transactions where quoting PAN is * mandatory. A customer who has no PAN gives a declaration instead: * * Form 97 — person with no PAN (income may be taxable). * Form 98 — person whose only income is agricultural and who has no PAN. * * The bank must be able to produce, for any period, every transaction put * through on the strength of such a declaration. That is what this report is. */ export type FormType = 'FORM_60' | 'FORM_61'; /** Screen and workbook labels — this bank numbers the declarations 97 and 98. */ export const FORM_LABEL: Record = { FORM_60: 'Form 97', FORM_61: 'Form 98' }; export interface FormDeclarant { /** Declaration acknowledgement number, as issued by the branch. */ ackNo: string; formType: FormType; name: string; fatherName: string; dateOfBirth: string; address: string; branchCode: string; accountNo: string; /** Why the customer has no PAN — printed on the declaration. */ reasonForNoPan: string; estimatedIncome: number; declaredOn: string; aadhaarLast4: string; } export type TxnMode = 'CASH' | 'TRANSFER' | 'CLEARING' | 'NEFT' | 'RTGS' | 'UPI' | 'ATM'; export interface FormDeclarantTxn { txnId: string; ackNo: string; date: string; branchCode: string; accountNo: string; mode: TxnMode; drCr: 'DR' | 'CR'; amount: number; /** What the transaction was — a Rule 114B category or ordinary activity. */ nature: string; /** Coarse bucket the nature belongs to, e.g. Cash, Clearing, Loan. */ group: string; /** True when the nature is one of the categories listed in Rule 114B. */ rule114B: boolean; particulars: string; voucherNo: string; } /** Deterministic linear congruential generator — same figures on every load. */ function lcg(seed: number): () => number { let s = seed >>> 0; return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; }; } function pick(rand: () => number, list: T[]): T { return list[Math.floor(rand() * list.length)]; } function isoDate(d: Date): string { const y = d.getUTCFullYear(); const m = `${d.getUTCMonth() + 1}`.padStart(2, '0'); const day = `${d.getUTCDate()}`.padStart(2, '0'); return `${y}-${m}-${day}`; } /** * Thresholds that make a row worth a second look. * * `RULE_114B_CASH` is the cash-deposit limit above which a declaration is * required at all; `CTR` is the aggregate cash figure that puts the customer * into the monthly Cash Transaction Report. */ export const FORM_THRESHOLD = { RULE_114B_CASH: 50_000, CTR: 1_000_000 }; const FIRST_NAMES = [ 'Ramesh', 'Sunita', 'Vitthal', 'Kamala', 'Ganpat', 'Shobha', 'Dnyaneshwar', 'Mangala', 'Bhaskar', 'Anita', 'Tukaram', 'Sarika', 'Nivrutti', 'Rekha', 'Popat', 'Chhaya', 'Ashok', 'Vaishali', 'Bapu', 'Nanda', 'Sitaram', 'Jayshree', 'Madhukar', 'Pushpa', 'Eknath', 'Sulochana', 'Baban', 'Indubai', 'Hanumant', 'Kalpana' ]; const SURNAMES = [ 'Jadhav', 'Pawar', 'Shinde', 'Gaikwad', 'More', 'Kadam', 'Bhosale', 'Patil', 'Sawant', 'Chavan', 'Salunkhe', 'Mane', 'Deshmukh', 'Thorat', 'Nikam', 'Wagh', 'Kale', 'Ghadge', 'Bhoir', 'Tandel' ]; const VILLAGES = [ 'Wadgaon', 'Kolhewadi', 'Shirgaon', 'Nandgaon', 'Pimpalgaon', 'Bhosari', 'Rajgurunagar', 'Talegaon', 'Karjat', 'Murbad', 'Shahapur', 'Vasind' ]; /** Reasons a Form 97 declarant gives — Form 98 has its own single reason. */ const NO_PAN_REASONS = [ 'Applied for PAN, allotment awaited', 'Total income below taxable limit', 'Not required to file return of income', 'First-time account holder, PAN not yet obtained' ]; /** * Every kind of transaction one of these customers puts through — not only the * ones that triggered a declaration. * * `rule114B` marks the categories listed in Rule 114B, where quoting PAN (and * therefore the Form 97/98 declaration) is mandatory above the limit. The rest * are ordinary account activity, which the report still has to show: the point * is the customer's whole picture, not just the declared slice. * * `drCr` is fixed where the category only goes one way; 'EITHER' lets the * generator choose. `cashOnly` forces counter cash, `nonCash` excludes it. */ interface TxnKind { nature: string; group: string; rule114B: boolean; drCr: 'DR' | 'CR' | 'EITHER'; cashOnly?: boolean; nonCash?: boolean; /** Relative frequency — small routine postings outnumber big declared ones. */ weight: number; /** Typical value band, before the occasional large-value tail. */ min: number; max: number; } const TXN_KINDS: TxnKind[] = [ // ── Rule 114B categories: the transactions the declaration was taken for ── { nature: 'Cash deposit exceeding ₹50,000 in a day', group: 'Cash', rule114B: true, drCr: 'CR', cashOnly: true, weight: 14, min: 50_000, max: 470_000 }, { nature: 'Cash withdrawal exceeding ₹50,000', group: 'Cash', rule114B: true, drCr: 'DR', cashOnly: true, weight: 8, min: 50_000, max: 380_000 }, { nature: 'Opening of deposit account', group: 'Account', rule114B: true, drCr: 'CR', cashOnly: true, weight: 3, min: 5_000, max: 50_000 }, { nature: 'Term deposit exceeding ₹50,000', group: 'Deposit', rule114B: true, drCr: 'DR', weight: 5, min: 50_000, max: 900_000 }, { nature: 'Purchase of demand draft in cash', group: 'Remittance', rule114B: true, drCr: 'DR', cashOnly: true, weight: 4, min: 50_000, max: 260_000 }, { nature: 'Sale/purchase of goods or services above ₹2,00,000', group: 'Trade', rule114B: true, drCr: 'EITHER', weight: 3, min: 200_000, max: 850_000 }, // ── Ordinary account activity ──────────────────────────────────────────── { nature: 'Cash deposit', group: 'Cash', rule114B: false, drCr: 'CR', cashOnly: true, weight: 16, min: 500, max: 49_900 }, { nature: 'Cash withdrawal', group: 'Cash', rule114B: false, drCr: 'DR', cashOnly: true, weight: 15, min: 500, max: 49_900 }, { nature: 'Cheque deposit — outward clearing', group: 'Clearing', rule114B: false, drCr: 'CR', nonCash: true, weight: 7, min: 2_000, max: 180_000 }, { nature: 'Cheque paid — inward clearing', group: 'Clearing', rule114B: false, drCr: 'DR', nonCash: true, weight: 6, min: 2_000, max: 160_000 }, { nature: 'NEFT inward credit', group: 'Remittance', rule114B: false, drCr: 'CR', nonCash: true, weight: 6, min: 1_000, max: 220_000 }, { nature: 'NEFT outward remittance', group: 'Remittance', rule114B: false, drCr: 'DR', nonCash: true, weight: 5, min: 1_000, max: 200_000 }, { nature: 'RTGS inward credit', group: 'Remittance', rule114B: false, drCr: 'CR', nonCash: true, weight: 2, min: 200_000, max: 1_200_000 }, { nature: 'UPI / mobile banking transfer', group: 'Digital', rule114B: false, drCr: 'EITHER', nonCash: true, weight: 10, min: 100, max: 24_000 }, { nature: 'ATM cash withdrawal', group: 'Digital', rule114B: false, drCr: 'DR', cashOnly: true, weight: 9, min: 500, max: 20_000 }, { nature: 'Transfer between own accounts', group: 'Transfer', rule114B: false, drCr: 'EITHER', nonCash: true, weight: 5, min: 1_000, max: 120_000 }, { nature: 'Loan instalment (EMI) recovery', group: 'Loan', rule114B: false, drCr: 'DR', weight: 7, min: 2_500, max: 42_000 }, { nature: 'Loan disbursement credited', group: 'Loan', rule114B: false, drCr: 'CR', nonCash: true, weight: 2, min: 50_000, max: 700_000 }, { nature: 'Interest credited on deposit', group: 'Interest', rule114B: false, drCr: 'CR', nonCash: true, weight: 6, min: 120, max: 14_000 }, { nature: 'Interest debited on loan', group: 'Interest', rule114B: false, drCr: 'DR', nonCash: true, weight: 4, min: 200, max: 11_000 }, { nature: 'Term deposit matured and credited', group: 'Deposit', rule114B: false, drCr: 'CR', nonCash: true, weight: 3, min: 25_000, max: 620_000 }, { nature: 'Recurring deposit instalment', group: 'Deposit', rule114B: false, drCr: 'DR', weight: 5, min: 500, max: 12_000 }, { nature: 'Service charges / commission recovered', group: 'Charges', rule114B: false, drCr: 'DR', nonCash: true, weight: 6, min: 25, max: 2_400 }, { nature: 'Cheque return charges', group: 'Charges', rule114B: false, drCr: 'DR', nonCash: true, weight: 2, min: 100, max: 900 }, { nature: 'Locker rent recovered', group: 'Charges', rule114B: false, drCr: 'DR', nonCash: true, weight: 1, min: 800, max: 4_500 }, { nature: 'Dividend credited on shares', group: 'Shares', rule114B: false, drCr: 'CR', nonCash: true, weight: 2, min: 200, max: 9_000 }, { nature: 'Share capital subscription', group: 'Shares', rule114B: false, drCr: 'DR', weight: 2, min: 500, max: 25_000 } ]; /** Flattened by weight so a single draw picks a category at the right rate. */ const KIND_POOL: TxnKind[] = TXN_KINDS.flatMap((k) => Array(k.weight).fill(k)); /** The distinct groups, for the report's category filter. */ export const TXN_GROUPS: string[] = [...new Set(TXN_KINDS.map((k) => k.group))].sort(); let declarantCache: FormDeclarant[] | null = null; let txnCache: FormDeclarantTxn[] | null = null; /** * The customers who filed a declaration. Roughly one in five is a Form 98 * (agricultural income only), which matches the mix a semi-urban co-operative * bank sees — the rural branches carry most of them. */ export function formDeclarants(): FormDeclarant[] { if (declarantCache) { return declarantCache; } const rand = lcg(31071998); const branches = GL_BRANCHES.filter((b) => b.code !== 'HO'); const out: FormDeclarant[] = []; for (let i = 0; i < 148; i++) { const branch = branches[i % branches.length]; // Form 98 is the agricultural-income declaration, so it clusters in the // outlying branches rather than being spread evenly. const rural = branch.code === '0004' || branch.code === '0006'; const formType: FormType = rand() < (rural ? 0.42 : 0.08) ? 'FORM_61' : 'FORM_60'; const first = pick(rand, FIRST_NAMES); const last = pick(rand, SURNAMES); const seq = `${i + 1}`.padStart(3, '0'); // Declarations are taken in the run-up to and during the ledger period. const declOffset = Math.floor(rand() * 150); const declDate = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`); declDate.setUTCDate(declDate.getUTCDate() - 60 + declOffset); const birthYear = 1955 + Math.floor(rand() * 45); const birthMonth = 1 + Math.floor(rand() * 12); const birthDay = 1 + Math.floor(rand() * 28); out.push({ ackNo: `${branch.code}/F${formType === 'FORM_60' ? '97' : '98'}/${seq}`, formType, name: `${first} ${last}`, fatherName: `${pick(rand, FIRST_NAMES)} ${last}`, dateOfBirth: `${birthYear}-${`${birthMonth}`.padStart(2, '0')}-${`${birthDay}`.padStart(2, '0')}`, address: `${pick(rand, VILLAGES)}, Tal. ${pick(rand, VILLAGES)}, Dist. Thane`, branchCode: branch.code, accountNo: `${branch.code}${(4100000 + i * 137).toString().padStart(8, '0')}`, reasonForNoPan: formType === 'FORM_61' ? 'Income consists only of agricultural income' : pick(rand, NO_PAN_REASONS), // Form 98 declarants report agricultural income, which the Act exempts, so // their declared figures sit below the Form 97 band. estimatedIncome: formType === 'FORM_61' ? 90_000 + Math.floor(rand() * 160_000) : 140_000 + Math.floor(rand() * 380_000), declaredOn: isoDate(declDate), aadhaarLast4: `${1000 + Math.floor(rand() * 9000)}` }); } declarantCache = out; return out; } /** * Every transaction these customers put through inside the ledger period. * * A declaration is taken per transaction category, so an active customer shows * several rows; a customer who only opened an account shows one. */ export function formDeclarantTxns(): FormDeclarantTxn[] { if (txnCache) { return txnCache; } const rand = lcg(19980731); const start = new Date(`${GL_PERIOD.fyStart}T00:00:00Z`); const end = new Date(`${GL_PERIOD.fyEnd}T00:00:00Z`); const span = Math.round((end.getTime() - start.getTime()) / 86_400_000); const out: FormDeclarantTxn[] = []; let voucher = 1; for (const d of formDeclarants()) { // Weighted low — most declarants are occasional users and a handful bank // every week. Squaring the draw gives that long tail rather than a flat // spread, while still leaving enough volume for a day-wise filter to show. const count = 1 + Math.floor(rand() * rand() * 22); for (let n = 0; n < count; n++) { const day = new Date(start.getTime()); day.setUTCDate(day.getUTCDate() + Math.floor(rand() * span)); if (day.getUTCDay() === 0) { day.setUTCDate(day.getUTCDate() + 1); // Sunday — no counter business. } const kind = pick(rand, KIND_POOL); const drCr: 'DR' | 'CR' = kind.drCr === 'EITHER' ? (rand() < 0.5 ? 'DR' : 'CR') : kind.drCr; // The channel follows the category: cash categories go over the counter, // clearing goes through the house, and the rest split across the // electronic rails a co-operative bank offers. let mode: TxnMode; if (kind.cashOnly) { mode = kind.nature.startsWith('ATM') ? 'ATM' : 'CASH'; } else if (kind.nonCash) { mode = kind.group === 'Clearing' ? 'CLEARING' : kind.nature.startsWith('RTGS') ? 'RTGS' : kind.nature.startsWith('NEFT') ? 'NEFT' : kind.group === 'Digital' ? 'UPI' : 'TRANSFER'; } else { // Categories that can go either way — a term deposit or an EMI is paid // in cash as often as it is transferred at this kind of bank. mode = rand() < 0.55 ? 'CASH' : 'TRANSFER'; } // Inside the category's own band, with a thin large-value tail on the // Rule 114B rows so the report has CTR-sized entries to flag. let amount = kind.min + Math.floor(rand() * (kind.max - kind.min)); if (kind.rule114B && kind.cashOnly && rand() < 0.09) { amount = FORM_THRESHOLD.CTR + Math.floor(rand() * 1_400_000); } amount = Math.round(amount / 100) * 100 || 100; const cashWord = mode === 'CASH' || mode === 'ATM'; out.push({ txnId: `TXN${`${out.length + 1}`.padStart(5, '0')}`, ackNo: d.ackNo, date: isoDate(day), branchCode: d.branchCode, accountNo: d.accountNo, mode, drCr, amount, nature: kind.nature, group: kind.group, rule114B: kind.rule114B, particulars: drCr === 'DR' ? `${cashWord ? 'Cash paid to' : 'Debit to account of'} ${d.name} — ${kind.nature.toLowerCase()}` : `${cashWord ? 'Cash received from' : 'Credit to account of'} ${d.name} — ${kind.nature.toLowerCase()}`, voucherNo: `${d.branchCode}/V/${`${voucher++}`.padStart(5, '0')}` }); } } out.sort((a, b) => (a.date === b.date ? a.txnId.localeCompare(b.txnId) : a.date.localeCompare(b.date))); txnCache = out; return out; } export function declarantByAck(ackNo: string): FormDeclarant | undefined { return formDeclarants().find((d) => d.ackNo === ackNo); }