A cash deposit posted to the wrong account is moved to the right one in four steps: unpass the voucher, a manager approves, the cashier enters the correct account, a manager approves again. Nothing changes on any account until the final approval. - Voucher Correction page with four-step strip and popups: today's vouchers to unpass (admin or clerk), the unpassed vouchers waiting for their correct account (cashier) with the wrong and correct account side by side and the balance check, and the caller's own requests. A cashier whose customer has used the money closes the request and deposits separately. - Reached from the cashier bar, the clerk bar and the Cashier menu; the manager reviews both steps under Approvals > Voucher Corrections, laid out like the other approval tabs. Routes for Cash Transfers approvals are added to the same tab bar. - A Correction column on My Vouchers, the day book and the voucher register (and in the voucher detail popup) says whether a voucher is unpassed, revoked in favour of another, a correction, or closed, with the reason. - The deposit taken off the wrong account no longer shows on its passbook or transaction list. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
360 lines
13 KiB
TypeScript
360 lines
13 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 { Component, OnInit, inject } from '@angular/core';
|
||
import { CommonModule } from '@angular/common';
|
||
import { FormsModule } from '@angular/forms';
|
||
import { ActivatedRoute } from '@angular/router';
|
||
import { MatIconModule } from '@angular/material/icon';
|
||
import { catchError, of } from 'rxjs';
|
||
import { CashierService } from '../cashier.service';
|
||
import { businessToday } from 'app/core/utils/business-today';
|
||
import { VoucherStatusComponent, VoucherStatus } from '../voucher-status/voucher-status.component';
|
||
|
||
/**
|
||
* Which side of the register a screen shows.
|
||
*
|
||
* 'CR' is the Received report (money in), 'DR' the Payment report (money out). Set from the route,
|
||
* so the two screens are the same component pointed at opposite sides.
|
||
*/
|
||
export type VoucherSide = 'CR' | 'DR' | 'ALL';
|
||
|
||
/** One voucher as the register shows it: the number, the two sides, and who raised it. */
|
||
interface VoucherRow {
|
||
voucherNo: string;
|
||
/** Where the voucher stands after a correction: active, revoked, under correction, or corrected. */
|
||
voucherStatus?: VoucherStatus;
|
||
revokedByVoucherNo?: string | null;
|
||
replacesVoucherNo?: string | null;
|
||
correctionNote?: string | null;
|
||
voucherType: string | null;
|
||
amount: number | null;
|
||
createdAt: string | null;
|
||
/** The business date the posting belongs to, which is not always the day it was written. */
|
||
ledgerDate?: string | null;
|
||
/** True when the voucher was written after the day it posts against had already closed. */
|
||
postedAfterClose?: boolean;
|
||
userId: number | null;
|
||
username: string | null;
|
||
branchName: string | null;
|
||
verificationStatus: string | null;
|
||
debitParty: string | null;
|
||
debitAccount: string | null;
|
||
creditParty: string | null;
|
||
creditAccount: string | null;
|
||
/** Sides beyond the one debit and one credit shown — a DD's charges, a compound journal. */
|
||
extraLines: number;
|
||
/**
|
||
* Whether a denomination breakdown was recorded against this voucher.
|
||
*
|
||
* True proves cash was counted. False does not prove the opposite: several registers pay real
|
||
* money out without recording a breakdown, so this marks a row as unconfirmed, not as non-cash.
|
||
*/
|
||
denominationCounted: boolean;
|
||
}
|
||
|
||
interface VoucherReport {
|
||
from: string;
|
||
to: string;
|
||
username: string | null;
|
||
/** Set by the server from the caller's roles; a cashier is never offered the user filter. */
|
||
canSeeAllUsers: boolean;
|
||
/** Whether the server restricted the register to cash movements. */
|
||
cashOnly: boolean;
|
||
vouchers: VoucherRow[];
|
||
count: number;
|
||
totalAmount: number;
|
||
}
|
||
|
||
/**
|
||
* The vouchers raised over a day or a month, one row each.
|
||
*
|
||
* A cashier sees their own work; a clerk, head cashier or manager sees everyone's and can narrow to
|
||
* one user. That is decided by the server, not here — this screen only shows or hides the filter.
|
||
*/
|
||
@Component({
|
||
selector: 'mifosx-voucher-report',
|
||
templateUrl: './voucher-report.component.html',
|
||
styleUrls: [
|
||
'../cashier-shared.scss',
|
||
'./voucher-report.component.scss'
|
||
],
|
||
standalone: true,
|
||
imports: [
|
||
CommonModule,
|
||
FormsModule,
|
||
MatIconModule,
|
||
VoucherStatusComponent
|
||
]
|
||
})
|
||
export class VoucherReportComponent implements OnInit {
|
||
private service = inject(CashierService);
|
||
private route = inject(ActivatedRoute);
|
||
|
||
/** Day is the working default; a month is what you ask for at a close. */
|
||
period: 'DAY' | 'MONTH' = 'DAY';
|
||
/** Anchor date — the day itself, or any day inside the month wanted. */
|
||
anchor = this.todayIso();
|
||
usernameFilter = '';
|
||
typeFilter = '';
|
||
|
||
/** Which side this screen is: Received (CR) or Payment (DR). Comes from the route's data. */
|
||
side: VoucherSide = 'CR';
|
||
|
||
/**
|
||
* Whether this screen is the cash register rather than the whole voucher register.
|
||
*
|
||
* A cashier handles notes and coins, so entries that cannot be cash — an interest posting, a
|
||
* clearing settlement, a cheque book fee — are left out, and everything that could have crossed
|
||
* the counter stays in. The filter errs towards keeping a row: a book entry on the register can
|
||
* be seen and recognised, whereas cash that never appears is invisible until it turns up as an
|
||
* unexplained difference at the end of the day.
|
||
*/
|
||
cashOnly = false;
|
||
|
||
/**
|
||
* Whether this register is the counter's own — only the people who hold a drawer.
|
||
*
|
||
* These two screens reconcile the counter: what came in over it and what went out. Everyone
|
||
* else's work is their own to account for, and each person reads it on My Vouchers. Mixed in here
|
||
* it buried the cashier rows the registers exist for and left the totals matching no drawer.
|
||
*/
|
||
cashiersOnly = false;
|
||
|
||
/**
|
||
* Whether this register is the system's own postings rather than anybody's work.
|
||
*
|
||
* Interest a nightly run credited, a charge taken from a balance, an escheat, a journal keyed
|
||
* straight to the ledger. These belong to no member of staff, so My Vouchers cannot show them —
|
||
* it is scoped to the reader — and the two counter registers deliberately exclude them. That left
|
||
* no screen answering "what did the system post last night".
|
||
*/
|
||
systemOnly = false;
|
||
|
||
report: VoucherReport | null = null;
|
||
loading = false;
|
||
error: string | null = null;
|
||
|
||
ngOnInit(): void {
|
||
this.side = (this.route.snapshot.data['side'] as VoucherSide) ?? 'CR';
|
||
this.cashOnly = this.route.snapshot.data['cashOnly'] === true;
|
||
this.cashiersOnly = this.route.snapshot.data['cashiersOnly'] === true;
|
||
this.systemOnly = this.route.snapshot.data['systemOnly'] === true;
|
||
this.load();
|
||
}
|
||
|
||
get isReceived(): boolean {
|
||
return this.side === 'CR';
|
||
}
|
||
|
||
get title(): string {
|
||
if (this.systemOnly) {
|
||
return 'System Vouchers';
|
||
}
|
||
return this.isReceived ? 'Received Report' : 'Payment Report';
|
||
}
|
||
|
||
get subtitle(): string {
|
||
if (this.systemOnly) {
|
||
return 'What the system posted on its own — interest, charges, escheats and journals. Nobody raised these, so they appear on no one’s own list.';
|
||
}
|
||
// Said plainly, because a register that silently omits the reader's own entries is worse than
|
||
// one that explains why: their work is on My Vouchers, and the line points them there.
|
||
if (this.cashiersOnly) {
|
||
return this.isReceived
|
||
? 'Cash taken in at the counter by each cashier. Other staff read their own work on My Vouchers.'
|
||
: 'Cash paid out at the counter by each cashier. Other staff read their own work on My Vouchers.';
|
||
}
|
||
if (this.cashOnly) {
|
||
return this.isReceived
|
||
? 'Cash taken in over a day or a month, and who took it — book entries left out.'
|
||
: 'Cash paid out over a day or a month, and who paid it — book entries left out.';
|
||
}
|
||
return this.isReceived
|
||
? 'Every receipt voucher raised over a day or a month — money taken in, and who took it.'
|
||
: 'Every payment voucher raised over a day or a month — money paid out, and who paid it.';
|
||
}
|
||
|
||
/** The word for one row on this side, for counts and the empty state. */
|
||
get noun(): string {
|
||
if (this.systemOnly) {
|
||
return 'posting';
|
||
}
|
||
return this.isReceived ? 'receipt' : 'payment';
|
||
}
|
||
|
||
load(): void {
|
||
this.loading = true;
|
||
this.error = null;
|
||
|
||
const { from, to } = this.range();
|
||
this.service
|
||
.voucherReport(
|
||
from,
|
||
to,
|
||
this.usernameFilter.trim() || undefined,
|
||
this.typeFilter.trim() || undefined,
|
||
this.cashOnly,
|
||
this.cashiersOnly,
|
||
this.systemOnly
|
||
)
|
||
.pipe(
|
||
catchError((err: any) => {
|
||
this.error =
|
||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||
err?.error?.developerMessage ??
|
||
'The voucher report could not be loaded.';
|
||
return of(null);
|
||
})
|
||
)
|
||
.subscribe((res: VoucherReport | null) => {
|
||
this.loading = false;
|
||
this.report = res;
|
||
});
|
||
}
|
||
|
||
/** The period the anchor date names: that day, or the whole calendar month around it. */
|
||
private range(): { from: string; to: string } {
|
||
if (this.period === 'DAY') {
|
||
return { from: this.anchor, to: this.anchor };
|
||
}
|
||
const [
|
||
year,
|
||
month
|
||
] = this.anchor.split('-').map(Number);
|
||
// Day 0 of the next month is the last day of this one, which is how a 28/30/31-day month is
|
||
// ended correctly without a table of month lengths.
|
||
const lastDay = new Date(year, month, 0).getDate();
|
||
const mm = String(month).padStart(2, '0');
|
||
return { from: `${year}-${mm}-01`, to: `${year}-${mm}-${String(lastDay).padStart(2, '0')}` };
|
||
}
|
||
|
||
private todayIso(): string {
|
||
const now = businessToday();
|
||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||
}
|
||
|
||
/**
|
||
* The rows belonging to this screen's side.
|
||
*
|
||
* The side is read off the first segment of the voucher number — `CR/001/2608/0003` — which the
|
||
* server stamps when it raises the voucher: a deposit credits the member and gets CR, everything
|
||
* that takes money out gets DR. That prefix is the authoritative record of the side, so it is
|
||
* matched directly rather than re-deriving the side from the voucher type here — a new type added
|
||
* on the server then lands on the correct report with no change needed on this screen.
|
||
*/
|
||
get rows(): VoucherRow[] {
|
||
const all = this.report?.vouchers ?? [];
|
||
return this.side === 'ALL' ? all : all.filter((row) => this.sideOf(row) === this.side);
|
||
}
|
||
|
||
/**
|
||
* Whether a voucher has its two sides recorded against it.
|
||
*
|
||
* The registers raise a voucher when the request is made and only attach its sides once it is
|
||
* approved and posted, so a voucher raised before that step existed carries a number but no
|
||
* lines. Those rows are labelled rather than shown as an empty dash, which reads like the money
|
||
* went nowhere.
|
||
*/
|
||
hasSides(row: VoucherRow): boolean {
|
||
return !!(row.debitParty || row.creditParty);
|
||
}
|
||
|
||
/** How many rows on this side are missing their debit/credit lines. */
|
||
get missingSidesCount(): number {
|
||
return this.rows.filter((row) => !this.hasSides(row)).length;
|
||
}
|
||
|
||
/** A voucher's side, from its number's prefix. Null when the number is missing or unrecognised. */
|
||
private sideOf(row: VoucherRow): VoucherSide | null {
|
||
const no = (row.voucherNo ?? '').toUpperCase();
|
||
if (no.startsWith('CR/')) {
|
||
return 'CR';
|
||
}
|
||
if (no.startsWith('DR/')) {
|
||
return 'DR';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** How many rows on this side — not the server's count, which spans both. */
|
||
get count(): number {
|
||
return this.rows.length;
|
||
}
|
||
|
||
/** Total for this side only. */
|
||
get totalAmount(): number {
|
||
return this.rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||
}
|
||
|
||
/**
|
||
* The part of this side's total whose notes and coins were actually counted.
|
||
*
|
||
* Always at most `totalAmount`: a voucher can be real cash and still be missing its breakdown,
|
||
* because the register that raised it records none. So this is the confirmed part of the cash,
|
||
* not the whole of it, and the gap is reported rather than treated as book entries.
|
||
*/
|
||
get countedTotal(): number {
|
||
return this.rows.filter((row) => row.denominationCounted).reduce((sum, row) => sum + (row.amount ?? 0), 0);
|
||
}
|
||
|
||
/** Rows carrying no denomination breakdown — unconfirmed against the drawer, not non-cash. */
|
||
get uncountedCount(): number {
|
||
return this.rows.filter((row) => !row.denominationCounted).length;
|
||
}
|
||
|
||
/** How much of this side's total has no note-and-coin breakdown behind it. */
|
||
get uncountedTotal(): number {
|
||
return this.totalAmount - this.countedTotal;
|
||
}
|
||
|
||
/**
|
||
* Vouchers whose number carries neither prefix, so they appear on neither report.
|
||
*
|
||
* Counted and shown rather than silently dropped: a voucher that vanishes from both sides of the
|
||
* register is exactly the kind of thing a cashier must be told about, not left to discover when
|
||
* the two reports do not add up to the day.
|
||
*/
|
||
get unclassifiedCount(): number {
|
||
const all = this.report?.vouchers ?? [];
|
||
return all.filter((row) => this.sideOf(row) === null).length;
|
||
}
|
||
|
||
/** Totals per user, for the summary a manager reads before the rows. */
|
||
get byUser(): { username: string; count: number; total: number }[] {
|
||
const rows = this.rows;
|
||
const tally = new Map<string, { username: string; count: number; total: number }>();
|
||
for (const row of rows) {
|
||
const key = row.username ?? '—';
|
||
const entry = tally.get(key) ?? { username: key, count: 0, total: 0 };
|
||
entry.count += 1;
|
||
entry.total += row.amount ?? 0;
|
||
tally.set(key, entry);
|
||
}
|
||
return [...tally.values()].sort((a, b) => b.total - a.total);
|
||
}
|
||
|
||
/** Totals per voucher type — deposits against withdrawals against journals. */
|
||
get byType(): { type: string; count: number; total: number }[] {
|
||
const rows = this.rows;
|
||
const tally = new Map<string, { type: string; count: number; total: number }>();
|
||
for (const row of rows) {
|
||
const key = row.voucherType ?? '—';
|
||
const entry = tally.get(key) ?? { type: key, count: 0, total: 0 };
|
||
entry.count += 1;
|
||
entry.total += row.amount ?? 0;
|
||
tally.set(key, entry);
|
||
}
|
||
return [...tally.values()].sort((a, b) => b.total - a.total);
|
||
}
|
||
|
||
print(): void {
|
||
window.print();
|
||
}
|
||
}
|