Files
edr-platform/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts

303 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
};
}