feat(cashier,savings,tasks): clerk-manager-cashier account closure flow
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

Replaces the one-click Close action on the savings account page (removed from
the Danger Zone menu) with a maker-checker flow, since closing an account pays
real cash out of a teller's drawer.

Clerk: an Action button on Transaction Entry, shown once an ACTIVE savings
account is loaded. Ticking "Close this account" previews the payout -- balance
plus interest calculated to today by the same code the closure itself runs --
and Save drops that figure into the slip's AMOUNT. The clerk then fills
TOK. NO. on the slip and presses Save, which raises the request. Token and
narration deliberately live on the slip, not in the popup, so the closure is
entered like any other transaction.

Manager: a Closure Requests tab in the Approval Center, reviewed in a popup
dialog (matching Disbursement Requests) rather than a separate route.

Cashier: the closure token is recognised at the counter alongside withdrawal
and dividend tokens, auto-filling the rounded cash figure, locking it, and
closing the account once the denomination is counted.

Save-path fixes, all of which silently blocked a valid closure:
 - amount came from the denomination total whenever clerkMode was false --
   which it is for any route other than the Clerk Dashboard wrapper -- so the
   calculated payout was replaced by zero
 - the balance check rejected the payout because it legitimately exceeds
   today's withdrawable amount: it includes interest close() posts before
   withdrawing. Min-balance, withdrawal-facility and clerk-side denomination
   checks do not apply to a closure either
 - on the cashier side the account is already CLOSED, so accountDetails is a
   stub with no balance and the same check reported "Withdrawable 0.00"
 - the closure branch had to precede the plain clerk-withdrawal branch: a
   closure is a full-balance withdrawal, so that branch would have swallowed
   it and the account would never have closed
 - onSave's one silent return now says what is missing, instead of leaving
   Save looking like a broken button

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 15:45:26 +05:30
co-authored by Claude Opus 5
parent 9739ff0758
commit ee47a1ce68
18 changed files with 2304 additions and 36 deletions
@@ -20,6 +20,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 { ClerkClosureRequestService } from '../clerk-closure-request.service';
import { DividendClaimRequestService } from '../../clerk/dividend-claim-request.service';
import { ClerkDisbursementService } from '../clerk-disbursement.service';
import { ClerkShareWithdrawalService } from '../clerk-share-withdrawal.service';
@@ -1455,14 +1456,39 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Active approved withdrawal (cashier disburse flow)
activeWithdrawalRequest: any = null;
activeDividendClaimRequest: any = null;
activeClosureRequest: any = null;
loadingWithdrawal = false;
// ── Account Actions (clerk) — "Close this account" ──────────────────────────
/** Account Actions popup, opened from the footer once an account is loaded. */
showAccountActions = false;
/** The one action offered today: close the account (Cash payout, maker-checker). */
closeAccountChecked = false;
/** Interest accrued to today, previewed the moment the checkbox is ticked so the clerk can see
* what the member will actually receive before submitting. Null until calculated.
* `upToDate` is false when the backend could not refresh the accrual (e.g. this user lacks the
* CALCULATEINTEREST permission) and the figure shown is the last stored one instead. */
closurePreview: {
balance: number;
interest: number;
total: number;
upToDate: boolean;
asOfDate: string | null;
} | null = null;
closurePreviewLoading = false;
closurePreviewError = '';
/** Closure applied to the slip but not yet submitted: the AMOUNT is filled and Save will raise a
* closure request instead of an ordinary withdrawal request. TOK. NO. and DESCRIPTION are taken
* from the slip's own fields, not from the popup. */
pendingClosure = 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 closureRequestService = inject(ClerkClosureRequestService);
private dividendClaimRequestService = inject(DividendClaimRequestService);
private clerkDisbursementService = inject(ClerkDisbursementService);
private clerkShareWithdrawalService = inject(ClerkShareWithdrawalService);
@@ -2535,16 +2561,32 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.loadingWithdrawal = true;
this.activeWithdrawalRequest = null;
this.activeDividendClaimRequest = null;
this.activeClosureRequest = null;
this.clerkWithdrawalService
.getByToken(token)
.pipe(
catchError((err: any) => {
// Not a withdrawal-request token at all (as opposed to PENDING/WITHDRAWN/REJECTED,
// which are legitimate matches with their own specific error) — try the dividend
// cash-claim token pool before giving up, since both share the same TOK. NO. field.
// cash-claim pool, then the account-closure pool, before giving up: all three share
// the same TOK. NO. field at this counter.
if (err?.status === 404) {
return this.dividendClaimRequestService.getByToken(token).pipe(
catchError((divErr: any) => {
if (divErr?.status === 404) {
return this.closureRequestService.getByToken(token).pipe(
catchError((closeErr: any) => {
this.loadingWithdrawal = false;
const msg: string =
closeErr?.error?.message ??
closeErr?.error?.errors?.[0]?.defaultUserMessage ??
`Token '${token}' could not be verified. Please try again.`;
this.showAlert('error', `Token '${token}'`, [msg]);
return of(null);
}),
map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null))
);
}
this.loadingWithdrawal = false;
const msg: string =
divErr?.error?.message ??
@@ -2553,7 +2595,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.showAlert('error', `Token '${token}'`, [msg]);
return of(null);
}),
map((divReq: any) => (divReq ? { __dividendClaim: true, ...divReq } : null))
// Only tag a result that actually came from the dividend pool — a closure hit
// recovered by the inner fallback above is already tagged and must stay that way.
map((divReq: any) => (divReq && !divReq.__closureRequest ? { __dividendClaim: true, ...divReq } : divReq))
);
}
this.loadingWithdrawal = false;
@@ -2586,6 +2630,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
if (req.__closureRequest) {
this.activeClosureRequest = req;
// The account is already CLOSED at Fineract level by the time a cashier sees this token
// (the close command ran at manager approval), so its own detail response would show a
// closed account with a zero balance. What the cashier needs here is the rounded cash
// figure to count out, which the request itself carries.
this.accountDetails = {
id: null,
accountNo: req.account_no,
clientName: req.client_name
};
this.kycDetails = null;
this.clientDetails = null;
this.accountError = '';
row.acNo = req.account_no ?? '';
row.acTyp = 'SD';
row.amount = Number(req.cashier_disbursed_amount) || null;
row.dbCr = 'Dr';
// A closure request stores payment_type_id as NULL, meaning "Cash (Default)" — resolve the
// real cash payment type here so the cashier isn't asked to pick one and the
// "Payment Type Required" guard on the disbursement path is satisfied.
row.paymentTypeId = req.payment_type_id ?? this.findCashPaymentTypeId();
row.description = 'Account closure — ' + (req.client_name ?? req.account_no);
this.selectedRow = row;
return;
}
this.activeWithdrawalRequest = req;
row.acNo = req.account_no ?? '';
row.acTyp = 'SD';
@@ -3626,6 +3697,173 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
});
}
// ── Account Actions (clerk): close this account ──────────────────────────────
/** The Action button only makes sense once a real, ACTIVE savings account is on screen — a
* closure request needs an actual account id to submit against, and only a clerk is the maker
* in this flow (the cashier's side is the token disbursement, not the request). */
get canShowAccountActions(): boolean {
return (
this.isClerk &&
!this.reviewMode &&
!this.editMode &&
this.selectedRow?.acTyp === 'SD' &&
!!this.accountDetails?.id &&
this.accountDetails?.status?.value === 'Active'
);
}
openAccountActions(): void {
this.closeAccountChecked = this.pendingClosure;
this.closurePreviewError = '';
if (!this.pendingClosure) {
this.closurePreview = null;
}
this.showAccountActions = true;
}
closeAccountActions(): void {
this.showAccountActions = false;
}
/** Ticking "Close this account" immediately previews what the member would receive — the balance
* plus interest accrued to date — so the clerk sees the real figure before submitting. Nothing
* is posted; the binding amount is computed by Fineract's own close command at approval time. */
onCloseAccountToggle(): void {
if (!this.closeAccountChecked) {
this.closurePreview = null;
this.closurePreviewError = '';
return;
}
const accountId = this.accountDetails?.id;
if (!accountId) return;
this.closurePreviewLoading = true;
this.closurePreviewError = '';
this.closureRequestService
.preview(accountId)
.pipe(
catchError((err: any) => {
this.closurePreviewError =
err?.error?.message ??
err?.error?.errors?.[0]?.defaultUserMessage ??
'Could not calculate the closing amount. Please try again.';
return of(null);
})
)
.subscribe((res: any) => {
this.closurePreviewLoading = false;
if (!res) return;
this.closurePreview = {
balance: Number(res.balance ?? 0),
interest: Number(res.accruedInterest ?? 0),
total: Number(res.total ?? 0),
upToDate: res.interestUpToDate !== false,
asOfDate: res.asOfDate ?? null
};
});
}
/**
* Applies the closure to the slip on screen — it does NOT submit anything. The calculated payout
* drops into the row's AMOUNT, the row is marked as a closure, and the clerk then fills TOK. NO.
* and DESCRIPTION on the slip itself and presses Save like any other entry. Save is what actually
* raises the request (see submitClosureRequest()).
*/
applyCloseAccount(): void {
if (!this.closeAccountChecked) {
// Unticking is how the clerk backs out of a closure they had applied.
this.clearPendingClosure();
this.showAccountActions = false;
return;
}
if (!this.closurePreview) return;
// Fall back to the first row: the popup is opened from the footer, so a stray click can leave
// selectedRow unset even though a row is plainly on screen — and Save reads selectedRow too.
const row = this.selectedRow ?? this.rows[0] ?? null;
if (!row) {
this.showAlert('error', 'No Transaction Row', ['Add a transaction row before closing the account.']);
return;
}
this.selectedRow = row;
row.amount = this.closurePreview.total;
row.dbCr = 'Dr';
if (!row.description) {
row.description = 'Account closure — full balance withdrawal';
}
this.pendingClosure = true;
this.showAccountActions = false;
}
/** Drops closure mode and the amount it filled in, so the slip goes back to an ordinary entry. */
clearPendingClosure(): void {
this.pendingClosure = false;
this.closeAccountChecked = false;
this.closurePreview = null;
this.closurePreviewError = '';
const row = this.selectedRow;
if (row && row.description === 'Account closure — full balance withdrawal') {
row.description = '';
}
if (row) {
row.amount = null;
}
}
/**
* Raises the closure request from the slip's own fields. Reached from confirmSubmit() when the
* clerk presses Save with closure mode applied, instead of the ordinary withdrawal-request path.
*/
private submitClosureRequest(row: TxnRow): void {
const accountId = this.accountDetails?.id;
if (!accountId) return;
this.saving = true;
this.closureRequestService
.submit({
savingsAccountId: accountId,
paymentTypeId: null as number | null,
paymentTypeName: 'Cash',
description: row.description || null,
tokNo: row.tokNo?.trim() || null
})
.pipe(
catchError((err: any) => {
const msg =
err?.error?.message ??
err?.error?.errors?.[0]?.defaultUserMessage ??
'Could not submit the closure request. Please try again.';
this.showAlert('error', 'Closure Request Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (!res) return;
const preview = this.closurePreview;
this.showAlert(
'success',
'Closure Request Submitted',
[
`Account ${this.accountDetails?.accountNo} has been sent to the Bank Manager for closure approval.`,
preview
? `Payout: ₹${preview.total.toLocaleString('en-IN', { minimumFractionDigits: 2 })} (balance ₹${preview.balance.toLocaleString('en-IN', { minimumFractionDigits: 2 })} + interest ₹${preview.interest.toLocaleString('en-IN', { minimumFractionDigits: 2 })}).`
: '',
`Token No: ${row.tokNo?.trim()} — the cashier will hand over the cash against this token once approved.`,
'Cash is handed over rounded to whole rupees; the paise difference goes to the Round Off Account.'
].filter(Boolean)
);
this.pendingClosure = false;
this.closeAccountChecked = false;
this.closurePreview = null;
this.onCancel();
});
}
// ── Same-day compliance indicators (copied from SavingsAccountTransactionsComponent) ──
private computeSameDayNeftRtgs(transactions: any[] | undefined): void {
@@ -3873,20 +4111,37 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
const row = this.selectedRow;
if (!row || !row.acNo.trim() || !this.accountDetails) return;
// Never fail silently here: a Save that does literally nothing is indistinguishable from a
// broken button, and this guard is the one path in onSave() that used to return without saying
// anything.
if (!row || !row.acNo.trim() || !this.accountDetails) {
this.showAlert('error', 'Nothing to Save', [
!row
? 'No transaction row is selected. Click into a row first.'
: !row.acNo.trim()
? 'Enter an account number on the row.'
: 'Account details are not loaded yet. Re-enter the account number and wait for it to load.'
]);
return;
}
const selectedPtEarly = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const isCash = !!selectedPtEarly?.isCashPayment;
const isDdCashGl = this.isDdCashGlRow(row);
// Clerk mode: always use manually entered amount; Cashier mode: cash amount comes from denomination
// DD + Cash + GL: amount is DD face value + charges + GST (rounded up), not the counted denomination
// A closure's amount is the calculated payout on the row (balance + interest to date), never the
// counted denomination: the clerk raising the request counts no cash — the cashier does that
// later, at hand-over. Keyed off pendingClosure rather than clerkMode because clerkMode is only
// set by the Clerk Dashboard wrapper, so a clerk reaching this screen by any other route would
// otherwise have the payout silently replaced by a zero denomination total.
const amount = isDdCashGl
? this.ddTotalPayableFor(row)
: isCash && !this.clerkMode
: isCash && !this.clerkMode && !this.pendingClosure
? this.denominationTotal
: (row.amount ?? this.denominationTotal);
if (isCash && this.denominationTotal <= 0 && !this.clerkMode && !isDdCashGl) {
if (isCash && this.denominationTotal <= 0 && !this.clerkMode && !isDdCashGl && !this.pendingClosure) {
this.showAlert('error', 'Denomination Required', [
'Cash transaction: Please enter denomination (notes/coins count) first.'
]);
@@ -3953,7 +4208,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
!this.reviewMode &&
!isDeposit &&
!this.activeWithdrawalRequest &&
!this.activeDividendClaimRequest
!this.activeDividendClaimRequest &&
!this.activeClosureRequest
) {
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
'Withdrawals cannot be processed directly by the cashier.',
@@ -3963,9 +4219,17 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Approved withdrawal / dividend cash claim: amount/denomination must exactly match
if (!this.isClerk && (this.activeWithdrawalRequest || this.activeDividendClaimRequest) && !isDeposit) {
const rawApprovedAmt = Number(this.activeWithdrawalRequest?.amount ?? this.activeDividendClaimRequest?.amount);
// Approved withdrawal / dividend cash claim / account closure: amount/denomination must match
if (
!this.isClerk &&
(this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) &&
!isDeposit
) {
const rawApprovedAmt = Number(
this.activeWithdrawalRequest?.amount ??
this.activeDividendClaimRequest?.amount ??
this.activeClosureRequest?.cashier_disbursed_amount
);
// A dividend cash claim's liability is stored with paise precision (a proportional profit
// share), but cash can only be handed over in whole-rupee notes/coins -- compare against
// the floored amount so an exact denomination match is actually achievable. Ordinary
@@ -4068,7 +4332,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Main Vault) already posted at claim-approval time — there is no real savings/current
// account behind this row, so skip balance/min-balance/KYC/dormant/facility checks entirely
// (none apply to a GL-only liability payout) and go straight to the confirm dialog.
if (this.activeDividendClaimRequest) {
// An approved account closure is the same situation: close() already posted the interest and the
// real withdrawal at manager approval, and the account is CLOSED by the time a cashier scans the
// token — so accountDetails here is a stub with no balance and the balance/min-balance/facility
// checks below would reject the hand-over with "Withdrawable Amount: ₹0.00". Only the cash-drawer
// checks still matter, and they are the ones kept.
if (this.activeDividendClaimRequest || this.activeClosureRequest) {
if (!row.paymentTypeId) {
this.showAlert('error', 'Payment Type Required', ['Please select Payment Type (Cash / Cheque / NEFT etc.)']);
return;
@@ -4088,6 +4357,20 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Clerk raising a closure request: nothing moves yet, so the ordinary withdrawal gauntlet below
// does not apply and actively rejects a valid closure —
// · Balance: the payout is balance + interest accrued but NOT yet posted, so it is legitimately
// MORE than today's withdrawable amount. close() posts that interest before withdrawing.
// · Min balance: meaningless for an account that is being closed to zero.
// · Withdrawal facility: a member closing their account is not making an ordinary withdrawal.
// · Denomination: counted by the cashier at hand-over, not by the clerk here.
// Everything that genuinely matters (token number, account loaded, ACTIVE status) is already
// checked in onSave() before this point, and the backend re-validates on submit.
if (this.pendingClosure && this.isClerk && isWithdrawal) {
this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly);
return;
}
// GL transactions post as journal entries — skip account-level facility/balance checks
if (isGlType) {
if (!this.cashVaultGlId) {
@@ -4153,7 +4436,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// and posted the real withdrawal (money already left the account). Re-running the balance/
// min-balance checks here would compare the post-deduction balance against the same
// already-approved amount again, which fails for almost any real withdrawal.
const isTokenDisbursement = !this.isClerk && !!this.activeWithdrawalRequest;
const isTokenDisbursement = !this.isClerk && (!!this.activeWithdrawalRequest || !!this.activeClosureRequest);
if (!isTokenDisbursement) {
// 3. Balance check
@@ -4209,7 +4492,9 @@ 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 || !!this.activeDividendClaimRequest);
!isDeposit &&
!this.isClerk &&
(!!this.activeWithdrawalRequest || !!this.activeDividendClaimRequest || !!this.activeClosureRequest);
// Cashier deposit (savings/current/GL) goes via maker-checker: balance credits only after manager approval
const isDepositRequest =
isDeposit &&
@@ -4229,7 +4514,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
? 'DEPOSIT REQUEST'
: isDeposit
? 'DEPOSIT'
: 'WITHDRAWAL',
: this.pendingClosure && this.isClerk
? 'ACCOUNT CLOSURE'
: 'WITHDRAWAL',
isDisbursement,
isDepositRequest,
accountNo: this.accountDetails.glCode ?? this.accountDetails.accountNo,
@@ -4240,7 +4527,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
paymentType: selectedPt?.name ?? 'Cash',
chequeNo: row.chequeNo || null,
symbol: this.currencySymbol,
tokNo: this.activeWithdrawalRequest?.tok_no ?? this.activeDividendClaimRequest?.tok_no ?? null,
tokNo:
this.activeWithdrawalRequest?.tok_no ??
this.activeDividendClaimRequest?.tok_no ??
this.activeClosureRequest?.tok_no ??
null,
isDdCashGl: this.isDdCashGlRow(row),
ddFaceValue: row.amount ?? 0,
ddCharges: row.ddCharges ?? 0,
@@ -4284,6 +4575,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Clerk applied "Close this account" from Action → raise a closure request rather than an
// ordinary withdrawal request. Must come first: a closure IS a full-balance withdrawal, so the
// plain clerk-withdrawal branch below would otherwise swallow it and the account would never
// actually close.
if (this.pendingClosure && this.isClerk && !isDeposit) {
this.submitClosureRequest(row);
return;
}
// Clerk mode + withdrawal → route through maker-checker (create PENDING request, no direct deduction)
if (this.clerkMode && !isDeposit) {
this.submitClerkWithdrawalRequest(row, amount);
@@ -4339,6 +4639,46 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Approved account closure token → the close command (interest, TDS and the real GL
// withdrawal) already ran at manager approval. Nothing new is posted here either — this only
// records the physical hand-over of the rounded cash figure, mirroring the two branches above.
if (!this.isClerk && this.activeClosureRequest && !isDeposit) {
this.saving = true;
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.activeClosureRequest;
this.closureRequestService
.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) {
const closureVoucher = res.voucherNo || req.voucher_no || '—';
this.showAlert('success', 'Account Closed & Cash Disbursed', [
`₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} handed over to the customer.`,
`Account ${req.account_no} is now CLOSED.`,
`Token No: ${req.tok_no} · Client: ${req.client_name}`,
`Voucher No: ${closureVoucher}`
]);
this.activeClosureRequest = null;
this.clearDenominations();
this.loadOpeningPosition();
}
});
return;
}
// Hard block: cashier direct withdrawal never reaches here — caught in onSave()
// Extra safety net in case confirmSubmit() is ever called directly
if (
@@ -4346,7 +4686,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
!this.reviewMode &&
!isDeposit &&
!this.activeWithdrawalRequest &&
!this.activeDividendClaimRequest
!this.activeDividendClaimRequest &&
!this.activeClosureRequest
) {
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
'Withdrawals must go through the maker-checker approval process.',