Merge pull request #1241 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-11 17:47:26 +03:00
committed by GitHub
70 changed files with 4284 additions and 3368 deletions

View File

@@ -49,6 +49,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-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 { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
import { SupportContentModule } from "./modules/support-content/support-content.module"; import { SupportContentModule } from "./modules/support-content/support-content.module";
import { OtpModule } from "./modules/otp/otp.module"; import { OtpModule } from "./modules/otp/otp.module";
@@ -221,6 +222,7 @@ if (!process.env.APPLICATION_NAME) {
FileUploadSettingsModule, FileUploadSettingsModule,
DropdownSettingsModule, DropdownSettingsModule,
ExchangeSettingsModule, ExchangeSettingsModule,
StampSettingsModule,
ContractTemplatesModule, ContractTemplatesModule,
SupportContentModule, SupportContentModule,
OtpModule, OtpModule,

View File

@@ -0,0 +1,210 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Onboarding revamp: one company, one verified identity.
*
* The general manager is removed outright — it named who to talk to and gated
* nothing — and the company's people become its **owner** (whoever the eTrade
* licence names as the business's manager) and its **Power of Attorney**.
* Exactly one of them is identity-verified, chosen by the company's own answer
* to "does anyone hold power of attorney for you?", stored as
* `attributes.poaDeclared`.
*
* The order below matters — each step depends on data a later step destroys:
*
* 1. Rescue notification addresses. `companyNotifyEmailExpr` used to fall
* through to `attributes->>'generalManagerEmail'`, and `companies.email` was
* only ever written for a Fayda-verified owner — so every foreign company
* had none and was reached solely through that fallback. Promote it to the
* column before the key is stripped, or those companies stop receiving mail
* in silence.
* 2. Backfill the owner. `ownerName`/`ownerEmail`/`ownerPhone` are now required
* onboarding fields; without this every already-onboarded company would
* report three missing fields the moment it opened its settings page.
* 3. Resolve `poaSameAsOwner`. That flag waived the DARS delegation paper. It
* is gone, so the companies holding it must be re-expressed:
* - NOT a freight forwarder → "no PoA" (the owner represents themselves,
* nothing to delegate). Their PoA details are cleared.
* - A freight forwarder → "yes" and details KEPT. A forwarder signs on
* other companies' behalf, so a representative is non-negotiable and the
* waiver no longer exists. These companies will be asked for a
* delegation paper they were previously excused — an intentional,
* visible consequence, not an oversight. Count them before deploying.
* 4. Derive the declaration for everyone else, from whether PoA details exist.
* 5. Move drafts off the deleted "personnel" wizard step.
* 6. Only now drop the columns and strip the retired attribute keys.
*
* Irreversible by design: `down()` restores the columns' shape but cannot
* recover the values, and re-deriving `poaSameAsOwner` from `poaDeclared` would
* be a guess.
*/
export class RemoveGeneralManager3390000000000 implements MigrationInterface {
name = "RemoveGeneralManager3390000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// 1. Rescue the notification address before the key it lives under is gone.
await queryRunner.query(`
UPDATE freight.companies
SET email = COALESCE(
NULLIF(email, ''),
NULLIF(attributes->>'ownerEmail', ''),
NULLIF(attributes->>'generalManagerEmail', ''),
NULLIF(attributes->>'contactPersonEmail', '')
)
WHERE COALESCE(email, '') = ''
`);
// 2. Backfill the owner from the best source each company actually has:
// its Fayda-verified owner claims (already under owner*), then the general
// manager it named, then its contact person. A company with none of these
// never finished onboarding, and will be asked on its next visit.
await queryRunner.query(`
UPDATE freight.companies
SET attributes = attributes
|| jsonb_strip_nulls(jsonb_build_object(
'ownerName', COALESCE(
NULLIF(attributes->>'ownerName', ''),
NULLIF(attributes->>'generalManagerName', ''),
NULLIF(attributes->>'contactPersonName', '')
),
'ownerEmail', COALESCE(
NULLIF(attributes->>'ownerEmail', ''),
NULLIF(attributes->>'generalManagerEmail', ''),
NULLIF(attributes->>'contactPersonEmail', ''),
NULLIF(email, '')
),
'ownerPhone', COALESCE(
NULLIF(attributes->>'ownerPhone', ''),
NULLIF(attributes->>'generalManagerPhone', ''),
NULLIF(attributes->>'contactPersonPhone', ''),
NULLIF(phone, '')
)
))
WHERE attributes IS NOT NULL
`);
// 2b. Capture eTrade's manager for the owner-vs-licence check the
// backoffice now makes. Nothing stored it before, so the best we have is
// the owner name itself — which makes existing companies read as "matches"
// rather than as a false mismatch on data nobody ever compared. The value
// is refreshed for real on the company's next eTrade lookup.
await queryRunner.query(`
UPDATE freight.companies
SET attributes = jsonb_set(
attributes, '{etradeManagerName}', to_jsonb(attributes->>'ownerName')
)
WHERE COALESCE(attributes->>'ownerName', '') <> ''
AND attributes->>'etradeManagerName' IS NULL
AND COALESCE(licence_number, '') <> ''
`);
// 3a. Owner-represents-themselves, and NOT a forwarder → "no PoA".
await queryRunner.query(`
UPDATE freight.companies c
SET attributes = (c.attributes - 'poaName' - 'poaPhone' - 'poaEmail'
- 'poaLocation' - 'poaAddress' - 'poaFaydaSub'
- 'poaFaydaVerifiedAt' - 'poaBirthdate' - 'poaGender')
|| jsonb_build_object('poaDeclared', 'no')
WHERE (c.attributes->>'poaSameAsOwner')::boolean IS TRUE
AND NOT EXISTS (
SELECT 1 FROM freight.company_profiles cp
WHERE cp.company_id = c.id
AND cp.type = 'freight_forwarder'
AND cp.deleted_at IS NULL
)
`);
// 3b. Forwarders keep their representative and lose the waiver.
await queryRunner.query(`
UPDATE freight.companies c
SET attributes = c.attributes || jsonb_build_object('poaDeclared', 'yes')
WHERE (c.attributes->>'poaSameAsOwner')::boolean IS TRUE
AND EXISTS (
SELECT 1 FROM freight.company_profiles cp
WHERE cp.company_id = c.id
AND cp.type = 'freight_forwarder'
AND cp.deleted_at IS NULL
)
`);
// 4. Everyone else: "yes" if a representative was named or the company is a
// forwarder, "no" if it finished onboarding without one. A company still
// mid-onboarding is left unanswered — it will be asked, which is the point.
await queryRunner.query(`
UPDATE freight.companies c
SET attributes = COALESCE(c.attributes, '{}'::jsonb)
|| jsonb_build_object('poaDeclared', 'yes')
WHERE c.attributes->>'poaDeclared' IS NULL
AND (
COALESCE(c.attributes->>'poaName', '') <> ''
OR COALESCE(c.attributes->>'poaPhone', '') <> ''
OR COALESCE(c.attributes->>'poaEmail', '') <> ''
OR COALESCE(c.attributes->>'poaLocation', '') <> ''
OR COALESCE(c.attributes->>'poaAddress', '') <> ''
OR EXISTS (
SELECT 1 FROM freight.company_profiles cp
WHERE cp.company_id = c.id
AND cp.type = 'freight_forwarder'
AND cp.deleted_at IS NULL
)
)
`);
await queryRunner.query(`
UPDATE freight.companies c
SET attributes = COALESCE(c.attributes, '{}'::jsonb)
|| jsonb_build_object('poaDeclared', 'no')
WHERE c.attributes->>'poaDeclared' IS NULL
AND EXISTS (
SELECT 1 FROM freight.external_profiles ep
WHERE ep.company_id = c.id
AND ep.onboarding_completed = true
AND ep.deleted_at IS NULL
)
`);
// 5. The "personnel" (general manager) wizard step no longer exists; a
// draft resting on it would fall back to the very first step and make the
// customer walk the whole wizard again.
await queryRunner.query(`
UPDATE freight.external_profiles
SET onboarding_step = 'owner'
WHERE onboarding_step = 'personnel'
`);
// 6. Retire the general manager and the flag it shared the model with.
await queryRunner.query(`
UPDATE freight.companies
SET attributes = attributes - 'generalManagerName' - 'generalManagerEmail'
- 'generalManagerPhone' - 'gmSameAsOwner' - 'gmFaydaSub'
- 'gmFaydaVerifiedAt' - 'gmName' - 'gmEmail' - 'gmPhone'
- 'gmAddress' - 'gmBirthdate' - 'gmGender'
- 'poaSameAsOwner'
WHERE attributes IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS general_manager_name,
DROP COLUMN IF EXISTS general_manager_email,
DROP COLUMN IF EXISTS general_manager_phone
`);
}
/**
* Restores the columns' shape only. The values, the `gm*` attributes and the
* `poaSameAsOwner` flag are not recoverable — this migration folded them into
* `ownerEmail` / `poaDeclared`, and there is no way back that isn't a guess.
*/
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS general_manager_name varchar(100),
ADD COLUMN IF NOT EXISTS general_manager_email varchar(150),
ADD COLUMN IF NOT EXISTS general_manager_phone varchar(20)
`);
await queryRunner.query(`
UPDATE freight.external_profiles
SET onboarding_step = 'personnel'
WHERE onboarding_step = 'owner'
`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Single-row table holding the one company stamp/seal image stamped onto
* generated invoice/receipt PDFs (see StampSettingsService /
* InvoiceDocumentService). Same single-row shape as exchange_settings; the
* app never inserts more than one row.
*/
export class StampSettings3400000000000 implements MigrationInterface {
name = "StampSettings3400000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.stamp_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
stamp_file_id uuid REFERENCES freight.files(id),
updated_by_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.stamp_settings;`);
}
}

View File

@@ -7,7 +7,9 @@ import { PdfRenderService } from "./pdf-render.service";
* Standalone document infrastructure — generic HTML→PDF plus the shared * Standalone document infrastructure — generic HTML→PDF plus the shared
* invoice/receipt renderer. Has no domain dependencies, so any module (billing, * invoice/receipt renderer. Has no domain dependencies, so any module (billing,
* warehouses, …) can import it to print invoices without coupling to the * 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({ @Module({
providers: [PdfRenderService, InvoiceDocumentService], providers: [PdfRenderService, InvoiceDocumentService],

View File

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

View File

@@ -39,6 +39,7 @@ import {
CompleteIdentityVerificationDto, CompleteIdentityVerificationDto,
} from "./dto/complete-identity-verification.dto"; } from "./dto/complete-identity-verification.dto";
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.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 { StartOnboardingDto } from "./dto/start-onboarding.dto";
import { DashboardQueryDto } from "./dto/dashboard-query.dto"; import { DashboardQueryDto } from "./dto/dashboard-query.dto";
import { import {
@@ -265,6 +266,7 @@ export class CompaniesController {
dto.companyType, dto.companyType,
dto.roles, dto.roles,
dto.nationality, dto.nationality,
dto.cooperative,
); );
return new CompanyInfoResponseDto(profile, company); return new CompanyInfoResponseDto(profile, company);
} }
@@ -415,90 +417,31 @@ export class CompaniesController {
@PortalCustomer() @PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: 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. " + "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.", "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( async completeIdentityVerification(
@CurrentUser() user: CurrentIamUser, @CurrentUser() user: CurrentIamUser,
@Body() dto: CompleteIdentityVerificationDto, @Body() dto: CompleteIdentityVerificationDto,
): Promise<CompanyIdentityStateDto> { ): Promise<CompanyIdentityStateDto> {
return this.companiesService.completeIdentityVerification(user.id, dto, { return this.companiesService.completeIdentityVerification(user.id, dto);
email: user.email,
phoneNumber: user.phoneNumber,
});
} }
@Post("identity/gm/same-as-owner") @Patch("identity/poa-declared")
@PortalCustomer() @PortalCustomer()
@ApiOperation({ @ApiOperation({
summary: summary:
"Declare the General Manager is the company's owner, copying the owner's verified identity across. " + "Answer whether anyone holds power of attorney for this company — the question that decides whose identity is verified. " +
"Refused until the owner is Fayda-verified — there would be nothing proven to copy.", '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, @CurrentUser() user: CurrentIamUser,
@Body() dto: SetPoaDeclaredDto,
): Promise<CompanyIdentityStateDto> { ): Promise<CompanyIdentityStateDto> {
return this.companiesService.setGmSameAsOwner(user.id, { return this.companiesService.setPoaDeclared(user.id, dto.declared);
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);
} }
@Patch("onboarding-step") @Patch("onboarding-step")

View File

@@ -174,21 +174,26 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
).resolves.toBeDefined(); ).resolves.toBeDefined();
}); });
it("waives the paper when the owner represents the company themselves", async () => { it('owes nothing when the company answered "no representative"', async () => {
// Nobody delegates to themselves, so a self-declared PoA owes no DARS // "The owner represents the company themselves" is now expressed as the
// paper — the representative's own details are still required. // 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({ const { service } = makeService({
attributes: { ...VERIFIED_IDENTITIES, poaSameAsOwner: true }, attributes: { ...VERIFIED_IDENTITIES, poaDeclared: "no" },
}); });
await expect( await expect(
service.updateProfile("user-1", POA as never), service.updateProfile("user-1", {} as never),
).resolves.toBeDefined(); ).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({ const { service } = makeService({
attributes: { ...VERIFIED_IDENTITIES, ...POA, poaSameAsOwner: true }, attributes: { ...VERIFIED_IDENTITIES, ...POA, poaDeclared: "yes" },
}); });
await expect( await expect(
@@ -196,7 +201,7 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
"user-1", "user-1",
ProfileType.freightForwarder, ProfileType.freightForwarder,
), ),
).resolves.toBeDefined(); ).rejects.toBeInstanceOf(BadRequestException);
}); });
it("rejects a paper the reviewer sent back for correction", async () => { it("rejects a paper the reviewer sent back for correction", async () => {

View File

@@ -26,7 +26,13 @@ function makeService(existing: ExistingProfile[]) {
})), })),
softDelete: jest.fn(async () => undefined), 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 = { const profilesRepo = {
findByUserId: jest.fn(async () => ({ findByUserId: jest.fn(async () => ({
id: "external-1", id: "external-1",

File diff suppressed because it is too large Load Diff

View File

@@ -23,9 +23,18 @@ export const COMPANY_FIELD_LABELS: Record<string, string> = {
contactPersonPhone: "Contact person phone", contactPersonPhone: "Contact person phone",
contactPersonEmail: "Contact person email", contactPersonEmail: "Contact person email",
contactPersonPosition: "Contact person position", contactPersonPosition: "Contact person position",
generalManagerName: "General manager name", ownerName: "Owner name",
generalManagerPhone: "General manager phone", ownerPhone: "Owner phone",
generalManagerEmail: "General manager email", 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", poaName: "PoA name",
poaPhone: "PoA phone", poaPhone: "PoA phone",
poaEmail: "PoA email", poaEmail: "PoA email",

View File

@@ -5,21 +5,61 @@ import { Company, CompanyNationality } from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity"; import { ProfileType } from "../entities/company-profile.entity";
/** /**
* The three people a company is verified through — its owner, its Power of * The two people a company can be described through.
* 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 GM is very often the owner, which is what the portal's "same as owner" * The **owner** is whoever the eTrade TIN record names as the business's
* copy is for: that path reuses the owner's verified identity outright rather * manager. Not necessarily the legal owner — eTrade's `ManagerNameEng` is
* than asking the same human to verify twice. * 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]; 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 { export class CompleteIdentityVerificationDto {
@ApiProperty({ @ApiProperty({
enum: IDENTITY_SUBJECTS, 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) @IsIn(IDENTITY_SUBJECTS)
subject!: IdentitySubject; subject!: IdentitySubject;
@@ -35,9 +75,11 @@ export class CompleteIdentityVerificationDto {
state!: string; 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 { 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 }) name!: string | null;
@ApiProperty({ nullable: true }) phone!: string | null; @ApiProperty({ nullable: true }) phone!: string | null;
@ApiProperty({ nullable: true }) email!: string | null; @ApiProperty({ nullable: true }) email!: string | null;
@@ -45,13 +87,11 @@ export class IdentityVerificationStateDto {
@ApiProperty({ nullable: true }) verifiedAt!: string | null; @ApiProperty({ nullable: true }) verifiedAt!: string | null;
@ApiProperty({ nullable: true }) birthdate!: string | null; @ApiProperty({ nullable: true }) birthdate!: string | null;
@ApiProperty({ nullable: true }) gender!: string | null; @ApiProperty({ nullable: true }) gender!: string | null;
}
export class OwnerIdentityStateDto extends IdentityVerificationStateDto {
@ApiProperty({ @ApiProperty({
nullable: true, nullable: true,
description: 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; passportNumber!: string | null;
} }
@@ -59,44 +99,55 @@ export class OwnerIdentityStateDto extends IdentityVerificationStateDto {
export class CompanyIdentityStateDto { export class CompanyIdentityStateDto {
@ApiProperty({ @ApiProperty({
description: 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({ @ApiProperty({
enum: POA_DECLARATIONS,
nullable: true,
description: 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 }) @ApiProperty({
owner!: OwnerIdentityStateDto; 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 }) @ApiProperty({ type: IdentityVerificationStateDto })
poa!: IdentityVerificationStateDto; poa!: IdentityVerificationStateDto;
@ApiProperty({ @ApiProperty({
description: 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({ @ApiProperty({
type: IdentityVerificationStateDto, nullable: true,
description: description:
"General manager. `verified` is true both when the GM verified with Fayda in their own right and when the company declared the GM is the owner — in the latter case the owner's Fayda sub backs it.", "The manager named on the eTrade licence, captured at lookup. Null when eTrade returned none (ManagerNameEng is frequently blank).",
}) })
gm!: IdentityVerificationStateDto; etradeManagerName!: string | null;
@ApiProperty({
nullable: true,
description:
"Does the owner the company put forward match the person on the eTrade licence? This is the backoffice's check. Null when there is nothing to compare — no eTrade manager on file, or no owner name yet. Advisory, not a gate: eTrade's Latin transliteration and Fayda's rarely agree character-for-character, so a reviewer decides.",
})
ownerMatchesEtrade!: boolean | null;
@ApiProperty({ @ApiProperty({
description: 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.", "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.",
})
gmSameAsOwner!: boolean;
@ApiProperty({
description:
"False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.",
}) })
complete!: boolean; complete!: boolean;
} }
@@ -105,26 +156,9 @@ export class CompanyIdentityStateDto {
const PREFIX: Record<IdentitySubject, string> = { const PREFIX: Record<IdentitySubject, string> = {
owner: "owner", owner: "owner",
poa: "poa", poa: "poa",
gm: "gm",
}; };
/** /** `company.attributes` keys that together mean "a representative was entered". */
* 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". */
const POA_KEYS = [ const POA_KEYS = [
"poaName", "poaName",
"poaPhone", "poaPhone",
@@ -139,7 +173,7 @@ function stateFor(
): IdentityVerificationStateDto { ): IdentityVerificationStateDto {
const p = PREFIX[subject]; const p = PREFIX[subject];
const read = (key: string) => (attrs[key] as string | undefined) ?? null; const read = (key: string) => (attrs[key] as string | undefined) ?? null;
const state: IdentityVerificationStateDto = { return {
verified: Boolean(read(`${p}FaydaSub`)), verified: Boolean(read(`${p}FaydaSub`)),
name: read(`${p}Name`), name: read(`${p}Name`),
phone: read(`${p}Phone`), phone: read(`${p}Phone`),
@@ -148,88 +182,111 @@ function stateFor(
verifiedAt: read(`${p}FaydaVerifiedAt`), verifiedAt: read(`${p}FaydaVerifiedAt`),
birthdate: read(`${p}Birthdate`), birthdate: read(`${p}Birthdate`),
gender: read(`${p}Gender`), gender: read(`${p}Gender`),
}; passportNumber: read(`${p}PassportNumber`),
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),
}; };
} }
/** /**
* 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` * A freight forwarder is never asked: it acts on other companies' behalf, so a
* renders from it, so the settings page and the onboarding wizard can never * representative and the DARS paper behind them are mandatory. Forcing it here
* disagree with the rule the API actually enforces. * — 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( export function buildCompanyIdentityState(
company: Company, company: Company,
): CompanyIdentityStateDto { ): CompanyIdentityStateDto {
const attrs = company.attributes ?? {}; 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 // Fayda is an Ethiopian national ID. A foreign company's people may hold
// one, so a typed passport number is the mandatory credential there instead. // none, so a typed passport number stands in — either one proves the person,
// The two are mutually exclusive by nationality but independently tracked, // and holding both is fine.
// since a foreign owner verifying with Fayda doesn't waive the passport. const passportAccepted = company.nationality === CompanyNationality.Foreign;
const foreign = company.nationality === CompanyNationality.Foreign;
const faydaRequired = !foreign;
const passportRequired = foreign;
const owner: OwnerIdentityStateDto = { const owner = stateFor(attrs, "owner");
...stateFor(attrs, "owner"),
passportNumber: read("ownerPassportNumber"),
};
const poa = stateFor(attrs, "poa"); const poa = stateFor(attrs, "poa");
const poaDue = const poaDeclared = readPoaDeclaration(company);
(company.companyProfiles ?? []).some( const subject: IdentitySubject | null =
(p) => p.type === ProfileType.freightForwarder, poaDeclared === "yes" ? "poa" : poaDeclared === "no" ? "owner" : null;
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
const gm = stateFor(attrs, "gm"); const proven = (s: IdentityVerificationStateDto) =>
const gmSameAsOwner = Boolean(attrs.gmSameAsOwner); s.verified || (passportAccepted && Boolean(s.passportNumber?.trim()));
const poaSameAsOwner = Boolean(attrs.poaSameAsOwner);
const ownerProven = faydaRequired const identityProven =
? owner.verified subject === null ? false : proven(subject === "poa" ? poa : owner);
: !passportRequired || Boolean(owner.passportNumber);
// Fayda is an Ethiopian national ID, so only an Ethiopian company's const etradeManagerName =
// personnel can be held to it. A foreign company may nominate a (attrs[ETRADE_MANAGER_NAME_KEY] as string | undefined) ?? null;
// 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);
return { return {
faydaRequired, passportAccepted,
passportRequired, poaDeclared,
subject,
owner, owner,
poa, poa,
poaSameAsOwner, identityProven,
gm, etradeManagerName,
gmSameAsOwner, ownerMatchesEtrade: ownerNameMatchesEtrade(owner.name, etradeManagerName),
complete, complete: subject !== null && identityProven,
}; };
} }

View File

@@ -8,7 +8,10 @@
* truth the wizard uses to auto-finish. * 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 { export interface OnboardingInfoField {
key: string; key: string;
@@ -38,15 +41,19 @@ export interface OnboardingLicenseProfile {
} }
export interface OnboardingPoaState { 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 * True when the company operates as a freight forwarder: it signs on other
* mandatory) and is not the owner themselves. An owner representing their own * companies' behalf, so a Power of Attorney is non-negotiable and the portal
* company delegates to nobody, so there is no delegation to evidence. * 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; delegationLetterRequired: boolean;
/** True when the DARS delegation paper is stored for the company. */ /** True when the DARS delegation paper is stored for the company. */
delegationLetterUploaded: boolean; delegationLetterUploaded: boolean;
@@ -61,7 +68,19 @@ export interface OnboardingPoaState {
export class OnboardingRequirementsResponseDto { export class OnboardingRequirementsResponseDto {
/** Resolved document setting code (by nationality) the docs were drawn from. */ /** Resolved document setting code (by nationality) the docs were drawn from. */
documentSettingCode: string; 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; 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. */ /** Required company-information fields and whether each is filled. */
companyInfo: { companyInfo: {
@@ -99,7 +118,9 @@ export class OnboardingRequirementsResponseDto {
constructor(init: Omit<OnboardingRequirementsResponseDto, never>) { constructor(init: Omit<OnboardingRequirementsResponseDto, never>) {
this.documentSettingCode = init.documentSettingCode; this.documentSettingCode = init.documentSettingCode;
this.cooperativeDocumentSettingCode = init.cooperativeDocumentSettingCode;
this.nationality = init.nationality; this.nationality = init.nationality;
this.cooperative = init.cooperative;
this.companyInfo = init.companyInfo; this.companyInfo = init.companyInfo;
this.documents = init.documents; this.documents = init.documents;
this.licenseProfiles = init.licenseProfiles; this.licenseProfiles = init.licenseProfiles;

View File

@@ -2,7 +2,7 @@ import {
buildCompanyIdentityState, buildCompanyIdentityState,
CompanyIdentityStateDto, CompanyIdentityStateDto,
} from "./complete-identity-verification.dto"; } 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 { ExternalProfile } from "../entities/external-profile.entity";
import { import {
ChangeRequestStatus, ChangeRequestStatus,
@@ -15,6 +15,12 @@ export class ProfileResponseDto {
companyName: string; companyName: string;
companyType: string; companyType: string;
nationality: string | null; 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; companyLocation: string;
companyAddress: string | null; companyAddress: string | null;
tinNumber: string; tinNumber: string;
@@ -42,9 +48,10 @@ export class ProfileResponseDto {
contactPersonPhone: string | null; contactPersonPhone: string | null;
/** Phone that passed SMS OTP verification (drives the verify-step resume). */ /** Phone that passed SMS OTP verification (drives the verify-step resume). */
contactVerifiedPhone: string | null; contactVerifiedPhone: string | null;
generalManagerName: string | null; /** The owner — whoever the eTrade licence names as the business's manager. */
generalManagerEmail: string | null; ownerName: string | null;
generalManagerPhone: string | null; ownerEmail: string | null;
ownerPhone: string | null;
poaName: string | null; poaName: string | null;
poaPhone: string | null; poaPhone: string | null;
@@ -55,12 +62,13 @@ export class ProfileResponseDto {
profileId: string; profileId: string;
/** /**
* Fayda verification state for the company's owner and PoA — not the general * The company's single identity verification, plus who it belongs to.
* manager, which is a separate typed role. The settings tabs and the *
* onboarding wizard render from `identity.faydaRequired` / * `identity.subject` follows the company's PoA declaration — the
* `identity.passportRequired`: an Ethiopian company verifies the owner (and * representative when one is named, otherwise the owner. The settings tabs
* PoA) instead of typing their details; a foreign one requires a typed * and the onboarding wizard render from it: `passportAccepted` says whether a
* passport number instead. * typed passport number is an alternative to Fayda (foreign companies only),
* and `ownerMatchesEtrade` is the check the backoffice makes.
*/ */
identity: CompanyIdentityStateDto; identity: CompanyIdentityStateDto;
@@ -84,6 +92,7 @@ export class ProfileResponseDto {
this.companyName = company.name; this.companyName = company.name;
this.companyType = company.type; this.companyType = company.type;
this.nationality = company.nationality ?? null; this.nationality = company.nationality ?? null;
this.cooperative = isCooperative(company);
this.companyProfiles = this.companyProfiles =
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ?? company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
[]; [];
@@ -113,9 +122,9 @@ export class ProfileResponseDto {
this.contactPersonEmail = attrs.contactPersonEmail ?? null; this.contactPersonEmail = attrs.contactPersonEmail ?? null;
this.contactPersonPhone = attrs.contactPersonPhone ?? null; this.contactPersonPhone = attrs.contactPersonPhone ?? null;
this.contactVerifiedPhone = attrs.contactVerifiedPhone ?? null; this.contactVerifiedPhone = attrs.contactVerifiedPhone ?? null;
this.generalManagerName = attrs.generalManagerName ?? null; this.ownerName = attrs.ownerName ?? null;
this.generalManagerEmail = attrs.generalManagerEmail ?? null; this.ownerEmail = attrs.ownerEmail ?? null;
this.generalManagerPhone = attrs.generalManagerPhone ?? null; this.ownerPhone = attrs.ownerPhone ?? null;
this.poaName = attrs.poaName ?? null; this.poaName = attrs.poaName ?? null;
this.poaPhone = attrs.poaPhone ?? null; this.poaPhone = attrs.poaPhone ?? null;
this.poaEmail = attrs.poaEmail ?? null; this.poaEmail = attrs.poaEmail ?? null;

View File

@@ -3,6 +3,7 @@ import {
CompanyType, CompanyType,
CompanyStatus, CompanyStatus,
CompanyNationality, CompanyNationality,
isCooperative,
} from '../entities/company.entity'; } from '../entities/company.entity';
import { import {
CompanyProfile, CompanyProfile,
@@ -55,6 +56,12 @@ export class ResponseCompanyDto {
type: CompanyType; type: CompanyType;
status: CompanyStatus; status: CompanyStatus;
nationality?: CompanyNationality | null; 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; tin: string;
vatNumber?: string | null; vatNumber?: string | null;
fanNumber?: string | null; fanNumber?: string | null;
@@ -89,9 +96,14 @@ export class ResponseCompanyDto {
houseNo?: string | null; houseNo?: string | null;
/** /**
* Owner/PoA Fayda verification state, shared with the portal * The company's single identity verification, shared with the portal
* (`buildCompanyIdentityState`) so backoffice never re-derives — or * (`buildCompanyIdentityState`) so backoffice never re-derives — or disagrees
* disagrees with — the rule the API actually enforces. * 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; identity: CompanyIdentityStateDto;
@@ -105,6 +117,7 @@ export class ResponseCompanyDto {
this.type = company.type; this.type = company.type;
this.status = company.status; this.status = company.status;
this.nationality = company.nationality ?? null; this.nationality = company.nationality ?? null;
this.cooperative = isCooperative(company);
this.tin = company.tin; this.tin = company.tin;
this.vatNumber = company.vatNumber; this.vatNumber = company.vatNumber;
this.fanNumber = company.fanNumber; this.fanNumber = company.fanNumber;

View File

@@ -0,0 +1,17 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsIn } from "class-validator";
import {
POA_DECLARATIONS,
PoaDeclaration,
} from "./complete-identity-verification.dto";
export class SetPoaDeclaredDto {
@ApiProperty({
enum: POA_DECLARATIONS,
description:
'Whether anyone holds power of attorney for this company. "no" tears down any representative already recorded.',
})
@IsIn(POA_DECLARATIONS)
declared!: PoaDeclaration;
}

View File

@@ -1,4 +1,10 @@
import { ArrayMinSize, IsArray, IsEnum, IsOptional } from "class-validator"; import {
ArrayMinSize,
IsArray,
IsBoolean,
IsEnum,
IsOptional,
} from "class-validator";
import { CompanyNationality, CompanyType } from "../entities/company.entity"; import { CompanyNationality, CompanyType } from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity"; import { ProfileType } from "../entities/company-profile.entity";
@@ -14,4 +20,15 @@ export class StartOnboardingDto {
@IsOptional() @IsOptional()
@IsEnum(CompanyNationality) @IsEnum(CompanyNationality)
nationality?: CompanyNationality; nationality?: CompanyNationality;
/**
* The company trades as a co-operative: it holds a TIN but no business
* licence, so there is no eTrade record to fetch its registration from.
* Chosen on the same step as the nationality and the roles, because it
* decides all three of what the next step asks for, which documents apply,
* and which roles are even available (a co-op cannot freight-forward).
*/
@IsOptional()
@IsBoolean()
cooperative?: boolean;
} }

View File

@@ -5,7 +5,6 @@ import {
MaxLength, MaxLength,
IsEnum, IsEnum,
IsIn, IsIn,
Matches,
} from "class-validator"; } from "class-validator";
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from "@edr/types"; import { ETHIOPIAN_REGIONS, type EthiopianRegion } from "@edr/types";
import { CompanyNationality } from "../entities/company.entity"; import { CompanyNationality } from "../entities/company.entity";
@@ -36,20 +35,21 @@ export class UpdateProfileDto {
@IsTin({ message: "TIN must be exactly 10 digits" }) @IsTin({ message: "TIN must be exactly 10 digits" })
tin?: string; tin?: string;
// Ethiopian VAT registration numbers are 10 digits (the same shape as the // No shape check. Ethiopian VAT numbers are usually 10 or 11 digits, but a
// TIN), but some are issued with an 11th. Both portal forms enforce the same // foreign company's is whatever its own tax authority issues — letters,
// range; without it here the API happily stored whatever a stale client sent, // dashes and any length — and a co-operative's registration numbering does
// and the two layers disagreed about what the column may hold. // 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() @IsOptional()
@IsString() @IsString()
@Matches(/^\d{10,11}$/, { message: "VAT number must be 10 or 11 digits" }) @MaxLength(64)
vatNumber?: string; vatNumber?: string;
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the // `fanNumber` is deliberately absent: the FAN is a Fayda number, so it would
// company's PoA (or its general manager), so it is derived from a completed // have to come from a completed verification rather than be typed — and
// Fayda verification rather than typed. The global validation pipe runs with // Fayda's userinfo carries no national ID number, so nothing produces one.
// forbidNonWhitelisted, so a client that still sends it gets a 400 telling it // The global validation pipe runs with forbidNonWhitelisted, so a client that
// so — see CompaniesService.completeIdentityVerification. // still sends it gets a 400 telling it so.
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -78,18 +78,31 @@ export class UpdateProfileDto {
@IsValidPhone() @IsValidPhone()
contactVerifiedPhone?: string; 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() @IsOptional()
@IsString() @IsString()
generalManagerName?: string; ownerName?: string;
@IsOptional() @IsOptional()
@IsEmail() @IsEmail()
generalManagerEmail?: string; ownerEmail?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@IsValidPhone() @IsValidPhone()
generalManagerPhone?: string; ownerPhone?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -113,15 +126,22 @@ export class UpdateProfileDto {
poaAddress?: string; poaAddress?: string;
/** /**
* The owner's passport number — the identity credential for a foreign * Passport numbers — the alternative identity credential for a foreign
* company, since Fayda is an Ethiopian national ID. Plain typed field, never * company, since Fayda is an Ethiopian national ID. Plain typed fields, never
* written or locked by a Fayda verification: still required even if the * written or locked by a Fayda verification.
* owner also verifies. *
* 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() @IsOptional()
@IsString() @IsString()
ownerPassportNumber?: string; ownerPassportNumber?: string;
@IsOptional()
@IsString()
poaPassportNumber?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(100) @MaxLength(100)

View File

@@ -32,6 +32,25 @@ export enum CompanyNationality {
Foreign = "foreign", 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" }) @Entity({ schema: "freight", name: "companies" })
@Index(["tin"]) @Index(["tin"])
@Index(["type"]) @Index(["type"])
@@ -112,29 +131,11 @@ export class Company extends BaseEntity {
}) })
contactPersonPhone?: string | null; contactPersonPhone?: string | null;
@Column({ // The general manager used to live here as three columns. It named who to
name: "general_manager_name", // talk to, gated nothing, and nothing ever populated the columns — the write
type: "varchar", // path put the values in `attributes`. Removed in RemoveGeneralManager; the
length: 100, // company's people are now its owner (whoever the eTrade licence names) and
nullable: true, // its Power of Attorney, both in `attributes`.
})
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;
@Column({ name: "website", type: "varchar", length: 200, nullable: true }) @Column({ name: "website", type: "varchar", length: 200, nullable: true })
website?: string | null; website?: string | null;

View File

@@ -17,6 +17,7 @@ import {
} from "./interfaces/file-upload-settings.repository.interface"; } from "./interfaces/file-upload-settings.repository.interface";
import { import {
COMPANY_ONBOARDING_CODE_PREFIX, COMPANY_ONBOARDING_CODE_PREFIX,
COOPERATIVE_ONBOARDING_CODE,
POA_DELEGATION_FILE_KEY, POA_DELEGATION_FILE_KEY,
poaDelegationField, poaDelegationField,
} from "./poa-delegation.constants"; } from "./poa-delegation.constants";
@@ -56,6 +57,10 @@ export class FileUploadSettingsService {
*/ */
private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting { private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting {
if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting; 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 ?? []; const fields = setting.fields ?? [];
if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting; if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting;

View File

@@ -26,6 +26,14 @@ export const POA_DELEGATION_LABEL = "DARS Delegation Paper";
/** Prefix of the setting codes the field is injected into. */ /** Prefix of the setting codes the field is injected into. */
export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_"; 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 = const POA_DELEGATION_HELP =
"Delegation paper issued by the Documents Authentication and Registration " + "Delegation paper issued by the Documents Authentication and Registration " +
"Service (DARS) delegating the representative named above. Upload the " + "Service (DARS) delegating the representative named above. Upload the " +

View File

@@ -12,8 +12,8 @@ import { DataSource, EntityManager } from "typeorm";
* `companies.contact_person_phone` is deliberately NOT consulted: the live write * `companies.contact_person_phone` is deliberately NOT consulted: the live write
* path stores that value in the `attributes` jsonb and has never populated the * 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. * 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 * The retired `general_manager_email` column was the same trap on the email
* {@link companyNotifyEmailExpr}. * 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` * SQL expression for the company's notification address, given the joined `pc`
* alias. * alias.
* *
* `companies.email` alone is not enough: it is written from ONE place — a * `companies.email` is now the owner's email, written on every profile save
* Fayda-verified owner's email claim — so a foreign company, whose owner proves * whether or not the owner verified with Fayda — and the owner's email is a
* identity by passport instead, never gets one. Readers papered over that with * required onboarding field, so a company that finished onboarding has one.
* `COALESCE(email, general_manager_email)`, but that column has the same problem * (It used to be written ONLY for a Fayda-verified owner, which meant every
* `contact_person_phone` has above: onboarding writes the value into the * foreign company had none; the gap was papered over with a
* `attributes` jsonb and nothing has ever populated the column, so the fallback * `general_manager_email` leg that could never fire, because onboarding wrote
* could not fire and the mail was dropped in silence. * that value into `attributes` and nothing ever populated the column.)
* *
* So: the company address, then the two the customer actually filled in during * The `generalManagerEmail` attribute is still consulted, after the contact
* onboarding, then the account that registered them — which always has one, * person: the general manager was removed, but companies onboarded before that
* signup requires it. `NULLIF` because a blank jsonb key is not an address and * may carry an address there and nowhere else. RemoveGeneralManager backfills
* `COALESCE` would happily stop on it. * `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 { export function companyNotifyEmailExpr(alias: string): string {
return `COALESCE( return `COALESCE(
NULLIF(${alias}.email, ''), NULLIF(${alias}.email, ''),
NULLIF(${alias}.attributes->>'generalManagerEmail', ''), NULLIF(${alias}.attributes->>'ownerEmail', ''),
NULLIF(${alias}.attributes->>'contactPersonEmail', ''), NULLIF(${alias}.attributes->>'contactPersonEmail', ''),
NULLIF(${alias}.attributes->>'generalManagerEmail', ''),
NULLIF(pc.email, '') NULLIF(pc.email, '')
)`; )`;
} }

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString, MinLength } from "class-validator";
export class UpdateStampSettingDto {
@ApiProperty({ description: "Stamp image as a base64 data URL (PNG/JPG)." })
@IsString()
@MinLength(1)
stampImageBase64!: string;
}

View File

@@ -0,0 +1,24 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
import { FileRecord } from "../../files/entities/file.entity";
/**
* Single-row table holding the one company stamp/seal image stamped onto
* generated invoice/receipt PDFs (see InvoiceDocumentService). Mirrors the
* exchange_settings single-row pattern — `get()` lazily creates the row, and
* there is never more than one.
*/
@Entity({ schema: "freight", name: "stamp_settings" })
export class StampSetting extends BaseEntity {
@Column({ name: "stamp_file_id", type: "uuid", nullable: true })
stampFileId?: string | null;
@ManyToOne(() => FileRecord, { nullable: true })
@JoinColumn({ name: "stamp_file_id" })
stampFile?: FileRecord | null;
/** IAM user id of the last operator to set/clear the stamp. */
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
updatedById?: string | null;
}

View File

@@ -0,0 +1,39 @@
import { Body, Controller, Delete, Get, Put } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { UpdateStampSettingDto } from "./dto/update-stamp-setting.dto";
import { StampSettingsService } from "./stamp-settings.service";
@ApiTags("stamp-settings")
@ApiBearerAuth()
@Controller("stamp-settings")
export class StampSettingsController {
constructor(private readonly service: StampSettingsService) {}
@Get()
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.view, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Current company stamp used on invoice/receipt PDFs" })
get() {
return this.service.getView();
}
@Put()
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Replace the company stamp" })
update(@Body() dto: UpdateStampSettingDto, @CurrentUser() user: TCurrentUser) {
return this.service.setStamp(dto.stampImageBase64, user?.id ?? null);
}
@Delete()
@BookingStaff([FREIGHT_PERMS.settings.invoiceStamp.manage, FREIGHT_PERMS.admin])
@ApiOperation({
summary: "Clear the company stamp (invoices fall back to the plain seal)",
})
clear(@CurrentUser() user: TCurrentUser) {
return this.service.clearStamp(user?.id ?? null);
}
}

View File

@@ -0,0 +1,23 @@
import { Global, Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { FilesModule } from "../files/files.module";
import { MinioModule } from "../minio/minio.module";
import { StampSetting } from "./entities/stamp-setting.entity";
import { StampSettingsController } from "./stamp-settings.controller";
import { StampSettingsRepository } from "./stamp-settings.repository";
import { StampSettingsService } from "./stamp-settings.service";
/**
* Global so DocumentsModule (invoice PDF rendering) can inject
* {@link StampSettingsService} without pulling in a circular billing/warehouse
* dependency — same reasoning as ExchangeSettingsModule.
*/
@Global()
@Module({
imports: [TypeOrmModule.forFeature([StampSetting]), FilesModule, MinioModule],
controllers: [StampSettingsController],
providers: [StampSettingsRepository, StampSettingsService],
exports: [StampSettingsService],
})
export class StampSettingsModule {}

View File

@@ -0,0 +1,21 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
import { StampSetting } from "./entities/stamp-setting.entity";
@Injectable()
export class StampSettingsRepository extends BaseRepository<StampSetting> {
constructor(
@InjectRepository(StampSetting)
repo: Repository<StampSetting>,
) {
super(repo);
}
/** The single settings row, with its stamp file joined, or null before first upload. */
findSingleton(): Promise<StampSetting | null> {
return this.repository.findOne({ where: {}, relations: ["stampFile"] });
}
}

View File

@@ -0,0 +1,154 @@
import { Injectable, Logger } from "@nestjs/common";
import { Readable } from "stream";
import { DataSource } from "typeorm";
import { FilesService } from "../files/files.service";
import { FileRecord } from "../files/entities/file.entity";
import { MinioService } from "../minio/minio.service";
import { StampSettingsRepository } from "./stamp-settings.repository";
import { StampSetting } from "./entities/stamp-setting.entity";
export interface StampSettingView {
stampImageUrl: string | null;
updatedById: string | null;
updatedAt: Date | null;
}
/**
* Owns the single `stamp_settings` row: the one company stamp/seal image used
* on generated invoice/receipt PDFs (see InvoiceDocumentService). Same
* single-row shape as ExchangeSettingsService, but the value is an uploaded
* image (via FilesService) rather than a scalar.
*/
@Injectable()
export class StampSettingsService {
private readonly logger = new Logger(StampSettingsService.name);
constructor(
private readonly repository: StampSettingsRepository,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly dataSource: DataSource,
) {}
/** The settings row, created empty on first access. */
async get(): Promise<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 invoice PDFs. Never throws — invoice
* generation must succeed even if the stamp lookup fails; callers fall back
* to the programmatic seal when this returns null.
*/
async getStampImageUrl(): Promise<string | null> {
try {
const setting = await this.get();
return await this.inlineImageUrl(setting.stampFile?.url);
} catch (err) {
this.logger.warn(
`Could not load company stamp for PDF rendering: ${(err as Error).message}`,
);
return null;
}
}
/** Replace the stamp image, storing it in MinIO via FilesService. */
async setStamp(
stampImageBase64: string,
updatedById?: string | null,
): Promise<StampSettingView> {
const current = await this.get();
const previousFileId = current.stampFileId ?? null;
const fileRecord = await this.filesService.upload({
resourceId: current.id,
resource: "stamp_settings",
code: "stamp",
file: this.toUploadFile(stampImageBase64),
uploadedByUserId: updatedById ?? null,
});
await this.repository.update(current.id, {
stampFileId: fileRecord.id,
updatedById: updatedById ?? null,
});
if (previousFileId && previousFileId !== fileRecord.id) {
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
}
this.logger.log(`Company stamp updated by ${updatedById ?? "unknown user"}`);
return this.getView();
}
/** Clear the stamp (invoices fall back to the programmatic seal). */
async clearStamp(updatedById?: string | null): Promise<StampSettingView> {
const current = await this.get();
const previousFileId = current.stampFileId ?? null;
await this.repository.update(current.id, {
stampFileId: null,
updatedById: updatedById ?? null,
});
if (previousFileId) {
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
}
return this.getView();
}
private toUploadFile(base64: string): Express.Multer.File {
const raw = base64.includes(",") ? base64.split(",")[1]! : base64;
const buffer = Buffer.from(raw, "base64");
return {
fieldname: "stamp",
originalname: "company-stamp.png",
encoding: "7bit",
mimetype: "image/png",
size: buffer.length,
buffer,
stream: Readable.from(buffer),
destination: "",
filename: "",
path: "",
};
}
private async inlineImageUrl(url?: string | null): Promise<string | null> {
if (!url) return null;
if (url.startsWith("data:")) return url;
try {
const objectName = this.minioService.getObjectNameFromUrl(url);
const stream = await this.minioService.getFileStream(objectName);
const buffer = await this.streamToBuffer(stream);
return `data:image/png;base64,${buffer.toString("base64")}`;
} catch {
return url;
}
}
private streamToBuffer(stream: Readable): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk: Buffer | string) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
stream.on("error", reject);
stream.on("end", () => resolve(Buffer.concat(chunks)));
});
}
}

View File

@@ -157,9 +157,6 @@ async function main() {
email: 'negad-indode-demo@edr.local', email: 'negad-indode-demo@edr.local',
contactPersonName: 'Marshalling Demo', contactPersonName: 'Marshalling Demo',
contactPersonPhone: '251900000202', contactPersonPhone: '251900000202',
generalManagerName: 'Demo Manager',
generalManagerEmail: 'negad-indode-demo@edr.local',
generalManagerPhone: '251900000202',
}), }),
)); ));

View File

@@ -247,9 +247,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder {
website: null, website: null,
contactPersonName: 'First Last Mile Demo', contactPersonName: 'First Last Mile Demo',
contactPersonPhone: '251900000101', contactPersonPhone: '251900000101',
generalManagerName: 'Demo Manager',
generalManagerEmail: COMPANY_EMAIL,
generalManagerPhone: '251900000101',
}, },
{ conflictPaths: { tin: true } }, { conflictPaths: { tin: true } },
); );

View File

@@ -324,9 +324,6 @@ export class DemoBookingsSeeder {
website: null, website: null,
contactPersonName: "Train Scheduling", contactPersonName: "Train Scheduling",
contactPersonPhone: "251900000001", contactPersonPhone: "251900000001",
generalManagerName: "Demo Manager",
generalManagerEmail: COMPANY_EMAIL,
generalManagerPhone: "251900000001",
}, },
{ conflictPaths: { tin: true } }, { conflictPaths: { tin: true } },
); );

View File

@@ -154,6 +154,31 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
// }, // },
// ]; // ];
/**
* Extra documents a co-operative union or farm provides, merged on top of its
* nationality set. It has a TIN but no business licence, so the papers that
* evidence the co-operative itself stand in for the trade licence every other
* company uploads.
*
* Only the registration certificate is seeded, and the set is admin-managed
* like every other onboarding set — what these members must actually produce
* is a backoffice decision, edited in the file-settings editor.
*/
const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
{
fileKey: "cooperative_registration_certificate",
fileLabel: "Co-operative Union / Farm Registration Certificate",
helpText:
"Certificate issued by the co-operative promotion agency that registered the union or farm.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 50,
displayOrder: 1,
},
];
interface OnboardingDocumentSetting { interface OnboardingDocumentSetting {
code: string; code: string;
label: string; label: string;
@@ -176,6 +201,14 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [
entity: "customer", entity: "customer",
fields: FOREIGN_ONBOARDING_FIELDS, 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 // Legacy per-company-type codes — removed, unused by any resolver or portal
// lookup (only `company_onboarding_documents_ethiopian`/`_foreign` are live). // lookup (only `company_onboarding_documents_ethiopian`/`_foreign` are live).
// { // {

View File

@@ -1187,6 +1187,26 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:settings:dropdown:manage", "edr_freight_app:settings:dropdown:manage",
"Manage dropdown settings", "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( perm(
"b4c00001-0001-4000-8000-000000000001", "b4c00001-0001-4000-8000-000000000001",
"edr_freight_app:audit:view", "edr_freight_app:audit:view",
@@ -1876,6 +1896,16 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:dropdown:view", view: "edr_freight_app:settings:dropdown:view",
manage: "edr_freight_app:settings:dropdown:manage", 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: { exchangeRate: {
view: "edr_freight_app:settings:exchange_rate:view", view: "edr_freight_app:settings:exchange_rate:view",
manage: "edr_freight_app:settings:exchange_rate:manage", manage: "edr_freight_app:settings:exchange_rate:manage",

View File

@@ -175,9 +175,6 @@ export class PaidImportExportMileDemoSeeder {
website: null, website: null,
contactPersonName: 'Paid Mile Demo', contactPersonName: 'Paid Mile Demo',
contactPersonPhone: '251900000202', contactPersonPhone: '251900000202',
generalManagerName: 'Demo Manager',
generalManagerEmail: COMPANY_EMAIL,
generalManagerPhone: '251900000202',
}, },
{ conflictPaths: { tin: true } }, { conflictPaths: { tin: true } },
); );

View File

@@ -51,6 +51,8 @@ import { NO_ACCESS_PATH, resolveLandingPath } from "./lib/landing";
import NoAccessPage from "./pages/NoAccessPage"; import NoAccessPage from "./pages/NoAccessPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; 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 ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
import PortalContentPage from "./pages/portal_content/PortalContentPage"; import PortalContentPage from "./pages/portal_content/PortalContentPage";
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
@@ -117,6 +119,20 @@ import {
findActiveSidebarLabel, findActiveSidebarLabel,
} from "@/components/layout/sidebar-sections"; } 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 DashboardShell = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -285,9 +301,7 @@ const App = () => {
<Route <Route
path="clearance/:id" path="clearance/:id"
element={ element={
<RequirePermission <RequirePermission permission={CLEARANCE_DETAIL_PERMS}>
permission={FREIGHT_PERMS.bookings.clearanceView}
>
<DocumentClearanceDetailPage /> <DocumentClearanceDetailPage />
</RequirePermission> </RequirePermission>
} }
@@ -321,9 +335,7 @@ const App = () => {
<Route <Route
path="bookings/:bookingId/clearance" path="bookings/:bookingId/clearance"
element={ element={
<RequirePermission <RequirePermission permission={CLEARANCE_DETAIL_PERMS}>
permission={FREIGHT_PERMS.bookings.clearanceView}
>
<DocumentClearanceDetailPage /> <DocumentClearanceDetailPage />
</RequirePermission> </RequirePermission>
} }
@@ -773,6 +785,24 @@ const App = () => {
</RequirePermission> </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 <Route
path="audit-logs" path="audit-logs"
element={ element={

View File

@@ -170,12 +170,13 @@ export function ContractCustomerCard({
/> />
</SectionCard> </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 <InfoRows
rows={[ rows={[
{ icon: User, label: "Name", value: company.generalManagerName }, { icon: User, label: "Name", value: company.ownerName },
{ icon: Mail, label: "Email", value: company.generalManagerEmail }, { icon: Mail, label: "Email", value: company.ownerEmail },
{ icon: Phone, label: "Phone", value: company.generalManagerPhone }, { icon: Phone, label: "Phone", value: company.ownerPhone },
]} ]}
/> />
</SectionCard> </SectionCard>

View File

@@ -43,9 +43,17 @@ export const FIELD_LABELS: Record<string, string> = {
contactPersonPosition: "Contact position", contactPersonPosition: "Contact position",
contactPersonEmail: "Contact email", contactPersonEmail: "Contact email",
contactPersonPhone: "Contact phone", contactPersonPhone: "Contact phone",
generalManagerName: "General manager", ownerName: "Owner name",
generalManagerEmail: "GM email", ownerEmail: "Owner email",
generalManagerPhone: "GM phone", 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", poaName: "PoA name",
poaPhone: "PoA phone", poaPhone: "PoA phone",
poaEmail: "PoA email", poaEmail: "PoA email",
@@ -79,9 +87,9 @@ export function currentValue(company: Company, key: string): string {
nationality: c.nationality, nationality: c.nationality,
contactPersonName: c.contactPersonName ?? attrs.contactPersonName, contactPersonName: c.contactPersonName ?? attrs.contactPersonName,
contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone, contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone,
generalManagerName: c.generalManagerName ?? attrs.generalManagerName, ownerName: c.ownerName ?? attrs.ownerName,
generalManagerEmail: c.generalManagerEmail ?? attrs.generalManagerEmail, ownerEmail: c.ownerEmail ?? attrs.ownerEmail,
generalManagerPhone: c.generalManagerPhone ?? attrs.generalManagerPhone, ownerPhone: c.ownerPhone ?? attrs.ownerPhone,
}; };
const v = key in map ? map[key] : (c[key] ?? attrs[key]); const v = key in map ? map[key] : (c[key] ?? attrs[key]);
return v === null || v === undefined || v === "" ? "—" : String(v); return v === null || v === undefined || v === "" ? "—" : String(v);

View File

@@ -485,6 +485,18 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
icon: <Settings />, icon: <Settings />,
permission: FREIGHT_PERMS.settings.dropdown.view, 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", label: "Contract templates",
href: "/dashboard/contract-templates", href: "/dashboard/contract-templates",

View File

@@ -0,0 +1,45 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { stampSettingsService } from "@/services/stampSettings.service";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
const QUERY_KEY = ["stampSettings"];
export const useStampSettingsQuery = () =>
useQuery({
queryKey: QUERY_KEY,
queryFn: () => stampSettingsService.get(),
});
export const useSetStamp = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
return useMutation({
mutationFn: (stampImageBase64: string) =>
stampSettingsService.set(stampImageBase64),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
toast.success(t("stampSettings.updated", "Company stamp updated"));
},
onError: handleError,
});
};
export const useClearStamp = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
return useMutation({
mutationFn: () => stampSettingsService.clear(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
toast.success(t("stampSettings.cleared", "Company stamp removed"));
},
onError: handleError,
});
};

View File

@@ -320,6 +320,16 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:dropdown:view", view: "edr_freight_app:settings:dropdown:view",
manage: "edr_freight_app:settings:dropdown:manage", 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: { exchangeRate: {
view: "edr_freight_app:settings:exchange_rate:view", view: "edr_freight_app:settings:exchange_rate:view",
manage: "edr_freight_app:settings:exchange_rate:manage", manage: "edr_freight_app:settings:exchange_rate:manage",

View File

@@ -17,6 +17,7 @@ import {
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
import { import {
AlertTriangle,
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
Banknote, Banknote,
@@ -638,8 +639,9 @@ export default function CustomerDetailPage() {
const hasPoaDetails = poaFields.some((f) => f.value?.trim()); const hasPoaDetails = poaFields.some((f) => f.value?.trim());
// Shared with the portal (buildCompanyIdentityState) — same derivation, so // Shared with the portal (buildCompanyIdentityState) — same derivation, so
// this page can never disagree with the rule the API actually enforces. // this page can never disagree with the rule the API actually enforces.
const ownerIdentity = company?.identity?.owner; const identityState = company?.identity;
const poaIdentity = company?.identity?.poa; const ownerIdentity = identityState?.owner;
const poaIdentity = identityState?.poa;
const hasEtradeRecord = Boolean(company?.licenceNumber?.trim()); const hasEtradeRecord = Boolean(company?.licenceNumber?.trim());
// A freight forwarder acts on other companies' behalf, so its PoA — details // A freight forwarder acts on other companies' behalf, so its PoA — details
// and DARS delegation paper both — is mandatory rather than optional. // and DARS delegation paper both — is mandatory rather than optional.
@@ -647,7 +649,7 @@ export default function CustomerDetailPage() {
(p) => p.type === "freight_forwarder", (p) => p.type === "freight_forwarder",
); );
const delegationMissing = const delegationMissing =
(hasPoaDetails || poaMandatory) && poaLive.length === 0; company?.identity?.poaDeclared === "yes" && poaLive.length === 0;
if (isLoading) { if (isLoading) {
return ( return (
@@ -820,6 +822,16 @@ export default function CustomerDetailPage() {
: undefined : 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="Address" value={company.address} />
<InfoField label="Website" value={company.website} /> <InfoField label="Website" value={company.website} />
<InfoField label="Email" value={company.email} /> <InfoField label="Email" value={company.email} />
@@ -834,18 +846,9 @@ export default function CustomerDetailPage() {
value={company.contactPersonPhone} value={company.contactPersonPhone}
/> />
<Box /> <Box />
<InfoField <InfoField label="Owner" value={company.ownerName} />
label="General manager" <InfoField label="Owner email" value={company.ownerEmail} />
value={company.generalManagerName} <InfoField label="Owner phone" value={company.ownerPhone} />
/>
<InfoField
label="GM email"
value={company.generalManagerEmail}
/>
<InfoField
label="GM phone"
value={company.generalManagerPhone}
/>
</SimpleGrid> </SimpleGrid>
</Stack> </Stack>
</Card> </Card>
@@ -912,6 +915,11 @@ export default function CustomerDetailPage() {
<Text fw={600} c="edr-text"> <Text fw={600} c="edr-text">
Owner identity Owner identity
</Text> </Text>
{identityState?.subject === "owner" && (
<Badge size="sm" color="blue" variant="light">
Verifies for this company
</Badge>
)}
{ownerIdentity?.verified ? ( {ownerIdentity?.verified ? (
<Badge size="sm" color="edr-green" variant="light"> <Badge size="sm" color="edr-green" variant="light">
Fayda verified Fayda verified
@@ -922,6 +930,41 @@ export default function CustomerDetailPage() {
</Badge> </Badge>
)} )}
</Group> </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 ? ( {ownerIdentity?.verified ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
<InfoField label="Name" value={ownerIdentity.name} /> <InfoField label="Name" value={ownerIdentity.name} />

View File

@@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Button } from "@/shared/common/ui/button";
import { Save, Trash2 } from "lucide-react";
import { StampUpload } from "@/components/contracts/StampUpload";
import {
useClearStamp,
useSetStamp,
useStampSettingsQuery,
} from "@/hooks/useStampSettings";
/**
* The one company stamp/seal stamped onto every generated invoice/receipt
* PDF (InvoiceDocumentService). Single global image — no per-employee choice.
*/
export default function InvoiceStampSettingsPage() {
const { data, isLoading } = useStampSettingsQuery();
const setStamp = useSetStamp();
const clearStamp = useClearStamp();
const [draft, setDraft] = useState<string | null>(null);
useEffect(() => {
setDraft(null);
}, [data?.stampImageUrl]);
const value = draft !== null ? draft : (data?.stampImageUrl ?? null);
const dirty = draft !== null && draft !== data?.stampImageUrl;
const handleSave = async () => {
if (!draft) return;
await setStamp.mutateAsync(draft);
};
const handleClear = async () => {
if (!data?.stampImageUrl) return;
await clearStamp.mutateAsync();
};
return (
<div className="p-4 w-full max-w-screen-sm mx-auto">
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
<CardHeader>
<CardTitle>Invoice stamp</CardTitle>
<CardDescription>
Stamped onto every generated invoice and receipt PDF. Replacing it
here changes it everywhere at once there is no per-invoice or
per-user choice.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<StampUpload
value={isLoading ? null : value}
onChange={setDraft}
label="Company stamp"
description="Shown on every invoice/receipt PDF in place of the plain seal."
/>
<div className="flex items-center gap-2">
<Button
onClick={handleSave}
disabled={!dirty || setStamp.isPending}
>
<Save className="mr-2 h-4 w-4" />
Save
</Button>
{data?.stampImageUrl && !dirty && (
<Button
variant="outline"
onClick={handleClear}
disabled={clearStamp.isPending}
>
<Trash2 className="mr-2 h-4 w-4" />
Remove
</Button>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -609,10 +609,18 @@ const UploadTeeterAndSignature = () => {
)} )}
</TabsContent> </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"> <TabsContent value="teeter" className="p-4 space-y-6">
{teeters.length > 0 && ( {teeters.length > 0 && (
<div className="space-y-6"> <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 }) => ( {teeters.map(({ id, url }) => (
<div key={id} className="space-y-3"> <div key={id} className="space-y-3">
<p className="text-sm text-gray-600 dark:text-gray-300"> <p className="text-sm text-gray-600 dark:text-gray-300">
@@ -635,6 +643,7 @@ const UploadTeeterAndSignature = () => {
</div> </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"> <div className="border-2 border-dashed border-primary-300 dark:border-primary-600 rounded-lg p-6 text-center space-y-4">
{!stampBlocks && !showLanguagePicker && ( {!stampBlocks && !showLanguagePicker && (
<Button <Button
@@ -792,6 +801,7 @@ const UploadTeeterAndSignature = () => {
</> </>
)} )}
</div> </div>
)}
</TabsContent> </TabsContent>
</Tabs> </Tabs>

View File

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

View File

@@ -0,0 +1,31 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import type { ApiResponse } from "@/types/apiResponse";
const BASE = "/stamp-settings";
/** Company stamp/seal used on generated invoice/receipt PDFs. */
export interface StampSettings {
stampImageUrl: string | null;
updatedById: string | null;
updatedAt: string | null;
}
export const stampSettingsService = {
get: async (): Promise<StampSettings> => {
const response = await client.get<ApiResponse<StampSettings>>(BASE);
return unwrap(response.data);
},
set: async (stampImageBase64: string): Promise<StampSettings> => {
const response = await client.put<ApiResponse<StampSettings>>(BASE, {
stampImageBase64,
});
return unwrap(response.data);
},
clear: async (): Promise<StampSettings> => {
const response = await client.delete<ApiResponse<StampSettings>>(BASE);
return unwrap(response.data);
},
};

View File

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

View File

@@ -179,23 +179,34 @@ export interface IdentityVerificationState {
verifiedAt: string | null; verifiedAt: string | null;
birthdate: string | null; birthdate: string | null;
gender: string | null; gender: string | null;
} /** Typed passport number — the foreign-company alternative to Fayda. */
/** Mirrors `OwnerIdentityStateDto`. */
export interface OwnerIdentityState extends IdentityVerificationState {
passportNumber: string | null; 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 * (`buildCompanyIdentityState`) so backoffice never re-derives — or
* disagrees with — the rule the API actually enforces. * disagrees with — the rule the API actually enforces.
*/ */
export interface CompanyIdentityState { export interface CompanyIdentityState {
faydaRequired: boolean; /** Foreign company: a passport number proves the person as Fayda would. */
passportRequired: boolean; passportAccepted: boolean;
owner: OwnerIdentityState; /** 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; 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; complete: boolean;
} }
@@ -211,14 +222,21 @@ export interface Company {
fanNumber?: string | null; fanNumber?: string | null;
country: string; country: string;
nationality?: CompanyNationality | null; 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; address?: string | null;
phone?: string | null; phone?: string | null;
email?: string | null; email?: string | null;
contactPersonName?: string | null; contactPersonName?: string | null;
contactPersonPhone?: string | null; contactPersonPhone?: string | null;
generalManagerName?: string | null; /** The owner — whoever the eTrade licence names as the business's manager. */
generalManagerEmail?: string | null; ownerName?: string | null;
generalManagerPhone?: string | null; ownerEmail?: string | null;
ownerPhone?: string | null;
poaName?: string | null; poaName?: string | null;
poaEmail?: string | null; poaEmail?: string | null;
poaPhone?: string | null; poaPhone?: string | null;

View File

@@ -11,7 +11,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { UseFormRegisterReturn } from "react-hook-form"; 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 { useETradeData } from "@/hooks/useETradeData";
import { extractApiError } from "@/utils/result"; import { extractApiError } from "@/utils/result";
import type { CompanyRegistrationData } from "@edr/types"; import type { CompanyRegistrationData } from "@edr/types";
@@ -54,6 +54,14 @@ interface ETradeInfoProps {
* than silently snapping to eTrade's first one. * than silently snapping to eTrade's first one.
*/ */
selectedLicenceNumber?: string; 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 // Digits, not just length: a 10-character non-numeric TIN used to fire a lookup
@@ -70,6 +78,7 @@ export default function ETradeInfo({
onReset, onReset,
alreadyVerified, alreadyVerified,
selectedLicenceNumber, selectedLicenceNumber,
registrationOptional = false,
}: ETradeInfoProps) { }: ETradeInfoProps) {
const mutation = useETradeData(); const mutation = useETradeData();
const isLoading = mutation.isPending; const isLoading = mutation.isPending;
@@ -327,16 +336,26 @@ export default function ETradeInfo({
</Stack> </Stack>
)} )}
{notFound && ( {notFound &&
<Alert (registrationOptional ? (
icon={<AlertCircle size={16} />} <Alert
color="red" icon={<Info size={16} />}
title="No matching business record" color="blue"
> title="Nothing on file at eTrade for this TIN"
This TIN isn't registered with eTrade. Check the number — we can't >
continue without a matching business record. That's expected without a trade licence. Fill in your registration
</Alert> 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 && ( {errorMessage && (
<Alert <Alert

View File

@@ -1,6 +1,7 @@
import { import {
Box, Box,
Button, Button,
Checkbox,
Group, Group,
Modal, Modal,
ScrollArea, ScrollArea,
@@ -41,12 +42,17 @@ import type { UpdateProfilePayload } from "@/types/profile";
import { extractApiError } from "@/utils/result"; import { extractApiError } from "@/utils/result";
/** Form steps rendered by CompanyProfileForm. */ /** Form steps rendered by CompanyProfileForm. */
type FormStep = "company" | "personnel" | "contact" | "poa" | "documents"; type FormStep =
| "company"
| "owner"
| "representation"
| "contact"
| "documents";
const FORM_STEPS: FormStep[] = [ const FORM_STEPS: FormStep[] = [
"company", "company",
"personnel", "owner",
"representation",
"contact", "contact",
"poa",
"documents", "documents",
]; ];
@@ -68,23 +74,25 @@ const STEP_META: Record<
icon: <Building2 size={20} />, icon: <Building2 size={20} />,
title: "Company Information", title: "Company Information",
description: 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} />, icon: <User size={20} />,
title: "General Manager", title: "Company Owner",
description: "Who is the general manager of the company?", 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: { contact: {
icon: <UserCheck size={20} />, icon: <UserCheck size={20} />,
title: "Contact Person", title: "Contact Person",
description: "Who should we reach out to about this account?", 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: { documents: {
icon: <UploadCloud size={20} />, icon: <UploadCloud size={20} />,
title: "Upload Documents", title: "Upload Documents",
@@ -157,6 +165,15 @@ export default function OnboardingWizardDialog({
const [roles, setRoles] = useState<string[]>( const [roles, setRoles] = useState<string[]>(
existingProfiles.map((p) => p.type), 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< const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null> Record<string, File | File[] | null>
>({}); >({});
@@ -207,10 +224,11 @@ export default function OnboardingWizardDialog({
companyType: string; companyType: string;
roles: ProfileTypeValue[]; roles: ProfileTypeValue[];
nationality?: CompanyNationality; nationality?: CompanyNationality;
cooperative?: boolean;
}) => api.companies.startOnboarding.call(vars), }) => api.companies.startOnboarding.call(vars),
onSuccess: async () => { onSuccess: async () => {
// Nationality drives the server-resolved identity requirements (Fayda vs // 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 // onboardingRequirements/profile. Re-entering role selection can change
// it, so both must be refetched alongside getInfo or the form step would // it, so both must be refetched alongside getInfo or the form step would
// keep rendering the previous nationality's requirements. // keep rendering the previous nationality's requirements.
@@ -289,6 +307,7 @@ export default function OnboardingWizardDialog({
resumedRef.current = true; resumedRef.current = true;
setRoles(existingProfiles.map((p) => p.type)); setRoles(existingProfiles.map((p) => p.type));
setNationality(savedNationality); setNationality(savedNationality);
setCooperative(company?.company?.attributes?.cooperative === true);
// Resume into the form only when profiles exist; otherwise send the user to // Resume into the form only when profiles exist; otherwise send the user to
// role selection so the missing operational profiles get created. // role selection so the missing operational profiles get created.
setPhase(hasOperationalProfiles ? "form" : "nationality-role"); setPhase(hasOperationalProfiles ? "form" : "nationality-role");
@@ -303,8 +322,9 @@ export default function OnboardingWizardDialog({
companyType: companyTypeForRoles(roles), companyType: companyTypeForRoles(roles),
roles: roles as ProfileTypeValue[], roles: roles as ProfileTypeValue[],
nationality: nationality ?? undefined, nationality: nationality ?? undefined,
cooperative,
}); });
}, [roles, nationality, startMutation]); }, [roles, nationality, cooperative, startMutation]);
// Back from the form's first step returns to nationality/role selection. // Back from the form's first step returns to nationality/role selection.
// Safe to re-enter: startOnboarding is idempotent — it reuses the existing // Safe to re-enter: startOnboarding is idempotent — it reuses the existing
@@ -414,15 +434,21 @@ export default function OnboardingWizardDialog({
const requiredDocsMissing = requirementDocuments.some( const requiredDocsMissing = requirementDocuments.some(
(d) => d.isRequired && !d.uploaded, (d) => d.isRequired && !d.uploaded,
); );
// The PoA gets the same treatment: a resumed draft that predates the // The representation step gets the same treatment. An unanswered
// delegation-letter requirement (or a forwarder whose PoA is blank) must land // power-of-attorney question, or a declared representative still missing
// back on the PoA step, where both the details and the letter are entered. // details or the DARS paper, must land the customer back on the step where
const poaIncomplete = requirementsQuery.data?.poa?.complete === false; // 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. // Each unmet requirement lowers the ceiling; resume never moves forward.
let ceiling = FORM_STEPS.length - 1; let ceiling = FORM_STEPS.length - 1;
if (requiredDocsMissing) if (requiredDocsMissing)
ceiling = Math.min(ceiling, FORM_STEPS.indexOf("documents")); 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 = const effectiveResumeStep: FormStep =
FORM_STEPS[ FORM_STEPS[
Math.min(Math.max(FORM_STEPS.indexOf(resumeFormStep), 0), ceiling) Math.min(Math.max(FORM_STEPS.indexOf(resumeFormStep), 0), ceiling)
@@ -446,10 +472,19 @@ export default function OnboardingWizardDialog({
onLicenseChange: setLicenseFiles, onLicenseChange: setLicenseFiles,
uploadedDocumentKeys, uploadedDocumentKeys,
onUploadDocuments: handleUploadDocuments, onUploadDocuments: handleUploadDocuments,
// Fayda verification state for the owner and the PoA — the general manager // The company's single identity verification, and whose it is. Fayda is
// stays a plain typed role. Mandatory (Fayda) for an Ethiopian company; // mandatory for an Ethiopian company; a foreign one may instead type a
// a foreign one requires a typed passport number for the owner instead. // passport number for the same person.
identity: requirementsQuery.data?.identity, 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: () => { onIdentityChange: () => {
void profileQuery.refetch(); void profileQuery.refetch();
void requirementsQuery.refetch(); void requirementsQuery.refetch();
@@ -517,6 +552,16 @@ export default function OnboardingWizardDialog({
onChange={setNationality} onChange={setNationality}
embedded 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"> <Text fw={600} size="lg" c="edr-text">
What does your company do?(multiple) What does your company do?(multiple)
</Text> </Text>
@@ -524,6 +569,9 @@ export default function OnboardingWizardDialog({
value={roles} value={roles}
onChange={setRoles} onChange={setRoles}
embedded 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 && ( {startError && (
<Text size="sm" c="red"> <Text size="sm" c="red">

View File

@@ -49,49 +49,46 @@ import TabAccount from "./settings/TabAccount";
import TabCompanyProfile from "./settings/TabCompanyProfile"; import TabCompanyProfile from "./settings/TabCompanyProfile";
import TabContactPerson from "./settings/TabContactPerson"; import TabContactPerson from "./settings/TabContactPerson";
import TabDocuments from "./settings/TabDocuments"; import TabDocuments from "./settings/TabDocuments";
import TabGeneralManager from "./settings/TabGeneralManager"; import TabOwner from "./settings/TabOwner";
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney"; import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
type SettingsTab = type SettingsTab =
| "account" | "account"
| "company" | "company"
| "contact" | "contact"
| "gm" | "owner"
| "poa" | "poa"
| "documents"; | "documents";
/** A section is "incomplete" when its required fields aren't filled in yet. */ /** A section is "incomplete" when its required fields aren't filled in yet. */
function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean { function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean {
switch (tabId) { switch (tabId) {
case "company": { case "company":
// Identity proof lives here: the owner's Fayda verification for an return !profile.companyAddress;
// 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 "contact": case "contact":
return !profile.contactPersonName || !profile.contactPersonPhone; return !profile.contactPersonName || !profile.contactPersonPhone;
case "gm": case "owner":
// The GM is established through Fayda — verified in their own right or // The owner is whoever the eTrade licence names. All three details are
// declared the same person as the owner — so the identity answers this, // required whatever supplied them, and the identity verification lives
// not the typed columns. A company that may still type them (foreign, // on whichever person the PoA declaration points at — flagged here when
// whose manager may hold no Fayda ID) is judged on those instead. // it is the owner and still unproven.
if (profile.identity?.gm.verified) return false;
if (profile.identity?.faydaRequired) return true;
return ( return (
!profile.generalManagerName || !profile.ownerName ||
!profile.generalManagerEmail || !profile.ownerEmail ||
!profile.generalManagerPhone !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": case "account":
// Account fields live on the IAM user, not the company profile, and are // Account fields live on the IAM user, not the company profile, and are
// always populated (signup requires them) — nothing to nag about here. // always populated (signup requires them) — nothing to nag about here.
case "poa":
case "documents": case "documents":
return false; return false;
} }
@@ -101,7 +98,7 @@ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
{ id: "account", label: "Account", icon: <UserCog size={16} /> }, { id: "account", label: "Account", icon: <UserCog size={16} /> },
{ id: "company", label: "Company", icon: <Building2 size={16} /> }, { id: "company", label: "Company", icon: <Building2 size={16} /> },
{ id: "contact", label: "Contact Person", icon: <User 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: "poa", label: "Power of Attorney", icon: <UserCheck size={16} /> },
{ id: "documents", label: "Documents", icon: <FileCheck size={16} /> }, { id: "documents", label: "Documents", icon: <FileCheck size={16} /> },
]; ];
@@ -405,8 +402,8 @@ export default function SettingsPage() {
<Tabs.Panel value="contact"> <Tabs.Panel value="contact">
<TabContactPerson profile={profile} mode="edit" /> <TabContactPerson profile={profile} mode="edit" />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="gm"> <Tabs.Panel value="owner">
<TabGeneralManager profile={profile} mode="edit" /> <TabOwner profile={profile} mode="edit" />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="poa"> <Tabs.Panel value="poa">
<TabPowerOfAttorney profile={profile} mode="edit" /> <TabPowerOfAttorney profile={profile} mode="edit" />

View File

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

View File

@@ -4,6 +4,8 @@ import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type { CompanyIdentityState } from "@/services/verifayda.service"; import type { CompanyIdentityState } from "@/services/verifayda.service";
import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField"; import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField";
import type { FieldSource } from "./SourcedField";
import { import {
ETRADE_BUNDLE_FIELDS, ETRADE_BUNDLE_FIELDS,
type CompanyStep, type CompanyStep,
@@ -69,7 +71,6 @@ export function normalizeIdentityPhones(
...identity, ...identity,
owner: fix(identity.owner), owner: fix(identity.owner),
poa: fix(identity.poa), 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); 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. */ /** Mask all but the first 7 chars of an E.164 phone for display. */
export const maskPhone = (p: string) => export const maskPhone = (p: string) =>
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p; 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, vatNumber: data.vatNumber,
attributes: { attributes: {
ownerPassportNumber: data.ownerPassportNumber || undefined, ownerPassportNumber: data.ownerPassportNumber || undefined,
poaPassportNumber: data.poaPassportNumber || undefined,
contactPersonName: data.contactPersonName, contactPersonName: data.contactPersonName,
contactPersonPosition: data.contactPersonPosition || undefined, contactPersonPosition: data.contactPersonPosition || undefined,
contactPersonEmail: data.contactPersonEmail || undefined, contactPersonEmail: data.contactPersonEmail || undefined,
contactPersonPhone: data.contactPersonPhone, contactPersonPhone: data.contactPersonPhone,
generalManagerName: data.generalManagerName, ownerName: data.ownerName,
generalManagerEmail: data.generalManagerEmail, ownerEmail: data.ownerEmail,
generalManagerPhone: data.generalManagerPhone, ownerPhone: data.ownerPhone,
// The representative's own details are written by their Fayda
// verification, so the city is all the form has to send.
poaLocation: data.poaLocation || undefined, poaLocation: data.poaLocation || undefined,
}, },
}; };
@@ -135,23 +202,28 @@ export function stepPayload(
} }
if (dirty.tinNumber) etrade.tin = d.tinNumber; if (dirty.tinNumber) etrade.tin = d.tinNumber;
return { 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, vatNumber: d.vatNumber,
ownerPassportNumber: d.ownerPassportNumber || undefined,
...etrade, ...etrade,
}; };
} }
case "personnel": case "owner":
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null and // `|| undefined`, never "": the DTO's `@IsOptional()` only skips null and
// undefined, so an empty string is validated and 400s with // undefined, so an empty string is validated and 400s with
// "generalManagerEmail must be an email". An Ethiopian company never types // "ownerEmail must be an email". A field the eTrade lookup or the Fayda
// these — the GM comes from the Fayda verification (or the "same as owner" // claim already filled is legitimately blank in the form — it has no
// declaration), so the form fields are legitimately blank and would fail a // input — so sending "" would fail a step with nothing on screen to fix.
// step that has no input to fix.
return { return {
generalManagerName: d.generalManagerName || undefined, ownerName: d.ownerName || undefined,
generalManagerEmail: d.generalManagerEmail || undefined, ownerEmail: d.ownerEmail || undefined,
generalManagerPhone: d.generalManagerPhone || undefined, ownerPhone: d.ownerPhone || undefined,
ownerPassportNumber: d.ownerPassportNumber || undefined,
}; };
case "contact": case "contact":
return { return {
@@ -160,12 +232,16 @@ export function stepPayload(
contactPersonEmail: d.contactPersonEmail || undefined, contactPersonEmail: d.contactPersonEmail || undefined,
contactPersonPhone: d.contactPersonPhone, contactPersonPhone: d.contactPersonPhone,
}; };
case "poa": case "representation":
return { return {
poaName: d.poaName || undefined, poaName: d.poaName || undefined,
poaEmail: d.poaEmail || undefined, poaEmail: d.poaEmail || undefined,
poaPhone: d.poaPhone || undefined, poaPhone: d.poaPhone || undefined,
poaLocation: d.poaLocation || 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: default:
return {}; return {};
@@ -183,6 +259,7 @@ export function toFormValues(p: ProfileResponse): FormData {
tinNumber: tin, tinNumber: tin,
vatNumber: p.vatNumber ?? "", vatNumber: p.vatNumber ?? "",
ownerPassportNumber: p.identity?.owner.passportNumber ?? "", ownerPassportNumber: p.identity?.owner.passportNumber ?? "",
poaPassportNumber: p.identity?.poa.passportNumber ?? "",
licenceNumber: p.licenceNumber ?? "", licenceNumber: p.licenceNumber ?? "",
statusDescription: p.statusDescription ?? "", statusDescription: p.statusDescription ?? "",
dateRegistered: p.dateRegistered ?? "", dateRegistered: p.dateRegistered ?? "",
@@ -198,12 +275,11 @@ export function toFormValues(p: ProfileResponse): FormData {
contactPersonPosition: p.contactPersonPosition ?? "", contactPersonPosition: p.contactPersonPosition ?? "",
contactPersonEmail: p.contactPersonEmail ?? "", contactPersonEmail: p.contactPersonEmail ?? "",
contactPersonPhone: p.contactPersonPhone ?? "", contactPersonPhone: p.contactPersonPhone ?? "",
generalManagerName: p.generalManagerName ?? "", ownerName: p.ownerName ?? "",
generalManagerEmail: p.generalManagerEmail ?? "", ownerEmail: p.ownerEmail ?? "",
generalManagerPhone: p.generalManagerPhone ?? "", ownerPhone: p.ownerPhone ?? "",
poaName: p.poaName ?? "", poaName: p.poaName ?? "",
poaPhone: p.poaPhone ?? "", poaPhone: p.poaPhone ?? "",
poaAddress: p.poaAddress ?? "",
poaEmail: p.poaEmail ?? "", poaEmail: p.poaEmail ?? "",
poaLocation: p.poaLocation ?? "", poaLocation: p.poaLocation ?? "",
}; };

View File

@@ -6,6 +6,7 @@ import {
firstValidEmail, firstValidEmail,
firstValidPhone, firstValidPhone,
normalizeIdentityPhones, normalizeIdentityPhones,
resolveOwnerSources,
stepPayload, stepPayload,
} from "./helpers"; } from "./helpers";
import type { FormData } from "./schema"; import type { FormData } from "./schema";
@@ -35,9 +36,9 @@ const values = (over: Partial<FormData> = {}): FormData =>
contactPersonPosition: "", contactPersonPosition: "",
contactPersonEmail: "", contactPersonEmail: "",
contactPersonPhone: "+251911223344", contactPersonPhone: "+251911223344",
generalManagerName: "", ownerName: "",
generalManagerEmail: "", ownerEmail: "",
generalManagerPhone: "", ownerPhone: "",
poaName: "", poaName: "",
poaPhone: "", poaPhone: "",
poaAddress: "", poaAddress: "",
@@ -65,18 +66,16 @@ describe("VAT number", () => {
).toBeUndefined(); ).toBeUndefined();
}); });
it("rejects twelve digits", () => { // No shape rule any more: a foreign tax authority's VAT number carries
expect(errorFor(values({ vatNumber: "001234567890" }), "vatNumber")).toBe( // letters and dashes, and a co-operative union's registration numbering
"VAT number must be 10 or 11 digits", // 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",
// `.length(10)` used to pass this, so a ten-letter string reached the API. (vat) => {
it("rejects ten non-digits", () => { expect(errorFor(values({ vatNumber: vat }), "vatNumber")).toBeUndefined();
expect(errorFor(values({ vatNumber: "ABCDEFGHIJ" }), "vatNumber")).toBe( },
"VAT number must be 10 or 11 digits", );
);
});
it("rejects blank", () => { it("rejects blank", () => {
expect(errorFor(values({ vatNumber: "" }), "vatNumber")).toBe( 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 // 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 // a field it renders no input for, or Continue fails with the error attached
// to nothing on screen. // to nothing on screen.
it("never gates the company step on a derived or read-only field", () => { //
const unreachable = [ // Listing a field on a step is no longer the gate — `requiredKeys` is. The
"etradePhone", // 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", "companyName",
"licenceNumber",
"statusDescription",
"dateRegistered",
"renewedFrom",
"renewalDate",
"renewedTo",
"region", "region",
"zone", "zone",
"woreda", "woreda",
"kebele", "kebele",
"houseNo", ]).safeParse(
]; values({ companyName: "", region: "", zone: "", woreda: "", kebele: "" }),
expect(stepFields.company.filter((f) => unreachable.includes(f))).toEqual(
[],
); );
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, data: FormData,
required: (keyof FormData)[], required: (keyof FormData)[],
): (keyof FormData)[] => { ): (keyof FormData)[] => {
const parsed = buildOnboardingSchema(false, required).safeParse(data); const parsed = buildOnboardingSchema(required).safeParse(data);
return parsed.success return parsed.success
? [] ? []
: (parsed.error.issues.map((i) => i.path[0]) as (keyof FormData)[]); : (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 // eTrade returns no email and Fayda's email/phone claims are optional: the
// the verification did not supply, and requires exactly those. Nothing else — // step renders an input for what no source supplied, and requires exactly
// a field with no input on screen must never fail Continue. // those. Nothing else — a field with no input on screen must never fail
// Continue.
it("requires only the keys it is handed", () => { it("requires only the keys it is handed", () => {
const issues = issuesFor(values(), [ const issues = issuesFor(values(), ["ownerEmail", "ownerPhone"]);
"generalManagerEmail", expect(issues).toEqual(["ownerEmail", "ownerPhone"]);
"generalManagerPhone",
]);
expect(issues).toEqual(["generalManagerEmail", "generalManagerPhone"]);
}); });
it("passes once those keys are filled", () => { it("passes once those keys are filled", () => {
expect( expect(
issuesFor( issuesFor(
values({ values({
generalManagerEmail: "gm@example.com", ownerEmail: "owner@example.com",
generalManagerPhone: "+251911223344", ownerPhone: "+251911223344",
}), }),
["generalManagerEmail", "generalManagerPhone"], ["ownerEmail", "ownerPhone"],
), ),
).toEqual([]); ).toEqual([]);
}); });
@@ -165,9 +225,7 @@ describe("buildOnboardingSchema (conditionally required fields)", () => {
}); });
it("names the field in the message, so it reads under its own input", () => { it("names the field in the message, so it reads under its own input", () => {
const parsed = buildOnboardingSchema(false, ["poaEmail"]).safeParse( const parsed = buildOnboardingSchema(["poaEmail"]).safeParse(values());
values(),
);
expect(parsed.success).toBe(false); expect(parsed.success).toBe(false);
if (parsed.success) return; if (parsed.success) return;
expect(parsed.error.issues[0]?.message).toBe( expect(parsed.error.issues[0]?.message).toBe(
@@ -196,39 +254,43 @@ describe("stepPayload (company)", () => {
// Still only the dirty ones. // Still only the dirty ones.
expect(payload.licenceNumber).toBeUndefined(); 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)", () => { describe("stepPayload (owner)", () => {
// An Ethiopian company never types the GM — Fayda (or "same as owner") owns // A Fayda claim owns whatever it supplied, so the form holds "" for those.
// those fields — so the form holds "". `@IsOptional()` on the DTO skips only // `@IsOptional()` on the DTO skips only null/undefined, so an empty string is
// null/undefined, so an empty string is validated and comes back as // validated and comes back as "ownerEmail must be an email" — on a step that
// "generalManagerEmail must be an email", on a step that renders no input. // renders no input for it.
it("omits blank GM fields instead of sending empty strings", () => { it("omits blank owner fields instead of sending empty strings", () => {
const payload = stepPayload( const payload = stepPayload(
"personnel", "owner",
values({ values({ ownerName: "", ownerEmail: "", ownerPhone: "" }),
generalManagerName: "",
generalManagerEmail: "",
generalManagerPhone: "",
}),
); );
expect(payload.generalManagerName).toBeUndefined(); expect(payload.ownerName).toBeUndefined();
expect(payload.generalManagerEmail).toBeUndefined(); expect(payload.ownerEmail).toBeUndefined();
expect(payload.generalManagerPhone).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( const payload = stepPayload(
"personnel", "owner",
values({ values({
generalManagerName: "Abebe Bikila", ownerName: "Abebe Bikila",
generalManagerEmail: "gm@example.com", ownerEmail: "owner@example.com",
generalManagerPhone: "+251911223344", ownerPhone: "+251911223344",
}), }),
); );
expect(payload.generalManagerName).toBe("Abebe Bikila"); expect(payload.ownerName).toBe("Abebe Bikila");
expect(payload.generalManagerEmail).toBe("gm@example.com"); expect(payload.ownerEmail).toBe("owner@example.com");
expect(payload.generalManagerPhone).toBe("+251911223344"); 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", () => { describe("normalizeIdentityPhones", () => {
it("converts a local Fayda phone claim to E.164", () => { it("converts a local Fayda phone claim to E.164", () => {
const identity = { const identity = {
faydaRequired: true, passportAccepted: false,
passportRequired: false, poaDeclared: "yes",
subject: "poa",
owner: { owner: {
verified: true, verified: false,
name: "A", name: "A",
phone: "0911223344", phone: "0911223344",
email: null, email: null,
@@ -283,28 +449,22 @@ describe("normalizeIdentityPhones", () => {
passportNumber: null, passportNumber: null,
}, },
poa: { poa: {
verified: false, verified: true,
name: null, name: "B",
phone: null,
email: null,
address: null,
verifiedAt: null,
},
gm: {
verified: false,
name: null,
phone: "251911223344", phone: "251911223344",
email: null, email: null,
address: null, address: null,
verifiedAt: null, verifiedAt: null,
passportNumber: null,
}, },
gmSameAsOwner: false, identityProven: true,
complete: false, etradeManagerName: null,
ownerMatchesEtrade: null,
complete: true,
} as CompanyIdentityState; } as CompanyIdentityState;
const fixed = normalizeIdentityPhones(identity)!; const fixed = normalizeIdentityPhones(identity)!;
expect(fixed.owner.phone).toBe("+251911223344"); expect(fixed.owner.phone).toBe("+251911223344");
expect(fixed.gm.phone).toBe("+251911223344"); expect(fixed.poa.phone).toBe("+251911223344");
expect(fixed.poa.phone).toBeNull();
}); });
}); });

View File

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

View File

@@ -1,12 +1,10 @@
import { Stack, TextInput } from "@mantine/core"; import { Select, SimpleGrid, Stack, Text, TextInput } from "@mantine/core";
import type { UseFormReturn } from "react-hook-form"; import type { UseFormReturn } from "react-hook-form";
import type { CompanyRegistrationData } from "@edr/types"; import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import ETradeInfo, { import ETradeInfo, {
type ETradeStatus, type ETradeStatus,
} from "@/components/onboarding/ETradeInfo"; } from "@/components/onboarding/ETradeInfo";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import type { FormData } from "../schema"; import type { FormData } from "../schema";
import ETradeCompanyCard from "../ETradeCompanyCard"; import ETradeCompanyCard from "../ETradeCompanyCard";
@@ -14,14 +12,16 @@ import StepSection from "../StepSection";
export interface CompanyInfoStepProps { export interface CompanyInfoStepProps {
form: UseFormReturn<FormData>; 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; tinStatus: ETradeStatus;
tinVerified: boolean; tinVerified: boolean;
/** Registration fields are already populated (a lookup passed, now or earlier). */ /** Registration fields are already populated (a lookup passed, now or earlier). */
hasRegistrationDetails: boolean; 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; onETradeDataLoaded: (data: CompanyRegistrationData) => void;
onETradeStatusChange: (status: ETradeStatus) => void; onETradeStatusChange: (status: ETradeStatus) => void;
onETradeReset: () => void; onETradeReset: () => void;
@@ -29,11 +29,10 @@ export interface CompanyInfoStepProps {
export default function CompanyInfoStep({ export default function CompanyInfoStep({
form, form,
identity,
verifiedIdentity,
tinStatus, tinStatus,
tinVerified, tinVerified,
hasRegistrationDetails, hasRegistrationDetails,
cooperative = false,
onETradeDataLoaded, onETradeDataLoaded,
onETradeStatusChange, onETradeStatusChange,
onETradeReset, onETradeReset,
@@ -41,77 +40,51 @@ export default function CompanyInfoStep({
const { const {
register, register,
watch, watch,
setValue,
formState: { errors }, formState: { errors },
} = form; } = form;
const region = watch("region") ?? "";
return ( return (
<Stack gap="xl"> <Stack gap="xl">
<StepSection <StepSection
index={1} index={1}
title="VAT number" title="VAT number"
status={ status={watch("vatNumber")?.trim() && !errors.vatNumber ? "done" : "todo"}
(watch("vatNumber")?.length ?? 0) >= 10 && !errors.vatNumber
? "done"
: "todo"
}
> >
<TextInput <TextInput
aria-label="VAT Number" aria-label="VAT Number"
placeholder="0012345678" placeholder="0012345678"
maxLength={11}
error={errors.vatNumber?.message} error={errors.vatNumber?.message}
{...register("vatNumber")} {...register("vatNumber")}
/> />
</StepSection> </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 <StepSection
index={2} 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" 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={ status={
tinVerified ? "done" : tinStatus === "taken" ? "blocked" : "todo" tinStatus === "taken"
? "blocked"
: cooperative
? watch("tinNumber")?.trim() && !errors.tinNumber
? "done"
: "todo"
: tinVerified
? "done"
: "todo"
} }
> >
<ETradeInfo <ETradeInfo
@@ -123,11 +96,87 @@ export default function CompanyInfoStep({
onReset={onETradeReset} onReset={onETradeReset}
alreadyVerified={hasRegistrationDetails} alreadyVerified={hasRegistrationDetails}
selectedLicenceNumber={watch("licenceNumber")} selectedLicenceNumber={watch("licenceNumber")}
registrationOptional={cooperative}
/> />
{tinVerified && ( {!cooperative && tinVerified && (
<ETradeCompanyCard tin={watch("tinNumber")} watch={watch} /> <ETradeCompanyCard tin={watch("tinNumber")} watch={watch} />
)} )}
</StepSection> </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> </Stack>
); );
} }

View File

@@ -9,19 +9,19 @@ import { LinkCheckboxCard } from "../LinkCheckboxCard";
export interface ContactStepProps { export interface ContactStepProps {
form: UseFormReturn<FormData>; form: UseFormReturn<FormData>;
/** /**
* The GM's name from whichever source established them (verification or form) * The owner's name from whichever source established them (eTrade, Fayda or
* — the "same as GM" card only makes sense once there is a GM. * typed) — the "same as owner" card only makes sense once there is one.
*/ */
gmName?: string; ownerName?: string;
contactSameAsGm: boolean; contactSameAsOwner: boolean;
onToggleContactSameAsGm: (checked: boolean) => void; onToggleContactSameAsOwner: (checked: boolean) => void;
} }
export default function ContactStep({ export default function ContactStep({
form, form,
gmName, ownerName,
contactSameAsGm, contactSameAsOwner,
onToggleContactSameAsGm, onToggleContactSameAsOwner,
}: ContactStepProps) { }: ContactStepProps) {
const { const {
register, register,
@@ -34,21 +34,26 @@ export default function ContactStep({
<Text fw={600} size="sm" c="edr-text"> <Text fw={600} size="sm" c="edr-text">
Contact Person Contact Person
</Text> </Text>
{/* `gmName`, not the raw form field: a Fayda-verified GM never {/* `ownerName`, not the raw form field: the owner's name usually comes
fills `generalManagerName`, so gating on it hid this card from from the eTrade lookup or a Fayda claim rather than being typed, so
every Ethiopian company — the majority case. */} gating on the form value would hide this card from most companies. */}
{gmName && ( {ownerName && (
<LinkCheckboxCard <LinkCheckboxCard
checked={contactSameAsGm} checked={contactSameAsOwner}
onToggle={onToggleContactSameAsGm} onToggle={onToggleContactSameAsOwner}
title="Same as General Manager" title="Same as company owner"
description="Reuse the general manager's name, email and phone. Uncheck to enter different details." description="Reuse the owner's name, email and phone. Uncheck to enter different details."
/> />
)} )}
<SimpleGrid cols={2} spacing="md"> <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 <TextInput
label="Name" label="Name"
placeholder="Jane Smith" placeholder="Jane Smith"
disabled={contactSameAsOwner}
error={errors.contactPersonName?.message} error={errors.contactPersonName?.message}
{...register("contactPersonName")} {...register("contactPersonName")}
/> />
@@ -64,6 +69,7 @@ export default function ContactStep({
label="Email (Optional)" label="Email (Optional)"
type="email" type="email"
placeholder="contact@company.com" placeholder="contact@company.com"
disabled={contactSameAsOwner}
error={errors.contactPersonEmail?.message} error={errors.contactPersonEmail?.message}
{...register("contactPersonEmail")} {...register("contactPersonEmail")}
/> />
@@ -71,6 +77,7 @@ export default function ContactStep({
control={control} control={control}
name="contactPersonPhone" name="contactPersonPhone"
label="Phone" label="Phone"
disabled={contactSameAsOwner}
required required
/> />
</SimpleGrid> </SimpleGrid>

View File

@@ -0,0 +1,151 @@
import { Alert, SimpleGrid, Stack, Text, TextInput } from "@mantine/core";
import { AlertTriangle, Info } from "lucide-react";
import type { UseFormReturn } from "react-hook-form";
import { ControlledPhoneField } from "@/components/PhoneField";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import type { FormData } from "../schema";
import SourcedField, { type FieldSource } from "../SourcedField";
/** The owner details this step is responsible for. */
type OwnerField = "name" | "email" | "phone";
export interface OwnerStepProps {
form: UseFormReturn<FormData>;
identity?: CompanyIdentityState;
/** eTrade's registered manager, once a TIN lookup has succeeded. */
etradeOwner: { name: string; phone: string } | null;
/**
* Which source owns each of the owner's details, or null where none does.
*
* A sourced field is shown read-only with its provenance; every other one is
* an editable input. Neither the eTrade licence nor a Fayda verification is
* the customer's to retype — the first is the record the backoffice checks
* this company against, the second is the government's. CompanyProfileForm
* computes this and requires exactly the unsourced fields, so every input on
* screen is one the customer is actually asked to fill and nothing is
* required that has none.
*/
source: Record<OwnerField, FieldSource | null>;
/** The value to display for a field its source owns. */
sourced: Record<OwnerField, string>;
/** A co-operative union or farm: no licence, so no eTrade record to match. */
cooperative?: boolean;
}
/**
* Who the company's owner is — meaning whoever the eTrade licence names as the
* business's manager. Not necessarily the legal owner, but the person the
* record has to match: the backoffice's check is precisely "is this the person
* on the licence".
*
* Nothing here falls back to the signed-in account. The person doing the
* onboarding is often not the person on the licence, and stamping their name,
* email and phone onto the owner turned three required fields into a guess
* wearing the licence's authority.
*
* A co-operative union or farm has no licence, so there is nobody named on one
* — the owner is simply the person who runs it, typed in full and compared
* against nothing.
*/
export default function OwnerStep({
form,
identity,
etradeOwner,
source,
sourced,
cooperative = false,
}: OwnerStepProps) {
const {
register,
control,
formState: { errors },
} = form;
const ownerVerified = identity?.owner.verified ?? false;
// A Fayda verification that names someone other than the person on the
// licence is the one thing this step exists to catch. Advisory here — the two
// sources transliterate Amharic names differently, so the reviewer decides —
// but the customer should see it now rather than be rejected later.
const mismatch = identity?.ownerMatchesEtrade === false;
return (
<Stack gap="md">
<Text size="sm" c="edr-muted">
{cooperative && !etradeOwner
? "The person who runs the co-operative union or farm. eTrade held no record for your TIN, so we need all of these from you."
: "These are the details of the person registered on your eTrade licence. What eTrade and Fayda gave us is shown as they gave it; anything they left blank we need from you."}
</Text>
{/* A co-operative is not told its licence listed no manager — it has no
licence. Its own "nothing came back" case is covered by the line
above. */}
{!cooperative && !etradeOwner && !ownerVerified && (
<Alert color="blue" variant="light" icon={<Info size={18} />}>
Your eTrade licence didn't list a manager, so there's nothing for us
to prefill. Enter the details of the person registered on it.
</Alert>
)}
{mismatch && (
<Alert
color="amber"
variant="light"
icon={<AlertTriangle size={18} />}
title="This doesn't match your eTrade licence"
>
Your licence lists <strong>{identity?.etradeManagerName}</strong>, but
the name here is <strong>{identity?.owner.name}</strong>. You can
continue, but our team will check this before approving your account
so make sure it's the person the licence actually names.
</Alert>
)}
<SourcedField
label="Owner's Name"
value={sourced.name}
source={source.name}
>
<TextInput
label="Owner's Name"
placeholder="Abebe Bikila"
error={errors.ownerName?.message}
{...register("ownerName")}
/>
</SourcedField>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{/* eTrade never returns an email for the manager and Fayda's email
claim is optional, so this is the field most companies actually
type it is required either way (`REQUIRED_COMPANY_INFO`). */}
<SourcedField
label="Owner's Email"
value={sourced.email}
source={source.email}
>
<TextInput
label="Owner's Email"
type="email"
placeholder="owner@company.com"
error={errors.ownerEmail?.message}
{...register("ownerEmail")}
/>
</SourcedField>
<SourcedField
label="Owner's Phone"
value={sourced.phone}
source={source.phone}
>
<ControlledPhoneField
control={control}
name="ownerPhone"
label="Owner's Phone"
/>
</SourcedField>
</SimpleGrid>
</Stack>
);
}

View File

@@ -1,150 +0,0 @@
import { SimpleGrid, Text, TextInput } from "@mantine/core";
import type { UseFormReturn } from "react-hook-form";
import { ControlledPhoneField } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import type { FormData } from "../schema";
import { LinkCheckboxCard } from "../LinkCheckboxCard";
export interface PersonnelStepProps {
form: UseFormReturn<FormData>;
identity?: CompanyIdentityState;
/** eTrade-registered owner, once a TIN lookup has succeeded. */
etradeOwner: { name: string; phone: string } | null;
gmSameAsOwner: boolean;
onToggleGmSameAsOwner: (checked: boolean) => void;
/** A server-side "same as owner" declaration is in flight. */
gmLinkPending: boolean;
gmVerified: boolean;
/**
* Which of the manager's contact details their Fayda verification did not
* supply, and are therefore typed here. Computed by CompanyProfileForm, which
* requires exactly these in the schema.
*/
gaps: { name: boolean; email: boolean; phone: boolean };
}
export default function PersonnelStep({
form,
identity,
etradeOwner,
gmSameAsOwner,
onToggleGmSameAsOwner,
gmLinkPending,
gmVerified,
gaps,
}: PersonnelStepProps) {
const {
register,
control,
formState: { errors },
} = form;
return (
<>
<Text fw={600} size="sm" c="edr-text">
General Manager
</Text>
{/* The GM is very often the owner. Where the owner is
Fayda-verified this reuses that proven identity outright
rather than making the same human verify twice; where the
owner is backed by a typed passport there is nothing proven
to copy, so it stays a local prefill. */}
<LinkCheckboxCard
checked={gmSameAsOwner}
onToggle={onToggleGmSameAsOwner}
title={
identity?.owner.verified
? "Same as verified owner"
: "Same as business owner"
}
description={
identity?.owner.verified
? "Reuse the Fayda-verified owner's identity for the general manager. Uncheck to verify a different person."
: etradeOwner
? "Reuse the eTrade-registered owner's name, plus the company email and phone as you entered them. Uncheck to enter different details."
: "Reuse your account's name and the company email and phone as you entered them. Uncheck to enter different details."
}
/>
{/* Verifying a second person is only meaningful when the GM is
someone other than the owner. */}
{!gmSameAsOwner && identity && (
<FaydaVerifyPanel
subject="gm"
title="General Manager"
state={identity.gm}
required={identity.faydaRequired}
disabled={gmLinkPending}
/>
)}
{/* Fayda's name, email and phone claims are all optional, and the
manager's own verification has no account to fall back on the way the
owner's does — the person onboarding is not necessarily the manager.
Whatever the verification left empty is typed here, and required:
without it the submit fails on "Add your general manager name" with no
field anywhere to satisfy it. */}
{gaps.name && (
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
)}
{(gaps.email || gaps.phone) && (
<SimpleGrid cols={gaps.email && gaps.phone ? 2 : 1} spacing="md">
{gaps.email && (
<TextInput
label="Email"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
)}
{gaps.phone && (
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone"
required
/>
)}
</SimpleGrid>
)}
{/* Typed details survive only where Fayda cannot be required —
a foreign company's manager may hold no Fayda ID. Once
verified the API owns these fields, so they go away. */}
{!gmSameAsOwner && !gmVerified && !identity?.faydaRequired && (
<>
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Email"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone"
required
/>
</SimpleGrid>
</>
)}
</>
);
}

View File

@@ -1,217 +0,0 @@
import { Button, Divider, Group, SimpleGrid, Text, TextInput } from "@mantine/core";
import { Trash2 } from "lucide-react";
import type { UseFormReturn } from "react-hook-form";
import { SmartFileInput } from "@edr/ui-common";
import { ControlledPhoneField } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import type { FileUploadSetting } from "@/types/fileUploadSettings";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import type { FormData } from "../schema";
import { LinkCheckboxCard } from "../LinkCheckboxCard";
export interface PoaStepProps {
form: UseFormReturn<FormData>;
identity?: CompanyIdentityState;
/** This company holds a freight-forwarder profile, so the PoA is mandatory. */
requirePoa: boolean;
/** The owner represents the company themselves. */
poaSameAsOwner: boolean;
onTogglePoaSameAsOwner: (checked: boolean) => void;
/** A server-side "same as owner" declaration is in flight. */
poaLinkPending: boolean;
/** eTrade-registered owner, once a TIN lookup has succeeded. */
etradeOwner: { name: string; phone: string } | null;
/**
* Which of the representative's details the Fayda verification did not
* supply, and are therefore typed here. Computed by CompanyProfileForm, which
* requires exactly these in the schema — so every input rendered below is one
* the customer is actually asked to fill.
*/
gaps: { name: boolean; email: boolean; phone: boolean; address: boolean };
/** Drop a verified representative the company decided against. */
onRemovePoa: () => void;
removePending: boolean;
/** The DARS delegation paper is owed (a PoA exists, or the company forwards). */
delegationRequired: boolean;
/** Single-field upload setting carrying just the delegation letter. */
poaDocumentSetting?: FileUploadSetting;
documentFiles: Record<string, File | File[] | null>;
uploadedDocumentKeys?: string[];
documentFieldErrors: Record<string, string>;
onDocumentFilesChange: (next: Record<string, File | File[] | null>) => void;
}
export default function PoaStep({
form,
identity,
requirePoa,
poaSameAsOwner,
onTogglePoaSameAsOwner,
poaLinkPending,
etradeOwner,
gaps,
onRemovePoa,
removePending,
delegationRequired,
poaDocumentSetting,
documentFiles,
uploadedDocumentKeys,
documentFieldErrors,
onDocumentFilesChange,
}: PoaStepProps) {
const {
register,
control,
formState: { errors },
} = form;
// Fayda's email/phone/address claims are optional and routinely come back
// empty, so a *verified* representative can still be missing the email and
// phone the API demands from a freight forwarder (`REQUIRED_POA_FIELDS`) —
// and the panel above renders no input for them, which dead-ends the step on
// "Add the poa email first". `gaps` is exactly what the verification did not
// supply: the API keeps those keys typeable, since a claim that returned
// nothing owns no value to protect (`faydaOwnedKeys`).
const needsEmail = gaps.email;
const needsPhone = gaps.phone;
// Fayda is mandatory for an Ethiopian company's representative, so there the
// link can only reuse a proven owner — with none there would be nothing to
// copy and the declaration could never satisfy the gate. A foreign company's
// owner is backed by a typed passport, so it prefills instead.
const linkNeedsVerifiedOwner =
(identity?.faydaRequired ?? false) && !identity?.owner.verified;
return (
<>
<Text size="sm" c="edr-muted">
{requirePoa
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details are required."
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue."}{" "}
{poaSameAsOwner
? "You represent the company yourself, so no delegation paper is needed."
: "If the representative is someone other than the owner, upload the delegation paper authenticated by DARS."}
</Text>
{/* An owner who represents their own company is the ordinary
small-business case. Where the owner is Fayda-verified this reuses
that proven identity outright rather than sending the same human
through Fayda twice; where they are backed by a typed passport there
is nothing proven to copy, so it stays a local prefill. Either way it
is the declaration that waives the DARS paper. */}
{identity && (
<LinkCheckboxCard
checked={poaSameAsOwner}
onToggle={onTogglePoaSameAsOwner}
disabled={poaLinkPending || (linkNeedsVerifiedOwner && !poaSameAsOwner)}
title={
identity.owner.verified
? "Same as verified owner"
: "Same as business owner"
}
description={
linkNeedsVerifiedOwner
? "Verify the company owner with Fayda first — then you can reuse that identity here."
: identity.owner.verified
? "You represent the company yourself. Reuses the Fayda-verified owner's identity, and no DARS delegation paper is needed. Uncheck to name someone else."
: etradeOwner
? "You represent the company yourself. Reuses the eTrade-registered owner's name plus the company email and phone as you entered them, and no DARS delegation paper is needed. Uncheck to name someone else."
: "You represent the company yourself. Reuses your account's name, email and phone, and no DARS delegation paper is needed. Uncheck to name someone else."
}
/>
)}
{/* A representative acts for the company inside Ethiopia
whoever owns it, so the PoA is proven with Fayda regardless of
nationality — their name, email, phone and address all come
from the verification and are never typed here. Verifying a second
person is only meaningful when the representative is not the owner. */}
{identity && !poaSameAsOwner && (
<FaydaVerifyPanel
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={requirePoa}
disabled={poaLinkPending}
/>
)}
{/* A verification cannot be undone by clearing the form — it owns those
fields — and its mere existence makes the delegation paper due, which
then blocks the submit. So an optional representative needs a way
back out, here rather than only in settings (unreachable until
onboarding finishes). */}
{identity?.poa.verified && !requirePoa && !poaSameAsOwner && (
<Group justify="flex-end">
<Button
type="button"
variant="subtle"
color="red"
size="xs"
loading={removePending}
leftSection={<Trash2 size={14} />}
onClick={onRemovePoa}
>
Remove this representative
</Button>
</Group>
)}
{/* Whatever the Fayda claim did carry is shown on the panel above and
is never typed here — the verification owns it. */}
{gaps.name && (
<TextInput
label="Representative's Name"
placeholder="Abebe Bikila"
error={errors.poaName?.message}
{...register("poaName")}
/>
)}
{(needsEmail || needsPhone) && (
<SimpleGrid cols={needsEmail && needsPhone ? 2 : 1} spacing="md">
{needsEmail && (
<TextInput
label="Representative's Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
)}
{needsPhone && (
<ControlledPhoneField
control={control}
name="poaPhone"
label="Representative's Phone"
/>
)}
</SimpleGrid>
)}
{gaps.address && (
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
)}
{/* The paper authorises the representative, so it shows once one
exists — or straight away for a freight forwarder, who owes it
either way and must not be failed on submit for a file the
step never offered. */}
{delegationRequired && poaDocumentSetting && (
<>
<Divider my="sm" />
<SmartFileInput
file={poaDocumentSetting}
value={documentFiles}
uploadedKeys={uploadedDocumentKeys}
errors={documentFieldErrors}
onChange={onDocumentFilesChange}
/>
</>
)}
</>
);
}

View File

@@ -0,0 +1,363 @@
import {
Button,
Card,
Divider,
Group,
Loader,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { IdCard, ShieldCheck, UserCheck, UserX } from "lucide-react";
import type { UseFormReturn } from "react-hook-form";
import { SmartFileInput } from "@edr/ui-common";
import { ControlledPhoneField } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import RoleCard from "@/pages/settings/RoleCard";
import type { FileUploadSetting } from "@/types/fileUploadSettings";
import type {
CompanyIdentityState,
PoaDeclaration,
} from "@/services/verifayda.service";
import type { FormData } from "../schema";
/** How a foreign company chose to prove the person: Fayda, or a passport. */
export type IdentityMethod = "fayda" | "passport";
export interface RepresentationStepProps {
form: UseFormReturn<FormData>;
identity?: CompanyIdentityState;
/** Answer the power-of-attorney question (persisted server-side). */
onDeclare: (declared: PoaDeclaration) => void;
/** A declaration change is in flight. */
declarePending: boolean;
/**
* The answer is not the company's to change — it operates as a freight
* forwarder, which the API forces to "yes". No alert says so: the summary
* simply offers no way back, which is the same information without the
* lecture.
*/
declarationLocked: boolean;
/**
* Which of the representative's details the Fayda verification owns. Same
* contract as OwnerStep's `locked`: a locked field is read-only, every other
* one is an input, and the schema requires exactly the unlocked ones.
*
* There is deliberately no `address` here. Fayda's address claim is stored as
* `poaAddress`, which the portal never sends; the input below writes
* `poaLocation`, a different field the company states itself. Gating one on
* the other hid the only input for `poaLocation` from every verified
* representative whose Fayda record happened to carry an address.
*/
locked: { name: boolean; email: boolean; phone: boolean };
/**
* How a foreign company is proving the subject. Null until it picks — the
* either/or is a fork, not a fallback, so nothing below it renders until one
* side is chosen. Always "fayda" for an Ethiopian company, which has no
* choice to make.
*/
method: IdentityMethod | null;
onMethodChange: (method: IdentityMethod) => void;
/** Single-field upload setting carrying just the DARS delegation letter. */
poaDocumentSetting?: FileUploadSetting;
documentFiles: Record<string, File | File[] | null>;
uploadedDocumentKeys?: string[];
documentFieldErrors: Record<string, string>;
onDocumentFilesChange: (next: Record<string, File | File[] | null>) => void;
}
/**
* Who acts for this company — and, as a direct consequence, whose identity gets
* verified.
*
* A company proves itself through exactly one person. This step asks which:
* name a Power of Attorney and it is the representative who verifies (plus the
* DARS paper evidencing the delegation); say there is none and the owner
* verifies here instead. There is no third answer — "the owner represents the
* company themselves" IS "no".
*
* A freight forwarder is never asked. It signs on other companies' behalf, so a
* representative and the paper behind them are non-negotiable; the API forces
* the answer regardless of what the portal sends.
*/
export default function RepresentationStep({
form,
identity,
onDeclare,
declarePending,
declarationLocked,
locked,
method,
onMethodChange,
poaDocumentSetting,
documentFiles,
uploadedDocumentKeys,
documentFieldErrors,
onDocumentFilesChange,
}: RepresentationStepProps) {
const {
register,
control,
formState: { errors },
} = form;
if (!identity) {
return (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
);
}
const declared = identity.poaDeclared;
const passportAccepted = identity.passportAccepted;
const subject = declared === "yes" ? identity.poa : identity.owner;
const verified = subject.verified;
// Nothing below the fork renders until the person is actually established:
// a Fayda claim that came back, or the passport path deliberately chosen.
// Asking for a name before the verification runs is asking for a value the
// verification is about to overwrite.
const established = verified || method === "passport";
const who = declared === "yes" ? "Representative" : "Owner";
const passportField =
declared === "yes" ? "poaPassportNumber" : "ownerPassportNumber";
return (
<Stack gap="lg">
{/* ------------------------------------------------------------------ */}
{/* The question. Once answered it collapses to its answer, so the step */}
{/* is about the person rather than re-presenting a settled choice. */}
{/* ------------------------------------------------------------------ */}
{declared === null ? (
<>
<Stack gap="xs">
<Text fw={600} size="lg" c="edr-text">
Does anyone hold power of attorney for this company?
</Text>
<Text size="sm" c="edr-muted">
Your answer decides whose identity we verify the
representative's, or the owner's.
</Text>
</Stack>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<RoleCard
label="Yes, we have a representative"
description="Someone other than the owner is authorised to act for the company. We'll verify their identity and ask for the DARS delegation paper."
icon={<UserCheck size={20} />}
selected={false}
onClick={declarePending ? undefined : () => onDeclare("yes")}
/>
<RoleCard
label="No, the owner acts for us"
description="Nobody holds power of attorney. We'll verify the owner instead, and no delegation paper is needed."
icon={<UserX size={20} />}
selected={false}
onClick={declarePending ? undefined : () => onDeclare("no")}
/>
</SimpleGrid>
</>
) : (
<ChoiceSummary
icon={declared === "yes" ? <UserCheck size={18} /> : <UserX size={18} />}
label={
declared === "yes"
? "A representative holds power of attorney"
: "The owner acts for the company"
}
detail={
declared === "yes"
? "We'll verify their identity and ask for the DARS delegation paper."
: "Nobody holds power of attorney, so we verify the owner."
}
onChange={
declarePending || declarationLocked
? undefined
: () => onDeclare(declared === "yes" ? "no" : "yes")
}
changeLabel={declared === "yes" ? "We have no representative" : "We have a representative"}
/>
)}
{declared !== null && (
<>
<Divider />
{/* -------------------------------------------------------------- */}
{/* How the person is proved. Ethiopian: Fayda, no choice. Foreign: */}
{/* Fayda or a passport — one or the other, picked outright. */}
{/* -------------------------------------------------------------- */}
{passportAccepted && !verified && method === null ? (
<Stack gap="xs">
<Text fw={600} c="edr-text">
How would you like to prove {who.toLowerCase()}'s identity?
</Text>
<Text size="sm" c="edr-muted">
Either one is enough — you don't need both.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mt="xs">
<RoleCard
label="Verify with Fayda"
description="Their name, phone and address come straight from the national ID — nothing to type."
icon={<ShieldCheck size={20} />}
selected={false}
onClick={() => onMethodChange("fayda")}
/>
<RoleCard
label="Use a passport instead"
description="For someone who holds no Fayda ID. You'll enter their passport number and details yourself."
icon={<IdCard size={20} />}
selected={false}
onClick={() => onMethodChange("passport")}
/>
</SimpleGrid>
</Stack>
) : (
<>
{(method === "fayda" || !passportAccepted || verified) && (
<FaydaVerifyPanel
subject={declared === "yes" ? "poa" : "owner"}
title={declared === "yes" ? "Power of Attorney" : "Company owner"}
state={subject}
required={!passportAccepted}
/>
)}
{passportAccepted && !verified && method === "passport" && (
<TextInput
label={`${who}'s Passport Number`}
description="Fayda is an Ethiopian national ID, so a passport number proves this person instead."
placeholder="P1234567"
error={errors[passportField]?.message}
{...register(passportField)}
/>
)}
{passportAccepted && !verified && method !== null && (
<Button
variant="subtle"
size="compact-xs"
style={{ alignSelf: "flex-start" }}
onClick={() =>
onMethodChange(method === "fayda" ? "passport" : "fayda")
}
>
{method === "fayda"
? "Use a passport instead"
: "Verify with Fayda instead"}
</Button>
)}
</>
)}
{/* -------------------------------------------------------------- */}
{/* The representative's own details — only once the person exists, */}
{/* and only the parts the verification did not carry. What Fayda */}
{/* supplied is on the card above; echoing it back here as read-only */}
{/* rows was the same data twice with nothing to do about either. */}
{/* -------------------------------------------------------------- */}
{declared === "yes" && established && (
<>
{!locked.name && (
<TextInput
label="Representative's Name"
placeholder="Abebe Bikila"
error={errors.poaName?.message}
{...register("poaName")}
/>
)}
{(!locked.email || !locked.phone) && (
<SimpleGrid
cols={{ base: 1, sm: !locked.email && !locked.phone ? 2 : 1 }}
spacing="md"
>
{!locked.email && (
<TextInput
label="Representative's Email"
type="email"
placeholder="representative@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
)}
{!locked.phone && (
<ControlledPhoneField
control={control}
name="poaPhone"
label="Representative's Phone"
/>
)}
</SimpleGrid>
)}
<TextInput
label="Representative's Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
{poaDocumentSetting && (
<>
<Divider my="sm" />
<SmartFileInput
file={poaDocumentSetting}
value={documentFiles}
uploadedKeys={uploadedDocumentKeys}
errors={documentFieldErrors}
onChange={onDocumentFilesChange}
/>
</>
)}
</>
)}
</>
)}
</Stack>
);
}
/** A settled choice, shown as its answer with a way back to the question. */
function ChoiceSummary({
icon,
label,
detail,
onChange,
changeLabel,
}: {
icon: React.ReactNode;
label: string;
detail: string;
onChange?: () => void;
changeLabel: string;
}) {
return (
<Card padding="md" radius="md" withBorder>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" align="flex-start" wrap="nowrap">
<span style={{ display: "flex", marginTop: 2 }}>{icon}</span>
<Stack gap={2}>
<Text fw={600} size="sm" c="edr-text">
{label}
</Text>
<Text size="xs" c="edr-muted">
{detail}
</Text>
</Stack>
</Group>
{onChange && (
<Button variant="subtle" size="compact-xs" onClick={onChange}>
{changeLabel}
</Button>
)}
</Group>
</Card>
);
}

View File

@@ -9,6 +9,11 @@ interface OnboardingRoleSelectProps {
onChange: (next: string[]) => void; onChange: (next: string[]) => void;
/** Render only the option grid — the wizard supplies its own header/card. */ /** Render only the option grid — the wizard supplies its own header/card. */
embedded?: boolean; 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, value,
onChange, onChange,
embedded = false, embedded = false,
excludeTypes,
}: OnboardingRoleSelectProps) { }: OnboardingRoleSelectProps) {
const selected = new Set(value); const selected = new Set(value);
const roles = excludeTypes?.length
? CUSTOMER_ROLES.filter((r) => !excludeTypes.includes(r.type))
: CUSTOMER_ROLES;
const toggleRole = (type: string) => { const toggleRole = (type: string) => {
const next = new Set(value); const next = new Set(value);
@@ -34,7 +43,7 @@ export default function OnboardingRoleSelect({
const grid = ( const grid = (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md"> <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{CUSTOMER_ROLES.map((role) => ( {roles.map((role) => (
<RoleCard <RoleCard
key={role.type} key={role.type}
label={role.label} label={role.label}

View File

@@ -24,14 +24,12 @@ import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import type { CompanyRegistrationData } from "@edr/types"; import type { CompanyRegistrationData } from "@edr/types";
import OnboardingRoleSelect from "./OnboardingRoleSelect"; import OnboardingRoleSelect from "./OnboardingRoleSelect";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import ETradeInfo, { import ETradeInfo, {
type ETradeStatus, type ETradeStatus,
} from "@/components/onboarding/ETradeInfo"; } from "@/components/onboarding/ETradeInfo";
import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField"; import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField";
import StepSection from "@/pages/accounts/companyProfileForm/StepSection"; import StepSection from "@/pages/accounts/companyProfileForm/StepSection";
import { ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS } from "@/pages/accounts/companyProfileForm/schema"; 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({ export const COMPANY_PROFILE_SCHEMA = z.object({
// eTrade-sourced and read-only, like the registration block below. // 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 // 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 // number), which neither this form's E.164 validation nor the API's
// `@IsValidPhone()` accepts. Normalize on read — same as the wizard. // `@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 // companyAddress is composed from the (locked) eTrade address parts, not
// typed directly. // typed directly.
@@ -287,13 +280,6 @@ export default function TabCompanyProfile({
validationError ?? validationError ??
(mutation.isError ? extractApiError(mutation.error).message : null); (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 // During onboarding the role selection gates the form: nothing else shows
// until the user picks Importer/Exporter or Freight Forwarder. // until the user picks Importer/Exporter or Freight Forwarder.
@@ -335,51 +321,9 @@ export default function TabCompanyProfile({
/> />
</StepSection> </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 <StepSection
index={3} index={2}
title="Company TIN" title="Company TIN"
subtitle="Re-verify with eTrade to refresh your registration record — nothing here is typed by hand." subtitle="Re-verify with eTrade to refresh your registration record — nothing here is typed by hand."
status={ status={

View File

@@ -1,333 +0,0 @@
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Briefcase, CheckCircle2, Save, XCircle } from "lucide-react";
import {
Alert,
Card,
Group,
Stack,
Title,
Text,
TextInput,
Button,
Grid,
} from "@mantine/core";
import { api } from "@/services/api";
import { verifaydaService } from "@/services/verifayda.service";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import type { ProfileResponse } from "@/types/profile";
// Optional, not unrequired: an Ethiopian company's GM is established through
// Fayda and never types these, so a blanket `min(1)` would fail a form that is
// correct. Presence is gated below, where the identity state says which route
// applies; zod only polices format for the companies that still type them.
const schema = z.object({
generalManagerName: z.string().optional(),
generalManagerEmail: z
.string()
.optional()
.refine(
(v) => !v || z.string().email().safeParse(v).success,
"Invalid GM email",
),
generalManagerPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
});
type FormData = z.infer<typeof schema>;
interface TabGeneralManagerProps {
profile: ProfileResponse;
mode?: "edit" | "onboarding";
onContinue?: () => void;
}
/**
* The general manager's identity comes from Fayda: either verified in their
* own right, or declared to be the owner — very often the same human, which is
* what "Same as owner" is for. Typed details survive only for a foreign
* company, whose manager may hold no Fayda ID at all.
*/
export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) {
const queryClient = useQueryClient();
const owner = profile.identity?.owner;
const identity = profile.identity;
const gm = identity?.gm;
const faydaRequired = identity?.faydaRequired ?? false;
const [gmSameAsOwner, setGmSameAsOwner] = useState(
identity?.gmSameAsOwner ?? false,
);
const defaultValues = useMemo((): FormData => {
return {
generalManagerName: profile.generalManagerName ?? "",
generalManagerEmail: profile.generalManagerEmail ?? "",
generalManagerPhone: profile.generalManagerPhone ?? "",
};
}, [profile]);
const {
register,
control,
handleSubmit,
reset,
setValue,
formState: { errors, isDirty },
} = useForm<FormData>({
resolver: zodResolver(schema),
values: defaultValues,
});
/**
* With a Fayda-verified owner the declaration is made server-side — the API
* copies the proven identity onto the GM — so nothing is typed here. Without
* one (a foreign company, whose owner is backed by a passport) there is
* nothing proven to copy and this stays a local prefill.
*/
const [linkPending, setLinkPending] = useState(false);
const [linkError, setLinkError] = useState<string | null>(null);
const toggleGmSameAsOwner = async (checked: boolean) => {
setGmSameAsOwner(checked);
setLinkError(null);
if (!owner?.verified) {
if (checked && owner) {
setValue("generalManagerName", owner.name ?? "", { shouldValidate: true });
setValue("generalManagerEmail", owner.email ?? "", { shouldValidate: true });
setValue("generalManagerPhone", owner.phone ?? "", { shouldValidate: true });
}
return;
}
setLinkPending(true);
try {
if (checked) await verifaydaService.setGmSameAsOwner();
else await verifaydaService.clearGmIdentity();
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
} catch (err) {
setGmSameAsOwner(!checked);
setLinkError(
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
(err instanceof Error ? err.message : "Could not update the general manager"),
);
} finally {
setLinkPending(false);
}
};
const mutation = useMutation({
mutationFn: (data: FormData) =>
api.companies.updateProfile.call({
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null
// and undefined, so an empty string is validated and 400s with
// "generalManagerEmail must be an email". A verified manager legitimately
// leaves the fields Fayda did supply blank here.
generalManagerName: data.generalManagerName || undefined,
generalManagerEmail: data.generalManagerEmail || undefined,
generalManagerPhone: data.generalManagerPhone || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
if (mode === "onboarding") onContinue?.();
},
});
const onSubmit = (data: FormData) => mutation.mutate(data);
// Nothing to save when Fayda owns the details: the verification and the
// "same as owner" declaration both write server-side, so the form would be
// posting empty strings over a proven identity.
const typedFieldsInUse = !gmSameAsOwner && !gm?.verified && !faydaRequired;
// Except for what the verification never supplied. Fayda's name, email and
// phone claims are all optional, and a manager verified without them has no
// account to fall back on the way the owner does — so those stay typed, here
// as well as in onboarding, or a wrong value could never be corrected.
const gmGaps = {
name: !gmSameAsOwner && Boolean(gm?.verified) && !gm?.name?.trim(),
email: !gmSameAsOwner && Boolean(gm?.verified) && !gm?.email?.trim(),
phone: !gmSameAsOwner && Boolean(gm?.verified) && !gm?.phone?.trim(),
};
const savable =
typedFieldsInUse || gmGaps.name || gmGaps.email || gmGaps.phone;
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Briefcase size={20} />
<Title order={3}>General Manager</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
Manage the general manager information
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<LinkCheckboxCard
checked={gmSameAsOwner}
onToggle={toggleGmSameAsOwner}
title={
owner?.verified ? "Same as verified owner" : "Same as business owner"
}
description={
owner?.verified
? "Reuse the Fayda-verified owner's identity for the general manager. Uncheck to verify a different person."
: "Reuse the owner's name, email and phone. Uncheck to enter different details."
}
/>
{linkError && (
<Alert color="red" variant="light" icon={<XCircle size={18} />}>
{linkError}
</Alert>
)}
{/* Verifying a second person only means something when the manager
is someone other than the owner. */}
{!gmSameAsOwner && gm && (
<FaydaVerifyPanel
subject="gm"
title="General Manager"
state={gm}
required={faydaRequired}
disabled={linkPending || mutation.isPending}
/>
)}
{/* Whatever the verification did not supply is typed instead — the
API keeps exactly those keys writable. */}
{gmGaps.name && (
<TextInput
label="Full Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
)}
{(gmGaps.email || gmGaps.phone) && (
<Grid>
{gmGaps.email && (
<Grid.Col span={gmGaps.phone ? 6 : 12}>
<TextInput
label="Email Address"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
</Grid.Col>
)}
{gmGaps.phone && (
<Grid.Col span={gmGaps.email ? 6 : 12}>
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone Number"
required
/>
</Grid.Col>
)}
</Grid>
)}
{/* Typed details survive only where Fayda cannot be required — a
foreign company's manager may hold no Fayda ID. Once verified the
API owns these fields and refuses edits, so they go away. */}
{!gmSameAsOwner && !gm?.verified && !faydaRequired && (
<>
<TextInput
label="Full Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
<Grid>
<Grid.Col span={6}>
<TextInput
label="Email Address"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
</Grid.Col>
<Grid.Col span={6}>
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone Number"
required
/>
</Grid.Col>
</Grid>
</>
)}
</Stack>
<Group
justify="space-between"
mt="xl"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{mutation.isSuccess && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>Saved successfully</Text>
</Group>
)}
{mutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>Save failed</Text>
</Group>
)}
</Group>
<Group gap="md">
{mode === "edit" && typedFieldsInUse && (
<Button
type="button"
variant="outline"
disabled={mutation.isPending || !isDirty}
onClick={() => reset()}
>
Reset
</Button>
)}
{/* Saving only means something while the details are typed: under
Fayda both routes write server-side, so a submit would post
empty strings at an identity the API owns and refuses to
overwrite. Onboarding still needs a way forward, so the button
becomes a plain Continue rather than disappearing. */}
{savable ? (
<Button
type="submit"
leftSection={<Save size={16} />}
loading={mutation.isPending}
>
{mode === "onboarding" ? "Continue" : "Save Changes"}
</Button>
) : (
mode === "onboarding" && (
<Button type="button" onClick={() => onContinue?.()}>
Continue
</Button>
)
)}
</Group>
</Group>
</form>
</Card>
);
}

View File

@@ -0,0 +1,219 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { AlertTriangle, Briefcase, Save } from "lucide-react";
import {
Alert,
Button,
Card,
Grid,
Group,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { api } from "@/services/api";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import SourcedField from "@/pages/accounts/companyProfileForm/SourcedField";
import type { ProfileResponse } from "@/types/profile";
// Optional, not unrequired: whatever a Fayda verification supplied is owned by
// the API and never typed here, so a blanket `min(1)` would fail a form that is
// correct. Presence is gated below, where the identity state says which fields
// are actually on screen; zod only polices format.
const schema = z.object({
ownerName: z.string().optional(),
ownerEmail: z
.string()
.optional()
.refine(
(v) => !v || z.string().email().safeParse(v).success,
"Invalid owner email",
),
ownerPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
});
type FormData = z.infer<typeof schema>;
interface TabOwnerProps {
profile: ProfileResponse;
mode?: "edit" | "onboarding";
onContinue?: () => void;
}
/**
* The company's owner — meaning whoever the eTrade licence names as the
* business's manager. Not necessarily the legal owner, but the person the
* record has to match: the backoffice's check is that comparison.
*
* Their identity is Fayda-verified only when the company has NO Power of
* Attorney; when it names a representative it is the representative who
* verifies, and the owner's details are simply recorded (from eTrade, or typed
* here). Either way all three are required.
*/
export default function TabOwner({
profile,
mode = "edit",
onContinue,
}: TabOwnerProps) {
const queryClient = useQueryClient();
const identity = profile.identity;
const owner = identity?.owner;
// Only the person the declaration points at carries the verification, so the
// panel is offered here only when that person is the owner.
const ownerIsSubject = identity?.subject === "owner";
// A Fayda verification owns what its claims filled — the API refuses to
// overwrite those, so they show read-only. Anything it left blank stays
// editable here, whatever value is currently stored.
const ownerVerified = owner?.verified ?? false;
const ownerLocked = {
name: ownerVerified && Boolean(owner?.name?.trim()),
email: ownerVerified && Boolean(owner?.email?.trim()),
phone: ownerVerified && Boolean(owner?.phone?.trim()),
};
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
ownerName: profile.ownerName ?? "",
ownerEmail: profile.ownerEmail ?? "",
ownerPhone: profile.ownerPhone ?? "",
},
});
const mutation = useMutation({
mutationFn: (data: FormData) =>
api.companies.updateProfile.call({
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null
// and undefined, so an empty string is validated and 400s with
// "ownerEmail must be an email". A verified owner legitimately leaves
// the fields Fayda did supply blank here.
ownerName: data.ownerName || undefined,
ownerEmail: data.ownerEmail || undefined,
ownerPhone: data.ownerPhone || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
if (mode === "onboarding") onContinue?.();
},
});
// What the verification did NOT supply. Fayda's email and phone claims are
// optional, so a verified owner can still be missing details the API demands
// — the API leaves exactly those keys typeable, and so does this form.
const gaps = {
name: !owner?.name?.trim(),
email: !owner?.email?.trim(),
phone: !owner?.phone?.trim(),
};
const savable = gaps.name || gaps.email || gaps.phone;
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Briefcase size={20} />
<Title order={3}>Company Owner</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
The person registered on your eTrade licence.
</Text>
<form onSubmit={handleSubmit((data) => mutation.mutate(data))}>
<Stack gap="md">
{identity?.ownerMatchesEtrade === false && (
<Alert
color="amber"
variant="light"
icon={<AlertTriangle size={18} />}
title="This doesn't match your eTrade licence"
>
Your licence lists <strong>{identity.etradeManagerName}</strong>.
Our team checks this before approving changes.
</Alert>
)}
{ownerIsSubject && owner && (
<FaydaVerifyPanel
subject="owner"
title="Owner identity"
state={owner}
required={!identity?.passportAccepted}
pendingReview={profile.reviewStatus === "pending"}
/>
)}
<SourcedField
label="Name"
value={owner?.name}
source={ownerLocked.name ? "Fayda" : null}
>
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.ownerName?.message}
{...register("ownerName")}
/>
</SourcedField>
<Grid>
<Grid.Col span={{ base: 12, sm: 6 }}>
<SourcedField
label="Email"
value={owner?.email}
source={ownerLocked.email ? "Fayda" : null}
>
<TextInput
label="Email"
type="email"
placeholder="owner@company.com"
error={errors.ownerEmail?.message}
{...register("ownerEmail")}
/>
</SourcedField>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<SourcedField
label="Phone"
value={owner?.phone}
source={ownerLocked.phone ? "Fayda" : null}
>
<ControlledPhoneField
control={control}
name="ownerPhone"
label="Phone"
/>
</SourcedField>
</Grid.Col>
</Grid>
{savable && (
<Group justify="flex-end">
<Button
type="submit"
color="edr-green"
loading={mutation.isPending}
leftSection={<Save size={16} />}
>
{mode === "onboarding" ? "Save & continue" : "Save changes"}
</Button>
</Group>
)}
</Stack>
</form>
</Card>
);
}

View File

@@ -13,6 +13,7 @@ import {
Undo2, Undo2,
UploadCloud, UploadCloud,
UserCheck, UserCheck,
UserX,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { import {
@@ -24,6 +25,7 @@ import {
Card, Card,
Group, Group,
Loader, Loader,
SimpleGrid,
Stack, Stack,
Title, Title,
Text, Text,
@@ -41,7 +43,7 @@ import {
} from "@/services/companies.service"; } from "@/services/companies.service";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import { verifaydaService } from "@/services/verifayda.service"; 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"; import type { ProfileResponse } from "@/types/profile";
// The representative's name, email, phone and address all come from their // 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 // company inside Ethiopia either way. A PoA therefore exists exactly when one
// has been verified. // has been verified.
const identity = profile.identity; const identity = profile.identity;
const owner = identity?.owner;
const poaProvided = identity?.poa.verified ?? false; const poaProvided = identity?.poa.verified ?? false;
const [poaSameAsOwner, setPoaSameAsOwner] = useState( // Whether there is a representative at all is the company's own declaration,
identity?.poaSameAsOwner ?? false, // held server-side — it decides whose identity the API gates on, so it is
); // never local state here.
// The paper authorises the representative named above, so there is nothing const declared = identity?.poaDeclared ?? null;
// for it to authorise until one has been verified — the upload is hidden // The paper is owed exactly when the company says it has a representative —
// until then, and requiring it while hidden would block the save on a // the same single rule `assertPoaDelegationSatisfied` enforces. Keying it on
// control the customer cannot see. A freight forwarder is still held to // the verification instead would hide the upload from a foreign company whose
// having a PoA at all, by the verification gate on the panel and by the API. // representative proves themselves by passport, then fail the save for a file
// // that was never offered.
// And nobody delegates to themselves: an owner representing their own company const letterRequired = declared === "yes";
// has no delegation to evidence, which is the same waiver the API applies in
// `assertPoaDelegationSatisfied`.
const letterRequired = poaProvided && !poaSameAsOwner;
const letterMissing = letterRequired && !hasLetterAfterSave; const letterMissing = letterRequired && !hasLetterAfterSave;
const fileDirty = Boolean(pickedFile) || removeIds.length > 0; 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 * A verified representative cannot be removed by blanking the form — their
* fields are owned by the verification — so removal is its own action that * fields are owned by the verification — so removal is answering the
* clears the identity and the delegation paper together. * declaration "no", which clears the identity, the details and the
* delegation paper together. Refused by the API for a freight forwarder.
*/ */
const removeMutation = useMutation({ const removeMutation = useMutation({
mutationFn: () => verifaydaService.removePoa(), mutationFn: () => verifaydaService.setPoaDeclared("no"),
onSuccess: () => { onSuccess: () => {
resetAll(); resetAll();
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -208,25 +207,20 @@ export default function TabPowerOfAttorney({
}); });
/** /**
* "Same as owner": the owner represents the company themselves. Always goes * Answer the power-of-attorney question.
* 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.
* *
* Unchecking undoes the declaration only. It leaves the paper on file and is * "No" means the owner acts for the company themselves there is no
* allowed for a freight forwarder, which is how one changes who represents * delegation, so no DARS paper is owed and it is the OWNER whose identity is
* it; "Remove representative" below is the harder action that takes the paper * verified. The API tears the representative down when this is answered, and
* with it and is refused to a forwarder. * refuses "no" outright for a freight forwarder.
*/ */
const [linkPending, setLinkPending] = useState(false); const [linkPending, setLinkPending] = useState(false);
const [linkError, setLinkError] = useState<string | null>(null); const [linkError, setLinkError] = useState<string | null>(null);
const togglePoaSameAsOwner = async (checked: boolean) => { const declare = async (next: "yes" | "no") => {
setPoaSameAsOwner(checked);
setLinkError(null); setLinkError(null);
setLinkPending(true); setLinkPending(true);
try { try {
if (checked) await verifaydaService.setPoaSameAsOwner(); await verifaydaService.setPoaDeclared(next);
else await verifaydaService.clearPoaSameAsOwner();
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(), queryKey: api.companies.getProfile.queryKey(),
}); });
@@ -234,7 +228,6 @@ export default function TabPowerOfAttorney({
queryKey: api.companies.poaDelegation.queryKey(), queryKey: api.companies.poaDelegation.queryKey(),
}); });
} catch (err) { } catch (err) {
setPoaSameAsOwner(!checked);
setLinkError( setLinkError(
(err as { response?: { data?: { message?: string } } })?.response?.data (err as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? ?.message ??
@@ -295,12 +288,14 @@ export default function TabPowerOfAttorney({
)} )}
</Group> </Group>
<Text c="edr-muted" size="sm" mb="lg"> <Text c="edr-muted" size="sm" mb="lg">
{requirePoa {/* The "Required for freight forwarder" badge above already says why
? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney is required." a forwarder has no choice here; repeating it in prose was a
: "Power of Attorney details are optional."}{" "} lecture, not information. */}
{poaSameAsOwner {!requirePoa &&
? "You represent the company yourself, so no delegation paper is needed." "Tell us whether anyone is authorised to act for the company your answer decides whose identity we verify. "}
: "If you name a representative, upload the DARS delegation paper authorising them."} {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> </Text>
{/* The owner representing their own company is the ordinary {/* 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 mandatory it needs a verified owner first — there would be nothing
proven to copy, and a representative who could never satisfy the proven to copy, and a representative who could never satisfy the
gate. */} 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 && ( {identity && (
<LinkCheckboxCard <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mb="md">
checked={poaSameAsOwner} <RoleCard
onToggle={togglePoaSameAsOwner} label="Yes, we have a representative"
disabled={ description="Someone other than the owner is authorised to act for the company. Their identity is verified and the DARS delegation paper is required."
linkPending || icon={<UserCheck size={20} />}
mutation.isPending || selected={declared === "yes"}
(!poaSameAsOwner && onClick={
(identity.faydaRequired ?? false) && linkPending || declared === "yes"
!owner?.verified) ? undefined
} : () => void declare("yes")
title={ }
owner?.verified />
? "Same as verified owner" <RoleCard
: "Same as business owner" 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."
description={ icon={<UserX size={20} />}
(identity.faydaRequired ?? false) && !owner?.verified selected={declared === "no"}
? "Verify the company owner with Fayda first — then you can reuse that identity here." onClick={
: owner?.verified linkPending || declared === "no"
? "You represent the company yourself. Reuses the Fayda-verified owner's identity, and no DARS delegation paper is needed. Uncheck to name someone else." ? undefined
: "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." : () => void declare("no")
} }
/> />
</SimpleGrid>
)} )}
{linkError && ( {linkError && (
@@ -343,12 +343,12 @@ export default function TabPowerOfAttorney({
{/* Verifying a second person only means something when the {/* Verifying a second person only means something when the
representative is someone other than the owner. */} representative is someone other than the owner. */}
{identity && !poaSameAsOwner && ( {identity && declared === "yes" && (
<FaydaVerifyPanel <FaydaVerifyPanel
subject="poa" subject="poa"
title="Power of Attorney" title="Power of Attorney"
state={identity.poa} state={identity.poa}
required={requirePoa} required={!identity.passportAccepted}
disabled={mutation.isPending || linkPending} disabled={mutation.isPending || linkPending}
/> />
)} )}
@@ -359,7 +359,7 @@ export default function TabPowerOfAttorney({
verification and are shown on the panel above. Only a company verification and are shown on the panel above. Only a company
whose representative may hold no Fayda ID still types a whose representative may hold no Fayda ID still types a
location. */} location. */}
{!poaProvided && !(identity?.faydaRequired ?? false) && ( {declared === "yes" && !poaProvided && (
<Grid> <Grid>
<Grid.Col span={6}> <Grid.Col span={6}>
<TextInput <TextInput
@@ -376,7 +376,7 @@ export default function TabPowerOfAttorney({
{/* ------------------------ Delegation letter ------------------------ */} {/* ------------------------ Delegation letter ------------------------ */}
{/* The paper authorises the representative the verification named, {/* The paper authorises the representative the verification named,
so it only has meaning once one exists. */} so it only has meaning once one exists. */}
{poaProvided && !poaSameAsOwner && ( {declared === "yes" && (
<Stack gap="sm" mt="xl"> <Stack gap="sm" mt="xl">
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Group gap="sm"> <Group gap="sm">
@@ -418,7 +418,7 @@ export default function TabPowerOfAttorney({
> >
{requirePoa {requirePoa
? "Upload the DARS delegation paper before saving — it is required for freight forwarders." ? "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> </Alert>
)} )}
@@ -551,12 +551,12 @@ export default function TabPowerOfAttorney({
)} )}
</Group> </Group>
<Group gap="md"> <Group gap="md">
{/* Not offered against a "same as owner" declaration: unchecking {/* Answering "the owner acts for us" is the same teardown, so
the card above is the way out of that one, and it leaves the this is only a shortcut — and it is refused to a forwarder,
paper alone. */} which cannot be without a representative. */}
{mode === "edit" && {mode === "edit" &&
identity?.poa.verified && identity?.poa.verified &&
!poaSameAsOwner && declared === "yes" &&
!requirePoa && ( !requirePoa && (
<Button <Button
type="button" type="button"

View File

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

View File

@@ -158,14 +158,17 @@ export interface OnboardingLicenseProfile {
uploaded: boolean; 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 { export interface OnboardingPoaState {
required: boolean;
provided: boolean;
/** /**
* True when the DARS delegation paper is owed. False when the owner * True for a freight forwarder: it signs on other companies' behalf, so a
* represents the company themselves — nobody delegates to themselves. * 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; delegationLetterRequired: boolean;
delegationLetterUploaded: boolean; delegationLetterUploaded: boolean;
/** True when a reviewer sent the DARS delegation paper back for correction. */ /** True when a reviewer sent the DARS delegation paper back for correction. */
@@ -181,7 +184,16 @@ export interface OnboardingPoaState {
*/ */
export interface OnboardingRequirements { export interface OnboardingRequirements {
documentSettingCode: string; 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; nationality: string;
/** No business licence: registration typed by hand, no eTrade lookup. */
cooperative: boolean;
companyInfo: { companyInfo: {
complete: boolean; complete: boolean;
missingFields: { key: string; label: string }[]; missingFields: { key: string; label: string }[];
@@ -189,7 +201,7 @@ export interface OnboardingRequirements {
documents: OnboardingDocumentField[]; documents: OnboardingDocumentField[];
licenseProfiles: OnboardingLicenseProfile[]; licenseProfiles: OnboardingLicenseProfile[];
poa: OnboardingPoaState; poa: OnboardingPoaState;
/** Fayda verification state; `required` is false for a foreign company. */ /** The company's single identity verification, and whose it is. */
identity: CompanyIdentityState; identity: CompanyIdentityState;
progress: { completed: number; total: number }; progress: { completed: number; total: number };
isComplete: boolean; isComplete: boolean;
@@ -321,6 +333,7 @@ export const companiesService = {
companyType: string; companyType: string;
roles: ProfileTypeValue[]; roles: ProfileTypeValue[];
nationality?: CompanyNationality; nationality?: CompanyNationality;
cooperative?: boolean;
}): Promise<CompanyInfoResponse> => { }): Promise<CompanyInfoResponse> => {
const response = await client.post<ApiResponse<CompanyInfoResponse>>( const response = await client.post<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.ONBOARDING_START, URL_CONSTANTS.COMPANIES_API.ONBOARDING_START,

View File

@@ -3,14 +3,21 @@ import { unwrap } from "@/utils/endpoint";
import type { ApiResponse } from "@/types/apiResponse"; import type { ApiResponse } from "@/types/apiResponse";
/** /**
* Which of the company's people a verification is for. The owner is who the * Which of the company's two people a verification is for.
* 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 * The **owner** is whoever the eTrade licence names as the business's manager
* making the same human prove themselves twice. * — 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 { export interface IdentityVerificationState {
verified: boolean; verified: boolean;
name: string | null; name: string | null;
@@ -18,43 +25,49 @@ export interface IdentityVerificationState {
email: string | null; email: string | null;
address: string | null; address: string | null;
verifiedAt: string | null; verifiedAt: string | null;
}
export interface OwnerIdentityState extends IdentityVerificationState {
/** /**
* Typed passport number — the foreign-company identity credential. * Typed passport number — the ALTERNATIVE to Fayda for a foreign company,
* Independent of Fayda: never written by a verification, and still required * never written by a verification. Only asked of whichever person carries
* even if the owner also verifies. * the company's identity, and only when `passportAccepted`.
*/ */
passportNumber: string | null; passportNumber: string | null;
} }
export interface CompanyIdentityState { export interface CompanyIdentityState {
/** /**
* True when Fayda verification is mandatory — Ethiopian companies only. * True for a foreign company: a typed passport number proves the identity
* Doubles as "may this person be typed instead": Fayda is an Ethiopian * just as a Fayda verification does. Fayda is an Ethiopian national ID, so an
* national ID, so a foreign company's GM and PoA are offered the * Ethiopian company has no alternative to it.
* verification but fall back to typed details when they hold none.
*/ */
faydaRequired: boolean; passportAccepted: boolean;
/** True when the owner's passport number is mandatory — foreign companies only. */ /**
passportRequired: boolean; * The company's answer to the power-of-attorney question. Null until it
owner: OwnerIdentityState; * 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; 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 * That manager's phone (E.164), from the same lookup. Together with the name
* "same as owner". Waives the DARS delegation paper — nobody delegates to * this is what survives a refresh: the live lookup result does not, so
* themselves — and, where the owner is Fayda-verified, backs `poa.verified` * without these two a resumed wizard cannot tell an eTrade-sourced owner from
* with the owner's sub. * 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 * Does the owner the company put forward match the eTrade licence? This is
* own right, and the company declaring the GM is the owner (in which case * the backoffice's check. Null when there is nothing to compare. Advisory:
* `gmSameAsOwner` is set and the owner's Fayda sub backs it). * eTrade's and Fayda's transliterations rarely agree exactly.
*/ */
gm: IdentityVerificationState; ownerMatchesEtrade: boolean | null;
gmSameAsOwner: boolean;
complete: boolean; complete: boolean;
} }
@@ -127,65 +140,19 @@ export const verifaydaService = {
}, },
/** /**
* Declare the General Manager is the company's owner, reusing the owner's * Answer whether anyone holds power of attorney for this company — the
* verified identity rather than making the same human verify twice. The copy * question that decides whose identity is verified.
* 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.
* *
* 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> => { setPoaDeclared: async (
const response = await client.post<ApiResponse<CompanyIdentityState>>( declared: PoaDeclaration,
"/api/companies/identity/poa/same-as-owner", ): Promise<CompanyIdentityState> => {
); const response = await client.patch<ApiResponse<CompanyIdentityState>>(
return unwrap(response.data); "/api/companies/identity/poa-declared",
}, { declared },
/**
* 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",
); );
return unwrap(response.data); return unwrap(response.data);
}, },

View File

@@ -6,6 +6,8 @@ export interface ProfileResponse {
companyName: string; companyName: string;
companyType: string; companyType: string;
nationality: string | null; nationality: string | null;
/** No business licence: the registration is typed, not fetched from eTrade. */
cooperative: boolean;
companyProfiles: CompanyProfileResponse[]; companyProfiles: CompanyProfileResponse[];
companyLocation: string; companyLocation: string;
companyAddress: string | null; companyAddress: string | null;
@@ -30,19 +32,15 @@ export interface ProfileResponse {
contactPersonPhone: string | null; contactPersonPhone: string | null;
/** Phone that passed SMS OTP verification (resumes the verify step's state). */ /** Phone that passed SMS OTP verification (resumes the verify step's state). */
contactVerifiedPhone: string | null; contactVerifiedPhone: string | null;
generalManagerName: string | null; /** The owner — whoever the eTrade licence names as the business's manager. */
generalManagerEmail: string | null; ownerName: string | null;
generalManagerPhone: string | null; ownerEmail: string | null;
ownerPhone: string | null;
/** /**
* Fayda verification state for the owner, the PoA and the general manager. * The company's single identity verification. `identity.subject` says whose
* `identity.faydaRequired` / `identity.passportRequired` is the * it is (the PoA when one is declared, otherwise the owner);
* Ethiopian/foreign switch: an Ethiopian company verifies all three with * `identity.passportAccepted` is the Ethiopian/foreign switch — a foreign
* Fayda, while a foreign one proves its owner with a typed passport number * company may prove the same person with a typed passport number instead.
* 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.
*/ */
identity: CompanyIdentityState; identity: CompanyIdentityState;
poaName: string | null; poaName: string | null;
@@ -88,14 +86,18 @@ export interface UpdateProfilePayload {
contactPersonEmail?: string; contactPersonEmail?: string;
contactPersonPhone?: string; contactPersonPhone?: string;
contactVerifiedPhone?: string; contactVerifiedPhone?: string;
generalManagerName?: string; ownerName?: string;
generalManagerEmail?: string; ownerEmail?: string;
generalManagerPhone?: string; ownerPhone?: string;
poaName?: string; poaName?: string;
poaPhone?: string; poaPhone?: string;
poaEmail?: string; poaEmail?: string;
poaLocation?: string; poaLocation?: string;
poaAddress?: 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; ownerPassportNumber?: string;
poaPassportNumber?: string;
} }