feat(cashier+checker): add cashier deposit maker-checker flow and voucher system

- Add cashier deposit service and submit flow via maker-checker
- New CashierDepositService: submit, approve, reject, list endpoints
- Transaction-entry: cashier SD deposit goes to deposit-requests instead of direct credit
- Checker inbox: new Deposit Requests tab with approve/reject review dialog
- Cashier nav: My Deposit Requests tracking page
- Withdrawal requests: show voucher_no from backend instead of JS-padded id
- clerk-withdrawal.service: disburse denominationData accepts null for non-cash
- Role-based checker enforcement: only manager/head-cashier can approve/reject
- Maker cannot approve own request (self-approval blocked at backend)
- Denomination total validation: breakdown must match transaction amount

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-12 10:52:35 +05:30
co-authored by Claude Sonnet 4.6
parent 61cb09f352
commit e5d6cd32dc
18 changed files with 2360 additions and 18 deletions
@@ -1415,9 +1415,9 @@
class="te-confirm-header"
[class.te-confirm-header--deposit]="confirmData.type === 'DEPOSIT'"
[class.te-confirm-header--withdrawal]="confirmData.type === 'WITHDRAWAL'"
[class.te-confirm-header--disbursement]="confirmData.isDisbursement"
[class.te-confirm-header--disbursement]="confirmData.isDisbursement || confirmData.isDepositRequest"
>
<span class="te-confirm-icon">{{ confirmData.type === 'DEPOSIT' ? '⬇' : '⬆' }}</span>
<span class="te-confirm-icon">{{ confirmData.type === 'WITHDRAWAL' ? '⬆' : '⬇' }}</span>
<span>Confirm {{ confirmData.isDisbursement ? 'CASH DISBURSEMENT' : confirmData.type }}</span>
</div>
<div class="te-confirm-body">
@@ -1465,6 +1465,25 @@
>Balance already deducted at manager approval — no further deduction</span
>
</div>
} @else if (confirmData.isDepositRequest) {
<!-- Maker-checker deposit: balance credits only after manager approval -->
<div class="te-confirm-row">
<span class="te-confirm-label">Current Balance</span>
<span class="te-confirm-val"
>{{ confirmData.symbol }}{{ confirmData.currentBalance | number: '1.2-2' }}</span
>
</div>
<div class="te-confirm-row te-confirm-row--amount">
<span class="te-confirm-label">Deposit Amount</span>
<span class="te-confirm-val te-cr"
>+{{ confirmData.symbol }}{{ confirmData.amount | number: '1.2-2' }}</span
>
</div>
<div class="te-confirm-row te-confirm-disbursed-note">
<span class="te-confirm-disbursed-badge"
>Sent for manager approval — balance will be credited only after approval</span
>
</div>
} @else {
<div class="te-confirm-row">
<span class="te-confirm-label">Current Balance</span
@@ -1499,7 +1518,13 @@
<button class="te-btn" type="button" (click)="showConfirmDialog = false">Cancel</button>
<button class="te-btn te-btn--primary" type="button" [disabled]="saving" (click)="confirmSubmit()">
{{
saving ? 'Posting…' : confirmData.isDisbursement ? 'Confirm DISBURSEMENT' : 'Confirm ' + confirmData.type
saving
? 'Posting…'
: confirmData.isDisbursement
? 'Confirm DISBURSEMENT'
: confirmData.isDepositRequest
? 'Submit for Approval'
: 'Confirm ' + confirmData.type
}}
</button>
</div>
@@ -19,6 +19,7 @@ import { CashierService } from '../cashier.service';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
import { CashierDepositService } from '../cashier-deposit.service';
import { SettingsService } from '../../settings/settings.service';
interface TxnRow {
@@ -653,6 +654,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private dialog = inject(MatDialog);
private clerkUiState = inject(ClerkUiStateService);
private clerkWithdrawalService = inject(ClerkWithdrawalService);
private cashierDepositService = inject(CashierDepositService);
private router = inject(Router);
private settingsService = inject(SettingsService);
@@ -2277,15 +2279,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private openConfirmDialog(row: TxnRow, amount: number, isDeposit: boolean, selectedPt: any): void {
// Token-based cashier disbursement: balance was already deducted at manager approval
const isDisbursement = !isDeposit && !this.isClerk && !!this.activeWithdrawalRequest;
const balanceAfterTxn = isDisbursement
? this.currentBalance // no further deduction — just handing cash
: isDeposit
? this.currentBalance + amount
: this.currentBalance - amount;
// Cashier savings deposit goes via maker-checker: balance credits only after manager approval
const isDepositRequest = isDeposit && !this.isClerk && row.acTyp === 'SD';
const balanceAfterTxn =
isDisbursement || isDepositRequest
? this.currentBalance // no immediate balance change
: isDeposit
? this.currentBalance + amount
: this.currentBalance - amount;
this.confirmData = {
type: isDisbursement ? 'DISBURSEMENT' : isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
type: isDisbursement
? 'DISBURSEMENT'
: isDepositRequest
? 'DEPOSIT REQUEST'
: isDeposit
? 'DEPOSIT'
: 'WITHDRAWAL',
isDisbursement,
isDepositRequest,
accountNo: this.accountDetails.glCode ?? this.accountDetails.accountNo,
clientName: this.clientName,
amount,
@@ -2320,6 +2332,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Cashier savings deposit → route through maker-checker (create PENDING request, no direct credit)
// Use the dialog-confirmed amount — for cash that is the denomination total
if (!this.isClerk && !this.reviewMode && isDeposit && row.acTyp === 'SD') {
this.submitCashierDepositRequest(row, this.confirmData?.amount ?? amount);
return;
}
// Hard block: cashier direct withdrawal never reaches here — caught in onSave()
// Extra safety net in case confirmSubmit() is ever called directly
if (!this.isClerk && !this.reviewMode && !isDeposit && !this.activeWithdrawalRequest) {
@@ -2334,7 +2353,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Do NOT post another savings transaction — just record disbursement
if (!this.isClerk && this.activeWithdrawalRequest && !isDeposit) {
this.saving = true;
const denomJson = JSON.stringify(this.denominationCounts);
// Send denominations only for cash payments — stale counts on a cheque
// disbursement would fail the backend denomination-total check
const selectedPtDisb = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const denomJson =
selectedPtDisb?.isCashPayment && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null;
const req = this.activeWithdrawalRequest;
this.clerkWithdrawalService
.disburse(req.id, denomJson, amount)
@@ -2352,9 +2375,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
.subscribe((res: any) => {
this.saving = false;
if (res) {
const disbVoucher = res.voucherNo || req.voucher_no || '—';
this.showAlert('success', 'Cash Disbursed Successfully', [
`₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} disbursed to customer.`,
`Token No: ${req.tok_no} · Client: ${req.client_name}`
`Token No: ${req.tok_no} · Client: ${req.client_name}`,
`Voucher No: ${disbVoucher}`
]);
this.activeWithdrawalRequest = null;
this.clearDenominations();
@@ -2646,18 +2671,78 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
.subscribe((res: any) => {
this.saving = false;
if (res) {
const ref = res.resourceId ?? res.requestId ?? res.id ?? 'N/A';
const tok = row.tokNo?.trim() ?? '—';
const voucherNo = res.voucherNo ?? '—';
this.showAlert('success', 'Withdrawal Request Submitted', [
`Withdrawal of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} sent for manager approval.`,
'Balance will be deducted only after approval.',
`Token No: ${tok} · Request No: ${ref}`
`Token No: ${tok} · Voucher No: ${voucherNo}`
]);
this.onCancel();
}
});
}
/** Build the deposit-request payload from the current row + loaded account. */
private buildDepositPayload(row: TxnRow, amount: number): any {
const today = new Date();
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
// Send denominations only for cash payments — stale counts on a cheque
// deposit would fail the backend denomination-total check
const isCash = !!selectedPt?.isCashPayment;
return {
accountNo: this.accountDetails.accountNo,
accountType: 'SAVINGS',
savingsAccountId: this.accountDetails.id,
clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null,
clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null,
amount,
txnDate: txnDateIso,
paymentTypeId: row.paymentTypeId || null,
paymentTypeName: selectedPt?.name ?? 'Cash',
description: row.description || null,
chequeNo: row.chequeNo || null,
bankName: row.bankName || null,
denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null
};
}
/** Cashier maker-checker: create a PENDING deposit request instead of crediting balance directly. */
private submitCashierDepositRequest(row: TxnRow, amount: number): void {
const payload = this.buildDepositPayload(row, amount);
this.saving = true;
this.cashierDepositService
.submit(payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
err?.error?.defaultUserMessage ??
'Failed to submit deposit request. Please try again.';
this.showAlert('error', 'Deposit Request Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
const voucherNo = res.voucherNo ?? '—';
this.showAlert('success', 'Deposit Request Submitted', [
`Deposit of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} sent for manager approval.`,
'Balance will be credited only after approval.',
`Voucher No: ${voucherNo}`
]);
this.clearDenominations();
this.onCancel();
}
});
}
onCancel(): void {
this.clerkUiState.clear();
this.accountDetails = null;