/** * Copyright since 2025 Mifos Initiative * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Component, OnInit, OnDestroy, inject, Input, ElementRef, ViewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { FormsModule } from '@angular/forms'; import { HttpClient, HttpParams } from '@angular/common/http'; import { catchError, debounceTime, distinctUntilChanged, map } from 'rxjs/operators'; import { forkJoin, of, Subject } from 'rxjs'; import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { Router } from '@angular/router'; import { CashierService } from '../cashier.service'; import { AuthenticationService } from '../../core/authentication/authentication.service'; import { ClerkUiStateService, ClerkSavedAccount } from '../clerk-ui-state.service'; import { ClerkWithdrawalService } from '../clerk-withdrawal.service'; import { ClerkClosureRequestService } from '../clerk-closure-request.service'; import { DividendClaimRequestService } from '../../clerk/dividend-claim-request.service'; import { ClerkDisbursementService } from '../clerk-disbursement.service'; import { ClerkShareWithdrawalService } from '../clerk-share-withdrawal.service'; import { CashierDepositService } from '../cashier-deposit.service'; import { SettingsService } from '../../settings/settings.service'; import { CustomerSearchDialogComponent, CustomerSearchResult } from '../../shared/customer-search-dialog/customer-search-dialog.component'; import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service'; import { OrganizationService } from '../../organization/organization.service'; import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component'; interface TxnRow { typ: string; br: number; trTp: string; acTyp: string; acNo: string; ecsAcNo: string; dbCr: string; chequeNo: string; cheqDate: string; amount: number | null; description: string; tokNo: string; paymentTypeId: number | null; bankName: string; routingCode: string; /** DD issuance charges/commission — only used when TYPE=DD, TXN TP=Cash, AC.TP=GL */ ddCharges: number | null; sponsorBankId?: number | null; /** Only used when this row is AC.TP=SHR inside an Enter Bank Transfer to its linked savings * account — governs the share redemption (see submitShareRedemptionTransfer()). */ resolutionNumber?: string; meetingDate?: string; } interface Denomination { id: number; value: number; kind: 'note' | 'coin'; label: string; } // ── Account Statement Dialog ─────────────────────────────────────────────────── // // Deliberately the same layout and print mechanism as // savings/savings-account-view/passbook-tab.component.ts (bank-header/branch-line/ // account-details-band/balance-summary-strip/statement-table/statement-footer, cloned into a popup // window for window.print()) rather than a generated PDF: a UCB's own passbook screen is the // reference for what a statement should look like, and jsPDF's own layout would inevitably drift // from it. Nothing downloads on open — the statement is only ever shown as a preview here; printing // (and "Save as PDF" from the browser's own print dialog, if a file is wanted) is a deliberate, // separate click. @Component({ selector: 'mifosx-account-statement-dialog', template: `
account_balance
{{ bankName }}
{{ bankSubtitle }}
STATEMENT OF ACCOUNT
{{ fromDate || '—' }} to {{ toDate || '—' }}
@if (branchName || ifscCode || micrCode) {
@if (branchName) { {{ branchName }} Branch } @if (branchAddress) { {{ branchAddress }} } @if (ifscCode) { IFSC: {{ ifscCode }} } @if (micrCode) { MICR: {{ micrCode }} }
}
Current Balance
{{ currencySym }}{{ currentBalance | number: '1.2-2' }}
Available Balance
{{ currencySym }}{{ availableBalance | number: '1.2-2' }}
Total Transactions
{{ filteredRows.length }}
@if (filteredRows.length > 0) { @for (row of filteredRows; track row.id; let i = $index) { }
# Date Description Ref No. Debit (Dr) Credit (Cr) Balance
{{ i + 1 }} {{ row.dateLabel }} {{ row.description }} {{ row.id }} @if (row.debit > 0) { {{ currencySym }}{{ row.debit | number: '1.2-2' }} } @if (row.credit > 0) { {{ currencySym }}{{ row.credit | number: '1.2-2' }} } {{ currencySym }}{{ row.runningBalance | number: '1.2-2' }}
Closing Balance {{ currencySym }}{{ currentBalance | number: '1.2-2' }}
} @else {
inbox

No transactions in this period.

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