Merge branch 'dev'

This commit is contained in:
Marshal
2026-08-12 06:44:30 +00:00
94 changed files with 5399 additions and 3583 deletions

View File

@@ -48,6 +48,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module";
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
import { SupportContentModule } from "./modules/support-content/support-content.module";
import { OtpModule } from "./modules/otp/otp.module";
@@ -206,6 +207,7 @@ if (!process.env.APPLICATION_NAME) {
FileUploadSettingsModule,
DropdownSettingsModule,
ExchangeSettingsModule,
StampSettingsModule,
ContractTemplatesModule,
SupportContentModule,
OtpModule,

View File

@@ -0,0 +1,99 @@
import { ContractViewModelBuilder, ContractSignatureView } from "./contract-view-model.builder";
/**
* The EDR side of a contract is sealed with the ONE global company stamp, read
* live at render time; the client side keeps whatever stamp the customer
* uploaded. These specs pin that asymmetry — the standing rule is that
* centralizing the EDR seal must not touch customer stamps.
*/
describe("ContractViewModelBuilder.attachProviderStamp", () => {
const STAMP = "data:image/png;base64,RURS";
const build = (stampImageUrl: string | null = STAMP) => {
const getStampImageUrl = jest.fn().mockResolvedValue(stampImageUrl);
const builder = Object.create(
ContractViewModelBuilder.prototype,
) as ContractViewModelBuilder;
Object.assign(builder, { stampSettings: { getStampImageUrl } });
return { builder, getStampImageUrl };
};
const sig = (role: "STAFF" | "CUSTOMER", extra: Partial<ContractSignatureView> = {}) =>
({
role,
signerDisplayName: `${role} signer`,
signedAt: "1 January 2026",
signatureImageUrl: "https://minio.local/sig.png",
...extra,
}) as ContractSignatureView;
it("stamps the EDR side with the global stamp", async () => {
const { builder } = build();
const signatures = [sig("STAFF")];
await builder.attachProviderStamp(signatures);
expect(signatures[0]!.stampImageUrl).toBe(STAMP);
});
it("leaves the customer side untouched", async () => {
const { builder } = build();
const customerStamp = "data:image/png;base64,Q1VTVA==";
const signatures = [
sig("CUSTOMER", { stampImageUrl: customerStamp }),
sig("STAFF"),
];
await builder.attachProviderStamp(signatures);
expect(signatures[0]!.stampImageUrl).toBe(customerStamp);
expect(signatures[1]!.stampImageUrl).toBe(STAMP);
});
it("does not read the stamp at all when EDR has not signed yet", async () => {
const { builder, getStampImageUrl } = build();
const signatures = [sig("CUSTOMER")];
await builder.attachProviderStamp(signatures);
expect(getStampImageUrl).not.toHaveBeenCalled();
expect(signatures[0]!.stampImageUrl).toBeUndefined();
});
it("renders unstamped rather than failing when no stamp is configured", async () => {
const { builder } = build(null);
const signatures = [sig("STAFF")];
await expect(builder.attachProviderStamp(signatures)).resolves.toBeUndefined();
expect(signatures[0]!.stampImageUrl).toBeNull();
});
it("reads the stamp once for every EDR signature row", async () => {
const { builder, getStampImageUrl } = build();
const signatures = [sig("STAFF"), sig("STAFF")];
await builder.attachProviderStamp(signatures);
expect(getStampImageUrl).toHaveBeenCalledTimes(1);
expect(signatures.map((s) => s.stampImageUrl)).toEqual([STAMP, STAMP]);
});
it("is applied by loadSignatures, so the HTML view and the PDF agree", async () => {
const { builder } = build();
Object.assign(builder, {
bookingsRepository: {
findContractSignatures: jest.fn().mockResolvedValue([
{ signerRole: "STAFF", signerDisplayName: "EDR", signedAt: new Date() },
]),
},
});
const views = await (
builder as unknown as {
loadSignatures(id: string): Promise<ContractSignatureView[]>;
}
).loadSignatures("b-1");
expect(views[0]!.stampImageUrl).toBe(STAMP);
});
});

View File

@@ -9,6 +9,7 @@ import {
import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder';
import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder';
import { ContractTemplateResolver } from './contract-template.resolver';
import { StampSettingsService } from '../modules/stamp-settings/stamp-settings.service';
import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
export interface ContractSignatureView {
@@ -16,6 +17,11 @@ export interface ContractSignatureView {
signerDisplayName: string;
signedAt: string;
signatureImageUrl?: string | null;
/**
* Round company seal shown beside the signature. Populated for the EDR
* (STAFF) side only, from the single global stamp — see attachProviderStamp.
*/
stampImageUrl?: string | null;
}
/**
@@ -114,6 +120,7 @@ export class ContractViewModelBuilder {
private readonly templateResolver: ContractTemplateResolver,
private readonly pricingBuilder: ContractPricingScheduleBuilder,
private readonly rateScheduleBuilder: ContractRateScheduleBuilder,
private readonly stampSettings: StampSettingsService,
) {}
async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> {
@@ -194,7 +201,30 @@ export class ContractViewModelBuilder {
private async loadSignatures(bookingId: string): Promise<ContractSignatureView[]> {
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
return rows.map((s) => this.toSignatureView(s));
const views = rows.map((s) => this.toSignatureView(s));
await this.attachProviderStamp(views);
return views;
}
/**
* Stamp the EDR side of the contract with the ONE global company stamp
* (StampSettingsService) — staff never upload or pick a stamp, so nothing is
* stored per signature and the seal is read live at render time. The client
* side is left alone: a customer's own stamp is their business.
*
* Read live and deliberately not snapshotted, so replacing the company stamp
* re-seals contracts on their next render. `getStampImageUrl()` never throws
* and returns a data URL, which `signatures_block.hbs` renders as-is and the
* signature inliner skips.
*/
async attachProviderStamp(signatures: ContractSignatureView[]): Promise<void> {
const staff = signatures.filter((s) => s.role === 'STAFF');
if (staff.length === 0) return;
const stampImageUrl = await this.stampSettings.getStampImageUrl();
for (const sig of staff) {
sig.stampImageUrl = stampImageUrl;
}
}
toSignatureView(row: BookingContractSignature): ContractSignatureView {

View File

@@ -14,10 +14,13 @@ import {
import { AppModule } from "./app.module";
/**
* JSON body ceiling. Signing posts the signature AND the company stamp as
* base64 in one JSON body, and base64 inflates bytes by ~4/3 — a 50MB asset is
* ~67MB on the wire. Express defaults to 100kb, which rejected any real stamp
* image with a 413 "request entity too large".
* JSON body ceiling. Customer signing posts the signature AND the customer's
* own company stamp as base64 in one JSON body, and base64 inflates bytes by
* ~4/3 — a 50MB asset is ~67MB on the wire. Express defaults to 100kb, which
* rejected any real stamp image with a 413 "request entity too large".
* (Staff signing posts only a signature: EDR's seal is the one global stamp,
* read server-side. Uploading that stamp under Settings goes through this same
* ceiling, so the headroom is still needed on both counts.)
*
* Sized to clear the 50MB per-document ceiling
* (`DOCUMENT_UPLOAD_MAX_BYTES`) after base64 inflation, with room for the

View File

@@ -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<void> {
// 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<void> {
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'
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.stamp_settings;`);
}
}

View File

@@ -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],

View File

@@ -1,6 +1,8 @@
import { Injectable } from "@nestjs/common";
import { StampSettingsService } from "../../stamp-settings/stamp-settings.service";
import { PdfRenderService } from "./pdf-render.service";
import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util";
import {
PdfColor,
assembleSinglePagePdf,
@@ -53,6 +55,13 @@ export interface InvoiceDocumentModel {
totals: InvoiceDocumentTotal[];
/** Override the round seal text; defaults from kind/status. */
sealText?: string;
/**
* Company stamp image (data URL) to render instead of the plain text seal.
* Callers normally leave this unset — `InvoiceDocumentService.render()`
* fills it in from the single global stamp in StampSettingsService; set it
* explicitly only to override that default for one document.
*/
stampImageUrl?: string | null;
}
/**
@@ -63,12 +72,21 @@ export interface InvoiceDocumentModel {
*/
@Injectable()
export class InvoiceDocumentService {
constructor(private readonly pdf: PdfRenderService) {}
constructor(
private readonly pdf: PdfRenderService,
private readonly stampSettings: StampSettingsService,
) {}
async render(
model: InvoiceDocumentModel,
): Promise<{ filename: string; buffer: Buffer }> {
const html = this.buildHtml(model);
const stampImageUrl =
model.stampImageUrl !== undefined
? model.stampImageUrl
: await this.stampSettings.getStampImageUrl();
const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl };
const html = this.buildHtml(resolvedModel);
const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice";
return {
filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`,
@@ -77,7 +95,11 @@ export class InvoiceDocumentService {
// Chromium-less fallback: draw a genuine styled invoice (header, seal,
// summary grid, line-item table, totals) from the model — not a flat
// plain-text dump — so it still reads as a proper invoice document.
fallback: () => this.buildFallbackPdf(model),
// ponytail: still draws the plain vector seal, not the uploaded stamp
// image — embedding a raster image needs a new PDF XObject primitive
// in styled-pdf.util.ts. Upgrade when the Chromium-less path needs to
// carry the real stamp too; today it's a rare degraded fallback.
fallback: () => this.buildFallbackPdf(resolvedModel),
}),
};
}
@@ -218,6 +240,8 @@ export class InvoiceDocumentService {
const showCategory = Boolean(model.categoryHeader);
const sealText =
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
const sealInner = sealMarkup(model.stampImageUrl, sealText);
const sealCssClass = sealClass(model.stampImageUrl);
const summaryRows = model.summary
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
@@ -256,6 +280,7 @@ export class InvoiceDocumentService {
.meta { text-align: right; font-size: 12px; color: #475569; }
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
${sealImageCss()}
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
@@ -283,7 +308,7 @@ export class InvoiceDocumentService {
Issued: ${esc(date(model.issuedAt))}
</div>
</div>
<div class="seal">${esc(sealText)}</div>
<div class="${sealCssClass}">${sealInner}</div>
<div class="summary">${summaryRows}</div>
<table>
<thead>

View File

@@ -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(
`<img src="${STAMP}" alt="Company stamp" />`,
);
});
it("falls back to text rings when no stamp is configured", () => {
expect(sealMarkup(null, ["EDR", "Warehouse", "Cleared"])).toBe(
"<span>EDR<br />Warehouse<br />Cleared</span>",
);
});
it("treats undefined as unset", () => {
expect(sealMarkup(undefined, "EDR")).toBe("<span>EDR</span>");
});
it("accepts a bare string as a single line", () => {
expect(sealMarkup(null, "EDR")).toBe("<span>EDR</span>");
});
it("escapes text lines so document data cannot inject markup", () => {
expect(sealMarkup(null, ['<script>alert("x")</script>'])).toBe(
"<span>&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;</span>",
);
});
it("escapes the image src so it cannot break out of the attribute", () => {
expect(sealMarkup('data:image/png;base64,A" onerror="x', "EDR")).toBe(
'<img src="data:image/png;base64,A&quot; onerror=&quot;x" alt="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");
});
});
});

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* CSS overrides that neutralize a document's own ring/rotation styling when the
* seal is a real stamp image. Append inside a document's <style> block, after
* its own `.seal` rules. `selector` is the document's seal class ("seal").
*/
export function sealImageCss(selector = "seal"): string {
return [
`.${selector}.${selector}-image { border: none; border-radius: 0; opacity: 1; transform: none; }`,
`.${selector}.${selector}-image::before { content: none; }`,
`.${selector} img { max-width: 100%; max-height: 100%; object-fit: contain; }`,
].join("\n ");
}
/**
* Inner markup for the seal element: the stamp image, or the given text lines
* wrapped in a <span> (matching the `.seal span { position: relative }` rule
* the ring-drawing documents rely on).
*
* `stampImageUrl` is expected to be a data URL from
* StampSettingsService.getStampImageUrl(); null renders the text fallback.
*/
export function sealMarkup(
stampImageUrl: string | null | undefined,
textLines: string | string[],
): string {
if (stampImageUrl) {
return `<img src="${escapeHtml(stampImageUrl)}" alt="Company stamp" />`;
}
const lines = Array.isArray(textLines) ? textLines : [textLines];
return `<span>${lines.map(escapeHtml).join("<br />")}</span>`;
}
/** Class attribute for the seal element — adds the image modifier when stamped. */
export function sealClass(
stampImageUrl: string | null | undefined,
selector = "seal",
): string {
return stampImageUrl ? `${selector} ${selector}-image` : selector;
}

View File

@@ -304,6 +304,7 @@ export class BookingContractService {
async getSignatures(bookingId: string) {
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r));
await this.viewModelBuilder.attachProviderStamp(views);
await this.inlineSignatureImages(views);
return { signatures: views };
}

View File

@@ -12,6 +12,12 @@ export class ContractSignatureDto {
@ApiPropertyOptional()
signatureImageUrl?: string | null;
@ApiPropertyOptional({
description:
'Company seal beside the signature. Set for the EDR (STAFF) side from the single global company stamp; null for the client side.',
})
stampImageUrl?: string | null;
}
export class SavedSignatureViewDto {

View File

@@ -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 {
@@ -265,6 +266,7 @@ export class CompaniesController {
dto.companyType,
dto.roles,
dto.nationality,
dto.cooperative,
);
return new CompanyInfoResponseDto(profile, company);
}
@@ -415,90 +417,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<CompanyIdentityStateDto> {
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<CompanyIdentityStateDto> {
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<CompanyIdentityStateDto> {
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<CompanyIdentityStateDto> {
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<CompanyIdentityStateDto> {
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<CompanyIdentityStateDto> {
return this.companiesService.removePoaIdentity(user.id);
return this.companiesService.setPoaDeclared(user.id, dto.declared);
}
@Patch("onboarding-step")

View File

@@ -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 () => {

View File

@@ -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",

File diff suppressed because it is too large Load Diff

View File

@@ -23,9 +23,18 @@ export const COMPANY_FIELD_LABELS: Record<string, string> = {
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",

View File

@@ -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,62 @@ 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:
"That manager's phone, normalized to E.164 and captured at the same lookup. Paired with the name so the portal can still tell that the owner's details came from eTrade after a refresh, when the live lookup result is long gone — without it a resumed wizard offers them back as typeable inputs.",
})
etradeManagerPhone!: 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 +163,9 @@ export class CompanyIdentityStateDto {
const PREFIX: Record<IdentitySubject, string> = {
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 +180,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 +189,114 @@ 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<Company, "attributes" | "companyProfiles">,
): 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;
const etradeManagerPhone =
(attrs[ETRADE_MANAGER_PHONE_KEY] as string | undefined) ?? null;
return {
faydaRequired,
passportRequired,
passportAccepted,
poaDeclared,
subject,
owner,
poa,
poaSameAsOwner,
gm,
gmSameAsOwner,
complete,
identityProven,
etradeManagerName,
etradeManagerPhone,
ownerMatchesEtrade: ownerNameMatchesEtrade(owner.name, etradeManagerName),
complete: subject !== null && identityProven,
};
}

View File

@@ -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;
@@ -61,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: {
@@ -99,7 +118,9 @@ export class OnboardingRequirementsResponseDto {
constructor(init: Omit<OnboardingRequirementsResponseDto, never>) {
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;

View File

@@ -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;
@@ -42,9 +48,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 +62,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;
@@ -84,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)) ??
[];
@@ -113,9 +122,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;

View File

@@ -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;
@@ -89,9 +96,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;
@@ -105,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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,20 +35,21 @@ 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 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)

View File

@@ -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<Company, "attributes"> | null | undefined,
): boolean {
return company?.attributes?.[COOPERATIVE_KEY] === true;
}
@Entity({ schema: "freight", name: "companies" })
@Index(["tin"])
@Index(["type"])
@@ -112,29 +131,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;

View File

@@ -0,0 +1,152 @@
import { BadRequestException } from '@nestjs/common';
import { ContractTransitionService } from './contract-transition.service';
/**
* Where the booking-contract view reads the global stamp live, the contracts
* path SNAPSHOTS it onto the signature row at signing time, so replacing the
* company stamp can never restamp an already-executed contract. These specs
* pin the sourcing split: EDR always seals with the global stamp and staff
* never supply one, while the customer must upload their own.
*/
describe('applySignature stamp sourcing', () => {
const contract = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
const GLOBAL_STAMP = 'data:image/png;base64,RURS';
const build = (globalStamp: string | null = GLOBAL_STAMP) => {
const uploads: Array<{ code: string; image: string }> = [];
const saved: unknown[] = [];
const service = Object.create(
ContractTransitionService.prototype,
) as ContractTransitionService;
Object.assign(service, {
logger: { warn: jest.fn(), log: jest.fn() },
stampSettings: {
getStampImageUrl: jest.fn().mockResolvedValue(globalStamp),
},
contractsRepository: {
saveSignature: jest.fn((row: unknown) => {
saved.push(row);
return Promise.resolve(undefined);
}),
},
signaturesService: {
getForUser: jest.fn().mockResolvedValue(null),
upsertForUser: jest.fn().mockResolvedValue(undefined),
},
uploadSignatureAsset: jest.fn((_c: unknown, code: string, image: string) => {
uploads.push({ code, image });
return Promise.resolve({ id: `file-${code}` });
}),
});
return { service, uploads, saved };
};
const apply = (
service: ContractTransitionService,
dto: Record<string, unknown>,
) =>
(
service as unknown as {
applySignature(
c: unknown,
d: unknown,
o: { signerUserId?: string },
): Promise<void>;
}
).applySignature(contract, dto, { signerUserId: 'u-1' });
const staffDto = {
role: 'STAFF' as const,
signerDisplayName: 'E. Staff',
signatureImageBase64: 'data:image/png;base64,U0lH',
};
it('seals the EDR side with the global stamp', async () => {
const { service, uploads, saved } = build();
await apply(service, staffDto);
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
expect(saved[0]).toEqual(
expect.objectContaining({ stampFileId: 'file-stamp_staff' }),
);
});
it('ignores a stamp a staff client tries to supply', async () => {
const { service, uploads } = build();
await apply(service, {
...staffDto,
stampImageBase64: 'data:image/png;base64,SEFDSw==',
});
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
expect(uploads.map((u) => u.image)).not.toContain(
'data:image/png;base64,SEFDSw==',
);
});
/**
* Failing loudly matters here: getStampImageUrl degrades to null when the
* stamp cannot be inlined, and silently executing an unsealed contract would
* be worse than refusing to counter-sign.
*/
it('refuses to counter-sign when no global stamp is configured', async () => {
const { service, saved } = build(null);
await expect(apply(service, staffDto)).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(apply(service, staffDto)).rejects.toThrow(/company stamp is configured/i);
expect(saved).toHaveLength(0);
});
it('requires the customer to upload their own stamp', async () => {
const { service, saved } = build();
await expect(
apply(service, {
role: 'CUSTOMER',
signerDisplayName: 'C. Customer',
signatureImageBase64: 'data:image/png;base64,U0lH',
}),
).rejects.toThrow(/company stamp is required/i);
expect(saved).toHaveLength(0);
});
it('snapshots the customer stamp and never substitutes the global one', async () => {
const { service, uploads } = build();
const customerStamp = 'data:image/png;base64,Q1VTVA==';
await apply(service, {
role: 'CUSTOMER',
signerDisplayName: 'C. Customer',
signatureImageBase64: 'data:image/png;base64,U0lH',
stampImageBase64: customerStamp,
});
expect(uploads).toContainEqual({
code: 'stamp_customer',
image: customerStamp,
});
expect(uploads.map((u) => u.image)).not.toContain(GLOBAL_STAMP);
});
/**
* DIRECTOR/CEO rows are internal approval signatures, not party seals, so
* they are deliberately exempt from the stamp requirement.
*/
it('lets internal approval signatures through without any stamp', async () => {
const { service, uploads, saved } = build();
await apply(service, {
role: 'DIRECTOR',
signerDisplayName: 'D. Director',
signatureImageBase64: 'data:image/png;base64,U0lH',
});
expect(uploads.map((u) => u.code)).toEqual(['signature_director']);
expect(saved[0]).toEqual(expect.objectContaining({ stampFileId: null }));
});
});

View File

@@ -35,6 +35,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FilesService } from '../files/files.service';
import { SignaturesService } from '../signatures/signatures.service';
import { StampSettingsService } from '../stamp-settings/stamp-settings.service';
import { OtpService } from '../otp/otp.service';
import { ContractTemplatesService } from '../contract-templates/contract-templates.service';
import { ContractPricingService } from './contract-pricing.service';
@@ -175,6 +176,7 @@ export class ContractTransitionService {
private readonly otpService: OtpService,
private readonly notifier: ContractNotifierService,
private readonly contractTemplates: ContractTemplatesService,
private readonly stampSettings: StampSettingsService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
@@ -1103,23 +1105,39 @@ export class ContractTransitionService {
// The company stamp is a separate image from the drawn signature. Both
// parties to the contract (client + EDR) must seal it; DIRECTOR/CEO rows
// are internal approval signatures, not party seals, so they stay exempt.
const stampRequired = role === 'CUSTOMER' || role === 'STAFF';
if (stampRequired && !dto.stampImageBase64) {
//
// The two parties source their seal differently: the customer uploads their
// own company stamp, while EDR always seals with the ONE global stamp
// (StampSettingsService) — staff never upload or pick a stamp.
if (role === 'CUSTOMER' && !dto.stampImageBase64) {
throw new BadRequestException(
'A company stamp is required to sign this contract.',
);
}
// Snapshot whichever stamp applies onto the signature row rather than
// referencing the global one, so replacing the company stamp later can
// never restamp an already-executed contract.
let stampImageBase64 = dto.stampImageBase64 ?? null;
if (role === 'STAFF') {
stampImageBase64 = await this.stampSettings.getStampImageUrl();
if (!stampImageBase64) {
throw new BadRequestException(
'No company stamp is configured. Upload the company stamp under Settings before counter-signing contracts.',
);
}
}
const fileRecord = await this.uploadSignatureAsset(
contract,
`signature_${role.toLowerCase()}`,
imageBase64,
);
const stampRecord = dto.stampImageBase64
const stampRecord = stampImageBase64
? await this.uploadSignatureAsset(
contract,
`stamp_${role.toLowerCase()}`,
dto.stampImageBase64,
stampImageBase64,
)
: null;

View File

@@ -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;

View File

@@ -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 " +

View File

@@ -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, '')
)`;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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<StampSetting> {
constructor(
@InjectRepository(StampSetting)
repo: Repository<StampSetting>,
) {
super(repo);
}
/** The single settings row, with its stamp file joined, or null before first upload. */
findSingleton(): Promise<StampSetting | null> {
return this.repository.findOne({ where: {}, relations: ["stampFile"] });
}
}

View File

@@ -0,0 +1,110 @@
import { Readable } from "stream";
import { StampSettingsService } from "./stamp-settings.service";
/**
* The one global company stamp feeds three document paths (invoices, warehouse
* papers, contract signature blocks). All three treat the returned value as a
* `data:` URL — so the data-URL-or-null contract of getStampImageUrl is what
* these specs pin down, especially its behaviour when MinIO cannot be reached.
*/
describe("StampSettingsService.getStampImageUrl", () => {
const PNG = Buffer.from("fake-png-bytes");
const OBJECT_URL = "https://minio.local:9000/edr-freight/stamp/company.png";
const build = (
overrides: {
stampUrl?: string | null;
getFileStream?: jest.Mock;
findSingleton?: jest.Mock;
} = {},
) => {
const service = Object.create(
StampSettingsService.prototype,
) as StampSettingsService;
const warn = jest.fn();
Object.assign(service, {
logger: { warn, log: jest.fn() },
repository: {
findSingleton:
overrides.findSingleton ??
jest.fn().mockResolvedValue({
id: "s-1",
stampFileId: overrides.stampUrl ? "f-1" : null,
stampFile: overrides.stampUrl ? { url: overrides.stampUrl } : null,
updatedById: null,
updatedAt: null,
}),
create: jest.fn(),
update: jest.fn(),
},
minioService: {
getObjectNameFromUrl: jest.fn().mockReturnValue("stamp/company.png"),
getFileStream:
overrides.getFileStream ??
jest.fn().mockResolvedValue(Readable.from(PNG)),
},
filesService: { upload: jest.fn() },
});
return { service, warn };
};
it("inlines the stored stamp as a data URL", async () => {
const { service } = build({ stampUrl: OBJECT_URL });
await expect(service.getStampImageUrl()).resolves.toBe(
`data:image/png;base64,${PNG.toString("base64")}`,
);
});
it("returns null when no stamp is configured", async () => {
const { service } = build({ stampUrl: null });
await expect(service.getStampImageUrl()).resolves.toBeNull();
});
it("passes an already-inlined data URL straight through", async () => {
const dataUrl = "data:image/png;base64,QUJD";
const { service } = build({ stampUrl: dataUrl });
await expect(service.getStampImageUrl()).resolves.toBe(dataUrl);
});
/**
* Regression: inlineImageUrl falls back to the raw object URL when MinIO is
* unreachable, which is right for getView (a browser can fetch it) but wrong
* here. ContractTransitionService base64-decodes this value to snapshot the
* seal — and a URL decodes to garbage bytes WITHOUT throwing, so a transient
* MinIO failure used to seal an executed contract with a corrupt image file.
* Degrade to null instead so callers draw their text/vector seal.
*/
it("returns null rather than a raw object URL when MinIO inlining fails", async () => {
const { service, warn } = build({
stampUrl: OBJECT_URL,
getFileStream: jest.fn().mockRejectedValue(new Error("connect ECONNREFUSED")),
});
await expect(service.getStampImageUrl()).resolves.toBeNull();
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/plain seal/i));
});
it("never throws when the settings lookup itself fails", async () => {
const { service, warn } = build({
findSingleton: jest.fn().mockRejectedValue(new Error("db is down")),
});
await expect(service.getStampImageUrl()).resolves.toBeNull();
expect(warn).toHaveBeenCalledWith(expect.stringContaining("db is down"));
});
it("still exposes the raw URL through getView, which a browser can load", async () => {
const { service } = build({
stampUrl: OBJECT_URL,
getFileStream: jest.fn().mockRejectedValue(new Error("connect ECONNREFUSED")),
});
await expect(service.getView()).resolves.toEqual(
expect.objectContaining({ stampImageUrl: OBJECT_URL }),
);
});
});

View File

@@ -0,0 +1,170 @@
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<StampSetting> {
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<StampSettingView> {
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 generated documents, ALWAYS as a
* `data:` URL or null. Never throws — document generation must succeed even
* if the stamp lookup fails; callers fall back to their own seal on null.
*
* The data-URL-or-null guarantee is load-bearing, not cosmetic. Callers do
* two things with this value that a bare MinIO URL silently corrupts:
* ContractTransitionService base64-decodes it to snapshot the seal onto a
* signature row (a URL decodes to garbage bytes, not an error, permanently
* sealing an executed contract with a broken image), and the HTML render
* path inlines it into an <img> that headless Chromium cannot fetch. So
* where getView() may hand a raw URL to a browser that can load it, this
* degrades to null and lets the caller draw its text/vector seal instead.
*/
async getStampImageUrl(): Promise<string | null> {
try {
const setting = await this.get();
const inlined = await this.inlineImageUrl(setting.stampFile?.url);
if (inlined && !inlined.startsWith("data:")) {
this.logger.warn(
`Company stamp could not be inlined for document rendering (falling back to the plain seal): ${inlined}`,
);
return null;
}
return inlined;
} 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<StampSettingView> {
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<StampSettingView> {
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<string | null> {
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<Buffer> {
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)));
});
}
}

View File

@@ -3329,6 +3329,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<void> {
if (!bookingIds.length) return;
@@ -3337,6 +3343,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

View File

@@ -34,6 +34,8 @@ import {
primaryContactUserJoin,
} from '../notifications/resolve-company-phone.util';
import { SignaturesService } from '../signatures/signatures.service';
import { StampSettingsService } from '../stamp-settings/stamp-settings.service';
import { sealClass, sealImageCss, sealMarkup } from '../billing/documents/seal-markup.util';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
@@ -414,6 +416,7 @@ export class WarehouseInventoryService {
private readonly handover: HandoverService,
private readonly inbox: NotificationInboxService,
private readonly events: EventEmitter2,
private readonly stampSettings: StampSettingsService,
) {}
/**
@@ -3744,6 +3747,7 @@ export class WarehouseInventoryService {
const html = this.buildReleaseDocumentHtml({
reference,
issuedAt,
stampImageUrl: await this.stampSettings.getStampImageUrl(),
bookingReference,
bookingStatus: row?.bookingStatus ?? null,
customerName: row?.customerName ?? null,
@@ -4798,6 +4802,7 @@ export class WarehouseInventoryService {
const html = this.buildHandoverDocumentHtml({
reference,
handedOverAt,
stampImageUrl: await this.stampSettings.getStampImageUrl(),
bookingReference,
bookingStatus: row.bookingStatus ?? null,
customerName: row.customerName ?? null,
@@ -5603,6 +5608,8 @@ export class WarehouseInventoryService {
truckType?: string | null;
truckGateOut?: string | null;
truckWeightTons?: number | null;
/** The one global company stamp; null falls back to the drawn text seal. */
stampImageUrl?: string | null;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
@@ -5689,6 +5696,7 @@ export class WarehouseInventoryService {
.seal { 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()}
</style>
</head>
<body>
@@ -5722,7 +5730,7 @@ export class WarehouseInventoryService {
</div>
<div class="signatures">
<div class="line">Officer in charge name / signature / date</div>
<div class="seal"><span>EDR<br />Warehouse<br />Cleared</span></div>
<div class="${sealClass(data.stampImageUrl)}">${sealMarkup(data.stampImageUrl, ['EDR', 'Warehouse', 'Cleared'])}</div>
<div class="line">Customer or driver name / signature / date</div>
</div>
</div>
@@ -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()}
</style>
</head>
<body>
@@ -5882,7 +5893,7 @@ export class WarehouseInventoryService {
</div>
<div class="signatures">
<div class="line">Officer in charge name / signature / date</div>
<div class="seal"><span>EDR<br />Warehouse<br />Handover</span></div>
<div class="${sealClass(data.stampImageUrl)}">${sealMarkup(data.stampImageUrl, ['EDR', 'Warehouse', 'Handover'])}</div>
<div class="line">
${approval?.signatureImageUrl ? `<img class="signature-img" src="${esc(approval.signatureImageUrl)}" />` : ''}
<div class="signature-meta">${approval ? esc(approval.signerDisplayName) : 'Customer or driver name / signature / date'}</div>

View File

@@ -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',
}),
));

View File

@@ -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 } },
);

View File

@@ -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 } },
);

View File

@@ -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).
// {

View File

@@ -1187,6 +1187,31 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:settings:dropdown:manage",
"Manage dropdown settings",
),
perm(
"b4b00002-0001-4000-8000-000000000001",
"edr_freight_app:settings:stamp:view",
"View stamp settings",
),
perm(
"b4b00002-0001-4000-8000-000000000002",
"edr_freight_app:settings:stamp:manage",
"Manage stamp settings",
),
perm(
"b4b00003-0001-4000-8000-000000000001",
"edr_freight_app:settings:invoice_stamp:view",
"View invoice stamp settings",
),
perm(
"b4b00003-0001-4000-8000-000000000002",
"edr_freight_app:settings:invoice_stamp:manage",
"Manage invoice stamp settings",
),
perm(
"b4c00001-0001-4000-8000-000000000001",
"edr_freight_app:audit:view",
"View audit logs",
),
];
// M. Staff / IAM admin — NEW keys only. The employee_registration / role_assignment
@@ -1299,7 +1324,10 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [
"Edit contract templates & articles",
),
// Granular split of contract-template access. `view` opens the sidebar page;
// `read` is API-read-only for other pages that display template data.
// `read` is API-read-only for other pages that display template data — and is
// NOT written out here: deriveReadPermissions mints the `:read` twin of every
// `:view` key, so a hand-written one duplicates the key (Postgres 21000 on the
// seeder's ON CONFLICT (key) insert) and carries a v4 id where twins are v5.
perm(
"b4e00001-0001-4000-8000-000000000003",
"edr_freight_app:settings:contract_templates:create",
@@ -1315,11 +1343,6 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:settings:contract_templates:delete",
"Delete bulk contract templates",
),
perm(
"b4e00001-0001-4000-8000-000000000006",
"edr_freight_app:settings:contract_templates:read",
"Read contract template data (API only)",
),
perm(
"b4f00001-0001-4000-8000-000000000001",
"edr_freight_app:settings:support_content:view",
@@ -1884,6 +1907,16 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:dropdown:view",
manage: "edr_freight_app:settings:dropdown:manage",
},
stamp: {
view: "edr_freight_app:settings:stamp:view",
manage: "edr_freight_app:settings:stamp:manage",
},
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate
// from `stamp` above, which is the per-employee approval-record teeter.
invoiceStamp: {
view: "edr_freight_app:settings:invoice_stamp:view",
manage: "edr_freight_app:settings:invoice_stamp:manage",
},
exchangeRate: {
view: "edr_freight_app:settings:exchange_rate:view",
manage: "edr_freight_app:settings:exchange_rate:manage",
@@ -2293,7 +2326,8 @@ export const POSITION_PERMISSION_PRESETS = {
FREIGHT_PERMS.payments.view,
]),
// Director additionally manages train scheduling + rail fleet (same block the
// operation officer/chief hold), on top of the approval-chain role preset.
// operation officer/chief hold), on top of the approval-chain role preset,
// and carries the same full warehouse authority the chief tier holds.
director: dedupe([
...ROLE_PERMISSION_PRESETS.director,
FREIGHT_PERMS.trainScheduling.view,
@@ -2306,6 +2340,18 @@ export const POSITION_PERMISSION_PRESETS = {
FREIGHT_PERMS.fleet.view,
FREIGHT_PERMS.fleet.manage,
...FLEET_GRANULAR_KEYS,
// Warehouse — full CRUD, matching the chief tier. Unlike the dispatcher,
// the director also owns the allocation and fee rules themselves.
FREIGHT_PERMS.warehouseDashboard.view,
...Object.values(FREIGHT_PERMS.warehouses),
...Object.values(FREIGHT_PERMS.warehouseYards),
...Object.values(FREIGHT_PERMS.warehouseZones),
...Object.values(FREIGHT_PERMS.warehouseAllocationRules),
...Object.values(FREIGHT_PERMS.warehouseFeeRules),
...Object.values(FREIGHT_PERMS.warehouseInventory),
...Object.values(FREIGHT_PERMS.warehouseInspectionReports),
...Object.values(FREIGHT_PERMS.interchangeDocuments),
...Object.values(FREIGHT_PERMS.warehouseFeeInvoices),
]),
ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]),
ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]),

View File

@@ -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 } },
);

View File

@@ -51,6 +51,8 @@ import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing";
import NoAccessPage from "./pages/NoAccessPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import StampSettings from "./record-management/components/Settings/uploadTeeterandSingature";
import InvoiceStampSettingsPage from "./pages/settings/InvoiceStampSettingsPage";
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
import PortalContentPage from "./pages/portal_content/PortalContentPage";
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
@@ -117,6 +119,20 @@ import {
findActiveSidebarLabel,
} from "@/components/layout/sidebar-sections";
/**
* The per-shipment clearance detail page is the shared destination of three
* hubs (Operations → Clearance, Clearance Documents, Self-Clearance Review),
* none of which are gated on `bookings:clearance_view`. Gating the detail on
* that key alone bounced reviewers back to their landing page (Bookings) the
* moment they opened a row, so accept any key that can reach a hub.
*/
const CLEARANCE_DETAIL_PERMS = [
FREIGHT_PERMS.bookings.clearanceView,
FREIGHT_PERMS.contracts.clearanceReview,
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.opsClearanceReview,
];
const DashboardShell = () => {
const navigate = useNavigate();
const location = useLocation();
@@ -286,9 +302,7 @@ const App = () => {
<Route
path="clearance/:id"
element={
<RequirePermission
permission={FREIGHT_PERMS.bookings.clearanceView}
>
<RequirePermission permission={CLEARANCE_DETAIL_PERMS}>
<DocumentClearanceDetailPage />
</RequirePermission>
}
@@ -322,9 +336,7 @@ const App = () => {
<Route
path="bookings/:bookingId/clearance"
element={
<RequirePermission
permission={FREIGHT_PERMS.bookings.clearanceView}
>
<RequirePermission permission={CLEARANCE_DETAIL_PERMS}>
<DocumentClearanceDetailPage />
</RequirePermission>
}
@@ -774,6 +786,24 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="stamp-settings"
element={
<RequirePermission permission={FREIGHT_PERMS.settings.stamp.view}>
<StampSettings />
</RequirePermission>
}
/>
<Route
path="invoice-stamp-settings"
element={
<RequirePermission
permission={FREIGHT_PERMS.settings.invoiceStamp.view}
>
<InvoiceStampSettingsPage />
</RequirePermission>
}
/>
<Route
path="contract-templates"
element={

View File

@@ -170,12 +170,13 @@ export function ContractCustomerCard({
/>
</SectionCard>
<SectionCard icon={User} title="General manager" accent="grape">
{/* Whoever the eTrade licence names as the business's manager. */}
<SectionCard icon={User} title="Owner" accent="grape">
<InfoRows
rows={[
{ icon: User, label: "Name", value: company.generalManagerName },
{ icon: Mail, label: "Email", value: company.generalManagerEmail },
{ icon: Phone, label: "Phone", value: company.generalManagerPhone },
{ icon: User, label: "Name", value: company.ownerName },
{ icon: Mail, label: "Email", value: company.ownerEmail },
{ icon: Phone, label: "Phone", value: company.ownerPhone },
]}
/>
</SectionCard>

View File

@@ -43,9 +43,17 @@ export const FIELD_LABELS: Record<string, string> = {
contactPersonPosition: "Contact position",
contactPersonEmail: "Contact email",
contactPersonPhone: "Contact phone",
generalManagerName: "General manager",
generalManagerEmail: "GM email",
generalManagerPhone: "GM phone",
ownerName: "Owner name",
ownerEmail: "Owner email",
ownerPhone: "Owner phone",
poaDeclared: "Has a Power of Attorney",
poaPassportNumber: "PoA passport number",
// Nothing writes these any more — the general manager was removed — but
// change requests filed before that still carry them, and without a label
// the reviewer sees a raw attribute key.
generalManagerName: "General manager (retired)",
generalManagerEmail: "GM email (retired)",
generalManagerPhone: "GM phone (retired)",
poaName: "PoA name",
poaPhone: "PoA phone",
poaEmail: "PoA email",
@@ -79,9 +87,9 @@ export function currentValue(company: Company, key: string): string {
nationality: c.nationality,
contactPersonName: c.contactPersonName ?? attrs.contactPersonName,
contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone,
generalManagerName: c.generalManagerName ?? attrs.generalManagerName,
generalManagerEmail: c.generalManagerEmail ?? attrs.generalManagerEmail,
generalManagerPhone: c.generalManagerPhone ?? attrs.generalManagerPhone,
ownerName: c.ownerName ?? attrs.ownerName,
ownerEmail: c.ownerEmail ?? attrs.ownerEmail,
ownerPhone: c.ownerPhone ?? attrs.ownerPhone,
};
const v = key in map ? map[key] : (c[key] ?? attrs[key]);
return v === null || v === undefined || v === "" ? "—" : String(v);

View File

@@ -485,6 +485,18 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
icon: <Settings />,
permission: FREIGHT_PERMS.settings.dropdown.view,
},
{
label: "Stamp settings",
href: "/dashboard/stamp-settings",
icon: <FileSignature />,
permission: FREIGHT_PERMS.settings.stamp.view,
},
{
label: "Invoice stamp",
href: "/dashboard/invoice-stamp-settings",
icon: <Receipt />,
permission: FREIGHT_PERMS.settings.invoiceStamp.view,
},
{
label: "Contract templates",
href: "/dashboard/contract-templates",

View File

@@ -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<string | null>(null);
const [stampData, setStampData] = useState<string | null>(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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileSignature className="size-4" />
Signature &amp; Stamp
Signature
</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
@@ -112,54 +90,29 @@ export function MySignatureCard() {
<Loader2 className="size-6 animate-spin text-primary" />
</div>
) : (
<>
<div className="space-y-2">
{saved?.signatureImageUrl ? (
<>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.signatureImageUrl}
alt="My saved signature"
className="mx-auto h-36 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">
Saved as {saved.signerDisplayName}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">
You have not saved a signature yet.
<div className="space-y-2">
{saved?.signatureImageUrl ? (
<>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.signatureImageUrl}
alt="My saved signature"
className="mx-auto h-36 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">
Saved as {saved.signerDisplayName}
</p>
)}
<Button variant="outline" size="sm" onClick={openSignatureDialog}>
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
</Button>
</div>
<div className="space-y-2">
{saved?.stampImageUrl ? (
<>
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
<img
src={saved.stampImageUrl}
alt="My saved company stamp"
className="mx-auto h-24 w-full object-contain"
/>
</div>
<p className="text-xs text-muted-foreground">Company stamp</p>
</>
) : (
<p className="text-sm text-muted-foreground">
You have not uploaded a company stamp yet.
</p>
)}
<Button variant="outline" size="sm" onClick={openStampDialog}>
<Stamp className="size-4" />
{saved?.stampImageUrl ? "Update stamp" : "Upload stamp"}
</Button>
</div>
</>
</>
) : (
<p className="text-sm text-muted-foreground">
You have not saved a signature yet.
</p>
)}
<Button variant="outline" size="sm" onClick={openSignatureDialog}>
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
</Button>
</div>
)}
</CardContent>
@@ -203,39 +156,6 @@ export function MySignatureCard() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={stampOpen} onOpenChange={setStampOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Company stamp</DialogTitle>
<DialogDescription>
Upload your official company stamp or seal as an image. It is
stored on your profile and applied next to your signature on
contracts.
</DialogDescription>
</DialogHeader>
<StampUpload
value={stampData}
onChange={setStampData}
description="Stored on your profile and prefilled when you sign contracts."
/>
<DialogFooter>
<Button variant="outline" onClick={() => setStampOpen(false)}>
Cancel
</Button>
<Button
disabled={saveMutation.isPending || !stampData}
onClick={saveStamp}
>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Save stamp"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -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,
});
};

View File

@@ -328,6 +328,16 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:dropdown:view",
manage: "edr_freight_app:settings:dropdown:manage",
},
stamp: {
view: "edr_freight_app:settings:stamp:view",
manage: "edr_freight_app:settings:stamp:manage",
},
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate
// from `stamp` above, which is the per-employee approval-record teeter.
invoiceStamp: {
view: "edr_freight_app:settings:invoice_stamp:view",
manage: "edr_freight_app:settings:invoice_stamp:manage",
},
exchangeRate: {
view: "edr_freight_app:settings:exchange_rate:view",
manage: "edr_freight_app:settings:exchange_rate:manage",

View File

@@ -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<string | null>(null);
// Company stamp: prefilled from the profile, or uploaded here when none is
// saved yet.
const [stampData, setStampData] = useState<string | null>(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() {
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
<StampUpload
value={stampData}
onChange={setStampData}
description={
savedStampImage
? "Your saved company stamp — replace it for this contract if needed."
: "Required. Attach your official company stamp or seal."
}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSignOpen(false)}>
@@ -261,7 +245,6 @@ export default function BookingContractPage() {
disabled={
signMutation.isPending ||
(!usingSaved && !signatureData) ||
!stampData ||
!signerName.trim()
}
onClick={confirmSign}

View File

@@ -18,7 +18,6 @@ import toast from "react-hot-toast";
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { StampUpload } from "@/components/contracts/StampUpload";
import { contractsService } from "@/services/contracts.service";
import { extractApiError } from "@/utils/result";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
@@ -40,7 +39,6 @@ export default function ContractViewPage() {
const [successOpen, setSuccessOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
const [stampData, setStampData] = useState<string | null>(null);
const [drawNew, setDrawNew] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
@@ -56,10 +54,11 @@ export default function ContractViewPage() {
mutationFn: () =>
contractsService.signContract(id!, {
role: "STAFF",
// No stamp is sent: EDR's seal is the ONE global company stamp, applied
// server-side from StampSettingsService when the signature is stored.
signatureImageBase64: usingSaved
? (savedSignatureImage as string)
: (signatureData ?? ""),
stampImageBase64: stampData ?? "",
signerDisplayName: signerName.trim(),
consentText: "I confirm this contract on behalf of EDR.",
}),
@@ -98,14 +97,12 @@ export default function ContractViewPage() {
const openSign = () => {
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
// Prefill with the reusable stamp saved on the profile; still replaceable.
setStampData(data?.savedSignature?.stampImageUrl ?? null);
setDrawNew(false);
setSignOpen(true);
};
const confirmSign = () => {
if (!signerName.trim() || !stampData) return;
if (!signerName.trim()) return;
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
signMutation.mutate();
@@ -243,13 +240,6 @@ export default function ContractViewPage() {
<ContractSignaturePad onChange={setSignatureData} />
)}
<StampUpload
value={stampData}
onChange={setStampData}
label="EDR company stamp"
description="Attach the official EDR stamp or seal — it is applied to the contract next to the signature."
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setSignOpen(false)}>
Cancel
@@ -260,8 +250,7 @@ export default function ContractViewPage() {
disabled={
signMutation.isPending ||
!signerName.trim() ||
(!usingSaved && !signatureData) ||
!stampData
(!usingSaved && !signatureData)
}
onClick={confirmSign}
>

View File

@@ -17,6 +17,7 @@ import {
Text,
} from "@mantine/core";
import {
AlertTriangle,
ArrowLeft,
ArrowRight,
Banknote,
@@ -638,8 +639,9 @@ export default function CustomerDetailPage() {
const hasPoaDetails = poaFields.some((f) => f.value?.trim());
// Shared with the portal (buildCompanyIdentityState) — same derivation, so
// this page can never disagree with the rule the API actually enforces.
const ownerIdentity = company?.identity?.owner;
const poaIdentity = company?.identity?.poa;
const identityState = company?.identity;
const ownerIdentity = identityState?.owner;
const poaIdentity = identityState?.poa;
const hasEtradeRecord = Boolean(company?.licenceNumber?.trim());
// A freight forwarder acts on other companies' behalf, so its PoA — details
// and DARS delegation paper both — is mandatory rather than optional.
@@ -647,7 +649,7 @@ export default function CustomerDetailPage() {
(p) => p.type === "freight_forwarder",
);
const delegationMissing =
(hasPoaDetails || poaMandatory) && poaLive.length === 0;
company?.identity?.poaDeclared === "yes" && poaLive.length === 0;
if (isLoading) {
return (
@@ -820,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. */}
<InfoField
label="Registration"
value={
company.cooperative
? "Co-operative union / farm (no trade licence)"
: "eTrade trade licence"
}
/>
<InfoField label="Address" value={company.address} />
<InfoField label="Website" value={company.website} />
<InfoField label="Email" value={company.email} />
@@ -834,18 +846,9 @@ export default function CustomerDetailPage() {
value={company.contactPersonPhone}
/>
<Box />
<InfoField
label="General manager"
value={company.generalManagerName}
/>
<InfoField
label="GM email"
value={company.generalManagerEmail}
/>
<InfoField
label="GM phone"
value={company.generalManagerPhone}
/>
<InfoField label="Owner" value={company.ownerName} />
<InfoField label="Owner email" value={company.ownerEmail} />
<InfoField label="Owner phone" value={company.ownerPhone} />
</SimpleGrid>
</Stack>
</Card>
@@ -912,6 +915,11 @@ export default function CustomerDetailPage() {
<Text fw={600} c="edr-text">
Owner identity
</Text>
{identityState?.subject === "owner" && (
<Badge size="sm" color="blue" variant="light">
Verifies for this company
</Badge>
)}
{ownerIdentity?.verified ? (
<Badge size="sm" color="edr-green" variant="light">
Fayda verified
@@ -922,6 +930,41 @@ export default function CustomerDetailPage() {
</Badge>
)}
</Group>
{/* THE check: is the owner the company put forward the person
the eTrade licence actually names? Advisory — eTrade and
Fayda transliterate Amharic names differently, so this is a
prompt to look, not a verdict. */}
{identityState?.ownerMatchesEtrade === false ? (
<Alert
color="amber"
variant="light"
icon={<AlertTriangle size={16} />}
title="Does not match the eTrade licence"
>
The licence names{" "}
<strong>{identityState.etradeManagerName}</strong>, but this
company recorded <strong>{company.ownerName}</strong>.
</Alert>
) : identityState?.ownerMatchesEtrade === true ? (
<Badge
size="sm"
color="edr-green"
variant="light"
style={{ alignSelf: "flex-start" }}
>
Matches the eTrade licence
</Badge>
) : company.cooperative ? (
<Text size="xs" c="dimmed">
A co-operative union or farm holds no trade licence, so
there is no eTrade record to check the owner against.
</Text>
) : (
<Text size="xs" c="dimmed">
No eTrade manager name on file to compare against.
</Text>
)}
{ownerIdentity?.verified ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
<InfoField label="Name" value={ownerIdentity.name} />

View File

@@ -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<string | null>(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 (
<div className="p-4 w-full max-w-screen-sm mx-auto">
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader>
<CardTitle>Invoice stamp</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<StampUpload
value={isLoading ? null : value}
onChange={setDraft}
label="Company stamp"
description="Shown on every invoice/receipt PDF in place of the plain seal."
/>
<div className="flex items-center gap-2">
<Button
onClick={handleSave}
disabled={!dirty || setStamp.isPending}
>
<Save className="mr-2 h-4 w-4" />
Save
</Button>
{data?.stampImageUrl && !dirty && (
<Button
variant="outline"
onClick={handleClear}
disabled={clearStamp.isPending}
>
<Trash2 className="mr-2 h-4 w-4" />
Remove
</Button>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -609,10 +609,18 @@ const UploadTeeterAndSignature = () => {
)}
</TabsContent>
{/* Teeter Tab */}
{/* Teeter Tab — single active stamp only: remove the current one to upload a replacement. */}
<TabsContent value="teeter" className="p-4 space-y-6">
{teeters.length > 0 && (
<div className="space-y-6">
{teeters.length > 1 && (
<p className="rounded border border-amber-200 bg-amber-50 p-2 text-sm text-amber-700 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-300">
{t(
"signatureUpload.multipleStampsWarning",
"Only one stamp is allowed. Remove the extras below to keep a single active stamp.",
)}
</p>
)}
{teeters.map(({ id, url }) => (
<div key={id} className="space-y-3">
<p className="text-sm text-gray-600 dark:text-gray-300">
@@ -635,6 +643,7 @@ const UploadTeeterAndSignature = () => {
</div>
)}
{teeters.length === 0 && (
<div className="border-2 border-dashed border-primary-300 dark:border-primary-600 rounded-lg p-6 text-center space-y-4">
{!stampBlocks && !showLanguagePicker && (
<Button
@@ -792,6 +801,7 @@ const UploadTeeterAndSignature = () => {
</>
)}
</div>
)}
</TabsContent>
</Tabs>

View File

@@ -111,11 +111,14 @@ export interface ConsolidationDetails {
splitBilling: { bookingShare: number; partnerShare: number } | null;
}
/**
* No stamp field: the backoffice only ever signs as STAFF, and EDR's seal is
* the ONE global company stamp applied server-side. Customer stamps are posted
* from the portal, not here.
*/
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
/** Company stamp/seal image; the API requires one for CUSTOMER and STAFF. */
stampImageBase64?: string;
signerDisplayName: string;
consentText?: string;
}

View File

@@ -118,11 +118,14 @@ export interface ContractView {
} | null;
}
/**
* No stamp field: the backoffice only ever counter-signs as STAFF, and EDR's
* seal is the ONE global company stamp, snapshotted server-side from
* StampSettingsService when the signature is stored.
*/
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
/** Company stamp/seal image; required to sign a contract. */
stampImageBase64?: string;
signerDisplayName: string;
consentText?: string;
}

View File

@@ -24,7 +24,7 @@ const cleanParams = (params: object) =>
),
);
/** Lift attributes JSONB into the flat contact/manager fields the UI reads. */
/** Lift attributes JSONB into the flat contact/owner fields the UI reads. */
function mapCompany(dto: Record<string, unknown>): Company {
const attrs = (dto.attributes as Record<string, unknown> | null) ?? {};
return {
@@ -32,9 +32,9 @@ function mapCompany(dto: Record<string, unknown>): Company {
companyProfiles: (dto.companyProfiles as Company["companyProfiles"]) ?? [],
contactPersonName: (attrs.contactPersonName as string | null) ?? null,
contactPersonPhone: (attrs.contactPersonPhone as string | null) ?? null,
generalManagerName: (attrs.generalManagerName as string | null) ?? null,
generalManagerEmail: (attrs.generalManagerEmail as string | null) ?? null,
generalManagerPhone: (attrs.generalManagerPhone as string | null) ?? null,
ownerName: (attrs.ownerName as string | null) ?? null,
ownerEmail: (attrs.ownerEmail as string | null) ?? null,
ownerPhone: (attrs.ownerPhone as string | null) ?? null,
poaName: (attrs.poaName as string | null) ?? null,
poaEmail: (attrs.poaEmail as string | null) ?? null,
poaPhone: (attrs.poaPhone as string | null) ?? null,

View File

@@ -9,12 +9,16 @@ export interface SavedSignature {
stampImageUrl?: string | null;
}
/**
* Signature only. A backoffice employee has no personal stamp — EDR seals with
* the one global company stamp managed under Settings — so the stamp half of
* PUT /me/signature is deliberately not exposed here, even though the shared
* endpoint still accepts it for portal (customer) users.
*/
export interface SaveSignaturePayload {
signerDisplayName: string;
/** Omit to keep the existing saved signature (stamp-only update). */
/** Omit to keep the existing saved signature. */
signatureImageBase64?: string;
/** Omit to keep the existing saved stamp. */
stampImageBase64?: string;
}
export const signaturesService = {

View File

@@ -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<StampSettings> => {
const response = await client.get<ApiResponse<StampSettings>>(BASE);
return unwrap(response.data);
},
set: async (stampImageBase64: string): Promise<StampSettings> => {
const response = await client.put<ApiResponse<StampSettings>>(BASE, {
stampImageBase64,
});
return unwrap(response.data);
},
clear: async (): Promise<StampSettings> => {
const response = await client.delete<ApiResponse<StampSettings>>(BASE);
return unwrap(response.data);
},
};

View File

@@ -64,9 +64,9 @@ export interface BookingCompany {
email?: string | null;
contactPersonName?: string | null;
contactPersonPhone?: string | null;
generalManagerName?: string | null;
generalManagerEmail?: string | null;
generalManagerPhone?: string | null;
ownerName?: string | null;
ownerEmail?: string | null;
ownerPhone?: string | null;
website?: string | null;
}

View File

@@ -179,23 +179,34 @@ export interface IdentityVerificationState {
verifiedAt: string | null;
birthdate: string | null;
gender: string | null;
}
/** Mirrors `OwnerIdentityStateDto`. */
export interface OwnerIdentityState extends IdentityVerificationState {
/** Typed passport number — the foreign-company alternative to Fayda. */
passportNumber: string | null;
}
/**
* Owner/PoA Fayda verification, shared with the portal's derivation
* The company's single identity verification, shared with the portal's derivation
* (`buildCompanyIdentityState`) so backoffice never re-derives — or
* disagrees with — the rule the API actually enforces.
*/
export interface CompanyIdentityState {
faydaRequired: boolean;
passportRequired: boolean;
owner: OwnerIdentityState;
/** Foreign company: a passport number proves the person as Fayda would. */
passportAccepted: boolean;
/** Whether the company named a representative. Null = never answered. */
poaDeclared: "yes" | "no" | null;
/** Whose verification the company is gated on — PoA if declared, else owner. */
subject: "owner" | "poa" | null;
owner: IdentityVerificationState;
poa: IdentityVerificationState;
identityProven: boolean;
/** The manager named on the eTrade licence, captured at lookup. */
etradeManagerName: string | null;
/**
* Does the owner the company put forward match the eTrade licence?
* THE reviewer check. Null when there is nothing to compare. Advisory —
* eTrade and Fayda transliterate Amharic names differently, so a `false` is
* "look at this", not "reject this".
*/
ownerMatchesEtrade: boolean | null;
complete: boolean;
}
@@ -211,14 +222,21 @@ export interface Company {
fanNumber?: string | null;
country: string;
nationality?: CompanyNationality | null;
/**
* A co-operative union or farm: a TIN but no trade licence, so its
* registration was typed rather than fetched from eTrade, there is no eTrade
* manager to check the owner against, and it holds no freight-forwarder role.
*/
cooperative?: boolean;
address?: string | null;
phone?: string | null;
email?: string | null;
contactPersonName?: string | null;
contactPersonPhone?: string | null;
generalManagerName?: string | null;
generalManagerEmail?: string | null;
generalManagerPhone?: string | null;
/** The owner — whoever the eTrade licence names as the business's manager. */
ownerName?: string | null;
ownerEmail?: string | null;
ownerPhone?: string | null;
poaName?: string | null;
poaEmail?: string | null;
poaPhone?: string | null;

View File

@@ -11,7 +11,7 @@ import {
} from "@mantine/core";
import { useEffect, useRef, useState } from "react";
import type { UseFormRegisterReturn } from "react-hook-form";
import { AlertCircle, Building2, Download } from "lucide-react";
import { AlertCircle, Building2, Download, Info } from "lucide-react";
import { useETradeData } from "@/hooks/useETradeData";
import { extractApiError } from "@/utils/result";
import type { CompanyRegistrationData } from "@edr/types";
@@ -54,6 +54,14 @@ interface ETradeInfoProps {
* than silently snapping to eTrade's first one.
*/
selectedLicenceNumber?: string;
/**
* A record is worth having but not required — a co-operative union or farm
* registers on a TIN alone, so eTrade may legitimately hold nothing for it.
* The lookup still runs (plenty of co-operatives DO have a record, and it
* beats typing), but "not found" stops being a red dead end and becomes the
* expected outcome, with the form below to fill in by hand.
*/
registrationOptional?: boolean;
}
// Digits, not just length: a 10-character non-numeric TIN used to fire a lookup
@@ -70,6 +78,7 @@ export default function ETradeInfo({
onReset,
alreadyVerified,
selectedLicenceNumber,
registrationOptional = false,
}: ETradeInfoProps) {
const mutation = useETradeData();
const isLoading = mutation.isPending;
@@ -327,16 +336,26 @@ export default function ETradeInfo({
</Stack>
)}
{notFound && (
<Alert
icon={<AlertCircle size={16} />}
color="red"
title="No matching business record"
>
This TIN isn't registered with eTrade. Check the number — we can't
continue without a matching business record.
</Alert>
)}
{notFound &&
(registrationOptional ? (
<Alert
icon={<Info size={16} />}
color="blue"
title="Nothing on file at eTrade for this TIN"
>
That's expected without a trade licence. Fill in your registration
details below and we'll take them as you give them.
</Alert>
) : (
<Alert
icon={<AlertCircle size={16} />}
color="red"
title="No matching business record"
>
This TIN isn't registered with eTrade. Check the number — we can't
continue without a matching business record.
</Alert>
))}
{errorMessage && (
<Alert

View File

@@ -1,6 +1,7 @@
import {
Box,
Button,
Checkbox,
Group,
Modal,
ScrollArea,
@@ -41,12 +42,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 +74,25 @@ const STEP_META: Record<
icon: <Building2 size={20} />,
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: <User size={20} />,
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: <FileText size={20} />,
title: "Who Acts For You",
description:
"Tell us whether anyone holds power of attorney — your answer decides whose identity we verify.",
},
contact: {
icon: <UserCheck size={20} />,
title: "Contact Person",
description: "Who should we reach out to about this account?",
},
poa: {
icon: <FileText size={20} />,
title: "Power of Attorney",
description: "Optionally add a representative with power of attorney.",
},
documents: {
icon: <UploadCloud size={20} />,
title: "Upload Documents",
@@ -157,6 +165,15 @@ export default function OnboardingWizardDialog({
const [roles, setRoles] = useState<string[]>(
existingProfiles.map((p) => p.type),
);
const [cooperative, setCooperative] = useState<boolean>(
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<string, File | File[] | null>
>({});
@@ -207,10 +224,11 @@ export default function OnboardingWizardDialog({
companyType: string;
roles: ProfileTypeValue[];
nationality?: CompanyNationality;
cooperative?: boolean;
}) => api.companies.startOnboarding.call(vars),
onSuccess: async () => {
// Nationality drives the server-resolved identity requirements (Fayda vs
// passport), the document set and the GM/PoA copy — all read from
// passport), the document set and the PoA copy — all read from
// onboardingRequirements/profile. Re-entering role selection can change
// it, so both must be refetched alongside getInfo or the form step would
// keep rendering the previous nationality's requirements.
@@ -289,6 +307,7 @@ export default function OnboardingWizardDialog({
resumedRef.current = true;
setRoles(existingProfiles.map((p) => p.type));
setNationality(savedNationality);
setCooperative(company?.company?.attributes?.cooperative === true);
// Resume into the form only when profiles exist; otherwise send the user to
// role selection so the missing operational profiles get created.
setPhase(hasOperationalProfiles ? "form" : "nationality-role");
@@ -303,8 +322,9 @@ export default function OnboardingWizardDialog({
companyType: companyTypeForRoles(roles),
roles: roles as ProfileTypeValue[],
nationality: nationality ?? undefined,
cooperative,
});
}, [roles, nationality, startMutation]);
}, [roles, nationality, cooperative, startMutation]);
// Back from the form's first step returns to nationality/role selection.
// Safe to re-enter: startOnboarding is idempotent — it reuses the existing
@@ -414,15 +434,21 @@ export default function OnboardingWizardDialog({
const requiredDocsMissing = requirementDocuments.some(
(d) => d.isRequired && !d.uploaded,
);
// The PoA gets the same treatment: a resumed draft that predates the
// delegation-letter requirement (or a forwarder whose PoA is blank) must land
// back on the PoA step, where both the details and the letter are entered.
const poaIncomplete = requirementsQuery.data?.poa?.complete === false;
// The representation step gets the same treatment. An unanswered
// power-of-attorney question, or a declared representative still missing
// details or the DARS paper, must land the customer back on the step where
// all of that is entered — including a draft that predates the question
// existing at all, whose `declared` comes back null.
const representationIncomplete =
requirementsQuery.data?.poa?.declared == null ||
requirementsQuery.data?.poa?.complete === false ||
requirementsQuery.data?.identity?.identityProven === false;
// Each unmet requirement lowers the ceiling; resume never moves forward.
let ceiling = FORM_STEPS.length - 1;
if (requiredDocsMissing)
ceiling = Math.min(ceiling, FORM_STEPS.indexOf("documents"));
if (poaIncomplete) ceiling = Math.min(ceiling, FORM_STEPS.indexOf("poa"));
if (representationIncomplete)
ceiling = Math.min(ceiling, FORM_STEPS.indexOf("representation"));
const effectiveResumeStep: FormStep =
FORM_STEPS[
Math.min(Math.max(FORM_STEPS.indexOf(resumeFormStep), 0), ceiling)
@@ -446,10 +472,19 @@ export default function OnboardingWizardDialog({
onLicenseChange: setLicenseFiles,
uploadedDocumentKeys,
onUploadDocuments: handleUploadDocuments,
// Fayda verification state for the owner and the PoA — the general manager
// stays a plain typed role. Mandatory (Fayda) for an Ethiopian company;
// a foreign one requires a typed passport number for the owner instead.
// The company's single identity verification, and whose it is. Fayda is
// mandatory for an Ethiopian company; a foreign one may instead type a
// passport number for the same person.
identity: requirementsQuery.data?.identity,
// Server-confirmed, not the local checkbox: the flag is only real once
// startOnboarding has persisted it, and the form's whole company step
// branches on it.
cooperative: requirementsQuery.data?.cooperative ?? cooperative,
extraDocumentSettingCode:
requirementsQuery.data?.cooperativeDocumentSettingCode ?? null,
// A freight forwarder cannot answer the power-of-attorney question — the
// API forces "yes" — so the step offers no way to change it.
declarationLocked: requirementsQuery.data?.poa?.locked ?? false,
onIdentityChange: () => {
void profileQuery.refetch();
void requirementsQuery.refetch();
@@ -517,6 +552,16 @@ export default function OnboardingWizardDialog({
onChange={setNationality}
embedded
/>
{/* A co-operative union or farm registers on a TIN alone. It
changes what the next step asks for (typed registration, no
eTrade lookup), which documents apply, and which roles are on
offer — so it is answered here, alongside the other two. */}
<Checkbox
checked={cooperative}
onChange={(e) => 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."
/>
<Text fw={600} size="lg" c="edr-text">
What does your company do?(multiple)
</Text>
@@ -524,6 +569,9 @@ export default function OnboardingWizardDialog({
value={roles}
onChange={setRoles}
embedded
// Forwarding is licensed work — a co-op holds no licence, so
// the role is not offered rather than refused later.
excludeTypes={cooperative ? ["freight_forwarder"] : undefined}
/>
{startError && (
<Text size="sm" c="red">

View File

@@ -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: <UserCog size={16} /> },
{ id: "company", label: "Company", icon: <Building2 size={16} /> },
{ id: "contact", label: "Contact Person", icon: <User size={16} /> },
{ id: "gm", label: "General Manager", icon: <Briefcase size={16} /> },
{ id: "owner", label: "Owner", icon: <Briefcase size={16} /> },
{ id: "poa", label: "Power of Attorney", icon: <UserCheck size={16} /> },
{ id: "documents", label: "Documents", icon: <FileCheck size={16} /> },
];
@@ -405,8 +402,8 @@ export default function SettingsPage() {
<Tabs.Panel value="contact">
<TabContactPerson profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="gm">
<TabGeneralManager profile={profile} mode="edit" />
<Tabs.Panel value="owner">
<TabOwner profile={profile} mode="edit" />
</Tabs.Panel>
<Tabs.Panel value="poa">
<TabPowerOfAttorney profile={profile} mode="edit" />

View File

@@ -0,0 +1,61 @@
import { Badge, Group, Stack, Text } from "@mantine/core";
import type { ReactNode } from "react";
/** Where a locked value came from, shown as a badge next to it. */
export type FieldSource = "eTrade" | "Fayda";
const SOURCE_NOTE: Record<FieldSource, string> = {
eTrade: "From your eTrade licence",
Fayda: "From the Fayda verification",
};
/**
* One person-detail field that an outside source may have taken ownership of.
*
* `source` — not "does a value exist" — decides which side renders. That
* distinction is the whole point: `ownerName` holds a value the moment the
* customer types it and the step saves, and keying off presence meant the input
* they had just filled in turned into a read-only badge as soon as they
* navigated away and back, with no way to correct it. Only a real source owns a
* field: a Fayda verification (the API refuses to overwrite those) or the
* eTrade licence (the record the backoffice checks the company against).
*
* It pairs with `requiredKeys` in CompanyProfileForm, which requires exactly
* the fields that fall through to `children`: **a field is required if and only
* if there is an input on screen to satisfy it.**
*/
export default function SourcedField({
label,
value,
source,
children,
}: {
label: string;
/** The value to display when a source owns this field. */
value?: string | null;
/** The owning source, or null while the field is still the customer's. */
source: FieldSource | null;
/** The input rendered whenever the field is still the customer's to fill. */
children: ReactNode;
}) {
if (!source || !value?.trim()) return <>{children}</>;
return (
<Stack gap={2}>
<Group gap="xs" align="center">
<Text size="xs" c="dimmed">
{label}
</Text>
<Badge size="xs" variant="light" color="edr-green">
{source}
</Badge>
</Group>
<Text className="wrap-break-word" size="sm" c="edr-text" fw={500}>
{value}
</Text>
<Text size="xs" c="edr-muted">
{SOURCE_NOTE[source]}
</Text>
</Stack>
);
}

View File

@@ -4,6 +4,8 @@ import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField";
import type { FieldSource } from "./SourcedField";
import {
ETRADE_BUNDLE_FIELDS,
type CompanyStep,
@@ -69,7 +71,6 @@ export function normalizeIdentityPhones(
...identity,
owner: fix(identity.owner),
poa: fix(identity.poa),
gm: fix(identity.gm),
};
}
@@ -82,6 +83,73 @@ export const samePhone = (a?: string | null, b?: string | null) => {
return da.length === 9 && da === phoneDigits(b);
};
/** The owner details an outside source can take ownership of. */
export type OwnerField = "name" | "email" | "phone";
export interface OwnerSources {
/** Which source owns each field, or null where it is still the customer's. */
source: Record<OwnerField, FieldSource | null>;
/** What to display for an owned field — normalized as the payload will be. */
sourced: Record<OwnerField, string>;
}
/**
* Who owns each of the owner's fields, and with what value.
*
* Two sources can own a field, and they rank. A Fayda verification owns
* whatever its claims filled (the API refuses to overwrite those), and the
* eTrade licence owns the manager's name and phone: that record is the thing
* the backoffice checks the company against, so it is reported, not proposed.
* Neither is typeable. Everything left over is the customer's — an editable
* input, required precisely because there is an input for it. Fayda outranks
* eTrade on the same person: the stronger claim, and the one the API keeps.
*
* Two things deliberately do NOT take ownership.
*
* A value merely being present. It exists the moment the customer types one and
* the step saves — so a presence test turned the input they had just filled
* into a read-only badge on the way back through the wizard, and dropped the
* field out of `requiredKeys` at the same time.
*
* And a value that isn't usable. Both sources hold contact details as free
* text: Fayda's phone is whatever the national registry recorded, eTrade's is
* whatever was typed at the licence desk ("09 " is a real answer, and it
* normalizes to a non-empty, invalid `+2519`). Locking one of those behind a
* read-only row leaves the customer told to fix a field with no input, or the
* step saved with a value the API rejects. So a source owns an email or a phone
* only if what it supplies holds up as one; otherwise the field falls through
* to an input and is required like any other. Names have no format to fail, so
* presence is the whole test there.
*/
export function resolveOwnerSources(
identity: CompanyIdentityState | undefined,
etradeOwner: { name: string; phone: string } | null,
): OwnerSources {
const verified = identity?.owner.verified ?? false;
const faydaName = verified && Boolean(identity?.owner.name?.trim());
const faydaEmail = verified ? firstValidEmail(identity?.owner.email) : "";
const faydaPhone = verified ? firstValidPhone(identity?.owner.phone) : "";
const etradeName = etradeOwner?.name?.trim() ?? "";
const etradePhone = firstValidPhone(etradeOwner?.phone);
const source: Record<OwnerField, FieldSource | null> = {
name: faydaName ? "Fayda" : etradeName ? "eTrade" : null,
// eTrade never returns an email for the manager, so this one is Fayda's or
// it is the customer's to type.
email: faydaEmail ? "Fayda" : null,
phone: faydaPhone ? "Fayda" : etradePhone ? "eTrade" : null,
};
return {
source,
sourced: {
name: source.name === "Fayda" ? (identity?.owner.name?.trim() ?? "") : etradeName,
email: faydaEmail,
phone: source.phone === "Fayda" ? faydaPhone : etradePhone,
},
};
}
/** Mask all but the first 7 chars of an E.164 phone for display. */
export const maskPhone = (p: string) =>
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
@@ -97,15 +165,14 @@ export function buildPayload(
vatNumber: data.vatNumber,
attributes: {
ownerPassportNumber: data.ownerPassportNumber || undefined,
poaPassportNumber: data.poaPassportNumber || undefined,
contactPersonName: data.contactPersonName,
contactPersonPosition: data.contactPersonPosition || undefined,
contactPersonEmail: data.contactPersonEmail || undefined,
contactPersonPhone: data.contactPersonPhone,
generalManagerName: data.generalManagerName,
generalManagerEmail: data.generalManagerEmail,
generalManagerPhone: data.generalManagerPhone,
// The representative's own details are written by their Fayda
// verification, so the city is all the form has to send.
ownerName: data.ownerName,
ownerEmail: data.ownerEmail,
ownerPhone: data.ownerPhone,
poaLocation: data.poaLocation || undefined,
},
};
@@ -135,23 +202,28 @@ export function stepPayload(
}
if (dirty.tinNumber) etrade.tin = d.tinNumber;
return {
companyAddress: d.companyAddress,
// Composed from the address parts, so it is only as complete as they
// are. Sending it while they are still empty (the lookup hasn't landed,
// or eTrade left them blank) would overwrite a stored address with a
// degraded version of itself — an absent key means "untouched".
...(d.companyAddress?.trim()
? { companyAddress: d.companyAddress }
: {}),
vatNumber: d.vatNumber,
ownerPassportNumber: d.ownerPassportNumber || undefined,
...etrade,
};
}
case "personnel":
case "owner":
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null and
// undefined, so an empty string is validated and 400s with
// "generalManagerEmail must be an email". An Ethiopian company never types
// these — the GM comes from the Fayda verification (or the "same as owner"
// declaration), so the form fields are legitimately blank and would fail a
// step that has no input to fix.
// "ownerEmail must be an email". A field the eTrade lookup or the Fayda
// claim already filled is legitimately blank in the form — it has no
// input — so sending "" would fail a step with nothing on screen to fix.
return {
generalManagerName: d.generalManagerName || undefined,
generalManagerEmail: d.generalManagerEmail || undefined,
generalManagerPhone: d.generalManagerPhone || undefined,
ownerName: d.ownerName || undefined,
ownerEmail: d.ownerEmail || undefined,
ownerPhone: d.ownerPhone || undefined,
ownerPassportNumber: d.ownerPassportNumber || undefined,
};
case "contact":
return {
@@ -160,12 +232,16 @@ export function stepPayload(
contactPersonEmail: d.contactPersonEmail || undefined,
contactPersonPhone: d.contactPersonPhone,
};
case "poa":
case "representation":
return {
poaName: d.poaName || undefined,
poaEmail: d.poaEmail || undefined,
poaPhone: d.poaPhone || undefined,
poaLocation: d.poaLocation || undefined,
poaPassportNumber: d.poaPassportNumber || undefined,
// The step renders one passport input, for whichever person the
// declaration made the identity subject — so it has to save both.
ownerPassportNumber: d.ownerPassportNumber || undefined,
};
default:
return {};
@@ -183,6 +259,7 @@ export function toFormValues(p: ProfileResponse): FormData {
tinNumber: tin,
vatNumber: p.vatNumber ?? "",
ownerPassportNumber: p.identity?.owner.passportNumber ?? "",
poaPassportNumber: p.identity?.poa.passportNumber ?? "",
licenceNumber: p.licenceNumber ?? "",
statusDescription: p.statusDescription ?? "",
dateRegistered: p.dateRegistered ?? "",
@@ -198,12 +275,11 @@ export function toFormValues(p: ProfileResponse): FormData {
contactPersonPosition: p.contactPersonPosition ?? "",
contactPersonEmail: p.contactPersonEmail ?? "",
contactPersonPhone: p.contactPersonPhone ?? "",
generalManagerName: p.generalManagerName ?? "",
generalManagerEmail: p.generalManagerEmail ?? "",
generalManagerPhone: p.generalManagerPhone ?? "",
ownerName: p.ownerName ?? "",
ownerEmail: p.ownerEmail ?? "",
ownerPhone: p.ownerPhone ?? "",
poaName: p.poaName ?? "",
poaPhone: p.poaPhone ?? "",
poaAddress: p.poaAddress ?? "",
poaEmail: p.poaEmail ?? "",
poaLocation: p.poaLocation ?? "",
};

View File

@@ -6,6 +6,7 @@ import {
firstValidEmail,
firstValidPhone,
normalizeIdentityPhones,
resolveOwnerSources,
stepPayload,
} from "./helpers";
import type { FormData } from "./schema";
@@ -35,9 +36,9 @@ const values = (over: Partial<FormData> = {}): FormData =>
contactPersonPosition: "",
contactPersonEmail: "",
contactPersonPhone: "+251911223344",
generalManagerName: "",
generalManagerEmail: "",
generalManagerPhone: "",
ownerName: "",
ownerEmail: "",
ownerPhone: "",
poaName: "",
poaPhone: "",
poaAddress: "",
@@ -65,18 +66,16 @@ describe("VAT number", () => {
).toBeUndefined();
});
it("rejects twelve digits", () => {
expect(errorFor(values({ vatNumber: "001234567890" }), "vatNumber")).toBe(
"VAT number must be 10 or 11 digits",
);
});
// `.length(10)` used to pass this, so a ten-letter string reached the API.
it("rejects ten non-digits", () => {
expect(errorFor(values({ vatNumber: "ABCDEFGHIJ" }), "vatNumber")).toBe(
"VAT number must be 10 or 11 digits",
);
});
// No shape rule any more: a foreign tax authority's VAT number carries
// letters and dashes, and a co-operative union's registration numbering
// follows the trade-licence pattern not at all. Length and alphabet are not
// ours to police — only presence is.
it.each(["001234567890", "GB123456789", "ET-2024/0091"])(
"accepts %s",
(vat) => {
expect(errorFor(values({ vatNumber: vat }), "vatNumber")).toBeUndefined();
},
);
it("rejects blank", () => {
expect(errorFor(values({ vatNumber: "" }), "vatNumber")).toBe(
@@ -104,25 +103,88 @@ describe("stepFields", () => {
// The regression this whole change exists to prevent: a step must not gate on
// a field it renders no input for, or Continue fails with the error attached
// to nothing on screen.
it("never gates the company step on a derived or read-only field", () => {
const unreachable = [
"etradePhone",
//
// Listing a field on a step is no longer the gate — `requiredKeys` is. The
// registration fields appear on the company step because a co-operative union
// or farm types them, and a licensed company gets them read-only from eTrade;
// the base schema must accept them blank either way.
it("never gates the company step on a field with no input", () => {
const derived = ["etradePhone", "licenceNumber", "statusDescription"];
expect(stepFields.company.filter((f) => derived.includes(f))).toEqual([]);
});
it("leaves the registration fields optional in the base schema", () => {
for (const field of ["companyName", "region", "zone", "woreda", "kebele"] as const) {
expect(errorFor(values({ [field]: "" }), field)).toBeUndefined();
}
});
it("requires the registration fields once a co-operative types them", () => {
const parsed = buildOnboardingSchema([
"companyName",
"licenceNumber",
"statusDescription",
"dateRegistered",
"renewedFrom",
"renewalDate",
"renewedTo",
"region",
"zone",
"woreda",
"kebele",
"houseNo",
];
expect(stepFields.company.filter((f) => unreachable.includes(f))).toEqual(
[],
]).safeParse(
values({ companyName: "", region: "", zone: "", woreda: "", kebele: "" }),
);
expect(parsed.success).toBe(false);
const paths = parsed.success
? []
: parsed.error.issues.map((i) => String(i.path[0]));
expect(paths).toEqual(
expect.arrayContaining([
"companyName",
"region",
"zone",
"woreda",
"kebele",
]),
);
});
});
describe("the identity subject's passport", () => {
// The representation step renders ONE passport input, for whoever the
// power-of-attorney answer made the identity subject. When the answer is "no
// PoA" that is the owner — so the owner's passport number is typed on the
// representation step and has to be carried by it. It used to belong to the
// owner step alone, which is already behind the customer by then: the number
// was typed, dropped, and the final submit failed `assertIdentityVerified`
// naming a field they could see was filled in.
it("is carried by the step that renders the input", () => {
expect(stepFields.representation).toContain("ownerPassportNumber");
expect(stepFields.representation).toContain("poaPassportNumber");
});
it("saves the owner's passport from the representation step", () => {
const payload = stepPayload(
"representation",
values({ ownerPassportNumber: "P1234567" }),
);
expect(payload.ownerPassportNumber).toBe("P1234567");
});
it("still omits it when there is none, rather than sending an empty string", () => {
const payload = stepPayload(
"representation",
values({ ownerPassportNumber: "" }),
);
expect(payload.ownerPassportNumber).toBeUndefined();
});
});
describe("stepPayload (representation)", () => {
// `poaAddress` is a Fayda-owned claim (`IDENTITY_OWNED_FIELDS` server-side).
// The portal states `poaLocation` instead and must never send the other.
it("sends the company's stated location, never the Fayda address", () => {
const payload = stepPayload(
"representation",
values({ poaLocation: "Dire Dawa, Ethiopia" }),
);
expect(payload.poaLocation).toBe("Dire Dawa, Ethiopia");
expect("poaAddress" in payload).toBe(false);
});
});
@@ -131,31 +193,29 @@ describe("buildOnboardingSchema (conditionally required fields)", () => {
data: FormData,
required: (keyof FormData)[],
): (keyof FormData)[] => {
const parsed = buildOnboardingSchema(false, required).safeParse(data);
const parsed = buildOnboardingSchema(required).safeParse(data);
return parsed.success
? []
: (parsed.error.issues.map((i) => i.path[0]) as (keyof FormData)[]);
};
// Fayda's email/phone claims are optional: the step renders an input for what
// the verification did not supply, and requires exactly those. Nothing else —
// a field with no input on screen must never fail Continue.
// eTrade returns no email and Fayda's email/phone claims are optional: the
// step renders an input for what no source supplied, and requires exactly
// those. Nothing else — a field with no input on screen must never fail
// Continue.
it("requires only the keys it is handed", () => {
const issues = issuesFor(values(), [
"generalManagerEmail",
"generalManagerPhone",
]);
expect(issues).toEqual(["generalManagerEmail", "generalManagerPhone"]);
const issues = issuesFor(values(), ["ownerEmail", "ownerPhone"]);
expect(issues).toEqual(["ownerEmail", "ownerPhone"]);
});
it("passes once those keys are filled", () => {
expect(
issuesFor(
values({
generalManagerEmail: "gm@example.com",
generalManagerPhone: "+251911223344",
ownerEmail: "owner@example.com",
ownerPhone: "+251911223344",
}),
["generalManagerEmail", "generalManagerPhone"],
["ownerEmail", "ownerPhone"],
),
).toEqual([]);
});
@@ -165,9 +225,7 @@ describe("buildOnboardingSchema (conditionally required fields)", () => {
});
it("names the field in the message, so it reads under its own input", () => {
const parsed = buildOnboardingSchema(false, ["poaEmail"]).safeParse(
values(),
);
const parsed = buildOnboardingSchema(["poaEmail"]).safeParse(values());
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(parsed.error.issues[0]?.message).toBe(
@@ -196,39 +254,43 @@ describe("stepPayload (company)", () => {
// Still only the dirty ones.
expect(payload.licenceNumber).toBeUndefined();
});
// The address is composed from the parts, so it is only ever as complete as
// they are. An absent key means "untouched" to the API; sending a blank one
// would replace a stored address with nothing.
it("omits a blank composed address rather than clearing the stored one", () => {
const payload = stepPayload("company", values({ companyAddress: "" }), {});
expect("companyAddress" in payload).toBe(false);
});
});
describe("stepPayload (personnel)", () => {
// An Ethiopian company never types the GM — Fayda (or "same as owner") owns
// those fields — so the form holds "". `@IsOptional()` on the DTO skips only
// null/undefined, so an empty string is validated and comes back as
// "generalManagerEmail must be an email", on a step that renders no input.
it("omits blank GM fields instead of sending empty strings", () => {
describe("stepPayload (owner)", () => {
// A Fayda claim owns whatever it supplied, so the form holds "" for those.
// `@IsOptional()` on the DTO skips only null/undefined, so an empty string is
// validated and comes back as "ownerEmail must be an email" — on a step that
// renders no input for it.
it("omits blank owner fields instead of sending empty strings", () => {
const payload = stepPayload(
"personnel",
values({
generalManagerName: "",
generalManagerEmail: "",
generalManagerPhone: "",
}),
"owner",
values({ ownerName: "", ownerEmail: "", ownerPhone: "" }),
);
expect(payload.generalManagerName).toBeUndefined();
expect(payload.generalManagerEmail).toBeUndefined();
expect(payload.generalManagerPhone).toBeUndefined();
expect(payload.ownerName).toBeUndefined();
expect(payload.ownerEmail).toBeUndefined();
expect(payload.ownerPhone).toBeUndefined();
});
it("still sends typed GM details (foreign company)", () => {
it("sends the owner details the customer typed or eTrade prefilled", () => {
const payload = stepPayload(
"personnel",
"owner",
values({
generalManagerName: "Abebe Bikila",
generalManagerEmail: "gm@example.com",
generalManagerPhone: "+251911223344",
ownerName: "Abebe Bikila",
ownerEmail: "owner@example.com",
ownerPhone: "+251911223344",
}),
);
expect(payload.generalManagerName).toBe("Abebe Bikila");
expect(payload.generalManagerEmail).toBe("gm@example.com");
expect(payload.generalManagerPhone).toBe("+251911223344");
expect(payload.ownerName).toBe("Abebe Bikila");
expect(payload.ownerEmail).toBe("owner@example.com");
expect(payload.ownerPhone).toBe("+251911223344");
});
});
@@ -268,13 +330,117 @@ describe("firstValidEmail", () => {
});
});
describe("resolveOwnerSources", () => {
const identity = (over: {
verified?: boolean;
name?: string | null;
email?: string | null;
phone?: string | null;
}): CompanyIdentityState =>
({
passportAccepted: false,
poaDeclared: "no",
subject: "owner",
owner: {
verified: over.verified ?? true,
name: over.name ?? null,
email: over.email ?? null,
phone: over.phone ?? null,
address: null,
verifiedAt: null,
passportNumber: null,
},
poa: {
verified: false,
name: null,
email: null,
phone: null,
address: null,
verifiedAt: null,
passportNumber: null,
},
identityProven: false,
etradeManagerName: null,
etradeManagerPhone: null,
ownerMatchesEtrade: null,
complete: false,
}) as CompanyIdentityState;
it("locks what each source supplied, Fayda outranking eTrade", () => {
const { source, sourced } = resolveOwnerSources(
identity({
name: "Abebe Bikila",
email: "owner@example.com",
phone: "+251911223344",
}),
{ name: "A. Bikila", phone: "+251911999888" },
);
expect(source).toEqual({ name: "Fayda", email: "Fayda", phone: "Fayda" });
expect(sourced.phone).toBe("+251911223344");
});
// The point of the whole exercise: a field is read-only only if what the
// source gave can actually be submitted. Otherwise the customer is shown a
// badge holding a value the API will reject, with no input to fix it.
it("falls back to an input when Fayda's phone claim is unusable", () => {
const { source, sourced } = resolveOwnerSources(
identity({ name: "Abebe Bikila", phone: "09 " }),
null,
);
expect(source.phone).toBeNull();
expect(sourced.phone).toBe("");
});
it("falls back to an input when Fayda's email claim is malformed", () => {
const { source } = resolveOwnerSources(
identity({ name: "Abebe Bikila", email: "not-an-email" }),
null,
);
expect(source.email).toBeNull();
});
it("falls back to an input when eTrade's manager phone is unusable", () => {
const { source, sourced } = resolveOwnerSources(undefined, {
name: "Abebe Bikila",
phone: "09 ",
});
// The name is still eTrade's — names have no format to fail.
expect(source.name).toBe("eTrade");
expect(source.phone).toBeNull();
expect(sourced.phone).toBe("");
});
it("takes eTrade's phone where Fayda has none, normalized", () => {
const { source, sourced } = resolveOwnerSources(
identity({ verified: false }),
{ name: "Abebe Bikila", phone: "0911223344" },
);
expect(source.phone).toBe("eTrade");
expect(sourced.phone).toBe("+251911223344");
});
it("owns nothing when the verification never happened", () => {
const { source } = resolveOwnerSources(
identity({
verified: false,
name: "Abebe Bikila",
email: "owner@example.com",
phone: "+251911223344",
}),
null,
);
expect(source).toEqual({ name: null, email: null, phone: null });
});
});
describe("normalizeIdentityPhones", () => {
it("converts a local Fayda phone claim to E.164", () => {
const identity = {
faydaRequired: true,
passportRequired: false,
passportAccepted: false,
poaDeclared: "yes",
subject: "poa",
owner: {
verified: true,
verified: false,
name: "A",
phone: "0911223344",
email: null,
@@ -283,28 +449,22 @@ describe("normalizeIdentityPhones", () => {
passportNumber: null,
},
poa: {
verified: false,
name: null,
phone: null,
email: null,
address: null,
verifiedAt: null,
},
gm: {
verified: false,
name: null,
verified: true,
name: "B",
phone: "251911223344",
email: null,
address: null,
verifiedAt: null,
passportNumber: null,
},
gmSameAsOwner: false,
complete: false,
identityProven: true,
etradeManagerName: null,
ownerMatchesEtrade: null,
complete: true,
} as CompanyIdentityState;
const fixed = normalizeIdentityPhones(identity)!;
expect(fixed.owner.phone).toBe("+251911223344");
expect(fixed.gm.phone).toBe("+251911223344");
expect(fixed.poa.phone).toBeNull();
expect(fixed.poa.phone).toBe("+251911223344");
});
});

View File

@@ -5,9 +5,9 @@ import { isValidPhone } from "@/components/PhoneField";
export type CompanyStep =
| "company"
| "personnel"
| "owner"
| "representation"
| "contact"
| "poa"
| "documents"
| "additional";
@@ -22,25 +22,33 @@ export const onboardingSchema = z.object({
// can diverge without the backend's eTrade-authenticity check misfiring.
etradePhone: z.string().optional(),
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
// `.length(10)` alone accepted "ABCDEFGHIJ" — the check has to be on digits.
vatNumber: z
.string()
.min(1, "VAT number is required")
.regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"),
// The owner's passport number — the foreign-company identity credential
// (Fayda is an Ethiopian national ID). Required only for a foreign company;
// enforced in buildOnboardingSchema since that depends on `nationality`.
// Required, but no shape check. Ethiopian VAT numbers are usually 10 or 11
// digits; a foreign company's is whatever its own tax authority issues, and a
// co-operative's registration numbering follows neither. A format rule here
// only ever rejected valid numbers we had no business judging.
vatNumber: z.string().min(1, "VAT number is required"),
// Passport numbers — the alternative identity credential for a foreign
// company (Fayda is an Ethiopian national ID). Only the one belonging to the
// declared identity subject is ever asked for, and only when that person has
// not verified with Fayda — so requiredness is decided per render and lives
// in `requiredKeys`, not here.
ownerPassportNumber: z.string().optional(),
poaPassportNumber: z.string().optional(),
licenceNumber: z.string().optional(),
statusDescription: z.string().optional(),
dateRegistered: z.string().optional(),
renewedFrom: z.string().optional(),
renewalDate: z.string().optional(),
renewedTo: z.string().optional(),
// The registered address comes from eTrade and nowhere else — the form
// renders these read-only, so requiring them would be a Continue button that
// fails on a field with no input to fix it. A gap in eTrade's own data stays
// a gap rather than becoming a customer-typed claim wearing eTrade's badge.
// The registered address normally comes from eTrade and nowhere else — the
// form renders these read-only, so requiring them would be a Continue button
// that fails on a field with no input to fix it. A gap in eTrade's own data
// stays a gap rather than becoming a customer-typed claim wearing eTrade's
// badge.
//
// A co-operative is the exception: it has no business licence, so there is no
// eTrade record at all and these ARE typed. Requiredness follows the same
// invariant as everywhere else — it is decided per render, in `requiredKeys`.
region: z.string().optional(),
zone: z.string().optional(),
woreda: z.string().optional(),
@@ -57,21 +65,23 @@ export const onboardingSchema = z.object({
.string()
.min(1, "Contact person phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
// Optional here, not unrequired: the GM is now established by Fayda — either
// verified in their own right or declared the same person as the owner — so
// for an Ethiopian company these fields are never typed and would fail a
// blanket `min(1)`. Presence is gated per nationality in the step's own
// check, where the identity state is available; zod only polices format for
// the foreign companies that still type them.
generalManagerName: z.string().optional(),
generalManagerEmail: z
// The owner — whoever the eTrade licence names as the business's manager.
//
// Optional here, not unrequired: the eTrade lookup fills the name and phone,
// and a Fayda verification can fill all three, so on a well-supplied company
// none of them is typed and a blanket `min(1)` would fail a step with no
// input on screen. What IS required is decided per render — a field is
// required exactly when the step renders an input for it (`requiredKeys`).
// zod only polices format here.
ownerName: z.string().optional(),
ownerEmail: z
.string()
.optional()
.refine(
(v) => !v || z.string().email().safeParse(v).success,
"Invalid Manager email",
"Invalid owner email",
),
generalManagerPhone: z
ownerPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
@@ -80,7 +90,6 @@ export const onboardingSchema = z.object({
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
poaAddress: z.string().optional(),
poaEmail: z
.string()
.optional()
@@ -88,6 +97,10 @@ export const onboardingSchema = z.object({
(v) => !v || z.string().email().safeParse(v).success,
"Invalid email address",
),
// Where the representative is based, as the company states it. Deliberately
// NOT `poaAddress`: that one is a Fayda-owned claim (`IDENTITY_OWNED_FIELDS`)
// the verification writes and the portal must never send — the two used to
// sit side by side here, with the Fayda address silently hiding this input.
poaLocation: z.string().optional(),
});
@@ -102,45 +115,45 @@ export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
* message has to be built here rather than attached to the base schema.
*/
const CONDITIONAL_LABELS: Partial<Record<keyof FormData, string>> = {
// Typed only by a co-operative — every other company gets these from eTrade.
companyName: "Company name",
region: "Region",
zone: "Zone",
woreda: "Woreda",
kebele: "Kebele",
poaName: "Representative's name",
poaEmail: "Representative's email",
poaPhone: "Representative's phone",
generalManagerName: "General manager's name",
generalManagerEmail: "General manager's email",
generalManagerPhone: "General manager's phone",
poaPassportNumber: "Representative's passport number",
ownerName: "Owner's name",
ownerEmail: "Owner's email",
ownerPhone: "Owner's phone",
ownerPassportNumber: "Owner's passport number",
};
/**
* The PoA's and GM's identifying fields normally come from their Fayda
* verification, so nothing in the base schema requires them. But Fayda's email
* and phone claims are optional and routinely come back empty, and the steps
* render an input for whatever the verification did not supply — so those
* fields become mandatory exactly then.
* The owner's and the representative's identifying fields arrive from three
* places — the eTrade lookup, a Fayda verification, or the customer typing them
* and which one supplies what varies per company. eTrade returns no email at
* all; Fayda's email and phone claims are optional and routinely come back
* empty. So nothing in the base schema requires them, and the steps render an
* input for whatever no source supplied.
*
* `requiredKeys` is that decision, made by CompanyProfileForm from the same
* state that drives the rendering: a field is required iff an input exists for
* it. Passing it in (rather than deriving it here) is what keeps the two from
* drifting into a Continue button that fails on a field nobody can see.
* state that drives the rendering: **a field is required iff an input exists
* for it**. Passing it in (rather than deriving it here) is what keeps the two
* from drifting into a Continue button that fails on a field nobody can see.
*/
export function buildOnboardingSchema(
/** True for a foreign company: the owner's passport number is mandatory. */
passportRequired = false,
/** Fields the current step renders an input for and must not leave blank. */
requiredKeys: readonly (keyof FormData)[] = [],
) {
if (!passportRequired && requiredKeys.length === 0) return onboardingSchema;
if (requiredKeys.length === 0) return onboardingSchema;
return onboardingSchema.superRefine((d, ctx) => {
if (passportRequired && !d.ownerPassportNumber?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["ownerPassportNumber"],
message: "The owner's passport number is required",
});
}
for (const key of requiredKeys) {
if (d[key]?.trim()) continue;
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
path: [key],
message: `${CONDITIONAL_LABELS[key] ?? key} is required`,
});
@@ -184,25 +197,46 @@ export const ETRADE_BUNDLE_FIELDS = [
* (`REQUIRED_COMPANY_INFO`), and reports it with a message.
*/
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
// Only the three fields this step actually renders an input for. The company
// name and the registered address are eTrade's, shown read-only.
company: ["tinNumber", "vatNumber", "ownerPassportNumber"],
personnel: [
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
// Only what this step actually renders an input for. The company name and the
// registered address are eTrade's, shown read-only — except for a
// co-operative, which types them (added per render via `requiredKeys`).
company: [
"tinNumber",
"vatNumber",
"companyName",
"region",
"zone",
"woreda",
"kebele",
"houseNo",
],
// `REQUIRED_COMPANY_INFO` demands all three server-side, so the step offers
// an input wherever eTrade and Fayda between them left a gap.
owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerPassportNumber"],
contact: [
"contactPersonName",
"contactPersonPosition",
"contactPersonEmail",
"contactPersonPhone",
],
// The API requires poaName/poaEmail/poaPhone from a freight forwarder
// (`REQUIRED_POA_FIELDS`), so the step has to offer them wherever the
// representative isn't proven by Fayda — otherwise the save is rejected
// naming fields the form never rendered.
poa: ["poaName", "poaEmail", "poaPhone", "poaLocation"],
// The API requires poaName/poaEmail/poaPhone once a representative is
// declared (`REQUIRED_POA_FIELDS`), so the step has to offer them wherever
// Fayda didn't supply them — otherwise the save is rejected naming fields the
// form never rendered.
// `ownerPassportNumber` belongs here as much as the PoA's: the step renders
// whichever passport input the declaration calls for, and when the answer is
// "no PoA" that is the owner's. Leaving it out meant the number was typed on
// this step, validated by nothing, and dropped by `stepPayload` — so the
// final submit failed `assertIdentityVerified` over a field two steps back
// that the customer could see was filled in.
representation: [
"poaName",
"poaEmail",
"poaPhone",
"poaLocation",
"poaPassportNumber",
"ownerPassportNumber",
],
documents: [],
additional: [],
};

View File

@@ -1,12 +1,10 @@
import { Stack, TextInput } from "@mantine/core";
import { Select, SimpleGrid, Stack, Text, TextInput } from "@mantine/core";
import type { UseFormReturn } from "react-hook-form";
import type { CompanyRegistrationData } from "@edr/types";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types";
import ETradeInfo, {
type ETradeStatus,
} from "@/components/onboarding/ETradeInfo";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import type { FormData } from "../schema";
import ETradeCompanyCard from "../ETradeCompanyCard";
@@ -14,14 +12,16 @@ import StepSection from "../StepSection";
export interface CompanyInfoStepProps {
form: UseFormReturn<FormData>;
/** Fayda verification state, phone-normalized by the parent. */
identity?: CompanyIdentityState;
/** True when Fayda (not a passport) is what this company must prove with. */
verifiedIdentity: boolean;
tinStatus: ETradeStatus;
tinVerified: boolean;
/** Registration fields are already populated (a lookup passed, now or earlier). */
hasRegistrationDetails: boolean;
/**
* The company is a co-operative union or farm: it has a TIN but no business
* licence, so eTrade holds no record to look up and the registration is typed
* here instead.
*/
cooperative?: boolean;
onETradeDataLoaded: (data: CompanyRegistrationData) => void;
onETradeStatusChange: (status: ETradeStatus) => void;
onETradeReset: () => void;
@@ -29,11 +29,10 @@ export interface CompanyInfoStepProps {
export default function CompanyInfoStep({
form,
identity,
verifiedIdentity,
tinStatus,
tinVerified,
hasRegistrationDetails,
cooperative = false,
onETradeDataLoaded,
onETradeStatusChange,
onETradeReset,
@@ -41,77 +40,51 @@ export default function CompanyInfoStep({
const {
register,
watch,
setValue,
formState: { errors },
} = form;
const region = watch("region") ?? "";
return (
<Stack gap="xl">
<StepSection
index={1}
title="VAT number"
status={
(watch("vatNumber")?.length ?? 0) >= 10 && !errors.vatNumber
? "done"
: "todo"
}
status={watch("vatNumber")?.trim() && !errors.vatNumber ? "done" : "todo"}
>
<TextInput
aria-label="VAT Number"
placeholder="0012345678"
maxLength={11}
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
</StepSection>
{/* The TIN lookup runs for everyone, co-operative included. A co-op has
no trade licence, but plenty of them are on eTrade all the same — and
when the record is there it is better data than anything typed, so we
ask for it first and fall back to the form below rather than deciding
in advance that nothing will be found. What differs for a co-op is
only the consequence of finding nothing: expected, not an error. */}
<StepSection
index={2}
title="Owner identity"
subtitle={
!identity?.owner.verified && !verifiedIdentity
? "Provide the company owner's passport number."
: undefined
}
status={
verifiedIdentity
? identity?.owner.verified
? "done"
: identity?.faydaRequired
? "blocked"
: "todo"
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
? "done"
: identity?.passportRequired
? "blocked"
: "todo"
}
>
{identity && (
<>
<FaydaVerifyPanel
subject="owner"
title="Owner"
state={identity.owner}
required={identity.faydaRequired}
/>
{identity.passportRequired && (
<TextInput
label="Owner Passport Number"
placeholder="P1234567"
error={errors.ownerPassportNumber?.message}
{...register("ownerPassportNumber")}
/>
)}
</>
)}
</StepSection>
<StepSection
index={3}
title="Company TIN"
subtitle="We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
subtitle={
cooperative
? "We'll check eTrade for your TIN. Co-operatives often aren't listed — if yours isn't, you'll fill the details in below."
: "We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
}
status={
tinVerified ? "done" : tinStatus === "taken" ? "blocked" : "todo"
tinStatus === "taken"
? "blocked"
: cooperative
? watch("tinNumber")?.trim() && !errors.tinNumber
? "done"
: "todo"
: tinVerified
? "done"
: "todo"
}
>
<ETradeInfo
@@ -123,11 +96,87 @@ export default function CompanyInfoStep({
onReset={onETradeReset}
alreadyVerified={hasRegistrationDetails}
selectedLicenceNumber={watch("licenceNumber")}
registrationOptional={cooperative}
/>
{tinVerified && (
{!cooperative && tinVerified && (
<ETradeCompanyCard tin={watch("tinNumber")} watch={watch} />
)}
</StepSection>
{/* 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 && (
<StepSection
index={3}
title="Registration details"
subtitle={
hasRegistrationDetails
? "From eTrade. Correct anything that doesn't look right — for a co-operative these are yours to state."
: "Everything we'd normally read off an eTrade licence. We need it from you instead."
}
status={
watch("companyName")?.trim() && watch("region")?.trim()
? "done"
: "todo"
}
>
<Stack gap="md">
<TextInput
label="Company Name"
placeholder="Registered name of the union or farm"
error={errors.companyName?.message}
{...register("companyName")}
/>
<Text size="sm" c="edr-muted">
Registered address
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Select
label="Region"
placeholder="Select region"
// eTrade's own spelling may not be one of ours. Carrying it in
// as an option keeps the lookup's answer visible instead of
// silently blanking the field it just filled.
data={
region &&
!(ETHIOPIAN_REGIONS as readonly string[]).includes(region)
? [...ETHIOPIAN_REGIONS, region]
: [...ETHIOPIAN_REGIONS]
}
searchable
value={region || null}
onChange={(v) =>
setValue("region", v ?? "", { shouldValidate: true })
}
error={errors.region?.message}
/>
<TextInput
label="Zone"
error={errors.zone?.message}
{...register("zone")}
/>
<TextInput
label="Woreda"
error={errors.woreda?.message}
{...register("woreda")}
/>
<TextInput
label="Kebele"
error={errors.kebele?.message}
{...register("kebele")}
/>
<TextInput
label="House No."
error={errors.houseNo?.message}
{...register("houseNo")}
/>
</SimpleGrid>
</Stack>
</StepSection>
)}
</Stack>
);
}

View File

@@ -9,19 +9,19 @@ import { LinkCheckboxCard } from "../LinkCheckboxCard";
export interface ContactStepProps {
form: UseFormReturn<FormData>;
/**
* 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,21 +34,26 @@ export default function ContactStep({
<Text fw={600} size="sm" c="edr-text">
Contact Person
</Text>
{/* `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 && (
<LinkCheckboxCard
checked={contactSameAsGm}
onToggle={onToggleContactSameAsGm}
title="Same as General Manager"
description="Reuse the general manager's name, email and phone. Uncheck to enter different details."
checked={contactSameAsOwner}
onToggle={onToggleContactSameAsOwner}
title="Same as company owner"
description="Reuse the owner's name, email and phone. Uncheck to enter different details."
/>
)}
<SimpleGrid cols={2} spacing="md">
{/* Disabled while linked, not merely prefilled: the mirror effect
rewrites these from the owner whenever the owner changes, so an edit
made here would be silently thrown away the next time it fires.
Position is the customer's either way — the owner has no equivalent. */}
<TextInput
label="Name"
placeholder="Jane Smith"
disabled={contactSameAsOwner}
error={errors.contactPersonName?.message}
{...register("contactPersonName")}
/>
@@ -64,6 +69,7 @@ export default function ContactStep({
label="Email (Optional)"
type="email"
placeholder="contact@company.com"
disabled={contactSameAsOwner}
error={errors.contactPersonEmail?.message}
{...register("contactPersonEmail")}
/>
@@ -71,6 +77,7 @@ export default function ContactStep({
control={control}
name="contactPersonPhone"
label="Phone"
disabled={contactSameAsOwner}
required
/>
</SimpleGrid>

View File

@@ -0,0 +1,151 @@
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, { type FieldSource } from "../SourcedField";
/** The owner details this step is responsible for. */
type OwnerField = "name" | "email" | "phone";
export interface OwnerStepProps {
form: UseFormReturn<FormData>;
identity?: CompanyIdentityState;
/** eTrade's registered manager, once a TIN lookup has succeeded. */
etradeOwner: { name: string; phone: string } | null;
/**
* Which source owns each of the owner's details, or null where none does.
*
* A sourced field is shown read-only with its provenance; every other one is
* an editable input. Neither the eTrade licence nor a Fayda verification is
* the customer's to retype — the first is the record the backoffice checks
* this company against, the second is the government's. CompanyProfileForm
* computes this and requires exactly the unsourced fields, so every input on
* screen is one the customer is actually asked to fill and nothing is
* required that has none.
*/
source: Record<OwnerField, FieldSource | null>;
/** The value to display for a field its source owns. */
sourced: Record<OwnerField, string>;
/** A co-operative union or farm: no licence, so no eTrade record to match. */
cooperative?: 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.
*
* 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,
source,
sourced,
cooperative = false,
}: OwnerStepProps) {
const {
register,
control,
formState: { errors },
} = form;
const ownerVerified = identity?.owner.verified ?? false;
// 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 (
<Stack gap="md">
<Text size="sm" c="edr-muted">
{cooperative && !etradeOwner
? "The person who runs the co-operative union or farm. eTrade held no record for your TIN, so we need all of these from you."
: "These are the details of the person registered on your eTrade licence. What eTrade and Fayda gave us is shown as they gave it; anything they left blank we need from you."}
</Text>
{/* A co-operative is not told its licence listed no manager — it has no
licence. Its own "nothing came back" case is covered by the line
above. */}
{!cooperative && !etradeOwner && !ownerVerified && (
<Alert color="blue" variant="light" icon={<Info size={18} />}>
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.
</Alert>
)}
{mismatch && (
<Alert
color="amber"
variant="light"
icon={<AlertTriangle size={18} />}
title="This doesn't match your eTrade licence"
>
Your licence lists <strong>{identity?.etradeManagerName}</strong>, but
the name here is <strong>{identity?.owner.name}</strong>. You can
continue, but our team will check this before approving your account
so make sure it's the person the licence actually names.
</Alert>
)}
<SourcedField
label="Owner's Name"
value={sourced.name}
source={source.name}
>
<TextInput
label="Owner's Name"
placeholder="Abebe Bikila"
error={errors.ownerName?.message}
{...register("ownerName")}
/>
</SourcedField>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{/* 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`). */}
<SourcedField
label="Owner's Email"
value={sourced.email}
source={source.email}
>
<TextInput
label="Owner's Email"
type="email"
placeholder="owner@company.com"
error={errors.ownerEmail?.message}
{...register("ownerEmail")}
/>
</SourcedField>
<SourcedField
label="Owner's Phone"
value={sourced.phone}
source={source.phone}
>
<ControlledPhoneField
control={control}
name="ownerPhone"
label="Owner's Phone"
/>
</SourcedField>
</SimpleGrid>
</Stack>
);
}

View File

@@ -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<FormData>;
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 (
<>
<Text fw={600} size="sm" c="edr-text">
General Manager
</Text>
{/* 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. */}
<LinkCheckboxCard
checked={gmSameAsOwner}
onToggle={onToggleGmSameAsOwner}
title={
identity?.owner.verified
? "Same as verified owner"
: "Same as business owner"
}
description={
identity?.owner.verified
? "Reuse the Fayda-verified owner's identity for the general manager. Uncheck to verify a different person."
: etradeOwner
? "Reuse the eTrade-registered owner's name, plus the company email and phone as you entered them. Uncheck to enter different details."
: "Reuse your account's name and the company email and phone as you entered them. Uncheck to enter different details."
}
/>
{/* Verifying a second person is only meaningful when the GM is
someone other than the owner. */}
{!gmSameAsOwner && identity && (
<FaydaVerifyPanel
subject="gm"
title="General Manager"
state={identity.gm}
required={identity.faydaRequired}
disabled={gmLinkPending}
/>
)}
{/* 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 && (
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
)}
{(gaps.email || gaps.phone) && (
<SimpleGrid cols={gaps.email && gaps.phone ? 2 : 1} spacing="md">
{gaps.email && (
<TextInput
label="Email"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
)}
{gaps.phone && (
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone"
required
/>
)}
</SimpleGrid>
)}
{/* 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 && (
<>
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Email"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone"
required
/>
</SimpleGrid>
</>
)}
</>
);
}

View File

@@ -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<FormData>;
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<string, File | File[] | null>;
uploadedDocumentKeys?: string[];
documentFieldErrors: Record<string, string>;
onDocumentFilesChange: (next: Record<string, File | File[] | null>) => 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 (
<>
<Text size="sm" c="edr-muted">
{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."}
</Text>
{/* 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 && (
<LinkCheckboxCard
checked={poaSameAsOwner}
onToggle={onTogglePoaSameAsOwner}
disabled={poaLinkPending || (linkNeedsVerifiedOwner && !poaSameAsOwner)}
title={
identity.owner.verified
? "Same as verified owner"
: "Same as business owner"
}
description={
linkNeedsVerifiedOwner
? "Verify the company owner with Fayda first — then you can reuse that identity here."
: identity.owner.verified
? "You represent the company yourself. Reuses the Fayda-verified owner's identity, and no DARS delegation paper is needed. Uncheck to name someone else."
: etradeOwner
? "You represent the company yourself. Reuses the eTrade-registered owner's name plus the company email and phone as you entered them, and no DARS delegation paper is needed. Uncheck to name someone else."
: "You represent the company yourself. Reuses your account's name, email and phone, and no DARS delegation paper is needed. Uncheck to name someone else."
}
/>
)}
{/* 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 && (
<FaydaVerifyPanel
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={requirePoa}
disabled={poaLinkPending}
/>
)}
{/* 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 && (
<Group justify="flex-end">
<Button
type="button"
variant="subtle"
color="red"
size="xs"
loading={removePending}
leftSection={<Trash2 size={14} />}
onClick={onRemovePoa}
>
Remove this representative
</Button>
</Group>
)}
{/* Whatever the Fayda claim did carry is shown on the panel above and
is never typed here — the verification owns it. */}
{gaps.name && (
<TextInput
label="Representative's Name"
placeholder="Abebe Bikila"
error={errors.poaName?.message}
{...register("poaName")}
/>
)}
{(needsEmail || needsPhone) && (
<SimpleGrid cols={needsEmail && needsPhone ? 2 : 1} spacing="md">
{needsEmail && (
<TextInput
label="Representative's Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
)}
{needsPhone && (
<ControlledPhoneField
control={control}
name="poaPhone"
label="Representative's Phone"
/>
)}
</SimpleGrid>
)}
{gaps.address && (
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
)}
{/* 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 && (
<>
<Divider my="sm" />
<SmartFileInput
file={poaDocumentSetting}
value={documentFiles}
uploadedKeys={uploadedDocumentKeys}
errors={documentFieldErrors}
onChange={onDocumentFilesChange}
/>
</>
)}
</>
);
}

View File

@@ -0,0 +1,363 @@
import {
Button,
Card,
Divider,
Group,
Loader,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { IdCard, ShieldCheck, 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";
/** How a foreign company chose to prove the person: Fayda, or a passport. */
export type IdentityMethod = "fayda" | "passport";
export interface RepresentationStepProps {
form: UseFormReturn<FormData>;
identity?: CompanyIdentityState;
/** Answer the power-of-attorney question (persisted server-side). */
onDeclare: (declared: PoaDeclaration) => void;
/** A declaration change is in flight. */
declarePending: boolean;
/**
* 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.
*/
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.
*
* There is deliberately no `address` here. Fayda's address claim is stored as
* `poaAddress`, which the portal never sends; the input below writes
* `poaLocation`, a different field the company states itself. Gating one on
* the other hid the only input for `poaLocation` from every verified
* representative whose Fayda record happened to carry an address.
*/
locked: { name: boolean; email: boolean; phone: 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<string, File | File[] | null>;
uploadedDocumentKeys?: string[];
documentFieldErrors: Record<string, string>;
onDocumentFilesChange: (next: Record<string, File | File[] | null>) => 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,
declarationLocked,
locked,
method,
onMethodChange,
poaDocumentSetting,
documentFiles,
uploadedDocumentKeys,
documentFieldErrors,
onDocumentFilesChange,
}: RepresentationStepProps) {
const {
register,
control,
formState: { errors },
} = form;
if (!identity) {
return (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
);
}
const declared = identity.poaDeclared;
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 (
<Stack gap="lg">
{/* ------------------------------------------------------------------ */}
{/* 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 ? (
<>
<Stack gap="xs">
<Text fw={600} size="lg" c="edr-text">
Does anyone hold power of attorney for this company?
</Text>
<Text size="sm" c="edr-muted">
Your answer decides whose identity we verify the
representative's, or the owner's.
</Text>
</Stack>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<RoleCard
label="Yes, we have a representative"
description="Someone other than the owner is authorised to act for the company. We'll verify their identity and ask for the DARS delegation paper."
icon={<UserCheck size={20} />}
selected={false}
onClick={declarePending ? undefined : () => onDeclare("yes")}
/>
<RoleCard
label="No, the owner acts for us"
description="Nobody holds power of attorney. We'll verify the owner instead, and no delegation paper is needed."
icon={<UserX size={20} />}
selected={false}
onClick={declarePending ? undefined : () => onDeclare("no")}
/>
</SimpleGrid>
</>
) : (
<ChoiceSummary
icon={declared === "yes" ? <UserCheck size={18} /> : <UserX size={18} />}
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"}
/>
)}
{declared !== null && (
<>
<Divider />
{/* -------------------------------------------------------------- */}
{/* How the person is proved. Ethiopian: Fayda, no choice. Foreign: */}
{/* Fayda or a passport — one or the other, picked outright. */}
{/* -------------------------------------------------------------- */}
{passportAccepted && !verified && method === null ? (
<Stack gap="xs">
<Text fw={600} c="edr-text">
How would you like to prove {who.toLowerCase()}'s identity?
</Text>
<Text size="sm" c="edr-muted">
Either one is enough — you don't need both.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mt="xs">
<RoleCard
label="Verify with Fayda"
description="Their name, phone and address come straight from the national ID — nothing to type."
icon={<ShieldCheck size={20} />}
selected={false}
onClick={() => onMethodChange("fayda")}
/>
<RoleCard
label="Use a passport instead"
description="For someone who holds no Fayda ID. You'll enter their passport number and details yourself."
icon={<IdCard size={20} />}
selected={false}
onClick={() => onMethodChange("passport")}
/>
</SimpleGrid>
</Stack>
) : (
<>
{(method === "fayda" || !passportAccepted || verified) && (
<FaydaVerifyPanel
subject={declared === "yes" ? "poa" : "owner"}
title={declared === "yes" ? "Power of Attorney" : "Company owner"}
state={subject}
required={!passportAccepted}
/>
)}
{passportAccepted && !verified && method === "passport" && (
<TextInput
label={`${who}'s Passport Number`}
description="Fayda is an Ethiopian national ID, so a passport number proves this person instead."
placeholder="P1234567"
error={errors[passportField]?.message}
{...register(passportField)}
/>
)}
{passportAccepted && !verified && method !== null && (
<Button
variant="subtle"
size="compact-xs"
style={{ alignSelf: "flex-start" }}
onClick={() =>
onMethodChange(method === "fayda" ? "passport" : "fayda")
}
>
{method === "fayda"
? "Use a passport instead"
: "Verify with Fayda instead"}
</Button>
)}
</>
)}
{/* -------------------------------------------------------------- */}
{/* The representative's own details — only once the person exists, */}
{/* and only the parts the verification did not carry. What Fayda */}
{/* supplied is on the card above; echoing it back here as read-only */}
{/* rows was the same data twice with nothing to do about either. */}
{/* -------------------------------------------------------------- */}
{declared === "yes" && established && (
<>
{!locked.name && (
<TextInput
label="Representative's Name"
placeholder="Abebe Bikila"
error={errors.poaName?.message}
{...register("poaName")}
/>
)}
{(!locked.email || !locked.phone) && (
<SimpleGrid
cols={{ base: 1, sm: !locked.email && !locked.phone ? 2 : 1 }}
spacing="md"
>
{!locked.email && (
<TextInput
label="Representative's Email"
type="email"
placeholder="representative@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
)}
{!locked.phone && (
<ControlledPhoneField
control={control}
name="poaPhone"
label="Representative's Phone"
/>
)}
</SimpleGrid>
)}
<TextInput
label="Representative's Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
{poaDocumentSetting && (
<>
<Divider my="sm" />
<SmartFileInput
file={poaDocumentSetting}
value={documentFiles}
uploadedKeys={uploadedDocumentKeys}
errors={documentFieldErrors}
onChange={onDocumentFilesChange}
/>
</>
)}
</>
)}
</>
)}
</Stack>
);
}
/** 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 (
<Card padding="md" radius="md" withBorder>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" align="flex-start" wrap="nowrap">
<span style={{ display: "flex", marginTop: 2 }}>{icon}</span>
<Stack gap={2}>
<Text fw={600} size="sm" c="edr-text">
{label}
</Text>
<Text size="xs" c="edr-muted">
{detail}
</Text>
</Stack>
</Group>
{onChange && (
<Button variant="subtle" size="compact-xs" onClick={onChange}>
{changeLabel}
</Button>
)}
</Group>
</Card>
);
}

View File

@@ -9,6 +9,11 @@ interface OnboardingRoleSelectProps {
onChange: (next: string[]) => void;
/** Render only the option grid — the wizard supplies its own header/card. */
embedded?: boolean;
/**
* Roles this company cannot hold, hidden rather than shown-and-refused. A
* co-operative has no business licence, so it cannot freight-forward.
*/
excludeTypes?: readonly string[];
}
/**
@@ -22,8 +27,12 @@ export default function OnboardingRoleSelect({
value,
onChange,
embedded = false,
excludeTypes,
}: OnboardingRoleSelectProps) {
const selected = new Set(value);
const roles = excludeTypes?.length
? CUSTOMER_ROLES.filter((r) => !excludeTypes.includes(r.type))
: CUSTOMER_ROLES;
const toggleRole = (type: string) => {
const next = new Set(value);
@@ -34,7 +43,7 @@ export default function OnboardingRoleSelect({
const grid = (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{CUSTOMER_ROLES.map((role) => (
{roles.map((role) => (
<RoleCard
key={role.type}
label={role.label}

View File

@@ -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<string, unknown>;
} | 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({
/>
</StepSection>
{identity && (
<StepSection
index={2}
title="Owner identity"
subtitle={
verifiedIdentity
? "Re-verify the company owner with Fayda — their name, phone, email and address are refreshed from the verification."
: "The company owner's passport number."
}
status={
verifiedIdentity
? identity.owner.verified
? "done"
: identity.faydaRequired
? "blocked"
: "todo"
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
? "done"
: identity.passportRequired
? "blocked"
: "todo"
}
>
<FaydaVerifyPanel
subject="owner"
title="Company owner"
state={identity.owner}
required={identity.faydaRequired}
disabled={mutation.isPending}
pendingReview={pendingOwnerReview}
/>
{identity.passportRequired && (
<TextInput
label="Owner Passport Number"
placeholder="P1234567"
description="The owner's identity credential — Fayda is an Ethiopian national ID, so a foreign company's owner is identified by passport instead."
error={errors.ownerPassportNumber?.message}
{...register("ownerPassportNumber")}
/>
)}
</StepSection>
)}
<StepSection
index={3}
index={2}
title="Company TIN"
subtitle="Re-verify with eTrade to refresh your registration record — nothing here is typed by hand."
status={

View File

@@ -1,333 +0,0 @@
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Briefcase, CheckCircle2, Save, XCircle } from "lucide-react";
import {
Alert,
Card,
Group,
Stack,
Title,
Text,
TextInput,
Button,
Grid,
} from "@mantine/core";
import { api } from "@/services/api";
import { verifaydaService } from "@/services/verifayda.service";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import type { ProfileResponse } from "@/types/profile";
// Optional, not unrequired: an Ethiopian company's GM is established through
// Fayda and never types these, so a blanket `min(1)` would fail a form that is
// correct. Presence is gated below, where the identity state says which route
// applies; zod only polices format for the companies that still type them.
const schema = z.object({
generalManagerName: z.string().optional(),
generalManagerEmail: z
.string()
.optional()
.refine(
(v) => !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<typeof schema>;
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<FormData>({
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<string | null>(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 (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Briefcase size={20} />
<Title order={3}>General Manager</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
Manage the general manager information
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<LinkCheckboxCard
checked={gmSameAsOwner}
onToggle={toggleGmSameAsOwner}
title={
owner?.verified ? "Same as verified owner" : "Same as business owner"
}
description={
owner?.verified
? "Reuse the Fayda-verified owner's identity for the general manager. Uncheck to verify a different person."
: "Reuse the owner's name, email and phone. Uncheck to enter different details."
}
/>
{linkError && (
<Alert color="red" variant="light" icon={<XCircle size={18} />}>
{linkError}
</Alert>
)}
{/* Verifying a second person only means something when the manager
is someone other than the owner. */}
{!gmSameAsOwner && gm && (
<FaydaVerifyPanel
subject="gm"
title="General Manager"
state={gm}
required={faydaRequired}
disabled={linkPending || mutation.isPending}
/>
)}
{/* Whatever the verification did not supply is typed instead — the
API keeps exactly those keys writable. */}
{gmGaps.name && (
<TextInput
label="Full Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
)}
{(gmGaps.email || gmGaps.phone) && (
<Grid>
{gmGaps.email && (
<Grid.Col span={gmGaps.phone ? 6 : 12}>
<TextInput
label="Email Address"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
</Grid.Col>
)}
{gmGaps.phone && (
<Grid.Col span={gmGaps.email ? 6 : 12}>
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone Number"
required
/>
</Grid.Col>
)}
</Grid>
)}
{/* 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 && (
<>
<TextInput
label="Full Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
<Grid>
<Grid.Col span={6}>
<TextInput
label="Email Address"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
</Grid.Col>
<Grid.Col span={6}>
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone Number"
required
/>
</Grid.Col>
</Grid>
</>
)}
</Stack>
<Group
justify="space-between"
mt="xl"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{mutation.isSuccess && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>Saved successfully</Text>
</Group>
)}
{mutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>Save failed</Text>
</Group>
)}
</Group>
<Group gap="md">
{mode === "edit" && typedFieldsInUse && (
<Button
type="button"
variant="outline"
disabled={mutation.isPending || !isDirty}
onClick={() => reset()}
>
Reset
</Button>
)}
{/* 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 ? (
<Button
type="submit"
leftSection={<Save size={16} />}
loading={mutation.isPending}
>
{mode === "onboarding" ? "Continue" : "Save Changes"}
</Button>
) : (
mode === "onboarding" && (
<Button type="button" onClick={() => onContinue?.()}>
Continue
</Button>
)
)}
</Group>
</Group>
</form>
</Card>
);
}

View File

@@ -0,0 +1,219 @@
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<typeof schema>;
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";
// 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,
control,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
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 (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Briefcase size={20} />
<Title order={3}>Company Owner</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
The person registered on your eTrade licence.
</Text>
<form onSubmit={handleSubmit((data) => mutation.mutate(data))}>
<Stack gap="md">
{identity?.ownerMatchesEtrade === false && (
<Alert
color="amber"
variant="light"
icon={<AlertTriangle size={18} />}
title="This doesn't match your eTrade licence"
>
Your licence lists <strong>{identity.etradeManagerName}</strong>.
Our team checks this before approving changes.
</Alert>
)}
{ownerIsSubject && owner && (
<FaydaVerifyPanel
subject="owner"
title="Owner identity"
state={owner}
required={!identity?.passportAccepted}
pendingReview={profile.reviewStatus === "pending"}
/>
)}
<SourcedField
label="Name"
value={owner?.name}
source={ownerLocked.name ? "Fayda" : null}
>
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.ownerName?.message}
{...register("ownerName")}
/>
</SourcedField>
<Grid>
<Grid.Col span={{ base: 12, sm: 6 }}>
<SourcedField
label="Email"
value={owner?.email}
source={ownerLocked.email ? "Fayda" : null}
>
<TextInput
label="Email"
type="email"
placeholder="owner@company.com"
error={errors.ownerEmail?.message}
{...register("ownerEmail")}
/>
</SourcedField>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<SourcedField
label="Phone"
value={owner?.phone}
source={ownerLocked.phone ? "Fayda" : null}
>
<ControlledPhoneField
control={control}
name="ownerPhone"
label="Phone"
/>
</SourcedField>
</Grid.Col>
</Grid>
{savable && (
<Group justify="flex-end">
<Button
type="submit"
color="edr-green"
loading={mutation.isPending}
leftSection={<Save size={16} />}
>
{mode === "onboarding" ? "Save & continue" : "Save changes"}
</Button>
</Group>
)}
</Stack>
</form>
</Card>
);
}

View File

@@ -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<string | null>(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 ??
@@ -295,12 +288,14 @@ export default function TabPowerOfAttorney({
)}
</Group>
<Text c="edr-muted" size="sm" mb="lg">
{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."}
{/* 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."}
</Text>
{/* The owner representing their own company is the ordinary
@@ -309,30 +304,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 && (
<LinkCheckboxCard
checked={poaSameAsOwner}
onToggle={togglePoaSameAsOwner}
disabled={
linkPending ||
mutation.isPending ||
(!poaSameAsOwner &&
(identity.faydaRequired ?? false) &&
!owner?.verified)
}
title={
owner?.verified
? "Same as verified owner"
: "Same as business owner"
}
description={
(identity.faydaRequired ?? false) && !owner?.verified
? "Verify the company owner with Fayda first — then you can reuse that identity here."
: owner?.verified
? "You represent the company yourself. Reuses the Fayda-verified owner's identity, and no DARS delegation paper is needed. Uncheck to name someone else."
: "You represent the company yourself. Reuses the owner's name, email and phone, and no DARS delegation paper is needed. Uncheck to name someone else."
}
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mb="md">
<RoleCard
label="Yes, we have a representative"
description="Someone other than the owner is authorised to act for the company. Their identity is verified and the DARS delegation paper is required."
icon={<UserCheck size={20} />}
selected={declared === "yes"}
onClick={
linkPending || declared === "yes"
? undefined
: () => void declare("yes")
}
/>
<RoleCard
label="No, the owner acts for us"
description="Nobody holds power of attorney. The owner's identity is verified instead, and no delegation paper is needed."
icon={<UserX size={20} />}
selected={declared === "no"}
onClick={
linkPending || declared === "no"
? undefined
: () => void declare("no")
}
/>
</SimpleGrid>
)}
{linkError && (
@@ -343,12 +343,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" && (
<FaydaVerifyPanel
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={requirePoa}
required={!identity.passportAccepted}
disabled={mutation.isPending || linkPending}
/>
)}
@@ -359,7 +359,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 && (
<Grid>
<Grid.Col span={6}>
<TextInput
@@ -376,7 +376,7 @@ export default function TabPowerOfAttorney({
{/* ------------------------ Delegation letter ------------------------ */}
{/* The paper authorises the representative the verification named,
so it only has meaning once one exists. */}
{poaProvided && !poaSameAsOwner && (
{declared === "yes" && (
<Stack gap="sm" mt="xl">
<Group justify="space-between" align="center">
<Group gap="sm">
@@ -418,7 +418,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."}
</Alert>
)}
@@ -551,12 +551,12 @@ export default function TabPowerOfAttorney({
)}
</Group>
<Group gap="md">
{/* 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 && (
<Button
type="button"

View File

@@ -238,6 +238,8 @@ export const api = {
companyType: string;
roles: ProfileTypeValue[];
nationality?: CompanyNationality;
/** No business licence: registration typed, no eTrade lookup, no forwarding. */
cooperative?: boolean;
},
CompanyInfoResponse
>("companies", "startOnboarding", companiesService.startOnboarding),

View File

@@ -158,14 +158,17 @@ export interface OnboardingLicenseProfile {
uploaded: boolean;
}
/** Power of Attorney state — mandatory for freight forwarders, optional otherwise. */
/** Power of Attorney state, driven by the company's own declaration. */
export interface OnboardingPoaState {
required: boolean;
provided: boolean;
/**
* True when the DARS delegation paper is owed. False when the owner
* represents the company themselves — nobody delegates to themselves.
* True for a freight forwarder: it signs on other companies' behalf, so a
* representative is non-negotiable and the question is shown answered rather
* than asked.
*/
locked: boolean;
/** The company's answer. Null until it answers — itself an outstanding item. */
declared: "yes" | "no" | null;
/** True when the DARS delegation paper is owed — i.e. `declared === "yes"`. */
delegationLetterRequired: boolean;
delegationLetterUploaded: boolean;
/** True when a reviewer sent the DARS delegation paper back for correction. */
@@ -181,7 +184,16 @@ export interface OnboardingPoaState {
*/
export interface OnboardingRequirements {
documentSettingCode: string;
/**
* Extra document set merged on top of the nationality one for a co-operative,
* null otherwise. `documents` already carries the merged list; this is only
* so the pickers, which render from the file-settings endpoint, can fetch the
* same extra fields.
*/
cooperativeDocumentSettingCode: string | null;
nationality: string;
/** No business licence: registration typed by hand, no eTrade lookup. */
cooperative: boolean;
companyInfo: {
complete: boolean;
missingFields: { key: string; label: string }[];
@@ -189,7 +201,7 @@ export interface OnboardingRequirements {
documents: OnboardingDocumentField[];
licenseProfiles: OnboardingLicenseProfile[];
poa: OnboardingPoaState;
/** Fayda verification state; `required` is false for a foreign company. */
/** The company's single identity verification, and whose it is. */
identity: CompanyIdentityState;
progress: { completed: number; total: number };
isComplete: boolean;
@@ -321,6 +333,7 @@ export const companiesService = {
companyType: string;
roles: ProfileTypeValue[];
nationality?: CompanyNationality;
cooperative?: boolean;
}): Promise<CompanyInfoResponse> => {
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.ONBOARDING_START,

View File

@@ -3,14 +3,21 @@ import { unwrap } from "@/utils/endpoint";
import type { ApiResponse } from "@/types/apiResponse";
/**
* Which of the company's people a verification is for. The owner is who the
* company is proven through; the PoA and GM are personnel it names. The GM is
* very often the owner — "same as owner" reuses that verification rather than
* making the same human prove themselves twice.
* Which of the company's two people a verification is for.
*
* The **owner** is whoever the eTrade licence names as the business's manager
* — not necessarily the legal owner, but the person the record has to match.
* The **PoA** is who the company delegates to act for it.
*
* Exactly one of them is verified, chosen by the company's own answer to "does
* anyone hold power of attorney for you?" — see `poaDeclared`.
*/
export type IdentitySubject = "owner" | "poa" | "gm";
export type IdentitySubject = "owner" | "poa";
/** One person's Fayda verification state, as the API reports it. */
/** Whether the company named a representative. Null until it answers. */
export type PoaDeclaration = "yes" | "no";
/** One person's identity state, as the API reports it. */
export interface IdentityVerificationState {
verified: boolean;
name: string | null;
@@ -18,43 +25,49 @@ export interface IdentityVerificationState {
email: string | null;
address: string | null;
verifiedAt: string | null;
}
export interface OwnerIdentityState extends IdentityVerificationState {
/**
* 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 — the ALTERNATIVE to Fayda for a foreign company,
* never written by a verification. Only asked of whichever person carries
* the company's identity, and only when `passportAccepted`.
*/
passportNumber: string | null;
}
export interface CompanyIdentityState {
/**
* True when Fayda verification is mandatory — Ethiopian companies only.
* Doubles as "may this person be typed instead": Fayda is an Ethiopian
* national ID, so a foreign company's GM and PoA are offered the
* verification but fall back to typed details when they hold none.
* True for a foreign company: a typed passport number proves the identity
* just as a Fayda verification does. Fayda is an Ethiopian national ID, so an
* Ethiopian company has no alternative to it.
*/
faydaRequired: boolean;
/** True when the owner's passport number is mandatory — foreign companies only. */
passportRequired: boolean;
owner: OwnerIdentityState;
passportAccepted: boolean;
/**
* The company's answer to the power-of-attorney question. Null until it
* answers — which is itself outstanding, since the answer decides who
* verifies. Always "yes" for a freight forwarder, which cannot operate
* without a representative and is never asked.
*/
poaDeclared: PoaDeclaration | null;
/** Whose verification the company is gated on. Null while undeclared. */
subject: IdentitySubject | null;
owner: IdentityVerificationState;
poa: IdentityVerificationState;
/** True once `subject` is proven — Fayda-verified, or passport where accepted. */
identityProven: boolean;
/** The manager named on the eTrade licence, captured at lookup. */
etradeManagerName: string | null;
/**
* True when the representative is the owner themselves, declared through
* "same as owner". Waives the DARS delegation paper — nobody delegates to
* themselves — and, where the owner is Fayda-verified, backs `poa.verified`
* with the owner's sub.
* That manager's phone (E.164), from the same lookup. Together with the name
* this is what survives a refresh: the live lookup result does not, so
* without these two a resumed wizard cannot tell an eTrade-sourced owner from
* a typed one, and offers the licence's own data back as editable inputs.
*/
poaSameAsOwner: boolean;
etradeManagerPhone: string | null;
/**
* General manager. `verified` covers both routes: the GM verifying in their
* own right, and the company declaring the GM is the owner (in which case
* `gmSameAsOwner` is set and the owner's Fayda sub backs it).
* Does the owner the company put forward match the eTrade licence? This is
* the backoffice's check. Null when there is nothing to compare. Advisory:
* eTrade's and Fayda's transliterations rarely agree exactly.
*/
gm: IdentityVerificationState;
gmSameAsOwner: boolean;
ownerMatchesEtrade: boolean | null;
complete: boolean;
}
@@ -127,65 +140,19 @@ export const verifaydaService = {
},
/**
* Declare the General Manager is the company's owner, reusing the owner's
* verified identity rather than making the same human verify twice. The copy
* happens server-side from the stored owner identity — the portal never
* supplies the values — and is refused until the owner is verified.
*/
setGmSameAsOwner: async (): Promise<CompanyIdentityState> => {
const response = await client.post<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/gm/same-as-owner",
);
return unwrap(response.data);
},
/**
* Clear the GM's identity — the "same as owner" declaration or a verification
* of their own — leaving them open to be re-established either way.
*/
clearGmIdentity: async (): Promise<CompanyIdentityState> => {
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/gm",
);
return unwrap(response.data);
},
/**
* Declare the Power of Attorney is the company's owner. A Fayda-verified
* owner's identity is copied server-side (the portal never supplies it); a
* foreign company's owner has nothing proven to copy, so the API records the
* declaration and the form types the representative's details as usual.
* Answer whether anyone holds power of attorney for this company — the
* question that decides whose identity is verified.
*
* Either way the declaration is what waives the DARS delegation paper.
* Answering "no" tears the representative down server-side: their details,
* their verification, their passport number and the DARS delegation paper.
* Refused for a freight forwarder, which cannot operate without one.
*/
setPoaSameAsOwner: async (): Promise<CompanyIdentityState> => {
const response = await client.post<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/poa/same-as-owner",
);
return unwrap(response.data);
},
/**
* Undo that declaration and the identity it copied, leaving the
* representative open to be verified in their own right. Unlike
* {@link removePoa} this is allowed for a freight forwarder — it is how they
* change who represents them — and leaves the delegation paper on file.
*/
clearPoaSameAsOwner: async (): Promise<CompanyIdentityState> => {
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/poa/same-as-owner",
);
return unwrap(response.data);
},
/**
* Drop the Power of Attorney — verified identity, details and delegation
* paper together. A verified person's fields are locked, so blanking the form
* is no longer a way to remove them. Refused for a freight forwarder.
*/
removePoa: async (): Promise<CompanyIdentityState> => {
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/fayda/poa",
setPoaDeclared: async (
declared: PoaDeclaration,
): Promise<CompanyIdentityState> => {
const response = await client.patch<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/poa-declared",
{ declared },
);
return unwrap(response.data);
},

View File

@@ -6,6 +6,8 @@ export interface ProfileResponse {
companyName: string;
companyType: string;
nationality: string | null;
/** No business licence: the registration is typed, not fetched from eTrade. */
cooperative: boolean;
companyProfiles: CompanyProfileResponse[];
companyLocation: string;
companyAddress: string | null;
@@ -30,19 +32,15 @@ export interface ProfileResponse {
contactPersonPhone: string | null;
/** Phone that passed SMS OTP verification (resumes the verify step's state). */
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;
/**
* Fayda verification state for the owner, the PoA and the general manager.
* `identity.faydaRequired` / `identity.passportRequired` is the
* Ethiopian/foreign switch: an Ethiopian company verifies all three with
* Fayda, while a foreign one proves its owner with a typed passport number
* and may type its GM and PoA, whose holders may have no Fayda ID.
*
* The `generalManager*` fields above are the same person's details written
* flat — a verification keeps them in step, since the booking, contract and
* train-scheduling notifiers mail `generalManagerEmail` directly.
* The company's single identity verification. `identity.subject` says whose
* it is (the PoA when one is declared, otherwise the owner);
* `identity.passportAccepted` is the Ethiopian/foreign switch — a foreign
* company may prove the same person with a typed passport number instead.
*/
identity: CompanyIdentityState;
poaName: string | null;
@@ -88,14 +86,18 @@ export interface UpdateProfilePayload {
contactPersonEmail?: string;
contactPersonPhone?: string;
contactVerifiedPhone?: string;
generalManagerName?: string;
generalManagerEmail?: string;
generalManagerPhone?: string;
ownerName?: string;
ownerEmail?: string;
ownerPhone?: string;
poaName?: string;
poaPhone?: string;
poaEmail?: string;
poaLocation?: string;
poaAddress?: string;
/** The owner's passport number — the foreign-company identity credential. */
/**
* Passport numbers — the ALTERNATIVE to Fayda for a foreign company. Only the
* one belonging to the declared identity subject is ever collected.
*/
ownerPassportNumber?: string;
poaPassportNumber?: string;
}

View File

@@ -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<T> {
const url = `${this.baseUrl}${path}`;
this.logger.log("=====================================================================");
this.logger.log(`URL ${url}`);
this.logger.log("=====================================================================");
try {
const response = await firstValueFrom(
this.http.request<T>({

View File

@@ -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) {

View File

@@ -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(

View File

@@ -24,6 +24,8 @@ export default function ReportsPage() {
const [dateRange, setDateRange] = useState('30');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [routeOrigin, setRouteOrigin] = useState('');
const [routeDestination, setRouteDestination] = useState('');
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
@@ -78,6 +80,12 @@ export default function ReportsPage() {
return r ? 1 / r.rate : null;
};
const getBookingTicketCount = (booking: any): number => {
if (Array.isArray(booking.tickets)) return booking.tickets.length;
if (typeof booking.ticketCount === 'number') return booking.ticketCount;
return 0;
};
const calcGrand = (rows: { currency: string; totalMinor: number }[]) =>
rows.reduce((sum, { currency, totalMinor }) => {
const rate = toEtbRate(currency);
@@ -107,6 +115,14 @@ export default function ReportsPage() {
const confirmedBookings = bookings.filter((b: any) => b.status !== 'CANCELLED' && b.status !== 'REFUNDED');
const totalRevenueMinor = confirmedBookings.reduce((sum, b: any) => {
const rate = toEtbRate(b.currency);
return rate !== null ? sum + Math.round((b.totalMinor || 0) * rate) : sum;
}, 0);
const totalBookingsCount = confirmedBookings.length;
const totalTicketsCount = confirmedBookings.reduce((sum, b: any) => sum + getBookingTicketCount(b), 0);
const byDate = confirmedBookings.reduce((acc: Record<string, any>, b: any) => {
const date = new Date(b.createdAt).toISOString().split('T')[0];
if (!acc[date]) acc[date] = { totalMinor: 0, count: 0 };
@@ -123,7 +139,50 @@ export default function ReportsPage() {
bookings: d.count || 0,
}));
const avgDailyRevenue = chartData.length > 0 ? Math.round(overallGrand / 100 / chartData.length) : 0;
const avgDailyRevenueMinor = chartData.length > 0 ? Math.round(totalRevenueMinor / chartData.length) : 0;
const totalRegularBookingsCount = confirmedBookings.filter((b: any) => {
const bookingType = String(b.bookingType || '').toUpperCase();
return bookingType !== 'PACKAGE' && !b.packageId;
}).length;
const totalPackageBookingsCount = confirmedBookings.filter((b: any) => {
const bookingType = String(b.bookingType || '').toUpperCase();
return bookingType === 'PACKAGE' || Boolean(b.packageId);
}).length;
const filteredRouteBookings = confirmedBookings.filter((b: any) => {
const origin = b.schedule?.originStation?.name || b.originStationName || b.origin || 'Unknown';
const destination = b.schedule?.destinationStation?.name || b.destinationStationName || b.destination || 'Unknown';
if (routeOrigin && origin !== routeOrigin) return false;
if (routeDestination && destination !== routeDestination) return false;
return true;
});
const routeOriginOptions = [
...new Set(confirmedBookings.map((b: any) => b.schedule?.originStation?.name || b.originStationName || b.origin || 'Unknown').filter(Boolean)),
].sort() as string[];
const routeDestinationOptions = [
...new Set(confirmedBookings.map((b: any) => b.schedule?.destinationStation?.name || b.destinationStationName || b.destination || 'Unknown').filter(Boolean)),
].sort() as string[];
const routeRevenueData = Object.entries(
filteredRouteBookings.reduce((acc: Record<string, { totalEtbMinor: number; bookings: number }>, b: any) => {
const origin = b.schedule?.originStation?.name || b.originStationName || b.origin || 'Unknown';
const destination = b.schedule?.destinationStation?.name || b.destinationStationName || b.destination || 'Unknown';
const route = `${origin}${destination}`;
const rate = toEtbRate(b.currency);
const etbMinor = rate !== null ? Math.round((b.totalMinor || 0) * rate) : 0;
if (!acc[route]) acc[route] = { totalEtbMinor: 0, bookings: 0 };
acc[route].totalEtbMinor += etbMinor;
acc[route].bookings += 1;
return acc;
}, {}),
).map(([route, value]) => ({
route,
totalEtbMinor: value.totalEtbMinor,
bookings: value.bookings,
})).sort((a, b) => b.totalEtbMinor - a.totalEtbMinor);
const REPORT_COLS = ['Date', 'Revenue (ETB)', 'Confirmed Bookings'];
@@ -161,6 +220,20 @@ export default function ReportsPage() {
setExportModalOpen(false);
};
const doExportRouteRevenue = () => {
if (!routeRevenueData.length) { alert('No route revenue to export'); return; }
const rows = routeRevenueData.map((row) => [row.route, String(row.bookings), formatCurrency(row.totalEtbMinor, 'ETB')]);
const headers = ['Route', 'Bookings', 'Revenue (ETB)'];
const csv = [headers.map((h) => `"${h}"`).join(','), ...rows.map((r) => r.map((v) => `"${v}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `route-revenue-${dates.startDate}-${dates.endDate}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const renderCurrencyRow = ({ currency, totalMinor }: { currency: string; totalMinor: number }) => {
const rate = toEtbRate(currency);
const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null;
@@ -231,7 +304,7 @@ export default function ReportsPage() {
</div>
</div>
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums mt-1">
{statsLoading ? '—' : formatCurrency(overallGrand, 'ETB')}
{isLoading ? '—' : formatCurrency(totalRevenueMinor, 'ETB')}
</p>
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
<div className="flex justify-between text-xs">
@@ -254,16 +327,16 @@ export default function ReportsPage() {
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">
{statsLoading ? '—' : (stats?.totalBookings ?? 0).toLocaleString()}
{isLoading ? '—' : totalBookingsCount.toLocaleString()}
</p>
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Regular</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalNormalBookings ?? 0).toLocaleString()}</span>
<span className="font-semibold tabular-nums">{isLoading ? '—' : totalRegularBookingsCount.toLocaleString()}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Package</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalPackageBookings ?? 0).toLocaleString()}</span>
<span className="font-semibold tabular-nums">{isLoading ? '—' : totalPackageBookingsCount.toLocaleString()}</span>
</div>
</div>
</div>
@@ -277,16 +350,16 @@ export default function ReportsPage() {
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">
{statsLoading ? '—' : (stats?.totalTickets ?? 0).toLocaleString()}
{isLoading ? '—' : totalTicketsCount.toLocaleString()}
</p>
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Regular</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalNormalTickets ?? 0).toLocaleString()}</span>
<span className="font-semibold tabular-nums">{isLoading ? '—' : totalRegularBookingsCount.toLocaleString()}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Package</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalPackageTickets ?? 0).toLocaleString()}</span>
<span className="font-semibold tabular-nums">{isLoading ? '—' : totalPackageBookingsCount.toLocaleString()}</span>
</div>
</div>
</div>
@@ -300,7 +373,7 @@ export default function ReportsPage() {
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">
{isLoading ? '—' : formatCurrency(avgDailyRevenue * 100, 'ETB')}
{isLoading ? '—' : formatCurrency(avgDailyRevenueMinor, 'ETB')}
</p>
<p className="text-xs text-muted-foreground mt-auto pt-2 border-t border-border">
Over {chartData.length} active day{chartData.length !== 1 ? 's' : ''} in range
@@ -368,6 +441,85 @@ export default function ReportsPage() {
)}
</div>
{/* Route Revenue Breakdown */}
<div className="card">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
<div>
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
Revenue by Route
</h2>
<p className="text-xs text-muted-foreground">
Confirmed booking revenue for the selected date range, grouped by route.
</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{routeRevenueData.length} route{routeRevenueData.length !== 1 ? 's' : ''}</span>
<button
type="button"
className="text-primary underline"
onClick={() => {
setRouteOrigin('');
setRouteDestination('');
}}
>
Clear filters
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-4">
<div>
<label className="label">Origin</label>
<select
className="input"
value={routeOrigin}
onChange={(e) => setRouteOrigin(e.target.value)}
disabled={isLoading}
>
<option value="">All origins</option>
{routeOriginOptions.map((origin) => (
<option key={origin} value={origin}>{origin}</option>
))}
</select>
</div>
<div>
<label className="label">Destination</label>
<select
className="input"
value={routeDestination}
onChange={(e) => setRouteDestination(e.target.value)}
disabled={isLoading}
>
<option value="">All destinations</option>
{routeDestinationOptions.map((destination) => (
<option key={destination} value={destination}>{destination}</option>
))}
</select>
</div>
<div className="flex items-end justify-end">
<ActionButton variant="secondary" onClick={doExportRouteRevenue} disabled={isLoading || routeRevenueData.length === 0}>
Export route revenue
</ActionButton>
</div>
</div>
{routeRevenueData.length === 0 ? (
<p className="text-sm text-muted-foreground">No route revenue data available for this range.</p>
) : (
<div className="space-y-3">
{routeRevenueData.slice(0, 10).map((route) => (
<div key={route.route} className="grid grid-cols-1 md:grid-cols-[1.4fr_0.8fr_0.8fr] gap-3 items-center rounded-md bg-muted/20 p-3">
<div className="text-sm font-medium break-words">{route.route}</div>
<div className="text-sm text-muted-foreground">{route.bookings.toLocaleString()} booking{route.bookings !== 1 ? 's' : ''}</div>
<div className="text-right text-sm font-semibold tabular-nums">
{formatCurrency(route.totalEtbMinor, 'ETB')}
</div>
</div>
))}
</div>
)}
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 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 }) => (
<div key={label} className="border border-border rounded-lg p-3 text-center">
<p className="text-xs text-muted-foreground">{label}</p>

View File

@@ -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);
});
});
});

View File

@@ -49,6 +49,14 @@ export interface ProviderResultInput {
rawResponse?: Record<string, unknown>;
}
/**
* 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). */