639 lines
16 KiB
TypeScript
639 lines
16 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_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<FormType, string> = {
|
|
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<T>(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<TxnKind>(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);
|
|
}
|