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

View File

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

View File

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

View File

@@ -7,7 +7,9 @@ import { PdfRenderService } from "./pdf-render.service";
* Standalone document infrastructure — generic HTML→PDF plus the shared
* invoice/receipt renderer. Has no domain dependencies, so any module (billing,
* warehouses, …) can import it to print invoices without coupling to the
* billing payment graph.
* billing payment graph. StampSettingsService is @Global (see
* StampSettingsModule) so InvoiceDocumentService can inject it without this
* module declaring an explicit import.
*/
@Module({
providers: [PdfRenderService, InvoiceDocumentService],

View File

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

View File

@@ -39,6 +39,7 @@ import {
CompleteIdentityVerificationDto,
} from "./dto/complete-identity-verification.dto";
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
import { SetPoaDeclaredDto } from "./dto/set-poa-declared.dto";
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
import {
@@ -265,6 +266,7 @@ export class CompaniesController {
dto.companyType,
dto.roles,
dto.nationality,
dto.cooperative,
);
return new CompanyInfoResponseDto(profile, company);
}
@@ -415,90 +417,31 @@ export class CompaniesController {
@PortalCustomer()
@ApiOperation({
summary:
"Bind a completed Fayda verification to the company's owner or Power of Attorney. " +
"Bind a completed Fayda verification to the company's single identity. " +
"Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " +
"`subject` must match the company's PoA declaration — the representative when one is named, otherwise the owner. " +
"The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.",
})
async completeIdentityVerification(
@CurrentUser() user: CurrentIamUser,
@Body() dto: CompleteIdentityVerificationDto,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.completeIdentityVerification(user.id, dto, {
email: user.email,
phoneNumber: user.phoneNumber,
});
return this.companiesService.completeIdentityVerification(user.id, dto);
}
@Post("identity/gm/same-as-owner")
@Patch("identity/poa-declared")
@PortalCustomer()
@ApiOperation({
summary:
"Declare the General Manager is the company's owner, copying the owner's verified identity across. " +
"Refused until the owner is Fayda-verified — there would be nothing proven to copy.",
"Answer whether anyone holds power of attorney for this company — the question that decides whose identity is verified. " +
'Answering "no" removes the representative entirely: their details, their verification, their passport number and the DARS delegation paper. ' +
'Refused for a freight forwarder, which cannot operate without a representative (its answer is always "yes").',
})
async setGmSameAsOwner(
async setPoaDeclared(
@CurrentUser() user: CurrentIamUser,
@Body() dto: SetPoaDeclaredDto,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.setGmSameAsOwner(user.id, {
email: user.email,
phoneNumber: user.phoneNumber,
});
}
@Delete("identity/gm")
@PortalCustomer()
@ApiOperation({
summary:
"Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " +
"Leaves the GM open to be verified in their own right, or typed where Fayda is optional.",
})
async clearGmIdentity(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.clearGmIdentity(user.id);
}
@Post("identity/poa/same-as-owner")
@PortalCustomer()
@ApiOperation({
summary:
"Declare the Power of Attorney is the company's owner, copying the owner's identity across. " +
"Waives the DARS delegation paper — nobody delegates to themselves. " +
"Refused for an Ethiopian company whose owner is not Fayda-verified yet: its representative must be verified, and there would be nothing proven to copy.",
})
async setPoaSameAsOwner(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.setPoaSameAsOwner(user.id, {
email: user.email,
phoneNumber: user.phoneNumber,
});
}
@Delete("identity/poa/same-as-owner")
@PortalCustomer()
@ApiOperation({
summary:
"Undo the Power of Attorney \"same as owner\" declaration and the identity it copied, leaving the representative open to be verified in their own right. " +
"Unlike DELETE identity/fayda/poa this is allowed for a freight forwarder — it is how they change who represents them — and leaves the delegation paper on file.",
})
async clearPoaSameAsOwner(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.clearPoaSameAsOwner(user.id);
}
@Delete("identity/fayda/poa")
@PortalCustomer()
@ApiOperation({
summary:
"Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " +
"Refused while the company holds a freight forwarder role, which cannot operate without a representative.",
})
async removePoaIdentity(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.removePoaIdentity(user.id);
return this.companiesService.setPoaDeclared(user.id, dto.declared);
}
@Patch("onboarding-step")

View File

@@ -174,21 +174,26 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
).resolves.toBeDefined();
});
it("waives the paper when the owner represents the company themselves", async () => {
// Nobody delegates to themselves, so a self-declared PoA owes no DARS
// paper — the representative's own details are still required.
it('owes nothing when the company answered "no representative"', async () => {
// "The owner represents the company themselves" is now expressed as the
// declaration being "no" — there is no delegation, so no paper is due. The
// representative's details are cleared with the answer, so there is nothing
// left to evidence either.
const { service } = makeService({
attributes: { ...VERIFIED_IDENTITIES, poaSameAsOwner: true },
attributes: { ...VERIFIED_IDENTITIES, poaDeclared: "no" },
});
await expect(
service.updateProfile("user-1", POA as never),
service.updateProfile("user-1", {} as never),
).resolves.toBeDefined();
});
it("grants the forwarder role to a self-represented company with no paper", async () => {
it("refuses the forwarder role without a paper, however it represents itself", async () => {
// The self-representation waiver is gone: a freight forwarder signs on
// other companies' behalf, so the delegation and the paper evidencing it
// are non-negotiable.
const { service } = makeService({
attributes: { ...VERIFIED_IDENTITIES, ...POA, poaSameAsOwner: true },
attributes: { ...VERIFIED_IDENTITIES, ...POA, poaDeclared: "yes" },
});
await expect(
@@ -196,7 +201,7 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
).rejects.toBeInstanceOf(BadRequestException);
});
it("rejects a paper the reviewer sent back for correction", async () => {

View File

@@ -26,7 +26,13 @@ function makeService(existing: ExistingProfile[]) {
})),
softDelete: jest.fn(async () => undefined),
};
const companiesRepo = { update: jest.fn(async () => null) };
// `findById` is only consulted when the co-operative flag is in play (adding
// a forwarder role, or setting the flag itself) — a plain company row is the
// right answer for every case here.
const companiesRepo = {
update: jest.fn(async () => null),
findById: jest.fn(async () => ({ id: "company-1", attributes: {} })),
};
const profilesRepo = {
findByUserId: jest.fn(async () => ({
id: "external-1",

File diff suppressed because it is too large Load Diff

View File

@@ -23,9 +23,18 @@ export const COMPANY_FIELD_LABELS: Record<string, string> = {
contactPersonPhone: "Contact person phone",
contactPersonEmail: "Contact person email",
contactPersonPosition: "Contact person position",
generalManagerName: "General manager name",
generalManagerPhone: "General manager phone",
generalManagerEmail: "General manager email",
ownerName: "Owner name",
ownerPhone: "Owner phone",
ownerEmail: "Owner email",
ownerPassportNumber: "Owner passport number",
poaPassportNumber: "PoA passport number",
poaDeclared: "Has a Power of Attorney",
// Nothing writes these any more (the general manager was removed), but
// revisions and change requests filed before that still carry them — without
// the labels those rows render raw attribute keys to a reviewer.
generalManagerName: "General manager name (retired)",
generalManagerPhone: "General manager phone (retired)",
generalManagerEmail: "General manager email (retired)",
poaName: "PoA name",
poaPhone: "PoA phone",
poaEmail: "PoA email",

View File

@@ -5,21 +5,61 @@ import { Company, CompanyNationality } from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity";
/**
* The three people a company is verified through — its owner, its Power of
* Attorney and its General Manager. The owner is the person the company's
* existence is proven by; the other two are personnel it names.
* The two people a company can be described through.
*
* The GM is very often the owner, which is what the portal's "same as owner"
* copy is for: that path reuses the owner's verified identity outright rather
* than asking the same human to verify twice.
* The **owner** is whoever the eTrade TIN record names as the business's
* manager. Not necessarily the legal owner — eTrade's `ManagerNameEng` is
* simply the person on the licence — but that is the point: whoever the company
* puts forward here has to match the eTrade record, and the backoffice check is
* exactly that comparison (see `ownerMatchesEtrade`).
*
* The **Power of Attorney** is who the company delegates to act for it, when it
* delegates at all.
*
* Exactly ONE of them is identity-verified, and which one is decided by the
* company's own answer (see {@link PoaDeclaration}): the representative if
* there is one, otherwise the owner. There is no general manager — the concept
* was removed; it named who to talk to and gated nothing.
*/
export const IDENTITY_SUBJECTS = ["owner", "poa", "gm"] as const;
export const IDENTITY_SUBJECTS = ["owner", "poa"] as const;
export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
/**
* The company's answer to "does anyone hold power of attorney for you?".
*
* Explicit rather than derived from "are any `poa*` keys set", because "no" is
* an answer that moves the verification onto the owner, while *absent* is a
* question the customer has not reached yet. Stored on `company.attributes`
* under {@link POA_DECLARED_KEY}.
*
* A freight forwarder never gets to answer: it signs on other companies'
* behalf, so a Power of Attorney (and the DARS paper evidencing it) is
* non-negotiable. {@link readPoaDeclaration} forces "yes" for them, which is
* why the declaration is read through that helper rather than off the blob.
*/
export const POA_DECLARATIONS = ["yes", "no"] as const;
export type PoaDeclaration = (typeof POA_DECLARATIONS)[number];
/** `company.attributes` key holding the {@link PoaDeclaration}. */
export const POA_DECLARED_KEY = "poaDeclared";
/**
* `company.attributes` keys holding the eTrade record's own manager, captured
* at lookup time.
*
* Kept apart from `ownerName`/`ownerPhone` — which are what the *company*
* asserts, and what a Fayda verification overwrites — precisely so the two can
* be compared. Storing only one value would leave the reviewer comparing the
* owner field against itself.
*/
export const ETRADE_MANAGER_NAME_KEY = "etradeManagerName";
export const ETRADE_MANAGER_PHONE_KEY = "etradeManagerPhone";
export class CompleteIdentityVerificationDto {
@ApiProperty({
enum: IDENTITY_SUBJECTS,
description: "Which of the company's people this verification is for.",
description:
"Which of the company's people this verification is for. Must match the company's PoA declaration — the representative when one is named, the owner when not.",
})
@IsIn(IDENTITY_SUBJECTS)
subject!: IdentitySubject;
@@ -35,9 +75,11 @@ export class CompleteIdentityVerificationDto {
state!: string;
}
/** One person's verification state, as reported back to the portal. */
/** One person's identity state, as reported back to the portal. */
export class IdentityVerificationStateDto {
@ApiProperty() verified!: boolean;
@ApiProperty({ description: "True once a Fayda verification is bound." })
verified!: boolean;
@ApiProperty({ nullable: true }) name!: string | null;
@ApiProperty({ nullable: true }) phone!: string | null;
@ApiProperty({ nullable: true }) email!: string | null;
@@ -45,13 +87,11 @@ export class IdentityVerificationStateDto {
@ApiProperty({ nullable: true }) verifiedAt!: string | null;
@ApiProperty({ nullable: true }) birthdate!: string | null;
@ApiProperty({ nullable: true }) gender!: string | null;
}
export class OwnerIdentityStateDto extends IdentityVerificationStateDto {
@ApiProperty({
nullable: true,
description:
"Typed passport number — the foreign-company identity credential. Independent of Fayda: never written by a verification, and still required even if the owner also verifies.",
"Typed passport number. Fayda is an Ethiopian national ID, so a foreign company proves the identity with either — this is the alternative, not an addition.",
})
passportNumber!: string | null;
}
@@ -59,44 +99,55 @@ export class OwnerIdentityStateDto extends IdentityVerificationStateDto {
export class CompanyIdentityStateDto {
@ApiProperty({
description:
"True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.",
"True for a foreign company: a typed passport number proves the identity just as a Fayda verification does. An Ethiopian company must use Fayda.",
})
faydaRequired!: boolean;
passportAccepted!: boolean;
@ApiProperty({
enum: POA_DECLARATIONS,
nullable: true,
description:
"True when the owner's passport number is mandatory — foreign companies only. Independent of faydaRequired: a foreign owner may verify with Fayda too, but the passport is still required.",
'Whether the company named a Power of Attorney. Null until the customer answers — which is itself an outstanding onboarding item, since the answer decides who verifies.',
})
passportRequired!: boolean;
poaDeclared!: PoaDeclaration | null;
@ApiProperty({ type: OwnerIdentityStateDto })
owner!: OwnerIdentityStateDto;
@ApiProperty({
enum: IDENTITY_SUBJECTS,
nullable: true,
description:
"Who the company's single identity verification belongs to: the PoA when one is named, the owner when not. Null while the declaration is unanswered.",
})
subject!: IdentitySubject | null;
@ApiProperty({ type: IdentityVerificationStateDto })
owner!: IdentityVerificationStateDto;
@ApiProperty({ type: IdentityVerificationStateDto })
poa!: IdentityVerificationStateDto;
@ApiProperty({
description:
"True when the Power of Attorney is the company's owner, declared through the portal's \"same as owner\" copy. Waives the DARS delegation paper — nobody delegates to themselves.",
"True once the subject is proven — Fayda-verified, or carrying a passport number where that is accepted.",
})
poaSameAsOwner!: boolean;
identityProven!: boolean;
@ApiProperty({
type: IdentityVerificationStateDto,
nullable: true,
description:
"General manager. `verified` is true both when the GM verified with Fayda in their own right and when the company declared the GM is the owner — in the latter case the owner's Fayda sub backs it.",
"The manager named on the eTrade licence, captured at lookup. Null when eTrade returned none (ManagerNameEng is frequently blank).",
})
gm!: IdentityVerificationStateDto;
etradeManagerName!: string | null;
@ApiProperty({
nullable: true,
description:
"Does the owner the company put forward match the person on the eTrade licence? This is the backoffice's check. Null when there is nothing to compare — no eTrade manager on file, or no owner name yet. Advisory, not a gate: eTrade's Latin transliteration and Fayda's rarely agree character-for-character, so a reviewer decides.",
})
ownerMatchesEtrade!: boolean | null;
@ApiProperty({
description:
"True when the GM's identity is the owner's, declared through the portal's \"same as owner\" copy rather than a separate verification.",
})
gmSameAsOwner!: boolean;
@ApiProperty({
description:
"False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.",
"False while the declaration is unanswered or the subject is unproven. Field-level completeness (owner/PoA details, documents) is reported separately by the onboarding requirements.",
})
complete!: boolean;
}
@@ -105,26 +156,9 @@ export class CompanyIdentityStateDto {
const PREFIX: Record<IdentitySubject, string> = {
owner: "owner",
poa: "poa",
gm: "gm",
};
/**
* Typed GM fields, kept in step with the Fayda-written ones.
*
* The GM predates this verification: its details are plain company columns
* that three notifier services mail (booking-lifecycle, train-scheduling and
* contract notifiers all read `company.generalManagerEmail`). A verification
* therefore writes BOTH — the `gm*` attributes carry the proof, these carry
* the value everything else already reads — and an unverified company keeps
* showing whatever was typed before this existed.
*/
const GM_TYPED_KEYS = {
name: "generalManagerName",
email: "generalManagerEmail",
phone: "generalManagerPhone",
} as const;
/** company.attributes keys that together mean "a PoA was entered". */
/** `company.attributes` keys that together mean "a representative was entered". */
const POA_KEYS = [
"poaName",
"poaPhone",
@@ -139,7 +173,7 @@ function stateFor(
): IdentityVerificationStateDto {
const p = PREFIX[subject];
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
const state: IdentityVerificationStateDto = {
return {
verified: Boolean(read(`${p}FaydaSub`)),
name: read(`${p}Name`),
phone: read(`${p}Phone`),
@@ -148,88 +182,111 @@ function stateFor(
verifiedAt: read(`${p}FaydaVerifiedAt`),
birthdate: read(`${p}Birthdate`),
gender: read(`${p}Gender`),
};
if (subject !== "gm" || state.verified) return state;
// Companies onboarded before the GM was verifiable have typed details and no
// `gm*` attributes at all. Report those rather than a blank card — they are
// still what the notifiers mail — leaving `verified` false so the portal
// offers the upgrade instead of pretending the identity is proven.
//
// Only for such an unverified GM, which is the whole population this exists
// for. Merging the typed columns into a *verified* manager's state would read
// back the email the portal asked them to type when Fayda supplied none, and
// the input offering it — keyed on that value being absent — would vanish the
// moment it was saved, leaving a typo uncorrectable.
return {
...state,
name: state.name ?? read(GM_TYPED_KEYS.name),
email: state.email ?? read(GM_TYPED_KEYS.email),
phone: state.phone ?? read(GM_TYPED_KEYS.phone),
passportNumber: read(`${p}PassportNumber`),
};
}
/**
* Derive both people's verification state from the company row.
* The company's PoA declaration, or null when it hasn't answered yet.
*
* Pure and shared: `CompaniesService` gates on it and `ProfileResponseDto`
* renders from it, so the settings page and the onboarding wizard can never
* disagree with the rule the API actually enforces.
* A freight forwarder is never asked: it acts on other companies' behalf, so a
* representative and the DARS paper behind them are mandatory. Forcing it here
* — rather than only disabling the radio in the portal — is what stops a
* forwarder role added *after* onboarding from inheriting an old "no".
*/
export function readPoaDeclaration(
company: Pick<Company, "attributes" | "companyProfiles">,
): PoaDeclaration | null {
if (
(company.companyProfiles ?? []).some(
(p) => p.type === ProfileType.freightForwarder,
)
) {
return "yes";
}
const value = company.attributes?.[POA_DECLARED_KEY];
if (value === "yes" || value === "no") return value;
// No explicit answer, but the company holds a representative's details —
// so it has one, and owes everything a representative brings with them.
//
// Covers rows that predate the question (the migration derives the same way)
// and any write that reaches the attributes without going through
// `setPoaDeclared`. Without this, PoA details could be saved with the
// delegation paper silently unowed. Safe against a genuine "no": answering
// it clears these keys, so they cannot outlive the answer.
return POA_KEYS.some((k) => (company.attributes?.[k] as string | undefined)?.trim())
? "yes"
: null;
}
/**
* Do two people's names refer to the same person, as far as a string can tell?
*
* Deliberately loose: eTrade returns uppercase Latin transliterations of
* Amharic names and Fayda returns its own, so exact equality would flag almost
* every company. Case, punctuation, extra whitespace and word ORDER are all
* ignored — "ABEBE KEBEDE TESFA" and "Tesfa, Abebe Kebede" match. Anything
* beyond that is the reviewer's call, which is why the verdict is advisory.
*/
export function ownerNameMatchesEtrade(
ownerName: string | null | undefined,
etradeName: string | null | undefined,
): boolean | null {
const words = (v: string | null | undefined) =>
(v ?? "")
.toLowerCase()
.replace(/[^a-z0-9-፿\s]/g, " ")
.split(/\s+/)
.filter(Boolean)
.sort();
const a = words(ownerName);
const b = words(etradeName);
if (a.length === 0 || b.length === 0) return null;
return a.length === b.length && a.every((w, i) => w === b[i]);
}
/**
* Derive the company's identity state from its row.
*
* Pure and shared: `CompaniesService` gates on it, `ProfileResponseDto` and the
* backoffice's company DTO render from it, so the settings page, the onboarding
* wizard and the reviewer can never disagree with the rule the API enforces.
*/
export function buildCompanyIdentityState(
company: Company,
): CompanyIdentityStateDto {
const attrs = company.attributes ?? {};
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
// Fayda is an Ethiopian national ID — a foreign company's owner may not hold
// one, so a typed passport number is the mandatory credential there instead.
// The two are mutually exclusive by nationality but independently tracked,
// since a foreign owner verifying with Fayda doesn't waive the passport.
const foreign = company.nationality === CompanyNationality.Foreign;
const faydaRequired = !foreign;
const passportRequired = foreign;
// Fayda is an Ethiopian national ID. A foreign company's people may hold
// none, so a typed passport number stands in — either one proves the person,
// and holding both is fine.
const passportAccepted = company.nationality === CompanyNationality.Foreign;
const owner: OwnerIdentityStateDto = {
...stateFor(attrs, "owner"),
passportNumber: read("ownerPassportNumber"),
};
const owner = stateFor(attrs, "owner");
const poa = stateFor(attrs, "poa");
const poaDue =
(company.companyProfiles ?? []).some(
(p) => p.type === ProfileType.freightForwarder,
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
const poaDeclared = readPoaDeclaration(company);
const subject: IdentitySubject | null =
poaDeclared === "yes" ? "poa" : poaDeclared === "no" ? "owner" : null;
const gm = stateFor(attrs, "gm");
const gmSameAsOwner = Boolean(attrs.gmSameAsOwner);
const poaSameAsOwner = Boolean(attrs.poaSameAsOwner);
const proven = (s: IdentityVerificationStateDto) =>
s.verified || (passportAccepted && Boolean(s.passportNumber?.trim()));
const ownerProven = faydaRequired
? owner.verified
: !passportRequired || Boolean(owner.passportNumber);
const identityProven =
subject === null ? false : proven(subject === "poa" ? poa : owner);
// Fayda is an Ethiopian national ID, so only an Ethiopian company's
// personnel can be held to it. A foreign company may nominate a
// representative who holds one — and is offered the verification — but a
// typed name has to remain sufficient, or a foreign company whose PoA has no
// Fayda ID could never finish onboarding.
const poaProven = faydaRequired
? poa.verified
: poa.verified || Boolean(poa.name?.trim());
// The GM is deliberately absent from this verdict: it names who to talk to,
// not what the company may do, and it has never gated trading. Capturing it
// through Fayda changes how it is collected, not whether it is required.
const complete = ownerProven && (!poaDue || poaProven);
const etradeManagerName =
(attrs[ETRADE_MANAGER_NAME_KEY] as string | undefined) ?? null;
return {
faydaRequired,
passportRequired,
passportAccepted,
poaDeclared,
subject,
owner,
poa,
poaSameAsOwner,
gm,
gmSameAsOwner,
complete,
identityProven,
etradeManagerName,
ownerMatchesEtrade: ownerNameMatchesEtrade(owner.name, etradeManagerName),
complete: subject !== null && identityProven,
};
}

View File

@@ -8,7 +8,10 @@
* truth the wizard uses to auto-finish.
*/
import { CompanyIdentityStateDto } from "./complete-identity-verification.dto";
import {
CompanyIdentityStateDto,
PoaDeclaration,
} from "./complete-identity-verification.dto";
export interface OnboardingInfoField {
key: string;
@@ -38,15 +41,19 @@ export interface OnboardingLicenseProfile {
}
export interface OnboardingPoaState {
/** True when the company operates as a freight forwarder — PoA is mandatory. */
required: boolean;
/** True once any PoA detail has been entered. */
provided: boolean;
/**
* True when the DARS delegation paper is owed — a PoA exists (or is
* mandatory) and is not the owner themselves. An owner representing their own
* company delegates to nobody, so there is no delegation to evidence.
* True when the company operates as a freight forwarder: it signs on other
* companies' behalf, so a Power of Attorney is non-negotiable and the portal
* renders the question answered and locked rather than asking it.
*/
locked: boolean;
/**
* The company's answer to "does anyone hold power of attorney for you?".
* Null until it answers — which is itself outstanding, since the answer
* decides whose identity is verified.
*/
declared: PoaDeclaration | null;
/** True when the DARS delegation paper is owed — i.e. `declared === "yes"`. */
delegationLetterRequired: boolean;
/** True when the DARS delegation paper is stored for the company. */
delegationLetterUploaded: boolean;
@@ -61,7 +68,19 @@ export interface OnboardingPoaState {
export class OnboardingRequirementsResponseDto {
/** Resolved document setting code (by nationality) the docs were drawn from. */
documentSettingCode: string;
/**
* The co-operative document set, merged on top of the nationality one — null
* for every other company. `documents` below already carries the merged
* result; this is only so the portal can fetch the same extra fields when it
* renders the pickers from the file-settings endpoint.
*/
cooperativeDocumentSettingCode: string | null;
nationality: string;
/**
* The company trades as a co-operative: no business licence, so no eTrade
* lookup, no per-role licence upload, and no freight-forwarder role.
*/
cooperative: boolean;
/** Required company-information fields and whether each is filled. */
companyInfo: {
@@ -99,7 +118,9 @@ export class OnboardingRequirementsResponseDto {
constructor(init: Omit<OnboardingRequirementsResponseDto, never>) {
this.documentSettingCode = init.documentSettingCode;
this.cooperativeDocumentSettingCode = init.cooperativeDocumentSettingCode;
this.nationality = init.nationality;
this.cooperative = init.cooperative;
this.companyInfo = init.companyInfo;
this.documents = init.documents;
this.licenseProfiles = init.licenseProfiles;

View File

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

View File

@@ -3,6 +3,7 @@ import {
CompanyType,
CompanyStatus,
CompanyNationality,
isCooperative,
} from '../entities/company.entity';
import {
CompanyProfile,
@@ -55,6 +56,12 @@ export class ResponseCompanyDto {
type: CompanyType;
status: CompanyStatus;
nationality?: CompanyNationality | null;
/**
* The company trades as a co-operative: no business licence, so its
* registration was typed rather than fetched from eTrade and there is no
* eTrade manager to check the owner against.
*/
cooperative: boolean;
tin: string;
vatNumber?: string | null;
fanNumber?: string | null;
@@ -89,9 +96,14 @@ export class ResponseCompanyDto {
houseNo?: string | null;
/**
* Owner/PoA Fayda verification state, shared with the portal
* (`buildCompanyIdentityState`) so backoffice never re-derives — or
* disagrees with — the rule the API actually enforces.
* The company's single identity verification, shared with the portal
* (`buildCompanyIdentityState`) so backoffice never re-derives — or disagrees
* with — the rule the API actually enforces.
*
* `subject` names whose verification it is (the PoA when one is declared,
* otherwise the owner), and `ownerMatchesEtrade` is the reviewer's check:
* does the owner the company put forward match the manager on the eTrade
* licence? Advisory — see the note on that field.
*/
identity: CompanyIdentityStateDto;
@@ -105,6 +117,7 @@ export class ResponseCompanyDto {
this.type = company.type;
this.status = company.status;
this.nationality = company.nationality ?? null;
this.cooperative = isCooperative(company);
this.tin = company.tin;
this.vatNumber = company.vatNumber;
this.fanNumber = company.fanNumber;

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ import {
MaxLength,
IsEnum,
IsIn,
Matches,
} from "class-validator";
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from "@edr/types";
import { CompanyNationality } from "../entities/company.entity";
@@ -36,20 +35,21 @@ export class UpdateProfileDto {
@IsTin({ message: "TIN must be exactly 10 digits" })
tin?: string;
// Ethiopian VAT registration numbers are 10 digits (the same shape as the
// TIN), but some are issued with an 11th. Both portal forms enforce the same
// range; without it here the API happily stored whatever a stale client sent,
// and the two layers disagreed about what the column may hold.
// No shape check. Ethiopian VAT numbers are usually 10 or 11 digits, but a
// foreign company's is whatever its own tax authority issues — letters,
// dashes and any length — and a co-operative's registration numbering does
// not follow the trade-licence pattern either. The field is required (the
// portal enforces non-blank) but its content is not ours to police.
@IsOptional()
@IsString()
@Matches(/^\d{10,11}$/, { message: "VAT number must be 10 or 11 digits" })
@MaxLength(64)
vatNumber?: string;
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
// company's PoA (or its general manager), so it is derived from a completed
// Fayda verification rather than typed. The global validation pipe runs with
// forbidNonWhitelisted, so a client that still sends it gets a 400 telling it
// so — see CompaniesService.completeIdentityVerification.
// `fanNumber` is deliberately absent: the FAN is a Fayda number, so it would
// have to come from a completed verification rather than be typed — and
// Fayda's userinfo carries no national ID number, so nothing produces one.
// The global validation pipe runs with forbidNonWhitelisted, so a client that
// still sends it gets a 400 telling it so.
@IsOptional()
@IsString()
@@ -78,18 +78,31 @@ export class UpdateProfileDto {
@IsValidPhone()
contactVerifiedPhone?: string;
/**
* The owner — whoever the eTrade licence names as the business's manager.
*
* All three are required before onboarding can be submitted, whatever their
* source: the eTrade lookup prefills the name and phone, a Fayda
* verification can supply all three, and the portal renders an input for
* whatever neither did (eTrade returns no email at all, and Fayda's email
* claim is optional, so that one is usually typed).
*
* Locked once a Fayda verification supplied them — see
* `IDENTITY_OWNED_FIELDS` — but only field by field: a claim that came back
* empty owns nothing and stays typeable.
*/
@IsOptional()
@IsString()
generalManagerName?: string;
ownerName?: string;
@IsOptional()
@IsEmail()
generalManagerEmail?: string;
ownerEmail?: string;
@IsOptional()
@IsString()
@IsValidPhone()
generalManagerPhone?: string;
ownerPhone?: string;
@IsOptional()
@IsString()
@@ -113,15 +126,22 @@ export class UpdateProfileDto {
poaAddress?: string;
/**
* The owner's passport number — the identity credential for a foreign
* company, since Fayda is an Ethiopian national ID. Plain typed field, never
* written or locked by a Fayda verification: still required even if the
* owner also verifies.
* Passport numbers — the alternative identity credential for a foreign
* company, since Fayda is an Ethiopian national ID. Plain typed fields, never
* written or locked by a Fayda verification.
*
* Only the one belonging to the company's declared identity subject matters:
* the PoA's when a representative is named, the owner's otherwise. An
* Ethiopian company is not offered either — it must use Fayda.
*/
@IsOptional()
@IsString()
ownerPassportNumber?: string;
@IsOptional()
@IsString()
poaPassportNumber?: string;
@IsOptional()
@IsString()
@MaxLength(100)

View File

@@ -32,6 +32,25 @@ export enum CompanyNationality {
Foreign = "foreign",
}
/**
* `attributes` key marking a co-operative union or farm.
*
* Such a company has a TIN but no business licence, so there is no eTrade record to
* look its registration up in — the company name, registered address and the
* owner are all typed instead of fetched, and the eTrade authenticity check is
* skipped rather than failed. It is a flag rather than a column because
* everything it changes is behavioural (which lookup runs, which documents
* apply, which roles are offered); nothing queries or joins on it.
*/
export const COOPERATIVE_KEY = "cooperative";
/** Is this a co-operative union or farm (a TIN, but no business licence)? */
export function isCooperative(
company: Pick<Company, "attributes"> | null | undefined,
): boolean {
return company?.attributes?.[COOPERATIVE_KEY] === true;
}
@Entity({ schema: "freight", name: "companies" })
@Index(["tin"])
@Index(["type"])
@@ -112,29 +131,11 @@ export class Company extends BaseEntity {
})
contactPersonPhone?: string | null;
@Column({
name: "general_manager_name",
type: "varchar",
length: 100,
nullable: true,
})
generalManagerName?: string | null;
@Column({
name: "general_manager_email",
type: "varchar",
length: 150,
nullable: true,
})
generalManagerEmail?: string | null;
@Column({
name: "general_manager_phone",
type: "varchar",
length: 20,
nullable: true,
})
generalManagerPhone?: string | null;
// The general manager used to live here as three columns. It named who to
// talk to, gated nothing, and nothing ever populated the columns — the write
// path put the values in `attributes`. Removed in RemoveGeneralManager; the
// company's people are now its owner (whoever the eTrade licence names) and
// its Power of Attorney, both in `attributes`.
@Column({ name: "website", type: "varchar", length: 200, nullable: true })
website?: string | null;

View File

@@ -17,6 +17,7 @@ import {
} from "./interfaces/file-upload-settings.repository.interface";
import {
COMPANY_ONBOARDING_CODE_PREFIX,
COOPERATIVE_ONBOARDING_CODE,
POA_DELEGATION_FILE_KEY,
poaDelegationField,
} from "./poa-delegation.constants";
@@ -56,6 +57,10 @@ export class FileUploadSettingsService {
*/
private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting {
if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting;
// The co-operative set is merged ON TOP of a nationality set that already
// carries the paper; injecting it here too would hand the portal the same
// slot twice.
if (setting.code === COOPERATIVE_ONBOARDING_CODE) return setting;
const fields = setting.fields ?? [];
if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting;

View File

@@ -26,6 +26,14 @@ export const POA_DELEGATION_LABEL = "DARS Delegation Paper";
/** Prefix of the setting codes the field is injected into. */
export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_";
/**
* The co-operative onboarding set. Unlike the nationality sets it is ADDITIVE —
* merged on top of the company's `_ethiopian`/`_foreign` set rather than
* replacing it — which is why the delegation paper is not injected into it: the
* set it is merged onto already carries one.
*/
export const COOPERATIVE_ONBOARDING_CODE = `${COMPANY_ONBOARDING_CODE_PREFIX}cooperative`;
const POA_DELEGATION_HELP =
"Delegation paper issued by the Documents Authentication and Registration " +
"Service (DARS) delegating the representative named above. Upload the " +

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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',
contactPersonName: 'Marshalling Demo',
contactPersonPhone: '251900000202',
generalManagerName: 'Demo Manager',
generalManagerEmail: 'negad-indode-demo@edr.local',
generalManagerPhone: '251900000202',
}),
));

View File

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

View File

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

View File

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

View File

@@ -1187,6 +1187,26 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:settings:dropdown:manage",
"Manage dropdown settings",
),
perm(
"b4b00002-0001-4000-8000-000000000001",
"edr_freight_app:settings:stamp:view",
"View stamp settings",
),
perm(
"b4b00002-0001-4000-8000-000000000002",
"edr_freight_app:settings:stamp:manage",
"Manage stamp settings",
),
perm(
"b4b00003-0001-4000-8000-000000000001",
"edr_freight_app:settings:invoice_stamp:view",
"View invoice stamp settings",
),
perm(
"b4b00003-0001-4000-8000-000000000002",
"edr_freight_app:settings:invoice_stamp:manage",
"Manage invoice stamp settings",
),
perm(
"b4c00001-0001-4000-8000-000000000001",
"edr_freight_app:audit:view",
@@ -1876,6 +1896,16 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:dropdown:view",
manage: "edr_freight_app:settings:dropdown:manage",
},
stamp: {
view: "edr_freight_app:settings:stamp:view",
manage: "edr_freight_app:settings:stamp:manage",
},
// Company stamp/seal image stamped onto invoice/receipt PDFs — separate
// from `stamp` above, which is the per-employee approval-record teeter.
invoiceStamp: {
view: "edr_freight_app:settings:invoice_stamp:view",
manage: "edr_freight_app:settings:invoice_stamp:manage",
},
exchangeRate: {
view: "edr_freight_app:settings:exchange_rate:view",
manage: "edr_freight_app:settings:exchange_rate:manage",

View File

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