mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
refactor(companies): one verified identity per company, no general manager
Replaces the owner/general-manager/PoA trio with a single identity whose
subject is the PoA when the company declares one and the owner otherwise.
- Drop the general manager everywhere: entity columns, DTOs, required-field
list, self-service attributes, gm* identity handling.
- New explicit poaDeclared answer ("yes"/"no") replaces poaSameAsOwner. A
DARS delegation letter is required iff it is "yes"; a freight forwarder is
forced to "yes" server-side and gets no waiver.
- Owner name/email/phone become typeable and required, prefilled from the
eTrade lookup, never falling back to the authenticated account.
- Store eTrade's manager separately (etradeManagerName/Phone) and expose
ownerMatchesEtrade so backoffice compares the asserted owner against the
licence instead of against itself.
- Foreign companies satisfy the identity with Fayda or a passport number, on
whichever subject is verifying (poaPassportNumber added).
- Write companies.email/phone from the owner unconditionally, so a company
without a Fayda-verified owner still has a notification address.
This commit is contained in:
@@ -39,6 +39,7 @@ import {
|
|||||||
CompleteIdentityVerificationDto,
|
CompleteIdentityVerificationDto,
|
||||||
} from "./dto/complete-identity-verification.dto";
|
} from "./dto/complete-identity-verification.dto";
|
||||||
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
||||||
|
import { SetPoaDeclaredDto } from "./dto/set-poa-declared.dto";
|
||||||
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
||||||
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
|
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
|
||||||
import {
|
import {
|
||||||
@@ -415,90 +416,31 @@ export class CompaniesController {
|
|||||||
@PortalCustomer()
|
@PortalCustomer()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Bind a completed Fayda verification to the company's owner or Power of Attorney. " +
|
"Bind a completed Fayda verification to the company's single identity. " +
|
||||||
"Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " +
|
"Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " +
|
||||||
|
"`subject` must match the company's PoA declaration — the representative when one is named, otherwise the owner. " +
|
||||||
"The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.",
|
"The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.",
|
||||||
})
|
})
|
||||||
async completeIdentityVerification(
|
async completeIdentityVerification(
|
||||||
@CurrentUser() user: CurrentIamUser,
|
@CurrentUser() user: CurrentIamUser,
|
||||||
@Body() dto: CompleteIdentityVerificationDto,
|
@Body() dto: CompleteIdentityVerificationDto,
|
||||||
): Promise<CompanyIdentityStateDto> {
|
): Promise<CompanyIdentityStateDto> {
|
||||||
return this.companiesService.completeIdentityVerification(user.id, dto, {
|
return this.companiesService.completeIdentityVerification(user.id, dto);
|
||||||
email: user.email,
|
|
||||||
phoneNumber: user.phoneNumber,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("identity/gm/same-as-owner")
|
@Patch("identity/poa-declared")
|
||||||
@PortalCustomer()
|
@PortalCustomer()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Declare the General Manager is the company's owner, copying the owner's verified identity across. " +
|
"Answer whether anyone holds power of attorney for this company — the question that decides whose identity is verified. " +
|
||||||
"Refused until the owner is Fayda-verified — there would be nothing proven to copy.",
|
'Answering "no" removes the representative entirely: their details, their verification, their passport number and the DARS delegation paper. ' +
|
||||||
|
'Refused for a freight forwarder, which cannot operate without a representative (its answer is always "yes").',
|
||||||
})
|
})
|
||||||
async setGmSameAsOwner(
|
async setPoaDeclared(
|
||||||
@CurrentUser() user: CurrentIamUser,
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Body() dto: SetPoaDeclaredDto,
|
||||||
): Promise<CompanyIdentityStateDto> {
|
): Promise<CompanyIdentityStateDto> {
|
||||||
return this.companiesService.setGmSameAsOwner(user.id, {
|
return this.companiesService.setPoaDeclared(user.id, dto.declared);
|
||||||
email: user.email,
|
|
||||||
phoneNumber: user.phoneNumber,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete("identity/gm")
|
|
||||||
@PortalCustomer()
|
|
||||||
@ApiOperation({
|
|
||||||
summary:
|
|
||||||
"Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " +
|
|
||||||
"Leaves the GM open to be verified in their own right, or typed where Fayda is optional.",
|
|
||||||
})
|
|
||||||
async clearGmIdentity(
|
|
||||||
@CurrentUser() user: CurrentIamUser,
|
|
||||||
): Promise<CompanyIdentityStateDto> {
|
|
||||||
return this.companiesService.clearGmIdentity(user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("identity/poa/same-as-owner")
|
|
||||||
@PortalCustomer()
|
|
||||||
@ApiOperation({
|
|
||||||
summary:
|
|
||||||
"Declare the Power of Attorney is the company's owner, copying the owner's identity across. " +
|
|
||||||
"Waives the DARS delegation paper — nobody delegates to themselves. " +
|
|
||||||
"Refused for an Ethiopian company whose owner is not Fayda-verified yet: its representative must be verified, and there would be nothing proven to copy.",
|
|
||||||
})
|
|
||||||
async setPoaSameAsOwner(
|
|
||||||
@CurrentUser() user: CurrentIamUser,
|
|
||||||
): Promise<CompanyIdentityStateDto> {
|
|
||||||
return this.companiesService.setPoaSameAsOwner(user.id, {
|
|
||||||
email: user.email,
|
|
||||||
phoneNumber: user.phoneNumber,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete("identity/poa/same-as-owner")
|
|
||||||
@PortalCustomer()
|
|
||||||
@ApiOperation({
|
|
||||||
summary:
|
|
||||||
"Undo the Power of Attorney \"same as owner\" declaration and the identity it copied, leaving the representative open to be verified in their own right. " +
|
|
||||||
"Unlike DELETE identity/fayda/poa this is allowed for a freight forwarder — it is how they change who represents them — and leaves the delegation paper on file.",
|
|
||||||
})
|
|
||||||
async clearPoaSameAsOwner(
|
|
||||||
@CurrentUser() user: CurrentIamUser,
|
|
||||||
): Promise<CompanyIdentityStateDto> {
|
|
||||||
return this.companiesService.clearPoaSameAsOwner(user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete("identity/fayda/poa")
|
|
||||||
@PortalCustomer()
|
|
||||||
@ApiOperation({
|
|
||||||
summary:
|
|
||||||
"Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " +
|
|
||||||
"Refused while the company holds a freight forwarder role, which cannot operate without a representative.",
|
|
||||||
})
|
|
||||||
async removePoaIdentity(
|
|
||||||
@CurrentUser() user: CurrentIamUser,
|
|
||||||
): Promise<CompanyIdentityStateDto> {
|
|
||||||
return this.companiesService.removePoaIdentity(user.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch("onboarding-step")
|
@Patch("onboarding-step")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -23,9 +23,18 @@ export const COMPANY_FIELD_LABELS: Record<string, string> = {
|
|||||||
contactPersonPhone: "Contact person phone",
|
contactPersonPhone: "Contact person phone",
|
||||||
contactPersonEmail: "Contact person email",
|
contactPersonEmail: "Contact person email",
|
||||||
contactPersonPosition: "Contact person position",
|
contactPersonPosition: "Contact person position",
|
||||||
generalManagerName: "General manager name",
|
ownerName: "Owner name",
|
||||||
generalManagerPhone: "General manager phone",
|
ownerPhone: "Owner phone",
|
||||||
generalManagerEmail: "General manager email",
|
ownerEmail: "Owner email",
|
||||||
|
ownerPassportNumber: "Owner passport number",
|
||||||
|
poaPassportNumber: "PoA passport number",
|
||||||
|
poaDeclared: "Has a Power of Attorney",
|
||||||
|
// Nothing writes these any more (the general manager was removed), but
|
||||||
|
// revisions and change requests filed before that still carry them — without
|
||||||
|
// the labels those rows render raw attribute keys to a reviewer.
|
||||||
|
generalManagerName: "General manager name (retired)",
|
||||||
|
generalManagerPhone: "General manager phone (retired)",
|
||||||
|
generalManagerEmail: "General manager email (retired)",
|
||||||
poaName: "PoA name",
|
poaName: "PoA name",
|
||||||
poaPhone: "PoA phone",
|
poaPhone: "PoA phone",
|
||||||
poaEmail: "PoA email",
|
poaEmail: "PoA email",
|
||||||
|
|||||||
@@ -5,21 +5,61 @@ import { Company, CompanyNationality } from "../entities/company.entity";
|
|||||||
import { ProfileType } from "../entities/company-profile.entity";
|
import { ProfileType } from "../entities/company-profile.entity";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The three people a company is verified through — its owner, its Power of
|
* The two people a company can be described through.
|
||||||
* Attorney and its General Manager. The owner is the person the company's
|
|
||||||
* existence is proven by; the other two are personnel it names.
|
|
||||||
*
|
*
|
||||||
* The GM is very often the owner, which is what the portal's "same as owner"
|
* The **owner** is whoever the eTrade TIN record names as the business's
|
||||||
* copy is for: that path reuses the owner's verified identity outright rather
|
* manager. Not necessarily the legal owner — eTrade's `ManagerNameEng` is
|
||||||
* than asking the same human to verify twice.
|
* simply the person on the licence — but that is the point: whoever the company
|
||||||
|
* puts forward here has to match the eTrade record, and the backoffice check is
|
||||||
|
* exactly that comparison (see `ownerMatchesEtrade`).
|
||||||
|
*
|
||||||
|
* The **Power of Attorney** is who the company delegates to act for it, when it
|
||||||
|
* delegates at all.
|
||||||
|
*
|
||||||
|
* Exactly ONE of them is identity-verified, and which one is decided by the
|
||||||
|
* company's own answer (see {@link PoaDeclaration}): the representative if
|
||||||
|
* there is one, otherwise the owner. There is no general manager — the concept
|
||||||
|
* was removed; it named who to talk to and gated nothing.
|
||||||
*/
|
*/
|
||||||
export const IDENTITY_SUBJECTS = ["owner", "poa", "gm"] as const;
|
export const IDENTITY_SUBJECTS = ["owner", "poa"] as const;
|
||||||
export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
|
export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The company's answer to "does anyone hold power of attorney for you?".
|
||||||
|
*
|
||||||
|
* Explicit rather than derived from "are any `poa*` keys set", because "no" is
|
||||||
|
* an answer that moves the verification onto the owner, while *absent* is a
|
||||||
|
* question the customer has not reached yet. Stored on `company.attributes`
|
||||||
|
* under {@link POA_DECLARED_KEY}.
|
||||||
|
*
|
||||||
|
* A freight forwarder never gets to answer: it signs on other companies'
|
||||||
|
* behalf, so a Power of Attorney (and the DARS paper evidencing it) is
|
||||||
|
* non-negotiable. {@link readPoaDeclaration} forces "yes" for them, which is
|
||||||
|
* why the declaration is read through that helper rather than off the blob.
|
||||||
|
*/
|
||||||
|
export const POA_DECLARATIONS = ["yes", "no"] as const;
|
||||||
|
export type PoaDeclaration = (typeof POA_DECLARATIONS)[number];
|
||||||
|
|
||||||
|
/** `company.attributes` key holding the {@link PoaDeclaration}. */
|
||||||
|
export const POA_DECLARED_KEY = "poaDeclared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `company.attributes` keys holding the eTrade record's own manager, captured
|
||||||
|
* at lookup time.
|
||||||
|
*
|
||||||
|
* Kept apart from `ownerName`/`ownerPhone` — which are what the *company*
|
||||||
|
* asserts, and what a Fayda verification overwrites — precisely so the two can
|
||||||
|
* be compared. Storing only one value would leave the reviewer comparing the
|
||||||
|
* owner field against itself.
|
||||||
|
*/
|
||||||
|
export const ETRADE_MANAGER_NAME_KEY = "etradeManagerName";
|
||||||
|
export const ETRADE_MANAGER_PHONE_KEY = "etradeManagerPhone";
|
||||||
|
|
||||||
export class CompleteIdentityVerificationDto {
|
export class CompleteIdentityVerificationDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
enum: IDENTITY_SUBJECTS,
|
enum: IDENTITY_SUBJECTS,
|
||||||
description: "Which of the company's people this verification is for.",
|
description:
|
||||||
|
"Which of the company's people this verification is for. Must match the company's PoA declaration — the representative when one is named, the owner when not.",
|
||||||
})
|
})
|
||||||
@IsIn(IDENTITY_SUBJECTS)
|
@IsIn(IDENTITY_SUBJECTS)
|
||||||
subject!: IdentitySubject;
|
subject!: IdentitySubject;
|
||||||
@@ -35,9 +75,11 @@ export class CompleteIdentityVerificationDto {
|
|||||||
state!: string;
|
state!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One person's verification state, as reported back to the portal. */
|
/** One person's identity state, as reported back to the portal. */
|
||||||
export class IdentityVerificationStateDto {
|
export class IdentityVerificationStateDto {
|
||||||
@ApiProperty() verified!: boolean;
|
@ApiProperty({ description: "True once a Fayda verification is bound." })
|
||||||
|
verified!: boolean;
|
||||||
|
|
||||||
@ApiProperty({ nullable: true }) name!: string | null;
|
@ApiProperty({ nullable: true }) name!: string | null;
|
||||||
@ApiProperty({ nullable: true }) phone!: string | null;
|
@ApiProperty({ nullable: true }) phone!: string | null;
|
||||||
@ApiProperty({ nullable: true }) email!: string | null;
|
@ApiProperty({ nullable: true }) email!: string | null;
|
||||||
@@ -45,13 +87,11 @@ export class IdentityVerificationStateDto {
|
|||||||
@ApiProperty({ nullable: true }) verifiedAt!: string | null;
|
@ApiProperty({ nullable: true }) verifiedAt!: string | null;
|
||||||
@ApiProperty({ nullable: true }) birthdate!: string | null;
|
@ApiProperty({ nullable: true }) birthdate!: string | null;
|
||||||
@ApiProperty({ nullable: true }) gender!: string | null;
|
@ApiProperty({ nullable: true }) gender!: string | null;
|
||||||
}
|
|
||||||
|
|
||||||
export class OwnerIdentityStateDto extends IdentityVerificationStateDto {
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
nullable: true,
|
nullable: true,
|
||||||
description:
|
description:
|
||||||
"Typed passport number — the foreign-company identity credential. Independent of Fayda: never written by a verification, and still required even if the owner also verifies.",
|
"Typed passport number. Fayda is an Ethiopian national ID, so a foreign company proves the identity with either — this is the alternative, not an addition.",
|
||||||
})
|
})
|
||||||
passportNumber!: string | null;
|
passportNumber!: string | null;
|
||||||
}
|
}
|
||||||
@@ -59,44 +99,55 @@ export class OwnerIdentityStateDto extends IdentityVerificationStateDto {
|
|||||||
export class CompanyIdentityStateDto {
|
export class CompanyIdentityStateDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
"True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.",
|
"True for a foreign company: a typed passport number proves the identity just as a Fayda verification does. An Ethiopian company must use Fayda.",
|
||||||
})
|
})
|
||||||
faydaRequired!: boolean;
|
passportAccepted!: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
|
enum: POA_DECLARATIONS,
|
||||||
|
nullable: true,
|
||||||
description:
|
description:
|
||||||
"True when the owner's passport number is mandatory — foreign companies only. Independent of faydaRequired: a foreign owner may verify with Fayda too, but the passport is still required.",
|
'Whether the company named a Power of Attorney. Null until the customer answers — which is itself an outstanding onboarding item, since the answer decides who verifies.',
|
||||||
})
|
})
|
||||||
passportRequired!: boolean;
|
poaDeclared!: PoaDeclaration | null;
|
||||||
|
|
||||||
@ApiProperty({ type: OwnerIdentityStateDto })
|
@ApiProperty({
|
||||||
owner!: OwnerIdentityStateDto;
|
enum: IDENTITY_SUBJECTS,
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
"Who the company's single identity verification belongs to: the PoA when one is named, the owner when not. Null while the declaration is unanswered.",
|
||||||
|
})
|
||||||
|
subject!: IdentitySubject | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: IdentityVerificationStateDto })
|
||||||
|
owner!: IdentityVerificationStateDto;
|
||||||
|
|
||||||
@ApiProperty({ type: IdentityVerificationStateDto })
|
@ApiProperty({ type: IdentityVerificationStateDto })
|
||||||
poa!: IdentityVerificationStateDto;
|
poa!: IdentityVerificationStateDto;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
"True when the Power of Attorney is the company's owner, declared through the portal's \"same as owner\" copy. Waives the DARS delegation paper — nobody delegates to themselves.",
|
"True once the subject is proven — Fayda-verified, or carrying a passport number where that is accepted.",
|
||||||
})
|
})
|
||||||
poaSameAsOwner!: boolean;
|
identityProven!: boolean;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: IdentityVerificationStateDto,
|
nullable: true,
|
||||||
description:
|
description:
|
||||||
"General manager. `verified` is true both when the GM verified with Fayda in their own right and when the company declared the GM is the owner — in the latter case the owner's Fayda sub backs it.",
|
"The manager named on the eTrade licence, captured at lookup. Null when eTrade returned none (ManagerNameEng is frequently blank).",
|
||||||
})
|
})
|
||||||
gm!: IdentityVerificationStateDto;
|
etradeManagerName!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
"Does the owner the company put forward match the person on the eTrade licence? This is the backoffice's check. Null when there is nothing to compare — no eTrade manager on file, or no owner name yet. Advisory, not a gate: eTrade's Latin transliteration and Fayda's rarely agree character-for-character, so a reviewer decides.",
|
||||||
|
})
|
||||||
|
ownerMatchesEtrade!: boolean | null;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
"True when the GM's identity is the owner's, declared through the portal's \"same as owner\" copy rather than a separate verification.",
|
"False while the declaration is unanswered or the subject is unproven. Field-level completeness (owner/PoA details, documents) is reported separately by the onboarding requirements.",
|
||||||
})
|
|
||||||
gmSameAsOwner!: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description:
|
|
||||||
"False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.",
|
|
||||||
})
|
})
|
||||||
complete!: boolean;
|
complete!: boolean;
|
||||||
}
|
}
|
||||||
@@ -105,26 +156,9 @@ export class CompanyIdentityStateDto {
|
|||||||
const PREFIX: Record<IdentitySubject, string> = {
|
const PREFIX: Record<IdentitySubject, string> = {
|
||||||
owner: "owner",
|
owner: "owner",
|
||||||
poa: "poa",
|
poa: "poa",
|
||||||
gm: "gm",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** `company.attributes` keys that together mean "a representative was entered". */
|
||||||
* Typed GM fields, kept in step with the Fayda-written ones.
|
|
||||||
*
|
|
||||||
* The GM predates this verification: its details are plain company columns
|
|
||||||
* that three notifier services mail (booking-lifecycle, train-scheduling and
|
|
||||||
* contract notifiers all read `company.generalManagerEmail`). A verification
|
|
||||||
* therefore writes BOTH — the `gm*` attributes carry the proof, these carry
|
|
||||||
* the value everything else already reads — and an unverified company keeps
|
|
||||||
* showing whatever was typed before this existed.
|
|
||||||
*/
|
|
||||||
const GM_TYPED_KEYS = {
|
|
||||||
name: "generalManagerName",
|
|
||||||
email: "generalManagerEmail",
|
|
||||||
phone: "generalManagerPhone",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/** company.attributes keys that together mean "a PoA was entered". */
|
|
||||||
const POA_KEYS = [
|
const POA_KEYS = [
|
||||||
"poaName",
|
"poaName",
|
||||||
"poaPhone",
|
"poaPhone",
|
||||||
@@ -139,7 +173,7 @@ function stateFor(
|
|||||||
): IdentityVerificationStateDto {
|
): IdentityVerificationStateDto {
|
||||||
const p = PREFIX[subject];
|
const p = PREFIX[subject];
|
||||||
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
||||||
const state: IdentityVerificationStateDto = {
|
return {
|
||||||
verified: Boolean(read(`${p}FaydaSub`)),
|
verified: Boolean(read(`${p}FaydaSub`)),
|
||||||
name: read(`${p}Name`),
|
name: read(`${p}Name`),
|
||||||
phone: read(`${p}Phone`),
|
phone: read(`${p}Phone`),
|
||||||
@@ -148,88 +182,111 @@ function stateFor(
|
|||||||
verifiedAt: read(`${p}FaydaVerifiedAt`),
|
verifiedAt: read(`${p}FaydaVerifiedAt`),
|
||||||
birthdate: read(`${p}Birthdate`),
|
birthdate: read(`${p}Birthdate`),
|
||||||
gender: read(`${p}Gender`),
|
gender: read(`${p}Gender`),
|
||||||
};
|
passportNumber: read(`${p}PassportNumber`),
|
||||||
if (subject !== "gm" || state.verified) return state;
|
|
||||||
|
|
||||||
// Companies onboarded before the GM was verifiable have typed details and no
|
|
||||||
// `gm*` attributes at all. Report those rather than a blank card — they are
|
|
||||||
// still what the notifiers mail — leaving `verified` false so the portal
|
|
||||||
// offers the upgrade instead of pretending the identity is proven.
|
|
||||||
//
|
|
||||||
// Only for such an unverified GM, which is the whole population this exists
|
|
||||||
// for. Merging the typed columns into a *verified* manager's state would read
|
|
||||||
// back the email the portal asked them to type when Fayda supplied none, and
|
|
||||||
// the input offering it — keyed on that value being absent — would vanish the
|
|
||||||
// moment it was saved, leaving a typo uncorrectable.
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
name: state.name ?? read(GM_TYPED_KEYS.name),
|
|
||||||
email: state.email ?? read(GM_TYPED_KEYS.email),
|
|
||||||
phone: state.phone ?? read(GM_TYPED_KEYS.phone),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive both people's verification state from the company row.
|
* The company's PoA declaration, or null when it hasn't answered yet.
|
||||||
*
|
*
|
||||||
* Pure and shared: `CompaniesService` gates on it and `ProfileResponseDto`
|
* A freight forwarder is never asked: it acts on other companies' behalf, so a
|
||||||
* renders from it, so the settings page and the onboarding wizard can never
|
* representative and the DARS paper behind them are mandatory. Forcing it here
|
||||||
* disagree with the rule the API actually enforces.
|
* — rather than only disabling the radio in the portal — is what stops a
|
||||||
|
* forwarder role added *after* onboarding from inheriting an old "no".
|
||||||
|
*/
|
||||||
|
export function readPoaDeclaration(
|
||||||
|
company: Pick<Company, "attributes" | "companyProfiles">,
|
||||||
|
): PoaDeclaration | null {
|
||||||
|
if (
|
||||||
|
(company.companyProfiles ?? []).some(
|
||||||
|
(p) => p.type === ProfileType.freightForwarder,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "yes";
|
||||||
|
}
|
||||||
|
const value = company.attributes?.[POA_DECLARED_KEY];
|
||||||
|
if (value === "yes" || value === "no") return value;
|
||||||
|
|
||||||
|
// No explicit answer, but the company holds a representative's details —
|
||||||
|
// so it has one, and owes everything a representative brings with them.
|
||||||
|
//
|
||||||
|
// Covers rows that predate the question (the migration derives the same way)
|
||||||
|
// and any write that reaches the attributes without going through
|
||||||
|
// `setPoaDeclared`. Without this, PoA details could be saved with the
|
||||||
|
// delegation paper silently unowed. Safe against a genuine "no": answering
|
||||||
|
// it clears these keys, so they cannot outlive the answer.
|
||||||
|
return POA_KEYS.some((k) => (company.attributes?.[k] as string | undefined)?.trim())
|
||||||
|
? "yes"
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do two people's names refer to the same person, as far as a string can tell?
|
||||||
|
*
|
||||||
|
* Deliberately loose: eTrade returns uppercase Latin transliterations of
|
||||||
|
* Amharic names and Fayda returns its own, so exact equality would flag almost
|
||||||
|
* every company. Case, punctuation, extra whitespace and word ORDER are all
|
||||||
|
* ignored — "ABEBE KEBEDE TESFA" and "Tesfa, Abebe Kebede" match. Anything
|
||||||
|
* beyond that is the reviewer's call, which is why the verdict is advisory.
|
||||||
|
*/
|
||||||
|
export function ownerNameMatchesEtrade(
|
||||||
|
ownerName: string | null | undefined,
|
||||||
|
etradeName: string | null | undefined,
|
||||||
|
): boolean | null {
|
||||||
|
const words = (v: string | null | undefined) =>
|
||||||
|
(v ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9ሀ-\s]/g, " ")
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort();
|
||||||
|
const a = words(ownerName);
|
||||||
|
const b = words(etradeName);
|
||||||
|
if (a.length === 0 || b.length === 0) return null;
|
||||||
|
return a.length === b.length && a.every((w, i) => w === b[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the company's identity state from its row.
|
||||||
|
*
|
||||||
|
* Pure and shared: `CompaniesService` gates on it, `ProfileResponseDto` and the
|
||||||
|
* backoffice's company DTO render from it, so the settings page, the onboarding
|
||||||
|
* wizard and the reviewer can never disagree with the rule the API enforces.
|
||||||
*/
|
*/
|
||||||
export function buildCompanyIdentityState(
|
export function buildCompanyIdentityState(
|
||||||
company: Company,
|
company: Company,
|
||||||
): CompanyIdentityStateDto {
|
): CompanyIdentityStateDto {
|
||||||
const attrs = company.attributes ?? {};
|
const attrs = company.attributes ?? {};
|
||||||
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
|
|
||||||
|
|
||||||
// Fayda is an Ethiopian national ID — a foreign company's owner may not hold
|
// Fayda is an Ethiopian national ID. A foreign company's people may hold
|
||||||
// one, so a typed passport number is the mandatory credential there instead.
|
// none, so a typed passport number stands in — either one proves the person,
|
||||||
// The two are mutually exclusive by nationality but independently tracked,
|
// and holding both is fine.
|
||||||
// since a foreign owner verifying with Fayda doesn't waive the passport.
|
const passportAccepted = company.nationality === CompanyNationality.Foreign;
|
||||||
const foreign = company.nationality === CompanyNationality.Foreign;
|
|
||||||
const faydaRequired = !foreign;
|
|
||||||
const passportRequired = foreign;
|
|
||||||
|
|
||||||
const owner: OwnerIdentityStateDto = {
|
const owner = stateFor(attrs, "owner");
|
||||||
...stateFor(attrs, "owner"),
|
|
||||||
passportNumber: read("ownerPassportNumber"),
|
|
||||||
};
|
|
||||||
const poa = stateFor(attrs, "poa");
|
const poa = stateFor(attrs, "poa");
|
||||||
const poaDue =
|
const poaDeclared = readPoaDeclaration(company);
|
||||||
(company.companyProfiles ?? []).some(
|
const subject: IdentitySubject | null =
|
||||||
(p) => p.type === ProfileType.freightForwarder,
|
poaDeclared === "yes" ? "poa" : poaDeclared === "no" ? "owner" : null;
|
||||||
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
|
||||||
|
|
||||||
const gm = stateFor(attrs, "gm");
|
const proven = (s: IdentityVerificationStateDto) =>
|
||||||
const gmSameAsOwner = Boolean(attrs.gmSameAsOwner);
|
s.verified || (passportAccepted && Boolean(s.passportNumber?.trim()));
|
||||||
const poaSameAsOwner = Boolean(attrs.poaSameAsOwner);
|
|
||||||
|
|
||||||
const ownerProven = faydaRequired
|
const identityProven =
|
||||||
? owner.verified
|
subject === null ? false : proven(subject === "poa" ? poa : owner);
|
||||||
: !passportRequired || Boolean(owner.passportNumber);
|
|
||||||
|
|
||||||
// Fayda is an Ethiopian national ID, so only an Ethiopian company's
|
const etradeManagerName =
|
||||||
// personnel can be held to it. A foreign company may nominate a
|
(attrs[ETRADE_MANAGER_NAME_KEY] as string | undefined) ?? null;
|
||||||
// representative who holds one — and is offered the verification — but a
|
|
||||||
// typed name has to remain sufficient, or a foreign company whose PoA has no
|
|
||||||
// Fayda ID could never finish onboarding.
|
|
||||||
const poaProven = faydaRequired
|
|
||||||
? poa.verified
|
|
||||||
: poa.verified || Boolean(poa.name?.trim());
|
|
||||||
|
|
||||||
// The GM is deliberately absent from this verdict: it names who to talk to,
|
|
||||||
// not what the company may do, and it has never gated trading. Capturing it
|
|
||||||
// through Fayda changes how it is collected, not whether it is required.
|
|
||||||
const complete = ownerProven && (!poaDue || poaProven);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
faydaRequired,
|
passportAccepted,
|
||||||
passportRequired,
|
poaDeclared,
|
||||||
|
subject,
|
||||||
owner,
|
owner,
|
||||||
poa,
|
poa,
|
||||||
poaSameAsOwner,
|
identityProven,
|
||||||
gm,
|
etradeManagerName,
|
||||||
gmSameAsOwner,
|
ownerMatchesEtrade: ownerNameMatchesEtrade(owner.name, etradeManagerName),
|
||||||
complete,
|
complete: subject !== null && identityProven,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
* truth the wizard uses to auto-finish.
|
* truth the wizard uses to auto-finish.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { CompanyIdentityStateDto } from "./complete-identity-verification.dto";
|
import {
|
||||||
|
CompanyIdentityStateDto,
|
||||||
|
PoaDeclaration,
|
||||||
|
} from "./complete-identity-verification.dto";
|
||||||
|
|
||||||
export interface OnboardingInfoField {
|
export interface OnboardingInfoField {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -38,15 +41,19 @@ export interface OnboardingLicenseProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface OnboardingPoaState {
|
export interface OnboardingPoaState {
|
||||||
/** True when the company operates as a freight forwarder — PoA is mandatory. */
|
|
||||||
required: boolean;
|
|
||||||
/** True once any PoA detail has been entered. */
|
|
||||||
provided: boolean;
|
|
||||||
/**
|
/**
|
||||||
* True when the DARS delegation paper is owed — a PoA exists (or is
|
* True when the company operates as a freight forwarder: it signs on other
|
||||||
* mandatory) and is not the owner themselves. An owner representing their own
|
* companies' behalf, so a Power of Attorney is non-negotiable and the portal
|
||||||
* company delegates to nobody, so there is no delegation to evidence.
|
* renders the question answered and locked rather than asking it.
|
||||||
*/
|
*/
|
||||||
|
locked: boolean;
|
||||||
|
/**
|
||||||
|
* The company's answer to "does anyone hold power of attorney for you?".
|
||||||
|
* Null until it answers — which is itself outstanding, since the answer
|
||||||
|
* decides whose identity is verified.
|
||||||
|
*/
|
||||||
|
declared: PoaDeclaration | null;
|
||||||
|
/** True when the DARS delegation paper is owed — i.e. `declared === "yes"`. */
|
||||||
delegationLetterRequired: boolean;
|
delegationLetterRequired: boolean;
|
||||||
/** True when the DARS delegation paper is stored for the company. */
|
/** True when the DARS delegation paper is stored for the company. */
|
||||||
delegationLetterUploaded: boolean;
|
delegationLetterUploaded: boolean;
|
||||||
|
|||||||
@@ -42,9 +42,10 @@ export class ProfileResponseDto {
|
|||||||
contactPersonPhone: string | null;
|
contactPersonPhone: string | null;
|
||||||
/** Phone that passed SMS OTP verification (drives the verify-step resume). */
|
/** Phone that passed SMS OTP verification (drives the verify-step resume). */
|
||||||
contactVerifiedPhone: string | null;
|
contactVerifiedPhone: string | null;
|
||||||
generalManagerName: string | null;
|
/** The owner — whoever the eTrade licence names as the business's manager. */
|
||||||
generalManagerEmail: string | null;
|
ownerName: string | null;
|
||||||
generalManagerPhone: string | null;
|
ownerEmail: string | null;
|
||||||
|
ownerPhone: string | null;
|
||||||
|
|
||||||
poaName: string | null;
|
poaName: string | null;
|
||||||
poaPhone: string | null;
|
poaPhone: string | null;
|
||||||
@@ -55,12 +56,13 @@ export class ProfileResponseDto {
|
|||||||
profileId: string;
|
profileId: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fayda verification state for the company's owner and PoA — not the general
|
* The company's single identity verification, plus who it belongs to.
|
||||||
* manager, which is a separate typed role. The settings tabs and the
|
*
|
||||||
* onboarding wizard render from `identity.faydaRequired` /
|
* `identity.subject` follows the company's PoA declaration — the
|
||||||
* `identity.passportRequired`: an Ethiopian company verifies the owner (and
|
* representative when one is named, otherwise the owner. The settings tabs
|
||||||
* PoA) instead of typing their details; a foreign one requires a typed
|
* and the onboarding wizard render from it: `passportAccepted` says whether a
|
||||||
* passport number instead.
|
* typed passport number is an alternative to Fayda (foreign companies only),
|
||||||
|
* and `ownerMatchesEtrade` is the check the backoffice makes.
|
||||||
*/
|
*/
|
||||||
identity: CompanyIdentityStateDto;
|
identity: CompanyIdentityStateDto;
|
||||||
|
|
||||||
@@ -113,9 +115,9 @@ export class ProfileResponseDto {
|
|||||||
this.contactPersonEmail = attrs.contactPersonEmail ?? null;
|
this.contactPersonEmail = attrs.contactPersonEmail ?? null;
|
||||||
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
|
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
|
||||||
this.contactVerifiedPhone = attrs.contactVerifiedPhone ?? null;
|
this.contactVerifiedPhone = attrs.contactVerifiedPhone ?? null;
|
||||||
this.generalManagerName = attrs.generalManagerName ?? null;
|
this.ownerName = attrs.ownerName ?? null;
|
||||||
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
|
this.ownerEmail = attrs.ownerEmail ?? null;
|
||||||
this.generalManagerPhone = attrs.generalManagerPhone ?? null;
|
this.ownerPhone = attrs.ownerPhone ?? null;
|
||||||
this.poaName = attrs.poaName ?? null;
|
this.poaName = attrs.poaName ?? null;
|
||||||
this.poaPhone = attrs.poaPhone ?? null;
|
this.poaPhone = attrs.poaPhone ?? null;
|
||||||
this.poaEmail = attrs.poaEmail ?? null;
|
this.poaEmail = attrs.poaEmail ?? null;
|
||||||
|
|||||||
@@ -89,9 +89,14 @@ export class ResponseCompanyDto {
|
|||||||
houseNo?: string | null;
|
houseNo?: string | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owner/PoA Fayda verification state, shared with the portal
|
* The company's single identity verification, shared with the portal
|
||||||
* (`buildCompanyIdentityState`) so backoffice never re-derives — or
|
* (`buildCompanyIdentityState`) so backoffice never re-derives — or disagrees
|
||||||
* disagrees with — the rule the API actually enforces.
|
* with — the rule the API actually enforces.
|
||||||
|
*
|
||||||
|
* `subject` names whose verification it is (the PoA when one is declared,
|
||||||
|
* otherwise the owner), and `ownerMatchesEtrade` is the reviewer's check:
|
||||||
|
* does the owner the company put forward match the manager on the eTrade
|
||||||
|
* licence? Advisory — see the note on that field.
|
||||||
*/
|
*/
|
||||||
identity: CompanyIdentityStateDto;
|
identity: CompanyIdentityStateDto;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -45,11 +45,11 @@ export class UpdateProfileDto {
|
|||||||
@Matches(/^\d{10,11}$/, { message: "VAT number must be 10 or 11 digits" })
|
@Matches(/^\d{10,11}$/, { message: "VAT number must be 10 or 11 digits" })
|
||||||
vatNumber?: string;
|
vatNumber?: string;
|
||||||
|
|
||||||
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
|
// `fanNumber` is deliberately absent: the FAN is a Fayda number, so it would
|
||||||
// company's PoA (or its general manager), so it is derived from a completed
|
// have to come from a completed verification rather than be typed — and
|
||||||
// Fayda verification rather than typed. The global validation pipe runs with
|
// Fayda's userinfo carries no national ID number, so nothing produces one.
|
||||||
// forbidNonWhitelisted, so a client that still sends it gets a 400 telling it
|
// The global validation pipe runs with forbidNonWhitelisted, so a client that
|
||||||
// so — see CompaniesService.completeIdentityVerification.
|
// still sends it gets a 400 telling it so.
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -78,18 +78,31 @@ export class UpdateProfileDto {
|
|||||||
@IsValidPhone()
|
@IsValidPhone()
|
||||||
contactVerifiedPhone?: string;
|
contactVerifiedPhone?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The owner — whoever the eTrade licence names as the business's manager.
|
||||||
|
*
|
||||||
|
* All three are required before onboarding can be submitted, whatever their
|
||||||
|
* source: the eTrade lookup prefills the name and phone, a Fayda
|
||||||
|
* verification can supply all three, and the portal renders an input for
|
||||||
|
* whatever neither did (eTrade returns no email at all, and Fayda's email
|
||||||
|
* claim is optional, so that one is usually typed).
|
||||||
|
*
|
||||||
|
* Locked once a Fayda verification supplied them — see
|
||||||
|
* `IDENTITY_OWNED_FIELDS` — but only field by field: a claim that came back
|
||||||
|
* empty owns nothing and stays typeable.
|
||||||
|
*/
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
generalManagerName?: string;
|
ownerName?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
generalManagerEmail?: string;
|
ownerEmail?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsValidPhone()
|
@IsValidPhone()
|
||||||
generalManagerPhone?: string;
|
ownerPhone?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -113,15 +126,22 @@ export class UpdateProfileDto {
|
|||||||
poaAddress?: string;
|
poaAddress?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The owner's passport number — the identity credential for a foreign
|
* Passport numbers — the alternative identity credential for a foreign
|
||||||
* company, since Fayda is an Ethiopian national ID. Plain typed field, never
|
* company, since Fayda is an Ethiopian national ID. Plain typed fields, never
|
||||||
* written or locked by a Fayda verification: still required even if the
|
* written or locked by a Fayda verification.
|
||||||
* owner also verifies.
|
*
|
||||||
|
* Only the one belonging to the company's declared identity subject matters:
|
||||||
|
* the PoA's when a representative is named, the owner's otherwise. An
|
||||||
|
* Ethiopian company is not offered either — it must use Fayda.
|
||||||
*/
|
*/
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
ownerPassportNumber?: string;
|
ownerPassportNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
poaPassportNumber?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
|
|||||||
@@ -112,29 +112,11 @@ export class Company extends BaseEntity {
|
|||||||
})
|
})
|
||||||
contactPersonPhone?: string | null;
|
contactPersonPhone?: string | null;
|
||||||
|
|
||||||
@Column({
|
// The general manager used to live here as three columns. It named who to
|
||||||
name: "general_manager_name",
|
// talk to, gated nothing, and nothing ever populated the columns — the write
|
||||||
type: "varchar",
|
// path put the values in `attributes`. Removed in RemoveGeneralManager; the
|
||||||
length: 100,
|
// company's people are now its owner (whoever the eTrade licence names) and
|
||||||
nullable: true,
|
// its Power of Attorney, both in `attributes`.
|
||||||
})
|
|
||||||
generalManagerName?: string | null;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: "general_manager_email",
|
|
||||||
type: "varchar",
|
|
||||||
length: 150,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
generalManagerEmail?: string | null;
|
|
||||||
|
|
||||||
@Column({
|
|
||||||
name: "general_manager_phone",
|
|
||||||
type: "varchar",
|
|
||||||
length: 20,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
generalManagerPhone?: string | null;
|
|
||||||
|
|
||||||
@Column({ name: "website", type: "varchar", length: 200, nullable: true })
|
@Column({ name: "website", type: "varchar", length: 200, nullable: true })
|
||||||
website?: string | null;
|
website?: string | null;
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import { DataSource, EntityManager } from "typeorm";
|
|||||||
* `companies.contact_person_phone` is deliberately NOT consulted: the live write
|
* `companies.contact_person_phone` is deliberately NOT consulted: the live write
|
||||||
* path stores that value in the `attributes` jsonb and has never populated the
|
* path stores that value in the `attributes` jsonb and has never populated the
|
||||||
* column, so every reader of it was silently falling through to `phone` anyway.
|
* column, so every reader of it was silently falling through to `phone` anyway.
|
||||||
* `companies.general_manager_email` is the same trap on the email side — see
|
* The retired `general_manager_email` column was the same trap on the email
|
||||||
* {@link companyNotifyEmailExpr}.
|
* side — see {@link companyNotifyEmailExpr}.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,24 +50,30 @@ export function companyNotifyPhoneExpr(alias: string): string {
|
|||||||
* SQL expression for the company's notification address, given the joined `pc`
|
* SQL expression for the company's notification address, given the joined `pc`
|
||||||
* alias.
|
* alias.
|
||||||
*
|
*
|
||||||
* `companies.email` alone is not enough: it is written from ONE place — a
|
* `companies.email` is now the owner's email, written on every profile save
|
||||||
* Fayda-verified owner's email claim — so a foreign company, whose owner proves
|
* whether or not the owner verified with Fayda — and the owner's email is a
|
||||||
* identity by passport instead, never gets one. Readers papered over that with
|
* required onboarding field, so a company that finished onboarding has one.
|
||||||
* `COALESCE(email, general_manager_email)`, but that column has the same problem
|
* (It used to be written ONLY for a Fayda-verified owner, which meant every
|
||||||
* `contact_person_phone` has above: onboarding writes the value into the
|
* foreign company had none; the gap was papered over with a
|
||||||
* `attributes` jsonb and nothing has ever populated the column, so the fallback
|
* `general_manager_email` leg that could never fire, because onboarding wrote
|
||||||
* could not fire and the mail was dropped in silence.
|
* that value into `attributes` and nothing ever populated the column.)
|
||||||
*
|
*
|
||||||
* So: the company address, then the two the customer actually filled in during
|
* The `generalManagerEmail` attribute is still consulted, after the contact
|
||||||
* onboarding, then the account that registered them — which always has one,
|
* person: the general manager was removed, but companies onboarded before that
|
||||||
* signup requires it. `NULLIF` because a blank jsonb key is not an address and
|
* may carry an address there and nowhere else. RemoveGeneralManager backfills
|
||||||
* `COALESCE` would happily stop on it.
|
* `companies.email` from it, so this is belt-and-braces for rows that migration
|
||||||
|
* could not resolve.
|
||||||
|
*
|
||||||
|
* `NULLIF` because a blank jsonb key is not an address and `COALESCE` would
|
||||||
|
* happily stop on it. The account that registered the company is the last
|
||||||
|
* resort — signup guarantees it has one.
|
||||||
*/
|
*/
|
||||||
export function companyNotifyEmailExpr(alias: string): string {
|
export function companyNotifyEmailExpr(alias: string): string {
|
||||||
return `COALESCE(
|
return `COALESCE(
|
||||||
NULLIF(${alias}.email, ''),
|
NULLIF(${alias}.email, ''),
|
||||||
NULLIF(${alias}.attributes->>'generalManagerEmail', ''),
|
NULLIF(${alias}.attributes->>'ownerEmail', ''),
|
||||||
NULLIF(${alias}.attributes->>'contactPersonEmail', ''),
|
NULLIF(${alias}.attributes->>'contactPersonEmail', ''),
|
||||||
|
NULLIF(${alias}.attributes->>'generalManagerEmail', ''),
|
||||||
NULLIF(pc.email, '')
|
NULLIF(pc.email, '')
|
||||||
)`;
|
)`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,9 +157,6 @@ async function main() {
|
|||||||
email: 'negad-indode-demo@edr.local',
|
email: 'negad-indode-demo@edr.local',
|
||||||
contactPersonName: 'Marshalling Demo',
|
contactPersonName: 'Marshalling Demo',
|
||||||
contactPersonPhone: '251900000202',
|
contactPersonPhone: '251900000202',
|
||||||
generalManagerName: 'Demo Manager',
|
|
||||||
generalManagerEmail: 'negad-indode-demo@edr.local',
|
|
||||||
generalManagerPhone: '251900000202',
|
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
@@ -247,9 +247,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder {
|
|||||||
website: null,
|
website: null,
|
||||||
contactPersonName: 'First Last Mile Demo',
|
contactPersonName: 'First Last Mile Demo',
|
||||||
contactPersonPhone: '251900000101',
|
contactPersonPhone: '251900000101',
|
||||||
generalManagerName: 'Demo Manager',
|
|
||||||
generalManagerEmail: COMPANY_EMAIL,
|
|
||||||
generalManagerPhone: '251900000101',
|
|
||||||
},
|
},
|
||||||
{ conflictPaths: { tin: true } },
|
{ conflictPaths: { tin: true } },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -324,9 +324,6 @@ export class DemoBookingsSeeder {
|
|||||||
website: null,
|
website: null,
|
||||||
contactPersonName: "Train Scheduling",
|
contactPersonName: "Train Scheduling",
|
||||||
contactPersonPhone: "251900000001",
|
contactPersonPhone: "251900000001",
|
||||||
generalManagerName: "Demo Manager",
|
|
||||||
generalManagerEmail: COMPANY_EMAIL,
|
|
||||||
generalManagerPhone: "251900000001",
|
|
||||||
},
|
},
|
||||||
{ conflictPaths: { tin: true } },
|
{ conflictPaths: { tin: true } },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -175,9 +175,6 @@ export class PaidImportExportMileDemoSeeder {
|
|||||||
website: null,
|
website: null,
|
||||||
contactPersonName: 'Paid Mile Demo',
|
contactPersonName: 'Paid Mile Demo',
|
||||||
contactPersonPhone: '251900000202',
|
contactPersonPhone: '251900000202',
|
||||||
generalManagerName: 'Demo Manager',
|
|
||||||
generalManagerEmail: COMPANY_EMAIL,
|
|
||||||
generalManagerPhone: '251900000202',
|
|
||||||
},
|
},
|
||||||
{ conflictPaths: { tin: true } },
|
{ conflictPaths: { tin: true } },
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user