From 5a02da91d200dd10cf5b8ed6a9017ec9a9d0c169 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 31 Jul 2026 07:38:49 +0000 Subject: [PATCH 1/6] refactor(api): extract self-service attributes for immediate application and poa verification improvement --- .../companies.fayda-identity.spec.ts | 64 ++++++-- .../companies.poa-delegation.spec.ts | 3 + .../modules/companies/companies.service.ts | 141 ++++++++++++------ .../dto/complete-identity-verification.dto.ts | 9 +- .../portal/src/pages/SettingsPage.tsx | 34 +++-- .../src/pages/accounts/CompanyProfileForm.tsx | 121 +++------------ .../accounts/companyProfileForm/helpers.ts | 14 +- .../accounts/companyProfileForm/schema.ts | 60 ++------ .../src/pages/settings/TabPowerOfAttorney.tsx | 124 ++++----------- 9 files changed, 237 insertions(+), 333 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index 7f72d431a..c13f79f5e 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -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 = {}) { }, changeRequestRepo: { findPendingByCompanyId: jest.fn(async () => null), + findLatestOpenByCompanyId: jest.fn(async () => null), findByCompanyId: jest.fn(async () => []), create: jest.fn(async (row: Record) => ({ 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 () => { diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts index 9e1c76477..27c42e581 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts @@ -81,6 +81,9 @@ function makeService(overrides: Partial = {}) { 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) => ({ id: "cr-1", diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 50cc648ad..c198d149f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 = {}; + const staged: Record = {}; + 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(), ); diff --git a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts index 6e7468c8a..a9988cd28 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts @@ -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 }; } diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index ba19e34ce..8bbe59284 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -287,9 +287,11 @@ export default function SettingsPage() { icon={} 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. )} {reviewStatus === "rejected" && ( @@ -358,9 +360,17 @@ export default function SettingsPage() { )} - {/* 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. */}
@@ -368,19 +378,13 @@ export default function SettingsPage() { -
- -
+
-
- -
+
-
- -
+
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index e79d06bf6..9c62b494d 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -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({ 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."} + {/* 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 && ( onIdentityChange?.()} /> )} - {!verifiedIdentity && watch("contactPersonName") && ( - - )} - {!verifiedIdentity && ( - <> - - - - - - - - - - - )} {/* The city is the one field the Fayda address claim does not - reliably decompose into, so it stays typed either way. */} - {verifiedIdentity && ( - - )} + reliably decompose into, so it stays typed. */} + {poaDocumentSetting && ( <> diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts index 944177ae3..f1a9f4301 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts @@ -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 {}; } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts index 4732678cc..2cd06ef8f 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts @@ -92,58 +92,28 @@ export type FormData = z.infer; /** 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) => - 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 = { "contactPersonEmail", "contactPersonPhone", ], - poa: [...POA_FIELDS], + poa: ["poaLocation"], documents: [], additional: [], }; diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx index 520e29d42..a3ef08078 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx @@ -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; @@ -98,22 +93,15 @@ export default function TabPowerOfAttorney({ const { view, viewer } = useFileViewer(); const uploadInputRef = useRef(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({ 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(null); const [removeIds, setRemoveIds] = useState([]); 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 } | null) - ?.faydaIdentity?.poaFaydaSub, - )} onVerified={() => { queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey(), @@ -300,39 +266,9 @@ export default function TabPowerOfAttorney({
- {/* 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 && ( - <> - - - - - - - - - - - - )} - + {/* 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. */} - {!verifiedIdentity && ( - - - - )} @@ -480,7 +406,10 @@ export default function TabPowerOfAttorney({ )} - {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") && ( @@ -527,7 +456,6 @@ export default function TabPowerOfAttorney({ {mode === "edit" && - verifiedIdentity && identity?.poa.verified && !requirePoa && ( + + + + + ); + } + return ( ))} + + {needsMobile && ( + setMobile(e.currentTarget.value)} + disabled={processing} + styles={{ + label: { fontSize: 12.5, fontWeight: 700, color: "#10202F" }, + description: { fontSize: 11.5 }, + }} + /> + )} {/* Footer */} @@ -228,7 +371,9 @@ export function PaymentMethodModal({ - 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"} @@ -248,15 +393,21 @@ export function PaymentMethodModal({ diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx index 927f1dbfb..a146dfb11 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx @@ -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(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} /> ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx index fca38aec7..7a9949e8a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayNowButton.tsx @@ -55,6 +55,7 @@ export function PayNowButton({ currency={pricing?.currency ?? booking.paymentCurrency} processing={pay.processing} error={pay.error} + otp={pay.otp} onConfirm={pay.confirm} /> diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts b/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts index 9725fea12..bb1bd33ab 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/payments/useBookingPayment.ts @@ -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); + }, }; } diff --git a/apps/edr-freight-web/portal/src/services/invoices.service.ts b/apps/edr-freight-web/portal/src/services/invoices.service.ts index 2ee18ec4e..6be426390 100644 --- a/apps/edr-freight-web/portal/src/services/invoices.service.ts +++ b/apps/edr-freight-web/portal/src/services/invoices.service.ts @@ -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 => { + const { data } = await client.post(B.CONFIRM_INVOICE_OTP(id), { otp }); + return data.data ?? data; + }, }; From 6f5f6d7b0aae12f1332c1ad73c3f5a1f4c30251d Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 31 Jul 2026 08:20:35 +0000 Subject: [PATCH 3/6] test(payment): cover the CAC Bank OTP confirm path The confirm flow settles money on a provider with no webhook, so the two places it can go wrong are worth pinning: that the OTP is forwarded against the GATEWAY intent id (not the local projection id), and that a rejected code leaves the intent open instead of failing the payment. Co-Authored-By: Claude Opus 5 (1M context) --- .../modules/payment/payment.service.spec.ts | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/payment/payment.service.spec.ts diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts new file mode 100644 index 000000000..ed0f8da58 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment/payment.service.spec.ts @@ -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 = {}) { + 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[]) { + const store = [...rows]; + return { + findOneBy: jest.fn((where: Record) => + Promise.resolve( + store.find((r) => + Object.entries(where).every(([k, v]) => r[k] === v), + ) ?? null, + ), + ), + update: jest.fn((where: { id: string }, data: Record) => { + 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, + 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, + ); + }); +}); From c176b9e7d653a958ff7937ebf619b7f92d4f925f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 31 Jul 2026 09:02:27 +0000 Subject: [PATCH 4/6] fix(payment): give the payment-service client a 60s budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CAC Bank's initiate SMSes an OTP before it answers and routinely takes more than the 10s this client allowed, so every CAC charge came back as a 502 while the bank was still working — and if the bank had answered, the payer would have been texted a code for an intent the freight side never recorded. Matches the passenger API, which already budgets 60s and reads the same PAYMENT_API_HTTP_TIMEOUT_MS override. Co-Authored-By: Claude Opus 5 (1M context) --- .../edr-freight-api/src/modules/payment/payment.module.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 05267746d..961aa32bd 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -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), From 991881108d1dd4299e8293bc867816bba3e3954c Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 31 Jul 2026 10:33:27 +0000 Subject: [PATCH 5/6] chore: test fixes --- docker-compose.e2e.yaml | 85 ++++++++++- .../cypress/e2e/flows/booking_cancel.cy.ts | 5 +- .../e2e/flows/bulk_critical_matrix.cy.ts | 11 +- .../e2e/flows/bulk_export_full_train.cy.ts | 3 +- .../e2e/flows/bulk_import_full_train.cy.ts | 3 +- .../e2e/flows/bulk_import_split_promote.cy.ts | 4 +- .../e2e/flows/contract-lifecycle.cy.ts | 3 +- .../cypress/e2e/flows/export_full_train.cy.ts | 3 +- .../cypress/e2e/flows/export_one_time.cy.ts | 19 ++- e2e/freight/cypress/e2e/flows/import-utils.ts | 138 +++++++++++++++--- .../cypress/e2e/flows/import_full_train.cy.ts | 3 +- .../e2e/flows/import_split_promote.cy.ts | 4 +- .../e2e/flows/intercity_one_time.cy.ts | 33 ++--- .../e2e/flows/mixed_export_full_train.cy.ts | 3 +- .../e2e/flows/mixed_import_full_train.cy.ts | 3 +- .../flows/mixed_import_split_promote.cy.ts | 5 +- .../cypress/e2e/flows/onboarding.cy.ts | 55 ++++--- e2e/freight/cypress/support/commands.ts | 63 +++++--- e2e/freight/etrade-mock/server.js | 124 ++++++++++++++++ e2e/freight/fayda-mock/server.js | 52 +++++++ e2e/freight/scripts/e2e.mjs | 21 +++ 21 files changed, 544 insertions(+), 96 deletions(-) create mode 100644 e2e/freight/etrade-mock/server.js create mode 100644 e2e/freight/fayda-mock/server.js diff --git a/docker-compose.e2e.yaml b/docker-compose.e2e.yaml index b00402761..5ce7702c3 100644 --- a/docker-compose.e2e.yaml +++ b/docker-compose.e2e.yaml @@ -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 diff --git a/e2e/freight/cypress/e2e/flows/booking_cancel.cy.ts b/e2e/freight/cypress/e2e/flows/booking_cancel.cy.ts index 9eba43831..1f7c14045 100644 --- a/e2e/freight/cypress/e2e/flows/booking_cancel.cy.ts +++ b/e2e/freight/cypress/e2e/flows/booking_cancel.cy.ts @@ -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 diff --git a/e2e/freight/cypress/e2e/flows/bulk_critical_matrix.cy.ts b/e2e/freight/cypress/e2e/flows/bulk_critical_matrix.cy.ts index 25213bcfb..1b6a5085c 100644 --- a/e2e/freight/cypress/e2e/flows/bulk_critical_matrix.cy.ts +++ b/e2e/freight/cypress/e2e/flows/bulk_critical_matrix.cy.ts @@ -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); }); }); diff --git a/e2e/freight/cypress/e2e/flows/bulk_export_full_train.cy.ts b/e2e/freight/cypress/e2e/flows/bulk_export_full_train.cy.ts index bb8e30b55..330639ec7 100644 --- a/e2e/freight/cypress/e2e/flows/bulk_export_full_train.cy.ts +++ b/e2e/freight/cypress/e2e/flows/bulk_export_full_train.cy.ts @@ -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); }); }); diff --git a/e2e/freight/cypress/e2e/flows/bulk_import_full_train.cy.ts b/e2e/freight/cypress/e2e/flows/bulk_import_full_train.cy.ts index 0ffdb37f3..b7ff74a8e 100644 --- a/e2e/freight/cypress/e2e/flows/bulk_import_full_train.cy.ts +++ b/e2e/freight/cypress/e2e/flows/bulk_import_full_train.cy.ts @@ -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); }); }); diff --git a/e2e/freight/cypress/e2e/flows/bulk_import_split_promote.cy.ts b/e2e/freight/cypress/e2e/flows/bulk_import_split_promote.cy.ts index 163bb7d79..afbbcbc10 100644 --- a/e2e/freight/cypress/e2e/flows/bulk_import_split_promote.cy.ts +++ b/e2e/freight/cypress/e2e/flows/bulk_import_split_promote.cy.ts @@ -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); }); }); diff --git a/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts b/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts index a7a875c59..67236cb9f 100644 --- a/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts +++ b/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts @@ -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 & diff --git a/e2e/freight/cypress/e2e/flows/export_full_train.cy.ts b/e2e/freight/cypress/e2e/flows/export_full_train.cy.ts index d7c2c728d..7de010077 100644 --- a/e2e/freight/cypress/e2e/flows/export_full_train.cy.ts +++ b/e2e/freight/cypress/e2e/flows/export_full_train.cy.ts @@ -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); }); }); diff --git a/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts b/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts index 28844bb1c..eaba7c298 100644 --- a/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts +++ b/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts @@ -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"); }); diff --git a/e2e/freight/cypress/e2e/flows/import-utils.ts b/e2e/freight/cypress/e2e/flows/import-utils.ts index 9f9b53688..ac81d6b6f 100644 --- a/e2e/freight/cypress/e2e/flows/import-utils.ts +++ b/e2e/freight/cypress/e2e/flows/import-utils.ts @@ -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); } diff --git a/e2e/freight/cypress/e2e/flows/import_full_train.cy.ts b/e2e/freight/cypress/e2e/flows/import_full_train.cy.ts index bfae79c6a..16849e4af 100644 --- a/e2e/freight/cypress/e2e/flows/import_full_train.cy.ts +++ b/e2e/freight/cypress/e2e/flows/import_full_train.cy.ts @@ -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); }); }); diff --git a/e2e/freight/cypress/e2e/flows/import_split_promote.cy.ts b/e2e/freight/cypress/e2e/flows/import_split_promote.cy.ts index a312063ba..190af595b 100644 --- a/e2e/freight/cypress/e2e/flows/import_split_promote.cy.ts +++ b/e2e/freight/cypress/e2e/flows/import_split_promote.cy.ts @@ -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); }); }); diff --git a/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts b/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts index b74de4993..8f478227a 100644 --- a/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts +++ b/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts @@ -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); }); }); diff --git a/e2e/freight/cypress/e2e/flows/mixed_export_full_train.cy.ts b/e2e/freight/cypress/e2e/flows/mixed_export_full_train.cy.ts index df458fc65..fd05814a7 100644 --- a/e2e/freight/cypress/e2e/flows/mixed_export_full_train.cy.ts +++ b/e2e/freight/cypress/e2e/flows/mixed_export_full_train.cy.ts @@ -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); }); }); diff --git a/e2e/freight/cypress/e2e/flows/mixed_import_full_train.cy.ts b/e2e/freight/cypress/e2e/flows/mixed_import_full_train.cy.ts index e1b779109..fe7d3727d 100644 --- a/e2e/freight/cypress/e2e/flows/mixed_import_full_train.cy.ts +++ b/e2e/freight/cypress/e2e/flows/mixed_import_full_train.cy.ts @@ -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); }); }); diff --git a/e2e/freight/cypress/e2e/flows/mixed_import_split_promote.cy.ts b/e2e/freight/cypress/e2e/flows/mixed_import_split_promote.cy.ts index 46f27d9a1..88ba5e81f 100644 --- a/e2e/freight/cypress/e2e/flows/mixed_import_split_promote.cy.ts +++ b/e2e/freight/cypress/e2e/flows/mixed_import_split_promote.cy.ts @@ -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); }); }); diff --git a/e2e/freight/cypress/e2e/flows/onboarding.cy.ts b/e2e/freight/cypress/e2e/flows/onboarding.cy.ts index ed6df900b..bb077df6b 100644 --- a/e2e/freight/cypress/e2e/flows/onboarding.cy.ts +++ b/e2e/freight/cypress/e2e/flows/onboarding.cy.ts @@ -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). diff --git a/e2e/freight/cypress/support/commands.ts b/e2e/freight/cypress/support/commands.ts index 43566075b..419e6b3fa 100644 --- a/e2e/freight/cypress/support/commands.ts +++ b/e2e/freight/cypress/support/commands.ts @@ -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); }); /** diff --git a/e2e/freight/etrade-mock/server.js b/e2e/freight/etrade-mock/server.js new file mode 100644 index 000000000..95f43ded2 --- /dev/null +++ b/e2e/freight/etrade-mock/server.js @@ -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}`); +}); diff --git a/e2e/freight/fayda-mock/server.js b/e2e/freight/fayda-mock/server.js new file mode 100644 index 000000000..e9a133c34 --- /dev/null +++ b/e2e/freight/fayda-mock/server.js @@ -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}`); +}); diff --git a/e2e/freight/scripts/e2e.mjs b/e2e/freight/scripts/e2e.mjs index 81c8bf216..ee59f0098 100644 --- a/e2e/freight/scripts/e2e.mjs +++ b/e2e/freight/scripts/e2e.mjs @@ -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(), }; } From a975aa75474117efb1110d38baea16ccbf195254 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 31 Jul 2026 10:33:46 +0000 Subject: [PATCH 6/6] fix(payment-providers): stop halving every non-DJF CAC Bank amount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both callers already hand the provider a major-currency amount before this call — freight sends invoice.balanceAmount (e.g. 700 for $700), passenger sends CurrencyService's converted charge amount — with the target currency's own decimal precision already applied (0dp DJF, 2dp USD/ETB). CAC's toMajorAmount then divided anything that wasn't DJF by 100 on top of that, so a USD 700 charge reached the bank as 7.00. This never surfaced because CAC has only ever been wired for DJF (passenger) until now. Also scoped the bank's documented 10-100,000 bound to DJF — it's the only currency the spec states bounds for, so a USD amount outside that DJF-shaped range is no longer rejected locally; an amount genuinely out of range still comes back as a bank rejection. Passenger is unaffected: its CAC currency has always been DJF, and the DJF branch's behavior (amount unchanged, bounds enforced) is untouched. Co-Authored-By: Claude Opus 5 (1M context) --- .../modules/providers/cac-bank-amount.spec.ts | 104 ++++++++++++++++++ .../providers/cac-bank/cac-bank.provider.ts | 30 +++-- 2 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 apps/edr-payment-api/src/modules/providers/cac-bank-amount.spec.ts diff --git a/apps/edr-payment-api/src/modules/providers/cac-bank-amount.spec.ts b/apps/edr-payment-api/src/modules/providers/cac-bank-amount.spec.ts new file mode 100644 index 000000000..e1898de53 --- /dev/null +++ b/apps/edr-payment-api/src/modules/providers/cac-bank-amount.spec.ts @@ -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 10–100,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); + }); +}); diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts index df69c4b1f..764d4a5d8 100644 --- a/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts @@ -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(