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,107 @@
<!--
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.Confirm Payout' | translate }}</h3>
<p class="description">
{{ 'labels.text.Please review the details carefully before confirming the payment.' | translate }}
</p>
<div class="confirmation-card">
<!-- Transaction info -->
@if (remittanceData) {
<div class="card-section">
<h4>{{ 'labels.heading.Transaction' | translate }}</h4>
@if (officeName) {
<div class="info-row">
<span class="label">{{ 'labels.inputs.Office' | translate }}</span>
<span class="value">{{ officeName }}</span>
</div>
}
<div class="info-row">
<span class="label">{{ 'labels.inputs.Code' | translate }}</span>
<span class="value">{{ remittanceData.pin }}</span>
</div>
<div class="info-row">
<span class="label">{{ 'labels.inputs.Date' | translate }}</span>
<span class="value">{{ remittanceData.created | date: 'medium' }}</span>
</div>
<div class="info-row">
<span class="label">{{ 'labels.inputs.Payout Amount' | translate }}</span>
<span class="value amount">{{
remittanceData.receivingAmount | currency: remittanceData.receivingCurrency
}}</span>
</div>
<div class="info-row">
<span class="label">{{ 'labels.inputs.Sender' | translate }}</span>
<span class="value">{{ remittanceData.senderName }}</span>
</div>
</div>
}
<mat-divider></mat-divider>
<!-- Recipient info -->
<div class="card-section">
<h4>{{ 'labels.heading.Recipient' | translate }}</h4>
<div class="info-row">
<span class="label">{{ 'labels.inputs.Name' | translate }}</span>
@if (recipientData?.givenName) {
<span class="value">{{ recipientData.givenName }} {{ recipientData.lastName }}</span>
} @else {
<span class="value">{{ remittanceData?.recipientName }}</span>
}
</div>
@if (recipientData?.primaryDocument?.documentType) {
<div class="info-row">
<span class="label">{{ 'labels.inputs.Document' | translate }}</span>
<span class="value"
>{{ recipientData.primaryDocument?.documentType }} -
{{ recipientData.primaryDocument?.documentNumber }}</span
>
</div>
}
</div>
@if (payoutAssignment) {
<mat-divider></mat-divider>
<!-- Assignment info -->
<div class="card-section">
<h4>{{ 'labels.heading.Payout Assignment' | translate }}</h4>
<div class="info-row">
<span class="label">{{ 'labels.inputs.Status' | translate }}</span>
<span class="value">{{ payoutAssignment.status }}</span>
</div>
@if (payoutAssignment?.trackingNumber) {
<div class="info-row">
<span class="label">{{ 'labels.inputs.Reference Number' | translate }}</span>
<span class="value mono">{{ payoutAssignment.trackingNumber }}</span>
</div>
}
</div>
}
</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="warn" (click)="confirmPayout()" [disabled]="isLoading || !vendor">
@if (isLoading) {
<mat-spinner diameter="20"></mat-spinner>
} @else {
<span><mat-icon>check_circle</mat-icon> {{ 'labels.buttons.Confirm Payment' | translate }}</span>
}
</button>
</div>
</div>
@@ -0,0 +1,99 @@
/**
* 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;
}
}
.confirmation-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
margin-bottom: 16px;
}
.card-section {
padding: 16px;
h4 {
margin: 0 0 12px;
font-weight: 500;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
.label {
font-size: 13px;
}
.value {
font-size: 14px;
font-weight: 500;
&.amount {
font-size: 18px;
font-weight: 700;
color: #2e7d32;
}
&.mono {
font-family: monospace;
font-size: 12px;
background: #f5f5f5;
padding: 2px 8px;
border-radius: 4px;
}
}
}
.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,88 @@
/**
* 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 } from '@angular/core';
import { STANDALONE_SHARED_IMPORTS } from '../../../standalone-shared.module';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatIcon } from '@angular/material/icon';
import { MatDivider } from '@angular/material/divider';
import { TranslateService } from '@ngx-translate/core';
import { RemittancesService } from '../../remittances.service';
import {
RemittanceTransaction,
RemittanceVendor,
PayoutAssignmentResponse,
PayoutConfirmationResponse
} from '../../models/remittance.model';
import { ValidatedRecipient } from '../../models/beneficiary.model';
import { AuthenticationService } from '../../../core/authentication/authentication.service';
@Component({
selector: 'mifosx-confirm-payment-step',
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
MatProgressSpinner,
MatIcon,
MatDivider
],
templateUrl: './confirm-payment-step.component.html',
styleUrls: ['./confirm-payment-step.component.scss']
})
export class ConfirmPaymentStepComponent {
@Input() vendor: RemittanceVendor | null = null;
@Input() transactionId = '';
@Input() remittanceData: RemittanceTransaction | null = null;
@Input() recipientData: ValidatedRecipient | null = null;
@Input() payoutAssignment: PayoutAssignmentResponse | null = null;
@Input() clientId: number | null = null;
@Output() payoutConfirmed = new EventEmitter<PayoutConfirmationResponse>();
private remittancesService = inject(RemittancesService);
private authService = inject(AuthenticationService);
private translateService = inject(TranslateService);
isLoading = false;
errorMessage = '';
get officeName(): string {
return this.authService.getCredentials()?.officeName || '';
}
confirmPayout(): void {
if (!this.vendor || !this.recipientData || !this.clientId) {
this.errorMessage = this.translateService.instant('labels.text.Missing required information. Please try again.');
return;
}
this.isLoading = true;
this.errorMessage = '';
this.remittancesService
.confirmPayout(this.vendor.headerValue, this.transactionId, this.clientId, {
additionalInfo: {
comment: 'Payout confirmation from web-app'
}
})
.subscribe({
next: (response) => {
this.isLoading = false;
this.payoutConfirmed.emit(response);
},
error: (err) => {
this.isLoading = false;
this.errorMessage =
err?.error?.message ||
this.translateService.instant(
'labels.text.Failed to confirm payment. Please try again or contact support.'
);
}
});
}
}