- KYC tab: fix Verify (PAN) and Send OTP (Aadhaar) being disabled in edit mode; unmask Aadhaar input and show on-file masked value as hint; resolve kycId from id/kycId/resourceId fallbacks. - Client stepper/view: add familyAllowed / nomineeAllowed predicates + CanActivate route guards. Family tab hidden for non-individuals, Nominee tab hidden for non-proprietor entities. Family-members step accepts a nomineeOnly input for proprietorship clients. Add/Edit/Delete buttons inside family-members and nominee tabs gated by canAdd. - cfcfrms: add cfcfrms-shell wrapper, restyle complaint/fraud/mule/escalate pages and dashboard. - Savings: identity-verify and transaction-confirm dialogs, manage / activate / add-charge flows, account preview styling. - Tasks, core shell, account-nominees, icons: minor style and behaviour updates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
451 lines
17 KiB
TypeScript
451 lines
17 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 { SavingsService } from '../../savings.service';
|
|
import { ClientsService } from 'app/clients/clients.service';
|
|
import { SettingsService } from 'app/settings/settings.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 './transaction-confirm-dialog/transaction-confirm-dialog.component';
|
|
import { IdentityVerifyDialogComponent } from './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 {
|
|
value: number;
|
|
kind: 'note' | 'coin';
|
|
label: string;
|
|
}
|
|
|
|
/**
|
|
* Create savings account transactions component (Deposit / Withdraw).
|
|
*
|
|
* Banking-grade redesign: gradient hero, denomination breakdown grid
|
|
* (Indian notes ₹500/₹200/₹100/₹50/₹20/₹10 + coins ₹20/₹10/₹5/₹2/Re 1)
|
|
* with live total + auto-sync into transactionAmount, payment & note
|
|
* panel below.
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-savings-transactions',
|
|
templateUrl: './savings-account-transactions.component.html',
|
|
styleUrls: ['./savings-account-transactions.component.scss'],
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
InputAmountComponent,
|
|
MatSlideToggle,
|
|
CdkTextareaAutosize,
|
|
FaIconComponent
|
|
]
|
|
})
|
|
export class SavingsAccountTransactionsComponent implements OnInit {
|
|
private formBuilder = inject(UntypedFormBuilder);
|
|
private route = inject(ActivatedRoute);
|
|
private router = inject(Router);
|
|
private dateUtils = inject(Dates);
|
|
private savingsService = inject(SavingsService);
|
|
private clientsService = inject(ClientsService);
|
|
private settingsService = inject(SettingsService);
|
|
private dialog = inject(MatDialog);
|
|
|
|
/** Minimum Due Date allowed. */
|
|
minDate = new Date(2000, 0, 1);
|
|
/** Maximum Due Date allowed. */
|
|
maxDate = new Date();
|
|
/** Savings account transaction form. */
|
|
savingAccountTransactionForm: UntypedFormGroup;
|
|
/** savings 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;
|
|
/** saving account's Id */
|
|
savingAccountId: string;
|
|
currency: Currency | null = null;
|
|
|
|
/** Indian banknote denominations (descending). */
|
|
readonly notes: Denomination[] = [
|
|
{ value: 500, kind: 'note', label: '₹500' },
|
|
{ value: 200, kind: 'note', label: '₹200' },
|
|
{ value: 100, kind: 'note', label: '₹100' },
|
|
{ value: 50, kind: 'note', label: '₹50' },
|
|
{ value: 20, kind: 'note', label: '₹20' },
|
|
{ value: 10, kind: 'note', label: '₹10' }
|
|
];
|
|
|
|
/** Indian coin denominations (descending). */
|
|
readonly coins: Denomination[] = [
|
|
{ value: 20, kind: 'coin', label: '₹20' },
|
|
{ value: 10, kind: 'coin', label: '₹10' },
|
|
{ value: 5, kind: 'coin', label: '₹5' },
|
|
{ value: 2, kind: 'coin', label: '₹2' },
|
|
{ value: 1, kind: 'coin', label: 'Re 1' }
|
|
];
|
|
|
|
/** Quantity per denomination — keyed by `${kind}:${value}`. */
|
|
denominationCounts: Record<string, number> = {};
|
|
|
|
// ── Context strip data (populated async) ───────────────────────────
|
|
/** Account number of the savings account. */
|
|
accountNo: string | null = null;
|
|
/** Client / group display name. */
|
|
clientName: string | null = null;
|
|
/** Client id (if this savings account is owned by a client, not a group)
|
|
* — used to open the identity-verify dialog on the withdrawal screen. */
|
|
clientId: number | null = null;
|
|
/** Best-effort KYC status: 'Verified' | 'Partial' | 'Pending'. */
|
|
kycStatus: 'Verified' | 'Partial' | 'Pending' = 'Pending';
|
|
/** Sum of NEFT/RTGS transactions on the current business day for this
|
|
* account — used as an AML hint for the cashier. */
|
|
sameDayNeftRtgs = 0;
|
|
/** Sum of same-day cheque transactions that are still awaiting
|
|
* clearing for this account — pre-empts duplicate cash credit. */
|
|
sameDayChequeClearing = 0;
|
|
/** Live available balance of the savings account. */
|
|
availableBalance = 0;
|
|
/** Tracks whether the cashier has opened the identity-verify pop-up
|
|
* at least once on this transaction; toggles off the attention pulse. */
|
|
identityVerified = false;
|
|
|
|
constructor() {
|
|
this.route.data.subscribe((data: { savingsAccountActionData: any }) => {
|
|
this.paymentTypeOptions = data.savingsAccountActionData.paymentTypeOptions;
|
|
if (data.savingsAccountActionData.currency) {
|
|
this.currency = data.savingsAccountActionData.currency;
|
|
}
|
|
});
|
|
this.transactionCommand = this.route.snapshot.params['name'].toLowerCase();
|
|
this.transactionType[this.transactionCommand as 'deposit' | 'withdrawal'] = true;
|
|
this.savingAccountId = this.route.snapshot.params['savingAccountId'];
|
|
}
|
|
|
|
ngOnInit() {
|
|
this.maxDate = this.settingsService.businessDate;
|
|
this.createSavingAccountTransactionForm();
|
|
this.loadContextStrip();
|
|
}
|
|
|
|
/**
|
|
* Pull the savings account + client to populate the cashier's context
|
|
* strip (account number, client name, KYC status, risk level, same-day
|
|
* NEFT/RTGS aggregate). Each fetch fails silently — the strip just
|
|
* shows '—' for whatever wasn't returned.
|
|
*/
|
|
private loadContextStrip(): void {
|
|
if (!this.savingAccountId) return;
|
|
this.savingsService.getSavingsAccountData(this.savingAccountId).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: () => {
|
|
// Leave defaults in place — strip falls back to '—'.
|
|
}
|
|
});
|
|
}
|
|
|
|
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: () => {}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Filter `transactions` to those on today's business date whose payment
|
|
* type or transaction-type label mentions NEFT or RTGS, and sum them.
|
|
* The list will be undefined for accounts loaded without the
|
|
* `transactions` association — in that case we just stay at 0.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Sum same-day cheque transactions that are still in clearing for this
|
|
* account. A row is treated as a cheque-clearing entry when either:
|
|
* • paymentDetailData carries a cheque/check number, OR
|
|
* • the transaction type / payment type label mentions "CHEQUE".
|
|
* Posted (cleared) entries are excluded so we only show the pending
|
|
* float — that's the AML-relevant figure for the cashier.
|
|
*/
|
|
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;
|
|
// Treat anything not yet posted as in-clearing — backends differ on
|
|
// the exact flag, so we look at `cleared` as a heuristic and exclude
|
|
// it from the float.
|
|
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);
|
|
}
|
|
|
|
createSavingAccountTransactionForm() {
|
|
this.savingAccountTransactionForm = 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.savingAccountTransactionForm?.controls?.['transactionAmount']?.value || 0);
|
|
return this.denominationTotal > 0 && formAmt === this.denominationTotal;
|
|
}
|
|
|
|
/** Push the counted total into the form's transactionAmount field. */
|
|
applyDenominationTotal(): void {
|
|
this.savingAccountTransactionForm.controls['transactionAmount'].setValue(this.denominationTotal);
|
|
this.savingAccountTransactionForm.controls['transactionAmount'].markAsDirty();
|
|
}
|
|
|
|
clearDenominations(): void {
|
|
this.denominationCounts = {};
|
|
}
|
|
|
|
/**
|
|
* Open the identity-verify pop-up. Only meaningful for withdrawals
|
|
* (where the cashier needs to match the customer at the counter
|
|
* against the on-file profile before paying out cash).
|
|
*/
|
|
openIdentityVerify(): void {
|
|
if (!this.clientId) return;
|
|
// Calm the attention pulse after the first open — the cashier has
|
|
// acknowledged the verification step.
|
|
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.savingAccountTransactionForm.addControl('accountNumber', new UntypedFormControl(''));
|
|
this.savingAccountTransactionForm.addControl('checkNumber', new UntypedFormControl(''));
|
|
this.savingAccountTransactionForm.addControl('routingCode', new UntypedFormControl(''));
|
|
this.savingAccountTransactionForm.addControl('receiptNumber', new UntypedFormControl(''));
|
|
this.savingAccountTransactionForm.addControl('bankNumber', new UntypedFormControl(''));
|
|
} else {
|
|
this.savingAccountTransactionForm.removeControl('accountNumber');
|
|
this.savingAccountTransactionForm.removeControl('checkNumber');
|
|
this.savingAccountTransactionForm.removeControl('routingCode');
|
|
this.savingAccountTransactionForm.removeControl('receiptNumber');
|
|
this.savingAccountTransactionForm.removeControl('bankNumber');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Method to submit the transaction details.
|
|
*
|
|
* Opens a pre-commit confirmation pop-up showing the current balance,
|
|
* the transaction amount, and the resulting balance. The actual API
|
|
* call only fires after the cashier confirms.
|
|
*/
|
|
submit() {
|
|
const formAmount = Number(this.savingAccountTransactionForm.value.transactionAmount) || 0;
|
|
const paymentTypeId = this.savingAccountTransactionForm.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();
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Actual API call — only invoked after the cashier confirms the dialog. */
|
|
private postTransaction(): void {
|
|
const savingAccountTransactionFormData = this.savingAccountTransactionForm.value;
|
|
const locale = this.settingsService.language.code;
|
|
const dateFormat = this.settingsService.dateFormat;
|
|
const prevTransactionDate: Date = this.savingAccountTransactionForm.value.transactionDate;
|
|
if (savingAccountTransactionFormData.transactionDate instanceof Date) {
|
|
savingAccountTransactionFormData.transactionDate = this.dateUtils.formatDate(prevTransactionDate, dateFormat);
|
|
}
|
|
const data = {
|
|
...savingAccountTransactionFormData,
|
|
dateFormat,
|
|
locale
|
|
};
|
|
data['transactionAmount'] = data['transactionAmount'] * 1;
|
|
// Surface the denomination breakdown on the wire — backend can ignore
|
|
// unknown fields, but if a cash-handling extension is enabled it can
|
|
// record the cashier's count for audit.
|
|
if (this.denominationTotal > 0) {
|
|
const breakdown = [
|
|
...this.notes,
|
|
...this.coins
|
|
]
|
|
.map((d) => ({
|
|
denomination: d.value,
|
|
kind: d.kind,
|
|
count: this.countFor(d),
|
|
subtotal: this.subtotalFor(d)
|
|
}))
|
|
.filter((row) => row.count > 0);
|
|
data['denominationBreakdown'] = breakdown;
|
|
}
|
|
this.savingsService
|
|
.executeSavingsAccountTransactionsCommand(this.savingAccountId, this.transactionCommand, data)
|
|
.subscribe(() => {
|
|
this.router.navigate(['../../transactions'], { relativeTo: this.route });
|
|
});
|
|
}
|
|
}
|