Apply Collection and Apply Additional Shares settle the same Share Collection receipts for the same member, so they are now deliberately the same screen. Apply Collection takes exactly two receipts and no more: one for the share value, one for the entry fee, in two separate slots. They are different things -- the share receipt is money moving into share capital, while the fee receipt only proves the fee was already collected and is never posted a second time -- and picking both out of one ticked list made that distinction invisible. Apply Additional Shares moves from its own route to a dialog. It was a full page a clerk had to leave the account for and come back from, to fill in a short form with the member standing at the counter. Its old route, resolver case and action- host branch are removed rather than left behind as dead paths. Both screens now draw their receipts from a single pool, whether a receipt already names the member or is a voucher paid straight into a GL account; an unattributed one is attached at the moment it is picked, so what used to be an Attach in one table and a tick in another is one deliberate click. The by-voucher search and the GL-entry detail both sit behind buttons, as exceptions rather than permanent furniture. Dialog chrome and the receipt shape are shared (share-dialog.shared.scss, share-receipt-option.ts) so the two cannot drift apart again. Also fixes content being clipped at the dialog edges: the wrapper cancelled a 24px surface padding that the panel class had already zeroed, pushing every edge outside the surface where overflow:hidden cut it off, and the height cap sat on the scrolling body rather than the wrapper, letting the footer stack past the bottom of the viewport. Adds the product-wise share summary report (authorised, issued, available and unpaid shares, distinctive range and account count per product). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
441 lines
19 KiB
TypeScript
441 lines
19 KiB
TypeScript
/**
|
|
* 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, HttpParams } from '@angular/common/http';
|
|
import { Observable, of } from 'rxjs';
|
|
import { map, catchError } from 'rxjs/operators';
|
|
|
|
/** HTTP service for the Cashier menu features. */
|
|
@Injectable({ providedIn: 'root' })
|
|
export class CashierService {
|
|
private http = inject(HttpClient);
|
|
|
|
// ── Active denominations ──
|
|
getActiveDenominations(): Observable<any[]> {
|
|
return this.http.get<any[]>('/denominationmaster/active');
|
|
}
|
|
|
|
// ── Cash Exchange ──
|
|
createCashExchange(data: any): Observable<any> {
|
|
return this.http.post('/cashexchange', data);
|
|
}
|
|
getCashExchanges(offset = 0, limit = 20): Observable<any> {
|
|
const params = new HttpParams().set('offset', offset).set('limit', limit);
|
|
return this.http.get<any>('/cashexchange', { params });
|
|
}
|
|
getCashExchange(id: number): Observable<any> {
|
|
return this.http.get(`/cashexchange/${id}`);
|
|
}
|
|
reverseCashExchange(id: number, reason: string): Observable<any> {
|
|
return this.http.post(`/cashexchange/${id}/reverse`, { reason });
|
|
}
|
|
|
|
// ── Tellers / Cashiers ──
|
|
getTellers(officeId?: number): Observable<any[]> {
|
|
let params = new HttpParams();
|
|
if (officeId) params = params.set('officeId', String(officeId));
|
|
return this.http.get<any[]>('/tellers', { params });
|
|
}
|
|
getAllCashiers(): Observable<any[]> {
|
|
return this.http.get<any[]>('/cashiers');
|
|
}
|
|
getCashiersForTeller(tellerId: number): Observable<any> {
|
|
return this.http.get(`/tellers/${tellerId}/cashiers`);
|
|
}
|
|
|
|
// ── Cash Transfer ──
|
|
createCashTransfer(data: any): Observable<any> {
|
|
return this.http.post('/cashtransfer', data);
|
|
}
|
|
getCashTransfers(offset = 0, limit = 20): Observable<any> {
|
|
const params = new HttpParams().set('offset', offset).set('limit', limit);
|
|
return this.http.get<any>('/cashtransfer', { params });
|
|
}
|
|
getCashTransferById(id: number): Observable<any> {
|
|
return this.http.get(`/cashtransfer/${id}`);
|
|
}
|
|
reverseCashTransfer(id: number, reason: string): Observable<any> {
|
|
return this.http.post(`/cashtransfer/${id}/reverse`, { reason });
|
|
}
|
|
|
|
// ── Cash Settlement ──
|
|
createCashSettlement(data: any): Observable<any> {
|
|
return this.http.post('/cashsettlement', data);
|
|
}
|
|
getCashSettlements(offset = 0, limit = 20): Observable<any> {
|
|
const params = new HttpParams().set('offset', offset).set('limit', limit);
|
|
return this.http.get<any>('/cashsettlement', { params });
|
|
}
|
|
getCashSettlementById(id: number): Observable<any> {
|
|
return this.http.get(`/cashsettlement/${id}`);
|
|
}
|
|
reverseCashSettlement(id: number, reason: string): Observable<any> {
|
|
return this.http.post(`/cashsettlement/${id}/reverse`, { reason });
|
|
}
|
|
|
|
// ── Inter-cashier Transfer ──
|
|
createInterCashierTransfer(data: any): Observable<any> {
|
|
return this.http.post('/cashintercashier', data);
|
|
}
|
|
getInterCashierTransfers(offset = 0, limit = 20): Observable<any> {
|
|
const params = new HttpParams().set('offset', offset).set('limit', limit);
|
|
return this.http.get<any>('/cashintercashier', { params });
|
|
}
|
|
getInterCashierById(id: number): Observable<any> {
|
|
return this.http.get(`/cashintercashier/${id}`);
|
|
}
|
|
reverseInterCashierTransfer(id: number, reason: string): Observable<any> {
|
|
return this.http.post(`/cashintercashier/${id}/reverse`, { reason });
|
|
}
|
|
|
|
// ── Reconciliation ──
|
|
createReconciliation(data: any): Observable<any> {
|
|
return this.http.post('/cashreconciliation', data);
|
|
}
|
|
getReconciliations(): Observable<any[]> {
|
|
return this.http.get<any[]>('/cashreconciliation');
|
|
}
|
|
getReconciliationById(id: number): Observable<any> {
|
|
return this.http.get(`/cashreconciliation/${id}`);
|
|
}
|
|
getExpectedBalance(cashierId: number, asOfDate?: string): Observable<any> {
|
|
let params = new HttpParams().set('cashierId', String(cashierId));
|
|
if (asOfDate) params = params.set('asOfDate', asOfDate);
|
|
return this.http.get('/cashreconciliation/expected', { params });
|
|
}
|
|
|
|
// ── Reports ──
|
|
closingCashReport(date: string): Observable<any> {
|
|
const params = new HttpParams().set('date', date);
|
|
return this.http.get('/cashreports/closing', { params });
|
|
}
|
|
/**
|
|
* Every voucher raised on one date — deposits, withdrawals, disbursements, collections and
|
|
* bank transfers in a single list, with the day's credit and debit totals.
|
|
*/
|
|
dayBook(date: string, side?: 'CR' | 'DR', status?: string): Observable<any> {
|
|
let params = new HttpParams().set('date', date);
|
|
if (side) params = params.set('side', side);
|
|
if (status) params = params.set('status', status);
|
|
return this.http.get('/cashreports/day-book', { params });
|
|
}
|
|
|
|
/**
|
|
* The vouchers raised over a period, one row each, with what was debited and what credited.
|
|
*
|
|
* Who appears is decided by the server from the caller's own roles — a cashier sees only their
|
|
* own work whatever they ask for — so `username` is a filter for those allowed to use it, not a
|
|
* way to choose whose report this is.
|
|
*/
|
|
voucherReport(
|
|
from: string,
|
|
to: string,
|
|
username?: string,
|
|
voucherType?: string,
|
|
cashOnly?: boolean,
|
|
cashiersOnly?: boolean,
|
|
systemOnly?: boolean
|
|
): Observable<any> {
|
|
let params = new HttpParams().set('from', from).set('to', to);
|
|
if (username) {
|
|
params = params.set('username', username);
|
|
}
|
|
if (voucherType) {
|
|
params = params.set('voucherType', voucherType);
|
|
}
|
|
// Only sent when asked for: the server treats a missing flag as the full register, so the
|
|
// cashier screens opt in to cash and the back-office ones keep seeing everything.
|
|
if (cashOnly) {
|
|
params = params.set('cashOnly', 'true');
|
|
}
|
|
// Opt-in the same way: the counter registers ask for the drawer-holders only, the back-office
|
|
// ones keep seeing whoever raised a voucher.
|
|
if (cashiersOnly) {
|
|
params = params.set('cashiersOnly', 'true');
|
|
}
|
|
// The postings nobody filed a request for: interest a nightly run credited, a charge taken from
|
|
// a balance, an escheat, a journal keyed to the ledger. Nowhere else shows them — My Vouchers is
|
|
// the reader's own work and a scheduled job is nobody's.
|
|
if (systemOnly) {
|
|
params = params.set('systemOnly', 'true');
|
|
}
|
|
return this.http.get('/vouchers/report', { params });
|
|
}
|
|
|
|
/**
|
|
* The signed-in user's own day: every voucher they raised and every one they verified, receipts
|
|
* and payments in one list. Always scoped to the caller by the server — there is no username
|
|
* parameter, because "my work" for somebody else is the register's job, not this screen's.
|
|
*/
|
|
myVoucherWork(from: string, to: string): Observable<any> {
|
|
const params = new HttpParams().set('from', from).set('to', to);
|
|
return this.http.get('/vouchers/my-work', { params });
|
|
}
|
|
|
|
/** One voucher and every debit/credit line it is made of — the full slip behind a summarised
|
|
* row (a "+2 more" badge in a register only ever shows the first side of each). */
|
|
voucherDetail(voucherNo: string): Observable<any> {
|
|
const params = new HttpParams().set('voucherNo', voucherNo);
|
|
return this.http.get('/vouchers/detail', { params });
|
|
}
|
|
|
|
/**
|
|
* Account register for one date: what opened and closed that day, each account's balance as it
|
|
* stood then, and what it holds now.
|
|
*/
|
|
accountRegister(date: string, section?: string): Observable<any> {
|
|
let params = new HttpParams().set('date', date);
|
|
if (section && section !== 'ALL') params = params.set('section', section);
|
|
return this.http.get('/accountregister', { params });
|
|
}
|
|
|
|
// ── Branch day register ──
|
|
|
|
/** The branch's day register, plus which day is currently open. Defaults to the caller's office. */
|
|
branchDayRegister(officeId?: number, limit?: number): Observable<any> {
|
|
let params = new HttpParams();
|
|
if (officeId) params = params.set('officeId', String(officeId));
|
|
if (limit) params = params.set('limit', String(limit));
|
|
return this.http.get('/branchday', { params });
|
|
}
|
|
|
|
/** Days opened and never closed — today's own open day excluded. */
|
|
branchDayMissed(officeId?: number): Observable<any> {
|
|
let params = new HttpParams();
|
|
if (officeId) params = params.set('officeId', String(officeId));
|
|
return this.http.get('/branchday/missed', { params });
|
|
}
|
|
|
|
/**
|
|
* What the day should close at, and the arithmetic behind it, so the closing figure does not
|
|
* have to be worked out by hand.
|
|
*/
|
|
branchDayClosePreview(businessDate?: string, officeId?: number): Observable<any> {
|
|
let params = new HttpParams();
|
|
if (businessDate) params = params.set('businessDate', businessDate);
|
|
if (officeId) params = params.set('officeId', String(officeId));
|
|
return this.http.get('/branchday/close-preview', { params });
|
|
}
|
|
|
|
/** openingDifferenceReason is required when the opening figure differs from yesterday's closing. */
|
|
openBranchDay(
|
|
businessDate: string,
|
|
openingCash?: number,
|
|
officeId?: number,
|
|
denominations?: { denominationId: number; denominationValue: number; count: number }[],
|
|
openingDifferenceReason?: string
|
|
): Observable<any> {
|
|
return this.http.post('/branchday/open', {
|
|
businessDate,
|
|
openingCash: openingCash != null ? String(openingCash) : null,
|
|
officeId: officeId ?? null,
|
|
denominations: denominations?.length ? denominations : null,
|
|
openingDifferenceReason: openingDifferenceReason ?? null,
|
|
locale: 'en'
|
|
});
|
|
}
|
|
|
|
/** forceCloseReason is required when closing a date earlier than today. */
|
|
closeBranchDay(
|
|
businessDate: string,
|
|
closingCash: number,
|
|
remarks?: string,
|
|
forceCloseReason?: string,
|
|
officeId?: number,
|
|
denominations?: { denominationId: number; denominationValue: number; count: number }[]
|
|
): Observable<any> {
|
|
return this.http.post('/branchday/close', {
|
|
businessDate,
|
|
closingCash: String(closingCash),
|
|
remarks: remarks ?? null,
|
|
forceCloseReason: forceCloseReason ?? null,
|
|
officeId: officeId ?? null,
|
|
denominations: denominations?.length ? denominations : null,
|
|
locale: 'en'
|
|
});
|
|
}
|
|
|
|
/** The opening/closing denomination breakdown recorded for one business day, if any was captured. */
|
|
branchDayDenominations(officeId?: number, businessDate?: string): Observable<any> {
|
|
let params = new HttpParams();
|
|
if (officeId) params = params.set('officeId', String(officeId));
|
|
if (businessDate) params = params.set('businessDate', businessDate);
|
|
return this.http.get('/branchday/denominations', { params });
|
|
}
|
|
|
|
/** What the office's cash-at-vault GL account currently holds — zero means the count itself will
|
|
* define its opening balance; non-zero means the count must match it exactly. */
|
|
cashGlStatus(officeId?: number): Observable<any> {
|
|
let params = new HttpParams();
|
|
if (officeId) params = params.set('officeId', String(officeId));
|
|
return this.http.get('/branchday/cash-gl-status', { params });
|
|
}
|
|
|
|
/** One-step new-institution path: defines (or reconciles against) the cash GL's opening balance
|
|
* from a counted denomination breakdown, then opens the business day with that same count. */
|
|
migrateCashDenomination(
|
|
officeId: number,
|
|
businessDate: string,
|
|
denominations: { denominationId: number; denominationValue: number; count: number }[],
|
|
openingDifferenceReason?: string
|
|
): Observable<any> {
|
|
return this.http.post('/branchday/migrate-cash-denomination', {
|
|
officeId,
|
|
businessDate,
|
|
denominations,
|
|
openingDifferenceReason: openingDifferenceReason ?? null,
|
|
locale: 'en'
|
|
});
|
|
}
|
|
|
|
/** Denomination-wise stock a vault (teller) currently holds — "how much of what does the bank have". */
|
|
vaultPosition(tellerId?: number, asOfDate?: string): Observable<any> {
|
|
let params = new HttpParams();
|
|
if (tellerId) params = params.set('tellerId', String(tellerId));
|
|
if (asOfDate) params = params.set('asOfDate', asOfDate);
|
|
return this.http.get('/cashreports/vault-position', { params });
|
|
}
|
|
excessTransactionReport(threshold: number, fromDate?: string, toDate?: string): Observable<any[]> {
|
|
let params = new HttpParams().set('threshold', String(threshold));
|
|
if (fromDate) params = params.set('fromDate', fromDate);
|
|
if (toDate) params = params.set('toDate', toDate);
|
|
return this.http.get<any[]>('/cashreports/excess', { params });
|
|
}
|
|
cashierPositionReport(cashierId?: number, asOfDate?: string): Observable<any> {
|
|
let params = new HttpParams();
|
|
if (cashierId) params = params.set('cashierId', String(cashierId));
|
|
if (asOfDate) params = params.set('asOfDate', asOfDate);
|
|
return this.http.get('/cashreports/cashier-position', { params });
|
|
}
|
|
getCashierPositionForMe(asOfDate?: string): Observable<any> {
|
|
let params = new HttpParams();
|
|
if (asOfDate) params = params.set('asOfDate', asOfDate);
|
|
return this.http.get('/cashreports/cashier-position/me', { params });
|
|
}
|
|
getCashierDayStatus(): Observable<any> {
|
|
return this.http.get('/cashreports/cashier-day-status');
|
|
}
|
|
|
|
getGlEntries(date: string, cashierId?: number, tellerId?: number, offset = 0, limit = 20): Observable<any> {
|
|
let params = new HttpParams().set('date', date).set('offset', String(offset)).set('limit', String(limit));
|
|
if (cashierId) params = params.set('cashierId', String(cashierId));
|
|
if (tellerId) params = params.set('tellerId', String(tellerId));
|
|
return this.http.get('/cashreports/gl-entries', { params });
|
|
}
|
|
|
|
checkSettlement(cashierId: number, date: string): Observable<any> {
|
|
const params = new HttpParams().set('cashierId', String(cashierId)).set('date', date);
|
|
return this.http.get('/cashsettlement/check', { params });
|
|
}
|
|
|
|
checkReconciliation(cashierId: number, date: string): Observable<any> {
|
|
const params = new HttpParams().set('cashierId', String(cashierId)).set('date', date);
|
|
return this.http.get('/cashreconciliation/check', { params });
|
|
}
|
|
|
|
getCashierMorningBalance(cashierId: number, date: string): Observable<any> {
|
|
const params = new HttpParams().set('cashierId', String(cashierId)).set('date', date);
|
|
return this.http.get('/cashreports/cashier-morning', { params });
|
|
}
|
|
|
|
getExpectedBalanceForMe(asOfDate?: string): Observable<any> {
|
|
let params = new HttpParams();
|
|
if (asOfDate) params = params.set('asOfDate', asOfDate);
|
|
return this.http.get('/cashreconciliation/expected/me', { params });
|
|
}
|
|
|
|
// ── Current cashier identity ──
|
|
// Returns cashierId + cashierName for the logged-in user, or null if not a cashier.
|
|
getCurrentCashierInfo(): Observable<{ cashierId: number; cashierName: string } | null> {
|
|
return this.getCashierPositionForMe().pipe(
|
|
map((pos: any) => (pos?.cashierId ? { cashierId: pos.cashierId, cashierName: pos.cashierName ?? '' } : null)),
|
|
catchError(() => of(null))
|
|
);
|
|
}
|
|
|
|
// ── Payment Types ──
|
|
getPaymentTypes(): Observable<any[]> {
|
|
return this.http.get<any[]>('/paymenttypes');
|
|
}
|
|
|
|
/** One unapplied Share Sundry receipt, found by the voucher number the member quotes. */
|
|
getShareAdvanceByVoucher(voucherNo: string, glAccountNo: string, clientId?: number): Observable<any> {
|
|
// The voucher travels as a query parameter, not a path segment: numbers are formatted
|
|
// CR/001/2108/0001, and Spring Security's strict firewall rejects the encoded slashes
|
|
// (%2F) outright with a 400 before the request ever reaches the resource.
|
|
// clientId lets the backend adopt a cashier deposit made straight to a GL account, which
|
|
// carries no member of its own. glAccountNo is required — which GL account to search in is
|
|
// always chosen by hand, never resolved from a share product's accounting setup, so a broken
|
|
// or missing product mapping can never make a genuine voucher unreachable.
|
|
let params = new HttpParams().set('voucherNo', voucherNo).set('glAccountNo', glAccountNo);
|
|
if (clientId) {
|
|
params = params.set('clientId', String(clientId));
|
|
}
|
|
return this.http.get('/shareadvancecollection/by-voucher', { params });
|
|
}
|
|
|
|
/** GL accounts selectable when searching for a voucher, for the picker on the Apply Collection screen. */
|
|
getShareAdvanceGlAccounts(): Observable<any> {
|
|
return this.http.get('/shareadvancecollection/glaccounts');
|
|
}
|
|
|
|
/** Share value + any outstanding entry fee for one share account, shown before a voucher search. */
|
|
getShareAdvanceRequirement(shareAccountId: number): Observable<any> {
|
|
return this.http.get('/shareadvancecollection/requirement', { params: { shareAccountId: String(shareAccountId) } });
|
|
}
|
|
|
|
/**
|
|
* Everything an Apply screen needs, in one call: `own` is this member's own unapplied receipts,
|
|
* ready to tick; `rows` is every other Share Collection receipt still available — unclaimed GL
|
|
* deposits and receipts linked to another member — which attach by voucher instead.
|
|
*
|
|
* One request rather than two: both lists come from the same table under the same filters, and
|
|
* splitting them across endpoints only meant the same rows were fetched twice and, where they
|
|
* overlapped, drawn twice on the same screen.
|
|
*/
|
|
getShareCollectionReceipts(clientId?: number): Observable<any> {
|
|
const params = clientId ? new HttpParams().set('clientId', String(clientId)) : undefined;
|
|
return this.http.get('/shareadvancecollection/unattributed', { params });
|
|
}
|
|
|
|
/** Moves a receipt out of Sundry into share capital for an activated account. */
|
|
applyShareAdvance(collectionId: number, data: any): Observable<any> {
|
|
return this.http.post(`/shareadvancecollection/${collectionId}/apply`, data);
|
|
}
|
|
|
|
/** The receipt's own real Dr/Cr GL legs — never the GL account currently picked in a search box,
|
|
* which is unrelated screen state that can name a different account than this receipt sits in. */
|
|
getShareAdvanceGlEntries(collectionId: number): Observable<any> {
|
|
return this.http.get(`/shareadvancecollection/${collectionId}/gl-entries`);
|
|
}
|
|
|
|
/** Every additional-share tranche awaiting approval, across every share account. */
|
|
getShareAdditionRequests(): Observable<any> {
|
|
return this.http.get('/shareadditionrequests');
|
|
}
|
|
|
|
/**
|
|
* Full detail for one pending tranche — the list row plus route-specific proof of funding: the
|
|
* voucher(s) behind a cash tranche, or the savings account already debited for a savings tranche.
|
|
*/
|
|
getShareAdditionRequest(transactionId: number): Observable<any> {
|
|
return this.http.get(`/shareadditionrequests/${transactionId}`);
|
|
}
|
|
|
|
/**
|
|
* Entry Type options for a GL credit (General / Share Collection / DD Issue / …) — the
|
|
* "GLEntryType" code under Admin > System > Manage Codes, so an admin can add another type later
|
|
* without a deployment. Empty on failure rather than erroring, since a row missing this dropdown's
|
|
* options should still be usable for every other field.
|
|
*/
|
|
getEntryTypeOptions(): Observable<any[]> {
|
|
return this.http.get<any[]>('/codes/name/GLEntryType/codevalues').pipe(catchError(() => of([])));
|
|
}
|
|
}
|