- Cash Exchange Entry: customer details with PAN (mandatory above Rs 50,000), cashier card, denomination-wise validation against the master and real-time impact preview. - Inter-Cashier Transfer: sender fixed to the logged-in cashier, both sides' details and denomination holdings shown, blocked until both are allocated cash. - Reconciliation, exchange and inter-cashier operate on the logged-in cashier only. - New Branch Cash Position screen (vault + cashier drawers, denomination-wise). - Customer search dialog shows the KYC PAN. - Cashier menu cleanup: Excess/Closing report and Exchange Detail removed, nav compacted to one row. - Reverse actions removed from cash transfer, settlement, exchange and inter-cashier. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
439 lines
15 KiB
TypeScript
439 lines
15 KiB
TypeScript
/**
|
|
* 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<number, string> = {
|
|
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<number, number> = {};
|
|
givenCounts: Record<number, number> = {};
|
|
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<number, number>();
|
|
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;
|
|
}
|
|
});
|
|
}
|
|
}
|