fix: update sponsor DD approvals, cashier transaction entry, FIU and dividend declaration
This commit is contained in:
@@ -91,6 +91,40 @@ interface TxnRow {
|
||||
fundingAccountId?: number;
|
||||
fundingClientId?: number;
|
||||
};
|
||||
/**
|
||||
* How many drafts this one row raises. 1 (or unset) is the ordinary single draft; 2 or more make
|
||||
* the row a multi-DD slip — one debit funding several drafts, each to its own beneficiary.
|
||||
*
|
||||
* Typed by the clerk BEFORE opening the dialog, so the dialog knows how many beneficiary forms to
|
||||
* present. Only a member-account debit can be a slip (see canRowBeMultiDd()).
|
||||
*/
|
||||
ddCount?: number | null;
|
||||
/**
|
||||
* The per-draft lines of a multi-DD slip, captured in the dialog and held here until Save — the
|
||||
* same "fill now, commit on Save" contract as ddDetails above.
|
||||
*
|
||||
* Each line carries its OWN face value and charges: a slip's drafts are routinely for different
|
||||
* amounts to different beneficiaries, and the charge is per draft rather than per slip. That is
|
||||
* why the row's own AMOUNT and CHARGES fields are locked and derived from these lines while a
|
||||
* slip is being built — a clerk-typed row total could disagree with the lines, and the account
|
||||
* would then be debited for a figure the drafts don't add up to.
|
||||
*/
|
||||
ddLines?: DdSlipLine[];
|
||||
/** The inventory the slip's leaflets are drawn from. The batch endpoint allocates by inventory
|
||||
* rather than by leaflet, since the numbers are only picked inside its own locked transaction. */
|
||||
ddInventoryId?: number;
|
||||
/** Resolved funding account for a slip, captured alongside the lines for the same reason. */
|
||||
ddFundingAccountId?: number;
|
||||
ddFundingClientId?: number;
|
||||
}
|
||||
|
||||
/** One draft on a multi-DD slip — its own beneficiary, its own face value, its own charges. */
|
||||
interface DdSlipLine {
|
||||
beneficiaryName: string;
|
||||
payableAtBranch: string;
|
||||
payableAtCity: string;
|
||||
faceValue: number;
|
||||
chargesAmount: number;
|
||||
}
|
||||
|
||||
interface Denomination {
|
||||
@@ -2415,19 +2449,159 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
*/
|
||||
entryTypeOptions: { id: number; name: string }[] = [];
|
||||
|
||||
/** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
|
||||
/**
|
||||
* Whether this row may raise more than one draft off a single debit.
|
||||
*
|
||||
* Any funding source the clerk debits: a member's savings/current account, or a GL account for an
|
||||
* institutional slip. Cash is the exception the batch endpoint still refuses, and for a real
|
||||
* reason rather than an arbitrary one — a slip is funded by one payment, and one counted pile of
|
||||
* notes cannot be apportioned across drafts without the denomination total ceasing to prove what
|
||||
* was actually taken at the counter.
|
||||
*/
|
||||
canRowBeMultiDd(row: TxnRow): boolean {
|
||||
return this.isDdAccountDebitRow(row);
|
||||
}
|
||||
|
||||
/** Whether the clerk has asked this row for more than one draft. */
|
||||
isMultiDdRow(row: TxnRow): boolean {
|
||||
return this.canRowBeMultiDd(row) && (Number(row.ddCount) || 1) > 1;
|
||||
}
|
||||
|
||||
/** How many drafts this row raises — always at least 1. */
|
||||
ddCountFor(row: TxnRow): number {
|
||||
const n = Number(row.ddCount) || 1;
|
||||
return n > 1 ? n : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps a typed DD count into range and discards lines that no longer fit.
|
||||
*
|
||||
* Lowering the count after the dialog has been filled would otherwise leave lines beyond the new
|
||||
* count still on the row: invisible to the clerk, but still sent at Save and still debited for.
|
||||
*/
|
||||
onDdCountChange(row: TxnRow, value: number): void {
|
||||
const n = Math.floor(Number(value) || 1);
|
||||
const previous = this.ddCountFor(row);
|
||||
row.ddCount = n < 1 ? 1 : n > this.maxDdSlipLines ? this.maxDdSlipLines : n;
|
||||
if (row.ddLines && row.ddLines.length !== row.ddCount) {
|
||||
this.clearDdCapture(row);
|
||||
}
|
||||
// Moving onto a slip empties the row's own amount and charge, because on a slip they mean
|
||||
// nothing: each draft carries its own face value and its own charge, entered in the dialog, and
|
||||
// these two fields become the total of those. Leaving a previously typed figure sitting here
|
||||
// would read as the slip's total while describing only one draft — which is exactly what showed
|
||||
// ₹1,012 against four drafts of ₹1,000.
|
||||
if (row.ddCount > 1 && previous <= 1) {
|
||||
row.amount = null;
|
||||
row.ddCharges = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors MAX_SLIP_LINES on the server, so an over-long slip is refused here with a clear message. */
|
||||
readonly maxDdSlipLines = 20;
|
||||
|
||||
/**
|
||||
* The run of leaflet numbers a slip will probably draw, for display only.
|
||||
*
|
||||
* Counts up from the next-available number, preserving its width so 000123 reads as 000124 rather
|
||||
* than 124. Indicative by nature — the binding allocation happens inside the server's submit
|
||||
* transaction, and a slip filed after another clerk's gets the numbers following theirs — which is
|
||||
* why the dialog labels these provisional and the confirmed numbers come back on submission.
|
||||
*
|
||||
* A number that is not a plain run of digits (a prefixed or hyphenated series) cannot be counted
|
||||
* on safely, so nothing is shown rather than something wrong.
|
||||
*/
|
||||
private previewDdNumbersFrom(firstDdNumber: string, count: number): string[] | undefined {
|
||||
const first = (firstDdNumber || '').trim();
|
||||
if (!/^\d+$/.test(first)) return undefined;
|
||||
const width = first.length;
|
||||
const start = Number(first);
|
||||
if (!Number.isSafeInteger(start)) return undefined;
|
||||
return Array.from({ length: count }, (_v, i) => String(start + i).padStart(width, '0'));
|
||||
}
|
||||
|
||||
/** GST on a single charge figure, at the admin-configured rate. */
|
||||
private ddGstOn(charges: number): number {
|
||||
const c = Number(charges) || 0;
|
||||
if (c <= 0) return 0;
|
||||
return Math.round(c * this.ddGstPercentage) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* GST on DD charges only (never on the DD face value), at the admin-configured rate.
|
||||
*
|
||||
* On a slip this is the sum of each line's own GST rather than GST on the summed charges. The two
|
||||
* can differ by a paisa once rounding is applied per line, and it is the per-line figure that is
|
||||
* stored against each draft and posted by the server — so the row must show that same total.
|
||||
*/
|
||||
ddGstAmountFor(row: TxnRow): number {
|
||||
const charges = Number(row.ddCharges) || 0;
|
||||
if (charges <= 0) return 0;
|
||||
return Math.round(charges * this.ddGstPercentage) / 100;
|
||||
if (row.ddLines?.length) {
|
||||
return row.ddLines.reduce((sum, l) => sum + this.ddGstOn(l.chargesAmount), 0);
|
||||
}
|
||||
return this.ddGstOn(row.ddCharges ?? 0);
|
||||
}
|
||||
|
||||
/** Face value of the row — the sum of every line once a slip has been captured. */
|
||||
ddFaceValueFor(row: TxnRow): number {
|
||||
if (row.ddLines?.length) {
|
||||
return row.ddLines.reduce((sum, l) => sum + (Number(l.faceValue) || 0), 0);
|
||||
}
|
||||
return Number(row.amount) || 0;
|
||||
}
|
||||
|
||||
/** Charges on the row — the sum of every line's own charge once a slip has been captured. */
|
||||
ddChargesFor(row: TxnRow): number {
|
||||
if (row.ddLines?.length) {
|
||||
return row.ddLines.reduce((sum, l) => sum + (Number(l.chargesAmount) || 0), 0);
|
||||
}
|
||||
return Number(row.ddCharges) || 0;
|
||||
}
|
||||
|
||||
/** Amount + Charges + GST, rounded UP to the next whole rupee — the total collected from the customer. */
|
||||
ddTotalPayableFor(row: TxnRow): number {
|
||||
const amount = Number(row.amount) || 0;
|
||||
const charges = Number(row.ddCharges) || 0;
|
||||
const gst = this.ddGstAmountFor(row);
|
||||
return Math.ceil(amount + charges + gst - 1e-9);
|
||||
return Math.ceil(this.ddFaceValueFor(row) + this.ddChargesFor(row) + this.ddGstAmountFor(row) - 1e-9);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the TOTAL field shows — a figure, or a dash while a slip has nothing to total yet.
|
||||
*
|
||||
* A slip whose drafts have not been entered has no total: its face values and charges live on the
|
||||
* lines, and there are no lines. Showing ₹0 there would read as a real total of zero rather than
|
||||
* as "not yet known", so the field says so instead.
|
||||
*/
|
||||
ddTotalDisplayFor(row: TxnRow): string {
|
||||
if (this.isSlipAwaitingDetails(row)) {
|
||||
return '—';
|
||||
}
|
||||
return String(this.ddTotalPayableFor(row));
|
||||
}
|
||||
|
||||
/** GST field's text, dashed for the same reason as the total while a slip is still empty. */
|
||||
ddGstDisplayFor(row: TxnRow): string {
|
||||
if (this.isSlipAwaitingDetails(row)) {
|
||||
return '—';
|
||||
}
|
||||
return this.ddGstAmountFor(row).toFixed(2);
|
||||
}
|
||||
|
||||
/** A slip the clerk has asked for but not yet filled in — nothing to total from. */
|
||||
isSlipAwaitingDetails(row: TxnRow): boolean {
|
||||
return this.isMultiDdRow(row) && !row.ddLines?.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets a row's captured draft details, leaving the row itself intact.
|
||||
*
|
||||
* Both shapes are cleared together on purpose: a row carrying a stale ddDetails alongside fresh
|
||||
* ddLines (or the reverse) would be read by whichever of the two Save paths it reached first, and
|
||||
* the clerk would have no way of seeing which one went.
|
||||
*/
|
||||
private clearDdCapture(row: TxnRow): void {
|
||||
row.ddDetails = undefined;
|
||||
row.ddLines = undefined;
|
||||
row.ddInventoryId = undefined;
|
||||
row.ddFundingAccountId = undefined;
|
||||
row.ddFundingClientId = undefined;
|
||||
}
|
||||
|
||||
get isSelectedRowDdCashGl(): boolean {
|
||||
@@ -2468,11 +2642,22 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
]);
|
||||
return;
|
||||
}
|
||||
// On a slip the face value lives on each line, typed in the dialog that is about to open — the
|
||||
// row's own AMOUNT is the sum of those lines and cannot exist yet. Demanding it here would make
|
||||
// a slip impossible to start.
|
||||
const isSlip = this.isMultiDdRow(row);
|
||||
const faceValue = Number(row.amount);
|
||||
if (!faceValue || faceValue <= 0) {
|
||||
if (!isSlip && (!faceValue || faceValue <= 0)) {
|
||||
this.showAlert('error', 'Invalid Amount', ['Please enter the DD Face Value amount before proceeding.']);
|
||||
return;
|
||||
}
|
||||
if (isSlip && !this.isDdAccountDebitRow(row)) {
|
||||
this.showAlert('error', 'Multiple DDs Not Available', [
|
||||
'More than one draft can only be raised against a member savings or current account.',
|
||||
'Set NO. OF DD back to 1 for this row.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
const chargesAmount = Number(row.ddCharges) || 0;
|
||||
const gstAmount = this.ddGstAmountFor(row);
|
||||
const totalCashPayable = this.ddTotalPayableFor(row);
|
||||
@@ -2508,8 +2693,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// Only a cash draft has notes to reconcile. An account debit takes no cash at the counter, so a
|
||||
// denomination count left over from an earlier row must not be checked against it.
|
||||
if (!accountDebit && this.denominationTotal > 0 && Math.abs(this.denominationTotal - totalCashPayable) > 0.005) {
|
||||
// denomination count left over from an earlier row must not be checked against it. A slip is
|
||||
// always an account debit, and its total is not known until the dialog is filled in either.
|
||||
if (
|
||||
!accountDebit &&
|
||||
!isSlip &&
|
||||
this.denominationTotal > 0 &&
|
||||
Math.abs(this.denominationTotal - totalCashPayable) > 0.005
|
||||
) {
|
||||
this.showAlert('error', 'Denomination Mismatch', [
|
||||
`Cash denomination counted (₹${this.denominationTotal}) does not match total payable cash (₹${totalCashPayable}).`,
|
||||
'Please verify and match the physical cash denomination count.'
|
||||
@@ -2532,8 +2723,19 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
const selectedBank = this.sponsorBanks.find((b: any) => b.id === row.sponsorBankId);
|
||||
const branchName = this.branchNameFor(row.br) || 'Branch ' + row.br;
|
||||
|
||||
// A slip is allocated by inventory, not leaflet: the server picks the whole run inside its
|
||||
// own locked transaction. Without the inventory behind this branch's next-available leaflet
|
||||
// there is nothing to allocate from, so the slip is refused here rather than at submit.
|
||||
if (isSlip && !leaflet.inventoryId) {
|
||||
this.showAlert('error', 'DD Inventory Not Identified', [
|
||||
'The DD inventory behind this branch and sponsor bank could not be identified, so multiple drafts cannot be allocated together.',
|
||||
'Set NO. OF DD back to 1, or ask an administrator to check the DD inventory registration.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const dialogRef = this.dialog.open(CashierIssueDdDialogComponent, {
|
||||
width: '600px',
|
||||
width: isSlip ? '760px' : '600px',
|
||||
disableClose: true,
|
||||
data: {
|
||||
leaflet,
|
||||
@@ -2546,7 +2748,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
totalCashPayable,
|
||||
paymentMethod: accountDebit ? ('ACCOUNT_DEBIT' as const) : ('CASH' as const),
|
||||
accountNo: accountDebit ? row.acNo : undefined,
|
||||
accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined
|
||||
accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined,
|
||||
ddCount: isSlip ? this.ddCountFor(row) : undefined,
|
||||
glFunded: isGlDebit,
|
||||
previewDdNumbers: isSlip ? this.previewDdNumbersFrom(leaflet.ddNumber, this.ddCountFor(row)) : undefined
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2555,6 +2760,31 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// review the entered details, and no way to abandon them either.
|
||||
dialogRef.afterClosed().subscribe((result) => {
|
||||
if (!result) return;
|
||||
|
||||
// A slip records its lines instead of a single beneficiary, and takes the row's AMOUNT and
|
||||
// CHARGES with it: from here on those two are the sum of the lines, shown read-only, so a
|
||||
// clerk cannot leave a row total behind that disagrees with what the drafts add up to.
|
||||
if (result.lines) {
|
||||
this.clearDdCapture(row);
|
||||
row.ddLines = result.lines;
|
||||
row.ddInventoryId = leaflet.inventoryId;
|
||||
row.ddFundingAccountId = fundingAccountId!;
|
||||
// Null on a GL slip, which is raised against no member at all.
|
||||
row.ddFundingClientId = fundingClientId ?? undefined;
|
||||
row.amount = this.ddFaceValueFor(row);
|
||||
row.ddCharges = this.ddChargesFor(row);
|
||||
this.showAlert('success', 'Slip Details Captured', [
|
||||
`${result.lines.length} demand drafts captured in favour of ${result.lines
|
||||
.map((l: DdSlipLine) => l.beneficiaryName)
|
||||
.join(', ')}.`,
|
||||
`₹${this.ddTotalPayableFor(row)} will be debited from ${isGlDebit ? 'GL account ' : ''}${row.acNo} as a single ${
|
||||
isGlDebit ? 'journal entry' : 'transaction'
|
||||
} once a checker approves.`,
|
||||
'DD numbers are allocated on submission. Press Save to send this slip for approval.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
row.ddDetails = {
|
||||
leafletId: leaflet.id,
|
||||
ddNumber: leaflet.ddNumber,
|
||||
@@ -2658,7 +2888,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
* got none has no way of telling which row was missing.
|
||||
*/
|
||||
private saveDdRows(ddRows: TxnRow[]): void {
|
||||
const unfilled = ddRows.filter((r) => !r.ddDetails);
|
||||
const unfilled = ddRows.filter((r) => !r.ddDetails && !r.ddLines?.length);
|
||||
if (unfilled.length > 0) {
|
||||
this.showAlert('error', 'DD Details Not Entered', [
|
||||
unfilled.length === 1
|
||||
@@ -2670,6 +2900,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
for (const row of ddRows) {
|
||||
// A slip goes to the batch endpoint, which allocates its leaflets and posts one debit for the
|
||||
// whole run. Sending its lines one at a time through the single-DD endpoint would debit the
|
||||
// member once per draft — the very thing a slip exists to avoid.
|
||||
if (row.ddLines?.length) {
|
||||
this.submitDdSlip(row);
|
||||
continue;
|
||||
}
|
||||
|
||||
const details = row.ddDetails!;
|
||||
const leaflet = { id: details.leafletId, ddNumber: details.ddNumber };
|
||||
if (this.isDdAccountDebitRow(row)) {
|
||||
@@ -2697,6 +2935,99 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a multi-DD slip: several drafts raised together against one debit of the member's
|
||||
* account.
|
||||
*
|
||||
* Maker-checker like the single-draft flow, and nothing is debited here. On approval the server
|
||||
* withdraws the combined face value, charges and GST of every line as ONE transaction and posts
|
||||
* one journal entry against it, then issues each draft — so the member's passbook carries a single
|
||||
* entry naming all the DD numbers rather than one entry per draft. The checker approves or rejects
|
||||
* the slip whole; there is no approving four drafts out of five.
|
||||
*
|
||||
* The leaflets are not chosen here. The server allocates the run inside its own locked transaction
|
||||
* and returns which number went to which line, which is the only point at which the numbers are
|
||||
* real — two clerks submitting at the same moment can therefore never be handed the same one.
|
||||
*/
|
||||
private submitDdSlip(row: TxnRow): void {
|
||||
const lines = row.ddLines ?? [];
|
||||
const inventoryId = row.ddInventoryId;
|
||||
const fundingAccountId = row.ddFundingAccountId;
|
||||
const fundingClientId = row.ddFundingClientId;
|
||||
|
||||
const isGl = row.acTyp === 'GL';
|
||||
|
||||
// Re-checked at Save, not just when the details were captured: the row's TYPE, AC.TP or account
|
||||
// number can all be edited afterwards, and a slip that has drifted onto a cash row is one the
|
||||
// batch endpoint refuses outright. A GL slip is raised against no member, so it carries no
|
||||
// client id and none is required of it — the same asymmetry as the single-draft GL path.
|
||||
if (!inventoryId || !fundingAccountId || (!isGl && !fundingClientId) || !this.canRowBeMultiDd(row)) {
|
||||
this.showAlert('error', 'Slip Cannot Be Submitted', [
|
||||
'This row has changed since its drafts were captured, so the slip no longer matches it.',
|
||||
'Open "Fill DD Details" again and re-enter the drafts.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const d = new Date();
|
||||
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
|
||||
|
||||
// The funding source goes in exactly one of savingsAccountId, currentAccountId or glAccountId,
|
||||
// as the single-draft path does: which one is set is what decides the debit leg on approval.
|
||||
const payload = {
|
||||
clientId: isGl ? undefined : fundingClientId,
|
||||
issuingBranchId: row.br,
|
||||
issuedDate,
|
||||
paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT',
|
||||
savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId,
|
||||
currentAccountId: !isGl && isCurrent ? fundingAccountId : undefined,
|
||||
glAccountId: isGl ? fundingAccountId : undefined,
|
||||
lines: lines.map((l) => ({
|
||||
beneficiaryName: l.beneficiaryName,
|
||||
payableAtBranch: l.payableAtBranch,
|
||||
payableAtCity: l.payableAtCity,
|
||||
faceValue: Number(l.faceValue),
|
||||
chargesAmount: Number(l.chargesAmount) || 0
|
||||
}))
|
||||
};
|
||||
|
||||
const totalDebited = this.ddTotalPayableFor(row);
|
||||
const accountNo = row.acNo;
|
||||
|
||||
this.saving = true;
|
||||
this.sponsorDDService.submitIssueRequestBatch(inventoryId, payload).subscribe({
|
||||
next: (result: any) => {
|
||||
this.saving = false;
|
||||
// The allocated numbers, named back to the clerk. Until this response they were provisional,
|
||||
// and these are what the customer's drafts will actually carry.
|
||||
const allocated = (result?.lines ?? [])
|
||||
.map((l: any) => `${l.ddNumber} · ${l.beneficiaryName} · ₹${l.faceValue}`)
|
||||
.join('\n');
|
||||
this.showAlert('success', 'DD Slip Submitted', [
|
||||
`Slip of ${lines.length} demand drafts submitted successfully.`,
|
||||
allocated || `${lines.length} drafts allocated.`,
|
||||
`₹${totalDebited} will be debited from ${accountNo} as a single transaction on approval.`,
|
||||
'Sent to Manager / Head Cashier for maker-checker approval of the whole slip.'
|
||||
]);
|
||||
row.amount = null;
|
||||
row.ddCharges = null;
|
||||
row.description = '';
|
||||
row.ddCount = 1;
|
||||
// Cleared with the row, as the single-draft paths do: leaving the lines behind would let a
|
||||
// second Save raise the same slip again against leaflets already allocated.
|
||||
this.clearDdCapture(row);
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving = false;
|
||||
this.showAlert('error', 'DD Slip Submission Failed', [
|
||||
err.error?.message || err.message,
|
||||
'No drafts were issued and nothing has been debited — the whole slip was refused together.'
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user