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:
+145
@@ -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>
|
||||
+150
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+167
@@ -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.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user