feat: KYC document update, FIU & AML recurring alerts integration, Sponsor DD revoke workflow, and TDS reports
This commit is contained in:
@@ -145,178 +145,33 @@ function makePan(rand: () => number, surname: string): string {
|
||||
return `${letter()}${letter()}${letter()}P${surname[0].toUpperCase()}${digits}${letter()}`;
|
||||
}
|
||||
|
||||
let deducteeCache: TdsDeductee[] | null = null;
|
||||
|
||||
/**
|
||||
* The bank's deductees.
|
||||
* The deductees the reports run on, loaded from the platform.
|
||||
*
|
||||
* 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.
|
||||
* This used to be sixty-four customers generated from a fixed seed — names, PANs, deposits and all.
|
||||
* The computation over them is the real section 194A calculation, so the figures looked like a
|
||||
* filing and were not one: not a rupee of it came from the bank's own books.
|
||||
*
|
||||
* It is set once by whoever loads the screen and read by the pure computation below, which is why
|
||||
* this is a module-level holder rather than a service the compute would have to inject.
|
||||
*/
|
||||
export function tdsDeductees(): TdsDeductee[] {
|
||||
if (deducteeCache) {
|
||||
return deducteeCache;
|
||||
}
|
||||
const rand = lcg(20260801);
|
||||
const branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
|
||||
const out: TdsDeductee[] = [];
|
||||
let liveDeductees: 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;
|
||||
export function setTdsDeductees(deductees: TdsDeductee[]): void {
|
||||
liveDeductees = deductees || [];
|
||||
// The row cache in the compute is keyed on the financial year alone, so it has to be told that
|
||||
// the underlying set changed or it would serve rows built from the previous load.
|
||||
tdsDeducteesVersion++;
|
||||
}
|
||||
|
||||
/** Bumped on every load, so memoised rows can tell one data set from the next. */
|
||||
export let tdsDeducteesVersion = 0;
|
||||
|
||||
export function tdsDeductees(): TdsDeductee[] {
|
||||
return liveDeductees;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Statutory date by which tax deducted in a given month must be deposited.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user