- 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>
538 lines
18 KiB
TypeScript
538 lines
18 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, 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);
|
|
}
|
|
});
|
|
}
|
|
}
|