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
+2
View File
@@ -48,6 +48,7 @@ import { CollectionsModule } from './collections/collections.module';
import { ProfileModule } from './profile/profile.module';
import { TasksModule } from './tasks/tasks.module';
import { ConfigurationWizardModule } from './configuration-wizard/configuration-wizard.module';
import { RemittancesModule } from './remittances/remittances.module';
import { PortalModule } from '@angular/cdk/portal';
/** Main Routing Module */
@@ -127,6 +128,7 @@ export function HttpLoaderFactory(http: HttpClient) {
CollectionsModule,
TasksModule,
ConfigurationWizardModule,
RemittancesModule,
AppRoutingModule,
NotFoundComponent,
CallbackComponent,
@@ -41,6 +41,10 @@ export class AuthenticationInterceptor implements HttpInterceptor {
* Intercepts a Http request and sets the request headers.
*/
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// Skip Fineract auth headers for external API calls (e.g. remittance, national ID)
if (request.url.startsWith('http://') || request.url.startsWith('https://')) {
return next.handle(request);
}
if (this.settingsService.tenantIdentifier) {
httpOptions.headers['Fineract-Platform-TenantId'] = this.settingsService.tenantIdentifier;
}
@@ -132,6 +132,19 @@
</mat-icon>
<a matLine>{{ 'labels.menus.Individual Collection Sheet' | translate }}</a>
</mat-list-item>
<mat-list-item
*ngIf="mifosRemittanceEnabled"
[routerLink]="['/remittances/process']"
[matTooltipPosition]="tooltipPosition"
matTooltip="{{ 'tooltips.Process Remittance' | translate }}"
routerLinkActive="active-menu"
[routerLinkActiveOptions]="{ exact: false }"
>
<mat-icon matListIcon>
<fa-icon icon="money-bill-wave" size="sm"></fa-icon>
</mat-icon>
<a matLine>{{ 'labels.menus.Remittances' | translate }}</a>
</mat-list-item>
<mat-list-item
[routerLink]="['/notifications']"
[matTooltipPosition]="tooltipPosition"
@@ -32,6 +32,7 @@ import { MatNavList, MatListItem } from '@angular/material/list';
import { MatIcon } from '@angular/material/icon';
import { MatLine } from '@angular/material/grid-list';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { remittanceConfig } from '../../../remittances/remittance.config';
import { catchError, finalize, of, take } from 'rxjs';
@@ -77,6 +78,8 @@ export class SidenavComponent implements OnInit, AfterViewInit {
mappedActivities: any[] = [];
/** Collection of possible frequent activities */
frequentActivities: any[] = frequentActivities;
/** Whether remittance feature is enabled */
mifosRemittanceEnabled = remittanceConfig.isRemittanceEnabled;
/* Refernce of logo */
@ViewChild('logo') logo: ElementRef<any>;
+1
View File
@@ -58,6 +58,7 @@ const activities: any[] = [
{ activity: 'audit', path: '/products/audit-trails' },
{ activity: 'create accounting closure', path: '' },
{ activity: 'navigation', path: '/navigation' },
{ activity: 'remittances', path: '/remittances/process' },
{ activity: '', path: 'home' }
];
@@ -0,0 +1,66 @@
/**
* 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/.
*/
/**
* Recipient validation request body.
* POST /v1/remittances/:tenant/:vendor/transactions/:id/recipient
*/
export interface ValidateRecipientRequest {
givenName: string;
lastName: string;
motherMaidenName?: string;
dateOfBirth?: string;
phone?: string;
address?: {
country?: string;
state?: string;
city?: string;
postalCode?: string;
line1?: string;
};
primaryDocument: {
documentType: string;
documentNumber: string;
issuingCountry?: string;
};
}
/**
* Validated recipient response from the API.
* Maps to all 17 beneficiary fields required by the spec.
*/
export interface ValidatedRecipient {
valid?: boolean;
givenName?: string;
firstName?: string;
lastName: string;
motherMaidenName?: string;
dateOfBirth?: string;
phone?: string;
identityCard?: string;
address?: {
country?: string;
state?: string;
city?: string;
postalCode?: string;
line1?: string;
line2?: string;
};
primaryDocument?: {
documentType?: string;
documentNumber?: string;
issuingCountry?: string;
};
occupation?: string;
economicActivity?: string;
/** Additional fields from spec (may be populated via additionalInfo or extended API) */
municipality?: string;
locality?: string;
newLocality?: string;
entityMember?: string;
}
@@ -0,0 +1,43 @@
/**
* 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/.
*/
export enum RemittanceStatus {
PENDING = 'PENDING',
READY_FOR_PAYOUT = 'READY_FOR_PAYOUT',
PAID = 'PAID',
REJECTED = 'REJECTED',
CANCELED = 'CANCELED',
EXPIRED = 'EXPIRED',
UNKNOWN = 'UNKNOWN'
}
/**
* Determines whether the given status represents a terminal (final) state.
*/
export function isTerminalStatus(status: RemittanceStatus): boolean {
return [
RemittanceStatus.PAID,
RemittanceStatus.REJECTED,
RemittanceStatus.CANCELED,
RemittanceStatus.EXPIRED
].includes(status);
}
/**
* Maps a raw status string from the API to the RemittanceStatus enum.
* Handles both CANCELED (US) and CANCELLED (UK) spellings.
*/
export function parseRemittanceStatus(raw: string): RemittanceStatus {
if (!raw) return RemittanceStatus.UNKNOWN;
let normalized = raw.toUpperCase().replace(/-/g, '_').trim();
if (normalized === 'CANCELLED') {
normalized = 'CANCELED';
}
const match = Object.values(RemittanceStatus).find((s) => s === normalized);
return match ?? RemittanceStatus.UNKNOWN;
}
@@ -0,0 +1,105 @@
/**
* 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/.
*/
/**
* Represents a remittance transaction returned by the Find-Remittance endpoint.
* GET /v1/remittances/:tenant/:vendor/transactions/:id
*/
export interface RemittanceTransaction {
sendingPartner: string;
externalTransactionId: string;
pin: string;
created: string;
processedAt: string;
senderName: string;
recipientName: string;
receivingAmount: number;
receivingCurrency: string;
recipientAddress: string;
status: string;
}
/**
* Payout assignment response (Endpoint 3).
* POST /v1/remittances/:tenant/:vendor/transactions/:id/payout-assignment
*/
export interface PayoutAssignmentResponse {
sendingPartner: string;
remittanceKey: string;
created: string;
receivingAmount: number;
receivingCurrency: string;
trackingNumber: string;
sender: string;
recipient: string;
status: string;
}
/**
* Payout confirmation response (Endpoint 4).
* POST /v1/remittances/:tenant/:vendor/transactions/:id/payout-confirmation/:clientId
*/
export interface PayoutConfirmationResponse {
transaction: {
receivingAmount: number;
receivingCurrency: string;
status: string;
sender: PersonName;
recipient: RecipientDetails;
};
}
export interface PersonName {
firstName: string;
lastName: string;
middleName?: string;
mothersMaidenName?: string;
}
export interface RecipientAddress {
country: string;
state: string;
city: string;
postalCode: string;
line1: string;
line2?: string;
}
export interface RecipientDocument {
documentType: string;
documentNumber: string;
issuingCountry: string;
}
export interface RecipientDetails extends PersonName {
phone?: string;
email?: string;
nationality?: string;
dateOfBirth?: string;
homeAddress?: RecipientAddress;
primaryDocument?: RecipientDocument;
occupation?: string;
economicActivity?: string;
}
/** Vendor item from the GET /v1/remittances/:tenant/vendors endpoint */
export interface RemittanceVendor {
name: string;
headerValue: string;
}
/** Request body for payout assignment */
export interface PayoutAssignmentRequest {
agent?: string;
additionalInfo?: Record<string, string>;
}
/** Request body for payout confirmation */
export interface PayoutConfirmationRequest {
additionalInfo?: Record<string, string>;
}
@@ -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="container">
<mat-card>
<mat-card-content>
<div class="header">
<h2>{{ 'labels.heading.Process Remittance' | translate }}</h2>
</div>
<mat-stepper #stepper orientation="vertical" [linear]="true">
<!-- Step 1: Search Remittance -->
<mat-step [editable]="!remittanceData" [completed]="!!remittanceData">
<ng-template matStepLabel>{{ 'labels.heading.Search Remittance' | translate }}</ng-template>
<mifosx-search-remittance-step (remittanceFound)="onRemittanceFound($event)"> </mifosx-search-remittance-step>
</mat-step>
<!-- Step 2: Remittance Details -->
<mat-step [editable]="false" [completed]="detailsReviewed">
<ng-template matStepLabel>{{ 'labels.heading.Remittance Details' | translate }}</ng-template>
<mifosx-remittance-details-step
[remittanceData]="remittanceData"
[parsedStatus]="parsedStatus"
(detailsNext)="onDetailsNext()"
>
</mifosx-remittance-details-step>
</mat-step>
<!-- Step 3: Search Beneficiary (only for PENDING) -->
@if (showBeneficiarySteps) {
<mat-step [editable]="false" [completed]="!!recipientData">
<ng-template matStepLabel>{{ 'labels.heading.Search Beneficiary' | translate }}</ng-template>
<mifosx-search-beneficiary-step
[vendor]="selectedVendor"
[transactionId]="transactionId"
(recipientValidated)="onRecipientValidated($event)"
>
</mifosx-search-beneficiary-step>
</mat-step>
<!-- Step 4: Beneficiary Details -->
<mat-step [editable]="false" [completed]="beneficiaryReviewed">
<ng-template matStepLabel>{{ 'labels.heading.Beneficiary Details' | translate }}</ng-template>
<mifosx-beneficiary-details-step
[recipientData]="recipientData"
(beneficiaryNext)="onBeneficiaryDetailsNext($event)"
>
</mifosx-beneficiary-details-step>
</mat-step>
<!-- Step 5: Transactional Profile / Assign Payout -->
<mat-step [editable]="false" [completed]="!!payoutAssignment">
<ng-template matStepLabel>{{ 'labels.heading.Transactional Profile' | translate }}</ng-template>
<mifosx-transactional-profile-step
[vendor]="selectedVendor"
[transactionId]="transactionId"
[remittanceData]="remittanceData"
[recipientData]="recipientData"
(payoutAssigned)="onPayoutAssigned($event)"
>
</mifosx-transactional-profile-step>
</mat-step>
}
<!-- Step 6: Confirm Payment -->
@if (showConfirmStep) {
<mat-step [editable]="false" [completed]="!!payoutConfirmation">
<ng-template matStepLabel>{{ 'labels.heading.Confirm Payment' | translate }}</ng-template>
<mifosx-confirm-payment-step
[vendor]="selectedVendor"
[transactionId]="transactionId"
[remittanceData]="remittanceData"
[recipientData]="recipientData"
[payoutAssignment]="payoutAssignment"
[clientId]="selectedClientId"
(payoutConfirmed)="onPayoutConfirmed($event)"
>
</mifosx-confirm-payment-step>
</mat-step>
}
<!-- Step 7: Payment Receipt (always shown) -->
<mat-step [editable]="false">
<ng-template matStepLabel>{{ 'labels.heading.Payment Receipt' | translate }}</ng-template>
<mifosx-payment-receipt-step
[remittanceData]="remittanceData"
[recipientData]="recipientData"
[payoutConfirmation]="payoutConfirmation"
[payoutAssignment]="payoutAssignment"
[parsedStatus]="parsedStatus"
(resetWorkflow)="resetWorkflow()"
>
</mifosx-payment-receipt-step>
</mat-step>
</mat-stepper>
</mat-card-content>
</mat-card>
</div>
@@ -0,0 +1,20 @@
/**
* 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/.
*/
.container {
padding: 16px;
}
.header {
margin-bottom: 16px;
h2 {
margin: 0;
font-weight: 500;
}
}
@@ -0,0 +1,217 @@
/**
* 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, ViewChild, ChangeDetectorRef, inject } from '@angular/core';
import { MatStepper, MatStep, MatStepLabel } from '@angular/material/stepper';
import { STANDALONE_SHARED_IMPORTS } from '../../standalone-shared.module';
import {
RemittanceTransaction,
PayoutAssignmentResponse,
PayoutConfirmationResponse,
RemittanceVendor
} from '../models/remittance.model';
import { ValidatedRecipient } from '../models/beneficiary.model';
import { RemittanceStatus, parseRemittanceStatus, isTerminalStatus } from '../models/remittance-status.enum';
import { SearchRemittanceStepComponent } from '../remittance-stepper/search-remittance-step/search-remittance-step.component';
import { RemittanceDetailsStepComponent } from '../remittance-stepper/remittance-details-step/remittance-details-step.component';
import { SearchBeneficiaryStepComponent } from '../remittance-stepper/search-beneficiary-step/search-beneficiary-step.component';
import { BeneficiaryDetailsStepComponent } from '../remittance-stepper/beneficiary-details-step/beneficiary-details-step.component';
import { TransactionalProfileStepComponent } from '../remittance-stepper/transactional-profile-step/transactional-profile-step.component';
import { ConfirmPaymentStepComponent } from '../remittance-stepper/confirm-payment-step/confirm-payment-step.component';
import { PaymentReceiptStepComponent } from '../remittance-stepper/payment-receipt-step/payment-receipt-step.component';
@Component({
selector: 'mifosx-process-remittance',
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
MatStepper,
MatStep,
MatStepLabel,
SearchRemittanceStepComponent,
RemittanceDetailsStepComponent,
SearchBeneficiaryStepComponent,
BeneficiaryDetailsStepComponent,
TransactionalProfileStepComponent,
ConfirmPaymentStepComponent,
PaymentReceiptStepComponent
],
templateUrl: './process-remittance.component.html',
styleUrls: ['./process-remittance.component.scss']
})
export class ProcessRemittanceComponent {
@ViewChild('stepper') stepper!: MatStepper;
private cdr = inject(ChangeDetectorRef);
/** Shared state across steps */
remittanceData: RemittanceTransaction | null = null;
recipientData: ValidatedRecipient | null = null;
payoutAssignment: PayoutAssignmentResponse | null = null;
payoutConfirmation: PayoutConfirmationResponse | null = null;
selectedVendor: RemittanceVendor | null = null;
transactionId = '';
parsedStatus: RemittanceStatus = RemittanceStatus.UNKNOWN;
selectedClientId: number | null = null;
/** Step completion flags */
detailsReviewed = false;
beneficiaryReviewed = false;
/** Step visibility control - explicit properties for better change detection */
showBeneficiarySteps = false;
showConfirmStep = false;
private updateStepVisibility(): void {
// Beneficiary steps (3-5) only show for PENDING.
// Per spec: READY_FOR_PAYOUT skips directly to Confirm Payment.
// IMPORTANT: Once shown, never hide — prevents mid-flow step destruction
// when assignPayout changes status from PENDING → READY_FOR_PAYOUT.
this.showBeneficiarySteps = this.showBeneficiarySteps || this.parsedStatus === RemittanceStatus.PENDING;
// Confirm step only shows for READY_FOR_PAYOUT (the only actionable confirm state).
// Terminal statuses (PAID, REJECTED, etc.) skip directly to Payment Receipt.
this.showConfirmStep = this.showConfirmStep || this.parsedStatus === RemittanceStatus.READY_FOR_PAYOUT;
}
/**
* Called when Step 1 (Search) finds a remittance.
*/
onRemittanceFound(event: {
transaction: RemittanceTransaction;
transactionId: string;
vendor: RemittanceVendor;
}): void {
this.remittanceData = event.transaction;
this.transactionId = event.transactionId;
this.selectedVendor = event.vendor;
this.parsedStatus = parseRemittanceStatus(event.transaction.status);
// Update step visibility based on new status
this.updateStepVisibility();
this.cdr.detectChanges();
setTimeout(() => {
this.cdr.detectChanges();
this.stepper.next();
});
}
/**
* Called after Step 2 (Remittance Details) user clicks Next.
* Routes based on status.
*/
onDetailsNext(): void {
this.detailsReviewed = true;
this.cdr.detectChanges();
if (isTerminalStatus(this.parsedStatus)) {
this.goToLastStep();
} else {
this.stepper.next();
}
}
/**
* Called when Step 3 (Search Beneficiary) validates a recipient.
*/
onRecipientValidated(recipient: ValidatedRecipient): void {
this.recipientData = recipient;
this.cdr.detectChanges();
this.stepper.next();
}
/**
* Called after Step 4 (Beneficiary Details) user selects client and clicks Next.
*/
onBeneficiaryDetailsNext(clientId: number): void {
this.selectedClientId = clientId;
this.beneficiaryReviewed = true;
this.cdr.detectChanges();
this.stepper.next();
}
/**
* Called when Step 5 (Transactional Profile) assigns payout.
* Status typically changes from PENDING → READY_FOR_PAYOUT here.
*/
onPayoutAssigned(assignment: PayoutAssignmentResponse): void {
this.payoutAssignment = assignment;
this.parsedStatus = parseRemittanceStatus(assignment.status);
// Update step visibility — showConfirmStep will become true
// showBeneficiarySteps stays true (never contracts once expanded)
this.updateStepVisibility();
this.cdr.detectChanges();
// Navigate to confirm step (next visible step after transactional profile)
if (isTerminalStatus(this.parsedStatus)) {
this.goToLastStep();
} else {
this.goToConfirmStep();
}
}
/**
* Called when Step 6 (Confirm Payment) confirms payout.
*/
onPayoutConfirmed(confirmation: PayoutConfirmationResponse): void {
this.payoutConfirmation = confirmation;
this.parsedStatus = RemittanceStatus.PAID;
this.cdr.detectChanges();
this.stepper.next();
}
/**
* Reset the entire workflow.
*/
resetWorkflow(): void {
this.remittanceData = null;
this.recipientData = null;
this.payoutAssignment = null;
this.payoutConfirmation = null;
this.selectedVendor = null;
this.transactionId = '';
this.parsedStatus = RemittanceStatus.UNKNOWN;
this.selectedClientId = null;
this.detailsReviewed = false;
this.beneficiaryReviewed = false;
// Reset step visibility
this.showBeneficiarySteps = false;
this.showConfirmStep = false;
this.stepper.reset();
}
private goToConfirmStep(): void {
// Use setTimeout to ensure the DOM is updated after status change
setTimeout(() => {
const steps = this.stepper.steps.toArray();
// Confirm step is always second to last (before Receipt)
const confirmIndex = steps.length - 2;
if (confirmIndex >= 0 && confirmIndex < steps.length) {
this.stepper.selectedIndex = confirmIndex;
} else {
this.goToLastStep();
}
}, 0);
}
private goToLastStep(): void {
// Use setTimeout to ensure the DOM is updated
setTimeout(() => {
this.stepper.selectedIndex = this.stepper.steps.length - 1;
}, 0);
}
}
@@ -0,0 +1,26 @@
/**
* 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 { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { remittanceConfig } from './remittance.config';
/**
* Guard to prevent access to the Remittance module if it is disabled in the config.
*/
export const remittanceEnabledGuard: CanActivateFn = () => {
const router = inject(Router);
if (remittanceConfig.isRemittanceEnabled) {
return true;
}
// If disabled, redirect to home or a 404 page
router.navigate(['/home'], { replaceUrl: true });
return false;
};
@@ -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.');
}
});
}
}
+19
View File
@@ -0,0 +1,19 @@
/**
* 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 { environment } from '../../environments/environment';
/**
* Global configuration for the Remittance module.
*
* Reads from environment settings so the feature toggle is centralized.
* Set `mifosRemittanceEnabled` in the environment (or window.env) to control this.
*/
export const remittanceConfig = {
isRemittanceEnabled: environment.mifosRemittanceEnabled
};
@@ -0,0 +1,42 @@
/**
* 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 { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Route } from '../core/route/route.service';
import { ProcessRemittanceComponent } from './process-remittance/process-remittance.component';
import { remittanceEnabledGuard } from './remittance-enabled.guard';
const routes: Routes = [
Route.withShell([
{
path: 'remittances',
canActivate: [remittanceEnabledGuard],
data: { title: 'Remittances', breadcrumb: 'Remittances' },
children: [
{
path: '',
redirectTo: 'process',
pathMatch: 'full'
},
{
path: 'process',
component: ProcessRemittanceComponent,
data: { title: 'Process Remittance', breadcrumb: 'Process' }
}
]
}
])
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class RemittancesRoutingModule {}
+41
View File
@@ -0,0 +1,41 @@
/**
* 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 { NgModule } from '@angular/core';
import { SharedModule } from 'app/shared/shared.module';
import { PipesModule } from 'app/pipes/pipes.module';
import { DirectivesModule } from 'app/directives/directives.module';
import { RemittancesRoutingModule } from './remittances-routing.module';
import { ProcessRemittanceComponent } from './process-remittance/process-remittance.component';
import { SearchRemittanceStepComponent } from './remittance-stepper/search-remittance-step/search-remittance-step.component';
import { RemittanceDetailsStepComponent } from './remittance-stepper/remittance-details-step/remittance-details-step.component';
import { SearchBeneficiaryStepComponent } from './remittance-stepper/search-beneficiary-step/search-beneficiary-step.component';
import { BeneficiaryDetailsStepComponent } from './remittance-stepper/beneficiary-details-step/beneficiary-details-step.component';
import { TransactionalProfileStepComponent } from './remittance-stepper/transactional-profile-step/transactional-profile-step.component';
import { ConfirmPaymentStepComponent } from './remittance-stepper/confirm-payment-step/confirm-payment-step.component';
import { PaymentReceiptStepComponent } from './remittance-stepper/payment-receipt-step/payment-receipt-step.component';
@NgModule({
imports: [
SharedModule,
PipesModule,
DirectivesModule,
RemittancesRoutingModule,
ProcessRemittanceComponent,
SearchRemittanceStepComponent,
RemittanceDetailsStepComponent,
SearchBeneficiaryStepComponent,
BeneficiaryDetailsStepComponent,
TransactionalProfileStepComponent,
ConfirmPaymentStepComponent,
PaymentReceiptStepComponent
],
providers: []
})
export class RemittancesModule {}
+152
View File
@@ -0,0 +1,152 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders, HttpBackend } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import {
RemittanceTransaction,
RemittanceVendor,
PayoutAssignmentRequest,
PayoutAssignmentResponse,
PayoutConfirmationRequest,
PayoutConfirmationResponse
} from './models/remittance.model';
import { ValidateRecipientRequest, ValidatedRecipient } from './models/beneficiary.model';
/**
* Remittances Service
*
* Handles all HTTP communication with the Mifos Remittance Microservice.
* Base URL pattern: {mifosRemittanceApiUrl}{mifosRemittanceApiProvider}{mifosRemittanceApiVersion}/remittances/:tenant/:vendor/...
*
* Auth: X-Gravitee-Api-Key header (injected server-side by the proxy)
*/
@Injectable({
providedIn: 'root'
})
export class RemittancesService {
private httpBackend = inject(HttpBackend);
/** Separate HttpClient that bypasses interceptors (avoids Fineract API prefix) */
private http = new HttpClient(this.httpBackend);
private get baseUrl(): string {
return `${environment.mifosRemittanceApiUrl}${environment.mifosRemittanceApiProvider}${environment.mifosRemittanceApiVersion}`;
}
private get tenant(): string {
return environment.fineractPlatformTenantId || 'default';
}
private getHeaders(): HttpHeaders {
return new HttpHeaders({ 'Content-Type': 'application/json' });
}
/**
* Endpoint 0: Get available remittance vendors for the tenant.
* GET /v1/remittances/:tenant/vendors
*/
getVendors(): Observable<{ vendors: RemittanceVendor[] }> {
return this.http.get<{ vendors: RemittanceVendor[] }>(`${this.baseUrl}/remittances/${this.tenant}/vendors`, {
headers: this.getHeaders()
});
}
/**
* Endpoint 1: Find remittance by external ID / PIN.
* GET /v1/remittances/:tenant/:vendor/transactions/:id
*/
findRemittance(vendor: string, externalId: string): Observable<RemittanceTransaction> {
return this.http.get<RemittanceTransaction>(
`${this.baseUrl}/remittances/${this.tenant}/${vendor}/transactions/${externalId}`,
{ headers: this.getHeaders() }
);
}
/**
* Endpoint 2: Validate recipient for a transaction.
* POST /v1/remittances/:tenant/:vendor/transactions/:id/recipient
*/
validateRecipient(
vendor: string,
transactionId: string,
data: ValidateRecipientRequest
): Observable<ValidatedRecipient> {
return this.http.post<ValidatedRecipient>(
`${this.baseUrl}/remittances/${this.tenant}/${vendor}/transactions/${transactionId}/recipient`,
data,
{ headers: this.getHeaders() }
);
}
/**
* Endpoint 3: Assign transaction ready to payout (lock for payout).
* POST /v1/remittances/:tenant/:vendor/transactions/:id/payout-assignment
*/
assignPayout(
vendor: string,
transactionId: string,
body?: PayoutAssignmentRequest
): Observable<PayoutAssignmentResponse> {
return this.http.post<PayoutAssignmentResponse>(
`${this.baseUrl}/remittances/${this.tenant}/${vendor}/transactions/${transactionId}/payout-assignment`,
body || {},
{ headers: this.getHeaders() }
);
}
/**
* Endpoint 4: Confirm transaction payout.
* POST /v1/remittances/:tenant/:vendor/transactions/:id/payout-confirmation/:clientId
*/
confirmPayout(
vendor: string,
transactionId: string,
clientId: number,
body?: PayoutConfirmationRequest
): Observable<PayoutConfirmationResponse> {
return this.http.post<PayoutConfirmationResponse>(
`${this.baseUrl}/remittances/${this.tenant}/${vendor}/transactions/${transactionId}/payout-confirmation/${clientId}`,
body || {},
{ headers: this.getHeaders() }
);
}
/**
* Unassign transaction from payout (state management).
* DELETE /v1/remittances/:tenant/:vendor/transactions/:id/payout-assignment
*/
unassignPayout(vendor: string, transactionId: string, reason: string): Observable<any> {
return this.http.request(
'DELETE',
`${this.baseUrl}/remittances/${this.tenant}/${vendor}/transactions/${transactionId}/payout-assignment`,
{
headers: this.getHeaders(),
body: { reason }
}
);
}
/**
* Reverse payout confirmation (state management).
* DELETE /v1/remittances/:tenant/:vendor/transactions/:id/payout-confirmation
*/
reversePayoutConfirmation(vendor: string, transactionId: string, reason: string): Observable<any> {
return this.http.request(
'DELETE',
`${this.baseUrl}/remittances/${this.tenant}/${vendor}/transactions/${transactionId}/payout-confirmation`,
{
headers: this.getHeaders(),
body: { reason }
}
);
}
}
+2
View File
@@ -64,6 +64,7 @@ import {
faMinusCircle,
faMinus,
faMoneyBillAlt,
faMoneyBillWave,
faMoneyCheck,
faPencilAlt,
faPlay,
@@ -216,6 +217,7 @@ export class IconsModule {
faMinus,
faMinusCircle,
faMoneyBillAlt,
faMoneyBillWave,
faMoneyCheck,
faPause,
faPencilAlt,