kyc exception at client creation time and some tds work
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Deductees and their deposit interest, for the two TDS reports.
|
||||
*
|
||||
* The bank's deduction obligation runs on time deposits, so the seed
|
||||
* gives each customer a mix of fixed and recurring deposits — and some savings
|
||||
* accounts, which are outside the section and must stay out of the aggregate.
|
||||
*
|
||||
* Everything is generated from a fixed seed so the figures do not move between
|
||||
* loads, and the challan schedule is deliberately imperfect: one quarter is
|
||||
* deposited late so the default-interest column has something to show.
|
||||
*/
|
||||
|
||||
import { GL_BRANCHES } from '../gl-reports/gl-reports-data';
|
||||
import {
|
||||
Declaration,
|
||||
Form15Kind,
|
||||
InterestCredit,
|
||||
TdsAccountInput
|
||||
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||
|
||||
/** The years the seed covers. 2025-26 is complete; 2026-27 is in progress. */
|
||||
export const TDS_FYS = [
|
||||
'2026-27',
|
||||
'2025-26'
|
||||
];
|
||||
|
||||
/** Business date the book is drawn to — Q1 of 2026-27 is closed, Q2 is running. */
|
||||
export const TDS_AS_AT = '2026-08-01';
|
||||
|
||||
export interface TdsDeductee {
|
||||
clientId: number;
|
||||
customerId: string;
|
||||
name: string;
|
||||
pan: string;
|
||||
dob: string;
|
||||
branchCode: string;
|
||||
/** Declarations on record, keyed by financial year. */
|
||||
declarations: Record<string, Declaration>;
|
||||
accounts: TdsAccountInput[];
|
||||
}
|
||||
|
||||
/** One month's remittance of deducted tax to the Government. */
|
||||
export interface TdsChallan {
|
||||
fy: string;
|
||||
/** Quarter the deduction belongs to. */
|
||||
quarter: 1 | 2 | 3 | 4;
|
||||
/** Month the tax was deducted in, e.g. '2026-06'. */
|
||||
month: string;
|
||||
amount: number;
|
||||
/** Statutory date by which it had to be deposited. */
|
||||
dueDate: string;
|
||||
/** When it actually reached the Government — null while still pending. */
|
||||
depositedOn: string | null;
|
||||
challanNo: string | null;
|
||||
bsrCode: string | null;
|
||||
}
|
||||
|
||||
/** 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)];
|
||||
}
|
||||
|
||||
const FIRST_NAMES = [
|
||||
'Ramesh',
|
||||
'Sunita',
|
||||
'Vitthal',
|
||||
'Kamala',
|
||||
'Ganpat',
|
||||
'Shobha',
|
||||
'Dnyaneshwar',
|
||||
'Mangala',
|
||||
'Bhaskar',
|
||||
'Anita',
|
||||
'Tukaram',
|
||||
'Sarika',
|
||||
'Nivrutti',
|
||||
'Rekha',
|
||||
'Popat',
|
||||
'Chhaya',
|
||||
'Ashok',
|
||||
'Vaishali',
|
||||
'Sitaram',
|
||||
'Jayshree',
|
||||
'Madhukar',
|
||||
'Pushpa',
|
||||
'Eknath',
|
||||
'Sulochana',
|
||||
'Hanumant',
|
||||
'Kalpana'
|
||||
];
|
||||
|
||||
const SURNAMES = [
|
||||
'Jadhav',
|
||||
'Pawar',
|
||||
'Shinde',
|
||||
'Gaikwad',
|
||||
'More',
|
||||
'Kadam',
|
||||
'Bhosale',
|
||||
'Patil',
|
||||
'Sawant',
|
||||
'Chavan',
|
||||
'Salunkhe',
|
||||
'Mane',
|
||||
'Deshmukh',
|
||||
'Thorat',
|
||||
'Nikam',
|
||||
'Wagh'
|
||||
];
|
||||
|
||||
/**
|
||||
* Quarter-end dates for a financial year. Interest is credited on these, which
|
||||
* is what makes the deduction fall due.
|
||||
*/
|
||||
function quarterEnds(fy: string): string[] {
|
||||
const y = Number(fy.slice(0, 4));
|
||||
return [
|
||||
`${y}-06-30`,
|
||||
`${y}-09-30`,
|
||||
`${y}-12-31`,
|
||||
`${y + 1}-03-31`
|
||||
];
|
||||
}
|
||||
|
||||
/** A well-formed individual PAN — fourth character 'P'. */
|
||||
function makePan(rand: () => number, surname: string): string {
|
||||
const letter = () => String.fromCharCode(65 + Math.floor(rand() * 26));
|
||||
const digits = `${1000 + Math.floor(rand() * 9000)}`;
|
||||
return `${letter()}${letter()}${letter()}P${surname[0].toUpperCase()}${digits}${letter()}`;
|
||||
}
|
||||
|
||||
let deducteeCache: TdsDeductee[] | null = null;
|
||||
|
||||
/**
|
||||
* The bank's deductees.
|
||||
*
|
||||
* The spread is deliberate so both reports have every case in them: customers
|
||||
* comfortably under the trigger, customers sitting just below it, customers well
|
||||
* over, senior citizens on the higher threshold, a few with no PAN on record
|
||||
* (deducted at the 206AA rate), some joint deposits held as second holder, and
|
||||
* some declarations that only came in mid-year.
|
||||
*/
|
||||
export function tdsDeductees(): TdsDeductee[] {
|
||||
if (deducteeCache) {
|
||||
return deducteeCache;
|
||||
}
|
||||
const rand = lcg(20260801);
|
||||
const branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
|
||||
const out: TdsDeductee[] = [];
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const branch = branches[i % branches.length];
|
||||
const first = pick(rand, FIRST_NAMES);
|
||||
const last = pick(rand, SURNAMES);
|
||||
const clientId = 5000 + i;
|
||||
|
||||
// A third of the book is senior citizens, which is about right for a
|
||||
// co-operative bank's deposit base.
|
||||
const senior = rand() < 0.34;
|
||||
const birthYear = senior ? 1948 + Math.floor(rand() * 15) : 1972 + Math.floor(rand() * 22);
|
||||
const dob = `${birthYear}-${`${1 + Math.floor(rand() * 12)}`.padStart(2, '0')}-${`${1 + Math.floor(rand() * 28)}`.padStart(2, '0')}`;
|
||||
|
||||
// Every seventeenth customer has no PAN on record, so tax comes off at the
|
||||
// higher section 206AA rate. Fixed by index rather than drawn at random, so
|
||||
// the case is always present.
|
||||
const hasPan = i % 17 !== 3;
|
||||
|
||||
// Aim each customer's annual time-deposit interest at a band, so the report
|
||||
// has a proper spread either side of the trigger.
|
||||
const draw = rand();
|
||||
let annualInterest: number;
|
||||
const limit = senior ? 100_000 : 50_000;
|
||||
if (draw < 0.3) {
|
||||
annualInterest = 4_000 + Math.floor(rand() * (limit - 10_000)); // comfortably under
|
||||
} else if (draw < 0.45) {
|
||||
annualInterest = limit - 6_000 + Math.floor(rand() * 6_000); // just below the threshold
|
||||
} else if (draw < 0.8) {
|
||||
annualInterest = limit + 2_000 + Math.floor(rand() * 40_000); // over
|
||||
} else {
|
||||
annualInterest = 110_000 + Math.floor(rand() * 320_000); // well over
|
||||
}
|
||||
|
||||
// Push two of the no-PAN customers over the trigger. Left to chance they can
|
||||
// all land under it, and then the 20% line of the return summary comes out
|
||||
// empty — a report line that is never exercised is one nobody has checked.
|
||||
if (!hasPan && (i === 3 || i === 37)) {
|
||||
annualInterest = 70_000 + i * 400;
|
||||
}
|
||||
|
||||
const accounts: TdsAccountInput[] = [];
|
||||
const acctBase = 41_00_000 + i * 137;
|
||||
|
||||
// One or two time deposits, split across the year's quarter ends.
|
||||
const depositCount = rand() < 0.4 ? 2 : 1;
|
||||
for (let d = 0; d < depositCount; d++) {
|
||||
const isRd = rand() < 0.3;
|
||||
const share = depositCount === 1 ? 1 : d === 0 ? 0.6 : 0.4;
|
||||
const credits: InterestCredit[] = [];
|
||||
|
||||
for (const fy of TDS_FYS) {
|
||||
// The current year is only part-run, so later quarters have no postings.
|
||||
const ends = quarterEnds(fy).filter((date) => date <= TDS_AS_AT);
|
||||
if (!ends.length) {
|
||||
continue;
|
||||
}
|
||||
// The prior year ran in full; scale the current year to what has posted.
|
||||
const perQuarter = Math.round((annualInterest * share) / 4);
|
||||
for (const date of ends) {
|
||||
credits.push({ date, amount: perQuarter });
|
||||
}
|
||||
}
|
||||
|
||||
accounts.push({
|
||||
accountNo: `${branch.code}${`${acctBase + d}`.padStart(8, '0')}`,
|
||||
depositTypeCode: isRd ? 'depositAccountType.recurringDeposit' : 'depositAccountType.fixedDeposit',
|
||||
accountStatus: 'SINGLE',
|
||||
clientId,
|
||||
interestPostingPeriodType: {
|
||||
// A fifth of the book is on annual posting, so tax on those settles
|
||||
// once at year end rather than quarterly.
|
||||
code: rand() < 0.2 ? 'savingsPostingInterestPeriodType.annual' : 'savingsPostingInterestPeriodType.quarterly'
|
||||
},
|
||||
credits
|
||||
});
|
||||
}
|
||||
|
||||
// Most customers also hold a savings account. Its interest must never reach
|
||||
// the deduction aggregate — that is the point of including it here.
|
||||
if (rand() < 0.75) {
|
||||
const sbCredits: InterestCredit[] = [];
|
||||
for (const fy of TDS_FYS) {
|
||||
for (const date of quarterEnds(fy).filter((x) => x <= TDS_AS_AT)) {
|
||||
sbCredits.push({ date, amount: 900 + Math.floor(rand() * 2_600) });
|
||||
}
|
||||
}
|
||||
accounts.push({
|
||||
accountNo: `${branch.code}${`${acctBase + 50}`.padStart(8, '0')}`,
|
||||
depositTypeCode: 'depositAccountType.savingsDeposit',
|
||||
accountStatus: 'SINGLE',
|
||||
clientId,
|
||||
interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.quarterly' },
|
||||
credits: sbCredits
|
||||
});
|
||||
}
|
||||
|
||||
// One in eight is the second holder on somebody else's deposit. Tax there is
|
||||
// deducted against the first-named holder, so it must not land on this
|
||||
// customer's aggregate.
|
||||
if (rand() < 0.12) {
|
||||
const jointCredits: InterestCredit[] = [];
|
||||
for (const fy of TDS_FYS) {
|
||||
for (const date of quarterEnds(fy).filter((x) => x <= TDS_AS_AT)) {
|
||||
jointCredits.push({ date, amount: 16_000 });
|
||||
}
|
||||
}
|
||||
accounts.push({
|
||||
accountNo: `${branch.code}${`${acctBase + 70}`.padStart(8, '0')}`,
|
||||
depositTypeCode: 'depositAccountType.fixedDeposit',
|
||||
accountStatus: 'JOINT',
|
||||
// Somebody else is first-named.
|
||||
primaryClientId: clientId + 1,
|
||||
clientId: clientId + 1,
|
||||
jointHolders: [{ clientId }],
|
||||
interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.quarterly' },
|
||||
credits: jointCredits
|
||||
});
|
||||
}
|
||||
|
||||
// Declarations. Only worth filing where tax would otherwise be deducted, so
|
||||
// they cluster on the customers over the trigger.
|
||||
const declarations: Record<string, Declaration> = {};
|
||||
if (annualInterest > (senior ? 100_000 : 50_000) && hasPan && rand() < 0.45) {
|
||||
const kind: Form15Kind = senior ? '15H' : '15G';
|
||||
for (const fy of TDS_FYS) {
|
||||
const y = Number(fy.slice(0, 4));
|
||||
// A quarter of them only came in mid-year, which leaves tax already
|
||||
// deducted that the branch cannot reverse.
|
||||
const midYear = rand() < 0.25;
|
||||
declarations[fy] = {
|
||||
kind,
|
||||
acceptedOn: midYear ? `${y}-10-01` : `${y}-04-05`,
|
||||
fy,
|
||||
uin: `${branch.code}${kind}${`${i + 1}`.padStart(4, '0')}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
out.push({
|
||||
clientId,
|
||||
customerId: `CIF${`${clientId}`.padStart(8, '0')}`,
|
||||
name: `${first} ${last}`,
|
||||
pan: hasPan ? makePan(rand, last) : '',
|
||||
dob,
|
||||
branchCode: branch.code,
|
||||
declarations,
|
||||
accounts
|
||||
});
|
||||
}
|
||||
|
||||
deducteeCache = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Statutory date by which tax deducted in a given month must be deposited.
|
||||
*
|
||||
* The seventh of the following month, except for March, which runs to 30 April.
|
||||
*/
|
||||
export function depositDueDate(month: string): string {
|
||||
const [
|
||||
y,
|
||||
m
|
||||
] = month.split('-').map(Number);
|
||||
if (m === 3) {
|
||||
return `${y}-04-30`;
|
||||
}
|
||||
const nextY = m === 12 ? y + 1 : y;
|
||||
const nextM = m === 12 ? 1 : m + 1;
|
||||
return `${nextY}-${`${nextM}`.padStart(2, '0')}-07`;
|
||||
}
|
||||
|
||||
let challanCache: Record<string, TdsChallan[]> | null = null;
|
||||
|
||||
/**
|
||||
* The remittance schedule, derived from what the engine says was deducted.
|
||||
*
|
||||
* Deductions land at each quarter end, so there is one challan per quarter. The
|
||||
* Q2 challan of the prior year is deliberately deposited a month and a bit late
|
||||
* so the late-deposit interest column is exercised, and the current year's latest
|
||||
* quarter is left pending, which is the ordinary state of a live book.
|
||||
*/
|
||||
export function tdsChallans(deductedByQuarter: Record<string, Record<number, number>>): Record<string, TdsChallan[]> {
|
||||
if (challanCache) {
|
||||
return challanCache;
|
||||
}
|
||||
const out: Record<string, TdsChallan[]> = {};
|
||||
|
||||
for (const fy of TDS_FYS) {
|
||||
const y = Number(fy.slice(0, 4));
|
||||
const months: Record<number, string> = {
|
||||
1: `${y}-06`,
|
||||
2: `${y}-09`,
|
||||
3: `${y}-12`,
|
||||
4: `${y + 1}-03`
|
||||
};
|
||||
const rows: TdsChallan[] = [];
|
||||
|
||||
for (const q of [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4
|
||||
] as (1 | 2 | 3 | 4)[]) {
|
||||
const amount = Math.round(deductedByQuarter[fy]?.[q] ?? 0);
|
||||
if (amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const month = months[q];
|
||||
const dueDate = depositDueDate(month);
|
||||
// Anything falling after the business date has not been deposited yet.
|
||||
const pending = dueDate > TDS_AS_AT;
|
||||
const late = fy === '2025-26' && q === 2;
|
||||
|
||||
rows.push({
|
||||
fy,
|
||||
quarter: q,
|
||||
month,
|
||||
amount,
|
||||
dueDate,
|
||||
depositedOn: pending ? null : late ? `${y}-11-14` : dueDate,
|
||||
challanNo: pending ? null : `${y}${q}${`${amount % 10000}`.padStart(4, '0')}`,
|
||||
bsrCode: pending ? null : '0510308'
|
||||
});
|
||||
}
|
||||
out[fy] = rows;
|
||||
}
|
||||
|
||||
challanCache = out;
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user