/** * 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 { CashierService } from '../cashier.service'; import { SettingsService } from 'app/settings/settings.service'; import { AuthenticationService } from 'app/core/authentication/authentication.service'; /** One part of the day's work — a section of the day book, or a kind of entry within it. */ interface SummaryLine { label: string; credit: number; debit: number; count: number; } /** * The single sheet a cashier signs the day off with. * * Every other cashier screen answers one question — what is in the drawer, which vouchers are * unverified, what did each person handle. At close of day somebody has to sign one piece of paper * saying the money was counted and it agreed, and until now that meant reading four screens and * copying figures onto a form by hand. * * Nothing here is computed on the server that was not already being computed: the closing report * supplies the drawer legs, the day book supplies the transactions, and the cashier position * supplies what the system believes is held. This screen assembles them in the order a person * verifying the day reads them in, and prints. */ @Component({ selector: 'mifosx-end-of-day-report', templateUrl: './end-of-day-report.component.html', styleUrls: [ '../cashier-shared.scss', './end-of-day-report.component.scss' ], standalone: true, imports: [ CommonModule, FormsModule, MatIconModule ] }) export class EndOfDayReportComponent implements OnInit { private service = inject(CashierService); private settings = inject(SettingsService); private auth = inject(AuthenticationService); date = ''; loading = false; loaded = false; /** Who is signing, and for which branch — read from the session, never typed in. */ preparedBy = ''; branchName = ''; // ── Section 1: transaction summary ── rows: any[] = []; creditTotal = 0; debitTotal = 0; // ── Section 2: cash drawer ── /** Drawn from the vault in the morning: a cashier's opening float. */ openingFloat = 0; cashDeposits = 0; interCashierIn = 0; cashWithdrawals = 0; settledOut = 0; interCashierOut = 0; /** Whether the server sent this day's own cash legs, which only a cashier-scoped call gets. */ hasDayLegs = false; /** Whether the drawer figures are one cashier's or the whole branch's counter work. */ scopedToCashier = false; /** What the drawers hold between them, per the system. */ drawersTotal: number | null = null; /** * Each cashier and what their drawer holds. * * A clerk holds no drawer of their own — they supervise the counter — so a single "actual cash" * figure says nothing to them. The position endpoint already answers for whoever is asking: one * drawer for a cashier, every drawer for a clerk or officer, so the same call serves both and the * screen reads differently depending on who opened it. */ cashierPositions: { cashierId: number | null; cashierName: string; totalAmount: number }[] = []; /** * The branch's own expected cash, from the day register. * * A branch-wide reading cannot be assembled from the per-cashier day legs: those are only * returned when the call is scoped to one cashier. The branch day register already keeps this * arithmetic for the close-day gate — opening cash, plus approved deposits, less what was * withdrawn — so the sheet reads it rather than inventing a second version of the same sum. * * Null when no day has been opened for this date, which is a real state: there is nothing to * reconcile against on a day the branch never opened. */ branchOpeningCash: number | null = null; branchDeposits: number | null = null; branchWithdrawals: number | null = null; branchExpectedCash: number | null = null; /** * What the vaults hold, which a branch-wide count must include. * * The branch register's expected cash covers the whole branch — vault and every drawer together — * because cash moved from the vault to a cashier has not left the branch. Counting only the * drawers against it would report the entire vault as a shortage. */ vaultTotal: number | null = null; ngOnInit(): void { this.date = this.businessDate(); this.preparedBy = this.currentUserName(); this.branchName = this.currentOfficeName(); this.load(); } /** * The date the books are open on, not the wall clock. * * An EOD sheet dated by the browser would be filed against the wrong day whenever the branch is * still working yesterday's business date, which is exactly when a day is being closed. */ private businessDate(): string { const d = this.settings.businessDate; const safe = d instanceof Date && !isNaN(d.getTime()) ? d : new Date(); return `${safe.getFullYear()}-${String(safe.getMonth() + 1).padStart(2, '0')}-${String(safe.getDate()).padStart(2, '0')}`; } /** The member of staff behind the login, falling back to the username when no staff is linked. */ private currentUserName(): string { const c = this.auth.getCredentials(); return c ? c.staffDisplayName || c.username || '' : ''; } private currentOfficeName(): string { return this.auth.getCredentials()?.officeName || ''; } load(): void { this.loading = true; this.loaded = false; this.service.dayBook(this.date).subscribe({ next: (res: any) => { this.rows = res?.rows || []; this.creditTotal = Number(res?.creditTotal || 0); this.debitTotal = Number(res?.debitTotal || 0); this.loading = false; this.loaded = true; }, error: () => { this.rows = []; this.creditTotal = 0; this.debitTotal = 0; this.loading = false; this.loaded = true; } }); this.service.closingCashReport(this.date).subscribe({ next: (res: any) => { // The four day legs are only sent for a cashier-scoped call. Read as absent rather than // zero: a leg that is missing and a leg that is genuinely nil mean different things, and // treating the first as the second is what let the sheet claim an expected balance of zero // for a whole branch. this.hasDayLegs = res?.todayVaultIn != null; this.openingFloat = Number(res?.todayVaultIn || 0); this.cashDeposits = Number(res?.depositTotalWithDd ?? res?.depositTotal ?? 0); this.cashWithdrawals = Number(res?.withdrawalTotal || 0); this.settledOut = Number(res?.todaySettledOut || 0); this.interCashierIn = Number(res?.todayInterCashierIn || 0); this.interCashierOut = Number(res?.todayInterCashierOut || 0); this.scopedToCashier = res?.scopedToCashier === true; }, error: () => { this.openingFloat = 0; this.cashDeposits = 0; this.cashWithdrawals = 0; this.settledOut = 0; this.interCashierIn = 0; this.interCashierOut = 0; this.scopedToCashier = false; this.hasDayLegs = false; } }); // The branch's expected cash for the date, which is the only basis a branch-wide reading can // reconcile on. Fails quietly for a plain cashier, who is not allowed to operate the day. this.service.branchDayClosePreview(this.date).subscribe({ next: (res: any) => { this.branchOpeningCash = res?.openingCash == null ? null : Number(res.openingCash); this.branchDeposits = res?.deposits == null ? null : Number(res.deposits); this.branchWithdrawals = res?.withdrawals == null ? null : Number(res.withdrawals); this.branchExpectedCash = res?.expectedCash == null ? null : Number(res.expectedCash); }, error: () => { this.branchOpeningCash = null; this.branchDeposits = null; this.branchWithdrawals = null; this.branchExpectedCash = null; } }); // One object for a cashier, an array of every cashier for a clerk or officer — the server // decides from the caller's own roles, so this screen does not have to ask who is reading it. this.service.cashierPositionReport(undefined, this.date).subscribe({ next: (res: any) => { const list = Array.isArray(res) ? res : res ? [res] : []; this.cashierPositions = list .filter((p: any) => p && p.cashierId != null) .map((p: any) => ({ cashierId: p.cashierId == null ? null : Number(p.cashierId), cashierName: p.cashierName || `Cashier ${p.cashierId}`, totalAmount: Number(p.totalAmount || 0) })) .sort((a: any, b: any) => b.totalAmount - a.totalAmount); this.drawersTotal = this.cashierPositions.length ? this.cashierPositions.reduce((s, p) => s + p.totalAmount, 0) : null; }, error: () => { // Nothing to count against, so the variance line stays blank rather than reading zero — a // variance of zero is a claim that somebody counted and it agreed. this.cashierPositions = []; this.drawersTotal = null; } }); // Vault stock, so a branch-wide count is of the whole branch and not just the counters. this.service.vaultPosition(undefined, this.date).subscribe({ next: (res: any) => { const list = Array.isArray(res) ? res : res ? [res] : []; this.vaultTotal = list.reduce((s: number, v: any) => s + Number(v?.totalAmount || 0), 0); }, error: () => { this.vaultTotal = null; } }); } // ── Section 1 ── /** * Only the entries that actually moved money. * * A pending request is listed on the day book so nobody loses sight of it, but it has taken no * cash — counting it into an EOD summary would make the sheet disagree with the drawer. */ /** * Last night's batch: posted against yesterday, surfacing on this report. * * A voucher's timestamp is the minute the job ran, while the transaction and the journal entry * behind it carry the business date. So the interest run that fires just after midnight stamps * itself with today although the ledger says yesterday — and yesterday was counted, reported and * signed off hours before. Re-opening that day would invalidate a signed sheet, so the work is * listed here instead, under its own heading, saying which day it belongs to. It is excluded from * every total on this page: it is not today's money. */ get priorDayRows(): any[] { return this.rows.filter((r) => r.section === 'SYSTEM_PRIOR'); } get priorDayCredit(): number { return this.priorDayRows.reduce((s, r) => s + (r.side === 'CR' ? Number(r.amount || 0) : 0), 0); } get priorDayDebit(): number { return this.priorDayRows.reduce((s, r) => s + (r.side === 'DR' ? Number(r.amount || 0) : 0), 0); } /** The business date last night's batch worked on, for the heading. */ get priorDayDate(): string | null { const withDate = this.priorDayRows.find((r) => r.effectiveDate); return withDate ? withDate.effectiveDate : null; } get approvedRows(): any[] { return this.rows.filter( (r) => r.section !== 'SYSTEM_PRIOR' && (r.status === 'APPROVED' || r.status === 'COMPLETED' || r.status === 'WITHDRAWN') ); } /** * The day split the way this system actually records it: where the entry came from. * * Not split by payment mode, which is the obvious shape for a retail till but the wrong one here. * A payment type is only recorded against a denomination breakdown, and a breakdown is only * written for physical cash — so a UPI or transfer deposit carries no payment type at all, and a * mode-wise table would file every non-cash entry under "not specified" while claiming to be a * breakdown by mode. The day book's own sections are recorded on every row, so this agrees with * the day book for the same date instead of quietly disagreeing with it. */ get sectionLines(): SummaryLine[] { const order = [ 'CASHIER', 'CLERK', 'SYSTEM' ]; const labels: Record = { CASHIER: 'Cash counter', CLERK: 'Clerk desk', SYSTEM: 'System — interest, charges, journals' }; const map = new Map(); for (const r of this.approvedRows) { const key = order.includes(r.section) ? r.section : 'SYSTEM'; let line = map.get(key); if (!line) { line = { label: labels[key] || key, credit: 0, debit: 0, count: 0 }; map.set(key, line); } this.addTo(line, r); } return order.filter((k) => map.has(k)).map((k) => map.get(k)!); } /** * What kind of entry it was — deposit, withdrawal, interest posting, charge. * * The sections above say where the day's work came from; this says what the work was, which is * the question somebody checking a figure against a register actually asks. */ get kindLines(): SummaryLine[] { const map = new Map(); for (const r of this.approvedRows) { const key = (r.kind || 'OTHER').toString(); let line = map.get(key); if (!line) { line = { label: this.titleCase(key), credit: 0, debit: 0, count: 0 }; map.set(key, line); } this.addTo(line, r); } return Array.from(map.values()).sort((a, b) => b.credit + b.debit - (a.credit + a.debit)); } /** Adds one day-book row onto a summary line, on the side the row belongs to. */ private addTo(line: SummaryLine, row: any): void { const amt = Number(row.amount || 0); if (row.side === 'CR') { line.credit += amt; } else { line.debit += amt; } line.count += 1; } /** DD CASH, INTEREST_POSTING and the like, as a person would read them. */ kindLabel(raw: string | null | undefined): string { if (!raw) { return '—'; } return raw .toString() .replace(/_/g, ' ') .toLowerCase() .replace(/\b\w/g, (c) => c.toUpperCase()); } private titleCase(raw: string): string { return this.kindLabel(raw); } get txnCount(): number { return this.approvedRows.length; } /** More than one drawer in view means a supervisor is reading, not a cashier. */ get supervising(): boolean { return this.cashierPositions.length > 1 || (!this.scopedToCashier && this.cashierPositions.length > 0); } get cashierPositionTotal(): number { return this.cashierPositions.reduce((s, p) => s + p.totalAmount, 0); } /** * Each person who handled the day's work, with their own credit, debit and net. * * The clerk signing the day off is verifying other people's work, so the sheet has to say whose. * Read off the day book's own handledBy, which every register stamps. */ get byUser(): SummaryLine[] { const map = new Map(); for (const r of this.approvedRows) { const key = (r.handledBy || 'Unattributed').toString(); let line = map.get(key); if (!line) { line = { label: key, credit: 0, debit: 0, count: 0 }; map.set(key, line); } this.addTo(line, r); } return Array.from(map.values()).sort((a, b) => b.credit + b.debit - (a.credit + a.debit)); } get summaryCreditTotal(): number { return this.sectionLines.reduce((s, l) => s + l.credit, 0); } get summaryDebitTotal(): number { return this.sectionLines.reduce((s, l) => s + l.debit, 0); } // ── Section 2 ── /** Everything that came into the drawer today, over the counter and from another cashier. */ get totalReceived(): number { return this.cashDeposits + this.interCashierIn; } /** Everything that left it: paid to members, handed to the vault, passed to another cashier. */ get totalPaidOut(): number { return this.cashWithdrawals + this.settledOut + this.interCashierOut; } /** What should be on the desk: the float, plus what came in, less what went out. */ get expectedClosing(): number { return this.openingFloat + this.totalReceived - this.totalPaidOut; } /** * Whether an expected-against-held comparison can honestly be made. * * Only for a single cashier, and only when the server sent that cashier's day legs. Both halves * then describe the same thing: today's legs, against a drawer that carries nothing forward * because a cashier draws in the morning and hands back at close. * * Never branch-wide. The branch register's expected cash is *today's* arithmetic — yesterday's * closing, plus today's deposits, less today's withdrawals — while the vault and drawer positions * are running totals accumulated since the system went live. Subtracting one from the other * compares an afternoon against a lifetime: it reported ₹2,11,932 extra on a day when ₹100 came * in and ₹100 went out. There is no branch-wide running total to compare against, so the sheet * shows the branch its movement and each drawer's position, and states no difference at all * rather than a confident wrong one. */ get canReconcile(): boolean { return this.scopedToCashier && this.hasDayLegs; } /** The cash the system believes this cashier is holding. */ get actualCash(): number | null { return this.scopedToCashier ? this.drawersTotal : null; } /** What today's legs say should be in the drawer. */ get expectedCash(): number | null { return this.canReconcile ? this.expectedClosing : null; } /** Held less expected, for one cashier's own day. Null wherever that comparison is not honest. */ get variance(): number | null { const expected = this.expectedCash; if (expected === null || this.actualCash === null) { return null; } return this.actualCash - expected; } /** Vaults and drawers together — what the branch is holding, per the system. */ get branchHeldTotal(): number | null { if (this.drawersTotal === null && this.vaultTotal === null) { return null; } return Number(this.drawersTotal ?? 0) + Number(this.vaultTotal ?? 0); } /** Today's net cash movement across the branch: what came in less what went out. */ get branchNetMovement(): number { return Number(this.branchDeposits ?? 0) - Number(this.branchWithdrawals ?? 0); } /** True only when a count exists and it agrees to the paisa. */ get varianceOk(): boolean { return this.variance !== null && Math.abs(this.variance) < 0.005; } get varianceClass(): string { if (this.variance === null) { return ''; } return this.varianceOk ? 'eod-ok' : 'eod-bad'; } // ── Section 3 ── /** Entries still waiting on somebody, which is what a day cannot be closed over. */ get pendingRows(): any[] { return this.rows.filter((r) => r.status === 'PENDING' || r.status === 'SUBMITTED'); } /** Cash entries whose paper slip nobody has checked against the screen. */ get unverifiedRows(): any[] { return this.approvedRows.filter((r) => !r.verifyStatus); } /** A slip that was checked and did not agree with what was keyed in. */ get mismatchRows(): any[] { return this.rows.filter((r) => r.verifyStatus === 'MISMATCH' || r.verifyStatus === 'DISPUTED'); } /** * Whether the day is ready to close. * * Nothing pending, no slip disagreeing, and — where a cash comparison was actually made — the * cash agreeing. A branch-wide reading makes no comparison, so it is not held to one: requiring * `varianceOk` there meant the sheet could never say a day was clear however clean it was. */ get allClear(): boolean { if (this.pendingRows.length > 0 || this.mismatchRows.length > 0) { return false; } return this.variance === null || this.varianceOk; } reload(): void { this.load(); } print(): void { window.print(); } }