mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(WIP): require the gm for fayda on ethiopian companies
This commit is contained in:
@@ -408,6 +408,30 @@ export class CompaniesController {
|
||||
return this.companiesService.completeIdentityVerification(user.id, dto);
|
||||
}
|
||||
|
||||
@Post("identity/gm/same-as-owner")
|
||||
@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.",
|
||||
})
|
||||
async setGmSameAsOwner(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
): Promise<CompanyIdentityStateDto> {
|
||||
return this.companiesService.setGmSameAsOwner(user.id);
|
||||
}
|
||||
|
||||
@Delete("identity/gm")
|
||||
@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);
|
||||
}
|
||||
|
||||
@Delete("identity/fayda/poa")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
|
||||
@@ -21,9 +21,11 @@ import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.
|
||||
* is named, both nationalities must verify them, and their details come from
|
||||
* the verified payload rather than the form.
|
||||
*
|
||||
* The owner is NOT the general manager — GM is a separate, plain typed role
|
||||
* the portal offers a "same as owner" copy for, but it is never itself
|
||||
* Fayda-verified or gated on.
|
||||
* The owner is NOT the general manager. The GM is proved the same way, by one
|
||||
* of two routes — verifying in their own right, or being declared the owner,
|
||||
* which reuses that verification rather than making one human prove themselves
|
||||
* twice. It stays out of the trading gate either way: the GM names who to talk
|
||||
* to, not what the company may do.
|
||||
*/
|
||||
|
||||
interface Ctx {
|
||||
@@ -418,10 +420,12 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("still requires a Fayda-verified PoA from a foreign company", async () => {
|
||||
// The owner's credential is nationality-specific; the representative's is
|
||||
// not. A PoA acts for the company inside Ethiopia whoever owns it, so a
|
||||
// typed foreign name is not a representative the platform can accept.
|
||||
it("accepts a typed PoA from a foreign company, whose representative may hold no Fayda ID", async () => {
|
||||
// Fayda is an Ethiopian national ID, so only an Ethiopian company's
|
||||
// representative can be held to it. A foreign company is offered the
|
||||
// verification and uses it where its representative holds one, but a typed
|
||||
// name stays sufficient — holding it to Fayda would leave a foreign
|
||||
// company whose representative has no Fayda ID unable to trade at all.
|
||||
const { service } = makeService({
|
||||
profileTypes: [ProfileType.importer],
|
||||
nationality: CompanyNationality.Foreign,
|
||||
@@ -434,6 +438,48 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("still refuses a foreign company that named no PoA at all", async () => {
|
||||
// The typed fallback is a different credential, not a waiver: a freight
|
||||
// forwarder acts on other companies' behalf and needs a representative
|
||||
// whatever its nationality.
|
||||
const { service } = makeService({
|
||||
profileTypes: [ProfileType.importer],
|
||||
nationality: CompanyNationality.Foreign,
|
||||
attributes: { ownerPassportNumber: "P1234567" },
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
ProfileType.freightForwarder,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("holds an Ethiopian company to a Fayda-verified PoA, typed details notwithstanding", async () => {
|
||||
// The relaxation above is scoped to foreign companies only — an Ethiopian
|
||||
// representative holds a Fayda ID, so typing a name must not substitute.
|
||||
const { service } = makeService({
|
||||
profileTypes: [ProfileType.importer],
|
||||
nationality: CompanyNationality.Ethiopian,
|
||||
attributes: {
|
||||
...OWNER_VERIFIED,
|
||||
poaName: "Abebe Bekele",
|
||||
poaEmail: "abebe@example.com",
|
||||
poaPhone: "+251911000000",
|
||||
},
|
||||
files: [paper()],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser(
|
||||
"user-1",
|
||||
@@ -463,4 +509,119 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// General manager
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("reuses the owner's verified identity when the GM is declared the same person", async () => {
|
||||
// The GM is very often the owner. Copying the proven identity is the whole
|
||||
// point — asking one human to complete two verifications proves nothing
|
||||
// extra, and typing the details instead would forge a verified badge.
|
||||
const { service, ctx } = makeService({
|
||||
attributes: {
|
||||
...OWNER_VERIFIED,
|
||||
ownerEmail: "abebe@example.com",
|
||||
ownerPhone: "+251911222333",
|
||||
},
|
||||
});
|
||||
|
||||
const state = await service.setGmSameAsOwner("user-1");
|
||||
|
||||
expect(state.gm.verified).toBe(true);
|
||||
expect(state.gmSameAsOwner).toBe(true);
|
||||
expect(state.gm.name).toBe("Abebe Bikila");
|
||||
expect(ctx.attributes.gmFaydaSub).toBe("owner-sub");
|
||||
// The notifiers mail the flat column, so a linked GM has to land there too.
|
||||
expect(ctx.attributes.generalManagerEmail).toBe("abebe@example.com");
|
||||
});
|
||||
|
||||
it("refuses to declare the GM is the owner while the owner is unverified", async () => {
|
||||
// Without a verification there is no proven identity to copy — only typed
|
||||
// text, which would arrive wearing a badge it had not earned.
|
||||
const { service } = makeService({ attributes: {} });
|
||||
|
||||
await expect(service.setGmSameAsOwner("user-1")).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it("lets the GM verify as the same human as the owner", async () => {
|
||||
// The owner/PoA collision check exists because self-delegation is not
|
||||
// delegation. It must not fire here: the GM being the owner is a supported
|
||||
// answer, so verifying with the owner's own Fayda sub has to succeed.
|
||||
const { service, ctx } = makeService({
|
||||
attributes: { ...OWNER_VERIFIED },
|
||||
verification: {
|
||||
purpose: "VERIFY",
|
||||
verified: true,
|
||||
sub: "owner-sub",
|
||||
fullName: "Abebe Bikila",
|
||||
email: "abebe@example.com",
|
||||
phoneNumber: "+251911222333",
|
||||
},
|
||||
});
|
||||
|
||||
const state = await service.completeIdentityVerification("user-1", {
|
||||
subject: "gm",
|
||||
code: "c",
|
||||
state: "s",
|
||||
});
|
||||
|
||||
expect(state.gm.verified).toBe(true);
|
||||
expect(ctx.attributes.gmFaydaSub).toBe("owner-sub");
|
||||
expect(ctx.attributes.generalManagerName).toBe("Abebe Bikila");
|
||||
});
|
||||
|
||||
it("still refuses a PoA who is the owner", async () => {
|
||||
// The GM exemption above must not have widened into the PoA.
|
||||
const { service } = makeService({
|
||||
attributes: { ...OWNER_VERIFIED },
|
||||
verification: {
|
||||
purpose: "VERIFY",
|
||||
verified: true,
|
||||
sub: "owner-sub",
|
||||
fullName: "Abebe Bikila",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.completeIdentityVerification("user-1", {
|
||||
subject: "poa",
|
||||
code: "c",
|
||||
state: "s",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("reports a pre-existing typed GM as unverified rather than blank", async () => {
|
||||
// Companies onboarded before the GM was verifiable have typed details and
|
||||
// no gm* attributes. Those details are still what the notifiers mail, so
|
||||
// they must survive — flagged unverified so the portal offers the upgrade.
|
||||
const { service, company } = makeService({
|
||||
attributes: {
|
||||
...OWNER_VERIFIED,
|
||||
generalManagerName: "Legacy Manager",
|
||||
generalManagerEmail: "legacy@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
const state = service.getCompanyIdentityState(company() as never);
|
||||
|
||||
expect(state.gm.verified).toBe(false);
|
||||
expect(state.gm.name).toBe("Legacy Manager");
|
||||
expect(state.gm.email).toBe("legacy@example.com");
|
||||
});
|
||||
|
||||
it("does not let an unproven GM block the company from trading", async () => {
|
||||
// The GM names who to talk to, not what the company may do. Capturing it
|
||||
// through Fayda changed how it is collected, not whether it gates.
|
||||
const { service } = makeService({
|
||||
attributes: { ...OWNER_VERIFIED },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createCompanyProfileForUser("user-1", ProfileType.importer),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
buildCompanyIdentityState,
|
||||
CompanyIdentityStateDto,
|
||||
CompleteIdentityVerificationDto,
|
||||
IDENTITY_SUBJECTS,
|
||||
IdentitySubject,
|
||||
} from "./dto/complete-identity-verification.dto";
|
||||
import { ETradeService } from "./services/etrade.service";
|
||||
@@ -122,31 +123,47 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
|
||||
|
||||
/**
|
||||
* `attributes` key prefix per verifiable person. The owner is NOT the general
|
||||
* manager — GM is a plain typed role (the portal offers a "same as owner" copy
|
||||
* once the owner is verified), while the owner is who this verification
|
||||
* actually proves. They're very often the same human; that's what the copy is
|
||||
* for.
|
||||
* manager: the owner is who the verification proves the company through, the
|
||||
* GM is personnel it names. They're very often the same human, which is what
|
||||
* the portal's "same as owner" copy is for.
|
||||
*/
|
||||
const IDENTITY_PREFIX: Record<IdentitySubject, "owner" | "poa"> = {
|
||||
const IDENTITY_PREFIX: Record<IdentitySubject, string> = {
|
||||
owner: "owner",
|
||||
poa: "poa",
|
||||
gm: "gm",
|
||||
};
|
||||
|
||||
const IDENTITY_LABEL: Record<IdentitySubject, string> = {
|
||||
owner: "owner",
|
||||
poa: "Power of Attorney",
|
||||
gm: "General Manager",
|
||||
};
|
||||
|
||||
/**
|
||||
* Typed GM columns a GM verification also writes. Three notifier services mail
|
||||
* `company.generalManagerEmail` directly, so leaving these behind would mean a
|
||||
* verified GM whose address the system never actually uses.
|
||||
*/
|
||||
const GM_TYPED_FIELDS = [
|
||||
"generalManagerName",
|
||||
"generalManagerEmail",
|
||||
"generalManagerPhone",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Identity fields a Fayda verification owns outright, per person. Once verified
|
||||
* these can no longer be typed — the government IdP is the source, so an edit
|
||||
* that disagrees with it is either a mistake or an attempt to launder the
|
||||
* guarantee away. The GM fields are deliberately absent: GM is never itself
|
||||
* Fayda-verified, so it stays freely editable regardless of the owner's state.
|
||||
* guarantee away.
|
||||
*
|
||||
* The GM's entries are its typed columns: a verified GM is locked the same way
|
||||
* the others are, while an unverified one (a foreign company's, or a record
|
||||
* that predates this) stays freely editable.
|
||||
*/
|
||||
const IDENTITY_OWNED_FIELDS: Record<IdentitySubject, string[]> = {
|
||||
owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"],
|
||||
poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"],
|
||||
gm: [...GM_TYPED_FIELDS],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -834,7 +851,7 @@ export class CompaniesService {
|
||||
|
||||
// Renaming a Fayda-verified person by hand would launder the guarantee
|
||||
// away, so the fields the verification owns are refused once it exists.
|
||||
for (const subject of ["owner", "poa"] as IdentitySubject[]) {
|
||||
for (const subject of IDENTITY_SUBJECTS) {
|
||||
if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
||||
for (const field of IDENTITY_OWNED_FIELDS[subject]) {
|
||||
const incoming = (dto as Record<string, unknown>)[field];
|
||||
@@ -2693,12 +2710,17 @@ export class CompaniesService {
|
||||
|
||||
// The owner delegating power of attorney to themselves is not a
|
||||
// delegation — it would let one identity satisfy both halves of the check.
|
||||
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
|
||||
const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
|
||||
if (otherSub && otherSub === result.sub) {
|
||||
throw new BadRequestException(
|
||||
`This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
|
||||
);
|
||||
// Only owner/PoA collide this way: the GM is very often the owner, and
|
||||
// saying so is a supported answer rather than a conflict, so it is left out
|
||||
// of this check entirely.
|
||||
if (dto.subject === "owner" || dto.subject === "poa") {
|
||||
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
|
||||
const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
|
||||
if (otherSub && otherSub === result.sub) {
|
||||
throw new BadRequestException(
|
||||
`This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
@@ -2714,13 +2736,26 @@ export class CompaniesService {
|
||||
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
||||
};
|
||||
|
||||
// A GM verification also lands on the typed columns the rest of the system
|
||||
// already reads (the booking, train-scheduling and contract notifiers all
|
||||
// mail `generalManagerEmail`), and clears any earlier "same as owner"
|
||||
// declaration — verifying in their own right is the GM answering for
|
||||
// themselves.
|
||||
if (dto.subject === "gm") {
|
||||
identity.gmSameAsOwner = false;
|
||||
if (result.fullName) identity.generalManagerName = result.fullName;
|
||||
if (result.email) identity.generalManagerEmail = result.email;
|
||||
if (result.phoneNumber)
|
||||
identity.generalManagerPhone = normalizeE164(result.phoneNumber);
|
||||
}
|
||||
|
||||
// An approved company's *owner* is its identity proof, so re-verifying one
|
||||
// is staged for backoffice review rather than quietly rewriting a live
|
||||
// record. The PoA is personnel — the company names its own representative,
|
||||
// and the delegation letter backing them is what the reviewer sees — so a
|
||||
// PoA verification lands live, matching the typed PoA fields in
|
||||
// `SELF_SERVICE_ATTRIBUTES`.
|
||||
if (company.status === CompanyStatus.Active && dto.subject !== "poa") {
|
||||
// record. The PoA and GM are personnel — the company names its own
|
||||
// representative and manager, and the delegation letter backing the PoA is
|
||||
// what the reviewer sees — so those land live, matching their typed
|
||||
// counterparts in `SELF_SERVICE_ATTRIBUTES`.
|
||||
if (company.status === CompanyStatus.Active && dto.subject === "owner") {
|
||||
await this.stageIdentityChange(company, userId, identity);
|
||||
return this.getCompanyIdentityState(company);
|
||||
}
|
||||
@@ -2734,6 +2769,85 @@ export class CompaniesService {
|
||||
return this.getCompanyIdentityState(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare that the General Manager is the company's owner.
|
||||
*
|
||||
* The GM is very often the owner, and making that human verify twice buys
|
||||
* nothing — the owner's verification already proves them. So this copies the
|
||||
* owner's verified identity across rather than starting a second flow, and
|
||||
* records `gmSameAsOwner` so the portal can show it as a declaration rather
|
||||
* than as a verification the GM passed in their own right.
|
||||
*
|
||||
* Refused until the owner is actually verified: without that there is no
|
||||
* proven identity to copy, only typed text that would arrive wearing a
|
||||
* verified badge.
|
||||
*/
|
||||
async setGmSameAsOwner(userId: string): Promise<CompanyIdentityStateDto> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
const attrs = company.attributes ?? {};
|
||||
const ownerSub = attrs.ownerFaydaSub as string | undefined;
|
||||
if (!ownerSub) {
|
||||
throw new BadRequestException(
|
||||
"Verify the company owner with Fayda first — there is no proven identity to reuse yet.",
|
||||
);
|
||||
}
|
||||
|
||||
const copied: Record<string, unknown> = {
|
||||
gmSameAsOwner: true,
|
||||
gmFaydaSub: ownerSub,
|
||||
gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(),
|
||||
gmName: attrs.ownerName ?? null,
|
||||
gmEmail: attrs.ownerEmail ?? null,
|
||||
gmPhone: attrs.ownerPhone ?? null,
|
||||
gmAddress: attrs.ownerAddress ?? null,
|
||||
gmBirthdate: attrs.ownerBirthdate ?? null,
|
||||
gmGender: attrs.ownerGender ?? null,
|
||||
// Kept in step for the notifiers, same as a GM verification does.
|
||||
generalManagerName: attrs.ownerName ?? null,
|
||||
generalManagerEmail: attrs.ownerEmail ?? null,
|
||||
generalManagerPhone: attrs.ownerPhone ?? null,
|
||||
};
|
||||
|
||||
const updated = await this.companiesRepo.update(company.id, {
|
||||
attributes: { ...attrs, ...copied },
|
||||
});
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company ${company.id} not found`);
|
||||
updated.companyProfiles = company.companyProfiles;
|
||||
return this.getCompanyIdentityState(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the "same as owner" declaration, clearing the copied identity so the
|
||||
* GM can be verified in their own right (or typed, where Fayda is optional).
|
||||
*/
|
||||
async clearGmIdentity(userId: string): Promise<CompanyIdentityStateDto> {
|
||||
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||
const attrs = { ...(company.attributes ?? {}) };
|
||||
for (const key of [
|
||||
"gmSameAsOwner",
|
||||
"gmFaydaSub",
|
||||
"gmFaydaVerifiedAt",
|
||||
"gmName",
|
||||
"gmEmail",
|
||||
"gmPhone",
|
||||
"gmAddress",
|
||||
"gmBirthdate",
|
||||
"gmGender",
|
||||
...GM_TYPED_FIELDS,
|
||||
]) {
|
||||
attrs[key] = null;
|
||||
}
|
||||
|
||||
const updated = await this.companiesRepo.update(company.id, {
|
||||
attributes: attrs,
|
||||
});
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company ${company.id} not found`);
|
||||
updated.companyProfiles = company.companyProfiles;
|
||||
return this.getCompanyIdentityState(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the Power of Attorney entirely — the verified identity, the details it
|
||||
* wrote and the delegation paper together.
|
||||
@@ -2864,14 +2978,28 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
// The representative is not. A PoA acts for the company inside Ethiopia
|
||||
// whoever owns it, so they are always an Ethiopian holding a Fayda ID —
|
||||
// a foreign company nominates one rather than typing a name.
|
||||
const poaNamed = POA_ATTRIBUTES.some((k) =>
|
||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||
);
|
||||
if (!opts.requirePoa && !poaNamed) return;
|
||||
|
||||
// Fayda is an Ethiopian national ID, so only an Ethiopian company's
|
||||
// representative can be held to it. A foreign company is offered the
|
||||
// verification and nominates a Fayda-holding representative where it can,
|
||||
// but a typed name has to remain sufficient — otherwise a foreign company
|
||||
// whose representative holds no Fayda ID could never trade at all. Mirrors
|
||||
// `poaProven` in buildCompanyIdentityState; the two must agree.
|
||||
if (state.passportRequired) {
|
||||
if (!state.poa.verified && !state.poa.name?.trim()) {
|
||||
throw new BadRequestException(
|
||||
opts.requirePoa
|
||||
? "Name your Power of Attorney — a freight forwarder cannot operate without one."
|
||||
: "Complete the Power of Attorney you named, or remove the representative.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.poa.verified) {
|
||||
throw new BadRequestException(
|
||||
opts.requirePoa
|
||||
|
||||
@@ -5,13 +5,15 @@ import { Company, CompanyNationality } from "../entities/company.entity";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
|
||||
/**
|
||||
* The two people a company is verified through — its owner and its Power of
|
||||
* Attorney. "Owner" is not the same as the General Manager: a company's GM is
|
||||
* a plain typed role (with a "same as owner" copy the portal offers), while
|
||||
* the owner is the person this verification proves. They're very often the
|
||||
* same human, which is exactly what the copy is for.
|
||||
* 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 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.
|
||||
*/
|
||||
export const IDENTITY_SUBJECTS = ["owner", "poa"] as const;
|
||||
export const IDENTITY_SUBJECTS = ["owner", "poa", "gm"] as const;
|
||||
export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
|
||||
|
||||
export class CompleteIdentityVerificationDto {
|
||||
@@ -73,6 +75,19 @@ export class CompanyIdentityStateDto {
|
||||
@ApiProperty({ type: IdentityVerificationStateDto })
|
||||
poa!: IdentityVerificationStateDto;
|
||||
|
||||
@ApiProperty({
|
||||
type: IdentityVerificationStateDto,
|
||||
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.",
|
||||
})
|
||||
gm!: IdentityVerificationStateDto;
|
||||
|
||||
@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.",
|
||||
@@ -81,11 +96,28 @@ export class CompanyIdentityStateDto {
|
||||
}
|
||||
|
||||
/** `attributes` key prefix per person. */
|
||||
const PREFIX: Record<IdentitySubject, "owner" | "poa"> = {
|
||||
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". */
|
||||
const POA_KEYS = [
|
||||
"poaName",
|
||||
@@ -101,7 +133,7 @@ function stateFor(
|
||||
): IdentityVerificationStateDto {
|
||||
const p = PREFIX[subject];
|
||||
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
||||
return {
|
||||
const state: IdentityVerificationStateDto = {
|
||||
verified: Boolean(read(`${p}FaydaSub`)),
|
||||
name: read(`${p}Name`),
|
||||
phone: read(`${p}Phone`),
|
||||
@@ -111,6 +143,18 @@ function stateFor(
|
||||
birthdate: read(`${p}Birthdate`),
|
||||
gender: read(`${p}Gender`),
|
||||
};
|
||||
if (subject !== "gm") 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.
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,14 +188,34 @@ export function buildCompanyIdentityState(
|
||||
(p) => p.type === ProfileType.freightForwarder,
|
||||
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
||||
|
||||
// Only the *owner's* credential is nationality-specific. A Power of Attorney
|
||||
// acts for the company inside Ethiopia whoever owns it, so the PoA is always
|
||||
// proven with Fayda — a foreign company nominates a representative who holds
|
||||
// one rather than typing a name nothing backs.
|
||||
const gm = stateFor(attrs, "gm");
|
||||
const gmSameAsOwner = Boolean(attrs.gmSameAsOwner);
|
||||
|
||||
const ownerProven = faydaRequired
|
||||
? owner.verified
|
||||
: !passportRequired || Boolean(owner.passportNumber);
|
||||
const complete = ownerProven && (!poaDue || poa.verified);
|
||||
|
||||
return { faydaRequired, passportRequired, owner, poa, complete };
|
||||
// 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);
|
||||
|
||||
return {
|
||||
faydaRequired,
|
||||
passportRequired,
|
||||
owner,
|
||||
poa,
|
||||
gm,
|
||||
gmSameAsOwner,
|
||||
complete,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user