feat(clearing): add CTS/NACH clearing UI with real NPCI batch sessions
- CTS clearing module: inward batches (validate/pass/return), outward presentment capture, positive pay, settlement, reconciliation - Outward Clearing merged into one screen: Pending Cheques + Batches tabs (a real clearing session per NPCI submission, either the 2-hourly job or manual send), drill into a batch to settle/dishonour its cheques - Date-range, text, and quick (Today/Yesterday/All) filters with pagination across inward batches, outward presentments, outward batches, and batch detail - NACH clearing module (mandates, batches, transaction decisioning) - Shell/sidenav wiring for both new modules Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ed2a40a8bf
commit
6d205c0ed2
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* 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, ElementRef, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
|
||||
import { formatDate } from '@angular/common';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import { Observable, concat } from 'rxjs';
|
||||
import { Router } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import lightGallery from 'lightgallery';
|
||||
import lgZoom from 'lightgallery/plugins/zoom';
|
||||
import lgFullscreen from 'lightgallery/plugins/fullscreen';
|
||||
import type { LightGallery } from 'lightgallery/lightgallery';
|
||||
import type { GalleryItem } from 'lightgallery/lg-utils';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Custom Services */
|
||||
import { CtsClearingService } from '../ctsclearing.service';
|
||||
import { pillClass, clearingError } from '../../clearing-network/clearing-status';
|
||||
|
||||
/**
|
||||
* Outward CTS clearing — landing page for the whole outward flow. Capture a cheque, see it sit as
|
||||
* "Pending" until a clearing session sends it, then find it as a row in the Batches table below (a real
|
||||
* session, one row per shared batchReference) — click a batch to see its cheques and settle/dishonour them.
|
||||
* There's no per-cheque "Send" any more: cheques only leave via the 2-hourly session job or the manual
|
||||
* "Send Pending Batch" fallback, so batch is always the unit you browse sent cheques by.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-outward-presentment',
|
||||
templateUrl: './outward-presentment.component.html',
|
||||
styleUrls: ['./outward-presentment.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatTableModule,
|
||||
MatTabsModule,
|
||||
MatPaginatorModule,
|
||||
MatSnackBarModule,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class OutwardPresentmentComponent implements OnInit, OnDestroy {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private ctsService = inject(CtsClearingService);
|
||||
private router = inject(Router);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
@ViewChild('lightboxRoot', { static: true }) lightboxRoot: ElementRef<HTMLElement>;
|
||||
private lightboxInstance: LightGallery | null = null;
|
||||
private readonly lightboxPlugins = [
|
||||
lgZoom,
|
||||
lgFullscreen
|
||||
];
|
||||
|
||||
pillClass = pillClass;
|
||||
|
||||
private notify(message: string, isError = false): void {
|
||||
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
|
||||
}
|
||||
|
||||
busy = false;
|
||||
showCaptureForm = false;
|
||||
|
||||
/** Cheque images picked in the capture form itself — uploaded right after creation, in the same Submit. */
|
||||
selectedFrontFile: File | null = null;
|
||||
selectedBackFile: File | null = null;
|
||||
frontPreviewUrl: string | null = null;
|
||||
backPreviewUrl: string | null = null;
|
||||
|
||||
captureForm: UntypedFormGroup;
|
||||
pendingFilterForm: UntypedFormGroup;
|
||||
batchesFilterForm: UntypedFormGroup;
|
||||
|
||||
// ----- Pending cheques (CAPTURED, not yet in any batch) -----
|
||||
|
||||
presentments: any[] = [];
|
||||
pendingTotalRecords = 0;
|
||||
pendingPageSize = 10;
|
||||
pendingPageIndex = 0;
|
||||
pageSizeOptions = [
|
||||
10,
|
||||
25,
|
||||
50,
|
||||
100
|
||||
];
|
||||
|
||||
pendingColumns = [
|
||||
'id',
|
||||
'chequeNumber',
|
||||
'drawerBank',
|
||||
'payeeName',
|
||||
'amount',
|
||||
'depositedDate',
|
||||
'image',
|
||||
'status'
|
||||
];
|
||||
|
||||
// ----- Batches (real, already-sent clearing sessions) -----
|
||||
|
||||
batches: any[] = [];
|
||||
batchesTotalRecords = 0;
|
||||
batchesPageSize = 10;
|
||||
batchesPageIndex = 0;
|
||||
|
||||
batchColumns = [
|
||||
'batchReference',
|
||||
'session',
|
||||
'items',
|
||||
'amount',
|
||||
'progress',
|
||||
'actions'
|
||||
];
|
||||
|
||||
/**
|
||||
* Defaults to today on first load so this isn't every session ever sent — "All" widens it. Persisted on
|
||||
* the singleton service so opening a batch and navigating back doesn't silently reset the choice.
|
||||
*/
|
||||
get batchDateFilter(): 'today' | 'all' {
|
||||
return this.ctsService.outwardBatchesDateFilter;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.captureForm = this.formBuilder.group({
|
||||
depositingAccountType: [
|
||||
'SAVINGS',
|
||||
Validators.required
|
||||
],
|
||||
depositingAccountId: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
chequeNumber: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
amount: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
payeeName: [''],
|
||||
drawerBankName: [''],
|
||||
drawerBankMicr: [''],
|
||||
drawerIfsc: [''],
|
||||
depositedDate: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
stopPaymentFlag: [false],
|
||||
iqaStatus: ['PASS'],
|
||||
iqaDetail: [''],
|
||||
drawerAccountNumber: ['']
|
||||
});
|
||||
this.pendingFilterForm = this.formBuilder.group({
|
||||
fromDate: [''],
|
||||
toDate: [''],
|
||||
chequeNumber: [''],
|
||||
drawerBankName: [''],
|
||||
payeeName: ['']
|
||||
});
|
||||
// Batches defaults to today on first load — matches the persisted Today/All quick toggle below.
|
||||
const initialBatchDate = this.batchDateFilter === 'today' ? new Date() : null;
|
||||
this.batchesFilterForm = this.formBuilder.group({
|
||||
fromDate: [initialBatchDate],
|
||||
toDate: [initialBatchDate],
|
||||
batchReference: ['']
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadPresentments();
|
||||
this.loadBatches();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyLightbox();
|
||||
}
|
||||
|
||||
/** Cheque image data URLs, keyed "<presentmentId>_<side>" — populated once fetched. */
|
||||
imageUrls: { [key: string]: string } = {};
|
||||
|
||||
loadPresentments(): void {
|
||||
const f = this.pendingFilterForm.value;
|
||||
const filters = {
|
||||
fromDate: this.toApiDate(f.fromDate),
|
||||
toDate: this.toApiDate(f.toDate),
|
||||
chequeNumber: f.chequeNumber || undefined,
|
||||
drawerBankName: f.drawerBankName || undefined,
|
||||
payeeName: f.payeeName || undefined
|
||||
};
|
||||
this.ctsService
|
||||
.getOutwardPresentments('CAPTURED', this.pendingPageIndex * this.pendingPageSize, this.pendingPageSize, filters)
|
||||
.subscribe((res: any) => {
|
||||
this.presentments = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
this.pendingTotalRecords = Number(res?.totalFilteredRecords ?? this.presentments.length);
|
||||
this.presentments.forEach((p: any) => {
|
||||
this.loadItemImage(p, 'front');
|
||||
this.loadItemImage(p, 'back');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
applyPendingFilters(): void {
|
||||
this.pendingPageIndex = 0;
|
||||
this.loadPresentments();
|
||||
}
|
||||
|
||||
clearPendingFilters(): void {
|
||||
this.pendingFilterForm.reset({ fromDate: '', toDate: '', chequeNumber: '', drawerBankName: '', payeeName: '' });
|
||||
this.pendingPageIndex = 0;
|
||||
this.loadPresentments();
|
||||
}
|
||||
|
||||
onPendingPageChange(event: PageEvent): void {
|
||||
this.pendingPageSize = event.pageSize;
|
||||
this.pendingPageIndex = event.pageIndex;
|
||||
this.loadPresentments();
|
||||
}
|
||||
|
||||
loadBatches(): void {
|
||||
const f = this.batchesFilterForm.value;
|
||||
const filters = {
|
||||
fromDate: this.toApiDate(f.fromDate),
|
||||
toDate: this.toApiDate(f.toDate),
|
||||
batchReference: f.batchReference || undefined
|
||||
};
|
||||
this.ctsService
|
||||
.getOutwardBatches(this.batchesPageIndex * this.batchesPageSize, this.batchesPageSize, filters)
|
||||
.subscribe((res: any) => {
|
||||
this.batches = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
this.batchesTotalRecords = Number(res?.totalFilteredRecords ?? this.batches.length);
|
||||
});
|
||||
}
|
||||
|
||||
applyBatchesFilters(): void {
|
||||
// A manual date edit no longer matches a plain "today"/"all" quick state — treat it as custom.
|
||||
this.ctsService.outwardBatchesDateFilter = 'all';
|
||||
this.batchesPageIndex = 0;
|
||||
this.loadBatches();
|
||||
}
|
||||
|
||||
clearBatchesFilters(): void {
|
||||
this.ctsService.outwardBatchesDateFilter = 'all';
|
||||
this.batchesFilterForm.reset({ fromDate: '', toDate: '', batchReference: '' });
|
||||
this.batchesPageIndex = 0;
|
||||
this.loadBatches();
|
||||
}
|
||||
|
||||
setBatchDateFilter(filter: 'today' | 'all'): void {
|
||||
this.ctsService.outwardBatchesDateFilter = filter;
|
||||
const day = filter === 'today' ? new Date() : null;
|
||||
this.batchesFilterForm.patchValue({ fromDate: day, toDate: day });
|
||||
this.batchesPageIndex = 0;
|
||||
this.loadBatches();
|
||||
}
|
||||
|
||||
onBatchesPageChange(event: PageEvent): void {
|
||||
this.batchesPageSize = event.pageSize;
|
||||
this.batchesPageIndex = event.pageIndex;
|
||||
this.loadBatches();
|
||||
}
|
||||
|
||||
openBatch(batch: any): void {
|
||||
this.router.navigate([
|
||||
'/ctsclearing',
|
||||
'outward-batches',
|
||||
batch.batchReference
|
||||
]);
|
||||
}
|
||||
|
||||
/** Fetches the image as an authenticated base64 data URL and caches it — 404 (no upload yet) is expected and silent. */
|
||||
loadItemImage(presentment: any, side: 'front' | 'back'): void {
|
||||
this.ctsService.getOutwardPresentmentImage(presentment.id, side).subscribe({
|
||||
next: (dataUrl: string) => (this.imageUrls[`${presentment.id}_${side}`] = dataUrl),
|
||||
error: () => {
|
||||
/* no image uploaded for this side yet */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getItemImageUrl(presentment: any, side: 'front' | 'back'): string | null {
|
||||
return this.imageUrls[`${presentment.id}_${side}`] || null;
|
||||
}
|
||||
|
||||
/** Opens the front/back cheque image(s) in a zoomable, pannable lightbox — same lightgallery setup used app-wide. */
|
||||
openImageZoom(presentment: any, side: 'front' | 'back'): void {
|
||||
const frontUrl = this.getItemImageUrl(presentment, 'front');
|
||||
const backUrl = this.getItemImageUrl(presentment, 'back');
|
||||
const items: GalleryItem[] = [];
|
||||
if (frontUrl) {
|
||||
items.push({
|
||||
src: frontUrl,
|
||||
thumb: frontUrl,
|
||||
subHtml: `<div class="lg-caption"><h4>Front — Cheque ${presentment.chequeNumber}</h4></div>`
|
||||
});
|
||||
}
|
||||
if (backUrl) {
|
||||
items.push({
|
||||
src: backUrl,
|
||||
thumb: backUrl,
|
||||
subHtml: `<div class="lg-caption"><h4>Back — Cheque ${presentment.chequeNumber}</h4></div>`
|
||||
});
|
||||
}
|
||||
if (!items.length) {
|
||||
return;
|
||||
}
|
||||
const startIndex = side === 'back' && frontUrl ? 1 : 0;
|
||||
this.destroyLightbox();
|
||||
this.lightboxInstance = lightGallery(this.lightboxRoot.nativeElement, {
|
||||
dynamic: true,
|
||||
dynamicEl: items,
|
||||
plugins: this.lightboxPlugins,
|
||||
download: false,
|
||||
closable: true,
|
||||
escKey: true,
|
||||
zoomFromOrigin: true
|
||||
});
|
||||
this.lightboxInstance.openGallery(startIndex);
|
||||
}
|
||||
|
||||
private destroyLightbox(): void {
|
||||
if (this.lightboxInstance) {
|
||||
this.lightboxInstance.destroy();
|
||||
this.lightboxInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
uploadItemImage(presentment: any, side: 'front' | 'back', event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files && input.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
this.ctsService.uploadOutwardPresentmentImage(presentment.id, side, file).subscribe({
|
||||
next: () => {
|
||||
this.notify(side === 'front' ? 'Front image uploaded' : 'Back image uploaded');
|
||||
this.loadItemImage(presentment, side);
|
||||
},
|
||||
error: (err: any) => this.notify(clearingError(err, 'Image upload failed'), true)
|
||||
});
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
private toApiDate(value: any): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return formatDate(value, 'yyyy-MM-dd', 'en');
|
||||
}
|
||||
|
||||
/** Picks a front/back cheque image right in the capture form — uploaded once the cheque is created below. */
|
||||
onCaptureFileSelected(side: 'front' | 'back', event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files && input.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
if (side === 'front') {
|
||||
if (this.frontPreviewUrl) {
|
||||
URL.revokeObjectURL(this.frontPreviewUrl);
|
||||
}
|
||||
this.selectedFrontFile = file;
|
||||
this.frontPreviewUrl = previewUrl;
|
||||
} else {
|
||||
if (this.backPreviewUrl) {
|
||||
URL.revokeObjectURL(this.backPreviewUrl);
|
||||
}
|
||||
this.selectedBackFile = file;
|
||||
this.backPreviewUrl = previewUrl;
|
||||
}
|
||||
}
|
||||
|
||||
private resetCaptureFiles(): void {
|
||||
if (this.frontPreviewUrl) {
|
||||
URL.revokeObjectURL(this.frontPreviewUrl);
|
||||
}
|
||||
if (this.backPreviewUrl) {
|
||||
URL.revokeObjectURL(this.backPreviewUrl);
|
||||
}
|
||||
this.selectedFrontFile = null;
|
||||
this.selectedBackFile = null;
|
||||
this.frontPreviewUrl = null;
|
||||
this.backPreviewUrl = null;
|
||||
}
|
||||
|
||||
/** Front + back cheque image are mandatory before a cheque can be captured — checked in the template too. */
|
||||
canSubmitCapture(): boolean {
|
||||
return this.captureForm.valid && !!this.selectedFrontFile && !!this.selectedBackFile && !this.busy;
|
||||
}
|
||||
|
||||
submitCapture(): void {
|
||||
if (!this.canSubmitCapture()) {
|
||||
if (this.captureForm.valid && (!this.selectedFrontFile || !this.selectedBackFile)) {
|
||||
this.notify('Front and back cheque image are both required', true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
const raw = this.captureForm.value;
|
||||
const payload = {
|
||||
depositingAccountType: raw.depositingAccountType,
|
||||
depositingAccountId: raw.depositingAccountId,
|
||||
chequeNumber: raw.chequeNumber,
|
||||
amount: raw.amount,
|
||||
payeeName: raw.payeeName || undefined,
|
||||
drawerBankName: raw.drawerBankName || undefined,
|
||||
drawerBankMicr: raw.drawerBankMicr || undefined,
|
||||
drawerIfsc: raw.drawerIfsc || undefined,
|
||||
depositedDate: this.toApiDate(raw.depositedDate),
|
||||
stopPaymentFlag: !!raw.stopPaymentFlag,
|
||||
iqaStatus: raw.iqaStatus || undefined,
|
||||
iqaDetail: raw.iqaDetail || undefined,
|
||||
drawerAccountNumber: raw.drawerAccountNumber || undefined,
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
locale: 'en'
|
||||
};
|
||||
this.ctsService.createOutwardPresentment(payload).subscribe({
|
||||
next: (res: any) => {
|
||||
const newId = res?.resourceId;
|
||||
const front = this.selectedFrontFile;
|
||||
const back = this.selectedBackFile;
|
||||
if (!newId || (!front && !back)) {
|
||||
this.finishCapture('Cheque captured');
|
||||
return;
|
||||
}
|
||||
// upload whichever image(s) were picked, in the same Submit action, before closing the form.
|
||||
// Sequential (concat), NOT parallel (forkJoin): two concurrent updates to the same row would race
|
||||
// against its @Version optimistic lock and throw OptimisticLockException on the second write.
|
||||
const uploads: Observable<any>[] = [];
|
||||
if (front) {
|
||||
uploads.push(this.ctsService.uploadOutwardPresentmentImage(newId, 'front', front));
|
||||
}
|
||||
if (back) {
|
||||
uploads.push(this.ctsService.uploadOutwardPresentmentImage(newId, 'back', back));
|
||||
}
|
||||
concat(...uploads).subscribe({
|
||||
complete: () => this.finishCapture('Cheque captured with image(s)'),
|
||||
error: (err: any) => {
|
||||
this.busy = false;
|
||||
this.notify(
|
||||
clearingError(err, 'Cheque captured, but image upload failed — upload it from the table row'),
|
||||
true
|
||||
);
|
||||
this.showCaptureForm = false;
|
||||
this.resetCaptureFiles();
|
||||
this.captureForm.reset({ depositingAccountType: 'SAVINGS', stopPaymentFlag: false, iqaStatus: 'PASS' });
|
||||
this.loadPresentments();
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.busy = false;
|
||||
this.notify(clearingError(err, 'Could not capture the cheque'), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancelCapture(): void {
|
||||
this.showCaptureForm = false;
|
||||
this.resetCaptureFiles();
|
||||
}
|
||||
|
||||
private finishCapture(message: string): void {
|
||||
this.busy = false;
|
||||
this.showCaptureForm = false;
|
||||
this.resetCaptureFiles();
|
||||
this.captureForm.reset({ depositingAccountType: 'SAVINGS', stopPaymentFlag: false, iqaStatus: 'PASS' });
|
||||
this.notify(message);
|
||||
this.loadPresentments();
|
||||
}
|
||||
|
||||
/** Fills the capture form with realistic sample data in one click, for quick demo/testing. */
|
||||
fillDummyData(): void {
|
||||
const stamp = Date.now().toString().slice(-6);
|
||||
const banks = [
|
||||
{ name: 'IDFC First Bank', micr: '400229015', ifsc: 'IDFC0000045' },
|
||||
{ name: 'HDFC Bank', micr: '400240012', ifsc: 'HDFC0000078' },
|
||||
{ name: 'ICICI Bank', micr: '400229033', ifsc: 'ICIC0000112' }
|
||||
];
|
||||
const bank = banks[Math.floor(Math.random() * banks.length)];
|
||||
const today = new Date();
|
||||
|
||||
this.captureForm.patchValue({
|
||||
depositingAccountType: 'SAVINGS',
|
||||
depositingAccountId: 1,
|
||||
chequeNumber: stamp,
|
||||
amount: (Math.floor(Math.random() * 900) + 100) * 100,
|
||||
payeeName: 'Test Payee ' + stamp,
|
||||
drawerBankName: bank.name,
|
||||
drawerBankMicr: bank.micr,
|
||||
drawerIfsc: bank.ifsc,
|
||||
drawerAccountNumber: 'XXXXXXXX' + stamp,
|
||||
depositedDate: today,
|
||||
stopPaymentFlag: false,
|
||||
iqaStatus: 'PASS',
|
||||
iqaDetail: 'Auto-filled sample data'
|
||||
});
|
||||
this.notify('Dummy values filled — review and Submit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual stand-in for the clearing-session scheduler (currently disabled): bundles every CAPTURED cheque
|
||||
* into one CTS batch file and sends it to the clearing house in a single go.
|
||||
*/
|
||||
sendPendingBatch(): void {
|
||||
if (this.busy) {
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.ctsService.sendPendingOutwardBatch().subscribe({
|
||||
next: (res: any) => {
|
||||
this.busy = false;
|
||||
const count = res?.resourceId ?? '';
|
||||
this.notify(count ? `Batch sent to the clearing house — ${count} cheque(s)` : 'No pending cheques to send');
|
||||
this.loadPresentments();
|
||||
this.loadBatches();
|
||||
},
|
||||
error: (err: any) => {
|
||||
this.busy = false;
|
||||
this.notify(clearingError(err, 'Could not send the pending batch'), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user