feat: KYC document update, FIU & AML recurring alerts integration, Sponsor DD revoke workflow, and TDS reports

This commit is contained in:
2026-09-14 11:43:26 +05:30
parent 7afa9c7a50
commit 2124bb4b76
40 changed files with 3028 additions and 929 deletions
@@ -57,6 +57,27 @@ interface TxnRow {
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
resolutionNumber?: string;
meetingDate?: string;
/**
* Beneficiary and leaflet captured in the DD dialog, held on the row until Save.
*
* The dialog used to submit the request itself, which left the worksheet's own Save button doing
* nothing for a DD row — the clerk filled the dialog, the request was already gone, and pressing
* Save afterwards had no effect. Filling in details and committing them are two different
* actions, so the dialog now only records what was entered and Save is what sends it.
*/
ddDetails?: {
leafletId: number;
ddNumber: string;
beneficiaryName: string;
payableAtBranch: string;
payableAtCity: string;
/** Present only for a walk-in cash draft. */
purchaser?: { name: string; address: string; aadhaar: string };
/** Note-and-coin breakdown, cash drafts only. */
denominations?: { [key: string]: number };
fundingAccountId?: number;
fundingClientId?: number;
};
}
interface Denomination {
@@ -1671,6 +1692,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
'GL',
'LA'
];
/**
* Account types a clerk may fund a demand draft from.
*
* A clerk raises a draft against a member's own balance, so the only sensible sources are the
* accounts a member actually holds money in. GL was offered here too, and picking it quietly
* switched the row onto the cash path — the one that asks for a walk-in purchaser's name and a
* note-and-coin count, neither of which a clerk debiting an account has. The option is removed
* rather than validated afterwards, so the wrong path cannot be reached at all.
*/
private static readonly DD_CLERK_ACTYPS = [
'SD',
'CA'
];
/**
* The account types this particular row may use.
*
* Every other row keeps the full list; only a clerk's DD row is narrowed, because that is the
* only row where the account type silently decides which funding flow runs.
*/
acTypOptionsFor(row: TxnRow): string[] {
if (this.isClerk && row.typ === 'DD') {
return TransactionEntryComponent.DD_CLERK_ACTYPS;
}
return this.acTypOptions;
}
dbCrOptions = [
'Dr',
'Cr'
@@ -2230,12 +2278,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// panel never appears and the row would post as an ordinary entry with the charges silently
// dropped.
//
// A clerk funds a draft through an account or an instrument instead, and their TXN TP. list no
// longer offers cash, so defaulting to it would open the field on a value it cannot show.
// isDdCashGlRow() still holds for them through AC.TP = GL.
// A clerk funds a draft by debiting a member's own account, so their row opens on savings and
// is debited, and GL is not on their AC.TP list at all (see acTypOptionsFor). Opening them on
// GL put the row on the cash path, which asks for a walk-in purchaser and a note-and-coin
// count that a clerk debiting an account does not have.
case 'DD':
row.dbCr = 'Cr';
row.acTyp = 'GL';
row.dbCr = this.isClerk ? 'Dr' : 'Cr';
row.acTyp = this.isClerk ? 'SD' : 'GL';
row.paymentTypeId = this.isClerk ? this.findTransferPaymentTypeId() : this.findCashPaymentTypeId();
row.chequeNo = '';
row.cheqDate = '';
@@ -2416,13 +2465,34 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
});
// Recorded on the row, not sent. Save is what commits the worksheet, and a DD row that
// submitted itself here left that button with nothing to do — so the clerk had no way to
// review the entered details, and no way to abandon them either.
dialogRef.afterClosed().subscribe((result) => {
if (!result) return;
if (accountDebit) {
this.submitAccountDebitDd(row, leaflet, result, fundingAccountId!, fundingClientId!);
} else {
this.submitWalkinCashDd(row, leaflet, result);
}
row.ddDetails = {
leafletId: leaflet.id,
ddNumber: leaflet.ddNumber,
beneficiaryName: result.beneficiaryName,
payableAtBranch: result.payableAtBranch,
payableAtCity: result.payableAtCity,
purchaser: accountDebit
? undefined
: {
name: result.walkinPurchaserName,
address: result.walkinPurchaserAddress,
aadhaar: result.walkinPurchaserAadhaar
},
fundingAccountId: accountDebit ? fundingAccountId! : undefined,
fundingClientId: accountDebit ? fundingClientId! : undefined
};
this.showAlert('success', 'DD Details Captured', [
`DD No. ${leaflet.ddNumber} · in favour of ${result.beneficiaryName}.`,
accountDebit
? `₹${this.ddTotalPayableFor(row)} will be debited from ${row.acNo} once a checker approves.`
: `₹${this.ddTotalPayableFor(row)} to be collected at the counter.`,
'Press Save to send this draft for approval.'
]);
});
},
error: (err) => {
@@ -2480,6 +2550,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
row.amount = null;
row.ddCharges = null;
row.description = '';
// Cleared with the row: leaving the captured details behind would let a second Save raise
// the same draft again against a leaflet that has already been used.
row.ddDetails = undefined;
},
error: (err) => {
this.saving = false;
@@ -2488,6 +2561,53 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
});
}
/**
* Sends the drafts on the worksheet for approval.
*
* Each row carries the beneficiary details the dialog captured; a row that was never filled in is
* refused by name rather than skipped, because a clerk who pressed Save expecting one draft and
* got none has no way of telling which row was missing.
*/
private saveDdRows(ddRows: TxnRow[]): void {
const unfilled = ddRows.filter((r) => !r.ddDetails);
if (unfilled.length > 0) {
this.showAlert('error', 'DD Details Not Entered', [
unfilled.length === 1
? 'One DD row has no beneficiary details yet.'
: `${unfilled.length} DD rows have no beneficiary details yet.`,
'Use "Fill DD Details & Send for Approval" on each DD row first, then press Save.'
]);
return;
}
for (const row of ddRows) {
const details = row.ddDetails!;
const leaflet = { id: details.leafletId, ddNumber: details.ddNumber };
if (this.isDdAccountDebitRow(row)) {
this.submitAccountDebitDd(
row,
leaflet,
{
beneficiaryName: details.beneficiaryName,
payableAtBranch: details.payableAtBranch,
payableAtCity: details.payableAtCity
},
details.fundingAccountId!,
details.fundingClientId!
);
} else {
this.submitWalkinCashDd(row, leaflet, {
beneficiaryName: details.beneficiaryName,
payableAtBranch: details.payableAtBranch,
payableAtCity: details.payableAtCity,
walkinPurchaserName: details.purchaser?.name,
walkinPurchaserAddress: details.purchaser?.address,
walkinPurchaserAadhaar: details.purchaser?.aadhaar
});
}
}
}
private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void {
// Read through countFor, which knows the map is keyed by kind:value. Indexing by d.id looked
// right but matched nothing, so every cash DD went in with no breakdown at all and its money
@@ -2557,6 +2677,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
row.amount = null;
row.ddCharges = null;
row.description = '';
// See the account-debit path: a second Save must not re-raise a draft already issued.
row.ddDetails = undefined;
this.clearDenominations();
},
error: (err) => {
@@ -4361,6 +4483,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
onSave(): void {
// Demand drafts commit here like everything else on the worksheet. Checked first because a DD
// row shares its shape with the ordinary entries below — without this it would fall through and
// post as a plain transaction, with the draft never raised and the charges silently dropped.
const ddRows = this.rows.filter((r) => this.isDdRow(r));
if (ddRows.length > 0) {
this.saveDdRows(ddRows);
return;
}
// Clerk bank transfer — N DR savings/CA + N CR savings/CA, immediate execution. 'Fund Transfer'
// is the same flow under a second TYPE label — onTypChange() already defaults it identically to
// 'Enter Bank Transfer', so submission has to recognise both or a share redemption or bank