/** * 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, Router } from '@angular/router'; import { catchError, of } from 'rxjs'; import { CashierService } from '../../cashier/cashier.service'; import { SettingsService } from 'app/settings/settings.service'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; interface Denom { id: number; value: number; kind: 'note' | 'coin'; label: string; } /** * Migrate Cash Denomination — the vault-side counterpart to Migrate Opening Balances. * * A freshly set-up institution's chart of accounts carries a "Cash at Vault" GL balance (defined via * Migrate Opening Balances, or not yet defined at all), but that figure is meaningless to a cashier * standing at the drawer until it is broken into actual notes and coins, and the branch has nowhere * to track that breakdown until its first business day is open. This screen is the one place both of * those happen together: count the drawer, and it either defines the cash GL's opening balance from * that count (nothing posted to it yet) or checks the count against what is already posted, then * opens the business day with that same breakdown - so a new bank goes from "GL seeded" straight to * "vault countable" without a separate trip through Manage Tellers or Open Business Day first. */ @Component({ selector: 'mifosx-migrate-cash-denomination', templateUrl: './migrate-cash-denomination.component.html', styleUrls: ['./migrate-cash-denomination.component.scss'], standalone: true, imports: [ CommonModule, FormsModule, MatIconModule, ...STANDALONE_SHARED_IMPORTS ] }) export class MigrateCashDenominationComponent implements OnInit { private service = inject(CashierService); private settingsService = inject(SettingsService); private route = inject(ActivatedRoute); private router = inject(Router); officeData: any[] = []; officeId: number | null = null; /** Bound to the datepicker as a Date, converted to a strict yyyy-MM-dd string only when * submitting — the branchday endpoints parse this with LocalDate.parse() directly, with no * locale/dateFormat hint alongside it, so a locale-formatted string (what dateUtils.formatDate * produces) is rejected outright. */ businessDate: Date = new Date(); maxDate = new Date(); glLoading = false; glStatus: any = null; glError = ''; notes: Denom[] = []; coins: Denom[] = []; counts: Record = {}; submitting = false; errorMsg = ''; successMsg = ''; result: any = null; /** Only asked for when the counted total disagrees with an already-posted GL balance — mirrors * the same "explain the gap" gate Open Business Day applies. */ differenceReason = ''; get allDenoms(): Denom[] { return [ ...this.notes, ...this.coins ]; } ngOnInit(): void { this.maxDate = this.settingsService.businessDate; this.businessDate = this.maxDate; this.route.data.subscribe((data: { offices: any }) => { this.officeData = data.offices || []; }); 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 = []; } }); } onOfficeChange(): void { this.glStatus = null; this.glError = ''; this.result = null; this.successMsg = ''; this.errorMsg = ''; if (!this.officeId) return; this.glLoading = true; this.service .cashGlStatus(this.officeId) .pipe(catchError(() => of(null))) .subscribe((res: any) => { this.glLoading = false; this.glStatus = res; if (res && res.configured === false) { this.glError = res.message || 'No cash GL account is configured for this tenant yet.'; } }); } countFor(d: Denom): number { return this.counts[d.id] || 0; } subtotalFor(d: Denom): number { return this.countFor(d) * d.value; } onCountChange(d: Denom, raw: any): void { this.counts[d.id] = Math.max(0, Math.floor(Number(raw) || 0)); } bumpCount(d: Denom, delta: number): void { this.counts[d.id] = Math.max(0, (this.counts[d.id] || 0) + delta); } get countedTotal(): number { return this.allDenoms.reduce((s, d) => s + this.subtotalFor(d), 0); } /** Whether this office's GL already carries a balance the count has to match, vs. an empty * account the count itself will define. */ get glHasBalance(): boolean { return !!this.glStatus?.hasBalance; } get glTarget(): number { return Number(this.glStatus?.currentBalance ?? 0); } get isReconciled(): boolean { if (!this.glStatus || this.glStatus.configured === false) return false; if (this.countedTotal <= 0) return false; return !this.glHasBalance || this.countedTotal === this.glTarget; } get needsDifferenceReason(): boolean { return ( this.glHasBalance && this.countedTotal > 0 && this.countedTotal !== this.glTarget && !this.differenceReason.trim() ); } get canSubmit(): boolean { return ( !!this.officeId && !!this.businessDate && !this.glError && this.countedTotal > 0 && !this.needsDifferenceReason && !this.submitting ); } private denominationPayload() { return this.allDenoms .filter((d) => this.countFor(d) > 0) .map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(d) })); } /** Strict yyyy-MM-dd, independent of the viewer's locale/date-format setting — see the comment on * `businessDate` for why this can't go through dateUtils.formatDate(). */ private toIsoDate(d: Date): string { const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } submit(): void { if (!this.canSubmit || !this.officeId) return; this.submitting = true; this.errorMsg = ''; this.successMsg = ''; this.service .migrateCashDenomination( this.officeId, this.toIsoDate(this.businessDate), this.denominationPayload(), this.differenceReason.trim() || undefined ) .pipe( catchError((err: any) => { this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.message ?? 'Could not migrate cash denomination.'; return of(null); }) ) .subscribe((res: any) => { this.submitting = false; if (res) { this.result = res; this.successMsg = res.glOpeningBalancePosted ? `Cash GL opening balance defined at ₹${this.countedTotal.toLocaleString('en-IN')} and business day ${res.businessDate} opened.` : `Business day ${res.businessDate} opened — count matched the existing GL balance.`; } }); } goToBranchDay(): void { this.router.navigate(['/branch-ops/day-register']); } }