From dc2078dd2836dd51875998b16df72108b8f7b685 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 11 Aug 2026 11:19:39 +0300 Subject: [PATCH 01/15] fix: ( payments ) stop reconcile-before-cancel deferring bookings forever --- .../payments/payment-client.service.ts | 16 ++- .../src/modules/payments/payments.service.ts | 19 ++- .../src/modules/tasks/tasks.service.ts | 59 ++++++-- .../intents/intents.service.cbe-bill.spec.ts | 126 ++++++++++++++++++ .../src/modules/intents/intents.service.ts | 69 ++++++++-- 5 files changed, 258 insertions(+), 31 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts index e723dc8ce..bf6c86fea 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-client.service.ts @@ -22,6 +22,13 @@ export interface PaymentDiagnostic { provider: ProviderStatus | null; } +/** + * Why a settlement check came back `unverifiable` (mirrors the payment service's + * ReconcileUnverifiableReason). `IN_FLIGHT` means money is actually moving and must be waited out; + * `PROVIDER_ERROR` can be a permanently unreachable gateway, which a sweep may eventually give up on. + */ +export type SettlementUnverifiableReason = "IN_FLIGHT" | "PROVIDER_ERROR"; + /** Settlement check from POST /payments/reconcile (verify-before-cancel). */ export interface SettlementResult { /** At least one intent for the order is paid (incl. a late capture just registered). */ @@ -31,6 +38,8 @@ export interface SettlementResult { /** Settlement could not be confirmed — a provider query errored, a payment is in flight, OR the * payment service was unreachable. The caller MUST NOT cancel the order. */ unverifiable: boolean; + /** Set whenever `unverifiable` — which of the two causes applies. */ + reason?: SettlementUnverifiableReason; } /** @@ -117,7 +126,9 @@ export class PaymentClientService { this.logger.warn( `reconcile ${referenceType}/${referenceId} failed: ${message}; treating as unverifiable (will not cancel)`, ); - return { paid: false, unverifiable: true }; + // The payment service itself is unreachable — indistinguishable from a dead gateway, and + // like one it may never recover, so it is a PROVIDER_ERROR (give-up-able), not IN_FLIGHT. + return { paid: false, unverifiable: true, reason: "PROVIDER_ERROR" }; } } @@ -173,9 +184,6 @@ export class PaymentClientService { body?: unknown, ): Promise { const url = `${this.baseUrl}${path}`; - this.logger.log("====================================================================="); - this.logger.log(`URL ${url}`); - this.logger.log("====================================================================="); try { const response = await firstValueFrom( this.http.request({ diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 137df45ee..5cd483830 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -39,6 +39,7 @@ import { import { PaymentClientService, PaymentDiagnostic, + SettlementUnverifiableReason, } from "./payment-client.service"; import { CurrencyService } from "../currency/currency.service"; import { AuditService } from "../../common/audit.service"; @@ -1116,10 +1117,17 @@ export class PaymentsService { * - not paid → verified unpaid; a cancellation caller may proceed. * - unverifiable (provider query errored, in-flight, or payment service unreachable) → a * cancellation caller must NOT cancel this cycle; defer and retry later. + * + * When unverifiable, `reason` says WHY, and the two are not interchangeable: `IN_FLIGHT` is a + * payment actually moving (defer forever — this is the case the guard exists for), while + * `PROVIDER_ERROR` may be a gateway that never comes back, which a sweep is allowed to give up + * on after a grace window rather than retry once a minute in perpetuity. */ - async reconcileAndConfirmIfPaid( - bookingId: string, - ): Promise<{ paid: boolean; verified: boolean }> { + async reconcileAndConfirmIfPaid(bookingId: string): Promise<{ + paid: boolean; + verified: boolean; + reason?: SettlementUnverifiableReason; + }> { const current = await this.prisma.booking.findUnique({ where: { id: bookingId }, select: { status: true }, @@ -1134,10 +1142,11 @@ export class PaymentsService { ); if (settlement.unverifiable) { + const reason = settlement.reason ?? "PROVIDER_ERROR"; this.logger.warn( - `reconcile-before-cancel: settlement UNVERIFIABLE for booking ${bookingId} — not cancelling`, + `reconcile-before-cancel: settlement UNVERIFIABLE (${reason}) for booking ${bookingId} — not cancelling`, ); - return { paid: false, verified: false }; + return { paid: false, verified: false, reason }; } if (settlement.paid) { diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 284eb61c0..fe4207a50 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -14,6 +14,15 @@ const AUDIT_LOG_RETENTION_DAYS = 365; const WEBHOOK_EVENT_RETENTION_DAYS = 90; const GATE_LOG_RETENTION_DAYS = 180; +// How long past its payment deadline a booking may sit undecided because the GATEWAY cannot be +// reached (PROVIDER_ERROR) before the sweep stops deferring and cancels anyway. Without a bound, +// a permanently unreachable provider pins a booking as PENDING_PAYMENT forever — its seats stay +// held and the sweep re-queries it once a minute, indefinitely. NEVER applied to an IN_FLIGHT +// settlement: money that is actually moving is waited out no matter how long it takes. +// Raise this in production — a 10-minute gateway outage should not mass-cancel bookings that may +// well be paid (a late payment then lands on a CANCELLED booking and needs a manual refund). +const RECONCILE_GRACE_MINUTES = Number(process.env.RECONCILE_GRACE_MINUTES) || 5; + function fmtTime(d: Date): string { return d.toLocaleTimeString('en-GB', { hour: '2-digit', @@ -325,15 +334,38 @@ export class TasksService { // Deadline passed — but NEVER cancel a booking that is actually paid. The payment.succeeded // event may have been lost (RabbitMQ down) or arrived late, leaving a paid booking stuck // PENDING_PAYMENT. Ask the payment service over HTTP; it confirms the booking synchronously - // if paid. Only proceed to cancel when settlement is VERIFIED unpaid. + // if paid. Cancel only on a VERIFIED-unpaid settlement — or, past the grace window below, + // on a settlement the gateway simply refuses to answer for. const settlement = await paymentsService.reconcileAndConfirmIfPaid(booking.id); - if (settlement.paid || !settlement.verified) { - this.logger.log( - `Skip auto-cancel ${booking.bookingRef}: ${settlement.paid ? 'PAID → confirmed' : 'unverifiable → deferred'}`, - ); + if (settlement.paid) { + this.logger.log(`Skip auto-cancel ${booking.bookingRef}: PAID → confirmed`); continue; } + // Unverifiable: defer — but not forever. IN_FLIGHT is real money moving, so it is waited + // out indefinitely. A PROVIDER_ERROR (dead gateway, payment service down) is bounded by + // RECONCILE_GRACE_MINUTES past the deadline; beyond that the booking is cancelled on an + // UNVERIFIED settlement, which is recorded explicitly below so finance can chase it. + let unverifiedGiveUp = false; + if (!settlement.verified) { + const graceExpiresAt = new Date( + paymentDeadline.getTime() + RECONCILE_GRACE_MINUTES * 60 * 1000, + ); + if (settlement.reason === 'IN_FLIGHT' || now < graceExpiresAt) { + this.logger.log( + `Skip auto-cancel ${booking.bookingRef}: unverifiable (${settlement.reason ?? 'PROVIDER_ERROR'}) → deferred`, + ); + continue; + } + unverifiedGiveUp = true; + this.logger.error( + `Auto-cancelling ${booking.bookingRef} on an UNVERIFIED settlement — the gateway has ` + + `been unreachable for ${RECONCILE_GRACE_MINUTES}+ min past the deadline. If this ` + + `booking was in fact paid, the payment will land on a CANCELLED booking and needs a ` + + `manual refund.`, + ); + } + // 1a. Release held seats (Journey rows are the occupancy source of truth once paid) await this.prisma.journey.deleteMany({ where: { bookingId: booking.id } as any }); @@ -349,15 +381,21 @@ export class TasksService { }); } - // 2. Audit record (no refund — payment was never completed) + // 2. Audit record (no refund — payment was verified never completed, or, on an unverified + // give-up, flagged for review because we could not establish that) await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: 'SYSTEM', - reason: 'Payment not completed before deadline', + reason: unverifiedGiveUp + ? `Payment not completed before deadline; settlement UNVERIFIED — gateway unreachable ` + + `for ${RECONCILE_GRACE_MINUTES}+ min past the deadline. Confirm no payment was taken.` + : 'Payment not completed before deadline', refundAmount: 0, refundMethod: booking.paymentIntent?.method ?? 'NONE', - refundStatus: 'NOT_APPLICABLE', + // An unverified give-up may yet turn out to have been paid, so it is neither + // NOT_APPLICABLE nor a refund actually owed — flag it for a human instead. + refundStatus: unverifiedGiveUp ? 'REVIEW_REQUIRED' : 'NOT_APPLICABLE', }, }).catch(() => null); @@ -380,7 +418,10 @@ export class TasksService { await this.sms.sendSms({ to: booking.contactPhone, message }).catch(() => null); } - this.logger.log(`Auto-cancelled: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})`); + this.logger.log( + `Auto-cancelled: ${booking.bookingRef} (deadline was ${fmtTime(paymentDeadline)})` + + (unverifiedGiveUp ? ' — UNVERIFIED settlement, review required' : ''), + ); cancelledCount++; } catch (err) { this.logger.error( 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). */ From 619b183a11327e462de96d01f9353623c9b3ea1b Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 11 Aug 2026 08:34:15 +0000 Subject: [PATCH 02/15] fix: redirect to booking issue fixed --- apps/edr-freight-web/backoffice/src/App.tsx | 22 +++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 00d5e0ddb..ec63fa53c 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -117,6 +117,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(); @@ -285,9 +299,7 @@ const App = () => { + } @@ -321,9 +333,7 @@ const App = () => { + } From dac2186c020712526050ce4698c113a2a9802755 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 11 Aug 2026 08:31:27 +0000 Subject: [PATCH 03/15] feat(permissions): grant the director full warehouse authority The director position held no warehouse permissions at all. It now carries the same warehouse set as the chief tier: dashboard, warehouse /yard/zone CRUD, allocation and fee rule CRUD, the inventory operation set, inspection reports, interchange documents and fee invoices. Unlike the dispatcher, the director owns the allocation and fee rules themselves. The positions seeder backfills existing environments on boot. --- .../src/seed/freight-permissions.registry.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 06511cb2b..227158db3 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -2256,7 +2256,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, @@ -2268,6 +2269,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]), From a1bcdfb692adf9be9880519a2ec2a49da7df23fd Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 11 Aug 2026 11:53:26 +0000 Subject: [PATCH 04/15] refactor(companies): one verified identity per company, no general manager Replaces the owner/general-manager/PoA trio with a single identity whose subject is the PoA when the company declares one and the owner otherwise. - Drop the general manager everywhere: entity columns, DTOs, required-field list, self-service attributes, gm* identity handling. - New explicit poaDeclared answer ("yes"/"no") replaces poaSameAsOwner. A DARS delegation letter is required iff it is "yes"; a freight forwarder is forced to "yes" server-side and gets no waiver. - Owner name/email/phone become typeable and required, prefilled from the eTrade lookup, never falling back to the authenticated account. - Store eTrade's manager separately (etradeManagerName/Phone) and expose ownerMatchesEtrade so backoffice compares the asserted owner against the licence instead of against itself. - Foreign companies satisfy the identity with Fayda or a passport number, on whichever subject is verifying (poaPassportNumber added). - Write companies.email/phone from the owner unconditionally, so a company without a Fayda-verified owner still has a notification address. --- .../modules/companies/companies.controller.ts | 80 +- .../modules/companies/companies.service.ts | 913 +++++++----------- .../companies/company-revision-diff.util.ts | 15 +- .../dto/complete-identity-verification.dto.ts | 283 +++--- .../onboarding-requirements-response.dto.ts | 23 +- .../companies/dto/profile-response.dto.ts | 26 +- .../companies/dto/response-company.dto.ts | 11 +- .../companies/dto/set-poa-declared.dto.ts | 17 + .../companies/dto/update-profile.dto.ts | 44 +- .../companies/entities/company.entity.ts | 28 +- .../resolve-company-phone.util.ts | 34 +- .../seed-negad-indode-arrived-train.ts | 3 - ...ved-first-lastmile-demo-bookings.seeder.ts | 3 - .../src/seed/demo-bookings.seeder.ts | 3 - .../paid-import-export-mile-demo.seeder.ts | 3 - 15 files changed, 659 insertions(+), 827 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/dto/set-poa-declared.dto.ts diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 1b14845b2..a0c609f67 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -39,6 +39,7 @@ import { CompleteIdentityVerificationDto, } from "./dto/complete-identity-verification.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; +import { SetPoaDeclaredDto } from "./dto/set-poa-declared.dto"; import { StartOnboardingDto } from "./dto/start-onboarding.dto"; import { DashboardQueryDto } from "./dto/dashboard-query.dto"; import { @@ -415,90 +416,31 @@ export class CompaniesController { @PortalCustomer() @ApiOperation({ summary: - "Bind a completed Fayda verification to the company's owner or Power of Attorney. " + + "Bind a completed Fayda verification to the company's single identity. " + "Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " + + "`subject` must match the company's PoA declaration — the representative when one is named, otherwise the owner. " + "The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.", }) async completeIdentityVerification( @CurrentUser() user: CurrentIamUser, @Body() dto: CompleteIdentityVerificationDto, ): Promise { - return this.companiesService.completeIdentityVerification(user.id, dto, { - email: user.email, - phoneNumber: user.phoneNumber, - }); + return this.companiesService.completeIdentityVerification(user.id, dto); } - @Post("identity/gm/same-as-owner") + @Patch("identity/poa-declared") @PortalCustomer() @ApiOperation({ summary: - "Declare the General Manager is the company's owner, copying the owner's verified identity across. " + - "Refused until the owner is Fayda-verified — there would be nothing proven to copy.", + "Answer whether anyone holds power of attorney for this company — the question that decides whose identity is verified. " + + 'Answering "no" removes the representative entirely: their details, their verification, their passport number and the DARS delegation paper. ' + + 'Refused for a freight forwarder, which cannot operate without a representative (its answer is always "yes").', }) - async setGmSameAsOwner( + async setPoaDeclared( @CurrentUser() user: CurrentIamUser, + @Body() dto: SetPoaDeclaredDto, ): Promise { - return this.companiesService.setGmSameAsOwner(user.id, { - email: user.email, - phoneNumber: user.phoneNumber, - }); - } - - @Delete("identity/gm") - @PortalCustomer() - @ApiOperation({ - summary: - "Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " + - "Leaves the GM open to be verified in their own right, or typed where Fayda is optional.", - }) - async clearGmIdentity( - @CurrentUser() user: CurrentIamUser, - ): Promise { - return this.companiesService.clearGmIdentity(user.id); - } - - @Post("identity/poa/same-as-owner") - @PortalCustomer() - @ApiOperation({ - summary: - "Declare the Power of Attorney is the company's owner, copying the owner's identity across. " + - "Waives the DARS delegation paper — nobody delegates to themselves. " + - "Refused for an Ethiopian company whose owner is not Fayda-verified yet: its representative must be verified, and there would be nothing proven to copy.", - }) - async setPoaSameAsOwner( - @CurrentUser() user: CurrentIamUser, - ): Promise { - return this.companiesService.setPoaSameAsOwner(user.id, { - email: user.email, - phoneNumber: user.phoneNumber, - }); - } - - @Delete("identity/poa/same-as-owner") - @PortalCustomer() - @ApiOperation({ - summary: - "Undo the Power of Attorney \"same as owner\" declaration and the identity it copied, leaving the representative open to be verified in their own right. " + - "Unlike DELETE identity/fayda/poa this is allowed for a freight forwarder — it is how they change who represents them — and leaves the delegation paper on file.", - }) - async clearPoaSameAsOwner( - @CurrentUser() user: CurrentIamUser, - ): Promise { - return this.companiesService.clearPoaSameAsOwner(user.id); - } - - @Delete("identity/fayda/poa") - @PortalCustomer() - @ApiOperation({ - summary: - "Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " + - "Refused while the company holds a freight forwarder role, which cannot operate without a representative.", - }) - async removePoaIdentity( - @CurrentUser() user: CurrentIamUser, - ): Promise { - return this.companiesService.removePoaIdentity(user.id); + return this.companiesService.setPoaDeclared(user.id, dto.declared); } @Patch("onboarding-step") diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 5dd55e704..4362c4285 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -33,8 +33,13 @@ import { buildCompanyIdentityState, CompanyIdentityStateDto, CompleteIdentityVerificationDto, + ETRADE_MANAGER_NAME_KEY, + ETRADE_MANAGER_PHONE_KEY, IDENTITY_SUBJECTS, IdentitySubject, + POA_DECLARED_KEY, + PoaDeclaration, + readPoaDeclaration, } from "./dto/complete-identity-verification.dto"; import { ETradeService } from "./services/etrade.service"; import { CompanyNotifierService } from "./company-notifier.service"; @@ -93,15 +98,16 @@ const POA_ATTRIBUTES = [ "poaAddress", ] as const; /** - * Personnel an approved company maintains itself: its contact person, its - * general manager and its Power of Attorney. These name who to talk to, not - * what the company is allowed to do, so freezing the settings page until a - * reviewer gets to a new phone number costs more than it protects. They write - * straight to the live row even for an active company. + * The one person an approved company maintains itself: its contact person. + * That names who to talk to, not what the company is allowed to do, so freezing + * the settings page until a reviewer gets to a new phone number costs more than + * it protects. It writes straight to the live row even for an active company. * - * The PoA's *delegation letter* is deliberately not here — the paper is the - * thing that actually evidences the delegation, so it still goes through - * review (see `uploadPoaDelegationLetter`), as does the owner's own identity. + * The owner and the Power of Attorney are deliberately NOT here. Between them + * they carry the company's only identity verification — the owner is who the + * eTrade licence names, the PoA is who may act for the company — so an edit to + * either is exactly the kind of change a reviewer exists to see. Their + * delegation letter has always gone through review (`uploadPoaDelegationLetter`). */ const SELF_SERVICE_ATTRIBUTES: readonly string[] = [ "contactPersonName", @@ -109,12 +115,8 @@ const SELF_SERVICE_ATTRIBUTES: readonly string[] = [ "contactPersonEmail", "contactPersonPhone", "contactVerifiedPhone", - "generalManagerName", - "generalManagerEmail", - "generalManagerPhone", - ...POA_ATTRIBUTES, ]; -/** Mandatory once the company operates as a freight forwarder. */ +/** Mandatory once the company names a Power of Attorney. */ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ { key: "poaName", label: "PoA name" }, { key: "poaEmail", label: "PoA email" }, @@ -122,42 +124,25 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ ]; /** - * `attributes` key prefix per verifiable person. The owner is NOT the general - * manager: the owner is who the verification proves the company through, the - * GM is personnel it names. They're very often the same human, which is what - * the portal's "same as owner" copy is for. + * `attributes` key prefix per person. The owner is whoever the eTrade licence + * names as the business's manager; the PoA is whoever the company delegates to. + * Exactly one of them carries the company's identity verification — which one + * is the company's own declaration (`poaDeclared`). */ const IDENTITY_PREFIX: Record = { owner: "owner", poa: "poa", - gm: "gm", }; -/** - * Typed GM columns a GM verification also writes. Three notifier services mail - * `company.generalManagerEmail` directly, so leaving these behind would mean a - * verified GM whose address the system never actually uses. - */ -const GM_TYPED_FIELDS = [ - "generalManagerName", - "generalManagerEmail", - "generalManagerPhone", -] as const; - /** * Identity fields a Fayda verification owns outright, per person. Once verified * these can no longer be typed — the government IdP is the source, so an edit * that disagrees with it is either a mistake or an attempt to launder the * guarantee away. - * - * The GM's entries are its typed columns: a verified GM is locked the same way - * the others are, while an unverified one (a foreign company's, or a record - * that predates this) stays freely editable. */ const IDENTITY_OWNED_FIELDS: Record = { owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"], poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"], - gm: [...GM_TYPED_FIELDS], }; /** @@ -242,23 +227,29 @@ export class CompaniesService { label: "Contact person phone", get: (c) => c.attributes?.contactPersonPhone, }, + // The owner — whoever the eTrade licence names as the business's manager. + // All three are required whatever their source: the eTrade lookup fills + // the name and phone, a Fayda verification can fill all three, and the + // portal renders an input for whatever neither supplied. eTrade never + // returns an email and Fayda's email claim is optional, so in practice + // that field is usually typed — which is fine, because there IS an input + // for it. What there is no longer is a fallback to the signed-in account: + // the person onboarding is not necessarily the person on the licence, and + // silently stamping their address onto the owner made the record a guess. { - key: "generalManagerName", - label: "General manager name", - get: (c) => c.attributes?.generalManagerName, + key: "ownerName", + label: "Owner name", + get: (c) => c.attributes?.ownerName, }, - // The manager's EMAIL is deliberately absent. It was demanded because the - // notifiers were believed to mail it, and Fayda's email claim is optional - // — so a manager the government proved without one blocked the whole - // submission over an address nothing could produce. `companyNotifyEmailExpr` - // now resolves the address itself and falls through to the contact - // person's, then to the registering account's (which signup guarantees), - // so nothing depends on this being filled. It is still collected and still - // preferred when present; it just no longer holds the company hostage. { - key: "generalManagerPhone", - label: "General manager phone", - get: (c) => c.attributes?.generalManagerPhone, + key: "ownerEmail", + label: "Owner email", + get: (c) => c.attributes?.ownerEmail, + }, + { + key: "ownerPhone", + label: "Owner phone", + get: (c) => c.attributes?.ownerPhone, }, ]; @@ -768,6 +759,7 @@ export class CompaniesService { company: Company, dto: Partial & { faydaIdentity?: VerifiedIdentityAttributes; + etradeManager?: { name: string; phone: string }; }, ): Record { const companyUpdates: Record = {}; @@ -796,12 +788,10 @@ export class CompaniesService { attrUpdates.contactVerifiedPhone = normalizeE164( dto.contactVerifiedPhone, ); - if (dto.generalManagerName !== undefined) - attrUpdates.generalManagerName = dto.generalManagerName; - if (dto.generalManagerEmail !== undefined) - attrUpdates.generalManagerEmail = dto.generalManagerEmail; - if (dto.generalManagerPhone !== undefined) - attrUpdates.generalManagerPhone = normalizeE164(dto.generalManagerPhone); + if (dto.ownerName !== undefined) attrUpdates.ownerName = dto.ownerName; + if (dto.ownerEmail !== undefined) attrUpdates.ownerEmail = dto.ownerEmail; + if (dto.ownerPhone !== undefined) + attrUpdates.ownerPhone = normalizeE164(dto.ownerPhone); if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName; if (dto.poaPhone !== undefined) attrUpdates.poaPhone = normalizeE164(dto.poaPhone); @@ -829,11 +819,29 @@ export class CompaniesService { if (dto.etradePhone !== undefined) companyUpdates.etradePhone = normalizeE164(dto.etradePhone); - // A plain typed field — never Fayda-verified, so no lock ever applies to - // it. Independent of the owner's verification: still required for a - // foreign company even if the owner also verifies with Fayda. + // Plain typed fields — never Fayda-verified, so no lock ever applies. For a + // foreign company a passport number proves the person just as a Fayda + // verification does, so it is collected for whichever of the two carries + // the company's identity. if (dto.ownerPassportNumber !== undefined) attrUpdates.ownerPassportNumber = dto.ownerPassportNumber; + if (dto.poaPassportNumber !== undefined) + attrUpdates.poaPassportNumber = dto.poaPassportNumber; + + // eTrade's own manager, captured at lookup by `applyEtradeSourcedFields`. + // Never off the wire — the global pipe runs `forbidNonWhitelisted`, so this + // reaches us only from that method, the same guarantee `faydaIdentity` has. + // Stored apart from `ownerName`/`ownerPhone` so the two can be COMPARED: + // the company asserts an owner, eTrade states a manager, and the backoffice + // check is whether they are the same person (`ownerMatchesEtrade`). + if (dto.etradeManager) { + if (dto.etradeManager.name) + attrUpdates[ETRADE_MANAGER_NAME_KEY] = dto.etradeManager.name; + if (dto.etradeManager.phone) + attrUpdates[ETRADE_MANAGER_PHONE_KEY] = normalizeE164( + dto.etradeManager.phone, + ); + } // A verified identity overwrites the person's details. `faydaIdentity` // never comes off the wire — the global validation pipe runs with @@ -844,19 +852,17 @@ export class CompaniesService { Object.assign(attrUpdates, dto.faydaIdentity); } - // Keyed on the verified VALUE, not on `ownerFaydaSub`: Fayda's email and - // phone claims are optional, so a verification can prove the person while - // supplying neither (see completeIdentityVerification's conditional - // spreads). The portal falls back to the account email / eTrade's - // registered phone in exactly that case and submits it on every save of - // the company step — locking against an absent value would 400 that - // forever, and re-verifying could never clear it because Fayda still has - // nothing to return. - if (attrUpdates.ownerFaydaSub) { - if (attrUpdates.ownerEmail) companyUpdates.email = attrUpdates.ownerEmail; - if (attrUpdates.ownerPhone) - companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone)); - } + // The company's own contact columns follow the owner, verified or not. + // + // This used to be gated on `ownerFaydaSub`, which meant `companies.email` + // was only ever written for a Fayda-verified owner — so every foreign + // company (passport instead of Fayda) had none, and the notification + // resolver papered over it by falling through to the general manager's + // address. The GM is gone and the owner's email is now required outright, + // so this is simply where it lands. + if (attrUpdates.ownerEmail) companyUpdates.email = attrUpdates.ownerEmail; + if (attrUpdates.ownerPhone) + companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone)); // Renaming a Fayda-verified person by hand would launder the guarantee // away, so the verification keeps these fields: a submission that disagrees @@ -874,12 +880,10 @@ export class CompaniesService { if (dto.faydaIdentity && field in dto.faydaIdentity) continue; const stored = company.attributes?.[field]; // A verification that supplied nothing for this field left no guarantee - // to protect, so it stays typeable. Matters most for the GM — - // `setGmSameAsOwner` copies `ownerEmail ?? null` onto - // `generalManagerEmail` while setting `gmFaydaSub`, and - // REQUIRED_COMPANY_INFO still demands that email, so holding a null - // here makes it required, hidden by the portal's "same as owner" card, - // and unwritable all at once. + // to protect, so it stays typeable. This is what makes the required + // owner email reachable: Fayda's email claim is optional, so a verified + // owner routinely has none stored — locking against that absence would + // make `REQUIRED_COMPANY_INFO` demand a field nobody could ever fill. if (stored === null || stored === undefined || stored === "") continue; attrUpdates[field] = stored; } @@ -965,9 +969,7 @@ export class CompaniesService { if (POA_ATTRIBUTES.some((k) => dto[k] !== undefined)) { const attributes = this.mapProfileDtoToCompanyUpdates(company, dto) .attributes as Record; - await this.assertPoaDelegationSatisfied(company.id, attributes, { - requirePoa: await this.isFreightForwarder(company.id), - }); + await this.assertPoaDelegationSatisfied(company, attributes); } if (company.status !== CompanyStatus.Active) { @@ -1615,12 +1617,13 @@ export class CompaniesService { // the last place it has to be checked — the role may have been applied // for before the paper was withdrawn. if (company && existing.type === ProfileType.freightForwarder) { - this.assertIdentityVerified(company, { requirePoa: true }); - await this.assertPoaDelegationSatisfied( - company.id, - company.attributes, - { requirePoa: true }, - ); + // The row was loaded FOR UPDATE, so its relations are not populated — + // and `readPoaDeclaration` reads `companyProfiles` to force "yes" for a + // forwarder. This IS the forwarder profile being approved, so naming it + // is enough (and truthful) for both assertions below. + company.companyProfiles = company.companyProfiles ?? [existing]; + this.assertIdentityVerified(company); + await this.assertPoaDelegationSatisfied(company, company.attributes); } const [companyDocs, profileDocs] = await Promise.all([ @@ -1884,11 +1887,11 @@ export class CompaniesService { // without a Power of Attorney and its DARS paper — checked here so the // customer is told at the point of asking, not at review. if (type === ProfileType.freightForwarder) { - this.assertIdentityVerified(company, { requirePoa: true }); + const asForwarder = this.withProfileType(company, type); + this.assertIdentityVerified(asForwarder); await this.assertPoaDelegationSatisfied( - companyId, + asForwarder, await this.effectivePoaAttributes(company), - { requirePoa: true }, ); } @@ -1930,11 +1933,11 @@ export class CompaniesService { let created = await this.companyProfilesRepo.findByType(companyId, type); if (!created && type === ProfileType.freightForwarder) { - this.assertIdentityVerified(company, { requirePoa: true }); + const asForwarder = this.withProfileType(company, type); + this.assertIdentityVerified(asForwarder); await this.assertPoaDelegationSatisfied( - companyId, + asForwarder, await this.effectivePoaAttributes(company), - { requirePoa: true }, ); } if (!created) { @@ -2028,33 +2031,26 @@ export class CompaniesService { ); const missingLicenses = licenseProfiles.filter((p) => !p.uploaded); - // 4. Power of Attorney. Optional in general, but a freight forwarder acts on - // other companies' behalf so its PoA is mandatory. Either way, a PoA that - // has been entered must be evidenced by the DARS delegation paper — a legal - // requirement, so unlike the documents above it does not depend on the - // upload set carrying a field for it (see poa-delegation.constants.ts). - const poaRequired = (company.companyProfiles ?? []).some( - (p) => p.type === ProfileType.freightForwarder, - ); - const poaProvided = POA_ATTRIBUTES.some((k) => - (company.attributes?.[k] as string | undefined)?.trim(), - ); + // 4. Power of Attorney. Whether there is one at all is the company's own + // declaration — the question the wizard asks outright — and that answer is + // what decides whose identity gets verified, so an unanswered one is itself + // outstanding. A freight forwarder never gets to answer: it signs on other + // companies' behalf, so `readPoaDeclaration` forces "yes". + // + // Once there IS a representative, their details and the DARS delegation + // paper are both due. The paper is a legal requirement, so unlike the + // documents above it does not depend on the upload set carrying a field for + // it (see poa-delegation.constants.ts). + const poaDue = identity.poaDeclared === "yes"; const delegation = await this.getPoaDelegationState(company.id); - // "There is a representative" and "a paper is owed for them" used to be the - // same condition. They part company once the owner represents the company - // themselves: the representative's details are still required, but nobody - // delegates to themselves, so no DARS paper is due (`assertPoaDelegationSatisfied` - // returns on the same flag — the two must agree). - const poaDue = poaRequired || poaProvided; - const delegationDue = poaDue && !identity.poaSameAsOwner; + const delegationDue = poaDue; // The representative's details normally arrive from their Fayda // verification — but Fayda's email and phone claims are optional and // routinely come back empty, and the PoA step renders an input for whatever // the verification did not supply. So these are askable after all, and are - // reported outstanding once a PoA is required or provided; reporting - // nothing here let a freight forwarder finish onboarding with a - // representative the API's own `REQUIRED_POA_FIELDS` calls incomplete, then - // 400'd their next PoA edit for it. + // reported outstanding once a PoA is declared; reporting nothing here let a + // freight forwarder finish onboarding with a representative the API's own + // `REQUIRED_POA_FIELDS` calls incomplete, then 400'd their next PoA edit. const missingPoaFields = poaDue ? REQUIRED_POA_FIELDS.filter( (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), @@ -2065,10 +2061,15 @@ export class CompaniesService { // replace it before the application counts as complete. const flaggedDelegation = delegationDue && delegation.flagged; - // Mirrors `poaProven` in buildCompanyIdentityState — see the note there. - const poaProven = identity.faydaRequired - ? identity.poa.verified - : identity.poa.verified || Boolean(identity.poa.name?.trim()); + // 5. The single identity. Who proves it is `identity.subject`; how they may + // prove it is nationality-dependent (Fayda always, a passport number as an + // alternative for a foreign company). Both are derived once in + // buildCompanyIdentityState so this list can never disagree with the gate + // `assertIdentityVerified` actually enforces. + const identitySubjectLabel = + identity.subject === "poa" + ? "your Power of Attorney" + : "the person named on your eTrade licence"; const outstanding = [ ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), @@ -2086,58 +2087,38 @@ export class CompaniesService { `Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`, ] : []), - ...(identity.faydaRequired && !identity.owner.verified - ? ["Verify the company owner's identity with Fayda"] + ...(identity.poaDeclared === null + ? ["Tell us whether anyone holds power of attorney for your company"] : []), - // Nationality-aware, exactly like `poaProven` in - // buildCompanyIdentityState and the check in `assertIdentityVerified`: - // Fayda is an Ethiopian national ID, so a foreign company's typed - // representative has to count. Demanding a verification here regardless - // made this list disagree with the rule actually enforced, and left a - // foreign freight forwarder unable to submit — asked for a Fayda - // verification its representative may have no way to obtain. - ...((poaRequired || poaProvided) && !poaProven + ...(identity.poaDeclared !== null && !identity.identityProven ? [ - identity.faydaRequired - ? "Verify your Power of Attorney's identity with Fayda" - : "Name your Power of Attorney, or verify them with Fayda", + identity.passportAccepted + ? `Verify ${identitySubjectLabel} with Fayda, or add their passport number` + : `Verify ${identitySubjectLabel} with Fayda`, ] : []), - ...(identity.passportRequired && !identity.owner.passportNumber - ? ["Add the company owner's passport number"] - : []), ]; // Progress spans every required item the user has to satisfy: company-info - // fields, required documents, one license per operational profile, and the - // PoA details/paper whenever those are mandatory. + // fields, required documents, one license per operational profile, the PoA + // details/paper once declared, and the two identity items — answering the + // declaration, and proving the person it points at. const requiredDocCount = documents.filter((d) => d.isRequired).length; // The delegation paper plus the representative's own required details — // `completed` below subtracts every one of those it is still missing, so // leaving them out of the total would make the bar understate progress. const poaItemCount = (poaDue ? REQUIRED_POA_FIELDS.length : 0) + (delegationDue ? 1 : 0); - // One item per identity credential the company has to prove: the owner - // always (Fayda for Ethiopian, passport for foreign), plus the PoA once - // there is one — Fayda for an Ethiopian company, a named representative - // for a foreign one, same rule as `poaProven` above. Counting a foreign - // company's typed PoA as unproven here left the progress bar permanently - // short of 100% on an item it had already satisfied. - const ownerCredentialDue = - identity.faydaRequired || identity.passportRequired; - const ownerCredentialProven = identity.faydaRequired - ? identity.owner.verified - : Boolean(identity.owner.passportNumber); - const identityItemCount = (ownerCredentialDue ? 1 : 0) + (poaDue ? 1 : 0); const missingIdentityCount = - (ownerCredentialDue && !ownerCredentialProven ? 1 : 0) + - (poaDue && !poaProven ? 1 : 0); + (identity.poaDeclared === null ? 1 : 0) + + (identity.identityProven ? 0 : 1); const total = requiredInfo.length + requiredDocCount + licenseProfiles.length + poaItemCount + - identityItemCount; + // The declaration and the verification it selects. + 2; const completed = total - (missingInfo.length + @@ -2157,8 +2138,12 @@ export class CompaniesService { documents, licenseProfiles, poa: { - required: poaRequired, - provided: poaProvided, + // "Locked" rather than "required": a freight forwarder is not asked the + // question at all, everyone else answers it themselves. + locked: identity.poaDeclared === "yes" && (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ), + declared: identity.poaDeclared, delegationLetterRequired: delegationDue, delegationLetterUploaded: delegation.onFile, delegationLetterFlagged: delegation.flagged, @@ -2689,39 +2674,44 @@ export class CompaniesService { * judged against the files that would survive it (`ignoreFileIds`). */ private async assertPoaDelegationSatisfied( - companyId: string, + company: Company, attributes: Record | null | undefined, - opts: { requirePoa: boolean; ignoreFileIds?: string[] }, + opts: { ignoreFileIds?: string[] } = {}, ): Promise { + // The declaration is the whole gate. A company that says it has no + // representative owes nothing here; one that says it has owes the details + // AND the paper, with no exceptions — including a freight forwarder, for + // whom `readPoaDeclaration` forces "yes" regardless of what is stored. + const declared = readPoaDeclaration({ + attributes: attributes as Company["attributes"], + companyProfiles: company.companyProfiles, + }); + if (declared !== "yes") return; + + const isForwarder = (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ); const read = (key: string) => (attributes?.[key] as string | undefined)?.trim(); - const poaProvided = POA_ATTRIBUTES.some((k) => read(k)); - if (!opts.requirePoa && !poaProvided) return; - if (opts.requirePoa) { - const missing = REQUIRED_POA_FIELDS.filter((f) => !read(f.key)); - if (missing.length > 0) { - throw new BadRequestException( - `A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` + - `Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`, - ); - } + const missing = REQUIRED_POA_FIELDS.filter((f) => !read(f.key)); + if (missing.length > 0) { + throw new BadRequestException( + (isForwarder + ? "A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. " + : "You told us someone holds power of attorney for this company. ") + + `Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`, + ); } - // Nobody delegates to themselves: an owner representing their own company - // has no delegation to evidence, so the DARS paper is not owed. The - // representative's own details are still required above — a forwarder's - // counterparties need someone to contact either way. - if (attributes?.poaSameAsOwner) return; - const { onFile, flagged } = await this.getPoaDelegationState( - companyId, + company.id, opts.ignoreFileIds, ); if (!onFile) { throw new BadRequestException( `Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` + - (opts.requirePoa ? " — it is required for freight forwarders." : "."), + (isForwarder ? " — it is required for freight forwarders." : "."), ); } if (flagged) { @@ -2732,54 +2722,145 @@ export class CompaniesService { } } - /** Does this company operate as a freight forwarder? */ - private async isFreightForwarder(companyId: string): Promise { - const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); - return profiles.some((p) => p.type === ProfileType.freightForwarder); - } - // --------------------------------------------------------------------------- - // Fayda identity verification (owner / PoA) + // Identity verification (one per company) // - // A completed VeriFayda verification proves a person's name, phone, email - // and address — Fayda's userinfo carries no national ID number, so none of - // that is collected here. For an Ethiopian company both the owner and its - // PoA (once named) must be verified before the company can trade. Fayda is - // an Ethiopian national ID system, so a foreign company's owner proves - // identity with a typed passport number instead — required on its own - // terms, not waived by an owner who happens to verify with Fayda too. + // A company proves itself through exactly ONE person. Which one is its own + // declaration: the Power of Attorney when it names a representative, + // otherwise the owner — whoever the eTrade licence names as the business's + // manager. There is no general manager and no "same as owner" copy: an owner + // who represents their own company simply answers "no, nobody holds power of + // attorney", and verifies as the owner. + // + // A completed VeriFayda verification proves that person's name, phone, email + // and address (Fayda's userinfo carries no national ID number, so none is + // collected). Fayda is an Ethiopian national ID, so a foreign company may + // instead type a passport number for the same person — an alternative, not an + // addition. // --------------------------------------------------------------------------- /** - * Verification state for both people, plus whether it is mandatory here. - * `complete` answers the gate question directly so the portal, the onboarding - * requirements and the assertions below all read the same verdict — the - * derivation itself is shared with ProfileResponseDto. + * The company's identity state: both people, who currently carries the + * verification, whether it is proven, and whether the owner the company put + * forward matches the eTrade licence. `complete` answers the gate question + * directly so the portal, the onboarding requirements and the assertions + * above all read the same verdict — the derivation itself is shared with + * ProfileResponseDto and the backoffice company DTO. */ getCompanyIdentityState(company: Company): CompanyIdentityStateDto { return buildCompanyIdentityState(company); } /** - * Complete a Fayda verification and bind the identity to one of the company's - * people. The portal starts the flow through the shared + * Record whether anyone holds power of attorney for this company. + * + * This is the question that decides whose identity gets verified, so it is + * stored rather than inferred from "are any `poa*` keys set" — absence means + * "not asked yet", which is an outstanding onboarding item in its own right. + * + * Answering "no" tears the representative down: their details, their + * verification, their passport number and the DARS paper evidencing them all + * go. Leaving any of it behind would keep the company on the hook for a + * delegation it has just said does not exist. + * + * Refused for a freight forwarder — it signs on other companies' behalf, so a + * representative is non-negotiable. (`readPoaDeclaration` forces "yes" for + * them anyway; this is the honest error rather than a silently ignored write.) + */ + async setPoaDeclared( + userId: string, + declared: PoaDeclaration, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + + if ( + declared === "no" && + (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ) + ) { + throw new BadRequestException( + "A freight forwarder acts on other companies' behalf, so it must have a Power of Attorney. Remove the freight forwarder role first.", + ); + } + + const attributes: Record = { + ...(company.attributes ?? {}), + [POA_DECLARED_KEY]: declared, + }; + + if (declared === "no") { + for (const key of [ + ...POA_ATTRIBUTES, + "poaFaydaSub", + "poaFaydaVerifiedAt", + "poaBirthdate", + "poaGender", + "poaPassportNumber", + ]) { + attributes[key] = null; + } + await this.deletePoaDelegationFiles(company.id); + } + + const updated = await this.companiesRepo.update(company.id, { attributes }); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + updated.companyProfiles = company.companyProfiles; + return this.getCompanyIdentityState(updated); + } + + /** Drop every DARS paper on file — the delegation it evidenced is gone. */ + private async deletePoaDelegationFiles(companyId: string): Promise { + const records = await this.filesService.findByResource( + companyId, + COMPANY_RESOURCE, + ); + for (const r of records) { + if ( + r.code === POA_DELEGATION_FILE_KEY || + r.code === POA_DELEGATION_PENDING_CODE + ) { + await this.filesService.remove(r.id); + await this.withdrawDocumentIntent(companyId, r.id); + } + } + } + + /** + * Complete a Fayda verification and bind the identity to the company. + * + * The portal starts the flow through the shared * `POST /fayda/verification/start` and only tells us which person it was for * here, at completion — so the verifayda module stays generic and its session * table needs no company-specific column. + * + * The subject has to be the one the company's declaration calls for. A + * verification bound to the other person would sit on the record looking + * proven while the gate — which reads only the declared subject — stayed + * unsatisfied, and nothing in the portal would explain why. */ async completeIdentityVerification( userId: string, dto: CompleteIdentityVerificationDto, - /** - * The signed-in account, used as the owner's fallback contact details. - * Optional so the callers that only have a user id keep compiling — they - * simply get no fallback. - */ - account?: { email?: string; phoneNumber?: string }, ): Promise { const { company } = await this.getCompanyInfoByUserId(userId); + const state = buildCompanyIdentityState(company); const prefix = IDENTITY_PREFIX[dto.subject]; + if (state.subject === null) { + throw new BadRequestException( + "Tell us whether anyone holds power of attorney for this company first — the answer decides whose identity we verify.", + ); + } + if (state.subject !== dto.subject) { + throw new BadRequestException( + state.subject === "poa" + ? "This company is represented by a Power of Attorney, so it is their identity we need — not the owner's." + : "This company has no Power of Attorney, so it is the owner's identity we need.", + ); + } + const result = await this.verifaydaService.completeVerification({ code: dto.code, state: dto.state, @@ -2790,71 +2871,38 @@ export class CompaniesService { ); } - // An owner who is also the company's representative is a supported answer, - // not a conflict — the same way the GM is very often the owner. Small - // companies routinely have one human in all three roles, and the portal's - // "same as owner" cards exist precisely so they can say so. No identity - // here is refused for colliding with another. - - const now = new Date().toISOString(); - - // Fayda's email and phone claims are optional and routinely come back empty. - // For the owner that leaves the company with no contact details at all: the - // step renders no input for them (they are the verification's output), and - // "same as owner" then copies those blanks onto `generalManagerEmail` / - // `generalManagerPhone`, which `REQUIRED_COMPANY_INFO` demands at submit — - // an unfixable dead end. The account doing the onboarding is the one contact - // we always have, and it is already OTP-proven, so it stands in. + // Only what Fayda actually returned is written. Its email and phone claims + // are optional and routinely come back empty — the portal renders an input + // for whatever is missing and the customer fills it in. // - // Owner only: the PoA and the GM are other people, and the registering - // account's address is not theirs to wear. - const isOwner = dto.subject === "owner"; - const email = result.email || (isOwner ? account?.email : undefined); - const phone = - result.phoneNumber || (isOwner ? account?.phoneNumber : undefined); - + // There is deliberately NO fallback to the signed-in account. The person + // onboarding is not necessarily the person on the licence, so stamping + // their address onto the owner turned a required field into a guess that + // looked verified. const identity: VerifiedIdentityAttributes = { [`${prefix}FaydaSub`]: result.sub, - [`${prefix}FaydaVerifiedAt`]: now, + [`${prefix}FaydaVerifiedAt`]: new Date().toISOString(), [`${prefix}Birthdate`]: result.birthdate ?? null, [`${prefix}Gender`]: result.gender ?? null, // The verified payload owns the person's details from here on. ...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}), - ...(email ? { [`${prefix}Email`]: email } : {}), + ...(result.email ? { [`${prefix}Email`]: result.email } : {}), // Fayda returns whatever the national registry holds, which is routinely a // local number ("0911223344"). Every typed phone in this service is stored // E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here // becomes a value the portal reads back and cannot resubmit. - ...(phone ? { [`${prefix}Phone`]: normalizeE164(phone) } : {}), + ...(result.phoneNumber + ? { [`${prefix}Phone`]: normalizeE164(result.phoneNumber) } + : {}), ...(result.address ? { [`${prefix}Address`]: result.address } : {}), }; - // A GM verification also lands on the typed columns the rest of the system - // already reads (the booking, train-scheduling and contract notifiers all - // mail `generalManagerEmail`), and clears any earlier "same as owner" - // declaration — verifying in their own right is the GM answering for - // themselves. - // Verifying the representative in their own right answers the question the - // "same as owner" declaration answered, so the declaration goes. - if (dto.subject === "poa") { - identity.poaSameAsOwner = false; - } - - if (dto.subject === "gm") { - identity.gmSameAsOwner = false; - if (result.fullName) identity.generalManagerName = result.fullName; - if (result.email) identity.generalManagerEmail = result.email; - if (result.phoneNumber) - identity.generalManagerPhone = normalizeE164(result.phoneNumber); - } - - // An approved company's *owner* is its identity proof, so re-verifying one - // is staged for backoffice review rather than quietly rewriting a live - // record. The PoA and GM are personnel — the company names its own - // representative and manager, and the delegation letter backing the PoA is - // what the reviewer sees — so those land live, matching their typed - // counterparts in `SELF_SERVICE_ATTRIBUTES`. - if (company.status === CompanyStatus.Active && dto.subject === "owner") { + // An approved company's identity is what its approval rested on, so + // re-verifying is staged for backoffice review rather than quietly + // rewriting a live record. Both subjects go through review now: whichever + // one the declaration points at IS the company's proof, and the owner is + // additionally the person the reviewer checks against the eTrade licence. + if (company.status === CompanyStatus.Active) { await this.stageIdentityChange(company, userId, identity); return this.getCompanyIdentityState(company); } @@ -2868,261 +2916,6 @@ export class CompaniesService { return this.getCompanyIdentityState(updated); } - /** - * Declare that the General Manager is the company's owner. - * - * The GM is very often the owner, and making that human verify twice buys - * nothing — the owner's verification already proves them. So this copies the - * owner's verified identity across rather than starting a second flow, and - * records `gmSameAsOwner` so the portal can show it as a declaration rather - * than as a verification the GM passed in their own right. - * - * Refused until the owner is actually verified: without that there is no - * proven identity to copy, only typed text that would arrive wearing a - * verified badge. - */ - async setGmSameAsOwner( - userId: string, - /** Same fallback as {@link completeIdentityVerification}, for owners - * verified before that fallback existed — their stored contact details are - * blank, and copying blanks here would block the submit. */ - account?: { email?: string; phoneNumber?: string }, - ): Promise { - const { company } = await this.getCompanyInfoByUserId(userId); - const attrs = company.attributes ?? {}; - const ownerSub = attrs.ownerFaydaSub as string | undefined; - if (!ownerSub) { - throw new BadRequestException( - "Verify the company owner with Fayda first — there is no proven identity to reuse yet.", - ); - } - - const ownerEmail = (attrs.ownerEmail as string | undefined) || account?.email || null; - const ownerPhone = (attrs.ownerPhone as string | undefined) || account?.phoneNumber || null; - - const copied: Record = { - gmSameAsOwner: true, - gmFaydaSub: ownerSub, - gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(), - gmName: attrs.ownerName ?? null, - gmEmail: ownerEmail, - gmPhone: ownerPhone ? normalizeE164(ownerPhone) : null, - gmAddress: attrs.ownerAddress ?? null, - gmBirthdate: attrs.ownerBirthdate ?? null, - gmGender: attrs.ownerGender ?? null, - // Kept in step for the notifiers, same as a GM verification does. - generalManagerName: attrs.ownerName ?? null, - generalManagerEmail: ownerEmail, - generalManagerPhone: ownerPhone ? normalizeE164(ownerPhone) : null, - }; - - const updated = await this.companiesRepo.update(company.id, { - attributes: { ...attrs, ...copied }, - }); - if (!updated) - throw new NotFoundException(`Company ${company.id} not found`); - updated.companyProfiles = company.companyProfiles; - return this.getCompanyIdentityState(updated); - } - - /** - * Undo the "same as owner" declaration, clearing the copied identity so the - * GM can be verified in their own right (or typed, where Fayda is optional). - */ - async clearGmIdentity(userId: string): Promise { - const { company } = await this.getCompanyInfoByUserId(userId); - const attrs = { ...(company.attributes ?? {}) }; - for (const key of [ - "gmSameAsOwner", - "gmFaydaSub", - "gmFaydaVerifiedAt", - "gmName", - "gmEmail", - "gmPhone", - "gmAddress", - "gmBirthdate", - "gmGender", - ...GM_TYPED_FIELDS, - ]) { - attrs[key] = null; - } - - const updated = await this.companiesRepo.update(company.id, { - attributes: attrs, - }); - if (!updated) - throw new NotFoundException(`Company ${company.id} not found`); - updated.companyProfiles = company.companyProfiles; - return this.getCompanyIdentityState(updated); - } - - /** - * Declare that the company's Power of Attorney is its owner. - * - * An owner representing their own company is the ordinary case for a small - * business, so this is a supported answer rather than the conflict it used to - * be refused as. Two shapes, matching {@link setGmSameAsOwner}: - * - * - A Fayda-verified owner is a proven identity, so it is copied outright — - * the representative inherits the verification instead of the same human - * being sent through Fayda a second time. - * - A foreign company's owner is backed by a typed passport, so there is - * nothing proven to copy. The declaration is still recorded (it is what - * waives the DARS paper) and whatever owner details exist come across; the - * portal types the rest, which `poaProven` accepts for a foreign company. - * - * Refused for an Ethiopian company whose owner is not verified yet: Fayda is - * mandatory for its representative, so a declaration there would record a - * representative that could never satisfy the gate. - */ - async setPoaSameAsOwner( - userId: string, - /** Same fallback as {@link completeIdentityVerification} — an owner whose - * Fayda claims carried no email/phone has none stored, and copying blanks - * onto a freight forwarder's PoA would block the submit on - * `REQUIRED_POA_FIELDS`. */ - account?: { email?: string; phoneNumber?: string }, - ): Promise { - const { company } = await this.getCompanyInfoByUserId(userId); - const attrs = company.attributes ?? {}; - const state = buildCompanyIdentityState(company); - const ownerSub = attrs.ownerFaydaSub as string | undefined; - - if (state.faydaRequired && !ownerSub) { - throw new BadRequestException( - "Verify the company owner with Fayda first — there is no proven identity to reuse yet.", - ); - } - - const ownerEmail = (attrs.ownerEmail as string | undefined) || account?.email; - const ownerPhone = - (attrs.ownerPhone as string | undefined) || account?.phoneNumber; - - // Only non-blank values are copied: a blank here would overwrite something - // the portal typed for a foreign company, whose owner has no verified - // claims to draw on. - const copied: Record = { poaSameAsOwner: true }; - const copy = (key: string, value: unknown) => { - if (value !== null && value !== undefined && value !== "") - copied[key] = value; - }; - copy("poaName", attrs.ownerName); - copy("poaEmail", ownerEmail); - copy("poaPhone", ownerPhone ? normalizeE164(ownerPhone) : undefined); - copy("poaAddress", attrs.ownerAddress); - - if (ownerSub) { - copied.poaFaydaSub = ownerSub; - copied.poaFaydaVerifiedAt = - attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(); - copy("poaBirthdate", attrs.ownerBirthdate); - copy("poaGender", attrs.ownerGender); - } - - const updated = await this.companiesRepo.update(company.id, { - attributes: { ...attrs, ...copied }, - }); - if (!updated) - throw new NotFoundException(`Company ${company.id} not found`); - updated.companyProfiles = company.companyProfiles; - return this.getCompanyIdentityState(updated); - } - - /** - * Undo the PoA "same as owner" declaration, clearing the identity it copied - * so a different representative can be verified (or typed, for a foreign - * company). - * - * Separate from {@link removePoaIdentity}, which drops the representative and - * their paper and is refused to a freight forwarder. Undoing a declaration is - * how a forwarder changes its mind about who represents it, so it must stay - * open to them — the submit gate still refuses a forwarder that never names a - * replacement. The delegation paper is left alone for the same reason: the - * company still owes one, now for whoever comes next. - * - * A no-op when no declaration is in place: a stray call must not wipe a - * representative who verified in their own right. - */ - async clearPoaSameAsOwner(userId: string): Promise { - const { company } = await this.getCompanyInfoByUserId(userId); - const attrs = { ...(company.attributes ?? {}) }; - if (!attrs.poaSameAsOwner) return this.getCompanyIdentityState(company); - - attrs.poaSameAsOwner = false; - for (const key of [ - ...POA_ATTRIBUTES, - "poaFaydaSub", - "poaFaydaVerifiedAt", - "poaBirthdate", - "poaGender", - ]) { - attrs[key] = null; - } - - const updated = await this.companiesRepo.update(company.id, { - attributes: attrs, - }); - if (!updated) - throw new NotFoundException(`Company ${company.id} not found`); - updated.companyProfiles = company.companyProfiles; - return this.getCompanyIdentityState(updated); - } - - /** - * Drop the Power of Attorney entirely — the verified identity, the details it - * wrote and the delegation paper together. - * - * Only the PoA can go: a company always has an owner, and a freight forwarder - * always has a representative. Once a PoA is Fayda-verified its - * fields are locked, so blanking the form is no longer a way out — without - * this the customer would be stuck with a representative they cannot remove. - */ - async removePoaIdentity(userId: string): Promise { - const { company } = await this.getCompanyInfoByUserId(userId); - if ( - (company.companyProfiles ?? []).some( - (p) => p.type === ProfileType.freightForwarder, - ) - ) { - throw new BadRequestException( - "A freight forwarder must have a Power of Attorney. Remove the freight forwarder role first.", - ); - } - - const cleared: Record = { poaSameAsOwner: false }; - for (const key of [ - ...POA_ATTRIBUTES, - "poaFaydaSub", - "poaFaydaVerifiedAt", - "poaBirthdate", - "poaGender", - ]) { - cleared[key] = null; - } - const attributes = { ...(company.attributes ?? {}), ...cleared }; - - // The paper evidences a representative who no longer exists. - const records = await this.filesService.findByResource( - company.id, - COMPANY_RESOURCE, - ); - for (const r of records) { - if ( - r.code === POA_DELEGATION_FILE_KEY || - r.code === POA_DELEGATION_PENDING_CODE - ) { - await this.filesService.remove(r.id); - await this.withdrawDocumentIntent(company.id, r.id); - } - } - - const updated = await this.companiesRepo.update(company.id, { attributes }); - if (!updated) - throw new NotFoundException(`Company ${company.id} not found`); - updated.companyProfiles = company.companyProfiles; - return this.getCompanyIdentityState(updated); - } - /** Stage a verified identity onto the company's pending change request. */ private async stageIdentityChange( company: Company, @@ -3171,62 +2964,56 @@ export class CompaniesService { } /** - * The gate: an Ethiopian company's owner must be Fayda-verified, and so must - * its Power of Attorney once it has one; a foreign company's owner must carry - * a passport number instead. Called from the same places as - * `assertPoaDelegationSatisfied` — the two rules describe the same moment - * (who may act for this company, and on what evidence) and drifting them - * apart is how one of them ends up unenforced. + * The company as it will be once `type` is one of its roles. + * + * Taking on the freight-forwarder role is checked BEFORE the profile row + * exists, and both assertions below read `companyProfiles` — a forwarder is + * what forces the PoA declaration to "yes". Judging the company as it stands + * would let one that answered "no" pick up the role and skip the very + * requirement the role exists to impose. Read-only; never persisted. */ - private assertIdentityVerified( - company: Company, - opts: { requirePoa: boolean }, - ): void { + private withProfileType(company: Company, type: ProfileType): Company { + const profiles = company.companyProfiles ?? []; + if (profiles.some((p) => p.type === type)) return company; + return { + ...company, + companyProfiles: [...profiles, { type } as CompanyProfile], + } as Company; + } + + /** + * The gate: the company's ONE identity must be proven. + * + * Which person that is comes from the company's own declaration — the + * representative when it names one, otherwise the owner (whoever the eTrade + * licence names as manager). How they prove it depends on nationality: Fayda + * for an Ethiopian company, Fayda *or* a typed passport number for a foreign + * one, whose people may hold no Fayda ID at all. + * + * Called from the same places as `assertPoaDelegationSatisfied` — the two + * rules describe the same moment (who may act for this company, and on what + * evidence) and drifting them apart is how one of them ends up unenforced. + */ + private assertIdentityVerified(company: Company): void { const state = buildCompanyIdentityState(company); - // Only the owner's credential is nationality-specific: Fayda for an - // Ethiopian company, a typed passport number for a foreign one. - if (state.passportRequired) { - if (!state.owner.passportNumber) { - throw new BadRequestException( - "Add the company owner's passport number before continuing.", - ); - } - } else if (!state.owner.verified) { + if (state.poaDeclared === null) { throw new BadRequestException( - "Verify the company owner's identity with Fayda before continuing.", + "Tell us whether anyone holds power of attorney for this company — the answer decides whose identity we verify.", ); } - const poaNamed = POA_ATTRIBUTES.some((k) => - (company.attributes?.[k] as string | undefined)?.trim(), + if (state.identityProven) return; + + const who = + state.subject === "poa" + ? "your Power of Attorney" + : "the person named on your eTrade licence"; + throw new BadRequestException( + state.passportAccepted + ? `Verify ${who} with Fayda, or add their passport number.` + : `Verify ${who} with Fayda before continuing.`, ); - if (!opts.requirePoa && !poaNamed) return; - - // Fayda is an Ethiopian national ID, so only an Ethiopian company's - // representative can be held to it. A foreign company is offered the - // verification and nominates a Fayda-holding representative where it can, - // but a typed name has to remain sufficient — otherwise a foreign company - // whose representative holds no Fayda ID could never trade at all. Mirrors - // `poaProven` in buildCompanyIdentityState; the two must agree. - if (state.passportRequired) { - if (!state.poa.verified && !state.poa.name?.trim()) { - throw new BadRequestException( - opts.requirePoa - ? "Name your Power of Attorney — a freight forwarder cannot operate without one." - : "Complete the Power of Attorney you named, or remove the representative.", - ); - } - return; - } - - if (!state.poa.verified) { - throw new BadRequestException( - opts.requirePoa - ? "Verify your Power of Attorney with Fayda — a freight forwarder cannot operate without one." - : "Verify the Power of Attorney you named with Fayda, or remove the representative.", - ); - } } /** @@ -3350,12 +3137,9 @@ export class CompaniesService { // the representative it evidences is gone too (which, for an Active // company, means the clearing edit is already staged). await this.assertPoaDelegationSatisfied( - company.id, + company, await this.effectivePoaAttributes(company), - { - requirePoa: await this.isFreightForwarder(company.id), - ignoreFileIds: [fileId], - }, + { ignoreFileIds: [fileId] }, ); if (record.code === POA_DELEGATION_PENDING_CODE) { @@ -3583,7 +3367,7 @@ export class CompaniesService { */ private async applyEtradeSourcedFields( company: Company, - dto: UpdateProfileDto, + dto: UpdateProfileDto & { etradeManager?: { name: string; phone: string } }, ): Promise { const touched = ETRADE_SOURCED_FIELDS.some( (key) => key !== "tin" && dto[key] !== undefined, @@ -3626,5 +3410,18 @@ export class CompaniesService { // (the onboarding/settings card lets the customer type it directly then). if (value) (dto as Record)[key] = value; } + + // Capture the licence's own manager alongside the registration it belongs + // to. NOT written onto `ownerName`/`ownerPhone`: those are what the company + // asserts (and what a Fayda verification owns), and overwriting them here + // would destroy the very difference the backoffice is asked to check. The + // portal prefills the owner from these, so they agree unless someone made + // them disagree — which is exactly the case worth surfacing. + if (registration.managerName || registration.managerPhone) { + dto.etradeManager = { + name: registration.managerName, + phone: registration.managerPhone, + }; + } } } diff --git a/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts b/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts index 83fa539e3..71b60a268 100644 --- a/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts +++ b/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts @@ -23,9 +23,18 @@ export const COMPANY_FIELD_LABELS: Record = { contactPersonPhone: "Contact person phone", contactPersonEmail: "Contact person email", contactPersonPosition: "Contact person position", - generalManagerName: "General manager name", - generalManagerPhone: "General manager phone", - generalManagerEmail: "General manager email", + ownerName: "Owner name", + ownerPhone: "Owner phone", + ownerEmail: "Owner email", + ownerPassportNumber: "Owner passport number", + poaPassportNumber: "PoA passport number", + poaDeclared: "Has a Power of Attorney", + // Nothing writes these any more (the general manager was removed), but + // revisions and change requests filed before that still carry them — without + // the labels those rows render raw attribute keys to a reviewer. + generalManagerName: "General manager name (retired)", + generalManagerPhone: "General manager phone (retired)", + generalManagerEmail: "General manager email (retired)", poaName: "PoA name", poaPhone: "PoA phone", poaEmail: "PoA email", diff --git a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts index df39949d4..6fe1ecc74 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts @@ -5,21 +5,61 @@ import { Company, CompanyNationality } from "../entities/company.entity"; import { ProfileType } from "../entities/company-profile.entity"; /** - * The three people a company is verified through — its owner, its Power of - * Attorney and its General Manager. The owner is the person the company's - * existence is proven by; the other two are personnel it names. + * The two people a company can be described through. * - * The GM is very often the owner, which is what the portal's "same as owner" - * copy is for: that path reuses the owner's verified identity outright rather - * than asking the same human to verify twice. + * The **owner** is whoever the eTrade TIN record names as the business's + * manager. Not necessarily the legal owner — eTrade's `ManagerNameEng` is + * simply the person on the licence — but that is the point: whoever the company + * puts forward here has to match the eTrade record, and the backoffice check is + * exactly that comparison (see `ownerMatchesEtrade`). + * + * The **Power of Attorney** is who the company delegates to act for it, when it + * delegates at all. + * + * Exactly ONE of them is identity-verified, and which one is decided by the + * company's own answer (see {@link PoaDeclaration}): the representative if + * there is one, otherwise the owner. There is no general manager — the concept + * was removed; it named who to talk to and gated nothing. */ -export const IDENTITY_SUBJECTS = ["owner", "poa", "gm"] as const; +export const IDENTITY_SUBJECTS = ["owner", "poa"] as const; export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number]; +/** + * The company's answer to "does anyone hold power of attorney for you?". + * + * Explicit rather than derived from "are any `poa*` keys set", because "no" is + * an answer that moves the verification onto the owner, while *absent* is a + * question the customer has not reached yet. Stored on `company.attributes` + * under {@link POA_DECLARED_KEY}. + * + * A freight forwarder never gets to answer: it signs on other companies' + * behalf, so a Power of Attorney (and the DARS paper evidencing it) is + * non-negotiable. {@link readPoaDeclaration} forces "yes" for them, which is + * why the declaration is read through that helper rather than off the blob. + */ +export const POA_DECLARATIONS = ["yes", "no"] as const; +export type PoaDeclaration = (typeof POA_DECLARATIONS)[number]; + +/** `company.attributes` key holding the {@link PoaDeclaration}. */ +export const POA_DECLARED_KEY = "poaDeclared"; + +/** + * `company.attributes` keys holding the eTrade record's own manager, captured + * at lookup time. + * + * Kept apart from `ownerName`/`ownerPhone` — which are what the *company* + * asserts, and what a Fayda verification overwrites — precisely so the two can + * be compared. Storing only one value would leave the reviewer comparing the + * owner field against itself. + */ +export const ETRADE_MANAGER_NAME_KEY = "etradeManagerName"; +export const ETRADE_MANAGER_PHONE_KEY = "etradeManagerPhone"; + export class CompleteIdentityVerificationDto { @ApiProperty({ enum: IDENTITY_SUBJECTS, - description: "Which of the company's people this verification is for.", + description: + "Which of the company's people this verification is for. Must match the company's PoA declaration — the representative when one is named, the owner when not.", }) @IsIn(IDENTITY_SUBJECTS) subject!: IdentitySubject; @@ -35,9 +75,11 @@ export class CompleteIdentityVerificationDto { state!: string; } -/** One person's verification state, as reported back to the portal. */ +/** One person's identity state, as reported back to the portal. */ export class IdentityVerificationStateDto { - @ApiProperty() verified!: boolean; + @ApiProperty({ description: "True once a Fayda verification is bound." }) + verified!: boolean; + @ApiProperty({ nullable: true }) name!: string | null; @ApiProperty({ nullable: true }) phone!: string | null; @ApiProperty({ nullable: true }) email!: string | null; @@ -45,13 +87,11 @@ export class IdentityVerificationStateDto { @ApiProperty({ nullable: true }) verifiedAt!: string | null; @ApiProperty({ nullable: true }) birthdate!: string | null; @ApiProperty({ nullable: true }) gender!: string | null; -} -export class OwnerIdentityStateDto extends IdentityVerificationStateDto { @ApiProperty({ nullable: true, description: - "Typed passport number — the foreign-company identity credential. Independent of Fayda: never written by a verification, and still required even if the owner also verifies.", + "Typed passport number. Fayda is an Ethiopian national ID, so a foreign company proves the identity with either — this is the alternative, not an addition.", }) passportNumber!: string | null; } @@ -59,44 +99,55 @@ export class OwnerIdentityStateDto extends IdentityVerificationStateDto { export class CompanyIdentityStateDto { @ApiProperty({ description: - "True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.", + "True for a foreign company: a typed passport number proves the identity just as a Fayda verification does. An Ethiopian company must use Fayda.", }) - faydaRequired!: boolean; + passportAccepted!: boolean; @ApiProperty({ + enum: POA_DECLARATIONS, + nullable: true, description: - "True when the owner's passport number is mandatory — foreign companies only. Independent of faydaRequired: a foreign owner may verify with Fayda too, but the passport is still required.", + 'Whether the company named a Power of Attorney. Null until the customer answers — which is itself an outstanding onboarding item, since the answer decides who verifies.', }) - passportRequired!: boolean; + poaDeclared!: PoaDeclaration | null; - @ApiProperty({ type: OwnerIdentityStateDto }) - owner!: OwnerIdentityStateDto; + @ApiProperty({ + enum: IDENTITY_SUBJECTS, + nullable: true, + description: + "Who the company's single identity verification belongs to: the PoA when one is named, the owner when not. Null while the declaration is unanswered.", + }) + subject!: IdentitySubject | null; + + @ApiProperty({ type: IdentityVerificationStateDto }) + owner!: IdentityVerificationStateDto; @ApiProperty({ type: IdentityVerificationStateDto }) poa!: IdentityVerificationStateDto; @ApiProperty({ description: - "True when the Power of Attorney is the company's owner, declared through the portal's \"same as owner\" copy. Waives the DARS delegation paper — nobody delegates to themselves.", + "True once the subject is proven — Fayda-verified, or carrying a passport number where that is accepted.", }) - poaSameAsOwner!: boolean; + identityProven!: boolean; @ApiProperty({ - type: IdentityVerificationStateDto, + nullable: true, description: - "General manager. `verified` is true both when the GM verified with Fayda in their own right and when the company declared the GM is the owner — in the latter case the owner's Fayda sub backs it.", + "The manager named on the eTrade licence, captured at lookup. Null when eTrade returned none (ManagerNameEng is frequently blank).", }) - gm!: IdentityVerificationStateDto; + etradeManagerName!: string | null; + + @ApiProperty({ + nullable: true, + description: + "Does the owner the company put forward match the person on the eTrade licence? This is the backoffice's check. Null when there is nothing to compare — no eTrade manager on file, or no owner name yet. Advisory, not a gate: eTrade's Latin transliteration and Fayda's rarely agree character-for-character, so a reviewer decides.", + }) + ownerMatchesEtrade!: boolean | null; @ApiProperty({ description: - "True when the GM's identity is the owner's, declared through the portal's \"same as owner\" copy rather than a separate verification.", - }) - gmSameAsOwner!: boolean; - - @ApiProperty({ - description: - "False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.", + "False while the declaration is unanswered or the subject is unproven. Field-level completeness (owner/PoA details, documents) is reported separately by the onboarding requirements.", }) complete!: boolean; } @@ -105,26 +156,9 @@ export class CompanyIdentityStateDto { const PREFIX: Record = { owner: "owner", poa: "poa", - gm: "gm", }; -/** - * Typed GM fields, kept in step with the Fayda-written ones. - * - * The GM predates this verification: its details are plain company columns - * that three notifier services mail (booking-lifecycle, train-scheduling and - * contract notifiers all read `company.generalManagerEmail`). A verification - * therefore writes BOTH — the `gm*` attributes carry the proof, these carry - * the value everything else already reads — and an unverified company keeps - * showing whatever was typed before this existed. - */ -const GM_TYPED_KEYS = { - name: "generalManagerName", - email: "generalManagerEmail", - phone: "generalManagerPhone", -} as const; - -/** company.attributes keys that together mean "a PoA was entered". */ +/** `company.attributes` keys that together mean "a representative was entered". */ const POA_KEYS = [ "poaName", "poaPhone", @@ -139,7 +173,7 @@ function stateFor( ): IdentityVerificationStateDto { const p = PREFIX[subject]; const read = (key: string) => (attrs[key] as string | undefined) ?? null; - const state: IdentityVerificationStateDto = { + return { verified: Boolean(read(`${p}FaydaSub`)), name: read(`${p}Name`), phone: read(`${p}Phone`), @@ -148,88 +182,111 @@ function stateFor( verifiedAt: read(`${p}FaydaVerifiedAt`), birthdate: read(`${p}Birthdate`), gender: read(`${p}Gender`), - }; - if (subject !== "gm" || state.verified) return state; - - // Companies onboarded before the GM was verifiable have typed details and no - // `gm*` attributes at all. Report those rather than a blank card — they are - // still what the notifiers mail — leaving `verified` false so the portal - // offers the upgrade instead of pretending the identity is proven. - // - // Only for such an unverified GM, which is the whole population this exists - // for. Merging the typed columns into a *verified* manager's state would read - // back the email the portal asked them to type when Fayda supplied none, and - // the input offering it — keyed on that value being absent — would vanish the - // moment it was saved, leaving a typo uncorrectable. - return { - ...state, - name: state.name ?? read(GM_TYPED_KEYS.name), - email: state.email ?? read(GM_TYPED_KEYS.email), - phone: state.phone ?? read(GM_TYPED_KEYS.phone), + passportNumber: read(`${p}PassportNumber`), }; } /** - * Derive both people's verification state from the company row. + * The company's PoA declaration, or null when it hasn't answered yet. * - * Pure and shared: `CompaniesService` gates on it and `ProfileResponseDto` - * renders from it, so the settings page and the onboarding wizard can never - * disagree with the rule the API actually enforces. + * A freight forwarder is never asked: it acts on other companies' behalf, so a + * representative and the DARS paper behind them are mandatory. Forcing it here + * — rather than only disabling the radio in the portal — is what stops a + * forwarder role added *after* onboarding from inheriting an old "no". + */ +export function readPoaDeclaration( + company: Pick, +): PoaDeclaration | null { + if ( + (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ) + ) { + return "yes"; + } + const value = company.attributes?.[POA_DECLARED_KEY]; + if (value === "yes" || value === "no") return value; + + // No explicit answer, but the company holds a representative's details — + // so it has one, and owes everything a representative brings with them. + // + // Covers rows that predate the question (the migration derives the same way) + // and any write that reaches the attributes without going through + // `setPoaDeclared`. Without this, PoA details could be saved with the + // delegation paper silently unowed. Safe against a genuine "no": answering + // it clears these keys, so they cannot outlive the answer. + return POA_KEYS.some((k) => (company.attributes?.[k] as string | undefined)?.trim()) + ? "yes" + : null; +} + +/** + * Do two people's names refer to the same person, as far as a string can tell? + * + * Deliberately loose: eTrade returns uppercase Latin transliterations of + * Amharic names and Fayda returns its own, so exact equality would flag almost + * every company. Case, punctuation, extra whitespace and word ORDER are all + * ignored — "ABEBE KEBEDE TESFA" and "Tesfa, Abebe Kebede" match. Anything + * beyond that is the reviewer's call, which is why the verdict is advisory. + */ +export function ownerNameMatchesEtrade( + ownerName: string | null | undefined, + etradeName: string | null | undefined, +): boolean | null { + const words = (v: string | null | undefined) => + (v ?? "") + .toLowerCase() + .replace(/[^a-z0-9ሀ-፿\s]/g, " ") + .split(/\s+/) + .filter(Boolean) + .sort(); + const a = words(ownerName); + const b = words(etradeName); + if (a.length === 0 || b.length === 0) return null; + return a.length === b.length && a.every((w, i) => w === b[i]); +} + +/** + * Derive the company's identity state from its row. + * + * Pure and shared: `CompaniesService` gates on it, `ProfileResponseDto` and the + * backoffice's company DTO render from it, so the settings page, the onboarding + * wizard and the reviewer can never disagree with the rule the API enforces. */ export function buildCompanyIdentityState( company: Company, ): CompanyIdentityStateDto { const attrs = company.attributes ?? {}; - const read = (key: string) => (attrs[key] as string | undefined) ?? null; - // Fayda is an Ethiopian national ID — a foreign company's owner may not hold - // one, so a typed passport number is the mandatory credential there instead. - // The two are mutually exclusive by nationality but independently tracked, - // since a foreign owner verifying with Fayda doesn't waive the passport. - const foreign = company.nationality === CompanyNationality.Foreign; - const faydaRequired = !foreign; - const passportRequired = foreign; + // Fayda is an Ethiopian national ID. A foreign company's people may hold + // none, so a typed passport number stands in — either one proves the person, + // and holding both is fine. + const passportAccepted = company.nationality === CompanyNationality.Foreign; - const owner: OwnerIdentityStateDto = { - ...stateFor(attrs, "owner"), - passportNumber: read("ownerPassportNumber"), - }; + const owner = stateFor(attrs, "owner"); const poa = stateFor(attrs, "poa"); - const poaDue = - (company.companyProfiles ?? []).some( - (p) => p.type === ProfileType.freightForwarder, - ) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim()); + const poaDeclared = readPoaDeclaration(company); + const subject: IdentitySubject | null = + poaDeclared === "yes" ? "poa" : poaDeclared === "no" ? "owner" : null; - const gm = stateFor(attrs, "gm"); - const gmSameAsOwner = Boolean(attrs.gmSameAsOwner); - const poaSameAsOwner = Boolean(attrs.poaSameAsOwner); + const proven = (s: IdentityVerificationStateDto) => + s.verified || (passportAccepted && Boolean(s.passportNumber?.trim())); - const ownerProven = faydaRequired - ? owner.verified - : !passportRequired || Boolean(owner.passportNumber); + const identityProven = + subject === null ? false : proven(subject === "poa" ? poa : owner); - // Fayda is an Ethiopian national ID, so only an Ethiopian company's - // personnel can be held to it. A foreign company may nominate a - // representative who holds one — and is offered the verification — but a - // typed name has to remain sufficient, or a foreign company whose PoA has no - // Fayda ID could never finish onboarding. - const poaProven = faydaRequired - ? poa.verified - : poa.verified || Boolean(poa.name?.trim()); - - // The GM is deliberately absent from this verdict: it names who to talk to, - // not what the company may do, and it has never gated trading. Capturing it - // through Fayda changes how it is collected, not whether it is required. - const complete = ownerProven && (!poaDue || poaProven); + const etradeManagerName = + (attrs[ETRADE_MANAGER_NAME_KEY] as string | undefined) ?? null; return { - faydaRequired, - passportRequired, + passportAccepted, + poaDeclared, + subject, owner, poa, - poaSameAsOwner, - gm, - gmSameAsOwner, - complete, + identityProven, + etradeManagerName, + ownerMatchesEtrade: ownerNameMatchesEtrade(owner.name, etradeManagerName), + complete: subject !== null && identityProven, }; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index 9b69bb15d..b1baa2553 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -8,7 +8,10 @@ * truth the wizard uses to auto-finish. */ -import { CompanyIdentityStateDto } from "./complete-identity-verification.dto"; +import { + CompanyIdentityStateDto, + PoaDeclaration, +} from "./complete-identity-verification.dto"; export interface OnboardingInfoField { key: string; @@ -38,15 +41,19 @@ export interface OnboardingLicenseProfile { } export interface OnboardingPoaState { - /** True when the company operates as a freight forwarder — PoA is mandatory. */ - required: boolean; - /** True once any PoA detail has been entered. */ - provided: boolean; /** - * True when the DARS delegation paper is owed — a PoA exists (or is - * mandatory) and is not the owner themselves. An owner representing their own - * company delegates to nobody, so there is no delegation to evidence. + * True when the company operates as a freight forwarder: it signs on other + * companies' behalf, so a Power of Attorney is non-negotiable and the portal + * renders the question answered and locked rather than asking it. */ + locked: boolean; + /** + * The company's answer to "does anyone hold power of attorney for you?". + * Null until it answers — which is itself outstanding, since the answer + * decides whose identity is verified. + */ + declared: PoaDeclaration | null; + /** True when the DARS delegation paper is owed — i.e. `declared === "yes"`. */ delegationLetterRequired: boolean; /** True when the DARS delegation paper is stored for the company. */ delegationLetterUploaded: boolean; diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index a70c52b5d..dc2ddc4c3 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -42,9 +42,10 @@ export class ProfileResponseDto { contactPersonPhone: string | null; /** Phone that passed SMS OTP verification (drives the verify-step resume). */ contactVerifiedPhone: 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; poaPhone: string | null; @@ -55,12 +56,13 @@ export class ProfileResponseDto { profileId: string; /** - * Fayda verification state for the company's owner and PoA — not the general - * manager, which is a separate typed role. The settings tabs and the - * onboarding wizard render from `identity.faydaRequired` / - * `identity.passportRequired`: an Ethiopian company verifies the owner (and - * PoA) instead of typing their details; a foreign one requires a typed - * passport number instead. + * The company's single identity verification, plus who it belongs to. + * + * `identity.subject` follows the company's PoA declaration — the + * representative when one is named, otherwise the owner. The settings tabs + * and the onboarding wizard render from it: `passportAccepted` says whether a + * typed passport number is an alternative to Fayda (foreign companies only), + * and `ownerMatchesEtrade` is the check the backoffice makes. */ identity: CompanyIdentityStateDto; @@ -113,9 +115,9 @@ export class ProfileResponseDto { this.contactPersonEmail = attrs.contactPersonEmail ?? null; this.contactPersonPhone = attrs.contactPersonPhone ?? null; this.contactVerifiedPhone = attrs.contactVerifiedPhone ?? null; - this.generalManagerName = attrs.generalManagerName ?? null; - this.generalManagerEmail = attrs.generalManagerEmail ?? null; - this.generalManagerPhone = attrs.generalManagerPhone ?? null; + this.ownerName = attrs.ownerName ?? null; + this.ownerEmail = attrs.ownerEmail ?? null; + this.ownerPhone = attrs.ownerPhone ?? null; this.poaName = attrs.poaName ?? null; this.poaPhone = attrs.poaPhone ?? null; this.poaEmail = attrs.poaEmail ?? null; diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index d705e7323..b78925285 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -89,9 +89,14 @@ export class ResponseCompanyDto { houseNo?: string | null; /** - * Owner/PoA Fayda verification state, shared with the portal - * (`buildCompanyIdentityState`) so backoffice never re-derives — or - * disagrees with — the rule the API actually enforces. + * The company's single identity verification, shared with the portal + * (`buildCompanyIdentityState`) so backoffice never re-derives — or disagrees + * with — the rule the API actually enforces. + * + * `subject` names whose verification it is (the PoA when one is declared, + * otherwise the owner), and `ownerMatchesEtrade` is the reviewer's check: + * does the owner the company put forward match the manager on the eTrade + * licence? Advisory — see the note on that field. */ identity: CompanyIdentityStateDto; diff --git a/apps/edr-freight-api/src/modules/companies/dto/set-poa-declared.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/set-poa-declared.dto.ts new file mode 100644 index 000000000..37c54b254 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/set-poa-declared.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsIn } from "class-validator"; + +import { + POA_DECLARATIONS, + PoaDeclaration, +} from "./complete-identity-verification.dto"; + +export class SetPoaDeclaredDto { + @ApiProperty({ + enum: POA_DECLARATIONS, + description: + 'Whether anyone holds power of attorney for this company. "no" tears down any representative already recorded.', + }) + @IsIn(POA_DECLARATIONS) + declared!: PoaDeclaration; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index c89326fa1..b7b814da1 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -45,11 +45,11 @@ export class UpdateProfileDto { @Matches(/^\d{10,11}$/, { message: "VAT number must be 10 or 11 digits" }) vatNumber?: string; - // `fanNumber` is deliberately absent: the FAN is the Fayda number of the - // company's PoA (or its general manager), so it is derived from a completed - // Fayda verification rather than typed. The global validation pipe runs with - // forbidNonWhitelisted, so a client that still sends it gets a 400 telling it - // so — see CompaniesService.completeIdentityVerification. + // `fanNumber` is deliberately absent: the FAN is a Fayda number, so it would + // have to come from a completed verification rather than be typed — and + // Fayda's userinfo carries no national ID number, so nothing produces one. + // The global validation pipe runs with forbidNonWhitelisted, so a client that + // still sends it gets a 400 telling it so. @IsOptional() @IsString() @@ -78,18 +78,31 @@ export class UpdateProfileDto { @IsValidPhone() contactVerifiedPhone?: string; + /** + * The owner — whoever the eTrade licence names as the business's manager. + * + * All three are required before onboarding can be submitted, whatever their + * source: the eTrade lookup prefills the name and phone, a Fayda + * verification can supply all three, and the portal renders an input for + * whatever neither did (eTrade returns no email at all, and Fayda's email + * claim is optional, so that one is usually typed). + * + * Locked once a Fayda verification supplied them — see + * `IDENTITY_OWNED_FIELDS` — but only field by field: a claim that came back + * empty owns nothing and stays typeable. + */ @IsOptional() @IsString() - generalManagerName?: string; + ownerName?: string; @IsOptional() @IsEmail() - generalManagerEmail?: string; + ownerEmail?: string; @IsOptional() @IsString() @IsValidPhone() - generalManagerPhone?: string; + ownerPhone?: string; @IsOptional() @IsString() @@ -113,15 +126,22 @@ export class UpdateProfileDto { poaAddress?: string; /** - * The owner's passport number — the identity credential for a foreign - * company, since Fayda is an Ethiopian national ID. Plain typed field, never - * written or locked by a Fayda verification: still required even if the - * owner also verifies. + * Passport numbers — the alternative identity credential for a foreign + * company, since Fayda is an Ethiopian national ID. Plain typed fields, never + * written or locked by a Fayda verification. + * + * Only the one belonging to the company's declared identity subject matters: + * the PoA's when a representative is named, the owner's otherwise. An + * Ethiopian company is not offered either — it must use Fayda. */ @IsOptional() @IsString() ownerPassportNumber?: string; + @IsOptional() + @IsString() + poaPassportNumber?: string; + @IsOptional() @IsString() @MaxLength(100) diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index f254e0121..2ecfa3deb 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -112,29 +112,11 @@ export class Company extends BaseEntity { }) contactPersonPhone?: string | null; - @Column({ - name: "general_manager_name", - type: "varchar", - length: 100, - nullable: true, - }) - generalManagerName?: string | null; - - @Column({ - name: "general_manager_email", - type: "varchar", - length: 150, - nullable: true, - }) - generalManagerEmail?: string | null; - - @Column({ - name: "general_manager_phone", - type: "varchar", - length: 20, - nullable: true, - }) - generalManagerPhone?: string | null; + // The general manager used to live here as three columns. It named who to + // talk to, gated nothing, and nothing ever populated the columns — the write + // path put the values in `attributes`. Removed in RemoveGeneralManager; the + // company's people are now its owner (whoever the eTrade licence names) and + // its Power of Attorney, both in `attributes`. @Column({ name: "website", type: "varchar", length: 200, nullable: true }) website?: string | null; diff --git a/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts b/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts index 886aa8b85..104456384 100644 --- a/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts +++ b/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts @@ -12,8 +12,8 @@ import { DataSource, EntityManager } from "typeorm"; * `companies.contact_person_phone` is deliberately NOT consulted: the live write * path stores that value in the `attributes` jsonb and has never populated the * column, so every reader of it was silently falling through to `phone` anyway. - * `companies.general_manager_email` is the same trap on the email side — see - * {@link companyNotifyEmailExpr}. + * The retired `general_manager_email` column was the same trap on the email + * side — see {@link companyNotifyEmailExpr}. */ /** @@ -50,24 +50,30 @@ export function companyNotifyPhoneExpr(alias: string): string { * SQL expression for the company's notification address, given the joined `pc` * alias. * - * `companies.email` alone is not enough: it is written from ONE place — a - * Fayda-verified owner's email claim — so a foreign company, whose owner proves - * identity by passport instead, never gets one. Readers papered over that with - * `COALESCE(email, general_manager_email)`, but that column has the same problem - * `contact_person_phone` has above: onboarding writes the value into the - * `attributes` jsonb and nothing has ever populated the column, so the fallback - * could not fire and the mail was dropped in silence. + * `companies.email` is now the owner's email, written on every profile save + * whether or not the owner verified with Fayda — and the owner's email is a + * required onboarding field, so a company that finished onboarding has one. + * (It used to be written ONLY for a Fayda-verified owner, which meant every + * foreign company had none; the gap was papered over with a + * `general_manager_email` leg that could never fire, because onboarding wrote + * that value into `attributes` and nothing ever populated the column.) * - * So: the company address, then the two the customer actually filled in during - * onboarding, then the account that registered them — which always has one, - * signup requires it. `NULLIF` because a blank jsonb key is not an address and - * `COALESCE` would happily stop on it. + * The `generalManagerEmail` attribute is still consulted, after the contact + * person: the general manager was removed, but companies onboarded before that + * may carry an address there and nowhere else. RemoveGeneralManager backfills + * `companies.email` from it, so this is belt-and-braces for rows that migration + * could not resolve. + * + * `NULLIF` because a blank jsonb key is not an address and `COALESCE` would + * happily stop on it. The account that registered the company is the last + * resort — signup guarantees it has one. */ export function companyNotifyEmailExpr(alias: string): string { return `COALESCE( NULLIF(${alias}.email, ''), - NULLIF(${alias}.attributes->>'generalManagerEmail', ''), + NULLIF(${alias}.attributes->>'ownerEmail', ''), NULLIF(${alias}.attributes->>'contactPersonEmail', ''), + NULLIF(${alias}.attributes->>'generalManagerEmail', ''), NULLIF(pc.email, '') )`; } 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/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 } }, ); From f611c337223ad36a2f491017a85933f90e65bc66 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 11 Aug 2026 11:53:36 +0000 Subject: [PATCH 05/15] feat(companies): migrate existing companies off the general manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data rescue, not just schema tidying — the order matters: 1. Backfill companies.email from generalManagerEmail before the key is stripped, or companies whose only address lived there stop receiving mail. 2. Backfill ownerName/Email/Phone (now required) from owner, GM, contact person, then the columns; seed etradeManagerName so existing rows read as matching rather than as a false mismatch. 3. Convert poaSameAsOwner: non-forwarders become poaDeclared='no' with their poa* details cleared; forwarders become 'yes' and keep them, so they now owe a DARS letter they were previously waived. 4. Derive poaDeclared for everyone else. 5. Move drafts off the deleted 'personnel' wizard step. 6. Drop the general_manager_* columns and strip gm*/poaSameAsOwner. down() restores the column shape only; the stripped values are gone by design. --- .../3390000000000-RemoveGeneralManager.ts | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/3390000000000-RemoveGeneralManager.ts 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' + `); + } +} From 8cf041f494843dc69f46df94c7e610d0990b1869 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 11 Aug 2026 11:53:42 +0000 Subject: [PATCH 06/15] test(companies): rewrite identity specs around the single subject fayda-identity covers the two subjects, the passport alternative and the eTrade owner comparison in place of ~40 general-manager assertions. poa-delegation drops the poaSameAsOwner waiver case and asserts the opposite: a forwarder is refused without a delegation letter however it represents itself. --- .../companies.fayda-identity.spec.ts | 1063 ++++++----------- .../companies.poa-delegation.spec.ts | 21 +- 2 files changed, 379 insertions(+), 705 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index fe40cf08f..30c323ac3 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -177,782 +177,451 @@ function makeService(overrides: Partial = {}) { return { service, ctx, deps, company }; } +describe("one company, one verified identity", () => { + it("refuses a verification before the company says who represents it", async () => { + const { service } = makeService(); + await expect( + service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); -describe("Fayda identity verification binds a person to the company", () => { - it("writes the verified identity", async () => { - const { service, ctx } = makeService(); - + it("writes the verified identity onto the declared subject", async () => { + const { service, ctx } = makeService({ attributes: { poaDeclared: "no" } }); const state = await service.completeIdentityVerification("user-1", { subject: "owner", code: "c", state: "s", }); + expect(state.owner.verified).toBe(true); + expect(state.owner.name).toBe("Haile Gebrselassie"); + expect(state.owner.email).toBe("haile@example.com"); + expect(state.owner.address).toBe("Addis Ababa"); expect(ctx.attributes.ownerFaydaSub).toBe("new-sub"); - expect(ctx.attributes.ownerName).toBe("Haile Gebrselassie"); - expect(state.owner.verified).toBe(true); + expect(state.identityProven).toBe(true); + expect(state.complete).toBe(true); }); - it("fills every PoA detail from the payload, address included", async () => { - const { service, ctx } = makeService(); - - await service.completeIdentityVerification("user-1", { - subject: "poa", - code: "c", - state: "s", - }); - - expect(ctx.attributes.poaName).toBe("Haile Gebrselassie"); - expect(ctx.attributes.poaEmail).toBe("haile@example.com"); - expect(ctx.attributes.poaPhone).toBe("+251922000000"); - expect(ctx.attributes.poaAddress).toBe("Addis Ababa"); + it("refuses a verification for the person the declaration does not point at", async () => { + // A PoA-declared company gates on the PoA. An owner verification here would + // sit on the record looking proven while the gate stayed unsatisfied. + const { service } = makeService({ attributes: { poaDeclared: "yes" } }); + await expect( + service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }), + ).rejects.toBeInstanceOf(BadRequestException); }); - it("verifies successfully even though Fayda returns no national ID number", async () => { - // Fayda's userinfo carries no FAN/FIN claim at all — this must be the - // normal, successful path, not an error. - const { service } = makeService({ - verification: { - purpose: "VERIFY", - verified: true, - sub: "x", - fullName: "No Fan Here", - }, - }); - - const state = await service.completeIdentityVerification("user-1", { - subject: "owner", - code: "c", - state: "s", - }); - - expect(state.owner.verified).toBe(true); - }); - - it("lets one identity be both owner and PoA", async () => { - // An owner who represents their own company is the ordinary small-business - // case, not a conflict — the same answer the GM has always been allowed. - const { service, ctx } = makeService({ - attributes: { ownerFaydaSub: "same-person" }, - verification: { - purpose: "VERIFY", - verified: true, - sub: "same-person", - fullName: "Abebe Bikila", - }, - }); - + it("verifies the representative when one is declared", async () => { + const { service, ctx } = makeService({ attributes: { poaDeclared: "yes" } }); const state = await service.completeIdentityVerification("user-1", { subject: "poa", code: "c", state: "s", }); - + expect(state.subject).toBe("poa"); expect(state.poa.verified).toBe(true); - expect(ctx.attributes.poaFaydaSub).toBe("same-person"); - }); - - it("stages an owner re-verification for review on an approved company", async () => { - // The owner is the live company's identity proof, so re-verifying one is - // exactly what the backoffice review exists for: it must not rewrite the - // row directly. - const { service, ctx, deps } = makeService({ - status: CompanyStatus.Active, - }); - - await service.completeIdentityVerification("user-1", { - subject: "owner", - code: "c", - state: "s", - }); - - expect(deps.changeRequestRepo.create).toHaveBeenCalled(); - expect(ctx.attributes.ownerFaydaSub).toBeUndefined(); - }); - - it("applies a PoA verification live on an approved company", async () => { - // The PoA is personnel the company names for itself — the delegation paper - // is what a reviewer actually judges — so it does not go to review. - const { service, ctx, deps } = makeService({ - status: CompanyStatus.Active, - }); - - await service.completeIdentityVerification("user-1", { - subject: "poa", - code: "c", - state: "s", - }); - - expect(deps.changeRequestRepo.create).not.toHaveBeenCalled(); + expect(state.poa.address).toBe("Addis Ababa"); + expect(state.identityProven).toBe(true); expect(ctx.attributes.poaFaydaSub).toBe("new-sub"); }); - it("stages nothing for a verified field an approved company resubmits", async () => { - // Approving it could not move the live row — the verified value is written - // back over it — so it must never reach a reviewer as a pending change. - const { service, deps } = makeService({ - status: CompanyStatus.Active, - attributes: { ...OWNER_VERIFIED, ownerEmail: "abebe@example.com" }, - }); - - await expect( - service.updateProfile("user-1", { - companyEmail: "someone-else@example.com", - } as never), - ).resolves.toBeDefined(); - expect(deps.changeRequestRepo.create).not.toHaveBeenCalled(); - expect(deps.changeRequestRepo.update).not.toHaveBeenCalled(); - expect(deps.companiesRepo.update).not.toHaveBeenCalled(); - }); - - // The verified value wins, and it wins by overwriting rather than by - // rejecting: nobody types these fields, so a submission that disagrees is a - // stale form echoing itself back, not an edit. Failing it would block a save - // the customer never made — and leave them no way through, since re-verifying - // returns the same value they are being 400'd for. - it("overwrites a hand-renamed verified person with the verified name", async () => { + it("verifies successfully even though Fayda returns no national ID number", async () => { const { service, ctx } = makeService({ - attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, - files: [paper()], - }); - - await expect( - service.updateProfile("user-1", { poaName: "Someone Else" } as never), - ).resolves.toBeDefined(); - expect(ctx.attributes.poaName).toBe(POA_VERIFIED.poaName); - }); - - // Fayda's email and phone claims are optional — a verification can prove the - // person and return neither. Holding the company mirrors to "the owner is - // verified" rather than to "the verification supplied this value" would - // clobber the fallbacks the portal is built to send (account email, eTrade's - // registered phone) with nothing at all. OWNER_VERIFIED is exactly that - // shape: a sub, no contact details. - it("keeps company contact details a Fayda verification never supplied", async () => { - const { deps } = makeService({ - attributes: { ...OWNER_VERIFIED }, - }); - - const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!; - expect(patch.email).toBe("account@example.com"); - expect(patch.phone).toBe("+251911777777"); - }); - - // "Same as owner" copies `ownerEmail ?? null` onto the GM while setting - // `gmFaydaSub`. Locking that null made generalManagerEmail required by - // onboarding, hidden by the portal's link card and unwritable at once. - it("lets the GM's details be typed when the copied owner identity carried none", async () => { - const { service } = makeService({ - attributes: { - ...OWNER_VERIFIED, - gmSameAsOwner: true, - gmFaydaSub: "owner-sub", - generalManagerName: "Abebe Bikila", - generalManagerEmail: null, - generalManagerPhone: null, + attributes: { poaDeclared: "no" }, + verification: { + purpose: "VERIFY", + verified: true, + sub: "new-sub", + fullName: "Haile Gebrselassie", }, }); - - await expect( - service.updateProfile("user-1", { - generalManagerEmail: "gm@example.com", - generalManagerPhone: "+251911888888", - } as never), - ).resolves.toBeDefined(); - }); - - // Fayda's email and phone claims are optional and routinely absent. The owner - // is who the company is reached through and the step renders no input for - // their contact details, so the onboarding account — already OTP-proven — - // stands in rather than leaving the company unreachable. - describe("account contact details stand in for absent Fayda claims", () => { - const noContactClaims = { - purpose: "VERIFY", - verified: true, - sub: "new-sub", - fullName: "Haile Gebrselassie", - address: "Addis Ababa", - }; - const account = { - email: "account@example.com", - phoneNumber: "+251911777777", - }; - - it("falls back to the account for an owner Fayda gave no email or phone", async () => { - const { service, ctx } = makeService({ verification: noContactClaims }); - - await service.completeIdentityVerification( - "user-1", - { subject: "owner", code: "c", state: "s" }, - account, - ); - - expect(ctx.attributes.ownerEmail).toBe("account@example.com"); - expect(ctx.attributes.ownerPhone).toBe("+251911777777"); - }); - - it("prefers the Fayda claim over the account when there is one", async () => { - const { service, ctx } = makeService(); - - await service.completeIdentityVerification( - "user-1", - { subject: "owner", code: "c", state: "s" }, - account, - ); - - expect(ctx.attributes.ownerEmail).toBe("haile@example.com"); - expect(ctx.attributes.ownerPhone).toBe("+251922000000"); - }); - - it("leaves the PoA alone — the account is not that person", async () => { - const { service, ctx } = makeService({ verification: noContactClaims }); - - await service.completeIdentityVerification( - "user-1", - { subject: "poa", code: "c", state: "s" }, - account, - ); - - expect(ctx.attributes.poaEmail).toBeUndefined(); - expect(ctx.attributes.poaPhone).toBeUndefined(); - }); - - // Owners verified before the fallback existed hold blank contacts. Copying - // those blanks onto the GM makes generalManagerEmail required by onboarding - // with no field anywhere to satisfy it. - it("fills the GM copy from the account when the stored owner has no contacts", async () => { - const { service, ctx } = makeService({ - attributes: { ...OWNER_VERIFIED }, - }); - - await service.setGmSameAsOwner("user-1", account); - - expect(ctx.attributes.gmEmail).toBe("account@example.com"); - expect(ctx.attributes.generalManagerEmail).toBe("account@example.com"); - expect(ctx.attributes.generalManagerPhone).toBe("+251911777777"); - }); - - it("keeps the stored owner contacts when the GM copy has them", async () => { - const { service, ctx } = makeService({ - attributes: { - ...OWNER_VERIFIED, - ownerEmail: "abebe@example.com", - ownerPhone: "+251911000111", - }, - }); - - await service.setGmSameAsOwner("user-1", account); - - expect(ctx.attributes.generalManagerEmail).toBe("abebe@example.com"); - expect(ctx.attributes.generalManagerPhone).toBe("+251911000111"); - }); - }); - - it("never locks or gates the general manager — it is not the verified subject", async () => { - // GM is a plain typed role; the portal offers a "same as owner" copy, but - // the backend must not treat it as identity-owned or require it verified. - const { service } = makeService({ - attributes: { ...OWNER_VERIFIED }, - }); - - await expect( - service.updateProfile("user-1", { - generalManagerName: "Someone Else", - generalManagerEmail: "someone@example.com", - generalManagerPhone: "+251911223344", - } as never), - ).resolves.toBeDefined(); - }); -}); - -describe("Ethiopian companies verify with Fayda; foreign companies verify identity by passport", () => { - // The company is applying for the forwarder role, so it must not already - // hold it — createCompanyProfileForUser short-circuits on an existing profile - // and would never reach the gate. - const applyingForFf = { - profileTypes: [ProfileType.importer], - attributes: { ...POA_VERIFIED }, - files: [paper()], - }; - - it("blocks the forwarder role while the owner is unverified", async () => { - const { service } = makeService(applyingForFf); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it("blocks the forwarder role while the PoA is unverified", async () => { - const { service } = makeService({ - profileTypes: [ProfileType.importer], - attributes: { - ...OWNER_VERIFIED, - poaName: "Tirunesh Dibaba", - poaEmail: "t@example.com", - poaPhone: "+251911000000", - }, - files: [paper()], - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it("grants the forwarder role once owner and PoA are both verified", async () => { - const { service } = makeService({ - profileTypes: [ProfileType.importer], - attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, - files: [paper()], - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).resolves.toBeDefined(); - }); - - it("never asks a foreign company for Fayda, verified or not", async () => { - const { service } = makeService({ - nationality: CompanyNationality.Foreign, - }); - const state = await service.completeIdentityVerification("user-1", { subject: "owner", code: "c", state: "s", }); - - // Still lets the owner verify — a foreign owner verifying is allowed, just - // never required — but the passport is the thing that actually gates it. expect(state.owner.verified).toBe(true); - expect(state.faydaRequired).toBe(false); - expect(state.passportRequired).toBe(true); + expect(ctx.attributes.fanNumber).toBeUndefined(); }); - it("blocks the forwarder role for a foreign company with no owner passport", async () => { - const { service } = makeService({ - profileTypes: [ProfileType.importer], - nationality: CompanyNationality.Foreign, - attributes: { - poaName: "Jean Dupont", - poaEmail: "jean@example.com", - poaPhone: "+33100000000", - }, - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it("grants the forwarder role to a foreign company whose owner has a passport and whose PoA is Fayda-verified", async () => { - const { service } = makeService({ - profileTypes: [ProfileType.importer], - nationality: CompanyNationality.Foreign, - attributes: { - ownerPassportNumber: "P1234567", - ...POA_VERIFIED, - }, - files: [paper()], - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).resolves.toBeDefined(); - }); - - it("accepts a typed PoA from a foreign company, whose representative may hold no Fayda ID", async () => { - // Fayda is an Ethiopian national ID, so only an Ethiopian company's - // representative can be held to it. A foreign company is offered the - // verification and uses it where its representative holds one, but a typed - // name stays sufficient — holding it to Fayda would leave a foreign - // company whose representative has no Fayda ID unable to trade at all. - const { service } = makeService({ - profileTypes: [ProfileType.importer], - nationality: CompanyNationality.Foreign, - attributes: { - ownerPassportNumber: "P1234567", - poaName: "Jean Dupont", - poaEmail: "jean@example.com", - poaPhone: "+33100000000", - }, - files: [paper()], - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).resolves.toBeDefined(); - }); - - it("still refuses a foreign company that named no PoA at all", async () => { - // The typed fallback is a different credential, not a waiver: a freight - // forwarder acts on other companies' behalf and needs a representative - // whatever its nationality. - const { service } = makeService({ - profileTypes: [ProfileType.importer], - nationality: CompanyNationality.Foreign, - attributes: { ownerPassportNumber: "P1234567" }, - files: [paper()], - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it("holds an Ethiopian company to a Fayda-verified PoA, typed details notwithstanding", async () => { - // The relaxation above is scoped to foreign companies only — an Ethiopian - // representative holds a Fayda ID, so typing a name must not substitute. - const { service } = makeService({ - profileTypes: [ProfileType.importer], - nationality: CompanyNationality.Ethiopian, - attributes: { - ...OWNER_VERIFIED, - poaName: "Abebe Bekele", - poaEmail: "abebe@example.com", - poaPhone: "+251911000000", - }, - files: [paper()], - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => { - // Verifying is optional for a foreign owner, but it does not waive the - // passport requirement — the two are independent credentials. - const { service } = makeService({ - profileTypes: [ProfileType.importer], - nationality: CompanyNationality.Foreign, - attributes: { - ...OWNER_VERIFIED, - poaName: "Jean Dupont", - poaEmail: "jean@example.com", - poaPhone: "+33100000000", - }, - }); - - await expect( - service.createCompanyProfileForUser( - "user-1", - ProfileType.freightForwarder, - ), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - // ------------------------------------------------------------------------- - // General manager - // ------------------------------------------------------------------------- - - it("reuses the owner's verified identity when the GM is declared the same person", async () => { - // The GM is very often the owner. Copying the proven identity is the whole - // point — asking one human to complete two verifications proves nothing - // extra, and typing the details instead would forge a verified badge. + it("never stands the signed-in account in for an absent Fayda claim", async () => { + // The person onboarding is not necessarily the person on the licence. + // Stamping their address onto the owner made a required field a guess. const { service, ctx } = makeService({ - attributes: { - ...OWNER_VERIFIED, - ownerEmail: "abebe@example.com", - ownerPhone: "+251911222333", - }, - }); - - const state = await service.setGmSameAsOwner("user-1"); - - expect(state.gm.verified).toBe(true); - expect(state.gmSameAsOwner).toBe(true); - expect(state.gm.name).toBe("Abebe Bikila"); - expect(ctx.attributes.gmFaydaSub).toBe("owner-sub"); - // The notifiers mail the flat column, so a linked GM has to land there too. - expect(ctx.attributes.generalManagerEmail).toBe("abebe@example.com"); - }); - - it("refuses to declare the GM is the owner while the owner is unverified", async () => { - // Without a verification there is no proven identity to copy — only typed - // text, which would arrive wearing a badge it had not earned. - const { service } = makeService({ attributes: {} }); - - await expect(service.setGmSameAsOwner("user-1")).rejects.toBeInstanceOf( - BadRequestException, - ); - }); - - it("lets the GM verify as the same human as the owner", async () => { - // One human in every role is the ordinary small-business shape, so - // verifying with the owner's own Fayda sub has to succeed. - const { service, ctx } = makeService({ - attributes: { ...OWNER_VERIFIED }, + attributes: { poaDeclared: "no" }, verification: { purpose: "VERIFY", verified: true, - sub: "owner-sub", - fullName: "Abebe Bikila", - email: "abebe@example.com", - phoneNumber: "+251911222333", + sub: "new-sub", + fullName: "Haile Gebrselassie", }, }); - const state = await service.completeIdentityVerification("user-1", { - subject: "gm", + subject: "owner", code: "c", state: "s", }); - - expect(state.gm.verified).toBe(true); - expect(ctx.attributes.gmFaydaSub).toBe("owner-sub"); - expect(ctx.attributes.generalManagerName).toBe("Abebe Bikila"); + expect(state.owner.email).toBeNull(); + expect(state.owner.phone).toBeNull(); + expect(ctx.attributes.ownerEmail).toBeUndefined(); }); - it("declares the PoA is the owner, copying the verified identity across", async () => { - const { service, ctx } = makeService({ attributes: { ...OWNER_VERIFIED } }); - - const state = await service.setPoaSameAsOwner("user-1"); - - expect(state.poaSameAsOwner).toBe(true); - expect(state.poa.verified).toBe(true); - expect(ctx.attributes.poaFaydaSub).toBe(OWNER_VERIFIED.ownerFaydaSub); - expect(ctx.attributes.poaName).toBe(OWNER_VERIFIED.ownerName); + it("stages a verification for review on an approved company", async () => { + const { service, deps, ctx } = makeService({ + status: CompanyStatus.Active, + attributes: { poaDeclared: "no", ...OWNER_VERIFIED }, + }); + await service.completeIdentityVerification("user-1", { + subject: "owner", + code: "c", + state: "s", + }); + expect(deps.changeRequestRepo.create).toHaveBeenCalled(); + // The live row is untouched until a reviewer approves. + expect(ctx.attributes.ownerFaydaSub).toBe("owner-sub"); }); - it("refuses to declare the PoA is the owner while an Ethiopian owner is unverified", async () => { - // Its representative must be Fayda-verified, so a declaration here would - // record one that could never satisfy the gate. - const { service } = makeService({ attributes: {} }); + it("overwrites a hand-renamed verified person with the verified name", async () => { + const { service } = makeService({ + attributes: { poaDeclared: "no", ...OWNER_VERIFIED }, + }); + const profile = await service.updateProfile("user-1", { + ownerName: "Someone Else", + } as never); + expect(profile.ownerName).toBe("Abebe Bikila"); + }); - await expect(service.setPoaSameAsOwner("user-1")).rejects.toBeInstanceOf( + it("keeps a field the verification never supplied typeable", async () => { + // Fayda's email claim is optional and `REQUIRED_COMPANY_INFO` demands one, + // so locking against an absent value would make it unfillable forever. + const { service, ctx } = makeService({ + attributes: { poaDeclared: "no", ...OWNER_VERIFIED }, + }); + await service.updateProfile("user-1", { + ownerEmail: "typed@example.com", + } as never); + expect(ctx.attributes.ownerEmail).toBe("typed@example.com"); + }); + + it("writes the owner's email onto the company, verified or not", async () => { + // `companies.email` is what the notification resolver reads first. Gating + // this on a Fayda verification left every foreign company without one. + const { service, deps } = makeService({ attributes: { poaDeclared: "no" } }); + await service.updateProfile("user-1", { + ownerEmail: "owner@example.com", + ownerPhone: "+251911223344", + } as never); + const patch = deps.companiesRepo.update.mock.calls.at(-1)?.[1] as Record< + string, + unknown + >; + expect(patch.email).toBe("owner@example.com"); + expect(patch.phone).toBe("+251911223344"); + }); +}); + +describe("the declaration decides who verifies", () => { + it("points at the owner when the company says it has no representative", async () => { + const { service, company } = makeService({ + attributes: { poaDeclared: "no" }, + }); + expect(service.getCompanyIdentityState(company() as never).subject).toBe( + "owner", + ); + }); + + it("points at the representative when it says it has one", async () => { + const { service, company } = makeService({ + attributes: { poaDeclared: "yes" }, + }); + expect(service.getCompanyIdentityState(company() as never).subject).toBe( + "poa", + ); + }); + + it("is null until the company answers, and nothing is proven yet", async () => { + const { service, company } = makeService({ attributes: OWNER_VERIFIED }); + const state = service.getCompanyIdentityState(company() as never); + expect(state.poaDeclared).toBeNull(); + expect(state.subject).toBeNull(); + expect(state.identityProven).toBe(false); + expect(state.complete).toBe(false); + }); + + it('forces "yes" for a freight forwarder whatever is stored', async () => { + // A forwarder signs on other companies' behalf, so a representative is + // non-negotiable — including one that answered "no" before taking the role. + const { service, company } = makeService({ + attributes: { poaDeclared: "no" }, + profileTypes: [ProfileType.freightForwarder], + }); + const state = service.getCompanyIdentityState(company() as never); + expect(state.poaDeclared).toBe("yes"); + expect(state.subject).toBe("poa"); + }); + + it('refuses to set "no" for a freight forwarder', async () => { + const { service } = makeService({ + profileTypes: [ProfileType.freightForwarder], + }); + await expect(service.setPoaDeclared("user-1", "no")).rejects.toBeInstanceOf( BadRequestException, ); }); - it("undoes the PoA \"same as owner\" declaration without touching a real verification", async () => { - const { service, ctx } = makeService({ - attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, + it('tears the representative down when answered "no"', async () => { + const { service, ctx, deps } = makeService({ + attributes: { poaDeclared: "yes", ...POA_VERIFIED }, + files: [paper()], }); - - // No declaration in place: the verified representative must survive. - await service.clearPoaSameAsOwner("user-1"); - expect(ctx.attributes.poaFaydaSub).toBe(POA_VERIFIED.poaFaydaSub); - - await service.setPoaSameAsOwner("user-1"); - const state = await service.clearPoaSameAsOwner("user-1"); - - expect(state.poaSameAsOwner).toBe(false); + const state = await service.setPoaDeclared("user-1", "no"); + expect(state.poaDeclared).toBe("no"); expect(state.poa.verified).toBe(false); + expect(ctx.attributes.poaName).toBeNull(); expect(ctx.attributes.poaFaydaSub).toBeNull(); - }); - - it("reports a pre-existing typed GM as unverified rather than blank", async () => { - // Companies onboarded before the GM was verifiable have typed details and - // no gm* attributes. Those details are still what the notifiers mail, so - // they must survive — flagged unverified so the portal offers the upgrade. - const { service, company } = makeService({ - attributes: { - ...OWNER_VERIFIED, - generalManagerName: "Legacy Manager", - generalManagerEmail: "legacy@example.com", - }, - }); - - const state = service.getCompanyIdentityState(company() as never); - - expect(state.gm.verified).toBe(false); - expect(state.gm.name).toBe("Legacy Manager"); - expect(state.gm.email).toBe("legacy@example.com"); - }); - - // The mirror image, and the reason the fallback above is gated on the GM - // being unverified: a manager Fayda proved but supplied no email for types - // one instead, and the portal decides whether to render that input by asking - // whether the identity holds one. Reading the typed column back as part of - // the verified identity would answer "yes" the moment it was saved — the - // input would vanish and a typo could never be corrected. - it("keeps a verified GM's typed email out of the verified identity", async () => { - const { service, company } = makeService({ - attributes: { - ...OWNER_VERIFIED, - gmFaydaSub: "gm-sub", - gmFaydaVerifiedAt: "2026-07-03T00:00:00.000Z", - gmName: "Derartu Tulu", - generalManagerName: "Derartu Tulu", - generalManagerEmail: "typed@example.com", - }, - }); - - const state = service.getCompanyIdentityState(company() as never); - - expect(state.gm.verified).toBe(true); - expect(state.gm.name).toBe("Derartu Tulu"); - expect(state.gm.email).toBeNull(); - }); - - it("never stands the account in for a GM Fayda gave no email", async () => { - // Deliberate: the account is the person onboarding, not necessarily the - // manager. The portal asks for the email instead. - const { service, ctx } = makeService({ - attributes: { ...OWNER_VERIFIED }, - verification: { - purpose: "VERIFY", - verified: true, - sub: "gm-sub", - fullName: "Derartu Tulu", - phoneNumber: "+251911222333", - }, - }); - - const state = await service.completeIdentityVerification( - "user-1", - { subject: "gm", code: "c", state: "s" }, - { email: "account@example.com", phoneNumber: "+251911777777" }, - ); - - expect(ctx.attributes.gmEmail).toBeUndefined(); - expect(state.gm.verified).toBe(true); - expect(state.gm.email).toBeNull(); - }); - - it("accepts the email typed for a GM whose verification carried none", async () => { - const { service, ctx } = makeService({ - attributes: { - ...OWNER_VERIFIED, - gmFaydaSub: "gm-sub", - gmName: "Derartu Tulu", - generalManagerName: "Derartu Tulu", - }, - }); - - await service.updateProfile("user-1", { - generalManagerEmail: "gm@example.com", - } as never); - - expect(ctx.attributes.generalManagerEmail).toBe("gm@example.com"); - }); - - it("does not let an unproven GM block the company from trading", async () => { - // The GM names who to talk to, not what the company may do. Capturing it - // through Fayda changed how it is collected, not whether it gates. - const { service } = makeService({ - attributes: { ...OWNER_VERIFIED }, - }); - - await expect( - service.createCompanyProfileForUser("user-1", ProfileType.importer), - ).resolves.toBeDefined(); + // The paper evidenced a delegation that no longer exists. + expect(deps.filesService.remove).toHaveBeenCalledWith("file-1"); }); }); -/** - * Fayda's email and phone claims are optional, so a *verified* representative - * can still be missing the details `REQUIRED_POA_FIELDS` demands. The PoA step - * renders an input for whatever the verification did not supply — so onboarding - * has to report them outstanding, rather than letting a freight forwarder - * submit an incomplete representative and be refused its next PoA edit for it. - */ -describe("onboarding requirements name the PoA details Fayda did not supply", () => { - const POA_VERIFIED_NO_CONTACTS = { - poaFaydaSub: "poa-sub", - poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z", - poaName: "Tirunesh Dibaba", - }; +describe("foreign companies prove the same person by Fayda OR passport", () => { + const foreign = (attributes: Record) => + makeService({ nationality: CompanyNationality.Foreign, attributes }); - it("reports the missing email and phone for a freight forwarder", async () => { + it("accepts a passport number in place of Fayda", async () => { + const { service, company } = foreign({ + poaDeclared: "no", + ownerPassportNumber: "P1234567", + }); + const state = service.getCompanyIdentityState(company() as never); + expect(state.passportAccepted).toBe(true); + expect(state.identityProven).toBe(true); + }); + + it("accepts a Fayda verification instead — the passport is not additional", async () => { + const { service, company } = foreign({ poaDeclared: "no", ...OWNER_VERIFIED }); + expect( + service.getCompanyIdentityState(company() as never).identityProven, + ).toBe(true); + }); + + it("collects the passport of whichever person carries the identity", async () => { + const { service, company } = foreign({ + poaDeclared: "yes", + poaPassportNumber: "P7654321", + }); + const state = service.getCompanyIdentityState(company() as never); + expect(state.subject).toBe("poa"); + expect(state.identityProven).toBe(true); + }); + + it("is not satisfied by the OTHER person's passport", async () => { + // A PoA-represented company gates on the PoA; the owner's passport proves + // nobody relevant. + const { service, company } = foreign({ + poaDeclared: "yes", + ownerPassportNumber: "P1234567", + }); + expect( + service.getCompanyIdentityState(company() as never).identityProven, + ).toBe(false); + }); + + it("offers an Ethiopian company no passport alternative", async () => { + const { service, company } = makeService({ + attributes: { poaDeclared: "no", ownerPassportNumber: "P1234567" }, + }); + const state = service.getCompanyIdentityState(company() as never); + expect(state.passportAccepted).toBe(false); + expect(state.identityProven).toBe(false); + }); +}); + +describe("the owner is checked against the eTrade licence", () => { + it("matches ignoring case, punctuation and word order", async () => { + const { service, company } = makeService({ + attributes: { + poaDeclared: "no", + ownerName: "abebe bikila", + etradeManagerName: "BIKILA, Abebe", + }, + }); + expect( + service.getCompanyIdentityState(company() as never).ownerMatchesEtrade, + ).toBe(true); + }); + + it("flags a different person", async () => { + const { service, company } = makeService({ + attributes: { + poaDeclared: "no", + ownerName: "Haile Gebrselassie", + etradeManagerName: "Abebe Bikila", + }, + }); + expect( + service.getCompanyIdentityState(company() as never).ownerMatchesEtrade, + ).toBe(false); + }); + + it("reports null when there is nothing to compare", async () => { + // eTrade's ManagerNameEng is frequently blank; a null must not read as a + // mismatch, which would flag half the customer base. + const { service, company } = makeService({ + attributes: { poaDeclared: "no", ownerName: "Abebe Bikila" }, + }); + expect( + service.getCompanyIdentityState(company() as never).ownerMatchesEtrade, + ).toBeNull(); + }); + + it("never blocks on a mismatch — it is the reviewer's call", async () => { const { service } = makeService({ - attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED_NO_CONTACTS }, - profileTypes: [ProfileType.freightForwarder], + attributes: { + poaDeclared: "no", + ...OWNER_VERIFIED, + // The verified owner is a different human from the one on the licence. + ownerName: "Haile Gebrselassie", + etradeManagerName: "Abebe Bikila", + }, + }); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.outstanding).not.toContain( + expect.stringContaining("eTrade"), + ); + }); +}); + +describe("the freight-forwarder gate", () => { + const addForwarder = (service: CompaniesService) => + service.addCompanyProfilesForUser("user-1", [ProfileType.freightForwarder]); + + it("blocks the role while the representative is unverified", async () => { + const { service } = makeService({ attributes: { poaDeclared: "yes" } }); + await expect(addForwarder(service)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it("blocks the role on a company that answered no — it becomes yes", async () => { + // Taking the role forces the declaration, so a company that had answered + // "no" cannot inherit that answer past the gate. + const { service } = makeService({ + attributes: { poaDeclared: "no", ...OWNER_VERIFIED }, + }); + await expect(addForwarder(service)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it("blocks the role without the DARS delegation paper", async () => { + const { service } = makeService({ + attributes: { poaDeclared: "yes", ...POA_VERIFIED }, + files: [], + }); + await expect(addForwarder(service)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it("grants the role once the representative is proven and the paper is on file", async () => { + const { service } = makeService({ + attributes: { poaDeclared: "yes", ...POA_VERIFIED }, files: [paper()], }); + await expect(addForwarder(service)).resolves.toBeDefined(); + }); - const req = await service.getOnboardingRequirements("user-1"); + it("grants it to a foreign forwarder whose representative has a passport", async () => { + const { service } = makeService({ + nationality: CompanyNationality.Foreign, + attributes: { + poaDeclared: "yes", + ...POA_VERIFIED, + poaFaydaSub: undefined, + poaPassportNumber: "P7654321", + }, + files: [paper()], + }); + await expect(addForwarder(service)).resolves.toBeDefined(); + }); +}); - expect(req.poa.missingFields.map((f) => f.key)).toEqual([ +describe("onboarding requirements report exactly what is outstanding", () => { + it("asks the power-of-attorney question before anything else about identity", async () => { + const { service } = makeService(); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.poa.declared).toBeNull(); + expect(reqs.outstanding).toContain( + "Tell us whether anyone holds power of attorney for your company", + ); + }); + + it("names the representative's missing details once one is declared", async () => { + const { service } = makeService({ + attributes: { poaDeclared: "yes", poaName: "Tirunesh Dibaba" }, + }); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.poa.missingFields.map((f) => f.key)).toEqual([ "poaEmail", "poaPhone", ]); - expect(req.poa.complete).toBe(false); - expect(req.outstanding).toEqual( - expect.arrayContaining(["Add your poa email", "Add your poa phone"]), - ); }); - it("clears once they are typed", async () => { + it("asks nothing about a representative from a company that has none", async () => { const { service } = makeService({ - attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED }, - profileTypes: [ProfileType.freightForwarder], - files: [paper()], + attributes: { poaDeclared: "no", ...OWNER_VERIFIED }, }); - - const req = await service.getOnboardingRequirements("user-1"); - - expect(req.poa.missingFields).toEqual([]); - expect(req.poa.complete).toBe(true); - expect(req.outstanding).not.toContain("Add your poa email"); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.poa.missingFields).toEqual([]); + expect(reqs.poa.delegationLetterRequired).toBe(false); }); - // Fayda's email claim is optional and the GM's verification has no account to - // fall back on, so demanding one blocked a manager the government had already - // proved. `companyNotifyEmailExpr` resolves the address from the contact - // person or the registering account instead, so nothing needs this filled. - it("does not hold a company back for a general manager with no email", async () => { + it("demands the delegation paper from every declared representative", async () => { + // No waiver: the owner representing the company IS the "no" answer, so a + // "yes" always means a delegation that has to be evidenced. const { service } = makeService({ - attributes: { - ...OWNER_VERIFIED, - gmFaydaSub: "gm-sub", - generalManagerName: "Derartu Tulu", - generalManagerPhone: "+251911222333", - }, + attributes: { poaDeclared: "yes", ...POA_VERIFIED }, + files: [], }); - - const req = await service.getOnboardingRequirements("user-1"); - - expect(req.companyInfo.missingFields.map((f) => f.key)).not.toContain( - "generalManagerEmail", - ); - expect(req.outstanding).not.toContain("Add your general manager email"); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.poa.delegationLetterRequired).toBe(true); + expect(reqs.isComplete).toBe(false); }); - it("still holds it back for the manager's name and phone", async () => { - const { service } = makeService({ attributes: { ...OWNER_VERIFIED } }); - - const req = await service.getOnboardingRequirements("user-1"); - - expect(req.companyInfo.missingFields.map((f) => f.key)).toEqual( - expect.arrayContaining(["generalManagerName", "generalManagerPhone"]), + it("names the owner's missing details", async () => { + const { service } = makeService({ attributes: { poaDeclared: "no" } }); + const reqs = await service.getOnboardingRequirements("user-1"); + const missing = reqs.companyInfo.missingFields.map((f) => f.key); + expect(missing).toEqual( + expect.arrayContaining(["ownerName", "ownerEmail", "ownerPhone"]), ); }); - // An importer that never named a representative owes nothing here — the step - // is one it may walk straight past. - it("asks nothing of a company with no PoA at all", async () => { - const { service } = makeService({ attributes: { ...OWNER_VERIFIED } }); + it("reports the verification against the person it actually gates on", async () => { + const { service } = makeService({ attributes: { poaDeclared: "yes" } }); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.outstanding).toContain( + "Verify your Power of Attorney with Fayda", + ); + }); - const req = await service.getOnboardingRequirements("user-1"); - - expect(req.poa.missingFields).toEqual([]); - expect(req.poa.complete).toBe(true); + it("offers the passport alternative to a foreign company", async () => { + const { service } = makeService({ + nationality: CompanyNationality.Foreign, + attributes: { poaDeclared: "no" }, + }); + const reqs = await service.getOnboardingRequirements("user-1"); + expect(reqs.outstanding).toContain( + "Verify the person named on your eTrade licence with Fayda, or add their passport number", + ); }); }); diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts index b85344e8e..96dc3ff53 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts @@ -174,21 +174,26 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => { ).resolves.toBeDefined(); }); - it("waives the paper when the owner represents the company themselves", async () => { - // Nobody delegates to themselves, so a self-declared PoA owes no DARS - // paper — the representative's own details are still required. + it('owes nothing when the company answered "no representative"', async () => { + // "The owner represents the company themselves" is now expressed as the + // declaration being "no" — there is no delegation, so no paper is due. The + // representative's details are cleared with the answer, so there is nothing + // left to evidence either. const { service } = makeService({ - attributes: { ...VERIFIED_IDENTITIES, poaSameAsOwner: true }, + attributes: { ...VERIFIED_IDENTITIES, poaDeclared: "no" }, }); await expect( - service.updateProfile("user-1", POA as never), + service.updateProfile("user-1", {} as never), ).resolves.toBeDefined(); }); - it("grants the forwarder role to a self-represented company with no paper", async () => { + it("refuses the forwarder role without a paper, however it represents itself", async () => { + // The self-representation waiver is gone: a freight forwarder signs on + // other companies' behalf, so the delegation and the paper evidencing it + // are non-negotiable. const { service } = makeService({ - attributes: { ...VERIFIED_IDENTITIES, ...POA, poaSameAsOwner: true }, + attributes: { ...VERIFIED_IDENTITIES, ...POA, poaDeclared: "yes" }, }); await expect( @@ -196,7 +201,7 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => { "user-1", ProfileType.freightForwarder, ), - ).resolves.toBeDefined(); + ).rejects.toBeInstanceOf(BadRequestException); }); it("rejects a paper the reviewer sent back for correction", async () => { From 293f255daa2beb6ce3a676c159521c51c7869934 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 11 Aug 2026 11:53:56 +0000 Subject: [PATCH 07/15] refactor(freight-portal): rebuild the onboarding wizard around owner and representation Steps are now company -> owner -> representation -> contact -> documents; PersonnelStep and PoaStep are gone. - OwnerStep shows whoever the eTrade licence names, read-only with a provenance badge (SourcedField), and renders an input for every gap eTrade and Fayda left. It warns when the stored owner does not match eTrade. - RepresentationStep asks outright whether anyone holds power of attorney, then branches: "no" verifies the owner, "yes" collects the representative and the DARS delegation letter. Freight forwarders cannot answer "no". - Passport input appears only for a foreign company whose subject has not verified with Fayda. - Drop every same-as-owner copy-across and the auth-user fallbacks for the owner's email and phone. Re-picking a business licence now clears the eTrade owner prefill, since licences under one TIN can name different managers. - Settings: TabGeneralManager replaced by TabOwner; TabPowerOfAttorney reworked around poaDeclared. --- .../onboarding/OnboardingWizardDialog.tsx | 55 +- .../portal/src/pages/SettingsPage.tsx | 51 +- .../src/pages/accounts/CompanyProfileForm.tsx | 699 +++++------------- .../companyProfileForm/SourcedField.tsx | 61 ++ .../accounts/companyProfileForm/helpers.ts | 37 +- .../companyProfileForm/schema.test.ts | 103 ++- .../accounts/companyProfileForm/schema.ts | 103 +-- .../steps/CompanyInfoStep.tsx | 50 -- .../companyProfileForm/steps/ContactStep.tsx | 32 +- .../companyProfileForm/steps/OwnerStep.tsx | 132 ++++ .../steps/PersonnelStep.tsx | 150 ---- .../companyProfileForm/steps/PoaStep.tsx | 217 ------ .../steps/RepresentationStep.tsx | 273 +++++++ .../src/pages/settings/TabCompanyProfile.tsx | 58 +- .../src/pages/settings/TabGeneralManager.tsx | 333 --------- .../portal/src/pages/settings/TabOwner.tsx | 198 +++++ .../src/pages/settings/TabPowerOfAttorney.tsx | 132 ++-- .../portal/src/services/companies.service.ts | 15 +- .../portal/src/services/verifayda.service.ts | 138 ++-- .../portal/src/types/profile.ts | 32 +- 20 files changed, 1187 insertions(+), 1682 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/SourcedField.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/OwnerStep.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PersonnelStep.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PoaStep.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/RepresentationStep.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index fa9c5919b..b9fdca622 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -41,12 +41,17 @@ import type { UpdateProfilePayload } from "@/types/profile"; import { extractApiError } from "@/utils/result"; /** Form steps rendered by CompanyProfileForm. */ -type FormStep = "company" | "personnel" | "contact" | "poa" | "documents"; +type FormStep = + | "company" + | "owner" + | "representation" + | "contact" + | "documents"; const FORM_STEPS: FormStep[] = [ "company", - "personnel", + "owner", + "representation", "contact", - "poa", "documents", ]; @@ -68,23 +73,25 @@ const STEP_META: Record< icon: , 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", @@ -210,7 +217,7 @@ export default function OnboardingWizardDialog({ }) => 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. @@ -414,15 +421,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,9 +459,9 @@ 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, onIdentityChange: () => { void profileQuery.refetch(); 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..93ab3cfc2 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -24,18 +24,19 @@ import { import { buildPayload, firstPresent, - firstValidEmail, - firstValidPhone, normalizeIdentityPhones, 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 from "./companyProfileForm/steps/RepresentationStep"; import DocumentsStep from "./companyProfileForm/steps/DocumentsStep"; export default function CompanyProfileForm({ @@ -96,7 +97,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 @@ -148,7 +149,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(() => { @@ -174,16 +175,6 @@ export default function CompanyProfileForm({ }), ); - // 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", - ); - // 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; - // 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 // validation time rather than at render time — the resolver below runs on @@ -192,16 +183,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 +200,7 @@ export default function CompanyProfileForm({ tinNumber: "", vatNumber: "", ownerPassportNumber: "", + poaPassportNumber: "", licenceNumber: "", statusDescription: "", dateRegistered: "", @@ -225,9 +216,9 @@ export default function CompanyProfileForm({ contactPersonPosition: "", contactPersonEmail: "", contactPersonPhone: "", - generalManagerName: "", - generalManagerEmail: "", - generalManagerPhone: "", + ownerName: "", + ownerEmail: "", + ownerPhone: "", poaName: "", poaPhone: "", poaAddress: "", @@ -273,8 +264,9 @@ export default function CompanyProfileForm({ // 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. + // The manager eTrade lists for this licence. 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 [etradeOwner, setEtradeOwner] = useState<{ name: string; phone: string; @@ -302,31 +294,45 @@ 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({ + const owner = { name: data.managerName, phone: toEthiopianE164( data.managerPhone || data.regularPhone || data.mobilePhone, ), - }); + }; + setEtradeOwner(owner); + + // Prefill the owner inputs rather than replacing them. The customer can + // still correct a name eTrade transliterated oddly — and if they change it + // to a different person, `identity.ownerMatchesEtrade` says so to both them + // and the reviewer. Only fills what is empty: a value the customer already + // typed (or a Fayda claim already stored) is not overwritten by a lookup. + if (owner.name && !getValues("ownerName")?.trim()) { + setValue("ownerName", owner.name, { shouldValidate: true, ...dirty }); + } + if (owner.phone && !getValues("ownerPhone")?.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 = () => { setValue("licenceNumber", ""); setValue("statusDescription", ""); @@ -340,273 +346,91 @@ export default function CompanyProfileForm({ setValue("kebele", ""); setValue("houseNo", ""); setValue("etradePhone", ""); + if (!identity?.owner.verified) { + if (getValues("ownerName") === etradeOwner?.name) setValue("ownerName", ""); + if (getValues("ownerPhone") === etradeOwner?.phone) + setValue("ownerPhone", ""); + } 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. + * Answer the power-of-attorney question. * - * 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. + * 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 [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); - } - }; - - /** - * 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. - */ - 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 }); } onIdentityChange?.(); - } catch (err) { - setPoaSameAsOwner(!checked); - setSaveError( - (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message ?? - (err instanceof Error - ? err.message - : "Could not update the Power of Attorney"), - ); - } finally { - setPoaLinkPending(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. - */ - // 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 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"), + : "Could not save your answer"), ); } finally { - setPoaRemovePending(false); + setDeclarePending(false); } }; + /** + * What no source supplied, per person. + * + * A Fayda verification owns the fields its claims filled — the API refuses to + * let those be overwritten — but its email, phone and address claims are + * optional and routinely come back empty. eTrade fills the owner's name and + * phone, and nothing at all fills an email. + * + * So "what still has to be asked" varies per company. 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.** + */ + const ownerGaps = { + name: !identity?.owner.name?.trim(), + email: !identity?.owner.email?.trim(), + phone: !identity?.owner.phone?.trim(), + }; + const poaGaps = { + name: !identity?.poa.name?.trim(), + email: !identity?.poa.email?.trim(), + phone: !identity?.poa.phone?.trim(), + address: !identity?.poa.address?.trim(), + }; + + // The owner's name from whichever source established them — powers the + // contact step's "same as owner" card. + const ownerName = firstPresent(identity?.owner.name, watch("ownerName")); + const ownerEmail = firstPresent(identity?.owner.email, watch("ownerEmail")); + const ownerPhone = firstPresent(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 +440,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, ); @@ -713,8 +537,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", @@ -732,51 +555,17 @@ export default function CompanyProfileForm({ // 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,90 +574,30 @@ 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 (step === "owner") { + // All three are required by the API (`REQUIRED_COMPANY_INFO`), and an input + // is rendered for each one a Fayda claim did not already own. + if (ownerGaps.name) requiredKeys.push("ownerName"); + if (ownerGaps.email) requiredKeys.push("ownerEmail"); + if (ownerGaps.phone) requiredKeys.push("ownerPhone"); + } else if (step === "representation" && identity?.poaDeclared === "yes") { + // Only once a representative is actually declared: a company that answered + // "no" has no representative to describe. if (poaGaps.name) requiredKeys.push("poaName"); if (poaGaps.email) requiredKeys.push("poaEmail"); if (poaGaps.phone) requiredKeys.push("poaPhone"); @@ -879,19 +608,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,21 +628,10 @@ 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)); @@ -969,13 +683,13 @@ 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; } @@ -995,66 +709,50 @@ export default function CompanyProfileForm({ ); 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 - ) { + // 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) { setSaveError( - "Verify the company owner's identity with Fayda before continuing.", + "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) { + // 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"; 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.", + 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); + const fieldsOk = await trigger(stepFields.representation); setSaveError( [ - 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 @@ -1092,8 +790,6 @@ export default function CompanyProfileForm({ {step === "company" && ( )} - {step === "personnel" && ( - + )} + + {step === "representation" && ( + )} {step === "contact" && ( - )} - - {step === "poa" && ( - )} @@ -1197,6 +883,7 @@ export default function CompanyProfileForm({ disabled={ isPending || saving || + declarePending || (step === "documents" && !hasDocuments && loadingDocuments) } loading={isPending || saving} 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..480606db8 --- /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 prefilled 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 may already be answered for us. + * + * The onboarding wizard fills what it can from the eTrade lookup and the Fayda + * verification, and asks the customer only for what neither supplied. Both + * sources are patchy in practice — eTrade returns no email at all and often no + * manager name; Fayda's email and phone claims are optional and routinely come + * back empty — so "what is missing" varies per company and cannot be decided + * once at build time. + * + * This is the single place that decision is rendered: a supplied value shows + * read-only with its provenance, a gap shows the input. It pairs with + * `requiredKeys` in CompanyProfileForm, which requires exactly the fields that + * fall through to `children` — the invariant being that **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 a source supplied. Blank/absent means "ask the customer". */ + value?: string | null; + source: FieldSource; + /** The input rendered when no source supplied a value. */ + children: ReactNode; +}) { + if (!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..ca745a953 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 @@ -69,7 +69,6 @@ export function normalizeIdentityPhones( ...identity, owner: fix(identity.owner), poa: fix(identity.poa), - gm: fix(identity.gm), }; } @@ -97,15 +96,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, }, }; @@ -137,21 +135,20 @@ export function stepPayload( return { 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 +157,13 @@ 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, }; default: return {}; @@ -183,6 +181,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,9 +197,9 @@ 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 ?? "", 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..ebff24687 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 @@ -35,9 +35,9 @@ const values = (over: Partial = {}): FormData => contactPersonPosition: "", contactPersonEmail: "", contactPersonPhone: "+251911223344", - generalManagerName: "", - generalManagerEmail: "", - generalManagerPhone: "", + ownerName: "", + ownerEmail: "", + ownerPhone: "", poaName: "", poaPhone: "", poaAddress: "", @@ -131,31 +131,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 +163,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( @@ -198,37 +194,33 @@ describe("stepPayload (company)", () => { }); }); -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"); }); }); @@ -271,10 +263,11 @@ describe("firstValidEmail", () => { 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 +276,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..770e565b8 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"; @@ -27,10 +27,13 @@ export const onboardingSchema = z.object({ .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`. + // 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(), @@ -57,21 +60,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"), @@ -105,42 +110,36 @@ const CONDITIONAL_LABELS: Partial> = { 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 +183,29 @@ 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. + company: ["tinNumber", "vatNumber"], + // `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. + representation: [ + "poaName", + "poaEmail", + "poaPhone", + "poaLocation", + "poaPassportNumber", + ], 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..f57d7c433 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 @@ -2,11 +2,9 @@ import { Stack, TextInput } from "@mantine/core"; import type { UseFormReturn } from "react-hook-form"; import type { CompanyRegistrationData } from "@edr/types"; -import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; 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,10 +12,6 @@ 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). */ @@ -29,8 +23,6 @@ export interface CompanyInfoStepProps { export default function CompanyInfoStep({ form, - identity, - verifiedIdentity, tinStatus, tinVerified, hasRegistrationDetails, @@ -66,48 +58,6 @@ export default function CompanyInfoStep({ 0 - ? "done" - : identity?.passportRequired - ? "blocked" - : "todo" - } - > - {identity && ( - <> - - {identity.passportRequired && ( - - )} - - )} - - - ; /** - * The GM's name from whichever source established them (verification or form) - * — the "same as GM" card only makes sense once there is a GM. + * The owner's name from whichever source established them (eTrade, Fayda or + * typed) — the "same as owner" card only makes sense once there is one. */ - gmName?: string; - contactSameAsGm: boolean; - onToggleContactSameAsGm: (checked: boolean) => void; + ownerName?: string; + contactSameAsOwner: boolean; + onToggleContactSameAsOwner: (checked: boolean) => void; } export default function ContactStep({ form, - gmName, - contactSameAsGm, - onToggleContactSameAsGm, + ownerName, + contactSameAsOwner, + onToggleContactSameAsOwner, }: ContactStepProps) { const { register, @@ -34,15 +34,15 @@ export default function ContactStep({ Contact Person - {/* `gmName`, not the raw form field: a Fayda-verified GM never - fills `generalManagerName`, so gating on it hid this card from - every Ethiopian company — the majority case. */} - {gmName && ( + {/* `ownerName`, not the raw form field: the owner's name usually comes + from the eTrade lookup or a Fayda claim rather than being typed, so + gating on the form value would hide this card from most companies. */} + {ownerName && ( )} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/OwnerStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/OwnerStep.tsx new file mode 100644 index 000000000..7c7c05fec --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/OwnerStep.tsx @@ -0,0 +1,132 @@ +import { Alert, SimpleGrid, Stack, Text, TextInput } from "@mantine/core"; +import { AlertTriangle, Info } from "lucide-react"; +import type { UseFormReturn } from "react-hook-form"; + +import { ControlledPhoneField } from "@/components/PhoneField"; +import type { CompanyIdentityState } from "@/services/verifayda.service"; + +import type { FormData } from "../schema"; +import SourcedField from "../SourcedField"; + +export interface OwnerStepProps { + form: UseFormReturn; + identity?: CompanyIdentityState; + /** eTrade's registered manager, once a TIN lookup has succeeded. */ + etradeOwner: { name: string; phone: string } | null; + /** + * Which of the owner's details neither eTrade nor Fayda supplied, and are + * therefore typed here. Computed by CompanyProfileForm, which requires + * exactly these in the schema — so every input below is one the customer is + * actually asked to fill, and nothing is required that has no input. + */ + gaps: { name: boolean; email: boolean; phone: boolean }; +} + +/** + * Who the company's owner is — meaning whoever the eTrade licence names as the + * business's manager. Not necessarily the legal owner, but the person the + * record has to match: the backoffice's check is precisely "is this the person + * on the licence". + * + * Nothing here falls back to the signed-in account. The person doing the + * onboarding is often not the person on the licence, and stamping their name, + * email and phone onto the owner turned three required fields into a guess + * wearing the licence's authority. + */ +export default function OwnerStep({ + form, + identity, + etradeOwner, + gaps, +}: OwnerStepProps) { + const { + register, + control, + formState: { errors }, + } = form; + + // A verified owner's own claims outrank eTrade's record for display: the + // government IdP is the higher-trust source, and the API locks those fields + // to it. eTrade still supplies the name and phone when there is no + // verification — which is the case for every company represented by a PoA. + const ownerVerified = identity?.owner.verified ?? false; + const nameValue = identity?.owner.name || etradeOwner?.name || ""; + const phoneValue = identity?.owner.phone || etradeOwner?.phone || ""; + const emailValue = identity?.owner.email || ""; + const nameSource = identity?.owner.name ? "Fayda" : "eTrade"; + const phoneSource = identity?.owner.phone ? "Fayda" : "eTrade"; + + // A Fayda verification that names someone other than the person on the + // licence is the one thing this step exists to catch. Advisory here — the two + // sources transliterate Amharic names differently, so the reviewer decides — + // but the customer should see it now rather than be rejected later. + const mismatch = identity?.ownerMatchesEtrade === false; + + return ( + + + These are the details of the person registered on your eTrade licence. + We fill in whatever eTrade and Fayda gave us; anything they left blank + we need from you. + + + {!etradeOwner && !ownerVerified && ( + }> + Your eTrade licence didn't list a manager, so there's nothing for us + to prefill. Enter the details of the person registered on it. + + )} + + {mismatch && ( + } + title="This doesn't match your eTrade licence" + > + Your licence lists{" "} + {identity?.etradeManagerName}, but the name here is{" "} + {nameValue}. You can continue, but our team will + check this before approving your account — so make sure it's the + person the licence actually names. + + )} + + + + + + + {/* eTrade never returns an email for the manager and Fayda's email + claim is optional, so this is the field most companies actually + type — it is required either way (`REQUIRED_COMPANY_INFO`). */} + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PersonnelStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PersonnelStep.tsx deleted file mode 100644 index d3324e0a7..000000000 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PersonnelStep.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { SimpleGrid, Text, TextInput } from "@mantine/core"; -import type { UseFormReturn } from "react-hook-form"; - -import { ControlledPhoneField } from "@/components/PhoneField"; -import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; -import type { CompanyIdentityState } from "@/services/verifayda.service"; - -import type { FormData } from "../schema"; -import { LinkCheckboxCard } from "../LinkCheckboxCard"; - -export interface PersonnelStepProps { - form: UseFormReturn; - identity?: CompanyIdentityState; - /** eTrade-registered owner, once a TIN lookup has succeeded. */ - etradeOwner: { name: string; phone: string } | null; - gmSameAsOwner: boolean; - onToggleGmSameAsOwner: (checked: boolean) => void; - /** A server-side "same as owner" declaration is in flight. */ - gmLinkPending: boolean; - gmVerified: boolean; - /** - * Which of the manager's contact details their Fayda verification did not - * supply, and are therefore typed here. Computed by CompanyProfileForm, which - * requires exactly these in the schema. - */ - gaps: { name: boolean; email: boolean; phone: boolean }; -} - -export default function PersonnelStep({ - form, - identity, - etradeOwner, - gmSameAsOwner, - onToggleGmSameAsOwner, - gmLinkPending, - gmVerified, - gaps, -}: PersonnelStepProps) { - const { - register, - control, - formState: { errors }, - } = form; - - return ( - <> - - General Manager - - {/* The GM is very often the owner. Where the owner is - Fayda-verified this reuses that proven identity outright - rather than making the same human verify twice; where the - owner is backed by a typed passport there is nothing proven - to copy, so it stays a local prefill. */} - - - {/* Verifying a second person is only meaningful when the GM is - someone other than the owner. */} - {!gmSameAsOwner && identity && ( - - )} - - {/* Fayda's name, email and phone claims are all optional, and the - manager's own verification has no account to fall back on the way the - owner's does — the person onboarding is not necessarily the manager. - Whatever the verification left empty is typed here, and required: - without it the submit fails on "Add your general manager name" with no - field anywhere to satisfy it. */} - {gaps.name && ( - - )} - {(gaps.email || gaps.phone) && ( - - {gaps.email && ( - - )} - {gaps.phone && ( - - )} - - )} - - {/* Typed details survive only where Fayda cannot be required — - a foreign company's manager may hold no Fayda ID. Once - verified the API owns these fields, so they go away. */} - {!gmSameAsOwner && !gmVerified && !identity?.faydaRequired && ( - <> - - - - - - - )} - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PoaStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PoaStep.tsx deleted file mode 100644 index efece363b..000000000 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PoaStep.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import { Button, Divider, Group, SimpleGrid, Text, TextInput } from "@mantine/core"; -import { Trash2 } from "lucide-react"; -import type { UseFormReturn } from "react-hook-form"; - -import { SmartFileInput } from "@edr/ui-common"; -import { ControlledPhoneField } from "@/components/PhoneField"; -import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; -import type { FileUploadSetting } from "@/types/fileUploadSettings"; -import type { CompanyIdentityState } from "@/services/verifayda.service"; - -import type { FormData } from "../schema"; -import { LinkCheckboxCard } from "../LinkCheckboxCard"; - -export interface PoaStepProps { - form: UseFormReturn; - identity?: CompanyIdentityState; - /** This company holds a freight-forwarder profile, so the PoA is mandatory. */ - requirePoa: boolean; - /** The owner represents the company themselves. */ - poaSameAsOwner: boolean; - onTogglePoaSameAsOwner: (checked: boolean) => void; - /** A server-side "same as owner" declaration is in flight. */ - poaLinkPending: boolean; - /** eTrade-registered owner, once a TIN lookup has succeeded. */ - etradeOwner: { name: string; phone: string } | null; - /** - * Which of the representative's details the Fayda verification did not - * supply, and are therefore typed here. Computed by CompanyProfileForm, which - * requires exactly these in the schema — so every input rendered below is one - * the customer is actually asked to fill. - */ - gaps: { name: boolean; email: boolean; phone: boolean; address: boolean }; - /** Drop a verified representative the company decided against. */ - onRemovePoa: () => void; - removePending: boolean; - /** The DARS delegation paper is owed (a PoA exists, or the company forwards). */ - delegationRequired: boolean; - /** Single-field upload setting carrying just the delegation letter. */ - poaDocumentSetting?: FileUploadSetting; - documentFiles: Record; - uploadedDocumentKeys?: string[]; - documentFieldErrors: Record; - onDocumentFilesChange: (next: Record) => void; -} - -export default function PoaStep({ - form, - identity, - requirePoa, - poaSameAsOwner, - onTogglePoaSameAsOwner, - poaLinkPending, - etradeOwner, - gaps, - onRemovePoa, - removePending, - delegationRequired, - poaDocumentSetting, - documentFiles, - uploadedDocumentKeys, - documentFieldErrors, - onDocumentFilesChange, -}: PoaStepProps) { - const { - register, - control, - formState: { errors }, - } = form; - - // Fayda's email/phone/address claims are optional and routinely come back - // empty, so a *verified* representative can still be missing the email and - // phone the API demands from a freight forwarder (`REQUIRED_POA_FIELDS`) — - // and the panel above renders no input for them, which dead-ends the step on - // "Add the poa email first". `gaps` is exactly what the verification did not - // supply: the API keeps those keys typeable, since a claim that returned - // nothing owns no value to protect (`faydaOwnedKeys`). - const needsEmail = gaps.email; - const needsPhone = gaps.phone; - - // Fayda is mandatory for an Ethiopian company's representative, so there the - // link can only reuse a proven owner — with none there would be nothing to - // copy and the declaration could never satisfy the gate. A foreign company's - // owner is backed by a typed passport, so it prefills instead. - const linkNeedsVerifiedOwner = - (identity?.faydaRequired ?? false) && !identity?.owner.verified; - - return ( - <> - - {requirePoa - ? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details are required." - : "Power of Attorney details are optional. Fill them in if you have them, or skip to continue."}{" "} - {poaSameAsOwner - ? "You represent the company yourself, so no delegation paper is needed." - : "If the representative is someone other than the owner, upload the delegation paper authenticated by DARS."} - - - {/* An owner who represents their own company is the ordinary - small-business case. Where the owner is Fayda-verified this reuses - that proven identity outright rather than sending the same human - through Fayda twice; where they are backed by a typed passport there - is nothing proven to copy, so it stays a local prefill. Either way it - is the declaration that waives the DARS paper. */} - {identity && ( - - )} - - {/* A representative acts for the company inside Ethiopia - whoever owns it, so the PoA is proven with Fayda regardless of - nationality — their name, email, phone and address all come - from the verification and are never typed here. Verifying a second - person is only meaningful when the representative is not the owner. */} - {identity && !poaSameAsOwner && ( - - )} - {/* A verification cannot be undone by clearing the form — it owns those - fields — and its mere existence makes the delegation paper due, which - then blocks the submit. So an optional representative needs a way - back out, here rather than only in settings (unreachable until - onboarding finishes). */} - {identity?.poa.verified && !requirePoa && !poaSameAsOwner && ( - - - - )} - {/* Whatever the Fayda claim did carry is shown on the panel above and - is never typed here — the verification owns it. */} - {gaps.name && ( - - )} - {(needsEmail || needsPhone) && ( - - {needsEmail && ( - - )} - {needsPhone && ( - - )} - - )} - {gaps.address && ( - - )} - - {/* The paper authorises the representative, so it shows once one - exists — or straight away for a freight forwarder, who owes it - either way and must not be failed on submit for a file the - step never offered. */} - {delegationRequired && poaDocumentSetting && ( - <> - - - - )} - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/RepresentationStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/RepresentationStep.tsx new file mode 100644 index 000000000..52fd8c56d --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/RepresentationStep.tsx @@ -0,0 +1,273 @@ +import { + Alert, + Divider, + Group, + Loader, + SimpleGrid, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { Info, UserCheck, UserX } from "lucide-react"; +import type { UseFormReturn } from "react-hook-form"; + +import { SmartFileInput } from "@edr/ui-common"; +import { ControlledPhoneField } from "@/components/PhoneField"; +import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; +import RoleCard from "@/pages/settings/RoleCard"; +import type { FileUploadSetting } from "@/types/fileUploadSettings"; +import type { + CompanyIdentityState, + PoaDeclaration, +} from "@/services/verifayda.service"; + +import type { FormData } from "../schema"; +import SourcedField from "../SourcedField"; + +export interface RepresentationStepProps { + form: UseFormReturn; + identity?: CompanyIdentityState; + /** Answer the power-of-attorney question (persisted server-side). */ + onDeclare: (declared: PoaDeclaration) => void; + /** A declaration change is in flight. */ + declarePending: boolean; + /** + * Which of the representative's details the Fayda verification did not + * supply. Same contract as OwnerStep's `gaps`: an input is rendered for + * exactly these, and the schema requires exactly these. + */ + gaps: { name: boolean; email: boolean; phone: boolean; address: boolean }; + /** Single-field upload setting carrying just the DARS delegation letter. */ + poaDocumentSetting?: FileUploadSetting; + documentFiles: Record; + uploadedDocumentKeys?: string[]; + documentFieldErrors: Record; + onDocumentFilesChange: (next: Record) => void; +} + +/** + * Who acts for this company — and, as a direct consequence, whose identity gets + * verified. + * + * A company proves itself through exactly one person. This step asks which: + * name a Power of Attorney and it is the representative who verifies (plus the + * DARS paper evidencing the delegation); say there is none and the owner + * verifies here instead. There is no third answer — "the owner represents the + * company themselves" IS "no". + * + * A freight forwarder is never asked. It signs on other companies' behalf, so a + * representative and the paper behind them are non-negotiable; the API forces + * the answer regardless of what the portal sends. + */ +export default function RepresentationStep({ + form, + identity, + onDeclare, + declarePending, + gaps, + poaDocumentSetting, + documentFiles, + uploadedDocumentKeys, + documentFieldErrors, + onDocumentFilesChange, +}: RepresentationStepProps) { + const { + register, + control, + formState: { errors }, + } = form; + + if (!identity) { + return ( + + + + ); + } + + const declared = identity.poaDeclared; + const locked = identity.poaDeclared === "yes" && identity.subject === "poa"; + // Only the API knows whether the lock is the freight-forwarder rule; it + // reports the answer as "yes" for them no matter what is stored, so a company + // that cannot switch to "no" is one the API will refuse. Rather than + // duplicating the role check here, the "no" card simply reports the refusal. + const passportAccepted = identity.passportAccepted; + + return ( + + + + Does anyone hold power of attorney for this company? + + + Your answer decides whose identity we verify — the representative's, + or the owner's. + + + + + } + selected={declared === "yes"} + onClick={ + declarePending || declared === "yes" + ? undefined + : () => onDeclare("yes") + } + /> + } + selected={declared === "no"} + onClick={ + declarePending || declared === "no" + ? undefined + : () => onDeclare("no") + } + /> + + + {locked && ( + }> + As a freight forwarder you act on other companies' behalf, so a Power + of Attorney is required — this can't be set to "no" while you hold the + freight forwarder role. + + )} + + {declared === null && ( + + Pick one to continue. + + )} + + {/* ---------------------------------------------------------------- */} + {/* No representative → the owner is the one who verifies. */} + {/* ---------------------------------------------------------------- */} + {declared === "no" && ( + <> + + + {/* Fayda is an Ethiopian national ID, so a foreign company's owner + may hold none — a passport number proves them instead. Offered + alongside, not after: either one satisfies the gate. */} + {passportAccepted && !identity.owner.verified && ( + + )} + + )} + + {/* ---------------------------------------------------------------- */} + {/* A representative → they verify, and the delegation is evidenced. */} + {/* ---------------------------------------------------------------- */} + {declared === "yes" && ( + <> + + + {passportAccepted && !identity.poa.verified && ( + + )} + + {/* Whatever the Fayda claim carried is shown on the panel above and + never typed here — the verification owns it. The rest is asked + for outright, because the API demands name/email/phone from any + declared representative (`REQUIRED_POA_FIELDS`). */} + + + + + + + + + + + + + + + {gaps.address && ( + + )} + + {poaDocumentSetting && ( + <> + + + Upload the delegation paper authenticated by DARS. It is what + evidences that this person was actually delegated. + + + + )} + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index b11fecefc..a359a3ff0 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -24,14 +24,12 @@ import { useForm } from "react-hook-form"; import { z } from "zod"; import type { CompanyRegistrationData } from "@edr/types"; import OnboardingRoleSelect from "./OnboardingRoleSelect"; -import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; import ETradeInfo, { type ETradeStatus, } from "@/components/onboarding/ETradeInfo"; import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField"; import StepSection from "@/pages/accounts/companyProfileForm/StepSection"; import { ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS } from "@/pages/accounts/companyProfileForm/schema"; -import { normalizeIdentityPhones } from "@/pages/accounts/companyProfileForm/helpers"; export const COMPANY_PROFILE_SCHEMA = z.object({ // eTrade-sourced and read-only, like the registration block below. @@ -149,11 +147,6 @@ export default function TabCompanyProfile({ // Fayda stores the phone as the national registry holds it (often a local // number), which neither this form's E.164 validation nor the API's // `@IsValidPhone()` accepts. Normalize on read — same as the wizard. - const identity = useMemo( - () => normalizeIdentityPhones(profile?.identity), - [profile?.identity], - ); - const verifiedIdentity = identity?.faydaRequired === true; // companyAddress is composed from the (locked) eTrade address parts, not // typed directly. @@ -287,13 +280,6 @@ export default function TabCompanyProfile({ validationError ?? (mutation.isError ? extractApiError(mutation.error).message : null); - const pendingOwnerReview = Boolean( - ( - profile?.pendingChanges as { - faydaIdentity?: Record; - } | null - )?.faydaIdentity?.ownerFaydaSub, - ); // During onboarding the role selection gates the form: nothing else shows // until the user picks Importer/Exporter or Freight Forwarder. @@ -335,51 +321,9 @@ export default function TabCompanyProfile({ /> - {identity && ( - 0 - ? "done" - : identity.passportRequired - ? "blocked" - : "todo" - } - > - - {identity.passportRequired && ( - - )} - - )} !v || z.string().email().safeParse(v).success, - "Invalid GM email", - ), - generalManagerPhone: z - .string() - .optional() - .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), -}); - -type FormData = z.infer; - -interface TabGeneralManagerProps { - profile: ProfileResponse; - mode?: "edit" | "onboarding"; - onContinue?: () => void; -} - -/** - * The general manager's identity comes from Fayda: either verified in their - * own right, or declared to be the owner — very often the same human, which is - * what "Same as owner" is for. Typed details survive only for a foreign - * company, whose manager may hold no Fayda ID at all. - */ -export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) { - const queryClient = useQueryClient(); - const owner = profile.identity?.owner; - const identity = profile.identity; - const gm = identity?.gm; - const faydaRequired = identity?.faydaRequired ?? false; - const [gmSameAsOwner, setGmSameAsOwner] = useState( - identity?.gmSameAsOwner ?? false, - ); - - const defaultValues = useMemo((): FormData => { - return { - generalManagerName: profile.generalManagerName ?? "", - generalManagerEmail: profile.generalManagerEmail ?? "", - generalManagerPhone: profile.generalManagerPhone ?? "", - }; - }, [profile]); - - const { - register, - control, - handleSubmit, - reset, - setValue, - formState: { errors, isDirty }, - } = useForm({ - resolver: zodResolver(schema), - values: defaultValues, - }); - - /** - * With a Fayda-verified owner the declaration is made server-side — the API - * copies the proven identity onto the GM — so nothing is typed here. Without - * one (a foreign company, whose owner is backed by a passport) there is - * nothing proven to copy and this stays a local prefill. - */ - const [linkPending, setLinkPending] = useState(false); - const [linkError, setLinkError] = useState(null); - const toggleGmSameAsOwner = async (checked: boolean) => { - setGmSameAsOwner(checked); - setLinkError(null); - if (!owner?.verified) { - if (checked && owner) { - setValue("generalManagerName", owner.name ?? "", { shouldValidate: true }); - setValue("generalManagerEmail", owner.email ?? "", { shouldValidate: true }); - setValue("generalManagerPhone", owner.phone ?? "", { shouldValidate: true }); - } - return; - } - - setLinkPending(true); - try { - if (checked) await verifaydaService.setGmSameAsOwner(); - else await verifaydaService.clearGmIdentity(); - queryClient.invalidateQueries({ - queryKey: api.companies.getProfile.queryKey(), - }); - } catch (err) { - setGmSameAsOwner(!checked); - setLinkError( - (err as { response?: { data?: { message?: string } } })?.response?.data - ?.message ?? - (err instanceof Error ? err.message : "Could not update the general manager"), - ); - } finally { - setLinkPending(false); - } - }; - - const mutation = useMutation({ - mutationFn: (data: FormData) => - api.companies.updateProfile.call({ - // `|| 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". A verified manager legitimately - // leaves the fields Fayda did supply blank here. - generalManagerName: data.generalManagerName || undefined, - generalManagerEmail: data.generalManagerEmail || undefined, - generalManagerPhone: data.generalManagerPhone || undefined, - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); - if (mode === "onboarding") onContinue?.(); - }, - }); - - const onSubmit = (data: FormData) => mutation.mutate(data); - - // Nothing to save when Fayda owns the details: the verification and the - // "same as owner" declaration both write server-side, so the form would be - // posting empty strings over a proven identity. - const typedFieldsInUse = !gmSameAsOwner && !gm?.verified && !faydaRequired; - - // Except for what the verification never supplied. Fayda's name, email and - // phone claims are all optional, and a manager verified without them has no - // account to fall back on the way the owner does — so those stay typed, here - // as well as in onboarding, or a wrong value could never be corrected. - const gmGaps = { - name: !gmSameAsOwner && Boolean(gm?.verified) && !gm?.name?.trim(), - email: !gmSameAsOwner && Boolean(gm?.verified) && !gm?.email?.trim(), - phone: !gmSameAsOwner && Boolean(gm?.verified) && !gm?.phone?.trim(), - }; - const savable = - typedFieldsInUse || gmGaps.name || gmGaps.email || gmGaps.phone; - - return ( - - - - General Manager - - - Manage the general manager information - - -
- - - - {linkError && ( - }> - {linkError} - - )} - - {/* Verifying a second person only means something when the manager - is someone other than the owner. */} - {!gmSameAsOwner && gm && ( - - )} - - {/* Whatever the verification did not supply is typed instead — the - API keeps exactly those keys writable. */} - {gmGaps.name && ( - - )} - {(gmGaps.email || gmGaps.phone) && ( - - {gmGaps.email && ( - - - - )} - {gmGaps.phone && ( - - - - )} - - )} - - {/* Typed details survive only where Fayda cannot be required — a - foreign company's manager may hold no Fayda ID. Once verified the - API owns these fields and refuses edits, so they go away. */} - {!gmSameAsOwner && !gm?.verified && !faydaRequired && ( - <> - - - - - - - - - - - - )} - - - - - {mutation.isSuccess && ( - - - Saved successfully - - )} - {mutation.isError && ( - - - Save failed - - )} - - - {mode === "edit" && typedFieldsInUse && ( - - )} - {/* Saving only means something while the details are typed: under - Fayda both routes write server-side, so a submit would post - empty strings at an identity the API owns and refuses to - overwrite. Onboarding still needs a way forward, so the button - becomes a plain Continue rather than disappearing. */} - {savable ? ( - - ) : ( - mode === "onboarding" && ( - - ) - )} - - -
-
- ); -} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx new file mode 100644 index 000000000..2c03aaed3 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx @@ -0,0 +1,198 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { AlertTriangle, Briefcase, Save } from "lucide-react"; +import { + Alert, + Button, + Card, + Grid, + Group, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; + +import { api } from "@/services/api"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; +import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; +import SourcedField from "@/pages/accounts/companyProfileForm/SourcedField"; +import type { ProfileResponse } from "@/types/profile"; + +// Optional, not unrequired: whatever a Fayda verification supplied is owned by +// the API and never typed here, so a blanket `min(1)` would fail a form that is +// correct. Presence is gated below, where the identity state says which fields +// are actually on screen; zod only polices format. +const schema = z.object({ + ownerName: z.string().optional(), + ownerEmail: z + .string() + .optional() + .refine( + (v) => !v || z.string().email().safeParse(v).success, + "Invalid owner email", + ), + ownerPhone: z + .string() + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), +}); + +type FormData = z.infer; + +interface TabOwnerProps { + profile: ProfileResponse; + mode?: "edit" | "onboarding"; + onContinue?: () => void; +} + +/** + * The company's owner — meaning whoever the eTrade licence names as the + * business's manager. Not necessarily the legal owner, but the person the + * record has to match: the backoffice's check is that comparison. + * + * Their identity is Fayda-verified only when the company has NO Power of + * Attorney; when it names a representative it is the representative who + * verifies, and the owner's details are simply recorded (from eTrade, or typed + * here). Either way all three are required. + */ +export default function TabOwner({ + profile, + mode = "edit", + onContinue, +}: TabOwnerProps) { + const queryClient = useQueryClient(); + const identity = profile.identity; + const owner = identity?.owner; + // Only the person the declaration points at carries the verification, so the + // panel is offered here only when that person is the owner. + const ownerIsSubject = identity?.subject === "owner"; + + const { + register, + control, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + ownerName: profile.ownerName ?? "", + ownerEmail: profile.ownerEmail ?? "", + ownerPhone: profile.ownerPhone ?? "", + }, + }); + + const mutation = useMutation({ + mutationFn: (data: FormData) => + api.companies.updateProfile.call({ + // `|| undefined`, never "": the DTO's `@IsOptional()` only skips null + // and undefined, so an empty string is validated and 400s with + // "ownerEmail must be an email". A verified owner legitimately leaves + // the fields Fayda did supply blank here. + ownerName: data.ownerName || undefined, + ownerEmail: data.ownerEmail || undefined, + ownerPhone: data.ownerPhone || undefined, + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }); + if (mode === "onboarding") onContinue?.(); + }, + }); + + // What the verification did NOT supply. Fayda's email and phone claims are + // optional, so a verified owner can still be missing details the API demands + // — the API leaves exactly those keys typeable, and so does this form. + const gaps = { + name: !owner?.name?.trim(), + email: !owner?.email?.trim(), + phone: !owner?.phone?.trim(), + }; + const savable = gaps.name || gaps.email || gaps.phone; + + return ( + + + + Company Owner + + + The person registered on your eTrade licence. + + +
mutation.mutate(data))}> + + {identity?.ownerMatchesEtrade === false && ( + } + title="This doesn't match your eTrade licence" + > + Your licence lists {identity.etradeManagerName}. + Our team checks this before approving changes. + + )} + + {ownerIsSubject && owner && ( + + )} + + + + + + + + + + + + + + + + + + + {savable && ( + + + + )} + +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx index 40f118580..a3118ad28 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx @@ -13,6 +13,7 @@ import { Undo2, UploadCloud, UserCheck, + UserX, XCircle, } from "lucide-react"; import { @@ -24,6 +25,7 @@ import { Card, Group, Loader, + SimpleGrid, Stack, Title, Text, @@ -41,7 +43,7 @@ import { } from "@/services/companies.service"; import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; import { verifaydaService } from "@/services/verifayda.service"; -import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard"; +import RoleCard from "@/pages/settings/RoleCard"; import type { ProfileResponse } from "@/types/profile"; // The representative's name, email, phone and address all come from their @@ -136,21 +138,17 @@ export default function TabPowerOfAttorney({ // company inside Ethiopia either way. A PoA therefore exists exactly when one // has been verified. const identity = profile.identity; - const owner = identity?.owner; const poaProvided = identity?.poa.verified ?? false; - const [poaSameAsOwner, setPoaSameAsOwner] = useState( - identity?.poaSameAsOwner ?? false, - ); - // The paper authorises the representative named above, so there is nothing - // for it to authorise until one has been verified — the upload is hidden - // until then, and requiring it while hidden would block the save on a - // control the customer cannot see. A freight forwarder is still held to - // having a PoA at all, by the verification gate on the panel and by the API. - // - // And nobody delegates to themselves: an owner representing their own company - // has no delegation to evidence, which is the same waiver the API applies in - // `assertPoaDelegationSatisfied`. - const letterRequired = poaProvided && !poaSameAsOwner; + // Whether there is a representative at all is the company's own declaration, + // held server-side — it decides whose identity the API gates on, so it is + // never local state here. + const declared = identity?.poaDeclared ?? null; + // The paper is owed exactly when the company says it has a representative — + // the same single rule `assertPoaDelegationSatisfied` enforces. Keying it on + // the verification instead would hide the upload from a foreign company whose + // representative proves themselves by passport, then fail the save for a file + // that was never offered. + const letterRequired = declared === "yes"; const letterMissing = letterRequired && !hasLetterAfterSave; const fileDirty = Boolean(pickedFile) || removeIds.length > 0; @@ -191,11 +189,12 @@ export default function TabPowerOfAttorney({ /** * A verified representative cannot be removed by blanking the form — their - * fields are owned by the verification — so removal is its own action that - * clears the identity and the delegation paper together. + * fields are owned by the verification — so removal is answering the + * declaration "no", which clears the identity, the details and the + * delegation paper together. Refused by the API for a freight forwarder. */ const removeMutation = useMutation({ - mutationFn: () => verifaydaService.removePoa(), + mutationFn: () => verifaydaService.setPoaDeclared("no"), onSuccess: () => { resetAll(); queryClient.invalidateQueries({ @@ -208,25 +207,20 @@ export default function TabPowerOfAttorney({ }); /** - * "Same as owner": the owner represents the company themselves. Always goes - * to the API, whichever credential backs the owner — the declaration is what - * waives the DARS paper, so it has to be recorded server-side even when there - * is no proven identity to copy. + * Answer the power-of-attorney question. * - * Unchecking undoes the declaration only. It leaves the paper on file and is - * allowed for a freight forwarder, which is how one changes who represents - * it; "Remove representative" below is the harder action that takes the paper - * with it and is refused to a forwarder. + * "No" means the owner acts for the company themselves — there is no + * delegation, so no DARS paper is owed and it is the OWNER whose identity is + * verified. The API tears the representative down when this is answered, and + * refuses "no" outright for a freight forwarder. */ const [linkPending, setLinkPending] = useState(false); const [linkError, setLinkError] = useState(null); - const togglePoaSameAsOwner = async (checked: boolean) => { - setPoaSameAsOwner(checked); + const declare = async (next: "yes" | "no") => { setLinkError(null); setLinkPending(true); try { - if (checked) await verifaydaService.setPoaSameAsOwner(); - else await verifaydaService.clearPoaSameAsOwner(); + await verifaydaService.setPoaDeclared(next); queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey(), }); @@ -234,7 +228,6 @@ export default function TabPowerOfAttorney({ queryKey: api.companies.poaDelegation.queryKey(), }); } catch (err) { - setPoaSameAsOwner(!checked); setLinkError( (err as { response?: { data?: { message?: string } } })?.response?.data ?.message ?? @@ -297,10 +290,10 @@ export default function TabPowerOfAttorney({ {requirePoa ? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney is required." - : "Power of Attorney details are optional."}{" "} - {poaSameAsOwner - ? "You represent the company yourself, so no delegation paper is needed." - : "If you name a representative, upload the DARS delegation paper authorising them."} + : "Tell us whether anyone is authorised to act for the company — your answer decides whose identity we verify."}{" "} + {declared === "no" + ? "The owner acts for the company, so no delegation paper is needed." + : "A representative must be identified, and the DARS delegation paper authorising them uploaded."} {/* The owner representing their own company is the ordinary @@ -309,30 +302,35 @@ export default function TabPowerOfAttorney({ mandatory it needs a verified owner first — there would be nothing proven to copy, and a representative who could never satisfy the gate. */} + {/* The declaration itself. "No" is not a lesser answer — it means the + owner acts for the company, so it is the OWNER who verifies and no + delegation paper is owed. A freight forwarder cannot choose it; the + API refuses and the error lands in `linkError`. */} {identity && ( - + + } + selected={declared === "yes"} + onClick={ + linkPending || declared === "yes" + ? undefined + : () => void declare("yes") + } + /> + } + selected={declared === "no"} + onClick={ + linkPending || declared === "no" + ? undefined + : () => void declare("no") + } + /> + )} {linkError && ( @@ -343,12 +341,12 @@ export default function TabPowerOfAttorney({ {/* Verifying a second person only means something when the representative is someone other than the owner. */} - {identity && !poaSameAsOwner && ( + {identity && declared === "yes" && ( )} @@ -359,7 +357,7 @@ export default function TabPowerOfAttorney({ verification and are shown on the panel above. Only a company whose representative may hold no Fayda ID still types a location. */} - {!poaProvided && !(identity?.faydaRequired ?? false) && ( + {declared === "yes" && !poaProvided && ( @@ -418,7 +416,7 @@ export default function TabPowerOfAttorney({ > {requirePoa ? "Upload the DARS delegation paper before saving — it is required for freight forwarders." - : "Upload the DARS delegation paper for the representative you named, or clear the PoA details."} + : "Upload the DARS delegation paper for the representative you named, or answer \u201cthe owner acts for us\u201d instead."} )} @@ -551,12 +549,12 @@ export default function TabPowerOfAttorney({ )} - {/* Not offered against a "same as owner" declaration: unchecking - the card above is the way out of that one, and it leaves the - paper alone. */} + {/* Answering "the owner acts for us" is the same teardown, so + this is only a shortcut — and it is refused to a forwarder, + which cannot be without a representative. */} {mode === "edit" && identity?.poa.verified && - !poaSameAsOwner && + declared === "yes" && !requirePoa && ( + + + +
+
+ + +
+
+ + +
+
+ + 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}

From 1be72799f4bbf8938b3c4579b4ac663c01665c7b Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 11 Aug 2026 12:13:34 +0000 Subject: [PATCH 10/15] fix(train-scheduling): exempt direct-to-train export bookings from GRN gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit confirmScheduleLoading's assertExportBookingsReceived checked every wagon-assigned export booking for a warehouse GRN, with no exception for DIRECT_TO_TRAIN (manual truck-straight-to-wagon) bookings — the one call site that lacked the carve-out already applied everywhere else GRN is checked (export-received-gate, booking-journey, carriage acceptance). Warehouse-routed export cargo still requires GRN; direct handovers rely on the carriage acceptance sheet instead. --- .../train-scheduling/services/train-scheduling.service.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 657703ada..1cd900d66 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -3262,6 +3262,12 @@ export class TrainSchedulingService { * Every export booking being confirmed loaded must already be received at the * warehouse with a GRN. An allocation puts a booking on a wagon on paper; this * is the check that the cargo is physically in the yard before we call it loaded. + * + * Direct truck-to-train (exportHandoverMode = DIRECT_TO_TRAIN) is excluded — + * that cargo is manually loaded from the customer's truck straight onto the + * wagon, never sees the warehouse, and is never GRN'd. Its custody is attested + * by the carriage acceptance sheet instead (same carve-out as the shared + * assertExportReceivedWithGrn gate — see common/export-received-gate.ts). */ private async assertExportBookingsReceived(bookingIds: string[]): Promise { if (!bookingIds.length) return; @@ -3270,6 +3276,7 @@ export class TrainSchedulingService { FROM freight.bookings b WHERE b.id = ANY($1) AND b.deleted_at IS NULL + AND b.export_handover_mode IS DISTINCT FROM 'DIRECT_TO_TRAIN' AND NOT EXISTS ( SELECT 1 FROM freight.warehouse_inventory inv WHERE inv.booking_id = b.id From 72164b0b8e55851d85867849a60e531f130bf0ff Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 11 Aug 2026 12:54:38 +0000 Subject: [PATCH 11/15] fix(freight-portal): stop owner details vanishing between wizard steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SourcedField rendered read-only whenever a value existed, so the input a customer had just typed into turned into a badge as soon as the step saved and they navigated back — and dropped out of requiredKeys at the same time. It now locks on ownership instead: a Fayda verification owns what its claims filled, everything else stays an editable, prefilled input. The representation step follows the same rule and asks in the right order: - The power-of-attorney question collapses to its answer once given, with a button back to it (none for a freight forwarder, whose answer is forced). - A foreign company picks how to prove the person outright — Fayda or a passport — rather than being shown both at once. - The representative's own fields appear only once the person is established, and only for what the verification did not supply; what it did supply is already on the panel above and is no longer repeated beneath it. - Dropped the freight-forwarder lecture and the DARS blurb; the badge and the upload field's own help text already say both. VAT numbers accept any non-blank value. A foreign tax authority's carries letters and dashes and a co-operative's follows neither pattern, so the 10-11 digit rule only ever rejected numbers we had no business judging. --- .../companies/dto/update-profile.dto.ts | 12 +- .../companyProfileForm/SourcedField.tsx | 34 +- .../companyProfileForm/steps/OwnerStep.tsx | 67 +-- .../steps/RepresentationStep.tsx | 442 +++++++++++------- .../portal/src/pages/settings/TabOwner.tsx | 30 +- .../src/pages/settings/TabPowerOfAttorney.tsx | 8 +- 6 files changed, 363 insertions(+), 230 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index b7b814da1..2273bf79a 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -5,7 +5,6 @@ import { MaxLength, IsEnum, IsIn, - Matches, } from "class-validator"; import { ETHIOPIAN_REGIONS, type EthiopianRegion } from "@edr/types"; import { CompanyNationality } from "../entities/company.entity"; @@ -36,13 +35,14 @@ export class UpdateProfileDto { @IsTin({ message: "TIN must be exactly 10 digits" }) tin?: string; - // Ethiopian VAT registration numbers are 10 digits (the same shape as the - // TIN), but some are issued with an 11th. Both portal forms enforce the same - // range; without it here the API happily stored whatever a stale client sent, - // and the two layers disagreed about what the column may hold. + // No shape check. Ethiopian VAT numbers are usually 10 or 11 digits, but a + // foreign company's is whatever its own tax authority issues — letters, + // dashes and any length — and a co-operative's registration numbering does + // not follow the trade-licence pattern either. The field is required (the + // portal enforces non-blank) but its content is not ours to police. @IsOptional() @IsString() - @Matches(/^\d{10,11}$/, { message: "VAT number must be 10 or 11 digits" }) + @MaxLength(64) vatNumber?: string; // `fanNumber` is deliberately absent: the FAN is a Fayda number, so it would 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 index 480606db8..3b3601711 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/SourcedField.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/SourcedField.tsx @@ -1,7 +1,7 @@ import { Badge, Group, Stack, Text } from "@mantine/core"; import type { ReactNode } from "react"; -/** Where a prefilled value came from, shown as a badge next to it. */ +/** Where a locked value came from, shown as a badge next to it. */ export type FieldSource = "eTrade" | "Fayda"; const SOURCE_NOTE: Record = { @@ -10,35 +10,37 @@ const SOURCE_NOTE: Record = { }; /** - * One person-detail field that may already be answered for us. + * One person-detail field that a verification may have taken ownership of. * - * The onboarding wizard fills what it can from the eTrade lookup and the Fayda - * verification, and asks the customer only for what neither supplied. Both - * sources are patchy in practice — eTrade returns no email at all and often no - * manager name; Fayda's email and phone claims are optional and routinely come - * back empty — so "what is missing" varies per company and cannot be decided - * once at build time. + * `locked` — not "does a value exist" — decides which side renders. That + * distinction is the whole point: `ownerName` holds a value the moment the + * eTrade lookup prefills it or the customer types it and the step saves, and + * keying off presence meant the input a customer 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 Fayda verification actually owns a field — the API refuses + * to overwrite those — so only those lock. * - * This is the single place that decision is rendered: a supplied value shows - * read-only with its provenance, a gap shows the input. It pairs with - * `requiredKeys` in CompanyProfileForm, which requires exactly the fields that - * fall through to `children` — the invariant being that **a field is required - * if and only if there is an input on screen to satisfy it**. + * 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, + locked, children, }: { label: string; - /** The value a source supplied. Blank/absent means "ask the customer". */ + /** The value to display when locked. */ value?: string | null; source: FieldSource; - /** The input rendered when no source supplied a value. */ + /** A verification owns this field: show it read-only instead of an input. */ + locked: boolean; + /** The input rendered whenever the field is still the customer's to fill. */ children: ReactNode; }) { - if (!value?.trim()) return <>{children}; + if (!locked || !value?.trim()) return <>{children}; return ( diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/OwnerStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/OwnerStep.tsx index 7c7c05fec..7861f4818 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/OwnerStep.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/OwnerStep.tsx @@ -14,12 +14,15 @@ export interface OwnerStepProps { /** eTrade's registered manager, once a TIN lookup has succeeded. */ etradeOwner: { name: string; phone: string } | null; /** - * Which of the owner's details neither eTrade nor Fayda supplied, and are - * therefore typed here. Computed by CompanyProfileForm, which requires - * exactly these in the schema — so every input below is one the customer is - * actually asked to fill, and nothing is required that has no input. + * Which of the owner's details a Fayda verification owns. A locked field is + * shown read-only; every other one is an editable input, prefilled from + * eTrade or from what was saved earlier. CompanyProfileForm computes this and + * requires exactly the unlocked fields, so every input on screen is one the + * customer is actually asked to fill and nothing is required that has none. */ - gaps: { name: boolean; email: boolean; phone: boolean }; + locked: { name: boolean; email: boolean; phone: boolean }; + /** A co-operative union or farm: no licence, so no eTrade record to match. */ + cooperative?: boolean; } /** @@ -32,12 +35,17 @@ export interface OwnerStepProps { * onboarding is often not the person on the licence, and stamping their name, * email and phone onto the owner turned three required fields into a guess * wearing the licence's authority. + * + * A co-operative union or farm has no licence, so there is nobody named on one + * — the owner is simply the person who runs it, typed in full and compared + * against nothing. */ export default function OwnerStep({ form, identity, etradeOwner, - gaps, + locked, + cooperative = false, }: OwnerStepProps) { const { register, @@ -45,16 +53,7 @@ export default function OwnerStep({ formState: { errors }, } = form; - // A verified owner's own claims outrank eTrade's record for display: the - // government IdP is the higher-trust source, and the API locks those fields - // to it. eTrade still supplies the name and phone when there is no - // verification — which is the case for every company represented by a PoA. const ownerVerified = identity?.owner.verified ?? false; - const nameValue = identity?.owner.name || etradeOwner?.name || ""; - const phoneValue = identity?.owner.phone || etradeOwner?.phone || ""; - const emailValue = identity?.owner.email || ""; - const nameSource = identity?.owner.name ? "Fayda" : "eTrade"; - const phoneSource = identity?.owner.phone ? "Fayda" : "eTrade"; // A Fayda verification that names someone other than the person on the // licence is the one thing this step exists to catch. Advisory here — the two @@ -65,12 +64,12 @@ export default function OwnerStep({ return ( - These are the details of the person registered on your eTrade licence. - We fill in whatever eTrade and Fayda gave us; anything they left blank - we need from you. + {cooperative + ? "The person who runs the co-operative union or farm. We have no licence record to fill these in from, so we need all of them from you." + : "These are the details of the person registered on your eTrade licence. We fill in whatever eTrade and Fayda gave us; anything they left blank we need from you."} - {!etradeOwner && !ownerVerified && ( + {!cooperative && !etradeOwner && !ownerVerified && ( }> Your eTrade licence didn't list a manager, so there's nothing for us to prefill. Enter the details of the person registered on it. @@ -84,15 +83,19 @@ export default function OwnerStep({ icon={} title="This doesn't match your eTrade licence" > - Your licence lists{" "} - {identity?.etradeManagerName}, but the name here is{" "} - {nameValue}. You can continue, but our team will - check this before approving your account — so make sure it's the - person the licence actually names. + Your licence lists {identity?.etradeManagerName}, but + the name here is {identity?.owner.name}. You can + continue, but our team will check this before approving your account — + so make sure it's the person the licence actually names. )} - + - + {/* eTrade never returns an email for the manager and Fayda's email claim is optional, so this is the field most companies actually type — it is required either way (`REQUIRED_COMPANY_INFO`). */} - + ; identity?: CompanyIdentityState; @@ -32,11 +36,26 @@ export interface RepresentationStepProps { /** A declaration change is in flight. */ declarePending: boolean; /** - * Which of the representative's details the Fayda verification did not - * supply. Same contract as OwnerStep's `gaps`: an input is rendered for - * exactly these, and the schema requires exactly these. + * The answer is not the company's to change — it operates as a freight + * forwarder, which the API forces to "yes". No alert says so: the summary + * simply offers no way back, which is the same information without the + * lecture. */ - gaps: { name: boolean; email: boolean; phone: boolean; address: boolean }; + declarationLocked: boolean; + /** + * Which of the representative's details the Fayda verification owns. Same + * contract as OwnerStep's `locked`: a locked field is read-only, every other + * one is an input, and the schema requires exactly the unlocked ones. + */ + locked: { name: boolean; email: boolean; phone: boolean; address: boolean }; + /** + * How a foreign company is proving the subject. Null until it picks — the + * either/or is a fork, not a fallback, so nothing below it renders until one + * side is chosen. Always "fayda" for an Ethiopian company, which has no + * choice to make. + */ + method: IdentityMethod | null; + onMethodChange: (method: IdentityMethod) => void; /** Single-field upload setting carrying just the DARS delegation letter. */ poaDocumentSetting?: FileUploadSetting; documentFiles: Record; @@ -64,7 +83,10 @@ export default function RepresentationStep({ identity, onDeclare, declarePending, - gaps, + declarationLocked, + locked, + method, + onMethodChange, poaDocumentSetting, documentFiles, uploadedDocumentKeys, @@ -86,184 +108,220 @@ export default function RepresentationStep({ } const declared = identity.poaDeclared; - const locked = identity.poaDeclared === "yes" && identity.subject === "poa"; - // Only the API knows whether the lock is the freight-forwarder rule; it - // reports the answer as "yes" for them no matter what is stored, so a company - // that cannot switch to "no" is one the API will refuse. Rather than - // duplicating the role check here, the "no" card simply reports the refusal. const passportAccepted = identity.passportAccepted; + const subject = declared === "yes" ? identity.poa : identity.owner; + const verified = subject.verified; + + // Nothing below the fork renders until the person is actually established: + // a Fayda claim that came back, or the passport path deliberately chosen. + // Asking for a name before the verification runs is asking for a value the + // verification is about to overwrite. + const established = verified || method === "passport"; + + const who = declared === "yes" ? "Representative" : "Owner"; + const passportField = + declared === "yes" ? "poaPassportNumber" : "ownerPassportNumber"; return ( - - - Does anyone hold power of attorney for this company? - - - Your answer decides whose identity we verify — the representative's, - or the owner's. - - - - - } - selected={declared === "yes"} - onClick={ - declarePending || declared === "yes" - ? undefined - : () => onDeclare("yes") - } - /> - } - selected={declared === "no"} - onClick={ - declarePending || declared === "no" - ? undefined - : () => onDeclare("no") - } - /> - - - {locked && ( - }> - As a freight forwarder you act on other companies' behalf, so a Power - of Attorney is required — this can't be set to "no" while you hold the - freight forwarder role. - - )} - - {declared === null && ( - - Pick one to continue. - - )} - - {/* ---------------------------------------------------------------- */} - {/* No representative → the owner is the one who verifies. */} - {/* ---------------------------------------------------------------- */} - {declared === "no" && ( + {/* ------------------------------------------------------------------ */} + {/* The question. Once answered it collapses to its answer, so the step */} + {/* is about the person rather than re-presenting a settled choice. */} + {/* ------------------------------------------------------------------ */} + {declared === null ? ( <> - - - {/* Fayda is an Ethiopian national ID, so a foreign company's owner - may hold none — a passport number proves them instead. Offered - alongside, not after: either one satisfies the gate. */} - {passportAccepted && !identity.owner.verified && ( - + + Does anyone hold power of attorney for this company? + + + Your answer decides whose identity we verify — the + representative's, or the owner's. + + + + + } + selected={false} + onClick={declarePending ? undefined : () => onDeclare("yes")} /> - )} - - )} - - {/* ---------------------------------------------------------------- */} - {/* A representative → they verify, and the delegation is evidenced. */} - {/* ---------------------------------------------------------------- */} - {declared === "yes" && ( - <> - - - {passportAccepted && !identity.poa.verified && ( - } + selected={false} + onClick={declarePending ? undefined : () => onDeclare("no")} /> - )} - - {/* Whatever the Fayda claim carried is shown on the panel above and - never typed here — the verification owns it. The rest is asked - for outright, because the API demands name/email/phone from any - declared representative (`REQUIRED_POA_FIELDS`). */} - - - - - - - - - - - - + + ) : ( + : } + label={ + declared === "yes" + ? "A representative holds power of attorney" + : "The owner acts for the company" + } + detail={ + declared === "yes" + ? "We'll verify their identity and ask for the DARS delegation paper." + : "Nobody holds power of attorney, so we verify the owner." + } + onChange={ + declarePending || declarationLocked + ? undefined + : () => onDeclare(declared === "yes" ? "no" : "yes") + } + changeLabel={declared === "yes" ? "We have no representative" : "We have a representative"} + /> + )} - {gaps.address && ( - + {declared !== null && ( + <> + + + {/* -------------------------------------------------------------- */} + {/* How the person is proved. Ethiopian: Fayda, no choice. Foreign: */} + {/* Fayda or a passport — one or the other, picked outright. */} + {/* -------------------------------------------------------------- */} + {passportAccepted && !verified && method === null ? ( + + + How would you like to prove {who.toLowerCase()}'s identity? + + + Either one is enough — you don't need both. + + + } + selected={false} + onClick={() => onMethodChange("fayda")} + /> + } + selected={false} + onClick={() => onMethodChange("passport")} + /> + + + ) : ( + <> + {(method === "fayda" || !passportAccepted || verified) && ( + + )} + + {passportAccepted && !verified && method === "passport" && ( + + )} + + {passportAccepted && !verified && method !== null && ( + + )} + )} - {poaDocumentSetting && ( + {/* -------------------------------------------------------------- */} + {/* The representative's own details — only once the person exists, */} + {/* and only the parts the verification did not already carry. What */} + {/* Fayda supplied is shown on the panel above and never repeated. */} + {/* -------------------------------------------------------------- */} + {declared === "yes" && established && ( <> - - - Upload the delegation paper authenticated by DARS. It is what - evidences that this person was actually delegated. - - + + + + + + + + + + + + + + + {!locked.address && ( + + )} + + {poaDocumentSetting && ( + <> + + + + )} )} @@ -271,3 +329,41 @@ export default function RepresentationStep({ ); } + +/** A settled choice, shown as its answer with a way back to the question. */ +function ChoiceSummary({ + icon, + label, + detail, + onChange, + changeLabel, +}: { + icon: React.ReactNode; + label: string; + detail: string; + onChange?: () => void; + changeLabel: string; +}) { + return ( + + + + {icon} + + + {label} + + + {detail} + + + + {onChange && ( + + )} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx index 2c03aaed3..9399c5efc 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx @@ -69,6 +69,15 @@ export default function TabOwner({ // Only the person the declaration points at carries the verification, so the // panel is offered here only when that person is the owner. const ownerIsSubject = identity?.subject === "owner"; + // A Fayda verification owns what its claims filled — the API refuses to + // overwrite those, so they show read-only. Anything it left blank stays + // editable here, whatever value is currently stored. + const ownerVerified = owner?.verified ?? false; + const ownerLocked = { + name: ownerVerified && Boolean(owner?.name?.trim()), + email: ownerVerified && Boolean(owner?.email?.trim()), + phone: ownerVerified && Boolean(owner?.phone?.trim()), + }; const { register, @@ -147,7 +156,12 @@ export default function TabOwner({ /> )} - + - + - + - {requirePoa - ? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney is required." - : "Tell us whether anyone is authorised to act for the company — your answer decides whose identity we verify."}{" "} + {/* The "Required for freight forwarder" badge above already says why + a forwarder has no choice here; repeating it in prose was a + lecture, not information. */} + {!requirePoa && + "Tell us whether anyone is authorised to act for the company — your answer decides whose identity we verify. "} {declared === "no" ? "The owner acts for the company, so no delegation paper is needed." : "A representative must be identified, and the DARS delegation paper authorising them uploaded."} From d6e349f32961e26b010f5115bc435c03415b4fde Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 11 Aug 2026 12:54:51 +0000 Subject: [PATCH 12/15] feat(companies): onboard co-operative unions and farms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They hold a TIN but no business licence, so there is no eTrade record to look their registration up in. A checkbox on the first wizard step marks them, and everything that assumed a trade licence bends around it: - The company step replaces the eTrade lookup with typed registration details — name, region, zone, woreda, kebele, house number — required exactly because they are now on screen. applyEtradeSourcedFields skips the lookup rather than failing it, so what the customer sends is what is stored. - No freight-forwarder role. Forwarding is licensed work, so the option is not offered, and the API refuses it at start-onboarding and at every later role-add rather than letting approval fail on a document they cannot produce. - No per-role business-licence upload, client-side or in the completion gate. - Their own document set (company_onboarding_documents_cooperative) merges on top of the nationality one, admin-managed like every other set. Nationality wins a fileKey collision so no slot renders twice, and the DARS paper is not injected into it — the set it merges onto already carries one. - The owner is typed in full; with no eTrade manager on file the licence comparison reports "nothing to compare against", which backoffice now explains rather than leaving as a bare dash. Stored as an attributes flag, not a column: everything it changes is behavioural, and nothing queries or joins on it. --- apps/edr-freight-api/src/app.module.ts | 2 + .../migrations/3400000000000-StampSettings.ts | 28 +++ .../billing/documents/documents.module.ts | 4 +- .../documents/invoice-document.service.ts | 35 +++- .../modules/companies/companies.controller.ts | 1 + .../companies/companies.role-deselect.spec.ts | 8 +- .../modules/companies/companies.service.ts | 98 ++++++++++- .../onboarding-requirements-response.dto.ts | 14 ++ .../companies/dto/profile-response.dto.ts | 9 +- .../companies/dto/response-company.dto.ts | 8 + .../companies/dto/start-onboarding.dto.ts | 19 +- .../companies/entities/company.entity.ts | 19 ++ .../file-upload-settings.service.ts | 5 + .../poa-delegation.constants.ts | 8 + .../dto/update-stamp-setting.dto.ts | 9 + .../entities/stamp-setting.entity.ts | 24 +++ .../stamp-settings.controller.ts | 39 ++++ .../stamp-settings/stamp-settings.module.ts | 23 +++ .../stamp-settings.repository.ts | 21 +++ .../stamp-settings/stamp-settings.service.ts | 154 ++++++++++++++++ .../src/seed/file-upload-settings.seeder.ts | 33 ++++ .../src/seed/freight-permissions.registry.ts | 30 ++++ apps/edr-freight-web/backoffice/src/App.tsx | 20 +++ .../components/layout/sidebar-sections.tsx | 12 ++ .../backoffice/src/hooks/useStampSettings.ts | 45 +++++ .../backoffice/src/lib/permissions.ts | 10 ++ .../pages/customers/CustomerDetailPage.tsx | 15 ++ .../settings/InvoiceStampSettingsPage.tsx | 88 ++++++++++ .../Settings/uploadTeeterandSingature.tsx | 12 +- .../src/services/stampSettings.service.ts | 31 ++++ .../backoffice/src/types/customer.ts | 6 + .../onboarding/OnboardingWizardDialog.tsx | 37 +++- .../src/pages/accounts/CompanyProfileForm.tsx | 166 ++++++++++++++---- .../companyProfileForm/schema.test.ts | 68 ++++--- .../accounts/companyProfileForm/schema.ts | 43 +++-- .../steps/CompanyInfoStep.tsx | 137 +++++++++++---- .../pages/settings/OnboardingRoleSelect.tsx | 11 +- .../portal/src/services/api.ts | 2 + .../portal/src/services/companies.service.ts | 10 ++ .../portal/src/types/profile.ts | 2 + 40 files changed, 1188 insertions(+), 118 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3400000000000-StampSettings.ts create mode 100644 apps/edr-freight-api/src/modules/stamp-settings/dto/update-stamp-setting.dto.ts create mode 100644 apps/edr-freight-api/src/modules/stamp-settings/entities/stamp-setting.entity.ts create mode 100644 apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.controller.ts create mode 100644 apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.module.ts create mode 100644 apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.repository.ts create mode 100644 apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/hooks/useStampSettings.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/settings/InvoiceStampSettingsPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/stampSettings.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index c1b890719..029f2040b 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -49,6 +49,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"; @@ -221,6 +222,7 @@ if (!process.env.APPLICATION_NAME) { FileUploadSettingsModule, DropdownSettingsModule, ExchangeSettingsModule, + StampSettingsModule, ContractTemplatesModule, SupportContentModule, OtpModule, 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..72146491d 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,5 +1,6 @@ import { Injectable } from "@nestjs/common"; +import { StampSettingsService } from "../../stamp-settings/stamp-settings.service"; import { PdfRenderService } from "./pdf-render.service"; import { PdfColor, @@ -53,6 +54,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 +71,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 +94,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 +239,10 @@ export class InvoiceDocumentService { const showCategory = Boolean(model.categoryHeader); const sealText = model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); + const sealMarkup = model.stampImageUrl + ? `Company stamp` + : esc(sealText); + const sealClass = model.stampImageUrl ? "seal seal-image" : "seal"; const summaryRows = model.summary .map((row) => `
${esc(row.label)}${esc(row.value)}
`) @@ -256,6 +281,8 @@ 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; } + .seal.seal-image { border: none; border-radius: 0; opacity: 1; transform: none; } + .seal img { max-width: 100%; max-height: 100%; object-fit: contain; } .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 +310,7 @@ export class InvoiceDocumentService { Issued: ${esc(date(model.issuedAt))}
-
${esc(sealText)}
+
${sealMarkup}
${summaryRows}
diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index a0c609f67..d35400f1a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -266,6 +266,7 @@ export class CompaniesController { dto.companyType, dto.roles, dto.nationality, + dto.cooperative, ); return new CompanyInfoResponseDto(profile, company); } diff --git a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts index 5961be90f..ea81e4dbe 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts @@ -26,7 +26,13 @@ function makeService(existing: ExistingProfile[]) { })), softDelete: jest.fn(async () => undefined), }; - const companiesRepo = { update: jest.fn(async () => null) }; + // `findById` is only consulted when the co-operative flag is in play (adding + // a forwarder role, or setting the flag itself) — a plain company row is the + // right answer for every case here. + const companiesRepo = { + update: jest.fn(async () => null), + findById: jest.fn(async () => ({ id: "company-1", attributes: {} })), + }; const profilesRepo = { findByUserId: jest.fn(async () => ({ id: "external-1", diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 4362c4285..cf2f2e84d 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -24,6 +24,7 @@ import { FilesService } from "../files/files.service"; import { FileRecord } from "../files/entities/file.entity"; import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; import { + COOPERATIVE_ONBOARDING_CODE, POA_DELEGATION_FILE_KEY, POA_DELEGATION_LABEL, POA_DELEGATION_PENDING_CODE, @@ -60,6 +61,8 @@ import { CompanyNationality, CompanyStatus, CompanyType, + COOPERATIVE_KEY, + isCooperative, } from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; import { @@ -262,6 +265,7 @@ export class CompaniesService { : "company_onboarding_documents_ethiopian"; } + async createCompany(dto: CreateCompanyDto): Promise { const exists = await this.companiesRepo.existsByTin(dto.tin); if (exists) { @@ -368,19 +372,41 @@ export class CompaniesService { companyType: CompanyType, roles: ProfileType[], nationality?: CompanyNationality, + cooperative?: boolean, ): Promise<{ profile: ExternalProfile; company: Company }> { // Already started — reuse the existing draft, just ensure roles exist and // keep the nationality up to date if it was (re)selected. const existing = await this.profilesRepo.findByUserId(identity.userId); if (existing) { const companyId = existing.company?.id ?? existing.companyId; + // Only load the row when the answer actually depends on it: to merge the + // flag into `attributes`, or to read a stored one the caller didn't send. + const needsCompany = + cooperative !== undefined || + roles.includes(ProfileType.freightForwarder); + const current = needsCompany + ? await this.companiesRepo.findById(companyId) + : null; + this.assertRolesAllowedForCooperative( + cooperative ?? isCooperative(current), + roles, + ); await this.syncCompanyProfiles(companyId, companyType, roles); - if (nationality) { - await this.companiesRepo.update(companyId, { nationality }); + const updates: Partial = {}; + if (nationality) updates.nationality = nationality; + if (cooperative !== undefined) { + updates.attributes = { + ...(current?.attributes ?? {}), + [COOPERATIVE_KEY]: cooperative, + }; + } + if (Object.keys(updates).length > 0) { + await this.companiesRepo.update(companyId, updates); } return this.getCompanyInfoByUserId(identity.userId); } + this.assertRolesAllowedForCooperative(cooperative === true, roles); const allowedTypes = this.getProfileTypeForCompanyType(companyType); const chosenTypes = roles.filter((t) => allowedTypes.includes(t)); @@ -393,6 +419,7 @@ export class CompaniesService { country: "Ethiopia", nationality: nationality ?? CompanyNationality.Ethiopian, status: CompanyStatus.Pending, + ...(cooperative ? { attributes: { [COOPERATIVE_KEY]: true } } : {}), }); await this.profilesRepo.create({ @@ -410,6 +437,27 @@ export class CompaniesService { return this.getCompanyInfoByUserId(identity.userId); } + /** + * A co-operative union or farm cannot hold the freight-forwarder role. + * + * Forwarding is licensed work — the forwarder signs on other companies' + * behalf, which is why the role carries a mandatory Power of Attorney and a + * DARS delegation paper. A co-op is here precisely because it has no business + * licence, so the role is refused at the door rather than left to fail later + * at approval with a document it can never produce. + */ + private assertRolesAllowedForCooperative( + cooperative: boolean, + roles: ProfileType[], + ): void { + if (!cooperative) return; + if (roles.includes(ProfileType.freightForwarder)) { + throw new BadRequestException( + "A co-operative union or farm cannot register as a freight forwarder — that role requires a business licence.", + ); + } + } + /** * Reconcile the company's operational profiles with the roles the user has * selected: create the missing ones, drop the ones they deselected. @@ -1887,6 +1935,7 @@ export class CompaniesService { // without a Power of Attorney and its DARS paper — checked here so the // customer is told at the point of asking, not at review. if (type === ProfileType.freightForwarder) { + this.assertRolesAllowedForCooperative(isCooperative(company), [type]); const asForwarder = this.withProfileType(company, type); this.assertIdentityVerified(asForwarder); await this.assertPoaDelegationSatisfied( @@ -1933,6 +1982,7 @@ export class CompaniesService { let created = await this.companyProfilesRepo.findByType(companyId, type); if (!created && type === ProfileType.freightForwarder) { + this.assertRolesAllowedForCooperative(isCooperative(company), [type]); const asForwarder = this.withProfileType(company, type); this.assertIdentityVerified(asForwarder); await this.assertPoaDelegationSatisfied( @@ -1986,18 +2036,35 @@ export class CompaniesService { .filter((f) => !f.get(company)) .map((f) => ({ key: f.key, label: f.label })); - // 2. Nationality-based company documents + which are already uploaded. + // 2. Nationality-based company documents + which are already uploaded. A + // co-operative adds its own set on top: it provides everything its + // nationality demands, plus the papers standing in for the business licence + // it does not hold. + const cooperative = isCooperative(company); const documentSettingCode = this.documentSettingCodeFor( company.nationality, ); - const [setting, uploadedFiles] = await Promise.all([ + const [setting, coopSetting, uploadedFiles] = await Promise.all([ this.fileUploadSettingsService .getByCode(documentSettingCode) .catch(() => null), + cooperative + ? this.fileUploadSettingsService + .getByCode(COOPERATIVE_ONBOARDING_CODE) + .catch(() => null) + : Promise.resolve(null), this.filesService.findByResource(company.id, "companies"), ]); const uploadedCodes = new Set(uploadedFiles.map((f) => f.code)); - const documents = (setting?.fields ?? []) + // The co-op set is admin-managed and could name a fileKey the nationality + // set already carries; the nationality field wins so the same slot is never + // rendered (or required) twice. + const baseFields = setting?.fields ?? []; + const baseKeys = new Set(baseFields.map((f) => f.fileKey)); + const documents = [ + ...baseFields, + ...(coopSetting?.fields ?? []).filter((f) => !baseKeys.has(f.fileKey)), + ] .slice() .sort((a, b) => a.displayOrder - b.displayOrder) .map((f) => ({ @@ -2029,7 +2096,13 @@ export class CompaniesService { }; }), ); - const missingLicenses = licenseProfiles.filter((p) => !p.uploaded); + // A co-operative holds no business licence — that is the whole reason it + // skips the eTrade lookup — so the per-role licence is not owed. Its own + // document set (merged above) is what stands in for it. The profiles are + // still reported so the portal can show them; only the requirement lifts. + const missingLicenses = cooperative + ? [] + : licenseProfiles.filter((p) => !p.uploaded); // 4. Power of Attorney. Whether there is one at all is the company's own // declaration — the question the wizard asks outright — and that answer is @@ -2115,7 +2188,7 @@ export class CompaniesService { const total = requiredInfo.length + requiredDocCount + - licenseProfiles.length + + (cooperative ? 0 : licenseProfiles.length) + poaItemCount + // The declaration and the verification it selects. 2; @@ -2130,7 +2203,11 @@ export class CompaniesService { return new OnboardingRequirementsResponseDto({ documentSettingCode, + cooperativeDocumentSettingCode: cooperative + ? COOPERATIVE_ONBOARDING_CODE + : null, nationality: company.nationality ?? CompanyNationality.Ethiopian, + cooperative, companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo, @@ -3369,6 +3446,13 @@ export class CompaniesService { company: Company, dto: UpdateProfileDto & { etradeManager?: { name: string; phone: string } }, ): Promise { + // A co-operative union or farm has a TIN but no business licence, so eTrade holds no + // record to check these against — the customer types the company name and + // the registered address themselves, and what they send IS the data. The + // check is skipped rather than failed: running the lookup would 400 every + // save with "no registration found for this TIN". + if (isCooperative(company)) return; + const touched = ETRADE_SOURCED_FIELDS.some( (key) => key !== "tin" && dto[key] !== undefined, ); diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index b1baa2553..8d47dbb4e 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -68,7 +68,19 @@ export interface OnboardingPoaState { export class OnboardingRequirementsResponseDto { /** Resolved document setting code (by nationality) the docs were drawn from. */ documentSettingCode: string; + /** + * The co-operative document set, merged on top of the nationality one — null + * for every other company. `documents` below already carries the merged + * result; this is only so the portal can fetch the same extra fields when it + * renders the pickers from the file-settings endpoint. + */ + cooperativeDocumentSettingCode: string | null; nationality: string; + /** + * The company trades as a co-operative: no business licence, so no eTrade + * lookup, no per-role licence upload, and no freight-forwarder role. + */ + cooperative: boolean; /** Required company-information fields and whether each is filled. */ companyInfo: { @@ -106,7 +118,9 @@ export class OnboardingRequirementsResponseDto { constructor(init: Omit) { this.documentSettingCode = init.documentSettingCode; + this.cooperativeDocumentSettingCode = init.cooperativeDocumentSettingCode; this.nationality = init.nationality; + this.cooperative = init.cooperative; this.companyInfo = init.companyInfo; this.documents = init.documents; this.licenseProfiles = init.licenseProfiles; diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index dc2ddc4c3..0d7293ab0 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -2,7 +2,7 @@ import { buildCompanyIdentityState, CompanyIdentityStateDto, } from "./complete-identity-verification.dto"; -import { Company } from "../entities/company.entity"; +import { Company, isCooperative } from "../entities/company.entity"; import { ExternalProfile } from "../entities/external-profile.entity"; import { ChangeRequestStatus, @@ -15,6 +15,12 @@ export class ProfileResponseDto { companyName: string; companyType: string; nationality: string | null; + /** + * The company trades as a co-operative: it has a TIN but no business licence, + * so the company step collects the registration by hand instead of fetching + * it from eTrade. + */ + cooperative: boolean; companyLocation: string; companyAddress: string | null; tinNumber: string; @@ -86,6 +92,7 @@ export class ProfileResponseDto { this.companyName = company.name; this.companyType = company.type; this.nationality = company.nationality ?? null; + this.cooperative = isCooperative(company); this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ?? []; diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index b78925285..e75182889 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -3,6 +3,7 @@ import { CompanyType, CompanyStatus, CompanyNationality, + isCooperative, } from '../entities/company.entity'; import { CompanyProfile, @@ -55,6 +56,12 @@ export class ResponseCompanyDto { type: CompanyType; status: CompanyStatus; nationality?: CompanyNationality | null; + /** + * The company trades as a co-operative: no business licence, so its + * registration was typed rather than fetched from eTrade and there is no + * eTrade manager to check the owner against. + */ + cooperative: boolean; tin: string; vatNumber?: string | null; fanNumber?: string | null; @@ -110,6 +117,7 @@ export class ResponseCompanyDto { this.type = company.type; this.status = company.status; this.nationality = company.nationality ?? null; + this.cooperative = isCooperative(company); this.tin = company.tin; this.vatNumber = company.vatNumber; this.fanNumber = company.fanNumber; diff --git a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts index 7687faab1..91fcb44a1 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts @@ -1,4 +1,10 @@ -import { ArrayMinSize, IsArray, IsEnum, IsOptional } from "class-validator"; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsEnum, + IsOptional, +} from "class-validator"; import { CompanyNationality, CompanyType } from "../entities/company.entity"; import { ProfileType } from "../entities/company-profile.entity"; @@ -14,4 +20,15 @@ export class StartOnboardingDto { @IsOptional() @IsEnum(CompanyNationality) nationality?: CompanyNationality; + + /** + * The company trades as a co-operative: it holds a TIN but no business + * licence, so there is no eTrade record to fetch its registration from. + * Chosen on the same step as the nationality and the roles, because it + * decides all three of what the next step asks for, which documents apply, + * and which roles are even available (a co-op cannot freight-forward). + */ + @IsOptional() + @IsBoolean() + cooperative?: boolean; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index 2ecfa3deb..54c36bf41 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -32,6 +32,25 @@ export enum CompanyNationality { Foreign = "foreign", } +/** + * `attributes` key marking a co-operative union or farm. + * + * Such a company has a TIN but no business licence, so there is no eTrade record to + * look its registration up in — the company name, registered address and the + * owner are all typed instead of fetched, and the eTrade authenticity check is + * skipped rather than failed. It is a flag rather than a column because + * everything it changes is behavioural (which lookup runs, which documents + * apply, which roles are offered); nothing queries or joins on it. + */ +export const COOPERATIVE_KEY = "cooperative"; + +/** Is this a co-operative union or farm (a TIN, but no business licence)? */ +export function isCooperative( + company: Pick | null | undefined, +): boolean { + return company?.attributes?.[COOPERATIVE_KEY] === true; +} + @Entity({ schema: "freight", name: "companies" }) @Index(["tin"]) @Index(["type"]) diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts index 9651d4f37..21ddc4c91 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.service.ts @@ -17,6 +17,7 @@ import { } from "./interfaces/file-upload-settings.repository.interface"; import { COMPANY_ONBOARDING_CODE_PREFIX, + COOPERATIVE_ONBOARDING_CODE, POA_DELEGATION_FILE_KEY, poaDelegationField, } from "./poa-delegation.constants"; @@ -56,6 +57,10 @@ export class FileUploadSettingsService { */ private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting { if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting; + // The co-operative set is merged ON TOP of a nationality set that already + // carries the paper; injecting it here too would hand the portal the same + // slot twice. + if (setting.code === COOPERATIVE_ONBOARDING_CODE) return setting; const fields = setting.fields ?? []; if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting; diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts index 7f8a34175..e8d593ded 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/poa-delegation.constants.ts @@ -26,6 +26,14 @@ export const POA_DELEGATION_LABEL = "DARS Delegation Paper"; /** Prefix of the setting codes the field is injected into. */ export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_"; +/** + * The co-operative onboarding set. Unlike the nationality sets it is ADDITIVE — + * merged on top of the company's `_ethiopian`/`_foreign` set rather than + * replacing it — which is why the delegation paper is not injected into it: the + * set it is merged onto already carries one. + */ +export const COOPERATIVE_ONBOARDING_CODE = `${COMPANY_ONBOARDING_CODE_PREFIX}cooperative`; + const POA_DELEGATION_HELP = "Delegation paper issued by the Documents Authentication and Registration " + "Service (DARS) delegating the representative named above. Upload the " + diff --git a/apps/edr-freight-api/src/modules/stamp-settings/dto/update-stamp-setting.dto.ts b/apps/edr-freight-api/src/modules/stamp-settings/dto/update-stamp-setting.dto.ts new file mode 100644 index 000000000..96f86bf61 --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/dto/update-stamp-setting.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, MinLength } from "class-validator"; + +export class UpdateStampSettingDto { + @ApiProperty({ description: "Stamp image as a base64 data URL (PNG/JPG)." }) + @IsString() + @MinLength(1) + stampImageBase64!: string; +} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/entities/stamp-setting.entity.ts b/apps/edr-freight-api/src/modules/stamp-settings/entities/stamp-setting.entity.ts new file mode 100644 index 000000000..7ab7a0e2c --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/entities/stamp-setting.entity.ts @@ -0,0 +1,24 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, JoinColumn, ManyToOne } from "typeorm"; + +import { FileRecord } from "../../files/entities/file.entity"; + +/** + * Single-row table holding the one company stamp/seal image stamped onto + * generated invoice/receipt PDFs (see InvoiceDocumentService). Mirrors the + * exchange_settings single-row pattern — `get()` lazily creates the row, and + * there is never more than one. + */ +@Entity({ schema: "freight", name: "stamp_settings" }) +export class StampSetting extends BaseEntity { + @Column({ name: "stamp_file_id", type: "uuid", nullable: true }) + stampFileId?: string | null; + + @ManyToOne(() => FileRecord, { nullable: true }) + @JoinColumn({ name: "stamp_file_id" }) + stampFile?: FileRecord | null; + + /** IAM user id of the last operator to set/clear the stamp. */ + @Column({ name: "updated_by_id", type: "uuid", nullable: true }) + updatedById?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.controller.ts b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.controller.ts new file mode 100644 index 000000000..f03d55058 --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.controller.ts @@ -0,0 +1,39 @@ +import { Body, Controller, Delete, Get, Put } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { CurrentUser } from "@edr/api-common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { UpdateStampSettingDto } from "./dto/update-stamp-setting.dto"; +import { StampSettingsService } from "./stamp-settings.service"; + +@ApiTags("stamp-settings") +@ApiBearerAuth() +@Controller("stamp-settings") +export class StampSettingsController { + constructor(private readonly service: StampSettingsService) {} + + @Get() + @BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.view, FREIGHT_PERMS.admin]) + @ApiOperation({ summary: "Current company stamp used on invoice/receipt PDFs" }) + get() { + return this.service.getView(); + } + + @Put() + @BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin]) + @ApiOperation({ summary: "Replace the company stamp" }) + update(@Body() dto: UpdateStampSettingDto, @CurrentUser() user: TCurrentUser) { + return this.service.setStamp(dto.stampImageBase64, user?.id ?? null); + } + + @Delete() + @BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin]) + @ApiOperation({ + summary: "Clear the company stamp (invoices fall back to the plain seal)", + }) + clear(@CurrentUser() user: TCurrentUser) { + return this.service.clearStamp(user?.id ?? null); + } +} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.module.ts b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.module.ts new file mode 100644 index 000000000..6c9fc3a36 --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.module.ts @@ -0,0 +1,23 @@ +import { Global, Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { FilesModule } from "../files/files.module"; +import { MinioModule } from "../minio/minio.module"; +import { StampSetting } from "./entities/stamp-setting.entity"; +import { StampSettingsController } from "./stamp-settings.controller"; +import { StampSettingsRepository } from "./stamp-settings.repository"; +import { StampSettingsService } from "./stamp-settings.service"; + +/** + * Global so DocumentsModule (invoice PDF rendering) can inject + * {@link StampSettingsService} without pulling in a circular billing/warehouse + * dependency — same reasoning as ExchangeSettingsModule. + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([StampSetting]), FilesModule, MinioModule], + controllers: [StampSettingsController], + providers: [StampSettingsRepository, StampSettingsService], + exports: [StampSettingsService], +}) +export class StampSettingsModule {} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.repository.ts b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.repository.ts new file mode 100644 index 000000000..0ca4cfb68 --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.repository.ts @@ -0,0 +1,21 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; + +import { StampSetting } from "./entities/stamp-setting.entity"; + +@Injectable() +export class StampSettingsRepository extends BaseRepository { + constructor( + @InjectRepository(StampSetting) + repo: Repository, + ) { + super(repo); + } + + /** The single settings row, with its stamp file joined, or null before first upload. */ + findSingleton(): Promise { + return this.repository.findOne({ where: {}, relations: ["stampFile"] }); + } +} diff --git a/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.ts b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.ts new file mode 100644 index 000000000..01352e133 --- /dev/null +++ b/apps/edr-freight-api/src/modules/stamp-settings/stamp-settings.service.ts @@ -0,0 +1,154 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { Readable } from "stream"; +import { DataSource } from "typeorm"; + +import { FilesService } from "../files/files.service"; +import { FileRecord } from "../files/entities/file.entity"; +import { MinioService } from "../minio/minio.service"; +import { StampSettingsRepository } from "./stamp-settings.repository"; +import { StampSetting } from "./entities/stamp-setting.entity"; + +export interface StampSettingView { + stampImageUrl: string | null; + updatedById: string | null; + updatedAt: Date | null; +} + +/** + * Owns the single `stamp_settings` row: the one company stamp/seal image used + * on generated invoice/receipt PDFs (see InvoiceDocumentService). Same + * single-row shape as ExchangeSettingsService, but the value is an uploaded + * image (via FilesService) rather than a scalar. + */ +@Injectable() +export class StampSettingsService { + private readonly logger = new Logger(StampSettingsService.name); + + constructor( + private readonly repository: StampSettingsRepository, + private readonly filesService: FilesService, + private readonly minioService: MinioService, + private readonly dataSource: DataSource, + ) {} + + /** The settings row, created empty on first access. */ + async get(): Promise { + const existing = await this.repository.findSingleton(); + if (existing) return existing; + return this.repository.create({ stampFileId: null, updatedById: null }); + } + + /** Current stamp, with the image inlined as a data URL (or null if unset). */ + async getView(): Promise { + const setting = await this.get(); + return { + stampImageUrl: await this.inlineImageUrl(setting.stampFile?.url), + updatedById: setting.updatedById ?? null, + updatedAt: setting.updatedAt ?? null, + }; + } + + /** + * The stamp image for embedding into invoice PDFs. Never throws — invoice + * generation must succeed even if the stamp lookup fails; callers fall back + * to the programmatic seal when this returns null. + */ + async getStampImageUrl(): Promise { + try { + const setting = await this.get(); + return await this.inlineImageUrl(setting.stampFile?.url); + } catch (err) { + this.logger.warn( + `Could not load company stamp for PDF rendering: ${(err as Error).message}`, + ); + return null; + } + } + + /** Replace the stamp image, storing it in MinIO via FilesService. */ + async setStamp( + stampImageBase64: string, + updatedById?: string | null, + ): Promise { + const current = await this.get(); + const previousFileId = current.stampFileId ?? null; + + const fileRecord = await this.filesService.upload({ + resourceId: current.id, + resource: "stamp_settings", + code: "stamp", + file: this.toUploadFile(stampImageBase64), + uploadedByUserId: updatedById ?? null, + }); + + await this.repository.update(current.id, { + stampFileId: fileRecord.id, + updatedById: updatedById ?? null, + }); + + if (previousFileId && previousFileId !== fileRecord.id) { + await this.dataSource.getRepository(FileRecord).delete(previousFileId); + } + + this.logger.log(`Company stamp updated by ${updatedById ?? "unknown user"}`); + return this.getView(); + } + + /** Clear the stamp (invoices fall back to the programmatic seal). */ + async clearStamp(updatedById?: string | null): Promise { + const current = await this.get(); + const previousFileId = current.stampFileId ?? null; + + await this.repository.update(current.id, { + stampFileId: null, + updatedById: updatedById ?? null, + }); + + if (previousFileId) { + await this.dataSource.getRepository(FileRecord).delete(previousFileId); + } + + return this.getView(); + } + + private toUploadFile(base64: string): Express.Multer.File { + const raw = base64.includes(",") ? base64.split(",")[1]! : base64; + const buffer = Buffer.from(raw, "base64"); + return { + fieldname: "stamp", + originalname: "company-stamp.png", + encoding: "7bit", + mimetype: "image/png", + size: buffer.length, + buffer, + stream: Readable.from(buffer), + destination: "", + filename: "", + path: "", + }; + } + + private async inlineImageUrl(url?: string | null): Promise { + if (!url) return null; + if (url.startsWith("data:")) return url; + try { + const objectName = this.minioService.getObjectNameFromUrl(url); + const stream = await this.minioService.getFileStream(objectName); + const buffer = await this.streamToBuffer(stream); + return `data:image/png;base64,${buffer.toString("base64")}`; + } catch { + return url; + } + } + + private streamToBuffer(stream: Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + stream.on("error", reject); + stream.on("end", () => resolve(Buffer.concat(chunks))); + }); + } +} 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 06511cb2b..fb9afb5e1 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1164,6 +1164,26 @@ 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", @@ -1849,6 +1869,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/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index ec63fa53c..58b8286f0 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"; @@ -783,6 +785,24 @@ const App = () => { } /> + + + + } + /> + + + + } + /> , 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/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 09790091d..932604ce3 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -315,6 +315,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/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index f197e1952..a6367786a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -822,6 +822,16 @@ export default function CustomerDetailPage() { : undefined } /> + {/* Why this company's registration was typed rather than + fetched, and why it carries no business licence. */} + @@ -945,6 +955,11 @@ export default function CustomerDetailPage() { > Matches the eTrade licence + ) : company.cooperative ? ( + + A co-operative union or farm holds no trade licence, so + there is no eTrade record to check the owner against. + ) : ( No eTrade manager name on file to compare against. diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/InvoiceStampSettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/InvoiceStampSettingsPage.tsx new file mode 100644 index 000000000..407218d70 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/settings/InvoiceStampSettingsPage.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/common/ui/card"; +import { Button } from "@/shared/common/ui/button"; +import { Save, Trash2 } from "lucide-react"; + +import { StampUpload } from "@/components/contracts/StampUpload"; +import { + useClearStamp, + useSetStamp, + useStampSettingsQuery, +} from "@/hooks/useStampSettings"; + +/** + * The one company stamp/seal stamped onto every generated invoice/receipt + * PDF (InvoiceDocumentService). Single global image — no per-employee choice. + */ +export default function InvoiceStampSettingsPage() { + const { data, isLoading } = useStampSettingsQuery(); + const setStamp = useSetStamp(); + const clearStamp = useClearStamp(); + const [draft, setDraft] = useState(null); + + useEffect(() => { + setDraft(null); + }, [data?.stampImageUrl]); + + const value = draft !== null ? draft : (data?.stampImageUrl ?? null); + const dirty = draft !== null && draft !== data?.stampImageUrl; + + const handleSave = async () => { + if (!draft) return; + await setStamp.mutateAsync(draft); + }; + + const handleClear = async () => { + if (!data?.stampImageUrl) return; + await clearStamp.mutateAsync(); + }; + + return ( +
+ + + Invoice stamp + + Stamped onto every generated invoice and receipt PDF. Replacing it + here changes it everywhere at once — there is no per-invoice or + per-user choice. + + + + + +
+ + {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/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/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 4fd920916..87a71bf6e 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -222,6 +222,12 @@ 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; diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index b9fdca622..6d75e502f 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -1,6 +1,7 @@ import { Box, Button, + Checkbox, Group, Modal, ScrollArea, @@ -164,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 >({}); @@ -214,6 +224,7 @@ 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 @@ -296,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"); @@ -310,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 @@ -463,6 +476,15 @@ export default function OnboardingWizardDialog({ // 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(); @@ -530,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) @@ -537,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/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 93ab3cfc2..f4a788437 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -36,7 +36,9 @@ import type { import CompanyInfoStep from "./companyProfileForm/steps/CompanyInfoStep"; import OwnerStep from "./companyProfileForm/steps/OwnerStep"; import ContactStep from "./companyProfileForm/steps/ContactStep"; -import RepresentationStep from "./companyProfileForm/steps/RepresentationStep"; +import RepresentationStep, { + type IdentityMethod, +} from "./companyProfileForm/steps/RepresentationStep"; import DocumentsStep from "./companyProfileForm/steps/DocumentsStep"; export default function CompanyProfileForm({ @@ -60,6 +62,9 @@ export default function CompanyProfileForm({ onUploadDocuments, identity: rawIdentity, onIdentityChange, + cooperative = false, + declarationLocked = false, + extraDocumentSettingCode, }: { documentSettingCode: string; documentFiles?: Record; @@ -105,6 +110,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 @@ -168,12 +187,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 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, + }), + ); + 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 @@ -388,30 +431,58 @@ export default function CompanyProfileForm({ }; /** - * What no source supplied, per person. + * Which fields a verification owns, per person. * * A Fayda verification owns the fields its claims filled — the API refuses to * let those be overwritten — but its email, phone and address claims are - * optional and routinely come back empty. eTrade fills the owner's name and - * phone, and nothing at all fills an email. + * optional and routinely come back empty. Everything it did NOT fill stays + * the customer's: an editable input, prefilled from eTrade or from what was + * saved earlier, and required precisely because there is an input for it. * - * So "what still has to be asked" varies per company. 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.** + * Keyed off `verified`, deliberately, not off "does a value exist". A value + * exists the moment eTrade prefills the owner or 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. Only a verification locks. */ - const ownerGaps = { - name: !identity?.owner.name?.trim(), - email: !identity?.owner.email?.trim(), - phone: !identity?.owner.phone?.trim(), + const ownerVerified = identity?.owner.verified ?? false; + const poaVerified = identity?.poa.verified ?? false; + const ownerLocked = { + name: ownerVerified && Boolean(identity?.owner.name?.trim()), + email: ownerVerified && Boolean(identity?.owner.email?.trim()), + phone: ownerVerified && Boolean(identity?.owner.phone?.trim()), }; - const poaGaps = { - name: !identity?.poa.name?.trim(), - email: !identity?.poa.email?.trim(), - phone: !identity?.poa.phone?.trim(), - address: !identity?.poa.address?.trim(), + const poaLocked = { + name: poaVerified && Boolean(identity?.poa.name?.trim()), + email: poaVerified && Boolean(identity?.poa.email?.trim()), + phone: poaVerified && Boolean(identity?.poa.phone?.trim()), + address: poaVerified && Boolean(identity?.poa.address?.trim()), }; + /** + * 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. const ownerName = firstPresent(identity?.owner.name, watch("ownerName")); @@ -494,9 +565,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; @@ -549,7 +623,10 @@ 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. @@ -589,18 +666,29 @@ export default function CompanyProfileForm({ Boolean(watch(passportField)?.trim())); const requiredKeys: (keyof FormData)[] = []; - if (step === "owner") { + 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 claim did not already own. - if (ownerGaps.name) requiredKeys.push("ownerName"); - if (ownerGaps.email) requiredKeys.push("ownerEmail"); - if (ownerGaps.phone) requiredKeys.push("ownerPhone"); - } else if (step === "representation" && identity?.poaDeclared === "yes") { - // Only once a representative is actually declared: a company that answered - // "no" has no representative to describe. - if (poaGaps.name) requiredKeys.push("poaName"); - if (poaGaps.email) requiredKeys.push("poaEmail"); - if (poaGaps.phone) requiredKeys.push("poaPhone"); + // is rendered for each one a Fayda verification does not own. + if (!ownerLocked.name) requiredKeys.push("ownerName"); + if (!ownerLocked.email) requiredKeys.push("ownerEmail"); + if (!ownerLocked.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; @@ -695,6 +783,8 @@ export default function CompanyProfileForm({ } // 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( "This TIN is already registered to another company account.", @@ -793,6 +883,7 @@ export default function CompanyProfileForm({ tinStatus={tinStatus} tinVerified={tinVerified} hasRegistrationDetails={hasRegistrationDetails} + cooperative={cooperative} onETradeDataLoaded={handleETradeDataLoaded} onETradeStatusChange={setTinStatus} onETradeReset={handleETradeReset} @@ -804,7 +895,8 @@ export default function CompanyProfileForm({ form={form} identity={identity} etradeOwner={etradeOwner} - gaps={ownerGaps} + locked={ownerLocked} + cooperative={cooperative} /> )} @@ -814,7 +906,10 @@ export default function CompanyProfileForm({ identity={identity} onDeclare={handleDeclare} declarePending={declarePending} - gaps={poaGaps} + declarationLocked={declarationLocked} + locked={poaLocked} + method={effectiveMethod} + onMethodChange={setIdentityMethod} poaDocumentSetting={poaDocumentSetting} documentFiles={documentFiles} uploadedDocumentKeys={uploadedDocumentKeys} @@ -840,7 +935,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} 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 ebff24687..4b5b7cdb7 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 @@ -65,18 +65,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,24 +102,44 @@ 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", + ]), ); }); }); 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 770e565b8..d642e48c7 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 @@ -22,11 +22,11 @@ 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"), + // 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 @@ -40,10 +40,15 @@ export const onboardingSchema = z.object({ 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(), @@ -107,6 +112,12 @@ 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", @@ -184,8 +195,18 @@ export const ETRADE_BUNDLE_FIELDS = [ */ export const stepFields: Record = { // Only what this step actually renders an input for. The company name and the - // registered address are eTrade's, shown read-only. - company: ["tinNumber", "vatNumber"], + // 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"], 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 f57d7c433..5e4ad7f85 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,7 +1,7 @@ -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 { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types"; import ETradeInfo, { type ETradeStatus, } from "@/components/onboarding/ETradeInfo"; @@ -16,6 +16,12 @@ export interface CompanyInfoStepProps { 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; @@ -26,6 +32,7 @@ export default function CompanyInfoStep({ tinStatus, tinVerified, hasRegistrationDetails, + cooperative = false, onETradeDataLoaded, onETradeStatusChange, onETradeReset, @@ -33,6 +40,7 @@ export default function CompanyInfoStep({ const { register, watch, + setValue, formState: { errors }, } = form; @@ -41,43 +49,112 @@ export default function CompanyInfoStep({ = 10 && !errors.vatNumber - ? "done" - : "todo" - } + status={watch("vatNumber")?.trim() && !errors.vatNumber ? "done" : "todo"} > - - - {tinVerified && ( - - )} - + {cooperative ? ( + <> + + + + + + + + + Registered address + + + - setValue("region", v ?? "", { shouldValidate: true }) - } - error={errors.region?.message} - /> - - - - - - - - - ) : ( + : tinVerified + ? "done" + : "todo" + } + > + + {!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 && ( - - {tinVerified && ( - - )} + + + + Registered address + + +
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/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index fb9afb5e1..56664de57 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1301,7 +1301,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", @@ -1317,11 +1320,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", 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/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() { ) : ( )} -