Files
cbsnexora/src/app/cashier/transaction-entry/transaction-entry.component.ts
T
nitesh 01e86173c1
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
feat(shares): close share account directly on withdrawal approval; drop separate closure-request flow
Replaces the auto-submit-closure-request behavior with an immediate
account close, in the same approval action, when a withdrawal brings
shares to zero — matching the backend's new single-step close-on-
approval flow. Removes the now-unneeded Share Closure Request feature
entirely: dedicated create/list components (Clerk Dashboard + Approval
Center), routes, nav links, and the "eligible for closure" banner on
the Share Account view.

Also adds a clear "this will close the account" notice wherever a
withdrawal would zero out the account's shares — the standalone Share
Withdrawal Request page (submit + review) and Transaction Entry's
Share Redemption Transfer confirm dialog — styled as an amber warning
callout rather than a plain info line. Trims that confirm dialog to
just the required details (dropped the redundant ECS A/C No. line and
the validation-checklist block) and compacts its overall size.
2026-08-06 19:27:51 +05:30

3906 lines
146 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, OnDestroy, inject, Input } 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: `
<div class="jhd-wrap">
<div class="jhd-header">
<div class="jhd-header-left">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span>Joint Holder Details</span>
</div>
<button class="jhd-close" (click)="close()">✕</button>
</div>
@if (loading) {
<div class="jhd-loading">Loading details…</div>
} @else {
<div class="jhd-body">
<!-- Identity section -->
<div class="jhd-identity">
<div class="jhd-media-col">
<div class="jhd-media-label">PHOTO</div>
@if (photo) {
<img
[src]="photo"
class="jhd-photo"
alt="Photo"
(click)="previewImg(photo, 'Joint Holder Photo')"
title="Click to enlarge"
/>
} @else {
<div class="jhd-no-media">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.5">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
</svg>
<span>No Photo</span>
</div>
}
</div>
<div class="jhd-media-col">
<div class="jhd-media-label">SIGNATURE</div>
@if (signature) {
<img
[src]="signature"
class="jhd-sig"
alt="Signature"
(click)="previewImg(signature, 'Joint Holder Signature')"
title="Click to enlarge"
/>
} @else {
<div class="jhd-no-media">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.5">
<path d="M20 20H7L3 16l13.5-13.5a2 2 0 0 1 2.8 0L21 4.3a2 2 0 0 1 0 2.8L10.5 17" />
</svg>
<span>No Signature</span>
</div>
}
</div>
<div class="jhd-info-col">
<div class="jhd-name">{{ data.jh.displayName }}</div>
<div class="jhd-badges">
<span class="jhd-role-badge">{{ data.jh.role ?? 'Co-Holder' }}</span>
@if (data.jh.signingAuthority?.toLowerCase() === 'mandatory') {
<span class="jhd-sign-badge">✍ Mandatory Signing</span>
}
</div>
<div class="jhd-fields">
<div class="jhd-row">
<span class="jhd-lbl">CIF No.</span><span class="jhd-val mono">{{ client?.cifNumber ?? '—' }}</span>
</div>
<div class="jhd-row">
<span class="jhd-lbl">Mobile</span><span class="jhd-val">{{ client?.mobileNo ?? '—' }}</span>
</div>
<div class="jhd-row">
<span class="jhd-lbl">Email</span><span class="jhd-val">{{ client?.emailAddress ?? '—' }}</span>
</div>
<div class="jhd-row">
<span class="jhd-lbl">Share %</span><span class="jhd-val">{{ data.jh.sharePercentage }}%</span>
</div>
</div>
</div>
</div>
<!-- KYC section -->
<div class="jhd-kyc-row">
<div class="jhd-kyc-card">
<span class="jhd-kyc-label">KYC Status</span>
<span class="jhd-kyc-val" [style.color]="kyc?.kycStatus === 300 ? '#059669' : '#f59e0b'">
{{ kyc?.kycStatus === 300 ? '✅ Verified' : kyc?.kycStatus === 100 ? '⏳ Pending' : '—' }}
</span>
</div>
<div class="jhd-kyc-card">
<span class="jhd-kyc-label">PAN</span>
<span class="jhd-kyc-val mono">{{ kyc?.panNumber ?? '—' }}</span>
</div>
<div class="jhd-kyc-card">
<span class="jhd-kyc-label">Aadhaar</span>
<span class="jhd-kyc-val mono">{{ kyc?.aadhaarNumberMasked ?? 'XXXX/XXXX/XXXX' }}</span>
</div>
<div class="jhd-kyc-card">
<span class="jhd-kyc-label">CKYC</span>
<span class="jhd-kyc-val mono">{{ kyc?.ckycNumber || '—' }}</span>
</div>
</div>
</div>
}
</div>
`,
styles: [
`
.jhd-wrap {
display: flex;
flex-direction: column;
width: 600px;
max-width: 94vw;
border-radius: 12px;
overflow: hidden;
}
.jhd-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
background: #1e293b;
color: #e2e8f0;
}
.jhd-header-left {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.jhd-close {
background: transparent;
border: none;
color: #94a3b8;
font-size: 18px;
cursor: pointer;
padding: 0 4px;
&:hover {
color: #fff;
}
}
.jhd-loading {
padding: 32px;
text-align: center;
color: #64748b;
font-size: 13px;
background: #f8fafc;
}
.jhd-body {
background: #f8fafc;
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.jhd-identity {
display: grid;
grid-template-columns: 100px 160px 1fr;
gap: 16px;
background: #fff;
border-radius: 10px;
padding: 14px;
border: 1px solid #e2e8f0;
}
.jhd-media-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.jhd-media-label {
font-size: 10px;
font-weight: 700;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.jhd-photo {
width: 80px;
height: 96px;
object-fit: cover;
border-radius: 8px;
border: 1px solid #e2e8f0;
cursor: zoom-in;
transition: transform 0.15s;
&:hover {
transform: scale(1.05);
}
}
.jhd-sig {
width: 140px;
height: 60px;
object-fit: contain;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
cursor: zoom-in;
}
.jhd-no-media {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
color: #94a3b8;
font-size: 11px;
padding: 8px;
}
.jhd-info-col {
display: flex;
flex-direction: column;
gap: 8px;
}
.jhd-name {
font-size: 16px;
font-weight: 700;
color: #1e293b;
}
.jhd-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.jhd-role-badge {
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
background: #dbeafe;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
.jhd-sign-badge {
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
background: #fff7ed;
color: #c2410c;
border: 1px solid #fed7aa;
}
.jhd-fields {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 4px;
}
.jhd-row {
display: flex;
gap: 8px;
font-size: 12px;
border-bottom: 1px dotted #f1f5f9;
padding-bottom: 3px;
}
.jhd-lbl {
color: #64748b;
font-weight: 600;
min-width: 60px;
}
.jhd-val {
color: #1e293b;
font-weight: 500;
}
.jhd-val.mono {
font-family: monospace;
}
.jhd-kyc-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.jhd-kyc-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.jhd-kyc-label {
font-size: 10px;
font-weight: 700;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.jhd-kyc-val {
font-size: 13px;
font-weight: 700;
color: #1e293b;
}
.jhd-kyc-val.mono {
font-family: monospace;
font-size: 11px;
}
`
],
standalone: true,
imports: [CommonModule]
})
export class JointHolderDialogComponent implements OnInit {
dialogRef = inject<MatDialogRef<JointHolderDialogComponent>>(MatDialogRef);
data = inject<{ jh: any; clientId: number }>(MAT_DIALOG_DATA);
private http = inject(HttpClient);
private dialog = inject(MatDialog);
loading = true;
photo: string | null = null;
signature: string | null = null;
kyc: any = null;
client: any = null;
ngOnInit(): void {
const cid = this.data.clientId;
forkJoin({
client: this.http.get<any>(`/clients/${cid}`).pipe(catchError(() => of(null))),
kyc: this.http.get<any>(`/clients/${cid}/kyc`).pipe(catchError(() => of([]))),
photo: this.http.get(`/clients/${cid}/images`, { responseType: 'text' }).pipe(catchError(() => of(null))),
documents: this.http.get<any[]>(`/clients/${cid}/documents`).pipe(catchError(() => of([])))
}).subscribe(({ client, kyc, photo, documents }) => {
this.client = client;
const kycArr: any[] = Array.isArray(kyc) ? kyc : kyc ? [kyc] : [];
this.kyc = kycArr[0] ?? null;
this.photo = photo ?? null;
const docs: any[] = Array.isArray(documents) ? documents : [];
// Multiple 'clientSignature' documents can exist for clients who re-cropped/replaced their
// signature before uploads became single-slot, so pick the highest id (latest) rather than
// the first unordered match.
const sigDoc = docs
.filter((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'))
.sort((a: any, b: any) => b.id - a.id)[0];
this.loading = false;
if (sigDoc) {
this.http
.get(`/clients/${cid}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' })
.pipe(catchError(() => of(null)))
.subscribe((blob: Blob | null) => {
if (blob) this.signature = URL.createObjectURL(blob);
});
}
});
}
previewImg(url: string | null, title: string): void {
if (!url) return;
this.dialog.open(ImagePreviewDialogComponent, {
data: { url, title },
maxWidth: '90vw',
maxHeight: '90vh'
});
}
close(): void {
this.dialogRef.close();
}
}
// ── Image Preview Dialog (declared before use) ────────────────────────────────
@Component({
selector: 'mifosx-image-preview-dialog',
template: `
<div class="ipd-wrap">
<div class="ipd-topbar">
<span class="ipd-title">{{ data.title }}</span>
<button class="ipd-close-btn" (click)="close()" title="Close">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</button>
</div>
<div
class="ipd-body"
(wheel)="onWheel($event)"
(mousedown)="onMouseDown($event)"
(mousemove)="onMouseMove($event)"
(mouseup)="onMouseUp()"
(mouseleave)="onMouseUp()"
[style.cursor]="dragging ? 'grabbing' : zoom > 1 ? 'grab' : 'default'"
>
<img
[src]="safeUrl"
[alt]="data.title"
class="ipd-img"
[style.transform]="imgTransform"
(dragstart)="$event.preventDefault()"
/>
</div>
<div class="ipd-footer">
<div class="ipd-toolbar">
<button class="ipd-tool" (click)="zoomOut()" [disabled]="zoom <= min" title="Zoom out">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.5" />
<path d="M4 6h4M11 11l-2.5-2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
<span class="ipd-zoom-val">{{ zoomPct }}%</span>
<button class="ipd-tool" (click)="zoomIn()" [disabled]="zoom >= max" title="Zoom in">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="6" cy="6" r="5" stroke="currentColor" stroke-width="1.5" />
<path d="M6 4v4M4 6h4M11 11l-2.5-2.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
<div class="ipd-sep"></div>
<button class="ipd-tool" (click)="reset()" title="Reset zoom">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M2 7a5 5 0 1 0 1.2-3.2M2 3.5V7h3.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<span class="ipd-hint">Scroll to zoom &nbsp;·&nbsp; Drag to pan</span>
</div>
</div>
`,
styles: [
`
.ipd-wrap {
display: flex;
flex-direction: column;
width: min(88vw, 840px);
border-radius: 20px;
overflow: hidden;
background: #07080f;
box-shadow:
0 40px 100px rgba(0, 0, 0, 0.75),
0 0 0 1px rgba(255, 255, 255, 0.06);
}
/* ── Top bar ─────────────────────────── */
.ipd-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.ipd-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.55);
}
.ipd-close-btn {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
padding: 0;
}
.ipd-close-btn:hover {
background: rgba(239, 68, 68, 0.18);
border-color: rgba(239, 68, 68, 0.45);
color: #fca5a5;
}
/* ── Image area ──────────────────────── */
.ipd-body {
background: #040508;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
height: 68vh;
user-select: none;
position: relative;
}
.ipd-img {
max-width: 82%;
max-height: 64vh;
object-fit: contain;
border-radius: 10px;
transform-origin: center center;
transition: transform 0.07s ease;
pointer-events: none;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.55);
}
/* ── Footer ──────────────────────────── */
.ipd-footer {
background: rgba(255, 255, 255, 0.02);
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 12px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
/* ── Toolbar pill ────────────────────── */
.ipd-toolbar {
display: flex;
align-items: center;
gap: 2px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 50px;
padding: 4px 6px;
}
.ipd-tool {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
padding: 0;
flex-shrink: 0;
}
.ipd-tool:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.22);
color: #a5b4fc;
}
.ipd-tool:disabled {
opacity: 0.2;
cursor: not-allowed;
}
.ipd-zoom-val {
font-size: 12px;
font-weight: 700;
color: rgba(255, 255, 255, 0.85);
min-width: 44px;
text-align: center;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
padding: 0 2px;
}
.ipd-sep {
width: 1px;
height: 18px;
background: rgba(255, 255, 255, 0.1);
margin: 0 4px;
flex-shrink: 0;
}
/* ── Hint ────────────────────────────── */
.ipd-hint {
font-size: 10.5px;
color: rgba(255, 255, 255, 0.2);
letter-spacing: 0.03em;
}
`
],
standalone: true,
imports: [CommonModule]
})
export class ImagePreviewDialogComponent {
dialogRef = inject<MatDialogRef<ImagePreviewDialogComponent>>(MatDialogRef);
data = inject<{ url: string; title: string }>(MAT_DIALOG_DATA);
private sanitizer = inject(DomSanitizer);
get safeUrl(): SafeResourceUrl {
return this.sanitizer.bypassSecurityTrustResourceUrl(this.data.url);
}
zoom = 1;
tx = 0;
ty = 0;
dragging = false;
private lx = 0;
private ly = 0;
readonly step = 0.25;
readonly min = 0.25;
readonly max = 6;
get zoomPct(): number {
return Math.round(this.zoom * 100);
}
get imgTransform(): string {
return `scale(${this.zoom}) translate(${this.tx}px,${this.ty}px)`;
}
zoomIn(): void {
this.zoom = Math.min(this.max, +(this.zoom + this.step).toFixed(2));
}
zoomOut(): void {
this.zoom = Math.max(this.min, +(this.zoom - this.step).toFixed(2));
this.clamp();
}
reset(): void {
this.zoom = 1;
this.tx = 0;
this.ty = 0;
}
onWheel(e: WheelEvent): void {
e.preventDefault();
const d = e.deltaY < 0 ? this.step : -this.step;
this.zoom = Math.min(this.max, Math.max(this.min, +(this.zoom + d).toFixed(2)));
if (this.zoom === this.min) {
this.tx = 0;
this.ty = 0;
}
}
onMouseDown(e: MouseEvent): void {
if (this.zoom <= 1) return;
this.dragging = true;
this.lx = e.clientX;
this.ly = e.clientY;
e.preventDefault();
}
onMouseMove(e: MouseEvent): void {
if (!this.dragging) return;
this.tx += (e.clientX - this.lx) / this.zoom;
this.ty += (e.clientY - this.ly) / this.zoom;
this.lx = e.clientX;
this.ly = e.clientY;
this.clamp();
}
onMouseUp(): void {
this.dragging = false;
}
private clamp(): void {
const m = Math.max(0, (this.zoom - 1) * 350);
this.tx = Math.max(-m, Math.min(m, this.tx));
this.ty = Math.max(-m, Math.min(m, this.ty));
}
close(): void {
this.dialogRef.close();
}
}
// ─────────────────────────────────────────────────────────────────────────────
@Component({
selector: 'mifosx-transaction-entry',
templateUrl: './transaction-entry.component.html',
styleUrls: ['./transaction-entry.component.scss'],
standalone: true,
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatIconModule
]
})
export class TransactionEntryComponent implements OnInit, OnDestroy {
@Input() clerkMode = false;
/** When set, the component runs in manager-review mode for that withdrawal request id. */
@Input() reviewRequestId: number | null = null;
/** When set, the clerk edits & resubmits that (rejected) withdrawal request id. */
@Input() editRequestId: number | null = null;
// Manager review state
reviewRequest: any = null;
showRejectDialog = false;
rejectionReason = '';
// Clerk edit (rejected resubmit) state
editRequest: any = null;
// Active approved withdrawal (cashier disburse flow)
activeWithdrawalRequest: any = null;
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<string, number> = {};
rowAccountNames: Record<string, string> = {};
// Per-row Share account market price, keyed by acNo — lets a Share row's "SHARE NO." field
// auto-fill the settlement amount on any other row added alongside it (calculator convenience
// only; a Share row still can never itself be saved from this screen — see accountTypeConfig).
rowSharePrices: Record<string, number> = {};
// Per-row client id, keyed by acNo — needed to submit a Share Withdrawal Request from a row
// that isn't necessarily the currently "selected" one.
rowClientIds: Record<string, number> = {};
// Per-row linked savings account number, keyed by the Share account's own acNo — used to make
// sure a Share-to-linked-account Bank Transfer actually credits the right account.
rowLinkedSavingsAcNo: Record<string, string> = {};
// Per-row shares currently held, keyed by acNo — lets submitShareRedemptionTransfer() warn the
// clerk when a withdrawal will bring the account's holding to zero (closing it on approval).
rowShareHoldings: Record<string, number> = {};
// Full account + client data
accountDetails: any = null;
clientDetails: any = null;
kycDetails: any = null;
nominees: any[] = [];
jointHolders: any[] = [];
clientAddresses: any[] = [];
nonPersonDetails: any = null;
// Extended cashier data
clientPhotoUrl: string | null = null;
signatureUrl: string | null = null;
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<string, number> = {};
// ── Opening / Closing position ────────────────────────────────────────────
openingDenoms: any[] = [];
openingTotal = 0;
openingLoaded = false;
// ── Cashier day status ────────────────────────────────────────────────────
previousDayUnsettled = false;
unsettledSince: string | null = null;
morningTransferReceived = true;
dayStatusLoaded = false;
// ── Transaction saving state ──────────────────────────────────────────────
saving = false;
ngOnInit(): void {
const d = 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<string, number>();
for (const d of this.openingDenoms) {
openMap.set(`${d.kind}:${d.denominationValue}`, Number(d.count ?? 0));
}
const overdrafts: { label: string; requested: number; available: number; shortfall: number }[] = [];
for (const d of [
...this.notes,
...this.coins
]) {
const requested = this.countFor(d);
if (requested <= 0) continue;
const available = openMap.get(this.denomKey(d)) ?? 0;
if (requested > available) {
overdrafts.push({ label: d.label, requested, available, shortfall: requested - available });
}
}
return overdrafts;
}
get hasWithdrawalDenomOverdraft(): boolean {
return this.denominationOverdrafts.length > 0;
}
get closingDenoms(): any[] {
const openMap = new Map<string, any>();
for (const d of this.openingDenoms) {
openMap.set(`${d.kind}:${d.denominationValue}`, d);
}
const sign = this.selectedRow?.dbCr === 'Cr' ? 1 : -1;
const result: any[] = [];
for (const denom of [
...this.notes,
...this.coins
]) {
const key = this.denomKey(denom);
const open = openMap.get(key);
const openCount = open?.count ?? 0;
const entered = this.countFor(denom);
if (openCount === 0 && entered === 0) continue;
result.push({
denominationValue: denom.value,
kind: denom.kind,
openCount,
entered,
closingCount: openCount + entered * sign
});
}
return result.sort((a, b) => b.denominationValue - a.denominationValue);
}
applyDenominationTotal(): void {
// DD + Cash + GL: amount is derived from Face Value + Charges + GST, not from counted cash —
// overwriting it here would feed back into the Charges/GST sum and compound on every click.
if (this.selectedRow && !this.isDdCashGlRow(this.selectedRow)) {
this.selectedRow.amount = this.denominationTotal;
}
}
clearDenominations(): void {
this.denominationCounts = {};
}
addRow(): void {
const active = this.selectedRow;
// Disbursement/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<string, unknown>,
shareRow: TxnRow,
shares: number,
linkedAcNo: string
): void {
this.saving = true;
this.clerkShareWithdrawalService
.submit(payload)
.pipe(
catchError((err: any) => {
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ??
err?.error?.developerMessage ??
err?.error?.defaultUserMessage ??
'Share withdrawal submission failed. Please try again.';
this.showAlert('error', 'Submission Failed', [msg]);
this.saving = false;
return of(null);
})
)
.subscribe((res: any) => {
this.saving = false;
if (res) {
const willCloseAccount = (this.rowShareHoldings[shareRow.acNo] ?? 0) - shares <= 0;
this.showAlert('success', 'Share Withdrawal Submitted', [
`${shares} share(s) from ${shareRow.acNo} → settlement to ${linkedAcNo} on approval.`,
willCloseAccount
? 'Pending Manager approval — this will close the share account, since all shares will be withdrawn. Track status in "My Share Requests".'
: 'Pending Manager approval. Track status in "My Share Requests".'
]);
this.rows = [];
this.rowAccountIds = {};
this.rowAccountNames = {};
this.rowSharePrices = {};
this.rowClientIds = {};
this.rowLinkedSavingsAcNo = {};
this.rowShareHoldings = {};
this.addRow();
this.selectedRow = this.rows[0];
this.accountDetails = null;
this.clientDetails = null;
}
});
}
/** Cashier enters a token number → look up the APPROVED withdrawal request for auto-fill. */
onTokNoBlur(row: TxnRow): void {
if (this.isClerk || !row.tokNo?.trim()) return;
const token = row.tokNo.trim();
this.loadingWithdrawal = true;
this.activeWithdrawalRequest = null;
this.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<string, string> = {
SAVINGS: 'SD',
CURRENT: 'CA',
FIXED_DEPOSIT: 'FD',
RECURRING_DEPOSIT: 'RD'
};
openCustomerSearch(): void {
const dialogRef = this.dialog.open(CustomerSearchDialogComponent, {
width: '960px',
maxWidth: '95vw'
});
dialogRef.afterClosed().subscribe((result: CustomerSearchResult | undefined) => {
if (!result || !result.accountNo) return;
// selectedRow is only set once the clerk types into A/C NO. themselves - on a fresh screen
// it's still null, so fall back to the first (only) row that addRow() created at startup.
const targetRow = this.selectedRow ?? this.rows[0];
if (!targetRow) return;
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[result.accountType || ''] || 'SD';
targetRow.acTyp = acTyp;
targetRow.acNo = result.accountNo;
this.selectedRow = targetRow;
this.loadAccountDetails(result.accountNo, acTyp);
});
}
private loadAccountDetails(acNo: string, acTyp: string): void {
this.lastLoadedAcNo = acNo;
this.loadingAccount = true;
this.accountDetails = null;
this.clientDetails = null;
this.kycDetails = null;
this.nominees = [];
this.jointHolders = [];
this.clientAddresses = [];
this.clientPhotoUrl = null;
if (this.signatureUrl?.startsWith('blob:')) URL.revokeObjectURL(this.signatureUrl);
this.signatureUrl = null;
this.accountNotes = [];
this.standingInstructions = [];
this.todayTransactions = [];
this.linkedAccounts = null;
this.sameDayNeftRtgs = 0;
this.sameDayChequeClearing = 0;
this.accountError = '';
const token = ++this.searchToken; // Every new search gets a unique token
const cfg = this.accountTypeConfig(acTyp);
// Step 1: search on the correct endpoint for this account type
this.http
.get<any>(
`${cfg.list}?${cfg.listQueryParam}=${encodeURIComponent(acNo)}${cfg.supportsPagination ? `&offset=0&limit=${cfg.maxLimit}` : ''}`
)
.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 <empty>" 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<any>(`/clients/${clientId}?associations=clientNonPersonDetails`)
.pipe(catchError(() => of(null))),
kyc: this.http.get<any>(`/clients/${clientId}/kyc`).pipe(catchError(() => of([]))),
nominees: this.http
.get<any>(`/clients/${clientId}/nominees?accountNo=${acNo}&accountType=${cfg.nominee}`)
.pipe(catchError(() => of([]))),
addresses: this.http.get<any>(`/client/${clientId}/addresses`).pipe(catchError(() => of([]))),
family: this.http.get<any>(`/clients/${clientId}/familymembers`).pipe(catchError(() => of([]))),
photo: this.http.get(`/clients/${clientId}/images`, { responseType: 'text' }).pipe(catchError(() => of(null))),
documents: this.http.get<any[]>(`/clients/${clientId}/documents`).pipe(catchError(() => of([]))),
notes: this.http.get<any>(`/savings/${accountId}/notes`).pipe(catchError(() => of([]))),
linkedAcc: this.http.get<any>(`/clients/${clientId}/accounts`).pipe(catchError(() => of(null))),
si: cfg.siFetchUnsupported
? of({ pageItems: [] })
: this.http
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
.pipe(catchError(() => of({ pageItems: [] }))),
todayTxns: cfg.txnFetchUnsupported
? of({ pageItems: [] })
: cfg.txnSearchSupported
? this.http
.get<any>(
`${cfg.txnBase}/${accountId}/transactions/search?fromDate=${todayStr}&toDate=${todayStr}&limit=20`
)
.pipe(catchError(() => of({ pageItems: [] })))
: this.http
.get<any>(`${cfg.txnBase}/${accountId}/transactions?limit=50`)
.pipe(catchError(() => of({ pageItems: [] })))
});
if (clientIdHint) {
// FAST PATH: account detail + all client calls fire simultaneously (1 round-trip saved)
forkJoin({
account: this.http.get<any>(accountUrl).pipe(catchError(() => of(null))),
...buildClientCalls(clientIdHint)
}).subscribe((result: any) => {
if (token !== this.searchToken) return;
const { account, ...clientData } = result;
if (!applyAccountData(account)) return;
applyClientData(clientIdHint, clientData);
});
return;
}
// SEQUENTIAL PATH: for GL accounts (no clientId in search result)
this.http
.get<any>(accountUrl)
.pipe(catchError(() => of(null)))
.subscribe((acc: any) => {
if (token !== this.searchToken) return;
if (!applyAccountData(acc)) return;
const clientId = acc.clientId?.toString();
if (!clientId) {
this.loadingAccount = false;
return;
}
forkJoin(buildClientCalls(clientId)).subscribe((r: any) => {
applyClientData(clientId, r);
});
});
}
// ── Alert getters ─────────────────────────────────────────────────────────
get accountAlerts(): { type: 'danger' | 'warning' | 'info'; message: string }[] {
const alerts: { type: 'danger' | 'warning' | 'info'; message: string }[] = [];
const acc = this.accountDetails;
if (!acc) return alerts;
// Block reason
if (acc.reasonForBlock) alerts.push({ type: 'danger', message: `Account Blocked: ${acc.reasonForBlock}` });
// Dormancy
const sub = acc.subStatus?.value ?? '';
if (sub === 'Dormant') alerts.push({ type: 'danger', message: 'Account is DORMANT — restricted transactions' });
if (sub === 'Inactive') alerts.push({ type: 'warning', message: 'Account is INACTIVE' });
if (sub === 'Escheat') alerts.push({ type: 'danger', message: 'Account is ESCHEAT — refer to manager' });
// Lien
const lienAmt = Number(acc.summary?.totalSavingsAmountOnHold ?? acc.onHoldFundsDerived ?? 0);
if (lienAmt > 0)
alerts.push({
type: 'warning',
message: `Lien / Hold: ₹${lienAmt.toLocaleString('en-IN', { minimumFractionDigits: 2 })}`
});
// TDS
if (acc.withHoldTax)
alerts.push({ type: 'info', message: `TDS Applicable — Tax Group: ${acc.taxGroupData?.name ?? ''}` });
// Sanctions (from KYC) — DB stores 'YES'/'NO' strings, not booleans
const isYes = (v: any) => v === true || v === 'YES' || v === 'yes' || v === 'Y';
if (isYes(this.kycDetails?.bannedEntityMatch))
alerts.push({ type: 'danger', message: 'Sanctions Match — DO NOT process' });
if (isYes(this.kycDetails?.willfulDefaulter))
alerts.push({ type: 'danger', message: 'Willful Defaulter — Refer to Manager' });
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<string, string> = {
'ATM Facility': 'ATM Facility',
'ATM Withdrawal': 'ATM Withdrawal',
'UPI Facility': 'UPI Facility',
'POS Transaction': 'POS Transactions',
'Cheque Book': 'Cheque Book',
'IMPS Facility': 'IMPS Facility',
'SMS Alert': 'SMS Alerts',
'BBPS Facility': 'BBPS Facility',
'Insurance Cover': 'Insurance Cover',
'BEPG Facility': 'BEPG Facility',
'NEFT Facility': 'NEFT Facility',
'RTGS Facility': 'RTGS Facility',
'Inter-Bank Cap': 'Inter-Bank Cap',
'Withdrawal Facility': 'Withdrawal',
'Deposit KYC': 'Deposit (KYC)'
};
const facilityItems = charges
.filter((c: any) => c.isActive)
.map((c: any) => {
const key = Object.keys(chargeFacilityMap).find((k) => c.name?.includes(k));
return key ? { label: chargeFacilityMap[key], active: true } : null;
})
.filter((x): x is { label: string; active: boolean } => x !== null);
// Remove duplicates, add OD separately
const seen = new Set<string>();
const unique = facilityItems.filter((i) => (seen.has(i.label) ? false : !!seen.add(i.label)));
unique.push({ label: 'OD', active: !!this.accountDetails?.allowOverdraft });
return unique;
}
// Fallback: show OD only
return [{ label: 'OD', active: !!this.accountDetails?.allowOverdraft }];
}
get todayCashTotal(): number {
return this.todayTransactions
.filter((t: any) => !t.reversed)
.reduce((s: number, t: any) => {
const amt = Number(t.amount ?? 0);
return s + (t.transactionType?.deposit ? amt : -amt);
}, 0);
}
// ── Balance getters ────────────────────────────────────────────────────────
get currentBalance(): number {
if (this.selectedRow?.acTyp === 'SHR') {
const shares = Number(this.accountDetails?.summary?.totalApprovedShares ?? 0);
const unitPrice = Number(this.accountDetails?.currentMarketPrice ?? 0);
return shares * unitPrice;
}
return this.accountDetails?.summary?.accountBalance ?? this.accountDetails?.organizationRunningBalance ?? 0;
}
get clearBalance(): number {
return this.accountDetails?.summary?.availableBalance ?? this.currentBalance;
}
get todayClgAmount(): number {
return this.accountDetails?.summary?.totalDepositsPostMaturityLevel ?? 0;
}
get freezeAmount(): number {
// Savings exposes the lien/user hold as root-level `savingsAmountOnHold`; Current exposes its
// own (differently-sourced) hold as `onHoldFunds`. `summary.holdAmountBalance` never existed in
// either API response, so this always read 0 before.
const acTyp = this.selectedRow?.acTyp;
const isCurrent = acTyp === 'CA' || acTyp === 'CB';
if (isCurrent) {
return this.accountDetails?.onHoldFunds ?? this.accountDetails?.summary?.onHoldFunds ?? 0;
}
return this.accountDetails?.savingsAmountOnHold ?? 0;
}
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<string, string> = {
FORMER_OR_SURVIVOR: 'Former or Survivor',
EITHER_OR_SURVIVOR: 'Either or Survivor',
ANYONE_OR_SURVIVOR: 'Any One or Survivor',
JOINTLY: 'Both Jointly',
SINGLE: 'Single',
OTHER: 'Other'
};
const raw = this.accountDetails?.modeOfOperation;
const mode = (typeof raw === 'object' ? raw?.value : raw) ?? '';
return modeMap[mode] ?? mode.replace(/_/g, ' ');
}
// ── Client detail getters ─────────────────────────────────────────────────
get clientName(): string {
return this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? '—';
}
get guardianName(): string {
// S/D/W/H/O derived from family members
// API returns: firstName, middleName, lastName, relationship
const family: any[] = this.clientDetails?.familyMembers ?? [];
if (!family.length) return this.clientDetails?.guardianClientName ?? '—';
const priority = [
'FATHER',
'MOTHER',
'HUSBAND',
'WIFE',
'SPOUSE'
];
const prefixMap: Record<string, string> = {
FATHER: 'S/O',
MOTHER: 'D/O',
HUSBAND: 'W/O',
WIFE: 'H/O',
SPOUSE: 'W/O'
};
for (const rel of priority) {
const member = family.find((m: any) => (m.relationship ?? m.relationshipName ?? '').toUpperCase() === rel);
if (member) {
// API uses camelCase: firstName, middleName, lastName
const name = [
member.firstName,
member.middleName,
member.lastName
]
.filter((v) => v && v.trim())
.join(' ')
.trim();
return name ? `${prefixMap[rel] ?? rel} ${name}` : (prefixMap[rel] ?? rel);
}
}
// Fallback: first family member
const m = family[0];
const name = [
m.firstName,
m.middleName,
m.lastName
]
.filter((v) => v && v.trim())
.join(' ')
.trim();
return name || '—';
}
get clientAddress(): string {
// From /client/{id}/addresses — AddressData fields: addressLine1, townVillage, city, stateName, postalCode
if (this.clientAddresses.length > 0) {
const a = this.clientAddresses[0];
return [
a.addressLine1,
a.addressLine2,
a.addressLine3,
a.townVillage,
a.city,
a.countyDistrict,
a.stateName ?? a.stateProvinceLabel,
a.postalCode
]
.filter((v) => v && v.toString().trim())
.join(', ');
}
return '—';
}
get clientPhone(): string {
const mob = this.clientDetails?.mobileNo ?? this.accountDetails?.clientMobileNo;
const alt = this.clientDetails?.alternateMobileNo;
return (
[
mob,
alt
]
.filter(Boolean)
.join(' / ') || '—'
);
}
get clientEmail(): string {
return this.clientDetails?.emailAddress ?? '—';
}
get officeName(): string {
return this.accountDetails?.officeName ?? this.clientDetails?.officeName ?? '—';
}
get openingDate(): string {
const raw =
this.accountDetails?.timeline?.submittedOnDate ??
this.accountDetails?.submittedOnDate ??
this.accountDetails?.activatedOnDate;
if (!raw) return '—';
// Array format [yyyy, mm, dd] — savings/FD/RD
if (Array.isArray(raw) && raw.length >= 3) {
return `${String(raw[2]).padStart(2, '0')}/${String(raw[1]).padStart(2, '0')}/${raw[0]}`;
}
if (typeof raw === 'string') {
// ISO format 'yyyy-MM-dd'
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
const [
y,
m,
d
] = raw.split('-');
return `${d}/${m}/${y}`;
}
// Locale string 'Jun 5, 2026' — current account
try {
const d = new Date(raw);
if (!isNaN(d.getTime())) {
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
}
} catch {
/* ignore */
}
}
return String(raw);
}
get lastTransactionDate(): string {
const d = this.accountDetails?.lastActiveTransactionDate;
return d ? `${d[2]}/${d[1]}/${d[0]}` : '—';
}
get panNumber(): string {
return this.kycDetails?.panNumber ?? this.kycDetails?.pan_number ?? '—';
}
get aadhaarNumber(): string {
return this.kycDetails?.aadhaarNumberMasked ?? this.kycDetails?.aadhaar_number_masked ?? 'XXXX/XXXX/XXXX';
}
get ckycNumber(): string {
return this.kycDetails?.ckycNumber ?? this.kycDetails?.ckyc_number ?? '—';
}
get kycStatus(): string {
const s = this.kycDetails?.kycStatus ?? this.kycDetails?.kyc_status;
if (!s) return '—';
const map: Record<number, string> = { 100: 'Pending', 200: 'In Progress', 300: 'Verified', 400: 'Rejected' };
return map[s] ?? `KYC-${s}`;
}
get cifNumber(): string {
return this.clientDetails?.cifNumber ?? this.clientDetails?.cif_number ?? '—';
}
// ── Joint holder click-to-expand ──────────────────────────────────────────
toggleHolder(index: number, jh: any): void {
const clientId = jh.clientId ?? jh.client_id;
this.dialog.open(JointHolderDialogComponent, {
data: { jh, clientId },
width: '620px',
maxWidth: '95vw',
panelClass: 'jhd-panel',
autoFocus: false
});
}
// ── 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();
}
}