/** * 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 { MatIconModule } from '@angular/material/icon'; import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { CashierService } from '../cashier.service'; import { businessToday } from 'app/core/utils/business-today'; /** Day-wise denomination-wise closing cash collected from deposits. */ @Component({ selector: 'mifosx-closing-cash-report', templateUrl: './closing-cash-report.component.html', styleUrls: ['../cashier-shared.scss'], standalone: true, imports: [ CommonModule, FormsModule, MatIconModule, MatPaginatorModule ] }) export class ClosingCashReportComponent implements OnInit { private service = inject(CashierService); date = ''; rows: any[] = []; exchangeRows: any[] = []; depositTotal = 0; withdrawalTotal = 0; netTotal = 0; loading = false; loaded = false; /** * Cash taken at the counter for a Demand Draft. * * A cash DD asks for a denomination breakdown but does not require one, and this report is built * entirely from breakdowns — so that cash was missing from the drawer figure even though the * ledger held it and the cashier held the notes. It is counted from the request itself and shown * on its own line, because the denomination rows below cannot account for it. */ ddCashTotal = 0; ddCashCount = 0; /** Deposits including the DD cash — the figure the drawer is actually checked against. */ depositTotalWithDd = 0; /** * The drawer, opening to closing. * * `netTotal` above is only the day's counter movement — deposits less withdrawals. It is not what * the cashier is holding, because it carries nothing forward from yesterday and ignores vault * allocations and inter-cashier legs. The closing balance is the figure that has to match a * physical count at the end of the day, so it is reported separately rather than inferred from * the counter totals. * * Null when the server did not scope the report to one cashier: a branch-wide opening balance is * the branch day register's business, not this report's. */ openingBalance: number | null = null; closingBalance: number | null = null; /** * The day's own cash legs, which is what a cashier reconciles against the drawer. * * A cashier carries no balance: they draw from the vault in the morning and hand back at close. * The cumulative opening/closing figures answer a question nobody at the counter asks, and one * carrying years of history answers it with a number that cannot be counted against anything on * the desk. */ todayVaultIn: number | null = null; todaySettledOut: number | null = null; todayInterCashierIn: number | null = null; todayInterCashierOut: number | null = null; /** Every leg of the day's cash movement, which is wider than deposits less withdrawals. */ cashMovement: number | null = null; /** Whether the figures above are one cashier's drawer or the whole branch's counter work. */ scopedToCashier = false; // GL View showGl = false; glRows: any[] = []; glTotalDr = 0; glTotalCr = 0; glLoading = false; glLoaded = false; // GL Pagination glTotal = 0; glOffset = 0; glPageSize = 20; glPageSizeOptions = [ 10, 20, 50, 100 ]; // Filters for GL tellers: any[] = []; cashiers: any[] = []; selectedTellerId: number | null = null; selectedCashierId: number | null = null; /** * What should be in the drawer at the end of the day, from the day's own legs. * * Drawn from the vault, plus anything taken over the counter and from another cashier, less what * went back out. This is the figure a cashier counts against — it does not carry a balance * forward, because a cashier does not. */ get expectedInDrawer(): number { return ( Number(this.todayVaultIn ?? 0) + Number(this.todayInterCashierIn ?? 0) + Number(this.depositTotalWithDd ?? 0) - Number(this.withdrawalTotal ?? 0) - Number(this.todaySettledOut ?? 0) - Number(this.todayInterCashierOut ?? 0) ); } /** True when any of the day's own legs carries a figure, so the panel is worth showing. */ get hasTodayLegs(): boolean { return this.todayVaultIn !== null; } get noteRows(): any[] { return this.rows.filter((r) => r.kind !== 'coin'); } /** * Totals for the notes table, including the un-itemised Demand Draft cash. * * The DD row carries an amount but no piece count, so pieces and amounts are summed separately * rather than one being derived from the other — a denomination times a count would not reach the * DD's figure, and adding a fictional count to make it balance would misstate the drawer. */ get noteDepositCount(): number { return this.noteRows.reduce((s, r) => s + Number(r.depositCount ?? 0), 0); } get noteDepositAmount(): number { return this.noteRows.reduce((s, r) => s + Number(r.depositAmount ?? 0), 0) + this.ddCashTotal; } get noteWithdrawalCount(): number { return this.noteRows.reduce((s, r) => s + Number(r.withdrawalCount ?? 0), 0); } get noteWithdrawalAmount(): number { return this.noteRows.reduce((s, r) => s + Number(r.withdrawalAmount ?? 0), 0); } get noteVaultInCount(): number { return this.noteRows.reduce((s, r) => s + Number(r.vaultInCount ?? 0), 0); } get noteVaultInAmount(): number { return this.noteRows.reduce((s, r) => s + Number(r.vaultInAmount ?? 0), 0); } get noteSettledOutCount(): number { return this.noteRows.reduce((s, r) => s + Number(r.settledOutCount ?? 0), 0); } get noteSettledOutAmount(): number { return this.noteRows.reduce((s, r) => s + Number(r.settledOutAmount ?? 0), 0); } /** * Summed from each row's own `netCount`, not re-derived from today's legs — a row's `netCount` * already carries forward what a cashier was holding from before today (`openingCount`), for the * force-closed-day/late-settlement case. Recomputing here from only today's vaultIn/deposit/ * withdrawal/settledOut dropped that carry-forward and put the Total row back into negative * numbers a cashier had already reconciled. */ get noteNetCount(): number { return this.noteRows.reduce((s, r) => s + Number(r.netCount ?? 0), 0); } /** * What is left in the drawer in notes: everything that came in, less everything that went out — * including cash carried forward from before today. See `noteNetCount` for why this sums each * row's own `netAmount` rather than re-deriving it from today's legs. */ get noteNetAmount(): number { return this.noteRows.reduce((s, r) => s + Number(r.netAmount ?? 0), 0); } get coinRows(): any[] { return this.rows.filter((r) => r.kind === 'coin'); } get exchangeNoteRows(): any[] { return this.exchangeRows.filter((r) => r.kind !== 'coin'); } get exchangeCoinRows(): any[] { return this.exchangeRows.filter((r) => r.kind === 'coin'); } ngOnInit(): void { const d = businessToday(); this.date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; this.load(); this.service.getTellers().subscribe({ next: (t: any[]) => (this.tellers = t || []), error: () => {} }); } load(): void { this.loading = true; this.service.closingCashReport(this.date).subscribe({ next: (res: any) => { this.rows = res?.rows || []; this.exchangeRows = res?.exchangeRows || []; this.depositTotal = Number(res?.depositTotal || 0); this.withdrawalTotal = Number(res?.withdrawalTotal || 0); this.netTotal = Number(res?.netTotal || 0); this.ddCashTotal = Number(res?.ddCashTotal || 0); this.ddCashCount = Number(res?.ddCashCount || 0); this.depositTotalWithDd = Number(res?.depositTotalWithDd ?? res?.depositTotal ?? 0); this.scopedToCashier = res?.scopedToCashier === true; // Sent only for a cashier-scoped call, and read as absent rather than zero when missing — // a zero opening balance and no opening balance at all are different statements. this.openingBalance = res?.openingBalance == null ? null : Number(res.openingBalance); this.closingBalance = res?.closingBalance == null ? null : Number(res.closingBalance); this.cashMovement = res?.cashMovement == null ? null : Number(res.cashMovement); this.todayVaultIn = res?.todayVaultIn == null ? null : Number(res.todayVaultIn); this.todaySettledOut = res?.todaySettledOut == null ? null : Number(res.todaySettledOut); this.todayInterCashierIn = res?.todayInterCashierIn == null ? null : Number(res.todayInterCashierIn); this.todayInterCashierOut = res?.todayInterCashierOut == null ? null : Number(res.todayInterCashierOut); this.loading = false; this.loaded = true; }, error: () => { this.rows = []; this.exchangeRows = []; this.depositTotal = 0; this.withdrawalTotal = 0; this.netTotal = 0; this.ddCashTotal = 0; this.ddCashCount = 0; this.depositTotalWithDd = 0; this.scopedToCashier = false; this.openingBalance = null; this.closingBalance = null; this.cashMovement = null; this.todayVaultIn = null; this.todaySettledOut = null; this.todayInterCashierIn = null; this.todayInterCashierOut = null; this.loading = false; this.loaded = true; } }); // GL Entries is scoped to `this.date` too, but was never re-fetched here -- changing the date and // clicking Load left glLoaded=true from whatever date was open before, so a stale, wrong-date GL // table stayed on screen under a header that had already relabelled itself to the new date. If // the panel was open, refresh it for the date just loaded; otherwise just invalidate it so the // next time it's opened it fetches fresh instead of showing leftover rows. this.glOffset = 0; this.glLoaded = false; if (this.showGl) { this.loadGl(); } else { this.glRows = []; } } toggleGl(): void { this.showGl = !this.showGl; if (this.showGl && !this.glLoaded) { this.loadGl(); } } onTellerChange(): void { this.selectedCashierId = null; this.cashiers = []; if (this.selectedTellerId) { this.service.getCashiersForTeller(this.selectedTellerId).subscribe({ next: (res: any) => (this.cashiers = res?.cashiers || res || []), error: () => {} }); } this.glOffset = 0; this.glLoaded = false; this.loadGl(); } onCashierChange(): void { this.glOffset = 0; this.glLoaded = false; this.loadGl(); } onGlPageChange(event: PageEvent): void { this.glPageSize = event.pageSize; this.glOffset = event.pageIndex * event.pageSize; this.loadGl(); } loadGl(): void { this.glLoading = true; this.service .getGlEntries( this.date, this.selectedCashierId || undefined, this.selectedTellerId || undefined, this.glOffset, this.glPageSize ) .subscribe({ next: (res: any) => { this.glRows = res?.pageItems || []; this.glTotal = Number(res?.totalFilteredRecords || 0); this.glTotalDr = Number(res?.totalDr || 0); this.glTotalCr = Number(res?.totalCr || 0); this.glLoading = false; this.glLoaded = true; }, error: () => { this.glRows = []; this.glTotal = 0; this.glTotalDr = 0; this.glTotalCr = 0; this.glLoading = false; this.glLoaded = true; } }); } }