feat(clearing): add CTS/NACH clearing UI with real NPCI batch sessions
- CTS clearing module: inward batches (validate/pass/return), outward presentment capture, positive pay, settlement, reconciliation - Outward Clearing merged into one screen: Pending Cheques + Batches tabs (a real clearing session per NPCI submission, either the 2-hourly job or manual send), drill into a batch to settle/dishonour its cheques - Date-range, text, and quick (Today/Yesterday/All) filters with pagination across inward batches, outward presentments, outward batches, and batch detail - NACH clearing module (mandates, batches, transaction decisioning) - Shell/sidenav wiring for both new modules Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ed2a40a8bf
commit
6d205c0ed2
@@ -0,0 +1,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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+41
@@ -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>
|
||||
+33
@@ -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%;
|
||||
}
|
||||
+108
@@ -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')
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user