Compare commits

...
2 Commits
Author SHA1 Message Date
nitesh 6442562df0 feat(tasks): show payout amount on the Closure Review popup while PENDING
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
The manager's Closure Review dialog only showed the amount once the request
reached APPROVED or later -- closing_amount/rounding_amount/cashier_disbursed_amount
were null on a PENDING record, since the backend only filled them in at
approve() time. A manager reviewing a still-pending closure saw client name,
payout mode and token, but no figure to actually approve.

The backend now populates those columns at submission (interest is posted
for real at that point, so the figure is already known -- see the matching
backend commit), so this just stops gating the amount card on
closing_amount != null and moves it to the top of the dialog as "Payout
Amount", shown for every status.

Also added: Closure Date ("Interest Posted As On"), Transaction Id once
close() has run, and a "Handed Over By Cashier" section once disbursed --
so the full lifecycle of a closure is visible from one dialog instead of
needing to cross-reference the account's own transaction history.
2026-09-03 10:47:07 +05:30
nitesh 17942509de fix(cashier): post closure interest for real when the checkbox is ticked
Ticking "Close this account" on the Transaction Entry Action popup called
GET /clerk/closure-requests/preview/{id} -- a dry run that computed the
payout and immediately rolled it back, so the account's own balance stayed
unchanged until a manager later approved the closure.

Wired to the new POST /clerk/closure-requests/post-interest/{id} instead
(see the matching backend commit), which posts the interest for real and
returns the account's actual resulting balance. Renamed
ClerkClosureRequestService#preview to #postClosureInterest to match, and
reworded the popup copy and loading state so it no longer implies posting
happens later at Save ("posted to the account as of ..." instead of
"calculated to ... and posted the moment you press Save").

Also corrected a stale comment on the balance-check bypass for a pending
closure: it used to justify the bypass by saying interest was NOT YET
posted, which stopped being true the moment the checkbox itself started
posting it for real.
2026-09-03 10:46:27 +05:30
5 changed files with 110 additions and 44 deletions
@@ -48,11 +48,11 @@ export class ClerkClosureRequestService {
return this.http.get(`/clerk/closure-requests/${id}`);
}
/** Preview of what the member would receive on closure. The backend dry-runs the very same
* interest posting that closing the account will perform, then rolls it back — so the figure
* matches the real payout to the paisa. Nothing is persisted. */
preview(savingsAccountId: number): Observable<any> {
return this.http.get(`/clerk/closure-requests/preview/${savingsAccountId}`);
/** Posts every rupee of interest the member has earned up to today straight onto the account —
* the same posting close() itself performs — and returns the resulting real balance. Not a dry
* run: ticking "Close this account" is meant to credit the interest immediately, not estimate it. */
postClosureInterest(savingsAccountId: number): Observable<any> {
return this.http.post(`/clerk/closure-requests/post-interest/${savingsAccountId}`, {});
}
approve(id: number): Observable<any> {
@@ -1804,7 +1804,7 @@
@if (closeAccountChecked) {
@if (closurePreviewLoading) {
<div class="te-aa-calc te-aa-calc--loading">Calculating interest to date…</div>
<div class="te-aa-calc te-aa-calc--loading">Posting interest to the account…</div>
} @else if (closurePreviewError) {
<div class="te-aa-calc te-aa-calc--error">{{ closurePreviewError }}</div>
} @else if (closurePreview) {
@@ -1813,10 +1813,20 @@
<span>Current Balance</span>
<span>₹{{ closurePreview.balance | number: '1.2-2' }}</span>
</div>
<div class="te-aa-calc__row">
<span>Interest to date</span>
<span>+ ₹{{ closurePreview.interest | number: '1.2-2' }}</span>
</div>
@if (closurePreview.interest < 0) {
<!-- Negative only when this run corrects interest that was over-posted earlier: the
delta is against the stored balance, so calling it "interest" would read as the
member being charged. The Payable figure below is the authoritative one either way. -->
<div class="te-aa-calc__row">
<span>Interest correction</span>
<span>− ₹{{ -closurePreview.interest | number: '1.2-2' }}</span>
</div>
} @else {
<div class="te-aa-calc__row">
<span>Interest to date</span>
<span>+ ₹{{ closurePreview.interest | number: '1.2-2' }}</span>
</div>
}
<div class="te-aa-calc__row te-aa-calc__row--total">
<span>Payable on Closure</span>
<span>₹{{ closurePreview.total | number: '1.2-2' }}</span>
@@ -1828,8 +1838,8 @@
</div>
}
<div class="te-aa-calc__note">
Interest calculated to {{ closurePreview.asOfDate }} using the same calculation the closure itself
performs. Cash is handed over rounded to whole rupees.
Interest has been posted to the account as of {{ closurePreview.asOfDate }} — this is the account's real
balance now, not an estimate. Cash is handed over rounded to whole rupees.
</div>
</div>
@@ -3746,9 +3746,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
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. */
/** Ticking "Close this account" posts every rupee of interest the member has earned to date onto
* the account for real — the same posting close() itself performs — and shows the resulting real
* balance. This is not an estimate: the interest is credited the moment the box is ticked, exactly
* as the user asked for ("jaise FD close/maturity ke time interest post hota tha"). Approval later
* finds this same posting already sitting at the matching period end and adds nothing more. */
onCloseAccountToggle(): void {
if (!this.closeAccountChecked) {
this.closurePreview = null;
@@ -3761,13 +3763,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.closurePreviewLoading = true;
this.closurePreviewError = '';
this.closureRequestService
.preview(accountId)
.postClosureInterest(accountId)
.pipe(
catchError((err: any) => {
this.closurePreviewError =
err?.error?.message ??
err?.error?.errors?.[0]?.defaultUserMessage ??
'Could not calculate the closing amount. Please try again.';
'Could not post interest to the account. Please try again.';
return of(null);
})
)
@@ -3864,13 +3866,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
if (!res) return;
const preview = this.closurePreview;
// The server has now POSTED the interest and returns the account's real resulting balance.
// That, not the popup's estimate, is what the closure pays out — so report it, and say so
// outright if the two ever disagree rather than letting a paise drift pass unnoticed.
const posted = res.payableAmount != null ? Number(res.payableAmount) : null;
const rupees = (v: number) => `₹${v.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`;
const drifted = posted != null && preview != null && Math.abs(posted - preview.total) >= 0.005;
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 })}).`
posted != null
? `Interest has been posted to the account. Payout: ${rupees(posted)}.`
: preview
? `Payout: ${rupees(preview.total)}.`
: '',
drifted && preview
? `Note: the posted figure ${rupees(posted!)} differs from the ${rupees(preview.total)} shown a moment ago — the posted figure is the one that will be paid.`
: '',
`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.'
@@ -4379,8 +4393,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// 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.
// · Balance: the popup already posted the accrued interest onto the account before filling this
// amount in (see onCloseAccountToggle -> postClosureInterest), so the account's own withdrawable
// balance already covers it -- this bypass exists for the checks below it, not this one.
// · 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.
@@ -58,6 +58,41 @@
</div>
</div>
<!-- Payout amount -- the interest is posted onto the account the moment the clerk raises the
request (see clerk-closure-request.service#postClosureInterest), so this is the account's
real, final balance, not an estimate. Approval re-confirms the identical figure; it does
not recompute a different one. Shown first because it is what a manager reviews for. -->
<div class="crv-card crv-card--approved">
<div class="crv-section-title">
<mat-icon>payments</mat-icon>
Payout Amount
</div>
<div class="crv-detail-grid">
<div class="crv-detail-item">
<span class="crv-detail-label">Exact Closing Amount</span>
<span class="crv-detail-value crv-amount">₹{{ request.closing_amount | number: '1.2-2' }}</span>
</div>
@if (request.rounding_amount && request.rounding_amount !== 0) {
<div class="crv-detail-item">
<span class="crv-detail-label">Rounding ({{ request.rounding_amount > 0 ? 'up' : 'down' }})</span>
<span class="crv-detail-value"
>{{ request.rounding_amount > 0 ? '+' : '' }}₹{{ request.rounding_amount | number: '1.2-2' }}</span
>
</div>
}
<div class="crv-detail-item">
<span class="crv-detail-label">Cash to Hand Over</span>
<span class="crv-detail-value crv-amount">₹{{ request.cashier_disbursed_amount | number: '1.2-2' }}</span>
</div>
@if (request.voucher_no) {
<div class="crv-detail-item">
<span class="crv-detail-label">Voucher No</span>
<span class="crv-detail-value cell-mono">{{ request.voucher_no }}</span>
</div>
}
</div>
</div>
<!-- Closure details -->
<div class="crv-card">
<div class="crv-section-title">
@@ -77,6 +112,16 @@
<span class="crv-detail-label">Token No</span>
<span class="crv-detail-value cell-mono">{{ request.tok_no || '—' }}</span>
</div>
<div class="crv-detail-item">
<span class="crv-detail-label">Interest Posted As On</span>
<span class="crv-detail-value">{{ formatDate(request.closure_date) }}</span>
</div>
@if (request.txn_id) {
<div class="crv-detail-item">
<span class="crv-detail-label">Transaction Id</span>
<span class="crv-detail-value cell-mono">{{ request.txn_id }}</span>
</div>
}
@if (request.description) {
<div class="crv-detail-item crv-detail-item--full">
<span class="crv-detail-label">Reason / Narration</span>
@@ -86,36 +131,22 @@
</div>
</div>
<!-- Computed amounts (once approved) -->
@if (request.closing_amount != null) {
<div class="crv-card crv-card--approved">
<!-- Cashier hand-over (once disbursed) -->
@if (request.status === 'CLOSED' && request.disbursed_at) {
<div class="crv-card">
<div class="crv-section-title">
<mat-icon>calculate</mat-icon>
Computed at Approval
<mat-icon>storefront</mat-icon>
Handed Over By Cashier
</div>
<div class="crv-detail-grid">
<div class="crv-detail-item">
<span class="crv-detail-label">Exact Closing Amount</span>
<span class="crv-detail-value crv-amount">₹{{ request.closing_amount | number: '1.2-2' }}</span>
<span class="crv-detail-label">Cashier</span>
<span class="crv-detail-value">{{ request.disbursed_by || '—' }}</span>
</div>
@if (request.rounding_amount && request.rounding_amount !== 0) {
<div class="crv-detail-item">
<span class="crv-detail-label">Rounding ({{ request.rounding_amount > 0 ? 'up' : 'down' }})</span>
<span class="crv-detail-value"
>{{ request.rounding_amount > 0 ? '+' : '' }}₹{{ request.rounding_amount | number: '1.2-2' }}</span
>
</div>
}
<div class="crv-detail-item">
<span class="crv-detail-label">Cash to Hand Over</span>
<span class="crv-detail-value crv-amount">₹{{ request.cashier_disbursed_amount | number: '1.2-2' }}</span>
<span class="crv-detail-label">Disbursed At</span>
<span class="crv-detail-value">{{ formatDateTime(request.disbursed_at) }}</span>
</div>
@if (request.voucher_no) {
<div class="crv-detail-item">
<span class="crv-detail-label">Voucher No</span>
<span class="crv-detail-value cell-mono">{{ request.voucher_no }}</span>
</div>
}
</div>
</div>
}
@@ -128,6 +128,16 @@ export class ClosureReviewDialogComponent implements OnInit {
return n.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
formatDate(val: any): string {
if (!val) return '—';
try {
const d = new Date(val);
return d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
} catch {
return String(val);
}
}
formatDateTime(val: any): string {
if (!val) return '—';
try {