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,234 @@
<!--
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/.
-->
@if (recipientData) {
<div class="step-content">
<h3>{{ 'labels.heading.Validated Recipient Information' | translate }}</h3>
<!-- Section: Identification -->
<div class="section-header">
<mat-icon>badge</mat-icon>
<h4>{{ 'labels.heading.Identification' | translate }}</h4>
</div>
<div class="details-grid">
@if (recipientData.primaryDocument?.documentType) {
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Document Type' | translate }}</span>
<span class="value">{{ recipientData.primaryDocument?.documentType }}</span>
</div>
}
@if (recipientData.primaryDocument?.documentNumber) {
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Document Number' | translate }}</span>
<span class="value">{{ recipientData.primaryDocument?.documentNumber }}</span>
</div>
}
@if (recipientData.identityCard) {
<div class="detail-item">
<span class="label">{{ 'labels.inputs.National Identity Card Number' | translate }}</span>
<span class="value">{{ recipientData.identityCard }}</span>
</div>
}
</div>
<!-- Section: Personal Information -->
<div class="section-header">
<mat-icon>person</mat-icon>
<h4>{{ 'labels.heading.Personal Information' | translate }}</h4>
</div>
<div class="details-grid">
<div class="detail-item">
<span class="label">{{ 'labels.inputs.First Name' | translate }}</span>
<span class="value">{{ recipientData.givenName }}</span>
</div>
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Last Name' | translate }}</span>
<span class="value">{{ recipientData.lastName }}</span>
</div>
@if (recipientData.motherMaidenName) {
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Mothers Maiden Name' | translate }}</span>
<span class="value">{{ recipientData.motherMaidenName }}</span>
</div>
}
@if (recipientData.dateOfBirth) {
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Date of Birth' | translate }}</span>
<span class="value">{{ recipientData.dateOfBirth | date: 'mediumDate' }}</span>
</div>
}
@if (recipientData.phone) {
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Phone Number' | translate }}</span>
<span class="value">{{ recipientData.phone }}</span>
</div>
}
</div>
<!-- Section: Address -->
<div class="section-header">
<mat-icon>location_on</mat-icon>
<h4>{{ 'labels.inputs.Address' | translate }}</h4>
</div>
<div class="details-grid">
@if (recipientData.address?.line1) {
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Address Line' | translate }}</span>
<span class="value">{{ recipientData.address?.line1 }}</span>
</div>
}
@if (recipientData.address?.postalCode) {
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Postal Code' | translate }}</span>
<span class="value">{{ recipientData.address?.postalCode }}</span>
</div>
}
@if (recipientData.address?.state) {
<div class="detail-item">
<span class="label">{{ 'labels.inputs.State' | translate }}</span>
<span class="value">{{ recipientData.address?.state }}</span>
</div>
}
@if (recipientData.municipality || recipientData.address?.city) {
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Municipality' | translate }}</span>
<span class="value">{{ recipientData.municipality || recipientData.address?.city }}</span>
</div>
}
@if (recipientData.locality) {
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Locality' | translate }}</span>
<span class="value">{{ recipientData.locality }}</span>
</div>
}
@if (recipientData.newLocality) {
<div class="detail-item">
<span class="label">{{ 'labels.inputs.New Locality' | translate }}</span>
<span class="value">{{ recipientData.newLocality }}</span>
</div>
}
@if (recipientData.address?.country) {
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Country' | translate }}</span>
<span class="value">{{ recipientData.address?.country }}</span>
</div>
}
</div>
<!-- Section: Professional Information -->
<div class="section-header">
<mat-icon>work</mat-icon>
<h4>{{ 'labels.heading.Professional Information' | translate }}</h4>
</div>
<div class="details-grid">
@if (recipientData.occupation) {
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Occupation' | translate }}</span>
<span class="value">{{ recipientData.occupation }}</span>
</div>
}
@if (recipientData.economicActivity) {
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Economic Activity' | translate }}</span>
<span class="value">{{ recipientData.economicActivity }}</span>
</div>
}
@if (recipientData.entityMember) {
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Entity Member' | translate }}</span>
<span class="value">{{ recipientData.entityMember }}</span>
</div>
}
</div>
<!-- Section: Assign to Mifos Client -->
<div class="section-header">
<mat-icon>assignment_ind</mat-icon>
<h4>{{ 'labels.heading.Assign to Mifos Client' | translate }}</h4>
</div>
<div class="client-search-section">
<p class="search-instruction">
{{ 'labels.text.Search and assign this beneficiary to an existing Mifos client account' | translate }}
</p>
<mat-form-field appearance="outline" class="client-search-field">
<mat-label>{{ 'labels.inputs.Search Client' | translate }}</mat-label>
<input
matInput
[formControl]="clientSearchControl"
[matAutocomplete]="clientAuto"
placeholder="{{ 'labels.inputs.Type client name or account number' | translate }}"
/>
<mat-icon matIconPrefix>search</mat-icon>
<mat-autocomplete
#clientAuto="matAutocomplete"
[displayWith]="displayFn"
(optionSelected)="onClientSelected($event.option.value)"
>
@for (client of filteredClients$ | async; track client.id) {
<mat-option [value]="client">
<div class="client-option">
<span class="client-name">{{ client.displayName }}</span>
<span class="client-details">
@if (client.accountNo) {
<span class="account-no">{{ 'labels.inputs.Account' | translate }}: {{ client.accountNo }}</span>
}
@if (client.externalId) {
<span class="external-id"
>{{ 'labels.inputs.External ID' | translate }}: {{ client.externalId }}</span
>
}
</span>
</div>
</mat-option>
}
</mat-autocomplete>
</mat-form-field>
@if (searchError) {
<div class="error-message">
<mat-icon>error</mat-icon>
<span>{{ 'labels.text.Failed to search clients. Please try again.' | translate }}</span>
</div>
}
@if (selectedClient) {
<div class="selected-client-info">
<mat-icon color="primary">check_circle</mat-icon>
<div class="client-info">
<strong>{{ selectedClient.displayName }}</strong>
@if (selectedClient.accountNo) {
<span class="subdued">{{ 'labels.inputs.Account' | translate }}: {{ selectedClient.accountNo }}</span>
}
</div>
</div>
}
</div>
<div class="step-actions">
<button mat-raised-button color="primary" (click)="onNext()" [disabled]="!selectedClient">
{{ 'labels.buttons.Next' | translate }}
<mat-icon>arrow_forward</mat-icon>
</button>
</div>
</div>
}
@@ -0,0 +1,161 @@
/**
* 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 16px;
font-weight: 500;
}
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin: 20px 0 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e0e0e0;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
h4 {
margin: 0;
font-weight: 500;
font-size: 14px;
}
&:first-of-type {
margin-top: 0;
}
}
.details-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 100%));
gap: 16px;
margin-bottom: 16px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
.label {
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.value {
font-size: 14px;
}
}
.step-actions {
margin-top: 16px;
}
.client-search-section {
margin: 16px 0;
padding: 16px;
border-radius: 8px;
background-color: var(--mdc-theme-surface, rgb(0 0 0 / 4%));
.search-instruction {
margin: 0 0 12px;
font-size: 13px;
opacity: 0.7;
}
.client-search-field {
width: 100%;
}
.error-message {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding: 8px 12px;
background-color: var(--mdc-theme-error-container, #ffebee);
border-radius: 4px;
color: var(--mdc-theme-on-error-container, #c62828);
font-size: 13px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.selected-client-info {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
padding: 12px;
background-color: var(--mdc-theme-primary-container, rgb(76 175 80 / 12%));
border-radius: 4px;
border: 1px solid var(--mdc-theme-primary, rgb(76 175 80 / 30%));
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
color: var(--mdc-theme-primary, #4caf50);
}
.client-info {
display: flex;
flex-direction: column;
gap: 4px;
strong {
font-size: 14px;
}
.subdued {
font-size: 12px;
opacity: 0.7;
}
}
}
}
.client-option {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 0;
.client-name {
font-weight: 500;
}
.client-details {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: #666;
.account-no,
.external-id {
white-space: nowrap;
}
}
}
@@ -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/.
*/
import { Component, EventEmitter, Input, Output, inject, OnInit } from '@angular/core';
import { STANDALONE_SHARED_IMPORTS } from '../../../standalone-shared.module';
import { MatIcon } from '@angular/material/icon';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { Observable, of } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap, startWith, catchError, map } from 'rxjs/operators';
import { ValidatedRecipient } from '../../models/beneficiary.model';
import { ClientsService } from '../../../clients/clients.service';
interface ClientSearchResult {
id: number;
displayName: string;
accountNo: string;
externalId: string;
}
@Component({
selector: 'mifosx-beneficiary-details-step',
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIcon,
ReactiveFormsModule,
MatAutocompleteModule
],
templateUrl: './beneficiary-details-step.component.html',
styleUrls: ['./beneficiary-details-step.component.scss']
})
export class BeneficiaryDetailsStepComponent implements OnInit {
@Input() recipientData: ValidatedRecipient | null = null;
@Output() beneficiaryNext = new EventEmitter<number>();
private clientsService = inject(ClientsService);
clientSearchControl = new FormControl<string | ClientSearchResult>('');
filteredClients$!: Observable<ClientSearchResult[]>;
selectedClient: ClientSearchResult | null = null;
searchError = false;
ngOnInit(): void {
this.filteredClients$ = this.clientSearchControl.valueChanges.pipe(
startWith(''),
debounceTime(300),
distinctUntilChanged(),
switchMap((searchValue) => {
const searchText = typeof searchValue === 'string' ? searchValue : '';
if (!searchText || searchText.trim().length < 2) {
return of([]);
}
return this.searchClients(searchText.trim());
})
);
}
private searchClients(searchText: string): Observable<ClientSearchResult[]> {
this.searchError = false;
return this.clientsService.searchByText(searchText, 0, 20, '', '').pipe(
map((response: any) => {
const clients = response?.content || [];
return clients.map((client: any) => ({
id: client.id,
displayName: client.displayName || '',
accountNo: client.accountNumber || '',
externalId: client.externalId || ''
}));
}),
catchError(() => {
this.searchError = true;
return of([]);
})
);
}
onClientSelected(client: ClientSearchResult): void {
this.selectedClient = client;
this.clientSearchControl.setValue(client.displayName);
}
displayFn = (value: string | ClientSearchResult): string => {
return typeof value === 'string' ? value : value?.displayName || '';
};
onNext(): void {
if (this.selectedClient) {
this.beneficiaryNext.emit(this.selectedClient.id);
}
}
}
@@ -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.'
);
}
});
}
}
@@ -0,0 +1,103 @@
<!--
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">
<div class="receipt-header" [ngClass]="{ success: isPaid, terminal: isTerminal }">
<mat-icon class="receipt-icon">{{ receiptIcon }}</mat-icon>
<h3>{{ receiptTitle }}</h3>
</div>
@if (remittanceData) {
<div class="receipt-body">
<div class="receipt-section">
<h4>{{ 'labels.heading.Transaction Details' | translate }}</h4>
@if (officeName) {
<div class="receipt-row">
<span class="label">{{ 'labels.inputs.Office' | translate }}</span>
<span class="value">{{ officeName }}</span>
</div>
}
<div class="receipt-row">
<span class="label">{{ 'labels.inputs.External ID' | translate }}</span>
<span class="value">{{ remittanceData.pin }}</span>
</div>
<div class="receipt-row">
<span class="label">{{ 'labels.inputs.Status' | translate }}</span>
<span class="value status-text">{{ payoutConfirmation?.transaction?.status || remittanceData.status }}</span>
</div>
<div class="receipt-row">
<span class="label">{{ 'labels.inputs.Payout Amount' | translate }}</span>
<span class="value amount">{{
remittanceData.receivingAmount | currency: remittanceData.receivingCurrency
}}</span>
</div>
<div class="receipt-row">
<span class="label">{{ 'labels.inputs.Sender' | translate }}</span>
<span class="value">{{ remittanceData.senderName }}</span>
</div>
</div>
<div class="receipt-section">
<h4>{{ 'labels.heading.Recipient Details' | translate }}</h4>
<div class="receipt-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="receipt-row">
<span class="label">{{ 'labels.inputs.Document' | translate }}</span>
<span class="value"
>{{ recipientData.primaryDocument.documentType }} -
{{ recipientData.primaryDocument.documentNumber }}</span
>
</div>
}
</div>
@if (payoutConfirmation) {
<div class="receipt-section">
<h4>{{ 'labels.heading.Confirmation Details' | translate }}</h4>
@if (payoutConfirmation?.transaction) {
<div class="receipt-row">
<span class="label">{{ 'labels.inputs.Status' | translate }}</span>
<span class="value">{{ payoutConfirmation.transaction.status }}</span>
</div>
@if (payoutAssignment?.trackingNumber) {
<div class="receipt-row">
<span class="label">{{ 'labels.inputs.Reference Number' | translate }}</span>
<span class="value mono">{{ payoutAssignment.trackingNumber }}</span>
</div>
}
<div class="receipt-row">
<span class="label">{{ 'labels.inputs.Payout Amount' | translate }}</span>
<span class="value">{{
payoutConfirmation.transaction.receivingAmount
| currency: payoutConfirmation.transaction.receivingCurrency
}}</span>
</div>
}
</div>
}
</div>
}
<div class="step-actions">
<button mat-raised-button (click)="printReceipt()">
<mat-icon>print</mat-icon>
{{ 'labels.buttons.Print Receipt' | translate }}
</button>
<button mat-raised-button color="primary" (click)="onNewRemittance()">
<mat-icon>add</mat-icon>
{{ 'labels.buttons.New Remittance' | translate }}
</button>
</div>
</div>
@@ -0,0 +1,192 @@
/**
* 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;
}
.receipt-header {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px;
margin-bottom: 24px;
border-radius: 8px;
text-align: center;
&.success {
background-color: #e8f5e9;
.receipt-icon {
color: #2e7d32;
}
h3 {
color: #2e7d32;
}
}
&.terminal {
background-color: #fff3e0;
.receipt-icon {
color: #e65100;
}
h3 {
color: #e65100;
}
}
.receipt-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin-bottom: 8px;
}
h3 {
margin: 0;
font-weight: 500;
}
}
.receipt-body {
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
margin-bottom: 24px;
}
.receipt-section {
padding: 16px;
&:not(:last-child) {
border-bottom: 1px solid #e0e0e0;
}
h4 {
margin: 0 0 12px;
font-weight: 500;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
.receipt-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
.label {
font-size: 13px;
}
.value {
font-size: 14px;
font-weight: 500;
&.amount {
font-size: 18px;
font-weight: 700;
color: #2e7d32;
}
&.status-text {
text-transform: uppercase;
font-size: 12px;
letter-spacing: 0.5px;
}
&.mono {
font-family: monospace;
font-size: 12px;
background: #f5f5f5;
padding: 2px 8px;
border-radius: 4px;
}
}
}
.step-actions {
display: flex;
gap: 12px;
margin-top: 16px;
}
@media print {
.step-actions {
display: none;
}
.step-content {
padding: 0;
}
.receipt-header {
border: none;
padding: 16px;
margin-bottom: 16px;
&.success {
background-color: transparent;
border-bottom: 2px solid #000;
.receipt-icon {
color: #000;
}
h3 {
color: #000;
}
}
&.terminal {
background-color: transparent;
border-bottom: 2px solid #000;
.receipt-icon {
color: #000;
}
h3 {
color: #000;
}
}
}
.receipt-body {
border: 1px solid #000;
}
.receipt-section {
border-color: #000 !important;
h4 {
color: #000;
}
}
.receipt-row {
.label,
.value {
color: #000;
}
.value.amount {
color: #000;
}
.value.mono {
background: transparent;
border: 1px solid #ccc;
}
}
}
@@ -0,0 +1,76 @@
/**
* 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 { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from '../../../standalone-shared.module';
import {
RemittanceTransaction,
PayoutAssignmentResponse,
PayoutConfirmationResponse
} from '../../models/remittance.model';
import { ValidatedRecipient } from '../../models/beneficiary.model';
import { RemittanceStatus, isTerminalStatus } from '../../models/remittance-status.enum';
import { AuthenticationService } from '../../../core/authentication/authentication.service';
import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'mifosx-payment-receipt-step',
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIcon
],
templateUrl: './payment-receipt-step.component.html',
styleUrls: ['./payment-receipt-step.component.scss']
})
export class PaymentReceiptStepComponent {
private authService = inject(AuthenticationService);
private translateService = inject(TranslateService);
@Input() remittanceData: RemittanceTransaction | null = null;
@Input() recipientData: ValidatedRecipient | null = null;
@Input() payoutConfirmation: PayoutConfirmationResponse | null = null;
@Input() payoutAssignment: PayoutAssignmentResponse | null = null;
@Input() parsedStatus: RemittanceStatus = RemittanceStatus.UNKNOWN;
@Output() resetWorkflow = new EventEmitter<void>();
get officeName(): string {
return this.authService.getCredentials()?.officeName || '';
}
get isPaid(): boolean {
return this.parsedStatus === RemittanceStatus.PAID;
}
get isTerminal(): boolean {
return isTerminalStatus(this.parsedStatus) && !this.isPaid;
}
get receiptIcon(): string {
if (this.isPaid) return 'check_circle';
if (this.isTerminal) return 'cancel';
return 'info';
}
get receiptTitle(): string {
if (this.isPaid) return this.translateService.instant('labels.heading.Payment Successful');
if (this.isTerminal)
return this.translateService.instant('labels.heading.Transaction') + ' ' + (this.remittanceData?.status || '');
return this.translateService.instant('labels.heading.Transaction Status');
}
onNewRemittance(): void {
this.resetWorkflow.emit();
}
printReceipt(): void {
window.print();
}
}
@@ -0,0 +1,92 @@
<!--
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/.
-->
@if (remittanceData) {
<div class="step-content">
<div class="status-row">
<span class="status-badge" [ngClass]="statusBadgeClass">{{ remittanceData.status }}</span>
</div>
<div class="details-grid">
@if (remittanceData.sendingPartner) {
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Sent By' | translate }}</span>
<span class="value">{{ remittanceData.sendingPartner }}</span>
</div>
}
<div class="detail-item">
<span class="label">{{ 'labels.inputs.External ID' | translate }}</span>
<span class="value">{{ remittanceData.pin }}</span>
</div>
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Created At' | translate }}</span>
@if (isValidDate(remittanceData.created)) {
<span class="value">{{ remittanceData.created | date: 'medium' }}</span>
} @else {
<span class="value">{{ remittanceData.created }}</span>
}
</div>
@if (remittanceData.processedAt && isValidDate(remittanceData.processedAt)) {
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Deposited At' | translate }}</span>
<span class="value">{{ remittanceData.processedAt | date: 'medium' }}</span>
</div>
}
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Sender Name' | translate }}</span>
<span class="value">{{ remittanceData.senderName }}</span>
</div>
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Payout Amount' | translate }}</span>
<span class="value">{{ remittanceData.receivingAmount | currency: remittanceData.receivingCurrency }}</span>
</div>
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Recipient Name' | translate }}</span>
<span class="value">{{ remittanceData.recipientName }}</span>
</div>
@if (remittanceData.recipientAddress) {
<div class="detail-item">
<span class="label">{{ 'labels.inputs.Address' | translate }}</span>
<span class="value">{{ remittanceData.recipientAddress }}</span>
</div>
}
</div>
@if (isPaid) {
<div class="terminal-info paid-info">
<mat-icon>check_circle</mat-icon>
<span>{{ 'labels.text.This remittance has already been paid successfully.' | translate }}</span>
</div>
} @else if (isTerminal) {
<div class="terminal-warning">
<mat-icon>info</mat-icon>
<span>{{
'labels.text.This remittance has a terminal status and cannot be processed further.' | translate
}}</span>
</div>
}
<div class="step-actions">
<button mat-raised-button color="primary" (click)="onNext()">
@if (isTerminal) {
{{ 'labels.buttons.View Receipt' | translate }}
} @else {
{{ 'labels.buttons.Next' | translate }}
}
<mat-icon>arrow_forward</mat-icon>
</button>
</div>
</div>
}
@@ -0,0 +1,104 @@
/**
* 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;
}
.status-row {
margin-bottom: 16px;
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-pending {
background-color: #fff3e0;
color: #e65100;
}
.badge-ready {
background-color: #e3f2fd;
color: #1565c0;
}
.badge-paid {
background-color: #e8f5e9;
color: #2e7d32;
}
.badge-terminal {
background-color: #ffebee;
color: #c62828;
}
.badge-unknown {
background-color: #f5f5f5;
color: #616161;
}
.details-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 100%));
gap: 16px;
margin-bottom: 16px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
.label {
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.value {
font-size: 14px;
}
}
.terminal-warning,
.terminal-info {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
margin: 16px 0;
border-radius: 4px;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.terminal-warning {
background-color: #fff3e0;
color: #e65100;
}
.paid-info {
background-color: #e8f5e9;
color: #2e7d32;
}
.step-actions {
margin-top: 16px;
}
@@ -0,0 +1,65 @@
/**
* 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 } from '@angular/core';
import { STANDALONE_SHARED_IMPORTS } from '../../../standalone-shared.module';
import { MatIcon } from '@angular/material/icon';
import { RemittanceTransaction } from '../../models/remittance.model';
import { RemittanceStatus, isTerminalStatus } from '../../models/remittance-status.enum';
@Component({
selector: 'mifosx-remittance-details-step',
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
MatIcon
],
templateUrl: './remittance-details-step.component.html',
styleUrls: ['./remittance-details-step.component.scss']
})
export class RemittanceDetailsStepComponent {
@Input() remittanceData: RemittanceTransaction | null = null;
@Input() parsedStatus: RemittanceStatus = RemittanceStatus.UNKNOWN;
@Output() detailsNext = new EventEmitter<void>();
get isTerminal(): boolean {
return isTerminalStatus(this.parsedStatus);
}
get isPaid(): boolean {
return this.parsedStatus === RemittanceStatus.PAID;
}
get statusBadgeClass(): string {
switch (this.parsedStatus) {
case RemittanceStatus.PENDING:
return 'badge-pending';
case RemittanceStatus.READY_FOR_PAYOUT:
return 'badge-ready';
case RemittanceStatus.PAID:
return 'badge-paid';
case RemittanceStatus.REJECTED:
case RemittanceStatus.CANCELED:
case RemittanceStatus.EXPIRED:
return 'badge-terminal';
default:
return 'badge-unknown';
}
}
isValidDate(value: string | undefined): boolean {
if (!value) return false;
const timestamp = Date.parse(value);
return !isNaN(timestamp);
}
onNext(): void {
this.detailsNext.emit();
}
}
@@ -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.'
);
}
});
}
}
@@ -0,0 +1,81 @@
<!--
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]="searchForm" (ngSubmit)="searchRemittance()">
<div class="form-fields">
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Vendor' | translate }}</mat-label>
<mat-select formControlName="vendor" [disabled]="vendorLoadError">
@for (v of vendors; track v.name) {
<mat-option [value]="v.headerValue">{{ v.name }}</mat-option>
}
</mat-select>
@if (isLoadingVendors) {
<mat-hint>{{ 'labels.inputs.Loading vendors...' | translate }}</mat-hint>
}
@if (vendorLoadError) {
<mat-error>{{ 'labels.inputs.Failed to load vendors' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.External Reference ID' | translate }}</mat-label>
<input
matInput
formControlName="externalId"
placeholder="{{ 'labels.placeholders.Enter remittance reference number' | translate }}"
/>
@if (searchForm.get('externalId')?.hasError('required') && searchForm.get('externalId')?.touched) {
<mat-error>{{ 'labels.inputs.Reference ID is required' | translate }}</mat-error>
}
@if (searchForm.get('externalId')?.hasError('minlength')) {
<mat-error>{{ 'labels.inputs.Minimum 3 characters' | translate }}</mat-error>
}
</mat-form-field>
</div>
@if (vendorLoadError) {
<div class="vendor-error-banner">
<mat-icon>warning</mat-icon>
<span>{{ 'labels.text.Unable to load vendors. Please check your connection and try again.' | translate }}</span>
<button mat-stroked-button color="primary" type="button" (click)="loadVendors()" [disabled]="isLoadingVendors">
@if (isLoadingVendors) {
<mat-spinner diameter="16"></mat-spinner>
} @else {
<mat-icon>refresh</mat-icon>
<span>{{ 'labels.buttons.Retry' | translate }}</span>
}
</button>
</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="button"
(click)="searchRemittance()"
[disabled]="isLoading || vendorLoadError"
>
@if (isLoading) {
<mat-spinner diameter="20"></mat-spinner>
} @else {
<span><mat-icon>search</mat-icon> {{ 'labels.buttons.Search' | translate }}</span>
}
</button>
</div>
</form>
</div>
@@ -0,0 +1,90 @@
/**
* 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;
}
}
.vendor-error-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
margin: 12px 0;
background-color: #fff3e0;
border-radius: 4px;
border-left: 4px solid #ff9800;
color: #e65100;
mat-icon {
color: #ff9800;
font-size: 24px;
width: 24px;
height: 24px;
flex-shrink: 0;
}
span {
flex: 1;
font-size: 14px;
}
button {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
mat-spinner {
display: inline-block;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
margin: 0;
}
}
}
.step-actions {
margin-top: 16px;
button {
mat-spinner {
display: inline-block;
margin-right: 8px;
}
}
}
@@ -0,0 +1,114 @@
/**
* 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, 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 { RemittanceTransaction, RemittanceVendor } from '../../models/remittance.model';
@Component({
selector: 'mifosx-search-remittance-step',
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
MatProgressSpinner,
MatIcon
],
templateUrl: './search-remittance-step.component.html',
styleUrls: ['./search-remittance-step.component.scss']
})
export class SearchRemittanceStepComponent implements OnInit {
@Output() remittanceFound = new EventEmitter<{
transaction: RemittanceTransaction;
transactionId: string;
vendor: RemittanceVendor;
}>();
private fb = inject(FormBuilder);
private remittancesService = inject(RemittancesService);
private translateService = inject(TranslateService);
searchForm!: FormGroup;
vendors: RemittanceVendor[] = [];
isLoading = false;
isLoadingVendors = false;
vendorLoadError = false;
errorMessage = '';
ngOnInit(): void {
this.searchForm = this.fb.group({
vendor: [
'',
Validators.required
],
externalId: [
'',
[
Validators.required,
Validators.minLength(3)
]
]
});
this.loadVendors();
}
loadVendors(): void {
this.isLoadingVendors = true;
this.vendorLoadError = false;
this.remittancesService.getVendors().subscribe({
next: (response) => {
this.vendors = response.vendors;
this.isLoadingVendors = false;
this.vendorLoadError = false;
if (this.vendors.length === 1) {
this.searchForm.patchValue({ vendor: this.vendors[0].headerValue });
}
},
error: () => {
this.vendorLoadError = true;
this.isLoadingVendors = false;
}
});
}
searchRemittance(): void {
this.searchForm.markAllAsTouched();
if (this.searchForm.invalid) return;
this.isLoading = true;
this.errorMessage = '';
const { vendor, externalId } = this.searchForm.value;
const selectedVendor = this.vendors.find((v) => v.headerValue === vendor);
this.remittancesService.findRemittance(vendor, externalId).subscribe({
next: (transaction) => {
this.isLoading = false;
this.remittanceFound.emit({
transaction,
transactionId: transaction.externalTransactionId || externalId,
vendor: selectedVendor || { name: vendor, headerValue: vendor }
});
},
error: (err) => {
this.isLoading = false;
this.errorMessage =
err?.error?.message ||
this.translateService.instant(
'labels.text.Remittance not found. Please check the reference number and try again.'
);
}
});
}
}
@@ -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.');
}
});
}
}