feat(cashier): complete maker-checker withdrawal disbursement flow

- Clerk manually enters token no with today-date unique validation (backend enforced)
- Cashier token lookup: auto-fill amount/account, lock amount field, status-specific errors
- Confirm dialog shows CASH DISBURSEMENT (blue) with no balance re-deduction warning
- Disbursement denomination saved to m_savings_txn_denomination for vault position update
- Block direct cashier withdrawal without approved maker-checker token
- DISBURSED status shown in My Requests and Checker inbox views with cashier/time info
- Remove getNextToken() service method and m_daily_token_counter table (0326 migration)
- Race condition protection: UPDATE WHERE status=APPROVED prevents double disbursement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-11 15:57:33 +05:30
co-authored by Claude Sonnet 4.6
parent de420bb8ab
commit 61cb09f352
26 changed files with 4822 additions and 248 deletions
@@ -6,16 +6,20 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { Component, OnInit, OnDestroy, inject, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { catchError, debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { forkJoin, of, Subject } from 'rxjs';
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { Router } from '@angular/router';
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 { SettingsService } from '../../settings/settings.service';
interface TxnRow {
typ: string;
@@ -620,15 +624,37 @@ export class ImagePreviewDialogComponent {
imports: [
CommonModule,
FormsModule,
MatDialogModule
MatDialogModule,
MatIconModule
]
})
export class TransactionEntryComponent implements OnInit, OnDestroy {
@Input() clerkMode = false;
/** When set, the component runs in manager-review mode for that withdrawal request id. */
@Input() reviewRequestId: number | null = null;
/** When set, the clerk edits & resubmits that (rejected) withdrawal request id. */
@Input() editRequestId: number | null = null;
// Manager review state
reviewRequest: any = null;
showRejectDialog = false;
rejectionReason = '';
// Clerk edit (rejected resubmit) state
editRequest: any = null;
// Active approved withdrawal (cashier disburse flow)
activeWithdrawalRequest: any = null;
loadingWithdrawal = false;
private http = inject(HttpClient);
private cashierService = inject(CashierService);
private authService = inject(AuthenticationService);
private dialog = inject(MatDialog);
private clerkUiState = inject(ClerkUiStateService);
private clerkWithdrawalService = inject(ClerkWithdrawalService);
private router = inject(Router);
private settingsService = inject(SettingsService);
today = '';
rows: TxnRow[] = [];
@@ -659,6 +685,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Search token — cancels stale async results
private searchToken = 0;
private lastLoadedAcNo = '';
private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>();
cashVaultGlId: number | null = null;
@@ -770,6 +797,16 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.loadCashVaultGl();
if (this.reviewMode) {
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
return;
}
if (this.editMode) {
this.loadRequestIntoForm(this.editRequestId!, 'edit');
return;
}
if (this.isClerk) {
const saved = this.clerkUiState.snapshot.savedAccount;
if (saved) {
@@ -799,6 +836,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.persistClerkState();
}
/** Save the full loaded account into shared clerk state so the facility/photo/SI/activity views can read it. */
private persistClerkState(): void {
if (this.isClerk && this.accountDetails) {
this.clerkUiState.saveFullState({
accountDetails: this.accountDetails,
@@ -1014,7 +1056,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
addRow(): void {
this.rows.push({
const row: TxnRow = {
typ: '~',
br: 1,
trTp: '',
@@ -1030,7 +1072,45 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
paymentTypeId: null,
bankName: '',
routingCode: ''
});
};
this.rows.push(row);
// Token is assigned by the backend at actual submission time — not pre-generated here
}
/** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */
onTokNoBlur(row: TxnRow): void {
if (this.isClerk || !row.tokNo?.trim()) return;
const token = row.tokNo.trim();
this.loadingWithdrawal = true;
this.activeWithdrawalRequest = null;
this.clerkWithdrawalService
.getByToken(token)
.pipe(
catchError((err: any) => {
this.loadingWithdrawal = false;
const msg: string =
err?.error?.message ??
err?.error?.errors?.[0]?.defaultUserMessage ??
`Token '${token}' could not be verified. Please try again.`;
this.showAlert('error', `Token '${token}'`, [msg]);
return of(null);
})
)
.subscribe((req: any) => {
this.loadingWithdrawal = false;
if (!req) return;
this.activeWithdrawalRequest = req;
row.acNo = req.account_no ?? '';
row.acTyp = 'SD';
row.amount = Number(req.amount) || null;
row.dbCr = 'Dr';
row.paymentTypeId = req.payment_type_id ?? null;
row.description = req.description ?? '';
this.selectedRow = row;
if (req.account_no) {
this.loadAccountDetails(req.account_no.trim(), 'SD');
}
});
}
onAcNoBlur(row: TxnRow, index: number): void {
@@ -1041,6 +1121,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
onAcNoInput(row: TxnRow, index: number, value: string): void {
row.acNo = value;
this.lastLoadedAcNo = '';
this.acNoInput$.next({ row, index, value });
}
@@ -1062,15 +1143,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
onRowSelect(row: TxnRow, index: number): void {
this.selectedRow = row;
// Only reload if account number changed or nothing is loaded yet
const loadedId = this.accountDetails?.glCode ?? this.accountDetails?.accountNo;
const alreadyLoaded = this.accountDetails && loadedId === row.acNo.trim();
if (row.acNo.trim() && !alreadyLoaded) {
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
const acNo = row.acNo.trim();
const alreadyLoaded = this.accountDetails && this.lastLoadedAcNo === acNo;
if (acNo && !alreadyLoaded) {
this.loadAccountDetails(acNo, row.acTyp);
}
}
private loadAccountDetails(acNo: string, acTyp: string): void {
this.lastLoadedAcNo = acNo;
this.loadingAccount = true;
this.accountDetails = null;
this.clientDetails = null;
@@ -1389,6 +1470,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
if (blob) {
const url = URL.createObjectURL(blob);
this.signatureUrl = url;
// Signature arrives after the main load — refresh shared state so embedded views get it
this.persistClerkState();
}
});
}
@@ -1405,6 +1488,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Today's transactions — savings uses "content", CA uses "pageItems"
this.todayTransactions =
todayTxns?.pageItems ?? todayTxns?.content ?? (Array.isArray(todayTxns) ? todayTxns : []);
// Push full state so embedded facility/photo/SI/activity views (e.g. manager review) can read it
this.persistClerkState();
}
);
});
@@ -1594,7 +1680,26 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
get isClerk(): boolean {
return this.authService.isClerkUser();
// clerkMode forces the clerk layout (used by the clerk dashboard and the manager review screen)
return this.clerkMode || this.authService.isClerkUser();
}
get reviewMode(): boolean {
return this.reviewRequestId != null;
}
get editMode(): boolean {
return this.editRequestId != null;
}
/** Only REJECTED requests are editable; PENDING/APPROVED open read-only. */
get editResubmittable(): boolean {
return this.editMode && this.editRequest?.status === 'REJECTED';
}
/** Form is locked in manager review, or when viewing a non-rejected request in edit mode. */
get formReadOnly(): boolean {
return this.reviewMode || (this.editMode && !this.editResubmittable);
}
get isNonIndividual(): boolean {
@@ -1901,12 +2006,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const row = this.selectedRow;
if (!row || !row.acNo.trim() || !this.accountDetails) return;
// Cash payment → amount MUST come from denomination (not manual entry)
const selectedPtEarly = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const isCash = !!selectedPtEarly?.isCashPayment;
const amount = isCash ? this.denominationTotal : (row.amount ?? this.denominationTotal);
// Clerk mode: always use manually entered amount; Cashier mode: cash amount comes from denomination
const amount = isCash && !this.clerkMode ? this.denominationTotal : (row.amount ?? this.denominationTotal);
if (isCash && this.denominationTotal <= 0) {
if (isCash && this.denominationTotal <= 0 && !this.clerkMode) {
this.showAlert('error', 'Denomination Required', [
'Cash transaction: Please enter denomination (notes/coins count) first.'
]);
@@ -1918,6 +2023,47 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
const isDeposit = row.dbCr === 'Cr';
// Token number is required for all withdrawals (clerk must enter, cashier must enter approved token)
if (!this.reviewMode && !isDeposit && !row.tokNo?.trim()) {
this.showAlert('error', 'Token Number Required', [
this.isClerk
? 'Please enter a Token Number (TOK. NO.) for this withdrawal request.'
: 'Please enter the approved Token Number (TOK. NO.) to process this withdrawal.'
]);
return;
}
// Cashier direct withdrawal BLOCKED — must use approved maker-checker token
if (!this.isClerk && !this.reviewMode && !isDeposit && !this.activeWithdrawalRequest) {
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
'Withdrawals cannot be processed directly by the cashier.',
'All withdrawals must go through the Clerk → Manager approval process.',
'Enter the approved Token Number (TOK. NO.) to disburse cash.'
]);
return;
}
// Approved withdrawal: amount/denomination must exactly match
if (!this.isClerk && this.activeWithdrawalRequest && !isDeposit) {
const approvedAmt = Number(this.activeWithdrawalRequest.amount);
if (Math.abs(amount - approvedAmt) > 0.005) {
if (isCash) {
this.showAlert('error', 'Denomination Mismatch — Approved Withdrawal', [
`Cash denomination total ₹${this.denominationTotal.toLocaleString('en-IN', { minimumFractionDigits: 2 })} does not match the approved withdrawal amount ₹${approvedAmt.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.`,
`Adjust the notes/coins breakdown to total exactly ₹${approvedAmt.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.`
]);
} else {
this.showAlert('error', 'Amount Mismatch — Approved Withdrawal', [
`Amount has been changed from the approved value.`,
`Approved: ₹${approvedAmt.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Entered: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'Reset the amount to the approved value before proceeding.'
]);
}
return;
}
}
const isWithdrawal = !isDeposit;
// ── Validation ─────────────────────────────────────────────────────────
@@ -2129,10 +2275,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
private openConfirmDialog(row: TxnRow, amount: number, isDeposit: boolean, selectedPt: any): void {
// ── Confirmation dialog ────────────────────────────────────────────────
const balanceAfterTxn = isDeposit ? this.currentBalance + amount : this.currentBalance - amount;
// 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;
this.confirmData = {
type: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
type: isDisbursement ? 'DISBURSEMENT' : isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
isDisbursement,
accountNo: this.accountDetails.glCode ?? this.accountDetails.accountNo,
clientName: this.clientName,
amount,
@@ -2140,7 +2293,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
balanceAfter: balanceAfterTxn,
paymentType: selectedPt?.name ?? 'Cash',
chequeNo: row.chequeNo || null,
symbol: this.currencySymbol
symbol: this.currencySymbol,
tokNo: this.activeWithdrawalRequest?.tok_no ?? null
};
this.showConfirmDialog = true;
}
@@ -2160,6 +2314,57 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Clerk mode + withdrawal → route through maker-checker (create PENDING request, no direct deduction)
if (this.clerkMode && !isDeposit) {
this.submitClerkWithdrawalRequest(row, 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) {
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
'Withdrawals must go through the maker-checker approval process.',
'Enter the approved token number to disburse.'
]);
return;
}
// Token-based approved withdrawal → balance already deducted at manager approval
// Do NOT post another savings transaction — just record disbursement
if (!this.isClerk && this.activeWithdrawalRequest && !isDeposit) {
this.saving = true;
const denomJson = JSON.stringify(this.denominationCounts);
const req = this.activeWithdrawalRequest;
this.clerkWithdrawalService
.disburse(req.id, denomJson, amount)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.message ??
err?.error?.errors?.[0]?.defaultUserMessage ??
'Disbursement recording failed. Please try again.';
this.showAlert('error', 'Disbursement Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
this.showAlert('success', 'Cash Disbursed Successfully', [
`₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} disbursed to customer.`,
`Token No: ${req.tok_no} · Client: ${req.client_name}`
]);
this.activeWithdrawalRequest = null;
this.clearDenominations();
this.loadOpeningPosition();
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
});
return;
}
const today = new Date();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
@@ -2224,6 +2429,235 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
});
}
// ── Manager review mode ──────────────────────────────────────────────────
/** Load a withdrawal request and pre-fill the workspace (manager review or clerk edit). */
private loadRequestIntoForm(id: number, mode: 'review' | 'edit'): void {
this.clerkWithdrawalService
.getById(id)
.pipe(catchError(() => of(null)))
.subscribe((req: any) => {
if (!req) {
this.showAlert('error', 'Request Not Found', ['This withdrawal request could not be loaded.']);
return;
}
if (mode === 'review') {
this.reviewRequest = req;
} else {
this.editRequest = req;
}
const acTyp = 'SD';
const row = this.rows[0];
row.acNo = req.account_no ?? '';
row.acTyp = acTyp;
row.paymentTypeId = req.payment_type_id ?? null;
row.amount = Number(req.amount) || 0;
row.description = req.description ?? '';
row.chequeNo = req.cheque_no ?? '';
row.bankName = req.bank_name ?? '';
row.tokNo = req.tok_no ?? '';
row.dbCr = 'Dr';
this.selectedRow = row;
try {
this.denominationCounts = req.denomination_data ? JSON.parse(req.denomination_data) : {};
} catch {
this.denominationCounts = {};
}
if (req.account_no) {
this.loadAccountDetails(req.account_no, acTyp);
}
});
}
/** Manager approves → backend posts the real savings withdrawal and deducts the balance. */
approveReview(): void {
if (!this.reviewRequest || this.saving) return;
this.saving = true;
this.clerkWithdrawalService
.approve(this.reviewRequest.id)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
err?.error?.defaultUserMessage ??
'Approval failed. Please try again.';
this.showAlert('error', 'Approval Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
this.showAlert(
'success',
'Withdrawal Approved',
[
'The withdrawal has been approved and the balance has been deducted.',
res.txnId ? `Transaction Ref: ${res.txnId}` : ''
].filter(Boolean)
);
setTimeout(() => this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']), 1200);
}
});
}
openRejectReview(): void {
this.rejectionReason = '';
this.showRejectDialog = true;
}
cancelRejectReview(): void {
this.showRejectDialog = false;
this.rejectionReason = '';
}
confirmRejectReview(): void {
if (!this.reviewRequest || this.saving) return;
if (!this.rejectionReason.trim()) {
this.showAlert('error', 'Reason Required', ['Please enter a reason for rejecting this request.']);
return;
}
this.saving = true;
this.clerkWithdrawalService
.reject(this.reviewRequest.id, this.rejectionReason.trim())
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
err?.error?.defaultUserMessage ??
'Rejection failed. Please try again.';
this.showAlert('error', 'Rejection Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
this.showRejectDialog = false;
if (res) {
this.showAlert('success', 'Withdrawal Rejected', [
'The request has been sent back to the clerk for correction.'
]);
setTimeout(() => this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']), 1200);
}
});
}
backToInbox(): void {
this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']);
}
/** Build the withdrawal-request payload from the current row + loaded account. */
private buildWithdrawalPayload(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);
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,
tokNo: row.tokNo?.trim() || null,
denominationData: JSON.stringify(this.denominationCounts)
};
}
/** Clerk edits a rejected request → resubmit (status back to PENDING) for manager approval. */
resubmitEdit(): void {
if (!this.editRequest || this.saving) return;
const row = this.selectedRow;
if (!row || !this.accountDetails) {
this.showAlert('error', 'Account Required', ['Please load the account before resubmitting.']);
return;
}
const amount = row.amount ?? 0;
if (!amount || amount <= 0) {
this.showAlert('error', 'Amount Required', ['Please enter a valid withdrawal amount.']);
return;
}
const payload = this.buildWithdrawalPayload(row, amount);
this.saving = true;
this.clerkWithdrawalService
.update(this.editRequest.id, payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
err?.error?.defaultUserMessage ??
'Failed to resubmit request. Please try again.';
this.showAlert('error', 'Resubmit Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
const tok = (this.editRequest as any)?.tok_no ?? row.tokNo?.trim() ?? '—';
this.showAlert('success', 'Withdrawal Resubmitted', [
`Withdrawal of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} resubmitted for manager approval.`,
`Token No: ${tok}`
]);
setTimeout(() => this.router.navigate(['/clerk/withdrawal-requests']), 1200);
}
});
}
cancelEdit(): void {
this.router.navigate(['/clerk/withdrawal-requests']);
}
/** Clerk maker-checker: create a PENDING withdrawal request instead of deducting balance directly. */
private submitClerkWithdrawalRequest(row: TxnRow, amount: number): void {
const payload = this.buildWithdrawalPayload(row, amount);
this.saving = true;
this.clerkWithdrawalService
.submit(payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
err?.error?.defaultUserMessage ??
'Failed to submit withdrawal request. Please try again.';
this.showAlert('error', 'Request Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
const ref = res.resourceId ?? res.requestId ?? res.id ?? 'N/A';
const tok = row.tokNo?.trim() ?? '—';
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}`
]);
this.onCancel();
}
});
}
onCancel(): void {
this.clerkUiState.clear();
this.accountDetails = null;