/** * 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 } 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 } 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 { 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'; 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; /** 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; } // ── 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; loadingWithdrawal = 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 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 = ''; /** 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()). * Every other account type/mode keeps the full, unfiltered list. */ paymentTypesForRow(row: TxnRow): any[] { if (row.acTyp === 'SHR') { return this.paymentTypes.filter((pt: any) => !pt.isCashPayment); } const isShareSettlementCredit = row.typ === 'Enter Bank Transfer' && row.dbCr === 'Cr' && this.rows.some((r) => r.typ === 'Enter Bank Transfer' && r.dbCr === 'Dr' && r.acTyp === 'SHR'); if (isShareSettlementCredit) { return this.paymentTypes.filter((pt: any) => !pt.isCashPayment); } return this.paymentTypes; } // 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; 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' ]; // ── 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 ?? [])); 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(); 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[]) => { const mapping = mappings && mappings.length ? mappings[0] : null; this.ddGstPercentage = Number(mapping?.gstPercentage ?? 18); this.ddLimitAmount = mapping?.ddLimitAmount != null ? Number(mapping.ddLimitAmount) : null; }); } 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/Bank Transfer: auto-add mirror CR row when active row is DR if ((active?.typ === 'Disbursement' || 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; } const row: TxnRow = { typ: '~', br: 1, trTp: '', acTyp: 'SD', acNo: '', ecsAcNo: '', dbCr: '', chequeNo: '', cheqDate: '', amount: null, description: '', tokNo: '', paymentTypeId: null, 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; case 'Cheque': row.dbCr = 'Dr'; row.paymentTypeId = this.findCashPaymentTypeId(); break; case 'Disbursement': 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; } } 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 + AC.TP=GL — the only combination that collects DD charges + GST from the customer. */ isDdCashGlRow(row: TxnRow): boolean { const pt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId); return row.typ === 'DD' && !!pt?.isCashPayment && row.acTyp === 'GL'; } /** 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; } /** * 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'); 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: cr.acTyp === 'CA' || cr.acTyp === 'CB' ? 'CURRENT' : cr.acTyp === 'GL' ? 'GL' : 'SAVINGS', 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: r.acTyp === 'CA' || r.acTyp === 'CB' ? 'CURRENT' : r.acTyp === 'GL' ? 'GL' : 'SAVINGS', 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) }; 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' : '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: r.acTyp === 'CA' || r.acTyp === 'CB' ? 'CURRENT' : 'SAVINGS', 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 a non-cash Payment Type on the CR row (e.g. Transfer or Share Redemption Transfer).' ]); 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.clerkWithdrawalService .getByToken(token) .pipe( catchError((err: any) => { 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; 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.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 // FULL unfiltered list rather than a server-side-narrowed one, so a partial match can't be // gated on "exactly one row came back" — it never would be for those types. const exactMatch = allItems.find((x: any) => x[f] === acNo); const partialMatch = !exactMatch ? (allItems.find((x: any) => x[f]?.endsWith(acNo)) ?? allItems.find((x: any) => x[f]?.includes(acNo))) : null; const match = exactMatch ?? partialMatch ?? null; if (match?.id) { const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null; this.fetchAllDetails(match.id, match[f] ?? acNo, cfg, token, acTyp, clientIdFromSearch); return; } this.loadingAccount = false; this.accountError = 'Account not found for: ' + acNo; }); } /** Maps UI account type → { listEndpoint, detailEndpoint, nomineeType, searchResource } */ 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) { this.loadingAccount = false; this.accountError = 'Account mismatch. Please re-enter account number and try again.'; return false; } this.accountDetails = acc; this.jointHolders = acc.jointHolders ?? []; // Track per-row account id/name for multi-row disbursement if (this.selectedRow?.acNo) { this.rowAccountIds[this.selectedRow.acNo] = acc.id; this.rowAccountNames[this.selectedRow.acNo] = acc.name ?? acc.displayName ?? 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' }); if (this.kycDetails?.amlCheckStatus === 'FLAGGED') alerts.push({ type: 'danger', message: 'AML Flag — Suspicious Activity' }); 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: 'Withdrawal', active: !!f.withdrawalFacility }, { 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; } 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 }); } // ── 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 if (this.isClerk && this.rows.length >= 2 && this.rows.every((r) => r.typ === 'Enter Bank 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) if (this.isClerk && this.rows.length >= 2 && this.rows.every((r) => r.typ === 'Disbursement')) { 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 title = isCollectionMode ? 'Confirm Collection 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; if (!row || !row.acNo.trim() || !this.accountDetails) 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 const amount = isDdCashGl ? this.ddTotalPayableFor(row) : isCash && !this.clerkMode ? this.denominationTotal : (row.amount ?? this.denominationTotal); if (isCash && this.denominationTotal <= 0 && !this.clerkMode && !isDdCashGl) { 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.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: amount/denomination must exactly match if (!this.isClerk && this.activeWithdrawalRequest && !isDeposit) { const approvedAmt = Number(this.activeWithdrawalRequest.amount); 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'; // 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; } const AML_GL = 200000; if (amount > AML_GL && isCash) { this.showConfirmAlert( 'warning', 'AML Alert — Large Cash Transaction', [ `Transaction Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`, 'This transaction requires manager approval.', 'Have you obtained approval to proceed?' ], 'Yes, Proceed', () => this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly) ); return; } 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; 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; } } // 6. AML large cash const AML_THRESHOLD = 200000; if (amount > AML_THRESHOLD && selectedPt?.isCashPayment) { this.showConfirmAlert( 'warning', 'AML Alert — Large Cash Transaction', [ `Transaction Amount: ₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`, 'This transaction requires manager approval.', 'Have you obtained approval to proceed?' ], 'Yes, Proceed', () => this.openConfirmDialog(row, amount, isDeposit, selectedPt) ); return; } 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; // 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' : '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 ?? 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!; 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 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; } // 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.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: 'SAVINGS', 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: isGl ? 'GL' : row.acTyp === 'CA' || row.acTyp === 'CB' ? 'CURRENT' : 'SAVINGS', 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(); } }