feat(cashier, sponsor-dd): GL settlement and DD funding entry points, DD registry redesign

Transaction Entry (clerk):
- Route a two-row Fund Transfer of Dr SHR / Cr GL to the share-withdrawal
  request with settlementMode GL, so a clerk can settle a share withdrawal
  into any GL account instead of the member's linked savings account.
- Offer GL as an account type for Demand Draft funding, and submit it as a
  GL debit (no client or savings/current account is involved).

My Work:
- Add a voucher detail dialog so a clerk can open a raised voucher from the
  request list and see its GL lines.

Closing Cash Report:
- Fix the total row so it reflects the corrected opening balance.

DD Leaflet Registry and DD Inventory:
- Redesign both pages onto the shared usr-* visual language: gradient header
  badge, status filter chips with counts, card-wrapped sticky-header table,
  and one primary action per row with the rest behind an overflow menu.
  Filtering, pagination and every action condition are unchanged.
- Register faHandPaper, needed by the new Stop Payment menu item.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-16 17:17:22 +05:30
co-authored by Claude Opus 5
parent d18bd9cf37
commit b71619c402
15 changed files with 2382 additions and 614 deletions
@@ -166,22 +166,30 @@
</div>
<div class="ws-f ws-f--actyp">
<label class="ws-f__lbl" title="Account Type">AC. TP.</label>
<select
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
}
</select>
@if (acTypLockedByToken && row === selectedRow) {
<span
class="ws-f__ctl ws-f__ctl--locked"
title="Locked — filled from a token-driven request, not a normal account entry"
>{{ acTypLockedLabel }}</span
>
} @else {
<select
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
}
</select>
}
</div>
</div>
</div>
@@ -206,14 +214,20 @@
? ddGlMapping?.ddPayableGlCode || 'Auto GL'
: 'Account number'
"
[readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
[class.ws-f__ctl--readonly]="row.typ === 'DD' && !isDdAccountDebitRow(row)"
[readonly]="
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
"
[class.ws-f__ctl--readonly]="
(row.typ === 'DD' && !isDdAccountDebitRow(row)) || (acTypLockedByToken && row === selectedRow)
"
[title]="
isDdAccountDebitRow(row)
? 'Member account the draft is funded from'
: row.typ === 'DD'
? 'Configured Sponsor DD Payable GL (Auto-selected)'
: 'Account number'
acTypLockedByToken && row === selectedRow
? 'Locked — filled from a token-driven request, not a normal account entry'
: isDdAccountDebitRow(row)
? 'Member account the draft is funded from'
: row.typ === 'DD'
? 'Configured Sponsor DD Payable GL (Auto-selected)'
: 'Account number'
"
/>
</div>
@@ -1523,22 +1537,30 @@
</div>
<div class="ws-f ws-f--actyp">
<label class="ws-f__lbl">AC. TP.</label>
<select
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
}
</select>
@if (acTypLockedByToken && row === selectedRow) {
<span
class="ws-f__ctl ws-f__ctl--locked"
title="Locked — filled from a token-driven request, not a normal account entry"
>{{ acTypLockedLabel }}</span
>
} @else {
<select
class="ws-f__ctl"
[(ngModel)]="row.acTyp"
(click)="$event.stopPropagation()"
(change)="
row.acTyp === 'GL' &&
row.typ !== 'Disbursement' &&
row.typ !== 'Fund Transfer' &&
row.typ !== 'Enter Bank Transfer' &&
(row.dbCr = 'Cr')
"
>
@for (a of acTypOptionsFor(row); track a) {
<option [value]="a">{{ a }}</option>
}
</select>
}
</div>
</div>
</div>
@@ -1556,10 +1578,16 @@
[placeholder]="
row.typ === 'DD' ? ddGlMapping?.ddPayableGlCode || 'Auto GL' : 'Account number'
"
[readonly]="row.typ === 'DD'"
[class.ws-f__ctl--readonly]="row.typ === 'DD'"
[readonly]="row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)"
[class.ws-f__ctl--readonly]="
row.typ === 'DD' || (acTypLockedByToken && row === selectedRow)
"
[title]="
row.typ === 'DD' ? 'Configured Sponsor DD Payable GL (Auto-selected)' : 'Account number'
acTypLockedByToken && row === selectedRow
? 'Locked — filled from a token-driven request, not a normal account entry'
: row.typ === 'DD'
? 'Configured Sponsor DD Payable GL (Auto-selected)'
: 'Account number'
"
/>
</div>
@@ -916,6 +916,20 @@ input.ws-f__ctl--readonly {
font-size: 11px;
}
/* AC.TP shown as a locked label instead of the normal editable select, while the row is filled
from a token-driven request whose real account type row.acTyp cannot show (see
acTypLockedByToken in the component). */
span.ws-f__ctl--locked {
display: inline-flex;
align-items: center;
background: #f1f5f9;
color: #475569;
cursor: not-allowed;
user-select: none;
font-size: 11px;
font-weight: 600;
}
input.ws-amt-input--locked {
background: #fef9c3;
color: #713f12;
@@ -1703,15 +1703,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/**
* Account types a clerk may fund a demand draft from.
*
* A clerk raises a draft against a member's own balance, so the only sensible sources are the
* accounts a member actually holds money in. GL was offered here too, and picking it quietly
* switched the row onto the cash path — the one that asks for a walk-in purchaser's name and a
* note-and-coin count, neither of which a clerk debiting an account has. The option is removed
* rather than validated afterwards, so the wrong path cannot be reached at all.
* A clerk raises a draft against a member's own balance (SD/CA) or, now, against a GL account
* directly for an institutional/holding-account draft (see isDdAccountDebitRow()) — both are
* governed, maker-checker submissions. GL used to be offered here too in a different sense: picking
* it quietly switched the row onto the CASHIER's walk-in cash path instead (the one that asks for a
* purchaser's name and a note-and-coin count), which isDdAccountDebitRow() now disambiguates by
* role — GL means "debit this GL account" for a clerk, and still means "cash/GL purchase" for a
* cashier, who never reaches this list at all (isDdAccountDebitRow() is clerk-only).
*/
private static readonly DD_CLERK_ACTYPS = [
'SD',
'CA'
'CA',
'GL'
];
/**
@@ -1726,6 +1729,33 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
return this.acTypOptions;
}
/** True while the selected row is filled from a token-driven request whose row.acTyp is a
* technical stand-in rather than the account's real type — a dividend claim or an account closure
* both set row.acTyp = 'SD' purely to route past the SHR-specific guards elsewhere on this screen
* (see onTokNoBlur()), not because the counter is paying against a savings account. The AC.TP
* dropdown must not be left editable there: it already shows the wrong value, and a cashier
* changing it further would desync the row from the request it was auto-filled from. A plain
* withdrawal-slip token is deliberately excluded — its row.acTyp is resolved to the account's real
* type there, so the normal editable control already shows it correctly and there's nothing to
* lock. */
get acTypLockedByToken(): boolean {
return !!(this.activeDividendClaimRequest || this.activeClosureRequest);
}
/** What to show in place of the AC.TP dropdown while it's locked — the same label the account
* summary strip above already uses for each of these token pools, so the two never contradict
* each other on screen. */
get acTypLockedLabel(): string {
if (this.activeClosureRequest) {
return 'Closure';
}
if (this.activeDividendClaimRequest) {
return 'Dividend';
}
return '';
}
dbCrOptions = [
'Dr',
'Cr'
@@ -2321,14 +2351,21 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
/**
* TYPE=DD funded by debiting a member's savings/current account rather than by cash.
* TYPE=DD funded by debiting a member's savings/current account, or a GL account directly, rather
* than by cash.
*
* Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account debit
* would let a draft be raised against an account with no cash to reconcile it against — and the
* clerk flow is the one that carries a maker-checker step over the member's balance.
* Clerk-only. A cashier's counter takes cash and nothing else, so offering them an account/GL debit
* would let a draft be raised with no cash to reconcile it against — and the clerk flow is the one
* that carries a maker-checker step over the money moved. AC.TP = GL means something different for
* a cashier's own DD row (a walk-in cash/GL purchase, see isDdCashGlRow()) — this method is checked
* first there specifically so a clerk's GL row is claimed here instead.
*/
isDdAccountDebitRow(row: TxnRow): boolean {
return this.isClerk && row.typ === 'DD' && (row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB');
return (
this.isClerk &&
row.typ === 'DD' &&
(row.acTyp === 'SD' || row.acTyp === 'CA' || row.acTyp === 'CB' || row.acTyp === 'GL')
);
}
/**
@@ -2408,19 +2445,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// An account-funded draft needs the account resolved to a Fineract id before it can be sent:
// the server takes a savings/current account id and a client id, not the typed number. Both are
// filled in by the A/C NO. lookup, so an unresolved account means the number was never found.
// A GL-funded draft is the exception: a GL account is never tied to a client at all (see
// fetchAllDetails() — GL rows never populate rowClientIds), so only the account id is required.
const isGlDebit = accountDebit && row.acTyp === 'GL';
let fundingAccountId: number | null = null;
let fundingClientId: number | null = null;
if (accountDebit) {
const acNo = (row.acNo || '').trim();
if (!acNo) {
this.showAlert('error', 'Account Required', [
'Enter the account number the Demand Draft is funded from.'
isGlDebit
? 'Enter the GL account the Demand Draft is funded from.'
: 'Enter the account number the Demand Draft is funded from.'
]);
return;
}
fundingAccountId = this.rowAccountIds[acNo] ?? null;
fundingClientId = this.rowClientIds[acNo] ?? null;
if (!fundingAccountId || !fundingClientId) {
fundingClientId = isGlDebit ? null : (this.rowClientIds[acNo] ?? null);
if (!fundingAccountId || (!isGlDebit && !fundingClientId)) {
this.showAlert('error', 'Account Not Found', [
`Account ${acNo} could not be resolved.`,
'Check the account number and wait for its details to load before raising the draft.'
@@ -2510,29 +2552,32 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
/**
* Raises a Demand Draft funded by debiting a member's savings or current account.
* Raises a Demand Draft funded by debiting a member's savings/current account, or a GL account
* directly.
*
* Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The member's
* account is only touched when a checker approves, which is also when the balance is re-checked —
* so a draft raised against a sufficient balance can still be refused if the balance has since
* gone. No denominations are sent, because no cash is taken.
* Clerk-only, and a maker-checker request like the cash flow: nothing is debited here. The
* account/GL is only touched when a checker approves, which for a member account is also when the
* balance is re-checked — so a draft raised against a sufficient balance can still be refused if
* the balance has since gone (a GL account carries no such balance check). No denominations are
* sent, because no cash is taken.
*
* The account goes in savingsAccountId or currentAccountId, never both: the server insists on
* exactly one, since which table the account lives in decides which control GL is debited.
* The funding account goes in exactly one of savingsAccountId, currentAccountId or glAccountId: the
* server insists on exactly one, since which one decides what gets debited on approval.
*/
private submitAccountDebitDd(
row: TxnRow,
leaflet: any,
beneficiaryData: any,
fundingAccountId: number,
fundingClientId: number
fundingClientId: number | null
): void {
const d = new Date();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
const isGl = row.acTyp === 'GL';
const payload = {
clientId: fundingClientId,
clientId: isGl ? undefined : (fundingClientId ?? undefined),
beneficiaryName: beneficiaryData.beneficiaryName,
payableAtBranch: beneficiaryData.payableAtBranch,
payableAtCity: beneficiaryData.payableAtCity,
@@ -2540,9 +2585,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
chargesAmount: Number(row.ddCharges) || 0,
issuingBranchId: row.br,
issuedDate,
paymentMethod: 'ACCOUNT_DEBIT' as const,
savingsAccountId: isCurrent ? undefined : fundingAccountId,
currentAccountId: isCurrent ? fundingAccountId : undefined
paymentMethod: (isGl ? 'GL' : 'ACCOUNT_DEBIT') as 'GL' | 'ACCOUNT_DEBIT',
savingsAccountId: isGl || isCurrent ? undefined : fundingAccountId,
currentAccountId: isGl || !isCurrent ? undefined : fundingAccountId,
glAccountId: isGl ? fundingAccountId : undefined
};
this.saving = true;
@@ -3044,6 +3090,168 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
);
}
/**
* Share redemption settled to a GL account the clerk picks on the CR row, instead of the
* member's linked savings account — mirrors submitShareRedemptionTransfer() above, but the
* credit side is any GL account (e.g. a "DD Payable" holding account), not a fixed linked
* account, so there is no "must match the linked account" check here.
*/
private submitShareGlSettlementTransfer(shareRow: TxnRow, glRow: TxnRow): void {
// Belt-and-braces: the TXN TP. dropdown already hides cash payment types for AC.TP = SHR (see
// paymentTypesForRow()), but re-check here too in case the payment type was set before that
// filter applied.
const settlementPaymentType = this.paymentTypes.find((p: any) => p.id === glRow.paymentTypeId);
if (!glRow.paymentTypeId) {
this.showAlert('error', 'Settlement Payment Type Required', [
'Select a Payment Type on the CR (GL 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, never cash.',
'Select Transfer as the Payment Type on the CR row.'
]);
return;
}
const shareAccountId = this.rowAccountIds[shareRow.acNo];
if (!shareAccountId) {
this.showAlert('error', 'Share Account Not Loaded', [`Please load account details for: ${shareRow.acNo}`]);
return;
}
const settlementGlAccountId = this.rowAccountIds[glRow.acNo];
if (!settlementGlAccountId) {
this.showAlert('error', 'GL Account Not Loaded', [`Please load account details for: ${glRow.acNo}`]);
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, same as the linked-savings path above.
const settlementRowSnapshot = {
branch: glRow.br,
accountType: glRow.acTyp,
accountNo: glRow.acNo,
ecsAccountNo: glRow.ecsAcNo || null,
dbCr: glRow.dbCr,
amount: glRow.amount ?? null,
chequeNo: glRow.chequeNo || null,
chequeDate: glRow.cheqDate || null,
tokenNo: glRow.tokNo || null,
description: glRow.description || null,
bankName: glRow.bankName || null,
routingCode: glRow.routingCode || null,
paymentTypeId: glRow.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: glRow.paymentTypeId,
settlementRowSnapshot,
settlementMode: 'GL',
settlementGlAccountId
};
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 (GL): ${glRow.acNo}`,
`Settlement Payment Type: ${settlementPaymentType?.name ?? '—'}`,
...(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.doSubmitShareGlSettlementTransfer(payload, shareRow, shares, glRow.acNo)
);
}
private doSubmitShareGlSettlementTransfer(
payload: Record<string, unknown>,
shareRow: TxnRow,
shares: number,
glAcNo: 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 GL ${glAcNo} 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;
}
});
}
private doSubmitShareRedemptionTransfer(
payload: Record<string, unknown>,
shareRow: TxnRow,
@@ -3103,37 +3311,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
.pipe(
catchError((err: any) => {
// Not a withdrawal-request token at all (as opposed to PENDING/WITHDRAWN/REJECTED,
// which are legitimate matches with their own specific error) — try the dividend
// cash-claim pool, then the account-closure pool, before giving up: all three share
// the same TOK. NO. field at this counter.
// which are legitimate matches with their own specific error) — try the account-closure
// pool before giving up: both share the same TOK. NO. field at this counter. A dividend
// claim was once a third pool here, but a dividend never actually pays out in cash over
// this counter — it only ever settles through a Demand Draft, which is its own DD
// issuance request and its own token, so looking one up here could only ever 404.
if (err?.status === 404) {
return this.dividendClaimRequestService.getByToken(token).pipe(
catchError((divErr: any) => {
if (divErr?.status === 404) {
return this.closureRequestService.getByToken(token).pipe(
catchError((closeErr: any) => {
this.loadingWithdrawal = false;
const msg: string =
closeErr?.error?.message ??
closeErr?.error?.errors?.[0]?.defaultUserMessage ??
`Token '${token}' could not be verified. Please try again.`;
this.showAlert('error', `Token '${token}'`, [msg]);
return of(null);
}),
map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null))
);
}
return this.closureRequestService.getByToken(token).pipe(
catchError((closeErr: any) => {
this.loadingWithdrawal = false;
const msg: string =
divErr?.error?.message ??
divErr?.error?.errors?.[0]?.defaultUserMessage ??
closeErr?.error?.message ??
closeErr?.error?.errors?.[0]?.defaultUserMessage ??
`Token '${token}' could not be verified. Please try again.`;
this.showAlert('error', `Token '${token}'`, [msg]);
return of(null);
}),
// Only tag a result that actually came from the dividend pool — a closure hit
// recovered by the inner fallback above is already tagged and must stay that way.
map((divReq: any) => (divReq && !divReq.__closureRequest ? { __dividendClaim: true, ...divReq } : divReq))
map((closeReq: any) => (closeReq ? { __closureRequest: true, ...closeReq } : null))
);
}
this.loadingWithdrawal = false;
@@ -3159,7 +3353,16 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.clientDetails = null;
this.accountError = '';
row.acNo = req.client_name ?? 'Dividend Claim #' + req.id;
// Marks this acNo as already resolved, the same way loadAccountDetails() itself would —
// without it, the next click into this row (onRowSelect) sees an acNo that was never
// "loaded" and re-searches for a real account matching this stub string, which can land
// on an unrelated real account whose number happens to share a suffix with it.
this.lastLoadedAcNo = row.acNo;
row.acTyp = 'SD';
// Every token-driven row is cash leaving the branch to a member, which is what the
// Payment register is for — not what this row's TYPE happened to default to before the
// token was entered.
row.typ = 'Payment';
row.amount = Number(req.amount) || null;
row.dbCr = 'Dr';
this.selectedRow = row;
@@ -3181,7 +3384,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.clientDetails = null;
this.accountError = '';
row.acNo = req.account_no ?? '';
// See the same line in the dividend-claim branch above for why this is required.
this.lastLoadedAcNo = row.acNo;
row.acTyp = 'SD';
row.typ = 'Payment';
row.amount = Number(req.cashier_disbursed_amount) || null;
row.dbCr = 'Dr';
// A closure request stores payment_type_id as NULL, meaning "Cash (Default)" — resolve the
@@ -3194,15 +3400,23 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
this.activeWithdrawalRequest = req;
// A plain withdrawal-slip request carries its own real account_type (SAVINGS, CURRENT,
// FIXED_DEPOSIT or RECURRING_DEPOSIT — see ClerkWithdrawalRequestApiResource.submit()), unlike
// the closure/dividend pools above, which are never anything but a savings-shaped stub.
// Hardcoding 'SD' here searched every non-savings account on the wrong endpoint (Current
// accounts live under /currentaccounts, not /savingsaccounts) and showed it mislabelled even
// when the lookup happened to succeed.
const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD';
row.acNo = req.account_no ?? '';
row.acTyp = 'SD';
row.acTyp = acTyp;
row.typ = 'Payment';
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');
this.loadAccountDetails(req.account_no.trim(), acTyp);
}
});
}
@@ -3229,10 +3443,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
onAcNoBlur(row: TxnRow, index: number): void {
if (!row.acNo.trim()) return;
this.selectedRow = row;
// A token-driven row's account fields are already authoritative (see onTokNoBlur) -- merely
// clicking into and out of the A/C NO. field here must not re-search for them. Doing so used
// row.acTyp's internal stand-in value (e.g. 'SD' for a dividend claim, which is never the
// account's real type) against the account's real number, either finding nothing ("Account
// not found") or, worse, matching a completely unrelated real account that happened to share a
// number suffix.
if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) {
return;
}
this.loadAccountDetails(row.acNo.trim(), row.acTyp);
}
onAcNoInput(row: TxnRow, index: number, value: string): void {
// Same reasoning as the guard at the top of onAcNoBlur() -- a token-driven row's account
// fields must not be edited or re-searched via this field at all.
if (this.activeWithdrawalRequest || this.activeDividendClaimRequest || this.activeClosureRequest) {
return;
}
row.acNo = value;
this.lastLoadedAcNo = '';
this.acNoInput$.next({ row, index, value });
@@ -3341,8 +3569,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const match = exactMatch ?? partialMatch ?? idMatch ?? null;
if (match?.id) {
const matchedGlCode = match[f] ?? acNo;
if (this.selectedRow && this.selectedRow.typ === 'DD' && match.glCode) {
this.selectedRow.acNo = match.glCode;
// Any row resolved via the partial/endsWith/id fallback above (not an exact code match)
// loads and displays the right account, but the row itself still carries whatever shorter
// string the cashier actually typed to search with -- and that's what gets submitted and
// shown everywhere downstream (vouchers, My Work, credit_entries), not the account's real
// full number. Correct the row to the account's actual code whenever the match wasn't
// already exact, for every account type -- not just the DD/GL cases this was previously
// limited to.
if (this.selectedRow && !exactMatch && matchedGlCode) {
this.selectedRow.acNo = matchedGlCode;
}
const clientIdFromSearch = acTyp !== 'GL' && match.clientId != null ? String(match.clientId) : null;
this.fetchAllDetails(match.id, matchedGlCode, cfg, token, acTyp, clientIdFromSearch);
@@ -4519,6 +4754,26 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// "Disbursement". 'Enter Bank Transfer' never carries a GL leg, so it always stays in this
// branch; only skip out to the Disbursement/Collection branch below when every row is 'Fund
// Transfer' and at least one of them is GL.
// Share redemption settled to a GL account instead of the linked savings account: DR=Share
// account, CR=any GL account the clerk picks (e.g. a "DD Payable" holding account, ahead of a
// second, independent manual entry that later moves the value out to an actual draft). Checked
// ahead of both branches below — the Enter Bank Transfer/Fund Transfer branch would otherwise
// treat a Fund Transfer carrying a GL leg as the Disbursement/Collection shape (see
// fundTransferRowsHaveGl below), and a Share account can never be debited by that path at all —
// no such Fineract command exists — so it would either be silently mis-posted or rejected late.
if (
this.isClerk &&
this.rows.length === 2 &&
this.rows.every((r) => r.typ === 'Fund Transfer' || r.typ === 'Enter Bank Transfer') &&
this.rows.some((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR') &&
this.rows.some((r) => r.dbCr === 'Cr' && r.acTyp === 'GL')
) {
const shareRow = this.rows.find((r) => r.dbCr === 'Dr' && r.acTyp === 'SHR')!;
const glRow = this.rows.find((r) => r.dbCr === 'Cr' && r.acTyp === 'GL')!;
this.submitShareGlSettlementTransfer(shareRow, glRow);
return;
}
const fundTransferRowsHaveGl =
this.rows.every((r) => r.typ === 'Fund Transfer') && this.rows.some((r) => r.acTyp === 'GL');
if (