feat(cashier): parallelize account fetch, add CA maker-checker deposit and withdrawal routing

- Optimize loadAccountDetails: extract clientId from search result and pass as hint
- fetchAllDetails fast path: parallel forkJoin for account+client calls when clientId known
- openConfirmDialog/confirmSubmit: extend deposit maker-checker to CA and CB account types
- buildDepositPayload: send accountType CURRENT for CA/CB accounts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-15 12:51:21 +05:30
co-authored by Claude Sonnet 4.6
parent e5d6cd32dc
commit 8e64f82c0a
@@ -1193,7 +1193,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
: null;
const match = exactMatch ?? partialMatch ?? null;
if (match?.id) {
this.fetchAllDetails(match.id, match[f] ?? acNo, cfg, token, acTyp);
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;
@@ -1359,142 +1360,143 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
detailParams?: string;
},
token: number,
acTyp: string = ''
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 ?? [];
if (this.selectedRow) {
this.selectedRow.ecsAcNo = acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '';
}
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
this.computeSameDayNeftRtgs(acc.transactions);
this.computeSameDayChequeClearing(acc.transactions);
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 : [];
const sigDoc = docs.find((d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature'));
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: this.http
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
.pipe(catchError(() => of({ pageItems: [] }))),
todayTxns: 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>(`${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`)
.get<any>(accountUrl)
.pipe(catchError(() => of(null)))
.subscribe((acc: any) => {
if (token !== this.searchToken) return; // stale response — newer search active
if (!acc) {
this.loadingAccount = false;
this.accountError = 'Could not load account details.';
return;
}
// Cross-check: returned identifier must match what cashier searched
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;
}
this.accountDetails = acc;
this.jointHolders = acc.jointHolders ?? [];
// Auto-fill ECS A/C NO.: GL → glCode, SHR → savingsAccountNumber, others → accountNo
if (this.selectedRow) {
this.selectedRow.ecsAcNo = acc.glCode ?? acc.savingsAccountNumber ?? acc.accountNo ?? '';
}
if (this.isClerk) this.clerkUiState.setFacilities(this.facilities, acNo, acTyp);
// Compute same-day compliance indicators from included transactions
this.computeSameDayNeftRtgs(acc.transactions);
this.computeSameDayChequeClearing(acc.transactions);
if (token !== this.searchToken) return;
if (!applyAccountData(acc)) return;
const clientId = acc.clientId?.toString();
if (!clientId) {
this.loadingAccount = false;
return;
}
const acType = cfg.nominee;
// Today's date for transaction filter
const todayStr = (() => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
})();
// Parallel: all cashier-relevant data
forkJoin({
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=${acType}`)
.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: this.http
.get<any>(`/standinginstructions?fromAccountType=${cfg.siAccountType}&fromAccountId=${accountId}&limit=10`)
.pipe(catchError(() => of({ pageItems: [] }))),
nonPerson: of(null), // loaded from client associations
todayTxns: 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: [] })))
}).subscribe(
({
client,
kyc,
nominees,
addresses,
family,
photo,
documents,
notes,
linkedAcc,
si,
nonPerson,
todayTxns
}) => {
this.loadingAccount = false;
this.clientDetails = { ...client, familyMembers: Array.isArray(family) ? family : [] };
const kycArr: any[] = Array.isArray(kyc) ? kyc : kyc ? [kyc] : [];
this.kycDetails = kycArr[0] ?? null;
this.nominees = Array.isArray(nominees) ? nominees : [];
this.nonPersonDetails = client?.clientNonPersonDetails ?? null;
this.clientAddresses = Array.isArray(addresses) ? addresses : [];
// Photo
this.clientPhotoUrl = photo ?? null;
// Signature — fetch as blob via HttpClient (which adds auth headers)
const docs: any[] = Array.isArray(documents) ? documents : [];
const sigDoc = docs.find(
(d: any) => d.name === 'clientSignature' || d.name?.toLowerCase().includes('signature')
);
if (sigDoc) {
this.http
.get(`/clients/${clientId}/documents/${sigDoc.id}/attachment`, { responseType: 'blob' })
.pipe(catchError(() => of(null)))
.subscribe((blob: Blob | null) => {
if (blob) {
const url = URL.createObjectURL(blob);
this.signatureUrl = url;
// Signature arrives after the main load — refresh shared state so embedded views get it
this.persistClerkState();
}
});
}
// Notes/alerts — /savings/{id}/notes returns array directly
this.accountNotes = Array.isArray(notes) ? notes : [];
// Linked accounts
this.linkedAccounts = linkedAcc;
// Standing Instructions
this.standingInstructions = si?.pageItems ?? (Array.isArray(si) ? si : []);
// Today's transactions — savings uses "content", CA uses "pageItems"
this.todayTransactions =
todayTxns?.pageItems ?? todayTxns?.content ?? (Array.isArray(todayTxns) ? todayTxns : []);
// Push full state so embedded facility/photo/SI/activity views (e.g. manager review) can read it
this.persistClerkState();
}
);
forkJoin(buildClientCalls(clientId)).subscribe((r: any) => {
applyClientData(clientId, r);
});
});
}
@@ -2279,8 +2281,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
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 savings deposit goes via maker-checker: balance credits only after manager approval
const isDepositRequest = isDeposit && !this.isClerk && row.acTyp === 'SD';
// Cashier deposit (savings/current) goes via maker-checker: balance credits only after manager approval
const isDepositRequest =
isDeposit && !this.isClerk && (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB');
const balanceAfterTxn =
isDisbursement || isDepositRequest
? this.currentBalance // no immediate balance change
@@ -2332,9 +2335,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Cashier savings deposit → route through maker-checker (create PENDING request, no direct credit)
// Cashier deposit (savings/current) → 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') {
if (
!this.isClerk &&
!this.reviewMode &&
isDeposit &&
(row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB')
) {
this.submitCashierDepositRequest(row, this.confirmData?.amount ?? amount);
return;
}
@@ -2694,7 +2702,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return {
accountNo: this.accountDetails.accountNo,
accountType: 'SAVINGS',
accountType: row.acTyp === 'CA' || row.acTyp === 'CB' ? 'CURRENT' : 'SAVINGS',
savingsAccountId: this.accountDetails.id,
clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null,
clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null,