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,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/.
-->
<div class="step-content">
<form [formGroup]="beneficiaryForm" (ngSubmit)="validateRecipient()">
<div class="form-fields">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.First Name' | translate }}</mat-label>
<input
matInput
formControlName="givenName"
placeholder="{{ 'labels.placeholders.Enter first name (include middle name)' | translate }}"
/>
@if (beneficiaryForm.get('givenName')?.hasError('required') && beneficiaryForm.get('givenName')?.touched) {
<mat-error>{{ 'labels.inputs.First name is required' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Last Name' | translate }}</mat-label>
<input
matInput
formControlName="lastName"
placeholder="{{ 'labels.placeholders.Enter last name' | translate }}"
/>
@if (beneficiaryForm.get('lastName')?.hasError('required') && beneficiaryForm.get('lastName')?.touched) {
<mat-error>{{ 'labels.inputs.Last name is required' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Document Type' | translate }}</mat-label>
<mat-select formControlName="documentType">
@for (doc of documentTypes; track doc.value) {
<mat-option [value]="doc.value">{{ doc.label | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Document Number' | translate }}</mat-label>
<input
matInput
formControlName="documentNumber"
placeholder="{{ 'labels.placeholders.Enter document number' | translate }}"
/>
@if (
beneficiaryForm.get('documentNumber')?.hasError('required') && beneficiaryForm.get('documentNumber')?.touched
) {
<mat-error>{{ 'labels.inputs.Document number is required' | translate }}</mat-error>
}
</mat-form-field>
</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" type="submit" [disabled]="beneficiaryForm.invalid || isLoading">
@if (isLoading) {
<mat-spinner diameter="20"></mat-spinner>
} @else {
<span><mat-icon>person_search</mat-icon> {{ 'labels.buttons.Validate Recipient' | translate }}</span>
}
</button>
</div>
</form>
</div>
@@ -0,0 +1,47 @@
/**
* 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;
}
.form-fields {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 480px;
}
.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,125 @@
/**
* 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, OnInit, Output, inject } from '@angular/core';
import { FormBuilder, FormGroup, Validators } 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 { TranslateService } from '@ngx-translate/core';
import { RemittancesService } from '../../remittances.service';
import { RemittanceVendor } from '../../models/remittance.model';
import { ValidatedRecipient } from '../../models/beneficiary.model';
@Component({
selector: 'mifosx-search-beneficiary-step',
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
MatProgressSpinner,
MatIcon
],
templateUrl: './search-beneficiary-step.component.html',
styleUrls: ['./search-beneficiary-step.component.scss']
})
export class SearchBeneficiaryStepComponent implements OnInit {
@Input() vendor: RemittanceVendor | null = null;
@Input() transactionId = '';
@Output() recipientValidated = new EventEmitter<ValidatedRecipient>();
private fb = inject(FormBuilder);
private remittancesService = inject(RemittancesService);
private translateService = inject(TranslateService);
beneficiaryForm!: FormGroup;
isLoading = false;
errorMessage = '';
documentTypes = [
{ value: 'NATIONAL_ID', label: 'labels.inputs.National ID' },
{ value: 'PASSPORT', label: 'labels.inputs.Passport' },
{ value: 'DRIVERS_LICENSE', label: "labels.inputs.Driver's License" },
{ value: 'VOTER_ID', label: 'labels.inputs.Voter ID' }
];
ngOnInit(): void {
this.beneficiaryForm = this.fb.group({
givenName: [
'',
Validators.required
],
lastName: [
'',
Validators.required
],
documentType: [
'NATIONAL_ID',
Validators.required
],
documentNumber: [
'',
[
Validators.required,
Validators.minLength(3)
]
]
});
}
validateRecipient(): void {
if (this.beneficiaryForm.invalid || !this.vendor) return;
this.isLoading = true;
this.errorMessage = '';
const { givenName, lastName, documentType, documentNumber } = this.beneficiaryForm.value;
this.remittancesService
.validateRecipient(this.vendor.headerValue, this.transactionId, {
givenName,
lastName,
primaryDocument: {
documentType,
documentNumber
}
})
.subscribe({
next: (recipient) => {
this.isLoading = false;
if (recipient && recipient.valid) {
// Merge form data into recipient so details show in Confirm step
const enrichedRecipient: ValidatedRecipient = {
...recipient,
firstName: givenName,
givenName: givenName,
lastName: lastName,
primaryDocument: {
documentType,
documentNumber,
issuingCountry: ''
}
};
this.recipientValidated.emit(enrichedRecipient);
} else {
this.errorMessage = this.translateService.instant(
'labels.text.Recipient is not valid for this transaction. Please check the details.'
);
}
},
error: (err) => {
this.isLoading = false;
this.errorMessage =
err?.error?.message ||
this.translateService.instant(
'labels.text.Recipient validation failed. Please check the document details and try again.'
);
}
});
}
}