Files
cbsnexora/src/app/cashier/transaction-entry/transaction-entry.component.ts
T

6524 lines
262 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, timeout } 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';
import { businessToday, businessTodayIso } from 'app/core/utils/business-today';
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;
/**
* What a GL credit is actually for — the id of a code value under "GLEntryType" (General, Share
* Collection, DD Issue, or whatever an admin has added under Admin > System > Manage Codes).
*
* A cashier Receipt (or a clerk Collection) into a GL account is the counter picking, by hand,
* any account to post cash into — deliberately, so a broken product mapping can never block a
* genuine voucher. That freedom is also the hole: two unrelated flows can land in the same GL
* account with nothing on the row itself saying which one the money was collected for, and a
* manager applying a share request can pick up a voucher that was really meant to fund a DD. This
* tag is what a day-end reconciliation groups by, and what Share Advance Collection / Sponsor DD
* issuance check before adopting a row — only shown/sent for AC.TP=GL, DR./CR.=Cr rows.
*/
entryTypeCvId?: 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;
/**
* Beneficiary and leaflet captured in the DD dialog, held on the row until Save.
*
* The dialog used to submit the request itself, which left the worksheet's own Save button doing
* nothing for a DD row — the clerk filled the dialog, the request was already gone, and pressing
* Save afterwards had no effect. Filling in details and committing them are two different
* actions, so the dialog now only records what was entered and Save is what sends it.
*/
ddDetails?: {
leafletId: number;
ddNumber: string;
beneficiaryName: string;
payableAtBranch: string;
payableAtCity: string;
/** Present only for a walk-in cash draft. */
purchaser?: { name: string; address: string; aadhaar: string };
/** Note-and-coin breakdown, cash drafts only. */
denominations?: { [key: string]: number };
fundingAccountId?: number;
fundingClientId?: number;
};
/**
* How many drafts this one row raises. 1 (or unset) is the ordinary single draft; 2 or more make
* the row a multi-DD slip — one debit funding several drafts, each to its own beneficiary.
*
* Typed by the clerk BEFORE opening the dialog, so the dialog knows how many beneficiary forms to
* present. Only a member-account debit can be a slip (see canRowBeMultiDd()).
*/
ddCount?: number | null;
/**
* The per-draft lines of a multi-DD slip, captured in the dialog and held here until Save — the
* same "fill now, commit on Save" contract as ddDetails above.
*
* Each line carries its OWN face value and charges: a slip's drafts are routinely for different
* amounts to different beneficiaries, and the charge is per draft rather than per slip. That is
* why the row's own AMOUNT and CHARGES fields are locked and derived from these lines while a
* slip is being built — a clerk-typed row total could disagree with the lines, and the account
* would then be debited for a figure the drafts don't add up to.
*/
ddLines?: DdSlipLine[];
/** The inventory the slip's leaflets are drawn from. The batch endpoint allocates by inventory
* rather than by leaflet, since the numbers are only picked inside its own locked transaction. */
ddInventoryId?: number;
/** Resolved funding account for a slip, captured alongside the lines for the same reason. */
ddFundingAccountId?: number;
ddFundingClientId?: number;
}
/** One draft on a multi-DD slip — its own beneficiary, its own face value, its own charges. */
interface DdSlipLine {
beneficiaryName: string;
payableAtBranch: string;
payableAtCity: string;
faceValue: number;
chargesAmount: number;
}
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: `
<div class="asd-shell">
<div class="asd-filterbar no-print">
<label class="asd-field">
<span>From</span>
<input type="date" [(ngModel)]="fromDate" [max]="toDate" (change)="applyFilter()" />
</label>
<label class="asd-field">
<span>To</span>
<input type="date" [(ngModel)]="toDate" [min]="fromDate" [max]="today" (change)="applyFilter()" />
</label>
<span class="asd-spacer"></span>
<button type="button" class="asd-btn asd-btn--primary" (click)="printStatement()">🖨 Print</button>
<button type="button" class="asd-btn" (click)="dialogRef.close()">Close</button>
</div>
<div class="asd-scroll">
<div class="statement-wrapper" #statementContent>
<div class="bank-header">
<div class="bank-logo-area">
<div class="bank-logo-circle">
<mat-icon>account_balance</mat-icon>
</div>
<div class="bank-name-block">
<div class="bank-title">{{ bankName }}</div>
<div class="bank-subtitle">{{ bankSubtitle }}</div>
</div>
</div>
<div class="statement-label">
<div class="stmt-label-text">STATEMENT OF ACCOUNT</div>
<div class="stmt-period">{{ fromDate || '—' }} to {{ toDate || '—' }}</div>
</div>
</div>
@if (branchName || ifscCode || micrCode) {
<div class="branch-line">
@if (branchName) {
<span>{{ branchName }} Branch</span>
}
@if (branchAddress) {
<span>{{ branchAddress }}</span>
}
@if (ifscCode) {
<span>IFSC: {{ ifscCode }}</span>
}
@if (micrCode) {
<span>MICR: {{ micrCode }}</span>
}
</div>
}
<div class="account-details-band">
<div class="account-details-grid">
<div class="detail-block">
<div class="detail-label">Account Holder</div>
<div class="detail-value bold">{{ accountHolder || '—' | uppercase }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Account Number</div>
<div class="detail-value mono bold">{{ accountNo }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Product</div>
<div class="detail-value">{{ productName || '—' }}</div>
</div>
</div>
</div>
<div class="balance-summary-strip">
<div class="balance-box">
<div class="balance-box-label">Current Balance</div>
<div class="balance-box-value">{{ currencySym }}{{ currentBalance | number: '1.2-2' }}</div>
</div>
<div class="balance-divider"></div>
<div class="balance-box">
<div class="balance-box-label">Available Balance</div>
<div class="balance-box-value">{{ currencySym }}{{ availableBalance | number: '1.2-2' }}</div>
</div>
<div class="balance-divider"></div>
<div class="balance-box">
<div class="balance-box-label">Total Transactions</div>
<div class="balance-box-value">{{ filteredRows.length }}</div>
</div>
</div>
@if (filteredRows.length > 0) {
<table class="statement-table">
<thead>
<tr>
<th class="col-sr">#</th>
<th class="col-date">Date</th>
<th class="col-desc">Description</th>
<th class="col-ref">Ref No.</th>
<th class="col-amount text-right">Debit (Dr)</th>
<th class="col-amount text-right">Credit (Cr)</th>
<th class="col-balance text-right">Balance</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows; track row.id; let i = $index) {
<tr [class.debit-row]="row.debit > 0" [class.credit-row]="row.credit > 0">
<td class="col-sr text-center">{{ i + 1 }}</td>
<td class="col-date">{{ row.dateLabel }}</td>
<td class="col-desc">
<span class="txn-desc">{{ row.description }}</span>
</td>
<td class="col-ref text-muted mono">{{ row.id }}</td>
<td class="col-amount text-right debit-amount">
@if (row.debit > 0) {
{{ currencySym }}{{ row.debit | number: '1.2-2' }}
}
</td>
<td class="col-amount text-right credit-amount">
@if (row.credit > 0) {
{{ currencySym }}{{ row.credit | number: '1.2-2' }}
}
</td>
<td class="col-balance text-right balance-amount">
{{ currencySym }}{{ row.runningBalance | number: '1.2-2' }}
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="4" class="text-right total-label">Closing Balance</td>
<td class="text-right total-label"></td>
<td class="text-right total-label"></td>
<td class="text-right closing-balance">{{ currencySym }}{{ currentBalance | number: '1.2-2' }}</td>
</tr>
</tfoot>
</table>
} @else {
<div class="no-data-block">
<mat-icon>inbox</mat-icon>
<p>No transactions in this period.</p>
</div>
}
<div class="statement-footer">
<div class="footer-note">
This is a computer-generated statement and does not require a signature. For queries, contact your branch
or call customer care.
</div>
<div class="footer-meta">Generated on: {{ today }} &nbsp;|&nbsp; Account No: {{ accountNo }}</div>
</div>
</div>
</div>
</div>
`,
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<AccountStatementDialogComponent>>(MatDialogRef);
data = inject<{ account: any }>(MAT_DIALOG_DATA);
private organizationService = inject(OrganizationService);
@ViewChild('statementContent') statementContent!: ElementRef;
today = businessTodayIso();
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 =
'<svg class="bank-logo-svg" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true"><path d="M4 10h3v7H4zM10.5 10h3v7h-3zM2 19h20v3H2zM17 10h3v7h-3zM12 1L2 6v2h20V6z" fill="#1a3a6b" /></svg>';
} else {
el.remove();
}
});
const printWindow = window.open('', 'Statement', 'height=800,width=1100');
if (!printWindow) return;
printWindow.document.write(`
<html>
<head>
<meta charset="utf-8" />
<title>Account Statement - ${this.accountNo}</title>
<style>${AccountStatementDialogComponent.PRINT_STYLES}</style>
</head>
<body>
${clone.outerHTML}
<script>
window.onafterprint = function () { window.close(); };
window.addEventListener('load', function () { window.focus(); window.print(); });
</script>
</body>
</html>
`);
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: `
<div class="jhd-wrap">
<div class="jhd-header">
<div class="jhd-header-left">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span>Joint Holder Details</span>
</div>
<button class="jhd-close" (click)="close()">✕</button>
</div>
@if (loading) {
<div class="jhd-loading">Loading details…</div>
} @else {
<div class="jhd-body">
<!-- Identity section -->
<div class="jhd-identity">
<div class="jhd-media-col">
<div class="jhd-media-label">PHOTO</div>
@if (photo) {
<img
[src]="photo"
class="jhd-photo"
alt="Photo"
(click)="previewImg(photo, 'Joint Holder Photo')"
title="Click to enlarge"
/>
} @else {
<div class="jhd-no-media">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.5">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
</svg>
<span>No Photo</span>
</div>
}
</div>
<div class="jhd-media-col">
<div class="jhd-media-label">SIGNATURE</div>
@if (signature) {
<img
[src]="signature"
class="jhd-sig"
alt="Signature"
(click)="previewImg(signature, 'Joint Holder Signature')"
title="Click to enlarge"
/>
} @else {
<div class="jhd-no-media">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.5">
<path d="M20 20H7L3 16l13.5-13.5a2 2 0 0 1 2.8 0L21 4.3a2 2 0 0 1 0 2.8L10.5 17" />
</svg>
<span>No Signature</span>
</div>
}
</div>
<div class="jhd-info-col">
<div class="jhd-name">{{ data.jh.displayName }}</div>
<div class="jhd-badges">
<span class="jhd-role-badge">{{ data.jh.role ?? 'Co-Holder' }}</span>
@if (data.jh.signingAuthority?.toLowerCase() === 'mandatory') {
<span class="jhd-sign-badge">✍ Mandatory Signing</span>
}
</div>
<div class="jhd-fields">
<div class="jhd-row">
<span class="jhd-lbl">CIF No.</span><span class="jhd-val mono">{{ client?.cifNumber ?? '—' }}</span>
</div>
<div class="jhd-row">
<span class="jhd-lbl">Mobile</span><span class="jhd-val">{{ client?.mobileNo ?? '—' }}</span>
</div>
<div class="jhd-row">
<span class="jhd-lbl">Email</span><span class="jhd-val">{{ client?.emailAddress ?? '—' }}</span>
</div>
<div class="jhd-row">
<span class="jhd-lbl">Share %</span><span class="jhd-val">{{ data.jh.sharePercentage }}%</span>
</div>
</div>
</div>
</div>
<!-- KYC section -->
<div class="jhd-kyc-row">
<div class="jhd-kyc-card">
<span class="jhd-kyc-label">KYC Status</span>
<span class="jhd-kyc-val" [style.color]="kyc?.kycStatus === 300 ? '#059669' : '#f59e0b'">
{{ kyc?.kycStatus === 300 ? '✅ Verified' : kyc?.kycStatus === 100 ? '⏳ Pending' : '—' }}
</span>
</div>
<div class="jhd-kyc-card">
<span class="jhd-kyc-label">PAN</span>
<span class="jhd-kyc-val mono">{{ kyc?.panNumber ?? '—' }}</span>
</div>
<div class="jhd-kyc-card">
<span class="jhd-kyc-label">Aadhaar</span>
<span class="jhd-kyc-val mono">{{ kyc?.aadhaarNumberMasked ?? 'XXXX/XXXX/XXXX' }}</span>
</div>
<div class="jhd-kyc-card">
<span class="jhd-kyc-label">CKYC</span>
<span class="jhd-kyc-val mono">{{ kyc?.ckycNumber || '—' }}</span>
</div>
</div>
</div>
}
</div>
`,
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<JointHolderDialogComponent>>(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<any>(`/clients/${cid}`).pipe(catchError(() => of(null))),
kyc: this.http.get<any>(`/clients/${cid}/kyc`).pipe(catchError(() => of([]))),
photo: this.http.get(`/clients/${cid}/images`, { responseType: 'text' }).pipe(catchError(() => of(null))),
documents: this.http.get<any[]>(`/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: `
<div class="ipd-wrap">
<div class="ipd-topbar">
<span class="ipd-title">{{ data.title }}</span>
<button class="ipd-close-btn" (click)="close()" title="Close">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</button>
</div>
<div
class="ipd-body"
(wheel)="onWheel($event)"
(mousedown)="onMouseDown($event)"
(mousemove)="onMouseMove($event)"
(mouseup)="onMouseUp()"
(mouseleave)="onMouseUp()"
[style.cursor]="dragging ? 'grabbing' : zoom > 1 ? 'grab' : 'default'"
>
<img
[src]="safeUrl"
[alt]="data.title"
class="ipd-img"
[style.transform]="imgTransform"
(dragstart)="$event.preventDefault()"
/>
</div>
<div class="ipd-footer">
<div class="ipd-toolbar">
<button class="ipd-tool" (click)="zoomOut()" [disabled]="zoom <= min" title="Zoom out">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.5" />
<path d="M4 6h4M11 11l-2.5-2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
<span class="ipd-zoom-val">{{ zoomPct }}%</span>
<button class="ipd-tool" (click)="zoomIn()" [disabled]="zoom >= max" title="Zoom in">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.5" />
<path d="M6 4v4M4 6h4M11 11l-2.5-2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
<div class="ipd-sep"></div>
<button class="ipd-tool" (click)="reset()" title="Reset zoom">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M2 7a5 5 0 1 0 1.2-3.2M2 3.5V7h3.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<span class="ipd-hint">Scroll to zoom &nbsp;·&nbsp; Drag to pan</span>
</div>
</div>
`,
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<ImagePreviewDialogComponent>>(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<string, number> = {};
rowAccountNames: Record<string, string> = {};
// 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<string, number> = {};
// 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<string, number> = {};
// 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<string, string> = {};
// 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<string, number> = {};
// 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;
/** The Cash Vault GL account's own code and name — the denomination breakdown for a manual GL
* entry has to be filed against THIS account, not whichever account the teller typed in, since
* that account is never the one physical notes actually moved through (see submitGlJournalEntry).
* Both resolved dynamically off whatever this tenant's own financial-activity mapping names as
* its cash/vault account (see loadCashVaultGl) — never a literal account name or code. */
cashVaultGlCode: string | null = null;
cashVaultGlName: string | 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'
];
/**
* Account types a clerk may fund a demand draft from.
*
* A clerk raises a draft against a member's own balance (SD/CA) or, now, against a GL account
* directly for an institutional/holding-account draft (see isDdAccountDebitRow()) — both are
* governed, maker-checker submissions. GL used to be offered here too in a different sense: picking
* it quietly switched the row onto the CASHIER's walk-in cash path instead (the one that asks for a
* purchaser's name and a note-and-coin count), which isDdAccountDebitRow() now disambiguates by
* role — GL means "debit this GL account" for a clerk, and still means "cash/GL purchase" for a
* cashier, who never reaches this list at all (isDdAccountDebitRow() is clerk-only).
*/
private static readonly DD_CLERK_ACTYPS = [
'SD',
'CA',
'GL'
];
/**
* The account types this particular row may use.
*
* Every other row keeps the full list; only a clerk's DD row is narrowed, because that is the
* only row where the account type silently decides which funding flow runs.
*/
acTypOptionsFor(row: TxnRow): string[] {
if (this.isClerk && row.typ === 'DD') {
return TransactionEntryComponent.DD_CLERK_ACTYPS;
}
return this.acTypOptions;
}
/** True while the selected row is filled from a token-driven request whose row.acTyp is a
* technical stand-in rather than the account's real type — a dividend claim or an account closure
* both set row.acTyp = 'SD' purely to route past the SHR-specific guards elsewhere on this screen
* (see onTokNoBlur()), not because the counter is paying against a savings account. The AC.TP
* dropdown must not be left editable there: it already shows the wrong value, and a cashier
* changing it further would desync the row from the request it was auto-filled from. A plain
* withdrawal-slip token is deliberately excluded — its row.acTyp is resolved to the account's real
* type there, so the normal editable control already shows it correctly and there's nothing to
* lock. */
get acTypLockedByToken(): boolean {
return !!(this.activeDividendClaimRequest || this.activeClosureRequest);
}
/** What to show in place of the AC.TP dropdown while it's locked — the same label the account
* summary strip above already uses for each of these token pools, so the two never contradict
* each other on screen. */
get acTypLockedLabel(): string {
if (this.activeClosureRequest) {
return 'Closure';
}
if (this.activeDividendClaimRequest) {
return 'Dividend';
}
return '';
}
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<string, number> = {};
// ── 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 = businessToday();
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();
this.cashierService
.getEntryTypeOptions()
.pipe(catchError(() => of([])))
.subscribe((options: any[]) => {
this.entryTypeOptions = options ?? [];
});
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<any>(`/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<any>(`/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 = businessToday();
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<string, number>();
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<string, any>();
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 by debiting a member's own account, so their row opens on savings and
// is debited, and GL is not on their AC.TP list at all (see acTypOptionsFor). Opening them on
// GL put the row on the cash path, which asks for a walk-in purchaser and a note-and-coin
// count that a clerk debiting an account does not have.
case 'DD':
row.dbCr = this.isClerk ? 'Dr' : 'Cr';
row.acTyp = this.isClerk ? 'SD' : '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, or a GL account directly, rather
* than by cash.
*
* Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account/GL debit
* would let a draft be raised with no cash to reconcile it against — and the clerk flow is the one
* that carries a maker-checker step over the money moved. AC.TP = GL means something different for
* a cashier's own DD row (a walk-in cash/GL purchase, see isDdCashGlRow()) — this method is checked
* first there specifically so a clerk's GL row is claimed here instead.
*/
isDdAccountDebitRow(row: TxnRow): boolean {
return (
this.isClerk &&
row.typ === 'DD' &&
(row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL')
);
}
/**
* 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);
}
/**
* Whether this row needs an Entry Type tag — a credit into a GL account, which is exactly the
* case where the money's real purpose isn't otherwise recorded anywhere (see TxnRow.entryTypeCvId).
* A debit out of a GL account, or any entry against a savings/current/share account, carries no
* such ambiguity — the account itself says what it's for.
*/
showsEntryType(row: TxnRow): boolean {
return row.acTyp === 'GL' && row.dbCr === 'Cr';
}
/**
* Entry Type choices for a GL credit — the "GLEntryType" code (Admin > System > Manage Codes),
* fetched once in ngOnInit(). A code value, not a free-text field, so the day-end "group by type"
* this exists for stays reliable; an admin extends the list there, not in this file.
*/
entryTypeOptions: { id: number; name: string }[] = [];
/**
* Whether this row may raise more than one draft off a single debit.
*
* Any funding source the clerk debits: a member's savings/current account, or a GL account for an
* institutional slip. Cash is the exception the batch endpoint still refuses, and for a real
* reason rather than an arbitrary one — a slip is funded by one payment, and one counted pile of
* notes cannot be apportioned across drafts without the denomination total ceasing to prove what
* was actually taken at the counter.
*/
canRowBeMultiDd(row: TxnRow): boolean {
return this.isDdAccountDebitRow(row);
}
/** Whether the clerk has asked this row for more than one draft. */
isMultiDdRow(row: TxnRow): boolean {
return this.canRowBeMultiDd(row) && (Number(row.ddCount) || 1) > 1;
}
/** How many drafts this row raises — always at least 1. */
ddCountFor(row: TxnRow): number {
const n = Number(row.ddCount) || 1;
return n > 1 ? n : 1;
}
/**
* Clamps a typed DD count into range and discards lines that no longer fit.
*
* Lowering the count after the dialog has been filled would otherwise leave lines beyond the new
* count still on the row: invisible to the clerk, but still sent at Save and still debited for.
*/
onDdCountChange(row: TxnRow, value: number): void {
const n = Math.floor(Number(value) || 1);
const previous = this.ddCountFor(row);
row.ddCount = n < 1 ? 1 : n > this.maxDdSlipLines ? this.maxDdSlipLines : n;
if (row.ddLines && row.ddLines.length !== row.ddCount) {
this.clearDdCapture(row);
}
// Moving onto a slip empties the row's own amount and charge, because on a slip they mean
// nothing: each draft carries its own face value and its own charge, entered in the dialog, and
// these two fields become the total of those. Leaving a previously typed figure sitting here
// would read as the slip's total while describing only one draft — which is exactly what showed
// ₹1,012 against four drafts of ₹1,000.
if (row.ddCount > 1 && previous <= 1) {
row.amount = null;
row.ddCharges = null;
}
}
/** Mirrors MAX_SLIP_LINES on the server, so an over-long slip is refused here with a clear message. */
readonly maxDdSlipLines = 20;
/**
* The run of leaflet numbers a slip will probably draw, for display only.
*
* Counts up from the next-available number, preserving its width so 000123 reads as 000124 rather
* than 124. Indicative by nature — the binding allocation happens inside the server's submit
* transaction, and a slip filed after another clerk's gets the numbers following theirs — which is
* why the dialog labels these provisional and the confirmed numbers come back on submission.
*
* A number that is not a plain run of digits (a prefixed or hyphenated series) cannot be counted
* on safely, so nothing is shown rather than something wrong.
*/
private previewDdNumbersFrom(firstDdNumber: string, count: number): string[] | undefined {
const first = (firstDdNumber || '').trim();
if (!/^\d+$/.test(first)) return undefined;
const width = first.length;
const start = Number(first);
if (!Number.isSafeInteger(start)) return undefined;
return Array.from({ length: count }, (_v, i) => String(start + i).padStart(width, '0'));
}
/** GST on a single charge figure, at the admin-configured rate. */
private ddGstOn(charges: number): number {
const c = Number(charges) || 0;
if (c <= 0) return 0;
return Math.round(c * this.ddGstPercentage) / 100;
}
/**
* GST on DD charges only (never on the DD face value), at the admin-configured rate.
*
* On a slip this is the sum of each line's own GST rather than GST on the summed charges. The two
* can differ by a paisa once rounding is applied per line, and it is the per-line figure that is
* stored against each draft and posted by the server — so the row must show that same total.
*/
ddGstAmountFor(row: TxnRow): number {
if (row.ddLines?.length) {
return row.ddLines.reduce((sum, l) => sum + this.ddGstOn(l.chargesAmount), 0);
}
return this.ddGstOn(row.ddCharges ?? 0);
}
/** Face value of the row — the sum of every line once a slip has been captured. */
ddFaceValueFor(row: TxnRow): number {
if (row.ddLines?.length) {
return row.ddLines.reduce((sum, l) => sum + (Number(l.faceValue) || 0), 0);
}
return Number(row.amount) || 0;
}
/** Charges on the row — the sum of every line's own charge once a slip has been captured. */
ddChargesFor(row: TxnRow): number {
if (row.ddLines?.length) {
return row.ddLines.reduce((sum, l) => sum + (Number(l.chargesAmount) || 0), 0);
}
return Number(row.ddCharges) || 0;
}
/** Amount + Charges + GST, rounded UP to the next whole rupee — the total collected from the customer. */
ddTotalPayableFor(row: TxnRow): number {
return Math.ceil(this.ddFaceValueFor(row) + this.ddChargesFor(row) + this.ddGstAmountFor(row) - 1e-9);
}
/**
* What the TOTAL field shows — a figure, or a dash while a slip has nothing to total yet.
*
* A slip whose drafts have not been entered has no total: its face values and charges live on the
* lines, and there are no lines. Showing ₹0 there would read as a real total of zero rather than
* as "not yet known", so the field says so instead.
*/
ddTotalDisplayFor(row: TxnRow): string {
if (this.isSlipAwaitingDetails(row)) {
return '—';
}
return String(this.ddTotalPayableFor(row));
}
/** GST field's text, dashed for the same reason as the total while a slip is still empty. */
ddGstDisplayFor(row: TxnRow): string {
if (this.isSlipAwaitingDetails(row)) {
return '—';
}
return this.ddGstAmountFor(row).toFixed(2);
}
/** A slip the clerk has asked for but not yet filled in — nothing to total from. */
isSlipAwaitingDetails(row: TxnRow): boolean {
return this.isMultiDdRow(row) && !row.ddLines?.length;
}
/**
* Forgets a row's captured draft details, leaving the row itself intact.
*
* Both shapes are cleared together on purpose: a row carrying a stale ddDetails alongside fresh
* ddLines (or the reverse) would be read by whichever of the two Save paths it reached first, and
* the clerk would have no way of seeing which one went.
*/
private clearDdCapture(row: TxnRow): void {
row.ddDetails = undefined;
row.ddLines = undefined;
row.ddInventoryId = undefined;
row.ddFundingAccountId = undefined;
row.ddFundingClientId = undefined;
}
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<number, string> = {
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;
}
// On a slip the face value lives on each line, typed in the dialog that is about to open — the
// row's own AMOUNT is the sum of those lines and cannot exist yet. Demanding it here would make
// a slip impossible to start.
const isSlip = this.isMultiDdRow(row);
const faceValue = Number(row.amount);
if (!isSlip && (!faceValue || faceValue <= 0)) {
this.showAlert('error', 'Invalid Amount', ['Please enter the DD Face Value amount before proceeding.']);
return;
}
if (isSlip && !this.isDdAccountDebitRow(row)) {
this.showAlert('error', 'Multiple DDs Not Available', [
'More than one draft can only be raised against a member savings or current account.',
'Set NO. OF DD back to 1 for this row.'
]);
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.
// A GL-funded draft is the exception: a GL account is never tied to a client at all (see
// fetchAllDetails() — GL rows never populate rowClientIds), so only the account id is required.
const isGlDebit = accountDebit && row.acTyp === 'GL';
let fundingAccountId: number | null = null;
let fundingClientId: number | null = null;
if (accountDebit) {
const acNo = (row.acNo || '').trim();
if (!acNo) {
this.showAlert('error', 'Account Required', [
isGlDebit
? 'Enter the GL account the Demand Draft is funded from.'
: 'Enter the account number the Demand Draft is funded from.'
]);
return;
}
fundingAccountId = this.rowAccountIds[acNo] ?? null;
fundingClientId = isGlDebit ? null : (this.rowClientIds[acNo] ?? null);
if (!fundingAccountId || (!isGlDebit && !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. A slip is
// always an account debit, and its total is not known until the dialog is filled in either.
if (
!accountDebit &&
!isSlip &&
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;
// A slip is allocated by inventory, not leaflet: the server picks the whole run inside its
// own locked transaction. Without the inventory behind this branch's next-available leaflet
// there is nothing to allocate from, so the slip is refused here rather than at submit.
if (isSlip && !leaflet.inventoryId) {
this.showAlert('error', 'DD Inventory Not Identified', [
'The DD inventory behind this branch and sponsor bank could not be identified, so multiple drafts cannot be allocated together.',
'Set NO. OF DD back to 1, or ask an administrator to check the DD inventory registration.'
]);
return;
}
const dialogRef = this.dialog.open(CashierIssueDdDialogComponent, {
width: isSlip ? '760px' : '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,
ddCount: isSlip ? this.ddCountFor(row) : undefined,
glFunded: isGlDebit,
previewDdNumbers: isSlip ? this.previewDdNumbersFrom(leaflet.ddNumber, this.ddCountFor(row)) : undefined
}
});
// Recorded on the row, not sent. Save is what commits the worksheet, and a DD row that
// submitted itself here left that button with nothing to do — so the clerk had no way to
// review the entered details, and no way to abandon them either.
dialogRef.afterClosed().subscribe((result) => {
if (!result) return;
// A slip records its lines instead of a single beneficiary, and takes the row's AMOUNT and
// CHARGES with it: from here on those two are the sum of the lines, shown read-only, so a
// clerk cannot leave a row total behind that disagrees with what the drafts add up to.
if (result.lines) {
this.clearDdCapture(row);
row.ddLines = result.lines;
row.ddInventoryId = leaflet.inventoryId;
row.ddFundingAccountId = fundingAccountId!;
// Null on a GL slip, which is raised against no member at all.
row.ddFundingClientId = fundingClientId ?? undefined;
row.amount = this.ddFaceValueFor(row);
row.ddCharges = this.ddChargesFor(row);
this.showAlert('success', 'Slip Details Captured', [
`${result.lines.length} demand drafts captured in favour of ${result.lines
.map((l: DdSlipLine) => l.beneficiaryName)
.join(', ')}.`,
`₹${this.ddTotalPayableFor(row)} will be debited from ${isGlDebit ? 'GL account ' : ''}${row.acNo} as a single ${
isGlDebit ? 'journal entry' : 'transaction'
} once a checker approves.`,
'DD numbers are allocated on submission. Press Save to send this slip for approval.'
]);
return;
}
row.ddDetails = {
leafletId: leaflet.id,
ddNumber: leaflet.ddNumber,
beneficiaryName: result.beneficiaryName,
payableAtBranch: result.payableAtBranch,
payableAtCity: result.payableAtCity,
purchaser: accountDebit
? undefined
: {
name: result.walkinPurchaserName,
address: result.walkinPurchaserAddress,
aadhaar: result.walkinPurchaserAadhaar
},
fundingAccountId: accountDebit ? fundingAccountId! : undefined,
fundingClientId: accountDebit ? fundingClientId! : undefined
};
this.showAlert('success', 'DD Details Captured', [
`DD No. ${leaflet.ddNumber} · in favour of ${result.beneficiaryName}.`,
accountDebit
? `₹${this.ddTotalPayableFor(row)} will be debited from ${row.acNo} once a checker approves.`
: `₹${this.ddTotalPayableFor(row)} to be collected at the counter.`,
'Press Save to send this draft for approval.'
]);
});
},
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/current account, or a GL account
* directly.
*
* Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The
* account/GL is only touched when a checker approves, which for a member account 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 (a GL account carries no such balance check). No denominations are
* sent, because no cash is taken.
*
* The funding account goes in exactly one of savingsAccountId, currentAccountId or glAccountId: the
* server insists on exactly one, since which one decides what gets debited on approval.
*/
private submitAccountDebitDd(
row: TxnRow,
leaflet: any,
beneficiaryData: any,
fundingAccountId: number,
fundingClientId: number | null
): void {
const d = businessToday();
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 isGl = row.acTyp === 'GL';
const payload = {
clientId: isGl ? undefined : (fundingClientId ?? undefined),
beneficiaryName: beneficiaryData.beneficiaryName,
payableAtBranch: beneficiaryData.payableAtBranch,
payableAtCity: beneficiaryData.payableAtCity,
faceValue: Number(row.amount),
chargesAmount: Number(row.ddCharges) || 0,
issuingBranchId: row.br,
issuedDate,
paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT',
savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId,
currentAccountId: isGl || !isCurrent ? undefined : fundingAccountId,
glAccountId: isGl ? 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 = '';
// Cleared with the row: leaving the captured details behind would let a second Save raise
// the same draft again against a leaflet that has already been used.
row.ddDetails = undefined;
},
error: (err) => {
this.saving = false;
this.showAlert('error', 'DD Submission Failed', [err.error?.message || err.message]);
}
});
}
/**
* Sends the drafts on the worksheet for approval.
*
* Each row carries the beneficiary details the dialog captured; a row that was never filled in is
* refused by name rather than skipped, because a clerk who pressed Save expecting one draft and
* got none has no way of telling which row was missing.
*/
private saveDdRows(ddRows: TxnRow[]): void {
const unfilled = ddRows.filter((r) => !r.ddDetails && !r.ddLines?.length);
if (unfilled.length > 0) {
this.showAlert('error', 'DD Details Not Entered', [
unfilled.length === 1
? 'One DD row has no beneficiary details yet.'
: `${unfilled.length} DD rows have no beneficiary details yet.`,
'Use "Fill DD Details & Send for Approval" on each DD row first, then press Save.'
]);
return;
}
for (const row of ddRows) {
// A slip goes to the batch endpoint, which allocates its leaflets and posts one debit for the
// whole run. Sending its lines one at a time through the single-DD endpoint would debit the
// member once per draft — the very thing a slip exists to avoid.
if (row.ddLines?.length) {
this.submitDdSlip(row);
continue;
}
const details = row.ddDetails!;
const leaflet = { id: details.leafletId, ddNumber: details.ddNumber };
if (this.isDdAccountDebitRow(row)) {
this.submitAccountDebitDd(
row,
leaflet,
{
beneficiaryName: details.beneficiaryName,
payableAtBranch: details.payableAtBranch,
payableAtCity: details.payableAtCity
},
details.fundingAccountId!,
details.fundingClientId!
);
} else {
this.submitWalkinCashDd(row, leaflet, {
beneficiaryName: details.beneficiaryName,
payableAtBranch: details.payableAtBranch,
payableAtCity: details.payableAtCity,
walkinPurchaserName: details.purchaser?.name,
walkinPurchaserAddress: details.purchaser?.address,
walkinPurchaserAadhaar: details.purchaser?.aadhaar
});
}
}
}
/**
* Submits a multi-DD slip: several drafts raised together against one debit of the member's
* account.
*
* Maker-checker like the single-draft flow, and nothing is debited here. On approval the server
* withdraws the combined face value, charges and GST of every line as ONE transaction and posts
* one journal entry against it, then issues each draft — so the member's passbook carries a single
* entry naming all the DD numbers rather than one entry per draft. The checker approves or rejects
* the slip whole; there is no approving four drafts out of five.
*
* The leaflets are not chosen here. The server allocates the run inside its own locked transaction
* and returns which number went to which line, which is the only point at which the numbers are
* real — two clerks submitting at the same moment can therefore never be handed the same one.
*/
private submitDdSlip(row: TxnRow): void {
const lines = row.ddLines ?? [];
const inventoryId = row.ddInventoryId;
const fundingAccountId = row.ddFundingAccountId;
const fundingClientId = row.ddFundingClientId;
const isGl = row.acTyp === 'GL';
// Re-checked at Save, not just when the details were captured: the row's TYPE, AC.TP or account
// number can all be edited afterwards, and a slip that has drifted onto a cash row is one the
// batch endpoint refuses outright. A GL slip is raised against no member, so it carries no
// client id and none is required of it — the same asymmetry as the single-draft GL path.
if (!inventoryId || !fundingAccountId || (!isGl && !fundingClientId) || !this.canRowBeMultiDd(row)) {
this.showAlert('error', 'Slip Cannot Be Submitted', [
'This row has changed since its drafts were captured, so the slip no longer matches it.',
'Open "Fill DD Details" again and re-enter the drafts.'
]);
return;
}
const d = businessToday();
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';
// The funding source goes in exactly one of savingsAccountId, currentAccountId or glAccountId,
// as the single-draft path does: which one is set is what decides the debit leg on approval.
const payload = {
clientId: isGl ? undefined : fundingClientId,
issuingBranchId: row.br,
issuedDate,
paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT',
savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId,
currentAccountId: !isGl && isCurrent ? fundingAccountId : undefined,
glAccountId: isGl ? fundingAccountId : undefined,
lines: lines.map((l) => ({
beneficiaryName: l.beneficiaryName,
payableAtBranch: l.payableAtBranch,
payableAtCity: l.payableAtCity,
faceValue: Number(l.faceValue),
chargesAmount: Number(l.chargesAmount) || 0
}))
};
const totalDebited = this.ddTotalPayableFor(row);
const accountNo = row.acNo;
this.saving = true;
this.sponsorDDService.submitIssueRequestBatch(inventoryId, payload).subscribe({
next: (result: any) => {
this.saving = false;
// The allocated numbers, named back to the clerk. Until this response they were provisional,
// and these are what the customer's drafts will actually carry.
const allocated = (result?.lines ?? [])
.map((l: any) => `${l.ddNumber} · ${l.beneficiaryName} · ₹${l.faceValue}`)
.join('\n');
this.showAlert('success', 'DD Slip Submitted', [
`Slip of ${lines.length} demand drafts submitted successfully.`,
allocated || `${lines.length} drafts allocated.`,
`₹${totalDebited} will be debited from ${accountNo} as a single transaction on approval.`,
'Sent to Manager / Head Cashier for maker-checker approval of the whole slip.'
]);
row.amount = null;
row.ddCharges = null;
row.description = '';
row.ddCount = 1;
// Cleared with the row, as the single-draft paths do: leaving the lines behind would let a
// second Save raise the same slip again against leaflets already allocated.
this.clearDdCapture(row);
},
error: (err) => {
this.saving = false;
this.showAlert('error', 'DD Slip Submission Failed', [
err.error?.message || err.message,
'No drafts were issued and nothing has been debited — the whole slip was refused together.'
]);
}
});
}
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 = businessToday();
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 = '';
// See the account-debit path: a second Save must not re-raise a draft already issued.
row.ddDetails = undefined;
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',
// Same ambiguity as a cashier Receipt — a Collection credits a GL account picked by hand.
// See TxnRow.entryTypeCvId.
...(isCollectionMode ? { entryTypeCvId: crRows[0]?.entryTypeCvId || null } : {})
};
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 "Disbursements & Transfer".'
]);
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 = businessToday();
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,
settlementMode: 'SAVINGS',
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.showShareWithdrawalConfirm(payload, confirmLines, () =>
this.doSubmitShareRedemptionTransfer(payload, shareRow, shares, linkedAcNo)
);
}
/**
* Shows the confirm dialog with the same checklist the Manager will see when approving this
* request — fetched live from the backend (`previewValidate`) rather than re-implemented here,
* so it can never show the clerk something different from what the approver actually sees. A
* failing check is shown, not blocked: approve() enforces its own hard rules separately (e.g.
* Share Advance Collection applied), this list is advisory so problems surface to whoever can
* still fix them, before a manager's time is spent on a request that was always going to fail.
*/
private showShareWithdrawalConfirm(
payload: Record<string, unknown>,
baseLines: string[],
onConfirm: () => void
): void {
this.clerkShareWithdrawalService
.previewValidate(payload)
.pipe(
// A request that never resolves (slow network, server busy) must not leave the Submit
// button looking dead — the dialog still has to open, just without the checklist.
timeout(5000),
catchError(() => of({ __previewFailed: true }))
)
.subscribe((res: any) => {
const checks: { label: string; pass: boolean; detail: string }[] = res?.checks ?? [];
// A failed call (most likely the backend not yet carrying the /preview-validate endpoint)
// says so explicitly rather than just omitting the section — a silently empty checklist
// looks identical to "everything passed, nothing to show", which is the one reading this
// must never give.
const checkLines = res?.__previewFailed ? [
'',
'⚠ Validation checklist unavailable right now — the request can still be submitted.'
] : checks.length ? [
'',
'Validation Checks (same as Manager approval):',
...checks.map((c) => `${c.pass ? '✓' : '⚠'} ${c.label}: ${c.detail}`)
] : [];
// baseLines ends with a blank line followed by the "Pending Manager approval…" footer
// sentence, which must stay last so the modal renders it as a footnote rather than a
// key/value row — so the checklist is spliced in just before that pair, not appended after.
const footerStart =
baseLines.length >= 2 && baseLines[baseLines.length - 2] === '' ? baseLines.length - 2 : baseLines.length;
const lines = [
...baseLines.slice(0, footerStart),
...checkLines,
...baseLines.slice(footerStart)
];
this.showConfirmAlert('info', 'Confirm Share Withdrawal Request', lines, 'Submit for Approval', onConfirm);
});
}
/**
* Share redemption settled to a GL account the clerk picks on the CR row, instead of the
* member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
* credit side is any GL account (e.g. a "DD Payable" holding account), not a fixed linked
* account, so there is no "must match the linked account" check here.
*/
private submitShareGlSettlementTransfer(shareRow: TxnRow, glRow: TxnRow): void {
// Belt-and-braces: the TXN TP. dropdown already hides cash payment types for AC.TP = SHR (see
// paymentTypesForRow()), but re-check here too in case the payment type was set before that
// filter applied.
const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === glRow.paymentTypeId);
if (!glRow.paymentTypeId) {
this.showAlert('error', 'Settlement Payment Type Required', [
'Select a Payment Type on the CR (GL 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, 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;
}
const settlementGlAccountId = this.rowAccountIds[glRow.acNo];
if (!settlementGlAccountId) {
this.showAlert('error', 'GL Account Not Loaded', [`Please load account details for: ${glRow.acNo}`]);
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 = businessToday();
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, same as the linked-savings path above.
const settlementRowSnapshot = {
branch: glRow.br,
accountType: glRow.acTyp,
accountNo: glRow.acNo,
ecsAccountNo: glRow.ecsAcNo || null,
dbCr: glRow.dbCr,
amount: glRow.amount ?? null,
chequeNo: glRow.chequeNo || null,
chequeDate: glRow.cheqDate || null,
tokenNo: glRow.tokNo || null,
description: glRow.description || null,
bankName: glRow.bankName || null,
routingCode: glRow.routingCode || null,
paymentTypeId: glRow.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: glRow.paymentTypeId,
settlementRowSnapshot,
settlementMode: 'GL',
settlementGlAccountId
};
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 (GL): ${glRow.acNo}`,
`Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
...(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.showShareWithdrawalConfirm(payload, confirmLines, () =>
this.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
);
}
private doSubmitShareGlSettlementTransfer(
payload: Record<string, unknown>,
shareRow: TxnRow,
shares: number,
glAcNo: 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 GL ${glAcNo} 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;
}
});
}
private doSubmitShareRedemptionTransfer(
payload: Record<string, unknown>,
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 account-closure
// pool before giving up: both share the same TOK. NO. field at this counter. A dividend
// claim was once a third pool here, but a dividend never actually pays out in cash over
// this counter — it only ever settles through a Demand Draft, which is its own DD
// issuance request and its own token, so looking one up here could only ever 404.
if (err?.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 =
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;
// Marks this acNo as already resolved, the same way loadAccountDetails() itself would —
// without it, the next click into this row (onRowSelect) sees an acNo that was never
// "loaded" and re-searches for a real account matching this stub string, which can land
// on an unrelated real account whose number happens to share a suffix with it.
this.lastLoadedAcNo = row.acNo;
row.acTyp = 'SD';
// Every token-driven row is cash leaving the branch to a member, which is what the
// Payment register is for — not what this row's TYPE happened to default to before the
// token was entered.
row.typ = 'Payment';
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 ?? '';
// See the same line in the dividend-claim branch above for why this is required.
this.lastLoadedAcNo = row.acNo;
row.acTyp = 'SD';
row.typ = 'Payment';
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;
// A plain withdrawal-slip request carries its own real account_type (SAVINGS, CURRENT,
// FIXED_DEPOSIT or RECURRING_DEPOSIT — see ClerkWithdrawalRequestApiResource.submit()), unlike
// the closure/dividend pools above, which are never anything but a savings-shaped stub.
// Hardcoding 'SD' here searched every non-savings account on the wrong endpoint (Current
// accounts live under /currentaccounts, not /savingsaccounts) and showed it mislabelled even
// when the lookup happened to succeed.
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD';
row.acNo = req.account_no ?? '';
row.acTyp = acTyp;
row.typ = 'Payment';
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(), acTyp);
}
});
}
/** 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;
// A token-driven row's account fields are already authoritative (see onTokNoBlur) -- merely
// clicking into and out of the A/C NO. field here must not re-search for them. Doing so used
// row.acTyp's internal stand-in value (e.g. 'SD' for a dividend claim, which is never the
// account's real type) against the account's real number, either finding nothing ("Account
// not found") or, worse, matching a completely unrelated real account that happened to share a
// number suffix.
if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) {
return;
}
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
onAcNoInput(row: TxnRow, index: number, value: string): void {
// Same reasoning as the guard at the top of onAcNoBlur() -- a token-driven row's account
// fields must not be edited or re-searched via this field at all.
if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) {
return;
}
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;
this.cashVaultGlCode = found.glAccountData.glCode ?? null;
this.cashVaultGlName = found.glAccountData.name ?? found.financialActivityData?.name ?? null;
}
});
}
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<string, string> = {
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<any>(
`${cfg.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}${cfg.extraListQueryParams ? `&${cfg.extraListQueryParams}` : ''}`
)
.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;
// Any row resolved via the partial/endsWith/id fallback above (not an exact code match)
// loads and displays the right account, but the row itself still carries whatever shorter
// string the cashier actually typed to search with -- and that's what gets submitted and
// shown everywhere downstream (vouchers, My Work, credit_entries), not the account's real
// full number. Correct the row to the account's actual code whenever the match wasn't
// already exact, for every account type -- not just the DD/GL cases this was previously
// limited to.
if (this.selectedRow && !exactMatch && matchedGlCode) {
this.selectedRow.acNo = matchedGlCode;
}
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 <empty>" SQL statement server-side. */
siFetchUnsupported?: boolean;
maxLimit: number;
skipGlobalSearch: boolean;
listQueryParam: string;
matchField: string;
detailParams: string;
supportsPagination: boolean;
/** Extra fixed query params (e.g. "usage=1") appended to every list-search request for this
* account type — used to keep Header GL accounts out of the results a clerk/cashier can pick
* for a transaction; a Header can never receive a posting, so it should never be offered. */
extraListQueryParams?: string;
} {
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,
// Detail-only: a Header account is a pure grouping node and can never receive a
// transaction, so it must never be a selectable match here in the first place.
extraListQueryParams: 'usage=1'
};
}
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 = businessToday();
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<any>(`/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<any>(`/clients/${clientId}?associations=clientNonPersonDetails`)
.pipe(catchError(() => of(null))),
kyc: this.http.get<any>(`/clients/${clientId}/kyc`).pipe(catchError(() => of([]))),
nominees: this.http
.get<any>(`/clients/${clientId}/nominees?accountNo=${acNo}&accountType=${cfg.nominee}`)
.pipe(catchError(() => of([]))),
addresses: this.http.get<any>(`/client/${clientId}/addresses`).pipe(catchError(() => of([]))),
family: this.http.get<any>(`/clients/${clientId}/familymembers`).pipe(catchError(() => of([]))),
photo: this.http.get(`/clients/${clientId}/images`, { responseType: 'text' }).pipe(catchError(() => of(null))),
documents: this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))),
notes: this.http.get<any>(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))),
linkedAcc: this.http.get<any>(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))),
si: cfg.siFetchUnsupported
? of({ pageItems: [] })
: this.http
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
.pipe(catchError(() => of({ pageItems: [] }))),
todayTxns: cfg.txnFetchUnsupported
? of({ pageItems: [] })
: cfg.txnSearchSupported
? this.http
.get<any>(
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
)
.pipe(catchError(() => of({ pageItems: [] })))
: this.http
.get<any>(`${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<any>(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<any>(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<string, string> = {
'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<string>();
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 = businessToday();
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<string, string> = {
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<string, string> = {
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<number, string> = { 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 = businessToday();
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 {
// Demand drafts commit here like everything else on the worksheet. Checked first because a DD
// row shares its shape with the ordinary entries below — without this it would fall through and
// post as a plain transaction, with the draft never raised and the charges silently dropped.
const ddRows = this.rows.filter((r) => this.isDdRow(r));
if (ddRows.length > 0) {
this.saveDdRows(ddRows);
return;
}
// 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.
//
// But 'Fund Transfer' rows that include a GL leg are NOT this flow — they are the
// Disbursement/Collection double-entry shape (see the TYPE options comment), just under the
// friendlier 'Fund Transfer' label so a non-loan GL movement doesn't have to be called a
// "Disbursement". 'Enter Bank Transfer' never carries a GL leg, so it always stays in this
// branch; only skip out to the Disbursement/Collection branch below when every row is 'Fund
// Transfer' and at least one of them is GL.
// Share redemption settled to a GL account instead of the linked savings account: DR=Share
// account, CR=any GL account the clerk picks (e.g. a "DD Payable" holding account, ahead of a
// second, independent manual entry that later moves the value out to an actual draft). Checked
// ahead of both branches below — the Enter Bank Transfer/Fund Transfer branch would otherwise
// treat a Fund Transfer carrying a GL leg as the Disbursement/Collection shape (see
// fundTransferRowsHaveGl below), and a Share account can never be debited by that path at all —
// no such Fineract command exists — so it would either be silently mis-posted or rejected late.
if (
this.isClerk &&
this.rows.length === 2 &&
this.rows.every((r) => r.typ === 'Fund Transfer' || r.typ === 'Enter Bank Transfer') &&
this.rows.some((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR') &&
this.rows.some((r) => r.dbCr === 'Cr' && r.acTyp === 'GL')
) {
const shareRow = this.rows.find((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR')!;
const glRow = this.rows.find((r) => r.dbCr === 'Cr' && r.acTyp === 'GL')!;
this.submitShareGlSettlementTransfer(shareRow, glRow);
return;
}
const fundTransferRowsHaveGl =
this.rows.every((r) => r.typ === 'Fund Transfer') && this.rows.some((r) => r.acTyp === 'GL');
if (
this.isClerk &&
this.rows.length >= 2 &&
this.rows.every((r) => r.typ === 'Enter Bank Transfer' || r.typ === 'Fund Transfer') &&
!fundTransferRowsHaveGl
) {
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/FD/RD/GL) → route through maker-checker (create PENDING request,
// no direct credit). FD and RD are their own account-type codes (see creditEntryAccountType()) and
// belong here just as much as a plain savings deposit -- a cashier crediting an FD/RD must not be
// able to move money in without a manager's approval either.
// 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 === 'FD' ||
row.acTyp === 'RD' ||
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 = businessToday();
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;
}
// Same bug, same fix as the token-driven withdrawal-slip lookup above (see its comment): the
// request carries its own real account_type (SAVINGS, CURRENT, FIXED_DEPOSIT or
// RECURRING_DEPOSIT), and hardcoding 'SD' here searched every non-savings account on the
// wrong endpoint, so reviewing a Current-account withdrawal always failed with "Account not
// found".
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || '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 = businessToday();
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 = businessToday();
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,
// What this GL credit is actually for — see TxnRow.entryTypeCvId. Sent for every GL deposit
// (the backend defaults to "General" when the counter didn't pick one), so Share Advance
// Collection and Sponsor DD issuance can tell whose voucher this really is before adopting it.
...(isGl ? { entryTypeCvId: row.entryTypeCvId || 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 = businessToday();
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)
}));
// Filed against the Cash Vault GL, not the account the teller selected on screen: that
// selected account (e.g. Sundry Creditors) is the other leg of the journal entry, and
// never the one whose physical notes actually changed hands. Tagging the breakdown to it
// instead of the vault is why this cash never reached the cashier/vault position.
this.http
.post('/cashier/gl-denomination', {
txnId: res.transactionId ?? res.resourceId,
accountNo: this.cashVaultGlCode ?? row.acNo,
accountName: this.cashVaultGlName ?? 'Cash at Vault',
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();
}
}