mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev'
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;`);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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><script>alert("x")</script></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" onerror="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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* The single decision every EDR document makes about its round seal: draw the
|
||||
* one uploaded company stamp when one is configured (StampSettingsService), or
|
||||
* fall back to the plain text rings the document styles itself.
|
||||
*
|
||||
* Only the image-vs-text branch and the image overrides live here — each
|
||||
* document keeps its own `.seal` geometry (the invoice's seal is absolutely
|
||||
* positioned top-right, the warehouse papers' sit inline above the signature
|
||||
* lines), so centralizing the source of the stamp does not relayout anything.
|
||||
*
|
||||
* These helpers are for the HTML/Chromium render path. The hand-built vector
|
||||
* fallbacks in styled-pdf.util.ts cannot embed a raster image and continue to
|
||||
* draw their vector seal — see InvoiceDocumentService for that caveat.
|
||||
*/
|
||||
|
||||
/** Escape a value for interpolation into HTML text or a quoted attribute. */
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS overrides that neutralize a document's own ring/rotation styling when the
|
||||
* seal is a real stamp image. Append inside a document's <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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 " +
|
||||
|
||||
@@ -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, '')
|
||||
)`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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"] });
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
));
|
||||
|
||||
|
||||
@@ -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 } },
|
||||
);
|
||||
|
||||
@@ -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 } },
|
||||
);
|
||||
|
||||
@@ -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).
|
||||
// {
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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 } },
|
||||
);
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? "",
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
219
apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx
Normal file
219
apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user