/** * 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 { ActivatedRoute, RouterLink } from '@angular/router'; import { catchError, forkJoin, map, of, switchMap } from 'rxjs'; import { CashierService } from '../cashier.service'; interface Denom { id: number; value: number; kind: 'note' | 'coin'; label: string; } interface BranchDay { id: number; officeId: number; officeName: string | null; businessDate: string; openedByName: string | null; openedAt: string | null; openingCash: number | null; closedByName: string | null; closedAt: string | null; closingCash: number | null; bookCash: number | null; cashDifference: number | null; forceClosed: boolean; forceCloseReason: string | null; remarks: string | null; status: 'OPEN' | 'CLOSED'; /** Only present on the missed-close list. */ daysOpen?: number; } /** * Branch day register — opening and closing the branch's business day, and the list of days that * were opened and never closed. * * A day left open is not a tidiness problem: its closing balance never carries forward, a * back-dated entry can still land in it, and any cash difference on that date goes unnoticed. */ @Component({ selector: 'mifosx-branch-day', templateUrl: './branch-day.component.html', styleUrls: [ '../cashier-shared.scss', '../day-book/day-book.component.scss', './branch-day.component.scss' ], standalone: true, imports: [ CommonModule, FormsModule, MatIconModule, RouterLink ] }) export class BranchDayComponent implements OnInit { private service = inject(CashierService); private route = inject(ActivatedRoute); /** * Set from the route: the missed-close register is the same data as the day register, shown with * only the never-closed days. Two menu entries used to point at one screen, so choosing "Missed * Close Register" looked like nothing had happened whenever the branch was up to date. */ missedOnly = false; days: BranchDay[] = []; missed: BranchDay[] = []; openDay: any = null; today = ''; /** Set by the backend from the caller's roles — a cashier may read the register but not act on it. */ canOperate = false; loading = false; loaded = false; errorMsg = ''; successMsg = ''; /** True only for a branch that has never opened a business day and whose Cash at Vault / Cash at * Teller GL account has never had anything posted to it — Open Day here would let day-to-day cash * tracking begin while the ledger silently stays wherever it already was. The backend enforces * this independently too (see BranchDayRegisterApiResource#requireCashMigrationBeforeFirstEverOpen); * this flag only steers the user to the right screen before they hit that rejection. */ firstOpenNeedsCashMigration = false; // Open-day form showOpenForm = false; openDate = ''; openingCash: number | null = null; /** Required only when the counted/typed opening figure differs from yesterday's own closing * figure — the backend rejects the open without it in that case. */ openDifferenceReason = ''; opening = false; // Close-day form. The date is fixed by which day is being closed, never typed. closeTarget: BranchDay | null = null; closingCash: number | null = null; closeRemarks = ''; forceReason = ''; closing = false; // Denomination breakdown, shared by both the open and close forms — a physical note/coin count // is the true source of the lump-sum figure, not a typed-in guess. Counted separately per form // since opening and closing are two independent physical counts. notes: Denom[] = []; coins: Denom[] = []; openCounts: Record = {}; closeCounts: Record = {}; /** What yesterday's own close counted, note by note — the open form starts from this instead of * zero. The backend already carries this figure forward on its own when nothing is submitted for * the day being opened; showing it here is only about letting whoever opens the day see and * correct it, not a second place the carry-forward logic lives. */ openCountsAuto: Record = {}; /** What the vault + every cashier's live position added up to when the close form opened — the * physical-count table starts from this instead of zero, since the EOD summary already fetched * it. Kept separately from closeCounts so a manual recount can be told apart from it and undone. */ closeCountsAuto: Record = {}; get allDenoms(): Denom[] { return [ ...this.notes, ...this.coins ]; } ngOnInit(): void { this.missedOnly = !!this.route.snapshot.data['missedOnly']; this.load(); this.service.getActiveDenominations().subscribe({ next: (list) => { const rows: Denom[] = (list || []).map((x: any) => ({ id: x.id, value: x.denomination, kind: x.kind, label: '₹' + x.denomination })); this.notes = rows.filter((r) => r.kind === 'note').sort((a, b) => b.value - a.value); this.coins = rows.filter((r) => r.kind === 'coin').sort((a, b) => b.value - a.value); }, error: () => { this.notes = []; this.coins = []; } }); } countFor(counts: Record, d: Denom): number { return counts[d.id] || 0; } subtotalFor(counts: Record, d: Denom): number { return this.countFor(counts, d) * d.value; } onCountChange(counts: Record, d: Denom, raw: any): void { counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0)); } bumpCount(counts: Record, d: Denom, delta: number): void { counts[d.id] = Math.max(0, (counts[d.id] || 0) + delta); } totalOf(counts: Record): number { return this.allDenoms.reduce((s, d) => s + this.subtotalFor(counts, d), 0); } /** Denominations with a positive count, shaped for the API payload. */ private denominationPayload(counts: Record) { return this.allDenoms .filter((d) => this.countFor(counts, d) > 0) .map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(counts, d) })); } get openDenomTotal(): number { return this.totalOf(this.openCounts); } get closeDenomTotal(): number { return this.totalOf(this.closeCounts); } /** * Savings products whose interest posting report is due today and not yet approved. The close is * refused by the backend while any is pending; this only says so before the user tries. */ interestPending: string[] = []; private checkInterestReports(): void { if (!this.openDay?.businessDate) { this.interestPending = []; return; } this.service .interestDayCloseCheck(this.openDay.businessDate, this.openDay.officeId) .pipe(catchError(() => of(null))) .subscribe((res) => (this.interestPending = res?.pendingProducts ?? [])); } load(): void { this.loading = true; this.errorMsg = ''; this.service .branchDayRegister() .pipe( catchError((err: any) => { this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not load the day register.'; return of(null); }) ) .subscribe((res: any) => { this.loading = false; this.loaded = true; this.days = res?.days ?? []; this.openDay = res?.openDay ?? null; this.today = res?.today ?? ''; this.canOperate = !!res?.canOperate; this.openDate = this.today; this.checkFirstOpenCashMigration(); this.checkInterestReports(); }); this.service .branchDayMissed() .pipe(catchError(() => of(null))) .subscribe((res: any) => { this.missed = res?.missed ?? []; }); } /** * Only relevant the moment this branch has never opened a business day before — every later * open/close on this screen is untouched, so there is nothing to re-check once history exists. */ private checkFirstOpenCashMigration(): void { if (this.days.length > 0 || this.openDay) { this.firstOpenNeedsCashMigration = false; return; } this.service.cashGlStatus().subscribe({ next: (status: any) => { // configured === false means this tenant has no Cash at Vault / Cash at Teller account // mapped at all — nothing for this check to reconcile against, so it does not block. this.firstOpenNeedsCashMigration = status?.configured !== false && !status?.hasBalance; }, error: () => { this.firstOpenNeedsCashMigration = false; } }); } /** True when the branch has no open day at all, so today can be opened. */ get canOpenToday(): boolean { return this.canOperate && !this.openDay && !this.firstOpenNeedsCashMigration; } /** * A still-open day's closing/book figures are dropped from the API response entirely (the * backend's Gson instance omits a null map value rather than sending it as JSON null), so the * field arrives as undefined, not null. Checking only `!== null` against that — which a stricter * pass of this file made the mistake of doing — leaves undefined on the "has a value" branch, * which then renders `'₹' + null` (the number pipe's own null output, string-concatenated) as the * literal text "₹null". This checks for either, the way an open day actually shows up. */ hasValue(value: number | null | undefined): boolean { return value !== null && value !== undefined; } startOpen(): void { this.showOpenForm = true; this.openDate = this.today; // Left blank on purpose: the backend carries yesterday's closing forward, and typing a figure // over it is how a carried balance drifts. this.openingCash = null; this.openCounts = {}; this.openCountsAuto = {}; this.openDifferenceReason = ''; this.successMsg = ''; this.errorMsg = ''; this.loadCarriedForwardDenominations(); } cancelOpen(): void { this.showOpenForm = false; this.openCountsAuto = {}; } /** * Pulls yesterday's own closing note/coin count so the open form shows what is about to be * carried forward instead of a blank table the caller has to trust blindly. Only the most * recently closed day is used — the same day the backend itself falls back to when the caller * submits no denominations and no lump sum. */ private loadCarriedForwardDenominations(): void { const lastClosed = this.days.find((d) => d.status === 'CLOSED' && d.closingCash != null); if (!lastClosed) return; this.service .branchDayDenominations(lastClosed.officeId, lastClosed.businessDate) .pipe(catchError(() => of(null))) .subscribe((res: any) => { const closing = res?.closing || []; if (!closing.length) return; const totals: Record = {}; for (const d of closing) { totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0); } this.openCountsAuto = totals; // Only overwrite the table if nothing has been typed/counted since the form opened. if (this.openDenomTotal === 0 && this.openingCash == null) { this.openCounts = { ...totals }; } }); } get hasAutoFilledOpenCounts(): boolean { return Object.keys(this.openCountsAuto).length > 0; } autoOpenCountFor(d: Denom): number { return this.openCountsAuto[d.id] || 0; } resetOpenToAutoFill(): void { this.openCounts = { ...this.openCountsAuto }; } get carriedForwardTotal(): number { return this.totalOf(this.openCountsAuto); } /** The figure the form would actually submit right now — the denomination total when any note/coin * has been counted, otherwise the plain typed-in amount. Mirrors {@link effectiveClosingFigure}. */ get effectiveOpeningFigure(): number | null { return this.openDenomTotal > 0 ? this.openDenomTotal : this.openingCash; } /** Difference between what is about to be opened with and what yesterday actually closed with — * visible before saving, the same way the close form shows a live difference against the book * figure, so a short/excess opening count isn't discovered only after the day is already running. */ get openLiveDifference(): number | null { if (!this.hasAutoFilledOpenCounts || this.effectiveOpeningFigure == null) return null; return this.effectiveOpeningFigure - this.carriedForwardTotal; } /** True once a reason is needed but hasn't been typed — the same "explain the gap before it's * allowed" gate the close form already applies to a late close. */ get needsOpenDifferenceReason(): boolean { return this.openLiveDifference != null && this.openLiveDifference !== 0 && !this.openDifferenceReason.trim(); } get canSubmitOpen(): boolean { return !!this.openDate && !this.opening && !this.needsOpenDifferenceReason; } submitOpen(): void { if (!this.openDate || this.needsOpenDifferenceReason) return; this.opening = true; this.errorMsg = ''; const denominations = this.denominationPayload(this.openCounts); this.service .openBranchDay( this.openDate, this.openingCash ?? undefined, undefined, denominations, this.openDifferenceReason.trim() || undefined ) .pipe( catchError((err: any) => { this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not open the day.'; return of(null); }) ) .subscribe((res: any) => { this.opening = false; if (res) { this.showOpenForm = false; this.successMsg = `Business day ${res.businessDate} opened.`; this.load(); } }); } /** The day's expected figure and the working behind it, fetched when the close form opens. */ preview: any = null; previewLoading = false; startClose(day: BranchDay): void { this.closeTarget = day; this.closingCash = null; this.closeCounts = {}; this.closeCountsAuto = {}; this.closeRemarks = ''; this.forceReason = ''; this.successMsg = ''; this.errorMsg = ''; this.preview = null; // Pre-fill the counted figure with what the books expect. It stays editable — the day is still // closed on what was physically counted — but in the ordinary case where the drawer tallies // there is nothing to type, and no chance of a slip of the keyboard inventing a difference. this.previewLoading = true; this.service .branchDayClosePreview(day.businessDate) .pipe(catchError(() => of(null))) .subscribe((res: any) => { this.previewLoading = false; this.preview = res; if (res?.expectedCash != null && this.closingCash == null) { this.closingCash = Number(res.expectedCash); } }); this.loadEodSummary(day); } // ── EOD denomination summary: what's in the vault, and what every cashier under it is // currently holding, fetched automatically so closing the day doesn't require separately // checking Vault Position and every cashier's own Cashier Position screen by hand. ── eodLoading = false; eodTellers: any[] = []; /** * Vault + every cashier's current holding, added up across the whole branch and checked against * the book figure — the question a manager actually needs answered at close: does the BRANCH * tally, not just the vault alone. Computed on read rather than stored, so it never has to be * kept in sync across the two independent loads (the book preview and the EOD summary) that feed * it. */ get branchTotalSummary(): { vaultTotal: number; cashierTotal: number; grandTotal: number; expected: number; difference: number; } | null { if (!this.preview || this.eodTellers.length === 0) return null; let vaultTotal = 0; let cashierTotal = 0; for (const t of this.eodTellers) { vaultTotal += Number(t.vault?.totalAmount ?? 0); for (const c of t.cashiers) { cashierTotal += Number(c.currentTotal ?? 0); } } const grandTotal = vaultTotal + cashierTotal; const expected = Number(this.preview.expectedCash ?? 0); return { vaultTotal, cashierTotal, grandTotal, expected, difference: grandTotal - expected }; } private loadEodSummary(day: BranchDay): void { this.eodLoading = true; this.eodTellers = []; this.service .getTellers(day.officeId) .pipe( switchMap((tellers) => { const list = tellers || []; if (!list.length) return of([]); return forkJoin(list.map((t: any) => this.loadTellerSummary(t, day.businessDate))); }), catchError(() => of([])) ) .subscribe((results: any[]) => { this.eodLoading = false; this.eodTellers = results; this.autoFillCloseCounts(); }); } /** * Seeds the physical-count table from what the vault and every cashier are already shown to be * holding, instead of leaving it at zero and making whoever closes the day re-key numbers that * are already on screen above. It stays a starting point, not a lock — any row can still be * corrected against what was actually counted, and {@link resetToAutoFill} undoes a correction. */ private autoFillCloseCounts(): void { const totals: Record = {}; for (const t of this.eodTellers) { for (const d of t.vault?.denominations || []) { totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0); } for (const c of t.cashiers || []) { for (const d of c.denominations || []) { totals[d.denominationId] = (totals[d.denominationId] || 0) + Number(d.count || 0); } } } if (Object.keys(totals).length === 0) return; this.closeCountsAuto = totals; this.closeCounts = { ...totals }; } get hasAutoFilledCounts(): boolean { return Object.keys(this.closeCountsAuto).length > 0; } /** The auto-fetched count for one denomination, so a row that was hand-edited away from it can * still show what the vault + cashier positions said. */ autoCountFor(d: Denom): number { return this.closeCountsAuto[d.id] || 0; } resetToAutoFill(): void { this.closeCounts = { ...this.closeCountsAuto }; } private loadTellerSummary(teller: any, date: string) { return forkJoin({ vault: this.service.vaultPosition(teller.id, date).pipe(catchError(() => of(null))), cashiers: this.service.getCashiersForTeller(teller.id).pipe(catchError(() => of({ cashiers: [] }))) }).pipe( switchMap(({ vault, cashiers }) => { const cashierList = cashiers?.cashiers || []; if (!cashierList.length) { return of({ tellerId: teller.id, tellerName: teller.name, vault, cashiers: [] }); } return forkJoin(cashierList.map((c: any) => this.loadCashierSummary(c, date))).pipe( map((cashierSummaries: any[]) => ({ tellerId: teller.id, tellerName: teller.name, vault, cashiers: cashierSummaries })) ); }) ); } private loadCashierSummary(cashier: any, date: string) { return forkJoin({ position: this.service.cashierPositionReport(cashier.id, date).pipe(catchError(() => of(null))), morning: this.service.getCashierMorningBalance(cashier.id, date).pipe(catchError(() => of(null))), reconciliation: this.service.checkReconciliation(cashier.id, date).pipe(catchError(() => of(null))), settlement: this.service.checkSettlement(cashier.id, date).pipe(catchError(() => of(null))), glEntries: this.service.getGlEntries(date, cashier.id, undefined, 0, 1).pipe(catchError(() => of(null))) }).pipe( map(({ position, morning, reconciliation, settlement, glEntries }: any) => { const morningMap = new Map(); (morning?.denominations || []).forEach((d: any) => morningMap.set(d.denominationId, d.morningCount ?? 0)); return { cashierId: cashier.id, cashierName: cashier.staffName, morningTotal: Number(morning?.totalMorning ?? 0), currentTotal: Number(position?.totalAmount ?? 0), reconciled: reconciliation?.reconciled ?? null, alreadySettled: settlement?.alreadySettled ?? null, txnCount: glEntries?.totalFilteredRecords ?? null, denominations: ((position?.denominations || []) as any[]).map((d: any) => ({ ...d, morningCount: morningMap.get(d.denominationId) ?? 0, delta: d.count - (morningMap.get(d.denominationId) ?? 0) })) }; }) ); } /** The counted figure the form would submit right now — the denomination total when any note/coin * has been counted, otherwise the plain typed-in amount. */ get effectiveClosingFigure(): number | null { return this.closeDenomTotal > 0 ? this.closeDenomTotal : this.closingCash; } /** Difference the form would record right now, so it is visible before saving. */ get liveDifference(): number | null { if (!this.preview || this.effectiveClosingFigure == null) return null; return Number(this.effectiveClosingFigure) - Number(this.preview.expectedCash ?? 0); } /** Puts the expected figure back after an edit. */ useExpected(): void { if (this.preview?.expectedCash != null) { this.closingCash = Number(this.preview.expectedCash); } } cancelClose(): void { this.closeTarget = null; this.eodTellers = []; this.closeCountsAuto = {}; } /** A date earlier than today is a late close, and has to say why it was left open. */ get isLateClose(): boolean { return !!this.closeTarget && this.closeTarget.businessDate < this.today; } /** * True when any cashier who was given cash (currentTotal > 0) is still NOT reconciled * or NOT settled — meaning outstanding cash hasn't been accounted for yet. */ get hasUnreconciledOrUnsettledCashiers(): boolean { for (const t of this.eodTellers) { for (const c of t.cashiers) { if (c.currentTotal > 0 && (c.reconciled === false || c.alreadySettled === false)) { return true; } } } return false; } /** Names of cashiers blocking the close, for the error message. */ get unreconciledOrUnsettledCashierDetails(): { name: string; notReconciled: boolean; notSettled: boolean }[] { const result: { name: string; notReconciled: boolean; notSettled: boolean }[] = []; for (const t of this.eodTellers) { for (const c of t.cashiers) { if (c.currentTotal > 0 && (c.reconciled === false || c.alreadySettled === false)) { result.push({ name: c.cashierName, notReconciled: c.reconciled === false, notSettled: c.alreadySettled === false }); } } } return result; } /** * Whether a reason has to be given before the day can close. * * Only for a late close. Cash still sitting with a cashier is not something a reason excuses — * see canSubmitClose. */ get needsForceReason(): boolean { return this.isLateClose; } get canSubmitClose(): boolean { const hasFigure = this.closingCash != null || this.closeDenomTotal > 0; if (!this.closeTarget || !hasFigure || this.closing) return false; // Block while cashier/vault data is still loading — the reconciliation check can't run yet. if (this.eodLoading || this.previewLoading) return false; // An unreconciled cashier is a reason-required condition, not an absolute block: the backend // accepts the close once a reason is recorded (and appends the cashiers' names to it), and // refusing outright left a day that could never be closed at all — a cashier who reconciled on // the wrong date, went on leave, or left mid-shift would strand that day and every day after it. if (this.isLateClose || this.hasUnreconciledOrUnsettledCashiers) { return !!this.forceReason.trim(); } return true; } submitClose(): void { if (this.eodLoading || this.previewLoading) { this.errorMsg = 'Still loading the cashier and vault positions — try again in a moment.'; return; } // Safety net mirroring canSubmitClose: never send a close that the backend will refuse for a // missing override reason. if ((this.isLateClose || this.hasUnreconciledOrUnsettledCashiers) && !this.forceReason.trim()) { this.errorMsg = 'Record why this day is being closed before submitting.'; return; } const day = this.closeTarget; const denominations = this.denominationPayload(this.closeCounts); const figure = denominations.length ? this.closeDenomTotal : this.closingCash; if (!day || figure == null) return; this.closing = true; this.errorMsg = ''; this.service .closeBranchDay( day.businessDate, Number(figure), this.closeRemarks.trim() || undefined, // Sent whenever one was typed, not only on a late close: it is also the override the backend // needs for a same-day close over an unreconciled cashier, and gating it on isLateClose // dropped it on exactly the request that could not succeed without it. this.forceReason.trim() || undefined, undefined, denominations ) .pipe( catchError((err: any) => { this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not close the day.'; return of(null); }) ) .subscribe((res: any) => { this.closing = false; if (res) { this.closeTarget = null; const diff = Number(res.cashDifference ?? 0); this.successMsg = diff === 0 ? `Business day ${res.businessDate} closed — cash tallied.` : `Business day ${res.businessDate} closed with a difference of ₹${diff.toFixed(2)}.`; this.load(); } }); } absValue(n: number): number { return Math.abs(n); } /** Short/excess wording, since a bare signed number reads ambiguously on a cash figure. */ differenceLabel(d: number | null): string { if (d == null) return '—'; const n = Number(d); if (n === 0) return 'Tallied'; return n > 0 ? `Excess ₹${Math.abs(n).toFixed(2)}` : `Short ₹${Math.abs(n).toFixed(2)}`; } stamp(iso: string | null): string { if (!iso) return '—'; const [ d, t ] = iso.split('T'); return t ? `${d} ${t.slice(0, 5)}` : d; } timeOnly(iso: string | null): string { if (!iso) return '—'; const t = iso.split('T')[1]; return t ? t.slice(0, 5) : '—'; } }