From 4a3461a2017ed2f8e281b9a492f8edd39110d8c3 Mon Sep 17 00:00:00 2001 From: Anvay Kharb Date: Sat, 26 Sep 2026 12:54:30 +0530 Subject: [PATCH] WEB-1236: Add bill and service payment workflow --- src/app/home/activities.ts | 11 + .../base-teller/base-teller.service.spec.ts | 59 ++ .../base-teller/base-teller.service.ts | 135 +++++ .../service-payment.component.html | 450 ++++++++++++++ .../service-payment.component.scss | 135 +++++ .../service-payment.component.spec.ts | 306 ++++++++++ .../service-payment.component.ts | 562 ++++++++++++++++++ .../service-payment.guard.spec.ts | 50 ++ .../service-payment/service-payment.guard.ts | 28 + .../organization-routing.module.ts | 11 + .../organization/organization.component.html | 34 ++ .../organization.component.spec.ts | 45 ++ .../organization/organization.component.ts | 4 +- src/assets/translations/az-AZ.json | 45 ++ src/assets/translations/cs-CS.json | 45 ++ src/assets/translations/de-DE.json | 45 ++ src/assets/translations/en-US.json | 45 ++ src/assets/translations/es-CL.json | 45 ++ src/assets/translations/es-MX.json | 45 ++ src/assets/translations/fr-FR.json | 45 ++ src/assets/translations/it-IT.json | 45 ++ src/assets/translations/ko-KO.json | 45 ++ src/assets/translations/lt-LT.json | 45 ++ src/assets/translations/lv-LV.json | 45 ++ src/assets/translations/ne-NE.json | 45 ++ src/assets/translations/pt-PT.json | 45 ++ src/assets/translations/sw-SW.json | 45 ++ 27 files changed, 2459 insertions(+), 1 deletion(-) create mode 100644 src/app/organization/base-teller/service-payment/service-payment.component.html create mode 100644 src/app/organization/base-teller/service-payment/service-payment.component.scss create mode 100644 src/app/organization/base-teller/service-payment/service-payment.component.spec.ts create mode 100644 src/app/organization/base-teller/service-payment/service-payment.component.ts create mode 100644 src/app/organization/base-teller/service-payment/service-payment.guard.spec.ts create mode 100644 src/app/organization/base-teller/service-payment/service-payment.guard.ts create mode 100644 src/app/organization/organization.component.spec.ts diff --git a/src/app/home/activities.ts b/src/app/home/activities.ts index 5284ae9f4d..9131429711 100644 --- a/src/app/home/activities.ts +++ b/src/app/home/activities.ts @@ -16,6 +16,8 @@ * there; verify a new path against the route configs, and beware a parent route * with children but no component of its own, which renders a blank page. */ +import { environment } from 'environments/environment'; + const activities: any[] = [ { activity: 'client', path: '/clients' }, { activity: 'groups', path: '/groups' }, @@ -57,6 +59,15 @@ const activities: any[] = [ path: '/organization/base-teller/savings-account-deposits', permission: 'DEPOSIT_SAVINGSACCOUNT' }, + ...(environment.productionMode + ? [ + { + activity: 'labels.heading.Bill and Service Payment', + path: '/organization/base-teller/service-payments', + permission: 'READ_BASE_TELLER_SERVICE_PAYMENT' + } + ] + : []), { activity: 'manage funds', path: '/organization/manage-funds' }, { activity: 'chart of accounts', path: '/accounting/chart-of-accounts' }, { activity: 'frequent postings', path: '/accounting/journal-entries/frequent-postings' }, diff --git a/src/app/organization/base-teller/base-teller.service.spec.ts b/src/app/organization/base-teller/base-teller.service.spec.ts index 2a9de1aa92..9cfb10bd02 100644 --- a/src/app/organization/base-teller/base-teller.service.spec.ts +++ b/src/app/organization/base-teller/base-teller.service.spec.ts @@ -212,4 +212,63 @@ describe('BaseTellerService', () => { ); expect((await resultPromise).error.defaultUserMessage).toBe('Returned check has already been settled.'); }); + + it('uses the exact WEB-1236 endpoints and contracts', async () => { + const servicesPromise = firstValueFrom(service.getServicePaymentServices()); + const servicesRequest = httpMock.expectOne('/v2/base-teller/service-payments/services'); + expect(servicesRequest.request.method).toBe('GET'); + servicesRequest.flush([{ id: 5, code: 'POWER', name: 'Power', active: true, denominations: [] }]); + expect((await servicesPromise)[0].code).toBe('POWER'); + + const clientPromise = firstValueFrom(service.getServicePaymentClient(42)); + const clientRequest = httpMock.expectOne('/v2/base-teller/service-payments/clients/42'); + expect(clientRequest.request.method).toBe('GET'); + clientRequest.flush({ clientId: 42, displayName: 'Ada' }); + expect((await clientPromise).clientId).toBe(42); + + const quote = { + payerType: 'NON_CLIENT' as const, + payerName: 'Ada', + serviceId: 5, + serviceReference: 'INV-1', + baseAmount: '100.00', + currencyCode: 'USD' + }; + const quotePromise = firstValueFrom(service.quoteServicePayment(quote)); + const quoteRequest = httpMock.expectOne('/v2/base-teller/service-payments/quote'); + expect(quoteRequest.request.method).toBe('POST'); + expect(quoteRequest.request.body).toEqual(quote); + quoteRequest.flush({ ...quote, commission: 2, commissionVat: 0.26, totalToPay: 102.26 }); + expect((await quotePromise).totalToPay).toBe(102.26); + + const payment = { + ...quote, + idempotencyKey: 'stable-key', + businessDate: '2026-09-25', + paymentTypeId: 1, + denominations: [{ denominationId: '100', value: 100, quantity: 2 }] + }; + const paymentPromise = firstValueFrom(service.createServicePayment(payment)); + const paymentRequest = httpMock.expectOne('/v2/base-teller/service-payments'); + expect(paymentRequest.request.method).toBe('POST'); + expect(paymentRequest.request.body).toEqual(payment); + paymentRequest.flush({ transactionId: 77, receiptNumber: 'SP-77' }); + expect((await paymentPromise).transactionId).toBe(77); + + const receiptPromise = firstValueFrom(service.getServicePaymentReceipt(77)); + const receiptRequest = httpMock.expectOne('/v2/base-teller/service-payments/77/receipt'); + expect(receiptRequest.request.method).toBe('GET'); + receiptRequest.flush({ transactionId: 77, receiptNumber: 'SP-77' }); + expect((await receiptPromise).receiptNumber).toBe('SP-77'); + }); + + it('searches only clients for the service-payment payer lookup', async () => { + const resultPromise = firstValueFrom(service.searchServicePaymentClients('Ada')); + const request = httpMock.expectOne((candidate) => candidate.url === '/search'); + expect(request.request.params.get('query')).toBe('Ada'); + expect(request.request.params.get('resource')).toBe('clients'); + expect(request.request.params.get('exactMatch')).toBe('false'); + request.flush([{ entityId: 42, entityName: 'Ada' }]); + expect(await resultPromise).toEqual([{ entityId: 42, entityName: 'Ada' }]); + }); }); diff --git a/src/app/organization/base-teller/base-teller.service.ts b/src/app/organization/base-teller/base-teller.service.ts index eb13233918..d82a7463c0 100644 --- a/src/app/organization/base-teller/base-teller.service.ts +++ b/src/app/organization/base-teller/base-teller.service.ts @@ -221,6 +221,107 @@ export interface BaseTellerOption { isCashPayment?: boolean; } +export type ServicePaymentPayerType = 'CLIENT' | 'NON_CLIENT'; +export type ServicePaymentCommissionType = 'NONE' | 'FIXED' | 'PERCENTAGE'; +export type ServicePaymentAmount = number | string; + +export interface ServicePaymentDenominationConfiguration { + identifier: string; + value: ServicePaymentAmount; + type: string; +} + +export interface ServicePaymentServiceConfiguration { + id: FineractId; + code: string; + name: string; + active: boolean; + currencyCode: string; + commissionType: ServicePaymentCommissionType; + commissionValue: ServicePaymentAmount; + commissionVatRate: ServicePaymentAmount; + denominations: ServicePaymentDenominationConfiguration[]; +} + +export interface ServicePaymentClient { + clientId: FineractId; + accountNo: string; + externalId?: string; + displayName: string; + officeId: FineractId; + officeName: string; + status: string; +} + +export interface ServicePaymentQuoteRequest { + payerType: ServicePaymentPayerType; + clientId?: FineractId; + payerName?: string; + serviceId: FineractId; + serviceReference: string; + baseAmount: ServicePaymentAmount; + currencyCode: string; +} + +export interface ServicePaymentQuote extends ServicePaymentQuoteRequest { + clientAccountNo?: string; + payerName: string; + serviceCode: string; + serviceName: string; + commission: ServicePaymentAmount; + commissionVat: ServicePaymentAmount; + totalToPay: ServicePaymentAmount; + businessDate: string; +} + +export interface ServicePaymentDenomination { + denominationId: string; + value: ServicePaymentAmount; + quantity: number; +} + +export interface ServicePaymentRequest extends ServicePaymentQuoteRequest { + idempotencyKey: string; + businessDate: string; + paymentTypeId: FineractId; + denominations: ServicePaymentDenomination[]; +} + +export interface ServicePaymentReceipt { + transactionId: FineractId; + receiptNumber: string; + status: string; + businessDate: string; + officeId: FineractId; + officeName: string; + tellerId: FineractId; + tellerName: string; + cashierId: FineractId; + cashierName: string; + operatorId: FineractId; + operatorName: string; + payerType: ServicePaymentPayerType; + clientId?: FineractId; + clientAccountNo?: string; + payerName: string; + serviceId: FineractId; + serviceCode: string; + serviceName: string; + serviceReference: string; + baseAmount: ServicePaymentAmount; + commission: ServicePaymentAmount; + commissionVat: ServicePaymentAmount; + totalPaid: ServicePaymentAmount; + amountReceived: ServicePaymentAmount; + change: ServicePaymentAmount; + currencyCode: string; + cashierTransactionId?: FineractId; + accountingTransactionId?: string; + createdOnUtc: string; + completedOnUtc?: string; + denominations: ServicePaymentDenomination[]; +} + /** * Base Teller service. */ @@ -232,6 +333,7 @@ export class BaseTellerService { private readonly savingsAccountOpeningsPath = '/v2/base-teller/savings-account-openings'; private readonly returnedChecksPath = '/v2/base-teller/returned-checks'; + private readonly servicePaymentsPath = '/v2/base-teller/service-payments'; /** * Searches customers through the Base Teller savings-opening workflow API. @@ -380,4 +482,37 @@ export class BaseTellerService { getReturnedCheckPaymentTypes(): Observable { return this.http.get('/paymenttypes'); } + + /** Lists the backend-configured active service-payment catalog. */ + getServicePaymentServices(): Observable { + return this.http.get(`${this.servicePaymentsPath}/services`); + } + + /** Searches the platform client index before resolving the payer through WEB-1236. */ + searchServicePaymentClients( + searchTerm: string + ): Observable { + const params = new HttpParams().set('exactMatch', 'false').set('query', searchTerm).set('resource', 'clients'); + return this.http.get('/search', { params }); + } + + /** Resolves an authoritative, office-scoped client payer. */ + getServicePaymentClient(clientId: FineractId): Observable { + return this.http.get(`${this.servicePaymentsPath}/clients/${clientId}`); + } + + /** Gets the backend-authoritative commission, VAT, total, and business date. */ + quoteServicePayment(payload: ServicePaymentQuoteRequest): Observable { + return this.http.post(`${this.servicePaymentsPath}/quote`, payload); + } + + /** Posts a cash service payment with the backend idempotency contract. */ + createServicePayment(payload: ServicePaymentRequest): Observable { + return this.http.post(this.servicePaymentsPath, payload); + } + + /** Retrieves the immutable backend receipt for display or reprint. */ + getServicePaymentReceipt(transactionId: FineractId): Observable { + return this.http.get(`${this.servicePaymentsPath}/${transactionId}/receipt`); + } } diff --git a/src/app/organization/base-teller/service-payment/service-payment.component.html b/src/app/organization/base-teller/service-payment/service-payment.component.html new file mode 100644 index 0000000000..b4ff43a2ce --- /dev/null +++ b/src/app/organization/base-teller/service-payment/service-payment.component.html @@ -0,0 +1,450 @@ + + +
+ + +

{{ 'labels.heading.Bill and Service Payment' | translate }}

+ + @if (errorMessage) { + + } + + @if (canRead) { + @if (isLoading) { +
+ } @else if (!services.length && !receipt) { +
+ {{ 'servicePayment.text.noServices' | translate }} +
+ + } @else { + + + {{ 'servicePayment.heading.paymentDetails' | translate }} +
+ + + {{ 'servicePayment.inputs.existingClient' | translate }} + + + {{ 'servicePayment.inputs.nonClient' | translate }} + + + + @if (detailsForm.controls.payerType.value === 'CLIENT') { +
+ + {{ 'servicePayment.inputs.clientSearch' | translate }} + + + +
+ @if (hasSearchedClients && !isSearchingClients && !clientResults.length && !selectedClient) { +
{{ 'servicePayment.text.noClients' | translate }}
+ } + @if (clientResults.length) { + + @for (client of clientResults; track client.id || client.entityId || client.clientId) { + + {{ clientName(client) }} + @if (client.accountNo || client.entityAccountNo) { + — {{ client.accountNo || client.entityAccountNo }} + } + + } + + } + @if (selectedClient) { +
+
+ {{ 'labels.inputs.Client' | translate }}{{ selectedClient.displayName }} +
+
+ {{ 'labels.inputs.Account Number' | translate }}{{ selectedClient.accountNo }} +
+
+ {{ 'labels.inputs.Office' | translate }}{{ selectedClient.officeName }} +
+
+ } + } @else { + + {{ 'servicePayment.inputs.payerName' | translate }} + + @if (detailsForm.controls.payerName.hasError('required')) { + {{ 'servicePayment.inputs.payerNameRequired' | translate }} + } + + } + +
+ + {{ 'servicePayment.inputs.service' | translate }} + + @for (service of services; track service.id) { + {{ service.name }} ({{ service.code }}) + } + + + + {{ 'servicePayment.inputs.serviceReference' | translate }} + + + + {{ 'labels.inputs.Amount' | translate }} + + {{ selectedService?.currencyCode }} + @if (detailsForm.controls.baseAmount.hasError('pattern')) { + {{ 'servicePayment.text.validAmount' | translate }} + } + +
+ +
+ + +
+ + @if (quote) { +
+
+ {{ 'labels.inputs.Business Date' | translate }}{{ quote.businessDate }} +
+
+ {{ 'labels.inputs.Amount' | translate }}{{ quote.baseAmount }} {{ quote.currencyCode }} +
+
+ {{ 'servicePayment.inputs.commission' | translate }}{{ quote.commission }} {{ quote.currencyCode }} +
+
+ {{ 'servicePayment.inputs.commissionVat' | translate }}{{ quote.commissionVat }} {{ quote.currencyCode }} +
+
+ {{ 'servicePayment.inputs.totalToPay' | translate }}{{ quote.totalToPay }} {{ quote.currencyCode }} +
+
+ } +
+
+ + + {{ 'labels.heading.Cash Payment' | translate }} +
+ + {{ 'labels.inputs.Payment Type' | translate }} + + @for (paymentType of cashPaymentTypes; track paymentType.id) { + {{ paymentType.name || paymentType.value }} + } + + @if (!cashPaymentTypes.length) { + {{ 'labels.text.No eligible cash payment types available' | translate }} + } + + + @if (!denominations.length) { +
+ {{ 'servicePayment.text.noDenominations' | translate }} +
+ } @else { +
+

{{ 'labels.inputs.Denominations' | translate }}

+
+ @for ( + denomination of denominations.controls; + track denomination.controls.denominationId.value; + let i = $index + ) { +
+
+ {{ 'labels.inputs.Denomination' | translate }} + {{ denomination.controls.value.value }} {{ quote?.currencyCode }} +
+ + {{ 'labels.inputs.Quantity' | translate }} + + @if (denomination.controls.quantity.hasError('pattern')) { + {{ 'labels.text.Quantity must be a whole number' | translate }} + } + +
+ {{ 'servicePayment.inputs.subtotal' | translate }} + {{ lineTotal(denomination) }} {{ quote?.currencyCode }} +
+
+ } +
+
+ } + +
+
+ {{ 'servicePayment.inputs.totalToPay' | translate }}{{ quote?.totalToPay }} {{ quote?.currencyCode }} +
+
+ {{ 'labels.inputs.Total Cash Received' | translate }}{{ totalCashReceived }} {{ quote?.currencyCode }} +
+
+ {{ 'labels.inputs.Change to Give' | translate }}{{ changeAmount }} {{ quote?.currencyCode }} +
+
+ @if (!cashIsSufficient) { +
+ {{ 'servicePayment.text.insufficientCash' | translate }} +
+ } + +
+ + +
+
+
+ + + {{ 'labels.heading.Confirmation' | translate }} + @if (quote) { +
+
+ {{ 'servicePayment.inputs.payer' | translate }}{{ quote.payerName }} +
+
+ {{ 'servicePayment.inputs.service' | translate }}{{ quote.serviceName }} +
+
+ {{ 'servicePayment.inputs.serviceReference' | translate }}{{ quote.serviceReference }} +
+
+ {{ 'labels.inputs.Amount' | translate }}{{ quote.baseAmount }} {{ quote.currencyCode }} +
+
+ {{ 'servicePayment.inputs.commission' | translate }}{{ quote.commission }} {{ quote.currencyCode }} +
+
+ {{ 'servicePayment.inputs.commissionVat' | translate }}{{ quote.commissionVat }} {{ quote.currencyCode }} +
+
+ {{ 'servicePayment.inputs.totalToPay' | translate }}{{ quote.totalToPay }} {{ quote.currencyCode }} +
+
+ {{ 'labels.inputs.Total Cash Received' | translate }}{{ totalCashReceived }} {{ quote.currencyCode }} +
+
+ {{ 'labels.inputs.Change to Give' | translate }}{{ changeAmount }} {{ quote.currencyCode }} +
+
+ {{ 'labels.inputs.Business Date' | translate }}{{ quote.businessDate }} +
+
+

{{ 'labels.inputs.Denominations' | translate }}

+
+ @for (denomination of denominations.controls; track denomination.controls.denominationId.value) { + @if ((denomination.controls.quantity.value ?? 0) > 0) { + + {{ denomination.controls.value.value }} × {{ denomination.controls.quantity.value }} = + {{ lineTotal(denomination) }} {{ quote.currencyCode }} + + } + } +
+ } +
+ + +
+ @if (!canCreate) { +
+ {{ 'servicePayment.text.noCreatePermission' | translate }} +
+ } +
+ + + {{ 'labels.heading.Receipt' | translate }} + @if (receipt) { +
+

{{ 'servicePayment.text.success' | translate }}

+
+
+ {{ 'labels.inputs.Transaction ID' | translate }}{{ receipt.transactionId }} +
+
+ {{ 'labels.inputs.Receipt Number' | translate }}{{ receipt.receiptNumber }} +
+
+ {{ 'labels.inputs.Status' | translate }}{{ receipt.status }} +
+
+ {{ 'labels.inputs.Business Date' | translate }}{{ receipt.businessDate }} +
+
+ {{ 'servicePayment.inputs.payer' | translate }}{{ receipt.payerName }} +
+
+ {{ 'servicePayment.inputs.service' | translate }}{{ receipt.serviceName }} +
+
+ {{ 'servicePayment.inputs.serviceReference' | translate }}{{ receipt.serviceReference }} +
+
+ {{ 'labels.inputs.Amount' | translate }}{{ receipt.baseAmount }} {{ receipt.currencyCode }} +
+
+ {{ 'servicePayment.inputs.commission' | translate }}{{ receipt.commission }} {{ receipt.currencyCode }} +
+
+ {{ 'servicePayment.inputs.commissionVat' | translate }}{{ receipt.commissionVat }} {{ receipt.currencyCode }} +
+
+ {{ 'labels.inputs.Total Paid' | translate }}{{ receipt.totalPaid }} {{ receipt.currencyCode }} +
+
+ {{ 'labels.inputs.Total Cash Received' | translate }}{{ receipt.amountReceived }} {{ receipt.currencyCode }} +
+
+ {{ 'labels.inputs.Change to Give' | translate }}{{ receipt.change }} {{ receipt.currencyCode }} +
+
+ {{ 'labels.inputs.Teller' | translate }}{{ receipt.tellerName }} +
+
+ {{ 'labels.inputs.Cashier' | translate }}{{ receipt.cashierName }} +
+
+ {{ 'labels.inputs.Operator' | translate }}{{ receipt.operatorName }} +
+
+ {{ 'labels.inputs.Office' | translate }}{{ receipt.officeName }} +
+
+

{{ 'labels.inputs.Denominations' | translate }}

+
+ @for (denomination of receipt.denominations; track denomination.denominationId) { + {{ denomination.value }} × {{ denomination.quantity }} + } +
+
+
+ + + +
+ } +
+
+ } + } +
+
+
diff --git a/src/app/organization/base-teller/service-payment/service-payment.component.scss b/src/app/organization/base-teller/service-payment/service-payment.component.scss new file mode 100644 index 0000000000..b687ece62e --- /dev/null +++ b/src/app/organization/base-teller/service-payment/service-payment.component.scss @@ -0,0 +1,135 @@ +/** + * 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/. + */ + +.service-payment { + .workflow-card { + max-width: 1120px; + margin: 0 auto; + } + + h2 { + margin: 0 0 24px; + } + + .form-grid, + .summary-grid, + .totals-grid { + display: flex; + flex-wrap: wrap; + gap: 16px; + } + + .form-grid mat-form-field, + .summary-grid div, + .totals-grid div { + flex: 1 1 224px; + } + + .summary-grid, + .totals-grid { + margin: 16px 0; + + div { + border: 1px solid rgb(0 0 0 / 12%); + border-radius: 4px; + min-height: 64px; + padding: 16px; + } + + span { + display: block; + font-size: 12px; + margin-bottom: 8px; + opacity: 0.72; + } + } + + .authoritative-summary { + border-top: 1px solid rgb(0 0 0 / 12%); + padding-top: 16px; + } + + .client-results, + .cash-panel { + border: 1px solid rgb(0 0 0 / 12%); + border-radius: 4px; + } + + .cash-panel { + padding: 16px; + + h3 { + margin-top: 0; + } + } + + .denomination-row { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 16px; + justify-content: space-between; + + div, + mat-form-field { + flex: 1 1 192px; + } + + span { + display: block; + font-size: 12px; + opacity: 0.72; + } + } + + .receipt-denominations { + display: flex; + flex-wrap: wrap; + gap: 16px; + } + + .loading-state { + display: flex; + justify-content: center; + padding: 24px; + } + + .message { + border-radius: 4px; + margin: 16px 0; + padding: 16px; + } + + .error, + .error-message { + color: #b00020; + } + + .error-message { + background: rgb(176 0 32 / 8%); + } + + .empty-state { + background: rgb(0 0 0 / 4%); + } +} + +@media print { + .service-payment { + h2, + mat-step-header, + .step-actions, + .error-message { + display: none !important; + } + + .workflow-card { + box-shadow: none; + } + } +} diff --git a/src/app/organization/base-teller/service-payment/service-payment.component.spec.ts b/src/app/organization/base-teller/service-payment/service-payment.component.spec.ts new file mode 100644 index 0000000000..ac7fd610c0 --- /dev/null +++ b/src/app/organization/base-teller/service-payment/service-payment.component.spec.ts @@ -0,0 +1,306 @@ +/** + * 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 { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; +import { faArrowLeft, faArrowRight } from '@fortawesome/free-solid-svg-icons'; +import { TranslateModule } from '@ngx-translate/core'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { Subject, of, throwError } from 'rxjs'; +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; + +import { AuthenticationService } from 'app/core/authentication/authentication.service'; +import { + BaseTellerService, + ServicePaymentQuote, + ServicePaymentReceipt, + ServicePaymentServiceConfiguration +} from '../base-teller.service'; +import { ServicePaymentComponent } from './service-payment.component'; + +describe('ServicePaymentComponent', () => { + let component: ServicePaymentComponent; + let fixture: ComponentFixture; + let service: jest.Mocked; + + const configuredService: ServicePaymentServiceConfiguration = { + id: 5, + code: 'POWER', + name: 'Power Utility', + active: true, + currencyCode: 'USD', + commissionType: 'PERCENTAGE', + commissionValue: '2', + commissionVatRate: '13', + denominations: [ + { identifier: 'USD-100', value: '100.00', type: 'NOTE' }, + { identifier: 'USD-10', value: '10.00', type: 'NOTE' } + ] + }; + const quote: ServicePaymentQuote = { + payerType: 'NON_CLIENT', + payerName: 'Ada Lovelace', + serviceId: 5, + serviceCode: 'POWER', + serviceName: 'Power Utility', + serviceReference: 'INV-42', + baseAmount: '100.00', + commission: '2.00', + commissionVat: '0.26', + totalToPay: '102.26', + currencyCode: 'USD', + businessDate: '2026-09-25' + }; + const receipt = { + ...quote, + transactionId: 77, + receiptNumber: 'SP-77', + status: 'COMPLETED', + officeId: 1, + officeName: 'Head Office', + tellerId: 2, + tellerName: 'Main Teller', + cashierId: 3, + cashierName: 'Cashier One', + operatorId: 4, + operatorName: 'operator', + totalPaid: '102.26', + amountReceived: '110.00', + change: '7.74', + cashierTransactionId: 88, + accountingTransactionId: '99', + createdOnUtc: '2026-09-25T10:00:00Z', + completedOnUtc: '2026-09-25T10:00:01Z', + denominations: [{ denominationId: 'USD-10', value: '10.00', quantity: 11 }] + } as ServicePaymentReceipt; + + beforeEach(async () => { + service = { + getServicePaymentServices: jest.fn(() => of([configuredService])), + getReturnedCheckPaymentTypes: jest.fn(() => + of([ + { id: 1, name: 'Cash', isCashPayment: true }, + { id: 2, name: 'Check', isCashPayment: false } + ]) + ), + searchServicePaymentClients: jest.fn(() => of([{ entityId: 42, entityName: 'Grace Hopper' }])), + getServicePaymentClient: jest.fn(() => + of({ + clientId: 42, + accountNo: 'CL-42', + displayName: 'Grace Hopper', + officeId: 1, + officeName: 'Head Office', + status: '300' + }) + ), + quoteServicePayment: jest.fn(() => of(quote)), + createServicePayment: jest.fn(() => of(receipt)), + getServicePaymentReceipt: jest.fn(() => of(receipt)) + } as unknown as jest.Mocked; + + await TestBed.configureTestingModule({ + imports: [ + ServicePaymentComponent, + TranslateModule.forRoot() + ], + providers: [ + { provide: BaseTellerService, useValue: service }, + { + provide: AuthenticationService, + useValue: { getCredentials: jest.fn(() => ({ permissions: ['ALL_FUNCTIONS'] })) } + }, + { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({}) } } }, + { provide: Router, useValue: { navigate: jest.fn(() => Promise.resolve(true)) } }, + provideAnimationsAsync() + ] + }).compileComponents(); + + TestBed.inject(FaIconLibrary).addIcons(faArrowLeft, faArrowRight); + fixture = TestBed.createComponent(ServicePaymentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + function prepareNonClientPayment(): void { + component.changePayerType('NON_CLIENT'); + component.detailsForm.patchValue({ + payerName: 'Ada Lovelace', + serviceId: 5, + serviceReference: 'INV-42', + baseAmount: '100.00' + }); + component.serviceChanged(); + component.requestQuote(); + component.denominations.at(0).controls.quantity.setValue(0); + component.denominations.at(1).controls.quantity.setValue(11); + } + + it('creates the component and loads only configured services and cash payment types', () => { + expect(component).toBeTruthy(); + expect(component.services).toEqual([configuredService]); + expect(component.cashPaymentTypes.map((paymentType) => paymentType.id)).toEqual([1]); + expect(component.cashForm.controls.paymentTypeId.value).toBe(1); + }); + + it('supports client search and resolves the authoritative client payer', () => { + component.detailsForm.controls.clientSearch.setValue('Grace'); + component.searchClients(); + expect(service.searchServicePaymentClients).toHaveBeenCalledWith('Grace'); + component.selectClient(component.clientResults[0]); + expect(service.getServicePaymentClient).toHaveBeenCalledWith(42); + expect(component.selectedClient?.accountNo).toBe('CL-42'); + }); + + it('keeps the resolved client when the selected payer toggle is clicked again', () => { + component.selectedClient = { + clientId: 42, + accountNo: 'CL-42', + displayName: 'Grace Hopper', + officeId: 1, + officeName: 'Head Office', + status: '300' + }; + fixture.detectChanges(); + + const selectedToggle = fixture.nativeElement.querySelector('mat-button-toggle button') as HTMLButtonElement; + selectedToggle.click(); + fixture.detectChanges(); + + expect(component.selectedClient?.clientId).toBe(42); + }); + + it('requests and renders the backend-authoritative quote for a non-client', () => { + prepareNonClientPayment(); + expect(service.quoteServicePayment).toHaveBeenCalledWith({ + payerType: 'NON_CLIENT', + clientId: undefined, + payerName: 'Ada Lovelace', + serviceId: 5, + serviceReference: 'INV-42', + baseAmount: '100.00', + currencyCode: 'USD' + }); + expect(component.quote?.commission).toBe('2.00'); + expect(component.quote?.commissionVat).toBe('0.26'); + expect(component.quote?.totalToPay).toBe('102.26'); + }); + + it('ignores a quote response after the request context changes', () => { + const pendingQuote = new Subject(); + service.quoteServicePayment.mockReturnValue(pendingQuote); + component.changePayerType('NON_CLIENT'); + component.detailsForm.patchValue({ + payerName: 'Ada Lovelace', + serviceId: 5, + serviceReference: 'INV-42', + baseAmount: '100.00' + }); + component.requestQuote(); + + component.detailsForm.controls.baseAmount.setValue('200.00'); + pendingQuote.next(quote); + pendingQuote.complete(); + + expect(component.quote).toBeUndefined(); + }); + + it('uses backend denominations and decimal-safe cash/change previews', () => { + prepareNonClientPayment(); + expect(component.denominations.length).toBe(2); + expect(component.totalCashReceived).toBe('110'); + expect(component.changeAmount).toBe('7.74'); + expect(component.cashIsSufficient).toBe(true); + component.denominations.at(1).controls.quantity.setValue(1.5); + expect(component.denominations.at(1).controls.quantity.hasError('pattern')).toBe(true); + }); + + it('blocks quote requests for invalid payer or amount details', () => { + component.changePayerType('NON_CLIENT'); + component.detailsForm.patchValue({ serviceId: 5, serviceReference: 'INV-42', baseAmount: '-1' }); + component.requestQuote(); + expect(service.quoteServicePayment).not.toHaveBeenCalled(); + }); + + it('submits the exact quote context and configured denomination identifiers once', () => { + prepareNonClientPayment(); + component.finalizePayment(); + expect(service.createServicePayment).toHaveBeenCalledTimes(1); + const request = service.createServicePayment.mock.calls[0][0]; + expect(request.businessDate).toBe('2026-09-25'); + expect(request.paymentTypeId).toBe(1); + expect(request.denominations).toEqual([{ denominationId: 'USD-10', value: '10.00', quantity: 11 }]); + expect(request.idempotencyKey).toBeTruthy(); + expect(component.receipt).toEqual(receipt); + }); + + it('prevents duplicate submit while the same idempotent request is pending', () => { + const pending = new Subject(); + service.createServicePayment.mockReturnValue(pending); + prepareNonClientPayment(); + component.finalizePayment(); + component.finalizePayment(); + expect(service.createServicePayment).toHaveBeenCalledTimes(1); + pending.next(receipt); + pending.complete(); + }); + + it('surfaces a safe backend payment error and permits retry with the same key', () => { + service.createServicePayment.mockReturnValueOnce( + throwError(() => ({ error: { defaultUserMessage: 'Accounting configuration is incomplete.' } })) + ); + prepareNonClientPayment(); + component.finalizePayment(); + const firstKey = service.createServicePayment.mock.calls[0][0].idempotencyKey; + expect(component.errorMessage).toBe('Accounting configuration is incomplete.'); + component.finalizePayment(); + expect(service.createServicePayment.mock.calls[1][0].idempotencyKey).toBe(firstKey); + }); + + it('uses a new idempotency key after quote context invalidation and recalculation', () => { + service.createServicePayment.mockReturnValueOnce( + throwError(() => ({ error: { defaultUserMessage: 'Payment failed.' } })) + ); + prepareNonClientPayment(); + component.finalizePayment(); + const firstKey = service.createServicePayment.mock.calls[0][0].idempotencyKey; + + service.quoteServicePayment.mockReturnValueOnce( + of({ + ...quote, + baseAmount: '101.00', + totalToPay: '103.26' + }) + ); + component.detailsForm.controls.baseAmount.setValue('101.00'); + component.requestQuote(); + component.denominations.at(1).controls.quantity.setValue(11); + component.finalizePayment(); + + expect(service.createServicePayment.mock.calls[1][0].idempotencyKey).not.toBe(firstKey); + }); + + it('retrieves the immutable receipt before reprinting', () => { + const print = jest.spyOn(window, 'print').mockImplementation(() => undefined); + jest.useFakeTimers(); + component.receipt = receipt; + component.reprintReceipt(); + expect(service.getServicePaymentReceipt).toHaveBeenCalledWith(77); + jest.runOnlyPendingTimers(); + expect(print).toHaveBeenCalled(); + print.mockRestore(); + jest.useRealTimers(); + }); + + it('shows an empty configuration state without frontend defaults', () => { + component.services = []; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('servicePayment.text.noServices'); + }); +}); diff --git a/src/app/organization/base-teller/service-payment/service-payment.component.ts b/src/app/organization/base-teller/service-payment/service-payment.component.ts new file mode 100644 index 0000000000..fd3e30afc1 --- /dev/null +++ b/src/app/organization/base-teller/service-payment/service-payment.component.ts @@ -0,0 +1,562 @@ +/** + * 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 { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, ViewChild, inject } from '@angular/core'; +import { AbstractControl, FormArray, FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatStepper, MatStepperModule } from '@angular/material/stepper'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatListModule } from '@angular/material/list'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { finalize, forkJoin } from 'rxjs'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { AuthenticationService } from 'app/core/authentication/authentication.service'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { environment } from 'environments/environment'; +import { + BaseTellerOption, + BaseTellerService, + DepositSearchResult, + FineractId, + ServicePaymentClient, + ServicePaymentDenominationConfiguration, + ServicePaymentPayerType, + ServicePaymentQuote, + ServicePaymentQuoteRequest, + ServicePaymentReceipt, + ServicePaymentRequest, + ServicePaymentServiceConfiguration +} from '../base-teller.service'; + +type DenominationGroup = FormGroup<{ + denominationId: FormControl; + value: FormControl; + quantity: FormControl; +}>; + +@Component({ + selector: 'mifosx-service-payment', + templateUrl: './service-payment.component.html', + styleUrls: ['./service-payment.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatButtonToggleModule, + MatListModule, + MatProgressSpinnerModule, + MatStepperModule, + FaIconComponent + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ServicePaymentComponent implements OnInit { + private formBuilder = inject(FormBuilder); + private baseTellerService = inject(BaseTellerService); + private authenticationService = inject(AuthenticationService); + private cdr = inject(ChangeDetectorRef); + private route = inject(ActivatedRoute); + private router = inject(Router); + + @ViewChild(MatStepper) stepper?: MatStepper; + + detailsForm = this.formBuilder.group({ + payerType: new FormControl('CLIENT', { nonNullable: true }), + clientSearch: new FormControl('', { nonNullable: true }), + payerName: new FormControl('', { nonNullable: true, validators: [Validators.maxLength(250)] }), + serviceId: new FormControl(null, Validators.required), + serviceReference: new FormControl('', { + nonNullable: true, + validators: [ + Validators.required, + Validators.maxLength(200) + ] + }), + baseAmount: new FormControl('', { + nonNullable: true, + validators: [ + Validators.required, + Validators.min(0.000001), + Validators.pattern(/^\d+(\.\d{1,6})?$/) + ] + }) + }); + cashForm = this.formBuilder.group({ + paymentTypeId: new FormControl(null, Validators.required), + denominations: new FormArray([]) + }); + + services: ServicePaymentServiceConfiguration[] = []; + cashPaymentTypes: BaseTellerOption[] = []; + clientResults: DepositSearchResult[] = []; + selectedClient?: ServicePaymentClient; + quote?: ServicePaymentQuote; + receipt?: ServicePaymentReceipt; + selectedStepIndex = 0; + errorMessage = ''; + isLoading = false; + isSearchingClients = false; + isResolvingClient = false; + isQuoting = false; + isSubmitting = false; + isReprinting = false; + hasSearchedClients = false; + canRead = false; + canCreate = false; + private idempotencyKey = this.createIdempotencyKey(); + + get denominations(): FormArray { + return this.cashForm.controls.denominations; + } + + get selectedService(): ServicePaymentServiceConfiguration | undefined { + return this.services.find((service) => String(service.id) === String(this.detailsForm.controls.serviceId.value)); + } + + get totalCashUnits(): bigint { + return this.denominations.controls.reduce( + (sum, denomination) => + sum + this.toUnits(denomination.controls.value.value) * this.toQuantity(denomination.controls.quantity.value), + 0n + ); + } + + get totalCashReceived(): string { + return this.fromUnits(this.totalCashUnits); + } + + get changeUnits(): bigint { + return this.totalCashUnits - this.toUnits(this.quote?.totalToPay); + } + + get changeAmount(): string { + return this.fromUnits(this.changeUnits); + } + + get cashIsSufficient(): boolean { + return !!this.quote && this.totalCashUnits >= this.toUnits(this.quote.totalToPay); + } + + get payerIsValid(): boolean { + return this.detailsForm.controls.payerType.value === 'CLIENT' + ? !!this.selectedClient + : !!this.detailsForm.controls.payerName.value.trim() && this.detailsForm.controls.payerName.valid; + } + + ngOnInit(): void { + this.canRead = this.hasPermission('READ_BASE_TELLER_SERVICE_PAYMENT'); + this.canCreate = this.hasPermission('CREATE_BASE_TELLER_SERVICE_PAYMENT'); + if (!this.canRead) { + this.errorMessage = 'servicePayment.text.noReadPermission'; + return; + } + this.detailsForm.valueChanges.subscribe(() => { + this.invalidateQuote(); + this.cdr.markForCheck(); + }); + this.loadConfiguration(); + } + + loadConfiguration(): void { + this.isLoading = true; + this.errorMessage = ''; + this.loadReceiptFromUrl(); + forkJoin({ + services: this.baseTellerService.getServicePaymentServices(), + paymentTypes: this.baseTellerService.getReturnedCheckPaymentTypes() + }) + .pipe( + finalize(() => { + this.isLoading = false; + this.cdr.markForCheck(); + }) + ) + .subscribe({ + next: ({ services, paymentTypes }) => { + this.services = services; + this.cashPaymentTypes = this.extractOptions(paymentTypes).filter((option) => option.isCashPayment === true); + if (this.cashPaymentTypes.length === 1) { + this.cashForm.controls.paymentTypeId.setValue(this.cashPaymentTypes[0].id); + } + }, + error: (error: unknown) => { + this.errorMessage = this.extractErrorMessage(error) || 'servicePayment.text.configurationError'; + } + }); + } + + changePayerType(payerType: ServicePaymentPayerType): void { + if (this.detailsForm.controls.payerType.value !== payerType) { + this.detailsForm.controls.payerType.setValue(payerType); + } + this.selectedClient = undefined; + this.clientResults = []; + this.detailsForm.controls.clientSearch.setValue(''); + this.detailsForm.controls.payerName.setValue(''); + if (payerType === 'NON_CLIENT') { + this.detailsForm.controls.payerName.addValidators(Validators.required); + } else { + this.detailsForm.controls.payerName.removeValidators(Validators.required); + } + this.detailsForm.controls.payerName.updateValueAndValidity(); + } + + searchClients(): void { + const term = this.detailsForm.controls.clientSearch.value.trim(); + if (!term || this.isSearchingClients) { + return; + } + this.errorMessage = ''; + this.hasSearchedClients = false; + this.clientResults = []; + if (this.selectedClient) { + this.invalidateQuote(); + } + this.selectedClient = undefined; + this.isSearchingClients = true; + this.baseTellerService + .searchServicePaymentClients(term) + .pipe( + finalize(() => { + this.isSearchingClients = false; + this.hasSearchedClients = true; + this.cdr.markForCheck(); + }) + ) + .subscribe({ + next: (response) => { + this.clientResults = Array.isArray(response) ? response : (response.pageItems ?? []); + }, + error: (error: unknown) => { + this.errorMessage = this.extractErrorMessage(error) || 'servicePayment.text.clientSearchError'; + } + }); + } + + selectClient(result: DepositSearchResult): void { + const clientId = result.clientId ?? result.entityId ?? result.id; + if (clientId === undefined || clientId === null || this.isResolvingClient) { + return; + } + this.errorMessage = ''; + this.isResolvingClient = true; + this.baseTellerService + .getServicePaymentClient(clientId) + .pipe( + finalize(() => { + this.isResolvingClient = false; + this.cdr.markForCheck(); + }) + ) + .subscribe({ + next: (client) => { + this.selectedClient = client; + this.clientResults = []; + this.detailsForm.controls.clientSearch.setValue(client.displayName, { emitEvent: false }); + this.invalidateQuote(); + }, + error: (error: unknown) => { + this.errorMessage = this.extractErrorMessage(error) || 'servicePayment.text.clientError'; + } + }); + } + + serviceChanged(): void { + this.invalidateQuote(); + this.setDenominations(this.selectedService?.denominations ?? []); + } + + requestQuote(): void { + this.errorMessage = ''; + this.detailsForm.markAllAsTouched(); + if (this.detailsForm.invalid || !this.payerIsValid || !this.selectedService || this.isQuoting) { + return; + } + const request = this.buildQuoteRequest(); + this.invalidateQuote(); + this.isQuoting = true; + this.baseTellerService + .quoteServicePayment(request) + .pipe( + finalize(() => { + this.isQuoting = false; + this.cdr.markForCheck(); + }) + ) + .subscribe({ + next: (quote) => { + if (!this.quoteRequestMatchesCurrentContext(request)) { + return; + } + this.quote = quote; + this.setDenominations(this.selectedService?.denominations ?? []); + }, + error: (error: unknown) => { + if (!this.quoteRequestMatchesCurrentContext(request)) { + return; + } + this.invalidateQuote(); + this.errorMessage = this.extractErrorMessage(error) || 'servicePayment.text.quoteError'; + } + }); + } + + lineTotal(control: DenominationGroup): string { + return this.fromUnits( + this.toUnits(control.controls.value.value) * this.toQuantity(control.controls.quantity.value) + ); + } + + finalizePayment(): void { + this.errorMessage = ''; + if (this.isSubmitting || !this.canCreate || !this.quote || this.cashForm.invalid || !this.cashIsSufficient) { + this.cashForm.markAllAsTouched(); + return; + } + this.isSubmitting = true; + this.baseTellerService + .createServicePayment(this.buildPaymentRequest()) + .pipe( + finalize(() => { + this.isSubmitting = false; + this.cdr.markForCheck(); + }) + ) + .subscribe({ + next: (receipt) => { + this.receipt = receipt; + this.selectedStepIndex = 3; + this.stepper?.next(); + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { transactionId: receipt.transactionId }, + replaceUrl: true + }); + }, + error: (error: unknown) => { + this.errorMessage = this.extractErrorMessage(error) || 'servicePayment.text.paymentError'; + } + }); + } + + reprintReceipt(): void { + if (!this.receipt || this.isReprinting) { + return; + } + this.isReprinting = true; + this.errorMessage = ''; + this.baseTellerService + .getServicePaymentReceipt(this.receipt.transactionId) + .pipe( + finalize(() => { + this.isReprinting = false; + this.cdr.markForCheck(); + }) + ) + .subscribe({ + next: (receipt) => { + this.receipt = receipt; + setTimeout(() => window.print()); + }, + error: (error: unknown) => { + this.errorMessage = this.extractErrorMessage(error) || 'servicePayment.text.receiptError'; + } + }); + } + + printReceipt(): void { + window.print(); + } + + newPayment(): void { + this.stepper?.reset(); + this.selectedStepIndex = 0; + this.detailsForm.reset({ + payerType: 'CLIENT', + clientSearch: '', + payerName: '', + serviceReference: '', + baseAmount: '' + }); + this.detailsForm.controls.payerName.removeValidators(Validators.required); + this.detailsForm.controls.payerName.updateValueAndValidity(); + this.cashForm.controls.paymentTypeId.setValue( + this.cashPaymentTypes.length === 1 ? this.cashPaymentTypes[0].id : null + ); + this.denominations.clear(); + this.selectedClient = undefined; + this.clientResults = []; + this.quote = undefined; + this.receipt = undefined; + this.errorMessage = ''; + this.idempotencyKey = this.createIdempotencyKey(); + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { transactionId: null }, + queryParamsHandling: 'merge', + replaceUrl: true + }); + } + + clientName(result: DepositSearchResult): string { + return result.displayName || result.entityName || result.name || result.accountNo || String(result.id ?? ''); + } + + private buildQuoteRequest(): ServicePaymentQuoteRequest { + const values = this.detailsForm.getRawValue(); + const clientPayer = values.payerType === 'CLIENT'; + return { + payerType: values.payerType, + clientId: clientPayer ? this.selectedClient?.clientId : undefined, + payerName: clientPayer ? undefined : values.payerName.trim(), + serviceId: values.serviceId as FineractId, + serviceReference: values.serviceReference.trim(), + baseAmount: values.baseAmount, + currencyCode: this.selectedService?.currencyCode ?? '' + }; + } + + private buildPaymentRequest(): ServicePaymentRequest { + return { + ...this.buildQuoteRequest(), + idempotencyKey: this.idempotencyKey, + businessDate: this.quote?.businessDate ?? '', + paymentTypeId: this.cashForm.controls.paymentTypeId.value as FineractId, + denominations: this.denominations.controls + .filter((control) => this.toQuantity(control.controls.quantity.value) > 0n) + .map((control) => ({ + denominationId: control.controls.denominationId.value, + value: control.controls.value.value, + quantity: Number(this.toQuantity(control.controls.quantity.value)) + })) + }; + } + + private invalidateQuote(): void { + this.quote = undefined; + this.idempotencyKey = this.createIdempotencyKey(); + } + + private quoteRequestMatchesCurrentContext(request: ServicePaymentQuoteRequest): boolean { + const current = this.buildQuoteRequest(); + return ( + current.payerType === request.payerType && + current.clientId === request.clientId && + current.payerName === request.payerName && + String(current.serviceId) === String(request.serviceId) && + current.serviceReference === request.serviceReference && + current.baseAmount === request.baseAmount && + current.currencyCode === request.currencyCode + ); + } + + private setDenominations(configurations: ServicePaymentDenominationConfiguration[]): void { + this.denominations.clear(); + configurations.forEach((denomination) => { + this.denominations.push( + this.formBuilder.group({ + denominationId: new FormControl(denomination.identifier, { nonNullable: true }), + value: new FormControl(String(denomination.value), { nonNullable: true }), + quantity: new FormControl(0, [ + Validators.required, + Validators.min(0), + Validators.pattern(/^\d+$/) + ]) + }) + ); + }); + } + + private extractOptions(value: BaseTellerOption[] | { pageItems?: BaseTellerOption[] }): BaseTellerOption[] { + return Array.isArray(value) ? value : (value.pageItems ?? []); + } + + private loadReceiptFromUrl(): void { + const transactionId = this.route.snapshot.queryParamMap.get('transactionId'); + if (!transactionId || !/^\d+$/.test(transactionId)) { + return; + } + this.isReprinting = true; + this.baseTellerService + .getServicePaymentReceipt(transactionId) + .pipe( + finalize(() => { + this.isReprinting = false; + this.cdr.markForCheck(); + }) + ) + .subscribe({ + next: (receipt) => { + this.receipt = receipt; + this.selectedStepIndex = 3; + this.cdr.markForCheck(); + }, + error: (error: unknown) => { + this.errorMessage = this.extractErrorMessage(error) || 'servicePayment.text.receiptError'; + } + }); + } + + private hasPermission(permission: string): boolean { + if (!environment.productionModeEnableRBAC) { + return true; + } + const permissions = this.authenticationService.getCredentials()?.permissions ?? []; + return ( + permissions.includes('ALL_FUNCTIONS') || + (permission.startsWith('READ_') && permissions.includes('ALL_FUNCTIONS_READ')) || + permissions.includes(permission) + ); + } + + private extractErrorMessage(error: unknown): string { + const response = error as { + error?: { + defaultUserMessage?: string; + developerMessage?: string; + message?: string; + errors?: Array<{ defaultUserMessage?: string; developerMessage?: string }>; + }; + }; + return ( + response?.error?.errors?.[0]?.defaultUserMessage || + response?.error?.errors?.[0]?.developerMessage || + response?.error?.defaultUserMessage || + response?.error?.developerMessage || + response?.error?.message || + '' + ); + } + + private toUnits(value: unknown): bigint { + const normalized = String(value ?? '0').trim(); + const match = normalized.match(/^(-?)(\d+)(?:\.(\d{1,6}))?$/); + if (!match) { + return 0n; + } + const units = BigInt(match[2]) * 1_000_000n + BigInt((match[3] ?? '').padEnd(6, '0')); + return match[1] ? -units : units; + } + + private fromUnits(units: bigint): string { + const negative = units < 0n; + const absolute = negative ? -units : units; + const fraction = String(absolute % 1_000_000n) + .padStart(6, '0') + .replace(/0+$/, ''); + return `${negative ? '-' : ''}${absolute / 1_000_000n}${fraction ? `.${fraction}` : ''}`; + } + + private toQuantity(value: unknown): bigint { + const normalized = String(value ?? '0').trim(); + return /^\d+$/.test(normalized) ? BigInt(normalized) : 0n; + } + + private createIdempotencyKey(): string { + return globalThis.crypto?.randomUUID?.() ?? `service-payment-${Date.now()}-${Math.random().toString(36).slice(2)}`; + } +} diff --git a/src/app/organization/base-teller/service-payment/service-payment.guard.spec.ts b/src/app/organization/base-teller/service-payment/service-payment.guard.spec.ts new file mode 100644 index 0000000000..f12a2e8111 --- /dev/null +++ b/src/app/organization/base-teller/service-payment/service-payment.guard.spec.ts @@ -0,0 +1,50 @@ +/** + * 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 { TestBed } from '@angular/core/testing'; +import { Router, UrlTree } from '@angular/router'; +import { afterEach, describe, expect, it, jest } from '@jest/globals'; + +import { AuthenticationService } from 'app/core/authentication/authentication.service'; +import { environment } from 'environments/environment'; +import { servicePaymentGuard } from './service-payment.guard'; + +describe('servicePaymentGuard', () => { + const originalProductionMode = environment.productionMode; + const originalRbac = environment.productionModeEnableRBAC; + + afterEach(() => { + environment.productionMode = originalProductionMode; + environment.productionModeEnableRBAC = originalRbac; + }); + + function run(productionMode: boolean, permissions: string[]): boolean | UrlTree { + const deniedTree = {} as UrlTree; + TestBed.configureTestingModule({ + providers: [ + { provide: AuthenticationService, useValue: { getCredentials: () => ({ permissions }) } }, + { provide: Router, useValue: { createUrlTree: jest.fn(() => deniedTree) } } + ] + }); + environment.productionMode = productionMode; + environment.productionModeEnableRBAC = true; + return TestBed.runInInjectionContext(() => servicePaymentGuard({} as never, {} as never)) as boolean | UrlTree; + } + + it('redirects when production mode is disabled', () => { + expect(run(false, ['READ_BASE_TELLER_SERVICE_PAYMENT'])).not.toBe(true); + }); + + it('allows the exact backend read permission in production mode', () => { + expect(run(true, ['READ_BASE_TELLER_SERVICE_PAYMENT'])).toBe(true); + }); + + it('redirects users without the backend read permission', () => { + expect(run(true, ['READ_TELLER'])).not.toBe(true); + }); +}); diff --git a/src/app/organization/base-teller/service-payment/service-payment.guard.ts b/src/app/organization/base-teller/service-payment/service-payment.guard.ts new file mode 100644 index 0000000000..f0eeda66bb --- /dev/null +++ b/src/app/organization/base-teller/service-payment/service-payment.guard.ts @@ -0,0 +1,28 @@ +/** + * 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 { CanActivateFn, Router } from '@angular/router'; + +import { AuthenticationService } from 'app/core/authentication/authentication.service'; +import { environment } from 'environments/environment'; + +export const servicePaymentGuard: CanActivateFn = () => { + if (!environment.productionMode) { + return inject(Router).createUrlTree(['/organization']); + } + if (!environment.productionModeEnableRBAC) { + return true; + } + const permissions = inject(AuthenticationService).getCredentials()?.permissions ?? []; + const canRead = + permissions.includes('ALL_FUNCTIONS') || + permissions.includes('ALL_FUNCTIONS_READ') || + permissions.includes('READ_BASE_TELLER_SERVICE_PAYMENT'); + return canRead || inject(Router).createUrlTree(['/organization']); +}; diff --git a/src/app/organization/organization-routing.module.ts b/src/app/organization/organization-routing.module.ts index bf9f1105b5..0c16c862be 100644 --- a/src/app/organization/organization-routing.module.ts +++ b/src/app/organization/organization-routing.module.ts @@ -30,6 +30,8 @@ import { SavingsAccountOpeningComponent } from './base-teller/savings-account-op import { SavingsAccountDepositComponent } from './base-teller/savings-account-deposit/savings-account-deposit.component'; import { ReturnedCheckPaymentComponent } from './base-teller/returned-check-payment/returned-check-payment.component'; import { returnedCheckPaymentGuard } from './base-teller/returned-check-payment/returned-check-payment.guard'; +import { ServicePaymentComponent } from './base-teller/service-payment/service-payment.component'; +import { servicePaymentGuard } from './base-teller/service-payment/service-payment.guard'; import { PaymentTypesComponent } from './payment-types/payment-types.component'; import { EditPaymentTypeComponent } from './payment-types/edit-payment-type/edit-payment-type.component'; import { PasswordPreferencesComponent } from './password-preferences/password-preferences.component'; @@ -466,6 +468,15 @@ const routes: Routes = [ breadcrumb: 'labels.heading.Returned Check Payment' } }, + { + path: 'base-teller/service-payments', + component: ServicePaymentComponent, + canActivate: [servicePaymentGuard], + data: { + title: 'Bill and Service Payment', + breadcrumb: 'Bill and Service Payment' + } + }, { path: 'tellers', data: { title: 'Tellers', breadcrumb: 'Tellers' }, diff --git a/src/app/organization/organization.component.html b/src/app/organization/organization.component.html index 338aa6ad38..b40fc54ecf 100644 --- a/src/app/organization/organization.component.html +++ b/src/app/organization/organization.component.html @@ -569,6 +569,40 @@ + @if (productionMode) { + + } +