feat(cashier): add GL denomination tracking, real-time balance, and enhanced closing report

- Save denomination breakdown for GL journal entries via new /cashier/gl-denomination endpoint
- Add payment type, date/time, teller name columns to GL Entries closing report
- Show GL badge for GL vouchers vs numeric ID for savings transactions
- Add payment type required and denomination overdraft validations for GL transactions
- Fix GL account number display in confirm dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-10 15:29:50 +05:30
co-authored by Claude Sonnet 4.6
parent 010adcb44f
commit 05cbe21542
4 changed files with 339 additions and 30 deletions
@@ -660,6 +660,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Search token — cancels stale async results
private searchToken = 0;
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;
@@ -767,6 +768,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
});
this.loadCashVaultGl();
if (this.isClerk) {
const saved = this.clerkUiState.snapshot.savedAccount;
if (saved) {
@@ -1041,10 +1044,27 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
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;
// Only reload if account number changed or nothing is loaded yet
const alreadyLoaded = this.accountDetails && this.accountDetails.accountNo === row.acNo.trim();
const loadedId = this.accountDetails?.glCode ?? this.accountDetails?.accountNo;
const alreadyLoaded = this.accountDetails && loadedId === row.acNo.trim();
if (row.acNo.trim() && !alreadyLoaded) {
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
@@ -1072,22 +1092,25 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const token = ++this.searchToken; // Every new search gets a unique token
const cfg = this.accountTypeConfig(acTyp);
// Step 1: search by accountNo on the correct endpoint for this account type
// Step 1: search on the correct endpoint for this account type
this.http
.get<any>(`${cfg.list}?accountNo=${encodeURIComponent(acNo)}&offset=0&limit=${cfg.maxLimit}`)
.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 (backend may return by last-N-digits)
const exactMatch = allItems.find((x: any) => x.accountNo === acNo);
const exactMatch = allItems.find((x: any) => x[f] === acNo);
const partialMatch =
!exactMatch && allItems.length === 1
? allItems.find((x: any) => x.accountNo?.endsWith(acNo) || x.accountNo?.includes(acNo))
? allItems.find((x: any) => x[f]?.endsWith(acNo) || x[f]?.includes(acNo))
: null;
const match = exactMatch ?? partialMatch ?? null;
if (match?.id) {
this.fetchAllDetails(match.id, match.accountNo, cfg, token, acTyp);
this.fetchAllDetails(match.id, match[f] ?? acNo, cfg, token, acTyp);
return;
}
this.loadingAccount = false;
@@ -1106,6 +1129,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
txnSearchSupported: boolean;
maxLimit: number;
skipGlobalSearch: boolean;
listQueryParam: string;
matchField: string;
detailParams: string;
supportsPagination: boolean;
} {
const t = acTyp?.toUpperCase();
if (t === 'LA') {
@@ -1118,12 +1145,14 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
txnBase: '/loans',
txnSearchSupported: true,
maxLimit: 1,
skipGlobalSearch: true
skipGlobalSearch: true,
listQueryParam: 'accountNo',
matchField: 'accountNo',
detailParams: '?associations=all',
supportsPagination: true
};
}
if (t === 'SHR') {
// Backend ignores accountNo filter on list — fetch all and match client-side
// Global /search?resource=share returns 500 — skip it
return {
list: '/accounts/share',
detail: '/accounts/share',
@@ -1133,10 +1162,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
txnBase: '/accounts/share',
txnSearchSupported: false,
maxLimit: 500,
skipGlobalSearch: true
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',
@@ -1146,7 +1180,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
txnBase: '/glaccounts',
txnSearchSupported: false,
maxLimit: 500,
skipGlobalSearch: true
skipGlobalSearch: true,
listQueryParam: 'searchParam',
matchField: 'glCode',
detailParams: '?fetchRunningBalance=true',
supportsPagination: false
};
}
if (t === 'CA' || t === 'CB') {
@@ -1159,7 +1197,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
txnBase: '/currentaccounts',
txnSearchSupported: true,
maxLimit: 1,
skipGlobalSearch: true
skipGlobalSearch: true,
listQueryParam: 'accountNo',
matchField: 'accountNo',
detailParams: '?associations=all',
supportsPagination: true
};
}
if (t === 'FD') {
@@ -1172,7 +1214,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
txnBase: '/savingsaccounts',
txnSearchSupported: true,
maxLimit: 1,
skipGlobalSearch: true
skipGlobalSearch: true,
listQueryParam: 'accountNo',
matchField: 'accountNo',
detailParams: '?associations=all',
supportsPagination: true
};
}
if (t === 'RD') {
@@ -1185,7 +1231,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
txnBase: '/savingsaccounts',
txnSearchSupported: true,
maxLimit: 1,
skipGlobalSearch: true
skipGlobalSearch: true,
listQueryParam: 'accountNo',
matchField: 'accountNo',
detailParams: '?associations=all',
supportsPagination: true
};
}
// SD — savings
@@ -1198,7 +1248,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
txnBase: '/savingsaccounts',
txnSearchSupported: true,
maxLimit: 1,
skipGlobalSearch: true
skipGlobalSearch: true,
listQueryParam: 'accountNo',
matchField: 'accountNo',
detailParams: '?associations=all',
supportsPagination: true
};
}
@@ -1219,12 +1273,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
siAccountType: number;
txnBase: string;
txnSearchSupported: boolean;
detailParams?: string;
},
token: number,
acTyp: string = ''
): void {
this.http
.get<any>(`${cfg.detail}/${accountId}?associations=all`)
.get<any>(`${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`)
.pipe(catchError(() => of(null)))
.subscribe((acc: any) => {
if (token !== this.searchToken) return; // stale response — newer search active
@@ -1233,17 +1288,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.accountError = 'Could not load account details.';
return;
}
// Cross-check: returned accountNo must match what cashier searched
if (acc.accountNo && acc.accountNo !== acNo) {
// 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. with savings account number (SHR) or account number
// Auto-fill ECS A/C NO.: GL → glCode, SHR → savingsAccountNumber, others → accountNo
if (this.selectedRow) {
this.selectedRow.ecsAcNo = acc.savingsAccountNumber ?? acc.accountNo ?? '';
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
@@ -1484,7 +1540,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// ── Balance getters ────────────────────────────────────────────────────────
get currentBalance(): number {
return this.accountDetails?.summary?.accountBalance ?? 0;
return this.accountDetails?.summary?.accountBalance ?? this.accountDetails?.organizationRunningBalance ?? 0;
}
get clearBalance(): number {
@@ -1935,6 +1991,55 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
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) {
@@ -2028,7 +2133,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const balanceAfterTxn = isDeposit ? this.currentBalance + amount : this.currentBalance - amount;
this.confirmData = {
type: isDeposit ? 'DEPOSIT' : 'WITHDRAWAL',
accountNo: this.accountDetails.accountNo,
accountNo: this.accountDetails.glCode ?? this.accountDetails.accountNo,
clientName: this.clientName,
amount,
currentBalance: this.currentBalance,
@@ -2049,6 +2154,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const accountId = this.accountDetails.id;
const cfg = this.accountTypeConfig(row.acTyp);
// GL type → post as manual journal entry
if (row.acTyp === 'GL') {
this.submitGlJournalEntry(row, amount, isDeposit);
return;
}
const today = new Date();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
@@ -2134,6 +2245,102 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
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);