feat(clerk): add collection flow — multi-DR savings to CR GL with maker-checker

- Transaction entry: detect collection mode (N DR savings → 1 CR GL) and disbursement mode (1 DR GL → N CR savings); build dbCr-tagged payload for backend
- Disbursement review: show 'GL Credit (Target)' / 'Debit Entries' labels for collection; fix double-count by summing only DR entries for collection totals; dynamic approve button text
- My disbursement requests: add DR/CR badge column in expanded credit-entries preview
- My requests / withdrawal-requests: minor consistency updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-16 15:28:27 +05:30
co-authored by Claude Sonnet 4.6
parent efb01d5441
commit 6cccb58c04
11 changed files with 345 additions and 102 deletions
@@ -330,7 +330,7 @@
<span>Approved by: {{ activeWithdrawalRequest.manager_name || '—' }}</span>
</div>
<div class="ws-withdrawal-banner__note">
After transaction succeeds, this request will be automatically marked <strong>DISBURSED</strong>.
After transaction succeeds, this request will be automatically marked <strong>WITHDRAWN</strong>.
</div>
</div>
<button type="button" class="ws-withdrawal-banner__clear" (click)="activeWithdrawalRequest = null" title="Unlink">
@@ -1122,7 +1122,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
row.paymentTypeId = this.findCashPaymentTypeId();
break;
case 'Disbursement':
row.dbCr = 'Dr';
if (!row.dbCr) row.dbCr = 'Dr';
row.paymentTypeId = this.findTransferPaymentTypeId();
break;
}
@@ -1139,90 +1139,106 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
/**
* Clerk double-entry disbursement (maker-checker):
* 1 DR row (GL source) + 1..N CR rows (savings/current destinations).
* Submits a PENDING request — manager must approve before balances are credited.
* Submits a clerk double-entry request (maker-checker, PENDING until manager approves).
* Disbursement: 1 DR [GL] source → N CR savings/current/GL destinations.
* Collection: N DR [Savings/CA] sources → 1 CR [GL] target.
*/
private submitDisbursementDoubleEntry(): void {
const drRow = this.rows.find((r) => r.dbCr === 'Dr');
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
if (!drRow) {
this.showAlert('error', 'DR Row Missing', ['One DR row (GL source) is required.']);
return;
}
if (crRows.length === 0) {
this.showAlert('error', 'CR Row Missing', ['At least one CR row (destination account) is required.']);
return;
}
const isCollectionMode = drRows.every((r) => r.acTyp !== 'GL') && crRows.length === 1 && crRows[0].acTyp === 'GL';
const isDisbursementMode = drRows.length === 1 && drRows[0].acTyp === 'GL';
const drAmount = drRow.amount ?? 0;
if (!drAmount || drAmount <= 0) {
this.showAlert('error', 'Amount Required', ['Please enter the amount in the DR (GL) row.']);
return;
}
// Validate all CR rows have amounts and accounts
for (const cr of crRows) {
if (!cr.acNo?.trim()) {
this.showAlert('error', 'Account Required', ['Please fill account number in all CR rows.']);
// Validate each row has account, amount, and payment type (skip paymentTypeId for GL rows)
for (const r of [
...drRows,
...crRows
]) {
if (!r.acNo?.trim()) {
this.showAlert('error', 'Account Required', ['Please fill account number in all rows.']);
return;
}
if (!cr.amount || cr.amount <= 0) {
this.showAlert('error', 'Amount Required', [`Please enter amount for CR row: ${cr.acNo}`]);
if (!r.amount || r.amount <= 0) {
this.showAlert('error', 'Amount Required', [`Please enter amount for: ${r.acNo}`]);
return;
}
if (!cr.paymentTypeId) {
this.showAlert('error', 'TXN TP Required', [`Please select payment type for CR row: ${cr.acNo}`]);
if (!r.paymentTypeId && r.acTyp !== 'GL') {
this.showAlert('error', 'TXN TP Required', [`Please select payment type for: ${r.acNo}`]);
return;
}
}
// Total CR must equal DR
const totalCr = crRows.reduce((s, r) => s + (r.amount ?? 0), 0);
if (Math.abs(drAmount - totalCr) > 0.005) {
this.showAlert('error', 'Unbalanced Entry', [
`DR ₹${drAmount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} ≠ Total CR ₹${totalCr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.`,
'All CR amounts must sum to the DR amount.'
]);
return;
}
// Each CR row must have its account loaded (savingsAccountId required for approval)
// We store accountId per row using a per-row map: acNo → accountId
// For simplicity, require that the selected row is a CR row with accountDetails loaded
// Build creditEntries from each CR row
const creditEntries = crRows.map((cr) => ({
accountNo: cr.acNo,
accountName: this.rowAccountNames[cr.acNo] ?? cr.acNo,
accountType: cr.acTyp === 'CA' || cr.acTyp === 'CB' ? 'CURRENT' : 'SAVINGS',
savingsAccountId: this.rowAccountIds[cr.acNo] ?? null,
amount: String(cr.amount),
paymentTypeId: cr.paymentTypeId,
description: cr.description ?? null
}));
// Verify all CR accounts have been loaded
const unloaded = crRows.filter((cr) => !this.rowAccountIds[cr.acNo]);
// Verify all accounts have been loaded (accountId required for approval)
const unloaded = [
...drRows,
...crRows
].filter((r) => !this.rowAccountIds[r.acNo]);
if (unloaded.length > 0) {
this.showAlert('error', 'Account Not Loaded', [
`Please load account details for: ${unloaded.map((r) => r.acNo).join(', ')}`,
'Click on each CR row and wait for account details to load.'
'Click on each row and wait for account details to load.'
]);
return;
}
const totalDr = drRows.reduce((s, r) => s + (r.amount ?? 0), 0);
// Build entries and main GL fields based on mode
let glAccountId: number | null = null;
let glAccountNo: string | null = null;
let glAccountName: string | null = null;
let entries: any[];
if (isDisbursementMode) {
const drRow = drRows[0];
glAccountId = this.rowAccountIds[drRow.acNo] ?? null;
glAccountNo = drRow.acNo;
glAccountName = this.rowAccountNames[drRow.acNo] ?? drRow.acNo;
entries = crRows.map((cr) => ({
dbCr: 'Cr',
accountNo: cr.acNo,
accountName: this.rowAccountNames[cr.acNo] ?? cr.acNo,
accountType: cr.acTyp === 'CA' || cr.acTyp === 'CB' ? 'CURRENT' : cr.acTyp === 'GL' ? 'GL' : 'SAVINGS',
savingsAccountId: cr.acTyp !== 'GL' ? (this.rowAccountIds[cr.acNo] ?? null) : null,
glAccountId: cr.acTyp === 'GL' ? (this.rowAccountIds[cr.acNo] ?? null) : null,
amount: String(cr.amount),
paymentTypeId: cr.paymentTypeId,
description: cr.description ?? null
}));
} else {
// Collection: store target GL in main fields; include all rows with dbCr field
const crGl = crRows[0];
glAccountId = this.rowAccountIds[crGl.acNo] ?? null;
glAccountNo = crGl.acNo;
glAccountName = this.rowAccountNames[crGl.acNo] ?? crGl.acNo;
entries = [
...drRows,
...crRows
].map((r) => ({
dbCr: r.dbCr,
accountNo: r.acNo,
accountName: this.rowAccountNames[r.acNo] ?? r.acNo,
accountType: r.acTyp === 'CA' || r.acTyp === 'CB' ? 'CURRENT' : r.acTyp === 'GL' ? 'GL' : 'SAVINGS',
savingsAccountId: r.acTyp !== 'GL' ? (this.rowAccountIds[r.acNo] ?? null) : null,
glAccountId: r.acTyp === 'GL' ? (this.rowAccountIds[r.acNo] ?? null) : null,
amount: String(r.amount),
paymentTypeId: r.paymentTypeId,
description: r.description ?? null
}));
}
const firstRow = drRows[0] ?? crRows[0];
const payload = {
glAccountId: this.rowAccountIds[drRow.acNo] ?? null,
glAccountNo: drRow.acNo,
glAccountName: this.rowAccountNames[drRow.acNo] ?? drRow.acNo,
officeId: drRow.br ?? 1,
glAccountId,
glAccountNo,
glAccountName,
officeId: firstRow?.br ?? 1,
currencyCode: 'INR',
paymentTypeId: drRow.paymentTypeId ?? crRows[0]?.paymentTypeId,
paymentTypeName:
this.paymentTypes.find((p: any) => p.id === (drRow.paymentTypeId ?? crRows[0]?.paymentTypeId))?.name ?? null,
description: drRow.description || crRows[0]?.description || null,
creditEntries: JSON.stringify(creditEntries)
paymentTypeId: firstRow?.paymentTypeId,
paymentTypeName: this.paymentTypes.find((p: any) => p.id === firstRow?.paymentTypeId)?.name ?? null,
description: firstRow?.description || null,
creditEntries: JSON.stringify(entries)
};
this.saving = true;
@@ -1233,7 +1249,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.message ??
'Disbursement request submission failed. Please try again.';
'Request submission failed. Please try again.';
this.showAlert('error', 'Submission Failed', [msg]);
this.saving = false;
return of(null);
@@ -1242,10 +1258,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
.subscribe((res: any) => {
this.saving = false;
if (res) {
this.showAlert('success', 'Disbursement Request Submitted', [
`Voucher No: ${res.voucherNo} · Total: ₹${drAmount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`DR: GL — ${drRow.acNo} → ${crRows.length} CR account(s)`,
'Pending manager approval. Balance will be credited after approval.'
const modeLabel = isCollectionMode ? 'Collection' : 'Disbursement';
const summaryLine = isCollectionMode
? `DR: ${drRows.length} account(s) → CR: GL ${crRows[0].acNo}`
: `DR: GL ${drRows[0].acNo} → CR: ${crRows.length} account(s)`;
this.showAlert('success', `${modeLabel} Request Submitted`, [
`Voucher No: ${res.voucherNo} · Total: ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
summaryLine,
'Pending manager approval.'
]);
this.rows = [];
this.rowAccountIds = {};
@@ -2197,6 +2217,73 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
onSave(): void {
// Clerk multi-row disbursement — handle before single-row guard (selected row may be incomplete)
if (this.isClerk && this.rows.length >= 2 && this.rows.every((r) => r.typ === 'Disbursement')) {
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
if (drRows.length === 0) {
this.showAlert('error', 'DR Row Missing', [
'One DR row (source GL account) is required.',
'Select DR for the source account row.'
]);
return;
}
if (crRows.length === 0) {
this.showAlert('error', 'CR Row Missing', ['At least one CR row (destination account) is required.']);
return;
}
// Detect mode
// Disbursement: exactly 1 DR [GL] + N CR accounts
// Collection: N DR [Savings/CA] + exactly 1 CR [GL]
const isCollectionMode = drRows.every((r) => r.acTyp !== 'GL') && crRows.length === 1 && crRows[0].acTyp === 'GL';
const isDisbursementMode = drRows.length === 1 && drRows[0].acTyp === 'GL';
if (!isCollectionMode && !isDisbursementMode) {
this.showAlert('error', 'Invalid Entry Structure', [
'Disbursement: 1 DR [GL] source + N CR accounts.',
'Collection: N DR [Savings/CA] + 1 CR [GL] target.',
`Found: ${drRows.length} DR row(s) — verify account types.`
]);
return;
}
const totalDr = drRows.reduce((s, r) => s + (r.amount ?? 0), 0);
const totalCr = crRows.reduce((s, r) => s + (r.amount ?? 0), 0);
if (Math.abs(totalDr - totalCr) > 0.005) {
this.showAlert('error', 'Unbalanced Entry', [
`DR ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })} ≠ Total CR ₹${totalCr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'All CR amounts must sum to total DR amount.'
]);
return;
}
const title = isCollectionMode ? 'Confirm Collection Request' : 'Confirm Disbursement Request';
const confirmLines = isCollectionMode ? [
`DR (${drRows.length} account${drRows.length !== 1 ? 's' : ''} to collect):`,
...drRows.map(
(r) =>
` [${r.acTyp}] ${r.acNo || '—'} ₹${(r.amount ?? 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`
),
`CR [${crRows[0].acTyp}] ${crRows[0].acNo || '—'} — ₹${totalCr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'Request will be PENDING until manager approves.'
] : [
`DR [${drRows[0].acTyp}] ${drRows[0].acNo || '—'} — ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`CR (${crRows.length} account${crRows.length !== 1 ? 's' : ''}):`,
...crRows.map(
(r) =>
` [${r.acTyp}] ${r.acNo} ₹${(r.amount ?? 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`
),
'Request will be PENDING until manager approves.'
];
this.showConfirmAlert('info', title, confirmLines, 'Submit for Approval', () =>
this.submitDisbursementDoubleEntry()
);
return;
}
const row = this.selectedRow;
if (!row || !row.acNo.trim() || !this.accountDetails) return;
@@ -2218,12 +2305,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const isDeposit = row.dbCr === 'Cr';
// Clerk multi-row disbursement (GL DR + Savings/Current CR) — atomic double-entry
if (this.isClerk && this.rows.length >= 2 && this.rows.every((r) => r.typ === 'Disbursement')) {
this.submitDisbursementDoubleEntry();
return;
}
// Clerk cash deposit is not permitted — cash handling (notes/coins) is cashier's responsibility
if (this.isClerk && isDeposit && isCash) {
this.showAlert('error', 'Cash Deposit Not Permitted', [