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
@@ -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;