diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 2ef6d2fb9..9420a00d9 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -48,6 +48,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; +import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { SupportContentModule } from "./modules/support-content/support-content.module"; import { OtpModule } from "./modules/otp/otp.module"; @@ -206,6 +207,7 @@ if (!process.env.APPLICATION_NAME) { FileUploadSettingsModule, DropdownSettingsModule, ExchangeSettingsModule, + StampSettingsModule, ContractTemplatesModule, SupportContentModule, OtpModule, diff --git a/apps/edr-freight-api/src/contracts/contract-provider-stamp.spec.ts b/apps/edr-freight-api/src/contracts/contract-provider-stamp.spec.ts new file mode 100644 index 000000000..023e4ac8c --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-provider-stamp.spec.ts @@ -0,0 +1,99 @@ +import { ContractViewModelBuilder, ContractSignatureView } from "./contract-view-model.builder"; + +/** + * The EDR side of a contract is sealed with the ONE global company stamp, read + * live at render time; the client side keeps whatever stamp the customer + * uploaded. These specs pin that asymmetry — the standing rule is that + * centralizing the EDR seal must not touch customer stamps. + */ +describe("ContractViewModelBuilder.attachProviderStamp", () => { + const STAMP = "data:image/png;base64,RURS"; + + const build = (stampImageUrl: string | null = STAMP) => { + const getStampImageUrl = jest.fn().mockResolvedValue(stampImageUrl); + const builder = Object.create( + ContractViewModelBuilder.prototype, + ) as ContractViewModelBuilder; + Object.assign(builder, { stampSettings: { getStampImageUrl } }); + return { builder, getStampImageUrl }; + }; + + const sig = (role: "STAFF" | "CUSTOMER", extra: Partial = {}) => + ({ + role, + signerDisplayName: `${role} signer`, + signedAt: "1 January 2026", + signatureImageUrl: "https://minio.local/sig.png", + ...extra, + }) as ContractSignatureView; + + it("stamps the EDR side with the global stamp", async () => { + const { builder } = build(); + const signatures = [sig("STAFF")]; + + await builder.attachProviderStamp(signatures); + + expect(signatures[0]!.stampImageUrl).toBe(STAMP); + }); + + it("leaves the customer side untouched", async () => { + const { builder } = build(); + const customerStamp = "data:image/png;base64,Q1VTVA=="; + const signatures = [ + sig("CUSTOMER", { stampImageUrl: customerStamp }), + sig("STAFF"), + ]; + + await builder.attachProviderStamp(signatures); + + expect(signatures[0]!.stampImageUrl).toBe(customerStamp); + expect(signatures[1]!.stampImageUrl).toBe(STAMP); + }); + + it("does not read the stamp at all when EDR has not signed yet", async () => { + const { builder, getStampImageUrl } = build(); + const signatures = [sig("CUSTOMER")]; + + await builder.attachProviderStamp(signatures); + + expect(getStampImageUrl).not.toHaveBeenCalled(); + expect(signatures[0]!.stampImageUrl).toBeUndefined(); + }); + + it("renders unstamped rather than failing when no stamp is configured", async () => { + const { builder } = build(null); + const signatures = [sig("STAFF")]; + + await expect(builder.attachProviderStamp(signatures)).resolves.toBeUndefined(); + expect(signatures[0]!.stampImageUrl).toBeNull(); + }); + + it("reads the stamp once for every EDR signature row", async () => { + const { builder, getStampImageUrl } = build(); + const signatures = [sig("STAFF"), sig("STAFF")]; + + await builder.attachProviderStamp(signatures); + + expect(getStampImageUrl).toHaveBeenCalledTimes(1); + expect(signatures.map((s) => s.stampImageUrl)).toEqual([STAMP, STAMP]); + }); + + it("is applied by loadSignatures, so the HTML view and the PDF agree", async () => { + const { builder } = build(); + Object.assign(builder, { + bookingsRepository: { + findContractSignatures: jest.fn().mockResolvedValue([ + { signerRole: "STAFF", signerDisplayName: "EDR", signedAt: new Date() }, + ]), + }, + }); + + const views = await ( + builder as unknown as { + loadSignatures(id: string): Promise; + } + ).loadSignatures("b-1"); + + expect(views[0]!.stampImageUrl).toBe(STAMP); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index d1158d035..94a6809e6 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -9,6 +9,7 @@ import { import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder'; import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder'; import { ContractTemplateResolver } from './contract-template.resolver'; +import { StampSettingsService } from '../modules/stamp-settings/stamp-settings.service'; import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry'; export interface ContractSignatureView { @@ -16,6 +17,11 @@ export interface ContractSignatureView { signerDisplayName: string; signedAt: string; signatureImageUrl?: string | null; + /** + * Round company seal shown beside the signature. Populated for the EDR + * (STAFF) side only, from the single global stamp — see attachProviderStamp. + */ + stampImageUrl?: string | null; } /** @@ -114,6 +120,7 @@ export class ContractViewModelBuilder { private readonly templateResolver: ContractTemplateResolver, private readonly pricingBuilder: ContractPricingScheduleBuilder, private readonly rateScheduleBuilder: ContractRateScheduleBuilder, + private readonly stampSettings: StampSettingsService, ) {} async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> { @@ -194,7 +201,30 @@ export class ContractViewModelBuilder { private async loadSignatures(bookingId: string): Promise { const rows = await this.bookingsRepository.findContractSignatures(bookingId); - return rows.map((s) => this.toSignatureView(s)); + const views = rows.map((s) => this.toSignatureView(s)); + await this.attachProviderStamp(views); + return views; + } + + /** + * Stamp the EDR side of the contract with the ONE global company stamp + * (StampSettingsService) — staff never upload or pick a stamp, so nothing is + * stored per signature and the seal is read live at render time. The client + * side is left alone: a customer's own stamp is their business. + * + * Read live and deliberately not snapshotted, so replacing the company stamp + * re-seals contracts on their next render. `getStampImageUrl()` never throws + * and returns a data URL, which `signatures_block.hbs` renders as-is and the + * signature inliner skips. + */ + async attachProviderStamp(signatures: ContractSignatureView[]): Promise { + const staff = signatures.filter((s) => s.role === 'STAFF'); + if (staff.length === 0) return; + + const stampImageUrl = await this.stampSettings.getStampImageUrl(); + for (const sig of staff) { + sig.stampImageUrl = stampImageUrl; + } } toSignatureView(row: BookingContractSignature): ContractSignatureView { diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index f16493273..98fed950e 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -14,10 +14,13 @@ import { import { AppModule } from "./app.module"; /** - * JSON body ceiling. Signing posts the signature AND the company stamp as - * base64 in one JSON body, and base64 inflates bytes by ~4/3 — a 50MB asset is - * ~67MB on the wire. Express defaults to 100kb, which rejected any real stamp - * image with a 413 "request entity too large". + * JSON body ceiling. Customer signing posts the signature AND the customer's + * own company stamp as base64 in one JSON body, and base64 inflates bytes by + * ~4/3 — a 50MB asset is ~67MB on the wire. Express defaults to 100kb, which + * rejected any real stamp image with a 413 "request entity too large". + * (Staff signing posts only a signature: EDR's seal is the one global stamp, + * read server-side. Uploading that stamp under Settings goes through this same + * ceiling, so the headroom is still needed on both counts.) * * Sized to clear the 50MB per-document ceiling * (`DOCUMENT_UPLOAD_MAX_BYTES`) after base64 inflation, with room for the diff --git a/apps/edr-freight-api/src/migrations/3390000000000-RemoveGeneralManager.ts b/apps/edr-freight-api/src/migrations/3390000000000-RemoveGeneralManager.ts new file mode 100644 index 000000000..055c3771b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3390000000000-RemoveGeneralManager.ts @@ -0,0 +1,210 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Onboarding revamp: one company, one verified identity. + * + * The general manager is removed outright — it named who to talk to and gated + * nothing — and the company's people become its **owner** (whoever the eTrade + * licence names as the business's manager) and its **Power of Attorney**. + * Exactly one of them is identity-verified, chosen by the company's own answer + * to "does anyone hold power of attorney for you?", stored as + * `attributes.poaDeclared`. + * + * The order below matters — each step depends on data a later step destroys: + * + * 1. Rescue notification addresses. `companyNotifyEmailExpr` used to fall + * through to `attributes->>'generalManagerEmail'`, and `companies.email` was + * only ever written for a Fayda-verified owner — so every foreign company + * had none and was reached solely through that fallback. Promote it to the + * column before the key is stripped, or those companies stop receiving mail + * in silence. + * 2. Backfill the owner. `ownerName`/`ownerEmail`/`ownerPhone` are now required + * onboarding fields; without this every already-onboarded company would + * report three missing fields the moment it opened its settings page. + * 3. Resolve `poaSameAsOwner`. That flag waived the DARS delegation paper. It + * is gone, so the companies holding it must be re-expressed: + * - NOT a freight forwarder → "no PoA" (the owner represents themselves, + * nothing to delegate). Their PoA details are cleared. + * - A freight forwarder → "yes" and details KEPT. A forwarder signs on + * other companies' behalf, so a representative is non-negotiable and the + * waiver no longer exists. These companies will be asked for a + * delegation paper they were previously excused — an intentional, + * visible consequence, not an oversight. Count them before deploying. + * 4. Derive the declaration for everyone else, from whether PoA details exist. + * 5. Move drafts off the deleted "personnel" wizard step. + * 6. Only now drop the columns and strip the retired attribute keys. + * + * Irreversible by design: `down()` restores the columns' shape but cannot + * recover the values, and re-deriving `poaSameAsOwner` from `poaDeclared` would + * be a guess. + */ +export class RemoveGeneralManager3390000000000 implements MigrationInterface { + name = "RemoveGeneralManager3390000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // 1. Rescue the notification address before the key it lives under is gone. + await queryRunner.query(` + UPDATE freight.companies + SET email = COALESCE( + NULLIF(email, ''), + NULLIF(attributes->>'ownerEmail', ''), + NULLIF(attributes->>'generalManagerEmail', ''), + NULLIF(attributes->>'contactPersonEmail', '') + ) + WHERE COALESCE(email, '') = '' + `); + + // 2. Backfill the owner from the best source each company actually has: + // its Fayda-verified owner claims (already under owner*), then the general + // manager it named, then its contact person. A company with none of these + // never finished onboarding, and will be asked on its next visit. + await queryRunner.query(` + UPDATE freight.companies + SET attributes = attributes + || jsonb_strip_nulls(jsonb_build_object( + 'ownerName', COALESCE( + NULLIF(attributes->>'ownerName', ''), + NULLIF(attributes->>'generalManagerName', ''), + NULLIF(attributes->>'contactPersonName', '') + ), + 'ownerEmail', COALESCE( + NULLIF(attributes->>'ownerEmail', ''), + NULLIF(attributes->>'generalManagerEmail', ''), + NULLIF(attributes->>'contactPersonEmail', ''), + NULLIF(email, '') + ), + 'ownerPhone', COALESCE( + NULLIF(attributes->>'ownerPhone', ''), + NULLIF(attributes->>'generalManagerPhone', ''), + NULLIF(attributes->>'contactPersonPhone', ''), + NULLIF(phone, '') + ) + )) + WHERE attributes IS NOT NULL + `); + + // 2b. Capture eTrade's manager for the owner-vs-licence check the + // backoffice now makes. Nothing stored it before, so the best we have is + // the owner name itself — which makes existing companies read as "matches" + // rather than as a false mismatch on data nobody ever compared. The value + // is refreshed for real on the company's next eTrade lookup. + await queryRunner.query(` + UPDATE freight.companies + SET attributes = jsonb_set( + attributes, '{etradeManagerName}', to_jsonb(attributes->>'ownerName') + ) + WHERE COALESCE(attributes->>'ownerName', '') <> '' + AND attributes->>'etradeManagerName' IS NULL + AND COALESCE(licence_number, '') <> '' + `); + + // 3a. Owner-represents-themselves, and NOT a forwarder → "no PoA". + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = (c.attributes - 'poaName' - 'poaPhone' - 'poaEmail' + - 'poaLocation' - 'poaAddress' - 'poaFaydaSub' + - 'poaFaydaVerifiedAt' - 'poaBirthdate' - 'poaGender') + || jsonb_build_object('poaDeclared', 'no') + WHERE (c.attributes->>'poaSameAsOwner')::boolean IS TRUE + AND NOT EXISTS ( + SELECT 1 FROM freight.company_profiles cp + WHERE cp.company_id = c.id + AND cp.type = 'freight_forwarder' + AND cp.deleted_at IS NULL + ) + `); + + // 3b. Forwarders keep their representative and lose the waiver. + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = c.attributes || jsonb_build_object('poaDeclared', 'yes') + WHERE (c.attributes->>'poaSameAsOwner')::boolean IS TRUE + AND EXISTS ( + SELECT 1 FROM freight.company_profiles cp + WHERE cp.company_id = c.id + AND cp.type = 'freight_forwarder' + AND cp.deleted_at IS NULL + ) + `); + + // 4. Everyone else: "yes" if a representative was named or the company is a + // forwarder, "no" if it finished onboarding without one. A company still + // mid-onboarding is left unanswered — it will be asked, which is the point. + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = COALESCE(c.attributes, '{}'::jsonb) + || jsonb_build_object('poaDeclared', 'yes') + WHERE c.attributes->>'poaDeclared' IS NULL + AND ( + COALESCE(c.attributes->>'poaName', '') <> '' + OR COALESCE(c.attributes->>'poaPhone', '') <> '' + OR COALESCE(c.attributes->>'poaEmail', '') <> '' + OR COALESCE(c.attributes->>'poaLocation', '') <> '' + OR COALESCE(c.attributes->>'poaAddress', '') <> '' + OR EXISTS ( + SELECT 1 FROM freight.company_profiles cp + WHERE cp.company_id = c.id + AND cp.type = 'freight_forwarder' + AND cp.deleted_at IS NULL + ) + ) + `); + await queryRunner.query(` + UPDATE freight.companies c + SET attributes = COALESCE(c.attributes, '{}'::jsonb) + || jsonb_build_object('poaDeclared', 'no') + WHERE c.attributes->>'poaDeclared' IS NULL + AND EXISTS ( + SELECT 1 FROM freight.external_profiles ep + WHERE ep.company_id = c.id + AND ep.onboarding_completed = true + AND ep.deleted_at IS NULL + ) + `); + + // 5. The "personnel" (general manager) wizard step no longer exists; a + // draft resting on it would fall back to the very first step and make the + // customer walk the whole wizard again. + await queryRunner.query(` + UPDATE freight.external_profiles + SET onboarding_step = 'owner' + WHERE onboarding_step = 'personnel' + `); + + // 6. Retire the general manager and the flag it shared the model with. + await queryRunner.query(` + UPDATE freight.companies + SET attributes = attributes - 'generalManagerName' - 'generalManagerEmail' + - 'generalManagerPhone' - 'gmSameAsOwner' - 'gmFaydaSub' + - 'gmFaydaVerifiedAt' - 'gmName' - 'gmEmail' - 'gmPhone' + - 'gmAddress' - 'gmBirthdate' - 'gmGender' + - 'poaSameAsOwner' + WHERE attributes IS NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS general_manager_name, + DROP COLUMN IF EXISTS general_manager_email, + DROP COLUMN IF EXISTS general_manager_phone + `); + } + + /** + * Restores the columns' shape only. The values, the `gm*` attributes and the + * `poaSameAsOwner` flag are not recoverable — this migration folded them into + * `ownerEmail` / `poaDeclared`, and there is no way back that isn't a guess. + */ + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS general_manager_name varchar(100), + ADD COLUMN IF NOT EXISTS general_manager_email varchar(150), + ADD COLUMN IF NOT EXISTS general_manager_phone varchar(20) + `); + await queryRunner.query(` + UPDATE freight.external_profiles + SET onboarding_step = 'personnel' + WHERE onboarding_step = 'owner' + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3400000000000-StampSettings.ts b/apps/edr-freight-api/src/migrations/3400000000000-StampSettings.ts new file mode 100644 index 000000000..9556318f9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3400000000000-StampSettings.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Single-row table holding the one company stamp/seal image stamped onto + * generated invoice/receipt PDFs (see StampSettingsService / + * InvoiceDocumentService). Same single-row shape as exchange_settings; the + * app never inserts more than one row. + */ +export class StampSettings3400000000000 implements MigrationInterface { + name = "StampSettings3400000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.stamp_settings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + stamp_file_id uuid REFERENCES freight.files(id), + updated_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.stamp_settings;`); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts index c320a5d44..363cda3f0 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts @@ -7,7 +7,9 @@ import { PdfRenderService } from "./pdf-render.service"; * Standalone document infrastructure — generic HTML→PDF plus the shared * invoice/receipt renderer. Has no domain dependencies, so any module (billing, * warehouses, …) can import it to print invoices without coupling to the - * billing payment graph. + * billing payment graph. StampSettingsService is @Global (see + * StampSettingsModule) so InvoiceDocumentService can inject it without this + * module declaring an explicit import. */ @Module({ providers: [PdfRenderService, InvoiceDocumentService], diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index 06d164bbd..268e94ad9 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -1,6 +1,8 @@ import { Injectable } from "@nestjs/common"; +import { StampSettingsService } from "../../stamp-settings/stamp-settings.service"; import { PdfRenderService } from "./pdf-render.service"; +import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util"; import { PdfColor, assembleSinglePagePdf, @@ -53,6 +55,13 @@ export interface InvoiceDocumentModel { totals: InvoiceDocumentTotal[]; /** Override the round seal text; defaults from kind/status. */ sealText?: string; + /** + * Company stamp image (data URL) to render instead of the plain text seal. + * Callers normally leave this unset — `InvoiceDocumentService.render()` + * fills it in from the single global stamp in StampSettingsService; set it + * explicitly only to override that default for one document. + */ + stampImageUrl?: string | null; } /** @@ -63,12 +72,21 @@ export interface InvoiceDocumentModel { */ @Injectable() export class InvoiceDocumentService { - constructor(private readonly pdf: PdfRenderService) {} + constructor( + private readonly pdf: PdfRenderService, + private readonly stampSettings: StampSettingsService, + ) {} async render( model: InvoiceDocumentModel, ): Promise<{ filename: string; buffer: Buffer }> { - const html = this.buildHtml(model); + const stampImageUrl = + model.stampImageUrl !== undefined + ? model.stampImageUrl + : await this.stampSettings.getStampImageUrl(); + const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl }; + + const html = this.buildHtml(resolvedModel); const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice"; return { filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`, @@ -77,7 +95,11 @@ export class InvoiceDocumentService { // Chromium-less fallback: draw a genuine styled invoice (header, seal, // summary grid, line-item table, totals) from the model — not a flat // plain-text dump — so it still reads as a proper invoice document. - fallback: () => this.buildFallbackPdf(model), + // ponytail: still draws the plain vector seal, not the uploaded stamp + // image — embedding a raster image needs a new PDF XObject primitive + // in styled-pdf.util.ts. Upgrade when the Chromium-less path needs to + // carry the real stamp too; today it's a rare degraded fallback. + fallback: () => this.buildFallbackPdf(resolvedModel), }), }; } @@ -218,6 +240,8 @@ export class InvoiceDocumentService { const showCategory = Boolean(model.categoryHeader); const sealText = model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); + const sealInner = sealMarkup(model.stampImageUrl, sealText); + const sealCssClass = sealClass(model.stampImageUrl); const summaryRows = model.summary .map((row) => `
${esc(row.label)}${esc(row.value)}
`) @@ -256,6 +280,7 @@ export class InvoiceDocumentService { .meta { text-align: right; font-size: 12px; color: #475569; } .meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; } .seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; } + ${sealImageCss()} .summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; } .summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; } .summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; } @@ -283,7 +308,7 @@ export class InvoiceDocumentService { Issued: ${esc(date(model.issuedAt))} -
${esc(sealText)}
+
${sealInner}
${summaryRows}
diff --git a/apps/edr-freight-api/src/modules/billing/documents/seal-markup.util.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/seal-markup.util.spec.ts new file mode 100644 index 000000000..d07f8ebf6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/seal-markup.util.spec.ts @@ -0,0 +1,78 @@ +import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util"; + +/** + * These three helpers are the single image-vs-text branch shared by every + * EDR document's round seal, so a regression here silently unstamps invoices, + * warehouse release papers and handover papers at once. + */ +describe("seal markup helpers", () => { + const STAMP = "data:image/png;base64,QUJD"; + + describe("sealMarkup", () => { + it("renders the stamp image when one is configured", () => { + expect(sealMarkup(STAMP, ["EDR", "Warehouse"])).toBe( + `Company stamp`, + ); + }); + + it("falls back to text rings when no stamp is configured", () => { + expect(sealMarkup(null, ["EDR", "Warehouse", "Cleared"])).toBe( + "EDR
Warehouse
Cleared
", + ); + }); + + it("treats undefined as unset", () => { + expect(sealMarkup(undefined, "EDR")).toBe("EDR"); + }); + + it("accepts a bare string as a single line", () => { + expect(sealMarkup(null, "EDR")).toBe("EDR"); + }); + + it("escapes text lines so document data cannot inject markup", () => { + expect(sealMarkup(null, [''])).toBe( + "<script>alert("x")</script>", + ); + }); + + it("escapes the image src so it cannot break out of the attribute", () => { + expect(sealMarkup('data:image/png;base64,A" onerror="x', "EDR")).toBe( + 'Company stamp', + ); + }); + }); + + describe("sealClass", () => { + it("adds the image modifier only when stamped", () => { + expect(sealClass(STAMP)).toBe("seal seal-image"); + expect(sealClass(null)).toBe("seal"); + }); + + it("honours a document's own seal selector", () => { + expect(sealClass(STAMP, "sig-stamp-box")).toBe( + "sig-stamp-box sig-stamp-box-image", + ); + expect(sealClass(null, "sig-stamp-box")).toBe("sig-stamp-box"); + }); + }); + + describe("sealImageCss", () => { + it("neutralizes the drawn ring and rotation for a real stamp image", () => { + const css = sealImageCss(); + + expect(css).toContain(".seal.seal-image { border: none;"); + expect(css).toContain("transform: none;"); + // The ::before pseudo-element draws the inner ring of the text seal. + expect(css).toContain(".seal.seal-image::before { content: none; }"); + expect(css).toContain(".seal img { max-width: 100%;"); + }); + + it("scopes every rule to the given selector", () => { + const css = sealImageCss("sig-stamp-box"); + + expect(css).not.toContain(".seal"); + expect(css).toContain(".sig-stamp-box.sig-stamp-box-image"); + expect(css).toContain(".sig-stamp-box img"); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/documents/seal-markup.util.ts b/apps/edr-freight-api/src/modules/billing/documents/seal-markup.util.ts new file mode 100644 index 000000000..2dd9c4715 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/seal-markup.util.ts @@ -0,0 +1,64 @@ +/** + * The single decision every EDR document makes about its round seal: draw the + * one uploaded company stamp when one is configured (StampSettingsService), or + * fall back to the plain text rings the document styles itself. + * + * Only the image-vs-text branch and the image overrides live here — each + * document keeps its own `.seal` geometry (the invoice's seal is absolutely + * positioned top-right, the warehouse papers' sit inline above the signature + * lines), so centralizing the source of the stamp does not relayout anything. + * + * These helpers are for the HTML/Chromium render path. The hand-built vector + * fallbacks in styled-pdf.util.ts cannot embed a raster image and continue to + * draw their vector seal — see InvoiceDocumentService for that caveat. + */ + +/** Escape a value for interpolation into HTML text or a quoted attribute. */ +function escapeHtml(value: unknown): string { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * CSS overrides that neutralize a document's own ring/rotation styling when the + * seal is a real stamp image. Append inside a document's @@ -5722,7 +5730,7 @@ export class WarehouseInventoryService {
Officer in charge name / signature / date
-
EDR
Warehouse
Cleared
+
${sealMarkup(data.stampImageUrl, ['EDR', 'Warehouse', 'Cleared'])}
Customer or driver name / signature / date
@@ -5762,6 +5770,8 @@ export class WarehouseInventoryService { signerDisplayName: string; signatureImageUrl: string | null; } | null; + /** The one global company stamp; null falls back to the drawn text seal. */ + stampImageUrl?: string | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -5839,6 +5849,7 @@ export class WarehouseInventoryService { .seal { position: relative; width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; } .seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; } .seal span { position: relative; } + ${sealImageCss()} @@ -5882,7 +5893,7 @@ export class WarehouseInventoryService {
Officer in charge name / signature / date
-
EDR
Warehouse
Handover
+
${sealMarkup(data.stampImageUrl, ['EDR', 'Warehouse', 'Handover'])}
${approval?.signatureImageUrl ? `` : ''}
${approval ? esc(approval.signerDisplayName) : 'Customer or driver name / signature / date'}
diff --git a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts index 9cf1ef0cd..174a32341 100644 --- a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -157,9 +157,6 @@ async function main() { email: 'negad-indode-demo@edr.local', contactPersonName: 'Marshalling Demo', contactPersonPhone: '251900000202', - generalManagerName: 'Demo Manager', - generalManagerEmail: 'negad-indode-demo@edr.local', - generalManagerPhone: '251900000202', }), )); diff --git a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts index 2e234bcf0..91848dff4 100644 --- a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts @@ -247,9 +247,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder { website: null, contactPersonName: 'First Last Mile Demo', contactPersonPhone: '251900000101', - generalManagerName: 'Demo Manager', - generalManagerEmail: COMPANY_EMAIL, - generalManagerPhone: '251900000101', }, { conflictPaths: { tin: true } }, ); diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 20d2a3f8b..4f259c28c 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -324,9 +324,6 @@ export class DemoBookingsSeeder { website: null, contactPersonName: "Train Scheduling", contactPersonPhone: "251900000001", - generalManagerName: "Demo Manager", - generalManagerEmail: COMPANY_EMAIL, - generalManagerPhone: "251900000001", }, { conflictPaths: { tin: true } }, ); diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 512229706..abc4e613c 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -154,6 +154,31 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ // }, // ]; +/** + * Extra documents a co-operative union or farm provides, merged on top of its + * nationality set. It has a TIN but no business licence, so the papers that + * evidence the co-operative itself stand in for the trade licence every other + * company uploads. + * + * Only the registration certificate is seeded, and the set is admin-managed + * like every other onboarding set — what these members must actually produce + * is a backoffice decision, edited in the file-settings editor. + */ +const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [ + { + fileKey: "cooperative_registration_certificate", + fileLabel: "Co-operative Union / Farm Registration Certificate", + helpText: + "Certificate issued by the co-operative promotion agency that registered the union or farm.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 50, + displayOrder: 1, + }, +]; + interface OnboardingDocumentSetting { code: string; label: string; @@ -176,6 +201,14 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [ entity: "customer", fields: FOREIGN_ONBOARDING_FIELDS, }, + // Additive, not a nationality of its own: a union or farm still uploads + // everything its nationality set demands, and these on top. + { + code: "company_onboarding_documents_cooperative", + label: "Co-operative union / farm onboarding documents (additional)", + entity: "customer", + fields: COOPERATIVE_ONBOARDING_FIELDS, + }, // Legacy per-company-type codes — removed, unused by any resolver or portal // lookup (only `company_onboarding_documents_ethiopian`/`_foreign` are live). // { diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 57bbda267..67df922b5 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1187,6 +1187,31 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:dropdown:manage", "Manage dropdown settings", ), + perm( + "b4b00002-0001-4000-8000-000000000001", + "edr_freight_app:settings:stamp:view", + "View stamp settings", + ), + perm( + "b4b00002-0001-4000-8000-000000000002", + "edr_freight_app:settings:stamp:manage", + "Manage stamp settings", + ), + perm( + "b4b00003-0001-4000-8000-000000000001", + "edr_freight_app:settings:invoice_stamp:view", + "View invoice stamp settings", + ), + perm( + "b4b00003-0001-4000-8000-000000000002", + "edr_freight_app:settings:invoice_stamp:manage", + "Manage invoice stamp settings", + ), + perm( + "b4c00001-0001-4000-8000-000000000001", + "edr_freight_app:audit:view", + "View audit logs", + ), ]; // M. Staff / IAM admin — NEW keys only. The employee_registration / role_assignment @@ -1299,7 +1324,10 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [ "Edit contract templates & articles", ), // Granular split of contract-template access. `view` opens the sidebar page; - // `read` is API-read-only for other pages that display template data. + // `read` is API-read-only for other pages that display template data — and is + // NOT written out here: deriveReadPermissions mints the `:read` twin of every + // `:view` key, so a hand-written one duplicates the key (Postgres 21000 on the + // seeder's ON CONFLICT (key) insert) and carries a v4 id where twins are v5. perm( "b4e00001-0001-4000-8000-000000000003", "edr_freight_app:settings:contract_templates:create", @@ -1315,11 +1343,6 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:contract_templates:delete", "Delete bulk contract templates", ), - perm( - "b4e00001-0001-4000-8000-000000000006", - "edr_freight_app:settings:contract_templates:read", - "Read contract template data (API only)", - ), perm( "b4f00001-0001-4000-8000-000000000001", "edr_freight_app:settings:support_content:view", @@ -1884,6 +1907,16 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:dropdown:view", manage: "edr_freight_app:settings:dropdown:manage", }, + stamp: { + view: "edr_freight_app:settings:stamp:view", + manage: "edr_freight_app:settings:stamp:manage", + }, + // Company stamp/seal image stamped onto invoice/receipt PDFs — separate + // from `stamp` above, which is the per-employee approval-record teeter. + invoiceStamp: { + view: "edr_freight_app:settings:invoice_stamp:view", + manage: "edr_freight_app:settings:invoice_stamp:manage", + }, exchangeRate: { view: "edr_freight_app:settings:exchange_rate:view", manage: "edr_freight_app:settings:exchange_rate:manage", @@ -2293,7 +2326,8 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.payments.view, ]), // Director additionally manages train scheduling + rail fleet (same block the - // operation officer/chief hold), on top of the approval-chain role preset. + // operation officer/chief hold), on top of the approval-chain role preset, + // and carries the same full warehouse authority the chief tier holds. director: dedupe([ ...ROLE_PERMISSION_PRESETS.director, FREIGHT_PERMS.trainScheduling.view, @@ -2306,6 +2340,18 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.fleet.manage, ...FLEET_GRANULAR_KEYS, + // Warehouse — full CRUD, matching the chief tier. Unlike the dispatcher, + // the director also owns the allocation and fee rules themselves. + FREIGHT_PERMS.warehouseDashboard.view, + ...Object.values(FREIGHT_PERMS.warehouses), + ...Object.values(FREIGHT_PERMS.warehouseYards), + ...Object.values(FREIGHT_PERMS.warehouseZones), + ...Object.values(FREIGHT_PERMS.warehouseAllocationRules), + ...Object.values(FREIGHT_PERMS.warehouseFeeRules), + ...Object.values(FREIGHT_PERMS.warehouseInventory), + ...Object.values(FREIGHT_PERMS.warehouseInspectionReports), + ...Object.values(FREIGHT_PERMS.interchangeDocuments), + ...Object.values(FREIGHT_PERMS.warehouseFeeInvoices), ]), ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]), ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]), diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts index 654f4f77f..a658ec13d 100644 --- a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -175,9 +175,6 @@ export class PaidImportExportMileDemoSeeder { website: null, contactPersonName: 'Paid Mile Demo', contactPersonPhone: '251900000202', - generalManagerName: 'Demo Manager', - generalManagerEmail: COMPANY_EMAIL, - generalManagerPhone: '251900000202', }, { conflictPaths: { tin: true } }, ); diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 8c97dcc08..f48f302a1 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -51,6 +51,8 @@ import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing"; import NoAccessPage from "./pages/NoAccessPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; +import StampSettings from "./record-management/components/Settings/uploadTeeterandSingature"; +import InvoiceStampSettingsPage from "./pages/settings/InvoiceStampSettingsPage"; import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import PortalContentPage from "./pages/portal_content/PortalContentPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; @@ -117,6 +119,20 @@ import { findActiveSidebarLabel, } from "@/components/layout/sidebar-sections"; +/** + * The per-shipment clearance detail page is the shared destination of three + * hubs (Operations → Clearance, Clearance Documents, Self-Clearance Review), + * none of which are gated on `bookings:clearance_view`. Gating the detail on + * that key alone bounced reviewers back to their landing page (Bookings) the + * moment they opened a row, so accept any key that can reach a hub. + */ +const CLEARANCE_DETAIL_PERMS = [ + FREIGHT_PERMS.bookings.clearanceView, + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.opsClearanceReview, +]; + const DashboardShell = () => { const navigate = useNavigate(); const location = useLocation(); @@ -286,9 +302,7 @@ const App = () => { + } @@ -322,9 +336,7 @@ const App = () => { + } @@ -774,6 +786,24 @@ const App = () => { } /> + + + + } + /> + + + + } + /> - + {/* Whoever the eTrade licence names as the business's manager. */} + diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index d86e0079c..65c4f25e3 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -43,9 +43,17 @@ export const FIELD_LABELS: Record = { contactPersonPosition: "Contact position", contactPersonEmail: "Contact email", contactPersonPhone: "Contact phone", - generalManagerName: "General manager", - generalManagerEmail: "GM email", - generalManagerPhone: "GM phone", + ownerName: "Owner name", + ownerEmail: "Owner email", + ownerPhone: "Owner phone", + poaDeclared: "Has a Power of Attorney", + poaPassportNumber: "PoA passport number", + // Nothing writes these any more — the general manager was removed — but + // change requests filed before that still carry them, and without a label + // the reviewer sees a raw attribute key. + generalManagerName: "General manager (retired)", + generalManagerEmail: "GM email (retired)", + generalManagerPhone: "GM phone (retired)", poaName: "PoA name", poaPhone: "PoA phone", poaEmail: "PoA email", @@ -79,9 +87,9 @@ export function currentValue(company: Company, key: string): string { nationality: c.nationality, contactPersonName: c.contactPersonName ?? attrs.contactPersonName, contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone, - generalManagerName: c.generalManagerName ?? attrs.generalManagerName, - generalManagerEmail: c.generalManagerEmail ?? attrs.generalManagerEmail, - generalManagerPhone: c.generalManagerPhone ?? attrs.generalManagerPhone, + ownerName: c.ownerName ?? attrs.ownerName, + ownerEmail: c.ownerEmail ?? attrs.ownerEmail, + ownerPhone: c.ownerPhone ?? attrs.ownerPhone, }; const v = key in map ? map[key] : (c[key] ?? attrs[key]); return v === null || v === undefined || v === "" ? "—" : String(v); diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 307cb10aa..777877fd6 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -485,6 +485,18 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] icon: , permission: FREIGHT_PERMS.settings.dropdown.view, }, + { + label: "Stamp settings", + href: "/dashboard/stamp-settings", + icon: , + permission: FREIGHT_PERMS.settings.stamp.view, + }, + { + label: "Invoice stamp", + href: "/dashboard/invoice-stamp-settings", + icon: , + permission: FREIGHT_PERMS.settings.invoiceStamp.view, + }, { label: "Contract templates", href: "/dashboard/contract-templates", diff --git a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx index 0a70b3aec..b13ba4291 100644 --- a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx @@ -1,11 +1,10 @@ import { useState } from "react"; -import { FileSignature, Loader2, Stamp } from "lucide-react"; +import { FileSignature, Loader2 } from "lucide-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import toast from "react-hot-toast"; import { api } from "@/services/api"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; -import { StampUpload } from "@/components/contracts/StampUpload"; import { Card, CardContent, @@ -27,9 +26,13 @@ import { } from "@edr/ui-common"; /** - * Lets the signed-in user view and update the reusable signature and company - * stamp stored on their profile — managed independently of each other. Both - * are offered when signing a booking contract. + * Lets the signed-in user view and update the reusable signature stored on + * their profile, offered for approval when signing a contract. + * + * Signature only — there is no per-employee stamp. EDR seals with ONE global + * company stamp, managed under Settings and applied server-side, so a staff + * member never uploads or picks a stamp. (Customers do upload their own, in + * the portal — that is a different card.) */ export function MySignatureCard() { const { user } = useAuth(); @@ -39,10 +42,8 @@ export function MySignatureCard() { const saveMutation = useMutation(api.signatures.save.mutationOptions()); const [signatureOpen, setSignatureOpen] = useState(false); - const [stampOpen, setStampOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); - const [stampData, setStampData] = useState(null); const defaultName = user?.name?.en || user?.username || user?.email || ""; @@ -60,7 +61,6 @@ export function MySignatureCard() { { signerDisplayName: signerName.trim(), signatureImageBase64: signatureData, - // Stamp untouched — it is managed by its own dialog. }, { onSuccess: () => { @@ -72,38 +72,16 @@ export function MySignatureCard() { ); }; - const openStampDialog = () => { - setStampData(saved?.stampImageUrl ?? null); - setStampOpen(true); - }; - - const saveStamp = () => { - if (!stampData) return; - saveMutation.mutate( - { - signerDisplayName: savedName || defaultName, - // Signature untouched — stamp-only update. - stampImageBase64: stampData, - }, - { - onSuccess: () => { - toast.success("Stamp saved"); - setStampOpen(false); - }, - onError: () => toast.error("Failed to save stamp"), - }, - ); - }; - return ( - Signature & Stamp + Signature - This signature can be reused to sign booking contracts. + This signature can be reused to sign booking contracts. The EDR + company stamp is applied automatically — you do not upload one. @@ -112,54 +90,29 @@ export function MySignatureCard() {
) : ( - <> -
- {saved?.signatureImageUrl ? ( - <> -
- My saved signature -
-

- Saved as {saved.signerDisplayName} -

- - ) : ( -

- You have not saved a signature yet. +

+ {saved?.signatureImageUrl ? ( + <> +
+ My saved signature +
+

+ Saved as {saved.signerDisplayName}

- )} - -
- -
- {saved?.stampImageUrl ? ( - <> -
- My saved company stamp -
-

Company stamp

- - ) : ( -

- You have not uploaded a company stamp yet. -

- )} - -
- + + ) : ( +

+ You have not saved a signature yet. +

+ )} + +
)} @@ -203,39 +156,6 @@ export function MySignatureCard() { - - - - - Company stamp - - Upload your official company stamp or seal as an image. It is - stored on your profile and applied next to your signature on - contracts. - - - - - - - - - ); } diff --git a/apps/edr-freight-web/backoffice/src/hooks/useStampSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useStampSettings.ts new file mode 100644 index 000000000..9d1594602 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useStampSettings.ts @@ -0,0 +1,45 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; + +import { stampSettingsService } from "@/services/stampSettings.service"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; + +const QUERY_KEY = ["stampSettings"]; + +export const useStampSettingsQuery = () => + useQuery({ + queryKey: QUERY_KEY, + queryFn: () => stampSettingsService.get(), + }); + +export const useSetStamp = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + + return useMutation({ + mutationFn: (stampImageBase64: string) => + stampSettingsService.set(stampImageBase64), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + toast.success(t("stampSettings.updated", "Company stamp updated")); + }, + onError: handleError, + }); +}; + +export const useClearStamp = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + + return useMutation({ + mutationFn: () => stampSettingsService.clear(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + toast.success(t("stampSettings.cleared", "Company stamp removed")); + }, + onError: handleError, + }); +}; diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 74de62b74..26b46e37b 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -328,6 +328,16 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:dropdown:view", manage: "edr_freight_app:settings:dropdown:manage", }, + stamp: { + view: "edr_freight_app:settings:stamp:view", + manage: "edr_freight_app:settings:stamp:manage", + }, + // Company stamp/seal image stamped onto invoice/receipt PDFs — separate + // from `stamp` above, which is the per-employee approval-record teeter. + invoiceStamp: { + view: "edr_freight_app:settings:invoice_stamp:view", + manage: "edr_freight_app:settings:invoice_stamp:manage", + }, exchangeRate: { view: "edr_freight_app:settings:exchange_rate:view", manage: "edr_freight_app:settings:exchange_rate:manage", diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx index f176fc05d..e4d570da6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx @@ -12,7 +12,6 @@ import toast from "react-hot-toast"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; -import { StampUpload } from "@/components/contracts/StampUpload"; import { bookingSurface } from "@/components/bookings/booking-ui.styles"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { invalidateBookingDetail } from "@/utils/queryInvalidation"; @@ -41,9 +40,6 @@ export default function BookingContractPage() { const [signOpen, setSignOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); - // Company stamp: prefilled from the profile, or uploaded here when none is - // saved yet. - const [stampData, setStampData] = useState(null); // When the user has a saved signature we offer it for approval first; they // can switch to drawing a fresh one. const [drawNew, setDrawNew] = useState(false); @@ -59,7 +55,6 @@ export default function BookingContractPage() { const savedSignature = data?.savedSignature ?? null; const savedSignatureImage = savedSignature?.signatureImageUrl ?? null; - const savedStampImage = savedSignature?.stampImageUrl ?? null; // Show the approval view only while a saved signature exists and the user // hasn't opted to draw a new one. const usingSaved = Boolean(savedSignatureImage) && !drawNew; @@ -103,8 +98,6 @@ export default function BookingContractPage() { // approve it; otherwise start with an empty pad. setSignerName(savedSignature?.signerDisplayName ?? ""); setSignatureData(null); - // Prefill with the reusable stamp saved on the profile; still replaceable. - setStampData(savedStampImage); setDrawNew(false); setSignOpen(true); }; @@ -113,12 +106,12 @@ export default function BookingContractPage() { if (!canSign || !signerName.trim()) return; // Approve the saved signature, or submit the freshly drawn one. const image = usingSaved ? savedSignatureImage : signatureData; - // The API rejects a STAFF signature without a stamp. - if (!image || !stampData) return; + if (!image) return; + // No stamp is sent: EDR's seal is the ONE global company stamp, applied + // server-side at render time (see StampSettingsService). signMutation.mutate({ role: "STAFF", signatureImageBase64: image, - stampImageBase64: stampData, signerDisplayName: signerName.trim(), consentText: "I agree to the terms of this contract.", }); @@ -243,15 +236,6 @@ export default function BookingContractPage() { ) : ( )} -
+ {data?.stampImageUrl && !dirty && ( + + )} + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/Settings/uploadTeeterandSingature.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/Settings/uploadTeeterandSingature.tsx index 3035d58c7..a6855f540 100644 --- a/apps/edr-freight-web/backoffice/src/record-management/components/Settings/uploadTeeterandSingature.tsx +++ b/apps/edr-freight-web/backoffice/src/record-management/components/Settings/uploadTeeterandSingature.tsx @@ -609,10 +609,18 @@ const UploadTeeterAndSignature = () => { )} - {/* Teeter Tab */} + {/* Teeter Tab — single active stamp only: remove the current one to upload a replacement. */} {teeters.length > 0 && (
+ {teeters.length > 1 && ( +

+ {t( + "signatureUpload.multipleStampsWarning", + "Only one stamp is allowed. Remove the extras below to keep a single active stamp.", + )} +

+ )} {teeters.map(({ id, url }) => (

@@ -635,6 +643,7 @@ const UploadTeeterAndSignature = () => {

)} + {teeters.length === 0 && (
{!stampBlocks && !showLanguagePicker && (
+ )} diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index 5c9c8d900..b49a11f83 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -111,11 +111,14 @@ export interface ConsolidationDetails { splitBilling: { bookingShare: number; partnerShare: number } | null; } +/** + * No stamp field: the backoffice only ever signs as STAFF, and EDR's seal is + * the ONE global company stamp applied server-side. Customer stamps are posted + * from the portal, not here. + */ export interface SignContractPayload { role: "CUSTOMER" | "STAFF"; signatureImageBase64: string; - /** Company stamp/seal image; the API requires one for CUSTOMER and STAFF. */ - stampImageBase64?: string; signerDisplayName: string; consentText?: string; } diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index 042575f2c..69ee89ca5 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -118,11 +118,14 @@ export interface ContractView { } | null; } +/** + * No stamp field: the backoffice only ever counter-signs as STAFF, and EDR's + * seal is the ONE global company stamp, snapshotted server-side from + * StampSettingsService when the signature is stored. + */ export interface SignContractPayload { role: "CUSTOMER" | "STAFF"; signatureImageBase64: string; - /** Company stamp/seal image; required to sign a contract. */ - stampImageBase64?: string; signerDisplayName: string; consentText?: string; } diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts index eb86dd752..5b3cc4527 100644 --- a/apps/edr-freight-web/backoffice/src/services/customers.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts @@ -24,7 +24,7 @@ const cleanParams = (params: object) => ), ); -/** Lift attributes JSONB into the flat contact/manager fields the UI reads. */ +/** Lift attributes JSONB into the flat contact/owner fields the UI reads. */ function mapCompany(dto: Record): Company { const attrs = (dto.attributes as Record | null) ?? {}; return { @@ -32,9 +32,9 @@ function mapCompany(dto: Record): Company { companyProfiles: (dto.companyProfiles as Company["companyProfiles"]) ?? [], contactPersonName: (attrs.contactPersonName as string | null) ?? null, contactPersonPhone: (attrs.contactPersonPhone as string | null) ?? null, - generalManagerName: (attrs.generalManagerName as string | null) ?? null, - generalManagerEmail: (attrs.generalManagerEmail as string | null) ?? null, - generalManagerPhone: (attrs.generalManagerPhone as string | null) ?? null, + ownerName: (attrs.ownerName as string | null) ?? null, + ownerEmail: (attrs.ownerEmail as string | null) ?? null, + ownerPhone: (attrs.ownerPhone as string | null) ?? null, poaName: (attrs.poaName as string | null) ?? null, poaEmail: (attrs.poaEmail as string | null) ?? null, poaPhone: (attrs.poaPhone as string | null) ?? null, diff --git a/apps/edr-freight-web/backoffice/src/services/signatures.service.ts b/apps/edr-freight-web/backoffice/src/services/signatures.service.ts index 6a71c1836..d0a2dfe17 100644 --- a/apps/edr-freight-web/backoffice/src/services/signatures.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/signatures.service.ts @@ -9,12 +9,16 @@ export interface SavedSignature { stampImageUrl?: string | null; } +/** + * Signature only. A backoffice employee has no personal stamp — EDR seals with + * the one global company stamp managed under Settings — so the stamp half of + * PUT /me/signature is deliberately not exposed here, even though the shared + * endpoint still accepts it for portal (customer) users. + */ export interface SaveSignaturePayload { signerDisplayName: string; - /** Omit to keep the existing saved signature (stamp-only update). */ + /** Omit to keep the existing saved signature. */ signatureImageBase64?: string; - /** Omit to keep the existing saved stamp. */ - stampImageBase64?: string; } export const signaturesService = { diff --git a/apps/edr-freight-web/backoffice/src/services/stampSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/stampSettings.service.ts new file mode 100644 index 000000000..c88d4e08b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/stampSettings.service.ts @@ -0,0 +1,31 @@ +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; +import type { ApiResponse } from "@/types/apiResponse"; + +const BASE = "/stamp-settings"; + +/** Company stamp/seal used on generated invoice/receipt PDFs. */ +export interface StampSettings { + stampImageUrl: string | null; + updatedById: string | null; + updatedAt: string | null; +} + +export const stampSettingsService = { + get: async (): Promise => { + const response = await client.get>(BASE); + return unwrap(response.data); + }, + + set: async (stampImageBase64: string): Promise => { + const response = await client.put>(BASE, { + stampImageBase64, + }); + return unwrap(response.data); + }, + + clear: async (): Promise => { + const response = await client.delete>(BASE); + return unwrap(response.data); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index c863dbde4..e1c6b2cd6 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -64,9 +64,9 @@ export interface BookingCompany { email?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null; - generalManagerName?: string | null; - generalManagerEmail?: string | null; - generalManagerPhone?: string | null; + ownerName?: string | null; + ownerEmail?: string | null; + ownerPhone?: string | null; website?: string | null; } diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index cd2d67842..87a71bf6e 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -179,23 +179,34 @@ export interface IdentityVerificationState { verifiedAt: string | null; birthdate: string | null; gender: string | null; -} - -/** Mirrors `OwnerIdentityStateDto`. */ -export interface OwnerIdentityState extends IdentityVerificationState { + /** Typed passport number — the foreign-company alternative to Fayda. */ passportNumber: string | null; } /** - * Owner/PoA Fayda verification, shared with the portal's derivation + * The company's single identity verification, shared with the portal's derivation * (`buildCompanyIdentityState`) so backoffice never re-derives — or * disagrees with — the rule the API actually enforces. */ export interface CompanyIdentityState { - faydaRequired: boolean; - passportRequired: boolean; - owner: OwnerIdentityState; + /** Foreign company: a passport number proves the person as Fayda would. */ + passportAccepted: boolean; + /** Whether the company named a representative. Null = never answered. */ + poaDeclared: "yes" | "no" | null; + /** Whose verification the company is gated on — PoA if declared, else owner. */ + subject: "owner" | "poa" | null; + owner: IdentityVerificationState; poa: IdentityVerificationState; + identityProven: boolean; + /** The manager named on the eTrade licence, captured at lookup. */ + etradeManagerName: string | null; + /** + * Does the owner the company put forward match the eTrade licence? + * THE reviewer check. Null when there is nothing to compare. Advisory — + * eTrade and Fayda transliterate Amharic names differently, so a `false` is + * "look at this", not "reject this". + */ + ownerMatchesEtrade: boolean | null; complete: boolean; } @@ -211,14 +222,21 @@ export interface Company { fanNumber?: string | null; country: string; nationality?: CompanyNationality | null; + /** + * A co-operative union or farm: a TIN but no trade licence, so its + * registration was typed rather than fetched from eTrade, there is no eTrade + * manager to check the owner against, and it holds no freight-forwarder role. + */ + cooperative?: boolean; address?: string | null; phone?: string | null; email?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null; - generalManagerName?: string | null; - generalManagerEmail?: string | null; - generalManagerPhone?: string | null; + /** The owner — whoever the eTrade licence names as the business's manager. */ + ownerName?: string | null; + ownerEmail?: string | null; + ownerPhone?: string | null; poaName?: string | null; poaEmail?: string | null; poaPhone?: string | null; diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index 56273351d..acedc57f5 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -11,7 +11,7 @@ import { } from "@mantine/core"; import { useEffect, useRef, useState } from "react"; import type { UseFormRegisterReturn } from "react-hook-form"; -import { AlertCircle, Building2, Download } from "lucide-react"; +import { AlertCircle, Building2, Download, Info } from "lucide-react"; import { useETradeData } from "@/hooks/useETradeData"; import { extractApiError } from "@/utils/result"; import type { CompanyRegistrationData } from "@edr/types"; @@ -54,6 +54,14 @@ interface ETradeInfoProps { * than silently snapping to eTrade's first one. */ selectedLicenceNumber?: string; + /** + * A record is worth having but not required — a co-operative union or farm + * registers on a TIN alone, so eTrade may legitimately hold nothing for it. + * The lookup still runs (plenty of co-operatives DO have a record, and it + * beats typing), but "not found" stops being a red dead end and becomes the + * expected outcome, with the form below to fill in by hand. + */ + registrationOptional?: boolean; } // Digits, not just length: a 10-character non-numeric TIN used to fire a lookup @@ -70,6 +78,7 @@ export default function ETradeInfo({ onReset, alreadyVerified, selectedLicenceNumber, + registrationOptional = false, }: ETradeInfoProps) { const mutation = useETradeData(); const isLoading = mutation.isPending; @@ -327,16 +336,26 @@ export default function ETradeInfo({ )} - {notFound && ( - } - color="red" - title="No matching business record" - > - This TIN isn't registered with eTrade. Check the number — we can't - continue without a matching business record. - - )} + {notFound && + (registrationOptional ? ( + } + color="blue" + title="Nothing on file at eTrade for this TIN" + > + That's expected without a trade licence. Fill in your registration + details below and we'll take them as you give them. + + ) : ( + } + color="red" + title="No matching business record" + > + This TIN isn't registered with eTrade. Check the number — we can't + continue without a matching business record. + + ))} {errorMessage && ( , title: "Company Information", description: - "Confirm your VAT number, verify the owner's identity, and we'll pull your registration from eTrade.", + "Confirm your VAT number and we'll pull your registration straight from eTrade.", }, - personnel: { + owner: { icon: , - title: "General Manager", - description: "Who is the general manager of the company?", + title: "Company Owner", + description: + "The person registered on your eTrade licence. We fill in what eTrade and Fayda gave us.", + }, + representation: { + icon: , + title: "Who Acts For You", + description: + "Tell us whether anyone holds power of attorney — your answer decides whose identity we verify.", }, contact: { icon: , title: "Contact Person", description: "Who should we reach out to about this account?", }, - poa: { - icon: , - title: "Power of Attorney", - description: "Optionally add a representative with power of attorney.", - }, documents: { icon: , title: "Upload Documents", @@ -157,6 +165,15 @@ export default function OnboardingWizardDialog({ const [roles, setRoles] = useState( existingProfiles.map((p) => p.type), ); + const [cooperative, setCooperative] = useState( + company?.company?.attributes?.cooperative === true, + ); + // Ticking the box drops a role the company can no longer hold, rather than + // letting Continue fail on a selection the API refuses. + const handleCooperativeChange = useCallback((checked: boolean) => { + setCooperative(checked); + if (checked) setRoles((prev) => prev.filter((r) => r !== "freight_forwarder")); + }, []); const [documentFiles, setDocumentFiles] = useState< Record >({}); @@ -207,10 +224,11 @@ export default function OnboardingWizardDialog({ companyType: string; roles: ProfileTypeValue[]; nationality?: CompanyNationality; + cooperative?: boolean; }) => api.companies.startOnboarding.call(vars), onSuccess: async () => { // Nationality drives the server-resolved identity requirements (Fayda vs - // passport), the document set and the GM/PoA copy — all read from + // passport), the document set and the PoA copy — all read from // onboardingRequirements/profile. Re-entering role selection can change // it, so both must be refetched alongside getInfo or the form step would // keep rendering the previous nationality's requirements. @@ -289,6 +307,7 @@ export default function OnboardingWizardDialog({ resumedRef.current = true; setRoles(existingProfiles.map((p) => p.type)); setNationality(savedNationality); + setCooperative(company?.company?.attributes?.cooperative === true); // Resume into the form only when profiles exist; otherwise send the user to // role selection so the missing operational profiles get created. setPhase(hasOperationalProfiles ? "form" : "nationality-role"); @@ -303,8 +322,9 @@ export default function OnboardingWizardDialog({ companyType: companyTypeForRoles(roles), roles: roles as ProfileTypeValue[], nationality: nationality ?? undefined, + cooperative, }); - }, [roles, nationality, startMutation]); + }, [roles, nationality, cooperative, startMutation]); // Back from the form's first step returns to nationality/role selection. // Safe to re-enter: startOnboarding is idempotent — it reuses the existing @@ -414,15 +434,21 @@ export default function OnboardingWizardDialog({ const requiredDocsMissing = requirementDocuments.some( (d) => d.isRequired && !d.uploaded, ); - // The PoA gets the same treatment: a resumed draft that predates the - // delegation-letter requirement (or a forwarder whose PoA is blank) must land - // back on the PoA step, where both the details and the letter are entered. - const poaIncomplete = requirementsQuery.data?.poa?.complete === false; + // The representation step gets the same treatment. An unanswered + // power-of-attorney question, or a declared representative still missing + // details or the DARS paper, must land the customer back on the step where + // all of that is entered — including a draft that predates the question + // existing at all, whose `declared` comes back null. + const representationIncomplete = + requirementsQuery.data?.poa?.declared == null || + requirementsQuery.data?.poa?.complete === false || + requirementsQuery.data?.identity?.identityProven === false; // Each unmet requirement lowers the ceiling; resume never moves forward. let ceiling = FORM_STEPS.length - 1; if (requiredDocsMissing) ceiling = Math.min(ceiling, FORM_STEPS.indexOf("documents")); - if (poaIncomplete) ceiling = Math.min(ceiling, FORM_STEPS.indexOf("poa")); + if (representationIncomplete) + ceiling = Math.min(ceiling, FORM_STEPS.indexOf("representation")); const effectiveResumeStep: FormStep = FORM_STEPS[ Math.min(Math.max(FORM_STEPS.indexOf(resumeFormStep), 0), ceiling) @@ -446,10 +472,19 @@ export default function OnboardingWizardDialog({ onLicenseChange: setLicenseFiles, uploadedDocumentKeys, onUploadDocuments: handleUploadDocuments, - // Fayda verification state for the owner and the PoA — the general manager - // stays a plain typed role. Mandatory (Fayda) for an Ethiopian company; - // a foreign one requires a typed passport number for the owner instead. + // The company's single identity verification, and whose it is. Fayda is + // mandatory for an Ethiopian company; a foreign one may instead type a + // passport number for the same person. identity: requirementsQuery.data?.identity, + // Server-confirmed, not the local checkbox: the flag is only real once + // startOnboarding has persisted it, and the form's whole company step + // branches on it. + cooperative: requirementsQuery.data?.cooperative ?? cooperative, + extraDocumentSettingCode: + requirementsQuery.data?.cooperativeDocumentSettingCode ?? null, + // A freight forwarder cannot answer the power-of-attorney question — the + // API forces "yes" — so the step offers no way to change it. + declarationLocked: requirementsQuery.data?.poa?.locked ?? false, onIdentityChange: () => { void profileQuery.refetch(); void requirementsQuery.refetch(); @@ -517,6 +552,16 @@ export default function OnboardingWizardDialog({ onChange={setNationality} embedded /> + {/* A co-operative union or farm registers on a TIN alone. It + changes what the next step asks for (typed registration, no + eTrade lookup), which documents apply, and which roles are on + offer — so it is answered here, alongside the other two. */} + handleCooperativeChange(e.currentTarget.checked)} + label="We're a co-operative union or farm" + description="For members with a TIN but no business licence. You'll type your registration details instead of us pulling them from eTrade, and upload your co-operative papers in place of a trade licence." + /> What does your company do?(multiple) @@ -524,6 +569,9 @@ export default function OnboardingWizardDialog({ value={roles} onChange={setRoles} embedded + // Forwarding is licensed work — a co-op holds no licence, so + // the role is not offered rather than refused later. + excludeTypes={cooperative ? ["freight_forwarder"] : undefined} /> {startError && ( diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index 8a2f1d12f..13b0d4025 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -49,49 +49,46 @@ import TabAccount from "./settings/TabAccount"; import TabCompanyProfile from "./settings/TabCompanyProfile"; import TabContactPerson from "./settings/TabContactPerson"; import TabDocuments from "./settings/TabDocuments"; -import TabGeneralManager from "./settings/TabGeneralManager"; +import TabOwner from "./settings/TabOwner"; import TabPowerOfAttorney from "./settings/TabPowerOfAttorney"; type SettingsTab = | "account" | "company" | "contact" - | "gm" + | "owner" | "poa" | "documents"; /** A section is "incomplete" when its required fields aren't filled in yet. */ function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean { switch (tabId) { - case "company": { - // Identity proof lives here: the owner's Fayda verification for an - // Ethiopian company, or the owner's typed passport number for a foreign - // one. - const identity = profile.identity; - const identityIncomplete = identity - ? (identity.faydaRequired && !identity.owner.verified) || - (identity.passportRequired && !identity.owner.passportNumber) - : false; - return !profile.companyAddress || identityIncomplete; - } + case "company": + return !profile.companyAddress; case "contact": return !profile.contactPersonName || !profile.contactPersonPhone; - case "gm": - // The GM is established through Fayda — verified in their own right or - // declared the same person as the owner — so the identity answers this, - // not the typed columns. A company that may still type them (foreign, - // whose manager may hold no Fayda ID) is judged on those instead. - if (profile.identity?.gm.verified) return false; - if (profile.identity?.faydaRequired) return true; + case "owner": + // The owner is whoever the eTrade licence names. All three details are + // required whatever supplied them, and the identity verification lives + // on whichever person the PoA declaration points at — flagged here when + // it is the owner and still unproven. return ( - !profile.generalManagerName || - !profile.generalManagerEmail || - !profile.generalManagerPhone + !profile.ownerName || + !profile.ownerEmail || + !profile.ownerPhone || + (profile.identity?.subject === "owner" && + !profile.identity.identityProven) + ); + case "poa": + // Unanswered is itself incomplete — the answer decides whose identity is + // verified — as is a declared representative who has not proved theirs. + if (profile.identity?.poaDeclared == null) return true; + return ( + profile.identity.subject === "poa" && !profile.identity.identityProven ); case "account": // Account fields live on the IAM user, not the company profile, and are // always populated (signup requires them) — nothing to nag about here. - case "poa": case "documents": return false; } @@ -101,7 +98,7 @@ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [ { id: "account", label: "Account", icon: }, { id: "company", label: "Company", icon: }, { id: "contact", label: "Contact Person", icon: }, - { id: "gm", label: "General Manager", icon: }, + { id: "owner", label: "Owner", icon: }, { id: "poa", label: "Power of Attorney", icon: }, { id: "documents", label: "Documents", icon: }, ]; @@ -405,8 +402,8 @@ 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 92e1a7118..59fcc2223 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -9,7 +9,6 @@ import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { CompanyRegistrationData } from "@edr/types"; -import { toEthiopianE164 } from "@/components/PhoneField"; import { getMinFiles } from "@/types/fileUploadSettings"; import { api } from "@/services/api"; import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep"; @@ -27,15 +26,21 @@ import { firstValidEmail, firstValidPhone, normalizeIdentityPhones, + resolveOwnerSources, stepPayload, toFormValues, } from "./companyProfileForm/helpers"; import { verifaydaService } from "@/services/verifayda.service"; -import type { CompanyIdentityState } from "@/services/verifayda.service"; +import type { + CompanyIdentityState, + PoaDeclaration, +} from "@/services/verifayda.service"; import CompanyInfoStep from "./companyProfileForm/steps/CompanyInfoStep"; -import PersonnelStep from "./companyProfileForm/steps/PersonnelStep"; +import OwnerStep from "./companyProfileForm/steps/OwnerStep"; import ContactStep from "./companyProfileForm/steps/ContactStep"; -import PoaStep from "./companyProfileForm/steps/PoaStep"; +import RepresentationStep, { + type IdentityMethod, +} from "./companyProfileForm/steps/RepresentationStep"; import DocumentsStep from "./companyProfileForm/steps/DocumentsStep"; export default function CompanyProfileForm({ @@ -59,6 +64,9 @@ export default function CompanyProfileForm({ onUploadDocuments, identity: rawIdentity, onIdentityChange, + cooperative = false, + declarationLocked = false, + extraDocumentSettingCode, }: { documentSettingCode: string; documentFiles?: Record; @@ -96,7 +104,7 @@ export default function CompanyProfileForm({ onUploadDocuments?: () => Promise< { ok: true } | { ok: false; error: string } >; - /** Fayda verification state for the owner and the PoA (undefined until loaded). */ + /** The company's single identity verification (undefined until loaded). */ identity?: CompanyIdentityState; /** * Refetch the profile + requirements. Only the in-page identity actions need @@ -104,6 +112,20 @@ export default function CompanyProfileForm({ * a freshly booted app, so it has nothing to notify. */ onIdentityChange?: () => void; + /** + * The company trades as a co-operative: a TIN but no business licence, so the + * eTrade lookup is replaced by typed registration details, the per-role + * licence upload is not owed, and its own document set applies on top of the + * nationality one. + */ + cooperative?: boolean; + /** + * The company operates as a freight forwarder, so the power-of-attorney + * answer is forced to "yes" and cannot be changed here. + */ + declarationLocked?: boolean; + /** Additional document set merged in (the co-operative one), if any. */ + extraDocumentSettingCode?: string | null; }) { // A Fayda claim carries the phone as the national registry holds it, which is // often a local number the form's E.164 validation (and the API's @@ -116,7 +138,24 @@ export default function CompanyProfileForm({ const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); - const [saveError, setSaveError] = useState(null); + /** + * What is holding this step back. + * + * `kind` only picks the title. Most of these never reach the API at all — + * they are the step's own gates (a TIN eTrade hasn't confirmed, an + * unanswered declaration, a missing paper) — and heading every one of them + * "Couldn't save this step" told the customer a request failed when none was + * made, which reads as a bug on our side rather than a field to go fix. + */ + const [saveError, setSaveError] = useState<{ + kind: "check" | "save"; + message: string; + } | null>(null); + /** A step gate or a validation failure: something on screen to correct. */ + const failCheck = (message: string) => + setSaveError({ kind: "check", message }); + /** The API refused the save; the message is the server's, shown verbatim. */ + const failSave = (message: string) => setSaveError({ kind: "save", message }); // Live eTrade lookup status, reported up by ETradeInfo — drives the Continue // gate on the company step. const [tinStatus, setTinStatus] = useState("idle"); @@ -148,7 +187,7 @@ export default function CompanyProfileForm({ // Follow a parent-driven resume correction: if initialStep changes (the wizard // re-clamps it back once onboarding requirements load — e.g. a required - // document is still missing, so it must not skip ahead to Business License), + // document is still missing, so it must not skip ahead to the documents step), // adopt it, but only while the user hasn't started navigating themselves. const lastInitialStep = useRef(initialStep); useEffect(() => { @@ -167,22 +206,36 @@ export default function CompanyProfileForm({ const documentFiles = controlledFiles ?? internalFiles; const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles; - const { data: uploadSetting, isLoading: loadingDocuments } = useQuery( + const { data: nationalitySetting, isLoading: loadingDocuments } = useQuery( api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false, }), ); - - // A freight forwarder signs on other companies' behalf, so its Power of - // Attorney (details + DARS delegation paper) is mandatory rather than optional. - const requirePoa = (roleProfiles ?? []).some( - (p) => p.type === "freight_forwarder", + // A co-operative's own documents come as a second, additive set — it uploads + // everything its nationality demands, plus the papers standing in for the + // business licence it does not hold. The API merges the same two sets when it + // decides what is outstanding. + const { data: extraSetting } = useQuery( + api.fileUploadSettings.getByCode.queryOptions({ + input: { code: extraDocumentSettingCode ?? "" }, + enabled: Boolean(extraDocumentSettingCode), + refetchOnMount: false, + }), ); - // Fayda is an Ethiopian national ID: an Ethiopian company verifies its owner - // and PoA instead of typing their details, a foreign one keeps the typed - // forms (plus a mandatory owner passport number). - const verifiedIdentity = identity?.faydaRequired === true; + const uploadSetting = useMemo(() => { + if (!nationalitySetting) return nationalitySetting; + if (!extraSetting?.fields?.length) return nationalitySetting; + // Nationality wins a fileKey collision, so a slot is never rendered twice. + const seen = new Set(nationalitySetting.fields.map((f) => f.fileKey)); + return { + ...nationalitySetting, + fields: [ + ...nationalitySetting.fields, + ...extraSetting.fields.filter((f) => !seen.has(f.fileKey)), + ], + }; + }, [nationalitySetting, extraSetting]); // Which fields the current step renders an input for and therefore requires. // Filled in further down (it depends on values this form owns), and read at @@ -192,16 +245,15 @@ export default function CompanyProfileForm({ const form = useForm({ resolver: (values, context, options) => - zodResolver( - buildOnboardingSchema( - identity?.passportRequired === true, - requiredKeysRef.current, - ), - )(values, context, options), + zodResolver(buildOnboardingSchema(requiredKeysRef.current))( + values, + context, + options, + ), // `values` below re-seeds the form whenever the profile is refetched — and - // an in-page identity action (ticking "same as owner") refetches it. Without - // this, that reset silently throws away whatever the customer was part-way - // through typing on the current step. + // an in-page identity action (answering the PoA question) refetches it. + // Without this, that reset silently throws away whatever the customer was + // part-way through typing on the current step. resetOptions: { keepDirtyValues: true, keepErrors: true }, defaultValues: { companyName: "", @@ -210,6 +262,7 @@ export default function CompanyProfileForm({ tinNumber: "", vatNumber: "", ownerPassportNumber: "", + poaPassportNumber: "", licenceNumber: "", statusDescription: "", dateRegistered: "", @@ -225,12 +278,11 @@ export default function CompanyProfileForm({ contactPersonPosition: "", contactPersonEmail: "", contactPersonPhone: "", - generalManagerName: "", - generalManagerEmail: "", - generalManagerPhone: "", + ownerName: "", + ownerEmail: "", + ownerPhone: "", poaName: "", poaPhone: "", - poaAddress: "", poaEmail: "", poaLocation: "", }, @@ -248,14 +300,23 @@ export default function CompanyProfileForm({ formState: { dirtyFields }, } = form; - // The contact person's email still just seeds from the account and stays editable. + // Retire the alert the moment the customer starts acting on it. It was only + // ever cleared on navigation, so a red "fix these fields" banner sat above a + // form they had already fixed, right until they pressed Continue again — + // which reads as an error the page is refusing to let go of. useEffect(() => { - if (!user?.email) return; - if (!watch("contactPersonEmail")) { - setValue("contactPersonEmail", user.email); - } + const sub = watch(() => setSaveError(null)); + return () => sub.unsubscribe(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user?.email, rehydrate]); + }, []); + + // Nothing seeds the contact person's email. It used to be prefilled from the + // signed-in account, on the same assumption the owner fields were cleared of: + // the person doing the onboarding is routinely not the person the company + // wants contacted. Prefilled and editable is still prefilled — it is accepted + // as-is far more often than it is corrected, so the account address ends up on + // file as the company's contact by default rather than by anyone's decision. + // The field is optional, so an empty one costs nothing. // Keep the (hidden, derived) company address in sync with the editable address // fields — so it reflects both the eTrade auto-fill and any later user edits, @@ -269,22 +330,63 @@ export default function CompanyProfileForm({ const composed = [houseNo, kebele, woreda, zone, region] .filter((part) => part && part.trim()) .join(", "); + // No parts means nothing to compose from — the lookup hasn't landed yet, or + // this is the render before rehydration. Writing "" here would replace a + // saved address with a blank on the next company-step save. + if (!composed) return; setValue("companyAddress", composed); // eslint-disable-next-line react-hooks/exhaustive-deps }, [region, zone, woreda, kebele, houseNo]); - // The business owner/manager pulled from eTrade — powers "Use owner as - // manager" on the General Manager step. Null until a TIN lookup succeeds. - const [etradeOwner, setEtradeOwner] = useState<{ + // The manager eTrade lists for this licence, as returned by a lookup made in + // THIS session. This is the person the owner step is about — "owner" here + // means whoever the licence names, and the backoffice checks the stored owner + // against exactly this. + const [liveEtradeOwner, setLiveEtradeOwner] = useState<{ name: string; phone: string; } | null>(null); + // The TIN moved off the last verified lookup, so the persisted manager below + // describes a licence this company is no longer claiming. Without this, a + // reset would re-lock the owner fields against stale data the moment the + // memo below fell back to the server's copy. + const [etradeCleared, setEtradeCleared] = useState(false); + + /** + * The eTrade manager, live lookup or not. + * + * The lookup result dies with the page, but the fact that the owner came from + * eTrade must not — a resumed wizard that has forgotten it renders the + * licence's own name and phone as empty, typeable inputs, which is both a + * regression of the read-only rule and an invitation to overwrite the record + * the backoffice checks against. The API captured the manager at lookup time + * for exactly this, so a resume reads it back from the identity state. + */ + const etradeOwner = useMemo(() => { + if (liveEtradeOwner) return liveEtradeOwner; + if (etradeCleared) return null; + const name = identity?.etradeManagerName?.trim() ?? ""; + // Normalized on the way out of storage, and dropped if it cannot be — the + // stored value is eTrade's free text, and a resume must reach the same + // conclusion about it as the lookup did. + const phone = firstValidPhone(identity?.etradeManagerPhone); + return name || phone ? { name, phone } : null; + }, [ + liveEtradeOwner, + etradeCleared, + identity?.etradeManagerName, + identity?.etradeManagerPhone, + ]); + + /** A lookup has actually filled the registration fields this session. */ + const etradeFilledRef = useRef(false); // `shouldDirty` is what marks the eTrade bundle as "re-verified this session"; // `stepPayload` sends those keys only when dirty, so an unchanged record is // never echoed back to the API (which would make it re-query eTrade). const handleETradeDataLoaded = (data: CompanyRegistrationData) => { const dirty = { shouldDirty: true } as const; + etradeFilledRef.current = true; if (data.companyName) { setValue("companyName", data.companyName, { shouldValidate: true, @@ -302,32 +404,66 @@ export default function CompanyProfileForm({ setValue("woreda", data.woreda, dirty); setValue("kebele", data.kebele, dirty); setValue("houseNo", data.houseNo, dirty); - // companyAddress is composed reactively from the address fields below, so - // setting region/zone/woreda/kebele/houseNo above is enough — no need to - // compose it here. companyPhone is derived below (identity → eTrade → - // account), not set directly here. + // companyAddress is composed reactively from the address fields above. // etradePhone is the raw number eTrade returned for this TIN — kept as its - // own field (distinct from companyPhone, which prefers the Fayda-verified - // owner's phone) so the backend's "matches eTrade's current record" check - // always compares against what eTrade actually said, not the owner's phone. + // own field (distinct from the owner's phone) so the backend's "matches + // eTrade's current record" check always compares against what eTrade + // actually said. setValue( "etradePhone", data.managerPhone || data.regularPhone || data.mobilePhone, dirty, ); - setEtradeOwner({ + // The owner's phone is only eTrade's to own if eTrade gave a usable one. + // The licence desk's field is free text, so it may hold "09 " — which + // normalizes to something non-empty and invalid. Dropping it here leaves the + // owner step with an empty, required, editable phone input, which is the + // honest state: eTrade has nothing we can use. + const owner = { name: data.managerName, - phone: toEthiopianE164( + phone: firstValidPhone( data.managerPhone || data.regularPhone || data.mobilePhone, ), - }); + }; + setEtradeCleared(false); + setLiveEtradeOwner(owner); + + // What eTrade said, verbatim — this replaces whatever is in the field + // rather than only filling a gap. The owner step shows an eTrade-sourced + // name and phone read-only, so leaving an older value in place would send + // the API something the customer is no longer shown and cannot correct. + // + // A Fayda verification still outranks the licence: it owns those fields + // server-side, so overwriting them here would only produce a value the API + // discards on the way in. + if (owner.name && !identity?.owner.name?.trim()) { + setValue("ownerName", owner.name, { shouldValidate: true, ...dirty }); + } + if (owner.phone && !identity?.owner.phone?.trim()) { + setValue("ownerPhone", owner.phone, { shouldValidate: true, ...dirty }); + } }; // TIN changed since the last successful lookup — the registration/address // fields it filled in describe the OLD TIN, not this one, so clear them // rather than leaving them stale on screen. + // + // The owner goes too: different licences under one TIN can list different + // managers, so a prefill from the previous pick is someone else's name. + // Only the prefill is cleared — a Fayda-verified owner is the API's to own. const handleETradeReset = () => { + // ...unless there was no prefill to clear. A co-operative now runs the same + // lookup as everyone else, but it types these fields itself when eTrade + // holds nothing — and wiping them because the customer went back to fix a + // digit of their TIN would throw away an address they had just typed by + // hand, over a lookup that never filled anything in the first place. + if (cooperative && !etradeFilledRef.current) { + setLiveEtradeOwner(null); + setEtradeCleared(true); + return; + } + etradeFilledRef.current = false; setValue("licenceNumber", ""); setValue("statusDescription", ""); setValue("dateRegistered", ""); @@ -340,273 +476,159 @@ export default function CompanyProfileForm({ setValue("kebele", ""); setValue("houseNo", ""); setValue("etradePhone", ""); - setEtradeOwner(null); - }; - - // "Same as …" links. A checked card prefills the target step's fields from the - // source step and disables them (kept mirrored while linked); unchecking clears - // them and re-enables editing. - // Seeded from the server so a resumed draft reopens with the declaration the - // company already made, rather than an unticked box over a linked GM. - const [gmSameAsOwner, setGmSameAsOwner] = useState( - identity?.gmSameAsOwner ?? false, - ); - // `identity` is undefined on the first render (the requirements query is still - // in flight), so the initial state above freezes at `false` — adopt the - // server's declaration the moment it lands, or a resumed draft shows an - // unticked box over a GM that is linked server-side. - const [poaSameAsOwner, setPoaSameAsOwner] = useState( - identity?.poaSameAsOwner ?? false, - ); - const identityLoaded = useRef(false); - useEffect(() => { - if (!identity || identityLoaded.current) return; - identityLoaded.current = true; - setGmSameAsOwner(identity.gmSameAsOwner); - setPoaSameAsOwner(identity.poaSameAsOwner); - }, [identity]); - const [contactSameAsGm, setContactSameAsGm] = useState(false); - - // Where the owner's details come from when they are copied onto someone else - // — the GM, or the representative. A Fayda-verified owner outranks eTrade's - // registered owner: it's the higher-trust source, and the whole point of - // proving identity is to stop trusting typed/looked-up data for this. - const ownerSourceName = firstPresent( - identity?.owner.name, - etradeOwner?.name, - user.name?.en, - ); - - const ownerSourceEmail = firstValidEmail(identity?.owner.email, user.email); - // Same reason as `derivedPhone`: this value is written into - // `generalManagerPhone` / `poaPhone`, which the API validates with - // `@IsValidPhone()`, so an unusable eTrade number here 400s the step instead. - const ownerSourcePhone = firstValidPhone( - identity?.owner.phone, - etradeOwner?.phone, - user.phoneNumber, - ); - - useEffect(() => { - if (!gmSameAsOwner) return; - // A verified owner's identity is copied server-side and read back from - // `identity.gm`; mirroring it into form fields here would send typed - // values for something the API already owns. - if (identity?.owner.verified) return; - setValue("generalManagerName", ownerSourceName, { shouldValidate: true }); - setValue("generalManagerEmail", ownerSourceEmail, { shouldValidate: true }); - setValue("generalManagerPhone", ownerSourcePhone, { - shouldValidate: true, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [gmSameAsOwner, ownerSourceName, ownerSourceEmail, ownerSourcePhone]); - - // The representative's half of the same copy. A verified owner's identity is - // copied server-side and read back from `identity.poa`, so only an owner - // backed by a typed passport is mirrored into form fields here — the same - // split the GM makes above, for the same reason. - // - // Only non-empty sources are written. A source the owner does not have is a - // gap the step renders an input for (see `poaGaps`), and this effect re-runs - // whenever any *other* source changes — so blanking here would wipe what the - // customer is typing into that input the moment an eTrade lookup lands. - useEffect(() => { - if (!poaSameAsOwner || identity?.owner.verified) return; - if (ownerSourceName) setValue("poaName", ownerSourceName, { shouldValidate: true }); - if (ownerSourceEmail) setValue("poaEmail", ownerSourceEmail, { shouldValidate: true }); - if (ownerSourcePhone) setValue("poaPhone", ownerSourcePhone, { shouldValidate: true }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [poaSameAsOwner, ownerSourceName, ownerSourceEmail, ownerSourcePhone]); - - /** - * "Same as owner" has two meanings depending on what backs the owner. - * - * A Fayda-verified owner is a proven identity, so the declaration is made - * server-side: the API copies that identity onto the GM and records what it - * did. Anything typed here would arrive wearing a verified badge it hadn't - * earned, which is exactly what the verification exists to prevent. - * - * A foreign company's owner is backed by a typed passport instead, so there - * is nothing proven to copy — that stays the local field-mirroring it has - * always been. - */ - const [gmLinkPending, setGmLinkPending] = useState(false); - const toggleGmSameAsOwner = async (checked: boolean) => { - setGmSameAsOwner(checked); if (!identity?.owner.verified) { - if (!checked) { - setValue("generalManagerName", ""); - setValue("generalManagerEmail", ""); - setValue("generalManagerPhone", ""); - } - return; - } - - setGmLinkPending(true); - try { - if (checked) await verifaydaService.setGmSameAsOwner(); - else await verifaydaService.clearGmIdentity(); - onIdentityChange?.(); - } catch (err) { - setGmSameAsOwner(!checked); - setSaveError( - (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message ?? - (err instanceof Error - ? err.message - : "Could not update the general manager"), - ); - } finally { - setGmLinkPending(false); + if (getValues("ownerName") === etradeOwner?.name) setValue("ownerName", ""); + if (getValues("ownerPhone") === etradeOwner?.phone) + setValue("ownerPhone", ""); } + setLiveEtradeOwner(null); + setEtradeCleared(true); }; /** - * The representative is the owner. Unlike the GM's card this always goes to - * the API, whichever backs the owner: the declaration itself is what waives - * the DARS delegation paper, so it has to be recorded server-side even when - * there is no proven identity to copy and the details are mirrored locally. + * Answer the power-of-attorney question. + * + * Persisted server-side rather than held in form state: the answer decides + * whose identity the API gates on, and answering "no" tears down any + * representative already recorded (details, verification and DARS paper + * together) — none of which the form could do on its own. */ - const [poaLinkPending, setPoaLinkPending] = useState(false); - const togglePoaSameAsOwner = async (checked: boolean) => { + const [declarePending, setDeclarePending] = useState(false); + const handleDeclare = async (declared: PoaDeclaration) => { setSaveError(null); - setPoaSameAsOwner(checked); - setPoaLinkPending(true); + setDeclarePending(true); try { - if (checked) await verifaydaService.setPoaSameAsOwner(); - else { - await verifaydaService.clearPoaSameAsOwner(); - // Only the locally mirrored values are ours to clear; a copied identity - // is cleared by the call above. - if (!identity?.owner.verified) { - setValue("poaName", ""); - setValue("poaEmail", ""); - setValue("poaPhone", ""); + await verifaydaService.setPoaDeclared(declared); + if (declared === "no") { + // The paper is deleted server-side with the representative; a copy + // still sitting in the picker would be re-uploaded on the next step. + setDocumentFiles({ ...documentFiles, [POA_DELEGATION_FILE_KEY]: null }); + // The representative's details go the same way. Answering "no" nulls + // every PoA attribute server-side, but the refetch below cannot undo + // what is still in the form: `keepDirtyValues` protects exactly the + // values the customer typed, so the next step save would send them + // straight back — and the API accepts them without complaint, because + // its PoA rules only run when the declaration is "yes". The company + // ended up on file with no representative and a full set of their + // details. + for (const field of [ + "poaName", + "poaEmail", + "poaPhone", + "poaLocation", + "poaPassportNumber", + ] as const) { + setValue(field, "", { shouldDirty: false, shouldValidate: false }); } } onIdentityChange?.(); } catch (err) { - setPoaSameAsOwner(!checked); - setSaveError( + failSave( (err as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? (err instanceof Error ? err.message - : "Could not update the Power of Attorney"), + : "Could not save your answer"), ); } finally { - setPoaLinkPending(false); + setDeclarePending(false); } }; - // Where the GM's details come from depends on how they were established: a - // Fayda verification (or a "same as owner" declaration) owns them outright, - // and only a company that may still type them falls back to form state. - // A verified GM's identity wins, but Fayda's email and phone claims are - // optional: what the verification did not supply is typed on this step, and - // the API deliberately does not read those back onto the identity (they are - // not proven), so the form value is the only place they exist. - const gmVerified = identity?.gm.verified ?? false; - const gmName = gmVerified - ? firstPresent(identity?.gm.name, watch("generalManagerName")) - : watch("generalManagerName"); - const gmEmail = gmVerified - ? firstPresent(identity?.gm.email, watch("generalManagerEmail")) - : watch("generalManagerEmail"); - const gmPhone = gmVerified - ? firstPresent(identity?.gm.phone, watch("generalManagerPhone")) - : watch("generalManagerPhone"); - /** - * Whether the GM has been established at all — by verification, by the - * "same as owner" declaration, or (only where Fayda is optional) by typing. - * Fayda is an Ethiopian national ID, so a foreign company's GM may hold none. + * Who owns each of the owner's fields — and therefore which of them are shown + * read-only rather than as inputs. See `resolveOwnerSources`; the rule it + * encodes pairs with `requiredKeys` below, which requires exactly the fields + * no source owns. */ - // Matches what the API actually demands (`REQUIRED_COMPANY_INFO`): a name and - // a phone. The email is collected but optional — a manager proved through - // Fayda may have no email claim, and the notify resolver no longer needs one. - const gmTyped = Boolean( - watch("generalManagerName")?.trim() && - watch("generalManagerPhone")?.trim(), + const ownerVerified = identity?.owner.verified ?? false; + const poaVerified = identity?.poa.verified ?? false; + const usableEmail = (value?: string | null) => Boolean(firstValidEmail(value)); + const usablePhone = (value?: string | null) => Boolean(firstValidPhone(value)); + const { source: ownerSource, sourced: ownerSourced } = resolveOwnerSources( + identity, + etradeOwner, ); - const gmEstablished = - gmVerified || (identity ? !identity.faydaRequired && gmTyped : gmTyped); - /** - * Same rule for the representative: verified, or entered where Fayda is - * optional. - * - * Matches the API's own rule (`REQUIRED_POA_FIELDS`): a typed representative - * counts once they have a name, an email and a phone. The step now renders - * inputs for all three, so this is something the customer can actually - * satisfy — previously it gated on `poaName`, for which no input existed - * anywhere, leaving a foreign freight forwarder permanently stuck. - */ - const poaTyped = Boolean( - watch("poaName")?.trim() && - watch("poaEmail")?.trim() && - watch("poaPhone")?.trim(), - ); - const poaEstablished = - (identity?.poa.verified ?? false) || - (identity ? !identity.faydaRequired && poaTyped : false); - - /** - * Drop an optional representative the company no longer wants. - * - * Verifying a PoA is one click on a step that calls itself optional, and it - * is not reversible from the form: the verification owns the fields (so - * blanking them is refused), and its mere existence makes the DARS paper due - * — which then blocks the submit AND clamps the resume back to this step. The - * settings page has the same escape hatch, but `/settings` is off-limits - * until onboarding finishes, so without this the customer is stuck. - * - * Not offered to a freight forwarder: the API refuses (they must have one). - */ - const [poaRemovePending, setPoaRemovePending] = useState(false); - const removePoa = async () => { - setSaveError(null); - setPoaRemovePending(true); - try { - await verifaydaService.removePoa(); - for (const key of [ - "poaName", - "poaEmail", - "poaPhone", - "poaAddress", - "poaLocation", - ] as const) { - setValue(key, "", { shouldDirty: false }); - } - // The paper is deleted server-side with the identity; a copy still - // sitting in the picker would be re-uploaded on the documents step. - setDocumentFiles({ ...documentFiles, [POA_DELEGATION_FILE_KEY]: null }); - onIdentityChange?.(); - } catch (err) { - setSaveError( - (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message ?? - (err instanceof Error - ? err.message - : "Could not remove the Power of Attorney"), - ); - } finally { - setPoaRemovePending(false); + // Keep the form holding exactly what the read-only rows show. The two are + // filled from different places — the rows from the licence, the fields from + // the lookup that ran or from the rehydrated profile — and only the fields + // are submitted, so a divergence would send the API something the customer + // was never shown and had no input to correct. eTrade wins it, which is what + // "not editable" has to mean for a value already on file. + useEffect(() => { + if (ownerSource.name === "eTrade" && getValues("ownerName") !== ownerSourced.name) { + setValue("ownerName", ownerSourced.name, { shouldValidate: true }); } + if ( + ownerSource.phone === "eTrade" && + getValues("ownerPhone") !== ownerSourced.phone + ) { + setValue("ownerPhone", ownerSourced.phone, { shouldValidate: true }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ownerSource.name, ownerSource.phone, ownerSourced.name, ownerSourced.phone]); + // No `address`: Fayda's address claim is `poaAddress`, which the portal never + // sends — the step's only address-shaped input writes `poaLocation`, which is + // the company's own statement and stays typeable however well Fayda knows + // where the person lives. + // Same validity test as the owner's: a Fayda claim the schema would reject is + // not a claim this step can hide the input behind. + const poaLocked = { + name: poaVerified && Boolean(identity?.poa.name?.trim()), + email: poaVerified && usableEmail(identity?.poa.email), + phone: poaVerified && usablePhone(identity?.poa.phone), }; + /** + * How a foreign company chose to prove its subject: Fayda, or a passport. + * + * An either/or rather than a fallback, so nothing is asked until one side is + * picked. Seeded from what already happened — a completed verification or a + * saved passport number is itself the answer — and only then held locally, + * because the choice is a UI fork with nothing to persist: what the API + * stores is the proof, not the route taken to it. + */ + const [identityMethod, setIdentityMethod] = useState( + null, + ); + const passportSaved = Boolean( + identity?.subject === "poa" + ? identity?.poa.passportNumber?.trim() + : identity?.owner.passportNumber?.trim(), + ); + const subjectVerified = identity?.subject === "poa" ? poaVerified : ownerVerified; + const effectiveMethod: IdentityMethod | null = !identity?.passportAccepted + ? "fayda" // An Ethiopian company has no choice to make. + : subjectVerified + ? "fayda" + : (identityMethod ?? (passportSaved ? "passport" : null)); + + // The owner's name from whichever source established them — powers the + // contact step's "same as owner" card. + // + // Email and phone are picked by validity, not mere presence. Both are copied + // into contact fields the schema requires to be well-formed, and a Fayda + // claim or an eTrade record routinely carries something that is neither empty + // nor usable ("09 "). Taking it would fail the contact step on a value + // the customer never typed and — while the copy is linked — cannot edit. + const ownerName = firstPresent(identity?.owner.name, watch("ownerName")); + const ownerEmail = firstValidEmail(identity?.owner.email, watch("ownerEmail")); + const ownerPhone = firstValidPhone(identity?.owner.phone, watch("ownerPhone")); + + const [contactSameAsOwner, setContactSameAsOwner] = useState(false); // 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. useEffect(() => { - if (!contactSameAsGm) return; - setValue("contactPersonName", gmName ?? "", { shouldValidate: true }); - setValue("contactPersonEmail", gmEmail ?? ""); - setValue("contactPersonPhone", gmPhone ?? "", { shouldValidate: true }); + if (!contactSameAsOwner) return; + setValue("contactPersonName", ownerName, { shouldValidate: true }); + setValue("contactPersonEmail", ownerEmail); + setValue("contactPersonPhone", ownerPhone, { shouldValidate: true }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [contactSameAsGm, gmName, gmEmail, gmPhone]); + }, [contactSameAsOwner, ownerName, ownerEmail, ownerPhone]); - const toggleContactSameAsGm = (checked: boolean) => { - setContactSameAsGm(checked); + const toggleContactSameAsOwner = (checked: boolean) => { + setContactSameAsOwner(checked); // Checked → the mirror effect fills the fields; unchecked → reset them. if (!checked) { setValue("contactPersonName", ""); @@ -616,10 +638,10 @@ export default function CompanyProfileForm({ }; // 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 - // renders the remainder. Both halves share `documentFiles`, so the existing - // bulk upload still carries it. + // rest (the API guarantees it is there), but belongs on the representation + // step next to the details it evidences — so it's split out here and the + // Documents step renders the remainder. Both halves share `documentFiles`, so + // the existing bulk upload still carries it. const poaDocumentField = uploadSetting?.fields?.find( (f) => f.fileKey === POA_DELEGATION_FILE_KEY, ); @@ -643,8 +665,6 @@ export default function CompanyProfileForm({ [uploadSetting, poaDocumentField], ); - const hasDocuments = Boolean(documentsSetting?.fields?.length); - // Hard verification for the documents step: required company-level // documents and a business license per operational profile must both be // present before the user can continue. @@ -670,9 +690,12 @@ export default function CompanyProfileForm({ return errs; }; - // Every role needs at least one license file (existing or newly selected). + // Every role needs at least one license file (existing or newly selected) — + // except a co-operative's, which holds no business licence at all. Its own + // document set is what stands in, and the API lifts the same requirement. const validateLicenses = (): Record => { const errs: Record = {}; + if (cooperative) return errs; for (const p of roleProfiles ?? []) { const hasNew = (licenseFiles?.[p.id]?.length ?? 0) > 0; const hasExisting = p.existingFiles.length > 0; @@ -687,6 +710,9 @@ export default function CompanyProfileForm({ next: Record, ) => { setDocumentFiles(next); + // Files live outside the form, so the watch subscription above never sees + // them — the "upload the required documents" alert has to be retired here. + setSaveError(null); setDocumentFieldErrors((prev) => { if (Object.keys(prev).length === 0) return prev; const updated = { ...prev }; @@ -713,8 +739,7 @@ export default function CompanyProfileForm({ // The registration/license details come straight from the eTrade lookup and // are not user-editable — shown as a read-only confirmation once a TIN lookup - // (or rehydration) has filled them in. The address fields below are separate: - // user-entered and required. We watch the values so the display stays current. + // (or rehydration) has filled them in. const registration = watch([ "licenceNumber", "statusDescription", @@ -726,57 +751,26 @@ export default function CompanyProfileForm({ const hasRegistrationDetails = registration.some((v) => v && v.trim()); // A previously-saved (rehydrated) TIN counts as verified without a refetch — // the registration fields being populated at all is proof it passed before. - const tinVerified = tinStatus === "verified" || hasRegistrationDetails; + // A co-operative never runs the lookup, so there is nothing to be verified + // against; its TIN is validated by the schema like any other typed field. + const tinVerified = + cooperative || tinStatus === "verified" || hasRegistrationDetails; // Single source of truth for step sequence — navigation, labels and the // progress bar all derive from this so adding/removing a step is one edit. const stepOrder: CompanyStep[] = [ "company", - "personnel", + "owner", + "representation", "contact", - "poa", "documents", ]; const currentIdx = stepOrder.indexOf(step); - // The DARS delegation paper is what proves the representative was actually - // delegated, so it's required the moment a PoA exists. The API enforces the - // same rule on save, so skipping it here only costs the customer a - // round-trip. - // - // "Exists" is the API's own test (`POA_ATTRIBUTES.some(...)`): ANY detail, - // verified or typed. Requiring a complete typed representative here instead - // hid the upload from a customer who had entered only a name — for whom the - // API still demands the paper, and whose resume would then be clamped back to - // this step with nothing on it to fill. - const poaAnyDetail = [ - identity?.poa.name, - identity?.poa.email, - identity?.poa.phone, - identity?.poa.address, - watch("poaName"), - watch("poaEmail"), - watch("poaPhone"), - watch("poaLocation"), - ].some((v) => v?.trim()); - const poaProvided = (identity?.poa.verified ?? false) || poaAnyDetail; - // A freight forwarder owes the paper whether or not its representative could - // verify with Fayda — the API demands it at completion either way. Keying - // this on the verification alone hid the upload from a foreign forwarder and - // then failed them on submit for a file they were never shown. - // - // Unless the owner represents the company themselves: nobody delegates to - // themselves, so there is no delegation to evidence. Mirrors the API's own - // waiver in `assertPoaDelegationSatisfied` — the two must agree, or this - // demands a file the server would accept the submission without. - // - // Split from `poaDue` — "there is a representative, so their details are - // owed" — because a self-PoA keeps the second while dropping the first. The - // API draws the same line (`poaDue` / `delegationDue` in - // getOnboardingRequirements); anything that is about the *details* must key - // on `poaDue`, only the paper keys on this. - const poaDue = poaProvided || requirePoa; - const delegationRequired = poaDue && !poaSameAsOwner; + // The DARS delegation paper is owed exactly when the company says it has a + // representative. The API enforces the same rule on save, so skipping it here + // only costs the customer a round-trip. + const delegationRequired = identity?.poaDeclared === "yes"; const delegationPresent = (uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) || (() => { @@ -785,93 +779,44 @@ export default function CompanyProfileForm({ })(); /** - * What a Fayda verification did NOT supply, per person. + * Is the company's one identity proven? * - * Fayda's email, phone and address claims are optional and routinely come - * back empty, so a *verified* person can still be missing details the API - * demands (`REQUIRED_POA_FIELDS`, `REQUIRED_COMPANY_INFO`). Those gaps are - * typed instead — the API keeps exactly the keys a claim left empty typeable, - * since a claim that returned nothing owns no value to protect. - * - * Computed here, once, and handed to both the step (which renders an input - * per gap) and the schema (which requires exactly those) — a field is - * required if and only if there is an input on screen to fix it in. + * `identity.identityProven` is the server's verdict, but it is a step behind + * a passport number the customer has just typed and not yet saved — so the + * live form value counts too. Blocking on the stale server value would refuse + * to advance past a field the customer has visibly filled in. */ - // Where Fayda is mandatory an unverified representative must verify rather - // than be typed, so nothing is offered until the verification lands. - const poaTypedAllowed = - !identity || identity.poa.verified || !identity.faydaRequired; - // A representative the verification never proved holds only typed details — - // the API leaves those unlocked, so the inputs stay on screen and stay - // editable. Only a *verified* PoA hides the fields their claim did fill, - // which is also the only case the API refuses to let anyone overwrite. - const poaGap = (v?: string | null) => - poaTypedAllowed && (!identity?.poa.verified || !v?.trim()); - /** - * "Same as owner" answers each field only as far as the owner actually has - * one. Fayda's name, email and phone claims are all optional, the account and - * eTrade fallbacks can be empty or unusable, and `REQUIRED_POA_FIELDS` still - * demands a name, an email and a phone — so anything the copy could not - * supply stays askable. Assuming the copy filled everything is what dead-ends - * the submit on "Add your poa phone" with no input anywhere to satisfy it. - * - * Keyed on the *source*, never on the field's current value: an input that - * disappears the moment the first character is typed into it is unusable. - * A verified owner's identity is copied server-side, so `identity.poa` is the - * source there; otherwise it is the same owner-derived values the mirror - * effect writes. - */ - const poaCopyGap = (copied?: string | null, mirrored?: string | null) => - identity?.owner.verified ? !copied?.trim() : !mirrored?.trim(); - const poaGaps = poaSameAsOwner - ? { - name: poaCopyGap(identity?.poa.name, ownerSourceName), - email: poaCopyGap(identity?.poa.email, ownerSourceEmail), - phone: poaCopyGap(identity?.poa.phone, ownerSourcePhone), - // The location is the one detail the API never demands, so a blank one - // dead-ends nothing — and asking for the owner's city under a card that - // says "same as owner" reads as a contradiction. - address: false, - } - : { - name: poaGap(identity?.poa.name), - email: poaGap(identity?.poa.email), - phone: poaGap(identity?.poa.phone), - address: poaGap(identity?.poa.address), - }; - // The GM's own verification never falls back to the signed-in account — that - // account is the person onboarding, not necessarily the manager — so a GM - // verified with no email claim has nowhere else for one to come from. The - // name claim is optional too, and `REQUIRED_COMPANY_INFO` demands it, so it - // gets the same treatment rather than dead-ending the submit. - // "Same as owner" is exempt: the API copies the owner's (account-backed) - // contact details across, so there is no gap and no input. - const gmGaps = { - name: !gmSameAsOwner && gmVerified && !identity?.gm.name?.trim(), - email: !gmSameAsOwner && gmVerified && !identity?.gm.email?.trim(), - phone: !gmSameAsOwner && gmVerified && !identity?.gm.phone?.trim(), - }; + const passportField = + identity?.subject === "poa" ? "poaPassportNumber" : "ownerPassportNumber"; + const identityProven = + (identity?.identityProven ?? false) || + ((identity?.passportAccepted ?? false) && + Boolean(watch(passportField)?.trim())); const requiredKeys: (keyof FormData)[] = []; - if (step === "personnel") { - // The manager's email is offered but not demanded: the API dropped it from - // `REQUIRED_COMPANY_INFO` once the notify resolver stopped depending on it. - // The name and phone are still required there, so they are still required - // here. - if (gmGaps.name) requiredKeys.push("generalManagerName"); - if (gmGaps.phone) requiredKeys.push("generalManagerPhone"); - } else if (step === "poa" && poaDue) { - // Only once a PoA is required or provided: an untouched optional PoA is - // still a step the customer may walk straight past. - // - // `poaDue`, NOT `delegationRequired`: the paper is waived for a self-PoA - // but `REQUIRED_POA_FIELDS` is not, and the API reports every one of them - // missing (`missingPoaFields` keys on its own `poaDue`) — which fails the - // submit and clamps the resume back here. Keying this on the paper let the - // customer walk past an input this step had already put on screen. - if (poaGaps.name) requiredKeys.push("poaName"); - if (poaGaps.email) requiredKeys.push("poaEmail"); - if (poaGaps.phone) requiredKeys.push("poaPhone"); + if (step === "company" && cooperative) { + // A co-operative has no eTrade record, so the fields every other company + // gets read-only from the licence are typed here — and are therefore + // required here. House number stays optional: plenty of addresses have none. + requiredKeys.push("companyName", "region", "zone", "woreda", "kebele"); + } else if (step === "owner") { + // All three are required by the API (`REQUIRED_COMPANY_INFO`), and an input + // is rendered for each one a Fayda verification does not own. + if (!ownerSource.name) requiredKeys.push("ownerName"); + if (!ownerSource.email) requiredKeys.push("ownerEmail"); + if (!ownerSource.phone) requiredKeys.push("ownerPhone"); + } else if ( + step === "representation" && + identity?.poaDeclared === "yes" && + // The details are only on screen once the person is established — before + // that the step is still asking how to prove them, and requiring a name + // with no input rendered is the dead Continue button this rule exists to + // prevent. + (poaVerified || effectiveMethod === "passport") + ) { + if (!poaLocked.name) requiredKeys.push("poaName"); + if (!poaLocked.email) requiredKeys.push("poaEmail"); + if (!poaLocked.phone) requiredKeys.push("poaPhone"); } requiredKeysRef.current = requiredKeys; @@ -879,19 +824,15 @@ export default function CompanyProfileForm({ * Collect the messages for a set of fields into one sentence. * * A failed `trigger()` used to return silently, so Continue simply did - * nothing — and every field whose input is conditionally rendered (or derived - * and never rendered at all) turned into an invisible dead end. Naming the - * failures is the whole point: the ones worth reporting are exactly the ones - * with no error text on screen to read. + * nothing — and every field whose input is conditionally rendered turned into + * an invisible dead end. Naming the failures is the whole point: the ones + * worth reporting are exactly the ones with no error text on screen to read. */ const describeErrors = (fields: (keyof FormData)[]): string => { // Re-parse rather than read `errors`: that's the render-time snapshot, and // this runs immediately after an `await trigger()` that has not re-rendered // yet, so the closure would still be holding the previous attempt's state. - const parsed = buildOnboardingSchema( - identity?.passportRequired === true, - requiredKeys, - ).safeParse(getValues()); + const parsed = buildOnboardingSchema(requiredKeys).safeParse(getValues()); const wanted = new Set(fields as string[]); const messages = parsed.success ? [] @@ -903,24 +844,13 @@ export default function CompanyProfileForm({ : "Some details on this step are incomplete. Please review the fields above."; }; - /** - * The fields this step actually validates. `stepFields` covers what the step - * always renders; the company step additionally exposes company email/phone - * as inputs when nothing could be derived for them, and a field is validated - * exactly when the customer can see and fix it. - */ - const fieldsForStep = (s: CompanyStep): (keyof FormData)[] => { - if (s !== "company" || !identity) return stepFields[s]; - return [...stepFields.company]; - }; - /** Validate + persist the current step, returning whether we may advance. */ const saveCurrentStep = async (): Promise => { setSaveError(null); - const fields = fieldsForStep(step); + const fields = stepFields[step]; const isValid = await trigger(fields); if (!isValid) { - setSaveError(describeErrors(fields)); + failCheck(describeErrors(fields)); return false; } if (!onSaveStep) return true; @@ -928,7 +858,7 @@ export default function CompanyProfileForm({ try { const res = await onSaveStep(stepPayload(step, getValues(), dirtyFields)); if (!res.ok) { - setSaveError(res.error); + failSave(res.error); return false; } return true; @@ -951,7 +881,7 @@ export default function CompanyProfileForm({ ) { setDocumentFieldErrors(docErrors); setLicenseFieldErrors(licenseErrors); - setSaveError("Please upload all required documents before continuing."); + failCheck("Please upload all required documents before continuing."); return; } @@ -960,7 +890,7 @@ export default function CompanyProfileForm({ try { const res = await onUploadDocuments(); if (!res.ok) { - setSaveError(res.error); + failSave(res.error); return; } } finally { @@ -969,92 +899,78 @@ export default function CompanyProfileForm({ } setSaveError(null); - // Deliberately NOT `handleSubmit`: that re-validated all 34 schema fields - // — including every field belonging to a step that isn't on screen — and - // on failure did nothing at all, no alert and no navigation, which is the + // Deliberately NOT `handleSubmit`: that re-validated every schema field — + // including every field belonging to a step that isn't on screen — and on + // failure did nothing at all, no alert and no navigation, which is the // "Submit for review" button that appears dead. Each step has already - // validated and saved its own fields, and the API's `markOnboardingComplete` - // is the authority on what is still outstanding; its message reaches the - // customer through `submitError`. + // validated and saved its own fields, and the API's + // `markOnboardingComplete` is the authority on what is still outstanding; + // its message reaches the customer through `submitError`. onSubmit(buildPayload(getValues(), user)); return; } // The TIN must resolve to a real eTrade record before anything else on // this step is even worth validating — gates here rather than through zod. + // A co-operative is exempt: it has no licence for eTrade to hold, so + // `tinVerified` is true for it and only the duplicate-TIN check applies. if (step === "company" && tinStatus === "taken") { - setSaveError( + failCheck( "This TIN is already registered to another company account.", ); return; } if (step === "company" && !tinVerified) { - setSaveError( + failCheck( tinStatus === "choose-business" ? "This TIN holds more than one business licence — pick the one you're registering as." : "We need to confirm your TIN with eTrade before continuing.", ); return; } - // Fayda verification is proved outside the form state, so it gates here - // rather than through zod. The passport number is a plain typed field — - // buildOnboardingSchema already requires it when passportRequired, so - // saveCurrentStep()'s trigger() below catches that; checking the stale - // server-side identity.owner.passportNumber here would block a value the - // user just typed but hasn't saved yet. - if ( - step === "company" && - identity?.faydaRequired && - !identity.owner.verified - ) { - setSaveError( - "Verify the company owner's identity with Fayda before continuing.", + // The declaration decides whose identity is verified, so it has to be + // answered before the verification below can mean anything. + if (step === "representation" && !identity?.poaDeclared) { + failCheck( + "Tell us whether anyone holds power of attorney for this company.", ); return; } - // The GM is established through Fayda now, so the step gates on the - // identity rather than on typed text — same strength as the old required - // fields, different evidence. A foreign company's GM may hold no Fayda ID, - // so typed details still satisfy it there. - if (step === "personnel" && !gmEstablished) { - setSaveError( - identity?.faydaRequired - ? "Verify the general manager with Fayda, or tick “same as owner” if they are the company's owner." - : "Add the general manager's details, or verify them with Fayda.", + // The verification itself is proved outside the form state, so it gates + // here rather than through zod. + if (step === "representation" && !identityProven) { + const who = + identity?.subject === "poa" + ? "your Power of Attorney" + : "the company owner"; + failCheck( + identity?.passportAccepted + ? `Verify ${who} with Fayda, or enter their passport number.` + : `Verify ${who} with Fayda before continuing.`, ); return; } - if (step === "poa" && requirePoa && !poaEstablished) { - setSaveError( - identity?.faydaRequired - ? "Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda." - : "Freight forwarders act on other companies' behalf, so a Power of Attorney is required.", - ); - return; - } - // The PoA step also gates on a file, which lives outside the form state. - if (step === "poa" && delegationRequired && !delegationPresent) { + // The step also gates on a file, which lives outside the form state. + if (step === "representation" && delegationRequired && !delegationPresent) { setDocumentFieldErrors({ [POA_DELEGATION_FILE_KEY]: "DARS delegation paper is required", }); // Validate the text fields too, so every problem shows at once. - const fieldsOk = await trigger(stepFields.poa); - setSaveError( + const fieldsOk = await trigger(stepFields.representation); + failCheck( [ - requirePoa - ? "Freight forwarders must provide Power of Attorney details and the DARS delegation paper." - : "Upload the DARS delegation paper for the Power of Attorney you entered, or clear the PoA details to skip.", - fieldsOk ? null : describeErrors(stepFields.poa), + "Upload the DARS delegation paper for the representative you named.", + fieldsOk ? null : describeErrors(stepFields.representation), ] .filter(Boolean) .join(" "), ); return; } - // The API will not accept the PoA's details until the paper evidencing the - // delegation is actually on file, so the selection made on this step has to - // be uploaded before the save — not held back until the documents step, - // which is unreachable while this save keeps failing. - if (step === "poa" && delegationRequired && onUploadDocuments) { + // The API will not accept the representative's details until the paper + // evidencing the delegation is actually on file, so the selection made on + // this step has to be uploaded before the save — not held back until the + // documents step, which is unreachable while this save keeps failing. + if (step === "representation" && delegationRequired && onUploadDocuments) { const pending = documentFiles[POA_DELEGATION_FILE_KEY]; const hasPending = Array.isArray(pending) ? pending.length > 0 @@ -1064,7 +980,7 @@ export default function CompanyProfileForm({ try { const res = await onUploadDocuments(); if (!res.ok) { - setSaveError(res.error); + failSave(res.error); return; } } finally { @@ -1092,57 +1008,51 @@ export default function CompanyProfileForm({ {step === "company" && ( )} - {step === "personnel" && ( - + )} + + {step === "representation" && ( + )} {step === "contact" && ( - )} - - {step === "poa" && ( - )} @@ -1154,7 +1064,10 @@ export default function CompanyProfileForm({ uploadedDocumentKeys={uploadedDocumentKeys} documentFieldErrors={documentFieldErrors} onDocumentFilesChange={handleDocumentFilesChange} - roleProfiles={roleProfiles} + // A co-operative union or farm holds no business licence, so the + // per-role upload cards are not shown at all — offering a slot + // nothing can fill reads as an unfinishable step. + roleProfiles={cooperative ? [] : roleProfiles} licenseFiles={licenseFiles} licenseFieldErrors={licenseFieldErrors} onLicenseFilesChange={handleLicenseFilesChange} @@ -1166,9 +1079,13 @@ export default function CompanyProfileForm({ color="red" variant="light" icon={} - title={"Couldn't save this step"} + title={ + saveError.kind === "save" + ? "Couldn't save this step" + : "Please check this step" + } > - {saveError} + {saveError.message} )} @@ -1197,7 +1114,12 @@ export default function CompanyProfileForm({ disabled={ isPending || saving || - (step === "documents" && !hasDocuments && loadingDocuments) + declarePending || + // Nothing to validate against until the document set lands, so + // pressing Continue now would report every required upload as + // satisfied. `hasDocuments` used to be in here too, which let + // the button go live mid-load the moment the set resolved. + (step === "documents" && loadingDocuments) } loading={isPending || saving} rightSection={ diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/SourcedField.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/SourcedField.tsx new file mode 100644 index 000000000..2a42c424a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/SourcedField.tsx @@ -0,0 +1,61 @@ +import { Badge, Group, Stack, Text } from "@mantine/core"; +import type { ReactNode } from "react"; + +/** Where a locked value came from, shown as a badge next to it. */ +export type FieldSource = "eTrade" | "Fayda"; + +const SOURCE_NOTE: Record = { + eTrade: "From your eTrade licence", + Fayda: "From the Fayda verification", +}; + +/** + * One person-detail field that an outside source may have taken ownership of. + * + * `source` — not "does a value exist" — decides which side renders. That + * distinction is the whole point: `ownerName` holds a value the moment the + * customer types it and the step saves, and keying off presence meant the input + * they had just filled in turned into a read-only badge as soon as they + * navigated away and back, with no way to correct it. Only a real source owns a + * field: a Fayda verification (the API refuses to overwrite those) or the + * eTrade licence (the record the backoffice checks the company against). + * + * It pairs with `requiredKeys` in CompanyProfileForm, which requires exactly + * the fields that fall through to `children`: **a field is required if and only + * if there is an input on screen to satisfy it.** + */ +export default function SourcedField({ + label, + value, + source, + children, +}: { + label: string; + /** The value to display when a source owns this field. */ + value?: string | null; + /** The owning source, or null while the field is still the customer's. */ + source: FieldSource | null; + /** The input rendered whenever the field is still the customer's to fill. */ + children: ReactNode; +}) { + if (!source || !value?.trim()) return <>{children}; + + return ( + + + + {label} + + + {source} + + + + {value} + + + {SOURCE_NOTE[source]} + + + ); +} 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 20e889235..623f75e1a 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 @@ -4,6 +4,8 @@ import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { CompanyIdentityState } from "@/services/verifayda.service"; import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField"; +import type { FieldSource } from "./SourcedField"; + import { ETRADE_BUNDLE_FIELDS, type CompanyStep, @@ -69,7 +71,6 @@ export function normalizeIdentityPhones( ...identity, owner: fix(identity.owner), poa: fix(identity.poa), - gm: fix(identity.gm), }; } @@ -82,6 +83,73 @@ export const samePhone = (a?: string | null, b?: string | null) => { return da.length === 9 && da === phoneDigits(b); }; +/** The owner details an outside source can take ownership of. */ +export type OwnerField = "name" | "email" | "phone"; + +export interface OwnerSources { + /** Which source owns each field, or null where it is still the customer's. */ + source: Record; + /** What to display for an owned field — normalized as the payload will be. */ + sourced: Record; +} + +/** + * Who owns each of the owner's fields, and with what value. + * + * Two sources can own a field, and they rank. A Fayda verification owns + * whatever its claims filled (the API refuses to overwrite those), and the + * eTrade licence owns the manager's name and phone: that record is the thing + * the backoffice checks the company against, so it is reported, not proposed. + * Neither is typeable. Everything left over is the customer's — an editable + * input, required precisely because there is an input for it. Fayda outranks + * eTrade on the same person: the stronger claim, and the one the API keeps. + * + * Two things deliberately do NOT take ownership. + * + * A value merely being present. It exists the moment the customer types one and + * the step saves — so a presence test turned the input they had just filled + * into a read-only badge on the way back through the wizard, and dropped the + * field out of `requiredKeys` at the same time. + * + * And a value that isn't usable. Both sources hold contact details as free + * text: Fayda's phone is whatever the national registry recorded, eTrade's is + * whatever was typed at the licence desk ("09 " is a real answer, and it + * normalizes to a non-empty, invalid `+2519`). Locking one of those behind a + * read-only row leaves the customer told to fix a field with no input, or the + * step saved with a value the API rejects. So a source owns an email or a phone + * only if what it supplies holds up as one; otherwise the field falls through + * to an input and is required like any other. Names have no format to fail, so + * presence is the whole test there. + */ +export function resolveOwnerSources( + identity: CompanyIdentityState | undefined, + etradeOwner: { name: string; phone: string } | null, +): OwnerSources { + const verified = identity?.owner.verified ?? false; + const faydaName = verified && Boolean(identity?.owner.name?.trim()); + const faydaEmail = verified ? firstValidEmail(identity?.owner.email) : ""; + const faydaPhone = verified ? firstValidPhone(identity?.owner.phone) : ""; + const etradeName = etradeOwner?.name?.trim() ?? ""; + const etradePhone = firstValidPhone(etradeOwner?.phone); + + const source: Record = { + name: faydaName ? "Fayda" : etradeName ? "eTrade" : null, + // eTrade never returns an email for the manager, so this one is Fayda's or + // it is the customer's to type. + email: faydaEmail ? "Fayda" : null, + phone: faydaPhone ? "Fayda" : etradePhone ? "eTrade" : null, + }; + + return { + source, + sourced: { + name: source.name === "Fayda" ? (identity?.owner.name?.trim() ?? "") : etradeName, + email: faydaEmail, + phone: source.phone === "Fayda" ? faydaPhone : etradePhone, + }, + }; +} + /** Mask all but the first 7 chars of an E.164 phone for display. */ export const maskPhone = (p: string) => p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; @@ -97,15 +165,14 @@ export function buildPayload( vatNumber: data.vatNumber, attributes: { ownerPassportNumber: data.ownerPassportNumber || undefined, + poaPassportNumber: data.poaPassportNumber || undefined, contactPersonName: data.contactPersonName, contactPersonPosition: data.contactPersonPosition || undefined, contactPersonEmail: data.contactPersonEmail || undefined, contactPersonPhone: data.contactPersonPhone, - generalManagerName: data.generalManagerName, - generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: data.generalManagerPhone, - // The representative's own details are written by their Fayda - // verification, so the city is all the form has to send. + ownerName: data.ownerName, + ownerEmail: data.ownerEmail, + ownerPhone: data.ownerPhone, poaLocation: data.poaLocation || undefined, }, }; @@ -135,23 +202,28 @@ export function stepPayload( } if (dirty.tinNumber) etrade.tin = d.tinNumber; return { - companyAddress: d.companyAddress, + // Composed from the address parts, so it is only as complete as they + // are. Sending it while they are still empty (the lookup hasn't landed, + // or eTrade left them blank) would overwrite a stored address with a + // degraded version of itself — an absent key means "untouched". + ...(d.companyAddress?.trim() + ? { companyAddress: d.companyAddress } + : {}), vatNumber: d.vatNumber, - ownerPassportNumber: d.ownerPassportNumber || undefined, ...etrade, }; } - case "personnel": + case "owner": // `|| undefined`, never "": the DTO's `@IsOptional()` only skips null and // undefined, so an empty string is validated and 400s with - // "generalManagerEmail must be an email". An Ethiopian company never types - // these — the GM comes from the Fayda verification (or the "same as owner" - // declaration), so the form fields are legitimately blank and would fail a - // step that has no input to fix. + // "ownerEmail must be an email". A field the eTrade lookup or the Fayda + // claim already filled is legitimately blank in the form — it has no + // input — so sending "" would fail a step with nothing on screen to fix. return { - generalManagerName: d.generalManagerName || undefined, - generalManagerEmail: d.generalManagerEmail || undefined, - generalManagerPhone: d.generalManagerPhone || undefined, + ownerName: d.ownerName || undefined, + ownerEmail: d.ownerEmail || undefined, + ownerPhone: d.ownerPhone || undefined, + ownerPassportNumber: d.ownerPassportNumber || undefined, }; case "contact": return { @@ -160,12 +232,16 @@ export function stepPayload( contactPersonEmail: d.contactPersonEmail || undefined, contactPersonPhone: d.contactPersonPhone, }; - case "poa": + case "representation": return { poaName: d.poaName || undefined, poaEmail: d.poaEmail || undefined, poaPhone: d.poaPhone || undefined, poaLocation: d.poaLocation || undefined, + poaPassportNumber: d.poaPassportNumber || undefined, + // The step renders one passport input, for whichever person the + // declaration made the identity subject — so it has to save both. + ownerPassportNumber: d.ownerPassportNumber || undefined, }; default: return {}; @@ -183,6 +259,7 @@ export function toFormValues(p: ProfileResponse): FormData { tinNumber: tin, vatNumber: p.vatNumber ?? "", ownerPassportNumber: p.identity?.owner.passportNumber ?? "", + poaPassportNumber: p.identity?.poa.passportNumber ?? "", licenceNumber: p.licenceNumber ?? "", statusDescription: p.statusDescription ?? "", dateRegistered: p.dateRegistered ?? "", @@ -198,12 +275,11 @@ export function toFormValues(p: ProfileResponse): FormData { contactPersonPosition: p.contactPersonPosition ?? "", contactPersonEmail: p.contactPersonEmail ?? "", contactPersonPhone: p.contactPersonPhone ?? "", - generalManagerName: p.generalManagerName ?? "", - generalManagerEmail: p.generalManagerEmail ?? "", - generalManagerPhone: p.generalManagerPhone ?? "", + ownerName: p.ownerName ?? "", + ownerEmail: p.ownerEmail ?? "", + ownerPhone: p.ownerPhone ?? "", poaName: p.poaName ?? "", poaPhone: p.poaPhone ?? "", - poaAddress: p.poaAddress ?? "", poaEmail: p.poaEmail ?? "", poaLocation: p.poaLocation ?? "", }; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts index 95755e777..cd957eec7 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts @@ -6,6 +6,7 @@ import { firstValidEmail, firstValidPhone, normalizeIdentityPhones, + resolveOwnerSources, stepPayload, } from "./helpers"; import type { FormData } from "./schema"; @@ -35,9 +36,9 @@ const values = (over: Partial = {}): FormData => contactPersonPosition: "", contactPersonEmail: "", contactPersonPhone: "+251911223344", - generalManagerName: "", - generalManagerEmail: "", - generalManagerPhone: "", + ownerName: "", + ownerEmail: "", + ownerPhone: "", poaName: "", poaPhone: "", poaAddress: "", @@ -65,18 +66,16 @@ describe("VAT number", () => { ).toBeUndefined(); }); - it("rejects twelve digits", () => { - expect(errorFor(values({ vatNumber: "001234567890" }), "vatNumber")).toBe( - "VAT number must be 10 or 11 digits", - ); - }); - - // `.length(10)` used to pass this, so a ten-letter string reached the API. - it("rejects ten non-digits", () => { - expect(errorFor(values({ vatNumber: "ABCDEFGHIJ" }), "vatNumber")).toBe( - "VAT number must be 10 or 11 digits", - ); - }); + // No shape rule any more: a foreign tax authority's VAT number carries + // letters and dashes, and a co-operative union's registration numbering + // follows the trade-licence pattern not at all. Length and alphabet are not + // ours to police — only presence is. + it.each(["001234567890", "GB123456789", "ET-2024/0091"])( + "accepts %s", + (vat) => { + expect(errorFor(values({ vatNumber: vat }), "vatNumber")).toBeUndefined(); + }, + ); it("rejects blank", () => { expect(errorFor(values({ vatNumber: "" }), "vatNumber")).toBe( @@ -104,25 +103,88 @@ describe("stepFields", () => { // The regression this whole change exists to prevent: a step must not gate on // a field it renders no input for, or Continue fails with the error attached // to nothing on screen. - it("never gates the company step on a derived or read-only field", () => { - const unreachable = [ - "etradePhone", + // + // Listing a field on a step is no longer the gate — `requiredKeys` is. The + // registration fields appear on the company step because a co-operative union + // or farm types them, and a licensed company gets them read-only from eTrade; + // the base schema must accept them blank either way. + it("never gates the company step on a field with no input", () => { + const derived = ["etradePhone", "licenceNumber", "statusDescription"]; + expect(stepFields.company.filter((f) => derived.includes(f))).toEqual([]); + }); + + it("leaves the registration fields optional in the base schema", () => { + for (const field of ["companyName", "region", "zone", "woreda", "kebele"] as const) { + expect(errorFor(values({ [field]: "" }), field)).toBeUndefined(); + } + }); + + it("requires the registration fields once a co-operative types them", () => { + const parsed = buildOnboardingSchema([ "companyName", - "licenceNumber", - "statusDescription", - "dateRegistered", - "renewedFrom", - "renewalDate", - "renewedTo", "region", "zone", "woreda", "kebele", - "houseNo", - ]; - expect(stepFields.company.filter((f) => unreachable.includes(f))).toEqual( - [], + ]).safeParse( + values({ companyName: "", region: "", zone: "", woreda: "", kebele: "" }), ); + expect(parsed.success).toBe(false); + const paths = parsed.success + ? [] + : parsed.error.issues.map((i) => String(i.path[0])); + expect(paths).toEqual( + expect.arrayContaining([ + "companyName", + "region", + "zone", + "woreda", + "kebele", + ]), + ); + }); +}); + +describe("the identity subject's passport", () => { + // The representation step renders ONE passport input, for whoever the + // power-of-attorney answer made the identity subject. When the answer is "no + // PoA" that is the owner — so the owner's passport number is typed on the + // representation step and has to be carried by it. It used to belong to the + // owner step alone, which is already behind the customer by then: the number + // was typed, dropped, and the final submit failed `assertIdentityVerified` + // naming a field they could see was filled in. + it("is carried by the step that renders the input", () => { + expect(stepFields.representation).toContain("ownerPassportNumber"); + expect(stepFields.representation).toContain("poaPassportNumber"); + }); + + it("saves the owner's passport from the representation step", () => { + const payload = stepPayload( + "representation", + values({ ownerPassportNumber: "P1234567" }), + ); + expect(payload.ownerPassportNumber).toBe("P1234567"); + }); + + it("still omits it when there is none, rather than sending an empty string", () => { + const payload = stepPayload( + "representation", + values({ ownerPassportNumber: "" }), + ); + expect(payload.ownerPassportNumber).toBeUndefined(); + }); +}); + +describe("stepPayload (representation)", () => { + // `poaAddress` is a Fayda-owned claim (`IDENTITY_OWNED_FIELDS` server-side). + // The portal states `poaLocation` instead and must never send the other. + it("sends the company's stated location, never the Fayda address", () => { + const payload = stepPayload( + "representation", + values({ poaLocation: "Dire Dawa, Ethiopia" }), + ); + expect(payload.poaLocation).toBe("Dire Dawa, Ethiopia"); + expect("poaAddress" in payload).toBe(false); }); }); @@ -131,31 +193,29 @@ describe("buildOnboardingSchema (conditionally required fields)", () => { data: FormData, required: (keyof FormData)[], ): (keyof FormData)[] => { - const parsed = buildOnboardingSchema(false, required).safeParse(data); + const parsed = buildOnboardingSchema(required).safeParse(data); return parsed.success ? [] : (parsed.error.issues.map((i) => i.path[0]) as (keyof FormData)[]); }; - // Fayda's email/phone claims are optional: the step renders an input for what - // the verification did not supply, and requires exactly those. Nothing else — - // a field with no input on screen must never fail Continue. + // eTrade returns no email and Fayda's email/phone claims are optional: the + // step renders an input for what no source supplied, and requires exactly + // those. Nothing else — a field with no input on screen must never fail + // Continue. it("requires only the keys it is handed", () => { - const issues = issuesFor(values(), [ - "generalManagerEmail", - "generalManagerPhone", - ]); - expect(issues).toEqual(["generalManagerEmail", "generalManagerPhone"]); + const issues = issuesFor(values(), ["ownerEmail", "ownerPhone"]); + expect(issues).toEqual(["ownerEmail", "ownerPhone"]); }); it("passes once those keys are filled", () => { expect( issuesFor( values({ - generalManagerEmail: "gm@example.com", - generalManagerPhone: "+251911223344", + ownerEmail: "owner@example.com", + ownerPhone: "+251911223344", }), - ["generalManagerEmail", "generalManagerPhone"], + ["ownerEmail", "ownerPhone"], ), ).toEqual([]); }); @@ -165,9 +225,7 @@ describe("buildOnboardingSchema (conditionally required fields)", () => { }); it("names the field in the message, so it reads under its own input", () => { - const parsed = buildOnboardingSchema(false, ["poaEmail"]).safeParse( - values(), - ); + const parsed = buildOnboardingSchema(["poaEmail"]).safeParse(values()); expect(parsed.success).toBe(false); if (parsed.success) return; expect(parsed.error.issues[0]?.message).toBe( @@ -196,39 +254,43 @@ describe("stepPayload (company)", () => { // Still only the dirty ones. expect(payload.licenceNumber).toBeUndefined(); }); + + // The address is composed from the parts, so it is only ever as complete as + // they are. An absent key means "untouched" to the API; sending a blank one + // would replace a stored address with nothing. + it("omits a blank composed address rather than clearing the stored one", () => { + const payload = stepPayload("company", values({ companyAddress: "" }), {}); + expect("companyAddress" in payload).toBe(false); + }); }); -describe("stepPayload (personnel)", () => { - // An Ethiopian company never types the GM — Fayda (or "same as owner") owns - // those fields — so the form holds "". `@IsOptional()` on the DTO skips only - // null/undefined, so an empty string is validated and comes back as - // "generalManagerEmail must be an email", on a step that renders no input. - it("omits blank GM fields instead of sending empty strings", () => { +describe("stepPayload (owner)", () => { + // A Fayda claim owns whatever it supplied, so the form holds "" for those. + // `@IsOptional()` on the DTO skips only null/undefined, so an empty string is + // validated and comes back as "ownerEmail must be an email" — on a step that + // renders no input for it. + it("omits blank owner fields instead of sending empty strings", () => { const payload = stepPayload( - "personnel", - values({ - generalManagerName: "", - generalManagerEmail: "", - generalManagerPhone: "", - }), + "owner", + values({ ownerName: "", ownerEmail: "", ownerPhone: "" }), ); - expect(payload.generalManagerName).toBeUndefined(); - expect(payload.generalManagerEmail).toBeUndefined(); - expect(payload.generalManagerPhone).toBeUndefined(); + expect(payload.ownerName).toBeUndefined(); + expect(payload.ownerEmail).toBeUndefined(); + expect(payload.ownerPhone).toBeUndefined(); }); - it("still sends typed GM details (foreign company)", () => { + it("sends the owner details the customer typed or eTrade prefilled", () => { const payload = stepPayload( - "personnel", + "owner", values({ - generalManagerName: "Abebe Bikila", - generalManagerEmail: "gm@example.com", - generalManagerPhone: "+251911223344", + ownerName: "Abebe Bikila", + ownerEmail: "owner@example.com", + ownerPhone: "+251911223344", }), ); - expect(payload.generalManagerName).toBe("Abebe Bikila"); - expect(payload.generalManagerEmail).toBe("gm@example.com"); - expect(payload.generalManagerPhone).toBe("+251911223344"); + expect(payload.ownerName).toBe("Abebe Bikila"); + expect(payload.ownerEmail).toBe("owner@example.com"); + expect(payload.ownerPhone).toBe("+251911223344"); }); }); @@ -268,13 +330,117 @@ describe("firstValidEmail", () => { }); }); +describe("resolveOwnerSources", () => { + const identity = (over: { + verified?: boolean; + name?: string | null; + email?: string | null; + phone?: string | null; + }): CompanyIdentityState => + ({ + passportAccepted: false, + poaDeclared: "no", + subject: "owner", + owner: { + verified: over.verified ?? true, + name: over.name ?? null, + email: over.email ?? null, + phone: over.phone ?? null, + address: null, + verifiedAt: null, + passportNumber: null, + }, + poa: { + verified: false, + name: null, + email: null, + phone: null, + address: null, + verifiedAt: null, + passportNumber: null, + }, + identityProven: false, + etradeManagerName: null, + etradeManagerPhone: null, + ownerMatchesEtrade: null, + complete: false, + }) as CompanyIdentityState; + + it("locks what each source supplied, Fayda outranking eTrade", () => { + const { source, sourced } = resolveOwnerSources( + identity({ + name: "Abebe Bikila", + email: "owner@example.com", + phone: "+251911223344", + }), + { name: "A. Bikila", phone: "+251911999888" }, + ); + expect(source).toEqual({ name: "Fayda", email: "Fayda", phone: "Fayda" }); + expect(sourced.phone).toBe("+251911223344"); + }); + + // The point of the whole exercise: a field is read-only only if what the + // source gave can actually be submitted. Otherwise the customer is shown a + // badge holding a value the API will reject, with no input to fix it. + it("falls back to an input when Fayda's phone claim is unusable", () => { + const { source, sourced } = resolveOwnerSources( + identity({ name: "Abebe Bikila", phone: "09 " }), + null, + ); + expect(source.phone).toBeNull(); + expect(sourced.phone).toBe(""); + }); + + it("falls back to an input when Fayda's email claim is malformed", () => { + const { source } = resolveOwnerSources( + identity({ name: "Abebe Bikila", email: "not-an-email" }), + null, + ); + expect(source.email).toBeNull(); + }); + + it("falls back to an input when eTrade's manager phone is unusable", () => { + const { source, sourced } = resolveOwnerSources(undefined, { + name: "Abebe Bikila", + phone: "09 ", + }); + // The name is still eTrade's — names have no format to fail. + expect(source.name).toBe("eTrade"); + expect(source.phone).toBeNull(); + expect(sourced.phone).toBe(""); + }); + + it("takes eTrade's phone where Fayda has none, normalized", () => { + const { source, sourced } = resolveOwnerSources( + identity({ verified: false }), + { name: "Abebe Bikila", phone: "0911223344" }, + ); + expect(source.phone).toBe("eTrade"); + expect(sourced.phone).toBe("+251911223344"); + }); + + it("owns nothing when the verification never happened", () => { + const { source } = resolveOwnerSources( + identity({ + verified: false, + name: "Abebe Bikila", + email: "owner@example.com", + phone: "+251911223344", + }), + null, + ); + expect(source).toEqual({ name: null, email: null, phone: null }); + }); +}); + describe("normalizeIdentityPhones", () => { it("converts a local Fayda phone claim to E.164", () => { const identity = { - faydaRequired: true, - passportRequired: false, + passportAccepted: false, + poaDeclared: "yes", + subject: "poa", owner: { - verified: true, + verified: false, name: "A", phone: "0911223344", email: null, @@ -283,28 +449,22 @@ describe("normalizeIdentityPhones", () => { passportNumber: null, }, poa: { - verified: false, - name: null, - phone: null, - email: null, - address: null, - verifiedAt: null, - }, - gm: { - verified: false, - name: null, + verified: true, + name: "B", phone: "251911223344", email: null, address: null, verifiedAt: null, + passportNumber: null, }, - gmSameAsOwner: false, - complete: false, + identityProven: true, + etradeManagerName: null, + ownerMatchesEtrade: null, + complete: true, } as CompanyIdentityState; const fixed = normalizeIdentityPhones(identity)!; expect(fixed.owner.phone).toBe("+251911223344"); - expect(fixed.gm.phone).toBe("+251911223344"); - expect(fixed.poa.phone).toBeNull(); + expect(fixed.poa.phone).toBe("+251911223344"); }); }); 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 0017598e9..b274761b0 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 @@ -5,9 +5,9 @@ import { isValidPhone } from "@/components/PhoneField"; export type CompanyStep = | "company" - | "personnel" + | "owner" + | "representation" | "contact" - | "poa" | "documents" | "additional"; @@ -22,25 +22,33 @@ export const onboardingSchema = z.object({ // can diverge without the backend's eTrade-authenticity check misfiring. etradePhone: z.string().optional(), tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"), - // `.length(10)` alone accepted "ABCDEFGHIJ" — the check has to be on digits. - vatNumber: z - .string() - .min(1, "VAT number is required") - .regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"), - // The owner's passport number — the foreign-company identity credential - // (Fayda is an Ethiopian national ID). Required only for a foreign company; - // enforced in buildOnboardingSchema since that depends on `nationality`. + // Required, but no shape check. Ethiopian VAT numbers are usually 10 or 11 + // digits; a foreign company's is whatever its own tax authority issues, and a + // co-operative's registration numbering follows neither. A format rule here + // only ever rejected valid numbers we had no business judging. + vatNumber: z.string().min(1, "VAT number is required"), + // Passport numbers — the alternative identity credential for a foreign + // company (Fayda is an Ethiopian national ID). Only the one belonging to the + // declared identity subject is ever asked for, and only when that person has + // not verified with Fayda — so requiredness is decided per render and lives + // in `requiredKeys`, not here. ownerPassportNumber: z.string().optional(), + poaPassportNumber: z.string().optional(), licenceNumber: z.string().optional(), statusDescription: z.string().optional(), dateRegistered: z.string().optional(), renewedFrom: z.string().optional(), renewalDate: z.string().optional(), renewedTo: z.string().optional(), - // The registered address comes from eTrade and nowhere else — the form - // renders these read-only, so requiring them would be a Continue button that - // fails on a field with no input to fix it. A gap in eTrade's own data stays - // a gap rather than becoming a customer-typed claim wearing eTrade's badge. + // The registered address normally comes from eTrade and nowhere else — the + // form renders these read-only, so requiring them would be a Continue button + // that fails on a field with no input to fix it. A gap in eTrade's own data + // stays a gap rather than becoming a customer-typed claim wearing eTrade's + // badge. + // + // A co-operative is the exception: it has no business licence, so there is no + // eTrade record at all and these ARE typed. Requiredness follows the same + // invariant as everywhere else — it is decided per render, in `requiredKeys`. region: z.string().optional(), zone: z.string().optional(), woreda: z.string().optional(), @@ -57,21 +65,23 @@ export const onboardingSchema = z.object({ .string() .min(1, "Contact person phone is required") .refine(isValidPhone, "Enter a valid phone number"), - // Optional here, not unrequired: the GM is now established by Fayda — either - // verified in their own right or declared the same person as the owner — so - // for an Ethiopian company these fields are never typed and would fail a - // blanket `min(1)`. Presence is gated per nationality in the step's own - // check, where the identity state is available; zod only polices format for - // the foreign companies that still type them. - generalManagerName: z.string().optional(), - generalManagerEmail: z + // The owner — whoever the eTrade licence names as the business's manager. + // + // Optional here, not unrequired: the eTrade lookup fills the name and phone, + // and a Fayda verification can fill all three, so on a well-supplied company + // none of them is typed and a blanket `min(1)` would fail a step with no + // input on screen. What IS required is decided per render — a field is + // required exactly when the step renders an input for it (`requiredKeys`). + // zod only polices format here. + ownerName: z.string().optional(), + ownerEmail: z .string() .optional() .refine( (v) => !v || z.string().email().safeParse(v).success, - "Invalid Manager email", + "Invalid owner email", ), - generalManagerPhone: z + ownerPhone: z .string() .optional() .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), @@ -80,7 +90,6 @@ export const onboardingSchema = z.object({ .string() .optional() .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), - poaAddress: z.string().optional(), poaEmail: z .string() .optional() @@ -88,6 +97,10 @@ export const onboardingSchema = z.object({ (v) => !v || z.string().email().safeParse(v).success, "Invalid email address", ), + // Where the representative is based, as the company states it. Deliberately + // NOT `poaAddress`: that one is a Fayda-owned claim (`IDENTITY_OWNED_FIELDS`) + // the verification writes and the portal must never send — the two used to + // sit side by side here, with the Fayda address silently hiding this input. poaLocation: z.string().optional(), }); @@ -102,45 +115,45 @@ export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; * message has to be built here rather than attached to the base schema. */ const CONDITIONAL_LABELS: Partial> = { + // Typed only by a co-operative — every other company gets these from eTrade. + companyName: "Company name", + region: "Region", + zone: "Zone", + woreda: "Woreda", + kebele: "Kebele", poaName: "Representative's name", poaEmail: "Representative's email", poaPhone: "Representative's phone", - generalManagerName: "General manager's name", - generalManagerEmail: "General manager's email", - generalManagerPhone: "General manager's phone", + poaPassportNumber: "Representative's passport number", + ownerName: "Owner's name", + ownerEmail: "Owner's email", + ownerPhone: "Owner's phone", + ownerPassportNumber: "Owner's passport number", }; /** - * The PoA's and GM's identifying fields normally come from their Fayda - * verification, so nothing in the base schema requires them. But Fayda's email - * and phone claims are optional and routinely come back empty, and the steps - * render an input for whatever the verification did not supply — so those - * fields become mandatory exactly then. + * The owner's and the representative's identifying fields arrive from three + * places — the eTrade lookup, a Fayda verification, or the customer typing them + * — and which one supplies what varies per company. eTrade returns no email at + * all; Fayda's email and phone claims are optional and routinely come back + * empty. So nothing in the base schema requires them, and the steps render an + * input for whatever no source supplied. * * `requiredKeys` is that decision, made by CompanyProfileForm from the same - * state that drives the rendering: a field is required iff an input exists for - * it. Passing it in (rather than deriving it here) is what keeps the two from - * drifting into a Continue button that fails on a field nobody can see. + * state that drives the rendering: **a field is required iff an input exists + * for it**. Passing it in (rather than deriving it here) is what keeps the two + * from drifting into a Continue button that fails on a field nobody can see. */ export function buildOnboardingSchema( - /** True for a foreign company: the owner's passport number is mandatory. */ - passportRequired = false, /** Fields the current step renders an input for and must not leave blank. */ requiredKeys: readonly (keyof FormData)[] = [], ) { - if (!passportRequired && requiredKeys.length === 0) return onboardingSchema; + if (requiredKeys.length === 0) return onboardingSchema; return onboardingSchema.superRefine((d, ctx) => { - if (passportRequired && !d.ownerPassportNumber?.trim()) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["ownerPassportNumber"], - message: "The owner's passport number is required", - }); - } for (const key of requiredKeys) { if (d[key]?.trim()) continue; ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", path: [key], message: `${CONDITIONAL_LABELS[key] ?? key} is required`, }); @@ -184,25 +197,46 @@ export const ETRADE_BUNDLE_FIELDS = [ * (`REQUIRED_COMPANY_INFO`), and reports it with a message. */ export const stepFields: Record = { - // Only the three fields this step actually renders an input for. The company - // name and the registered address are eTrade's, shown read-only. - company: ["tinNumber", "vatNumber", "ownerPassportNumber"], - personnel: [ - "generalManagerName", - "generalManagerEmail", - "generalManagerPhone", + // Only what this step actually renders an input for. The company name and the + // registered address are eTrade's, shown read-only — except for a + // co-operative, which types them (added per render via `requiredKeys`). + company: [ + "tinNumber", + "vatNumber", + "companyName", + "region", + "zone", + "woreda", + "kebele", + "houseNo", ], + // `REQUIRED_COMPANY_INFO` demands all three server-side, so the step offers + // an input wherever eTrade and Fayda between them left a gap. + owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerPassportNumber"], contact: [ "contactPersonName", "contactPersonPosition", "contactPersonEmail", "contactPersonPhone", ], - // The API requires poaName/poaEmail/poaPhone from a freight forwarder - // (`REQUIRED_POA_FIELDS`), so the step has to offer them wherever the - // representative isn't proven by Fayda — otherwise the save is rejected - // naming fields the form never rendered. - poa: ["poaName", "poaEmail", "poaPhone", "poaLocation"], + // The API requires poaName/poaEmail/poaPhone once a representative is + // declared (`REQUIRED_POA_FIELDS`), so the step has to offer them wherever + // Fayda didn't supply them — otherwise the save is rejected naming fields the + // form never rendered. + // `ownerPassportNumber` belongs here as much as the PoA's: the step renders + // whichever passport input the declaration calls for, and when the answer is + // "no PoA" that is the owner's. Leaving it out meant the number was typed on + // this step, validated by nothing, and dropped by `stepPayload` — so the + // final submit failed `assertIdentityVerified` over a field two steps back + // that the customer could see was filled in. + representation: [ + "poaName", + "poaEmail", + "poaPhone", + "poaLocation", + "poaPassportNumber", + "ownerPassportNumber", + ], documents: [], additional: [], }; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx index 0473b26d1..b89e0e920 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx @@ -1,12 +1,10 @@ -import { Stack, TextInput } from "@mantine/core"; +import { Select, SimpleGrid, Stack, Text, TextInput } from "@mantine/core"; import type { UseFormReturn } from "react-hook-form"; -import type { CompanyRegistrationData } from "@edr/types"; -import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; +import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types"; import ETradeInfo, { type ETradeStatus, } from "@/components/onboarding/ETradeInfo"; -import type { CompanyIdentityState } from "@/services/verifayda.service"; import type { FormData } from "../schema"; import ETradeCompanyCard from "../ETradeCompanyCard"; @@ -14,14 +12,16 @@ import StepSection from "../StepSection"; export interface CompanyInfoStepProps { form: UseFormReturn; - /** Fayda verification state, phone-normalized by the parent. */ - identity?: CompanyIdentityState; - /** True when Fayda (not a passport) is what this company must prove with. */ - verifiedIdentity: boolean; tinStatus: ETradeStatus; tinVerified: boolean; /** Registration fields are already populated (a lookup passed, now or earlier). */ hasRegistrationDetails: boolean; + /** + * The company is a co-operative union or farm: it has a TIN but no business + * licence, so eTrade holds no record to look up and the registration is typed + * here instead. + */ + cooperative?: boolean; onETradeDataLoaded: (data: CompanyRegistrationData) => void; onETradeStatusChange: (status: ETradeStatus) => void; onETradeReset: () => void; @@ -29,11 +29,10 @@ export interface CompanyInfoStepProps { export default function CompanyInfoStep({ form, - identity, - verifiedIdentity, tinStatus, tinVerified, hasRegistrationDetails, + cooperative = false, onETradeDataLoaded, onETradeStatusChange, onETradeReset, @@ -41,77 +40,51 @@ export default function CompanyInfoStep({ const { register, watch, + setValue, formState: { errors }, } = form; + const region = watch("region") ?? ""; + return ( = 10 && !errors.vatNumber - ? "done" - : "todo" - } + status={watch("vatNumber")?.trim() && !errors.vatNumber ? "done" : "todo"} > + {/* The TIN lookup runs for everyone, co-operative included. A co-op has + no trade licence, but plenty of them are on eTrade all the same — and + when the record is there it is better data than anything typed, so we + ask for it first and fall back to the form below rather than deciding + in advance that nothing will be found. What differs for a co-op is + only the consequence of finding nothing: expected, not an error. */} 0 - ? "done" - : identity?.passportRequired - ? "blocked" - : "todo" - } - > - {identity && ( - <> - - {identity.passportRequired && ( - - )} - - )} - - - - {tinVerified && ( + {!cooperative && tinVerified && ( )} + + {/* A co-operative keeps its typed registration section either way. When + the lookup found something these arrive prefilled — still editable, + because for a co-op they are the customer's own statement rather than + the licence's, and the API takes them as given (`applyEtradeSourcedFields` + skips co-operatives entirely). */} + {cooperative && ( + + + + + Registered address + + + setRouteOrigin(e.target.value)} + disabled={isLoading} + > + + {routeOriginOptions.map((origin) => ( + + ))} + +
+
+ + +
+
+ + Export route revenue + +
+ + + {routeRevenueData.length === 0 ? ( +

No route revenue data available for this range.

+ ) : ( +
+ {routeRevenueData.slice(0, 10).map((route) => ( +
+
{route.route}
+
{route.bookings.toLocaleString()} booking{route.bookings !== 1 ? 's' : ''}
+
+ {formatCurrency(route.totalEtbMinor, 'ETB')} +
+
+ ))} +
+ )} + + {/* Charts */}
{/* Revenue Trend */} @@ -489,8 +641,8 @@ export default function ReportsPage() { { label: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length, fromStats: false }, { label: 'Boarded', value: bookings.filter((b: any) => b.status === 'BOARDED').length, fromStats: false }, { label: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length, fromStats: false }, - { label: 'Regular Bookings', value: stats?.totalNormalBookings ?? 0, fromStats: true }, - { label: 'Package Bookings', value: stats?.totalPackageBookings ?? 0, fromStats: true }, + { label: 'Regular Bookings', value: totalRegularBookingsCount, fromStats: false }, + { label: 'Package Bookings', value: totalPackageBookingsCount, fromStats: false }, ].map(({ label, value, fromStats }) => (

{label}

diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts b/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts index 953533d02..e80f3f640 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.cbe-bill.spec.ts @@ -155,4 +155,130 @@ describe("IntentsService CBE_BILL", () => { expect(snapshot.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION); expect(applySpy).not.toHaveBeenCalled(); }); + + /** + * Regression: reconcileReference used to route every non-FAILED intent through + * queryProviderStatus, which THROWS "Unknown provider" for CBE_BILL (no map entry — D5). The + * throw was counted as a provider error, so the check returned `unverifiable` forever and the + * owning app could never auto-cancel the booking: seats stayed held and the sweep re-queried + * the same booking once a minute for days. With no outbound query to make, the stored status + * IS the answer. + */ + describe("reconcileReference (reconcile-before-cancel)", () => { + const cbeIntent = (status: ProviderPaymentStatus) => + ({ + id: "intent-1", + service: PaymentService.PASSENGER, + referenceType: PaymentReferenceType.BOOKING, + referenceId: "booking-1", + merchantOrderId: "PSG-x", + provider: ProviderMethod.CBE_BILL, + status, + amountMinor: 1500, + currency: "ETB", + billReference: "000100000015", + }) as unknown as PaymentIntent; + + it("reports an unpaid CBE_BILL intent as VERIFIED not paid, not unverifiable", async () => { + repository.findAllByReference.mockResolvedValue([ + cbeIntent(ProviderPaymentStatus.REQUIRES_ACTION), + ] as never); + + const result = await service.reconcileReference( + PaymentService.PASSENGER, + PaymentReferenceType.BOOKING, + "booking-1", + ); + + expect(result).toEqual({ paid: false, unverifiable: false }); + expect(result.reason).toBeUndefined(); + }); + + it("still reports a retired-but-settled CBE_BILL intent as paid", async () => { + // The inbound /cbe/payment already flipped it; step 2 of the resolution catches it. + repository.findAllByReference.mockResolvedValue([ + cbeIntent(ProviderPaymentStatus.SUCCEEDED), + ] as never); + + const result = await service.reconcileReference( + PaymentService.PASSENGER, + PaymentReferenceType.BOOKING, + "booking-1", + ); + + expect(result.paid).toBe(true); + expect(result.unverifiable).toBe(false); + }); + + it("does not let an unqueryable sibling mask a real provider error", async () => { + const telebirr = { + ...cbeIntent(ProviderPaymentStatus.REQUIRES_ACTION), + id: "intent-2", + provider: ProviderMethod.TELEBIRR, + } as unknown as PaymentIntent; + providers.set(ProviderMethod.TELEBIRR, { + method: ProviderMethod.TELEBIRR, + queryStatus: jest.fn().mockRejectedValue(new Error("ETIMEDOUT")), + }); + repository.findAllByReference.mockResolvedValue([ + cbeIntent(ProviderPaymentStatus.REQUIRES_ACTION), + telebirr, + ] as never); + + const result = await service.reconcileReference( + PaymentService.PASSENGER, + PaymentReferenceType.BOOKING, + "booking-1", + ); + + expect(result).toEqual({ + paid: false, + unverifiable: true, + reason: "PROVIDER_ERROR", + }); + providers.delete(ProviderMethod.TELEBIRR); + }); + + it("reports IN_FLIGHT ahead of PROVIDER_ERROR so a caller never gives up on moving money", async () => { + const processing = { + ...cbeIntent(ProviderPaymentStatus.REQUIRES_ACTION), + id: "intent-2", + provider: ProviderMethod.TELEBIRR, + } as unknown as PaymentIntent; + const failing = { + ...cbeIntent(ProviderPaymentStatus.REQUIRES_ACTION), + id: "intent-3", + provider: ProviderMethod.WAAFI, + } as unknown as PaymentIntent; + providers.set(ProviderMethod.TELEBIRR, { + method: ProviderMethod.TELEBIRR, + queryStatus: jest + .fn() + .mockResolvedValue({ status: ProviderPaymentStatus.PROCESSING }), + }); + providers.set(ProviderMethod.WAAFI, { + method: ProviderMethod.WAAFI, + queryStatus: jest.fn().mockRejectedValue(new Error("ETIMEDOUT")), + }); + repository.findAllByReference.mockResolvedValue([ + processing, + failing, + ] as never); + repository.findById.mockResolvedValue(processing); + jest + .spyOn(service, "applyProviderResult") + .mockResolvedValue({ alreadyTerminal: false }); + + const result = await service.reconcileReference( + PaymentService.PASSENGER, + PaymentReferenceType.BOOKING, + "booking-1", + ); + + expect(result.unverifiable).toBe(true); + expect(result.reason).toBe("IN_FLIGHT"); + providers.delete(ProviderMethod.TELEBIRR); + providers.delete(ProviderMethod.WAAFI); + }); + }); }); diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index 21da0707f..53d4e70bd 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -49,6 +49,14 @@ export interface ProviderResultInput { rawResponse?: Record; } +/** + * Why a settlement check came back `unverifiable`. The two causes are NOT interchangeable: + * `IN_FLIGHT` is money actually moving and must be waited out indefinitely, while + * `PROVIDER_ERROR` can be a permanently unreachable gateway — a caller may eventually give up on + * that one rather than defer forever (see TasksService's reconcile grace window). + */ +export type ReconcileUnverifiableReason = "IN_FLIGHT" | "PROVIDER_ERROR"; + /** Result of {@link IntentsService.reconcileReference} — a settlement check for a domain order. */ export interface ReconcileReferenceResult { /** True when at least one intent for the order is settled (SUCCEEDED, incl. a just-registered late capture). */ @@ -56,10 +64,12 @@ export interface ReconcileReferenceResult { /** Snapshot of the paying intent when `paid`. */ intent?: PaymentIntentSnapshot; /** - * True when we could NOT confirm "not paid": at least one candidate intent's provider status - * query errored, so its settlement is unknown. Callers must treat this as "do not cancel". + * True when we could NOT confirm "not paid": a candidate intent's provider status query errored, + * or a payment is still in flight. Callers must treat this as "do not cancel". */ unverifiable: boolean; + /** Set whenever `unverifiable` — which of the two causes applies. */ + reason?: ReconcileUnverifiableReason; } @Injectable() @@ -486,19 +496,44 @@ export class IntentsService { const candidates = intents.filter( (i) => i.status !== ProviderPaymentStatus.FAILED, ); + + // Inbound-only methods (CBE_BILL) have deliberately no PAYMENT_PROVIDER_MAP entry — plan D5, + // docs/cbe/CBE_IMPLEMENTATION_PLAN.md. There is NO outbound query to make, so their stored + // status is the best truth available and step 2 above already checked it. Counting them as + // provider errors made every CBE_BILL order permanently `unverifiable` and therefore + // impossible to auto-cancel — the caller deferred forever, once a minute, indefinitely. + const queryable = candidates.filter((i) => this.providers.has(i.provider)); + const unqueryable = candidates.length - queryable.length; + if (unqueryable > 0) { + this.logger.log( + `reconcile: ${unqueryable}/${candidates.length} intent(s) for ${referenceType}/${referenceId} ` + + `have no outbound status query (inbound-only provider) — trusting the stored status`, + ); + } + + // Queried in parallel: a booking that accumulated several dead sessions used to serialise one + // 10s provider timeout per intent, so a single stuck order could hold the caller's sweep for + // 30s+. Results are still APPLIED in order, and we still stop at the first settled intent. + const probes = await Promise.all( + queryable.map(async (intent) => { + try { + return { intent, status: await this.queryProviderStatus(intent) }; + } catch (err) { + this.logger.warn( + `reconcile: queryStatus failed for intent ${intent.id} (${intent.merchantOrderId}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return { intent, status: null }; + } + }), + ); + let providerErrors = 0; let inFlight = false; - for (const intent of candidates) { - let status: ProviderStatus; - try { - status = await this.queryProviderStatus(intent); - } catch (err) { + for (const { intent, status } of probes) { + if (!status) { providerErrors++; - this.logger.warn( - `reconcile: queryStatus failed for intent ${intent.id} (${intent.merchantOrderId}): ${ - err instanceof Error ? err.message : String(err) - }`, - ); continue; } @@ -528,7 +563,15 @@ export class IntentsService { } } - return { paid: false, unverifiable: providerErrors > 0 || inFlight }; + // IN_FLIGHT outranks PROVIDER_ERROR: a caller that gives up after N minutes of gateway errors + // must NEVER apply that give-up to an order whose payment is actually moving. + if (inFlight) { + return { paid: false, unverifiable: true, reason: "IN_FLIGHT" }; + } + if (providerErrors > 0) { + return { paid: false, unverifiable: true, reason: "PROVIDER_ERROR" }; + } + return { paid: false, unverifiable: false }; } /** Best-effort live provider status for a merchant order id; never throws (returns null). */