/** * 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 { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { forkJoin, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { CashierService } from '../cashier.service'; import { ClientsService } from 'app/clients/clients.service'; import { CustomerSearchDialogComponent, CustomerSearchResult } from 'app/shared/customer-search-dialog/customer-search-dialog.component'; import { businessToday } from 'app/core/utils/business-today'; interface Denom { id: number; value: number; kind: 'note' | 'coin'; label: string; } /** An existing client picked through Customer Search, with what the counter needs to identify them. */ interface SelectedCustomer { clientId: number; displayName: string; cifNumber: string | null; mobileNo: string | null; officeName: string | null; accountNo: string | null; accountType: string | null; /** PAN on the client's KYC record, if any. */ kycPan: string | null; panVerified: boolean; kycStatus: string; } /** The cashier at the counter: the one who takes the customer's notes and hands over the change. */ interface CashierInfo { cashierId: number; name: string; tellerName: string | null; officeName: string | null; /** Cash the vault allocated to them for the day, once known. */ allocatedAmount: number | null; /** Cash they hold now. */ totalAmount: number; } /** Same rule as ClientKycApiConstants.PAN_REGEX on the server: 5 letters, 4 digits, 1 letter. */ const PAN_PATTERN = /^[A-Z]{5}[0-9]{4}[A-Z]$/; /** Income Tax Rule 114B / RBI: a cash exchange above this needs the customer's PAN. */ const PAN_MANDATORY_ABOVE = 50000; const KYC_STATUS: Record = { 100: 'Pending', 200: 'In Progress', 300: 'Verified', 400: 'Rejected', 500: 'Expired' }; /** Cashier cash-exchange entry: customer gives some denominations and takes equal-value change (net 0). */ @Component({ selector: 'mifosx-cash-exchange-entry', templateUrl: './cash-exchange-entry.component.html', styleUrls: ['./cash-exchange-entry.component.scss'], standalone: true, imports: [ CommonModule, FormsModule, MatIconModule, MatSnackBarModule, MatDialogModule ] }) export class CashExchangeEntryComponent implements OnInit { private service = inject(CashierService); private clientsService = inject(ClientsService); private dialog = inject(MatDialog); private snackBar = inject(MatSnackBar); notes: Denom[] = []; coins: Denom[] = []; receivedCounts: Record = {}; givenCounts: Record = {}; exchangeDate = ''; note = ''; saving = false; // ── Customer info ── /** An existing client found through Customer Search, or a walk-in identified at the counter. */ customerMode: 'existing' | 'walkin' = 'walkin'; selectedCustomer: SelectedCustomer | null = null; loadingCustomer = false; walkInName = ''; walkInMobile = ''; panNumber = ''; // ── Cashier at the counter, and their position (for the denomination overdraft check) ── /** The cashier linked to the logged-in user; null when the login is not a cashier's. */ cashier: CashierInfo | null = null; private availableMap = new Map(); positionLoaded = false; /** All denominations (notes then coins) */ get denoms(): Denom[] { return [ ...this.notes, ...this.coins ]; } ngOnInit(): void { const d = businessToday(); this.exchangeDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; 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 = []; } }); this.service.getCashierPositionForMe().subscribe({ next: (res: any) => { this.availableMap.clear(); ((res?.denominations || []) as any[]).forEach((d: any) => { if (d.denominationId) this.availableMap.set(Number(d.denominationId), Number(d.count ?? 0)); }); const cashierId = res?.cashierId ? Number(res.cashierId) : null; this.cashier = cashierId ? { cashierId, name: res.cashierName || '', tellerName: null, officeName: null, allocatedAmount: null, totalAmount: Number(res.totalAmount ?? 0) } : null; this.positionLoaded = true; if (cashierId) this.loadCashierDetails(cashierId); }, error: () => { this.positionLoaded = true; } }); } /** The counter, branch and today's allocation; the card still shows name and cash in hand without them. */ private loadCashierDetails(cashierId: number): void { this.service .getInterCashierCashierSummary(cashierId, this.exchangeDate) .pipe(catchError(() => of(null))) .subscribe((r: any) => { if (!r || this.cashier?.cashierId !== cashierId) return; this.cashier = { ...this.cashier, tellerName: r.tellerName || null, officeName: r.officeName || null, allocatedAmount: Number(r.allocatedAmount ?? 0) }; }); } /** Up to two letters for the cashier's avatar. */ get cashierInitials(): string { const words = (this.cashier?.name || '').trim().split(/\s+/).filter(Boolean); if (words.length === 0) return '?'; const first = words[0].charAt(0); const last = words.length > 1 ? words[words.length - 1].charAt(0) : ''; return (first + last).toUpperCase(); } countFor(side: 'r' | 'g', d: Denom): number { return (side === 'r' ? this.receivedCounts : this.givenCounts)[d.id] || 0; } subtotal(side: 'r' | 'g', d: Denom): number { return this.countFor(side, d) * d.value; } onChange(side: 'r' | 'g', d: Denom, raw: any): void { (side === 'r' ? this.receivedCounts : this.givenCounts)[d.id] = Math.max(0, Math.floor(Number(raw) || 0)); } bump(side: 'r' | 'g', d: Denom, delta: number): void { const map = side === 'r' ? this.receivedCounts : this.givenCounts; map[d.id] = Math.max(0, (map[d.id] || 0) + delta); } get receivedTotal(): number { return this.denoms.reduce((s, d) => s + this.subtotal('r', d), 0); } get givenTotal(): number { return this.denoms.reduce((s, d) => s + this.subtotal('g', d), 0); } get difference(): number { return this.receivedTotal - this.givenTotal; } get matches(): boolean { return this.receivedTotal > 0 && this.receivedTotal === this.givenTotal; } /** Above ₹50,000 the customer's PAN is mandatory (Income Tax Rule 114B, followed under RBI guidelines). */ get needsPan(): boolean { return this.receivedTotal > PAN_MANDATORY_ABOVE; } /** Up to two letters for the avatar: first and last word of the customer's name. */ get customerInitials(): string { const words = (this.selectedCustomer?.displayName || '').trim().split(/\s+/).filter(Boolean); if (words.length === 0) return '?'; const first = words[0].charAt(0); const last = words.length > 1 ? words[words.length - 1].charAt(0) : ''; return (first + last).toUpperCase(); } get panValue(): string { return this.panNumber.trim().toUpperCase(); } /** A PAN that is entered must be well-formed, required or not. */ get panFormatInvalid(): boolean { return !!this.panValue && !PAN_PATTERN.test(this.panValue); } get panMissing(): boolean { return this.needsPan && !this.panValue; } /** The PAN comes from the client's KYC record, so the cashier confirms it rather than types it. */ get panFromKyc(): boolean { return !!this.selectedCustomer?.kycPan && this.panValue === this.selectedCustomer.kycPan.toUpperCase(); } /** Who the customer is has to be known: a picked client, or a walk-in's name once PAN is required. */ get customerIdentified(): boolean { if (this.customerMode === 'existing') return !!this.selectedCustomer; return !this.needsPan || this.walkInName.trim().length > 0; } /** A login with no cashier behind it has no drawer to take notes into or give change from. */ get noCashier(): boolean { return this.positionLoaded && !this.cashier; } /** How many of the denomination the cashier holds, or null while that is not known (loading, or no cashier). */ availableFor(d: Denom): number | null { if (!this.positionLoaded || !this.cashier) return null; return this.availableMap.get(d.id) ?? 0; } /** The cashier is giving more of this denomination than they hold. */ exceedsAvailable(d: Denom): boolean { const available = this.availableFor(d); return available !== null && this.countFor('g', d) > available; } /** The + on the given side stops at what the cashier holds; a larger typed number is still caught and shown. */ canGiveMore(d: Denom): boolean { const available = this.availableFor(d); return available === null || this.countFor('g', d) < available; } /** What this exchange does to the cashier's count of the denomination: notes taken in less notes given out. */ netFor(d: Denom): number { return this.countFor('r', d) - this.countFor('g', d); } /** The cashier's count of the denomination once this exchange is recorded, or null while it is not known. */ drawerAfter(d: Denom): number | null { const available = this.availableFor(d); return available === null ? null : available + this.netFor(d); } /** * The denominations this exchange changes, with the drawer's count before and after, so the effect shows * while the counts are being typed. The branch's total for each denomination moves by the same amount, as * the customer's notes go into the drawer and the change comes out of it; the vault is not involved. */ get impactRows(): Array<{ id: number; label: string; kind: string; net: number; before: number; after: number }> { if (!this.positionLoaded || !this.cashier) return []; return this.denoms .filter((d) => this.netFor(d) !== 0) .map((d) => ({ id: d.id, label: d.label, kind: d.kind, net: this.netFor(d), before: this.availableFor(d) ?? 0, after: this.drawerAfter(d) ?? 0 })); } /** Denominations where cashier is giving more than currently available. */ get givenOverdraftItems(): Array<{ label: string; available: number; requested: number }> { // Without a cashier every denomination would read as "0 available"; the missing cashier is the // thing to tell the user, not a stock shortage. if (!this.positionLoaded || !this.cashier) return []; return this.denoms .map((d) => ({ label: d.label, available: this.availableMap.get(d.id) ?? 0, requested: this.countFor('g', d) })) .filter((item) => item.requested > 0 && item.requested > item.available); } get hasGivenOverdraft(): boolean { return this.givenOverdraftItems.length > 0; } get valid(): boolean { return ( this.matches && !!this.cashier && !this.hasGivenOverdraft && !this.panFormatInvalid && !this.panMissing && this.customerIdentified && !this.loadingCustomer ); } setCustomerMode(mode: 'existing' | 'walkin'): void { if (this.customerMode === mode) return; this.customerMode = mode; this.selectedCustomer = null; this.walkInName = ''; this.walkInMobile = ''; this.panNumber = ''; } /** Pick an existing client through Customer Search, then load their details and KYC (PAN). */ openCustomerSearch(): void { this.dialog // An existing customer without an account is still a known customer, so the account is not required. .open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', data: { requireAccount: false } }) .afterClosed() .subscribe((result: CustomerSearchResult | undefined) => { if (!result?.clientId) return; this.loadCustomer(result); }); } private loadCustomer(result: CustomerSearchResult): void { this.loadingCustomer = true; const id = String(result.clientId); forkJoin({ client: this.clientsService.getClientData(id, true).pipe(catchError(() => of(null))), kyc: this.clientsService.getClientKyc(id, true).pipe(catchError(() => of(null))) }).subscribe(({ client, kyc }: { client: any; kyc: any }) => { const kycPan: string | null = kyc?.panNumber || result.panNumber || null; this.selectedCustomer = { clientId: result.clientId, displayName: client?.displayName || result.displayName, cifNumber: client?.cifNumber || null, mobileNo: client?.mobileNo || result.mobileNo || null, officeName: client?.officeName || result.officeName || null, accountNo: result.accountNo || null, accountType: result.accountType || null, kycPan, panVerified: !!kyc?.panVerified, kycStatus: KYC_STATUS[Number(kyc?.kycStatus?.id ?? kyc?.kycStatus)] || 'Not on record' }; this.panNumber = kycPan ? kycPan.toUpperCase() : ''; this.loadingCustomer = false; }); } clearCustomer(): void { this.selectedCustomer = null; this.panNumber = ''; } clear(): void { this.receivedCounts = {}; this.givenCounts = {}; this.note = ''; this.customerMode = 'walkin'; this.selectedCustomer = null; this.walkInName = ''; this.walkInMobile = ''; this.panNumber = ''; } submit(): void { if (!this.valid || this.saving) return; this.saving = true; const build = (side: 'r' | 'g') => this.denoms .filter((d) => this.countFor(side, d) > 0) .map((d) => ({ denominationId: d.id, denominationValue: d.value, count: this.countFor(side, d) })); const customer = this.customerMode === 'existing' ? this.selectedCustomer : null; const pan = this.panValue || null; const payload: any = { exchangeDate: this.exchangeDate, note: this.note || null, clientId: customer?.clientId ?? null, accountNo: customer?.accountNo ?? null, customerName: customer ? customer.displayName : this.walkInName.trim() || null, customerMobile: customer ? customer.mobileNo : this.walkInMobile.trim() || null, panNumber: pan, customerIdProof: pan, received: build('r'), given: build('g') }; this.service.createCashExchange(payload).subscribe({ next: () => { this.snackBar.open('Cash exchange recorded', 'Close', { duration: 3000 }); this.clear(); this.saving = false; }, error: () => { this.saving = false; } }); } }