WEB-506 feat: add Remittance Module with 7-step payout wizard (#3200)

WEB-506 fix: address CodeRabbit review (security, validation)

WEB-506 fix: make API gateway header name configurable (MIFOS_REMITTANCE_API_HEADER)

WEB-506 fix: remove duplicate translation keys (Personal Information, Transaction Details)
This commit is contained in:
Shubham Kumar
2026-02-21 23:52:37 -06:00
committed by GitHub
parent e8bfdd435d
commit 4c8f5d8343
50 changed files with 3684 additions and 13 deletions
@@ -0,0 +1,145 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="step-content">
<h3>{{ 'labels.heading.Transactional Profile' | translate }}</h3>
<p class="description">
{{
'labels.text.Register the transactional profile for the beneficiary. Add entries for individuals or legal entities.'
| translate
}}
</p>
<!-- Entity Type Toggle -->
<div class="entity-type-toggle">
<mat-button-toggle-group [(ngModel)]="entityType" aria-label="Entity type">
<mat-button-toggle value="individual">
<mat-icon>person</mat-icon>
{{ 'labels.inputs.Individual' | translate }}
</mat-button-toggle>
<mat-button-toggle value="legal">
<mat-icon>business</mat-icon>
{{ 'labels.inputs.Legal Entity' | translate }}
</mat-button-toggle>
</mat-button-toggle-group>
</div>
<!-- Transactional Profile Data Table -->
<div class="table-section">
@if (profileData.length > 0) {
<table mat-table [dataSource]="profileData" class="profile-table">
<!-- # Remittance Column -->
<ng-container matColumnDef="remittanceNumber">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.# Remittance' | translate }}</th>
<td mat-cell *matCellDef="let row">{{ row.remittanceNumber }}</td>
</ng-container>
<!-- Avg Amount/Transaction Column -->
<ng-container matColumnDef="averageAmount">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Avg Amount/Transaction' | translate }}</th>
<td mat-cell *matCellDef="let row">{{ row.averageAmount | currency: row.currency }}</td>
</ng-container>
<!-- # Senders Column -->
<ng-container matColumnDef="senderCount">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.# Senders' | translate }}</th>
<td mat-cell *matCellDef="let row">{{ row.senderCount }}</td>
</ng-container>
<!-- Sender Registry Column -->
<ng-container matColumnDef="senderRegistry">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Sender Registry' | translate }}</th>
<td mat-cell *matCellDef="let row">{{ row.senderRegistry }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns; let i = index"
(click)="removeProfileEntry(i)"
matTooltip="{{ 'labels.text.Click to remove' | translate }}"
class="clickable-row"
></tr>
</table>
}
@if (profileData.length === 0) {
<div class="empty-table">
<mat-icon>table_chart</mat-icon>
<p>{{ 'labels.text.No transactional profile entries yet. Add one below.' | translate }}</p>
</div>
}
</div>
<!-- Add Profile Entry Form -->
<div class="add-entry-section">
<h4>{{ 'labels.heading.Add Profile Entry' | translate }}</h4>
<form [formGroup]="profileForm" class="entry-form">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.# Remittance' | translate }}</mat-label>
<input
matInput
formControlName="remittanceNumber"
placeholder="{{ 'labels.placeholders.# Remittance' | translate }}"
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Avg Amount/Transaction' | translate }}</mat-label>
<input matInput type="number" formControlName="averageAmount" placeholder="0.00" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.# Senders' | translate }}</mat-label>
<input matInput type="number" formControlName="senderCount" placeholder="0" min="0" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Sender Registry' | translate }}</mat-label>
<input
matInput
formControlName="senderRegistry"
placeholder="{{ 'labels.placeholders.Sender name' | translate }}"
/>
</mat-form-field>
<button
mat-stroked-button
color="primary"
type="button"
(click)="addProfileEntry()"
[disabled]="profileForm.invalid"
>
<mat-icon>add</mat-icon>
{{ 'labels.buttons.Add Entry' | translate }}
</button>
</form>
</div>
@if (errorMessage) {
<div class="error-banner">
<mat-icon>error_outline</mat-icon>
<span>{{ errorMessage }}</span>
</div>
}
<div class="step-actions">
<button
mat-raised-button
color="primary"
(click)="assignPayout()"
[disabled]="isLoading || !vendor || !recipientData || profileData.length === 0"
>
@if (isLoading) {
<mat-spinner diameter="20"></mat-spinner>
} @else {
<span><mat-icon>assignment_turned_in</mat-icon> {{ 'labels.buttons.Assign Payout' | translate }}</span>
}
</button>
</div>
</div>
@@ -0,0 +1,150 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
.step-content {
padding: 16px 0;
h3 {
margin: 0 0 8px;
font-weight: 500;
}
.description {
margin: 0 0 24px;
font-size: 14px;
}
}
.entity-type-toggle {
margin-bottom: 20px;
mat-button-toggle-group {
border-radius: 8px;
}
mat-icon {
margin-right: 4px;
font-size: 18px;
width: 18px;
height: 18px;
vertical-align: middle;
}
}
.table-section {
margin-bottom: 24px;
.profile-table {
width: 100%;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
th.mat-mdc-header-cell {
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
td.mat-mdc-cell {
font-size: 14px;
}
.clickable-row {
cursor: pointer;
&:hover {
background-color: #fff3f3;
}
}
}
.empty-table {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
background-color: #fafafa;
border: 1px dashed #e0e0e0;
border-radius: 8px;
mat-icon {
font-size: 40px;
width: 40px;
height: 40px;
margin-bottom: 8px;
}
p {
margin: 0;
font-size: 14px;
}
}
}
.add-entry-section {
margin-bottom: 24px;
padding: 16px;
border-radius: 8px;
border: 1px solid #e0e0e0;
h4 {
margin: 0 0 12px;
font-weight: 500;
font-size: 14px;
}
.entry-form {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-end;
mat-form-field {
flex: 1 1 180px;
min-width: 160px;
}
button {
margin-bottom: 3.5em;
margin-left: auto;
white-space: nowrap;
}
}
}
.error-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
margin: 8px 0;
background-color: #fdecea;
border-radius: 4px;
color: #611a15;
mat-icon {
color: #f44336;
font-size: 20px;
width: 20px;
height: 20px;
}
}
.step-actions {
margin-top: 16px;
button {
mat-spinner {
display: inline-block;
margin-right: 8px;
}
}
}
@@ -0,0 +1,167 @@
/**
* 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/.
*/
import { Component, EventEmitter, Input, Output, inject, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators, FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from '../../../standalone-shared.module';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatIcon } from '@angular/material/icon';
import { MatTableModule } from '@angular/material/table';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { TranslateService } from '@ngx-translate/core';
import { RemittancesService } from '../../remittances.service';
import { RemittanceTransaction, RemittanceVendor, PayoutAssignmentResponse } from '../../models/remittance.model';
import { ValidatedRecipient } from '../../models/beneficiary.model';
/**
* Represents a single row in the Transactional Profile data table.
* As required by the spec: # Remittance, Avg Amount/Transaction, # Senders, Sender Registry.
*/
export interface TransactionalProfileRow {
remittanceNumber: string;
averageAmount: number;
currency: string;
senderCount: number;
senderRegistry: string;
}
@Component({
selector: 'mifosx-transactional-profile-step',
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
MatProgressSpinner,
MatIcon,
MatTableModule,
MatButtonToggleModule,
FormsModule
],
templateUrl: './transactional-profile-step.component.html',
styleUrls: ['./transactional-profile-step.component.scss']
})
export class TransactionalProfileStepComponent implements OnInit {
@Input() vendor: RemittanceVendor | null = null;
@Input() transactionId = '';
@Input() remittanceData: RemittanceTransaction | null = null;
@Input() recipientData: ValidatedRecipient | null = null;
@Output() payoutAssigned = new EventEmitter<PayoutAssignmentResponse>();
private remittancesService = inject(RemittancesService);
private fb = inject(FormBuilder);
private translateService = inject(TranslateService);
isLoading = false;
errorMessage = '';
/** Transactional profile data table */
displayedColumns: string[] = [
'remittanceNumber',
'averageAmount',
'senderCount',
'senderRegistry'
];
profileData: TransactionalProfileRow[] = [];
/** Form for adding new profile entries */
profileForm!: FormGroup;
/** Entity type toggle: 'individual' or 'legal' */
entityType: 'individual' | 'legal' = 'individual';
ngOnInit(): void {
this.profileForm = this.fb.group({
remittanceNumber: [
'',
Validators.required
],
averageAmount: [
'',
[
Validators.required,
Validators.min(0)
]
],
senderCount: [
0,
[
Validators.required,
Validators.min(0)
]
],
senderRegistry: [
'',
Validators.required
]
});
// Pre-populate from the current transaction if available
if (this.remittanceData) {
this.profileData = [
{
remittanceNumber: this.remittanceData.pin,
averageAmount: this.remittanceData.receivingAmount,
currency: this.remittanceData.receivingCurrency,
senderCount: 1,
senderRegistry: this.remittanceData.senderName
}
];
}
}
addProfileEntry(): void {
if (this.profileForm.invalid) return;
const val = this.profileForm.value;
this.profileData = [
...this.profileData,
{
remittanceNumber: val.remittanceNumber,
averageAmount: val.averageAmount,
currency: this.remittanceData?.receivingCurrency || 'USD',
senderCount: Math.max(1, val.senderCount || 1),
senderRegistry: val.senderRegistry
}
];
this.profileForm.reset({
senderCount: 1
});
}
removeProfileEntry(index: number): void {
this.profileData = this.profileData.filter((_, i) => i !== index);
}
assignPayout(): void {
if (!this.vendor || !this.recipientData) return;
this.isLoading = true;
this.errorMessage = '';
this.remittancesService
.assignPayout(this.vendor.headerValue, this.transactionId, {
additionalInfo: {
comment: 'Payout assignment from web-app',
transactionalProfile: JSON.stringify(this.profileData),
entityType: this.entityType,
profileEntryCount: this.profileData.length.toString()
}
})
.subscribe({
next: (response) => {
this.isLoading = false;
this.payoutAssigned.emit(response);
},
error: (err) => {
this.isLoading = false;
this.errorMessage =
err?.error?.message ||
this.translateService.instant('labels.text.Failed to assign payout. Please try again.');
}
});
}
}