/** * 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 { Router, RouterModule } from '@angular/router'; import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { MatInputModule } from '@angular/material/input'; import { forkJoin, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { CashierService } from '../cashier.service'; import { businessToday } from 'app/core/utils/business-today'; type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form'; interface SettleDenomRow { denominationId: number; denominationValue: number; kind: 'note' | 'coin'; morningCount: number; expectedCount: number; physicalCount: number; } /** * End-of-day cash settlement with full validation: * - Auto-detects logged-in cashier via /cashreports/cashier-position/me * - Supervisor/admin mode: manual teller + cashier selection when not a cashier * - Blocks if reconciliation not done today * - Blocks if already settled today * - Pre-fills physical counts from system expected balance * - Requires variance reason when physical ≠ expected * - Backend enforces reconciliation-before-settlement and no duplicate per day */ @Component({ selector: 'mifosx-cash-settlement', templateUrl: './cash-settlement.component.html', styleUrls: [ '../cashier-shared.scss', './cash-settlement.component.scss' ], standalone: true, imports: [ CommonModule, FormsModule, RouterModule, MatIconModule, MatSnackBarModule, MatFormFieldModule, MatSelectModule, MatInputModule ] }) export class CashSettlementComponent implements OnInit { private service = inject(CashierService); private snackBar = inject(MatSnackBar); private router = inject(Router); pageState: PageState = 'loading'; isSupervisor = false; // Cashier being settled (auto-detected for cashier, manually selected for admin) myCashierId: number | null = null; myCashierName = ''; // Supervisor mode: teller selector to filter cashiers supervisorTellerId: number | null = null; supervisorCashiers: { id: number; label: string }[] = []; loadingCashiers = false; supervisorChecking = false; rows: SettleDenomRow[] = []; morningTotal = 0; settlementDate = ''; // Vault teller to deposit TO tellers: { id: number; name: string }[] = []; selectedTellerId: number | null = null; toParty = ''; note = ''; varianceReason = ''; saving = false; ngOnInit(): void { const d = businessToday(); this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; this.service .getTellers() .pipe(catchError(() => of([] as any[]))) .subscribe((list: any[]) => { this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name })); if (this.tellers.length === 1) { this.onTellerSelect(this.tellers[0].id); this.onSupervisorTellerSelect(this.tellers[0].id); } }); this.loadData(); } loadData(): void { this.pageState = 'loading'; this.rows = []; this.service .getCashierPositionForMe(this.settlementDate) .pipe(catchError(() => of(null))) .subscribe((pos: any) => { if (!pos?.cashierId) { // Not a cashier — supervisor/admin mode this.isSupervisor = true; this.pageState = 'no-cashier'; return; } this.isSupervisor = false; this.myCashierId = pos.cashierId; this.myCashierName = pos.cashierName || ''; this.loadCashierChecks(pos.cashierId); }); } // Called after cashier is known (auto or supervisor-selected) private loadCashierChecks(cashierId: number): void { forkJoin({ expected: this.service.getExpectedBalance(cashierId, this.settlementDate).pipe(catchError(() => of(null))), check: this.service .checkSettlement(cashierId, this.settlementDate) .pipe(catchError(() => of({ alreadySettled: false }))), recon: this.service .checkReconciliation(cashierId, this.settlementDate) .pipe(catchError(() => of({ reconciled: false }))), morning: this.service.getCashierMorningBalance(cashierId, this.settlementDate).pipe(catchError(() => of(null))) }).subscribe(({ expected, check, recon, morning }) => { this.supervisorChecking = false; if (check?.alreadySettled) { this.pageState = 'already-settled'; return; } if (!recon?.reconciled) { this.pageState = 'needs-reconciliation'; return; } // Build denomination → morningCount map const morningMap = new Map(); ((morning?.denominations || []) as any[]).forEach((d: any) => morningMap.set(d.denominationId, d.morningCount ?? 0) ); this.morningTotal = Number(morning?.totalMorning ?? 0); this.rows = ((expected?.denominations as any[]) || []) .filter((d: any) => (d.expectedCount ?? 0) > 0) .sort((a: any, b: any) => b.denominationValue - a.denominationValue) .map((d: any) => ({ denominationId: d.denominationId, denominationValue: d.denominationValue, kind: (d.kind ?? 'note') as 'note' | 'coin', morningCount: morningMap.get(d.denominationId) ?? 0, expectedCount: d.expectedCount, physicalCount: d.expectedCount })); this.pageState = 'form'; }); } // Supervisor: teller selected → load cashiers for that teller onSupervisorTellerSelect(tellerId: number): void { this.supervisorTellerId = tellerId; this.supervisorCashiers = []; this.myCashierId = null; this.myCashierName = ''; this.rows = []; if (!tellerId) return; this.loadingCashiers = true; this.service .getCashiersForTeller(tellerId) .pipe(catchError(() => of({ cashiers: [] }))) .subscribe((r: any) => { this.loadingCashiers = false; this.supervisorCashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName })); }); } // Supervisor: cashier selected → run checks onSupervisorCashierSelect(cashierId: number): void { const c = this.supervisorCashiers.find((x) => x.id === cashierId); this.myCashierId = cashierId; this.myCashierName = c ? c.label : ''; this.rows = []; this.supervisorChecking = true; this.loadCashierChecks(cashierId); } get noteRows(): SettleDenomRow[] { return this.rows.filter((r) => r.kind !== 'coin'); } get coinRows(): SettleDenomRow[] { return this.rows.filter((r) => r.kind === 'coin'); } get morningNoteTotal(): number { return this.noteRows.reduce((s, r) => s + r.morningCount * r.denominationValue, 0); } get morningCoinTotal(): number { return this.coinRows.reduce((s, r) => s + r.morningCount * r.denominationValue, 0); } varianceFor(row: SettleDenomRow): number { return row.physicalCount - row.expectedCount; } get totalExpected(): number { return this.rows.reduce((s, r) => s + r.denominationValue * r.expectedCount, 0); } get totalPhysical(): number { return this.rows.reduce((s, r) => s + r.denominationValue * r.physicalCount, 0); } get totalVariance(): number { return this.totalPhysical - this.totalExpected; } get hasVariance(): boolean { return this.totalVariance !== 0; } updatePhysical(row: SettleDenomRow, raw: any): void { row.physicalCount = Math.max(0, Math.floor(Number(raw) || 0)); } bump(row: SettleDenomRow, delta: number): void { row.physicalCount = Math.max(0, row.physicalCount + delta); } goToReconciliation(): void { this.router.navigate(['/cashier/reconciliation']); } onTellerSelect(id: number): void { this.selectedTellerId = id; const t = this.tellers.find((x) => x.id === id); this.toParty = t ? t.name : ''; } get valid(): boolean { return ( !!this.selectedTellerId && this.totalPhysical > 0 && (!this.hasVariance || this.varianceReason.trim().length > 0) ); } submit(): void { if (!this.valid || this.saving) return; this.saving = true; const payload = { settlementDate: this.settlementDate, fromCashierId: this.myCashierId, toTellerId: this.selectedTellerId, fromParty: this.myCashierName, toParty: this.toParty, note: [ this.note, this.hasVariance ? `Variance reason: ${this.varianceReason}` : null ] .filter(Boolean) .join(' | ') || null, denominations: this.rows .filter((r) => r.physicalCount > 0) .map((r) => ({ denominationId: r.denominationId, denominationValue: r.denominationValue, count: r.physicalCount })) }; this.service.createCashSettlement(payload).subscribe({ next: (res: any) => { if (res?.glWarning) { this.snackBar.open(`Settlement saved (GL warning: ${res.glWarning})`, 'Close', { duration: 6000 }); } else { this.snackBar.open('Cash settlement recorded successfully', 'Close', { duration: 3000 }); } this.pageState = 'already-settled'; this.saving = false; }, error: (err: any) => { const msg = err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.developerMessage || 'Settlement failed. Please try again.'; this.snackBar.open(msg, 'Close', { duration: 6000 }); this.saving = false; } }); } }