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
+23
View File
@@ -387,6 +387,29 @@ For more detailed configuration options, refer to the `env.sample` file in the r
| MIFOS_INTERBANK_TRANSFERS_API_VERSION | The Interbank server api version | /v1.0 | | MIFOS_INTERBANK_TRANSFERS_API_VERSION | The Interbank server api version | /v1.0 |
| MIFOS_INTERBANK_TRANSFERS_ENABLED | If the Interbank feature is enabled | true | | MIFOS_INTERBANK_TRANSFERS_ENABLED | If the Interbank feature is enabled | true |
#### Remittance Module Settings
These variables configure the Remittance Module, which provides a 7-step wizard for processing remittance payouts (search, validate recipient, assign payout, confirm payment, and generate receipt).
| Variable | Description | Default Value |
| ----------------------------- | -------------------------------------- | ---------------------------- |
| MIFOS_REMITTANCE_API_URL | The Remittance Microservice server URL | https://apis.mifos.community |
| MIFOS_REMITTANCE_API_PROVIDER | The Remittance server endpoint | /1.0/remittance |
| MIFOS_REMITTANCE_API_VERSION | The Remittance server API version | /v1 |
| MIFOS_REMITTANCE_ENABLED | If the Remittance feature is enabled | false |
| MIFOS_REMITTANCE_API_HEADER | API gateway auth header name | X-Gravitee-Api-Key |
| MIFOS_REMITTANCE_API_KEY | API key for the Remittance gateway | |
When `MIFOS_REMITTANCE_ENABLED` is set to `true`, a "Remittances" menu item appears in the sidenav and the `/remittances` route becomes accessible. The module supports the following workflow:
1. **Search Remittance** — Look up a transaction by vendor and external reference ID
2. **Remittance Details** — Review transaction details and status
3. **Search Beneficiary** — Validate the recipient's identity documents
4. **Beneficiary Details** — Review validated recipient info and assign to a Mifos client
5. **Transactional Profile** — Register the transactional profile and assign payout
6. **Confirm Payment** — Final confirmation before payout
7. **Payment Receipt** — View and print the payment receipt
### Client Data Masking Example ### Client Data Masking Example
When `MIFOS_COMPLIANCE_HIDE_CLIENT_DATA=false` (default): When `MIFOS_COMPLIANCE_HIDE_CLIENT_DATA=false` (default):
+27
View File
@@ -1,6 +1,9 @@
# Rate limiting zone for external National ID API (2 requests/second per IP) # Rate limiting zone for external National ID API (2 requests/second per IP)
limit_req_zone $binary_remote_addr zone=external_nationalid:10m rate=2r/s; limit_req_zone $binary_remote_addr zone=external_nationalid:10m rate=2r/s;
# Rate limiting zone for Remittance API (2 requests/second per IP)
limit_req_zone $binary_remote_addr zone=remittance_api:10m rate=2r/s;
server { server {
listen 80; listen 80;
server_name _; server_name _;
@@ -46,6 +49,30 @@ server {
# CORS is not needed since requests come from the same origin # CORS is not needed since requests come from the same origin
} }
# Remittance API proxy
location /remittance-api {
resolver 127.0.0.11 valid=30s;
# Rate limit to prevent abuse of the upstream Remittance API
limit_req zone=remittance_api burst=5 nodelay;
set $remittance_target "${MIFOS_REMITTANCE_API_URL}";
# Rewrite path: /remittance-api/... -> /1.0/remittance/...
rewrite ^/remittance-api(.*)$ /1.0/remittance$1 break;
proxy_pass $remittance_target;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Inject API key server-side so it is never exposed to the browser
proxy_set_header ${MIFOS_REMITTANCE_API_HEADER} "${MIFOS_REMITTANCE_API_KEY}";
proxy_pass_request_headers on;
}
# Angular app - serve index.html for all routes # Angular app - serve index.html for all routes
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
+37 -5
View File
@@ -51,12 +51,12 @@ const proxyConfig = [
changeOrigin: true, changeOrigin: true,
secure: true, secure: true,
logLevel: 'debug', logLevel: 'debug',
headers: {
...(process.env.EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY
? { 'X-Gravitee-Api-Key': process.env.EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY }
: {})
},
onProxyReq: function (proxyReq, req, res) { onProxyReq: function (proxyReq, req, res) {
// Inject API key server-side (same as nginx proxy_set_header in production)
const apiKey = process.env.EXTERNAL_NATIONAL_ID_SYSTEM_API_KEY || '';
if (apiKey) {
proxyReq.setHeader('X-Gravitee-Api-Key', apiKey);
}
const rewrittenPath = (req.url || '').replace(/^\/external-nationalid/, '/1.0/nationalid'); const rewrittenPath = (req.url || '').replace(/^\/external-nationalid/, '/1.0/nationalid');
console.log('[Proxy] Proxying:', req.method, req.url, '->', this.target + rewrittenPath); console.log('[Proxy] Proxying:', req.method, req.url, '->', this.target + rewrittenPath);
}, },
@@ -75,6 +75,38 @@ const proxyConfig = [
res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error')); res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error'));
} }
} }
},
{
context: ['/remittance-api'],
target: 'https://apis.mifos.community',
pathRewrite: { '^/remittance-api': '/1.0/remittance' },
changeOrigin: true,
secure: true,
logLevel: 'debug',
headers: {
...(process.env.MIFOS_REMITTANCE_API_KEY
? { [process.env.MIFOS_REMITTANCE_API_HEADER || 'X-Gravitee-Api-Key']: process.env.MIFOS_REMITTANCE_API_KEY }
: {})
},
onProxyReq: function (proxyReq, req, res) {
const rewrittenPath = (req.url || '').replace(/^\/remittance-api/, '/1.0/remittance');
console.log('[Proxy] Proxying:', req.method, req.url, '->', this.target + rewrittenPath);
},
onError: function (err, req, res) {
console.error(
'[Proxy] Error while proxying request:',
req && req.method,
req && req.url,
'->',
this.target,
'-',
err && err.message
);
if (res && !res.headersSent) {
res.writeHead(502, { 'Content-Type': 'text/plain' });
res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error'));
}
}
} }
// For local development use `proxy.localhost.conf js` . // For local development use `proxy.localhost.conf js` .
]; ];
+33
View File
@@ -72,5 +72,38 @@ module.exports = [
res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error')); res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error'));
} }
} }
},
{
context: ['/remittance-api'],
target: 'http://54.225.231.146:8080',
pathRewrite: { '^/remittance-api': '/1.0/remittance' },
changeOrigin: true,
secure: false,
logLevel: 'debug',
onProxyReq: function (proxyReq, req, res) {
// Inject API key server-side (same as nginx proxy_set_header in production)
const apiKey = process.env.MIFOS_REMITTANCE_API_KEY || '';
const apiHeader = process.env.MIFOS_REMITTANCE_API_HEADER || 'X-Gravitee-Api-Key';
if (apiKey) {
proxyReq.setHeader(apiHeader, apiKey);
}
const rewrittenPath = (req.url || '').replace(/^\/remittance-api/, '/1.0/remittance');
console.log('[Proxy] Proxying:', req.method, req.url, '->', this.target + rewrittenPath);
},
onError: function (err, req, res) {
console.error(
'[Proxy] Error while proxying request:',
req && req.method,
req && req.url,
'->',
this.target,
'-',
err && err.message
);
if (res && !res.headersSent) {
res.writeHead(502, { 'Content-Type': 'text/plain' });
res.end('Proxy error: ' + (err && err.message ? err.message : 'Unknown error'));
}
}
} }
]; ];
+2
View File
@@ -48,6 +48,7 @@ import { CollectionsModule } from './collections/collections.module';
import { ProfileModule } from './profile/profile.module'; import { ProfileModule } from './profile/profile.module';
import { TasksModule } from './tasks/tasks.module'; import { TasksModule } from './tasks/tasks.module';
import { ConfigurationWizardModule } from './configuration-wizard/configuration-wizard.module'; import { ConfigurationWizardModule } from './configuration-wizard/configuration-wizard.module';
import { RemittancesModule } from './remittances/remittances.module';
import { PortalModule } from '@angular/cdk/portal'; import { PortalModule } from '@angular/cdk/portal';
/** Main Routing Module */ /** Main Routing Module */
@@ -127,6 +128,7 @@ export function HttpLoaderFactory(http: HttpClient) {
CollectionsModule, CollectionsModule,
TasksModule, TasksModule,
ConfigurationWizardModule, ConfigurationWizardModule,
RemittancesModule,
AppRoutingModule, AppRoutingModule,
NotFoundComponent, NotFoundComponent,
CallbackComponent, CallbackComponent,
@@ -41,6 +41,10 @@ export class AuthenticationInterceptor implements HttpInterceptor {
* Intercepts a Http request and sets the request headers. * Intercepts a Http request and sets the request headers.
*/ */
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { 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) { if (this.settingsService.tenantIdentifier) {
httpOptions.headers['Fineract-Platform-TenantId'] = this.settingsService.tenantIdentifier; httpOptions.headers['Fineract-Platform-TenantId'] = this.settingsService.tenantIdentifier;
} }
@@ -132,6 +132,19 @@
</mat-icon> </mat-icon>
<a matLine>{{ 'labels.menus.Individual Collection Sheet' | translate }}</a> <a matLine>{{ 'labels.menus.Individual Collection Sheet' | translate }}</a>
</mat-list-item> </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 <mat-list-item
[routerLink]="['/notifications']" [routerLink]="['/notifications']"
[matTooltipPosition]="tooltipPosition" [matTooltipPosition]="tooltipPosition"
@@ -32,6 +32,7 @@ import { MatNavList, MatListItem } from '@angular/material/list';
import { MatIcon } from '@angular/material/icon'; import { MatIcon } from '@angular/material/icon';
import { MatLine } from '@angular/material/grid-list'; import { MatLine } from '@angular/material/grid-list';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { remittanceConfig } from '../../../remittances/remittance.config';
import { catchError, finalize, of, take } from 'rxjs'; import { catchError, finalize, of, take } from 'rxjs';
@@ -77,6 +78,8 @@ export class SidenavComponent implements OnInit, AfterViewInit {
mappedActivities: any[] = []; mappedActivities: any[] = [];
/** Collection of possible frequent activities */ /** Collection of possible frequent activities */
frequentActivities: any[] = frequentActivities; frequentActivities: any[] = frequentActivities;
/** Whether remittance feature is enabled */
mifosRemittanceEnabled = remittanceConfig.isRemittanceEnabled;
/* Refernce of logo */ /* Refernce of logo */
@ViewChild('logo') logo: ElementRef<any>; @ViewChild('logo') logo: ElementRef<any>;
+1
View File
@@ -58,6 +58,7 @@ const activities: any[] = [
{ activity: 'audit', path: '/products/audit-trails' }, { activity: 'audit', path: '/products/audit-trails' },
{ activity: 'create accounting closure', path: '' }, { activity: 'create accounting closure', path: '' },
{ activity: 'navigation', path: '/navigation' }, { activity: 'navigation', path: '/navigation' },
{ activity: 'remittances', path: '/remittances/process' },
{ activity: '', path: 'home' } { 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, faMinusCircle,
faMinus, faMinus,
faMoneyBillAlt, faMoneyBillAlt,
faMoneyBillWave,
faMoneyCheck, faMoneyCheck,
faPencilAlt, faPencilAlt,
faPlay, faPlay,
@@ -216,6 +217,7 @@ export class IconsModule {
faMinus, faMinus,
faMinusCircle, faMinusCircle,
faMoneyBillAlt, faMoneyBillAlt,
faMoneyBillWave,
faMoneyCheck, faMoneyCheck,
faPause, faPause,
faPencilAlt, faPencilAlt,
+8
View File
@@ -90,6 +90,14 @@
window['env']['mifosInterbankTransfersApiVersion'] = ''; window['env']['mifosInterbankTransfersApiVersion'] = '';
window['env']['mifosInterbankTransfersEnabled'] = 'true'; window['env']['mifosInterbankTransfersEnabled'] = 'true';
// Remittance Module Environment variables
window['env']['mifosRemittanceApiUrl'] = '';
window['env']['mifosRemittanceApiProvider'] = '/remittance-api';
window['env']['mifosRemittanceApiVersion'] = '/v1';
window['env']['mifosRemittanceEnabled'] = false;
window['env']['mifosRemittanceApiHeader'] = 'X-Gravitee-Api-Key';
window['env']['mifosRemittanceApiKey'] = '';
// Enable Role-Based Access Control (RBAC) for menu/button permissions // Enable Role-Based Access Control (RBAC) for menu/button permissions
// Set to true to enable RBAC, false (default) for backward compatibility // Set to true to enable RBAC, false (default) for backward compatibility
window['env']['productionModeEnableRBAC'] = false; window['env']['productionModeEnableRBAC'] = false;
+8
View File
@@ -102,6 +102,14 @@
window['env']['mifosInterbankTransfersApiVersion'] = '$MIFOS_INTERBANK_TRANSFERS_API_VERSION'; window['env']['mifosInterbankTransfersApiVersion'] = '$MIFOS_INTERBANK_TRANSFERS_API_VERSION';
window['env']['mifosInterbankTransfersEnabled'] = '$MIFOS_INTERBANK_TRANSFERS_ENABLED'; window['env']['mifosInterbankTransfersEnabled'] = '$MIFOS_INTERBANK_TRANSFERS_ENABLED';
// Remittance Module Environment variables
window['env']['mifosRemittanceApiUrl'] = '$MIFOS_REMITTANCE_API_URL';
window['env']['mifosRemittanceApiProvider'] = '$MIFOS_REMITTANCE_API_PROVIDER';
window['env']['mifosRemittanceApiVersion'] = '$MIFOS_REMITTANCE_API_VERSION';
window['env']['mifosRemittanceEnabled'] = '$MIFOS_REMITTANCE_ENABLED';
window['env']['mifosRemittanceApiHeader'] = '$MIFOS_REMITTANCE_API_HEADER';
window['env']['mifosRemittanceApiKey'] = '$MIFOS_REMITTANCE_API_KEY';
// Enable Role-Based Access Control (RBAC) for menu/button permissions // Enable Role-Based Access Control (RBAC) for menu/button permissions
// Set to 'true' to enable RBAC, 'false' (default) for backward compatibility // Set to 'true' to enable RBAC, 'false' (default) for backward compatibility
window['env']['productionModeEnableRBAC'] = '$MIFOS_PRODUCTION_MODE_ENABLE_RBAC'; window['env']['productionModeEnableRBAC'] = '$MIFOS_PRODUCTION_MODE_ENABLE_RBAC';
+111 -4
View File
@@ -502,6 +502,7 @@
"Resend OTP": "Resend OTP", "Resend OTP": "Resend OTP",
"Reset": "Reset", "Reset": "Reset",
"Reset Password": "Reset Password", "Reset Password": "Reset Password",
"Retry": "Retry",
"Retrieve": "Retrieve", "Retrieve": "Retrieve",
"Revert Transaction": "Revert Transaction", "Revert Transaction": "Revert Transaction",
"Run and Download Report": "Run and Download Report", "Run and Download Report": "Run and Download Report",
@@ -561,7 +562,14 @@
"View Active Charges": "View Active Charges", "View Active Charges": "View Active Charges",
"View Inactive Charges": "View Inactive Charges", "View Inactive Charges": "View Inactive Charges",
"Yes": "Yes", "Yes": "Yes",
"Is Self Service": "Is Self Service" "Is Self Service": "Is Self Service",
"Assign Payout": "Assign Payout",
"Confirm Payment": "Confirm Payment",
"New Remittance": "New Remittance",
"Print Receipt": "Print Receipt",
"Validate Recipient": "Validate Recipient",
"View Receipt": "View Receipt",
"Add Entry": "Add Entry"
}, },
"catalogs": { "catalogs": {
"Interest payment waiver": "Interest Payment Waiver", "Interest payment waiver": "Interest Payment Waiver",
@@ -1301,7 +1309,31 @@
"Date": "Date", "Date": "Date",
"Secure & Reliable": "Secure & Reliable", "Secure & Reliable": "Secure & Reliable",
"Community Driven": "Community Driven", "Community Driven": "Community Driven",
"Global Impact": "Global Impact" "Global Impact": "Global Impact",
"Assign Payout": "Assign Payout",
"Assign to Mifos Client": "Assign to Mifos Client",
"Beneficiary Details": "Beneficiary Details",
"Confirm Payment": "Confirm Payment",
"Confirm Payout": "Confirm Payout",
"Confirmation Details": "Confirmation Details",
"Payment Receipt": "Payment Receipt",
"Payment Successful": "Payment Successful",
"Process Remittance": "Process Remittance",
"Recipient Details": "Recipient Details",
"Recipient Summary": "Recipient Summary",
"Remittance Details": "Remittance Details",
"Search Beneficiary": "Search Beneficiary",
"Search Remittance": "Search Remittance",
"Transaction": "Transaction",
"Transaction Status": "Transaction Status",
"Transaction Summary": "Transaction Summary",
"Transactional Profile": "Transactional Profile",
"Validated Recipient Information": "Validated Recipient Information",
"Recipient": "Recipient",
"Payout Assignment": "Payout Assignment",
"Identification": "Identification",
"Professional Information": "Professional Information",
"Add Profile Entry": "Add Profile Entry"
}, },
"inputs": { "inputs": {
"Floating rate": "Floating Rate", "Floating rate": "Floating Rate",
@@ -1519,6 +1551,7 @@
"Client Details": "Client Details", "Client Details": "Client Details",
"Client ID": "Client ID", "Client ID": "Client ID",
"Client Id": "Client Id", "Client Id": "Client Id",
"Confirmation Number": "Confirmation Number",
"Client Member": "Client Member", "Client Member": "Client Member",
"Client Members": "Client Members", "Client Members": "Client Members",
"Client Name": "Client Name", "Client Name": "Client Name",
@@ -1707,6 +1740,9 @@
"Do not calculate interest on past due principal balances": "Do not calculate interest on past due principal balances", "Do not calculate interest on past due principal balances": "Do not calculate interest on past due principal balances",
"Documents": "Documents", "Documents": "Documents",
"Document Key": "Document Key", "Document Key": "Document Key",
"Document": "Document",
"Document Number": "Document Number",
"Document number is required": "Document number is required",
"Document Type": "Document Type", "Document Type": "Document Type",
"Dormancy Tracking": "Dormancy Tracking", "Dormancy Tracking": "Dormancy Tracking",
"Download": "Download", "Download": "Download",
@@ -1767,6 +1803,8 @@
"Extend Repayment Period": "Extend Repayment Period", "Extend Repayment Period": "Extend Repayment Period",
"External Asset Owner": "External Asset Owner", "External Asset Owner": "External Asset Owner",
"External ID": "External ID", "External ID": "External ID",
"Failed to load vendors": "Failed to load vendors",
"External Reference ID": "External Reference ID",
"External Id": "External Id", "External Id": "External Id",
"External id": "External id", "External id": "External id",
"External ID not found": "External ID not found", "External ID not found": "External ID not found",
@@ -1795,6 +1833,7 @@
"Filter By Version": "Filter By Version", "Filter By Version": "Filter By Version",
"Financial Activity": "Financial Activity", "Financial Activity": "Financial Activity",
"First Name": "First Name", "First Name": "First Name",
"First name is required": "First name is required",
"First repayment on": "First repayment on", "First repayment on": "First repayment on",
"Fixed Account": "Fixed Account", "Fixed Account": "Fixed Account",
"Fixed Deposit": "Fixed Deposit", "Fixed Deposit": "Fixed Deposit",
@@ -1986,6 +2025,7 @@
"Last Active Transaction Date": "Last Active Transaction Date", "Last Active Transaction Date": "Last Active Transaction Date",
"Last Loan Amount": "Last Loan Amount", "Last Loan Amount": "Last Loan Amount",
"Last Name": "Last Name", "Last Name": "Last Name",
"Last name is required": "Last name is required",
"Last Repayment": "Last Repayment", "Last Repayment": "Last Repayment",
"Last Repayment Date": "Last Repayment Date", "Last Repayment Date": "Last Repayment Date",
"Late": "Late", "Late": "Late",
@@ -1995,6 +2035,7 @@
"Liability Account": "Liability Account", "Liability Account": "Liability Account",
"Liability Account (CR)": "Liability Account (CR)", "Liability Account (CR)": "Liability Account (CR)",
"Lifted On": "Lifted On", "Lifted On": "Lifted On",
"Loading vendors...": "Loading vendors...",
"Link savings": "Link savings", "Link savings": "Link savings",
"Linked Savings Account": "Linked Savings Account", "Linked Savings Account": "Linked Savings Account",
"Linked Savings Account(Dividend Posting)": "Linked Savings Account(Dividend Posting)", "Linked Savings Account(Dividend Posting)": "Linked Savings Account(Dividend Posting)",
@@ -2426,6 +2467,7 @@
"Schedule Date": "Schedule Date", "Schedule Date": "Schedule Date",
"Score": "Score", "Score": "Score",
"Screen Report": "Screen Report", "Screen Report": "Screen Report",
"Search Client": "Search Client",
"Search Journal Entries": "Search Journal Entries", "Search Journal Entries": "Search Journal Entries",
"Select Action": "Select Action", "Select Action": "Select Action",
"Select Client Members for Transfer": "Select Client Members for Transfer", "Select Client Members for Transfer": "Select Client Members for Transfer",
@@ -2570,6 +2612,7 @@
"Trigger Type": "Trigger Type", "Trigger Type": "Trigger Type",
"Tuesday": "Tuesday", "Tuesday": "Tuesday",
"Type": "Type", "Type": "Type",
"Type client name or account number": "Type client name or account number",
"Type/Quality": "Type/Quality", "Type/Quality": "Type/Quality",
"Unassigned": "Unassigned", "Unassigned": "Unassigned",
"Unassignment Date": "Unassignment Date", "Unassignment Date": "Unassignment Date",
@@ -2692,7 +2735,45 @@
"Overdue": "Overdue", "Overdue": "Overdue",
"Overdue Fees": "Overdue Fees", "Overdue Fees": "Overdue Fees",
"Select action": "Select action", "Select action": "Select action",
"Select entity": "Select entity" "Select entity": "Select entity",
"Confirmed At": "Confirmed At",
"Full Name": "Full Name",
"Loading vendors...": "Loading vendors...",
"Minimum 3 characters": "Minimum 3 characters",
"National ID": "National ID",
"Nationality": "Nationality",
"Payout Amount": "Payout Amount",
"Payout Country": "Payout Country",
"Payout Token": "Payout Token",
"Recipient Name": "Recipient Name",
"Reference ID is required": "Reference ID is required",
"Send Amount": "Send Amount",
"Send Country": "Send Country",
"Sender": "Sender",
"Sender Name": "Sender Name",
"Sent By": "Sent By",
"Deposited At": "Deposited At",
"Vendor": "Vendor",
"Vendor is required": "Vendor is required",
"Voter ID": "Voter ID",
"Passport": "Passport",
"Driver's License": "Driver's License",
"Mothers Maiden Name": "Mother's Maiden Name",
"National Identity Card Number": "National Identity Card Number",
"State": "State",
"Municipality": "Municipality",
"Locality": "Locality",
"New Locality": "New Locality",
"Occupation": "Occupation",
"Economic Activity": "Economic Activity",
"Entity Member": "Entity Member",
"Country": "Country",
"# Remittance": "# Remittance",
"Avg Amount/Transaction": "Avg Amount/Transaction",
"# Senders": "# Senders",
"Sender Registry": "Sender Registry",
"Individual": "Individual",
"Legal Entity": "Legal Entity"
}, },
"links": { "links": {
"Community": "Community", "Community": "Community",
@@ -2782,6 +2863,8 @@
"Interbank Transfer": "Interbank Transfer", "Interbank Transfer": "Interbank Transfer",
"Interest Payment Waiver": "Interest Payment Waiver", "Interest Payment Waiver": "Interest Payment Waiver",
"Payments": "Payments", "Payments": "Payments",
"Remittances": "Remittances",
"Process Remittance": "Process Remittance",
"Payout Refund": "Payout Refund", "Payout Refund": "Payout Refund",
"Post Interest": "Post Interest", "Post Interest": "Post Interest",
"Post Interest As On": "Post Interest As On", "Post Interest As On": "Post Interest As On",
@@ -2833,8 +2916,14 @@
"Reject Additional Shares": "Reject Additional Shares" "Reject Additional Shares": "Reject Additional Shares"
}, },
"placeholders": { "placeholders": {
"# Remittance": "# Remittance",
"Add new server": "Add new server", "Add new server": "Add new server",
"Enter document number": "Enter document number",
"Enter first name (include middle name)": "Enter first name (include middle name)",
"Enter last name": "Enter last name",
"Enter remittance reference number": "Enter remittance reference number",
"Search Activity": "Search Activity", "Search Activity": "Search Activity",
"Sender name": "Sender name",
"Enter your username": "Enter your username", "Enter your username": "Enter your username",
"Enter your password": "Enter your password" "Enter your password": "Enter your password"
}, },
@@ -2949,6 +3038,7 @@
"Business Date functionality is not enabled": "The Business Date functionality is not enabled. If you need that, Go to Global Configurations and enable the configuration", "Business Date functionality is not enabled": "The Business Date functionality is not enabled. If you need that, Go to Global Configurations and enable the configuration",
"Button for accounting department": "This button opens the menu to access the system tools required by the accounting department.", "Button for accounting department": "This button opens the menu to access the system tools required by the accounting department.",
"Button for individual account": "This button will be used for individual accounts, groups and centers", "Button for individual account": "This button will be used for individual accounts, groups and centers",
"Failed to search clients. Please try again.": "Failed to search clients. Please try again.",
"Cancel Pending Sale": "Cancel Pending Sale", "Cancel Pending Sale": "Cancel Pending Sale",
"Cashier Transactions": "Cashier Transactions", "Cashier Transactions": "Cashier Transactions",
"Cashiers": "Cashiers", "Cashiers": "Cashiers",
@@ -3431,6 +3521,7 @@
"Preferences for generating account numbers for client": "Preferences for generating account numbers for client, loan and savings accounts", "Preferences for generating account numbers for client": "Preferences for generating account numbers for client, loan and savings accounts",
"Products": "Products", "Products": "Products",
"Products Mix": "Products Mix", "Products Mix": "Products Mix",
"Process Remittance": "Process Remittance",
"Profile": "Profile", "Profile": "Profile",
"Provisioning Criteria": "Provisioning Criteria", "Provisioning Criteria": "Provisioning Criteria",
"Provisioning Entries": "Provisioning Entries", "Provisioning Entries": "Provisioning Entries",
@@ -3482,6 +3573,7 @@
"Scheduler Job History": "Scheduler Job History", "Scheduler Job History": "Scheduler Job History",
"Scheduler Jobs can set batch jobs": "By using Scheduler Jobs, you can set batch jobs to be completed at regular intervals. From this page you can schedule, modify or delete jobs.", "Scheduler Jobs can set batch jobs": "By using Scheduler Jobs, you can set batch jobs to be completed at regular intervals. From this page you can schedule, modify or delete jobs.",
"Search": "Search", "Search": "Search",
"Search and assign this beneficiary to an existing Mifos client account": "Search and assign this beneficiary to an existing Mifos client account",
"Search by Text": "Search by Text", "Search by Text": "Search by Text",
"SearchByName": "Search by Name", "SearchByName": "Search by Name",
"SearchByClient": "Search by client name, external Id, mobile", "SearchByClient": "Search by client name, external Id, mobile",
@@ -3661,7 +3753,21 @@
"“Maker-Checker” principle requires every tasks": "The “Maker-Checker” principle requires every tasks to be completed by two people to reduce the chance of errors and misuse. One person initiates the process and the second completes it.", "“Maker-Checker” principle requires every tasks": "The “Maker-Checker” principle requires every tasks to be completed by two people to reduce the chance of errors and misuse. One person initiates the process and the second completes it.",
"Buy Down Fees": "Buy Down Fees", "Buy Down Fees": "Buy Down Fees",
"Undo Write-off Description": "This action will reverse the write-off transaction and restore the loan to its previous active status. Please provide a note explaining the reason for this action.", "Undo Write-off Description": "This action will reverse the write-off transaction and restore the loan to its previous active status. Please provide a note explaining the reason for this action.",
"UploadDocumentHint": "Upload a PDF or image to generate a preview." "UploadDocumentHint": "Upload a PDF or image to generate a preview.",
"This remittance has a terminal status and cannot be processed further.": "This remittance has a terminal status and cannot be processed further.",
"This remittance has already been paid successfully.": "This remittance has already been paid successfully.",
"Review the transaction and recipient details below, then assign the payout.": "Review the transaction and recipient details below, then assign the payout.",
"Please review the details carefully before confirming the payment.": "Please review the details carefully before confirming the payment.",
"Register the transactional profile for the beneficiary. Add entries for individuals or legal entities.": "Register the transactional profile for the beneficiary. Add entries for individuals or legal entities.",
"No transactional profile entries yet. Add one below.": "No transactional profile entries yet. Add one below.",
"Click to remove": "Click to remove",
"Recipient is not valid for this transaction. Please check the details.": "Recipient is not valid for this transaction. Please check the details.",
"Recipient validation failed. Please check the document details and try again.": "Recipient validation failed. Please check the document details and try again.",
"Missing required information. Please try again.": "Missing required information. Please try again.",
"Failed to confirm payment. Please try again or contact support.": "Failed to confirm payment. Please try again or contact support.",
"Failed to assign payout. Please try again.": "Failed to assign payout. Please try again.",
"Remittance not found. Please check the reference number and try again.": "Remittance not found. Please check the reference number and try again.",
"Unable to load vendors. Please check your connection and try again.": "Unable to load vendors. Please check your connection and try again."
}, },
"auditTrail": { "auditTrail": {
"actions": { "actions": {
@@ -3840,6 +3946,7 @@
"Indicates whether to enforce a minimum balance": "Indicates whether to enforce a minimum balance", "Indicates whether to enforce a minimum balance": "Indicates whether to enforce a minimum balance",
"Indicates whether the dividends can be processed even share account of this share product is inactive": "Indicates whether the dividends can be processed even share account of this share product is inactive", "Indicates whether the dividends can be processed even share account of this share product is inactive": "Indicates whether the dividends can be processed even share account of this share product is inactive",
"Individual Collection Sheet": "Individual Collection Sheet", "Individual Collection Sheet": "Individual Collection Sheet",
"Process Remittance": "Process Remittance",
"Input to calculating the repayment schedule for a loan account": "These fields are input to calculating the repayment schedule for a loan account and are used to determine when payments are due.", "Input to calculating the repayment schedule for a loan account": "These fields are input to calculating the repayment schedule for a loan account and are used to determine when payments are due.",
"Interest effective from date": "Interest effective from date", "Interest effective from date": "Interest effective from date",
"Interest rate is posted or credited to a saving account": "The period at which interest rate is posted or credited to a saving account", "Interest rate is posted or credited to a saving account": "The period at which interest rate is posted or credited to a saving account",
+95 -4
View File
@@ -560,7 +560,15 @@
"Is Self Service": "Es autoservicio", "Is Self Service": "Es autoservicio",
"Advanced Search": "Búsqueda avanzada", "Advanced Search": "Búsqueda avanzada",
"Show advanced search": "Mostrar búsqueda avanzada", "Show advanced search": "Mostrar búsqueda avanzada",
"Hide advanced search": "Ocultar búsqueda avanzada" "Hide advanced search": "Ocultar búsqueda avanzada",
"Validate Recipient": "Validar Beneficiario",
"Assign Payout": "Asignar Pago",
"Confirm Payment": "Confirmar Pago",
"Print Receipt": "Imprimir Recibo",
"New Remittance": "Nueva Remesa",
"View Receipt": "Ver Recibo",
"Add Entry": "Agregar Entrada",
"Retry": "Reintentar"
}, },
"catalogs": { "catalogs": {
"Interest payment waiver": "Exención de pago de intereses", "Interest payment waiver": "Exención de pago de intereses",
@@ -801,6 +809,9 @@
"Passport": "Pasaporte", "Passport": "Pasaporte",
"Id": "Identificación", "Id": "Identificación",
"Drivers License": "Licencia de conducir", "Drivers License": "Licencia de conducir",
"Driver's License": "Licencia de Conducir",
"National ID": "Identificación Nacional",
"Voter ID": "Credencial de Elector",
"Any Other Id Type": "Cualquier otro tipo de identificación", "Any Other Id Type": "Cualquier otro tipo de identificación",
"Check loan repayment overdue": "Verificar pagos vencidos de créditos", "Check loan repayment overdue": "Verificar pagos vencidos de créditos",
"Update loan arrears aging": "Actualizar antigüedad de atrasos en los créditos", "Update loan arrears aging": "Actualizar antigüedad de atrasos en los créditos",
@@ -1296,7 +1307,30 @@
"Contact Information": "Información de Contacto", "Contact Information": "Información de Contacto",
"Classification": "Clasificación", "Classification": "Clasificación",
"Status": "Estado", "Status": "Estado",
"Pending Tasks": "Tareas pendientes" "Pending Tasks": "Tareas pendientes",
"Process Remittance": "Procesar Remesa",
"Search Remittance": "Buscar Remesa",
"Remittance Details": "Detalles de la Remesa",
"Search Beneficiary": "Buscar Beneficiario",
"Beneficiary Details": "Detalles del Beneficiario",
"Transactional Profile": "Perfil Transaccional",
"Confirm Payout": "Confirmar Pago",
"Confirm Payment": "Confirmar Pago",
"Payment Receipt": "Recibo de Pago",
"Transaction": "Transacción",
"Recipient": "Beneficiario",
"Payout Assignment": "Asignación de Pago",
"Assign Payout": "Asignar Pago",
"Validated Recipient Information": "Información del Beneficiario Validada",
"Identification": "Identificación",
"Professional Information": "Información Profesional",
"Assign to Mifos Client": "Asignar a Cliente Mifos",
"Add Profile Entry": "Agregar Entrada de Perfil",
"Recipient Details": "Detalles del Beneficiario",
"Confirmation Details": "Detalles de Confirmación",
"Payment Successful": "Pago Exitoso",
"Transaction Status": "Estado de la Transacción",
"Transaction Summary": "Resumen de la Transacción"
}, },
"inputs": { "inputs": {
"Floating rate": "Tasa flotante", "Floating rate": "Tasa flotante",
@@ -1986,6 +2020,7 @@
"Liability Account": "Cuenta de responsabilidad", "Liability Account": "Cuenta de responsabilidad",
"Liability Account (CR)": "Cuenta de Pasivo (CR)", "Liability Account (CR)": "Cuenta de Pasivo (CR)",
"Lifted On": "levantado", "Lifted On": "levantado",
"Loading vendors...": "Cargando proveedores...",
"Link savings": "Ahorro de enlaces", "Link savings": "Ahorro de enlaces",
"Linked Savings Account": "Cuenta de Ahorros vinculada", "Linked Savings Account": "Cuenta de Ahorros vinculada",
"Linked Savings Account(Dividend Posting)": "Cuenta de Ahorros vinculada (publicación de dividendos)", "Linked Savings Account(Dividend Posting)": "Cuenta de Ahorros vinculada (publicación de dividendos)",
@@ -2462,6 +2497,7 @@
"Start Time": "Hora de inicio", "Start Time": "Hora de inicio",
"Started On": "Iniciado en", "Started On": "Iniciado en",
"Starting date": "Fecha de inicio", "Starting date": "Fecha de inicio",
"State": "Estado",
"State / Province": "Estado o Provincia", "State / Province": "Estado o Provincia",
"Status": "Estado", "Status": "Estado",
"Step Name": "Nombre del paso", "Step Name": "Nombre del paso",
@@ -2821,11 +2857,50 @@
"Redeem Shares": "Canjear acciones", "Redeem Shares": "Canjear acciones",
"Approve Additional Shares": "Aprobar acciones adicionales", "Approve Additional Shares": "Aprobar acciones adicionales",
"Reject Additional Shares": "Rechazar acciones adicionales", "Reject Additional Shares": "Rechazar acciones adicionales",
"Tasks": "Tareas" "Tasks": "Tareas",
"Vendor": "Proveedor",
"Loading vendors...": "Cargando proveedores...",
"Failed to load vendors": "Error al cargar proveedores",
"External Reference ID": "ID de Referencia Externa",
"Reference ID is required": "Se requiere ID de referencia",
"Minimum 3 characters": "Mínimo 3 caracteres",
"Sent By": "Enviado Por",
"Deposited At": "Depositado En",
"Sender Name": "Nombre del Remitente",
"Payout Amount": "Monto del Pago",
"Recipient Name": "Nombre del Beneficiario",
"Individual": "Individual",
"Legal Entity": "Persona Moral",
"# Remittance": "# Remesa",
"Avg Amount/Transaction": "Monto Promedio/Transacción",
"# Senders": "# Remitentes",
"Sender Registry": "Registro de Remitentes",
"Sender": "Remitente",
"Document": "Documento",
"Document Number": "Número de Documento",
"First name is required": "Se requiere nombre",
"Last name is required": "Se requiere apellido",
"Document number is required": "Se requiere número de documento",
"National Identity Card Number": "Número de Cédula de Identidad Nacional",
"Mothers Maiden Name": "Apellido de Soltera de la Madre",
"Municipality": "Municipio",
"Locality": "Localidad",
"New Locality": "Nueva Localidad",
"Occupation": "Ocupación",
"Economic Activity": "Actividad Económica",
"Entity Member": "Miembro de Entidad",
"Search Client": "Buscar Cliente",
"Type client name or account number": "Escriba nombre del cliente o número de cuenta"
}, },
"placeholders": { "placeholders": {
"# Remittance": "# Remesa",
"Add new server": "Agregar nuevo servidor", "Add new server": "Agregar nuevo servidor",
"Enter document number": "Ingrese número de documento",
"Enter first name (include middle name)": "Ingrese nombre (incluya segundo nombre)",
"Enter last name": "Ingrese apellido",
"Enter remittance reference number": "Ingrese número de referencia de remesa",
"Search Activity": "Actividad de búsqueda", "Search Activity": "Actividad de búsqueda",
"Sender name": "Nombre del remitente",
"Enter your username": "Ingrese su nombre de usuario", "Enter your username": "Ingrese su nombre de usuario",
"Enter your password": "Ingrese su contraseña" "Enter your password": "Ingrese su contraseña"
}, },
@@ -3852,7 +3927,23 @@
"Create Interest Refund": "Crear reembolso de intereses", "Create Interest Refund": "Crear reembolso de intereses",
"ACTIVE": "ACTIVO", "ACTIVE": "ACTIVO",
"PENDING": "PENDIENTE", "PENDING": "PENDIENTE",
"INACTIVE": "INACTIVO" "INACTIVE": "INACTIVO",
"Unable to load vendors. Please check your connection and try again.": "No se pudieron cargar los proveedores. Verifique su conexión e intente de nuevo.",
"This remittance has a terminal status and cannot be processed further.": "Esta remesa tiene un estado terminal y no puede ser procesada.",
"This remittance has already been paid successfully.": "Esta remesa ya ha sido pagada exitosamente.",
"Register the transactional profile for the beneficiary. Add entries for individuals or legal entities.": "Registre el perfil transaccional del beneficiario. Agregue entradas para individuos o personas morales.",
"Click to remove": "Clic para eliminar",
"No transactional profile entries yet. Add one below.": "Aún no hay entradas en el perfil transaccional. Agregue una a continuación.",
"Please review the details carefully before confirming the payment.": "Revise los detalles cuidadosamente antes de confirmar el pago.",
"Search and assign this beneficiary to an existing Mifos client account": "Buscar y asignar este beneficiario a una cuenta de cliente Mifos existente",
"Failed to search clients. Please try again.": "Error al buscar clientes. Intente de nuevo.",
"Recipient is not valid for this transaction. Please check the details.": "El beneficiario no es válido para esta transacción. Verifique los detalles.",
"Recipient validation failed. Please check the document details and try again.": "La validación del beneficiario falló. Verifique los detalles del documento e intente de nuevo.",
"Missing required information. Please try again.": "Falta información requerida. Intente de nuevo.",
"Failed to confirm payment. Please try again or contact support.": "Error al confirmar el pago. Intente de nuevo o contacte al soporte.",
"Failed to assign payout. Please try again.": "Error al asignar el pago. Intente de nuevo.",
"Remittance not found. Please check the reference number and try again.": "Remesa no encontrada. Verifique el número de referencia e intente de nuevo.",
"Review the transaction and recipient details below, then assign the payout.": "Revise los detalles de la transacción y del beneficiario a continuación, luego asigne el pago."
}, },
"countries": { "countries": {
"AF": "Afganistán", "AF": "Afganistán",
+9
View File
@@ -85,6 +85,15 @@ export const environment = {
mifosInterbankTransfersApiVersion: loadedEnv['mifosInterbankTransfersApiVersion'] || '/v1.0', mifosInterbankTransfersApiVersion: loadedEnv['mifosInterbankTransfersApiVersion'] || '/v1.0',
mifosInterbankTransfersEnabled: loadedEnv['mifosInterbankTransfersEnabled'] ?? true, mifosInterbankTransfersEnabled: loadedEnv['mifosInterbankTransfersEnabled'] ?? true,
/** Remittance Module Integration */
mifosRemittanceApiUrl: loadedEnv['mifosRemittanceApiUrl'] || '',
mifosRemittanceApiProvider: loadedEnv['mifosRemittanceApiProvider'] || '/remittance-api',
mifosRemittanceApiVersion: loadedEnv['mifosRemittanceApiVersion'] || '/v1',
mifosRemittanceEnabled:
loadedEnv['mifosRemittanceEnabled'] === true || loadedEnv['mifosRemittanceEnabled'] === 'true',
mifosRemittanceApiHeader: loadedEnv['mifosRemittanceApiHeader'] || 'X-Gravitee-Api-Key',
mifosRemittanceApiKey: loadedEnv['mifosRemittanceApiKey'] || '',
minPasswordLength: loadedEnv['minPasswordLength'] || 12, minPasswordLength: loadedEnv['minPasswordLength'] || 12,
/** External National ID System integration */ /** External National ID System integration */
+8
View File
@@ -87,6 +87,14 @@ export const environment = {
mifosInterbankTransfersApiVersion: window.env?.mifosInterbankTransfersApiVersion || '/v1.0', mifosInterbankTransfersApiVersion: window.env?.mifosInterbankTransfersApiVersion || '/v1.0',
mifosInterbankTransfersEnabled: window.env?.mifosInterbankTransfersEnabled ?? true, mifosInterbankTransfersEnabled: window.env?.mifosInterbankTransfersEnabled ?? true,
/** Remittance Module Integration */
mifosRemittanceApiUrl: window.env?.mifosRemittanceApiUrl || '',
mifosRemittanceApiProvider: window.env?.mifosRemittanceApiProvider || '/remittance-api',
mifosRemittanceApiVersion: window.env?.mifosRemittanceApiVersion || '/v1',
mifosRemittanceEnabled: window.env?.mifosRemittanceEnabled === true || window.env?.mifosRemittanceEnabled === 'true',
mifosRemittanceApiHeader: window.env?.mifosRemittanceApiHeader || 'X-Gravitee-Api-Key',
mifosRemittanceApiKey: window.env?.mifosRemittanceApiKey || '',
/** /**
* External National ID System Integration * External National ID System Integration
* When enabled, client creation/editing will lookup external National ID * When enabled, client creation/editing will lookup external National ID
+46
View File
@@ -719,6 +719,24 @@ mifosx-notifications-page td {
color: inherit !important; color: inherit !important;
} }
// ===== Global Button Icon Alignment =====
// Handles the span + mat-icon pattern used throughout the application
.mat-mdc-button,
.mat-mdc-raised-button,
.mat-mdc-outlined-button {
span {
display: inline-flex;
align-items: center;
gap: 8px;
mat-icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
}
}
.full-width-flex { .full-width-flex {
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
@@ -854,3 +872,31 @@ mat-card {
} }
} }
} }
/* Print styles — hide app shell, show only receipt content */
@media print {
mat-sidenav,
mifosx-toolbar,
mifosx-breadcrumb,
mifosx-footer,
.sidebar-panel,
.mat-mdc-tab-header,
.mat-stepper-vertical-line,
.mat-step-header {
display: none !important;
}
mat-sidenav-content.sidenav {
margin-left: 0 !important;
}
body,
html {
background: #fff !important;
}
@page {
/* stylelint-disable-next-line unit-allowed-list */
margin: 1.5cm;
}
}
+5
View File
@@ -24,6 +24,11 @@ interface Window {
mifosInterbankTransfersApiProvider?: string; mifosInterbankTransfersApiProvider?: string;
mifosInterbankTransfersApiVersion?: string; mifosInterbankTransfersApiVersion?: string;
mifosInterbankTransfersEnabled?: boolean; mifosInterbankTransfersEnabled?: boolean;
mifosRemittanceApiUrl?: string;
mifosRemittanceApiProvider?: string;
mifosRemittanceApiVersion?: string;
mifosRemittanceEnabled?: boolean;
mifosRemittanceApiKey?: string;
minPasswordLength?: number; minPasswordLength?: number;
}; };
} }