form 91/98 report, min max value update in AML rule and whihc rule apply day, week, month and some more updates
This commit is contained in:
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
<div class="fd-root">
|
||||
<!-- HEADER -->
|
||||
<div class="fd-head">
|
||||
<div class="fd-head-left">
|
||||
<fa-icon icon="file-signature" class="fd-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="fd-head-t">Form 97 / 98 Transaction Report</h2>
|
||||
<div class="fd-head-s">
|
||||
Every transaction put through on a declaration under Rule 114B, for customers who have no PAN. Form 97 is the
|
||||
general declaration; Form 98 is for a person whose only income is agricultural.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="fd-btn fd-btn--primary" [disabled]="generating" (click)="exportExcel()">
|
||||
<fa-icon icon="file-excel"></fa-icon> {{ generating ? 'Generating…' : 'Download Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- FILTERS -->
|
||||
<div class="fd-filters">
|
||||
<div class="fd-filter-row">
|
||||
<div class="fd-modes">
|
||||
@for (m of periodModes; track m.key) {
|
||||
<button class="fd-chip" [class.active]="periodMode === m.key" (click)="periodMode = m.key">
|
||||
{{ m.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@switch (periodMode) {
|
||||
@case ('day') {
|
||||
<label class="fd-field">
|
||||
<span>Date</span>
|
||||
<input type="date" [(ngModel)]="filterDay" [min]="period.fyStart" [max]="period.fyEnd" />
|
||||
</label>
|
||||
}
|
||||
@case ('week') {
|
||||
<label class="fd-field">
|
||||
<span>Any date in week</span>
|
||||
<input type="date" [(ngModel)]="filterWeek" [min]="period.fyStart" [max]="period.fyEnd" />
|
||||
</label>
|
||||
<span class="fd-resolved">Week: {{ rangeLabel }}</span>
|
||||
}
|
||||
@case ('month') {
|
||||
<label class="fd-field">
|
||||
<span>Month</span>
|
||||
<input type="month" [(ngModel)]="filterMonth" min="2026-04" max="2026-07" />
|
||||
</label>
|
||||
}
|
||||
@default {
|
||||
<label class="fd-field">
|
||||
<span>From</span>
|
||||
<input type="date" [(ngModel)]="filterFrom" [min]="period.fyStart" [max]="period.fyEnd" />
|
||||
</label>
|
||||
<label class="fd-field">
|
||||
<span>To</span>
|
||||
<input type="date" [(ngModel)]="filterTo" [min]="period.fyStart" [max]="period.fyEnd" />
|
||||
</label>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="fd-filter-row">
|
||||
<label class="fd-field">
|
||||
<span>Branch</span>
|
||||
<select [(ngModel)]="branchCode" [disabled]="!canSelectBranch">
|
||||
@if (canSelectBranch) {
|
||||
<option value="">All Branches</option>
|
||||
}
|
||||
@for (b of branches; track b.code) {
|
||||
<option [value]="b.code">{{ b.code }} — {{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="fd-field">
|
||||
<span>Declaration</span>
|
||||
<select [(ngModel)]="formType">
|
||||
<option value="">Both</option>
|
||||
<option value="FORM_60">Form 97 — no PAN</option>
|
||||
<option value="FORM_61">Form 98 — agricultural income</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="fd-field">
|
||||
<span>Transaction type</span>
|
||||
<select [(ngModel)]="group">
|
||||
<option value="">All types</option>
|
||||
@for (g of txnGroups; track g) {
|
||||
<option [value]="g">{{ g }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="fd-field fd-field--grow">
|
||||
<span>Search</span>
|
||||
<input type="text" [(ngModel)]="search" placeholder="Name, account number or acknowledgement no…" />
|
||||
</label>
|
||||
|
||||
<label class="fd-check">
|
||||
<input type="checkbox" [(ngModel)]="flaggedOnly" />
|
||||
Only CTR reportable
|
||||
</label>
|
||||
|
||||
<button class="fd-btn" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
|
||||
</div>
|
||||
|
||||
@if (!canSelectBranch) {
|
||||
<div class="fd-scope-note">
|
||||
<fa-icon icon="lock"></fa-icon>
|
||||
Showing {{ branchLabel }} only. Head Office logins can select any branch.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- SUMMARY -->
|
||||
<div class="fd-kpis">
|
||||
<div class="fd-kpi">
|
||||
<div class="fd-kpi-n">{{ summary.persons }}</div>
|
||||
<div class="fd-kpi-l">Declarants</div>
|
||||
<div class="fd-kpi-sub">{{ summary.form60Persons }} on Form 97 · {{ summary.form61Persons }} on Form 98</div>
|
||||
</div>
|
||||
<div class="fd-kpi fd-kpi--blue">
|
||||
<div class="fd-kpi-n">{{ summary.txnCount }}</div>
|
||||
<div class="fd-kpi-l">Transactions — all types</div>
|
||||
<div class="fd-kpi-sub">{{ summary.declarationRequired }} in a Rule 114B category</div>
|
||||
</div>
|
||||
<div class="fd-kpi fd-kpi--amber">
|
||||
<div class="fd-kpi-n">{{ summary.cashTotal | number: '1.0-0' }}</div>
|
||||
<div class="fd-kpi-l">Cash turnover (₹)</div>
|
||||
<div class="fd-kpi-sub">{{ summary.cashCount }} cash transactions</div>
|
||||
</div>
|
||||
<div class="fd-kpi" [class.fd-kpi--red]="summary.ctrReportable > 0">
|
||||
<div class="fd-kpi-n">{{ summary.ctrReportable }}</div>
|
||||
<div class="fd-kpi-l">CTR reportable</div>
|
||||
<div class="fd-kpi-sub">cash at or above ₹10,00,000</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CATEGORY MIX — what these customers actually do, beyond the declared rows -->
|
||||
@if (summary.byGroup.length) {
|
||||
<div class="fd-mix">
|
||||
<span class="fd-mix-t">Transaction mix</span>
|
||||
@for (g of summary.byGroup; track g.group) {
|
||||
<button
|
||||
class="fd-mix-item"
|
||||
[class.active]="group === g.group"
|
||||
(click)="group = group === g.group ? '' : g.group"
|
||||
>
|
||||
{{ g.group }} <strong>{{ g.count }}</strong>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- VIEW SWITCH -->
|
||||
<div class="fd-views">
|
||||
<button class="fd-chip" [class.active]="view === 'person'" (click)="view = 'person'">
|
||||
<fa-icon icon="users"></fa-icon> Person wise
|
||||
</button>
|
||||
<button class="fd-chip" [class.active]="view === 'transaction'" (click)="view = 'transaction'">
|
||||
<fa-icon icon="list"></fa-icon> Transaction wise
|
||||
</button>
|
||||
<span class="fd-spacer"></span>
|
||||
<span class="fd-count">
|
||||
{{ view === 'person' ? personRows.length + ' declarants' : txnRows.length + ' transactions' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- PERSON WISE -->
|
||||
@if (view === 'person') {
|
||||
<div class="fd-table-wrap">
|
||||
<table class="fd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Acknowledgement</th>
|
||||
<th>Form</th>
|
||||
<th>Name of declarant</th>
|
||||
<th>Account No</th>
|
||||
<th>Branch</th>
|
||||
<th class="num">Txns</th>
|
||||
<th class="num">Rule 114B</th>
|
||||
<th class="num">Cash turnover</th>
|
||||
<th class="num">Credit</th>
|
||||
<th class="num">Debit</th>
|
||||
<th class="num">Declared income</th>
|
||||
<th class="num">Cash ÷ income</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (p of personRows; track p.declarant.ackNo) {
|
||||
<tr class="fd-person" [class.open]="expanded === p.declarant.ackNo" (click)="toggle(p.declarant.ackNo)">
|
||||
<td>
|
||||
<fa-icon [icon]="expanded === p.declarant.ackNo ? 'caret-down' : 'caret-right'"></fa-icon>
|
||||
</td>
|
||||
<td class="mono">{{ p.declarant.ackNo }}</td>
|
||||
<td>
|
||||
<span class="fd-form" [class.fd-form--61]="p.declarant.formType === 'FORM_61'">
|
||||
{{ formLabel[p.declarant.formType] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ p.declarant.name }}</strong>
|
||||
@if (p.ctrCount > 0) {
|
||||
<span class="fd-tag fd-tag--ctr">{{ p.ctrCount }} CTR</span>
|
||||
}
|
||||
</td>
|
||||
<td class="mono">{{ p.declarant.accountNo }}</td>
|
||||
<td>{{ p.declarant.branchCode }}</td>
|
||||
<td class="num">{{ p.txnCount }}</td>
|
||||
<td class="num">{{ p.declaredCount }}</td>
|
||||
<td class="num strong">{{ p.cashTotal | number: '1.0-0' }}</td>
|
||||
<td class="num">{{ p.totalCredit | number: '1.0-0' }}</td>
|
||||
<td class="num">{{ p.totalDebit | number: '1.0-0' }}</td>
|
||||
<td class="num">{{ p.declarant.estimatedIncome | number: '1.0-0' }}</td>
|
||||
<td class="num">
|
||||
<span [class]="ratioClass(p.incomeRatio)">{{ p.incomeRatio | number: '1.1-1' }}×</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@if (expanded === p.declarant.ackNo) {
|
||||
<tr class="fd-detail">
|
||||
<td colspan="13">
|
||||
<div class="fd-decl">
|
||||
<div>
|
||||
<label>Father's name</label><span>{{ p.declarant.fatherName }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>Date of birth</label><span>{{ p.declarant.dateOfBirth }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>Aadhaar</label><span class="mono">XXXX XXXX {{ p.declarant.aadhaarLast4 }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>Declared on</label><span>{{ p.declarant.declaredOn }}</span>
|
||||
</div>
|
||||
<div class="wide">
|
||||
<label>Address</label><span>{{ p.declarant.address }}</span>
|
||||
</div>
|
||||
<div class="wide">
|
||||
<label>Reason for no PAN</label><span>{{ p.declarant.reasonForNoPan }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="fd-subtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Txn ID</th>
|
||||
<th>Mode</th>
|
||||
<th>Dr/Cr</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Nature of transaction (Rule 114B)</th>
|
||||
<th>Voucher</th>
|
||||
<th>Flag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of p.txns; track t.txnId) {
|
||||
<tr>
|
||||
<td class="mono">{{ t.date }}</td>
|
||||
<td class="mono">{{ t.txnId }}</td>
|
||||
<td>
|
||||
<span class="fd-mode">{{ t.mode }}</span>
|
||||
</td>
|
||||
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
|
||||
<td class="num strong">{{ t.amount | number: '1.0-0' }}</td>
|
||||
<td>{{ t.nature }}</td>
|
||||
<td class="mono">{{ t.voucherNo }}</td>
|
||||
<td>
|
||||
@if (t.ctrReportable) {
|
||||
<span class="fd-tag fd-tag--ctr">CTR</span>
|
||||
} @else if (t.declarationRequired) {
|
||||
<span class="fd-tag">Rule 114B</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="13" class="fd-empty">
|
||||
No Form 97 or Form 98 transactions for {{ rangeLabel }} at {{ branchLabel }}.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TRANSACTION WISE -->
|
||||
@if (view === 'transaction') {
|
||||
<div class="fd-table-wrap">
|
||||
<table class="fd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Txn ID</th>
|
||||
<th>Acknowledgement</th>
|
||||
<th>Form</th>
|
||||
<th>Name of declarant</th>
|
||||
<th>Account No</th>
|
||||
<th>Branch</th>
|
||||
<th>Category</th>
|
||||
<th>Mode</th>
|
||||
<th>Dr/Cr</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Nature of transaction</th>
|
||||
<th>Voucher</th>
|
||||
<th>Flag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (t of txnRows; track t.txnId) {
|
||||
<tr [class.fd-row--ctr]="t.ctrReportable">
|
||||
<td class="mono">{{ t.date }}</td>
|
||||
<td class="mono">{{ t.txnId }}</td>
|
||||
<td class="mono">{{ t.ackNo }}</td>
|
||||
<td>
|
||||
<span class="fd-form" [class.fd-form--61]="t.formType === 'FORM_61'">{{ formLabel[t.formType] }}</span>
|
||||
</td>
|
||||
<td>{{ t.declarantName }}</td>
|
||||
<td class="mono">{{ t.accountNo }}</td>
|
||||
<td>{{ t.branchCode }}</td>
|
||||
<td>
|
||||
<span class="fd-group">{{ t.group }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="fd-mode">{{ t.mode }}</span>
|
||||
</td>
|
||||
<td [class.cr]="t.drCr === 'CR'" [class.dr]="t.drCr === 'DR'">{{ t.drCr }}</td>
|
||||
<td class="num strong">{{ t.amount | number: '1.0-0' }}</td>
|
||||
<td>{{ t.nature }}</td>
|
||||
<td class="mono">{{ t.voucherNo }}</td>
|
||||
<td>
|
||||
@if (t.ctrReportable) {
|
||||
<span class="fd-tag fd-tag--ctr">CTR</span>
|
||||
} @else if (t.declarationRequired) {
|
||||
<span class="fd-tag">Rule 114B</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="14" class="fd-empty">
|
||||
No Form 97 or Form 98 transactions for {{ rangeLabel }} at {{ branchLabel }}.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="fd-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
|
||||
</div>
|
||||
@@ -0,0 +1,608 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
:host {
|
||||
--f-navy: #1a237e;
|
||||
--f-blue: #1d4ed8;
|
||||
--f-blue-l: #eff6ff;
|
||||
--f-green: #15803d;
|
||||
--f-green-l: #dcfce7;
|
||||
--f-amber: #b45309;
|
||||
--f-amber-l: #fef3c7;
|
||||
--f-red: #c0392b;
|
||||
--f-red-l: #fdecea;
|
||||
--f-g50: #f8fafc;
|
||||
--f-g100: #f1f5f9;
|
||||
--f-g200: #e2e8f0;
|
||||
--f-g300: #cbd5e1;
|
||||
--f-g500: #64748b;
|
||||
--f-g700: #334155;
|
||||
--f-g900: #0f172a;
|
||||
|
||||
display: block;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
background: var(--f-g50);
|
||||
color: var(--f-g900);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.fd-root {
|
||||
padding: 18px 22px;
|
||||
max-width: 1700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* HEADER */
|
||||
.fd-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.fd-head-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.fd-head-ic {
|
||||
font-size: 26px;
|
||||
color: var(--f-navy);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.fd-head-t {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fd-head-s {
|
||||
font-size: 12px;
|
||||
color: var(--f-g500);
|
||||
max-width: 820px;
|
||||
}
|
||||
|
||||
/* BUTTONS */
|
||||
.fd-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 15px;
|
||||
border: 1px solid var(--f-g300);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--f-g700);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--f-g50);
|
||||
border-color: var(--f-g500);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--f-green);
|
||||
border-color: var(--f-green);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #12692f;
|
||||
border-color: #12692f;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* FILTERS */
|
||||
.fd-filters {
|
||||
background: #fff;
|
||||
border: 1px solid var(--f-g200);
|
||||
border-radius: 11px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fd-filter-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fd-modes {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fd-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--f-g300);
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--f-g700);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--f-navy);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--f-navy);
|
||||
border-color: var(--f-navy);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.fd-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
span {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--f-g500);
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--f-g300);
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
color: var(--f-g900);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--f-navy);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--f-g100);
|
||||
color: var(--f-g500);
|
||||
}
|
||||
}
|
||||
|
||||
&--grow {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fd-resolved {
|
||||
font-size: 12px;
|
||||
color: var(--f-g500);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.fd-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--f-g700);
|
||||
padding-bottom: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fd-scope-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
color: var(--f-amber);
|
||||
background: var(--f-amber-l);
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 7px;
|
||||
padding: 6px 11px;
|
||||
}
|
||||
|
||||
/* KPIs */
|
||||
.fd-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.fd-kpi {
|
||||
background: #fff;
|
||||
border: 1px solid var(--f-g200);
|
||||
border-left: 4px solid var(--f-navy);
|
||||
border-radius: 11px;
|
||||
padding: 12px 16px;
|
||||
|
||||
&--blue {
|
||||
border-left-color: var(--f-blue);
|
||||
}
|
||||
|
||||
&--amber {
|
||||
border-left-color: var(--f-amber);
|
||||
}
|
||||
|
||||
&--red {
|
||||
border-left-color: var(--f-red);
|
||||
}
|
||||
}
|
||||
|
||||
.fd-kpi-n {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
.fd-kpi-l {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--f-g700);
|
||||
}
|
||||
|
||||
.fd-kpi-sub {
|
||||
font-size: 11px;
|
||||
color: var(--f-g500);
|
||||
}
|
||||
|
||||
/* VIEW SWITCH */
|
||||
.fd-views {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.fd-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fd-count {
|
||||
font-size: 12px;
|
||||
color: var(--f-g500);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* TABLE */
|
||||
.fd-table-wrap {
|
||||
background: #fff;
|
||||
border: 1px solid var(--f-g200);
|
||||
border-radius: 11px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.fd-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
background: var(--f-g100);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--f-g700);
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--f-g200);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--f-g100);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
tbody tr:hover:not(.fd-detail) {
|
||||
background: var(--f-g50);
|
||||
}
|
||||
}
|
||||
|
||||
.fd-person {
|
||||
cursor: pointer;
|
||||
|
||||
&.open {
|
||||
background: var(--f-blue-l);
|
||||
}
|
||||
}
|
||||
|
||||
.fd-row--ctr {
|
||||
background: var(--f-red-l);
|
||||
}
|
||||
|
||||
.fd-detail {
|
||||
background: #fcfcfe;
|
||||
|
||||
td {
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.fd-decl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px 18px;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--f-g200);
|
||||
border-radius: 8px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--f-g500);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.fd-subtable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--f-g200);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
background: var(--f-g50);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--f-g500);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
border-top: 1px solid var(--f-g100);
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-family: 'DM Mono', monospace;
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.fd-form {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
background: var(--f-blue-l);
|
||||
color: var(--f-blue);
|
||||
white-space: nowrap;
|
||||
|
||||
&--61 {
|
||||
background: var(--f-green-l);
|
||||
color: var(--f-green);
|
||||
}
|
||||
}
|
||||
|
||||
.fd-mode {
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 5px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
background: var(--f-g100);
|
||||
color: var(--f-g700);
|
||||
}
|
||||
|
||||
.fd-group {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
background: #fff;
|
||||
border: 1px solid var(--f-g300);
|
||||
color: var(--f-g700);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Category mix — doubles as a one-click filter. */
|
||||
.fd-mix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.fd-mix-t {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--f-g500);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.fd-mix-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 11px;
|
||||
border: 1px solid var(--f-g200);
|
||||
background: #fff;
|
||||
border-radius: 7px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--f-g700);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
|
||||
strong {
|
||||
font-family: 'DM Mono', monospace;
|
||||
color: var(--f-navy);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--f-navy);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--f-navy);
|
||||
border-color: var(--f-navy);
|
||||
color: #fff;
|
||||
|
||||
strong {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fd-tag {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: var(--f-amber-l);
|
||||
color: var(--f-amber);
|
||||
white-space: nowrap;
|
||||
|
||||
&--ctr {
|
||||
background: var(--f-red-l);
|
||||
color: var(--f-red);
|
||||
}
|
||||
}
|
||||
|
||||
.fd-ratio {
|
||||
font-weight: 600;
|
||||
|
||||
&--mid {
|
||||
color: var(--f-amber);
|
||||
}
|
||||
|
||||
&--high {
|
||||
color: var(--f-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.cr {
|
||||
color: var(--f-green);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dr {
|
||||
color: var(--f-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'DM Mono', monospace;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.fd-empty {
|
||||
padding: 44px;
|
||||
text-align: center;
|
||||
color: var(--f-g500);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* TOAST */
|
||||
.fd-toast {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: var(--f-navy);
|
||||
color: #fff;
|
||||
padding: 12px 22px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 6px 20px rgb(0 0 0 / 25%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.25s;
|
||||
z-index: 1500;
|
||||
|
||||
&.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.fd-kpis {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.fd-decl {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* 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 { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
|
||||
import { BranchScopeService } from '../core/branch-scope.service';
|
||||
import { GL_BRANCHES, GL_PERIOD, glBranchName } from '../gl-reports/gl-reports-data';
|
||||
import { FORM_LABEL, FORM_THRESHOLD, FormType, TXN_GROUPS } from './form-declaration-data';
|
||||
import {
|
||||
DateRange,
|
||||
FormPersonRow,
|
||||
FormReportFilter,
|
||||
FormReportSummary,
|
||||
FormTxnRow,
|
||||
formPersonRows,
|
||||
formReportSummary,
|
||||
formTxnRows,
|
||||
monthRange,
|
||||
weekRange
|
||||
} from './form-declaration.compute';
|
||||
|
||||
type PeriodMode = 'day' | 'week' | 'month' | 'range';
|
||||
type ViewMode = 'person' | 'transaction';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-form-declaration-report',
|
||||
templateUrl: './form-declaration-report.component.html',
|
||||
styleUrls: ['./form-declaration-report.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class FormDeclarationReportComponent implements OnInit {
|
||||
private scope = inject(BranchScopeService);
|
||||
|
||||
readonly period = GL_PERIOD;
|
||||
readonly branches = GL_BRANCHES;
|
||||
readonly formLabel = FORM_LABEL;
|
||||
readonly threshold = FORM_THRESHOLD;
|
||||
readonly txnGroups = TXN_GROUPS;
|
||||
|
||||
readonly periodModes: { key: PeriodMode; label: string }[] = [
|
||||
{ key: 'day', label: 'Day wise' },
|
||||
{ key: 'week', label: 'Week wise' },
|
||||
{ key: 'month', label: 'Month wise' },
|
||||
{ key: 'range', label: 'Between dates' }
|
||||
];
|
||||
|
||||
// ── Filter state ───────────────────────────────────────────────────────────
|
||||
periodMode: PeriodMode = 'month';
|
||||
filterDay = '2026-07-31';
|
||||
/** Any date inside the wanted week; resolved to Mon–Sat. */
|
||||
filterWeek = '2026-07-27';
|
||||
filterMonth = '2026-07';
|
||||
filterFrom = GL_PERIOD.fyStart;
|
||||
filterTo = GL_PERIOD.fyEnd;
|
||||
branchCode = '';
|
||||
formType: FormType | '' = '';
|
||||
group = '';
|
||||
search = '';
|
||||
flaggedOnly = false;
|
||||
|
||||
view: ViewMode = 'person';
|
||||
/** Acknowledgement number of the person row currently expanded. */
|
||||
expanded: string | null = null;
|
||||
|
||||
generating = false;
|
||||
toastMsg = '';
|
||||
toastVisible = false;
|
||||
private toastTimer: any;
|
||||
|
||||
ngOnInit(): void {
|
||||
// A branch login opens on its own book and cannot move off it.
|
||||
this.branchCode = this.scope.defaultBranchCode;
|
||||
}
|
||||
|
||||
// ── Filter resolution ──────────────────────────────────────────────────────
|
||||
|
||||
get range(): DateRange {
|
||||
if (this.periodMode === 'day') {
|
||||
return { from: this.filterDay, to: this.filterDay };
|
||||
}
|
||||
if (this.periodMode === 'week') {
|
||||
return weekRange(this.filterWeek);
|
||||
}
|
||||
if (this.periodMode === 'month') {
|
||||
return monthRange(this.filterMonth);
|
||||
}
|
||||
return { from: this.filterFrom, to: this.filterTo };
|
||||
}
|
||||
|
||||
get rangeLabel(): string {
|
||||
const r = this.range;
|
||||
return r.from === r.to ? r.from : `${r.from} to ${r.to}`;
|
||||
}
|
||||
|
||||
get canSelectBranch(): boolean {
|
||||
return this.scope.canSelectBranch;
|
||||
}
|
||||
|
||||
get branchLabel(): string {
|
||||
if (!this.canSelectBranch) {
|
||||
return this.scope.officeName;
|
||||
}
|
||||
return this.branchCode ? glBranchName(this.branchCode) : 'All Branches (Consolidated)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Applied at the data boundary rather than only in the UI, so a branch login
|
||||
* cannot widen its own scope by tampering with component state.
|
||||
*/
|
||||
private get effectiveBranch(): string {
|
||||
return this.scope.enforce(this.branchCode);
|
||||
}
|
||||
|
||||
private get filter(): FormReportFilter {
|
||||
return {
|
||||
range: this.range,
|
||||
branchCode: this.effectiveBranch,
|
||||
formType: this.formType,
|
||||
group: this.group,
|
||||
search: this.search,
|
||||
flaggedOnly: this.flaggedOnly
|
||||
};
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.periodMode = 'month';
|
||||
this.filterDay = '2026-07-31';
|
||||
this.filterWeek = '2026-07-27';
|
||||
this.filterMonth = '2026-07';
|
||||
this.filterFrom = GL_PERIOD.fyStart;
|
||||
this.filterTo = GL_PERIOD.fyEnd;
|
||||
this.formType = '';
|
||||
this.group = '';
|
||||
this.search = '';
|
||||
this.flaggedOnly = false;
|
||||
this.expanded = null;
|
||||
// Reset returns a branch login to its own branch, never to "all".
|
||||
this.branchCode = this.scope.defaultBranchCode;
|
||||
}
|
||||
|
||||
// ── Data ───────────────────────────────────────────────────────────────────
|
||||
|
||||
get summary(): FormReportSummary {
|
||||
return formReportSummary(this.filter);
|
||||
}
|
||||
|
||||
get personRows(): FormPersonRow[] {
|
||||
return formPersonRows(this.filter);
|
||||
}
|
||||
|
||||
get txnRows(): FormTxnRow[] {
|
||||
return formTxnRows(this.filter);
|
||||
}
|
||||
|
||||
toggle(ackNo: string): void {
|
||||
this.expanded = this.expanded === ackNo ? null : ackNo;
|
||||
}
|
||||
|
||||
/** Cash turnover far above declared income is the point of the report. */
|
||||
ratioClass(ratio: number): string {
|
||||
if (ratio >= 5) {
|
||||
return 'fd-ratio fd-ratio--high';
|
||||
}
|
||||
if (ratio >= 2) {
|
||||
return 'fd-ratio fd-ratio--mid';
|
||||
}
|
||||
return 'fd-ratio';
|
||||
}
|
||||
|
||||
// ── Excel ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async exportExcel(): Promise<void> {
|
||||
this.generating = true;
|
||||
try {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = 'CBSNexora';
|
||||
wb.created = new Date();
|
||||
|
||||
this.writeSummarySheet(wb);
|
||||
this.writePersonSheet(wb);
|
||||
this.writeTransactionSheet(wb);
|
||||
|
||||
const buffer = await wb.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `Form-97-98-Transaction-Report-${this.range.from}-to-${this.range.to}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
this.toast(`Exported ${this.summary.txnCount} transactions for ${this.rangeLabel}`);
|
||||
} catch {
|
||||
this.toast('Could not generate the workbook — please retry');
|
||||
} finally {
|
||||
this.generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Title band + filter caption, repeated on every sheet so pages stand alone. */
|
||||
private banner(sheet: ExcelJS.Worksheet, title: string, cols: number): void {
|
||||
const t = sheet.addRow([title]);
|
||||
sheet.mergeCells(t.number, 1, t.number, cols);
|
||||
t.font = { bold: true, size: 14, color: { argb: 'FFFFFFFF' } };
|
||||
t.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
t.height = 24;
|
||||
t.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1A237E' } };
|
||||
|
||||
const formCaption = this.formType ? FORM_LABEL[this.formType] : 'Form 97 and Form 98';
|
||||
const meta = sheet.addRow([
|
||||
`Period: ${this.rangeLabel} · Branch: ${this.branchLabel} · Declaration: ${formCaption}` +
|
||||
` · Category: ${this.group || 'All transaction types'}`
|
||||
]);
|
||||
sheet.mergeCells(meta.number, 1, meta.number, cols);
|
||||
meta.font = { italic: true, size: 10 };
|
||||
sheet.addRow([]);
|
||||
}
|
||||
|
||||
private headerRow(sheet: ExcelJS.Worksheet, headers: string[]): void {
|
||||
const head = sheet.addRow(headers);
|
||||
head.font = { bold: true };
|
||||
head.alignment = { wrapText: true, horizontal: 'center' };
|
||||
head.eachCell((c) => {
|
||||
c.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
|
||||
c.border = { bottom: { style: 'thin' } };
|
||||
});
|
||||
}
|
||||
|
||||
private writeSummarySheet(wb: ExcelJS.Workbook): void {
|
||||
const s = this.summary;
|
||||
const sheet = wb.addWorksheet('Summary');
|
||||
sheet.columns = [
|
||||
{ width: 52 },
|
||||
{ width: 24 }];
|
||||
this.banner(sheet, 'Form 97 / 98 Transaction Report — Summary', 2);
|
||||
this.headerRow(sheet, [
|
||||
'Particulars',
|
||||
'Value'
|
||||
]);
|
||||
|
||||
const money = new Set([
|
||||
'Total cash turnover',
|
||||
'Total credits',
|
||||
'Total debits'
|
||||
]);
|
||||
|
||||
const lines: [string, string | number][] = [
|
||||
[
|
||||
'Declarants transacting in the period',
|
||||
s.persons
|
||||
],
|
||||
[
|
||||
' of which Form 97 (no PAN)',
|
||||
s.form60Persons
|
||||
],
|
||||
[
|
||||
' of which Form 98 (agricultural income only)',
|
||||
s.form61Persons
|
||||
],
|
||||
[
|
||||
'Total transactions — all types',
|
||||
s.txnCount
|
||||
],
|
||||
[
|
||||
' of which cash (counter and ATM)',
|
||||
s.cashCount
|
||||
],
|
||||
[
|
||||
' of which in a Rule 114B category',
|
||||
s.declarationRequired
|
||||
],
|
||||
[
|
||||
'Total cash turnover',
|
||||
s.cashTotal
|
||||
],
|
||||
[
|
||||
'Total credits',
|
||||
s.totalCredit
|
||||
],
|
||||
[
|
||||
'Total debits',
|
||||
s.totalDebit
|
||||
],
|
||||
[
|
||||
`Cash transactions at or above ₹${FORM_THRESHOLD.CTR.toLocaleString('en-IN')} (CTR reportable)`,
|
||||
s.ctrReportable
|
||||
]
|
||||
];
|
||||
|
||||
for (const [
|
||||
label,
|
||||
value
|
||||
] of lines) {
|
||||
const row = sheet.addRow([
|
||||
label,
|
||||
value
|
||||
]);
|
||||
if (money.has(label)) {
|
||||
row.getCell(2).numFmt = '#,##0.00';
|
||||
}
|
||||
}
|
||||
|
||||
// Category mix — what the customer group actually does, beyond the
|
||||
// declared transactions.
|
||||
sheet.addRow([]);
|
||||
this.headerRow(sheet, [
|
||||
'Transaction category',
|
||||
'Count'
|
||||
]);
|
||||
for (const g of s.byGroup) {
|
||||
sheet.addRow([
|
||||
g.group,
|
||||
g.count
|
||||
]);
|
||||
}
|
||||
sheet.views = [{ state: 'frozen', ySplit: 4 }];
|
||||
}
|
||||
|
||||
private writePersonSheet(wb: ExcelJS.Workbook): void {
|
||||
const sheet = wb.addWorksheet('Person wise');
|
||||
const widths = [
|
||||
18,
|
||||
12,
|
||||
26,
|
||||
24,
|
||||
18,
|
||||
14,
|
||||
10,
|
||||
10,
|
||||
10,
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
14,
|
||||
10,
|
||||
34
|
||||
];
|
||||
sheet.columns = widths.map((w) => ({ width: w }));
|
||||
this.banner(sheet, 'Form 97 / 98 Transaction Report — Person wise', widths.length);
|
||||
this.headerRow(sheet, [
|
||||
'Acknowledgement',
|
||||
'Form',
|
||||
'Name of declarant',
|
||||
"Father's name",
|
||||
'Account No',
|
||||
'Branch',
|
||||
'Total txns',
|
||||
'Cash txns',
|
||||
'Rule 114B txns',
|
||||
'Cash turnover',
|
||||
'Total credit',
|
||||
'Total debit',
|
||||
'Declared income',
|
||||
'CTR rows',
|
||||
'Categories transacted'
|
||||
]);
|
||||
|
||||
for (const p of this.personRows) {
|
||||
const row = sheet.addRow([
|
||||
p.declarant.ackNo,
|
||||
FORM_LABEL[p.declarant.formType],
|
||||
p.declarant.name,
|
||||
p.declarant.fatherName,
|
||||
p.declarant.accountNo,
|
||||
glBranchName(p.declarant.branchCode),
|
||||
p.txnCount,
|
||||
p.cashCount,
|
||||
p.declaredCount,
|
||||
p.cashTotal,
|
||||
p.totalCredit,
|
||||
p.totalDebit,
|
||||
p.declarant.estimatedIncome,
|
||||
p.ctrCount,
|
||||
p.groups.join(', ')
|
||||
]);
|
||||
for (const c of [
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
13
|
||||
]) {
|
||||
row.getCell(c).numFmt = '#,##0.00';
|
||||
}
|
||||
}
|
||||
sheet.views = [{ state: 'frozen', ySplit: 4 }];
|
||||
}
|
||||
|
||||
private writeTransactionSheet(wb: ExcelJS.Workbook): void {
|
||||
const sheet = wb.addWorksheet('Transactions');
|
||||
const widths = [
|
||||
12,
|
||||
12,
|
||||
18,
|
||||
12,
|
||||
26,
|
||||
18,
|
||||
14,
|
||||
12,
|
||||
10,
|
||||
6,
|
||||
16,
|
||||
46,
|
||||
16,
|
||||
12
|
||||
];
|
||||
sheet.columns = widths.map((w) => ({ width: w }));
|
||||
this.banner(sheet, 'Form 97 / 98 Transaction Report — Transaction wise', widths.length);
|
||||
this.headerRow(sheet, [
|
||||
'Date',
|
||||
'Txn ID',
|
||||
'Acknowledgement',
|
||||
'Form',
|
||||
'Name of declarant',
|
||||
'Account No',
|
||||
'Branch',
|
||||
'Category',
|
||||
'Mode',
|
||||
'Dr/Cr',
|
||||
'Amount',
|
||||
'Nature of transaction',
|
||||
'Voucher No',
|
||||
'Flag'
|
||||
]);
|
||||
|
||||
for (const t of this.txnRows) {
|
||||
const row = sheet.addRow([
|
||||
t.date,
|
||||
t.txnId,
|
||||
t.ackNo,
|
||||
FORM_LABEL[t.formType],
|
||||
t.declarantName,
|
||||
t.accountNo,
|
||||
glBranchName(t.branchCode),
|
||||
t.group,
|
||||
t.mode,
|
||||
t.drCr,
|
||||
t.amount,
|
||||
t.nature,
|
||||
t.voucherNo,
|
||||
t.ctrReportable ? 'CTR' : t.declarationRequired ? 'Rule 114B' : ''
|
||||
]);
|
||||
row.getCell(11).numFmt = '#,##0.00';
|
||||
if (t.ctrReportable) {
|
||||
row.getCell(14).font = { bold: true, color: { argb: 'FFC0392B' } };
|
||||
}
|
||||
}
|
||||
sheet.views = [{ state: 'frozen', ySplit: 4 }];
|
||||
}
|
||||
|
||||
private toast(msg: string): void {
|
||||
this.toastMsg = msg;
|
||||
this.toastVisible = true;
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 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)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user