Files
cbsnexora/src/app/current-accounts/current-account-actions/current-account-transactions/current-account-transactions.component.ts
T
Jaanu423andClaude Sonnet 4.6 4ffeb6ee92 feat(cashier): add morning transfer & previous-day settlement hard block
- validateCashierHasMorningTransfer() blocks deposits & withdrawals when no today's cash transfer received
- validatePreviousDayClosed() blocks all transactions when previous day unsettled
- morningTransferReceived field added to cashier-day-status API response
- Warning banners shown in Savings, Current, FD, RD transaction forms
- Submit button disabled when either condition is active
- getCashierTotalCash SQL fixed: dm.denomination_value -> dm.denomination

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:14:29 +05:30

541 lines
20 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/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import {
UntypedFormGroup,
UntypedFormBuilder,
Validators,
UntypedFormControl,
ReactiveFormsModule
} from '@angular/forms';
import { Router, ActivatedRoute, RouterLink } from '@angular/router';
/** Custom Services */
import { CurrentAccountsService } from '../../current-accounts.service';
import { DenominationMasterService } from 'app/system/denomination-master/denomination-master.service';
import { ClientsService } from 'app/clients/clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { CashierService } from 'app/cashier/cashier.service';
import { Dates } from 'app/core/utils/dates';
import { Currency } from 'app/shared/models/general.model';
import { InputAmountComponent } from '../../../shared/input-amount/input-amount.component';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatDialog } from '@angular/material/dialog';
import { TransactionConfirmDialogComponent } from '../../../savings/saving-account-actions/savings-account-transactions/transaction-confirm-dialog/transaction-confirm-dialog.component';
import { IdentityVerifyDialogComponent } from '../../../savings/saving-account-actions/savings-account-transactions/identity-verify-dialog/identity-verify-dialog.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Single Indian denomination row (note or coin). */
interface Denomination {
id: number;
value: number;
kind: 'note' | 'coin';
label: string;
active: boolean;
}
/**
* Create current account transactions component (Deposit / Withdraw).
*/
@Component({
selector: 'mifosx-current-account-transactions',
templateUrl: './current-account-transactions.component.html',
styleUrls: ['./current-account-transactions.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
InputAmountComponent,
MatSlideToggle,
CdkTextareaAutosize,
FaIconComponent
]
})
export class CurrentAccountTransactionsComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private route = inject(ActivatedRoute);
private router = inject(Router);
private dateUtils = inject(Dates);
private currentAccountsService = inject(CurrentAccountsService);
private clientsService = inject(ClientsService);
private settingsService = inject(SettingsService);
private dialog = inject(MatDialog);
private denominationMasterService = inject(DenominationMasterService);
private cashierService = inject(CashierService);
/** Minimum Due Date allowed. */
minDate = new Date(2000, 0, 1);
/** Maximum Due Date allowed. */
maxDate = new Date();
/** Current account transaction form. */
currentAccountTransactionForm: UntypedFormGroup;
/** current account transaction payment options. */
paymentTypeOptions: {
id: number;
name: string;
description: string;
isCashPayment: boolean;
position: number;
}[];
/** Flag to enable payment details fields. */
addPaymentDetailsFlag: Boolean = false;
/** transaction type flag to render required UI */
transactionType: { deposit: boolean; withdrawal: boolean } = { deposit: false, withdrawal: false };
/** transaction command for submit request */
transactionCommand: string;
/** current account's Id */
currentAccountId: string;
currency: Currency | null = null;
/** Banknote denominations — loaded dynamically from the Denomination Master (active only). */
notes: Denomination[] = [];
/** Coin denominations — loaded dynamically from the Denomination Master. */
coins: Denomination[] = [];
/** When false, discontinued (inactive) denominations stay hidden in the grid. */
showInactive = false;
/** Quantity per denomination — keyed by `${kind}:${value}`. */
denominationCounts: Record<string, number> = {};
// ── Context strip data ──────────────────────────────────────────────
accountNo: string | null = null;
clientName: string | null = null;
clientId: number | null = null;
kycStatus: 'Verified' | 'Partial' | 'Pending' = 'Pending';
sameDayNeftRtgs = 0;
sameDayChequeClearing = 0;
availableBalance = 0;
identityVerified = false;
openingDenoms: any[] = [];
openingTotal = 0;
openingLoaded = false;
previousDayUnsettled = false;
morningTransferReceived = true;
unsettledSince = '';
pendingAmount = 0;
get openingNotes(): any[] {
return this.openingDenoms.filter((d) => d.kind !== 'coin');
}
get openingCoins(): any[] {
return this.openingDenoms.filter((d) => d.kind === 'coin');
}
get closingNotes(): any[] {
return this.closingDenoms.filter((d) => d.kind !== 'coin');
}
get closingCoins(): any[] {
return this.closingDenoms.filter((d) => d.kind === 'coin');
}
constructor() {
this.route.data.subscribe((data: { currentAccountActionData: any }) => {
this.paymentTypeOptions = data.currentAccountActionData.paymentTypeOptions;
if (data.currentAccountActionData.currency) {
this.currency = data.currentAccountActionData.currency;
}
});
this.transactionCommand = this.route.snapshot.params['name'].toLowerCase();
this.transactionType[this.transactionCommand as 'deposit' | 'withdrawal'] = true;
this.currentAccountId = this.route.snapshot.params['currentAccountId'];
}
ngOnInit() {
this.maxDate = this.settingsService.businessDate;
this.createCurrentAccountTransactionForm();
this.loadDenominations();
this.loadContextStrip();
this.loadOpeningPosition();
this.loadDayStatus();
}
/** Load active denominations from the Denomination Master so the cash
* breakdown grid reflects the bank's current configuration. */
private loadDenominations(): void {
this.denominationMasterService.getAll().subscribe({
next: (list: any[]) => {
const rows: Denomination[] = (list || []).map((d) => ({
id: d.id,
value: d.denomination,
kind: d.kind as 'note' | 'coin',
label: '₹' + d.denomination,
active: !!d.isActive
}));
const order = (a: Denomination, b: Denomination) => b.value - a.value;
this.notes = rows.filter((r) => r.kind === 'note').sort(order);
this.coins = rows.filter((r) => r.kind === 'coin').sort(order);
},
error: () => {
this.notes = [];
this.coins = [];
}
});
}
/** True when at least one discontinued denomination exists. */
get hasInactive(): boolean {
return [
...this.notes,
...this.coins
].some((d) => !d.active);
}
private loadContextStrip(): void {
if (!this.currentAccountId) return;
this.currentAccountsService.getCurrentAccountData(this.currentAccountId).subscribe({
next: (account: any) => {
this.accountNo = account?.accountNo ?? null;
this.clientName = account?.clientName ?? account?.groupName ?? null;
this.clientId = account?.clientId ?? null;
if (!this.currency && account?.currency) this.currency = account.currency;
this.availableBalance = Number(account?.summary?.availableBalance ?? account?.summary?.accountBalance ?? 0);
this.computeSameDayNeftRtgs(account?.transactions);
this.computeSameDayChequeClearing(account?.transactions);
if (account?.clientId) {
this.fetchClientKyc(String(account.clientId));
}
},
error: () => {}
});
}
private loadDayStatus(): void {
this.cashierService.getCashierDayStatus().subscribe({
next: (res: any) => {
this.previousDayUnsettled = res?.previousDayUnsettled ?? false;
this.morningTransferReceived = res?.morningTransferReceived ?? true;
this.unsettledSince = res?.unsettledSince ?? '';
this.pendingAmount = Number(res?.pendingAmount ?? 0);
},
error: () => {
this.previousDayUnsettled = false;
this.morningTransferReceived = true;
}
});
}
private loadOpeningPosition(): void {
const today = new Date();
const asOfDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
this.cashierService.getCashierPositionForMe(asOfDate).subscribe({
next: (res: any) => {
this.openingDenoms = res?.denominations || [];
this.openingTotal = Number(res?.totalAmount || 0);
this.openingLoaded = true;
},
error: () => {
this.openingDenoms = [];
this.openingTotal = 0;
this.openingLoaded = true;
}
});
}
get closingDenoms(): Array<any> {
const openMap = new Map<string, any>();
for (const d of this.openingDenoms) {
openMap.set(`${d.kind}:${d.denominationValue}`, d);
}
const keySet = new Set<string>();
for (const d of [
...this.notes,
...this.coins
]) keySet.add(this.denomKey(d));
for (const k of openMap.keys()) keySet.add(k);
const sign = this.transactionType.deposit ? 1 : -1;
const result: any[] = [];
for (const key of keySet) {
const open = openMap.get(key);
const denom = [
...this.notes,
...this.coins
].find((d) => this.denomKey(d) === key);
const openCount = open?.count ?? 0;
const enteredCount = denom ? this.countFor(denom) : 0;
const delta = enteredCount * sign;
const closingCount = openCount + delta;
const denomValue = open?.denominationValue ?? denom?.value ?? 0;
const kind = (open?.kind ?? denom?.kind ?? 'note') as string;
if (openCount !== 0 || enteredCount !== 0) {
result.push({
denominationId: open?.denominationId ?? denom?.id ?? 0,
denominationValue: denomValue,
kind,
openCount,
delta,
closingCount,
closingAmount: closingCount * denomValue
});
}
}
return result.sort((a, b) => b.denominationValue - a.denominationValue || a.kind.localeCompare(b.kind));
}
get closingTotal(): number {
const sign = this.transactionType.deposit ? 1 : -1;
return this.openingTotal + this.denominationTotal * sign;
}
private fetchClientKyc(clientId: string): void {
this.clientsService.getClientKyc(clientId).subscribe({
next: (kyc: any) => {
const pan = !!kyc?.panVerified;
const aadhaar = !!kyc?.aadhaarVerified;
this.kycStatus = pan && aadhaar ? 'Verified' : pan || aadhaar ? 'Partial' : 'Pending';
},
error: () => {}
});
}
private computeSameDayNeftRtgs(transactions: any[] | undefined): void {
if (!Array.isArray(transactions) || transactions.length === 0) {
this.sameDayNeftRtgs = 0;
return;
}
const today = this.settingsService.businessDate ?? new Date();
const sameDay = (d: any) => {
if (!d) return false;
const t = Array.isArray(d) && d.length >= 3 ? new Date(d[0], d[1] - 1, d[2]) : new Date(d);
return (
t.getFullYear() === today.getFullYear() && t.getMonth() === today.getMonth() && t.getDate() === today.getDate()
);
};
const isNeftRtgs = (t: any) => {
const txnType = (t?.transactionType?.value ?? t?.transactionType?.code ?? '').toUpperCase();
const payType = (t?.paymentDetailData?.paymentType?.name ?? '').toUpperCase();
return /NEFT|RTGS/.test(txnType) || /NEFT|RTGS/.test(payType);
};
this.sameDayNeftRtgs = transactions
.filter((t: any) => !t.reversed && sameDay(t.date) && isNeftRtgs(t))
.reduce((sum, t: any) => sum + (Number(t.amount) || 0), 0);
}
private computeSameDayChequeClearing(transactions: any[] | undefined): void {
if (!Array.isArray(transactions) || transactions.length === 0) {
this.sameDayChequeClearing = 0;
return;
}
const today = this.settingsService.businessDate ?? new Date();
const sameDay = (d: any) => {
if (!d) return false;
const t = Array.isArray(d) && d.length >= 3 ? new Date(d[0], d[1] - 1, d[2]) : new Date(d);
return (
t.getFullYear() === today.getFullYear() && t.getMonth() === today.getMonth() && t.getDate() === today.getDate()
);
};
const isCheque = (t: any) => {
const txnType = (t?.transactionType?.value ?? t?.transactionType?.code ?? '').toUpperCase();
const payType = (t?.paymentDetailData?.paymentType?.name ?? '').toUpperCase();
const hasCheckNo = !!t?.paymentDetailData?.checkNumber;
return hasCheckNo || /CHEQUE|CHECK/.test(txnType) || /CHEQUE|CHECK/.test(payType);
};
const isPending = (t: any) => {
const cleared = t?.transactionType?.bankTransfer === true || t?.transactionType?.deposit === true;
return !cleared;
};
this.sameDayChequeClearing = transactions
.filter((t: any) => !t.reversed && sameDay(t.date) && isCheque(t) && isPending(t))
.reduce((sum, t: any) => sum + (Number(t.amount) || 0), 0);
}
createCurrentAccountTransactionForm() {
this.currentAccountTransactionForm = this.formBuilder.group({
transactionDate: [
this.settingsService.businessDate,
Validators.required
],
transactionAmount: [
0,
Validators.required
],
paymentTypeId: [
'',
Validators.required
],
note: ['']
});
}
// ── Denomination helpers ────────────────────────────────────────────
denomKey(d: Denomination): string {
return `${d.kind}:${d.value}`;
}
countFor(d: Denomination): number {
return this.denominationCounts[this.denomKey(d)] || 0;
}
subtotalFor(d: Denomination): number {
return this.countFor(d) * d.value;
}
onDenomChange(d: Denomination, raw: any): void {
const n = Math.max(0, Math.floor(Number(raw) || 0));
this.denominationCounts[this.denomKey(d)] = n;
}
bumpDenom(d: Denomination, delta: number): void {
const next = Math.max(0, this.countFor(d) + delta);
this.denominationCounts[this.denomKey(d)] = next;
}
get notesTotal(): number {
return this.notes.reduce((s, d) => s + this.subtotalFor(d), 0);
}
get coinsTotal(): number {
return this.coins.reduce((s, d) => s + this.subtotalFor(d), 0);
}
get denominationTotal(): number {
return this.notesTotal + this.coinsTotal;
}
get totalPieces(): number {
return Object.values(this.denominationCounts).reduce((s, n) => s + (n || 0), 0);
}
get denominationMatchesForm(): boolean {
const formAmt = Number(this.currentAccountTransactionForm?.controls?.['transactionAmount']?.value || 0);
return this.denominationTotal > 0 && formAmt === this.denominationTotal;
}
get withdrawalDenomOverdraft(): Array<{ label: string; available: number; requested: number }> {
if (!this.transactionType.withdrawal || !this.openingLoaded) return [];
const openMap = new Map<string, number>();
for (const d of this.openingDenoms) {
openMap.set(`${d.kind}:${d.denominationValue}`, Number(d.count ?? 0));
}
return [
...this.notes,
...this.coins
]
.map((d) => ({ label: d.label, available: openMap.get(this.denomKey(d)) ?? 0, requested: this.countFor(d) }))
.filter((item) => item.requested > 0 && item.requested > item.available);
}
get hasWithdrawalDenomOverdraft(): boolean {
return this.withdrawalDenomOverdraft.length > 0;
}
applyDenominationTotal(): void {
this.currentAccountTransactionForm.controls['transactionAmount'].setValue(this.denominationTotal);
this.currentAccountTransactionForm.controls['transactionAmount'].markAsDirty();
}
clearDenominations(): void {
this.denominationCounts = {};
}
openIdentityVerify(): void {
if (!this.clientId) return;
this.identityVerified = true;
this.dialog.open(IdentityVerifyDialogComponent, {
width: '500px',
maxWidth: '92vw',
panelClass: 'idv-panel',
autoFocus: false,
data: {
clientId: this.clientId,
clientName: this.clientName,
accountNo: this.accountNo
}
});
}
/**
* Method to add payment detail fields to the UI.
*/
addPaymentDetails() {
this.addPaymentDetailsFlag = !this.addPaymentDetailsFlag;
if (this.addPaymentDetailsFlag) {
this.currentAccountTransactionForm.addControl('accountNumber', new UntypedFormControl(''));
this.currentAccountTransactionForm.addControl('checkNumber', new UntypedFormControl(''));
this.currentAccountTransactionForm.addControl('routingCode', new UntypedFormControl(''));
this.currentAccountTransactionForm.addControl('receiptNumber', new UntypedFormControl(''));
this.currentAccountTransactionForm.addControl('bankNumber', new UntypedFormControl(''));
} else {
this.currentAccountTransactionForm.removeControl('accountNumber');
this.currentAccountTransactionForm.removeControl('checkNumber');
this.currentAccountTransactionForm.removeControl('routingCode');
this.currentAccountTransactionForm.removeControl('receiptNumber');
this.currentAccountTransactionForm.removeControl('bankNumber');
}
}
/**
* Opens a confirmation dialog then posts the transaction.
*/
submit() {
const formAmount = Number(this.currentAccountTransactionForm.value.transactionAmount) || 0;
const paymentTypeId = this.currentAccountTransactionForm.value.paymentTypeId;
const paymentTypeLabel = this.paymentTypeOptions?.find((p) => p.id === paymentTypeId)?.name ?? null;
const ref = this.dialog.open(TransactionConfirmDialogComponent, {
width: '480px',
maxWidth: '92vw',
panelClass: 'tcd-panel',
autoFocus: false,
data: {
kind: this.transactionType.deposit ? 'deposit' : 'withdrawal',
clientName: this.clientName,
accountNo: this.accountNo,
currentBalance: this.availableBalance,
transactionAmount: formAmount,
paymentTypeLabel
}
});
ref.afterClosed().subscribe((result: any) => {
if (result?.confirm) {
this.postTransaction();
}
});
}
private postTransaction(): void {
const currentAccountTransactionFormData = this.currentAccountTransactionForm.value;
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const prevTransactionDate: Date = this.currentAccountTransactionForm.value.transactionDate;
if (currentAccountTransactionFormData.transactionDate instanceof Date) {
currentAccountTransactionFormData.transactionDate = this.dateUtils.formatDate(prevTransactionDate, dateFormat);
}
const data = {
...currentAccountTransactionFormData,
dateFormat,
locale
};
data['transactionAmount'] = data['transactionAmount'] * 1;
if (this.denominationTotal > 0) {
const breakdown = [
...this.notes,
...this.coins
]
.map((d) => ({
denominationId: d.id,
denominationValue: d.value,
kind: d.kind,
count: this.countFor(d),
subtotal: this.subtotalFor(d)
}))
.filter((row) => row.count > 0);
data['denominationBreakdown'] = breakdown;
}
this.currentAccountsService
.executeCurrentAccountTransactionsCommand(this.currentAccountId, this.transactionCommand, data)
.subscribe(() => {
this.router.navigate(['../../transactions'], { relativeTo: this.route });
});
}
}