feat(clearing): add CTS/NACH clearing UI with real NPCI batch sessions
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

- 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:
Jaanu423
2026-07-21 15:57:45 +05:30
co-authored by Claude Sonnet 5
parent ed2a40a8bf
commit 6d205c0ed2
55 changed files with 6801 additions and 8 deletions
+4
View File
@@ -37,6 +37,8 @@ import { GroupsModule } from './groups/groups.module';
import { CentersModule } from './centers/centers.module';
import { AccountingModule } from './accounting/accounting.module';
import { ChequeBookModule } from './chequebook/chequebook.module';
import { CtsClearingModule } from './ctsclearing/ctsclearing.module';
import { NachClearingModule } from './nachclearing/nachclearing.module';
import { SystemModule } from './system/system.module';
import { ProductsModule } from './products/products.module';
import { OrganizationModule } from './organization/organization.module';
@@ -144,6 +146,8 @@ export function HttpLoaderFactory(http: HttpClient) {
CentersModule,
AccountingModule,
ChequeBookModule,
CtsClearingModule,
NachClearingModule,
SystemModule,
ProductsModule,
OrganizationModule,
@@ -0,0 +1,54 @@
/**
* 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/.
*/
/**
* Maps a clearing status/decision string to one of the three shared .pill classes
* (ok / bad / neutral). Shared by every CTS, NACH and settlement/reconciliation screen so the
* colour language stays consistent across the whole clearing feature.
*/
export function pillClass(status: string | null | undefined): 'ok' | 'bad' | 'neutral' {
const s = (status || '').toUpperCase();
const ok = [
'PASSED',
'PASS',
'POSTED',
'MATCHED',
'SETTLED',
'SENT',
'SENT_TO_CLEARING_HOUSE',
'PROCESSED',
'VALIDATED',
'ACTIVE',
'CLEARED',
'HONOURED',
'COMPLETED'
];
const bad = [
'RETURNED',
'RETURN',
'REJECTED',
'MISMATCH',
'DISHONOURED',
'CANCELLED',
'FAILED',
'FAIL',
'UNROUTABLE'
];
if (ok.includes(s)) {
return 'ok';
}
if (bad.includes(s)) {
return 'bad';
}
return 'neutral';
}
/** Extracts the most specific backend error message from a Fineract-style error response. */
export function clearingError(err: any, fallback = 'Request failed'): string {
return err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? fallback;
}
@@ -0,0 +1,77 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* Shared clearing services used across CTS and NACH — return-reason-code master, settlement-batch posting,
* and reconciliation.
*/
@Injectable({
providedIn: 'root'
})
export class ClearingService {
private http = inject(HttpClient);
// ----- Return reason codes -----
/** appliesTo: 'CTS' | 'NACH'. Returns a raw array of reason codes. */
getReturnReasonCodes(appliesTo: string): Observable<any> {
return this.http.get('/clearing/return-reason-codes', {
params: new HttpParams().set('appliesTo', appliesTo)
});
}
// ----- Settlement batches -----
/** Server-side paginated — returns { pageItems, totalFilteredRecords }. */
getSettlementBatches(clearingType?: string, status?: string, offset = 0, limit = 20): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (clearingType) params = params.set('clearingType', clearingType);
if (status) params = params.set('status', status);
return this.http.get('/clearing/settlement-batches', { params });
}
getSettlementBatch(id: string | number): Observable<any> {
return this.http.get(`/clearing/settlement-batches/${id}`);
}
/** payload: { clearingType, sourceBatchId }. */
prepareSettlement(payload: any): Observable<any> {
return this.http.post('/clearing/settlement-batches', payload);
}
postSettlement(id: string | number): Observable<any> {
return this.http.post(
`/clearing/settlement-batches/${id}`,
{},
{ params: new HttpParams().set('command', 'post') }
);
}
rejectSettlement(id: string | number, payload: any): Observable<any> {
return this.http.post(`/clearing/settlement-batches/${id}`, payload, {
params: new HttpParams().set('command', 'reject')
});
}
// ----- Reconciliations -----
getReconciliations(clearingType?: string, offset = 0, limit = 20): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (clearingType) params = params.set('clearingType', clearingType);
return this.http.get('/clearing/reconciliations', { params });
}
runReconciliation(payload: any): Observable<any> {
return this.http.post('/clearing/reconciliations', payload);
}
}
+16 -6
View File
@@ -991,14 +991,24 @@
</mat-menu>
<mat-menu #clearingMenu="matMenu" [overlapTrigger]="false" class="nav-dropdown">
<button mat-menu-item tabindex="0">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Cheque Honoured
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/inward']">
<fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0">
<fa-icon icon="ban" class="menu-item-icon"></fa-icon> Cheque Book Stopped
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/outward']">
<fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward Clearing (CTS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/positive-pay']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Positive Pay (PPS)
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/settlement']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Settlement
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/ctsclearing/reconciliation']">
<fa-icon icon="check-circle" class="menu-item-icon"></fa-icon> Reconciliation
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/nachclearing']">
<fa-icon icon="money-check" class="menu-item-icon"></fa-icon> NACH Clearing
</button>
<button mat-menu-item tabindex="0"><fa-icon icon="arrow-right" class="menu-item-icon"></fa-icon> Outward</button>
<button mat-menu-item tabindex="0"><fa-icon icon="arrow-left" class="menu-item-icon"></fa-icon> Inward</button>
</mat-menu>
<mat-menu #hrmMenu="matMenu" class="hrm-mega-panel" [overlapTrigger]="false">
@@ -247,6 +247,32 @@
</mat-icon>
<a matLine>{{ 'labels.menus.Cheque Book Inventory' | translate }}</a>
</mat-list-item>
<mat-list-item
[routerLink]="['/ctsclearing']"
[matTooltipPosition]="tooltipPosition"
matTooltip="{{ 'tooltips.CTS Clearing' | translate }}"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
*mifosxHasPermission="'MENU_CLEARING'"
>
<mat-icon matListIcon>
<fa-icon icon="exchange" size="sm"></fa-icon>
</mat-icon>
<a matLine>{{ 'labels.menus.CTS Clearing' | translate }}</a>
</mat-list-item>
<mat-list-item
[routerLink]="['/nachclearing']"
[matTooltipPosition]="tooltipPosition"
matTooltip="{{ 'tooltips.NACH Clearing' | translate }}"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
*mifosxHasPermission="'MENU_CLEARING'"
>
<mat-icon matListIcon>
<fa-icon icon="money-check" size="sm"></fa-icon>
</mat-icon>
<a matLine>{{ 'labels.menus.NACH Clearing' | translate }}</a>
</mat-list-item>
<mat-list-item
[routerLink]="['/rbi-circulars']"
[matTooltipPosition]="tooltipPosition"
@@ -0,0 +1,29 @@
<!--
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/.
-->
<div class="container">
<div class="page-header">
<h2>{{ 'labels.menus.CTS Clearing' | translate }}</h2>
</div>
<div class="cards-grid">
@for (screen of screens; track screen.label) {
<mat-card class="cb-card" [routerLink]="screen.route">
<mat-card-content>
<div class="card-head">
<span class="card-icon">
<fa-icon [icon]="screen.icon"></fa-icon>
</span>
<span class="card-title">{{ screen.label | translate }}</span>
</div>
<p class="card-desc">{{ screen.description | translate }}</p>
</mat-card-content>
</mat-card>
}
</div>
</div>
@@ -0,0 +1,68 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.cb-card {
cursor: pointer;
transition:
box-shadow 0.2s ease,
transform 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
transform: translateY(-2px);
}
}
.card-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 10px;
flex: 0 0 auto;
background: rgb(25 118 210 / 10%);
color: #1976d2;
font-size: 1.15rem;
}
.card-title {
font-size: 1.05rem;
font-weight: 600;
line-height: 1.2;
}
.card-desc {
color: rgb(0 0 0 / 60%);
font-size: 0.85rem;
margin: 0;
}
@@ -0,0 +1,73 @@
/**
* 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 } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatCardModule } from '@angular/material/card';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* CTS Clearing landing page — one card per screen (inward presentment, outward presentment, settlement,
* reconciliation) plus a shortcut to the shared clearing network directory.
*/
@Component({
selector: 'mifosx-ctsclearing-dashboard',
templateUrl: './ctsclearing-dashboard.component.html',
styleUrls: ['./ctsclearing-dashboard.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
MatIconModule,
MatCardModule,
FaIconComponent
]
})
export class CtsClearingDashboardComponent {
screens = [
{
route: [
'/ctsclearing',
'inward'
],
label: 'Inward Clearing',
icon: 'arrow-left',
description: 'Presentment batches drawn on us — per-cheque validation, pass/return decisioning'
},
{
route: [
'/ctsclearing',
'outward'
],
label: 'Outward Clearing',
icon: 'arrow-right',
description:
'Cheques deposited by our customers — capture, then browse the 2-hourly clearing-session batches to settle/dishonour'
},
{
route: [
'/ctsclearing',
'settlement'
],
label: 'Settlement',
icon: 'money-check',
description: 'Net settlement batches — prepare, post to the GL, reject'
},
{
route: [
'/ctsclearing',
'reconciliation'
],
label: 'Reconciliation',
icon: 'check-circle',
description: 'Reconcile CBS totals against the clearing-house figures'
}
];
}
@@ -0,0 +1,81 @@
/**
* 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 { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
/** Routing Imports */
import { Route } from '../core/route/route.service';
/** Custom Components */
import { CtsClearingDashboardComponent } from './ctsclearing-dashboard/ctsclearing-dashboard.component';
import { InwardBatchesComponent } from './inward-batches/inward-batches.component';
import { InwardBatchDetailComponent } from './inward-batch-detail/inward-batch-detail.component';
import { OutwardPresentmentComponent } from './outward-presentment/outward-presentment.component';
import { OutwardBatchDetailComponent } from './outward-batch-detail/outward-batch-detail.component';
import { SettlementComponent } from './settlement/settlement.component';
import { ReconciliationComponent } from './reconciliation/reconciliation.component';
import { PositivePayComponent } from './positive-pay/positive-pay.component';
/** CTS Clearing Routes */
const routes: Routes = [
Route.withShell([
{
path: 'ctsclearing',
data: { title: 'CTS Clearing', breadcrumb: 'CTS Clearing' },
children: [
{
path: '',
component: CtsClearingDashboardComponent
},
{
path: 'inward',
component: InwardBatchesComponent,
data: { title: 'Inward Clearing', breadcrumb: 'Inward Clearing' }
},
{
path: 'inward/:id',
component: InwardBatchDetailComponent,
data: { title: 'Inward Batch', routeParamBreadcrumb: 'id' }
},
{
path: 'outward',
component: OutwardPresentmentComponent,
data: { title: 'Outward Clearing', breadcrumb: 'Outward Clearing' }
},
{
path: 'outward-batches/:id',
component: OutwardBatchDetailComponent,
data: { title: 'Outward Batch', routeParamBreadcrumb: 'id' }
},
{
path: 'settlement',
component: SettlementComponent,
data: { title: 'Settlement', breadcrumb: 'Settlement' }
},
{
path: 'reconciliation',
component: ReconciliationComponent,
data: { title: 'Reconciliation', breadcrumb: 'Reconciliation' }
},
{
path: 'positive-pay',
component: PositivePayComponent,
data: { title: 'Positive Pay', breadcrumb: 'Positive Pay' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class CtsClearingRoutingModule {}
+53
View File
@@ -0,0 +1,53 @@
/**
* 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 { NgModule } from '@angular/core';
/** Custom Modules */
import { SharedModule } from '../shared/shared.module';
import { DirectivesModule } from '../directives/directives.module';
import { PipesModule } from 'app/pipes/pipes.module';
import { CtsClearingRoutingModule } from './ctsclearing-routing.module';
/** Custom Components (standalone) */
import { CtsClearingDashboardComponent } from './ctsclearing-dashboard/ctsclearing-dashboard.component';
import { InwardBatchesComponent } from './inward-batches/inward-batches.component';
import { InwardBatchDetailComponent } from './inward-batch-detail/inward-batch-detail.component';
import { ItemDecisionDialogComponent } from './inward-batch-detail/item-decision-dialog/item-decision-dialog.component';
import { OutwardPresentmentComponent } from './outward-presentment/outward-presentment.component';
import { OutwardBatchDetailComponent } from './outward-batch-detail/outward-batch-detail.component';
import { SettlementComponent } from './settlement/settlement.component';
import { ReconciliationComponent } from './reconciliation/reconciliation.component';
import { PositivePayComponent } from './positive-pay/positive-pay.component';
/**
* CTS (Cheque Truncation System) Clearing Module.
*
* Inward presentment batches with per-cheque validation and pass/return decisioning, outward
* presentment life-cycle (capture -> send -> settle/dishonour), plus the CTS views of the shared
* settlement and reconciliation screens.
*/
@NgModule({
imports: [
SharedModule,
DirectivesModule,
PipesModule,
CtsClearingRoutingModule,
CtsClearingDashboardComponent,
InwardBatchesComponent,
InwardBatchDetailComponent,
ItemDecisionDialogComponent,
OutwardPresentmentComponent,
OutwardBatchDetailComponent,
SettlementComponent,
ReconciliationComponent,
PositivePayComponent
]
})
export class CtsClearingModule {}
+210
View File
@@ -0,0 +1,210 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* CTS (Cheque Truncation System) clearing — inward presentment batches with the per-cheque validation
* ruleset and pass/return decisioning, and the outward presentment life-cycle (capture -> send ->
* settle/dishonour). Shared settlement/reconciliation/bank/routing calls live in ClearingService.
*/
@Injectable({
providedIn: 'root'
})
export class CtsClearingService {
private http = inject(HttpClient);
/**
* Persists the Outward Clearing screen's Batches Today/All filter across navigation (e.g. viewing a batch
* and coming back) — otherwise the component would reset to "today" every time it's recreated.
*/
outwardBatchesDateFilter: 'today' | 'all' = 'today';
/** Same persistence, for the Inward Clearing screen's Today/Yesterday/All quick filter. */
inwardBatchesDateFilter: 'today' | 'yesterday' | 'all' = 'today';
// ----- Inward batches -----
/** Server-side paginated — returns { pageItems, totalFilteredRecords }. */
getInwardBatches(
status?: string,
offset = 0,
limit = 20,
filters?: { fromDate?: string; toDate?: string; batchReference?: string; presentingBankName?: string }
): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (status) params = params.set('status', status);
if (filters?.fromDate) params = params.set('fromDate', filters.fromDate);
if (filters?.toDate) params = params.set('toDate', filters.toDate);
if (filters?.batchReference) params = params.set('batchReference', filters.batchReference);
if (filters?.presentingBankName) params = params.set('presentingBankName', filters.presentingBankName);
return this.http.get('/ctsclearing/inward-batches', { params });
}
getInwardBatch(id: string | number): Observable<any> {
return this.http.get(`/ctsclearing/inward-batches/${id}`);
}
/** Sub-list may come back as a raw array OR a page — callers handle both. */
getInwardBatchItems(id: string | number): Observable<any> {
return this.http.get(`/ctsclearing/inward-batches/${id}/items`);
}
createInwardBatch(payload: any): Observable<any> {
return this.http.post('/ctsclearing/inward-batches', payload);
}
validateInwardBatch(id: string | number): Observable<any> {
return this.http.post(
`/ctsclearing/inward-batches/${id}`,
{},
{ params: new HttpParams().set('command', 'validate') }
);
}
// ----- Inward items -----
getInwardItem(id: string | number): Observable<any> {
return this.http.get(`/ctsclearing/inward-items/${id}`);
}
passInwardItem(id: string | number, payload: any): Observable<any> {
return this.http.post(`/ctsclearing/inward-items/${id}`, payload, {
params: new HttpParams().set('command', 'pass')
});
}
/** Uploads a real scanned cheque image (front|back) for an inward item. */
uploadInwardItemImage(id: string | number, side: 'front' | 'back', file: File): Observable<any> {
const formData = new FormData();
formData.append('file', file, file.name);
return this.http.post(`/ctsclearing/inward-items/${id}/image`, formData, {
params: new HttpParams().set('side', side)
});
}
/** Fetches the front|back cheque image as a base64 data URL (bind straight to [src]), via an authenticated call. */
getInwardItemImage(id: string | number, side: 'front' | 'back'): Observable<string> {
return this.http.get(`/ctsclearing/inward-items/${id}/image`, {
params: new HttpParams().set('side', side),
responseType: 'text'
});
}
returnInwardItem(id: string | number, payload: any): Observable<any> {
return this.http.post(`/ctsclearing/inward-items/${id}`, payload, {
params: new HttpParams().set('command', 'return')
});
}
// ----- Outward presentments -----
getOutwardPresentments(
status?: string,
offset = 0,
limit = 20,
filters?: {
fromDate?: string;
toDate?: string;
chequeNumber?: string;
drawerBankName?: string;
payeeName?: string;
batchReference?: string;
}
): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (status) params = params.set('status', status);
if (filters?.fromDate) params = params.set('fromDate', filters.fromDate);
if (filters?.toDate) params = params.set('toDate', filters.toDate);
if (filters?.chequeNumber) params = params.set('chequeNumber', filters.chequeNumber);
if (filters?.drawerBankName) params = params.set('drawerBankName', filters.drawerBankName);
if (filters?.payeeName) params = params.set('payeeName', filters.payeeName);
if (filters?.batchReference) params = params.set('batchReference', filters.batchReference);
return this.http.get('/ctsclearing/outward-presentments', { params });
}
/** One row per outward clearing session ("batch") — cheques grouped by the shared batchReference they were sent under. */
getOutwardBatches(
offset = 0,
limit = 20,
filters?: { fromDate?: string; toDate?: string; batchReference?: string }
): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (filters?.fromDate) params = params.set('fromDate', filters.fromDate);
if (filters?.toDate) params = params.set('toDate', filters.toDate);
if (filters?.batchReference) params = params.set('batchReference', filters.batchReference);
return this.http.get('/ctsclearing/outward-presentments/batches', { params });
}
getOutwardPresentment(id: string | number): Observable<any> {
return this.http.get(`/ctsclearing/outward-presentments/${id}`);
}
createOutwardPresentment(payload: any): Observable<any> {
return this.http.post('/ctsclearing/outward-presentments', payload);
}
/** command: 'send' | 'settle' | 'dishonour'. Dishonour carries { dishonourReasonCode }. */
actOnOutwardPresentment(id: string | number, command: string, payload: any = {}): Observable<any> {
return this.http.post(`/ctsclearing/outward-presentments/${id}`, payload, {
params: new HttpParams().set('command', command)
});
}
/** Uploads a real scanned cheque image (front|back) for an outward presentment. */
uploadOutwardPresentmentImage(id: string | number, side: 'front' | 'back', file: File): Observable<any> {
const formData = new FormData();
formData.append('file', file, file.name);
return this.http.post(`/ctsclearing/outward-presentments/${id}/image`, formData, {
params: new HttpParams().set('side', side)
});
}
/** Fetches the front|back cheque image as a base64 data URL (bind straight to [src]), via an authenticated call. */
getOutwardPresentmentImage(id: string | number, side: 'front' | 'back'): Observable<string> {
return this.http.get(`/ctsclearing/outward-presentments/${id}/image`, {
params: new HttpParams().set('side', side),
responseType: 'text'
});
}
/** Offices/branches, for the "presenting branch" selector on the outward capture form. */
getOffices(): Observable<any> {
return this.http.get('/offices');
}
/**
* Bundles every CAPTURED outward presentment into one CTS batch file and sends it to the clearing house in
* a single go — the same action the (disabled by default) clearing-session scheduler would run automatically.
*/
sendPendingOutwardBatch(): Observable<any> {
return this.http.post('/ctsclearing/outward-presentments/send-pending-batch', {});
}
// ----- Positive Pay (RBI PPS) -----
getPositivePayRegistrations(accountId?: string | number, status?: string, offset = 0, limit = 20): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (accountId) params = params.set('accountId', accountId);
if (status) params = params.set('status', status);
return this.http.get('/ctsclearing/positive-pay', { params });
}
registerPositivePay(payload: any): Observable<any> {
return this.http.post('/ctsclearing/positive-pay', payload);
}
cancelPositivePay(id: string | number, payload: any): Observable<any> {
return this.http.post(`/ctsclearing/positive-pay/${id}`, payload, {
params: new HttpParams().set('command', 'cancel')
});
}
}
@@ -0,0 +1,225 @@
<!--
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/.
-->
<div class="container">
<div class="page-header">
<h2>{{ 'labels.inputs.Inward Batch' | translate }} #{{ batch.id }}</h2>
<button mat-raised-button [routerLink]="['/ctsclearing', 'inward']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
<mat-card class="info-card">
<mat-card-content>
<div class="info-grid">
<div>
<span class="k">{{ 'labels.inputs.Batch Reference' | translate }}</span
><span>{{ batch.batchReference }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Session Date' | translate }}</span
><span>{{ batch.sessionDate ? (batch.sessionDate | dateFormat) : '–' }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Presentment Type' | translate }}</span
><span>{{ batch.presentmentType }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Items' | translate }}</span
><span>{{ batch.itemCount ?? batch.totalCount ?? items.length }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Total Amount' | translate }}</span
><span>{{ batch.totalAmount ?? '–' }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Status' | translate }}</span>
<span
><span class="pill" [ngClass]="pillClass(batch.status)">{{ batch.status }}</span></span
>
</div>
@if (batch.presentingBankName || batch.presentingBankCode) {
<div>
<span class="k">{{ 'labels.inputs.Presenting Bank' | translate }}</span
><span
>{{ batch.presentingBankName || '–' }} <span class="muted">{{ batch.presentingBankCode }}</span></span
>
</div>
}
@if (batch.returnTatDeadline) {
<div>
<span class="k">{{ 'labels.inputs.Return TAT Deadline' | translate }}</span
><span>{{ batch.returnTatDeadline | dateFormat }}</span>
</div>
}
</div>
</mat-card-content>
</mat-card>
<div class="action-toolbar">
<button mat-raised-button color="primary" (click)="runValidation()" [disabled]="busy">
<fa-icon icon="check-circle"></fa-icon>&nbsp;{{ 'labels.buttons.Run Validation' | translate }}
</button>
<button mat-raised-button (click)="prepareSettlement()" [disabled]="busy">
<fa-icon icon="money-check"></fa-icon>&nbsp;{{ 'labels.buttons.Prepare Settlement' | translate }}
</button>
</div>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="items">
<ng-container matColumnDef="chequeNumber">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Cheque Number' | translate }}</th>
<td mat-cell *matCellDef="let it">
{{ it.chequeNumber }}
@if (it.instrumentStopPaymentFlag) {
<span class="pill bad" matTooltip="Stop payment was flagged on the depositing side">STOP</span>
}
@if (it.micrCode) {
<span class="muted">{{ it.micrCode }}</span>
}
@if (it.ifsc) {
<span class="muted">{{ it.ifsc }}</span>
}
@if (it.ctsReferenceNumber) {
<span class="muted">{{ it.ctsReferenceNumber }}</span>
}
@if (it.instrumentDate) {
<span class="muted">Dated {{ it.instrumentDate | dateFormat }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="drawer">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Account' | translate }}</th>
<td mat-cell *matCellDef="let it">
{{ it.accountNo || it.accountId || '–' }}
@if (it.clientName) {
<span class="muted">{{ it.clientName }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="payeeName">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payee Name' | translate }}</th>
<td mat-cell *matCellDef="let it">{{ it.payeeName }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Amount' | translate }}</th>
<td mat-cell *matCellDef="let it" class="num">
{{ it.amount }}
@if (it.amountInWords) {
<div class="muted">{{ it.amountInWords }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="image">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Image' | translate }}</th>
<td mat-cell *matCellDef="let it">
<div class="image-cell">
@if (getItemImageUrl(it, 'front')) {
<img
[src]="getItemImageUrl(it, 'front')"
class="cheque-thumb"
alt="front"
matTooltip="Front image — click to zoom"
(click)="openImageZoom(it, 'front')"
/>
} @else {
<input #frontFile type="file" accept="image/*" hidden (change)="uploadItemImage(it, 'front', $event)" />
<button mat-icon-button (click)="frontFile.click()" matTooltip="Upload front image">
<fa-icon icon="plus"></fa-icon>
</button>
}
@if (getItemImageUrl(it, 'back')) {
<img
[src]="getItemImageUrl(it, 'back')"
class="cheque-thumb"
alt="back"
matTooltip="Back image — click to zoom"
(click)="openImageZoom(it, 'back')"
/>
} @else {
<input #backFile type="file" accept="image/*" hidden (change)="uploadItemImage(it, 'back', $event)" />
<button mat-icon-button (click)="backFile.click()" matTooltip="Upload back image">
<fa-icon icon="plus"></fa-icon>
</button>
}
</div>
</td>
</ng-container>
<ng-container matColumnDef="checks">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Validation' | translate }}</th>
<td mat-cell *matCellDef="let it">
<div class="check-badges">
@for (rule of rules; track rule.key) {
<span class="pill" [ngClass]="ruleClass(it, rule)" [title]="rule.label">{{ rule.label }}</span>
}
</div>
@if (it.validationDetail) {
<div class="validation-detail muted">{{ it.validationDetail }}</div>
}
@if (it.iqaStatus && it.iqaStatus !== 'NA') {
<span class="pill" [ngClass]="pillClass(it.iqaStatus)" matTooltip="Image Quality Assessment"
>IQA: {{ it.iqaStatus }}</span
>
}
@if (it.iqaDetail) {
<div class="muted">{{ it.iqaDetail }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="decision">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Decision' | translate }}</th>
<td mat-cell *matCellDef="let it">
@if (it.decision) {
<span class="pill" [ngClass]="pillClass(it.decision)">{{ it.decision }}</span>
} @else {
<span class="pill neutral">{{ it.itemStatus || 'PENDING' }}</span>
}
@if (it.returnReasonCode) {
<div class="muted">{{ it.returnReasonCode }}</div>
}
</td>
</ng-container>
<ng-container matColumnDef="hold">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Hold' | translate }}</th>
<td mat-cell *matCellDef="let it" class="num">
@if (it.holdPlaced) {
{{ it.holdAmount ?? '✓' }}
} @else {
–
}
</td>
</ng-container>
<ng-container matColumnDef="returnDeadline">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Return Deadline' | translate }}</th>
<td mat-cell *matCellDef="let it">
{{ it.returnDeadline ? (it.returnDeadline | dateFormat) : '–' }}
@if (it.originalSerialNumber) {
<span class="muted">Orig # {{ it.originalSerialNumber }}</span>
}
@if (it.depositDate) {
<span class="muted">Deposited {{ it.depositDate | dateFormat }}</span>
}
@if (it.presentingBranchCode) {
<span class="muted">Branch {{ it.presentingBranchCode }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let it">
<button mat-button color="primary" (click)="decide(it)">{{ 'labels.buttons.Decide' | translate }}</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
@if (items.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
</div>
<div #lightboxRoot style="display: none"></div>
</div>
@@ -0,0 +1,127 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.info-card {
margin-bottom: 16px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px 20px;
> div {
display: flex;
flex-direction: column;
}
.k {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 50%);
margin-bottom: 2px;
}
}
.action-toolbar {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.table-wrapper {
overflow-x: auto;
background: #fff;
}
table {
width: 100%;
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.check-badges {
display: flex;
gap: 4px;
flex-wrap: wrap;
max-width: 320px;
}
.validation-detail {
margin-top: 4px;
max-width: 320px;
}
.muted {
display: block;
color: rgb(0 0 0 / 50%);
font-size: 0.78rem;
margin-left: 0;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
white-space: nowrap;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.bad {
background: #fde3e3;
color: #d32f2f;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
.image-cell {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.cheque-thumb {
width: 40px;
height: 28px;
object-fit: cover;
border: 1px solid #ddd;
border-radius: 3px;
cursor: pointer;
}
@@ -0,0 +1,247 @@
/**
* 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 { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog } from '@angular/material/dialog';
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 { ClearingService } from '../../clearing-network/clearing.service';
import { ItemDecisionDialogComponent } from './item-decision-dialog/item-decision-dialog.component';
import { pillClass, clearingError } from '../../clearing-network/clearing-status';
/**
* One inward CTS batch — its header, the per-cheque items table with a badge per validation rule, and the
* batch-level "Run Validation" and "Prepare Settlement" actions. Each item can be manually PASSed or
* RETURNed through the item-decision dialog.
*/
@Component({
selector: 'mifosx-inward-batch-detail',
templateUrl: './inward-batch-detail.component.html',
styleUrls: ['./inward-batch-detail.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
MatButtonModule,
MatTableModule,
MatSnackBarModule,
FaIconComponent
]
})
export class InwardBatchDetailComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private ctsService = inject(CtsClearingService);
private clearingService = inject(ClearingService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
@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 });
}
batchId!: string;
batch: any = {};
items: any[] = [];
busy = false;
/** Per-cheque validation rules — label + the item field, and whether "true" is the good outcome. */
readonly rules: { key: string; label: string; goodWhenTrue: boolean }[] = [
{ key: 'accountCheckPassed', label: 'Account', goodWhenTrue: true },
{ key: 'balanceCheckPassed', label: 'Balance', goodWhenTrue: true },
{ key: 'signatureOk', label: 'Signature', goodWhenTrue: true },
{ key: 'duplicateCheckPassed', label: 'Duplicate', goodWhenTrue: true },
{ key: 'stopPaymentCheckPassed', label: 'Stop Pay', goodWhenTrue: true },
{ key: 'amountWordsFiguresMatch', label: 'Amt Match', goodWhenTrue: true },
{ key: 'staleOrPostdated', label: 'Stale/Postdated', goodWhenTrue: false }
];
columns = [
'chequeNumber',
'drawer',
'payeeName',
'amount',
'image',
'checks',
'decision',
'hold',
'returnDeadline',
'actions'
];
ngOnInit(): void {
this.batchId = this.route.snapshot.paramMap.get('id') as string;
this.loadAll();
}
ngOnDestroy(): void {
this.destroyLightbox();
}
/** Cheque image data URLs, keyed "<itemId>_<side>" — populated once fetched, absent means none uploaded (yet). */
imageUrls: { [key: string]: string } = {};
loadAll(): void {
this.ctsService.getInwardBatch(this.batchId).subscribe((data: any) => (this.batch = data || {}));
this.ctsService.getInwardBatchItems(this.batchId).subscribe((data: any) => {
this.items = data?.pageItems ?? (Array.isArray(data) ? data : []);
this.items.forEach((it: any) => {
this.loadItemImage(it, 'front');
this.loadItemImage(it, 'back');
});
});
}
/** Fetches the image as an authenticated base64 data URL and caches it for [src] binding — 404 (no upload yet) is expected and silent. */
loadItemImage(item: any, side: 'front' | 'back'): void {
this.ctsService.getInwardItemImage(item.id, side).subscribe({
next: (dataUrl: string) => (this.imageUrls[`${item.id}_${side}`] = dataUrl),
error: () => {
/* no image uploaded for this side yet */
}
});
}
getItemImageUrl(item: any, side: 'front' | 'back'): string | null {
return this.imageUrls[`${item.id}_${side}`] || null;
}
/** Opens the front/back cheque image(s) in a zoomable, pannable lightbox — same lightgallery setup used app-wide. */
openImageZoom(item: any, side: 'front' | 'back'): void {
const frontUrl = this.getItemImageUrl(item, 'front');
const backUrl = this.getItemImageUrl(item, 'back');
const items: GalleryItem[] = [];
if (frontUrl) {
items.push({
src: frontUrl,
thumb: frontUrl,
subHtml: `<div class="lg-caption"><h4>Front — Cheque ${item.chequeNumber}</h4></div>`
});
}
if (backUrl) {
items.push({
src: backUrl,
thumb: backUrl,
subHtml: `<div class="lg-caption"><h4>Back — Cheque ${item.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(item: 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.uploadInwardItemImage(item.id, side, file).subscribe({
next: () => {
this.notify(side === 'front' ? 'Front image uploaded' : 'Back image uploaded');
this.loadItemImage(item, side);
},
error: (err: any) => this.notify(clearingError(err, 'Image upload failed'), true)
});
input.value = '';
}
/** ok / bad / neutral for a single boolean validation rule, respecting whether true is the good outcome. */
ruleClass(item: any, rule: { key: string; goodWhenTrue: boolean }): 'ok' | 'bad' | 'neutral' {
const value = item?.[rule.key];
if (value === null || value === undefined) {
return 'neutral';
}
const good = rule.goodWhenTrue ? value === true : value === false;
return good ? 'ok' : 'bad';
}
runValidation(): void {
if (this.busy) {
return;
}
this.busy = true;
this.ctsService.validateInwardBatch(this.batchId).subscribe({
next: () => {
this.busy = false;
this.notify('Validation run');
this.loadAll();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Validation failed'), true);
}
});
}
prepareSettlement(): void {
if (this.busy) {
return;
}
this.busy = true;
this.clearingService.prepareSettlement({ clearingType: 'CTS', sourceBatchId: this.batch.id }).subscribe({
next: () => {
this.busy = false;
this.notify('Settlement batch prepared — see Settlement');
this.loadAll();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Could not prepare settlement'), true);
}
});
}
decide(item: any): void {
const dialogRef = this.dialog.open(ItemDecisionDialogComponent, { data: { item }, width: '480px' });
dialogRef.afterClosed().subscribe((result: any) => {
if (result?.actioned) {
this.loadAll();
}
});
}
}
@@ -0,0 +1,77 @@
<!--
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/.
-->
<h1 mat-dialog-title>{{ 'labels.inputs.Cheque' | translate }} {{ item.chequeNumber }}</h1>
<div mat-dialog-content>
<div class="info-grid">
<div>
<span class="k">{{ 'labels.inputs.Payee Name' | translate }}</span
><span>{{ item.payeeName || '–' }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Amount' | translate }}</span
><span>{{ item.amount }}</span>
</div>
<div>
<span class="k">{{ 'labels.inputs.Account' | translate }}</span
><span>{{ item.accountNo || item.accountId || '–' }}</span>
</div>
</div>
<div class="mode-toggle">
<button mat-stroked-button type="button" [class.active]="mode === 'PASS'" color="primary" (click)="setMode('PASS')">
{{ 'labels.buttons.Pass' | translate }}
</button>
<button
mat-stroked-button
type="button"
[class.active]="mode === 'RETURN'"
color="warn"
(click)="setMode('RETURN')"
>
{{ 'labels.buttons.Return' | translate }}
</button>
</div>
@if (mode === 'PASS') {
<form [formGroup]="passForm">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<input matInput formControlName="note" />
</mat-form-field>
</form>
} @else {
<form [formGroup]="returnForm">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Return Reason Code' | translate }}</mat-label>
<mat-select formControlName="returnReasonCode" required>
@for (c of reasonCodes; track c.id) {
<mat-option [value]="c.code">{{ c.code }} — {{ c.description }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
<input matInput formControlName="reason" />
</mat-form-field>
</form>
}
</div>
<div mat-dialog-actions align="end">
<button mat-raised-button type="button" mat-dialog-close>{{ 'labels.buttons.Cancel' | translate }}</button>
<button
mat-raised-button
[color]="mode === 'RETURN' ? 'warn' : 'primary'"
(click)="submit()"
[disabled]="busy || (mode === 'RETURN' && returnForm.invalid)"
>
{{ (mode === 'PASS' ? 'labels.buttons.Pass' : 'labels.buttons.Return') | translate }}
</button>
</div>
@@ -0,0 +1,41 @@
/**
* 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/.
*/
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 10px 16px;
margin-bottom: 16px;
> div {
display: flex;
flex-direction: column;
}
.k {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(0 0 0 / 50%);
margin-bottom: 2px;
}
}
.mode-toggle {
display: flex;
gap: 8px;
margin-bottom: 12px;
button.active {
background: rgb(25 118 210 / 12%);
}
}
mat-form-field {
width: 100%;
}
@@ -0,0 +1,128 @@
/**
* 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 {
MatDialogRef,
MAT_DIALOG_DATA,
MatDialogTitle,
MatDialogContent,
MatDialogActions
} from '@angular/material/dialog';
import { CdkScrollable } from '@angular/cdk/scrolling';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBar } from '@angular/material/snack-bar';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { CtsClearingService } from '../../ctsclearing.service';
import { ClearingService } from '../../../clearing-network/clearing.service';
import { clearingError } from '../../../clearing-network/clearing-status';
/**
* Manual decision on a single inward CTS cheque — PASS (with an optional note) or RETURN (with a return
* reason code loaded from the CTS reason-code master, plus a free-text reason).
*/
@Component({
selector: 'mifosx-item-decision-dialog',
templateUrl: './item-decision-dialog.component.html',
styleUrls: ['./item-decision-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
CdkScrollable,
MatDialogContent,
MatDialogActions,
MatButtonModule
]
})
export class ItemDecisionDialogComponent implements OnInit {
dialogRef = inject<MatDialogRef<ItemDecisionDialogComponent>>(MatDialogRef);
data = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private ctsService = inject(CtsClearingService);
private clearingService = inject(ClearingService);
private snackBar = inject(MatSnackBar);
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
item: any = this.data?.item || {};
mode: 'PASS' | 'RETURN' = 'PASS';
busy = false;
reasonCodes: any[] = [];
passForm: UntypedFormGroup;
returnForm: UntypedFormGroup;
constructor() {
this.passForm = this.formBuilder.group({
note: ['']
});
this.returnForm = this.formBuilder.group({
returnReasonCode: [
'',
Validators.required
],
reason: ['']
});
}
ngOnInit(): void {
this.clearingService.getReturnReasonCodes('CTS').subscribe((res: any) => {
const list = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.reasonCodes = list.filter((c: any) => c.active !== false);
});
}
setMode(mode: 'PASS' | 'RETURN'): void {
this.mode = mode;
}
submit(): void {
if (this.busy) {
return;
}
if (this.mode === 'PASS') {
this.busy = true;
const payload = { note: this.passForm.value.note || undefined, locale: 'en' };
this.ctsService.passInwardItem(this.item.id, payload).subscribe({
next: () => {
this.busy = false;
this.notify('Cheque passed');
this.dialogRef.close({ actioned: true });
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Could not pass the cheque'), true);
}
});
} else {
if (this.returnForm.invalid) {
return;
}
this.busy = true;
const raw = this.returnForm.value;
const payload = { returnReasonCode: raw.returnReasonCode, reason: raw.reason || undefined, locale: 'en' };
this.ctsService.returnInwardItem(this.item.id, payload).subscribe({
next: () => {
this.busy = false;
this.notify('Cheque returned');
this.dialogRef.close({ actioned: true });
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Could not return the cheque'), true);
}
});
}
}
}
@@ -0,0 +1,258 @@
<!--
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/.
-->
<div class="container">
<div class="page-header">
<h2>{{ 'labels.menus.Inward Clearing' | translate }}</h2>
<div class="header-actions">
<button mat-raised-button color="primary" (click)="showReceiveForm = !showReceiveForm">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'labels.buttons.Receive Batch' | translate }}
</button>
<button mat-raised-button [routerLink]="['/ctsclearing']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
</div>
@if (!showReceiveForm) {
<div class="filter-row">
@for (s of ['RECEIVED', 'VALIDATED', 'SETTLED']; track s) {
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
}
</div>
<div class="filter-row">
<button mat-stroked-button [class.active]="quickFilterMode === 'today'" (click)="setQuickFilter('today')">
Today
</button>
<button mat-stroked-button [class.active]="quickFilterMode === 'yesterday'" (click)="setQuickFilter('yesterday')">
Yesterday
</button>
<button mat-stroked-button [class.active]="quickFilterMode === 'all'" (click)="setQuickFilter('all')">All</button>
</div>
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="filterForm" (ngSubmit)="applyFilters()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.From Date' | translate }}</mat-label>
<input matInput [matDatepicker]="fromPicker" formControlName="fromDate" />
<mat-datepicker-toggle matSuffix [for]="fromPicker"></mat-datepicker-toggle>
<mat-datepicker #fromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.To Date' | translate }}</mat-label>
<input matInput [matDatepicker]="toPicker" formControlName="toDate" />
<mat-datepicker-toggle matSuffix [for]="toPicker"></mat-datepicker-toggle>
<mat-datepicker #toPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Batch Reference' | translate }}</mat-label>
<input matInput formControlName="batchReference" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Presenting Bank Name' | translate }}</mat-label>
<input matInput formControlName="presentingBankName" />
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="clearFilters()">
{{ 'labels.buttons.Clear' | translate }}
</button>
<button mat-raised-button color="primary" type="submit">{{ 'labels.buttons.Filter' | translate }}</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
@if (showReceiveForm) {
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="receiveForm" (ngSubmit)="submitReceive()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Batch Reference' | translate }}</mat-label>
<input matInput formControlName="batchReference" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Session Date' | translate }}</mat-label>
<input matInput [matDatepicker]="sessionPicker" formControlName="sessionDate" required />
<mat-datepicker-toggle matSuffix [for]="sessionPicker"></mat-datepicker-toggle>
<mat-datepicker #sessionPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Presentment Type' | translate }}</mat-label>
<mat-select formControlName="presentmentType" required>
<mat-option value="REGULAR">{{ 'labels.inputs.Regular' | translate }}</mat-option>
<mat-option value="EXPRESS">{{ 'labels.inputs.Express' | translate }}</mat-option>
<mat-option value="HIGH_VALUE">{{ 'labels.inputs.High Value' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Presenting Bank Name' | translate }}</mat-label>
<input matInput formControlName="presentingBankName" />
<mat-hint>{{ 'labels.text.Bank that presented this batch to us' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Presenting Bank Code' | translate }}</mat-label>
<input matInput formControlName="presentingBankCode" />
</mat-form-field>
</div>
<h3 class="section-title">{{ 'labels.inputs.Cheques' | translate }}</h3>
<div formArrayName="items" class="item-rows">
@for (row of items.controls; track row; let i = $index) {
<div class="item-row" [formGroupName]="i">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Cheque Number' | translate }}</mat-label>
<input matInput formControlName="chequeNumber" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.MICR Code' | translate }}</mat-label>
<input matInput formControlName="micrCode" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.IFSC' | translate }}</mat-label>
<input matInput formControlName="ifsc" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Payee Name' | translate }}</mat-label>
<input matInput formControlName="payeeName" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Amount' | translate }}</mat-label>
<input matInput type="number" formControlName="amount" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Amount In Words' | translate }}</mat-label>
<input matInput formControlName="amountInWords" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Instrument Date' | translate }}</mat-label>
<input matInput [matDatepicker]="instrPicker" formControlName="instrumentDate" />
<mat-datepicker-toggle matSuffix [for]="instrPicker"></mat-datepicker-toggle>
<mat-datepicker #instrPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Presented Date' | translate }}</mat-label>
<input matInput [matDatepicker]="presPicker" formControlName="presentedDate" />
<mat-datepicker-toggle matSuffix [for]="presPicker"></mat-datepicker-toggle>
<mat-datepicker #presPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Account Type' | translate }}</mat-label>
<mat-select formControlName="accountType">
<mat-option value="SAVINGS">{{ 'labels.inputs.Savings' | translate }}</mat-option>
<mat-option value="CURRENT">{{ 'labels.inputs.Current' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Account Id' | translate }}</mat-label>
<input matInput type="number" formControlName="accountId" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Original Serial No' | translate }}</mat-label>
<input matInput formControlName="originalSerialNumber" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Presenting Branch Code' | translate }}</mat-label>
<input matInput formControlName="presentingBranchCode" />
</mat-form-field>
<button
mat-icon-button
color="warn"
type="button"
class="row-remove"
(click)="removeItem(i)"
[disabled]="items.length === 1"
>
<fa-icon icon="trash"></fa-icon>
</button>
</div>
}
</div>
<button mat-stroked-button type="button" (click)="addItem()">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'labels.buttons.Add Cheque' | translate }}
</button>
<div class="form-actions">
<button mat-raised-button type="button" (click)="showReceiveForm = false">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" type="submit" [disabled]="receiveForm.invalid || busy">
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
@if (!showReceiveForm) {
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="batches">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Id' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.id }}</td>
</ng-container>
<ng-container matColumnDef="batchReference">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Batch Reference' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.batchReference }}</td>
</ng-container>
<ng-container matColumnDef="sessionDate">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Session Date' | translate }}</th>
<td mat-cell *matCellDef="let b">
{{ b.sessionDate | dateFormat }}
@if (b.receivedOn) {
<span class="muted">{{ b.receivedOn | dateFormat: 'hh:mm A' }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="presentmentType">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Presentment Type' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.presentmentType }}</td>
</ng-container>
<ng-container matColumnDef="items">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Items' | translate }}</th>
<td mat-cell *matCellDef="let b" class="num">{{ b.itemCount ?? b.totalCount ?? '–' }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Amount' | translate }}</th>
<td mat-cell *matCellDef="let b" class="num">{{ b.totalAmount ?? '–' }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let b">
<span class="pill" [ngClass]="pillClass(b.status)">{{ b.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let b" (click)="$event.stopPropagation()">
<button mat-button color="primary" (click)="open(b)">{{ 'labels.buttons.View' | translate }}</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns" (click)="open(row)" class="clickable-row"></tr>
</table>
@if (batches.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@if (totalRecords > 0) {
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pageIndex"
showFirstLastButtons
(page)="onPageChange($event)"
></mat-paginator>
}
</div>
}
</div>
@@ -0,0 +1,150 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.filter-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
button.active {
background: rgb(25 118 210 / 12%);
}
}
.add-form {
margin-bottom: 16px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 20px;
margin-bottom: 8px;
}
mat-form-field {
width: 100%;
}
.section-title {
margin: 12px 0 8px;
font-size: 0.95rem;
}
.item-rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.item-row {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 8px 12px;
align-items: center;
padding: 8px;
border: 1px solid rgb(0 0 0 / 8%);
border-radius: 8px;
}
.row-remove {
align-self: center;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.table-wrapper {
overflow-x: auto;
background: #fff;
}
table {
width: 100%;
}
.clickable-row {
cursor: pointer;
&:hover {
background: rgb(0 0 0 / 3%);
}
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.muted {
color: rgb(0 0 0 / 50%);
font-size: 0.8rem;
margin-left: 4px;
}
::ng-deep .ok-hint {
color: #28c07a;
font-weight: 600;
}
::ng-deep .err-hint {
color: #d32f2f;
font-weight: 600;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
white-space: nowrap;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.bad {
background: #fde3e3;
color: #d32f2f;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
@@ -0,0 +1,282 @@
/**
* 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 { formatDate } from '@angular/common';
import { UntypedFormArray, 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 { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { CtsClearingService } from '../ctsclearing.service';
import { pillClass, clearingError } from '../../clearing-network/clearing-status';
/**
* Inward CTS presentment batches — cheques drawn on us, received from the clearing house. Lists batches
* and captures a new batch with inline per-cheque item rows before it is validated on the detail screen.
*/
@Component({
selector: 'mifosx-inward-batches',
templateUrl: './inward-batches.component.html',
styleUrls: ['./inward-batches.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule,
FaIconComponent
]
})
export class InwardBatchesComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private ctsService = inject(CtsClearingService);
private router = inject(Router);
private snackBar = inject(MatSnackBar);
pillClass = pillClass;
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
batches: any[] = [];
totalRecords = 0;
pageSize = 10;
pageIndex = 0;
pageSizeOptions = [
10,
25,
50,
100
];
statusFilter = '';
busy = false;
showReceiveForm = false;
receiveForm: UntypedFormGroup;
filterForm: UntypedFormGroup;
columns = [
'id',
'batchReference',
'sessionDate',
'presentmentType',
'items',
'amount',
'status',
'actions'
];
constructor() {
this.receiveForm = this.formBuilder.group({
batchReference: [
'',
Validators.required
],
sessionDate: [
'',
Validators.required
],
presentmentType: [
'REGULAR',
Validators.required
],
presentingBankCode: [''],
presentingBankName: [''],
items: this.formBuilder.array([])
});
// Restores whichever quick filter (Today/Yesterday/All) was last selected, so navigating to a batch and
// back doesn't silently jump back to "today" — persisted on the singleton service, not this component.
const restoredDates = this.datesForQuickFilter(this.ctsService.inwardBatchesDateFilter);
this.filterForm = this.formBuilder.group({
fromDate: [restoredDates.fromDate],
toDate: [restoredDates.toDate],
batchReference: [''],
presentingBankName: ['']
});
}
get quickFilterMode(): 'today' | 'yesterday' | 'all' {
return this.ctsService.inwardBatchesDateFilter;
}
private datesForQuickFilter(mode: 'today' | 'yesterday' | 'all'): { fromDate: Date | null; toDate: Date | null } {
if (mode === 'all') {
return { fromDate: null, toDate: null };
}
const day = new Date();
if (mode === 'yesterday') {
day.setDate(day.getDate() - 1);
}
return { fromDate: day, toDate: day };
}
setQuickFilter(mode: 'today' | 'yesterday' | 'all'): void {
this.ctsService.inwardBatchesDateFilter = mode;
const dates = this.datesForQuickFilter(mode);
this.filterForm.patchValue({ fromDate: dates.fromDate, toDate: dates.toDate });
this.pageIndex = 0;
this.loadBatches();
}
ngOnInit(): void {
this.loadBatches();
this.addItem();
}
get items(): UntypedFormArray {
return this.receiveForm.get('items') as UntypedFormArray;
}
addItem(): void {
this.items.push(
this.formBuilder.group({
chequeNumber: [
'',
Validators.required
],
micrCode: [''],
ifsc: [''],
payeeName: [''],
amount: [
'',
Validators.required
],
amountInWords: [''],
instrumentDate: [''],
presentedDate: [''],
accountType: [
'SAVINGS',
Validators.required
],
accountId: [
'',
Validators.required
],
originalSerialNumber: [''],
presentingBranchCode: ['']
})
);
}
removeItem(index: number): void {
this.items.removeAt(index);
}
loadBatches(): void {
const f = this.filterForm.value;
const filters = {
fromDate: this.toApiDate(f.fromDate),
toDate: this.toApiDate(f.toDate),
batchReference: f.batchReference || undefined,
presentingBankName: f.presentingBankName || undefined
};
this.ctsService
.getInwardBatches(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize, filters)
.subscribe((res: any) => {
this.batches = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.totalRecords = Number(res?.totalFilteredRecords ?? this.batches.length);
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.loadBatches();
}
applyFilters(): void {
// A manual date edit no longer matches a plain Today/Yesterday/All quick state — treat it as custom.
this.ctsService.inwardBatchesDateFilter = 'all';
this.pageIndex = 0;
this.loadBatches();
}
clearFilters(): void {
this.ctsService.inwardBatchesDateFilter = 'all';
this.filterForm.reset({ fromDate: '', toDate: '', batchReference: '', presentingBankName: '' });
this.pageIndex = 0;
this.loadBatches();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.loadBatches();
}
open(batch: any): void {
this.router.navigate([
'/ctsclearing',
'inward',
batch.id
]);
}
private toApiDate(value: any): string | undefined {
if (!value) {
return undefined;
}
return formatDate(value, 'yyyy-MM-dd', 'en');
}
submitReceive(): void {
if (this.receiveForm.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.receiveForm.value;
const payload = {
batchReference: raw.batchReference,
sessionDate: this.toApiDate(raw.sessionDate),
presentmentType: raw.presentmentType,
presentingBankCode: raw.presentingBankCode || undefined,
presentingBankName: raw.presentingBankName || undefined,
items: (raw.items || []).map((it: any) => ({
chequeNumber: it.chequeNumber,
micrCode: it.micrCode || undefined,
ifsc: it.ifsc || undefined,
payeeName: it.payeeName || undefined,
amount: it.amount,
amountInWords: it.amountInWords || undefined,
instrumentDate: this.toApiDate(it.instrumentDate),
presentedDate: this.toApiDate(it.presentedDate),
accountType: it.accountType || undefined,
accountId: it.accountId || undefined,
originalSerialNumber: it.originalSerialNumber || undefined,
presentingBranchCode: it.presentingBranchCode || undefined
})),
dateFormat: 'yyyy-MM-dd',
locale: 'en'
};
this.ctsService.createInwardBatch(payload).subscribe({
next: () => {
this.busy = false;
this.showReceiveForm = false;
this.receiveForm.reset({ presentmentType: 'REGULAR' });
this.items.clear();
this.addItem();
this.notify('Inward batch received');
this.loadBatches();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Could not receive the batch'), true);
}
});
}
}
@@ -0,0 +1,192 @@
<!--
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/.
-->
<div class="container">
<div class="page-header">
<h2>{{ 'labels.inputs.Batch Reference' | translate }}: {{ batchReference }}</h2>
<button mat-raised-button [routerLink]="['/ctsclearing', 'outward']">
{{ 'labels.buttons.Back' | translate }}
</button>
</div>
<div class="filter-row">
@for (s of ['SENT_TO_CLEARING_HOUSE', 'SETTLED', 'DISHONOURED']; track s) {
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
}
</div>
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="filterForm" (ngSubmit)="applyFilters()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Cheque Number' | translate }}</mat-label>
<input matInput formControlName="chequeNumber" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Drawer Bank Name' | translate }}</mat-label>
<input matInput formControlName="drawerBankName" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Payee Name' | translate }}</mat-label>
<input matInput formControlName="payeeName" />
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="clearFilters()">
{{ 'labels.buttons.Clear' | translate }}
</button>
<button mat-raised-button color="primary" type="submit">{{ 'labels.buttons.Filter' | translate }}</button>
</div>
</form>
</mat-card-content>
</mat-card>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="presentments">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Id' | translate }}</th>
<td mat-cell *matCellDef="let p">{{ p.id }}</td>
</ng-container>
<ng-container matColumnDef="chequeNumber">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Cheque Number' | translate }}</th>
<td mat-cell *matCellDef="let p">{{ p.chequeNumber }}</td>
</ng-container>
<ng-container matColumnDef="drawerBank">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Drawer Bank' | translate }}</th>
<td mat-cell *matCellDef="let p">
{{ p.drawerBankName || '–' }}
@if (p.drawerIfsc) {
<span class="muted">{{ p.drawerIfsc }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="payeeName">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payee Name' | translate }}</th>
<td mat-cell *matCellDef="let p">{{ p.payeeName }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Amount' | translate }}</th>
<td mat-cell *matCellDef="let p" class="num">{{ p.amount }}</td>
</ng-container>
<ng-container matColumnDef="image">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Image' | translate }}</th>
<td mat-cell *matCellDef="let p">
<div class="image-cell">
@if (getItemImageUrl(p, 'front')) {
<img
[src]="getItemImageUrl(p, 'front')"
class="cheque-thumb"
alt="front"
matTooltip="Front image — click to zoom"
(click)="openImageZoom(p, 'front')"
/>
}
@if (getItemImageUrl(p, 'back')) {
<img
[src]="getItemImageUrl(p, 'back')"
class="cheque-thumb"
alt="back"
matTooltip="Back image — click to zoom"
(click)="openImageZoom(p, 'back')"
/>
}
</div>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let p">
<span class="pill" [ngClass]="pillClass(p.status)">{{ p.status }}</span>
@if (p.iqaStatus && p.iqaStatus !== 'NA') {
<span class="pill" [ngClass]="pillClass(p.iqaStatus)" matTooltip="Image Quality Assessment"
>IQA: {{ p.iqaStatus }}</span
>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let p">
@if (p.status === 'SENT_TO_CLEARING_HOUSE') {
<button mat-button color="primary" (click)="act(p, 'settle')">
{{ 'labels.buttons.Settle' | translate }}
</button>
<button mat-button color="warn" (click)="toggleDishonour(p)">
{{ 'labels.buttons.Dishonour' | translate }}
</button>
}
@if (p.ctsFileJson) {
<button mat-button color="primary" (click)="toggleEnvelope(p)">
{{ shownEnvelopeId === p.id ? 'Hide' : 'View' }} CTS file
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
@if (presentments.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@for (p of presentments; track p.id) {
@if (shownEnvelopeId === p.id && p.ctsFileJson) {
<div class="envelope-panel">
<div class="envelope-head">
📄 CTS-2010 outward file sent to the clearing house — cheque #{{ p.chequeNumber }}
</div>
<pre class="cts-file">{{ prettyJson(p.ctsFileJson) }}</pre>
@if (p.npciResponseJson) {
<div class="envelope-head">📥 NPCI's real routing response for this batch:</div>
<pre class="cts-file">{{ prettyJson(p.npciResponseJson) }}</pre>
}
</div>
}
}
@if (totalRecords > 0) {
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pageIndex"
showFirstLastButtons
(page)="onPageChange($event)"
></mat-paginator>
}
</div>
@if (dishonourForId) {
<mat-card class="add-form">
<mat-card-content>
<h3>{{ 'labels.inputs.Dishonour Cheque' | translate }}</h3>
<form [formGroup]="dishonourForm" (ngSubmit)="submitDishonour(dishonourForId)">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Dishonour Reason Code' | translate }}</mat-label>
<mat-select formControlName="dishonourReasonCode" required>
@for (c of reasonCodes; track c.id) {
<mat-option [value]="c.code">{{ c.code }} — {{ c.description }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="dishonourForId = null">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="warn" type="submit" [disabled]="dishonourForm.invalid || busy">
{{ 'labels.buttons.Dishonour' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
<div #lightboxRoot style="display: none"></div>
</div>
@@ -0,0 +1,145 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.filter-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
button.active {
background: rgb(25 118 210 / 12%);
}
}
.add-form {
margin-bottom: 16px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 20px;
margin-bottom: 8px;
}
mat-form-field {
width: 100%;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.table-wrapper {
overflow-x: auto;
background: #fff;
}
table {
width: 100%;
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.muted {
display: block;
color: rgb(0 0 0 / 50%);
font-size: 0.78rem;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
white-space: nowrap;
margin-right: 4px;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.bad {
background: #fde3e3;
color: #d32f2f;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
.envelope-panel {
margin: 8px;
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 6px;
background: #fafafa;
.envelope-head {
font-weight: 600;
margin-bottom: 6px;
}
}
.image-cell {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.cheque-thumb {
width: 40px;
height: 28px;
object-fit: cover;
border: 1px solid #ddd;
border-radius: 3px;
cursor: pointer;
}
.cts-file {
font-family: monospace;
font-size: 0.74rem;
background: #f5f7fa;
color: #1a2733;
border: 1px solid #dfe6ee;
padding: 10px;
border-radius: 4px;
max-height: 360px;
overflow: auto;
white-space: pre;
margin: 4px 0 12px;
}
@@ -0,0 +1,289 @@
/**
* 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 { ActivatedRoute } from '@angular/router';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
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 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 { ClearingService } from '../../clearing-network/clearing.service';
import { pillClass, clearingError } from '../../clearing-network/clearing-status';
/**
* One outward clearing session ("batch") — every cheque sent to NPCI sharing this batchReference. Settle /
* dishonour and image viewing work exactly as on the Outward Clearing screen; this view is scoped to a
* single session instead of showing every cheque ever captured.
*/
@Component({
selector: 'mifosx-outward-batch-detail',
templateUrl: './outward-batch-detail.component.html',
styleUrls: ['./outward-batch-detail.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule
]
})
export class OutwardBatchDetailComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private ctsService = inject(CtsClearingService);
private formBuilder = inject(UntypedFormBuilder);
private clearingService = inject(ClearingService);
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 });
}
batchReference!: string;
presentments: any[] = [];
busy = false;
dishonourForId: number | null = null;
shownEnvelopeId: number | null = null;
reasonCodes: any[] = [];
totalRecords = 0;
pageSize = 10;
pageIndex = 0;
pageSizeOptions = [
10,
25,
50,
100
];
statusFilter = '';
dishonourForm: UntypedFormGroup;
filterForm: UntypedFormGroup;
columns = [
'id',
'chequeNumber',
'drawerBank',
'payeeName',
'amount',
'image',
'status',
'actions'
];
constructor() {
this.dishonourForm = this.formBuilder.group({
dishonourReasonCode: [
'',
Validators.required
]
});
this.filterForm = this.formBuilder.group({
chequeNumber: [''],
drawerBankName: [''],
payeeName: ['']
});
}
ngOnInit(): void {
this.batchReference = this.route.snapshot.paramMap.get('id') as string;
this.loadAll();
this.clearingService.getReturnReasonCodes('CTS').subscribe((res: any) => {
const list = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.reasonCodes = list.filter((c: any) => c.active !== false);
});
}
ngOnDestroy(): void {
this.destroyLightbox();
}
/** Cheque image data URLs, keyed "<presentmentId>_<side>" — populated once fetched. */
imageUrls: { [key: string]: string } = {};
loadAll(): void {
const f = this.filterForm.value;
const filters = {
batchReference: this.batchReference,
chequeNumber: f.chequeNumber || undefined,
drawerBankName: f.drawerBankName || undefined,
payeeName: f.payeeName || undefined
};
this.ctsService
.getOutwardPresentments(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize, filters)
.subscribe((res: any) => {
this.presentments = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.totalRecords = Number(res?.totalFilteredRecords ?? this.presentments.length);
this.presentments.forEach((p: any) => {
this.loadItemImage(p, 'front');
this.loadItemImage(p, 'back');
});
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.loadAll();
}
applyFilters(): void {
this.pageIndex = 0;
this.loadAll();
}
clearFilters(): void {
this.filterForm.reset({ chequeNumber: '', drawerBankName: '', payeeName: '' });
this.pageIndex = 0;
this.loadAll();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.loadAll();
}
/** 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;
}
}
act(presentment: any, command: string): void {
if (this.busy) {
return;
}
this.busy = true;
const messages: any = {
settle: 'Presentment settled',
dishonour: 'Presentment dishonoured'
};
this.ctsService.actOnOutwardPresentment(presentment.id, command, { locale: 'en' }).subscribe({
next: () => {
this.busy = false;
this.notify(messages[command] || 'Done');
this.loadAll();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Action failed'), true);
}
});
}
toggleDishonour(presentment: any): void {
this.dishonourForId = this.dishonourForId === presentment.id ? null : presentment.id;
this.dishonourForm.reset();
}
submitDishonour(id: number): void {
if (this.dishonourForm.invalid || this.busy) {
return;
}
this.busy = true;
const payload = { dishonourReasonCode: this.dishonourForm.value.dishonourReasonCode, locale: 'en' };
this.ctsService.actOnOutwardPresentment(id, 'dishonour', payload).subscribe({
next: () => {
this.busy = false;
this.dishonourForId = null;
this.notify('Presentment dishonoured');
this.loadAll();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Dishonour failed'), true);
}
});
}
toggleEnvelope(presentment: any): void {
this.shownEnvelopeId = this.shownEnvelopeId === presentment.id ? null : presentment.id;
}
prettyJson(value: string): string {
if (!value) {
return '';
}
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
}
@@ -0,0 +1,434 @@
<!--
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/.
-->
<div class="container">
<div class="page-header">
<h2>{{ 'labels.menus.Outward Clearing' | translate }}</h2>
<div class="header-actions">
<button mat-raised-button color="primary" (click)="showCaptureForm = !showCaptureForm">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'labels.buttons.Capture Cheque' | translate }}
</button>
@if (showCaptureForm) {
<button
mat-stroked-button
color="accent"
(click)="fillDummyData()"
matTooltip="Auto-fill the capture form with sample values"
>
<fa-icon icon="magic"></fa-icon>&nbsp;Fill Dummy Data
</button>
}
<button
mat-raised-button
color="primary"
[disabled]="busy"
(click)="sendPendingBatch()"
matTooltip="Bundles every CAPTURED cheque into one CTS file and sends it to the clearing house — stands in for the (disabled) clearing-session scheduler"
>
<fa-icon icon="exchange"></fa-icon>&nbsp;Send Pending Batch
</button>
@if (showCaptureForm) {
<button mat-raised-button (click)="cancelCapture()">{{ 'labels.buttons.Back' | translate }}</button>
} @else {
<button mat-raised-button [routerLink]="['/ctsclearing']">{{ 'labels.buttons.Back' | translate }}</button>
}
</div>
</div>
@if (showCaptureForm) {
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="captureForm" (ngSubmit)="submitCapture()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Depositing Account Type' | translate }}</mat-label>
<mat-select formControlName="depositingAccountType" required>
<mat-option value="SAVINGS">{{ 'labels.inputs.Savings' | translate }}</mat-option>
<mat-option value="CURRENT">{{ 'labels.inputs.Current' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Depositing Account Id' | translate }}</mat-label>
<input matInput type="number" formControlName="depositingAccountId" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Cheque Number' | translate }}</mat-label>
<input matInput formControlName="chequeNumber" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Amount' | translate }}</mat-label>
<input matInput type="number" formControlName="amount" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Payee Name' | translate }}</mat-label>
<input matInput formControlName="payeeName" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Drawer Bank Name' | translate }}</mat-label>
<input matInput formControlName="drawerBankName" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Drawer Bank MICR' | translate }}</mat-label>
<input matInput formControlName="drawerBankMicr" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Drawer IFSC' | translate }}</mat-label>
<input matInput formControlName="drawerIfsc" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>Drawer Account Number</mat-label>
<input matInput formControlName="drawerAccountNumber" placeholder="account debited at the drawee bank" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Deposited Date' | translate }}</mat-label>
<input matInput [matDatepicker]="depPicker" formControlName="depositedDate" />
<mat-datepicker-toggle matSuffix [for]="depPicker"></mat-datepicker-toggle>
<mat-datepicker #depPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>IQA Status</mat-label>
<mat-select formControlName="iqaStatus">
<mat-option value="PASS">PASS</mat-option>
<mat-option value="FAIL">FAIL</mat-option>
<mat-option value="PENDING">PENDING</mat-option>
<mat-option value="NA">NA</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>IQA Detail</mat-label>
<input matInput formControlName="iqaDetail" placeholder="optional notes" />
</mat-form-field>
</div>
<div class="image-cell">
@if (frontPreviewUrl) {
<img [src]="frontPreviewUrl" class="cheque-thumb" alt="front" matTooltip="Front image" />
}
@if (backPreviewUrl) {
<img [src]="backPreviewUrl" class="cheque-thumb" alt="back" matTooltip="Back image" />
}
<input #frontFile type="file" accept="image/*" hidden (change)="onCaptureFileSelected('front', $event)" />
<input #backFile type="file" accept="image/*" hidden (change)="onCaptureFileSelected('back', $event)" />
<button
mat-stroked-button
type="button"
[color]="selectedFrontFile ? '' : 'warn'"
(click)="frontFile.click()"
>
<fa-icon icon="plus"></fa-icon>&nbsp;{{
selectedFrontFile ? selectedFrontFile.name : 'Front Cheque Image *'
}}
</button>
<button
mat-stroked-button
type="button"
[color]="selectedBackFile ? '' : 'warn'"
(click)="backFile.click()"
>
<fa-icon icon="plus"></fa-icon>&nbsp;{{
selectedBackFile ? selectedBackFile.name : 'Back Cheque Image *'
}}
</button>
@if (!selectedFrontFile || !selectedBackFile) {
<span class="muted">Both front &amp; back images are required</span>
}
</div>
<div class="stop-payment-row">
<mat-checkbox formControlName="stopPaymentFlag">Stop Payment on this cheque</mat-checkbox>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="cancelCapture()">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" type="submit" [disabled]="!canSubmitCapture()">
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
@if (!showCaptureForm) {
<mat-tab-group class="outward-tabs" animationDuration="0ms">
<mat-tab label="Pending Cheques">
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="pendingFilterForm" (ngSubmit)="applyPendingFilters()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.From Date' | translate }}</mat-label>
<input matInput [matDatepicker]="pendingFromPicker" formControlName="fromDate" />
<mat-datepicker-toggle matSuffix [for]="pendingFromPicker"></mat-datepicker-toggle>
<mat-datepicker #pendingFromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.To Date' | translate }}</mat-label>
<input matInput [matDatepicker]="pendingToPicker" formControlName="toDate" />
<mat-datepicker-toggle matSuffix [for]="pendingToPicker"></mat-datepicker-toggle>
<mat-datepicker #pendingToPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Cheque Number' | translate }}</mat-label>
<input matInput formControlName="chequeNumber" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Drawer Bank Name' | translate }}</mat-label>
<input matInput formControlName="drawerBankName" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Payee Name' | translate }}</mat-label>
<input matInput formControlName="payeeName" />
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="clearPendingFilters()">
{{ 'labels.buttons.Clear' | translate }}
</button>
<button mat-raised-button color="primary" type="submit">
{{ 'labels.buttons.Filter' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="presentments">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Id' | translate }}</th>
<td mat-cell *matCellDef="let p">{{ p.id }}</td>
</ng-container>
<ng-container matColumnDef="chequeNumber">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Cheque Number' | translate }}</th>
<td mat-cell *matCellDef="let p">{{ p.chequeNumber }}</td>
</ng-container>
<ng-container matColumnDef="drawerBank">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Drawer Bank' | translate }}</th>
<td mat-cell *matCellDef="let p">
{{ p.drawerBankName || '–' }}
@if (p.drawerIfsc) {
<span class="muted">{{ p.drawerIfsc }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="payeeName">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payee Name' | translate }}</th>
<td mat-cell *matCellDef="let p">{{ p.payeeName }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Amount' | translate }}</th>
<td mat-cell *matCellDef="let p" class="num">{{ p.amount }}</td>
</ng-container>
<ng-container matColumnDef="depositedDate">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Deposited Date' | translate }}</th>
<td mat-cell *matCellDef="let p">
{{ p.depositedDate ? (p.depositedDate | dateFormat) : '–' }}
@if (p.createdOn) {
<span class="muted">{{ p.createdOn | dateFormat: 'hh:mm A' }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="image">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Image' | translate }}</th>
<td mat-cell *matCellDef="let p">
<div class="image-cell">
@if (getItemImageUrl(p, 'front')) {
<img
[src]="getItemImageUrl(p, 'front')"
class="cheque-thumb"
alt="front"
matTooltip="Front image — click to zoom"
(click)="openImageZoom(p, 'front')"
/>
} @else {
<input
#frontFile
type="file"
accept="image/*"
hidden
(change)="uploadItemImage(p, 'front', $event)"
/>
<button mat-icon-button (click)="frontFile.click()" matTooltip="Upload front image">
<fa-icon icon="plus"></fa-icon>
</button>
}
@if (getItemImageUrl(p, 'back')) {
<img
[src]="getItemImageUrl(p, 'back')"
class="cheque-thumb"
alt="back"
matTooltip="Back image — click to zoom"
(click)="openImageZoom(p, 'back')"
/>
} @else {
<input
#backFile
type="file"
accept="image/*"
hidden
(change)="uploadItemImage(p, 'back', $event)"
/>
<button mat-icon-button (click)="backFile.click()" matTooltip="Upload back image">
<fa-icon icon="plus"></fa-icon>
</button>
}
</div>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let p">
<span class="pill" [ngClass]="pillClass(p.status)">{{ p.status }}</span>
@if (p.stopPaymentFlag) {
<span class="pill bad" matTooltip="Stop payment marked on this cheque">STOP</span>
}
@if (p.iqaStatus && p.iqaStatus !== 'NA') {
<span class="pill" [ngClass]="pillClass(p.iqaStatus)" matTooltip="Image Quality Assessment"
>IQA: {{ p.iqaStatus }}</span
>
}
@if (getItemImageUrl(p, 'front') && getItemImageUrl(p, 'back')) {
<span
class="muted"
matTooltip="Goes out automatically with the next 2-hourly clearing session, or via Send Pending Batch"
>
Awaiting next clearing session
</span>
} @else {
<span class="muted" matTooltip="Upload both front & back cheque image before it can be sent"
>Image required</span
>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="pendingColumns"></tr>
<tr mat-row *matRowDef="let row; columns: pendingColumns"></tr>
</table>
@if (presentments.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@if (pendingTotalRecords > 0) {
<mat-paginator
[length]="pendingTotalRecords"
[pageSize]="pendingPageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pendingPageIndex"
showFirstLastButtons
(page)="onPendingPageChange($event)"
></mat-paginator>
}
</div>
</mat-tab>
<mat-tab label="Batches">
<div class="filter-row">
<button mat-stroked-button [class.active]="batchDateFilter === 'today'" (click)="setBatchDateFilter('today')">
Today
</button>
<button mat-stroked-button [class.active]="batchDateFilter === 'all'" (click)="setBatchDateFilter('all')">
All
</button>
</div>
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="batchesFilterForm" (ngSubmit)="applyBatchesFilters()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.From Date' | translate }}</mat-label>
<input matInput [matDatepicker]="batchFromPicker" formControlName="fromDate" />
<mat-datepicker-toggle matSuffix [for]="batchFromPicker"></mat-datepicker-toggle>
<mat-datepicker #batchFromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.To Date' | translate }}</mat-label>
<input matInput [matDatepicker]="batchToPicker" formControlName="toDate" />
<mat-datepicker-toggle matSuffix [for]="batchToPicker"></mat-datepicker-toggle>
<mat-datepicker #batchToPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Batch Reference' | translate }}</mat-label>
<input matInput formControlName="batchReference" />
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="clearBatchesFilters()">
{{ 'labels.buttons.Clear' | translate }}
</button>
<button mat-raised-button color="primary" type="submit">
{{ 'labels.buttons.Filter' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="batches">
<ng-container matColumnDef="batchReference">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Batch Reference' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.batchReference }}</td>
</ng-container>
<ng-container matColumnDef="session">
<th mat-header-cell *matHeaderCellDef>Session</th>
<td mat-cell *matCellDef="let b">
{{ b.sessionStartedOn | dateFormat: 'DD MMM YYYY, hh:mm A' }}
@if (b.sessionEndedOn) {
<span class="muted">to {{ b.sessionEndedOn | dateFormat: 'hh:mm A' }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="items">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Items' | translate }}</th>
<td mat-cell *matCellDef="let b" class="num">{{ b.itemCount ?? '–' }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Total Amount' | translate }}</th>
<td mat-cell *matCellDef="let b" class="num">{{ b.totalAmount ?? '–' }}</td>
</ng-container>
<ng-container matColumnDef="progress">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let b">
@if (b.settledCount) {
<span class="pill ok">{{ b.settledCount }} settled</span>
}
@if (b.dishonouredCount) {
<span class="pill bad">{{ b.dishonouredCount }} dishonoured</span>
}
@if (b.sentCount) {
<span class="pill neutral">{{ b.sentCount }} awaiting settlement</span>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let b" (click)="$event.stopPropagation()">
<button mat-button color="primary" (click)="openBatch(b)">
{{ 'labels.buttons.View' | translate }}
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="batchColumns"></tr>
<tr mat-row *matRowDef="let row; columns: batchColumns" (click)="openBatch(row)" class="clickable-row"></tr>
</table>
@if (batches.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@if (batchesTotalRecords > 0) {
<mat-paginator
[length]="batchesTotalRecords"
[pageSize]="batchesPageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="batchesPageIndex"
showFirstLastButtons
(page)="onBatchesPageChange($event)"
></mat-paginator>
}
</div>
</mat-tab>
</mat-tab-group>
}
<div #lightboxRoot style="display: none"></div>
</div>
@@ -0,0 +1,172 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.filter-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
button.active {
background: rgb(25 118 210 / 12%);
}
}
.add-form {
margin-bottom: 16px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 20px;
margin-bottom: 8px;
}
mat-form-field {
width: 100%;
}
.stop-payment-row {
margin: 4px 0 12px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.outward-tabs {
margin-top: 8px;
::ng-deep .mat-mdc-tab-body-wrapper {
padding-top: 16px;
}
}
.table-wrapper {
overflow-x: auto;
background: #fff;
margin-bottom: 16px;
}
table {
width: 100%;
}
.clickable-row {
cursor: pointer;
&:hover {
background: rgb(0 0 0 / 3%);
}
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.muted {
display: block;
color: rgb(0 0 0 / 50%);
font-size: 0.78rem;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
white-space: nowrap;
margin-right: 4px;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.bad {
background: #fde3e3;
color: #d32f2f;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
.envelope-panel {
margin: 8px;
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 6px;
background: #fafafa;
.envelope-head {
font-weight: 600;
margin-bottom: 6px;
}
}
.image-cell {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.cheque-thumb {
width: 40px;
height: 28px;
object-fit: cover;
border: 1px solid #ddd;
border-radius: 3px;
cursor: pointer;
}
.cts-file {
font-family: monospace;
font-size: 0.74rem;
background: #f5f7fa;
color: #1a2733;
border: 1px solid #dfe6ee;
padding: 10px;
border-radius: 4px;
max-height: 360px;
overflow: auto;
white-space: pre;
margin: 4px 0 12px;
}
@@ -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);
}
});
}
}
@@ -0,0 +1,134 @@
<!--
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/.
-->
<div class="page">
<div class="header">
<div>
<h2>Positive Pay (RBI PPS)</h2>
<p class="muted">
Pre-register high-value cheques (≥ ₹50,000). Inward clearing returns an unregistered or mismatched high-value
cheque with reason code 13.
</p>
</div>
<button mat-raised-button color="primary" (click)="showForm = !showForm">
<fa-icon icon="plus"></fa-icon> Register Cheque
</button>
</div>
@if (showForm) {
<form [formGroup]="form" (ngSubmit)="submit()" class="capture-form mat-elevation-z1">
<div class="row">
<mat-form-field appearance="outline">
<mat-label>Account Type</mat-label>
<mat-select formControlName="accountType">
<mat-option value="SAVINGS">Savings</mat-option>
<mat-option value="CURRENT">Current</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Account Id</mat-label>
<input matInput type="number" formControlName="accountId" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Cheque Number</mat-label>
<input matInput formControlName="chequeNumber" />
</mat-form-field>
</div>
<div class="row">
<mat-form-field appearance="outline">
<mat-label>Instrument Date</mat-label>
<input matInput [matDatepicker]="idp" formControlName="instrumentDate" />
<mat-datepicker-toggle matSuffix [for]="idp"></mat-datepicker-toggle>
<mat-datepicker #idp></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Amount</mat-label>
<input matInput type="number" formControlName="amount" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Payee Name</mat-label>
<input matInput formControlName="payeeName" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Channel</mat-label>
<mat-select formControlName="channel">
<mat-option value="BRANCH">Branch</mat-option>
<mat-option value="NETBANKING">Net Banking</mat-option>
<mat-option value="MOBILE">Mobile</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="actions">
<button mat-button type="button" (click)="showForm = false">Cancel</button>
<button mat-raised-button color="primary" type="submit" [disabled]="form.invalid || busy">Register</button>
</div>
</form>
}
<div class="filters">
@for (s of ['ACTIVE', 'CANCELLED', 'CONSUMED']; track s) {
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
}
</div>
<div class="mat-elevation-z1 table-wrap">
<table mat-table [dataSource]="registrations">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>#</th>
<td mat-cell *matCellDef="let r">{{ r.id }}</td>
</ng-container>
<ng-container matColumnDef="account">
<th mat-header-cell *matHeaderCellDef>Account</th>
<td mat-cell *matCellDef="let r">
{{ r.accountNo || r.accountId }} <span class="muted">({{ r.accountType }})</span>
</td>
</ng-container>
<ng-container matColumnDef="chequeNumber">
<th mat-header-cell *matHeaderCellDef>Cheque #</th>
<td mat-cell *matCellDef="let r">{{ r.chequeNumber }}</td>
</ng-container>
<ng-container matColumnDef="instrumentDate">
<th mat-header-cell *matHeaderCellDef>Instrument Date</th>
<td mat-cell *matCellDef="let r">{{ r.instrumentDate | date: 'mediumDate' }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef>Amount</th>
<td mat-cell *matCellDef="let r">{{ r.amount }}</td>
</ng-container>
<ng-container matColumnDef="payeeName">
<th mat-header-cell *matHeaderCellDef>Payee</th>
<td mat-cell *matCellDef="let r">{{ r.payeeName }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let r">
<span class="pill" [ngClass]="pillClass(r.status)">{{ r.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let r">
@if (r.status === 'ACTIVE') {
<button mat-icon-button color="warn" (click)="cancel(r)" matTooltip="Cancel">
<fa-icon icon="ban"></fa-icon>
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageIndex]="pageIndex"
[pageSizeOptions]="pageSizeOptions"
(page)="onPageChange($event)"
></mat-paginator>
</div>
</div>
@@ -0,0 +1,89 @@
/**
* 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/.
*/
.page {
padding: 1.5rem;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.muted {
color: rgb(0 0 0 / 55%);
font-size: 0.85rem;
}
.capture-form {
padding: 1rem;
margin-bottom: 1rem;
border-radius: 4px;
.row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
mat-form-field {
flex: 1 1 200px;
}
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
}
.filters {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
button.active {
background: #3f51b5;
color: #fff;
}
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
}
.pill {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
&.ok {
background: #e6f4ea;
color: #1e7e34;
}
&.bad {
background: #fdecea;
color: #c62828;
}
&.neutral {
background: #eceff1;
color: #546e7a;
}
}
@@ -0,0 +1,178 @@
/**
* 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 { formatDate } from '@angular/common';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
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 { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { CtsClearingService } from '../ctsclearing.service';
import { pillClass, clearingError } from '../../clearing-network/clearing-status';
/**
* Positive Pay System (RBI PPS) — pre-registration of high-value cheque particulars. Inward clearing matches
* a presented high-value cheque against an active registration before honouring it.
*/
@Component({
selector: 'mifosx-positive-pay',
templateUrl: './positive-pay.component.html',
styleUrls: ['./positive-pay.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule,
FaIconComponent
]
})
export class PositivePayComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private ctsService = inject(CtsClearingService);
private snackBar = inject(MatSnackBar);
pillClass = pillClass;
registrations: any[] = [];
totalRecords = 0;
pageSize = 10;
pageIndex = 0;
pageSizeOptions = [
10,
25,
50,
100
];
statusFilter = '';
busy = false;
showForm = false;
form: UntypedFormGroup;
columns = [
'id',
'account',
'chequeNumber',
'instrumentDate',
'amount',
'payeeName',
'status',
'actions'
];
constructor() {
this.form = this.formBuilder.group({
accountType: [
'SAVINGS',
Validators.required
],
accountId: [
'',
Validators.required
],
chequeNumber: [
'',
Validators.required
],
instrumentDate: [''],
amount: [
'',
Validators.required
],
payeeName: [''],
channel: ['BRANCH']
});
}
ngOnInit(): void {
this.load();
}
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
private toApiDate(value: any): string | undefined {
return value ? formatDate(value, 'yyyy-MM-dd', 'en') : undefined;
}
load(): void {
this.ctsService
.getPositivePayRegistrations(
undefined,
this.statusFilter || undefined,
this.pageIndex * this.pageSize,
this.pageSize
)
.subscribe((res: any) => {
this.registrations = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.totalRecords = Number(res?.totalFilteredRecords ?? this.registrations.length);
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.load();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.load();
}
submit(): void {
if (this.form.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.form.value;
const payload = {
...raw,
instrumentDate: this.toApiDate(raw.instrumentDate),
dateFormat: 'yyyy-MM-dd',
locale: 'en'
};
this.ctsService.registerPositivePay(payload).subscribe({
next: () => {
this.busy = false;
this.showForm = false;
this.form.reset({ accountType: 'SAVINGS', channel: 'BRANCH' });
this.notify('Positive Pay registered');
this.load();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Could not register Positive Pay'), true);
}
});
}
cancel(registration: any): void {
const reason = window.prompt('Reason for cancelling this Positive Pay registration?');
if (!reason) {
return;
}
this.ctsService.cancelPositivePay(registration.id, { reason }).subscribe({
next: () => {
this.notify('Positive Pay cancelled');
this.load();
},
error: (err: any) => this.notify(clearingError(err, 'Could not cancel'), true)
});
}
}
@@ -0,0 +1,110 @@
<!--
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/.
-->
<div class="container">
<div class="page-header">
<h2>{{ 'labels.menus.Reconciliation' | translate }} — {{ 'labels.menus.CTS Clearing' | translate }}</h2>
<div class="header-actions">
<button mat-raised-button color="primary" (click)="showRunForm = !showRunForm">
<fa-icon icon="plus"></fa-icon>&nbsp;{{ 'labels.buttons.Run Reconciliation' | translate }}
</button>
<button mat-raised-button [routerLink]="['/ctsclearing']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
</div>
@if (showRunForm) {
<mat-card class="add-form">
<mat-card-content>
<form [formGroup]="runForm" (ngSubmit)="submitRun()">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Reconciliation Date' | translate }}</mat-label>
<input matInput [matDatepicker]="reconPicker" formControlName="reconciliationDate" required />
<mat-datepicker-toggle matSuffix [for]="reconPicker"></mat-datepicker-toggle>
<mat-datepicker #reconPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Source Reference' | translate }}</mat-label>
<input matInput formControlName="sourceReference" />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.CBS Total' | translate }}</mat-label>
<input matInput type="number" formControlName="cbsTotal" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Clearing House Total' | translate }}</mat-label>
<input matInput type="number" formControlName="clearingHouseTotal" required />
</mat-form-field>
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Detail' | translate }}</mat-label>
<input matInput formControlName="detail" />
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="showRunForm = false">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="primary" type="submit" [disabled]="runForm.invalid || busy">
{{ 'labels.buttons.Submit' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="reconciliations">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Id' | translate }}</th>
<td mat-cell *matCellDef="let r">{{ r.id }}</td>
</ng-container>
<ng-container matColumnDef="reconciliationDate">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Reconciliation Date' | translate }}</th>
<td mat-cell *matCellDef="let r">{{ r.reconciliationDate ? (r.reconciliationDate | dateFormat) : '–' }}</td>
</ng-container>
<ng-container matColumnDef="sourceReference">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Source Reference' | translate }}</th>
<td mat-cell *matCellDef="let r">{{ r.sourceReference || '–' }}</td>
</ng-container>
<ng-container matColumnDef="cbsTotal">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.CBS Total' | translate }}</th>
<td mat-cell *matCellDef="let r" class="num">{{ r.cbsTotal }}</td>
</ng-container>
<ng-container matColumnDef="clearingHouseTotal">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Clearing House Total' | translate }}</th>
<td mat-cell *matCellDef="let r" class="num">{{ r.clearingHouseTotal }}</td>
</ng-container>
<ng-container matColumnDef="difference">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Difference' | translate }}</th>
<td mat-cell *matCellDef="let r" class="num">{{ r.difference ?? '–' }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let r">
<span class="pill" [ngClass]="pillClass(r.status)">{{ r.status }}</span>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
@if (reconciliations.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@if (totalRecords > 0) {
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pageIndex"
showFirstLastButtons
(page)="onPageChange($event)"
></mat-paginator>
}
</div>
</div>
@@ -0,0 +1,90 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.add-form {
margin-bottom: 16px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 20px;
margin-bottom: 8px;
}
mat-form-field {
width: 100%;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.table-wrapper {
overflow-x: auto;
background: #fff;
}
table {
width: 100%;
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
white-space: nowrap;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.bad {
background: #fde3e3;
color: #d32f2f;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
@@ -0,0 +1,148 @@
/**
* 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 { formatDate } from '@angular/common';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
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 { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ClearingService } from '../../clearing-network/clearing.service';
import { pillClass, clearingError } from '../../clearing-network/clearing-status';
/**
* CTS view of the shared reconciliation register — records CBS vs clearing-house totals for a date and shows
* whether they MATCHED or MISMATCHed. clearingType is fixed to 'CTS'.
*/
@Component({
selector: 'mifosx-cts-reconciliation',
templateUrl: './reconciliation.component.html',
styleUrls: ['./reconciliation.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule,
FaIconComponent
]
})
export class ReconciliationComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private clearingService = inject(ClearingService);
private snackBar = inject(MatSnackBar);
readonly clearingType = 'CTS';
pillClass = pillClass;
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
reconciliations: any[] = [];
totalRecords = 0;
pageSize = 10;
pageIndex = 0;
pageSizeOptions = [
10,
25,
50,
100
];
busy = false;
showRunForm = false;
runForm: UntypedFormGroup;
columns = [
'id',
'reconciliationDate',
'sourceReference',
'cbsTotal',
'clearingHouseTotal',
'difference',
'status'
];
constructor() {
this.runForm = this.formBuilder.group({
reconciliationDate: [
'',
Validators.required
],
sourceReference: [''],
cbsTotal: [
'',
Validators.required
],
clearingHouseTotal: [
'',
Validators.required
],
detail: ['']
});
}
ngOnInit(): void {
this.loadReconciliations();
}
loadReconciliations(): void {
this.clearingService
.getReconciliations(this.clearingType, this.pageIndex * this.pageSize, this.pageSize)
.subscribe((res: any) => {
this.reconciliations = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.totalRecords = Number(res?.totalFilteredRecords ?? this.reconciliations.length);
});
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.loadReconciliations();
}
submitRun(): void {
if (this.runForm.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.runForm.value;
const payload = {
clearingType: this.clearingType,
reconciliationDate: formatDate(raw.reconciliationDate, 'yyyy-MM-dd', 'en'),
sourceReference: raw.sourceReference || undefined,
cbsTotal: raw.cbsTotal,
clearingHouseTotal: raw.clearingHouseTotal,
detail: raw.detail || undefined,
dateFormat: 'yyyy-MM-dd',
locale: 'en'
};
this.clearingService.runReconciliation(payload).subscribe({
next: () => {
this.busy = false;
this.showRunForm = false;
this.runForm.reset();
this.notify('Reconciliation recorded');
this.loadReconciliations();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Reconciliation failed'), true);
}
});
}
}
@@ -0,0 +1,116 @@
<!--
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/.
-->
<div class="container">
<div class="page-header">
<h2>{{ 'labels.menus.Settlement' | translate }} — {{ 'labels.menus.CTS Clearing' | translate }}</h2>
<button mat-raised-button [routerLink]="['/ctsclearing']">{{ 'labels.buttons.Back' | translate }}</button>
</div>
<div class="filter-row">
@for (s of ['CREATED', 'POSTED', 'REJECTED']; track s) {
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
}
</div>
<div class="table-wrapper mat-elevation-z1">
<table mat-table [dataSource]="batches">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Id' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.id }}</td>
</ng-container>
<ng-container matColumnDef="settlementDate">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Settlement Date' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.settlementDate ? (b.settlementDate | dateFormat) : '–' }}</td>
</ng-container>
<ng-container matColumnDef="sourceReference">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Source Reference' | translate }}</th>
<td mat-cell *matCellDef="let b">{{ b.sourceReference }}</td>
</ng-container>
<ng-container matColumnDef="passed">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Passed' | translate }}</th>
<td mat-cell *matCellDef="let b" class="num">
{{ b.passedCount ?? '–' }}
@if (b.totalPassedAmount != null) {
<span class="muted">{{ b.totalPassedAmount }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="returned">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Returned' | translate }}</th>
<td mat-cell *matCellDef="let b" class="num">
{{ b.returnedCount ?? '–' }}
@if (b.totalReturnedAmount != null) {
<span class="muted">{{ b.totalReturnedAmount }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="netAmount">
<th mat-header-cell *matHeaderCellDef class="num">{{ 'labels.inputs.Net Amount' | translate }}</th>
<td mat-cell *matCellDef="let b" class="num">{{ b.netAmount ?? '–' }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let b">
<span class="pill" [ngClass]="pillClass(b.status)">{{ b.status }}</span>
@if (b.glTransactionId) {
<span class="muted">GL #{{ b.glTransactionId }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let b">
@if (b.status === 'CREATED') {
<button mat-button color="primary" (click)="post(b)">{{ 'labels.buttons.Post' | translate }}</button>
<button mat-button color="warn" (click)="toggleReject(b)">{{ 'labels.buttons.Reject' | translate }}</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
@if (batches.length === 0) {
<div class="empty-state">{{ 'labels.text.No records to display' | translate }}</div>
}
@if (totalRecords > 0) {
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageSizeOptions]="pageSizeOptions"
[pageIndex]="pageIndex"
showFirstLastButtons
(page)="onPageChange($event)"
></mat-paginator>
}
</div>
@if (rejectForId) {
<mat-card class="add-form">
<mat-card-content>
<h3>{{ 'labels.inputs.Reject Settlement' | translate }}</h3>
<form [formGroup]="rejectForm" (ngSubmit)="submitReject(rejectForId)">
<div class="form-grid">
<mat-form-field appearance="fill">
<mat-label>{{ 'labels.inputs.Reason' | translate }}</mat-label>
<input matInput formControlName="reason" required />
</mat-form-field>
</div>
<div class="form-actions">
<button mat-raised-button type="button" (click)="rejectForId = null">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button mat-raised-button color="warn" type="submit" [disabled]="rejectForm.invalid || busy">
{{ 'labels.buttons.Reject' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
}
</div>
@@ -0,0 +1,102 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.filter-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
button.active {
background: rgb(25 118 210 / 12%);
}
}
.add-form {
margin-bottom: 16px;
margin-top: 16px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px 20px;
margin-bottom: 8px;
}
mat-form-field {
width: 100%;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 16px;
}
.table-wrapper {
overflow-x: auto;
background: #fff;
}
table {
width: 100%;
}
td.num,
th.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.muted {
display: block;
color: rgb(0 0 0 / 50%);
font-size: 0.78rem;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 600;
white-space: nowrap;
&.ok {
background: #dff5e8;
color: #28c07a;
}
&.bad {
background: #fde3e3;
color: #d32f2f;
}
&.neutral {
background: #e0e0e0;
color: #757575;
}
}
.empty-state {
padding: 24px;
text-align: center;
color: rgb(0 0 0 / 50%);
}
@@ -0,0 +1,161 @@
/**
* 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 { 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 { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { ClearingService } from '../../clearing-network/clearing.service';
import { pillClass, clearingError } from '../../clearing-network/clearing-status';
/**
* CTS view of the shared net-settlement batches — prepared from validated inward batches, posted to the GL
* or rejected. clearingType is fixed to 'CTS'.
*/
@Component({
selector: 'mifosx-cts-settlement',
templateUrl: './settlement.component.html',
styleUrls: ['./settlement.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule,
FaIconComponent
]
})
export class SettlementComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private clearingService = inject(ClearingService);
private snackBar = inject(MatSnackBar);
readonly clearingType = 'CTS';
pillClass = pillClass;
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
batches: any[] = [];
totalRecords = 0;
pageSize = 10;
pageIndex = 0;
pageSizeOptions = [
10,
25,
50,
100
];
statusFilter = '';
busy = false;
rejectForId: number | null = null;
rejectForm: UntypedFormGroup;
columns = [
'id',
'settlementDate',
'sourceReference',
'passed',
'returned',
'netAmount',
'status',
'actions'
];
constructor() {
this.rejectForm = this.formBuilder.group({
reason: [
'',
Validators.required
]
});
}
ngOnInit(): void {
this.loadBatches();
}
loadBatches(): void {
this.clearingService
.getSettlementBatches(
this.clearingType,
this.statusFilter || undefined,
this.pageIndex * this.pageSize,
this.pageSize
)
.subscribe((res: any) => {
this.batches = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.totalRecords = Number(res?.totalFilteredRecords ?? this.batches.length);
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.loadBatches();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.loadBatches();
}
post(batch: any): void {
if (this.busy) {
return;
}
this.busy = true;
this.clearingService.postSettlement(batch.id).subscribe({
next: () => {
this.busy = false;
this.notify('Settlement posted');
this.loadBatches();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Post failed'), true);
}
});
}
toggleReject(batch: any): void {
this.rejectForId = this.rejectForId === batch.id ? null : batch.id;
this.rejectForm.reset();
}
submitReject(id: number): void {
if (this.rejectForm.invalid || this.busy) {
return;
}
this.busy = true;
this.clearingService.rejectSettlement(id, { reason: this.rejectForm.value.reason, locale: 'en' }).subscribe({
next: () => {
this.busy = false;
this.rejectForId = null;
this.notify('Settlement rejected');
this.loadBatches();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Reject failed'), true);
}
});
}
}
@@ -0,0 +1,96 @@
<!--
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/.
-->
<div class="page">
@if (batch) {
<div class="header">
<div>
<h2>Batch {{ batch.batchReference }}</h2>
<p class="muted">
{{ batch.batchType }} · value {{ batch.valueDate | date: 'mediumDate' }} · {{ batch.totalTxnCount }} txns ·
{{ batch.totalAmount }}
<span class="pill" [ngClass]="pillClass(batch.status)">{{ batch.status }}</span>
</p>
</div>
<div class="header-actions">
@if (batch.status === 'CREATED') {
<button mat-raised-button color="primary" (click)="process()" [disabled]="busy">
<fa-icon icon="check-circle"></fa-icon> Process Batch
</button>
}
@if (batch.status === 'PROCESSED') {
<button mat-stroked-button color="primary" (click)="prepareSettlement()">
<fa-icon icon="university"></fa-icon> Prepare Settlement
</button>
}
</div>
</div>
<div class="mat-elevation-z1 table-wrap">
<table mat-table [dataSource]="transactions">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>#</th>
<td mat-cell *matCellDef="let t">{{ t.id }}</td>
</ng-container>
<ng-container matColumnDef="account">
<th mat-header-cell *matHeaderCellDef>Account</th>
<td mat-cell *matCellDef="let t">
{{ t.accountNo || t.accountId }} <span class="muted">({{ t.accountType }})</span>
</td>
</ng-container>
<ng-container matColumnDef="txnType">
<th mat-header-cell *matHeaderCellDef>Type</th>
<td mat-cell *matCellDef="let t">{{ t.txnType }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef>Amount</th>
<td mat-cell *matCellDef="let t">{{ t.amount }}</td>
</ng-container>
<ng-container matColumnDef="checks">
<th mat-header-cell *matHeaderCellDef>Validation</th>
<td mat-cell *matCellDef="let t">
<span
*ngFor="let c of checks(t)"
class="check"
[class.ok]="c.ok === true"
[class.bad]="c.ok === false"
[class.na]="c.ok === null || c.ok === undefined"
[matTooltip]="c.label"
>{{ c.label[0] }}</span
>
</td>
</ng-container>
<ng-container matColumnDef="decision">
<th mat-header-cell *matHeaderCellDef>Decision</th>
<td mat-cell *matCellDef="let t">
<span class="pill" [ngClass]="pillClass(t.decision)">{{ t.decision }}</span>
@if (t.returnReasonCode) {
<span class="muted"> {{ t.returnReasonCode }}</span>
}
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let t">
<span class="pill" [ngClass]="pillClass(t.status)">{{ t.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let t">
@if (t.decision === 'PENDING') {
<button mat-stroked-button color="primary" (click)="decide(t)">Decide</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
</div>
}
</div>
@@ -0,0 +1,86 @@
/**
* 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/.
*/
.page {
padding: 1.5rem;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1rem;
gap: 1rem;
flex-wrap: wrap;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.muted {
color: rgb(0 0 0 / 55%);
font-size: 0.85rem;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
}
.check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-right: 3px;
border-radius: 50%;
font-size: 0.7rem;
font-weight: 700;
color: #fff;
&.ok {
background: #2e7d32;
}
&.bad {
background: #c62828;
}
&.na {
background: #b0bec5;
}
}
.pill {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
&.ok {
background: #e6f4ea;
color: #1e7e34;
}
&.bad {
background: #fdecea;
color: #c62828;
}
&.neutral {
background: #eceff1;
color: #546e7a;
}
}
@@ -0,0 +1,130 @@
/**
* 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 { ActivatedRoute } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { NachClearingService } from '../nachclearing.service';
import { ClearingService } from '../../clearing-network/clearing.service';
import { pillClass, clearingError } from '../../clearing-network/clearing-status';
import { TxnDecisionDialogComponent } from './txn-decision-dialog/txn-decision-dialog.component';
/** One NACH batch: its transactions, their validation results, bulk processing and manual overrides. */
@Component({
selector: 'mifosx-nach-batch-detail',
templateUrl: './batch-detail.component.html',
styleUrls: ['./batch-detail.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIconModule,
MatButtonModule,
MatTableModule,
MatSnackBarModule,
MatDialogModule,
FaIconComponent
]
})
export class BatchDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private nachService = inject(NachClearingService);
private clearingService = inject(ClearingService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
pillClass = pillClass;
batchId!: string;
batch: any = null;
transactions: any[] = [];
busy = false;
columns = [
'id',
'account',
'txnType',
'amount',
'checks',
'decision',
'status',
'actions'
];
ngOnInit(): void {
this.batchId = this.route.snapshot.paramMap.get('id') as string;
this.reload();
}
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
reload(): void {
this.nachService.getBatch(this.batchId).subscribe((b: any) => (this.batch = b));
this.nachService.getBatchTransactions(this.batchId).subscribe((res: any) => {
this.transactions = res?.pageItems ?? (Array.isArray(res) ? res : []);
});
}
checks(t: any): { label: string; ok: boolean | null }[] {
return [
{ label: 'Account', ok: t.accountCheckPassed },
{ label: 'Mandate', ok: t.mandateActiveCheck },
{ label: 'Limit', ok: t.amountWithinLimitCheck },
{ label: 'Balance', ok: t.balanceCheckPassed },
{ label: 'Dup', ok: t.duplicateCheckPassed }
];
}
process(): void {
if (this.busy) {
return;
}
this.busy = true;
this.nachService.processBatch(this.batchId).subscribe({
next: () => {
this.busy = false;
this.notify('Batch processed');
this.reload();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Could not process the batch'), true);
}
});
}
prepareSettlement(): void {
this.clearingService.prepareSettlement({ clearingType: 'NACH', sourceBatchId: this.batchId }).subscribe({
next: () => {
this.notify('Settlement batch prepared');
this.reload();
},
error: (err: any) => this.notify(clearingError(err, 'Could not prepare settlement'), true)
});
}
decide(txn: any): void {
this.dialog
.open(TxnDecisionDialogComponent, { width: '520px', data: { transaction: txn } })
.afterClosed()
.subscribe((result: any) => {
if (result?.actioned) {
this.reload();
}
});
}
}
@@ -0,0 +1,41 @@
<!--
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/.
-->
<h2 mat-dialog-title>Decide Transaction #{{ data.transaction.id }}</h2>
<mat-dialog-content>
<p class="muted">
{{ data.transaction.txnType }} · {{ data.transaction.accountType }} account
{{ data.transaction.accountNo || data.transaction.accountId }} · amount {{ data.transaction.amount }}
</p>
<div class="toggle">
<button mat-stroked-button [class.sel-ok]="decision === 'PASS'" (click)="setDecision('PASS')">Pass</button>
<button mat-stroked-button [class.sel-bad]="decision === 'RETURN'" (click)="setDecision('RETURN')">Return</button>
</div>
<form [formGroup]="form">
@if (decision === 'RETURN') {
<mat-form-field appearance="outline" class="full">
<mat-label>Return Reason Code</mat-label>
<mat-select formControlName="returnReasonCode">
@for (rc of reasonCodes; track rc.code) {
<mat-option [value]="rc.code">{{ rc.code }} — {{ rc.description }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" class="full">
<mat-label>Remarks</mat-label>
<textarea matInput rows="2" formControlName="reason"></textarea>
</mat-form-field>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Cancel</button>
<button mat-raised-button color="primary" (click)="submit()" [disabled]="busy">Confirm</button>
</mat-dialog-actions>
@@ -0,0 +1,33 @@
/**
* 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/.
*/
.muted {
color: rgb(0 0 0 / 60%);
font-size: 0.85rem;
margin-bottom: 1rem;
}
.toggle {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
.sel-ok {
background: #2e7d32;
color: #fff;
}
.sel-bad {
background: #c62828;
color: #fff;
}
}
.full {
width: 100%;
}
@@ -0,0 +1,108 @@
/**
* 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 { MatButtonModule } from '@angular/material/button';
import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { NachClearingService } from '../../nachclearing.service';
import { ClearingService } from '../../../clearing-network/clearing.service';
import { clearingError } from '../../../clearing-network/clearing-status';
/** Pass or return a single NACH transaction. Return requires a standardized reason code. */
@Component({
selector: 'mifosx-nach-txn-decision-dialog',
templateUrl: './txn-decision-dialog.component.html',
styleUrls: ['./txn-decision-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatButtonModule,
MatDialogModule,
MatSnackBarModule
]
})
export class TxnDecisionDialogComponent implements OnInit {
dialogRef = inject<MatDialogRef<TxnDecisionDialogComponent>>(MatDialogRef);
data = inject<{ transaction: any }>(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private nachService = inject(NachClearingService);
private clearingService = inject(ClearingService);
private snackBar = inject(MatSnackBar);
decision: 'PASS' | 'RETURN' = 'PASS';
reasonCodes: any[] = [];
busy = false;
form: UntypedFormGroup;
constructor() {
this.form = this.formBuilder.group({
returnReasonCode: [''],
reason: ['']
});
}
ngOnInit(): void {
this.clearingService.getReturnReasonCodes('NACH').subscribe((res: any) => {
this.reasonCodes = Array.isArray(res) ? res : (res?.pageItems ?? []);
});
}
setDecision(decision: 'PASS' | 'RETURN'): void {
this.decision = decision;
const rc = this.form.get('returnReasonCode');
if (decision === 'RETURN') {
rc?.setValidators([Validators.required]);
} else {
rc?.clearValidators();
}
rc?.updateValueAndValidity();
}
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
submit(): void {
if (this.busy) {
return;
}
const id = this.data.transaction.id;
this.busy = true;
if (this.decision === 'PASS') {
this.nachService.passTransaction(id).subscribe({
next: () => this.done('Transaction passed'),
error: (err: any) => this.fail(err, 'Could not pass the transaction')
});
} else {
if (this.form.invalid) {
this.busy = false;
return;
}
this.nachService.returnTransaction(id, this.form.value).subscribe({
next: () => this.done('Transaction returned'),
error: (err: any) => this.fail(err, 'Could not return the transaction')
});
}
}
private done(message: string): void {
this.busy = false;
this.notify(message);
this.dialogRef.close({ actioned: true });
}
private fail(err: any, fallback: string): void {
this.busy = false;
this.notify(clearingError(err, fallback), true);
}
}
@@ -0,0 +1,142 @@
<!--
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/.
-->
<div class="page">
<div class="header">
<h2>NACH Batches</h2>
<button mat-raised-button color="primary" (click)="showForm = !showForm">
<fa-icon icon="plus"></fa-icon> New Batch
</button>
</div>
@if (showForm) {
<form [formGroup]="form" (ngSubmit)="submit()" class="capture-form mat-elevation-z1">
<div class="row">
<mat-form-field appearance="outline">
<mat-label>Batch Type</mat-label>
<mat-select formControlName="batchType">
<mat-option value="DEBIT">Debit</mat-option>
<mat-option value="CREDIT">Credit</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Value Date</mat-label>
<input matInput [matDatepicker]="vd" formControlName="valueDate" />
<mat-datepicker-toggle matSuffix [for]="vd"></mat-datepicker-toggle>
<mat-datepicker #vd></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Sponsor Bank Id</mat-label>
<input matInput type="number" formControlName="sponsorBankId" />
</mat-form-field>
</div>
<h4>Transactions</h4>
<div formArrayName="transactions">
@for (t of transactions.controls; track t; let i = $index) {
<div class="txn-row" [formGroupName]="i">
<mat-form-field appearance="outline">
<mat-label>Mandate Id</mat-label>
<input matInput type="number" formControlName="mandateId" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Acct Type</mat-label>
<mat-select formControlName="accountType">
<mat-option value="SAVINGS">Savings</mat-option>
<mat-option value="CURRENT">Current</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Account Id</mat-label>
<input matInput type="number" formControlName="accountId" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Amount</mat-label>
<input matInput type="number" formControlName="amount" />
</mat-form-field>
<button
mat-icon-button
color="warn"
type="button"
(click)="removeTxn(i)"
[disabled]="transactions.length === 1"
>
<fa-icon icon="trash"></fa-icon>
</button>
</div>
}
</div>
<button mat-stroked-button type="button" (click)="addTxn()">
<fa-icon icon="plus"></fa-icon> Add transaction
</button>
<div class="actions">
<button mat-button type="button" (click)="showForm = false">Cancel</button>
<button mat-raised-button color="primary" type="submit" [disabled]="form.invalid || busy">Create</button>
</div>
</form>
}
<div class="filters">
@for (s of ['CREATED', 'PROCESSED', 'SETTLED']; track s) {
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
}
</div>
<div class="mat-elevation-z1 table-wrap">
<table mat-table [dataSource]="batches">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>#</th>
<td mat-cell *matCellDef="let b">{{ b.id }}</td>
</ng-container>
<ng-container matColumnDef="batchReference">
<th mat-header-cell *matHeaderCellDef>Reference</th>
<td mat-cell *matCellDef="let b">{{ b.batchReference }}</td>
</ng-container>
<ng-container matColumnDef="batchType">
<th mat-header-cell *matHeaderCellDef>Type</th>
<td mat-cell *matCellDef="let b">{{ b.batchType }}</td>
</ng-container>
<ng-container matColumnDef="valueDate">
<th mat-header-cell *matHeaderCellDef>Value Date</th>
<td mat-cell *matCellDef="let b">{{ b.valueDate | date: 'mediumDate' }}</td>
</ng-container>
<ng-container matColumnDef="txns">
<th mat-header-cell *matHeaderCellDef>Txns</th>
<td mat-cell *matCellDef="let b">{{ b.totalTxnCount }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef>Amount</th>
<td mat-cell *matCellDef="let b">{{ b.totalAmount }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let b">
<span class="pill" [ngClass]="pillClass(b.status)">{{ b.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let b">
<button mat-icon-button color="primary" (click)="open(b)" matTooltip="Open">
<fa-icon icon="arrow-right"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns" (click)="open(row)" class="clickable"></tr>
</table>
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageIndex]="pageIndex"
[pageSizeOptions]="pageSizeOptions"
(page)="onPageChange($event)"
></mat-paginator>
</div>
</div>
@@ -0,0 +1,98 @@
/**
* 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/.
*/
.page {
padding: 1.5rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.capture-form {
padding: 1rem;
margin-bottom: 1rem;
border-radius: 4px;
.row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
mat-form-field {
flex: 1 1 200px;
}
}
.txn-row {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
mat-form-field {
flex: 1 1 140px;
}
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
}
.filters {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
button.active {
background: #3f51b5;
color: #fff;
}
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
}
.clickable {
cursor: pointer;
}
.pill {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
&.ok {
background: #e6f4ea;
color: #1e7e34;
}
&.bad {
background: #fdecea;
color: #c62828;
}
&.neutral {
background: #eceff1;
color: #546e7a;
}
}
@@ -0,0 +1,191 @@
/**
* 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 { formatDate } from '@angular/common';
import { UntypedFormArray, 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 { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { NachClearingService } from '../nachclearing.service';
import { pillClass, clearingError } from '../../clearing-network/clearing-status';
/** NACH batches — a sponsor bank's set of due debits/credits, decided in bulk when processed. */
@Component({
selector: 'mifosx-nach-batches',
templateUrl: './batches.component.html',
styleUrls: ['./batches.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule,
FaIconComponent
]
})
export class BatchesComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private nachService = inject(NachClearingService);
private router = inject(Router);
private snackBar = inject(MatSnackBar);
pillClass = pillClass;
batches: any[] = [];
totalRecords = 0;
pageSize = 10;
pageIndex = 0;
pageSizeOptions = [
10,
25,
50,
100
];
statusFilter = '';
busy = false;
showForm = false;
form: UntypedFormGroup;
columns = [
'id',
'batchReference',
'batchType',
'valueDate',
'txns',
'amount',
'status',
'actions'
];
constructor() {
this.form = this.formBuilder.group({
batchType: [
'DEBIT',
Validators.required
],
valueDate: [
'',
Validators.required
],
sponsorBankId: [''],
transactions: this.formBuilder.array([])
});
}
ngOnInit(): void {
this.load();
this.addTxn();
}
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
private toApiDate(value: any): string | undefined {
return value ? formatDate(value, 'yyyy-MM-dd', 'en') : undefined;
}
get transactions(): UntypedFormArray {
return this.form.get('transactions') as UntypedFormArray;
}
addTxn(): void {
this.transactions.push(
this.formBuilder.group({
mandateId: [''],
accountType: ['SAVINGS'],
accountId: [''],
amount: [
'',
Validators.required
]
})
);
}
removeTxn(index: number): void {
this.transactions.removeAt(index);
}
load(): void {
this.nachService
.getBatches(this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize)
.subscribe((res: any) => {
this.batches = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.totalRecords = Number(res?.totalFilteredRecords ?? this.batches.length);
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.load();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.load();
}
open(batch: any): void {
this.router.navigate([
'/nachclearing',
'batches',
batch.id
]);
}
submit(): void {
if (this.form.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.form.value;
const payload = {
batchType: raw.batchType,
valueDate: this.toApiDate(raw.valueDate),
sponsorBankId: raw.sponsorBankId || undefined,
transactions: (raw.transactions || []).map((t: any) => ({
mandateId: t.mandateId || undefined,
accountType: t.accountType || undefined,
accountId: t.accountId || undefined,
amount: t.amount,
txnType: raw.batchType
})),
dateFormat: 'yyyy-MM-dd',
locale: 'en'
};
this.nachService.createBatch(payload).subscribe({
next: () => {
this.busy = false;
this.showForm = false;
this.form.reset({ batchType: 'DEBIT' });
this.transactions.clear();
this.addTxn();
this.notify('NACH batch created');
this.load();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Could not create the batch'), true);
}
});
}
}
@@ -0,0 +1,158 @@
<!--
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/.
-->
<div class="page">
<div class="header">
<h2>NACH Mandates</h2>
<button mat-raised-button color="primary" (click)="showForm = !showForm">
<fa-icon icon="plus"></fa-icon> Register Mandate
</button>
</div>
@if (showForm) {
<form [formGroup]="form" (ngSubmit)="submit()" class="capture-form mat-elevation-z1">
<div class="row">
<mat-form-field appearance="outline">
<mat-label>Account Type</mat-label>
<mat-select formControlName="accountType">
<mat-option value="SAVINGS">Savings</mat-option>
<mat-option value="CURRENT">Current</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Account Id</mat-label>
<input matInput type="number" formControlName="accountId" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Mandate Type</mat-label>
<mat-select formControlName="mandateType">
<mat-option value="DEBIT">Debit (EMI / SIP)</mat-option>
<mat-option value="CREDIT">Credit (Salary / Subsidy)</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<mat-form-field appearance="outline">
<mat-label>Amount</mat-label>
<input matInput type="number" formControlName="amount" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Max Amount</mat-label>
<input matInput type="number" formControlName="maxAmount" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Frequency</mat-label>
<mat-select formControlName="frequency">
<mat-option value="MONTHLY">Monthly</mat-option>
<mat-option value="QUARTERLY">Quarterly</mat-option>
<mat-option value="YEARLY">Yearly</mat-option>
<mat-option value="ADHOC">As presented</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<mat-form-field appearance="outline">
<mat-label>Start Date</mat-label>
<input matInput [matDatepicker]="sd" formControlName="startDate" />
<mat-datepicker-toggle matSuffix [for]="sd"></mat-datepicker-toggle>
<mat-datepicker #sd></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>End Date</mat-label>
<input matInput [matDatepicker]="ed" formControlName="endDate" />
<mat-datepicker-toggle matSuffix [for]="ed"></mat-datepicker-toggle>
<mat-datepicker #ed></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Sponsor Bank Id</mat-label>
<input matInput type="number" formControlName="sponsorBankId" />
</mat-form-field>
</div>
<div class="row">
<mat-form-field appearance="outline">
<mat-label>Debtor / Beneficiary Name</mat-label>
<input matInput formControlName="debtorName" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Purpose</mat-label>
<input matInput formControlName="purpose" />
</mat-form-field>
</div>
<div class="actions">
<button mat-button type="button" (click)="showForm = false">Cancel</button>
<button mat-raised-button color="primary" type="submit" [disabled]="form.invalid || busy">Register</button>
</div>
</form>
}
<div class="filters">
@for (s of ['ACTIVE', 'CANCELLED', 'EXPIRED']; track s) {
<button mat-stroked-button [class.active]="statusFilter === s" (click)="filterByStatus(s)">{{ s }}</button>
}
</div>
<div class="mat-elevation-z1 table-wrap">
<table mat-table [dataSource]="mandates">
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>#</th>
<td mat-cell *matCellDef="let m">{{ m.id }}</td>
</ng-container>
<ng-container matColumnDef="mandateReference">
<th mat-header-cell *matHeaderCellDef>Reference</th>
<td mat-cell *matCellDef="let m">{{ m.mandateReference }}</td>
</ng-container>
<ng-container matColumnDef="account">
<th mat-header-cell *matHeaderCellDef>Account</th>
<td mat-cell *matCellDef="let m">
{{ m.accountNo || m.accountId }} <span class="muted">({{ m.accountType }})</span>
</td>
</ng-container>
<ng-container matColumnDef="mandateType">
<th mat-header-cell *matHeaderCellDef>Type</th>
<td mat-cell *matCellDef="let m">{{ m.mandateType }}</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef>Amount</th>
<td mat-cell *matCellDef="let m">
{{ m.amount }} <span class="muted" *ngIf="m.maxAmount">/ max {{ m.maxAmount }}</span>
</td>
</ng-container>
<ng-container matColumnDef="validity">
<th mat-header-cell *matHeaderCellDef>Validity</th>
<td mat-cell *matCellDef="let m">
{{ m.startDate | date: 'mediumDate' }} – {{ m.endDate ? (m.endDate | date: 'mediumDate') : '…' }}
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let m">
<span class="pill" [ngClass]="pillClass(m.status)">{{ m.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let m">
@if (m.status === 'ACTIVE') {
<button mat-icon-button color="warn" (click)="cancel(m)" matTooltip="Cancel mandate">
<fa-icon icon="ban"></fa-icon>
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr mat-row *matRowDef="let row; columns: columns"></tr>
</table>
<mat-paginator
[length]="totalRecords"
[pageSize]="pageSize"
[pageIndex]="pageIndex"
[pageSizeOptions]="pageSizeOptions"
(page)="onPageChange($event)"
></mat-paginator>
</div>
</div>
@@ -0,0 +1,87 @@
/**
* 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/.
*/
.page {
padding: 1.5rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.capture-form {
padding: 1rem;
margin-bottom: 1rem;
border-radius: 4px;
.row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
mat-form-field {
flex: 1 1 220px;
}
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
}
.filters {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
button.active {
background: #3f51b5;
color: #fff;
}
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
}
.muted {
color: rgb(0 0 0 / 50%);
font-size: 0.8rem;
}
.pill {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
&.ok {
background: #e6f4ea;
color: #1e7e34;
}
&.bad {
background: #fdecea;
color: #c62828;
}
&.neutral {
background: #eceff1;
color: #546e7a;
}
}
@@ -0,0 +1,179 @@
/**
* 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 { formatDate } from '@angular/common';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
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 { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Services */
import { NachClearingService } from '../nachclearing.service';
import { pillClass, clearingError } from '../../clearing-network/clearing-status';
/** NACH mandates — the standing authorizations that let recurring debits/credits run against an account. */
@Component({
selector: 'mifosx-nach-mandates',
templateUrl: './mandates.component.html',
styleUrls: ['./mandates.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatSnackBarModule,
FaIconComponent
]
})
export class MandatesComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private nachService = inject(NachClearingService);
private snackBar = inject(MatSnackBar);
pillClass = pillClass;
mandates: any[] = [];
totalRecords = 0;
pageSize = 10;
pageIndex = 0;
pageSizeOptions = [
10,
25,
50,
100
];
statusFilter = '';
busy = false;
showForm = false;
form: UntypedFormGroup;
columns = [
'id',
'mandateReference',
'account',
'mandateType',
'amount',
'validity',
'status',
'actions'
];
constructor() {
this.form = this.formBuilder.group({
accountType: [
'SAVINGS',
Validators.required
],
accountId: [
'',
Validators.required
],
mandateType: [
'DEBIT',
Validators.required
],
amount: [''],
maxAmount: [''],
frequency: ['MONTHLY'],
startDate: [
'',
Validators.required
],
endDate: [''],
debtorName: [''],
purpose: [''],
sponsorBankId: ['']
});
}
ngOnInit(): void {
this.load();
}
private notify(message: string, isError = false): void {
this.snackBar.open(message, 'Dismiss', { duration: isError ? 6000 : 3000 });
}
private toApiDate(value: any): string | undefined {
return value ? formatDate(value, 'yyyy-MM-dd', 'en') : undefined;
}
load(): void {
this.nachService
.getMandates(undefined, this.statusFilter || undefined, this.pageIndex * this.pageSize, this.pageSize)
.subscribe((res: any) => {
this.mandates = res?.pageItems ?? (Array.isArray(res) ? res : []);
this.totalRecords = Number(res?.totalFilteredRecords ?? this.mandates.length);
});
}
filterByStatus(status: string): void {
this.statusFilter = this.statusFilter === status ? '' : status;
this.pageIndex = 0;
this.load();
}
onPageChange(event: PageEvent): void {
this.pageSize = event.pageSize;
this.pageIndex = event.pageIndex;
this.load();
}
submit(): void {
if (this.form.invalid || this.busy) {
return;
}
this.busy = true;
const raw = this.form.value;
const payload = {
...raw,
amount: raw.amount || undefined,
maxAmount: raw.maxAmount || undefined,
sponsorBankId: raw.sponsorBankId || undefined,
startDate: this.toApiDate(raw.startDate),
endDate: this.toApiDate(raw.endDate),
dateFormat: 'yyyy-MM-dd',
locale: 'en'
};
this.nachService.createMandate(payload).subscribe({
next: () => {
this.busy = false;
this.showForm = false;
this.form.reset({ accountType: 'SAVINGS', mandateType: 'DEBIT', frequency: 'MONTHLY' });
this.notify('Mandate registered');
this.load();
},
error: (err: any) => {
this.busy = false;
this.notify(clearingError(err, 'Could not register the mandate'), true);
}
});
}
cancel(mandate: any): void {
const reason = window.prompt('Reason for cancelling this mandate?');
if (!reason) {
return;
}
this.nachService.cancelMandate(mandate.id, { reason }).subscribe({
next: () => {
this.notify('Mandate cancelled');
this.load();
},
error: (err: any) => this.notify(clearingError(err, 'Could not cancel the mandate'), true)
});
}
}
@@ -0,0 +1,24 @@
<!--
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/.
-->
<div class="dashboard">
<h2>NACH Clearing</h2>
<p class="subtitle">National Automated Clearing House — recurring, mandate-based bulk debits and credits.</p>
<div class="card-grid">
@for (card of cards; track card.title) {
<mat-card class="nav-card" [routerLink]="card.link">
<div class="icon"><fa-icon [icon]="card.icon"></fa-icon></div>
<div class="body">
<h3>{{ card.title }}</h3>
<p>{{ card.description }}</p>
</div>
</mat-card>
}
</div>
</div>
@@ -0,0 +1,52 @@
/**
* 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/.
*/
.dashboard {
padding: 1.5rem;
}
.subtitle {
color: rgb(0 0 0 / 60%);
margin-bottom: 1.5rem;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.nav-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.25rem;
cursor: pointer;
transition: box-shadow 0.15s ease-in-out;
&:hover {
box-shadow: 0 3px 10px rgb(0 0 0 / 18%);
}
.icon {
font-size: 1.75rem;
color: #3f51b5;
width: 2.5rem;
text-align: center;
}
h3 {
margin: 0 0 0.25rem;
}
p {
margin: 0;
color: rgb(0 0 0 / 60%);
font-size: 0.85rem;
}
}
@@ -0,0 +1,55 @@
/**
* 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 } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Landing page for the NACH clearing module. */
@Component({
selector: 'mifosx-nachclearing-dashboard',
templateUrl: './nachclearing-dashboard.component.html',
styleUrls: ['./nachclearing-dashboard.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCardModule,
MatIconModule,
FaIconComponent
]
})
export class NachClearingDashboardComponent {
cards = [
{
title: 'Mandates',
description: 'Register and manage recurring debit/credit mandates',
icon: 'money-check',
link: [
'/nachclearing',
'mandates'
]
},
{
title: 'Batches',
description: 'Submit and process NACH debit/credit batches',
icon: 'exchange',
link: [
'/nachclearing',
'batches'
]
},
{
title: 'Clearing Network',
description: 'Bank directory, keys and multi-bank routing demo',
icon: 'university',
link: ['/clearing-network']
}
];
}
@@ -0,0 +1,57 @@
/**
* 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 { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
/** Routing Imports */
import { Route } from '../core/route/route.service';
/** Custom Components */
import { NachClearingDashboardComponent } from './nachclearing-dashboard/nachclearing-dashboard.component';
import { MandatesComponent } from './mandates/mandates.component';
import { BatchesComponent } from './batches/batches.component';
import { BatchDetailComponent } from './batch-detail/batch-detail.component';
/** NACH Clearing Routes */
const routes: Routes = [
Route.withShell([
{
path: 'nachclearing',
data: { title: 'NACH Clearing', breadcrumb: 'NACH Clearing' },
children: [
{
path: '',
component: NachClearingDashboardComponent
},
{
path: 'mandates',
component: MandatesComponent,
data: { title: 'Mandates', breadcrumb: 'Mandates' }
},
{
path: 'batches',
component: BatchesComponent,
data: { title: 'Batches', breadcrumb: 'Batches' }
},
{
path: 'batches/:id',
component: BatchDetailComponent,
data: { title: 'Batch', routeParamBreadcrumb: 'id' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class NachClearingRoutingModule {}
@@ -0,0 +1,44 @@
/**
* 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 { NgModule } from '@angular/core';
/** Custom Modules */
import { SharedModule } from '../shared/shared.module';
import { DirectivesModule } from '../directives/directives.module';
import { PipesModule } from 'app/pipes/pipes.module';
import { NachClearingRoutingModule } from './nachclearing-routing.module';
/** Custom Components (standalone) */
import { NachClearingDashboardComponent } from './nachclearing-dashboard/nachclearing-dashboard.component';
import { MandatesComponent } from './mandates/mandates.component';
import { BatchesComponent } from './batches/batches.component';
import { BatchDetailComponent } from './batch-detail/batch-detail.component';
import { TxnDecisionDialogComponent } from './batch-detail/txn-decision-dialog/txn-decision-dialog.component';
/**
* NACH (National Automated Clearing House) Clearing Module.
*
* e-mandate registration/cancellation and the debit/credit transaction batches with per-transaction
* validation and manual pass/return decisioning.
*/
@NgModule({
imports: [
SharedModule,
DirectivesModule,
PipesModule,
NachClearingRoutingModule,
NachClearingDashboardComponent,
MandatesComponent,
BatchesComponent,
BatchDetailComponent,
TxnDecisionDialogComponent
]
})
export class NachClearingModule {}
@@ -0,0 +1,89 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/**
* NACH (National Automated Clearing House) clearing — e-mandate registration/cancellation and the
* debit/credit transaction batches with per-transaction validation and pass/return decisioning. Shared
* settlement/reconciliation/bank calls live in ClearingService.
*/
@Injectable({
providedIn: 'root'
})
export class NachClearingService {
private http = inject(HttpClient);
// ----- Mandates -----
/** Server-side paginated — returns { pageItems, totalFilteredRecords }. */
getMandates(accountId?: string | number, status?: string, offset = 0, limit = 20): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (accountId) params = params.set('accountId', accountId);
if (status) params = params.set('status', status);
return this.http.get('/nachclearing/mandates', { params });
}
getMandate(id: string | number): Observable<any> {
return this.http.get(`/nachclearing/mandates/${id}`);
}
createMandate(payload: any): Observable<any> {
return this.http.post('/nachclearing/mandates', payload);
}
cancelMandate(id: string | number, payload: any): Observable<any> {
return this.http.post(`/nachclearing/mandates/${id}`, payload, {
params: new HttpParams().set('command', 'cancel')
});
}
// ----- Batches -----
getBatches(status?: string, offset = 0, limit = 20): Observable<any> {
let params = new HttpParams().set('offset', offset).set('limit', limit);
if (status) params = params.set('status', status);
return this.http.get('/nachclearing/batches', { params });
}
getBatch(id: string | number): Observable<any> {
return this.http.get(`/nachclearing/batches/${id}`);
}
/** Sub-list may come back as a raw array OR a page — callers handle both. */
getBatchTransactions(id: string | number): Observable<any> {
return this.http.get(`/nachclearing/batches/${id}/transactions`);
}
createBatch(payload: any): Observable<any> {
return this.http.post('/nachclearing/batches', payload);
}
processBatch(id: string | number): Observable<any> {
return this.http.post(`/nachclearing/batches/${id}`, {}, { params: new HttpParams().set('command', 'process') });
}
// ----- Transactions -----
getTransaction(id: string | number): Observable<any> {
return this.http.get(`/nachclearing/transactions/${id}`);
}
passTransaction(id: string | number): Observable<any> {
return this.http.post(`/nachclearing/transactions/${id}`, {}, { params: new HttpParams().set('command', 'pass') });
}
returnTransaction(id: string | number, payload: any): Observable<any> {
return this.http.post(`/nachclearing/transactions/${id}`, payload, {
params: new HttpParams().set('command', 'return')
});
}
}
+6
View File
@@ -3171,6 +3171,9 @@
"More": "More",
"Analytics": "Analytics",
"Cheque Book Inventory": "Cheque Book Inventory",
"CTS Clearing": "CTS Clearing",
"NACH Clearing": "NACH Clearing",
"Clearing Network": "Clearing Network",
"RBI Circulars": "RBI Circulars & Notifications",
"CFCFRMS": "CFCFRMS",
"CFCFRMS Dashboard": "CFCFRMS Dashboard",
@@ -4491,6 +4494,9 @@
"Add Filters": "Add Filters",
"Analytics": "Analytics Dashboard (Apache Superset)",
"Cheque Book Inventory": "Cheque Book Inventory — stock, issuance, stop-payment, hotlisting",
"CTS Clearing": "CTS Clearing — inward/outward cheque clearing, pass/return, settlement",
"NACH Clearing": "NACH Clearing — mandate-based recurring debit/credit clearing",
"Clearing Network": "Clearing Network — bank directory, keys and multi-bank routing demo",
"RBI Circulars": "RBI Circulars & Notifications",
"CFCFRMS Dashboard": "CFCFRMS Cyber Fraud Complaint Dashboard",
"Complaints": "Complaint Ticket Management",
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '260707',
'hash': '8a1b7d5d'
'version': '260720',
'hash': 'ed2a40a8'
},
'allow_switching_backend_instance': true
};