mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 02:00:56 +00:00
Merge pull request #1042 from Tria-plc/freight/chore/payment-test
CAC Integration to the freight api, tests fix
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
190
apps/edr-freight-api/src/modules/payment/payment.service.spec.ts
Normal file
190
apps/edr-freight-api/src/modules/payment/payment.service.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user