Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/app/home/activities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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' },
Expand Down
59 changes: 59 additions & 0 deletions src/app/organization/base-teller/base-teller.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }]);
});
});
135 changes: 135 additions & 0 deletions src/app/organization/base-teller/base-teller.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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.
Expand Down Expand Up @@ -380,4 +482,37 @@ export class BaseTellerService {
getReturnedCheckPaymentTypes(): Observable<BaseTellerOption[]> {
return this.http.get<BaseTellerOption[]>('/paymenttypes');
}

/** Lists the backend-configured active service-payment catalog. */
getServicePaymentServices(): Observable<ServicePaymentServiceConfiguration[]> {
return this.http.get<ServicePaymentServiceConfiguration[]>(`${this.servicePaymentsPath}/services`);
}

/** Searches the platform client index before resolving the payer through WEB-1236. */
searchServicePaymentClients(
searchTerm: string
): Observable<DepositSearchResult[] | { pageItems?: DepositSearchResult[] }> {
const params = new HttpParams().set('exactMatch', 'false').set('query', searchTerm).set('resource', 'clients');
return this.http.get<DepositSearchResult[] | { pageItems?: DepositSearchResult[] }>('/search', { params });
}

/** Resolves an authoritative, office-scoped client payer. */
getServicePaymentClient(clientId: FineractId): Observable<ServicePaymentClient> {
return this.http.get<ServicePaymentClient>(`${this.servicePaymentsPath}/clients/${clientId}`);
}

/** Gets the backend-authoritative commission, VAT, total, and business date. */
quoteServicePayment(payload: ServicePaymentQuoteRequest): Observable<ServicePaymentQuote> {
return this.http.post<ServicePaymentQuote>(`${this.servicePaymentsPath}/quote`, payload);
}

/** Posts a cash service payment with the backend idempotency contract. */
createServicePayment(payload: ServicePaymentRequest): Observable<ServicePaymentReceipt> {
return this.http.post<ServicePaymentReceipt>(this.servicePaymentsPath, payload);
}

/** Retrieves the immutable backend receipt for display or reprint. */
getServicePaymentReceipt(transactionId: FineractId): Observable<ServicePaymentReceipt> {
return this.http.get<ServicePaymentReceipt>(`${this.servicePaymentsPath}/${transactionId}/receipt`);
}
}
Loading
Loading