Files
cbsnexora/src/app/chequebook/cheque-books/cheque-books.component.ts
T
Jaanu423andClaude Sonnet 5 24b1d7c087 fix(cashier,chequebook): surface real backend errors; skip redundant balance check on token disbursement
Cashier token-based withdrawal disbursement was re-running the balance/min-balance
checks against the already-approved amount, which always failed since the manager
approval had already deducted the balance — the check now skips for that case
(clerk-created requests still get the full validation).

Cheque book request submission now shows the actual backend error message
(e.g. insufficient-balance rejections) instead of a generic fallback string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 11:19:28 +05:30

367 lines
11 KiB
TypeScript

/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ChequeBookService } from '../chequebook.service';
/** Cheque book requisitions and issuance — request, issue (draws from available stock), reject, withdraw. */
@Component({
selector: 'mifosx-cheque-books',
templateUrl: './cheque-books.component.html',
styleUrls: ['./cheque-books.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule,
FaIconComponent
]
})
export class ChequeBooksComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private chequeBookService = inject(ChequeBookService);
private router = inject(Router);
private snackBar = inject(MatSnackBar);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
books: any[] = [];
totalRecords = 0;
pageSize = 10;
pageIndex = 0;
pageSizeOptions = [
10,
25,
50,
100
];
seriesList: any[] = [];
offices: any[] = [];
stockLots: any[] = [];
busy = false;
showRequestForm = false;
issueForBookId: number | null = null;
issueForBook: any = null;
rejectForBookId: number | null = null;
statusFilter = '';
requestForm: UntypedFormGroup;
issueForm: UntypedFormGroup;
rejectForm: UntypedFormGroup;
// account-number lookup for the request form
private accountSearch$ = new Subject<string>();
resolvedAccount: any = null;
searchingAccount = false;
accountError = '';
columns = [
'id',
'accountType',
'account',
'officeName',
'seriesCode',
'quantity',
'status',
'requestedDate',
'actions'
];
constructor() {
this.requestForm = this.formBuilder.group({
accountType: [
'SAVINGS',
Validators.required
],
accountNo: [
'',
Validators.required
],
accountId: [
null,
Validators.required
],
officeId: [
'',
Validators.required
],
seriesId: [
'',
Validators.required
],
noOfBooks: [
1,
Validators.required
]
});
this.issueForm = this.formBuilder.group({
collectionAcknowledged: [false]
});
this.rejectForm = this.formBuilder.group({
rejectionReason: [
'',
Validators.required
]
});
}
ngOnInit(): void {
this.loadBooks();
this.loadStock();
// populates a dropdown, so fetch every active series in one page rather than paging through it
this.chequeBookService
.getSeries('ACTIVE', 0, 500)
.subscribe((res: any) => (this.seriesList = (res?.pageItems ?? []).filter((s: any) => s.active)));
this.chequeBookService.getOffices().subscribe((data: any[]) => (this.offices = data || []));
// account-number lookup: debounce, only fire once at least 4 chars typed (mirrors the cashier module)
this.accountSearch$
.pipe(debounceTime(400), distinctUntilChanged())
.subscribe((accountNo) => this.doAccountSearch(accountNo));
}
/** Called on every keystroke in the account-number box; clears any prior match and (re)triggers the search. */
onAccountNoInput(value: string): void {
this.resolvedAccount = null;
this.accountError = '';
this.requestForm.patchValue({ accountId: null });
const trimmed = (value || '').trim();
if (trimmed.length >= 4) {
this.accountSearch$.next(trimmed);
}
}
/** Re-run the lookup against the other ledger when the account type toggles; the series choice narrows too. */
onAccountTypeChange(): void {
this.resolvedAccount = null;
this.accountError = '';
this.requestForm.patchValue({ accountId: null, seriesId: '' });
const accountNo = (this.requestForm.get('accountNo')?.value || '').trim();
if (accountNo.length >= 4) {
this.accountSearch$.next(accountNo);
}
}
/** Series applicable to whichever account type is currently selected in the request form — every series is
* tagged SAVINGS-only or CURRENT-only (never both), so this is a direct match. */
get requestableSeries(): any[] {
const type = this.requestForm.get('accountType')?.value;
return this.seriesList.filter((s) => s.applicableAccountType === type);
}
private doAccountSearch(accountNo: string): void {
const accountType = this.requestForm.get('accountType')?.value;
this.searchingAccount = true;
this.accountError = '';
this.chequeBookService.searchAccountByNo(accountType, accountNo).subscribe({
next: (res: any) => {
this.searchingAccount = false;
const items = res?.pageItems || (Array.isArray(res) ? res : []);
const exact = items.find((i: any) => i.accountNo === accountNo);
const match = exact || (items.length === 1 ? items[0] : null);
if (match) {
this.resolvedAccount = {
id: match.id,
accountNo: match.accountNo,
clientName: match.clientName || match.clientDisplayName || match.groupName || '',
status: match.status?.value || ''
};
this.requestForm.patchValue({ accountId: match.id });
} else {
this.accountError = 'No account found with that number';
}
},
error: () => {
this.searchingAccount = false;
this.accountError = 'Account lookup failed';
}
});
}
loadStock(): void {
// feeds availability calculations below, so fetch every available lot in one page rather than paging through it
this.chequeBookService
.getStockLots(undefined, undefined, 'AVAILABLE', 0, 500)
.subscribe((res: any) => (this.stockLots = res?.pageItems ?? []));
}
/** Whole books currently on hand for a series, optionally narrowed to one office. */
availableBooksForSeries(seriesId: number, officeId?: number): number {
return this.stockLots
.filter((l) => l.seriesId === seriesId && (!officeId || l.officeId === officeId) && l.status === 'AVAILABLE')
.reduce((sum, l) => sum + (l.remainingBooks || 0), 0);
}
/** Availability at whichever office is currently chosen in the request form. */
availableBooksForRequestSeries(seriesId: number): number {
return this.availableBooksForSeries(seriesId, this.requestForm.get('officeId')?.value);
}
loadBooks(): void {
this.chequeBookService
.getBooks(
undefined,
undefined,
undefined,
this.statusFilter || undefined,
this.pageIndex * this.pageSize,
this.pageSize
)
.subscribe((res: any) => {
this.books = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.totalRecords = Number(res?.totalFilteredRecords ?? this.books.length);
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.loadBooks();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.loadBooks();
}
openBook(book: any): void {
this.router.navigate([
'/chequebook',
'books',
book.id
]);
}
submitRequest(): void {
if (this.requestForm.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.requestForm.value;
const payload = {
accountType: raw.accountType,
accountId: raw.accountId,
officeId: raw.officeId,
seriesId: raw.seriesId,
noOfBooks: raw.noOfBooks,
locale: 'en'
};
this.chequeBookService.requestBook(payload).subscribe({
next: () => {
this.busy = false;
this.showRequestForm = false;
this.requestForm.reset({ accountType: 'SAVINGS', noOfBooks: 1 });
this.resolvedAccount = null;
this.accountError = '';
this.notify('Submitted for approval — see Approvals > Cheque Book Requests');
this.loadBooks();
},
error: (err: any) => {
this.busy = false;
const msg =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.developerMessage ||
'Request failed — check the account is active and the cheque-book facility is enabled';
this.notify(msg, true);
}
});
}
toggleIssue(book: any): void {
const opening = this.issueForBookId !== book.id;
this.issueForBookId = opening ? book.id : null;
this.issueForBook = opening ? book : null;
this.issueForm.reset({ collectionAcknowledged: false });
}
/** Books available right now for the series this book was already requested against. */
get issueAvailableBooks(): number {
return this.issueForBook?.seriesId
? this.availableBooksForSeries(this.issueForBook.seriesId, this.issueForBook.officeId)
: 0;
}
submitIssue(bookId: number): void {
if (this.issueForm.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.issueForm.value;
const payload = { collectionAcknowledged: !!raw.collectionAcknowledged, locale: 'en' };
this.chequeBookService.issueBook(bookId, payload).subscribe({
next: () => {
this.busy = false;
this.issueForBookId = null;
this.issueForBook = null;
this.notify('Cheque book issued');
this.loadBooks();
this.loadStock();
},
error: () => {
this.busy = false;
this.notify('Issue failed — check there is enough available stock for this series at the office', true);
}
});
}
toggleReject(book: any): void {
this.rejectForBookId = this.rejectForBookId === book.id ? null : book.id;
this.rejectForm.reset();
}
submitReject(bookId: number): void {
if (this.rejectForm.invalid || this.busy) {
return;
}
this.busy = true;
const payload = { rejectionReason: this.rejectForm.value.rejectionReason, locale: 'en' };
this.chequeBookService.rejectBook(bookId, payload).subscribe({
next: () => {
this.busy = false;
this.rejectForBookId = null;
this.notify('Cheque book request rejected');
this.loadBooks();
},
error: () => {
this.busy = false;
this.notify('Reject failed', true);
}
});
}
withdraw(book: any): void {
this.chequeBookService.withdrawBook(book.id).subscribe({
next: () => {
this.notify('Cheque book withdrawn');
this.loadBooks();
},
error: () => this.notify('Withdraw failed', true)
});
}
}