- 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>
450 lines
16 KiB
TypeScript
450 lines
16 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 {
|
|
UntypedFormBuilder,
|
|
UntypedFormControl,
|
|
UntypedFormGroup,
|
|
Validators,
|
|
ReactiveFormsModule
|
|
} from '@angular/forms';
|
|
import { FixedDepositsService } from '../../fixed-deposits.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 { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
|
import { Currency, PaymentType } from 'app/shared/models/general.model';
|
|
import { TransactionCommand, TransactionTypeFlags } from '../../../transaction.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';
|
|
|
|
interface Denomination {
|
|
id: number;
|
|
value: number;
|
|
kind: 'note' | 'coin';
|
|
label: string;
|
|
active: boolean;
|
|
}
|
|
|
|
@Component({
|
|
selector: 'mifosx-fixed-deposits-cash-transaction',
|
|
templateUrl: './fixed-deposits-cash-transaction.component.html',
|
|
styleUrls: ['./fixed-deposits-cash-transaction.component.scss'],
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
InputAmountComponent,
|
|
MatSlideToggle,
|
|
CdkTextareaAutosize,
|
|
FaIconComponent
|
|
]
|
|
})
|
|
export class FixedDepositsCashTransactionComponent implements OnInit {
|
|
private formBuilder = inject(UntypedFormBuilder);
|
|
private route = inject(ActivatedRoute);
|
|
private router = inject(Router);
|
|
private dateUtils = inject(Dates);
|
|
private fixedDepositsService = inject(FixedDepositsService);
|
|
private clientsService = inject(ClientsService);
|
|
private settingsService = inject(SettingsService);
|
|
private dialog = inject(MatDialog);
|
|
private denominationMasterService = inject(DenominationMasterService);
|
|
private cashierService = inject(CashierService);
|
|
|
|
minDate = new Date(2000, 0, 1);
|
|
maxDate = new Date();
|
|
accountTransactionForm: UntypedFormGroup;
|
|
paymentTypeOptions: PaymentType[] = [];
|
|
addPaymentDetailsFlag: Boolean = false;
|
|
transactionType: TransactionTypeFlags = { deposit: false, withdrawal: false };
|
|
transactionCommand: TransactionCommand;
|
|
actionName: string;
|
|
accountId: string;
|
|
currency: Currency;
|
|
|
|
notes: Denomination[] = [];
|
|
|
|
coins: Denomination[] = [];
|
|
|
|
/** When false, discontinued (inactive) denominations stay hidden in the grid. */
|
|
showInactive = false;
|
|
|
|
denominationCounts: Record<string, number> = {};
|
|
|
|
accountNo: string | null = null;
|
|
clientName: string | null = null;
|
|
clientId: number | null = null;
|
|
kycStatus: 'Verified' | 'Partial' | 'Pending' = 'Pending';
|
|
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: { fixedDepositsAccountActionData: any }) => {
|
|
this.currency = data.fixedDepositsAccountActionData.currency;
|
|
this.paymentTypeOptions = data.fixedDepositsAccountActionData.paymentTypeOptions;
|
|
});
|
|
this.actionName = this.route.snapshot.params['name'];
|
|
const lowerName = this.actionName.toLowerCase();
|
|
if (lowerName === 'deposit' || lowerName === 'withdrawal') {
|
|
this.transactionCommand = lowerName;
|
|
this.transactionType[this.transactionCommand] = true;
|
|
} else {
|
|
throw new Error(`Invalid transaction action: ${this.actionName}`);
|
|
}
|
|
this.accountId = this.route.parent.snapshot.params['fixedDepositAccountId'];
|
|
}
|
|
|
|
ngOnInit() {
|
|
this.maxDate = this.settingsService.businessDate;
|
|
this.createSavingAccountTransactionForm();
|
|
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.accountId) return;
|
|
this.fixedDepositsService.getFixedDepositsAccountData(this.accountId).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?.accountBalance ?? account?.summary?.availableBalance ?? 0);
|
|
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: () => {}
|
|
});
|
|
}
|
|
|
|
createSavingAccountTransactionForm() {
|
|
this.accountTransactionForm = this.formBuilder.group({
|
|
transactionDate: [
|
|
this.settingsService.businessDate,
|
|
Validators.required
|
|
],
|
|
transactionAmount: [
|
|
0,
|
|
Validators.required
|
|
],
|
|
paymentTypeId: [''],
|
|
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.accountTransactionForm?.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.accountTransactionForm.controls['transactionAmount'].setValue(this.denominationTotal);
|
|
this.accountTransactionForm.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 }
|
|
});
|
|
}
|
|
|
|
addPaymentDetails() {
|
|
this.addPaymentDetailsFlag = !this.addPaymentDetailsFlag;
|
|
if (this.addPaymentDetailsFlag) {
|
|
this.accountTransactionForm.addControl('accountNumber', new UntypedFormControl(''));
|
|
this.accountTransactionForm.addControl('checkNumber', new UntypedFormControl(''));
|
|
this.accountTransactionForm.addControl('routingCode', new UntypedFormControl(''));
|
|
this.accountTransactionForm.addControl('receiptNumber', new UntypedFormControl(''));
|
|
this.accountTransactionForm.addControl('bankNumber', new UntypedFormControl(''));
|
|
} else {
|
|
this.accountTransactionForm.removeControl('accountNumber');
|
|
this.accountTransactionForm.removeControl('checkNumber');
|
|
this.accountTransactionForm.removeControl('routingCode');
|
|
this.accountTransactionForm.removeControl('receiptNumber');
|
|
this.accountTransactionForm.removeControl('bankNumber');
|
|
}
|
|
}
|
|
|
|
submit() {
|
|
const formAmount = Number(this.accountTransactionForm.value.transactionAmount) || 0;
|
|
const paymentTypeId = this.accountTransactionForm.value.paymentTypeId;
|
|
const paymentTypeLabel = this.paymentTypeOptions?.find((p: any) => 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 formData = this.accountTransactionForm.value;
|
|
const locale = this.settingsService.language.code;
|
|
const dateFormat = this.settingsService.dateFormat;
|
|
const prevDate: Date = this.accountTransactionForm.value.transactionDate;
|
|
if (formData.transactionDate instanceof Date) {
|
|
formData.transactionDate = this.dateUtils.formatDate(prevDate, dateFormat);
|
|
}
|
|
const data = { ...formData, dateFormat, locale };
|
|
delete data.note;
|
|
data['transactionAmount'] = data['transactionAmount'] * 1;
|
|
if (this.denominationTotal > 0) {
|
|
data['denominationBreakdown'] = [
|
|
...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);
|
|
}
|
|
this.fixedDepositsService
|
|
.executeFixedDepositsAccountTransactionsCommand(this.accountId, this.transactionCommand, data)
|
|
.subscribe(() => {
|
|
this.router.navigate(['../../transactions'], { relativeTo: this.route });
|
|
});
|
|
}
|
|
}
|