mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
303 lines
11 KiB
TypeScript
303 lines
11 KiB
TypeScript
import { ApiProperty } from "@nestjs/swagger";
|
||
import { IsIn, IsString, IsNotEmpty } from "class-validator";
|
||
|
||
import { Company, CompanyNationality } from "../entities/company.entity";
|
||
import { ProfileType } from "../entities/company-profile.entity";
|
||
|
||
/**
|
||
* The two people a company can be described through.
|
||
*
|
||
* 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"] 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. Must match the company's PoA declaration — the representative when one is named, the owner when not.",
|
||
})
|
||
@IsIn(IDENTITY_SUBJECTS)
|
||
subject!: IdentitySubject;
|
||
|
||
@ApiProperty({ description: "Authorization code from the Fayda redirect." })
|
||
@IsString()
|
||
@IsNotEmpty()
|
||
code!: string;
|
||
|
||
@ApiProperty({ description: "CSRF state from the Fayda redirect." })
|
||
@IsString()
|
||
@IsNotEmpty()
|
||
state!: string;
|
||
}
|
||
|
||
/** One person's identity state, as reported back to the portal. */
|
||
export class IdentityVerificationStateDto {
|
||
@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;
|
||
@ApiProperty({ nullable: true }) address!: string | null;
|
||
@ApiProperty({ nullable: true }) verifiedAt!: string | null;
|
||
@ApiProperty({ nullable: true }) birthdate!: string | null;
|
||
@ApiProperty({ nullable: true }) gender!: string | null;
|
||
|
||
@ApiProperty({
|
||
nullable: true,
|
||
description:
|
||
"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;
|
||
}
|
||
|
||
export class CompanyIdentityStateDto {
|
||
@ApiProperty({
|
||
description:
|
||
"True for a foreign company: a typed passport number proves the identity just as a Fayda verification does. An Ethiopian company must use Fayda.",
|
||
})
|
||
passportAccepted!: boolean;
|
||
|
||
@ApiProperty({
|
||
enum: POA_DECLARATIONS,
|
||
nullable: true,
|
||
description:
|
||
'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.',
|
||
})
|
||
poaDeclared!: PoaDeclaration | null;
|
||
|
||
@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 once the subject is proven — Fayda-verified, or carrying a passport number where that is accepted.",
|
||
})
|
||
identityProven!: boolean;
|
||
|
||
@ApiProperty({
|
||
nullable: true,
|
||
description:
|
||
"The manager named on the eTrade licence, captured at lookup. Null when eTrade returned none (ManagerNameEng is frequently blank).",
|
||
})
|
||
etradeManagerName!: string | null;
|
||
|
||
@ApiProperty({
|
||
nullable: true,
|
||
description:
|
||
"That manager's phone, normalized to E.164 and captured at the same lookup. Paired with the name so the portal can still tell that the owner's details came from eTrade after a refresh, when the live lookup result is long gone — without it a resumed wizard offers them back as typeable inputs.",
|
||
})
|
||
etradeManagerPhone!: string | null;
|
||
|
||
@ApiProperty({
|
||
nullable: true,
|
||
description:
|
||
"Does the owner the company put forward match the person on the eTrade licence? This is the backoffice's check. Null when there is nothing to compare — no eTrade manager on file, or no owner name yet. Advisory, not a gate: eTrade's Latin transliteration and Fayda's rarely agree character-for-character, so a reviewer decides.",
|
||
})
|
||
ownerMatchesEtrade!: boolean | null;
|
||
|
||
@ApiProperty({
|
||
description:
|
||
"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;
|
||
}
|
||
|
||
/** `attributes` key prefix per person. */
|
||
const PREFIX: Record<IdentitySubject, string> = {
|
||
owner: "owner",
|
||
poa: "poa",
|
||
};
|
||
|
||
/** `company.attributes` keys that together mean "a representative was entered". */
|
||
const POA_KEYS = [
|
||
"poaName",
|
||
"poaPhone",
|
||
"poaEmail",
|
||
"poaLocation",
|
||
"poaAddress",
|
||
] as const;
|
||
|
||
function stateFor(
|
||
attrs: Record<string, unknown>,
|
||
subject: IdentitySubject,
|
||
): IdentityVerificationStateDto {
|
||
const p = PREFIX[subject];
|
||
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
||
return {
|
||
verified: Boolean(read(`${p}FaydaSub`)),
|
||
name: read(`${p}Name`),
|
||
phone: read(`${p}Phone`),
|
||
email: read(`${p}Email`),
|
||
address: read(`${p}Address`),
|
||
verifiedAt: read(`${p}FaydaVerifiedAt`),
|
||
birthdate: read(`${p}Birthdate`),
|
||
gender: read(`${p}Gender`),
|
||
passportNumber: read(`${p}PassportNumber`),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The company's PoA declaration, or null when it hasn't answered yet.
|
||
*
|
||
* 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 ?? {};
|
||
|
||
// 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 = stateFor(attrs, "owner");
|
||
const poa = stateFor(attrs, "poa");
|
||
const poaDeclared = readPoaDeclaration(company);
|
||
const subject: IdentitySubject | null =
|
||
poaDeclared === "yes" ? "poa" : poaDeclared === "no" ? "owner" : null;
|
||
|
||
const proven = (s: IdentityVerificationStateDto) =>
|
||
s.verified || (passportAccepted && Boolean(s.passportNumber?.trim()));
|
||
|
||
const identityProven =
|
||
subject === null ? false : proven(subject === "poa" ? poa : owner);
|
||
|
||
const etradeManagerName =
|
||
(attrs[ETRADE_MANAGER_NAME_KEY] as string | undefined) ?? null;
|
||
const etradeManagerPhone =
|
||
(attrs[ETRADE_MANAGER_PHONE_KEY] as string | undefined) ?? null;
|
||
|
||
return {
|
||
passportAccepted,
|
||
poaDeclared,
|
||
subject,
|
||
owner,
|
||
poa,
|
||
identityProven,
|
||
etradeManagerName,
|
||
etradeManagerPhone,
|
||
ownerMatchesEtrade: ownerNameMatchesEtrade(owner.name, etradeManagerName),
|
||
complete: subject !== null && identityProven,
|
||
};
|
||
}
|