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,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);
}
});
}
}
}