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
@@ -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')
});
}
}