/** * 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(); 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(); 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(); } }