Merge pull request #1042 from Tria-plc/freight/chore/payment-test

CAC Integration to the freight api, tests fix
This commit is contained in:
Nathnael Wondisha
2026-07-31 14:10:04 +03:00
committed by GitHub
50 changed files with 1706 additions and 665 deletions

View File

@@ -470,3 +470,78 @@ describe("BillingService.issuePayable", () => {
expect(manager.update).not.toHaveBeenCalled();
});
});
describe("BillingService — CAC Bank (OTP debit)", () => {
const openInvoice = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
type: "PREPAID",
invoiceNumber: "INV-20260101-00001",
currency: "USD",
balanceAmount: 500,
totalAmount: 500,
paymentId: "intent-1",
dueAt: null,
};
const build = (payment: Record<string, unknown>) => {
const repo = {
findOne: jest.fn().mockResolvedValue(openInvoice),
update: jest.fn().mockResolvedValue(undefined),
};
const service = new BillingService(
{ getRepository: () => repo } as never,
{} as never,
{} as never,
makeEvents() as never,
payment as never,
{} as never,
{} as never,
);
return { service, repo };
};
it("rejects a CAC Bank charge with no payer mobile before calling the gateway", async () => {
const initiate = jest.fn();
const { service } = build({ initiate });
await expect(
service.payInvoice("inv-1", { method: "CAC_BANK" }),
).rejects.toThrow(/payerAccount/);
expect(initiate).not.toHaveBeenCalled();
});
it("does not settle an OTP intent at initiate — the payer still has to confirm", async () => {
const handlePaymentEvent = jest.fn();
const { service } = build({
initiate: jest.fn().mockResolvedValue({
intentId: "intent-1",
immediateSuccess: false,
response: {
intentId: "intent-1",
status: "REQUIRES_ACTION",
clientAction: { type: "COLLECT_OTP", providerOrderId: "cac-1" },
},
}),
handlePaymentEvent,
});
await service.payInvoice("inv-1", {
method: "CAC_BANK",
payerAccount: "77123456",
});
expect(handlePaymentEvent).not.toHaveBeenCalled();
});
it("confirms the OTP against the intent stamped on the invoice", async () => {
const confirmOtp = jest.fn().mockResolvedValue({ status: "SUCCEEDED" });
const { service } = build({ confirmOtp });
await service.confirmInvoiceOtp("inv-1", "123456");
expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456");
});
});

View File

@@ -12,7 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm";
import { CompaniesService } from "../companies/companies.service";
import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto } from "../payment/payments.dto";
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
import {
InvoiceDocumentModel,
InvoiceDocumentService,
@@ -352,6 +352,34 @@ export class BillingService {
return this.payInvoice(id, opts);
}
/**
* Submit the CAC Bank OTP for one of the customer's own invoices
* (ownership-checked). Settlement of the invoice happens inside the payment
* service when the OTP succeeds.
*/
async confirmInvoiceOtpForUser(
id: string,
userId: string,
otp: string,
): Promise<IntentStatusDto> {
await this.findByIdForUser(id, userId);
return this.confirmInvoiceOtp(id, otp);
}
/** OTP confirmation by invoice id — the intent is the one stamped at initiate. */
async confirmInvoiceOtp(
invoiceId: string,
otp: string,
): Promise<IntentStatusDto> {
const invoice = await this.dataSource
.getRepository(Invoice)
.findOne({ where: { id: invoiceId } });
if (!invoice?.paymentId) {
throw new NotFoundException("No payment to confirm for this invoice");
}
return this.payment.confirmOtp(invoice.paymentId, otp);
}
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
async documentForUser(
id: string,
@@ -1035,6 +1063,17 @@ export class BillingService {
throw new BadRequestException("Invoice has no outstanding balance.");
}
// CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is
// required up front (the payment service rejects it otherwise, as a 502 here).
if (
(opts.method ?? "").toUpperCase() === "CAC_BANK" &&
!opts.payerAccount?.trim()
) {
throw new BadRequestException(
"payerAccount (mobile number) is required for CAC Bank",
);
}
const result = await this.payment.initiate({
referenceId: invoice.sourceId,
source: invoice.source,
@@ -1062,7 +1101,12 @@ export class BillingService {
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
// billing must not simulate it. Kept commented for local demos only.
if (!result.immediateSuccess) {
// An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the
// code — so the demo shortcut must never fire for it.
if (
!result.immediateSuccess &&
result.response.clientAction?.type !== "COLLECT_OTP"
) {
await this.payment.handlePaymentEvent({
eventType: "payment.succeeded",
eventId: `demo-${result.intentId}`,

View File

@@ -1,5 +1,13 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsNotEmpty, IsOptional, IsString } from "class-validator";
/** OTP submitted for a COLLECT_OTP provider (CAC Bank). */
export class ConfirmOtpDto {
@ApiProperty({ description: "One-time password SMSed by the bank." })
@IsString()
@IsNotEmpty()
otp!: string;
}
/** Gateway options for paying an invoice from the customer portal. */
export class PayInvoiceDto {

View File

@@ -18,7 +18,7 @@ import {
} from "../../common/resolve-auth-user-id";
import { sendPdf } from "./billing.controller";
import { BillingService } from "./billing.service";
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
import { ConfirmOtpDto, PayInvoiceDto } from "./dto/pay-invoice.dto";
/**
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
@@ -96,4 +96,20 @@ export class PortalBillingController {
failureUrl: dto.failureUrl,
});
}
@Post("my-invoices/:id/confirm")
@ApiOperation({
summary: "Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices",
})
confirmOtp(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto: ConfirmOtpDto,
) {
return this.billingService.confirmInvoiceOtpForUser(
id,
resolveAuthUserId(user),
dto.otp,
);
}
}

View File

@@ -10,12 +10,17 @@ import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.
* come from the verified payload, not typed. Fayda's userinfo carries no
* national ID number, so none is collected or derived here.
*
* - Ethiopian company: the owner (and its PoA, once named) is verified through
* Fayda, and their details can't be edited afterwards.
* Only the OWNER's credential varies by nationality:
* - Ethiopian company: the owner is verified through Fayda.
* - Foreign company: Fayda is an Ethiopian national ID, so the owner instead
* supplies a typed passport number — required on its own, whether or not the
* owner also completes a (purely optional) Fayda verification.
*
* The PoA does not vary. A representative acts for the company inside Ethiopia
* whoever owns it, so a PoA is always an Ethiopian holding a Fayda ID: once one
* is named, both nationalities must verify them, and their details come from
* the verified payload rather than the form.
*
* The owner is NOT the general manager — GM is a separate, plain typed role
* the portal offers a "same as owner" copy for, but it is never itself
* Fayda-verified or gated on.
@@ -107,6 +112,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
},
changeRequestRepo: {
findPendingByCompanyId: jest.fn(async () => null),
findLatestOpenByCompanyId: jest.fn(async () => null),
findByCompanyId: jest.fn(async () => []),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "cr-1",
@@ -229,9 +235,27 @@ describe("Fayda identity verification binds a person to the company", () => {
).rejects.toBeInstanceOf(BadRequestException);
});
it("stages the change for review on an approved company", async () => {
// Swapping the person who can act for a live company is exactly what the
// backoffice review exists for, so it must not rewrite the row directly.
it("stages an owner re-verification for review on an approved company", async () => {
// The owner is the live company's identity proof, so re-verifying one is
// exactly what the backoffice review exists for: it must not rewrite the
// row directly.
const { service, ctx, deps } = makeService({
status: CompanyStatus.Active,
});
await service.completeIdentityVerification("user-1", {
subject: "owner",
code: "c",
state: "s",
});
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
expect(ctx.attributes.ownerFaydaSub).toBeUndefined();
});
it("applies a PoA verification live on an approved company", async () => {
// The PoA is personnel the company names for itself — the delegation paper
// is what a reviewer actually judges — so it does not go to review.
const { service, ctx, deps } = makeService({
status: CompanyStatus.Active,
});
@@ -242,8 +266,8 @@ describe("Fayda identity verification binds a person to the company", () => {
state: "s",
});
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
expect(ctx.attributes.poaFaydaSub).toBeUndefined();
expect(deps.changeRequestRepo.create).not.toHaveBeenCalled();
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
});
it("refuses to rename a verified person by hand", async () => {
@@ -367,7 +391,29 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
).rejects.toBeInstanceOf(BadRequestException);
});
it("grants the forwarder role to a foreign company with an owner passport and no Fayda at all", async () => {
it("grants the forwarder role to a foreign company whose owner has a passport and whose PoA is Fayda-verified", async () => {
const { service } = makeService({
profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign,
attributes: {
ownerPassportNumber: "P1234567",
...POA_VERIFIED,
},
files: [paper()],
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
});
it("still requires a Fayda-verified PoA from a foreign company", async () => {
// The owner's credential is nationality-specific; the representative's is
// not. A PoA acts for the company inside Ethiopia whoever owns it, so a
// typed foreign name is not a representative the platform can accept.
const { service } = makeService({
profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign,
@@ -385,7 +431,7 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
).rejects.toBeInstanceOf(BadRequestException);
});
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {

View File

@@ -81,6 +81,9 @@ function makeService(overrides: Partial<Ctx> = {}) {
findPendingByCompanyId: jest.fn(async () =>
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
),
findLatestOpenByCompanyId: jest.fn(async () =>
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
),
findByCompanyId: jest.fn(async () => []),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "cr-1",

View File

@@ -81,6 +81,28 @@ const POA_ATTRIBUTES = [
"poaLocation",
"poaAddress",
] as const;
/**
* Personnel an approved company maintains itself: its contact person, its
* general manager and its Power of Attorney. These name who to talk to, not
* what the company is allowed to do, so freezing the settings page until a
* reviewer gets to a new phone number costs more than it protects. They write
* straight to the live row even for an active company.
*
* The PoA's *delegation letter* is deliberately not here — the paper is the
* thing that actually evidences the delegation, so it still goes through
* review (see `uploadPoaDelegationLetter`), as does the owner's own identity.
*/
const SELF_SERVICE_ATTRIBUTES: readonly string[] = [
"contactPersonName",
"contactPersonPosition",
"contactPersonEmail",
"contactPersonPhone",
"contactVerifiedPhone",
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
...POA_ATTRIBUTES,
];
/** Mandatory once the company operates as a freight forwarder. */
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
{ key: "poaName", label: "PoA name" },
@@ -844,10 +866,11 @@ export class CompaniesService {
*
* - Company not yet approved (onboarding) → write straight to the Company row,
* as before. The company/role pending→approve gate already covers first-run.
* - Company already `active` → do NOT touch the live Company. Stage the edit in
* a pending change request (merging into any open one) so a backoffice
* reviewer can approve (apply) or reject (with a note). This locks the
* customer until the review resolves.
* - Company already `active` → personnel details (`SELF_SERVICE_ATTRIBUTES`)
* still write straight through; everything else does NOT touch the live
* Company but is staged in a pending change request (merging into any open
* one) so a backoffice reviewer can approve (apply) or reject (with a
* note). Only the staged half locks the customer until the review resolves.
*/
async updateProfile(
userId: string,
@@ -882,9 +905,37 @@ export class CompaniesService {
return new ProfileResponseDto(profile, updated);
}
// Approved company: stage the change for review, leaving the live row intact.
// Approved company: personnel details apply immediately, the rest is staged
// for review with the live row left intact.
await this.assertTinAvailable(company, dto.tin);
const fields = this.pickDefined(dto);
const selfService: Record<string, any> = {};
const staged: Record<string, any> = {};
for (const [key, value] of Object.entries(fields)) {
if (SELF_SERVICE_ATTRIBUTES.includes(key)) selfService[key] = value;
else staged[key] = value;
}
let live = company;
if (Object.keys(selfService).length > 0) {
live =
(await this.companiesRepo.update(
company.id,
this.mapProfileDtoToCompanyUpdates(company, selfService),
)) ?? company;
live.companyProfiles = company.companyProfiles;
}
if (Object.keys(staged).length === 0) {
// Nothing a reviewer needs to see. Any request already open (a document
// upload, an owner verification) still surfaces so its banner survives —
// it just no longer gains fields it was never asked to review.
return new ProfileResponseDto(
profile,
live,
await this.changeRequestRepo.findLatestOpenByCompanyId(company.id),
);
}
const existing = await this.changeRequestRepo.findPendingByCompanyId(
company.id,
@@ -894,7 +945,7 @@ export class CompaniesService {
if (existing) {
request =
(await this.changeRequestRepo.update(existing.id, {
snapshot: { ...(existing.snapshot ?? {}), ...fields },
snapshot: { ...(existing.snapshot ?? {}), ...staged },
submittedBy: userId,
submittedAt: now,
note: null,
@@ -910,7 +961,7 @@ export class CompaniesService {
);
request = await this.changeRequestRepo.create({
companyId: company.id,
snapshot: fields,
snapshot: staged,
status: ChangeRequestStatus.Pending,
submittedBy: userId,
submittedAt: now,
@@ -922,8 +973,9 @@ export class CompaniesService {
);
}
// Live company is unchanged; surface the pending state for the settings page.
return new ProfileResponseDto(profile, company, request);
// Only the personnel half (if any) landed; surface the pending state for
// the settings page.
return new ProfileResponseDto(profile, live, request);
}
/** List a company's change requests, newest first (backoffice review). */
@@ -1720,16 +1772,11 @@ export class CompaniesService {
const poaProvided = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(),
);
// An Ethiopian company does not type its PoA details at all — they arrive
// from the Fayda verification — so reporting them as missing fields would
// ask for something the form no longer offers. The identity block below
// No company types its PoA details — they arrive from the Fayda
// verification whatever the nationality — so reporting them as missing
// fields would ask for something no form offers. The identity block below
// reports "verify your PoA" instead.
const missingPoaFields =
poaRequired && !identity.faydaRequired
? REQUIRED_POA_FIELDS.filter(
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
)
: [];
const missingPoaFields: typeof REQUIRED_POA_FIELDS = [];
const delegation = await this.getPoaDelegationState(company.id);
const delegationDue = poaRequired || poaProvided;
const missingDelegation = delegationDue && !delegation.onFile;
@@ -1754,9 +1801,7 @@ export class CompaniesService {
...(identity.faydaRequired && !identity.owner.verified
? ["Verify the company owner's identity with Fayda"]
: []),
...(identity.faydaRequired &&
(poaRequired || poaProvided) &&
!identity.poa.verified
...((poaRequired || poaProvided) && !identity.poa.verified
? ["Verify your Power of Attorney's identity with Fayda"]
: []),
...(identity.passportRequired && !identity.owner.passportNumber
@@ -1768,26 +1813,20 @@ export class CompaniesService {
// fields, required documents, one license per operational profile, and the
// PoA details/paper whenever those are mandatory.
const requiredDocCount = documents.filter((d) => d.isRequired).length;
const poaItemCount =
(poaRequired && !identity.faydaRequired
? REQUIRED_POA_FIELDS.length
: 0) + (delegationDue ? 1 : 0);
const poaItemCount = delegationDue ? 1 : 0;
// One item per identity credential the company has to prove: the owner
// always (Fayda for Ethiopian, passport for foreign), the PoA once there
// is one and Fayda is what's mandatory here.
const identityItemCount = identity.faydaRequired
? delegationDue
? 2
: 1
: identity.passportRequired
? 1
: 0;
const missingIdentityCount = identity.faydaRequired
? (identity.owner.verified ? 0 : 1) +
(delegationDue && !identity.poa.verified ? 1 : 0)
: identity.passportRequired && !identity.owner.passportNumber
? 1
: 0;
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once
// there is one — that one is Fayda whatever the nationality.
const ownerCredentialDue =
identity.faydaRequired || identity.passportRequired;
const ownerCredentialProven = identity.faydaRequired
? identity.owner.verified
: Boolean(identity.owner.passportNumber);
const identityItemCount =
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
const missingIdentityCount =
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
(delegationDue && !identity.poa.verified ? 1 : 0);
const total =
requiredInfo.length +
requiredDocCount +
@@ -2453,11 +2492,13 @@ export class CompaniesService {
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
};
// An approved company's profile edits are staged for backoffice review, and
// swapping the person who can act for the company is exactly the kind of
// edit that review exists for — so a verification lands the same way an
// ordinary edit does, rather than quietly rewriting a live record.
if (company.status === CompanyStatus.Active) {
// An approved company's *owner* is its identity proof, so re-verifying one
// is staged for backoffice review rather than quietly rewriting a live
// record. The PoA is personnel — the company names its own representative,
// and the delegation letter backing them is what the reviewer sees — so a
// PoA verification lands live, matching the typed PoA fields in
// `SELF_SERVICE_ATTRIBUTES`.
if (company.status === CompanyStatus.Active && dto.subject !== "poa") {
await this.stageIdentityChange(company, userId, identity);
return this.getCompanyIdentityState(company);
}
@@ -2586,21 +2627,23 @@ export class CompaniesService {
): void {
const state = buildCompanyIdentityState(company);
// Only the owner's credential is nationality-specific: Fayda for an
// Ethiopian company, a typed passport number for a foreign one.
if (state.passportRequired) {
if (!state.owner.passportNumber) {
throw new BadRequestException(
"Add the company owner's passport number before continuing.",
);
}
return;
}
if (!state.owner.verified) {
} else if (!state.owner.verified) {
throw new BadRequestException(
"Verify the company owner's identity with Fayda before continuing.",
);
}
// The representative is not. A PoA acts for the company inside Ethiopia
// whoever owns it, so they are always an Ethiopian holding a Fayda ID —
// a foreign company nominates one rather than typing a name.
const poaNamed = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(),
);

View File

@@ -144,9 +144,14 @@ export function buildCompanyIdentityState(
(p) => p.type === ProfileType.freightForwarder,
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
const complete = faydaRequired
? owner.verified && (!poaDue || poa.verified)
// Only the *owner's* credential is nationality-specific. A Power of Attorney
// acts for the company inside Ethiopia whoever owns it, so the PoA is always
// proven with Fayda — a foreign company nominates a representative who holds
// one rather than typing a name nothing backs.
const ownerProven = faydaRequired
? owner.verified
: !passportRequired || Boolean(owner.passportNumber);
const complete = ownerProven && (!poaDue || poa.verified);
return { faydaRequired, passportRequired, owner, poa, complete };
}

View File

@@ -1,4 +1,9 @@
import { BadGatewayException, Injectable, Logger } from "@nestjs/common";
import {
BadGatewayException,
BadRequestException,
Injectable,
Logger,
} from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { AxiosError } from "axios";
import { firstValueFrom } from "rxjs";
@@ -67,6 +72,33 @@ export class PaymentClientService {
}
}
/**
* POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider
* (CAC Bank). A wrong/expired OTP comes back as 400 from the payment service;
* surface that as a BadRequest (retryable) rather than a 502, so the payer can
* re-enter the code.
*/
async confirmOtp(intentId: string, otp: string): Promise<PaymentIntentSnapshot> {
try {
return await this.call<PaymentIntentSnapshot>(
"POST",
`/payments/intents/${intentId}/confirm`,
{ otp },
);
} catch (err) {
// `call` re-throws raw 404s and masks every other 4xx as BadGateway; an
// unknown intent or a bad OTP is client-fixable, so translate both to 400.
if (err instanceof AxiosError && err.response?.status === 404) {
throw new BadRequestException("PaymentIntent not found");
}
if (err instanceof BadGatewayException) {
const detail = err.message.replace(/^Payment service error: /, "");
throw new BadRequestException(detail);
}
throw err;
}
}
private async call<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
const url = `${this.baseUrl}${path}`;
try {

View File

@@ -56,7 +56,13 @@ function rabbitMQImport(): DynamicModule[] {
@Module({
imports: [
HttpModule.register({ timeout: 10_000 }),
// CAC Bank's initiate SMSes an OTP and routinely takes >10s, so the old
// 10s cap 502'd every CAC charge while the bank was still working —
// orphaning an intent the payer had already been texted about. Matches
// the passenger API's budget.
HttpModule.register({
timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000,
}),
ConfigModule,
forwardRef(() => BillingModule),
// forwardRef(() => TrainSchedulingModule),

View File

@@ -0,0 +1,190 @@
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { of, throwError } from "rxjs";
import { AxiosError, AxiosHeaders } from "axios";
import { PaymentReferenceType, ProviderPaymentStatus } from "@edr/types";
import { PaymentClientService } from "./payment-client.service";
import { PaymentService } from "./payment.service";
/** Local intent projection row (the invoice's `paymentId` points at this). */
function localIntent(overrides: Record<string, unknown> = {}) {
return {
id: "intent-1",
refId: "booking-1",
referenceType: PaymentReferenceType.SHIPMENT,
status: "action-required",
method: "cac-bank",
merchantOrderId: "EDR_INV_1",
clientAction: { type: "COLLECT_OTP", providerOrderId: "471583397" },
...overrides,
};
}
function makeRepo(rows: Record<string, unknown>[]) {
const store = [...rows];
return {
findOneBy: jest.fn((where: Record<string, unknown>) =>
Promise.resolve(
store.find((r) =>
Object.entries(where).every(([k, v]) => r[k] === v),
) ?? null,
),
),
update: jest.fn((where: { id: string }, data: Record<string, unknown>) => {
const row = store.find((r) => r.id === where.id);
if (row) Object.assign(row, data);
return Promise.resolve(undefined);
}),
};
}
describe("PaymentService.confirmOtp", () => {
const build = (
client: Partial<PaymentClientService>,
rows = [localIntent()],
) => {
const repo = makeRepo(rows);
const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) };
const service = new PaymentService(
repo as never,
client as never,
billing as never,
);
return { service, repo, billing };
};
it("settles the local intent and tells billing to settle the invoice on SUCCEEDED", async () => {
const paidAt = "2026-07-31T10:00:00.000Z";
const { service, repo, billing } = build({
getIntentByReference: jest
.fn()
.mockResolvedValue({ intentId: "gw-1", status: "REQUIRES_ACTION" }),
confirmOtp: jest.fn().mockResolvedValue({
intentId: "gw-1",
status: ProviderPaymentStatus.SUCCEEDED,
providerTxnId: "11709363209530624",
paidAt,
}),
});
const result = await service.confirmOtp("intent-1", "8280");
expect(repo.update).toHaveBeenCalledWith(
{ id: "intent-1" },
expect.objectContaining({
status: "success",
transactionId: "11709363209530624",
}),
);
// Billing settles the invoice linked by this intent id.
expect(billing.settleByPaymentId).toHaveBeenCalledWith(
"intent-1",
"11709363209530624",
new Date(paidAt),
);
expect(result.status).toBe(ProviderPaymentStatus.SUCCEEDED);
});
it("forwards the OTP against the GATEWAY intent id, not the local one", async () => {
const confirmOtp = jest
.fn()
.mockResolvedValue({ status: ProviderPaymentStatus.REQUIRES_ACTION });
const { service } = build({
getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }),
confirmOtp,
});
await service.confirmOtp("intent-1", "8280");
expect(confirmOtp).toHaveBeenCalledWith("gw-1", "8280");
});
it("leaves the intent open and does not settle when the OTP is not accepted", async () => {
const { service, repo, billing } = build({
getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }),
confirmOtp: jest.fn().mockResolvedValue({
status: ProviderPaymentStatus.REQUIRES_ACTION,
failureMessage: "OTP confirmation failed",
}),
});
const result = await service.confirmOtp("intent-1", "0000");
expect(billing.settleByPaymentId).not.toHaveBeenCalled();
expect(repo.update).toHaveBeenCalledWith(
{ id: "intent-1" },
expect.objectContaining({ status: "action-required" }),
);
expect(result.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION);
});
it("404s when the gateway has no active intent for the reference", async () => {
const { service } = build({
getIntentByReference: jest.fn().mockResolvedValue(null),
confirmOtp: jest.fn(),
});
await expect(service.confirmOtp("intent-1", "8280")).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe("PaymentClientService.confirmOtp", () => {
const axiosErr = (status: number, message: string) =>
new AxiosError(
`Request failed with status code ${status}`,
undefined,
undefined,
undefined,
{
status,
statusText: "",
data: { message },
headers: new AxiosHeaders(),
config: { headers: new AxiosHeaders() },
},
);
const build = (request: jest.Mock) =>
new PaymentClientService({ request } as never);
it("posts the OTP to the payment service intent-confirm route", async () => {
const request = jest
.fn()
.mockReturnValue(of({ data: { intentId: "gw-1", status: "SUCCEEDED" } }));
const result = await build(request).confirmOtp("gw-1", "8280");
expect(request).toHaveBeenCalledWith(
expect.objectContaining({
method: "POST",
url: expect.stringContaining("/payments/intents/gw-1/confirm"),
data: { otp: "8280" },
}),
);
expect(result.status).toBe("SUCCEEDED");
});
it("maps a rejected OTP (400) to BadRequest so the payer can retry", async () => {
const request = jest
.fn()
.mockReturnValue(
throwError(() => axiosErr(400, "OTP confirmation failed")),
);
await expect(build(request).confirmOtp("gw-1", "0000")).rejects.toBeInstanceOf(
BadRequestException,
);
});
it("maps an unknown intent (404) to BadRequest rather than a gateway error", async () => {
const request = jest
.fn()
.mockReturnValue(throwError(() => axiosErr(404, "PaymentIntent not found")));
await expect(build(request).confirmOtp("nope", "8280")).rejects.toBeInstanceOf(
BadRequestException,
);
});
});

View File

@@ -374,6 +374,53 @@ export class PaymentService {
return this.formatIntentStatus(refreshed ?? local);
}
/**
* Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by the LOCAL intent
* id (the invoice's `paymentId`) so the right invoice settles even when several
* invoices share a domain reference. The active gateway intent is looked up by
* reference, the OTP is forwarded, and the projection is refreshed. On success
* billing settles the linked invoice (idempotent — the outbox path converges too).
* A wrong/expired OTP bubbles up as a 400 and leaves the intent open for retry.
*/
async confirmOtp(intentId: string, otp: string): Promise<IntentStatusDto> {
const local = await this.paymentRepo.findOneBy({ id: intentId });
if (!local) throw new NotFoundException("PaymentIntent not found");
const snapshot = await this.paymentClient.getIntentByReference(
(local.referenceType as PaymentReferenceType) ??
PaymentReferenceType.SHIPMENT,
local.refId,
);
if (!snapshot) {
throw new NotFoundException("No active payment to confirm");
}
const confirmed = await this.paymentClient.confirmOtp(
snapshot.intentId,
otp,
);
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
await this.markIntentSucceeded(local.id, {
providerTxnId: confirmed.providerTxnId,
paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined,
notify: true,
});
} else {
await this.paymentRepo.update(
{ id: local.id },
{
status: this.toLocalStatus(confirmed.status),
failerCode: confirmed.failureCode ?? undefined,
failureMessage: confirmed.failureMessage ?? undefined,
},
);
}
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
return this.formatIntentStatus(refreshed ?? local);
}
/**
* Mark a gateway intent paid and (by default) notify billing to settle the
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`