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:
co-authored by
Claude Sonnet 4.6
parent
010adcb44f
commit
05cbe21542
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user