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`

View File

@@ -201,6 +201,8 @@ export const URL_CONSTANTS = {
MY_INVOICE_DOCUMENT: (id: string) => `/api/billing/my-invoices/${id}/document`,
MY_INVOICE_RECEIPT: (id: string) => `/api/billing/my-invoices/${id}/receipt`,
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
CONFIRM_INVOICE_OTP: (id: string) =>
`/api/billing/my-invoices/${id}/confirm`,
},
WAREHOUSE_INVOICES: {

View File

@@ -0,0 +1,115 @@
import { useMutation } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { useState } from "react";
import { invoicesService } from "@/services/invoices.service";
import {
paymentsService,
type InitiateResponse,
type PaymentMethod,
} from "@/services/payments.service";
/** How the invoice is charged — overridable for warehouse fee invoices. */
type InitiateFn = (
invoiceId: string,
method: PaymentMethod,
payerAccount?: string,
) => Promise<InitiateResponse>;
const payViaBilling: InitiateFn = (invoiceId, method, payerAccount) =>
invoicesService.pay(invoiceId, { method, platform: "web", payerAccount });
/** The server's message (`{ message }` / `{ message: [] }`), or a fallback. */
function apiMessage(err: unknown, fallback: string): string {
const message = (err as AxiosError<{ message?: string | string[] }>)?.response
?.data?.message;
const first = Array.isArray(message) ? message[0] : message;
return first || fallback;
}
/**
* One payment flow for every "pay this invoice" entry point: initiate, then
* either redirect to the provider or — for CAC Bank, an OTP debit with no
* redirect — collect the SMS'd code and confirm it in-app. Pass `initiate` to
* charge through a different endpoint (warehouse fee invoices); OTP
* confirmation always goes through billing, which owns the intent either way.
*/
export function useInvoicePayment(initiate: InitiateFn = payViaBilling) {
const [otpInvoiceId, setOtpInvoiceId] = useState<string | null>(null);
const [otpMessage, setOtpMessage] = useState<string | undefined>();
const payMutation = useMutation({
mutationFn: (vars: {
invoiceId: string;
method: PaymentMethod;
payerAccount?: string;
}) => initiate(vars.invoiceId, vars.method, vars.payerAccount),
onSuccess: (data, vars) => {
if (data?.clientAction?.type === "COLLECT_OTP") {
setOtpMessage(
data.clientAction.message ?? "Enter the OTP sent to your phone",
);
setOtpInvoiceId(vars.invoiceId);
return;
}
window.location.href =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({
invoiceId: vars.invoiceId,
method: vars.method,
});
},
});
const otpMutation = useMutation({
mutationFn: (otp: string) =>
invoicesService.confirmOtp(otpInvoiceId as string, otp),
// Settled — reload so the invoice/booking re-reads its now-paid state.
onSuccess: () => {
setOtpInvoiceId(null);
window.location.reload();
},
});
const reset = () => {
payMutation.reset();
otpMutation.reset();
setOtpInvoiceId(null);
};
return {
processing: payMutation.isPending,
error: payMutation.isError
? apiMessage(
payMutation.error,
payMutation.error instanceof Error
? payMutation.error.message
: "Could not start payment. Please try again.",
)
: null,
pay: (invoiceId: string, method: PaymentMethod, payerAccount?: string) =>
payMutation.mutate({ invoiceId, method, payerAccount }),
reset,
/** Drives the modal's OTP step; `open` only for CAC Bank. */
otp: {
open: otpInvoiceId !== null,
message: otpMessage,
submitting: otpMutation.isPending,
// A wrong/expired OTP is a 400 — keep the step open so the payer retries.
error: otpMutation.isError
? apiMessage(
otpMutation.error,
"Invalid or expired OTP. Please try again.",
)
: null,
submit: (otp: string) => otpMutation.mutate(otp),
cancel: () => {
otpMutation.reset();
setOtpInvoiceId(null);
},
},
};
}
export type InvoicePaymentFlow = ReturnType<typeof useInvoicePayment>;

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
import {
AlertTriangle,
@@ -10,9 +10,8 @@ import {
PackagePlus,
} from "lucide-react";
import { api } from "@/services/api";
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { invoicesService } from "@/services/invoices.service";
import { isPayable } from "@/pages/billing/invoice-ui";
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
@@ -60,29 +59,7 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
isPayable(inv.status),
)?.id;
const payMutation = useMutation({
mutationFn: (method: PaymentMethod) => {
if (!payableInvoiceId) {
throw new Error(
"No payable invoice found for this booking yet. Please refresh or contact support.",
);
}
return api.invoices.pay.call({
id: payableInvoiceId,
payload: { method, platform: "web" },
});
},
onSuccess: (data, method) => {
const url =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({
invoiceId: payableInvoiceId!,
method,
});
window.location.href = url;
},
});
const pay = useInvoicePayment();
if (items.length === 0) return null;
@@ -189,21 +166,24 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
<PaymentMethodModal
opened={payItem !== null}
onClose={() => {
if (!payMutation.isPending) {
if (!pay.processing) {
setPayItem(null);
payMutation.reset();
pay.reset();
}
}}
currency={undefined}
processing={payMutation.isPending}
processing={pay.processing}
error={
payMutation.isError
? payMutation.error instanceof Error
? payMutation.error.message
: "Could not start payment. Please try again."
: null
pay.error ??
(payItemInvoices.length > 0 && !payableInvoiceId
? "No payable invoice found for this booking yet. Please refresh or contact support."
: null)
}
otp={pay.otp}
onConfirm={(method, payerAccount) =>
payableInvoiceId &&
pay.pay(payableInvoiceId, method, payerAccount)
}
onConfirm={(method) => payMutation.mutate(method)}
/>
</ModalSafeWrapper>
</Card>

View File

@@ -287,9 +287,11 @@ export default function SettingsPage() {
icon={<Clock size={18} />}
title="Changes submitted for review"
>
Your recent changes are awaiting administrator approval. Editing is
disabled until the review is complete you'll be notified once it's
approved or if any changes are requested.
Your recent changes are awaiting administrator approval. Company
details and documents can't be edited until the review is complete —
you'll be notified once it's approved or if any changes are
requested. Your contact person, general manager and Power of
Attorney stay editable.
</Alert>
)}
{reviewStatus === "rejected" && (
@@ -358,9 +360,17 @@ export default function SettingsPage() {
)}
</Tabs.Panel>
{/* While a change request is pending, every panel's inputs + submit
buttons are disabled via the native fieldset; tab switching stays
enabled so the customer can still review what they submitted. */}
{/* While a change request is pending, the reviewed panels' inputs +
submit buttons are disabled via the native fieldset; tab switching
stays enabled so the customer can still review what they
submitted.
Personnel panels below (contact person, general manager, Power of
Attorney) are deliberately outside the lock: the API applies those
edits live rather than staging them, so locking them here would
re-impose the approval wait the API no longer does. The PoA's
delegation letter is still reviewed — that lock lives on the file
itself, not the panel. */}
<Tabs.Panel value="company">
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabCompanyProfile mode="edit" profile={profile} user={user ?? undefined} />
@@ -368,19 +378,13 @@ export default function SettingsPage() {
<OperationalServicesCard profile={profile} />
</Tabs.Panel>
<Tabs.Panel value="contact">
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabContactPerson profile={profile} mode="edit" />
</Fieldset>
<TabContactPerson profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="gm">
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabGeneralManager profile={profile} mode="edit" />
</Fieldset>
<TabGeneralManager profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="poa">
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabPowerOfAttorney profile={profile} mode="edit" />
</Fieldset>
<TabPowerOfAttorney profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="documents">
<Fieldset disabled={locked} variant="unstyled" p={0}>

View File

@@ -33,7 +33,6 @@ import {
buildOnboardingSchema,
type CompanyStep,
type FormData,
hasPoaDetails,
POA_DELEGATION_FILE_KEY,
stepFields,
} from "./companyProfileForm/schema";
@@ -191,11 +190,7 @@ export default function CompanyProfileForm({
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(
buildOnboardingSchema(
requirePoa,
verifiedIdentity,
identity?.passportRequired === true,
),
buildOnboardingSchema(identity?.passportRequired === true),
),
defaultValues: {
companyName: "",
@@ -321,7 +316,6 @@ export default function CompanyProfileForm({
// them and re-enables editing.
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
const [contactSameAsGm, setContactSameAsGm] = useState(false);
const [poaSameAsContact, setPoaSameAsContact] = useState(false);
// General Manager source. The company step's email/phone are seeded from
// eTrade (and the account email) but stay editable, so the link reads the
@@ -367,9 +361,6 @@ export default function CompanyProfileForm({
const gmName = watch("generalManagerName");
const gmEmail = watch("generalManagerEmail");
const gmPhone = watch("generalManagerPhone");
const contactName = watch("contactPersonName");
const contactEmail = watch("contactPersonEmail");
const contactPhone = watch("contactPersonPhone");
// While linked, mirror the source values into the (disabled) target fields so
// the copy stays current even if the user goes back and edits the source.
@@ -381,26 +372,6 @@ export default function CompanyProfileForm({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
// The contact-person step has no address of its own, so the linked PoA takes
// the company's composed address. poaLocation (the city) stays typed on the
// PoA step — the company step no longer has a location field to mirror.
const companyAddress = watch("companyAddress");
useEffect(() => {
if (!poaSameAsContact) return;
setValue("poaName", contactName ?? "");
setValue("poaEmail", contactEmail ?? "");
setValue("poaPhone", contactPhone ?? "");
setValue("poaAddress", companyAddress ?? "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
poaSameAsContact,
contactName,
contactEmail,
contactPhone,
companyAddress,
]);
const toggleContactSameAsGm = (checked: boolean) => {
setContactSameAsGm(checked);
// Checked → the mirror effect fills the fields; unchecked → reset them.
@@ -411,17 +382,6 @@ export default function CompanyProfileForm({
}
};
const togglePoaSameAsContact = (checked: boolean) => {
setPoaSameAsContact(checked);
if (!checked) {
setValue("poaName", "");
setValue("poaEmail", "");
setValue("poaPhone", "");
setValue("poaLocation", "");
setValue("poaAddress", "");
}
};
// The DARS delegation paper ships in the same nationality document set as the
// rest (the API guarantees it is there), but belongs on the PoA step next to
// the details it evidences — so it's split out here and the Documents step
@@ -551,7 +511,9 @@ export default function CompanyProfileForm({
// for a freight forwarder, whose PoA itself is mandatory. The API enforces
// the same rule on save, so skipping it here only costs the customer a
// round-trip.
const poaProvided = hasPoaDetails(watch());
// A PoA exists exactly when one has been verified — the details are the
// verification's output, so there is nothing else that could stand for one.
const poaProvided = identity?.poa.verified ?? false;
const delegationRequired = requirePoa || poaProvided;
const delegationPresent =
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
@@ -638,12 +600,7 @@ export default function CompanyProfileForm({
setSaveError("Verify the company owner's identity with Fayda before continuing.");
return;
}
if (
step === "poa" &&
verifiedIdentity &&
requirePoa &&
!identity?.poa.verified
) {
if (step === "poa" && requirePoa && !identity?.poa.verified) {
setSaveError(
"Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.",
);
@@ -876,71 +833,27 @@ export default function CompanyProfileForm({
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required."
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."}
</Text>
{/* A representative acts for the company inside Ethiopia
whoever owns it, so the PoA is proven with Fayda regardless of
nationality — their name, email, phone and address all come
from the verification and are never typed here. */}
{identity && (
<FaydaVerifyPanel
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={identity.faydaRequired}
required={requirePoa}
onVerified={() => onIdentityChange?.()}
/>
)}
{!verifiedIdentity && watch("contactPersonName") && (
<LinkCheckboxCard
checked={poaSameAsContact}
onToggle={togglePoaSameAsContact}
title="Same as contact person"
description="Reuse the contact person's name, email and phone, plus the company's location and address. Uncheck to enter different details."
/>
)}
{!verifiedIdentity && (
<>
<TextInput
label="PoA Name"
placeholder="Authorized Representative Name"
error={errors.poaName?.message}
{...register("poaName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
<ControlledPhoneField
control={control}
name="poaPhone"
label="PoA Phone"
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
<TextInput
label="PoA Address"
placeholder="Full Address"
error={errors.poaAddress?.message}
{...register("poaAddress")}
/>
</SimpleGrid>
</>
)}
{/* The city is the one field the Fayda address claim does not
reliably decompose into, so it stays typed either way. */}
{verifiedIdentity && (
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
)}
reliably decompose into, so it stays typed. */}
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
{poaDocumentSetting && (
<>

View File

@@ -37,10 +37,8 @@ export function buildPayload(
generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: data.generalManagerPhone,
poaName: data.poaName || undefined,
poaPhone: data.poaPhone || undefined,
poaAddress: data.poaAddress || undefined,
poaEmail: data.poaEmail || undefined,
// The representative's own details are written by their Fayda
// verification, so the city is all the form has to send.
poaLocation: data.poaLocation || undefined,
},
};
@@ -88,13 +86,7 @@ export function stepPayload(
contactPersonPhone: d.contactPersonPhone,
};
case "poa":
return {
poaName: d.poaName || undefined,
poaPhone: d.poaPhone || undefined,
poaEmail: d.poaEmail || undefined,
poaLocation: d.poaLocation || undefined,
poaAddress: d.poaAddress || undefined,
};
return { poaLocation: d.poaLocation || undefined };
default:
return {};
}

View File

@@ -92,58 +92,28 @@ export type FormData = z.infer<typeof onboardingSchema>;
/** fileKey of the delegation letter uploaded on the Power of Attorney step. */
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
export const POA_FIELDS = [
"poaName",
"poaPhone",
"poaEmail",
"poaLocation",
"poaAddress",
] as const satisfies readonly (keyof FormData)[];
/** True once the customer has entered any Power of Attorney detail. */
export const hasPoaDetails = (d: Partial<FormData>) =>
POA_FIELDS.some((f) => d[f]?.trim());
/**
* A freight forwarder acts on other companies' behalf, so its PoA is mandatory
* rather than optional. Everyone else keeps the optional PoA — but once they
* start filling it in, the identifying fields have to be complete (the
* delegation-letter upload is enforced alongside this, in CompanyProfileForm,
* since files live outside the form state).
* The PoA's identifying fields are never typed — they come from the Fayda
* verification, whatever the company's nationality — so nothing here requires
* them. A freight forwarder's mandatory PoA is gated on the verification
* itself, and its delegation letter alongside it, both in CompanyProfileForm
* (files live outside form state).
*
* That leaves the owner's passport number as the only conditional field.
*/
export function buildOnboardingSchema(
requirePoa: boolean,
/**
* True when the PoA's identity fields come from a Fayda verification rather
* than the form (Ethiopian companies). Requiring them here would fail
* validation against inputs the step no longer renders — the verification
* itself is what the step gates on instead.
*/
faydaOwnedPoa = false,
/** True for a foreign company: the owner's passport number is mandatory. */
passportRequired = false,
) {
const poaRequired = requirePoa && !faydaOwnedPoa;
if (!poaRequired && !passportRequired) return onboardingSchema;
if (!passportRequired) return onboardingSchema;
return onboardingSchema.superRefine((d, ctx) => {
const required: [keyof FormData, string][] = [];
if (poaRequired) {
required.push(
["poaName", "PoA name is required for freight forwarders"],
["poaEmail", "PoA email is required for freight forwarders"],
["poaPhone", "PoA phone is required for freight forwarders"],
);
}
if (passportRequired) {
required.push([
"ownerPassportNumber",
"The owner's passport number is required",
]);
}
for (const [path, message] of required) {
if (!d[path]?.trim()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message });
}
if (!d.ownerPassportNumber?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["ownerPassportNumber"],
message: "The owner's passport number is required",
});
}
});
}
@@ -180,7 +150,7 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
"contactPersonEmail",
"contactPersonPhone",
],
poa: [...POA_FIELDS],
poa: ["poaLocation"],
documents: [],
additional: [],
};

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
@@ -27,10 +27,7 @@ import toast from "react-hot-toast";
import { api } from "@/services/api";
import { invoicesService } from "@/services/invoices.service";
import {
paymentsService,
type PaymentMethod,
} from "@/services/payments.service";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
import { saveBlob } from "@/utils/download";
@@ -76,17 +73,7 @@ export default function InvoiceDetailPage() {
// Ownership-checked: POST /billing/my-invoices/:id/pay only ever charges
// one of the signed-in customer's own invoices (unlike the admin-facing
// /payments/initiate, which takes any invoiceId with no ownership check).
const payMutation = useMutation({
mutationFn: (method: PaymentMethod) =>
api.invoices.pay.call({ id, payload: { method, platform: "web" } }),
onSuccess: (data, method) => {
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({ invoiceId: id, method });
window.location.href = redirectUrl;
},
});
const pay = useInvoicePayment();
if (isLoading) {
return (
@@ -249,7 +236,7 @@ export default function InvoiceDetailPage() {
radius="md"
size="md"
leftSection={<CreditCard size={16} />}
loading={payMutation.isPending}
loading={pay.processing}
onClick={handlePay}
styles={{
root: { fontWeight: 600, height: 42, paddingInline: 18 },
@@ -376,22 +363,19 @@ export default function InvoiceDetailPage() {
<PaymentMethodModal
opened={payModalOpen}
onClose={() => {
if (!payMutation.isPending) {
if (!pay.processing) {
setPayModalOpen(false);
payMutation.reset();
pay.reset();
}
}}
amountLabel={formatCurrency(amountDue, invoice.currency)}
currency={invoice.currency}
processing={payMutation.isPending}
error={
payMutation.isError
? payMutation.error instanceof Error
? payMutation.error.message
: "Could not start payment. Please try again."
: null
processing={pay.processing}
error={pay.error}
otp={pay.otp}
onConfirm={(method, payerAccount) =>
pay.pay(id, method, payerAccount)
}
onConfirm={(method) => payMutation.mutate(method)}
/>
</Stack>
</Box>

View File

@@ -1,14 +1,8 @@
import { Group, Tabs } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { CreditCard, FileText, LayoutGrid } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import { useFileViewer } from "@/hooks/useFileViewer";
import { invoicesService } from "@/services/invoices.service";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import { isPayable } from "@/pages/billing/invoice-ui";
import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
@@ -41,6 +35,7 @@ import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard";
import { fmtDate, isNegative, priceTotal } from "./utils";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
export function ReadonlyBookingView({
booking,
@@ -53,7 +48,6 @@ export function ReadonlyBookingView({
// Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice.
useScrollToHash();
const status = booking.status as string;
const [payModalOpen, setPayModalOpen] = useState(false);
const { viewer } = useFileViewer();
// Re-book opens the New Shipment Booking form for the same contract, not the
@@ -63,44 +57,11 @@ export function ReadonlyBookingView({
: "/contracts/new";
const onRebook = () => navigate(rebookTo);
// Billing is invoice-centric — resolve the booking's currently payable
// invoice (same query/key BookingPaymentPanel uses, so this shares its
// cache) and pay it through the ownership-checked portal route.
const { data: bookingInvoices = [] } = useQuery({
queryKey: ["booking-invoices", booking.id],
queryFn: () => invoicesService.listForSource("booking", booking.id),
});
const payableInvoiceId = bookingInvoices.find((inv) =>
isPayable(inv.status),
)?.id;
// POST /billing/my-invoices/:id/pay creates the intent and returns the
// provider's redirect URL (clientAction.url). Send the browser straight
// there; fall back to the public /payments/checkout page if no redirect
// URL came back.
const payMutation = useMutation({
mutationFn: (method: PaymentMethod) => {
if (!payableInvoiceId) {
throw new Error(
"No payable invoice found for this booking yet. Please refresh or contact support.",
);
}
return api.invoices.pay.call({
id: payableInvoiceId,
payload: { method, platform: "web" },
});
},
onSuccess: (data, method) => {
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({
invoiceId: payableInvoiceId!,
method,
});
window.location.href = redirectUrl;
},
});
// Billing is invoice-centric — the shared hook resolves the booking's
// currently payable invoice (same query/key BookingPaymentPanel uses, so it
// shares that cache), charges it through the ownership-checked portal route,
// and handles redirect vs CAC Bank OTP.
const pay = useBookingPayment(booking.id);
const pricing = booking.pricingBreakdown;
// A general contract is paid once it's FULLY_EXECUTED (signed) — it never
@@ -171,7 +132,7 @@ export function ReadonlyBookingView({
green
icon={<CreditCard size={16} />}
label="Pay now"
onClick={() => setPayModalOpen(true)}
onClick={pay.open}
/>
)}
</Group>
@@ -278,8 +239,8 @@ export function ReadonlyBookingView({
<BookingPaymentPanel
booking={booking}
pricing={pricing}
onPay={() => setPayModalOpen(true)}
paying={payMutation.isPending}
onPay={pay.open}
paying={pay.processing}
showCountdown={showCountdown}
/>
<ScheduleCard
@@ -301,24 +262,14 @@ export function ReadonlyBookingView({
</Tabs>
<PaymentMethodModal
opened={payModalOpen}
onClose={() => {
if (!payMutation.isPending) {
setPayModalOpen(false);
payMutation.reset();
}
}}
opened={pay.modalOpen}
onClose={pay.close}
amountLabel={pricing ? priceTotal(pricing) : undefined}
currency={pricing?.currency ?? booking.paymentCurrency}
processing={payMutation.isPending}
error={
payMutation.isError
? payMutation.error instanceof Error
? payMutation.error.message
: "Could not start payment. Please try again."
: null
}
onConfirm={(method) => payMutation.mutate(method)}
processing={pay.processing}
error={pay.error}
otp={pay.otp}
onConfirm={pay.confirm}
/>
{viewer}
</PageShell>

View File

@@ -1,20 +1,32 @@
import { Box, Button, Group, Image, Modal, Stack, Text } from "@mantine/core";
import { Check, ShieldCheck } from "lucide-react";
import {
Box,
Button,
Group,
Image,
Modal,
PinInput,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { Check, Landmark, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { InvoicePaymentFlow } from "@/hooks/useInvoicePayment";
import type { PaymentMethod } from "@/services/payments.service";
interface ProviderOption {
method: PaymentMethod;
label: string;
description: string;
logo: string;
/** Logo asset; falls back to a bank glyph when the provider has none. */
logo?: string;
/** Currencies this provider settles in. */
currencies: string[];
accent: string;
}
// Only Telebirr and Waafi are enabled for now.
// Only Telebirr, Waafi and CAC Bank are enabled for now.
const PROVIDERS: ProviderOption[] = [
{
method: "TELEBIRR",
@@ -32,8 +44,18 @@ const PROVIDERS: ProviderOption[] = [
currencies: ["USD"],
accent: "#2E5B96",
},
{
method: "CAC_BANK",
label: "CAC Bank",
description: "Djibouti bank debit · confirmed by SMS OTP",
currencies: ["USD"],
accent: "#8A5A17",
},
];
/** Providers that debit against an SMS OTP instead of redirecting to a page. */
const isOtpMethod = (method: PaymentMethod) => method === "CAC_BANK";
/**
* Pick the provider that settles in the booking's currency. USD → Waafi,
* ETB → Telebirr. Falls back to the first provider when unknown.
@@ -91,13 +113,27 @@ function ProviderRow({
backgroundColor: "#fff",
}}
>
<Image
src={option.logo}
alt={`${option.label} logo`}
w={52}
h={52}
fit="cover"
/>
{option.logo ? (
<Image
src={option.logo}
alt={`${option.label} logo`}
w={52}
h={52}
fit="cover"
/>
) : (
<Box
style={{
width: 52,
height: 52,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Landmark size={24} color={option.accent} />
</Box>
)}
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text fz="15px" fw={800} c="#10202F" tt="capitalize">
@@ -135,6 +171,7 @@ export function PaymentMethodModal({
onConfirm,
processing,
error,
otp,
}: {
opened: boolean;
onClose: () => void;
@@ -142,12 +179,19 @@ export function PaymentMethodModal({
amountLabel?: string;
/** Booking payment currency — drives which provider is shown (USD → Waafi, ETB → Telebirr). */
currency?: string | null;
onConfirm: (method: PaymentMethod) => void;
onConfirm: (method: PaymentMethod, payerAccount?: string) => void;
processing?: boolean;
error?: string | null;
/** CAC Bank OTP step, from `useInvoicePayment`. Omit to disable OTP providers. */
otp?: InvoicePaymentFlow["otp"];
}) {
const providers = useMemo(() => providersForCurrency(currency), [currency]);
const providers = useMemo(
() => providersForCurrency(currency).filter((p) => otp || !isOtpMethod(p.method)),
[currency, otp],
);
const [method, setMethod] = useState<PaymentMethod>(providers[0].method);
const [mobile, setMobile] = useState("");
const [code, setCode] = useState("");
// Keep the selection valid when the currency (and therefore provider list) changes.
useEffect(() => {
@@ -156,6 +200,89 @@ export function PaymentMethodModal({
}
}, [providers, method]);
// A fresh OTP round always starts empty.
useEffect(() => {
if (otp?.open) setCode("");
}, [otp?.open]);
// CAC Bank debits the account behind this number and SMSes the OTP to it.
const needsMobile = isOtpMethod(method);
const canSubmit = !needsMobile || mobile.trim().length > 0;
if (otp?.open) {
return (
<Modal
opened={opened}
onClose={otp.cancel}
centered
radius={18}
size={420}
padding={0}
withCloseButton={false}
// A stray click must not drop the payer out of a live OTP window —
// Cancel is the only way back.
closeOnClickOutside={false}
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
>
<Box px={24} py={24}>
<Text fw={800} fz="18px" c="#10202F">
Enter OTP
</Text>
<Text mt={4} fz="13px" c="#7A8794">
{otp.message}
</Text>
<Box mt={18}>
<PinInput
length={6}
type="number"
inputMode="numeric"
oneTimeCode
value={code}
onChange={setCode}
onComplete={(value) => otp.submit(value)}
aria-label="One-time password"
/>
</Box>
{otp.error && (
<Text mt={10} fz="12.5px" c="#C0392B" fw={600}>
{otp.error}
</Text>
)}
<Group gap={10} wrap="nowrap" mt={20}>
<Button
variant="default"
radius={12}
onClick={otp.cancel}
disabled={otp.submitting}
styles={{
root: { height: 46, flex: "0 0 38%" },
label: { fontSize: 14, fontWeight: 700, color: "#475569" },
}}
>
Cancel
</Button>
<Button
radius={12}
color="edr-green"
loading={otp.submitting}
disabled={otp.submitting || code.trim().length === 0}
onClick={() => otp.submit(code.trim())}
styles={{
root: { height: 46, flex: 1 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
Confirm payment
</Button>
</Group>
</Box>
</Modal>
);
}
return (
<Modal
opened={opened}
@@ -215,6 +342,22 @@ export function PaymentMethodModal({
/>
))}
</Stack>
{needsMobile && (
<TextInput
mt={12}
label="Mobile number"
description="CAC Bank sends a one-time password to this number to authorise the debit."
placeholder="77xxxxxx"
value={mobile}
onChange={(e) => setMobile(e.currentTarget.value)}
disabled={processing}
styles={{
label: { fontSize: 12.5, fontWeight: 700, color: "#10202F" },
description: { fontSize: 11.5 },
}}
/>
)}
</Box>
{/* Footer */}
@@ -228,7 +371,9 @@ export function PaymentMethodModal({
<Group gap={6} align="center" justify="center" mb={12}>
<ShieldCheck size={14} color="#0A8A5F" />
<Text fz="11.5px" c="#7A8794">
Secured · you'll be redirected to your provider to pay
{needsMobile
? "Secured · you'll confirm with the OTP sent to your phone"
: "Secured · you'll be redirected to your provider to pay"}
</Text>
</Group>
@@ -248,15 +393,21 @@ export function PaymentMethodModal({
<Button
radius={12}
color="edr-green"
disabled={processing}
disabled={processing || !canSubmit}
loading={processing}
onClick={() => onConfirm(method)}
onClick={() =>
onConfirm(method, needsMobile ? mobile.trim() : undefined)
}
styles={{
root: { height: 48, flex: 1 },
label: { fontSize: 14, fontWeight: 800 },
}}
>
{processing ? "Redirecting…" : "Continue to payment"}
{processing
? needsMobile
? "Sending OTP"
: "Redirecting"
: "Continue to payment"}
</Button>
</Group>
</Box>

View File

@@ -1,10 +1,10 @@
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { CreditCard, Download, FileText, Receipt } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import {
warehouseInvoicesService,
type PortalWarehouseInvoice,
@@ -67,36 +67,20 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
const [payInvoice, setPayInvoice] = useState<PortalWarehouseInvoice | null>(null);
const payMutation = useMutation({
mutationFn: (method: PaymentMethod) => {
if (!payInvoice) throw new Error("No invoice selected for payment.");
return warehouseInvoicesService.payOnline(payInvoice.id, {
method,
platform: "web",
});
},
onSuccess: (data, method) => {
if (!payInvoice) return;
// Redirect to the provider (or the fallback checkout page) — same as the
// booking "Pay now" flow, so behaviour is identical everywhere.
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({ invoiceId: payInvoice.id, method });
window.location.href = redirectUrl;
},
});
const payError = payMutation.isError
? payMutation.error instanceof Error
? payMutation.error.message
: "Could not start payment. Please try again."
: null;
// Warehouse fees are charged through the warehouse route, but they are the
// same central invoices — so redirect vs CAC Bank OTP is the shared flow.
const pay = useInvoicePayment((invoiceId, method, payerAccount) =>
warehouseInvoicesService.payOnline(invoiceId, {
method,
platform: "web",
payerAccount,
}),
);
const closePayModal = () => {
if (!payMutation.isPending) {
if (!pay.processing) {
setPayInvoice(null);
payMutation.reset();
pay.reset();
}
};
@@ -253,9 +237,12 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
payInvoice ? money(payInvoice.balanceAmount, payInvoice.currency) : undefined
}
currency={payInvoice?.currency}
onConfirm={(method) => payMutation.mutate(method)}
processing={payMutation.isPending}
error={payError}
onConfirm={(method, payerAccount) =>
payInvoice && pay.pay(payInvoice.id, method, payerAccount)
}
processing={pay.processing}
error={pay.error}
otp={pay.otp}
/>
</SectionCard>
);

View File

@@ -55,6 +55,7 @@ export function PayNowButton({
currency={pricing?.currency ?? booking.paymentCurrency}
processing={pay.processing}
error={pay.error}
otp={pay.otp}
onConfirm={pay.confirm}
/>
</ModalSafeWrapper>

View File

@@ -1,23 +1,22 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { api } from "@/services/api";
import {
paymentsService,
type PaymentMethod,
} from "@/services/payments.service";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { type PaymentMethod } from "@/services/payments.service";
import { invoicesService } from "@/services/invoices.service";
import { isPayable } from "@/pages/billing/invoice-ui";
/**
* Shared payment flow for a single booking: opens the method modal, fires
* POST /billing/my-invoices/:id/pay for the booking's currently payable
* invoice, and redirects the browser to the provider (or the fallback
* checkout page). Reused by the booking detail page, the booking list, and
* the home page so "Pay now" behaves identically everywhere.
* invoice, and redirects the browser to the provider (or, for CAC Bank, an
* OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
* the booking detail page, the booking list, and the home page so "Pay now"
* behaves identically everywhere.
*/
export function useBookingPayment(bookingId: string) {
const [modalOpen, setModalOpen] = useState(false);
const [noInvoice, setNoInvoice] = useState(false);
const { data: invoices = [] } = useQuery({
queryKey: ["booking-invoices", bookingId],
@@ -25,51 +24,34 @@ export function useBookingPayment(bookingId: string) {
});
const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id;
const mutation = useMutation({
mutationFn: (method: PaymentMethod) => {
if (!payableInvoiceId) {
throw new Error(
"No payable invoice found for this booking yet. Please refresh or contact support.",
);
}
return api.invoices.pay.call({
id: payableInvoiceId,
payload: { method, platform: "web" },
});
},
onSuccess: (data, method) => {
const redirectUrl =
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
? data.clientAction.url
: paymentsService.checkoutUrlForInvoice({
invoiceId: payableInvoiceId!,
method,
});
window.location.href = redirectUrl;
},
});
const flow = useInvoicePayment();
const open = () => setModalOpen(true);
const close = () => {
if (!mutation.isPending) {
if (!flow.processing) {
setModalOpen(false);
mutation.reset();
setNoInvoice(false);
flow.reset();
}
};
const error = mutation.isError
? mutation.error instanceof Error
? mutation.error.message
: "Could not start payment. Please try again."
: null;
return {
modalOpen,
open,
close,
processing: mutation.isPending,
error,
confirm: (method: PaymentMethod) => mutation.mutate(method),
processing: flow.processing,
error: noInvoice
? "No payable invoice found for this booking yet. Please refresh or contact support."
: flow.error,
otp: flow.otp,
confirm: (method: PaymentMethod, payerAccount?: string) => {
if (!payableInvoiceId) {
setNoInvoice(true);
return;
}
setNoInvoice(false);
flow.pay(payableInvoiceId, method, payerAccount);
},
};
}

View File

@@ -39,20 +39,15 @@ import {
type LicenseFile,
type LicenseFileStatus,
} from "@/services/companies.service";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import { verifaydaService } from "@/services/verifayda.service";
import type { ProfileResponse } from "@/types/profile";
// The representative's name, email, phone and address all come from their
// Fayda verification — a PoA is always an Ethiopian holding one — so the city
// is the only detail this form owns.
const schema = z.object({
poaName: z.string().optional(),
poaEmail: z.string().optional(),
poaPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
poaLocation: z.string().optional(),
poaAddress: z.string().optional(),
});
type FormData = z.infer<typeof schema>;
@@ -98,22 +93,15 @@ export default function TabPowerOfAttorney({
const { view, viewer } = useFileViewer();
const uploadInputRef = useRef<HTMLInputElement>(null);
const defaultValues = useMemo((): FormData => {
return {
poaName: profile.poaName ?? "",
poaEmail: profile.poaEmail ?? "",
poaPhone: profile.poaPhone ?? "",
poaLocation: profile.poaLocation ?? "",
poaAddress: profile.poaAddress ?? "",
};
}, [profile]);
const defaultValues = useMemo(
(): FormData => ({ poaLocation: profile.poaLocation ?? "" }),
[profile],
);
const {
register,
control,
handleSubmit,
reset,
watch,
formState: { errors, isDirty },
} = useForm<FormData>({
resolver: zodResolver(schema),
@@ -123,10 +111,10 @@ export default function TabPowerOfAttorney({
const letterQuery = useQuery(api.companies.poaDelegation.queryOptions({}));
const letters = useMemo(() => letterQuery.data ?? [], [letterQuery.data]);
// The letter is staged locally, not uploaded on pick. Uploading immediately
// would open a change request, which locks the whole settings page (see
// SettingsPage's `locked` fieldset) before the text fields could be saved.
// Save submits the file and the fields together, into one change request.
// The letter is staged locally, not uploaded on pick: the paper is the one
// thing here that still goes to a reviewer, so picking it must not open a
// change request before the customer has committed to the save. Save submits
// the file and the fields together.
const [pickedFile, setPickedFile] = useState<File | null>(null);
const [removeIds, setRemoveIds] = useState<string[]>([]);
const [saveBlocked, setSaveBlocked] = useState(false);
@@ -142,22 +130,12 @@ export default function TabPowerOfAttorney({
const requirePoa = profile.companyProfiles.some(
(p) => p.type === "freight_forwarder",
);
// An Ethiopian company does not type its representative's details — they
// come from the Fayda verification. A foreign company keeps the typed form:
// its representative may hold no Fayda ID.
// No company types its representative's details — they come from the Fayda
// verification whatever the nationality, since a representative acts for the
// company inside Ethiopia either way. A PoA therefore exists exactly when one
// has been verified.
const identity = profile.identity;
const verifiedIdentity = identity?.faydaRequired === true;
const poaValues = watch([
"poaName",
"poaEmail",
"poaPhone",
"poaLocation",
"poaAddress",
]);
const poaProvided = verifiedIdentity
? (identity?.poa.verified ?? false)
: poaValues.some((v) => v?.trim());
const poaProvided = identity?.poa.verified ?? false;
const letterRequired = requirePoa || poaProvided;
const letterMissing = letterRequired && !hasLetterAfterSave;
@@ -166,16 +144,8 @@ export default function TabPowerOfAttorney({
const mutation = useMutation({
mutationFn: async (data: FormData) => {
// Every identity field except the city is written by the verification, so
// an Ethiopian company only ever saves the paper and the location here.
const fields = verifiedIdentity
? { poaLocation: data.poaLocation || undefined }
: {
poaName: data.poaName || undefined,
poaPhone: data.poaPhone || undefined,
poaEmail: data.poaEmail || undefined,
poaLocation: data.poaLocation || undefined,
poaAddress: data.poaAddress || undefined,
};
// only the paper and the location are ever saved here.
const fields = { poaLocation: data.poaLocation || undefined };
// A fresh upload already stages the removal of every paper on file, so
// the explicit removals only need applying when no replacement was
// picked. Saving the details after it means the API sees the new paper.
@@ -281,12 +251,8 @@ export default function TabPowerOfAttorney({
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={identity.faydaRequired}
required={requirePoa}
disabled={mutation.isPending}
pendingReview={Boolean(
(profile.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
?.faydaIdentity?.poaFaydaSub,
)}
onVerified={() => {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
@@ -300,39 +266,9 @@ export default function TabPowerOfAttorney({
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
{/* Name, email, phone and address are written by the Fayda
verification for an Ethiopian company, so only the city — which
the address claim does not reliably decompose into — is typed. */}
{!verifiedIdentity && (
<>
<TextInput
label="PoA Full Name"
placeholder="Authorized Representative Name"
error={errors.poaName?.message}
{...register("poaName")}
/>
<Grid>
<Grid.Col span={6}>
<TextInput
label="PoA Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
</Grid.Col>
<Grid.Col span={6}>
<ControlledPhoneField
control={control}
name="poaPhone"
label="PoA Phone"
/>
</Grid.Col>
</Grid>
</>
)}
{/* Name, email, phone and address are all written by the Fayda
verification, so only the city — which the address claim does
not reliably decompose into — is typed. */}
<Grid>
<Grid.Col span={6}>
<TextInput
@@ -342,16 +278,6 @@ export default function TabPowerOfAttorney({
{...register("poaLocation")}
/>
</Grid.Col>
{!verifiedIdentity && (
<Grid.Col span={6}>
<TextInput
label="PoA Address"
placeholder="Full Address"
error={errors.poaAddress?.message}
{...register("poaAddress")}
/>
</Grid.Col>
)}
</Grid>
</Stack>
@@ -480,7 +406,10 @@ export default function TabPowerOfAttorney({
</Stack>
)}
{profile.reviewStatus === "pending" && (
{/* Keyed on the paper's own staged status, not the company's
review state: the details on this tab now apply live, so a
pending review is just as likely to be about something else. */}
{letters.some((f) => f.status !== "live") && (
<Group gap={6} c="edr-amber-text">
<Clock size={13} />
<Text size="xs" fw={500}>
@@ -527,7 +456,6 @@ export default function TabPowerOfAttorney({
</Group>
<Group gap="md">
{mode === "edit" &&
verifiedIdentity &&
identity?.poa.verified &&
!requirePoa && (
<Button

View File

@@ -73,4 +73,10 @@ export const invoicesService = {
});
return data.data ?? data;
},
/** Submit the CAC Bank OTP for an invoice whose intent is awaiting confirmation. */
confirmOtp: async (id: string, otp: string): Promise<InitiateResponse> => {
const { data } = await client.post(B.CONFIRM_INVOICE_OTP(id), { otp });
return data.data ?? data;
},
};

View File

@@ -0,0 +1,104 @@
import { of } from "rxjs";
import { CacBankProvider } from "@edr/payment-providers";
/**
* Both freight (invoice.balanceAmount) and passenger (CurrencyService) hand the
* provider a MAJOR-currency amount — e.g. 700 for $700, not 70000 cents — with the
* target currency's own decimal precision already applied. CAC previously divided
* anything that wasn't DJF by 100, undercharging every non-DJF payment 100x (a USD
* 700 invoice would have been billed as $7). These pin the fix: the amount reaches
* the bank unscaled, and the bank's own DJF bounds don't leak onto other currencies.
*/
describe("CacBankProvider — amount handling", () => {
const config = {
get: (key: string) =>
({
"cac.baseUrl": "https://cac.example",
"cac.username": "u",
"cac.password": "p",
"cac.appKey": "app",
"cac.apiKey": "api",
"cac.companyServicesId": 21,
"cac.currency": "DJF",
"cac.tokenTtlMs": 3_600_000,
"cac.otpExpiryMs": 600_000,
"cac.httpTimeoutMs": 60_000,
})[key],
};
function build() {
const post = jest.fn();
// 1st call: CacBankAuth signin (axios parses this one normally — no raw-text
// override). 2nd call: PaymentInitiateRequest (raw text; CAC provider parses it
// itself via parseCacResponse to preserve oversized ids).
post
.mockReturnValueOnce(of({ data: { accessToken: "tok" } }))
.mockReturnValueOnce(
of({ data: JSON.stringify({ description: "ok", paymentRequestId: 1 }) }),
);
const provider = new CacBankProvider(config as never, { post } as never);
return { provider, post };
}
it("sends a USD amount unscaled — no more /100 division", async () => {
const { provider, post } = build();
await provider.initiate({
merchantOrderId: "m-1",
orderRef: "INV-1",
amountMinor: 700,
currency: "USD",
payerAccount: "77092076",
});
const body = JSON.parse(post.mock.calls[1][1]);
expect(body.amount).toBe(700);
expect(body.currency).toBe("USD");
});
it("still sends a DJF amount unscaled, matching pre-fix behaviour exactly", async () => {
const { provider, post } = build();
await provider.initiate({
merchantOrderId: "m-2",
orderRef: "INV-2",
amountMinor: 222,
currency: "DJF",
payerAccount: "77092076",
});
const body = JSON.parse(post.mock.calls[1][1]);
expect(body.amount).toBe(222);
});
it("rejects a DJF amount outside the bank's documented 10100,000 bounds", async () => {
const { provider } = build();
await expect(
provider.initiate({
merchantOrderId: "m-3",
orderRef: "INV-3",
amountMinor: 5,
currency: "DJF",
payerAccount: "77092076",
}),
).rejects.toThrow(/outside the accepted range/);
});
it("does not apply the DJF bounds to a USD amount outside that DJF range", async () => {
const { provider, post } = build();
// 500,000 is well past the DJF ceiling (100,000) but is a perfectly normal USD
// amount — the DJF-only bound must not reject it locally.
await provider.initiate({
merchantOrderId: "m-4",
orderRef: "INV-4",
amountMinor: 500_000,
currency: "USD",
payerAccount: "77092076",
});
const body = JSON.parse(post.mock.calls[1][1]);
expect(body.amount).toBe(500_000);
});
});

View File

@@ -61,6 +61,71 @@ services:
- mc alias set e2e http://minio-e2e:9000 e2e-minio e2e-minio-secret && mc mb --ignore-existing e2e/fhc
restart: "no"
# Stand-in for eSignet's token/userinfo endpoints (see fayda-mock/server.js).
# The real Fayda authorization step (phone + SMS OTP) can't run in e2e —
# Cypress bypasses the popup and drives POST start / POST complete directly,
# so this only needs to answer the token exchange + userinfo calls that
# `completeVerification` makes server-side.
fayda-mock-e2e:
image: node:20-alpine
volumes:
- ./e2e/freight/fayda-mock:/app:ro
working_dir: /app
command: ["node", "server.js"]
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://localhost:4400/userinfo').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 3s
timeout: 3s
retries: 10
# Stand-in for https://etrade.gov.et — ETradeService's base URL is
# hardcoded (not env-configurable like Fayda's endpoints), so this is
# reached by DNS alias instead: the "etrade.gov.et" network alias below
# makes freight-api-e2e's real hardcoded requests land here. See
# etrade-mock/server.js.
etrade-mock-e2e:
# Debian-based (not -alpine): ships openssl in the base image, so the
# self-signed cert below needs no package install at container start
# (alpine's apk would have to fetch it from the network every boot).
image: node:20
volumes:
- ./e2e/freight/etrade-mock:/app:ro
working_dir: /app
environment:
TLS_CERT_PATH: /tmp/etrade-mock/cert.pem
TLS_KEY_PATH: /tmp/etrade-mock/key.pem
# Throwaway self-signed cert generated fresh into /tmp on every
# container start — nothing shaped like a key/cert is committed (the
# code mount above is :ro, so this couldn't write there anyway).
command:
- sh
- -c
- >
mkdir -p /tmp/etrade-mock &&
openssl req -x509 -newkey rsa:2048 -keyout "$$TLS_KEY_PATH" -out "$$TLS_CERT_PATH" -days 1 -nodes -subj "/CN=etrade.gov.et" 2>/dev/null &&
node server.js
networks:
default:
aliases:
- etrade.gov.et
healthcheck:
test:
[
"CMD",
"node",
"-e",
"require('https').get({hostname:'localhost',port:443,path:'/api/BusinessMain/GetBusinessByLicenseNo?LicenseNo=x&Tin=x&Lang=en',rejectUnauthorized:false},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))",
]
interval: 3s
timeout: 3s
retries: 10
freight-api-e2e:
build:
context: .
@@ -74,6 +139,10 @@ services:
condition: service_healthy
minio-init-e2e:
condition: service_completed_successfully
fayda-mock-e2e:
condition: service_healthy
etrade-mock-e2e:
condition: service_healthy
environment:
PORT: "3001"
DB_HOST: postgres-freight-e2e
@@ -104,7 +173,21 @@ services:
MINIO_REGION: us-east-1
# External integrations off
RABBITMQ_ENABLED: "false"
FAYDA_ENABLED: "false"
# Fayda ON, pointed at the local mock (fayda-mock-e2e) instead of the
# real eSignet infra — see that service's comment above. Cypress drives
# verification via the API (start + complete), never the real popup.
FAYDA_ENABLED: "true"
FAYDA_CLIENT_ID: e2e-fayda-client
FAYDA_AUTHORIZATION_ENDPOINT: http://fayda-mock-e2e:4400/authorize
FAYDA_TOKEN_ENDPOINT: http://fayda-mock-e2e:4400/token
FAYDA_USERINFO_ENDPOINT: http://fayda-mock-e2e:4400/userinfo
FAYDA_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/callback
FAYDA_PORTAL_REDIRECT_URI: http://localhost:${E2E_PORTAL_PORT:-5373}/callback
# Throwaway e2e-only RSA JWK (client_assertion signing) — the mock
# never verifies the signature, this just has to be well-formed.
# Generated fresh per launch by e2e.mjs (fakeFaydaPrivateKeyBase64),
# not committed here, so nothing shaped like a private key sits in git.
FAYDA_PRIVATE_KEY_BASE64: ${FAYDA_PRIVATE_KEY_BASE64}
# SMS strategy has no kill switch and defaults to a real dev endpoint —
# blackhole it so e2e never sends SMS (failures are logged, non-fatal).
OZIKING_SMS_URL: http://127.0.0.1:9/sms

View File

@@ -20,6 +20,7 @@ import {
acceptExport,
apiPost,
bookBulk,
clearToOperationRequestPending,
createImportSchedule,
db,
departureAt,
@@ -83,8 +84,9 @@ describe("booking cancel: pre-commit only, blocked once allocated", { retries: 0
it("an un-accepted booking cancels, its invoice is expired, and re-cancel is rejected", () => {
bookBulk({ suffix: "CXA", tons: 700, scheduledDate: BOOKING_DAY });
clearToOperationRequestPending("CXA", BOOKING_DAY);
// Fresh non-customs export booking sits at OPERATION_REQUEST_PENDING.
// Cleared non-customs export booking sits at OPERATION_REQUEST_PENDING.
withBooking("CXA", (b) => {
expect(b.status, "pre-accept status").to.eq("OPERATION_REQUEST_PENDING");
cancel(b.id).then((res) => expect(res.status, "cancelled").to.be.oneOf([200, 201]));
@@ -112,6 +114,7 @@ describe("booking cancel: pre-commit only, blocked once allocated", { retries: 0
it("a PAID + allocated booking cannot be cancelled — the commit gate blocks it", () => {
bookBulk({ suffix: "CXB", tons: 700, scheduledDate: BOOKING_DAY });
clearToOperationRequestPending("CXB", BOOKING_DAY);
acceptExport("CXB");
markPaid("CXB");
pollAllocations("CXB", 10); // 700T / 70T = 10 CW4 wagons

View File

@@ -19,6 +19,8 @@ import {
acceptOperation,
apiPost,
bookBulk,
clearIntercityToFullyExecuted,
clearToOperationRequestPending,
closeBookingWindow,
completeDocReview,
createImportSchedule,
@@ -98,8 +100,10 @@ describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train
it("a through-corridor 1 400 T booking and a NAGAD→MOJO 700 T booking share the train", () => {
bookBulk({ suffix: "BM1", tons: 1400, scheduledDate: BOOKING_DAY });
clearToOperationRequestPending("BM1", BOOKING_DAY);
acceptOperation("BM1");
bookBulk({ suffix: "BMSUB", tons: 700, scheduledDate: BOOKING_DAY });
clearToOperationRequestPending("BMSUB", BOOKING_DAY);
acceptOperation("BMSUB");
withSchedule(DEPARTURE, (s) => {
@@ -122,7 +126,9 @@ describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train
it("bulk intercity ride-along: dateless DOMESTIC wheat accepted onto the import train's free leg", () => {
bookBulk({ suffix: "BMIC", tons: 140 }); // 2 wagons MOJO → KALITY, dateless
acceptOperation("BMIC");
// DOMESTIC bookings skip the shipment-day step entirely — finalize alone
// lands FULLY_EXECUTED; staff assign it to a passing train below.
clearIntercityToFullyExecuted("BMIC");
withSchedule(DEPARTURE, (s) => {
withBooking("BMIC", (b) => {
@@ -159,6 +165,7 @@ describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train
withSchedule(GIANT_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
bookBulk({ suffix: "BMG", tons: 4000, scheduledDate: GIANT_DAY });
clearToOperationRequestPending("BMG", GIANT_DAY);
acceptOperation("BMG");
withSchedule(GIANT_DEPARTURE, (s) => {
closeBookingWindow(s.id);
@@ -208,7 +215,7 @@ describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train
expectFailure: "must take the whole",
});
bookBulk({ suffix: "BMG", tons: 220, scheduledDate: REMAINDER_DAY });
pollBookingStatus("BMG", "OPERATION_REQUEST_PENDING", 5);
clearToOperationRequestPending("BMG", REMAINDER_DAY);
});
});

View File

@@ -19,6 +19,7 @@ import {
acceptExport,
apiPost,
bookBulk,
clearToOperationRequestPending,
completeBookingMilestone,
createImportSchedule,
db,
@@ -103,7 +104,7 @@ describe("bulk export: six wheat bookings fill the 54-wagon CW4 train (FCFS)", {
it("six exporters book wheat inside the one window", () => {
BOOKINGS.forEach((b) => {
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
});
});

View File

@@ -20,6 +20,7 @@ import {
acceptOperation,
apiPost,
bookBulk,
clearToOperationRequestPending,
closeBookingWindow,
completeBookingMilestone,
completeDocReview,
@@ -103,7 +104,7 @@ describe("bulk import: six wheat bookings fill the 54-wagon CW4 train", { retrie
it("customer books all six wheat shipments inside the first window", () => {
BOOKINGS.forEach((b) => {
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
});
});

View File

@@ -19,6 +19,7 @@
import {
acceptOperation,
bookBulk,
clearToOperationRequestPending,
closeBookingWindow,
completeDocReview,
createImportSchedule,
@@ -84,6 +85,7 @@ describe("bulk import: split offer, remainder rebooking, expiry + promotion", {
it("seven customers book wheat in the first window; operations accepts them in priority order", () => {
ORDER.forEach((suffix) => {
bookBulk({ suffix, tons: TONS[suffix], scheduledDate: BOOKING_DAY });
clearToOperationRequestPending(suffix, BOOKING_DAY);
acceptOperation(suffix);
});
ORDER.forEach((suffix, i) => setPriority(suffix, i + 1));
@@ -192,7 +194,7 @@ describe("bulk import: split offer, remainder rebooking, expiry + promotion", {
});
bookBulk({ suffix: "BSC", tons: 1120, scheduledDate: REMAINDER_DAY });
pollBookingStatus("BSC", "OPERATION_REQUEST_PENDING", 5);
clearToOperationRequestPending("BSC", REMAINDER_DAY);
});
});

View File

@@ -73,7 +73,8 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
cy.mantineSelect(/^Contract Kind/, "General Contract");
cy.mantineSelect(/^New or Renewal/, "New Contract");
cy.contains("Rail Transport Only", { timeout: 15000 }).click();
cy.mantineSelect(/^Payment Currency/, /^ETB/);
// Contracts are always quoted in USD now — the billing currency is picked
// per shipment at booking time, not here.
cy.contains("button", "Continue").click({ force: true });
// Step 1 — Cargo & Route. Container contracts now auto-cover BOTH 20ft &

View File

@@ -23,6 +23,7 @@ import {
acceptExport,
apiPost,
bookContainers,
clearToOperationRequestPending,
createImportSchedule,
db,
dbBooking,
@@ -118,7 +119,7 @@ describe("export: six bookings fill the 54-wagon corridor train (FCFS)", { retri
scheduledDate: BOOKING_DAY,
});
isoSeed += b.twenty + b.forty;
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
});
});

View File

@@ -21,6 +21,8 @@
* Sequential steps of one journey — retries off (steps are not idempotent).
*/
import { clearBookingClearance } from "./import-utils";
const customer = "user@gmail.com";
const companyTin = "0102030405"; // seed-company.sql
const opsStaff = "operation@edr.local";
@@ -477,7 +479,8 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
cy.mantineSelect(/^New or Renewal/, "New Contract");
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true });
cy.mantineSelect(/^Payment Currency/, /^ETB/);
// Contracts are always quoted in USD now — the billing currency is picked
// per shipment at booking time, not here.
cy.contains("button", "Continue").click({ force: true });
// Container contracts auto-cover both 20ft & 40ft (no size picker) and the
@@ -537,6 +540,12 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
cy.contains("button", "Confirm & book").click();
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
withBooking("CONTAINER", (b) => {
expect(b.status, "born in the clearance gate").to.eq("AWAITING_DOCUMENTS");
expect(b.scheduled_date, "export bookings carry a shipment day").to.be.a("string");
clearBookingClearance(b.id, b.scheduled_date!);
});
withBooking("CONTAINER", (b) => {
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
expect(b.scheduled_date, "export bookings carry a shipment day").to.be.a("string");
@@ -561,7 +570,8 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
cy.mantineSelect(/^New or Renewal/, "New Contract");
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true });
cy.mantineSelect(/^Payment Currency/, /^ETB/);
// Contracts are always quoted in USD now — the billing currency is picked
// per shipment at booking time, not here.
cy.contains("button", "Continue").click({ force: true });
cy.mantineSelect(/^Cargo Scope/, /General \/ Bulk cargo/);
@@ -605,6 +615,11 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
cy.contains("button", "Confirm & book").click();
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
withBooking("BULK", (b) => {
expect(b.status, "born in the clearance gate").to.eq("AWAITING_DOCUMENTS");
clearBookingClearance(b.id, b.scheduled_date!);
});
withBooking("BULK", (b) => {
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
});

View File

@@ -69,6 +69,44 @@ export function apiPost(
);
}
/**
* Drive Fayda identity verification via the API, bypassing the real
* popup+SMS flow entirely — `fayda-mock-e2e` stands in for eSignet's
* token/userinfo endpoints (see docker-compose.e2e.yaml), so a throwaway
* code is enough: start a session, then complete it — mirrors what
* FaydaVerifyPanel's popup + postMessage dance does server-side, minus the
* UI. Uses the browser's OWN auth-token cookie rather than `tokenFor` —
* this only ever runs for a user already logged in in the running test
* (the real flow requires being on an authenticated page to see the
* "Verify with Fayda" button at all), and `tokenFor` only knows how to log
* in fixed seeded accounts, not a signup created earlier in the same test
* with its own freshly-chosen password.
*/
export function completeFaydaVerification(subject: "owner" | "poa") {
return cy.getCookie("auth-token").then((cookie) => {
expect(cookie, "authenticated session (auth-token cookie)").to.not.be.null;
const headers = { Authorization: `Bearer ${cookie!.value}` };
return cy
.request({
method: "POST",
url: `${apiUrl()}/api/fayda/verification/start`,
headers,
body: { purpose: "VERIFY", platform: "PORTAL" },
})
.then((res) => {
const authorizationUrl: string = res.body.data.authorizationUrl;
const state = new URL(authorizationUrl).searchParams.get("state");
expect(state, "fayda session state").to.be.a("string");
return cy.request({
method: "POST",
url: `${apiUrl()}/api/companies/identity/fayda/complete`,
headers,
body: { subject, code: "e2e-mock-code", state },
});
});
});
}
export function apiGet(email: string, path: string, failOnStatusCode = true) {
return tokenFor(email).then((token) =>
cy.request({
@@ -457,13 +495,86 @@ export function bookBulk(opts: {
});
}
/**
* Upload one ad-hoc doc → GL approves it → finalize → customer proceeds with
* the shipment day. The e2e seed configures no required documents, so one
* doc satisfies the 100%-approved gate. Takes a bookingId directly so specs
* with their own booking lookup (not the *-suffix convention) can reuse it.
*
* KNOWN GAP — customs (`customsClearingEnabled`) bookings do NOT use this
* path: `finalizeClearance` rejects them outright ("General customs bookings
* use phased clearance — complete milestones via the phased actions instead
* of finalize"). Their real flow is a separate multi-step phased chain
* (transit permit upload → finalizePreClearance → delivery order upload for
* IMPORT; a shorter EXPORT_RELEASED milestone for EXPORT) that no spec here
* drives yet — see booking-clearance.service.ts / clearance-workflow.service.ts.
* Calling this on a customs booking 400s at the finalize step; callers with
* customs suffixes in the mix (the full_train specs' BF-series customs:
* true bookings) currently accept that those specific bookings — and only
* those — fail here until the phased-clearance walk is written.
*/
export function clearBookingClearance(bookingId: string, scheduledDate: string) {
glUpload(`/api/bookings/${bookingId}/clearance/documents`, {}, "custom_e2e");
apiPost(superAdmin, `/api/bookings/${bookingId}/clearance/review`, {
fileKey: "custom_e2e",
status: "APPROVED",
})
.its("status")
.should("be.oneOf", [200, 201]);
apiPost(superAdmin, `/api/bookings/${bookingId}/clearance/finalize`)
.its("status")
.should("be.oneOf", [200, 201]);
apiPost(customer, `/api/bookings/${bookingId}/clearance/proceed`, { scheduledDate })
.its("status")
.should("be.oneOf", [200, 201]);
}
/**
* Every contract booking is now born in the clearance gate — walk a *-suffix
* booking from AWAITING_DOCUMENTS to OPERATION_REQUEST_PENDING via
* {@link clearBookingClearance}.
*/
export function clearToOperationRequestPending(suffix: string, scheduledDate: string) {
withBooking(suffix, (b) => {
expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS");
clearBookingClearance(b.id, scheduledDate);
});
pollBookingStatus(suffix, "OPERATION_REQUEST_PENDING", 5);
}
/**
* DOMESTIC (intercity) bookings never pick a shipment day — there is no
* `clearance/proceed` step for them. `finalizeClearance` sends an approved
* intercity booking straight to FULLY_EXECUTED (the ride-along pool); staff
* assign it to a passing train separately. Takes a bookingId directly, like
* {@link clearBookingClearance}.
*/
export function clearIntercityBookingClearance(bookingId: string) {
glUpload(`/api/bookings/${bookingId}/clearance/documents`, {}, "custom_e2e");
apiPost(superAdmin, `/api/bookings/${bookingId}/clearance/review`, {
fileKey: "custom_e2e",
status: "APPROVED",
})
.its("status")
.should("be.oneOf", [200, 201]);
apiPost(superAdmin, `/api/bookings/${bookingId}/clearance/finalize`)
.its("status")
.should("be.oneOf", [200, 201]);
}
/** Walk a *-suffix DOMESTIC booking from AWAITING_DOCUMENTS to FULLY_EXECUTED. */
export function clearIntercityToFullyExecuted(suffix: string) {
withBooking(suffix, (b) => {
expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS");
clearIntercityBookingClearance(b.id);
});
pollBookingStatus(suffix, "FULLY_EXECUTED", 10);
}
/**
* Walk a GENERAL booking through its PER-BOOKING clearance chain (Path A):
* AWAITING_DOCUMENTS → upload one doc → GL approves it → finalize →
* CLEARANCE_READY → customer proceeds with the shipment day →
* OPERATION_REQUEST_PENDING → ops accept → FULLY_EXECUTED (pool). The e2e
* seed configures no required documents, so one ad-hoc doc satisfies the
* 100%-approved gate.
* AWAITING_DOCUMENTS → ... → OPERATION_REQUEST_PENDING (clearToOperationRequestPending)
* → ops accept → FULLY_EXECUTED (pool).
*/
export function clearGeneralBooking(
suffix: string,
@@ -471,22 +582,7 @@ export function clearGeneralBooking(
/** EXPORT ends at acceptExport (FCFS reserves on accept), not the pool. */
mode: "import" | "export" = "import",
) {
withBooking(suffix, (b) => {
expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS");
glUpload(`/api/bookings/${b.id}/clearance/documents`, {}, "custom_e2e");
apiPost(superAdmin, `/api/bookings/${b.id}/clearance/review`, {
fileKey: "custom_e2e",
status: "APPROVED",
})
.its("status")
.should("be.oneOf", [200, 201]);
apiPost(superAdmin, `/api/bookings/${b.id}/clearance/finalize`)
.its("status")
.should("be.oneOf", [200, 201]);
apiPost(customer, `/api/bookings/${b.id}/clearance/proceed`, { scheduledDate })
.its("status")
.should("be.oneOf", [200, 201]);
});
clearToOperationRequestPending(suffix, scheduledDate);
if (mode === "export") acceptExport(suffix);
else acceptOperation(suffix);
}

View File

@@ -21,6 +21,7 @@ import {
acceptOperation,
apiPost,
bookContainers,
clearToOperationRequestPending,
closeBookingWindow,
completeBookingMilestone,
completeDocReview,
@@ -113,7 +114,7 @@ describe("import: six bookings fill the 54-wagon corridor train", { retries: 0 }
scheduledDate: BOOKING_DAY,
});
isoSeed += b.twenty + b.forty;
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
});
});

View File

@@ -22,6 +22,7 @@ import {
resetCorridorDay,
acceptOperation,
bookContainers,
clearToOperationRequestPending,
closeBookingWindow,
completeDocReview,
createImportSchedule,
@@ -91,6 +92,7 @@ describe("import: split offer, remainder rebooking, expiry + promotion", { retri
scheduledDate: BOOKING_DAY,
});
isoSeed += s.twenty + s.forty;
clearToOperationRequestPending(suffix, BOOKING_DAY);
acceptOperation(suffix);
});
ORDER.forEach((suffix, i) => setPriority(suffix, i + 1));
@@ -217,7 +219,7 @@ describe("import: split offer, remainder rebooking, expiry + promotion", { retri
twenty: 32,
scheduledDate: REMAINDER_DAY,
});
pollBookingStatus("SC", "OPERATION_REQUEST_PENDING", 5);
clearToOperationRequestPending("SC", REMAINDER_DAY);
});
});

View File

@@ -18,9 +18,11 @@
* 7. backoffice — operations approves the document, then finalizes document
* approval → FULLY_EXECUTED
* 8. portal — customer books 2 × 20ft under the contract (intercity has
* no shipment date) → booking OPERATION_REQUEST_PENDING
* 9. backoffice — operations accepts the operation request → booking
* FULLY_EXECUTED (intercity waiting pool)
* no shipment date) → booking AWAITING_DOCUMENTS → customer
* uploads a doc, GL approves, finalize → DOMESTIC bookings
* skip the shipment-day step entirely, so finalize alone
* lands FULLY_EXECUTED (the intercity waiting pool) — there
* is no separate operations "accept the request" step.
* 10. backoffice — operations creates the EXPORT route
* Mojo → Dire Dawa → Djibouti Port (distances seeded)
* 11. backoffice — operations schedules the export train (built Train-Builder
@@ -34,6 +36,8 @@
* Sequential steps of one journey — retries off (steps are not idempotent).
*/
import { clearIntercityBookingClearance } from "./import-utils";
const customer = "user@gmail.com";
const companyTin = "0102030405"; // seed-company.sql
const opsStaff = "operation@edr.local";
@@ -153,12 +157,13 @@ function createIntercityContract() {
cy.loginPortal(customer);
cy.visitPortal("/contracts/new");
// Step 0 — Setup. Intercity forces ETB and hides the customs section.
// Step 0 — Setup. Intercity hides the customs section; the billing currency
// step moved to booking time (intercity always bills ETB there, per app
// comment in step2-service-type.tsx), so it is not selected here.
cy.mantineSelect(/^Operation Type/, /^Intercity$/);
cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
cy.mantineSelect(/^New or Renewal/, "New Contract");
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click();
cy.mantineSelect(/^Payment Currency/, /^ETB/);
cy.contains("button", "Continue").click({ force: true });
// Step 1 — Cargo & Route (Ethiopian yards only for intercity). Container
@@ -422,21 +427,15 @@ describe("intercity one-time journey: contract → booking → export train", {
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
withBooking((b) => {
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
expect(b.scheduled_date, "intercity bookings carry no scheduled date").to.eq(null);
expect(b.status, "born in the clearance gate").to.eq("AWAITING_DOCUMENTS");
clearIntercityBookingClearance(b.id);
});
});
it("operations accepts the operation request — booking joins the intercity pool", () => {
cy.loginBackoffice(opsStaff);
withBooking((b) => cy.visit(`/dashboard/booking-requests/${b.id}`));
cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click();
cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible");
cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click();
withBooking((b) => {
expect(b.status, "accepted intercity booking waits in the pool").to.eq("FULLY_EXECUTED");
expect(b.status, "DOMESTIC finalize skips straight to the pool").to.eq(
"FULLY_EXECUTED",
);
expect(b.scheduled_date, "intercity bookings carry no scheduled date").to.eq(null);
expect(b.train_schedule_id).to.eq(null);
});
});

View File

@@ -21,6 +21,7 @@ import {
apiPost,
bookBulk,
bookContainers,
clearToOperationRequestPending,
completeBookingMilestone,
createImportSchedule,
db,
@@ -120,7 +121,7 @@ describe("mixed export: containers and wheat share one 54-wagon FCFS train", { r
} else {
bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY });
}
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
});
});

View File

@@ -21,6 +21,7 @@ import {
apiPost,
bookBulk,
bookContainers,
clearToOperationRequestPending,
closeBookingWindow,
completeBookingMilestone,
completeDocReview,
@@ -113,7 +114,7 @@ describe("mixed import: containers and wheat share one 54-wagon train", { retrie
} else {
bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY });
}
pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
});
});

View File

@@ -23,6 +23,7 @@ import {
acceptOperation,
bookBulk,
bookContainers,
clearToOperationRequestPending,
closeBookingWindow,
completeDocReview,
createImportSchedule,
@@ -96,10 +97,12 @@ describe("mixed import: container split; container-freed wagons promote the bulk
scheduledDate: BOOKING_DAY,
});
isoSeed += b.twenty;
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
acceptOperation(b.suffix);
});
BULKS.forEach((b) => {
bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
clearToOperationRequestPending(b.suffix, BOOKING_DAY);
acceptOperation(b.suffix);
});
CONTAINERS.forEach((b, i) => setPriority(b.suffix, i + 1));
@@ -209,7 +212,7 @@ describe("mixed import: container split; container-freed wagons promote the bulk
twenty: 20,
scheduledDate: REMAINDER_DAY,
});
pollBookingStatus("MSX", "OPERATION_REQUEST_PENDING", 5);
clearToOperationRequestPending("MSX", REMAINDER_DAY);
});
});

View File

@@ -18,15 +18,15 @@
* journey's user/company from the DB instead of module variables.
*/
import { completeFaydaVerification } from "./import-utils";
const stamp = Date.now();
const email = `e2e.onboard.${stamp}@example.com`;
// Ethiopian mobile: 9 + 8 digits, unique per run.
const phoneNational = `9${String(stamp).slice(-8)}`;
const signupPassword = "Password@e2e1";
const companyName = `E2E Onboard Co ${stamp}`;
const tin = String(stamp).slice(-10).padStart(10, "1");
const vat = String(stamp + 1).slice(-10).padStart(10, "2");
const fan = String(stamp).slice(-13).padStart(16, "3");
const portal = () => Cypress.env("portalUrl") as string;
@@ -61,12 +61,6 @@ function fillPhone(index: number, national: string) {
describe("customer onboarding journey", { retries: 0 }, () => {
it("signs up with OTP and completes the onboarding wizard", () => {
// The eTrade TIN lookup 400s in e2e (external service unreachable). The
// form handles it ("fill in the details manually") but axios also throws
// an uncaught rejection — ignore just that one.
cy.on("uncaught:exception", (err) =>
err.message.includes("Request failed with status code 400") ? false : true,
);
cy.visit(`${portal()}/signup`);
fill(/^First name/, "Onboard");
@@ -91,20 +85,39 @@ describe("customer onboarding journey", { retries: 0 }, () => {
cy.contains("button", "Importer").click();
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
// Company step. TIN first — the eTrade auto-lookup fails in e2e (no
// external network) and the form allows manual entry.
cy.get('input[placeholder="0012345678"]', { timeout: 15000 }).type(tin);
fill(/^Company Name/, companyName);
fill(/^Company Email/, `ops.${stamp}@example.com`);
fillPhone(0, "911234567");
fill(/^Location/, "Addis Ababa, Ethiopia");
// Ethiopian companies gate the "Owner identity" step on Fayda
// verification — a real popup + SMS OTP flow that can't run in e2e.
// Complete it via the API against fayda-mock-e2e (the profile this
// attaches to was just created by the nationality/role step above).
// The wizard already fetched `identity` once when this step mounted —
// completing verification out-of-band (no popup, so no onVerified
// callback fires) leaves that fetch stale, so reload to force a fresh
// one. Wizard progress resumes server-side, so this doesn't lose the
// nationality/role step just completed.
completeFaydaVerification("owner");
cy.reload();
cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
"be.visible",
);
// Company step. TIN auto-triggers the eTrade lookup once it's a full 10
// digits (mocked in e2e — see docker-compose.e2e.yaml's etrade-mock-e2e).
// A successful lookup locks Company Name/Region/Zone/Woreda/Kebele/House
// No as read-only (ETradeCompanyCard) — nothing left to type there, and
// Company Email/Phone/Location were dropped from this step entirely (the
// Fayda-verified owner supplies contact details now). By label, not
// placeholder: the VAT Number field on this same step shares the TIN
// field's "0012345678" placeholder, so a placeholder selector matches 2.
fill(/^TIN Number/, tin);
cy.contains("Verified with eTrade", { timeout: 15000 }).should(
"be.visible",
);
// handleETradeDataLoaded sets several fields in sequence (name, region,
// zone, woreda, kebele, houseNo) — each a render, still settling right
// after the badge appears. Typing into VAT immediately raced one of
// those and detached mid-type; let it finish before touching the form.
cy.wait(500);
fill(/^VAT Number/, vat);
cy.get('input[placeholder="1234567890123456"]').type(fan);
cy.mantineSelect(/^Region/, "Addis Ababa");
fill(/^Zone/, "Zone 1");
fill(/^Woreda/, "Woreda 1");
fill(/^Kebele/, "Kebele 1");
fill(/^House No/, "123");
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
// Personnel (general manager).

View File

@@ -141,34 +141,55 @@ Cypress.Commands.add("mantineSelect", (label: string | RegExp, option: string |
* takes explicit Start/End dates and pre-fills neither, so "Accept & start
* approval" stays disabled until both are set.
*
* Mantine's DateInput parses typed text with its valueFormat, which defaults to
* "MMMM D, YYYY" — the same shape en-US toLocaleDateString produces.
* The fields are Mantine DateTimePickers — a button that opens a calendar
* popover, not a typeable input. Zoom out to the decade view (2 clicks on the
* header's middle control, which has no [data-direction]) then drill back
* down year → month → day, and confirm with the popover's submit (check)
* button — clicking a day alone only stages the value, it does not close
* the popover or commit the pick.
*/
const MONTH_ABBR = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
] as const;
const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
] as const;
Cypress.Commands.add("acceptValidityWindow", (days = 365) => {
const start = new Date();
const end = new Date(start.getTime() + days * 24 * 60 * 60 * 1000);
const asInput = (d: Date) =>
d.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
for (const [label, value] of [
["Start date", start],
["End date", end],
] as const) {
const pickDate = (label: string, date: Date) => {
cy.contains("label", label)
.invoke("attr", "for")
.then((id) => {
cy.get(`[id="${id}"]`)
.clear({ force: true })
.type(asInput(value), { force: true })
// DateInput commits on blur; it also closes the calendar popover,
// which would otherwise sit over the submit button.
.blur();
});
}
.then((id) => cy.get(`[id="${id}"]`).click({ force: true }));
// month view -> year view -> decade view.
for (let i = 0; i < 2; i++) {
cy.get('.mantine-Popover-dropdown [data-direction="previous"]')
.siblings("button")
.first()
.click({ force: true });
}
cy.get(".mantine-Popover-dropdown")
.contains("button", new RegExp(`^${date.getFullYear()}$`))
.click({ force: true });
cy.get(".mantine-Popover-dropdown")
.contains("button", new RegExp(`^${MONTH_ABBR[date.getMonth()]}$`))
.click({ force: true });
const dayAriaLabel = `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}`;
cy.get(`.mantine-Popover-dropdown [aria-label="${dayAriaLabel}"]`).click({
force: true,
});
cy.get(".mantine-DateTimePicker-submitButton").click({ force: true });
};
pickDate("Start date", start);
pickDate("End date", end);
});
/**

View File

@@ -0,0 +1,124 @@
// Stand-in for https://etrade.gov.et in the e2e stack. ETradeService's base
// URL is hardcoded (apps/edr-freight-api/src/modules/companies/services/etrade.service.ts),
// not env-configurable, so this is reached by aliasing this container AS
// "etrade.gov.et" on the compose network (see docker-compose.e2e.yaml) rather
// than by pointing an env var here. HTTPS because the real service is HTTPS
// — the cert itself only needs to exist, never be trusted, since
// ETradeService's httpsAgent sets rejectUnauthorized:false. The compose
// service's command generates a throwaway self-signed cert into
// TLS_CERT_PATH/TLS_KEY_PATH before starting this — nothing shaped like a
// key/cert is committed here.
//
// Every TIN resolves to the same canned company — these specs don't care
// about per-TIN business logic, only that the lookup succeeds so the rest
// of the company-info form (name, address, manager) auto-fills instead of
// staying gated behind a "No matching business record" alert.
const https = require("node:https");
const fs = require("node:fs");
const PORT = process.env.PORT || 443;
const options = {
key: fs.readFileSync(process.env.TLS_KEY_PATH || "/tmp/etrade-mock/key.pem"),
cert: fs.readFileSync(
process.env.TLS_CERT_PATH || "/tmp/etrade-mock/cert.pem",
),
};
function companyInfo(tin) {
return {
Tin: tin,
LegalCondtion: "Private Limited Company",
RegNo: "REG-E2E-0001",
RegDate: "2020-01-01",
// Embeds the (per-run-unique) TIN rather than a static name — specs
// that look a company up by name (e.g. onboarding.cy.ts) need this to
// stay unique across repeated e2e runs against the same warm DB, same
// as it would be if the customer had typed a real company name.
BusinessName: `E2E Mock Trading PLC ${tin}`,
BusinessNameAmh: "ኢቱኢ ሞክ ትሬዲንግ",
PaidUpCapital: 100000,
AssociateShortInfos: [],
Businesses: [
{
MainGuid: "e2e-guid-0001",
OwnerTIN: tin,
DateRegistered: "2020-01-01",
TradeNameAmh: "ኢቱኢ",
TradesName: "E2E Mock Trading",
LicenceNumber: "LIC-E2E-0001",
RenewalDate: "2026-01-01",
RenewedFrom: "2025-01-01",
RenewedTo: "2027-01-01",
BusinessLicensingGroupMain: null,
SubGroups: null,
},
],
};
}
function businessInfo(tin) {
return {
MainGuid: "e2e-guid-0001",
OwnerTIN: tin,
DateRegistered: "2020-01-01",
TradeName: "E2E Mock Trading",
LicenceNumber: "LIC-E2E-0001",
Status: 1,
StatusDescription: "Active",
Capital: 100000,
AssociateShortInfos: [
{
Position: "Manager",
ManagerName: "አበበ በቀለ",
ManagerNameEng: "Abebe Bekele",
Photo: null,
MobilePhone: "+251911223344",
RegularPhone: null,
},
],
AddressInfo: {
Region: "ADDIS ABABA",
Zone: "Zone 1",
Woreda: "Woreda 1",
Kebele: "Kebele 1",
HouseNo: "123",
MobilePhone: "+251911223344",
RegularPhone: "",
},
RenewedTo: "2027-01-01",
RenewedToDateString: "2027-01-01",
RenewalDate: "2026-01-01",
RenewedFrom: "2025-01-01",
CancellationDate: null,
};
}
const server = https.createServer(options, (req, res) => {
const url = new URL(req.url, `https://${req.headers.host}`);
const regMatch = url.pathname.match(
/^\/api\/Registration\/GetRegistrationInfoByTin\/([^/]+)\/en$/,
);
if (req.method === "GET" && regMatch) {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(companyInfo(regMatch[1])));
return;
}
if (
req.method === "GET" &&
url.pathname === "/api/BusinessMain/GetBusinessByLicenseNo"
) {
const tin = url.searchParams.get("Tin") || "";
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(businessInfo(tin)));
return;
}
res.writeHead(404).end();
});
server.listen(PORT, () => {
console.log(`etrade-mock listening on ${PORT}`);
});

View File

@@ -0,0 +1,52 @@
// Minimal eSignet stand-in for the e2e stack. freight-api-e2e's
// FAYDA_TOKEN_ENDPOINT / FAYDA_USERINFO_ENDPOINT point here instead of the
// real (unreachable) Fayda infrastructure. It does not validate the
// client_assertion, code, or code_verifier it's sent — Cypress drives the
// verification via the API directly (POST start, then POST complete with a
// throwaway code), never through the real popup+SMS flow, so nothing here
// needs to be cryptographically real, only shaped like a working OIDC token
// + userinfo response.
const http = require("node:http");
const PORT = process.env.PORT || 4400;
const USERINFO = {
sub: "e2e-fayda-sub-0001",
name: "Abebe Bekele",
"name#en": "Abebe Bekele",
email: "abebe.bekele@example.com",
phone_number: "+251911223344",
gender: "Male",
"gender#en": "Male",
birthdate: "1990-01-01",
"address#en": "Bole Sub City, Addis Ababa, Ethiopia",
};
const server = http.createServer((req, res) => {
// Drain the body so the client's request completes cleanly even though we
// never read it (POST /token sends a form-urlencoded body we don't parse).
req.on("data", () => {});
req.on("end", () => {
if (req.method === "POST" && req.url.startsWith("/token")) {
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
access_token: "e2e-mock-access-token",
token_type: "Bearer",
expires_in: 3600,
}),
);
return;
}
if (req.method === "GET" && req.url.startsWith("/userinfo")) {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(USERINFO));
return;
}
res.writeHead(404).end();
});
});
server.listen(PORT, () => {
console.log(`fayda-mock listening on ${PORT}`);
});

View File

@@ -16,6 +16,7 @@
*/
import { execFileSync, spawnSync } from "node:child_process";
import { generateKeyPairSync } from "node:crypto";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:net";
import { dirname, join, resolve } from "node:path";
@@ -110,6 +111,24 @@ async function resolvePorts() {
return ports;
}
/**
* Throwaway RSA JWK for FAYDA_PRIVATE_KEY_BASE64 — signs a client_assertion
* JWT that fayda-mock-e2e never verifies (see docker-compose.e2e.yaml), so
* a fresh one every launch is fine; nothing needs it to stay stable across
* runs the way ports do. Generated here instead of committed as a literal
* blob in the compose file so nothing that looks like a private key sits in
* git history, even though this one only unlocks a local mock.
*/
function fakeFaydaPrivateKeyBase64() {
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const jwk = privateKey.export({ format: "jwk" });
jwk.kty = "RSA";
jwk.use = "sig";
jwk.alg = "RS256";
jwk.kid = "e2e-fayda-mock";
return Buffer.from(JSON.stringify(jwk)).toString("base64");
}
function envFor(ports) {
return {
...process.env,
@@ -120,6 +139,8 @@ function envFor(ports) {
CYPRESS_API_URL: `http://localhost:${ports.E2E_API_PORT}`,
CYPRESS_PORTAL_URL: `http://localhost:${ports.E2E_PORTAL_PORT}`,
E2E_DB_URL: `postgres://edr_e2e:edr_e2e@localhost:${ports.E2E_DB_PORT}/edr_freight_e2e`,
FAYDA_PRIVATE_KEY_BASE64:
process.env.FAYDA_PRIVATE_KEY_BASE64 ?? fakeFaydaPrivateKeyBase64(),
};
}

View File

@@ -27,7 +27,9 @@ import type {
CacPaymentInitiateResponse,
} from "./cac-bank.types";
/** Bank-enforced amount bounds (PaymentInitiateRequest spec: between 10 and 100,000 DJF). */
/** Bank-enforced amount bounds (PaymentInitiateRequest spec: between 10 and 100,000 DJF). Not
* documented for other settlement currencies (e.g. USD) — skip the local pre-check there and
* let the bank's own validation reject an out-of-range amount. */
const CAC_MIN_AMOUNT = 10;
const CAC_MAX_AMOUNT = 100_000;
@@ -73,10 +75,14 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
}
const customerMobile = normalizeCacMobile(input.payerAccount);
const amount = this.toMajorAmount(input.amountMinor, input.currency);
if (amount < CAC_MIN_AMOUNT || amount > CAC_MAX_AMOUNT) {
const amount = this.toMajorAmount(input.amountMinor);
const currency = (input.currency || this.defaultCurrency).toUpperCase();
if (
currency === "DJF" &&
(amount < CAC_MIN_AMOUNT || amount > CAC_MAX_AMOUNT)
) {
throw new Error(
`CAC Bank amount ${amount} ${input.currency} is outside the accepted range ` +
`CAC Bank amount ${amount} DJF is outside the accepted range ` +
`(${CAC_MIN_AMOUNT}${CAC_MAX_AMOUNT} DJF)`,
);
}
@@ -85,7 +91,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
app_key: this.appKey,
api_key: this.apiKey,
customer_mobile: customerMobile,
currency: input.currency || this.defaultCurrency,
currency,
desc: `${input.orderRef}`.slice(0, 500),
vender_ref: input.merchantOrderId,
amount,
@@ -303,12 +309,14 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
return this.auth;
}
/** DJF has no fractional units — amountMinor is the major amount. */
private toMajorAmount(amountMinor: number, currency: string): number {
if (currency.toUpperCase() === "DJF") {
return amountMinor;
}
return amountMinor / 100;
/**
* Both callers (freight's invoice.balanceAmount, passenger's CurrencyService) already
* hand off a major-currency amount — e.g. 700 for $700, not 70000 cents — with the target
* currency's own decimal precision already applied (0dp for DJF, 2dp for USD/ETB). CAC Bank
* bills in that same major unit, so it is forwarded unchanged.
*/
private toMajorAmount(amountMinor: number): number {
return amountMinor;
}
private sanitizeKeys(