feat(shares): route dividend cash claims through cashier token disbursement
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s

Cash claims now require a token at claim submission; a cashier redeems
it at Transaction Entry to record physical hand-over, mirroring the
existing clerk-withdrawal-request flow -- no double-disbursement, and
the manager never hands over cash directly at approval time.

Also: dividend-claim tokens fall back through the same TOK. NO. lookup
used for withdrawal requests, silenced against the global error
interceptor since the caller already handles every outcome; the account
summary card now shows "Dividend Claim" instead of a misleading "A/c
Savings" label when resolving one; and cash amounts are floored to
whole rupees client-side to match cash's whole-rupee denominations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 10:19:51 +05:30
co-authored by Claude Sonnet 5
parent f379b2415c
commit 5a6fb1f9b5
7 changed files with 211 additions and 37 deletions
+7 -2
View File
@@ -7,8 +7,9 @@
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
/** HTTP service for Clerk Withdrawal Request maker-checker workflow. */
@Injectable({ providedIn: 'root' })
@@ -50,8 +51,12 @@ export class ClerkWithdrawalService {
return this.http.post(`/clerk/withdrawal-requests/${id}/reject`, { reason });
}
// Silenced: onTokNoBlur() probes this expecting a possible legitimate 404 (token not from this
// pool, could be a dividend claim's instead) and shows its own tailored message either way.
getByToken(token: string): Observable<any> {
return this.http.get(`/clerk/withdrawal-requests/by-token/${encodeURIComponent(token)}`);
return this.http.get(`/clerk/withdrawal-requests/by-token/${encodeURIComponent(token)}`, {
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
disburse(id: number, denominationData: string | null, transactionAmount?: number): Observable<any> {
@@ -925,42 +925,50 @@
<div class="te-acs-meta">
<span
class="te-acs-tag"
[class.te-acs-tag--active]="accountDetails?.status?.value === 'Active'"
[class.te-acs-tag--active]="activeDividendClaimRequest || accountDetails?.status?.value === 'Active'"
[class.te-acs-tag--blocked]="accountDetails?.status?.value === 'Blocked'"
[class.te-acs-tag--dormant]="accountDetails?.subStatus?.value === 'Dormant'"
>{{
accountDetails?.subStatus?.value === 'Dormant' ? 'Dormant' : (accountDetails?.status?.value ?? '—')
activeDividendClaimRequest
? 'Awaiting Disbursement'
: accountDetails?.subStatus?.value === 'Dormant'
? 'Dormant'
: (accountDetails?.status?.value ?? '—')
}}</span
>
<span class="te-acs-kv"
><b>A/c</b>
{{
accountDetails?.depositType?.value ||
(selectedRow?.acTyp === 'CA'
? 'Current'
: selectedRow?.acTyp === 'SD'
? 'Savings'
: selectedRow?.acTyp === 'FD'
? 'Fixed Dep.'
: selectedRow?.acTyp === 'RD'
? 'Recurring'
: selectedRow?.acTyp === 'GL'
? (accountDetails?.type?.value ?? 'GL Account')
: selectedRow?.acTyp === 'SHR'
? 'Share'
: selectedRow?.acTyp === 'LA'
? 'Loan'
: '—')
(activeDividendClaimRequest
? 'Dividend Claim'
: selectedRow?.acTyp === 'CA'
? 'Current'
: selectedRow?.acTyp === 'SD'
? 'Savings'
: selectedRow?.acTyp === 'FD'
? 'Fixed Dep.'
: selectedRow?.acTyp === 'RD'
? 'Recurring'
: selectedRow?.acTyp === 'GL'
? (accountDetails?.type?.value ?? 'GL Account')
: selectedRow?.acTyp === 'SHR'
? 'Share'
: selectedRow?.acTyp === 'LA'
? 'Loan'
: '—')
}}</span
>
<span class="te-acs-kv"><b>Branch</b> {{ officeName ?? '—' }}</span>
<span class="te-acs-kv"
><b>Product</b>
{{
accountDetails?.savingsProductName ||
accountDetails?.depositProductName ||
accountDetails?.productName ||
'—'
activeDividendClaimRequest
? 'Dividend Cash Payout'
: accountDetails?.savingsProductName ||
accountDetails?.depositProductName ||
accountDetails?.productName ||
'—'
}}</span
>
<span class="te-acs-kv"
@@ -11,7 +11,7 @@ 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 { catchError, debounceTime, distinctUntilChanged, map } 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';
@@ -20,6 +20,7 @@ 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 { DividendClaimRequestService } from '../../clerk/dividend-claim-request.service';
import { ClerkDisbursementService } from '../clerk-disbursement.service';
import { ClerkShareWithdrawalService } from '../clerk-share-withdrawal.service';
import { CashierDepositService } from '../cashier-deposit.service';
@@ -749,6 +750,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
// Active approved withdrawal (cashier disburse flow)
activeWithdrawalRequest: any = null;
activeDividendClaimRequest: any = null;
loadingWithdrawal = false;
private http = inject(HttpClient);
@@ -757,6 +759,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
private dialog = inject(MatDialog);
private clerkUiState = inject(ClerkUiStateService);
private clerkWithdrawalService = inject(ClerkWithdrawalService);
private dividendClaimRequestService = inject(DividendClaimRequestService);
private clerkDisbursementService = inject(ClerkDisbursementService);
private clerkShareWithdrawalService = inject(ClerkShareWithdrawalService);
private cashierDepositService = inject(CashierDepositService);
@@ -1731,10 +1734,28 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const token = row.tokNo.trim();
this.loadingWithdrawal = true;
this.activeWithdrawalRequest = null;
this.activeDividendClaimRequest = null;
this.clerkWithdrawalService
.getByToken(token)
.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 token pool before giving up, since both share the same TOK. NO. field.
if (err?.status === 404) {
return this.dividendClaimRequestService.getByToken(token).pipe(
catchError((divErr: any) => {
this.loadingWithdrawal = false;
const msg: string =
divErr?.error?.message ??
divErr?.error?.errors?.[0]?.defaultUserMessage ??
`Token '${token}' could not be verified. Please try again.`;
this.showAlert('error', `Token '${token}'`, [msg]);
return of(null);
}),
map((divReq: any) => (divReq ? { __dividendClaim: true, ...divReq } : null))
);
}
this.loadingWithdrawal = false;
const msg: string =
err?.error?.message ??
@@ -1747,6 +1768,24 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
.subscribe((req: any) => {
this.loadingWithdrawal = false;
if (!req) return;
if (req.__dividendClaim) {
this.activeDividendClaimRequest = req;
// No real savings/current account backs a dividend cash claim (it's a pure GL
// liability settled at claim-approval time) — stub just enough of accountDetails
// and kycDetails for onSave()'s generic guards to pass without stale/unrelated data.
this.accountDetails = { id: null, accountNo: 'DIVIDEND-CLAIM-' + req.id, clientName: req.client_name };
this.kycDetails = null;
this.clientDetails = null;
this.accountError = '';
row.acNo = req.client_name ?? 'Dividend Claim #' + req.id;
row.acTyp = 'SD';
row.amount = Number(req.amount) || null;
row.dbCr = 'Dr';
this.selectedRow = row;
return;
}
this.activeWithdrawalRequest = req;
row.acNo = req.account_no ?? '';
row.acTyp = 'SD';
@@ -3012,7 +3051,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
// Cashier direct withdrawal BLOCKED — must use approved maker-checker token
if (!this.isClerk && !this.reviewMode && !isDeposit && !this.activeWithdrawalRequest) {
if (
!this.isClerk &&
!this.reviewMode &&
!isDeposit &&
!this.activeWithdrawalRequest &&
!this.activeDividendClaimRequest
) {
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.',
@@ -3021,9 +3066,15 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Approved withdrawal: amount/denomination must exactly match
if (!this.isClerk && this.activeWithdrawalRequest && !isDeposit) {
const approvedAmt = Number(this.activeWithdrawalRequest.amount);
// Approved withdrawal / dividend cash claim: amount/denomination must exactly match
if (!this.isClerk && (this.activeWithdrawalRequest || this.activeDividendClaimRequest) && !isDeposit) {
const rawApprovedAmt = Number(this.activeWithdrawalRequest?.amount ?? this.activeDividendClaimRequest?.amount);
// A dividend cash claim's liability is stored with paise precision (a proportional profit
// share), but cash can only be handed over in whole-rupee notes/coins -- compare against
// the floored amount so an exact denomination match is actually achievable. Ordinary
// withdrawal-request amounts are already whole rupees by convention, so this only changes
// behavior for dividend claims.
const approvedAmt = this.activeDividendClaimRequest ? Math.floor(rawApprovedAmt) : rawApprovedAmt;
if (Math.abs(amount - approvedAmt) > 0.005) {
if (isCash) {
this.showAlert('error', 'Denomination Mismatch — Approved Withdrawal', [
@@ -3116,6 +3167,30 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
const isCash = !!selectedPtEarly?.isCashPayment;
const isGlType = row.acTyp === 'GL';
// Dividend cash claim token disbursement: the GL entry (Dr Dividend Payable / Cr Cash at
// Main Vault) already posted at claim-approval time — there is no real savings/current
// account behind this row, so skip balance/min-balance/KYC/dormant/facility checks entirely
// (none apply to a GL-only liability payout) and go straight to the confirm dialog.
if (this.activeDividendClaimRequest) {
if (!row.paymentTypeId) {
this.showAlert('error', 'Payment Type Required', ['Please select Payment Type (Cash / Cheque / NEFT etc.)']);
return;
}
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;
}
this.openConfirmDialog(row, amount, isDeposit, selectedPtEarly);
return;
}
// GL transactions post as journal entries — skip account-level facility/balance checks
if (isGlType) {
if (!this.cashVaultGlId) {
@@ -3261,7 +3336,8 @@ 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;
const isDisbursement =
!isDeposit && !this.isClerk && (!!this.activeWithdrawalRequest || !!this.activeDividendClaimRequest);
// Cashier deposit (savings/current/GL) goes via maker-checker: balance credits only after manager approval
const isDepositRequest =
isDeposit &&
@@ -3292,7 +3368,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
paymentType: selectedPt?.name ?? 'Cash',
chequeNo: row.chequeNo || null,
symbol: this.currencySymbol,
tokNo: this.activeWithdrawalRequest?.tok_no ?? null,
tokNo: this.activeWithdrawalRequest?.tok_no ?? this.activeDividendClaimRequest?.tok_no ?? null,
isDdCashGl: this.isDdCashGlRow(row),
ddFaceValue: row.amount ?? 0,
ddCharges: row.ddCharges ?? 0,
@@ -3354,9 +3430,52 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
// Dividend cash claim token disbursement → GL entry already posted at claim-approval time.
// Do NOT post any new transaction/journal entry — just record the physical hand-over via
// DividendClaimRequestService, mirroring the activeWithdrawalRequest branch just below.
if (!this.isClerk && this.activeDividendClaimRequest && !isDeposit) {
this.saving = true;
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.activeDividendClaimRequest;
this.dividendClaimRequestService
.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) {
this.showAlert('success', 'Cash Disbursed Successfully', [
`₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2 })} disbursed to customer.`,
`Token No: ${req.tok_no} · Client: ${req.client_name}`
]);
this.activeDividendClaimRequest = null;
this.clearDenominations();
this.loadOpeningPosition();
}
});
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) {
if (
!this.isClerk &&
!this.reviewMode &&
!isDeposit &&
!this.activeWithdrawalRequest &&
!this.activeDividendClaimRequest
) {
this.showAlert('error', 'Direct Withdrawal Not Permitted', [
'Withdrawals must go through the maker-checker approval process.',
'Enter the approved token number to disburse.'
@@ -7,8 +7,9 @@
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpContext } from '@angular/common/http';
import { Observable } from 'rxjs';
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
/**
* HTTP service for the maker-checker "claim my dividend early" workflow — a clerk submits a claim
@@ -27,6 +28,7 @@ export class DividendClaimRequestService {
savingsAccountId?: number;
savingsApplyAmount?: number;
cashApplyAmount?: number;
tokNo?: string;
}
): Observable<any> {
const body: any = { lockId };
@@ -45,9 +47,27 @@ export class DividendClaimRequestService {
if (breakdown.cashApplyAmount != null) {
body.cashApplyAmount = String(breakdown.cashApplyAmount);
}
if (breakdown.tokNo != null) {
body.tokNo = breakdown.tokNo;
}
return this.http.post('/clerk/dividend-claim-requests', body);
}
// Silenced: onTokNoBlur() in Transaction Entry probes this only after the withdrawal-request
// token pool 404s, and shows its own tailored message either way.
getByToken(token: string): Observable<any> {
return this.http.get(`/clerk/dividend-claim-requests/by-token/${encodeURIComponent(token)}`, {
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
});
}
disburse(id: number, denominationData: string | null, transactionAmount?: number): Observable<any> {
return this.http.post(`/clerk/dividend-claim-requests/${id}/disburse`, {
denominationData,
transactionAmount: transactionAmount != null ? String(transactionAmount) : null
});
}
getOverdueLoans(clientId: number): Observable<any> {
return this.http.get(`/clerk/dividend-claim-requests/overdue-loans/${clientId}`);
}
@@ -283,6 +283,12 @@
<span>Cash</span>
<input type="number" [(ngModel)]="claimCashAmount[l.lockId]" min="0" step="0.01" />
</label>
@if (claimCashAmount[l.lockId] > 0) {
<label class="inv-form-field">
<span>Token No. (for cashier hand-over)</span>
<input type="text" [(ngModel)]="claimTokNo[l.lockId]" />
</label>
}
<div class="dd-apply-panel-total">
Total: {{ claimTotal(l) | number: '1.2-2' }} / {{ l.amount | number: '1.2-2' }}
</div>
@@ -353,7 +359,12 @@
}}
</td>
<td class="num">{{ req.savings_apply_amount > 0 ? (req.savings_apply_amount | number: '1.2-2') : '—' }}</td>
<td class="num">{{ req.cash_apply_amount > 0 ? (req.cash_apply_amount | number: '1.2-2') : '—' }}</td>
<td class="num">
{{ req.cash_apply_amount > 0 ? (req.cash_apply_amount | number: '1.2-2') : '—' }}
@if (req.cash_apply_amount > 0 && req.tok_no) {
<div class="pg-empty-inline">Tok: {{ req.tok_no }}</div>
}
</td>
<td class="num">{{ req.locked_amount | number: '1.2-2' }}</td>
<td>
@if (req.has_overdue_loan) {
@@ -68,6 +68,7 @@ export class DividendDashboardComponent implements OnInit {
claimSavingsAmount: Record<number, number | null> = {};
claimSavingsAccountOverride: Record<number, number | null> = {};
claimCashAmount: Record<number, number | null> = {};
claimTokNo: Record<number, string> = {};
claimSubmitting = false;
claimError: string | null = null;
@@ -99,6 +100,7 @@ export class DividendDashboardComponent implements OnInit {
this.claimSavingsAmount[lock.lockId] = Number(lock.amount);
this.claimSavingsAccountOverride[lock.lockId] = null;
this.claimCashAmount[lock.lockId] = null;
this.claimTokNo[lock.lockId] = '';
this.claimRequestService
.getOverdueLoans(lock.clientId)
.pipe(catchError(() => of([])))
@@ -117,14 +119,17 @@ export class DividendDashboardComponent implements OnInit {
return (
Number(this.claimLoanAmount[lock.lockId] || 0) +
Number(this.claimSavingsAmount[lock.lockId] || 0) +
Number(this.claimCashAmount[lock.lockId] || 0)
Math.floor(Number(this.claimCashAmount[lock.lockId] || 0))
);
}
submitClaim(lock: any): void {
const loanAmount = Number(this.claimLoanAmount[lock.lockId] || 0);
const savingsAmount = Number(this.claimSavingsAmount[lock.lockId] || 0);
const cashAmount = Number(this.claimCashAmount[lock.lockId] || 0);
// Cash can only be handed over in whole-rupee notes/coins (no denomination below ₹1), while a
// dividend share often works out to paise — floor to the nearest rupee so the denomination
// total the cashier counts can always match exactly; any leftover paise stays unclaimed.
const cashAmount = Math.floor(Number(this.claimCashAmount[lock.lockId] || 0));
const total = loanAmount + savingsAmount + cashAmount;
if (total <= 0) {
this.claimError = 'Enter at least one amount (loan, savings, or cash), greater than zero.';
@@ -142,6 +147,11 @@ export class DividendDashboardComponent implements OnInit {
this.claimError = 'This share account has no linked savings account — enter a Savings Account ID to credit.';
return;
}
const tokNo = this.claimTokNo[lock.lockId]?.trim();
if (cashAmount > 0 && !tokNo) {
this.claimError = 'Enter a Token No. for the cash portion — the cashier will use it to hand over the cash.';
return;
}
this.claimSubmitting = true;
this.claimError = null;
this.claimRequestService
@@ -151,7 +161,8 @@ export class DividendDashboardComponent implements OnInit {
savingsAccountId:
savingsAmount > 0 && !lock.savingsAccountId ? this.claimSavingsAccountOverride[lock.lockId] : undefined,
savingsApplyAmount: savingsAmount > 0 ? savingsAmount : undefined,
cashApplyAmount: cashAmount > 0 ? cashAmount : undefined
cashApplyAmount: cashAmount > 0 ? cashAmount : undefined,
tokNo: cashAmount > 0 ? tokNo : undefined
})
.subscribe({
next: () => {
+1 -1
View File
@@ -10,7 +10,7 @@
export default {
'mifos_x': {
'version': '260811',
'hash': '7b895a0e'
'hash': 'f379b241'
},
'allow_switching_backend_instance': true
};