/**
* 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, OnDestroy, inject, Input, ElementRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { catchError, debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
import { forkJoin, of, Subject } from 'rxjs';
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { Router } from '@angular/router';
import { CashierService } from '../cashier.service';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service';
import { ClerkWithdrawalService } from '../clerk-withdrawal.service';
import { ClerkClosureRequestService } from '../clerk-closure-request.service';
import { DividendClaimRequestService } from '../../clerk/dividend-claim-request.service';
import { ClerkDisbursementService } from '../clerk-disbursement.service';
import { ClerkShareWithdrawalService } from '../clerk-share-withdrawal.service';
import { CashierDepositService } from '../cashier-deposit.service';
import { SettingsService } from '../../settings/settings.service';
import {
CustomerSearchDialogComponent,
CustomerSearchResult
} from '../../shared/customer-search-dialog/customer-search-dialog.component';
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
import { OrganizationService } from '../../organization/organization.service';
import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
interface TxnRow {
typ: string;
br: number;
trTp: string;
acTyp: string;
acNo: string;
ecsAcNo: string;
dbCr: string;
chequeNo: string;
cheqDate: string;
amount: number | null;
description: string;
tokNo: string;
paymentTypeId: number | null;
bankName: string;
routingCode: string;
/** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */
ddCharges: number | null;
sponsorBankId?: number | null;
/** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings
* account — governs the share redemption (see submitShareRedemptionTransfer()). */
resolutionNumber?: string;
meetingDate?: string;
}
interface Denomination {
id: number;
value: number;
kind: 'note' | 'coin';
label: string;
}
// ── Account Statement Dialog ───────────────────────────────────────────────────
//
// Deliberately the same layout and print mechanism as
// savings/savings-account-view/passbook-tab.component.ts (bank-header/branch-line/
// account-details-band/balance-summary-strip/statement-table/statement-footer, cloned into a popup
// window for window.print()) rather than a generated PDF: a UCB's own passbook screen is the
// reference for what a statement should look like, and jsPDF's own layout would inevitably drift
// from it. Nothing downloads on open — the statement is only ever shown as a preview here; printing
// (and "Save as PDF" from the browser's own print dialog, if a file is wanted) is a deliberate,
// separate click.
@Component({
selector: 'mifosx-account-statement-dialog',
template: `
`,
styles: [
`
.asd-shell {
display: flex;
flex-direction: column;
height: 82vh;
width: 780px;
max-width: 90vw;
}
.asd-filterbar {
display: flex;
align-items: flex-end;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
}
.asd-field {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 11px;
color: #64748b;
input {
padding: 6px 8px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
}
}
.asd-spacer {
flex: 1;
}
.asd-btn {
padding: 7px 14px;
border-radius: 6px;
border: 1px solid #e2e8f0;
background: #fff;
cursor: pointer;
font-size: 13px;
&--primary {
background: #1e40af;
border-color: #1e40af;
color: #fff;
}
}
.asd-scroll {
flex: 1;
overflow: auto;
padding: 16px;
background: #eef1f6;
}
/* Statement look — mirrors passbook-tab.component's PRINT_STYLES exactly, so this preview and
a real passbook read the same way. */
.statement-wrapper {
background: #fff;
border: 1px solid #d0d7e3;
border-radius: 6px;
overflow: hidden;
font-family: 'Segoe UI', Arial, sans-serif;
color: #333;
}
.bank-header {
background: #1a3a6b;
padding: 14px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 4px solid #c8a951;
}
.bank-logo-area {
display: flex;
align-items: center;
gap: 12px;
}
.bank-logo-circle {
width: 44px;
height: 44px;
border-radius: 50%;
background: #c8a951;
display: flex;
align-items: center;
justify-content: center;
mat-icon {
color: #1a3a6b;
}
}
.bank-name-block .bank-title {
color: #fff;
font-size: 16px;
font-weight: 700;
letter-spacing: 0.3px;
}
.bank-name-block .bank-subtitle {
color: #c8a951;
font-size: 10px;
font-weight: 500;
letter-spacing: 1.5px;
text-transform: uppercase;
margin-top: 2px;
}
.statement-label {
text-align: right;
}
.statement-label .stmt-label-text {
color: #c8a951;
font-size: 12px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
}
.statement-label .stmt-period {
color: rgb(255 255 255 / 75%);
font-size: 10px;
margin-top: 3px;
}
.branch-line {
background: #e8edf5;
border-bottom: 1px solid #d0d7e3;
padding: 5px 20px;
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
font-size: 9.5px;
color: #4a5a7a;
}
.account-details-band {
background: #f0f3f9;
border-bottom: 1px solid #d0d7e3;
padding: 12px 20px;
}
.account-details-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px 24px;
}
.detail-block .detail-label {
font-size: 8.5px;
font-weight: 700;
color: #7a8aaa;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 2px;
}
.detail-block .detail-value {
font-size: 12px;
color: #222;
}
.detail-block .detail-value.bold {
font-weight: 600;
}
.detail-block .detail-value.mono {
font-family: 'Courier New', monospace;
letter-spacing: 1px;
}
.balance-summary-strip {
display: flex;
align-items: stretch;
background: #1a3a6b;
}
.balance-box {
flex: 1;
padding: 10px 18px;
text-align: center;
}
.balance-box .balance-box-label {
font-size: 9px;
font-weight: 600;
color: rgb(255 255 255 / 65%);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 3px;
}
.balance-box .balance-box-value {
font-size: 15px;
font-weight: 700;
color: #c8a951;
}
.balance-divider {
width: 1px;
background: rgb(255 255 255 / 15%);
margin: 6px 0;
}
.statement-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
.statement-table thead tr {
background: #e8edf5;
border-top: 1px solid #d0d7e3;
border-bottom: 2px solid #1a3a6b;
}
.statement-table thead th {
padding: 8px 9px;
font-size: 10px;
font-weight: 700;
color: #1a3a6b;
text-transform: uppercase;
letter-spacing: 0.4px;
white-space: nowrap;
text-align: left;
}
.statement-table tbody tr {
border-bottom: 1px solid #e8ecf3;
}
.statement-table tbody tr:nth-child(even) {
background: #f4f7fb;
}
.statement-table tbody td {
padding: 6px 9px;
vertical-align: middle;
color: #333;
}
.statement-table tfoot .total-row {
background: #e8edf5;
border-top: 2px solid #1a3a6b;
}
.statement-table tfoot .total-row td {
padding: 8px 9px;
}
.col-sr {
width: 34px;
}
.col-date {
width: 100px;
white-space: nowrap;
}
.col-desc {
min-width: 150px;
}
.col-ref {
width: 74px;
}
.col-amount {
width: 100px;
}
.col-balance {
width: 110px;
}
.debit-amount {
color: #c0392b;
font-weight: 500;
}
.credit-amount {
color: #1a7a3f;
font-weight: 500;
}
.balance-amount {
color: #1a3a6b;
font-weight: 600;
font-family: 'Courier New', monospace;
}
.total-label {
font-size: 11px;
font-weight: 700;
color: #1a3a6b;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.closing-balance {
font-size: 13px;
font-weight: 700;
color: #1a3a6b;
font-family: 'Courier New', monospace;
}
.txn-desc {
font-weight: 500;
}
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
.text-muted {
color: #999;
}
.mono {
font-family: 'Courier New', monospace;
font-size: 10px;
}
.no-data-block {
padding: 40px;
text-align: center;
color: #aaa;
font-size: 13px;
}
.statement-footer {
background: #f0f3f9;
border-top: 1px solid #d0d7e3;
padding: 10px 20px;
text-align: center;
}
.statement-footer .footer-note {
font-size: 9px;
color: #888;
margin-bottom: 3px;
font-style: italic;
}
.statement-footer .footer-meta {
font-size: 9px;
color: #aaa;
letter-spacing: 0.3px;
}
`
],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatIconModule
]
})
export class AccountStatementDialogComponent implements OnInit {
dialogRef = inject>(MatDialogRef);
data = inject<{ account: any }>(MAT_DIALOG_DATA);
private organizationService = inject(OrganizationService);
@ViewChild('statementContent') statementContent!: ElementRef;
today = new Date().toISOString().slice(0, 10);
fromDate = '';
toDate = this.today;
bankName = 'Bank';
bankSubtitle = 'Account Statement';
branchName = '';
branchAddress = '';
ifscCode = '';
micrCode = '';
accountNo = '';
accountHolder = '';
productName = '';
currencySym = '₹';
currentBalance = 0;
availableBalance = 0;
private allRows: any[] = [];
filteredRows: any[] = [];
ngOnInit(): void {
const acc = this.data.account ?? {};
this.accountNo = acc.accountNo ?? '';
this.accountHolder = acc.clientName ?? acc.groupName ?? '';
this.productName = acc.savingsProductName ?? '';
this.currencySym = acc.currency?.displaySymbol ?? acc.currency?.code ?? '₹';
this.currentBalance = Number(acc.summary?.accountBalance ?? 0);
this.availableBalance = Number(acc.summary?.availableBalance ?? this.currentBalance);
this.allRows = this.buildRows(acc.transactions ?? []);
this.filteredRows = [...this.allRows];
forkJoin({
institution: this.organizationService.getInstitutionProfile().pipe(catchError(() => of(null))),
branches: this.organizationService.getBranchProfiles().pipe(catchError(() => of([])))
}).subscribe(({ institution, branches }: any) => {
if (institution) {
this.bankName = institution.passbookHeaderName || institution.bankName || institution.legalName || 'Bank';
this.bankSubtitle = institution.licenseNumber ? `Lic. No. ${institution.licenseNumber}` : 'Account Statement';
}
const branch = Array.isArray(branches) ? branches[0] : null;
if (branch) {
this.branchName = branch.officeName ?? '';
this.branchAddress = branch.address ?? '';
this.ifscCode = branch.ifscCode ?? '';
this.micrCode = branch.micrCode ?? '';
}
});
}
private transactionDateAsTime(date: any): number {
if (Array.isArray(date) && date.length >= 3) {
return new Date(date[0], date[1] - 1, date[2]).getTime();
}
return new Date(date).getTime();
}
private isDebit(transactionType: any): boolean {
return !!(
transactionType?.withdrawal ||
transactionType?.feeDeduction ||
transactionType?.overdraftInterest ||
transactionType?.withholdTax
);
}
/** Mirrors passbook-tab.component's recalculateRunningBalances()/isDebit() -- same account, same
* ledger, so the two screens must never disagree on what a transaction is worth. */
private buildRows(transactions: any[]): any[] {
const nonReversed = (transactions ?? []).filter((t: any) => !t.reversed);
const sorted = [...nonReversed].sort((a, b) => {
const da = this.transactionDateAsTime(a.date);
const db = this.transactionDateAsTime(b.date);
return da !== db ? da - db : (a.id ?? 0) - (b.id ?? 0);
});
let balance = 0;
return sorted.map((txn) => {
const affectsBalance =
!txn.transactionType?.accrual && !txn.transactionType?.amountHold && !txn.transactionType?.amountRelease;
const debit = this.isDebit(txn.transactionType);
if (affectsBalance) {
balance += debit ? -txn.amount : txn.amount;
}
const time = this.transactionDateAsTime(txn.date);
return {
id: txn.id,
time,
dateLabel: isNaN(time)
? '—'
: new Date(time).toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }),
description: txn.transactionType?.value ?? '—',
debit: debit ? Number(txn.amount) : 0,
credit: !debit ? Number(txn.amount) : 0,
runningBalance: Math.round(balance * 100) / 100
};
});
}
applyFilter(): void {
if (!this.fromDate || !this.toDate) {
this.filteredRows = [...this.allRows];
return;
}
const from = new Date(this.fromDate).getTime();
const to = new Date(this.toDate).getTime();
this.filteredRows = this.allRows.filter((r) => r.time >= from && r.time <= to);
}
/** Same clone-into-popup-window approach as passbook-tab.component.printPassbook() -- the on-screen
* preview is never itself printed directly, since the dialog chrome around it must not appear. */
printStatement(): void {
const source = this.statementContent?.nativeElement as HTMLElement;
if (!source) return;
const clone = source.cloneNode(true) as HTMLElement;
clone.querySelectorAll('mat-icon, .mat-icon').forEach((el: Element) => {
const insideLogo = !!el.closest('.bank-logo-circle');
if (insideLogo) {
el.outerHTML =
' ';
} else {
el.remove();
}
});
const printWindow = window.open('', 'Statement', 'height=800,width=1100');
if (!printWindow) return;
printWindow.document.write(`
Account Statement - ${this.accountNo}
${clone.outerHTML}
`);
printWindow.document.close();
printWindow.focus();
}
/**
* A popup window opened via window.open() doesn't inherit this component's own scoped Angular
* styles, so the exact same statement CSS has to be re-supplied here as plain text -- this is a
* duplicate of the `styles: [...]` block above by necessity, not an oversight; keep the two in sync
* if either changes. Mirrors passbook-tab.component.ts's own PRINT_STYLES constant for the same
* reason.
*/
private static readonly PRINT_STYLES = `
@page { size: A4 portrait; margin: 10mm; }
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body { font-family: 'Segoe UI', Arial, sans-serif; color: #333; background: #fff; }
.statement-wrapper { background: #fff; border: 1px solid #d0d7e3; border-radius: 6px; overflow: hidden; }
.bank-header { background: #1a3a6b; padding: 14px 20px; display: flex; justify-content: space-between; align-items: center; border-bottom: 4px solid #c8a951; }
.bank-logo-area { display: flex; align-items: center; gap: 12px; }
.bank-logo-circle { width: 44px; height: 44px; border-radius: 50%; background: #c8a951; display: flex; align-items: center; justify-content: center; }
.bank-logo-svg { fill: #1a3a6b; display: block; }
.bank-name-block .bank-title { color: #fff; font-size: 16px; font-weight: 700; letter-spacing: 0.3px; }
.bank-name-block .bank-subtitle { color: #c8a951; font-size: 10px; font-weight: 500; letter-spacing: 1.5px; text-transform: uppercase; margin-top: 2px; }
.statement-label { text-align: right; }
.statement-label .stmt-label-text { color: #c8a951; font-size: 12px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; }
.statement-label .stmt-period { color: rgba(255, 255, 255, 0.75); font-size: 10px; margin-top: 3px; }
.branch-line { background: #e8edf5; border-bottom: 1px solid #d0d7e3; padding: 5px 20px; display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 9.5px; color: #4a5a7a; }
.account-details-band { background: #f0f3f9; border-bottom: 1px solid #d0d7e3; padding: 12px 20px; }
.account-details-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 24px; }
.detail-block .detail-label { font-size: 8.5px; font-weight: 700; color: #7a8aaa; text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 2px; }
.detail-block .detail-value { font-size: 12px; color: #222; }
.detail-block .detail-value.bold { font-weight: 600; }
.detail-block .detail-value.mono { font-family: 'Courier New', monospace; letter-spacing: 1px; }
.balance-summary-strip { display: flex; align-items: stretch; background: #1a3a6b; }
.balance-box { flex: 1; padding: 10px 18px; text-align: center; }
.balance-box .balance-box-label { font-size: 9px; font-weight: 600; color: rgba(255, 255, 255, 0.65); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 3px; }
.balance-box .balance-box-value { font-size: 15px; font-weight: 700; color: #c8a951; }
.balance-divider { width: 1px; background: rgba(255, 255, 255, 0.15); margin: 6px 0; }
.statement-table { width: 100%; border-collapse: collapse; font-size: 11px; }
.statement-table thead { display: table-header-group; }
.statement-table tfoot { display: table-row-group; }
.statement-table tr { page-break-inside: avoid; break-inside: avoid; }
.statement-table thead tr { background: #e8edf5; border-top: 1px solid #d0d7e3; border-bottom: 2px solid #1a3a6b; }
.statement-table thead th { padding: 8px 9px; font-size: 10px; font-weight: 700; color: #1a3a6b; text-transform: uppercase; letter-spacing: 0.4px; white-space: nowrap; text-align: left; }
.statement-table tbody tr { border-bottom: 1px solid #e8ecf3; }
.statement-table tbody tr:nth-child(even) { background: #f4f7fb; }
.statement-table tbody td { padding: 6px 9px; vertical-align: middle; color: #333; }
.statement-table tfoot .total-row { background: #e8edf5; border-top: 2px solid #1a3a6b; }
.statement-table tfoot .total-row td { padding: 8px 9px; }
.col-sr { width: 34px; }
.col-date { width: 100px; white-space: nowrap; }
.col-desc { min-width: 150px; }
.col-ref { width: 74px; }
.col-amount { width: 100px; }
.col-balance { width: 110px; }
.debit-amount { color: #c0392b; font-weight: 500; }
.credit-amount { color: #1a7a3f; font-weight: 500; }
.balance-amount { color: #1a3a6b; font-weight: 600; font-family: 'Courier New', monospace; }
.total-label { font-size: 11px; font-weight: 700; color: #1a3a6b; text-transform: uppercase; letter-spacing: 0.5px; }
.closing-balance { font-size: 13px; font-weight: 700; color: #1a3a6b; font-family: 'Courier New', monospace; }
.txn-desc { font-weight: 500; }
.text-right { text-align: right; }
.text-center { text-align: center; }
.text-muted { color: #999; }
.mono { font-family: 'Courier New', monospace; font-size: 10px; }
.no-data-block { padding: 40px; text-align: center; color: #aaa; font-size: 13px; }
.statement-footer { background: #f0f3f9; border-top: 1px solid #d0d7e3; padding: 10px 20px; text-align: center; }
.statement-footer .footer-note { font-size: 9px; color: #888; margin-bottom: 3px; font-style: italic; }
.statement-footer .footer-meta { font-size: 9px; color: #aaa; letter-spacing: 0.3px; }
`;
}
// ── Joint Holder Detail Dialog ────────────────────────────────────────────────
@Component({
selector: 'mifosx-joint-holder-dialog',
template: `
@if (loading) {
Loading details…
} @else {
{{ data.jh.displayName }}
{{ data.jh.role ?? 'Co-Holder' }}
@if (data.jh.signingAuthority?.toLowerCase() === 'mandatory') {
✍ Mandatory Signing
}
CIF No. {{ client?.cifNumber ?? '—' }}
Mobile {{ client?.mobileNo ?? '—' }}
Email {{ client?.emailAddress ?? '—' }}
Share % {{ data.jh.sharePercentage }}%
KYC Status
{{ kyc?.kycStatus === 300 ? '✅ Verified' : kyc?.kycStatus === 100 ? '⏳ Pending' : '—' }}
PAN
{{ kyc?.panNumber ?? '—' }}
Aadhaar
{{ kyc?.aadhaarNumberMasked ?? 'XXXX/XXXX/XXXX' }}
CKYC
{{ kyc?.ckycNumber || '—' }}
}
`,
styles: [
`
.jhd-wrap {
display: flex;
flex-direction: column;
width: 600px;
max-width: 94vw;
border-radius: 12px;
overflow: hidden;
}
.jhd-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
background: #1e293b;
color: #e2e8f0;
}
.jhd-header-left {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.jhd-close {
background: transparent;
border: none;
color: #94a3b8;
font-size: 18px;
cursor: pointer;
padding: 0 4px;
&:hover {
color: #fff;
}
}
.jhd-loading {
padding: 32px;
text-align: center;
color: #64748b;
font-size: 13px;
background: #f8fafc;
}
.jhd-body {
background: #f8fafc;
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.jhd-identity {
display: grid;
grid-template-columns: 100px 160px 1fr;
gap: 16px;
background: #fff;
border-radius: 10px;
padding: 14px;
border: 1px solid #e2e8f0;
}
.jhd-media-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.jhd-media-label {
font-size: 10px;
font-weight: 700;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.jhd-photo {
width: 80px;
height: 96px;
object-fit: cover;
border-radius: 8px;
border: 1px solid #e2e8f0;
cursor: zoom-in;
transition: transform 0.15s;
&:hover {
transform: scale(1.05);
}
}
.jhd-sig {
width: 140px;
height: 60px;
object-fit: contain;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
cursor: zoom-in;
}
.jhd-no-media {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
color: #94a3b8;
font-size: 11px;
padding: 8px;
}
.jhd-info-col {
display: flex;
flex-direction: column;
gap: 8px;
}
.jhd-name {
font-size: 16px;
font-weight: 700;
color: #1e293b;
}
.jhd-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.jhd-role-badge {
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
background: #dbeafe;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
.jhd-sign-badge {
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
background: #fff7ed;
color: #c2410c;
border: 1px solid #fed7aa;
}
.jhd-fields {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 4px;
}
.jhd-row {
display: flex;
gap: 8px;
font-size: 12px;
border-bottom: 1px dotted #f1f5f9;
padding-bottom: 3px;
}
.jhd-lbl {
color: #64748b;
font-weight: 600;
min-width: 60px;
}
.jhd-val {
color: #1e293b;
font-weight: 500;
}
.jhd-val.mono {
font-family: monospace;
}
.jhd-kyc-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.jhd-kyc-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.jhd-kyc-label {
font-size: 10px;
font-weight: 700;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.jhd-kyc-val {
font-size: 13px;
font-weight: 700;
color: #1e293b;
}
.jhd-kyc-val.mono {
font-family: monospace;
font-size: 11px;
}
`
],
standalone: true,
imports: [CommonModule]
})
export class JointHolderDialogComponent implements OnInit {
dialogRef = inject>(MatDialogRef);
data = inject<{ jh: any; clientId: number }>(MAT_DIALOG_DATA);
private http = inject(HttpClient);
private dialog = inject(MatDialog);
loading = true;
photo: string | null = null;
signature: string | null = null;
kyc: any = null;
client: any = null;
ngOnInit(): void {
const cid = this.data.clientId;
forkJoin({
client: this.http.get(`/clients/${cid}`).pipe(catchError(() => of(null))),
kyc: this.http.get(`/clients/${cid}/kyc`).pipe(catchError(() => of([]))),
photo: this.http.get(`/clients/${cid}/images`, { responseType: 'text' }).pipe(catchError(() => of(null))),
documents: this.http.get(`/clients/${cid}/documents`).pipe(catchError(() => of([])))
}).subscribe(({ client, kyc, photo, documents }) => {
this.client = client;
const kycArr: any[] = Array.isArray(kyc) ? kyc : kyc ? [kyc] : [];
this.kyc = kycArr[0] ?? null;
this.photo = photo ?? null;
const docs: any[] = Array.isArray(documents) ? documents : [];
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
// signature before uploads became single-slot, so pick the highest id (latest) rather than
// the first unordered match.
const sigDoc = docs
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
.sort((a: any, b: any) => b.id - a.id)[0];
this.loading = false;
if (sigDoc) {
this.http
.get(`/clients/${cid}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' })
.pipe(catchError(() => of(null)))
.subscribe((blob: Blob | null) => {
if (blob) this.signature = URL.createObjectURL(blob);
});
}
});
}
previewImg(url: string | null, title: string): void {
if (!url) return;
this.dialog.open(ImagePreviewDialogComponent, {
data: { url, title },
maxWidth: '90vw',
maxHeight: '90vh'
});
}
close(): void {
this.dialogRef.close();
}
}
// ── Image Preview Dialog (declared before use) ────────────────────────────────
@Component({
selector: 'mifosx-image-preview-dialog',
template: `
1 ? 'grab' : 'default'"
>
`,
styles: [
`
.ipd-wrap {
display: flex;
flex-direction: column;
width: min(88vw, 840px);
border-radius: 20px;
overflow: hidden;
background: #07080f;
box-shadow:
0 40px 100px rgba(0, 0, 0, 0.75),
0 0 0 1px rgba(255, 255, 255, 0.06);
}
/* ── Top bar ─────────────────────────── */
.ipd-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.ipd-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.55);
}
.ipd-close-btn {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
padding: 0;
}
.ipd-close-btn:hover {
background: rgba(239, 68, 68, 0.18);
border-color: rgba(239, 68, 68, 0.45);
color: #fca5a5;
}
/* ── Image area ──────────────────────── */
.ipd-body {
background: #040508;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
height: 68vh;
user-select: none;
position: relative;
}
.ipd-img {
max-width: 82%;
max-height: 64vh;
object-fit: contain;
border-radius: 10px;
transform-origin: center center;
transition: transform 0.07s ease;
pointer-events: none;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.55);
}
/* ── Footer ──────────────────────────── */
.ipd-footer {
background: rgba(255, 255, 255, 0.02);
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 12px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
/* ── Toolbar pill ────────────────────── */
.ipd-toolbar {
display: flex;
align-items: center;
gap: 2px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 50px;
padding: 4px 6px;
}
.ipd-tool {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
padding: 0;
flex-shrink: 0;
}
.ipd-tool:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.22);
color: #a5b4fc;
}
.ipd-tool:disabled {
opacity: 0.2;
cursor: not-allowed;
}
.ipd-zoom-val {
font-size: 12px;
font-weight: 700;
color: rgba(255, 255, 255, 0.85);
min-width: 44px;
text-align: center;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
padding: 0 2px;
}
.ipd-sep {
width: 1px;
height: 18px;
background: rgba(255, 255, 255, 0.1);
margin: 0 4px;
flex-shrink: 0;
}
/* ── Hint ────────────────────────────── */
.ipd-hint {
font-size: 10.5px;
color: rgba(255, 255, 255, 0.2);
letter-spacing: 0.03em;
}
`
],
standalone: true,
imports: [CommonModule]
})
export class ImagePreviewDialogComponent {
dialogRef = inject>(MatDialogRef);
data = inject<{ url: string; title: string }>(MAT_DIALOG_DATA);
private sanitizer = inject(DomSanitizer);
get safeUrl(): SafeResourceUrl {
return this.sanitizer.bypassSecurityTrustResourceUrl(this.data.url);
}
zoom = 1;
tx = 0;
ty = 0;
dragging = false;
private lx = 0;
private ly = 0;
readonly step = 0.25;
readonly min = 0.25;
readonly max = 6;
get zoomPct(): number {
return Math.round(this.zoom * 100);
}
get imgTransform(): string {
return `scale(${this.zoom}) translate(${this.tx}px,${this.ty}px)`;
}
zoomIn(): void {
this.zoom = Math.min(this.max, +(this.zoom + this.step).toFixed(2));
}
zoomOut(): void {
this.zoom = Math.max(this.min, +(this.zoom - this.step).toFixed(2));
this.clamp();
}
reset(): void {
this.zoom = 1;
this.tx = 0;
this.ty = 0;
}
onWheel(e: WheelEvent): void {
e.preventDefault();
const d = e.deltaY < 0 ? this.step : -this.step;
this.zoom = Math.min(this.max, Math.max(this.min, +(this.zoom + d).toFixed(2)));
if (this.zoom === this.min) {
this.tx = 0;
this.ty = 0;
}
}
onMouseDown(e: MouseEvent): void {
if (this.zoom <= 1) return;
this.dragging = true;
this.lx = e.clientX;
this.ly = e.clientY;
e.preventDefault();
}
onMouseMove(e: MouseEvent): void {
if (!this.dragging) return;
this.tx += (e.clientX - this.lx) / this.zoom;
this.ty += (e.clientY - this.ly) / this.zoom;
this.lx = e.clientX;
this.ly = e.clientY;
this.clamp();
}
onMouseUp(): void {
this.dragging = false;
}
private clamp(): void {
const m = Math.max(0, (this.zoom - 1) * 350);
this.tx = Math.max(-m, Math.min(m, this.tx));
this.ty = Math.max(-m, Math.min(m, this.ty));
}
close(): void {
this.dialogRef.close();
}
}
// ─────────────────────────────────────────────────────────────────────────────
@Component({
selector: 'mifosx-transaction-entry',
templateUrl: './transaction-entry.component.html',
styleUrls: ['./transaction-entry.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatIconModule
]
})
export class TransactionEntryComponent implements OnInit, OnDestroy {
@Input() clerkMode = false;
/** When set, the component runs in manager-review mode for that withdrawal request id. */
@Input() reviewRequestId: number | null = null;
/** When set, the clerk edits & resubmits that (rejected) withdrawal request id. */
@Input() editRequestId: number | null = null;
// Manager review state
reviewRequest: any = null;
showRejectDialog = false;
rejectionReason = '';
// Clerk edit (rejected resubmit) state
editRequest: any = null;
// Active approved withdrawal (cashier disburse flow)
activeWithdrawalRequest: any = null;
activeDividendClaimRequest: any = null;
activeClosureRequest: any = null;
loadingWithdrawal = false;
// ── Account Actions (clerk) — "Close this account" ──────────────────────────
/** Account Actions popup, opened from the footer once an account is loaded. */
showAccountActions = false;
/** The one action offered today: close the account (Cash payout, maker-checker). */
closeAccountChecked = false;
/** Interest accrued to today, previewed the moment the checkbox is ticked so the clerk can see
* what the member will actually receive before submitting. Null until calculated.
* `upToDate` is false when the backend could not refresh the accrual (e.g. this user lacks the
* CALCULATEINTEREST permission) and the figure shown is the last stored one instead. */
closurePreview: {
balance: number;
interest: number;
total: number;
upToDate: boolean;
asOfDate: string | null;
} | null = null;
closurePreviewLoading = false;
closurePreviewError = '';
/** Closure applied to the slip but not yet submitted: the AMOUNT is filled and Save will raise a
* closure request instead of an ordinary withdrawal request. TOK. NO. and DESCRIPTION are taken
* from the slip's own fields, not from the popup. */
pendingClosure = false;
private http = inject(HttpClient);
private cashierService = inject(CashierService);
private authService = inject(AuthenticationService);
private dialog = inject(MatDialog);
private clerkUiState = inject(ClerkUiStateService);
private clerkWithdrawalService = inject(ClerkWithdrawalService);
private closureRequestService = inject(ClerkClosureRequestService);
private dividendClaimRequestService = inject(DividendClaimRequestService);
private clerkDisbursementService = inject(ClerkDisbursementService);
private clerkShareWithdrawalService = inject(ClerkShareWithdrawalService);
private cashierDepositService = inject(CashierDepositService);
readonly router = inject(Router);
private settingsService = inject(SettingsService);
private sponsorDDService = inject(SponsorDDService);
/** GST % on DD issuance charges — loaded from the admin GST/GL Settings (sponsor-dd module). */
ddGstPercentage = 18;
/** Admin-configured DD limit (sponsor-dd module) — caps a single DD's face value. Null = no limit. */
ddLimitAmount: number | null = null;
today = '';
rows: TxnRow[] = [];
selectedRow: TxnRow | null = null;
loadingAccount = false;
accountError = '';
/**
* A teller only ever picks Cash or Transfer — which specific internal settlement it actually is
* (a share redemption, a dividend payout, a GL-to-GL or GL-to-bank book entry) is business logic
* the backend already threads through from whichever payment type the clerk picked here (see
* ClerkShareWithdrawalRequestApiResource#creditLinkedSavingsAccount on the backend), not a separate
* teller-facing choice. System-defined payment types (Share Redemption Transfer, Dividend Claim
* Settlement, the loan repayment-adjustment types, ...) exist purely as internal audit labels and
* are never offered here.
*
* Share redemption settlement is always a book-transfer to the linked savings account — never a
* cash handover. A Share (SHR) row's own TXN TP. never offers a cash payment type, and neither
* does its paired CR (savings) row in "Enter Bank Transfer" mode — that CR row's payment type is
* the one that actually governs how the settlement credit posts (see submitShareRedemptionTransfer()).
*/
paymentTypesForRow(row: TxnRow): any[] {
// Sponsor-bank payment types are excluded. They were added for the Demand Draft module, which
// picks its bank from m_sponsor_bank on the DD row itself, and they are not counter instruments
// — a clerk choosing "SBI" as the TXN TP. of a withdrawal says nothing about how the money
// moves. They are recognised by their description rather than by name so that adding another
// sponsor bank does not put it back on this list.
const tellerFacing = this.paymentTypes.filter(
(pt: any) => !pt.isSystemDefined && !/sponsor bank/i.test(pt.description ?? '')
);
if (!this.isClerk) {
// Cashier counter takes cash only — cheque/transfer/DD instruments go through their own
// clerk/back-office approval flows, never straight through the cash drawer.
return tellerFacing.filter((pt: any) => !!pt.isCashPayment);
}
if (row.acTyp === 'SHR') {
return tellerFacing.filter((pt: any) => !pt.isCashPayment);
}
const isShareSettlementCredit =
(row.typ === 'Enter Bank Transfer' || row.typ === 'Fund Transfer') &&
row.dbCr === 'Cr' &&
this.rows.some(
(r) => (r.typ === 'Enter Bank Transfer' || r.typ === 'Fund Transfer') && r.dbCr === 'Dr' && r.acTyp === 'SHR'
);
if (isShareSettlementCredit) {
return tellerFacing.filter((pt: any) => !pt.isCashPayment);
}
// A withdrawal slip is a cash instruction and nothing else: the clerk raises it, the cashier
// counts the notes out against the token. There is no non-cash way to settle one, so cash is
// the only choice offered rather than the default among several.
if (row.typ === 'Withdrawal Slip') {
return tellerFacing.filter((pt: any) => !!pt.isCashPayment);
}
// A clerk's Demand Draft is never funded by cash they take themselves. Cash for a draft is
// counted at the counter, so it comes either from the cashier's own DD screen or from an
// approved cashier deposit request the clerk points the draft at; the clerk's own route is to
// debit the member's account. Offering cash here let a draft be raised against notes no drawer
// had counted, which is exactly how the six cash DDs with no denomination breakdown arose.
if (row.typ === 'DD') {
return tellerFacing.filter((pt: any) => !pt.isCashPayment);
}
// Everything else can go either way, so both are offered and the clerk says which. A cheque or
// a bank transfer normally settles through the instrument, a disbursement or fund transfer
// between accounts — the type alone does not fix it to one or the other.
return tellerFacing;
}
// Per-row account id/name map: acNo → Fineract id/name (used for multi-row disbursement)
rowAccountIds: Record = {};
rowAccountNames: Record = {};
// Per-row Share account market price, keyed by acNo — lets a Share row's "SHARE NO." field
// auto-fill the settlement amount on any other row added alongside it (calculator convenience
// only; a Share row still can never itself be saved from this screen — see accountTypeConfig).
rowSharePrices: Record = {};
// Per-row client id, keyed by acNo — needed to submit a Share Withdrawal Request from a row
// that isn't necessarily the currently "selected" one.
rowClientIds: Record = {};
// Per-row linked savings account number, keyed by the Share account's own acNo — used to make
// sure a Share-to-linked-account Bank Transfer actually credits the right account.
rowLinkedSavingsAcNo: Record = {};
// Per-row shares currently held, keyed by acNo — lets submitShareRedemptionTransfer() warn the
// clerk when a withdrawal will bring the account's holding to zero (closing it on approval).
rowShareHoldings: Record = {};
// Full account + client data
accountDetails: any = null;
clientDetails: any = null;
kycDetails: any = null;
nominees: any[] = [];
jointHolders: any[] = [];
clientAddresses: any[] = [];
nonPersonDetails: any = null;
// Extended cashier data
clientPhotoUrl: string | null = null;
signatureUrl: string | null = null;
/** Cash Hold (a manual/staff-placed lien) vs Clearing Hold (a cheque/DD deposit not yet realized,
* or a presented cheque not yet settled) — fetched separately from the account's own detail
* response, since only plain savings accounts (m_savings_account) carry this breakdown today. */
holdBreakdown: { cashHold: number; clearingHold: number } | null = null;
accountNotes: any[] = [];
standingInstructions: any[] = [];
todayTransactions: any[] = [];
linkedAccounts: any = null;
// AML compliance indicators (same as withdrawal screen)
sameDayNeftRtgs = 0;
sameDayChequeClearing = 0;
// Search token — cancels stale async results
private searchToken = 0;
private lastLoadedAcNo = '';
private acNoInput$ = new Subject<{ row: TxnRow; index: number; value: string }>();
cashVaultGlId: number | null = null;
// Joint holder expanded details (keyed by index)
expandedHolder: number | null = null;
holderDetails: Record<
number,
{ photo: string | null; signature: string | null; kyc: any; client: any; loading: boolean }
> = {};
paymentTypes: any[] = [];
showConfirmDialog = false;
confirmData: any = null;
// ── Custom alert/confirm modal ────────────────────────────────────────────
alertModal: {
type: 'error' | 'warning' | 'info' | 'success';
title: string;
lines: string[];
confirmLabel?: string;
onConfirm?: () => void;
} | null = null;
showAlert(type: 'error' | 'warning' | 'info' | 'success', title: string, lines: string[]): void {
this.alertModal = { type, title, lines };
}
showConfirmAlert(
type: 'warning' | 'info',
title: string,
lines: string[],
confirmLabel: string,
onConfirm: () => void
): void {
this.alertModal = { type, title, lines, confirmLabel, onConfirm };
}
closeAlert(): void {
this.alertModal = null;
}
confirmAlert(): void {
const cb = this.alertModal?.onConfirm;
this.alertModal = null;
if (cb) cb();
}
acTypOptions = [
'SD',
'CA',
'FD',
'RD',
'SHR',
'GL',
'LA'
];
dbCrOptions = [
'Dr',
'Cr'
];
trTpOptions = [
'C',
'D',
'T'
];
private readonly clerkTypOptions: { value: string; label: string }[] = [
{ value: 'Withdrawal Slip', label: 'Withdrawal Slip' },
{ value: 'Cheque', label: 'Cheque' },
{ value: 'Disbursement', label: 'Disbursement' },
// Mechanically identical to Disbursement (1 GL debit -> N account credits, same maker-checker
// double-entry flow) but labelled separately: "Disbursement" reads as a loan payout, which is
// wrong for crediting a GL amount into a savings/current account for any other reason (e.g.
// funding a newly-activated account's opening balance from a GL source).
{ value: 'Fund Transfer', label: 'Fund Transfer' },
{ value: 'Enter Bank Transfer', label: 'Enter Bank Transfer' },
{ value: 'DD', label: 'DD (Demand Draft)' }
];
/**
* A cashier takes cash in (Receipt), pays cash out (Payment), or collects the cash for a Demand
* Draft (DD). Cheque, Disbursement and Bank Transfer stay clerk/back-office instruments with their
* own approval flows and never appear at the cashier counter.
*
* DD belongs here because the whole DD-against-cash path is the cashier's: isDdCashGlRow() gates on
* a cash payment type against a GL account, onSave() makes the cashier count Face Value + Charges
* + GST exactly and enforces the admin DD limit at intake, and buildDepositPayload() persists that
* breakdown so the Sponsor DD issuance screen can read it back after approval. All of that was
* already written and reachable only by a cashier — it was just impossible to select.
*/
private readonly cashierTypOptions: { value: string; label: string }[] = [
{ value: 'Receipt', label: 'Receipt' },
{ value: 'Payment', label: 'Payment' },
{ value: 'DD', label: 'DD (Demand Draft)' }
];
get typOptions(): { value: string; label: string }[] {
return this.isClerk ? this.clerkTypOptions : this.cashierTypOptions;
}
sponsorBanks: any[] = [];
ddGlMapping: any = null;
// ── Denomination ──────────────────────────────────────────────────────────
notes: Denomination[] = [];
coins: Denomination[] = [];
denominationCounts: Record = {};
// ── Opening / Closing position ────────────────────────────────────────────
openingDenoms: any[] = [];
openingTotal = 0;
openingLoaded = false;
// ── Cashier day status ────────────────────────────────────────────────────
previousDayUnsettled = false;
unsettledSince: string | null = null;
morningTransferReceived = true;
dayStatusLoaded = false;
// ── Transaction saving state ──────────────────────────────────────────────
saving = false;
ngOnInit(): void {
const d = new Date();
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
this.addRow();
this.loadDenominations();
this.loadOpeningPosition();
this.loadDayStatus();
this.cashierService
.getPaymentTypes()
.pipe(catchError(() => of([])))
.subscribe((types: any[]) => {
this.paymentTypes = types ?? [];
// addRow() ran before this resolved, so the very first row's default TXN TP. (Cash, for
// Withdrawal Slip / Receipt / Payment) couldn't be looked up yet — backfill it now that
// the payment types are actually loaded. Only ever touches a row still sitting on the
// null it was created with, so this never overwrites anything already picked on screen.
for (const row of this.rows) {
if (!row.paymentTypeId && [
'Withdrawal Slip',
'Receipt',
'Payment'
].includes(row.typ)) {
row.paymentTypeId = this.findCashPaymentTypeId();
}
}
});
this.acNoInput$
.pipe(
debounceTime(400),
distinctUntilChanged((a, b) => a.value === b.value && a.row === b.row)
)
.subscribe(({ row, index, value }) => {
if (value.trim().length >= 4) {
this.selectedRow = row;
this.loadAccountDetails(value.trim(), row.acTyp);
}
});
this.loadCashVaultGl();
this.loadDdGstPercentage();
this.loadSponsorBanks();
if (this.reviewMode) {
this.loadRequestIntoForm(this.reviewRequestId!, 'review');
return;
}
if (this.editMode) {
this.loadRequestIntoForm(this.editRequestId!, 'edit');
return;
}
if (this.isClerk) {
const saved = this.clerkUiState.snapshot.savedAccount;
if (saved) {
this.accountDetails = saved.accountDetails;
this.clientDetails = saved.clientDetails;
this.kycDetails = saved.kycDetails;
this.nominees = saved.nominees;
this.jointHolders = saved.jointHolders;
this.clientAddresses = saved.clientAddresses;
this.nonPersonDetails = saved.nonPersonDetails;
this.clientPhotoUrl = saved.clientPhotoUrl;
this.signatureUrl = saved.signatureUrl;
this.accountNotes = saved.accountNotes;
this.standingInstructions = saved.standingInstructions;
this.todayTransactions = saved.todayTransactions;
this.linkedAccounts = saved.linkedAccounts;
this.sameDayNeftRtgs = saved.sameDayNeftRtgs;
this.sameDayChequeClearing = saved.sameDayChequeClearing;
this.selectedRow = saved.selectedRow;
if (saved.accountDetails) {
const acNo = saved.accountDetails.accountNo ?? '';
const acTyp = this.clerkUiState.snapshot.savedRow?.acTyp ?? '';
this.rows[0] = { ...this.rows[0], acNo, acTyp };
}
}
}
}
ngOnDestroy(): void {
this.persistClerkState();
}
/** Save the full loaded account into shared clerk state so the facility/photo/SI/activity views can read it. */
private persistClerkState(): void {
if (this.isClerk && this.accountDetails) {
this.clerkUiState.saveFullState({
accountDetails: this.accountDetails,
clientDetails: this.clientDetails,
kycDetails: this.kycDetails,
nominees: this.nominees,
jointHolders: this.jointHolders,
clientAddresses: this.clientAddresses,
nonPersonDetails: this.nonPersonDetails,
clientPhotoUrl: this.clientPhotoUrl,
signatureUrl: this.signatureUrl,
accountNotes: this.accountNotes,
standingInstructions: this.standingInstructions,
todayTransactions: this.todayTransactions,
linkedAccounts: this.linkedAccounts,
sameDayNeftRtgs: this.sameDayNeftRtgs,
sameDayChequeClearing: this.sameDayChequeClearing,
selectedRow: this.selectedRow
});
}
}
private loadDenominations(): void {
this.cashierService
.getActiveDenominations()
.pipe(catchError(() => of([])))
.subscribe((list: any[]) => {
const rows: Denomination[] = (list || []).map((d: any) => ({
id: d.id,
value: d.denomination ?? d.value,
kind: d.kind as 'note' | 'coin',
label: '₹' + (d.denomination ?? d.value)
}));
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);
});
}
/** Admin GST rate (set via GST / GL Settings in the sponsor-dd module) applied to DD issuance charges. */
private loadDdGstPercentage(): void {
this.sponsorDDService
.getGlMappings()
.pipe(catchError(() => of([])))
.subscribe((mappings: any[]) => {
this.ddGlMapping = mappings && mappings.length ? mappings[0] : null;
this.ddGstPercentage = Number(this.ddGlMapping?.gstPercentage ?? 18);
this.ddLimitAmount = this.ddGlMapping?.ddLimitAmount != null ? Number(this.ddGlMapping.ddLimitAmount) : null;
if (this.ddGlMapping?.ddPayableGlAccountId) {
if (this.ddGlMapping.ddPayableGlCode) {
this.applyDdGlMappingToDdRows();
} else {
this.http
.get(`/glaccounts/${this.ddGlMapping.ddPayableGlAccountId}`)
.pipe(catchError(() => of(null)))
.subscribe((glAcc: any) => {
if (glAcc) {
this.ddGlMapping.ddPayableGlCode = glAcc.glCode || String(glAcc.id);
this.ddGlMapping.ddPayableGlAccountName = glAcc.name;
}
this.applyDdGlMappingToDdRows();
});
}
}
});
}
applyDdGlMappingToRow(row: TxnRow): void {
if (row.typ !== 'DD' || !this.ddGlMapping) return;
// Leaves an account-funded DD alone: its A/C NO. is the member's account the clerk typed, and
// overwriting it with the DD Payable GL would silently turn the row back into a cash draft.
if (this.isDdAccountDebitRow(row)) return;
row.acTyp = 'GL';
row.dbCr = 'Cr';
if (this.ddGlMapping.ddPayableGlCode) {
row.acNo = this.ddGlMapping.ddPayableGlCode;
this.selectedRow = row;
this.loadAccountDetails(row.acNo, 'GL');
return;
}
if (this.ddGlMapping.ddPayableGlAccountId) {
this.http
.get(`/glaccounts/${this.ddGlMapping.ddPayableGlAccountId}`)
.pipe(catchError(() => of(null)))
.subscribe((glAcc: any) => {
if (glAcc) {
this.ddGlMapping.ddPayableGlCode = glAcc.glCode || String(glAcc.id);
this.ddGlMapping.ddPayableGlAccountName = glAcc.name;
row.acNo = this.ddGlMapping.ddPayableGlCode;
this.selectedRow = row;
this.loadAccountDetails(row.acNo, 'GL');
}
});
}
}
private applyDdGlMappingToDdRows(): void {
for (const row of this.rows) {
if (row.typ === 'DD') {
this.applyDdGlMappingToRow(row);
}
}
}
private loadSponsorBanks(): void {
this.sponsorDDService
.getSponsorBanks()
.pipe(catchError(() => of([])))
.subscribe((banks: any[]) => {
this.sponsorBanks = banks || [];
for (const row of this.rows) {
if (row.typ === 'DD' && !row.sponsorBankId && this.sponsorBanks.length > 0) {
row.sponsorBankId = this.sponsorBanks[0].id;
}
}
});
}
get isDdGlMappingConfigured(): boolean {
return !!(this.ddGlMapping && this.ddGlMapping.ddPayableGlAccountId && this.ddGlMapping.cashGlAccountId);
}
private loadDayStatus(): void {
this.cashierService
.getCashierDayStatus()
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
if (!res) {
this.dayStatusLoaded = true;
return;
}
this.previousDayUnsettled = !!res.previousDayUnsettled;
this.morningTransferReceived = res.morningTransferReceived !== false;
this.unsettledSince = res.unsettledSince ?? null;
this.dayStatusLoaded = true;
});
}
private loadOpeningPosition(): void {
const d = new Date();
const asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.cashierService
.getCashierPositionForMe(asOfDate)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
this.openingDenoms = res?.denominations ?? [];
this.openingTotal = Number(res?.totalAmount ?? 0);
this.openingLoaded = true;
});
}
// ── 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 {
this.denominationCounts[this.denomKey(d)] = Math.max(0, Math.floor(Number(raw) || 0));
}
bumpDenom(d: Denomination, delta: number): void {
this.denominationCounts[this.denomKey(d)] = Math.max(0, this.countFor(d) + delta);
}
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;
}
/**
* Denomination Entry order: ALL notes (desc) first, then ALL coins (desc).
* This matches how cashiers physically count cash — notes first, coins after.
* Opening/Closing panels use value-based interleaving (reporting format).
*/
get allDenominations(): Denomination[] {
const desc = (a: Denomination, b: Denomination) => b.value - a.value;
return [
...[...this.notes].sort(desc),
...[...this.coins].sort(desc)
];
}
// ── Opening / Closing ─────────────────────────────────────────────────────
get openingNotes(): any[] {
return this.openingDenoms.filter((d) => d.kind !== 'coin');
}
get openingCoins(): any[] {
return this.openingDenoms.filter((d) => d.kind === 'coin');
}
/** Opening sorted: all notes (desc) then all coins (desc) */
get sortedOpeningDenoms(): any[] {
const desc = (a: any, b: any) => b.denominationValue - a.denominationValue;
const notes = this.openingDenoms.filter((d) => d.kind !== 'coin').sort(desc);
const coins = this.openingDenoms.filter((d) => d.kind === 'coin').sort(desc);
return [
...notes,
...coins
];
}
/** Closing sorted: all notes (desc) then all coins (desc) */
get sortedClosingDenoms(): any[] {
const desc = (a: any, b: any) => b.denominationValue - a.denominationValue;
const notes = this.closingDenoms.filter((d) => d.kind !== 'coin').sort(desc);
const coins = this.closingDenoms.filter((d) => d.kind === 'coin').sort(desc);
return [
...notes,
...coins
];
}
get closingTotal(): number {
const row = this.selectedRow;
const sign = row?.dbCr === 'Cr' ? 1 : -1; // Cr = Deposit (+), Dr = Withdrawal (-)
return this.openingTotal + this.denominationTotal * sign;
}
/** Denomination overdraft: check if requested denomination counts exceed opening stock */
get denominationOverdrafts(): { label: string; requested: number; available: number; shortfall: number }[] {
if (this.denominationTotal <= 0) return [];
// Build openMap same way closingDenoms does — key = "kind:denominationValue"
const openMap = new Map();
for (const d of this.openingDenoms) {
openMap.set(`${d.kind}:${d.denominationValue}`, Number(d.count ?? 0));
}
const overdrafts: { label: string; requested: number; available: number; shortfall: number }[] = [];
for (const d of [
...this.notes,
...this.coins
]) {
const requested = this.countFor(d);
if (requested <= 0) continue;
const available = openMap.get(this.denomKey(d)) ?? 0;
if (requested > available) {
overdrafts.push({ label: d.label, requested, available, shortfall: requested - available });
}
}
return overdrafts;
}
get hasWithdrawalDenomOverdraft(): boolean {
return this.denominationOverdrafts.length > 0;
}
get closingDenoms(): any[] {
const openMap = new Map();
for (const d of this.openingDenoms) {
openMap.set(`${d.kind}:${d.denominationValue}`, d);
}
const sign = this.selectedRow?.dbCr === 'Cr' ? 1 : -1;
const result: any[] = [];
for (const denom of [
...this.notes,
...this.coins
]) {
const key = this.denomKey(denom);
const open = openMap.get(key);
const openCount = open?.count ?? 0;
const entered = this.countFor(denom);
if (openCount === 0 && entered === 0) continue;
result.push({
denominationValue: denom.value,
kind: denom.kind,
openCount,
entered,
closingCount: openCount + entered * sign
});
}
return result.sort((a, b) => b.denominationValue - a.denominationValue);
}
applyDenominationTotal(): void {
// DD + Cash + GL: amount is derived from Face Value + Charges + GST, not from counted cash —
// overwriting it here would feed back into the Charges/GST sum and compound on every click.
if (this.selectedRow && !this.isDdCashGlRow(this.selectedRow)) {
this.selectedRow.amount = this.denominationTotal;
}
}
clearDenominations(): void {
this.denominationCounts = {};
}
addRow(): void {
const active = this.selectedRow;
// Disbursement/Fund Transfer/Bank Transfer: auto-add mirror CR row when active row is DR
if (
(active?.typ === 'Disbursement' || active?.typ === 'Fund Transfer' || active?.typ === 'Enter Bank Transfer') &&
active.dbCr === 'Dr'
) {
const mirror: TxnRow = {
typ: active.typ,
br: active.br ?? 1,
trTp: '',
acTyp: 'SD',
acNo: '',
ecsAcNo: '',
dbCr: 'Cr',
chequeNo: '',
cheqDate: '',
amount: active.amount,
description: active.description ?? '',
tokNo: '',
paymentTypeId: this.findTransferPaymentTypeId(),
bankName: '',
routingCode: '',
ddCharges: null
};
this.rows.push(mirror);
this.selectedRow = mirror;
if (active.acTyp === 'SHR') {
this.syncShareLinkedAmounts();
}
return;
}
// Neither TYPE nor TXN TP. ever offers a blank/placeholder option any more, so a fresh row
// can't start in an unselected state either — it needs a real, valid TYPE from the moment it
// exists. Cashier: Receipt (money in) is the far more common starting point than Payment
// (money out, which needs an approved token first). Clerk: Withdrawal Slip, the plainest of
// the five original types — same TYPE the old blank-then-choose flow most often ended on
// anyway, just without the extra click to get there.
const row: TxnRow = {
typ: this.isClerk ? 'Withdrawal Slip' : 'Receipt',
br: 1,
trTp: '',
acTyp: 'SD',
acNo: '',
ecsAcNo: '',
dbCr: this.isClerk ? 'Dr' : 'Cr',
chequeNo: '',
cheqDate: '',
amount: null,
description: '',
tokNo: '',
paymentTypeId: this.findCashPaymentTypeId(),
bankName: '',
routingCode: '',
ddCharges: null
};
this.rows.push(row);
}
/** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */
onTypChange(row: TxnRow): void {
switch (row.typ) {
case 'Withdrawal Slip':
row.dbCr = 'Dr';
row.paymentTypeId = this.findCashPaymentTypeId();
row.chequeNo = '';
row.cheqDate = '';
break;
// A cheque clears through the bank rather than the drawer, so it opens on the transfer
// instrument. Cash is still on its dropdown — a cheque can be cashed over the counter — but
// it is the exception, not what the field should start on.
case 'Cheque':
row.dbCr = 'Dr';
row.paymentTypeId = this.findTransferPaymentTypeId();
break;
case 'Disbursement':
case 'Fund Transfer':
if (!row.dbCr) row.dbCr = 'Dr';
row.paymentTypeId = this.findTransferPaymentTypeId();
break;
case 'Enter Bank Transfer':
if (!row.dbCr) row.dbCr = 'Dr';
row.paymentTypeId = this.findTransferPaymentTypeId();
break;
// Cashier-only: Receipt = cash coming in (Cr), Payment = cash going out (Dr). The cashier
// TXN TP. dropdown only ever offers Cash (see paymentTypesForRow()), so that's the only
// sensible default here.
case 'Receipt':
row.dbCr = 'Cr';
row.paymentTypeId = this.findCashPaymentTypeId();
row.chequeNo = '';
row.cheqDate = '';
break;
case 'Payment':
row.dbCr = 'Dr';
row.paymentTypeId = this.findCashPaymentTypeId();
row.chequeNo = '';
row.cheqDate = '';
break;
// A DD is bought with cash: the purchaser hands over Face Value + Charges + GST, so it is
// money coming IN (Cr) against the GL the draft is drawn on. All three of Cr, a cash payment
// type and AC.TP = GL are what isDdCashGlRow() looks for -- without them the DD Charges & GST
// panel never appears and the row would post as an ordinary entry with the charges silently
// dropped.
//
// A clerk funds a draft through an account or an instrument instead, and their TXN TP. list no
// longer offers cash, so defaulting to it would open the field on a value it cannot show.
// isDdCashGlRow() still holds for them through AC.TP = GL.
case 'DD':
row.dbCr = 'Cr';
row.acTyp = 'GL';
row.paymentTypeId = this.isClerk ? this.findTransferPaymentTypeId() : this.findCashPaymentTypeId();
row.chequeNo = '';
row.cheqDate = '';
if (this.sponsorBanks.length > 0 && !row.sponsorBankId) {
row.sponsorBankId = this.sponsorBanks[0].id;
}
this.applyDdGlMappingToRow(row);
break;
}
}
private findCashPaymentTypeId(): number | null {
const pt = this.paymentTypes.find((p: any) => !!p.isCashPayment);
return pt?.id ?? null;
}
private findTransferPaymentTypeId(): number | null {
const pt = this.paymentTypes.find((p: any) => /transfer|neft|rtgs|imps/i.test(p.name ?? ''));
return pt?.id ?? null;
}
/** TYPE=DD + TXN TP=Cash */
isDdCashGlRow(row: TxnRow): boolean {
if (this.isDdAccountDebitRow(row)) return false;
const pt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
return row.typ === 'DD' && (row.acTyp === 'GL' || !row.acTyp || !!pt?.isCashPayment);
}
/**
* TYPE=DD funded by debiting a member's savings/current account rather than by cash.
*
* Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account debit
* would let a draft be raised against an account with no cash to reconcile it against — and the
* clerk flow is the one that carries a maker-checker step over the member's balance.
*/
isDdAccountDebitRow(row: TxnRow): boolean {
return this.isClerk && row.typ === 'DD' && (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB');
}
/**
* Any DD row the charges/GST panel applies to.
*
* Charges and GST come from the admin's DD configuration and are collected whichever way the
* draft is paid for — the difference is only where the money comes from, so the panel is the same
* for both and the label on the total changes rather than the arithmetic.
*/
isDdRow(row: TxnRow): boolean {
return this.isDdCashGlRow(row) || this.isDdAccountDebitRow(row);
}
/** GST on DD charges only (never on the DD face value), at the admin-configured rate. */
ddGstAmountFor(row: TxnRow): number {
const charges = Number(row.ddCharges) || 0;
if (charges <= 0) return 0;
return Math.round(charges * this.ddGstPercentage) / 100;
}
/** Amount + Charges + GST, rounded UP to the next whole rupee — the total collected from the customer. */
ddTotalPayableFor(row: TxnRow): number {
const amount = Number(row.amount) || 0;
const charges = Number(row.ddCharges) || 0;
const gst = this.ddGstAmountFor(row);
return Math.ceil(amount + charges + gst - 1e-9);
}
get isSelectedRowDdCashGl(): boolean {
return !!this.selectedRow && this.isDdCashGlRow(this.selectedRow);
}
/** Cash the cashier must physically count for the selected DD row — Face Value + Charges + GST. */
get ddTargetTotal(): number {
return this.selectedRow ? this.ddTotalPayableFor(this.selectedRow) : 0;
}
ddDenomMatchesTarget(): boolean {
return !!this.selectedRow && Math.abs(this.denominationTotal - this.ddTargetTotal) < 0.005;
}
branchNameFor(branchId: number): string {
const map: Record = {
1: '001 · Main',
2: '002 · M.G. Road',
14: '014 · Station Rd',
27: '027 · Industrial'
};
return map[branchId] || `Branch ${branchId}`;
}
/**
* Cashier Walk-in DD creation flow:
* 1. Validates one-time persistent GL configuration
* 2. Auto-selects next available leaflet
* 3. Opens dialog to capture purchaser/beneficiary details
* 4. Submits issue request directly with physical denominations for manager approval
*/
openIssueWalkinDd(row: TxnRow): void {
if (!this.isDdGlMappingConfigured) {
this.showAlert('error', 'DD GL Mapping Not Configured', [
'DD GL mapping is not configured.',
'Please ask an administrator to set up the GL accounts in Admin > Sponsor DD GST / GL Settings before creating Demand Drafts.'
]);
return;
}
const faceValue = Number(row.amount);
if (!faceValue || faceValue <= 0) {
this.showAlert('error', 'Invalid Amount', ['Please enter the DD Face Value amount before proceeding.']);
return;
}
const chargesAmount = Number(row.ddCharges) || 0;
const gstAmount = this.ddGstAmountFor(row);
const totalCashPayable = this.ddTotalPayableFor(row);
const accountDebit = this.isDdAccountDebitRow(row);
// An account-funded draft needs the account resolved to a Fineract id before it can be sent:
// the server takes a savings/current account id and a client id, not the typed number. Both are
// filled in by the A/C NO. lookup, so an unresolved account means the number was never found.
let fundingAccountId: number | null = null;
let fundingClientId: number | null = null;
if (accountDebit) {
const acNo = (row.acNo || '').trim();
if (!acNo) {
this.showAlert('error', 'Account Required', [
'Enter the account number the Demand Draft is funded from.'
]);
return;
}
fundingAccountId = this.rowAccountIds[acNo] ?? null;
fundingClientId = this.rowClientIds[acNo] ?? null;
if (!fundingAccountId || !fundingClientId) {
this.showAlert('error', 'Account Not Found', [
`Account ${acNo} could not be resolved.`,
'Check the account number and wait for its details to load before raising the draft.'
]);
return;
}
}
// Only a cash draft has notes to reconcile. An account debit takes no cash at the counter, so a
// denomination count left over from an earlier row must not be checked against it.
if (!accountDebit && this.denominationTotal > 0 && Math.abs(this.denominationTotal - totalCashPayable) > 0.005) {
this.showAlert('error', 'Denomination Mismatch', [
`Cash denomination counted (₹${this.denominationTotal}) does not match total payable cash (₹${totalCashPayable}).`,
'Please verify and match the physical cash denomination count.'
]);
return;
}
this.saving = true;
this.sponsorDDService.getNextAvailableLeaflet(row.br, row.sponsorBankId ?? undefined).subscribe({
next: (leaflet) => {
this.saving = false;
if (!leaflet) {
this.showAlert('error', 'No Available Leaflets', [
'No available Demand Draft leaflets found for this branch and sponsor bank.',
'Please register a DD inventory series first.'
]);
return;
}
const selectedBank = this.sponsorBanks.find((b: any) => b.id === row.sponsorBankId);
const branchName = this.branchNameFor(row.br) || 'Branch ' + row.br;
const dialogRef = this.dialog.open(CashierIssueDdDialogComponent, {
width: '600px',
disableClose: true,
data: {
leaflet,
branchName,
sponsorBankName: selectedBank?.name || 'Sponsor Bank',
faceValue,
chargesAmount,
gstPercentage: this.ddGstPercentage,
gstAmount,
totalCashPayable,
paymentMethod: accountDebit ? ('ACCOUNT_DEBIT' as const) : ('CASH' as const),
accountNo: accountDebit ? row.acNo : undefined,
accountHolderName: accountDebit ? this.rowAccountNames[(row.acNo || '').trim()] : undefined
}
});
dialogRef.afterClosed().subscribe((result) => {
if (!result) return;
if (accountDebit) {
this.submitAccountDebitDd(row, leaflet, result, fundingAccountId!, fundingClientId!);
} else {
this.submitWalkinCashDd(row, leaflet, result);
}
});
},
error: (err) => {
this.saving = false;
this.showAlert('error', 'Error Auto-Selecting DD Leaflet', [err.error?.message || err.message]);
}
});
}
/**
* Raises a Demand Draft funded by debiting a member's savings or current account.
*
* Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The member's
* account is only touched when a checker approves, which is also when the balance is re-checked —
* so a draft raised against a sufficient balance can still be refused if the balance has since
* gone. No denominations are sent, because no cash is taken.
*
* The account goes in savingsAccountId or currentAccountId, never both: the server insists on
* exactly one, since which table the account lives in decides which control GL is debited.
*/
private submitAccountDebitDd(
row: TxnRow,
leaflet: any,
beneficiaryData: any,
fundingAccountId: number,
fundingClientId: number
): void {
const d = new Date();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
const payload = {
clientId: fundingClientId,
beneficiaryName: beneficiaryData.beneficiaryName,
payableAtBranch: beneficiaryData.payableAtBranch,
payableAtCity: beneficiaryData.payableAtCity,
faceValue: Number(row.amount),
chargesAmount: Number(row.ddCharges) || 0,
issuingBranchId: row.br,
issuedDate,
paymentMethod: 'ACCOUNT_DEBIT' as const,
savingsAccountId: isCurrent ? undefined : fundingAccountId,
currentAccountId: isCurrent ? fundingAccountId : undefined
};
this.saving = true;
this.sponsorDDService.submitIssueRequest(leaflet.id, payload).subscribe({
next: () => {
this.saving = false;
this.showAlert('success', 'DD Issuance Request Submitted', [
`Demand Draft request for DD No. ${leaflet.ddNumber} (Face Value: ₹${row.amount}) submitted successfully!`,
`₹${this.ddTotalPayableFor(row)} will be debited from ${row.acNo} on approval.`,
'Sent to Manager / Head Cashier for maker-checker approval.'
]);
row.amount = null;
row.ddCharges = null;
row.description = '';
},
error: (err) => {
this.saving = false;
this.showAlert('error', 'DD Submission Failed', [err.error?.message || err.message]);
}
});
}
private submitWalkinCashDd(row: TxnRow, leaflet: any, purchaserData: any): void {
// Read through countFor, which knows the map is keyed by kind:value. Indexing by d.id looked
// right but matched nothing, so every cash DD went in with no breakdown at all and its money
// vanished from the closing report -- the notes were counted on screen and then dropped.
const denominationMap: { [key: string]: number } = {};
for (const d of [
...this.notes,
...this.coins
]) {
const count = this.countFor(d);
if (count > 0) {
denominationMap[this.denomKey(d)] = count;
}
}
// The breakdown is the only record of which notes were taken, and the server insists it add up
// to the cash collected. Checked here so the cashier is told which figure is wrong while the
// counts are still on screen, rather than getting a rejection after the fact.
//
// ddTotalPayableFor rounds up to the whole rupee actually collected, which is what the notes
// can add to; the exact figure with paise is accepted too, for a draft that lands on a rupee.
const totalCollected = Number(this.ddTotalPayableFor(row));
const exactPayable = Number(row.amount || 0) + (Number(row.ddCharges) || 0) + this.ddGstAmountFor(row);
const countedTotal = Object.entries(denominationMap).reduce(
(sum, [
key,
count
]) => sum + Number(key.split(':')[1]) * count,
0
);
if (countedTotal !== totalCollected && countedTotal !== exactPayable) {
this.showAlert('error', 'Denomination Entry Does Not Match', [
countedTotal === 0
? 'Enter the notes and coins taken from the purchaser before submitting.'
: `The notes and coins entered come to ₹${countedTotal.toFixed(2)}, but ₹${totalCollected.toFixed(2)} is collected (face value + charges + GST).`,
'The breakdown must equal the cash taken — it is what the closing report and the drawer are checked against.'
]);
return;
}
const d = new Date();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const payload = {
walkinPurchaserName: purchaserData.walkinPurchaserName,
walkinPurchaserAddress: purchaserData.walkinPurchaserAddress,
walkinPurchaserAadhaar: purchaserData.walkinPurchaserAadhaar,
beneficiaryName: purchaserData.beneficiaryName,
payableAtBranch: purchaserData.payableAtBranch,
payableAtCity: purchaserData.payableAtCity,
faceValue: Number(row.amount),
chargesAmount: Number(row.ddCharges) || 0,
issuingBranchId: row.br,
issuedDate,
paymentMethod: 'CASH' as const,
denominationData: Object.keys(denominationMap).length > 0 ? JSON.stringify(denominationMap) : undefined
};
this.saving = true;
this.sponsorDDService.submitIssueRequest(leaflet.id, payload).subscribe({
next: () => {
this.saving = false;
this.showAlert('success', 'DD Issuance Request Submitted', [
`Demand Draft request for DD No. ${leaflet.ddNumber} (Face Value: ₹${row.amount}, Total Cash: ₹${this.ddTotalPayableFor(row)}) submitted successfully!`,
'Sent to Manager / Head Cashier for maker-checker approval.'
]);
row.amount = null;
row.ddCharges = null;
row.description = '';
this.clearDenominations();
},
error: (err) => {
this.saving = false;
this.showAlert('error', 'DD Submission Failed', [err.error?.message || err.message]);
}
});
}
/**
* Submits a clerk double-entry request (maker-checker, PENDING until manager approves).
* Disbursement: 1 DR [GL] source → N CR savings/current/GL destinations.
* Collection: N DR [Savings/CA] sources → 1 CR [GL] target.
*/
private submitDisbursementDoubleEntry(): void {
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
// Captured now, not re-read from this.rows inside the async success callback below — the rows
// array gets reset to a fresh blank row as soon as that callback runs.
const isFundTransfer = this.rows[0]?.typ === 'Fund Transfer';
const isCollectionMode = drRows.every((r) => r.acTyp !== 'GL') && crRows.length === 1 && crRows[0].acTyp === 'GL';
const isDisbursementMode = drRows.length === 1 && drRows[0].acTyp === 'GL';
// Validate each row has account, amount, and payment type (skip paymentTypeId for GL rows)
for (const r of [
...drRows,
...crRows
]) {
if (!r.acNo?.trim()) {
this.showAlert('error', 'Account Required', ['Please fill account number in all rows.']);
return;
}
if (!r.amount || r.amount <= 0) {
this.showAlert('error', 'Amount Required', [`Please enter amount for: ${r.acNo}`]);
return;
}
if (!r.paymentTypeId && r.acTyp !== 'GL') {
this.showAlert('error', 'TXN TP Required', [`Please select payment type for: ${r.acNo}`]);
return;
}
}
// Verify all accounts have been loaded (accountId required for approval)
const unloaded = [
...drRows,
...crRows
].filter((r) => !this.rowAccountIds[r.acNo]);
if (unloaded.length > 0) {
this.showAlert('error', 'Account Not Loaded', [
`Please load account details for: ${unloaded.map((r) => r.acNo).join(', ')}`,
'Click on each row and wait for account details to load.'
]);
return;
}
const totalDr = drRows.reduce((s, r) => s + (r.amount ?? 0), 0);
// Build entries and main GL fields based on mode
let glAccountId: number | null = null;
let glAccountNo: string | null = null;
let glAccountName: string | null = null;
let entries: any[];
if (isDisbursementMode) {
const drRow = drRows[0];
glAccountId = this.rowAccountIds[drRow.acNo] ?? null;
glAccountNo = drRow.acNo;
glAccountName = this.rowAccountNames[drRow.acNo] ?? drRow.acNo;
entries = crRows.map((cr) => ({
dbCr: 'Cr',
accountNo: cr.acNo,
accountName: this.rowAccountNames[cr.acNo] ?? cr.acNo,
accountType: this.creditEntryAccountType(cr.acTyp),
savingsAccountId: cr.acTyp !== 'GL' ? (this.rowAccountIds[cr.acNo] ?? null) : null,
glAccountId: cr.acTyp === 'GL' ? (this.rowAccountIds[cr.acNo] ?? null) : null,
amount: String(cr.amount),
paymentTypeId: cr.paymentTypeId,
description: cr.description ?? null
}));
} else {
// Collection: store target GL in main fields; include all rows with dbCr field
const crGl = crRows[0];
glAccountId = this.rowAccountIds[crGl.acNo] ?? null;
glAccountNo = crGl.acNo;
glAccountName = this.rowAccountNames[crGl.acNo] ?? crGl.acNo;
entries = [
...drRows,
...crRows
].map((r) => ({
dbCr: r.dbCr,
accountNo: r.acNo,
accountName: this.rowAccountNames[r.acNo] ?? r.acNo,
accountType: this.creditEntryAccountType(r.acTyp),
savingsAccountId: r.acTyp !== 'GL' ? (this.rowAccountIds[r.acNo] ?? null) : null,
glAccountId: r.acTyp === 'GL' ? (this.rowAccountIds[r.acNo] ?? null) : null,
amount: String(r.amount),
paymentTypeId: r.paymentTypeId,
description: r.description ?? null
}));
}
const firstRow = drRows[0] ?? crRows[0];
const payload = {
glAccountId,
glAccountNo,
glAccountName,
officeId: firstRow?.br ?? 1,
currencyCode: 'INR',
paymentTypeId: firstRow?.paymentTypeId,
paymentTypeName: this.paymentTypes.find((p: any) => p.id === firstRow?.paymentTypeId)?.name ?? null,
description: firstRow?.description || null,
creditEntries: JSON.stringify(entries),
// Without this the backend defaults every request from this screen to DISBURSEMENT — a
// Collection or Fund Transfer submitted here was being saved (and shown in the checker's
// inbox) as a plain Disbursement.
requestType: isCollectionMode ? 'COLLECTION' : isFundTransfer ? 'FUND_TRANSFER' : 'DISBURSEMENT'
};
this.saving = true;
this.clerkDisbursementService
.submit(payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.message ??
'Request submission failed. Please try again.';
this.showAlert('error', 'Submission Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
const modeLabel = isCollectionMode ? 'Collection' : isFundTransfer ? 'Fund Transfer' : 'Disbursement';
const summaryLine = isCollectionMode
? `DR: ${drRows.length} account(s) → CR: GL ${crRows[0].acNo}`
: `DR: GL ${drRows[0].acNo} → CR: ${crRows.length} account(s)`;
this.showAlert('success', `${modeLabel} Request Submitted`, [
`Voucher No: ${res.voucherNo} · Total: ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
summaryLine,
'Pending manager approval.'
]);
this.rows = [];
this.rowAccountIds = {};
this.rowAccountNames = {};
this.addRow();
this.selectedRow = this.rows[0];
this.accountDetails = null;
this.clientDetails = null;
}
});
}
/** Submits an intra-bank book transfer for maker-checker approval. */
private submitBankTransfer(): void {
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
const firstRow = drRows[0] ?? crRows[0];
const toEntry = (r: TxnRow, dbCr: 'Dr' | 'Cr') => ({
dbCr,
accountNo: r.acNo,
accountName: this.rowAccountNames[r.acNo] ?? r.acNo,
accountType: this.creditEntryAccountType(r.acTyp),
savingsAccountId: this.rowAccountIds[r.acNo],
amount: String(r.amount),
description: r.description ?? null
});
const allEntries = [
...drRows.map((r) => toEntry(r, 'Dr')),
...crRows.map((r) => toEntry(r, 'Cr'))
];
const payload = {
requestType: 'BANK_TRANSFER',
officeId: firstRow?.br ?? 1,
currencyCode: 'INR',
description: firstRow?.description || undefined,
creditEntries: JSON.stringify(allEntries)
};
this.saving = true;
this.clerkDisbursementService
.submit(payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.message ??
'Bank transfer submission failed. Please try again.';
this.showAlert('error', 'Submission Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
const totalDr = drRows.reduce((s, r) => s + (r.amount ?? 0), 0);
this.showAlert('success', 'Bank Transfer Submitted', [
`Voucher No: ${res.voucherNo}`,
`Total: ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })} · DR: ${drRows.length} account(s) → CR: ${crRows.length} account(s)`,
'Pending Manager approval. Track status in "My Disbursement Requests".'
]);
this.rows = [];
this.rowAccountIds = {};
this.rowAccountNames = {};
this.addRow();
this.selectedRow = this.rows[0];
this.accountDetails = null;
this.clientDetails = null;
}
});
}
/** One-step Share redemption from Bank Transfer mode: DR row is the Share account (its
* "SHARE NO." field), CR row must be that account's own linked savings account. Submits a
* governed Share Withdrawal Request (same maker-checker table/flow as the dedicated screen) —
* approval both redeems the shares and settles the linked account automatically. */
private submitShareRedemptionTransfer(shareRow: TxnRow, savingsRow: TxnRow): void {
// Belt-and-braces: the TXN TP. dropdown already hides cash payment types for the CR (savings)
// row (see paymentTypesForRow()), but re-check here too in case its payment type was set before
// that filter applied — settlement is always a book-transfer to the linked savings account, and
// it's the CR row's payment type that actually governs how that credit posts, never the DR
// (Share) row's, which has no real payment-type concept.
const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === savingsRow.paymentTypeId);
if (!savingsRow.paymentTypeId) {
this.showAlert('error', 'Settlement Payment Type Required', [
'Select a Payment Type on the CR (savings account) row — it governs how the settlement credit posts.'
]);
return;
}
if (settlementPaymentType?.isCashPayment) {
this.showAlert('error', 'Cash Not Allowed for Share Transactions', [
'Share redemption settlement is always a book-transfer to the linked savings account, never cash.',
'Select Transfer as the Payment Type on the CR row.'
]);
return;
}
const shareAccountId = this.rowAccountIds[shareRow.acNo];
if (!shareAccountId) {
this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]);
return;
}
if (!this.rowAccountIds[savingsRow.acNo]) {
this.showAlert('error', 'Savings Account Not Loaded', [`Please load account details for: ${savingsRow.acNo}`]);
return;
}
const linkedAcNo = this.rowLinkedSavingsAcNo[shareRow.acNo];
const enteredAcNo = savingsRow.acNo?.trim();
const matchesLinked =
!!linkedAcNo &&
(linkedAcNo === enteredAcNo || linkedAcNo.endsWith(enteredAcNo) || savingsRow.ecsAcNo === linkedAcNo);
if (linkedAcNo && !matchesLinked) {
this.showAlert('error', 'Wrong Credit Account', [
`The CR account must be this Share account's own linked savings account: ${linkedAcNo}`,
`You entered: ${enteredAcNo}`
]);
return;
}
if (!linkedAcNo) {
this.showAlert('error', 'No Linked Savings Account', [
'This Share account has no linked savings account configured — settlement has nowhere to go.'
]);
return;
}
const shares = Number(shareRow.amount) || 0;
if (shares <= 0) {
this.showAlert('error', 'Share No. Required', ['Enter the number of shares to withdraw (greater than zero).']);
return;
}
if (!shareRow.resolutionNumber?.trim()) {
this.showAlert('error', 'Board Resolution Number Required', [
'A share withdrawal must be authorised by a Board Resolution.'
]);
return;
}
if (!shareRow.meetingDate) {
this.showAlert('error', 'Date of Meeting Required', ['Enter the Board meeting date that passed the resolution.']);
return;
}
const today = new Date();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
// for audit purposes. Business logic still only uses settlementPaymentTypeId above; the rest of
// these fields are not re-read by the backend, they are the audit record of what was on screen.
const settlementRowSnapshot = {
branch: savingsRow.br,
accountType: savingsRow.acTyp,
accountNo: savingsRow.acNo,
ecsAccountNo: savingsRow.ecsAcNo || null,
dbCr: savingsRow.dbCr,
amount: savingsRow.amount ?? null,
chequeNo: savingsRow.chequeNo || null,
chequeDate: savingsRow.cheqDate || null,
tokenNo: savingsRow.tokNo || null,
description: savingsRow.description || null,
bankName: savingsRow.bankName || null,
routingCode: savingsRow.routingCode || null,
paymentTypeId: savingsRow.paymentTypeId,
paymentTypeName: settlementPaymentType?.name ?? null
};
const payload = {
shareAccountId,
accountNo: shareRow.acNo,
clientId: this.rowClientIds[shareRow.acNo] ?? null,
clientName: this.rowAccountNames[shareRow.acNo] ?? null,
requestedShares: shares,
requestedDate,
resolutionNumber: shareRow.resolutionNumber.trim(),
meetingDate: shareRow.meetingDate,
description: shareRow.description || null,
settlementPaymentTypeId: savingsRow.paymentTypeId,
settlementRowSnapshot
};
const sharePrice = this.rowSharePrices[shareRow.acNo] ?? 0;
const currentHolding = this.rowShareHoldings[shareRow.acNo] ?? 0;
const remainingShares = currentHolding - shares;
const willCloseAccount = remainingShares <= 0;
const confirmLines = [
`Share Account: ${shareRow.acNo} · Client: ${this.rowAccountNames[shareRow.acNo] ?? '—'}`,
`Shares to Withdraw: ${shares} @ ₹${sharePrice.toLocaleString('en-IN', { minimumFractionDigits: 2 })} = ₹${(shares * sharePrice).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Remaining Shares After Withdrawal: ${Math.max(remainingShares, 0)}`,
...(willCloseAccount
? ['⚠ All shares withdrawn — this account will be CLOSED automatically when the Manager approves.']
: []),
`Board Resolution No.: ${payload.resolutionNumber}`,
`Date of Meeting: ${payload.meetingDate}`,
`Settlement Credit To: ${linkedAcNo}`,
`Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
...(settlementRowSnapshot.ecsAccountNo && settlementRowSnapshot.ecsAccountNo !== linkedAcNo
? [`ECS A/C No.: ${settlementRowSnapshot.ecsAccountNo}`]
: []),
...(settlementRowSnapshot.chequeNo ? [`Cheque No.: ${settlementRowSnapshot.chequeNo}`] : []),
...(settlementRowSnapshot.chequeDate ? [`Cheque Date: ${settlementRowSnapshot.chequeDate}`] : []),
...(settlementRowSnapshot.tokenNo ? [`Token No.: ${settlementRowSnapshot.tokenNo}`] : []),
...(settlementRowSnapshot.description ? [`CR Row Remarks: ${settlementRowSnapshot.description}`] : []),
'',
willCloseAccount
? 'Pending Manager approval — redemption, settlement and account closure all post together on approval.'
: 'Pending Manager approval — redemption and settlement both post together on approval.'
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', confirmLines, 'Submit for Approval', () =>
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
);
}
private doSubmitShareRedemptionTransfer(
payload: Record,
shareRow: TxnRow,
shares: number,
linkedAcNo: string
): void {
this.saving = true;
this.clerkShareWithdrawalService
.submit(payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
err?.error?.defaultUserMessage ??
'Share withdrawal submission failed. Please try again.';
this.showAlert('error', 'Submission Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
const willCloseAccount = (this.rowShareHoldings[shareRow.acNo] ?? 0) - shares <= 0;
this.showAlert('success', 'Share Withdrawal Submitted', [
`${shares} share(s) from ${shareRow.acNo} → settlement to ${linkedAcNo} on approval.`,
willCloseAccount
? 'Pending Manager approval — this will close the share account, since all shares will be withdrawn. Track status in "My Share Requests".'
: 'Pending Manager approval. Track status in "My Share Requests".'
]);
this.rows = [];
this.rowAccountIds = {};
this.rowAccountNames = {};
this.rowSharePrices = {};
this.rowClientIds = {};
this.rowLinkedSavingsAcNo = {};
this.rowShareHoldings = {};
this.addRow();
this.selectedRow = this.rows[0];
this.accountDetails = null;
this.clientDetails = null;
}
});
}
/** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */
onTokNoBlur(row: TxnRow): void {
if (this.isClerk || !row.tokNo?.trim()) return;
const token = row.tokNo.trim();
this.loadingWithdrawal = true;
this.activeWithdrawalRequest = null;
this.activeDividendClaimRequest = null;
this.activeClosureRequest = null;
this.clerkWithdrawalService
.getByToken(token)
.pipe(
catchError((err: any) => {
// Not a withdrawal-request token at all (as opposed to PENDING/WITHDRAWN/REJECTED,
// which are legitimate matches with their own specific error) — try the dividend
// cash-claim pool, then the account-closure pool, before giving up: all three share
// the same TOK. NO. field at this counter.
if (err?.status === 404) {
return this.dividendClaimRequestService.getByToken(token).pipe(
catchError((divErr: any) => {
if (divErr?.status === 404) {
return this.closureRequestService.getByToken(token).pipe(
catchError((closeErr: any) => {
this.loadingWithdrawal = false;
const msg: string =
closeErr?.error?.message ??
closeErr?.error?.errors?.[0]?.defaultUserMessage ??
`Token '${token}' could not be verified. Please try again.`;
this.showAlert('error', `Token '${token}'`, [msg]);
return of(null);
}),
map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null))
);
}
this.loadingWithdrawal = false;
const msg: string =
divErr?.error?.message ??
divErr?.error?.errors?.[0]?.defaultUserMessage ??
`Token '${token}' could not be verified. Please try again.`;
this.showAlert('error', `Token '${token}'`, [msg]);
return of(null);
}),
// Only tag a result that actually came from the dividend pool — a closure hit
// recovered by the inner fallback above is already tagged and must stay that way.
map((divReq: any) => (divReq && !divReq.__closureRequest ? { __dividendClaim: true, ...divReq } : divReq))
);
}
this.loadingWithdrawal = false;
const msg: string =
err?.error?.message ??
err?.error?.errors?.[0]?.defaultUserMessage ??
`Token '${token}' could not be verified. Please try again.`;
this.showAlert('error', `Token '${token}'`, [msg]);
return of(null);
})
)
.subscribe((req: any) => {
this.loadingWithdrawal = false;
if (!req) return;
if (req.__dividendClaim) {
this.activeDividendClaimRequest = req;
// No real savings/current account backs a dividend cash claim (it's a pure GL
// liability settled at claim-approval time) — stub just enough of accountDetails
// and kycDetails for onSave()'s generic guards to pass without stale/unrelated data.
this.accountDetails = { id: null, accountNo: 'DIVIDEND-CLAIM-' + req.id, clientName: req.client_name };
this.kycDetails = null;
this.clientDetails = null;
this.accountError = '';
row.acNo = req.client_name ?? 'Dividend Claim #' + req.id;
row.acTyp = 'SD';
row.amount = Number(req.amount) || null;
row.dbCr = 'Dr';
this.selectedRow = row;
return;
}
if (req.__closureRequest) {
this.activeClosureRequest = req;
// The account is already CLOSED at Fineract level by the time a cashier sees this token
// (the close command ran at manager approval), so its own detail response would show a
// closed account with a zero balance. What the cashier needs here is the rounded cash
// figure to count out, which the request itself carries.
this.accountDetails = {
id: null,
accountNo: req.account_no,
clientName: req.client_name
};
this.kycDetails = null;
this.clientDetails = null;
this.accountError = '';
row.acNo = req.account_no ?? '';
row.acTyp = 'SD';
row.amount = Number(req.cashier_disbursed_amount) || null;
row.dbCr = 'Dr';
// A closure request stores payment_type_id as NULL, meaning "Cash (Default)" — resolve the
// real cash payment type here so the cashier isn't asked to pick one and the
// "Payment Type Required" guard on the disbursement path is satisfied.
row.paymentTypeId = req.payment_type_id ?? this.findCashPaymentTypeId();
row.description = 'Account closure — ' + (req.client_name ?? req.account_no);
this.selectedRow = row;
return;
}
this.activeWithdrawalRequest = req;
row.acNo = req.account_no ?? '';
row.acTyp = 'SD';
row.amount = Number(req.amount) || null;
row.dbCr = 'Dr';
row.paymentTypeId = req.payment_type_id ?? null;
row.description = req.description ?? '';
this.selectedRow = row;
if (req.account_no) {
this.loadAccountDetails(req.account_no.trim(), 'SD');
}
});
}
/** When exactly one row on screen is a Share account with a known market price, auto-fills every
* OTHER row's amount as (shares entered on that row) × (its market price) — a calculator
* convenience for filling the paired settlement entry. The Share row itself still can never be
* saved from this screen (see the SHR guard in confirmSubmit()) — this only helps compute the
* correct amount to carry over into the separate manual Deposit Slip entry. */
syncShareLinkedAmounts(): void {
const shareRows = this.rows.filter((r) => r.acTyp?.toUpperCase() === 'SHR' && this.rowSharePrices[r.acNo]);
if (shareRows.length !== 1) return;
const shareRow = shareRows[0];
const price = this.rowSharePrices[shareRow.acNo] ?? 0;
const shares = Number(shareRow.amount) || 0;
const total = Math.round(shares * price * 100) / 100;
this.rows.forEach((r) => {
if (r !== shareRow) {
r.amount = total;
}
});
}
onAcNoBlur(row: TxnRow, index: number): void {
if (!row.acNo.trim()) return;
this.selectedRow = row;
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
onAcNoInput(row: TxnRow, index: number, value: string): void {
row.acNo = value;
this.lastLoadedAcNo = '';
this.acNoInput$.next({ row, index, value });
}
private loadCashVaultGl(): void {
// Dynamic: vault name > cash name > first ASSET type — no hardcoded IDs
this.http
.get('/financialactivityaccounts')
.pipe(catchError(() => of([])))
.subscribe((list: any) => {
const items: any[] = Array.isArray(list) ? list : [];
const byName = (re: RegExp) => items.find((a: any) => re.test(a.financialActivityData?.name ?? ''));
const found =
byName(/vault/i) ?? byName(/cash/i) ?? items.find((a: any) => a.glAccountData?.type?.value === 'ASSET');
if (found?.glAccountData?.id) {
this.cashVaultGlId = found.glAccountData.id;
}
});
}
onRowSelect(row: TxnRow, index: number): void {
this.selectedRow = row;
const acNo = row.acNo.trim();
const alreadyLoaded = this.accountDetails && this.lastLoadedAcNo === acNo;
if (acNo && !alreadyLoaded) {
this.loadAccountDetails(acNo, row.acTyp);
}
}
private static readonly SEARCH_ACCOUNT_TYPE_TO_AC_TYP: Record = {
SAVINGS: 'SD',
CURRENT: 'CA',
FIXED_DEPOSIT: 'FD',
RECURRING_DEPOSIT: 'RD'
};
openCustomerSearch(): void {
const dialogRef = this.dialog.open(CustomerSearchDialogComponent, {
width: '960px',
maxWidth: '95vw'
});
dialogRef.afterClosed().subscribe((result: CustomerSearchResult | undefined) => {
if (!result || !result.accountNo) return;
// selectedRow is only set once the clerk types into A/C NO. themselves - on a fresh screen
// it's still null, so fall back to the first (only) row that addRow() created at startup.
const targetRow = this.selectedRow ?? this.rows[0];
if (!targetRow) return;
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[result.accountType || ''] || 'SD';
targetRow.acTyp = acTyp;
targetRow.acNo = result.accountNo;
this.selectedRow = targetRow;
this.loadAccountDetails(result.accountNo, acTyp);
});
}
private loadAccountDetails(acNo: string, acTyp: string): void {
this.lastLoadedAcNo = acNo;
this.loadingAccount = true;
this.accountDetails = null;
this.clientDetails = null;
this.kycDetails = null;
this.nominees = [];
this.jointHolders = [];
this.clientAddresses = [];
this.clientPhotoUrl = null;
if (this.signatureUrl?.startsWith('blob:')) URL.revokeObjectURL(this.signatureUrl);
this.signatureUrl = null;
this.holdBreakdown = null;
this.accountNotes = [];
this.standingInstructions = [];
this.todayTransactions = [];
this.linkedAccounts = null;
this.sameDayNeftRtgs = 0;
this.sameDayChequeClearing = 0;
this.accountError = '';
const token = ++this.searchToken; // Every new search gets a unique token
const cfg = this.accountTypeConfig(acTyp);
// Step 1: search on the correct endpoint for this account type
this.http
.get(
`${cfg.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}`
)
.pipe(catchError(() => of(null)))
.subscribe((res: any) => {
if (token !== this.searchToken) return; // stale result — newer search started
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
const f = cfg.matchField;
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
const partialMatch = !exactMatch
? (allItems.find((x: any) => String(x[f] ?? '').endsWith(acNo)) ??
allItems.find((x: any) => String(x[f] ?? '').includes(acNo)))
: null;
const idMatch =
!exactMatch && !partialMatch && acTyp === 'GL'
? allItems.find((x: any) => String(x.id) === String(acNo))
: null;
const match = exactMatch ?? partialMatch ?? idMatch ?? null;
if (match?.id) {
const matchedGlCode = match[f] ?? acNo;
if (this.selectedRow && this.selectedRow.typ === 'DD' && match.glCode) {
this.selectedRow.acNo = match.glCode;
}
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
this.fetchAllDetails(match.id, matchedGlCode, cfg, token, acTyp, clientIdFromSearch);
return;
}
this.loadingAccount = false;
this.accountError = 'Account not found for: ' + acNo;
});
}
/** Maps UI account type → { listEndpoint, detailEndpoint, nomineeType, searchResource } */
/**
* The account-type label carried on a disbursement request's credit/debit entry.
*
* The backend (ClerkDisbursementRequestApiResource) only ever branches on 'GL' and 'CURRENT'; every
* other value takes the same path, and in the disbursement flow it resolves current-vs-savings from
* the database rather than from this string. So naming a fixed or recurring deposit accurately here
* changes only what the approving checker sees, never what gets posted. Previously anything that
* wasn't current or GL was labelled 'SAVINGS', which showed a checker an FD as a savings account --
* exactly the wrong information to approve a disbursement against.
*/
private creditEntryAccountType(acTyp: string | undefined | null): string {
switch ((acTyp ?? '').toUpperCase()) {
case 'GL':
return 'GL';
case 'CA':
case 'CB':
return 'CURRENT';
case 'FD':
return 'FIXED_DEPOSIT';
case 'RD':
return 'RECURRING_DEPOSIT';
case 'SHR':
return 'SHARE';
case 'LA':
return 'LOAN';
default:
return 'SAVINGS';
}
}
private accountTypeConfig(acTyp: string): {
list: string;
detail: string;
nominee: string;
search: string;
siAccountType: number;
txnBase: string;
txnSearchSupported: boolean;
/** True when this account type has no separate REST transactions sub-resource at all (e.g.
* Share accounts — Fineract returns transactions embedded on the account detail response
* itself via ?associations=all, never at /accounts/share/{id}/transactions). Skips the
* "today's transactions" HTTP call entirely instead of hitting a 404. */
txnFetchUnsupported?: boolean;
/** True when this account type has no standing-instruction "from account type" mapping on
* the backend at all (PortfolioAccountType only defines LOAN/SAVINGS/CURRENT — Share isn't
* a valid standing-instruction source account). Skips that HTTP call entirely instead of
* sending an fromAccountType value the backend's query-builder silently can't match,
* which otherwise produces a malformed "WHERE " SQL statement server-side. */
siFetchUnsupported?: boolean;
maxLimit: number;
skipGlobalSearch: boolean;
listQueryParam: string;
matchField: string;
detailParams: string;
supportsPagination: boolean;
} {
const t = acTyp?.toUpperCase();
if (t === 'LA') {
return {
list: '/loans',
detail: '/loans',
nominee: 'LA',
search: 'loan',
siAccountType: 1,
txnBase: '/loans',
txnSearchSupported: true,
maxLimit: 1,
skipGlobalSearch: true,
listQueryParam: 'accountNo',
matchField: 'accountNo',
detailParams: '?associations=all',
supportsPagination: true
};
}
if (t === 'SHR') {
return {
list: '/accounts/share',
detail: '/accounts/share',
nominee: 'SHR',
search: 'share',
siAccountType: 4,
txnBase: '/accounts/share',
txnSearchSupported: false,
txnFetchUnsupported: true,
siFetchUnsupported: true,
maxLimit: 500,
skipGlobalSearch: true,
listQueryParam: 'accountNo',
matchField: 'accountNo',
detailParams: '?associations=all',
supportsPagination: true
};
}
if (t === 'GL') {
// GL uses searchParam (not accountNo) and matches on glCode field
return {
list: '/glaccounts',
detail: '/glaccounts',
nominee: 'GL',
search: 'glaccount',
siAccountType: 0,
txnBase: '/glaccounts',
txnSearchSupported: false,
maxLimit: 500,
skipGlobalSearch: true,
listQueryParam: 'searchParam',
matchField: 'glCode',
detailParams: '?fetchRunningBalance=true',
supportsPagination: false
};
}
if (t === 'CA' || t === 'CB') {
return {
list: '/currentaccounts',
detail: '/currentaccounts',
nominee: 'CB',
search: 'currentaccount',
siAccountType: 3,
txnBase: '/currentaccounts',
txnSearchSupported: true,
maxLimit: 1,
skipGlobalSearch: true,
listQueryParam: 'accountNo',
matchField: 'accountNo',
detailParams: '?associations=all',
supportsPagination: true
};
}
if (t === 'FD') {
return {
list: '/savingsaccounts',
detail: '/fixeddepositaccounts',
nominee: 'FD',
search: 'fixeddeposit',
siAccountType: 2,
txnBase: '/savingsaccounts',
txnSearchSupported: true,
maxLimit: 1,
skipGlobalSearch: true,
listQueryParam: 'accountNo',
matchField: 'accountNo',
detailParams: '?associations=all',
supportsPagination: true
};
}
if (t === 'RD') {
return {
list: '/savingsaccounts',
detail: '/recurringdepositaccounts',
nominee: 'RD',
search: 'recurringdeposit',
siAccountType: 2,
txnBase: '/savingsaccounts',
txnSearchSupported: true,
maxLimit: 1,
skipGlobalSearch: true,
listQueryParam: 'accountNo',
matchField: 'accountNo',
detailParams: '?associations=all',
supportsPagination: true
};
}
// SD — savings
return {
list: '/savingsaccounts',
detail: '/savingsaccounts',
nominee: 'SB',
search: 'saving',
siAccountType: 2,
txnBase: '/savingsaccounts',
txnSearchSupported: true,
maxLimit: 1,
skipGlobalSearch: true,
listQueryParam: 'accountNo',
matchField: 'accountNo',
detailParams: '?associations=all',
supportsPagination: true
};
}
/** Maps UI account type code → backend accountType code for nominee filter */
private toNomineeAccountType(acTyp: string): string {
return this.accountTypeConfig(acTyp).nominee;
}
/** Parallel fetch: account (with associations) + client + KYC + nominees */
private fetchAllDetails(
accountId: number,
acNo: string,
cfg: {
list: string;
detail: string;
nominee: string;
search: string;
siAccountType: number;
txnBase: string;
txnSearchSupported: boolean;
txnFetchUnsupported?: boolean;
siFetchUnsupported?: boolean;
detailParams?: string;
},
token: number,
acTyp: string = '',
clientIdHint: string | null = null
): void {
const accountUrl = `${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`;
const todayStr = (() => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
})();
// Shared: apply account-level fields after fetch
const applyAccountData = (acc: any): boolean => {
if (!acc) {
this.loadingAccount = false;
this.accountError = 'Could not load account details.';
return false;
}
const returnedId = acc.glCode ?? acc.accountNo;
if (returnedId && returnedId !== acNo && String(acc.id) !== acNo) {
this.loadingAccount = false;
this.accountError = 'Account mismatch. Please re-enter account number and try again.';
return false;
}
this.accountDetails = acc;
this.jointHolders = acc.jointHolders ?? [];
// Cash Hold / Clearing Hold breakdown only exists for plain savings accounts (m_savings_account)
// — FD/RD/Current/Share/GL have no such row, so there's nothing to ask for.
if (acTyp?.toUpperCase() === 'SD' && acc.id) {
this.http
.get(`/savingsaccounts/${acc.id}/holdbreakdown`)
.pipe(catchError(() => of(null)))
.subscribe((hb: any) => {
if (token !== this.searchToken) return;
this.holdBreakdown = hb
? { cashHold: Number(hb.cashHold) || 0, clearingHold: Number(hb.clearingHold) || 0 }
: null;
});
}
// Track per-row account id/name for multi-row disbursement
if (this.selectedRow?.acNo) {
this.rowAccountIds[this.selectedRow.acNo] = acc.id;
// clientName must be in this chain: a deposit account's detail response carries the holder
// under clientName, not name/displayName (only GL accounts have `name`). Without it every FD
// and RD fell through to accountNo, so a disbursement request showed the checker an account
// number where the account holder's name belongs.
this.rowAccountNames[this.selectedRow.acNo] =
acc.name ?? acc.displayName ?? acc.clientName ?? acc.glAccountName ?? acc.accountNo ?? '';
this.rowClientIds[this.selectedRow.acNo] = acc.clientId ?? null;
}
if (this.selectedRow) {
// Share accounts: ECS field is the share account's own number — acc.savingsAccountNumber
// is a different, unrelated account (the linked settlement savings account) and must
// never be shown here.
this.selectedRow.ecsAcNo =
acTyp?.toUpperCase() === 'SHR'
? (acc.accountNo ?? '')
: (acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '');
}
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
this.computeSameDayNeftRtgs(acc.transactions);
this.computeSameDayChequeClearing(acc.transactions);
if (acTyp?.toUpperCase() === 'SHR' && this.selectedRow?.acNo) {
// Keyed by the row's own typed acNo (e.g. "0011"), not the resolved full account number
// (e.g. "000000011") — rowAccountIds/rowAccountNames above use the same key, and every
// lookup elsewhere (syncShareLinkedAmounts, submitShareRedemptionTransfer, the template's
// rowSharePrices[row.acNo]) reads via row.acNo, so this must match or every lookup misses.
this.rowSharePrices[this.selectedRow.acNo] = Number(acc.currentMarketPrice) || 0;
this.rowLinkedSavingsAcNo[this.selectedRow.acNo] = acc.savingsAccountNumber ?? '';
this.rowShareHoldings[this.selectedRow.acNo] = Number(
acc.summary?.totalApprovedShares ?? acc.totalApprovedShares ?? 0
);
this.syncShareLinkedAmounts();
}
return true;
};
// Shared: apply client-level fields after fetch
const applyClientData = (
clientId: string,
r: {
client: any;
kyc: any;
nominees: any;
addresses: any;
family: any;
photo: any;
documents: any;
notes: any;
linkedAcc: any;
si: any;
todayTxns: any;
}
): void => {
this.loadingAccount = false;
this.clientDetails = { ...r.client, familyMembers: Array.isArray(r.family) ? r.family : [] };
const kycArr: any[] = Array.isArray(r.kyc) ? r.kyc : r.kyc ? [r.kyc] : [];
this.kycDetails = kycArr[0] ?? null;
this.nominees = Array.isArray(r.nominees) ? r.nominees : [];
this.nonPersonDetails = r.client?.clientNonPersonDetails ?? null;
this.clientAddresses = Array.isArray(r.addresses) ? r.addresses : [];
this.clientPhotoUrl = r.photo ?? null;
const docs: any[] = Array.isArray(r.documents) ? r.documents : [];
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
// signature before uploads became single-slot, so pick the highest id (latest) rather than
// the first unordered match.
const sigDoc = docs
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
.sort((a: any, b: any) => b.id - a.id)[0];
if (sigDoc) {
this.http
.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' })
.pipe(catchError(() => of(null)))
.subscribe((blob: Blob | null) => {
if (blob) {
this.signatureUrl = URL.createObjectURL(blob);
this.persistClerkState();
}
});
}
this.accountNotes = Array.isArray(r.notes) ? r.notes : [];
this.linkedAccounts = r.linkedAcc;
this.standingInstructions = r.si?.pageItems ?? (Array.isArray(r.si) ? r.si : []);
this.todayTransactions =
r.todayTxns?.pageItems ?? r.todayTxns?.content ?? (Array.isArray(r.todayTxns) ? r.todayTxns : []);
this.persistClerkState();
};
// Build all client-side observable calls (reused in both paths)
const buildClientCalls = (clientId: string) => ({
client: this.http
.get(`/clients/${clientId}?associations=clientNonPersonDetails`)
.pipe(catchError(() => of(null))),
kyc: this.http.get(`/clients/${clientId}/kyc`).pipe(catchError(() => of([]))),
nominees: this.http
.get(`/clients/${clientId}/nominees?accountNo=${acNo}&accountType=${cfg.nominee}`)
.pipe(catchError(() => of([]))),
addresses: this.http.get(`/client/${clientId}/addresses`).pipe(catchError(() => of([]))),
family: this.http.get(`/clients/${clientId}/familymembers`).pipe(catchError(() => of([]))),
photo: this.http.get(`/clients/${clientId}/images`, { responseType: 'text' }).pipe(catchError(() => of(null))),
documents: this.http.get(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))),
notes: this.http.get(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))),
linkedAcc: this.http.get(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))),
si: cfg.siFetchUnsupported
? of({ pageItems: [] })
: this.http
.get(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
.pipe(catchError(() => of({ pageItems: [] }))),
todayTxns: cfg.txnFetchUnsupported
? of({ pageItems: [] })
: cfg.txnSearchSupported
? this.http
.get(
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
)
.pipe(catchError(() => of({ pageItems: [] })))
: this.http
.get(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
.pipe(catchError(() => of({ pageItems: [] })))
});
if (clientIdHint) {
// FAST PATH: account detail + all client calls fire simultaneously (1 round-trip saved)
forkJoin({
account: this.http.get(accountUrl).pipe(catchError(() => of(null))),
...buildClientCalls(clientIdHint)
}).subscribe((result: any) => {
if (token !== this.searchToken) return;
const { account, ...clientData } = result;
if (!applyAccountData(account)) return;
applyClientData(clientIdHint, clientData);
});
return;
}
// SEQUENTIAL PATH: for GL accounts (no clientId in search result)
this.http
.get(accountUrl)
.pipe(catchError(() => of(null)))
.subscribe((acc: any) => {
if (token !== this.searchToken) return;
if (!applyAccountData(acc)) return;
const clientId = acc.clientId?.toString();
if (!clientId) {
this.loadingAccount = false;
return;
}
forkJoin(buildClientCalls(clientId)).subscribe((r: any) => {
applyClientData(clientId, r);
});
});
}
// ── Alert getters ─────────────────────────────────────────────────────────
get accountAlerts(): { type: 'danger' | 'warning' | 'info'; message: string }[] {
const alerts: { type: 'danger' | 'warning' | 'info'; message: string }[] = [];
const acc = this.accountDetails;
if (!acc) return alerts;
// Block reason
if (acc.reasonForBlock) alerts.push({ type: 'danger', message: `Account Blocked: ${acc.reasonForBlock}` });
// Dormancy
const sub = acc.subStatus?.value ?? '';
if (sub === 'Dormant') alerts.push({ type: 'danger', message: 'Account is DORMANT — restricted transactions' });
if (sub === 'Inactive') alerts.push({ type: 'warning', message: 'Account is INACTIVE' });
if (sub === 'Escheat') alerts.push({ type: 'danger', message: 'Account is ESCHEAT — refer to manager' });
// Lien
const lienAmt = Number(acc.summary?.totalSavingsAmountOnHold ?? acc.onHoldFundsDerived ?? 0);
if (lienAmt > 0)
alerts.push({
type: 'warning',
message: `Lien / Hold: ₹${lienAmt.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`
});
// TDS
if (acc.withHoldTax)
alerts.push({ type: 'info', message: `TDS Applicable — Tax Group: ${acc.taxGroupData?.name ?? ''}` });
// Sanctions (from KYC) — DB stores 'YES'/'NO' strings, not booleans
const isYes = (v: any) => v === true || v === 'YES' || v === 'yes' || v === 'Y';
if (isYes(this.kycDetails?.bannedEntityMatch))
alerts.push({ type: 'danger', message: 'Sanctions Match — DO NOT process' });
if (isYes(this.kycDetails?.willfulDefaulter))
alerts.push({ type: 'danger', message: 'Willful Defaulter — Refer to Manager' });
// No AML-suspicion banner here by design. Sanctions and willful-defaulter above are legal
// prohibitions the cashier has to act on at the counter; AML suspicion is not — showing it
// risks tipping off the customer. It stays in the AML review queue and the STR/CTR reports.
return alerts;
}
get isDormant(): boolean {
const sub = this.accountDetails?.subStatus?.value ?? '';
return sub === 'Dormant' || sub === 'Inactive' || sub === 'Escheat';
}
get lienAmount(): number {
return Number(
this.accountDetails?.summary?.totalSavingsAmountOnHold ?? this.accountDetails?.onHoldFundsDerived ?? 0
);
}
get facilities(): { label: string; active: boolean }[] {
// Savings/FD/RD: accountFacilityConfig object
const f = this.accountDetails?.accountFacilityConfig ?? this.accountDetails?.facilityConfig;
if (f) {
// Savings / FD / RD — facilityConfig object available
return [
{ label: 'ATM Facility', active: !!f.atmFacility },
{ label: 'ATM Withdrawal', active: !!f.atmWithdrawalFacility },
{ label: 'UPI Facility', active: !!f.upiFacility },
{ label: 'POS Transactions', active: !!f.posTransactionsFacility },
{ label: 'Cheque Book', active: !!f.chequeBook },
{ label: 'IMPS Facility', active: !!f.impsFacility },
{ label: 'SMS Alerts', active: !!f.smsAlerts },
{ label: 'BBPS Facility', active: !!f.bbpsFacility },
{ label: 'Insurance Cover', active: !!f.insuranceCover },
{ label: 'BEPG Facility', active: !!f.bepgFacility },
{ label: 'NEFT Facility', active: !!f.neftFacility },
{ label: 'RTGS Facility', active: !!f.rtgsFacility },
{ label: 'Inter-Bank Cap', active: !!f.interBankTotalCapFacility },
{ label: 'Deposit (KYC)', active: !!f.depositFacilityKyc },
{ label: 'Virtual Debit Card', active: !!f.virtualDebitCard },
{ label: 'OD', active: !!this.accountDetails?.allowOverdraft }
];
}
// Current account — no facilityConfig; derive from charges array
// Each charge with isActive=true represents an enabled facility
const charges: any[] = this.accountDetails?.charges ?? [];
if (charges.length > 0) {
const chargeFacilityMap: Record = {
'ATM Facility': 'ATM Facility',
'ATM Withdrawal': 'ATM Withdrawal',
'UPI Facility': 'UPI Facility',
'POS Transaction': 'POS Transactions',
'Cheque Book': 'Cheque Book',
'IMPS Facility': 'IMPS Facility',
'SMS Alert': 'SMS Alerts',
'BBPS Facility': 'BBPS Facility',
'Insurance Cover': 'Insurance Cover',
'BEPG Facility': 'BEPG Facility',
'NEFT Facility': 'NEFT Facility',
'RTGS Facility': 'RTGS Facility',
'Inter-Bank Cap': 'Inter-Bank Cap',
'Withdrawal Facility': 'Withdrawal',
'Deposit KYC': 'Deposit (KYC)'
};
const facilityItems = charges
.filter((c: any) => c.isActive)
.map((c: any) => {
const key = Object.keys(chargeFacilityMap).find((k) => c.name?.includes(k));
return key ? { label: chargeFacilityMap[key], active: true } : null;
})
.filter((x): x is { label: string; active: boolean } => x !== null);
// Remove duplicates, add OD separately
const seen = new Set();
const unique = facilityItems.filter((i) => (seen.has(i.label) ? false : !!seen.add(i.label)));
unique.push({ label: 'OD', active: !!this.accountDetails?.allowOverdraft });
return unique;
}
// Fallback: show OD only
return [{ label: 'OD', active: !!this.accountDetails?.allowOverdraft }];
}
get todayCashTotal(): number {
return this.todayTransactions
.filter((t: any) => !t.reversed)
.reduce((s: number, t: any) => {
const amt = Number(t.amount ?? 0);
return s + (t.transactionType?.deposit ? amt : -amt);
}, 0);
}
// ── Balance getters ────────────────────────────────────────────────────────
get currentBalance(): number {
if (this.selectedRow?.acTyp === 'SHR') {
const shares = Number(this.accountDetails?.summary?.totalApprovedShares ?? 0);
const unitPrice = Number(this.accountDetails?.currentMarketPrice ?? 0);
return shares * unitPrice;
}
return this.accountDetails?.summary?.accountBalance ?? this.accountDetails?.organizationRunningBalance ?? 0;
}
get clearBalance(): number {
return this.accountDetails?.summary?.availableBalance ?? this.currentBalance;
}
get todayClgAmount(): number {
return this.accountDetails?.summary?.totalDepositsPostMaturityLevel ?? 0;
}
get freezeAmount(): number {
// Savings exposes the lien/user hold as root-level `savingsAmountOnHold`; Current exposes its
// own (differently-sourced) hold as `onHoldFunds`. `summary.holdAmountBalance` never existed in
// either API response, so this always read 0 before.
const acTyp = this.selectedRow?.acTyp;
const isCurrent = acTyp === 'CA' || acTyp === 'CB';
if (isCurrent) {
return this.accountDetails?.onHoldFunds ?? this.accountDetails?.summary?.onHoldFunds ?? 0;
}
return this.accountDetails?.savingsAmountOnHold ?? 0;
}
/** A manual, staff-placed lien — the same underlying figure "Transfer Hold" already shows
* ({@link freezeAmount}); Fineract has one bucket for this, not two, so both fields legitimately
* read the same number rather than one of them being invented data. */
get cashHold(): number {
return this.holdBreakdown?.cashHold ?? this.freezeAmount;
}
/** Money still moving through the clearing process — a cheque/DD deposit not yet realized, or a
* presented cheque not yet settled. Only populated for a plain savings account; 0 elsewhere. */
get clearingHold(): number {
return this.holdBreakdown?.clearingHold ?? 0;
}
get withdrawableAmount(): number {
const avail = this.accountDetails?.summary?.availableBalance ?? this.currentBalance;
const acTyp = this.selectedRow?.acTyp;
const isCurrent = acTyp === 'CA' || acTyp === 'CB';
// Savings' availableBalance is already computed hold-adjusted on the backend (subtracts both
// guarantor and lien holds) — subtracting freezeAmount again here would double-count it.
// Current's availableBalance is just the raw balance and does NOT subtract its hold, so it
// still needs freezeAmount subtracted client-side.
if (isCurrent) {
return Math.max(0, avail - this.freezeAmount);
}
return Math.max(0, avail);
}
get currencySymbol(): string {
return this.accountDetails?.currency?.displaySymbol ?? '₹';
}
get todayIso(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
// ── Entry grid totals ─────────────────────────────────────────────────────
get totalDebit(): number {
return this.rows.filter((r) => r.dbCr === 'Dr').reduce((s, r) => s + (Number(r.amount) || 0), 0);
}
get totalCredit(): number {
return this.rows.filter((r) => r.dbCr === 'Cr').reduce((s, r) => s + (Number(r.amount) || 0), 0);
}
get difference(): number {
return Math.abs(this.totalDebit - this.totalCredit);
}
get isBalanced(): boolean {
return this.totalDebit > 0 && this.totalDebit === this.totalCredit;
}
formatInr(n: number): string {
return new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
}
deleteRow(i: number): void {
if (this.rows.length <= 1) return;
this.rows.splice(i, 1);
if (this.selectedRow && !this.rows.includes(this.selectedRow)) this.selectedRow = null;
}
get isClerk(): boolean {
// clerkMode forces the clerk layout (used by the clerk dashboard and the manager review screen)
return this.clerkMode || this.authService.isClerkUser();
}
get reviewMode(): boolean {
return this.reviewRequestId != null;
}
get editMode(): boolean {
return this.editRequestId != null;
}
/** Only REJECTED requests are editable; PENDING/APPROVED open read-only. */
get editResubmittable(): boolean {
return this.editMode && this.editRequest?.status === 'REJECTED';
}
/** Form is locked in manager review, or when viewing a non-rejected request in edit mode. */
get formReadOnly(): boolean {
return this.reviewMode || (this.editMode && !this.editResubmittable);
}
get isNonIndividual(): boolean {
// legalForm.id=1 → Individual, legalForm.id=2 → Non-Individual (Entity)
const lf = this.clientDetails?.legalForm?.id ?? this.clientDetails?.legalFormId;
return lf === 2;
}
/** Returns count of mandatory joint holders */
get mandatoryHolderCount(): number {
return this.jointHolders.filter((jh: any) => (jh.signingAuthority ?? '').toLowerCase() === 'mandatory').length;
}
/** Withdrawal rule message based on joint holders + mode */
get withdrawalRule(): { icon: string; msg: string; type: 'danger' | 'warning' } | null {
if (!this.jointHolders.length) return null;
const mandatory = this.mandatoryHolderCount;
const mode = this.accountDetails?.modeOfOperation ?? '';
if (mandatory >= 2) {
return {
icon: '✍',
msg: `All ${mandatory} joint holders must sign for withdrawal`,
type: 'danger'
};
}
if (mandatory === 1 && this.jointHolders.length > 1) {
return { icon: '✍', msg: 'Mandatory holder signature required for withdrawal', type: 'warning' };
}
if (mode === 'FORMER_OR_SURVIVOR') {
return { icon: '⚠', msg: 'Former or Survivor: Only primary holder can withdraw while alive', type: 'warning' };
}
return null;
}
/** Returns true if currently selected payment type is cash */
get isCashPayment(): boolean {
const row = this.selectedRow;
if (!row?.paymentTypeId) return false;
const pt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
return !!pt?.isCashPayment;
}
/** Clerk attempting a cash deposit — blocked because cash/denomination is cashier's responsibility */
get isClerkCashDeposit(): boolean {
return this.isClerk && this.isCashPayment && this.selectedRow?.dbCr === 'Cr';
}
/** For cash: amount comes ONLY from denomination total */
get effectiveAmount(): number {
if (this.isCashPayment) return this.denominationTotal;
return this.selectedRow?.amount ?? 0;
}
get modeOfOperationLabel(): string {
const modeMap: Record = {
FORMER_OR_SURVIVOR: 'Former or Survivor',
EITHER_OR_SURVIVOR: 'Either or Survivor',
ANYONE_OR_SURVIVOR: 'Any One or Survivor',
JOINTLY: 'Both Jointly',
SINGLE: 'Single',
OTHER: 'Other'
};
const raw = this.accountDetails?.modeOfOperation;
const mode = (typeof raw === 'object' ? raw?.value : raw) ?? '';
return modeMap[mode] ?? mode.replace(/_/g, ' ');
}
// ── Client detail getters ─────────────────────────────────────────────────
get clientName(): string {
return this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? '—';
}
get guardianName(): string {
// S/D/W/H/O derived from family members
// API returns: firstName, middleName, lastName, relationship
const family: any[] = this.clientDetails?.familyMembers ?? [];
if (!family.length) return this.clientDetails?.guardianClientName ?? '—';
const priority = [
'FATHER',
'MOTHER',
'HUSBAND',
'WIFE',
'SPOUSE'
];
const prefixMap: Record = {
FATHER: 'S/O',
MOTHER: 'D/O',
HUSBAND: 'W/O',
WIFE: 'H/O',
SPOUSE: 'W/O'
};
for (const rel of priority) {
const member = family.find((m: any) => (m.relationship ?? m.relationshipName ?? '').toUpperCase() === rel);
if (member) {
// API uses camelCase: firstName, middleName, lastName
const name = [
member.firstName,
member.middleName,
member.lastName
]
.filter((v) => v && v.trim())
.join(' ')
.trim();
return name ? `${prefixMap[rel] ?? rel} ${name}` : (prefixMap[rel] ?? rel);
}
}
// Fallback: first family member
const m = family[0];
const name = [
m.firstName,
m.middleName,
m.lastName
]
.filter((v) => v && v.trim())
.join(' ')
.trim();
return name || '—';
}
get clientAddress(): string {
// From /client/{id}/addresses — AddressData fields: addressLine1, townVillage, city, stateName, postalCode
if (this.clientAddresses.length > 0) {
const a = this.clientAddresses[0];
return [
a.addressLine1,
a.addressLine2,
a.addressLine3,
a.townVillage,
a.city,
a.countyDistrict,
a.stateName ?? a.stateProvinceLabel,
a.postalCode
]
.filter((v) => v && v.toString().trim())
.join(', ');
}
return '—';
}
get clientPhone(): string {
const mob = this.clientDetails?.mobileNo ?? this.accountDetails?.clientMobileNo;
const alt = this.clientDetails?.alternateMobileNo;
return (
[
mob,
alt
]
.filter(Boolean)
.join(' / ') || '—'
);
}
get clientEmail(): string {
return this.clientDetails?.emailAddress ?? '—';
}
get officeName(): string {
return this.accountDetails?.officeName ?? this.clientDetails?.officeName ?? '—';
}
get openingDate(): string {
const raw =
this.accountDetails?.timeline?.submittedOnDate ??
this.accountDetails?.submittedOnDate ??
this.accountDetails?.activatedOnDate;
if (!raw) return '—';
// Array format [yyyy, mm, dd] — savings/FD/RD
if (Array.isArray(raw) && raw.length >= 3) {
return `${String(raw[2]).padStart(2, '0')}/${String(raw[1]).padStart(2, '0')}/${raw[0]}`;
}
if (typeof raw === 'string') {
// ISO format 'yyyy-MM-dd'
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
const [
y,
m,
d
] = raw.split('-');
return `${d}/${m}/${y}`;
}
// Locale string 'Jun 5, 2026' — current account
try {
const d = new Date(raw);
if (!isNaN(d.getTime())) {
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
}
} catch {
/* ignore */
}
}
return String(raw);
}
get lastTransactionDate(): string {
const d = this.accountDetails?.lastActiveTransactionDate;
return d ? `${d[2]}/${d[1]}/${d[0]}` : '—';
}
get panNumber(): string {
return this.kycDetails?.panNumber ?? this.kycDetails?.pan_number ?? '—';
}
get aadhaarNumber(): string {
return this.kycDetails?.aadhaarNumberMasked ?? this.kycDetails?.aadhaar_number_masked ?? 'XXXX/XXXX/XXXX';
}
get ckycNumber(): string {
return this.kycDetails?.ckycNumber ?? this.kycDetails?.ckyc_number ?? '—';
}
get kycStatus(): string {
const s = this.kycDetails?.kycStatus ?? this.kycDetails?.kyc_status;
if (!s) return '—';
const map: Record = { 100: 'Pending', 200: 'In Progress', 300: 'Verified', 400: 'Rejected' };
return map[s] ?? `KYC-${s}`;
}
get cifNumber(): string {
return this.clientDetails?.cifNumber ?? this.clientDetails?.cif_number ?? '—';
}
// ── Joint holder click-to-expand ──────────────────────────────────────────
toggleHolder(index: number, jh: any): void {
const clientId = jh.clientId ?? jh.client_id;
this.dialog.open(JointHolderDialogComponent, {
data: { jh, clientId },
width: '620px',
maxWidth: '95vw',
panelClass: 'jhd-panel',
autoFocus: false
});
}
/** Only meaningful for a plain savings account — the transactions array a Statement is built from
* is embedded on that account type's own detail response (?associations=all), never on
* FD/RD/Current/Share/GL. */
get canPrintStatement(): boolean {
return this.selectedRow?.acTyp === 'SD' && !!this.accountDetails?.id;
}
printStatement(): void {
if (!this.canPrintStatement) return;
this.dialog.open(AccountStatementDialogComponent, {
data: { account: this.accountDetails },
width: '820px',
maxWidth: '95vw',
autoFocus: false
});
}
// ── Account Actions (clerk): close this account ──────────────────────────────
/** The Action button only makes sense once a real, ACTIVE savings account is on screen — a
* closure request needs an actual account id to submit against, and only a clerk is the maker
* in this flow (the cashier's side is the token disbursement, not the request). */
get canShowAccountActions(): boolean {
return (
this.isClerk &&
!this.reviewMode &&
!this.editMode &&
this.selectedRow?.acTyp === 'SD' &&
!!this.accountDetails?.id &&
this.accountDetails?.status?.value === 'Active'
);
}
openAccountActions(): void {
this.closeAccountChecked = this.pendingClosure;
this.closurePreviewError = '';
if (!this.pendingClosure) {
this.closurePreview = null;
}
this.showAccountActions = true;
}
closeAccountActions(): void {
this.showAccountActions = false;
}
/** Ticking "Close this account" posts every rupee of interest the member has earned to date onto
* the account for real — the same posting close() itself performs — and shows the resulting real
* balance. This is not an estimate: the interest is credited the moment the box is ticked, exactly
* as the user asked for ("jaise FD close/maturity ke time interest post hota tha"). Approval later
* finds this same posting already sitting at the matching period end and adds nothing more. */
onCloseAccountToggle(): void {
if (!this.closeAccountChecked) {
this.closurePreview = null;
this.closurePreviewError = '';
return;
}
const accountId = this.accountDetails?.id;
if (!accountId) return;
this.closurePreviewLoading = true;
this.closurePreviewError = '';
this.closureRequestService
.postClosureInterest(accountId)
.pipe(
catchError((err: any) => {
this.closurePreviewError =
err?.error?.message ??
err?.error?.errors?.[0]?.defaultUserMessage ??
'Could not post interest to the account. Please try again.';
return of(null);
})
)
.subscribe((res: any) => {
this.closurePreviewLoading = false;
if (!res) return;
this.closurePreview = {
balance: Number(res.balance ?? 0),
interest: Number(res.accruedInterest ?? 0),
total: Number(res.total ?? 0),
upToDate: res.interestUpToDate !== false,
asOfDate: res.asOfDate ?? null
};
});
}
/**
* Applies the closure to the slip on screen — it does NOT submit anything. The calculated payout
* drops into the row's AMOUNT, the row is marked as a closure, and the clerk then fills TOK. NO.
* and DESCRIPTION on the slip itself and presses Save like any other entry. Save is what actually
* raises the request (see submitClosureRequest()).
*/
applyCloseAccount(): void {
if (!this.closeAccountChecked) {
// Unticking is how the clerk backs out of a closure they had applied.
this.clearPendingClosure();
this.showAccountActions = false;
return;
}
if (!this.closurePreview) return;
// Fall back to the first row: the popup is opened from the footer, so a stray click can leave
// selectedRow unset even though a row is plainly on screen — and Save reads selectedRow too.
const row = this.selectedRow ?? this.rows[0] ?? null;
if (!row) {
this.showAlert('error', 'No Transaction Row', ['Add a transaction row before closing the account.']);
return;
}
this.selectedRow = row;
row.amount = this.closurePreview.total;
row.dbCr = 'Dr';
if (!row.description) {
row.description = 'Account closure — full balance withdrawal';
}
this.pendingClosure = true;
this.showAccountActions = false;
}
/** Drops closure mode and the amount it filled in, so the slip goes back to an ordinary entry. */
clearPendingClosure(): void {
this.pendingClosure = false;
this.closeAccountChecked = false;
this.closurePreview = null;
this.closurePreviewError = '';
const row = this.selectedRow;
if (row && row.description === 'Account closure — full balance withdrawal') {
row.description = '';
}
if (row) {
row.amount = null;
}
}
/**
* Raises the closure request from the slip's own fields. Reached from confirmSubmit() when the
* clerk presses Save with closure mode applied, instead of the ordinary withdrawal-request path.
*/
private submitClosureRequest(row: TxnRow): void {
const accountId = this.accountDetails?.id;
if (!accountId) return;
this.saving = true;
this.closureRequestService
.submit({
savingsAccountId: accountId,
paymentTypeId: null as number | null,
paymentTypeName: 'Cash',
description: row.description || null,
tokNo: row.tokNo?.trim() || null
})
.pipe(
catchError((err: any) => {
const msg =
err?.error?.message ??
err?.error?.errors?.[0]?.defaultUserMessage ??
'Could not submit the closure request. Please try again.';
this.showAlert('error', 'Closure Request Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (!res) return;
const preview = this.closurePreview;
// The server has now POSTED the interest and returns the account's real resulting balance.
// That, not the popup's estimate, is what the closure pays out — so report it, and say so
// outright if the two ever disagree rather than letting a paise drift pass unnoticed.
const posted = res.payableAmount != null ? Number(res.payableAmount) : null;
const rupees = (v: number) => `₹${v.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`;
const drifted = posted != null && preview != null && Math.abs(posted - preview.total) >= 0.005;
this.showAlert(
'success',
'Closure Request Submitted',
[
`Account ${this.accountDetails?.accountNo} has been sent to the Bank Manager for closure approval.`,
posted != null
? `Interest has been posted to the account. Payout: ${rupees(posted)}.`
: preview
? `Payout: ${rupees(preview.total)}.`
: '',
drifted && preview
? `Note: the posted figure ${rupees(posted!)} differs from the ${rupees(preview.total)} shown a moment ago — the posted figure is the one that will be paid.`
: '',
`Token No: ${row.tokNo?.trim()} — the cashier will hand over the cash against this token once approved.`,
'Cash is handed over rounded to whole rupees; the paise difference goes to the Round Off Account.'
].filter(Boolean)
);
this.pendingClosure = false;
this.closeAccountChecked = false;
this.closurePreview = null;
this.onCancel();
});
}
// ── Same-day compliance indicators (copied from SavingsAccountTransactionsComponent) ──
private computeSameDayNeftRtgs(transactions: any[] | undefined): void {
if (!Array.isArray(transactions) || !transactions.length) {
this.sameDayNeftRtgs = 0;
return;
}
const today = 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()
);
};
this.sameDayNeftRtgs = transactions
.filter(
(t: any) =>
!t.reversed &&
sameDay(t.date ?? t.transactionDate) &&
/NEFT|RTGS/.test(
(
t.transactionType?.value ??
t.transactionType?.code ??
t.paymentDetailData?.paymentType?.name ??
''
).toUpperCase()
)
)
.reduce((s: number, t: any) => s + (Number(t.amount) || 0), 0);
}
private computeSameDayChequeClearing(transactions: any[] | undefined): void {
if (!Array.isArray(transactions) || !transactions.length) {
this.sameDayChequeClearing = 0;
return;
}
const today = 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()
);
};
this.sameDayChequeClearing = transactions
.filter(
(t: any) =>
!t.reversed &&
sameDay(t.date ?? t.transactionDate) &&
(!!t.paymentDetailData?.checkNumber ||
/CHEQUE|CHECK/.test(
(t.transactionType?.value ?? t.paymentDetailData?.paymentType?.name ?? '').toUpperCase()
))
)
.reduce((s: number, t: any) => s + (Number(t.amount) || 0), 0);
}
/** Open full-screen preview dialog for photo or signature */
previewImage(url: string | null, title: string): void {
if (!url) return;
this.dialog.open(ImagePreviewDialogComponent, {
data: { url, title },
maxWidth: '90vw',
maxHeight: '90vh',
panelClass: 'te-preview-dialog'
});
}
onSave(): void {
// Clerk bank transfer — N DR savings/CA + N CR savings/CA, immediate execution. 'Fund Transfer'
// is the same flow under a second TYPE label — onTypChange() already defaults it identically to
// 'Enter Bank Transfer', so submission has to recognise both or a share redemption or bank
// transfer raised under this label silently falls through to the wrong branch below.
if (
this.isClerk &&
this.rows.length >= 2 &&
this.rows.every((r) => r.typ === 'Enter Bank Transfer' || r.typ === 'Fund Transfer')
) {
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
if (drRows.length === 0) {
this.showAlert('error', 'DR Row Missing', ['At least one DR row (debit account) is required.']);
return;
}
if (crRows.length === 0) {
this.showAlert('error', 'CR Row Missing', ['At least one CR row (credit account) is required.']);
return;
}
// Share redemption via linked-account transfer: DR=Share account, CR=its linked savings
// account. A Share account can never be debited by a plain ledger transfer (no such Fineract
// command exists) — so this exact one-DR/one-CR shape is routed to submitShareRedemptionTransfer()
// instead, which submits a governed Share Withdrawal Request through the same maker-checker
// table as the dedicated screen; approval both redeems the shares and settles the linked account.
const shareDrRows = drRows.filter((r) => r.acTyp === 'SHR');
if (shareDrRows.length > 0) {
if (drRows.length !== 1 || crRows.length !== 1) {
this.showAlert('error', 'Unsupported Share Transfer', [
'A Share redemption transfer supports exactly one DR row (the Share account) and one CR row (its linked savings account).'
]);
return;
}
this.submitShareRedemptionTransfer(shareDrRows[0], crRows[0]);
return;
}
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
const allowedTypes = new Set([
'SD',
'CA',
'CB'
]);
const invalidAccounts = [
...drRows,
...crRows
].filter((r) => !allowedTypes.has(r.acTyp));
if (invalidAccounts.length > 0) {
this.showAlert('error', 'Invalid Account Type', [
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
'Only Savings (SD) and Current (CA) accounts are permitted.',
'GL, FD, RD, SHR accounts are not allowed — use Disbursement/Collection instead.'
]);
return;
}
const totalDr = drRows.reduce((s, r) => s + (r.amount ?? 0), 0);
const totalCr = crRows.reduce((s, r) => s + (r.amount ?? 0), 0);
if (Math.abs(totalDr - totalCr) > 0.005) {
this.showAlert('error', 'Unbalanced Entry', [
`DR ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })} ≠ CR ₹${totalCr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'Total DR must equal total CR for a bank transfer.'
]);
return;
}
// All accounts must be loaded
const unloaded = [
...drRows,
...crRows
].filter((r) => !this.rowAccountIds[r.acNo]);
if (unloaded.length > 0) {
this.showAlert('error', 'Account Not Loaded', [
`Please load account details for: ${unloaded.map((r) => r.acNo).join(', ')}`
]);
return;
}
const confirmLines = [
`DR (${drRows.length} account${drRows.length !== 1 ? 's' : ''}):`,
...drRows.map(
(r) => ` [${r.acTyp}] ${r.acNo} ₹${(r.amount ?? 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`
),
`CR (${crRows.length} account${crRows.length !== 1 ? 's' : ''}):`,
...crRows.map(
(r) => ` [${r.acTyp}] ${r.acNo} ₹${(r.amount ?? 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`
),
`Total: ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'Transfer requires Manager approval before execution.'
];
this.showConfirmAlert('info', 'Confirm Bank Transfer', confirmLines, 'Submit for Approval', () =>
this.submitBankTransfer()
);
return;
}
// Clerk multi-row disbursement — handle before single-row guard (selected row may be incomplete).
// Fund Transfer rides the exact same double-entry flow as Disbursement (see the TYPE options
// comment above) — only the label differs.
if (
this.isClerk &&
this.rows.length >= 2 &&
this.rows.every((r) => r.typ === 'Disbursement' || r.typ === 'Fund Transfer')
) {
const drRows = this.rows.filter((r) => r.dbCr === 'Dr');
const crRows = this.rows.filter((r) => r.dbCr === 'Cr');
if (drRows.length === 0) {
this.showAlert('error', 'DR Row Missing', [
'One DR row (source GL account) is required.',
'Select DR for the source account row.'
]);
return;
}
if (crRows.length === 0) {
this.showAlert('error', 'CR Row Missing', ['At least one CR row (destination account) is required.']);
return;
}
// Detect mode
// Disbursement: exactly 1 DR [GL] + N CR accounts
// Collection: N DR [Savings/CA] + exactly 1 CR [GL]
const isCollectionMode = drRows.every((r) => r.acTyp !== 'GL') && crRows.length === 1 && crRows[0].acTyp === 'GL';
const isDisbursementMode = drRows.length === 1 && drRows[0].acTyp === 'GL';
if (!isCollectionMode && !isDisbursementMode) {
this.showAlert('error', 'Invalid Entry Structure', [
'Disbursement: 1 DR [GL] source + N CR accounts.',
'Collection: N DR [Savings/CA] + 1 CR [GL] target.',
`Found: ${drRows.length} DR row(s) — verify account types.`
]);
return;
}
const totalDr = drRows.reduce((s, r) => s + (r.amount ?? 0), 0);
const totalCr = crRows.reduce((s, r) => s + (r.amount ?? 0), 0);
if (Math.abs(totalDr - totalCr) > 0.005) {
this.showAlert('error', 'Unbalanced Entry', [
`DR ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })} ≠ Total CR ₹${totalCr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'All CR amounts must sum to total DR amount.'
]);
return;
}
const isFundTransfer = this.rows[0]?.typ === 'Fund Transfer';
const title = isCollectionMode
? 'Confirm Collection Request'
: isFundTransfer
? 'Confirm Fund Transfer Request'
: 'Confirm Disbursement Request';
const confirmLines = isCollectionMode ? [
`DR (${drRows.length} account${drRows.length !== 1 ? 's' : ''} to collect):`,
...drRows.map(
(r) =>
` [${r.acTyp}] ${r.acNo || '—'} ₹${(r.amount ?? 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`
),
`CR [${crRows[0].acTyp}] ${crRows[0].acNo || '—'} — ₹${totalCr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'Request will be PENDING until manager approves.'
] : [
`DR [${drRows[0].acTyp}] ${drRows[0].acNo || '—'} — ₹${totalDr.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`CR (${crRows.length} account${crRows.length !== 1 ? 's' : ''}):`,
...crRows.map(
(r) =>
` [${r.acTyp}] ${r.acNo} ₹${(r.amount ?? 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}`
),
'Request will be PENDING until manager approves.'
];
this.showConfirmAlert('info', title, confirmLines, 'Submit for Approval', () =>
this.submitDisbursementDoubleEntry()
);
return;
}
const row = this.selectedRow;
// Never fail silently here: a Save that does literally nothing is indistinguishable from a
// broken button, and this guard is the one path in onSave() that used to return without saying
// anything.
if (!row || !row.acNo.trim() || !this.accountDetails) {
this.showAlert('error', 'Nothing to Save', [
!row
? 'No transaction row is selected. Click into a row first.'
: !row.acNo.trim()
? 'Enter an account number on the row.'
: 'Account details are not loaded yet. Re-enter the account number and wait for it to load.'
]);
return;
}
const selectedPtEarly = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const isCash = !!selectedPtEarly?.isCashPayment;
const isDdCashGl = this.isDdCashGlRow(row);
// Clerk mode: always use manually entered amount; Cashier mode: cash amount comes from denomination
// DD + Cash + GL: amount is DD face value + charges + GST (rounded up), not the counted denomination
// A closure's amount is the calculated payout on the row (balance + interest to date), never the
// counted denomination: the clerk raising the request counts no cash — the cashier does that
// later, at hand-over. Keyed off pendingClosure rather than clerkMode because clerkMode is only
// set by the Clerk Dashboard wrapper, so a clerk reaching this screen by any other route would
// otherwise have the payout silently replaced by a zero denomination total.
const amount = isDdCashGl
? this.ddTotalPayableFor(row)
: isCash && !this.clerkMode && !this.pendingClosure
? this.denominationTotal
: (row.amount ?? this.denominationTotal);
if (isCash && this.denominationTotal <= 0 && !this.clerkMode && !isDdCashGl && !this.pendingClosure) {
this.showAlert('error', 'Denomination Required', [
'Cash transaction: Please enter denomination (notes/coins count) first.'
]);
return;
}
// DD + Cash + GL: physical cash counted must reconcile with Face Value + Charges + GST
if (isDdCashGl && !this.clerkMode) {
if (this.denominationTotal <= 0) {
this.showAlert('error', 'Denomination Required', [
`Cash transaction: Please count notes/coins totalling ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} (DD Face Value + Charges + GST).`
]);
return;
}
if (Math.abs(this.denominationTotal - amount) > 0.005) {
this.showAlert('error', 'Denomination Mismatch', [
`Cash counted: ₹${this.denominationTotal.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Total To Collect: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} (Face Value + Charges + GST)`,
'Adjust the notes/coins breakdown to match the Total To Collect exactly.'
]);
return;
}
}
if (!amount || amount <= 0) {
this.showAlert('error', 'Amount Required', ['Please enter a valid transaction amount.']);
return;
}
// DD + Cash + GL: reject at cash intake, before the cashier even accepts the money, if the DD
// Face Value exceeds the admin-configured DD limit (same limit enforced again at DD issuance).
if (isDdCashGl && this.ddLimitAmount != null && this.ddLimitAmount > 0 && Number(row.amount) > this.ddLimitAmount) {
this.showAlert('error', 'DD Limit Exceeded', [
`DD Face Value (₹${Number(row.amount).toLocaleString('en-IN', { minimumFractionDigits: 2 })}) exceeds the configured DD limit of ₹${this.ddLimitAmount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.`,
'Reduce the amount, or issue this as separate DDs within the limit each.'
]);
return;
}
const isDeposit = row.dbCr === 'Cr';
// Clerk cash deposit is not permitted — cash handling (notes/coins) is cashier's responsibility
if (this.isClerk && isDeposit && isCash) {
this.showAlert('error', 'Cash Deposit Not Permitted', [
'Cash deposits cannot be processed by a clerk.',
'Only the cashier can accept cash deposits (notes/coins denomination is managed at cashier vault).'
]);
return;
}
// Token number is required for all withdrawals (clerk must enter, cashier must enter approved token)
if (!this.reviewMode && !isDeposit && !row.tokNo?.trim()) {
this.showAlert('error', 'Token Number Required', [
this.isClerk
? 'Please enter a Token Number (TOK. NO.) for this withdrawal request.'
: 'Please enter the approved Token Number (TOK. NO.) to process this withdrawal.'
]);
return;
}
// Cashier direct withdrawal BLOCKED — must use approved maker-checker token
if (
!this.isClerk &&
!this.reviewMode &&
!isDeposit &&
!this.activeWithdrawalRequest &&
!this.activeDividendClaimRequest &&
!this.activeClosureRequest
) {
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
'Withdrawals cannot be processed directly by the cashier.',
'All withdrawals must go through the Clerk → Manager approval process.',
'Enter the approved Token Number (TOK. NO.) to disburse cash.'
]);
return;
}
// Approved withdrawal / dividend cash claim / account closure: amount/denomination must match
if (
!this.isClerk &&
(this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) &&
!isDeposit
) {
const rawApprovedAmt = Number(
this.activeWithdrawalRequest?.amount ??
this.activeDividendClaimRequest?.amount ??
this.activeClosureRequest?.cashier_disbursed_amount
);
// A dividend cash claim's liability is stored with paise precision (a proportional profit
// share), but cash can only be handed over in whole-rupee notes/coins -- compare against
// the floored amount so an exact denomination match is actually achievable. Ordinary
// withdrawal-request amounts are already whole rupees by convention, so this only changes
// behavior for dividend claims.
const approvedAmt = this.activeDividendClaimRequest ? Math.floor(rawApprovedAmt) : rawApprovedAmt;
if (Math.abs(amount - approvedAmt) > 0.005) {
if (isCash) {
this.showAlert('error', 'Denomination Mismatch — Approved Withdrawal', [
`Cash denomination total ₹${this.denominationTotal.toLocaleString('en-IN', { minimumFractionDigits: 2 })} does not match the approved withdrawal amount ₹${approvedAmt.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.`,
`Adjust the notes/coins breakdown to total exactly ₹${approvedAmt.toLocaleString('en-IN', { minimumFractionDigits: 2 })}.`
]);
} else {
this.showAlert('error', 'Amount Mismatch — Approved Withdrawal', [
`Amount has been changed from the approved value.`,
`Approved: ₹${approvedAmt.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Entered: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'Reset the amount to the approved value before proceeding.'
]);
}
return;
}
}
const isWithdrawal = !isDeposit;
// ── Validation ─────────────────────────────────────────────────────────
// 0. Cashier day-status hard blocks
if (this.previousDayUnsettled) {
this.showAlert('error', 'Transaction Blocked — Previous Day Not Settled', [
`Cash settlement is pending${this.unsettledSince ? ' since ' + this.unsettledSince : ''}.`,
'Complete Cash Settlement before processing any transactions.'
]);
return;
}
if (!this.morningTransferReceived) {
this.showAlert('error', 'Transaction Blocked — No Cash Allocated', [
'No morning cash transfer has been received for your drawer today.',
'Request a morning transfer from the vault before processing any transactions.'
]);
return;
}
// 0a. Account blocked
if (this.accountDetails.reasonForBlock) {
this.showAlert('error', 'Account Blocked', [
`Reason: ${this.accountDetails.reasonForBlock}`,
'Please contact the branch manager.'
]);
return;
}
// 0b. Dormant / Inactive / Escheat
const subStatus = this.accountDetails?.subStatus?.value ?? '';
if (subStatus === 'Dormant') {
this.showAlert('error', 'Account is Dormant', ['Reactivate the account before processing any transactions.']);
return;
}
if (subStatus === 'Escheat') {
this.showAlert('error', 'Account is Escheat', ['Refer to Branch Manager — no transactions permitted.']);
return;
}
if (subStatus === 'Inactive') {
this.showConfirmAlert(
'warning',
'Account is Inactive',
[
'Transactions may be restricted.',
'Have you obtained manager approval to proceed?'
],
'Proceed Anyway',
() => this.proceedAfterValidation(row, amount, isDeposit, selectedPtEarly)
);
return;
}
// 0c. Sanctions / Defaulter — DB stores 'YES'/'NO' strings
const isYes = (v: any) => v === true || v === 'YES' || v === 'yes' || v === 'Y';
if (isYes(this.kycDetails?.bannedEntityMatch)) {
this.showAlert('error', 'Sanctions Match — Transaction Blocked', [
'DO NOT process — contact Compliance team immediately.'
]);
return;
}
if (isYes(this.kycDetails?.willfulDefaulter)) {
this.showAlert('error', 'Willful Defaulter', ['Refer to Branch Manager.']);
return;
}
// 0d–6: remaining validations
this.proceedAfterValidation(row, amount, isDeposit, selectedPtEarly);
}
private proceedAfterValidation(row: TxnRow, amount: number, isDeposit: boolean, selectedPtEarly: any): void {
const isWithdrawal = !isDeposit;
const isCash = !!selectedPtEarly?.isCashPayment;
const isGlType = row.acTyp === 'GL';
// Dividend cash claim token disbursement: the GL entry (Dr Dividend Payable / Cr Cash at
// Main Vault) already posted at claim-approval time — there is no real savings/current
// account behind this row, so skip balance/min-balance/KYC/dormant/facility checks entirely
// (none apply to a GL-only liability payout) and go straight to the confirm dialog.
// An approved account closure is the same situation: close() already posted the interest and the
// real withdrawal at manager approval, and the account is CLOSED by the time a cashier scans the
// token — so accountDetails here is a stub with no balance and the balance/min-balance/facility
// checks below would reject the hand-over with "Withdrawable Amount: ₹0.00". Only the cash-drawer
// checks still matter, and they are the ones kept.
if (this.activeDividendClaimRequest || this.activeClosureRequest) {
if (!row.paymentTypeId) {
this.showAlert('error', 'Payment Type Required', ['Please select Payment Type (Cash / Cheque / NEFT etc.)']);
return;
}
if (isWithdrawal && this.denominationTotal > 0 && this.hasWithdrawalDenomOverdraft) {
const lines = this.denominationOverdrafts.map(
(o) => `${o.label}: Need ${o.requested}, Available ${o.available} (Short by ${o.shortfall})`
);
this.showAlert('error', 'Insufficient Denomination Stock', [
'Your cash drawer does not have enough notes/coins:',
...lines,
'Adjust denomination or request vault replenishment.'
]);
return;
}
this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly);
return;
}
// Clerk raising a closure request: nothing moves yet, so the ordinary withdrawal gauntlet below
// does not apply and actively rejects a valid closure —
// · Balance: the popup already posted the accrued interest onto the account before filling this
// amount in (see onCloseAccountToggle -> postClosureInterest), so the account's own withdrawable
// balance already covers it -- this bypass exists for the checks below it, not this one.
// · Min balance: meaningless for an account that is being closed to zero.
// · Withdrawal facility: a member closing their account is not making an ordinary withdrawal.
// · Denomination: counted by the cashier at hand-over, not by the clerk here.
// Everything that genuinely matters (token number, account loaded, ACTIVE status) is already
// checked in onSave() before this point, and the backend re-validates on submit.
if (this.pendingClosure && this.isClerk && isWithdrawal) {
this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly);
return;
}
// GL transactions post as journal entries — skip account-level facility/balance checks
if (isGlType) {
if (!this.cashVaultGlId) {
this.showAlert('error', 'Cash Vault GL Not Configured', [
'Financial Activity Account (CASH_AT_TELLER) is not configured.',
'Please configure it in System → Financial Activity Accounts.'
]);
return;
}
// Payment type required (same as savings)
if (!row.paymentTypeId) {
this.showAlert('error', 'Payment Type Required', ['Please select Payment Type (Cash / Cheque / NEFT etc.)']);
return;
}
// Denomination overdraft check for GL withdrawal (same as savings)
if (isWithdrawal && this.denominationTotal > 0 && this.hasWithdrawalDenomOverdraft) {
const lines = this.denominationOverdrafts.map(
(o) => `${o.label}: Need ${o.requested}, Available ${o.available} (Short by ${o.shortfall})`
);
this.showAlert('error', 'Insufficient Denomination Stock', [
'Your cash drawer does not have enough notes/coins:',
...lines,
'Adjust denomination or request vault replenishment.'
]);
return;
}
// No AML prompt on the GL path either — see the note on the savings/current path below.
this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly);
return;
}
// 0d. Withdrawal facility
if (isWithdrawal) {
const withdrawalFac = this.facilities.find((f) => f.label === 'Withdrawal');
if (withdrawalFac && !withdrawalFac.active) {
this.showAlert('error', 'Withdrawal Facility Not Enabled', [
'Withdrawal facility is NOT enabled on this account.',
'Please enable the facility before processing a withdrawal.'
]);
return;
}
}
// 1. Payment type
if (!row.paymentTypeId) {
this.showAlert('error', 'Payment Type Required', ['Please select Payment Type (Cash / Cheque / NEFT etc.)']);
return;
}
// 2. Cheque number
const selectedPt = selectedPtEarly;
const isCheque = selectedPt && !selectedPt.isCashPayment;
if (isCheque && !row.chequeNo?.trim()) {
this.showAlert('error', 'Cheque Number Required', ['Cheque Number is required for cheque transactions.']);
return;
}
if (isWithdrawal) {
// Token-based cashier disbursement: the manager approval already validated the balance
// and posted the real withdrawal (money already left the account). Re-running the balance/
// min-balance checks here would compare the post-deduction balance against the same
// already-approved amount again, which fails for almost any real withdrawal.
const isTokenDisbursement = !this.isClerk && (!!this.activeWithdrawalRequest || !!this.activeClosureRequest);
if (!isTokenDisbursement) {
// 3. Balance check
const withdrawable = this.withdrawableAmount;
if (amount > withdrawable) {
this.showAlert('error', 'Insufficient Balance', [
`Withdrawable Amount: ₹${withdrawable.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Requested Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'Please reduce the withdrawal amount.'
]);
return;
}
// 4. Min balance
const enforceMin = !!this.accountDetails.enforceMinRequiredBalance;
const minBalance = Number(this.accountDetails.minRequiredBalance ?? 0);
if (enforceMin && minBalance > 0) {
const balanceAfter = this.currentBalance - amount;
if (balanceAfter < minBalance) {
this.showAlert('error', 'Minimum Balance Violation', [
`Required minimum balance: ₹${minBalance.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
`Balance after withdrawal: ₹${balanceAfter.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`,
'Please reduce the withdrawal amount.'
]);
return;
}
}
}
// 5. Denomination overdraft — fire whenever denomination is entered for withdrawal
if (this.denominationTotal > 0 && this.hasWithdrawalDenomOverdraft) {
const lines = this.denominationOverdrafts.map(
(o) => `${o.label}: Need ${o.requested}, Available ${o.available} (Short by ${o.shortfall})`
);
this.showAlert('error', 'Insufficient Denomination Stock', [
'Your cash drawer does not have enough notes/coins:',
...lines,
'Adjust denomination or request vault replenishment.'
]);
return;
}
}
// AML monitoring is deliberately invisible here. Every posted transaction is evaluated
// server-side against the AML rule catalog (AmlRuleEvaluationService) after it succeeds, and
// anything it flags surfaces only in the AML reports — alerts, STR cases and CTR returns.
// The cashier is never prompted or warned at the counter: tipping off the customer defeats
// the purpose of suspicious-transaction monitoring, and a hardcoded client-side threshold
// duplicated none of the real rule set anyway.
this.openConfirmDialog(row, amount, isDeposit, selectedPt);
}
private openConfirmDialog(row: TxnRow, amount: number, isDeposit: boolean, selectedPt: any): void {
// Token-based cashier disbursement: balance was already deducted at manager approval
const isDisbursement =
!isDeposit &&
!this.isClerk &&
(!!this.activeWithdrawalRequest || !!this.activeDividendClaimRequest || !!this.activeClosureRequest);
// Cashier deposit (savings/current/GL) goes via maker-checker: balance credits only after manager approval
const isDepositRequest =
isDeposit &&
!this.isClerk &&
(row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL');
const balanceAfterTxn =
isDisbursement || isDepositRequest
? this.currentBalance // no immediate balance change
: isDeposit
? this.currentBalance + amount
: this.currentBalance - amount;
this.confirmData = {
type: isDisbursement
? 'DISBURSEMENT'
: isDepositRequest
? 'DEPOSIT REQUEST'
: isDeposit
? 'DEPOSIT'
: this.pendingClosure && this.isClerk
? 'ACCOUNT CLOSURE'
: 'WITHDRAWAL',
isDisbursement,
isDepositRequest,
accountNo: this.accountDetails.glCode ?? this.accountDetails.accountNo,
clientName: this.clientName,
amount,
currentBalance: this.currentBalance,
balanceAfter: balanceAfterTxn,
paymentType: selectedPt?.name ?? 'Cash',
chequeNo: row.chequeNo || null,
symbol: this.currencySymbol,
tokNo:
this.activeWithdrawalRequest?.tok_no ??
this.activeDividendClaimRequest?.tok_no ??
this.activeClosureRequest?.tok_no ??
null,
isDdCashGl: this.isDdCashGlRow(row),
ddFaceValue: row.amount ?? 0,
ddCharges: row.ddCharges ?? 0,
ddGstPercentage: this.ddGstPercentage,
ddGstAmount: this.ddGstAmountFor(row)
};
this.showConfirmDialog = true;
}
confirmSubmit(): void {
this.showConfirmDialog = false;
const row = this.selectedRow!;
if (row.typ === 'DD') {
this.openIssueWalkinDd(row);
return;
}
const amount = this.isDdCashGlRow(row) ? this.ddTotalPayableFor(row) : (row.amount ?? this.denominationTotal);
const isDeposit = row.dbCr === 'Cr';
const command = isDeposit ? 'deposit' : 'withdrawal';
const accountId = this.accountDetails.id;
const cfg = this.accountTypeConfig(row.acTyp);
// Share accounts must never go through this generic Deposit/Withdrawal Slip flow — both
// buildDepositPayload() and buildWithdrawalPayload() hardcode accountType: 'SAVINGS' and send
// savingsAccountId: this.accountDetails.id, which for a Share account is the SHARE account's
// own numeric id, not a real savings account id. Approving that request would call
// savingsAccountWithdrawal/Deposit against whatever savings account happens to share that same
// numeric id — a completely unrelated account. Share purchase/redemption must go through the
// dedicated Buy Shares / Share Withdrawal Request screens, which carry the correct governance
// (resolution number, meeting date, minimum-tenure and linked-loan checks).
if (row.acTyp === 'SHR') {
this.showAlert('error', 'Not Supported for Share Accounts', [
isDeposit
? 'Share purchases must be made from the Buy Shares screen on the Share Account itself.'
: 'Share withdrawal (redeem shares) must be submitted from "New Share Withdrawal Request" in the Clerk Dashboard.',
'This generic Deposit/Withdrawal Slip does not carry the Board Resolution Number or the governance checks required for shares.'
]);
return;
}
// GL withdrawal → direct journal entry (no maker-checker)
// GL deposit → falls through to maker-checker block below
if (row.acTyp === 'GL' && !isDeposit) {
this.submitGlJournalEntry(row, amount, isDeposit);
return;
}
// Clerk applied "Close this account" from Action → raise a closure request rather than an
// ordinary withdrawal request. Must come first: a closure IS a full-balance withdrawal, so the
// plain clerk-withdrawal branch below would otherwise swallow it and the account would never
// actually close.
if (this.pendingClosure && this.isClerk && !isDeposit) {
this.submitClosureRequest(row);
return;
}
// Clerk mode + withdrawal → route through maker-checker (create PENDING request, no direct deduction)
if (this.clerkMode && !isDeposit) {
this.submitClerkWithdrawalRequest(row, amount);
return;
}
// Cashier deposit (savings/current/GL) → route through maker-checker (create PENDING request, no direct credit)
// Use the dialog-confirmed amount — for cash that is the denomination total
if (
!this.isClerk &&
!this.reviewMode &&
isDeposit &&
(row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL')
) {
this.submitCashierDepositRequest(row, this.confirmData?.amount ?? amount);
return;
}
// Dividend cash claim token disbursement → GL entry already posted at claim-approval time.
// Do NOT post any new transaction/journal entry — just record the physical hand-over via
// DividendClaimRequestService, mirroring the activeWithdrawalRequest branch just below.
if (!this.isClerk && this.activeDividendClaimRequest && !isDeposit) {
this.saving = true;
const selectedPtDisb = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const denomJson =
selectedPtDisb?.isCashPayment && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null;
const req = this.activeDividendClaimRequest;
this.dividendClaimRequestService
.disburse(req.id, denomJson, amount)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.message ??
err?.error?.errors?.[0]?.defaultUserMessage ??
'Disbursement recording failed. Please try again.';
this.showAlert('error', 'Disbursement Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
this.showAlert('success', 'Cash Disbursed Successfully', [
`₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} disbursed to customer.`,
`Token No: ${req.tok_no} · Client: ${req.client_name}`
]);
this.activeDividendClaimRequest = null;
this.clearDenominations();
this.loadOpeningPosition();
}
});
return;
}
// Approved account closure token → the close command (interest, TDS and the real GL
// withdrawal) already ran at manager approval. Nothing new is posted here either — this only
// records the physical hand-over of the rounded cash figure, mirroring the two branches above.
if (!this.isClerk && this.activeClosureRequest && !isDeposit) {
this.saving = true;
const selectedPtDisb = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const denomJson =
selectedPtDisb?.isCashPayment && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null;
const req = this.activeClosureRequest;
this.closureRequestService
.disburse(req.id, denomJson, amount)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.message ??
err?.error?.errors?.[0]?.defaultUserMessage ??
'Disbursement recording failed. Please try again.';
this.showAlert('error', 'Disbursement Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
const closureVoucher = res.voucherNo || req.voucher_no || '—';
this.showAlert('success', 'Account Closed & Cash Disbursed', [
`₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} handed over to the customer.`,
`Account ${req.account_no} is now CLOSED.`,
`Token No: ${req.tok_no} · Client: ${req.client_name}`,
`Voucher No: ${closureVoucher}`
]);
this.activeClosureRequest = null;
this.clearDenominations();
this.loadOpeningPosition();
}
});
return;
}
// Hard block: cashier direct withdrawal never reaches here — caught in onSave()
// Extra safety net in case confirmSubmit() is ever called directly
if (
!this.isClerk &&
!this.reviewMode &&
!isDeposit &&
!this.activeWithdrawalRequest &&
!this.activeDividendClaimRequest &&
!this.activeClosureRequest
) {
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
'Withdrawals must go through the maker-checker approval process.',
'Enter the approved token number to disburse.'
]);
return;
}
// Token-based approved withdrawal → balance already deducted at manager approval
// Do NOT post another savings transaction — just record disbursement
if (!this.isClerk && this.activeWithdrawalRequest && !isDeposit) {
this.saving = true;
// Send denominations only for cash payments — stale counts on a cheque
// disbursement would fail the backend denomination-total check
const selectedPtDisb = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const denomJson =
selectedPtDisb?.isCashPayment && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null;
const req = this.activeWithdrawalRequest;
this.clerkWithdrawalService
.disburse(req.id, denomJson, amount)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.message ??
err?.error?.errors?.[0]?.defaultUserMessage ??
'Disbursement recording failed. Please try again.';
this.showAlert('error', 'Disbursement Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
const disbVoucher = res.voucherNo || req.voucher_no || '—';
this.showAlert('success', 'Cash Disbursed Successfully', [
`₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} disbursed to customer.`,
`Token No: ${req.tok_no} · Client: ${req.client_name}`,
`Voucher No: ${disbVoucher}`
]);
this.activeWithdrawalRequest = null;
this.clearDenominations();
this.loadOpeningPosition();
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
});
return;
}
const today = new Date();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const payload: any = {
transactionDate: txnDate,
transactionAmount: amount,
paymentTypeId: row.paymentTypeId,
dateFormat: 'dd/MM/yyyy',
locale: 'en',
note: row.description || null
};
if (row.chequeNo) payload['checkNumber'] = row.chequeNo;
if (row.bankName) payload['bankNumber'] = row.bankName;
if (row.routingCode) payload['routingCode'] = row.routingCode;
// Only send denomination when cash payment AND denom total matches amount
const selectedPtForPayload = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const isCashForPayload = !!selectedPtForPayload?.isCashPayment;
if (isCashForPayload && this.denominationTotal > 0 && this.denominationTotal === amount) {
payload['denominationBreakdown'] = [
...this.notes,
...this.coins
]
.filter((d) => this.countFor(d) > 0)
.map((d) => ({
denominationId: d.id,
denominationValue: d.value,
kind: d.kind,
count: this.countFor(d),
subtotal: this.subtotalFor(d)
}));
}
this.saving = true;
const params = new HttpParams().set('command', command);
this.http
.post(`${cfg.detail}/${accountId}/transactions`, payload, { params })
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
'Transaction failed. Please try again.';
this.showAlert('error', 'Transaction Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
const ref = res.resourceId ?? res.transactionId ?? 'N/A';
this.showAlert('success', 'Transaction Successful', [
`${isDeposit ? 'Deposit' : 'Withdrawal'} of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} posted successfully.`,
`Reference No: ${ref}`
]);
this.clearDenominations();
this.loadOpeningPosition();
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
});
}
// ── Manager review mode ──────────────────────────────────────────────────
/** Load a withdrawal request and pre-fill the workspace (manager review or clerk edit). */
private loadRequestIntoForm(id: number, mode: 'review' | 'edit'): void {
this.clerkWithdrawalService
.getById(id)
.pipe(catchError(() => of(null)))
.subscribe((req: any) => {
if (!req) {
this.showAlert('error', 'Request Not Found', ['This withdrawal request could not be loaded.']);
return;
}
if (mode === 'review') {
this.reviewRequest = req;
} else {
this.editRequest = req;
}
const acTyp = 'SD';
const row = this.rows[0];
row.acNo = req.account_no ?? '';
row.acTyp = acTyp;
row.paymentTypeId = req.payment_type_id ?? null;
row.amount = Number(req.amount) || 0;
row.description = req.description ?? '';
row.chequeNo = req.cheque_no ?? '';
row.bankName = req.bank_name ?? '';
row.tokNo = req.tok_no ?? '';
row.dbCr = 'Dr';
this.selectedRow = row;
try {
this.denominationCounts = req.denomination_data ? JSON.parse(req.denomination_data) : {};
} catch {
this.denominationCounts = {};
}
if (req.account_no) {
this.loadAccountDetails(req.account_no, acTyp);
}
});
}
/** Manager approves → backend posts the real savings withdrawal and deducts the balance. */
approveReview(): void {
if (!this.reviewRequest || this.saving) return;
this.saving = true;
this.clerkWithdrawalService
.approve(this.reviewRequest.id)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
err?.error?.defaultUserMessage ??
'Approval failed. Please try again.';
this.showAlert('error', 'Approval Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
this.showAlert(
'success',
'Withdrawal Approved',
[
'The withdrawal has been approved and the balance has been deducted.',
res.txnId ? `Transaction Ref: ${res.txnId}` : ''
].filter(Boolean)
);
setTimeout(() => this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']), 1200);
}
});
}
openRejectReview(): void {
this.rejectionReason = '';
this.showRejectDialog = true;
}
cancelRejectReview(): void {
this.showRejectDialog = false;
this.rejectionReason = '';
}
confirmRejectReview(): void {
if (!this.reviewRequest || this.saving) return;
if (!this.rejectionReason.trim()) {
this.showAlert('error', 'Reason Required', ['Please enter a reason for rejecting this request.']);
return;
}
this.saving = true;
this.clerkWithdrawalService
.reject(this.reviewRequest.id, this.rejectionReason.trim())
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
err?.error?.defaultUserMessage ??
'Rejection failed. Please try again.';
this.showAlert('error', 'Rejection Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
this.showRejectDialog = false;
if (res) {
this.showAlert('success', 'Withdrawal Rejected', [
'The request has been sent back to the clerk for correction.'
]);
setTimeout(() => this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']), 1200);
}
});
}
backToInbox(): void {
this.router.navigate(['/checker-inbox-and-tasks/withdrawal-requests']);
}
/** Build the withdrawal-request payload from the current row + loaded account. */
private buildWithdrawalPayload(row: TxnRow, amount: number): any {
const today = new Date();
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
return {
accountNo: this.accountDetails.accountNo,
accountType: this.creditEntryAccountType(row.acTyp),
savingsAccountId: this.accountDetails.id,
clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null,
clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null,
amount,
txnDate: txnDateIso,
paymentTypeId: row.paymentTypeId || null,
paymentTypeName: selectedPt?.name ?? 'Cash',
description: row.description || null,
chequeNo: row.chequeNo || null,
bankName: row.bankName || null,
tokNo: row.tokNo?.trim() || null,
denominationData: JSON.stringify(this.denominationCounts)
};
}
/** Clerk edits a rejected request → resubmit (status back to PENDING) for manager approval. */
resubmitEdit(): void {
if (!this.editRequest || this.saving) return;
const row = this.selectedRow;
if (!row || !this.accountDetails) {
this.showAlert('error', 'Account Required', ['Please load the account before resubmitting.']);
return;
}
const amount = row.amount ?? 0;
if (!amount || amount <= 0) {
this.showAlert('error', 'Amount Required', ['Please enter a valid withdrawal amount.']);
return;
}
const payload = this.buildWithdrawalPayload(row, amount);
this.saving = true;
this.clerkWithdrawalService
.update(this.editRequest.id, payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
err?.error?.defaultUserMessage ??
'Failed to resubmit request. Please try again.';
this.showAlert('error', 'Resubmit Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
const tok = (this.editRequest as any)?.tok_no ?? row.tokNo?.trim() ?? '—';
this.showAlert('success', 'Withdrawal Resubmitted', [
`Withdrawal of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} resubmitted for manager approval.`,
`Token No: ${tok}`
]);
setTimeout(() => this.router.navigate(['/clerk/withdrawal-requests']), 1200);
}
});
}
cancelEdit(): void {
this.router.navigate(['/clerk/withdrawal-requests']);
}
/** Clerk maker-checker: create a PENDING withdrawal request instead of deducting balance directly. */
private submitClerkWithdrawalRequest(row: TxnRow, amount: number): void {
const payload = this.buildWithdrawalPayload(row, amount);
this.saving = true;
this.clerkWithdrawalService
.submit(payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
err?.error?.defaultUserMessage ??
'Failed to submit withdrawal request. Please try again.';
this.showAlert('error', 'Request Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
const tok = row.tokNo?.trim() ?? '—';
const voucherNo = res.voucherNo ?? '—';
this.showAlert('success', 'Withdrawal Request Submitted', [
`Withdrawal of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} sent for manager approval.`,
'Balance will be deducted only after approval.',
`Token No: ${tok} · Voucher No: ${voucherNo}`
]);
this.onCancel();
}
});
}
/** Build the deposit-request payload from the current row + loaded account. */
private buildDepositPayload(row: TxnRow, amount: number): any {
const today = new Date();
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
// Send denominations only for cash payments — stale counts on a cheque
// deposit would fail the backend denomination-total check
const isCash = !!selectedPt?.isCashPayment;
const isGl = row.acTyp === 'GL';
return {
accountNo: isGl ? (this.accountDetails.glCode ?? this.accountDetails.name) : this.accountDetails.accountNo,
accountType: this.creditEntryAccountType(row.acTyp),
savingsAccountId: isGl ? null : this.accountDetails.id,
// GL-specific fields — ignored by backend for non-GL types
glAccountId: isGl ? this.accountDetails.id : null,
contraGlId: isGl ? this.cashVaultGlId : null,
officeId: isGl ? row.br || 1 : null,
currencyCode: isGl ? (this.accountDetails?.currency?.code ?? 'INR') : null,
clientId: isGl ? null : (this.accountDetails.clientId ?? this.clientDetails?.id ?? null),
clientName: isGl
? (this.accountDetails?.name ?? null)
: (this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null),
amount,
txnDate: txnDateIso,
paymentTypeId: row.paymentTypeId || null,
paymentTypeName: selectedPt?.name ?? 'Cash',
description: row.description || null,
chequeNo: row.chequeNo || null,
bankName: row.bankName || null,
denominationData: isCash && this.denominationTotal > 0 ? JSON.stringify(this.denominationCounts) : null,
// DD + Cash + GL breakdown — persisted so the Sponsor DD issuance screen can read the face
// value/charges/GST back once this request is approved, instead of retyping them.
...(this.isDdCashGlRow(row)
? {
ddFaceValue: row.amount ?? 0,
ddCharges: row.ddCharges ?? 0,
ddGstPercentage: this.ddGstPercentage,
ddGstAmount: this.ddGstAmountFor(row)
}
: {})
};
}
/** Cashier maker-checker: create a PENDING deposit request instead of crediting balance directly. */
private submitCashierDepositRequest(row: TxnRow, amount: number): void {
const payload = this.buildDepositPayload(row, amount);
this.saving = true;
this.cashierDepositService
.submit(payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
err?.error?.defaultUserMessage ??
'Failed to submit deposit request. Please try again.';
this.showAlert('error', 'Deposit Request Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
const voucherNo = res.voucherNo ?? '—';
this.showAlert('success', 'Deposit Request Submitted', [
`Deposit of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} sent for manager approval.`,
'Balance will be credited only after approval.',
`Voucher No: ${voucherNo}`
]);
this.clearDenominations();
this.onCancel();
}
});
}
onCancel(): void {
this.clerkUiState.clear();
this.accountDetails = null;
this.clientDetails = null;
this.kycDetails = null;
this.nominees = [];
this.jointHolders = [];
this.clientAddresses = [];
this.clientPhotoUrl = null;
this.signatureUrl = null;
this.accountNotes = [];
this.standingInstructions = [];
this.todayTransactions = [];
this.linkedAccounts = null;
this.nonPersonDetails = null;
this.accountError = '';
this.selectedRow = null;
this.rows = [];
this.addRow();
}
private submitGlJournalEntry(row: TxnRow, amount: number, isDeposit: boolean): void {
if (!this.cashVaultGlId) {
this.showAlert('error', 'Cash Vault GL Not Configured', [
'Financial Activity Account (CASH_AT_TELLER) is not configured.',
'Please configure it in System → Financial Activity Accounts.'
]);
this.saving = false;
return;
}
const today = new Date();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedGlId = this.accountDetails.id;
const contraGlId = this.cashVaultGlId;
// isDeposit (CR button) = cashier receives cash → credit selected GL, debit contra (cash vault)
// isWithdrawal (DR button) = cashier pays out cash → debit selected GL, credit contra (cash vault)
const credits = isDeposit ? [{ glAccountId: selectedGlId, amount }] : [{ glAccountId: contraGlId, amount }];
const debits = isDeposit ? [{ glAccountId: contraGlId, amount }] : [{ glAccountId: selectedGlId, amount }];
const payload: any = {
officeId: row.br || 1,
transactionDate: txnDate,
dateFormat: 'dd/MM/yyyy',
locale: 'en',
currencyCode: this.accountDetails?.currency?.code ?? 'INR',
comments: row.description || null,
credits,
debits,
paymentTypeId: row.paymentTypeId
};
// Check if cash payment so we can record denomination after journal entry
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
const isCashPayment = !!selectedPt?.isCashPayment;
this.saving = true;
this.http
.post('/journalentries', payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
'Journal entry failed. Please try again.';
this.showAlert('error', 'Transaction Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
// Record denomination so cashier position stays accurate
// Same condition as savings: cash + denom entered + denom matches posted amount
if (isCashPayment && this.denominationTotal > 0 && this.denominationTotal === amount) {
const denomBreakdown = [
...this.notes,
...this.coins
]
.filter((d) => this.countFor(d) > 0)
.map((d) => ({
denominationId: d.id,
denominationValue: d.value,
kind: d.kind,
count: this.countFor(d),
subtotal: this.subtotalFor(d)
}));
this.http
.post('/cashier/gl-denomination', {
txnId: res.transactionId ?? res.resourceId,
accountNo: this.accountDetails?.glCode ?? row.acNo,
accountName: this.accountDetails?.name ?? '',
txnType: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
txnDate: txnDateIso,
paymentTypeName: selectedPt?.name ?? 'Cash',
denominationBreakdown: denomBreakdown
})
.pipe(catchError(() => of(null)))
.subscribe();
}
const ref = res.transactionId ?? res.resourceId ?? 'N/A';
this.showAlert('success', 'GL Transaction Posted', [
`Journal entry of ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} posted successfully.`,
`Transaction ID: ${ref}`
]);
this.clearDenominations();
this.loadOpeningPosition();
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
});
}
onSearch(): void {
if (this.selectedRow?.acNo.trim()) {
this.loadAccountDetails(this.selectedRow.acNo.trim(), this.selectedRow.acTyp);
}
}
onExit(): void {
history.back();
}
}