feat(cashier,savings,tasks): clerk-manager-cashier account closure flow
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:
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
|
||||
|
||||
/** HTTP service for the Clerk Closure Request maker-checker workflow (Cash-only savings account
|
||||
* closure). Mirrors ClerkWithdrawalService's shape against the /clerk/closure-requests endpoints. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ClerkClosureRequestService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
submit(data: any): Observable<any> {
|
||||
return this.http.post('/clerk/closure-requests', data);
|
||||
}
|
||||
|
||||
getMyRequests(status?: string, offset = 0, limit = 20, fromDate?: string, toDate?: string): Observable<any> {
|
||||
let params = new HttpParams().set('offset', String(offset)).set('limit', String(limit));
|
||||
if (status) params = params.set('status', status);
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
if (toDate) params = params.set('toDate', toDate);
|
||||
return this.http.get('/clerk/closure-requests/my', { params });
|
||||
}
|
||||
|
||||
getPendingRequests(
|
||||
status = 'PENDING',
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
fromDate?: string,
|
||||
toDate?: string,
|
||||
search?: string
|
||||
): Observable<any> {
|
||||
let params = new HttpParams().set('status', status).set('offset', String(offset)).set('limit', String(limit));
|
||||
if (fromDate) params = params.set('fromDate', fromDate);
|
||||
if (toDate) params = params.set('toDate', toDate);
|
||||
if (search) params = params.set('search', search);
|
||||
return this.http.get('/clerk/closure-requests', { params });
|
||||
}
|
||||
|
||||
getById(id: number): Observable<any> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
approve(id: number): Observable<any> {
|
||||
return this.http.post(`/clerk/closure-requests/${id}/approve`, {});
|
||||
}
|
||||
|
||||
reject(id: number, reason: string): Observable<any> {
|
||||
return this.http.post(`/clerk/closure-requests/${id}/reject`, { reason });
|
||||
}
|
||||
|
||||
// Silenced: onTokNoBlur() probes this expecting a possible legitimate 404 (token not from this
|
||||
// pool, could be a withdrawal/dividend claim's instead) and shows its own tailored message either way.
|
||||
getByToken(token: string): Observable<any> {
|
||||
return this.http.get(`/clerk/closure-requests/by-token/${encodeURIComponent(token)}`, {
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||
});
|
||||
}
|
||||
|
||||
disburse(id: number, denominationData: string | null, transactionAmount?: number): Observable<any> {
|
||||
return this.http.post(`/clerk/closure-requests/${id}/disburse`, {
|
||||
denominationData,
|
||||
transactionAmount: transactionAmount != null ? String(transactionAmount) : null
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user