diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 1b14845b2..a0c609f67 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -39,6 +39,7 @@ import { CompleteIdentityVerificationDto, } from "./dto/complete-identity-verification.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; +import { SetPoaDeclaredDto } from "./dto/set-poa-declared.dto"; import { StartOnboardingDto } from "./dto/start-onboarding.dto"; import { DashboardQueryDto } from "./dto/dashboard-query.dto"; import { @@ -415,90 +416,31 @@ export class CompaniesController { @PortalCustomer() @ApiOperation({ summary: - "Bind a completed Fayda verification to the company's owner or Power of Attorney. " + + "Bind a completed Fayda verification to the company's single identity. " + "Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " + + "`subject` must match the company's PoA declaration — the representative when one is named, otherwise the owner. " + "The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.", }) async completeIdentityVerification( @CurrentUser() user: CurrentIamUser, @Body() dto: CompleteIdentityVerificationDto, ): Promise { - return this.companiesService.completeIdentityVerification(user.id, dto, { - email: user.email, - phoneNumber: user.phoneNumber, - }); + return this.companiesService.completeIdentityVerification(user.id, dto); } - @Post("identity/gm/same-as-owner") + @Patch("identity/poa-declared") @PortalCustomer() @ApiOperation({ summary: - "Declare the General Manager is the company's owner, copying the owner's verified identity across. " + - "Refused until the owner is Fayda-verified — there would be nothing proven to copy.", + "Answer whether anyone holds power of attorney for this company — the question that decides whose identity is verified. " + + 'Answering "no" removes the representative entirely: their details, their verification, their passport number and the DARS delegation paper. ' + + 'Refused for a freight forwarder, which cannot operate without a representative (its answer is always "yes").', }) - async setGmSameAsOwner( + async setPoaDeclared( @CurrentUser() user: CurrentIamUser, + @Body() dto: SetPoaDeclaredDto, ): Promise { - return this.companiesService.setGmSameAsOwner(user.id, { - email: user.email, - phoneNumber: user.phoneNumber, - }); - } - - @Delete("identity/gm") - @PortalCustomer() - @ApiOperation({ - summary: - "Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " + - "Leaves the GM open to be verified in their own right, or typed where Fayda is optional.", - }) - async clearGmIdentity( - @CurrentUser() user: CurrentIamUser, - ): Promise { - 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 { - 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 { - 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 { - return this.companiesService.removePoaIdentity(user.id); + return this.companiesService.setPoaDeclared(user.id, dto.declared); } @Patch("onboarding-step") diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 5dd55e704..4362c4285 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -33,8 +33,13 @@ import { buildCompanyIdentityState, CompanyIdentityStateDto, CompleteIdentityVerificationDto, + ETRADE_MANAGER_NAME_KEY, + ETRADE_MANAGER_PHONE_KEY, IDENTITY_SUBJECTS, IdentitySubject, + POA_DECLARED_KEY, + PoaDeclaration, + readPoaDeclaration, } from "./dto/complete-identity-verification.dto"; import { ETradeService } from "./services/etrade.service"; import { CompanyNotifierService } from "./company-notifier.service"; @@ -93,15 +98,16 @@ const POA_ATTRIBUTES = [ "poaAddress", ] as const; /** - * Personnel an approved company maintains itself: its contact person, its - * general manager and its Power of Attorney. These name who to talk to, not - * what the company is allowed to do, so freezing the settings page until a - * reviewer gets to a new phone number costs more than it protects. They write - * straight to the live row even for an active company. + * The one person an approved company maintains itself: its contact person. + * That names who to talk to, not what the company is allowed to do, so freezing + * the settings page until a reviewer gets to a new phone number costs more than + * it protects. It writes straight to the live row even for an active company. * - * The PoA's *delegation letter* is deliberately not here — the paper is the - * thing that actually evidences the delegation, so it still goes through - * review (see `uploadPoaDelegationLetter`), as does the owner's own identity. + * The owner and the Power of Attorney are deliberately NOT here. Between them + * they carry the company's only identity verification — the owner is who the + * eTrade licence names, the PoA is who may act for the company — so an edit to + * either is exactly the kind of change a reviewer exists to see. Their + * delegation letter has always gone through review (`uploadPoaDelegationLetter`). */ const SELF_SERVICE_ATTRIBUTES: readonly string[] = [ "contactPersonName", @@ -109,12 +115,8 @@ const SELF_SERVICE_ATTRIBUTES: readonly string[] = [ "contactPersonEmail", "contactPersonPhone", "contactVerifiedPhone", - "generalManagerName", - "generalManagerEmail", - "generalManagerPhone", - ...POA_ATTRIBUTES, ]; -/** Mandatory once the company operates as a freight forwarder. */ +/** Mandatory once the company names a Power of Attorney. */ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ { key: "poaName", label: "PoA name" }, { key: "poaEmail", label: "PoA email" }, @@ -122,42 +124,25 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ ]; /** - * `attributes` key prefix per verifiable person. The owner is NOT the general - * 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. + * `attributes` key prefix per person. The owner is whoever the eTrade licence + * names as the business's manager; the PoA is whoever the company delegates to. + * Exactly one of them carries the company's identity verification — which one + * is the company's own declaration (`poaDeclared`). */ const IDENTITY_PREFIX: Record = { owner: "owner", poa: "poa", - gm: "gm", }; -/** - * 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'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 = { owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"], poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"], - gm: [...GM_TYPED_FIELDS], }; /** @@ -242,23 +227,29 @@ export class CompaniesService { label: "Contact person phone", get: (c) => c.attributes?.contactPersonPhone, }, + // The owner — whoever the eTrade licence names as the business's manager. + // All three are required whatever their source: the eTrade lookup fills + // the name and phone, a Fayda verification can fill all three, and the + // portal renders an input for whatever neither supplied. eTrade never + // returns an email and Fayda's email claim is optional, so in practice + // that field is usually typed — which is fine, because there IS an input + // for it. What there is no longer is a fallback to the signed-in account: + // the person onboarding is not necessarily the person on the licence, and + // silently stamping their address onto the owner made the record a guess. { - key: "generalManagerName", - label: "General manager name", - get: (c) => c.attributes?.generalManagerName, + key: "ownerName", + label: "Owner name", + get: (c) => c.attributes?.ownerName, }, - // The manager's EMAIL is deliberately absent. It was demanded because the - // notifiers were believed to mail it, and Fayda's email claim is optional - // — so a manager the government proved without one blocked the whole - // submission over an address nothing could produce. `companyNotifyEmailExpr` - // now resolves the address itself and falls through to the contact - // person's, then to the registering account's (which signup guarantees), - // so nothing depends on this being filled. It is still collected and still - // preferred when present; it just no longer holds the company hostage. { - key: "generalManagerPhone", - label: "General manager phone", - get: (c) => c.attributes?.generalManagerPhone, + key: "ownerEmail", + label: "Owner email", + get: (c) => c.attributes?.ownerEmail, + }, + { + key: "ownerPhone", + label: "Owner phone", + get: (c) => c.attributes?.ownerPhone, }, ]; @@ -768,6 +759,7 @@ export class CompaniesService { company: Company, dto: Partial & { faydaIdentity?: VerifiedIdentityAttributes; + etradeManager?: { name: string; phone: string }; }, ): Record { const companyUpdates: Record = {}; @@ -796,12 +788,10 @@ export class CompaniesService { attrUpdates.contactVerifiedPhone = normalizeE164( dto.contactVerifiedPhone, ); - if (dto.generalManagerName !== undefined) - attrUpdates.generalManagerName = dto.generalManagerName; - if (dto.generalManagerEmail !== undefined) - attrUpdates.generalManagerEmail = dto.generalManagerEmail; - if (dto.generalManagerPhone !== undefined) - attrUpdates.generalManagerPhone = normalizeE164(dto.generalManagerPhone); + if (dto.ownerName !== undefined) attrUpdates.ownerName = dto.ownerName; + if (dto.ownerEmail !== undefined) attrUpdates.ownerEmail = dto.ownerEmail; + if (dto.ownerPhone !== undefined) + attrUpdates.ownerPhone = normalizeE164(dto.ownerPhone); if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName; if (dto.poaPhone !== undefined) attrUpdates.poaPhone = normalizeE164(dto.poaPhone); @@ -829,11 +819,29 @@ export class CompaniesService { if (dto.etradePhone !== undefined) companyUpdates.etradePhone = normalizeE164(dto.etradePhone); - // A plain typed field — never Fayda-verified, so no lock ever applies to - // it. Independent of the owner's verification: still required for a - // foreign company even if the owner also verifies with Fayda. + // Plain typed fields — never Fayda-verified, so no lock ever applies. For a + // foreign company a passport number proves the person just as a Fayda + // verification does, so it is collected for whichever of the two carries + // the company's identity. if (dto.ownerPassportNumber !== undefined) attrUpdates.ownerPassportNumber = dto.ownerPassportNumber; + if (dto.poaPassportNumber !== undefined) + attrUpdates.poaPassportNumber = dto.poaPassportNumber; + + // eTrade's own manager, captured at lookup by `applyEtradeSourcedFields`. + // Never off the wire — the global pipe runs `forbidNonWhitelisted`, so this + // reaches us only from that method, the same guarantee `faydaIdentity` has. + // Stored apart from `ownerName`/`ownerPhone` so the two can be COMPARED: + // the company asserts an owner, eTrade states a manager, and the backoffice + // check is whether they are the same person (`ownerMatchesEtrade`). + if (dto.etradeManager) { + if (dto.etradeManager.name) + attrUpdates[ETRADE_MANAGER_NAME_KEY] = dto.etradeManager.name; + if (dto.etradeManager.phone) + attrUpdates[ETRADE_MANAGER_PHONE_KEY] = normalizeE164( + dto.etradeManager.phone, + ); + } // A verified identity overwrites the person's details. `faydaIdentity` // never comes off the wire — the global validation pipe runs with @@ -844,19 +852,17 @@ export class CompaniesService { Object.assign(attrUpdates, dto.faydaIdentity); } - // Keyed on the verified VALUE, not on `ownerFaydaSub`: Fayda's email and - // phone claims are optional, so a verification can prove the person while - // supplying neither (see completeIdentityVerification's conditional - // spreads). The portal falls back to the account email / eTrade's - // registered phone in exactly that case and submits it on every save of - // the company step — locking against an absent value would 400 that - // forever, and re-verifying could never clear it because Fayda still has - // nothing to return. - if (attrUpdates.ownerFaydaSub) { - if (attrUpdates.ownerEmail) companyUpdates.email = attrUpdates.ownerEmail; - if (attrUpdates.ownerPhone) - companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone)); - } + // The company's own contact columns follow the owner, verified or not. + // + // This used to be gated on `ownerFaydaSub`, which meant `companies.email` + // was only ever written for a Fayda-verified owner — so every foreign + // company (passport instead of Fayda) had none, and the notification + // resolver papered over it by falling through to the general manager's + // address. The GM is gone and the owner's email is now required outright, + // so this is simply where it lands. + if (attrUpdates.ownerEmail) companyUpdates.email = attrUpdates.ownerEmail; + if (attrUpdates.ownerPhone) + companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone)); // Renaming a Fayda-verified person by hand would launder the guarantee // away, so the verification keeps these fields: a submission that disagrees @@ -874,12 +880,10 @@ export class CompaniesService { if (dto.faydaIdentity && field in dto.faydaIdentity) continue; const stored = company.attributes?.[field]; // A verification that supplied nothing for this field left no guarantee - // to protect, so it stays typeable. Matters most for the GM — - // `setGmSameAsOwner` copies `ownerEmail ?? null` onto - // `generalManagerEmail` while setting `gmFaydaSub`, and - // REQUIRED_COMPANY_INFO still demands that email, so holding a null - // here makes it required, hidden by the portal's "same as owner" card, - // and unwritable all at once. + // to protect, so it stays typeable. This is what makes the required + // owner email reachable: Fayda's email claim is optional, so a verified + // owner routinely has none stored — locking against that absence would + // make `REQUIRED_COMPANY_INFO` demand a field nobody could ever fill. if (stored === null || stored === undefined || stored === "") continue; attrUpdates[field] = stored; } @@ -965,9 +969,7 @@ export class CompaniesService { if (POA_ATTRIBUTES.some((k) => dto[k] !== undefined)) { const attributes = this.mapProfileDtoToCompanyUpdates(company, dto) .attributes as Record; - await this.assertPoaDelegationSatisfied(company.id, attributes, { - requirePoa: await this.isFreightForwarder(company.id), - }); + await this.assertPoaDelegationSatisfied(company, attributes); } if (company.status !== CompanyStatus.Active) { @@ -1615,12 +1617,13 @@ export class CompaniesService { // the last place it has to be checked — the role may have been applied // for before the paper was withdrawn. if (company && existing.type === ProfileType.freightForwarder) { - this.assertIdentityVerified(company, { requirePoa: true }); - await this.assertPoaDelegationSatisfied( - company.id, - company.attributes, - { requirePoa: true }, - ); + // The row was loaded FOR UPDATE, so its relations are not populated — + // and `readPoaDeclaration` reads `companyProfiles` to force "yes" for a + // forwarder. This IS the forwarder profile being approved, so naming it + // is enough (and truthful) for both assertions below. + company.companyProfiles = company.companyProfiles ?? [existing]; + this.assertIdentityVerified(company); + await this.assertPoaDelegationSatisfied(company, company.attributes); } const [companyDocs, profileDocs] = await Promise.all([ @@ -1884,11 +1887,11 @@ export class CompaniesService { // without a Power of Attorney and its DARS paper — checked here so the // customer is told at the point of asking, not at review. if (type === ProfileType.freightForwarder) { - this.assertIdentityVerified(company, { requirePoa: true }); + const asForwarder = this.withProfileType(company, type); + this.assertIdentityVerified(asForwarder); await this.assertPoaDelegationSatisfied( - companyId, + asForwarder, await this.effectivePoaAttributes(company), - { requirePoa: true }, ); } @@ -1930,11 +1933,11 @@ export class CompaniesService { let created = await this.companyProfilesRepo.findByType(companyId, type); if (!created && type === ProfileType.freightForwarder) { - this.assertIdentityVerified(company, { requirePoa: true }); + const asForwarder = this.withProfileType(company, type); + this.assertIdentityVerified(asForwarder); await this.assertPoaDelegationSatisfied( - companyId, + asForwarder, await this.effectivePoaAttributes(company), - { requirePoa: true }, ); } if (!created) { @@ -2028,33 +2031,26 @@ export class CompaniesService { ); const missingLicenses = licenseProfiles.filter((p) => !p.uploaded); - // 4. Power of Attorney. Optional in general, but a freight forwarder acts on - // other companies' behalf so its PoA is mandatory. Either way, a PoA that - // has been entered must be evidenced by the DARS delegation paper — a legal - // requirement, so unlike the documents above it does not depend on the - // upload set carrying a field for it (see poa-delegation.constants.ts). - const poaRequired = (company.companyProfiles ?? []).some( - (p) => p.type === ProfileType.freightForwarder, - ); - const poaProvided = POA_ATTRIBUTES.some((k) => - (company.attributes?.[k] as string | undefined)?.trim(), - ); + // 4. Power of Attorney. Whether there is one at all is the company's own + // declaration — the question the wizard asks outright — and that answer is + // what decides whose identity gets verified, so an unanswered one is itself + // outstanding. A freight forwarder never gets to answer: it signs on other + // companies' behalf, so `readPoaDeclaration` forces "yes". + // + // Once there IS a representative, their details and the DARS delegation + // paper are both due. The paper is a legal requirement, so unlike the + // documents above it does not depend on the upload set carrying a field for + // it (see poa-delegation.constants.ts). + const poaDue = identity.poaDeclared === "yes"; const delegation = await this.getPoaDelegationState(company.id); - // "There is a representative" and "a paper is owed for them" used to be the - // same condition. They part company once the owner represents the company - // themselves: the representative's details are still required, but nobody - // delegates to themselves, so no DARS paper is due (`assertPoaDelegationSatisfied` - // returns on the same flag — the two must agree). - const poaDue = poaRequired || poaProvided; - const delegationDue = poaDue && !identity.poaSameAsOwner; + const delegationDue = poaDue; // The representative's details normally arrive from their Fayda // verification — but Fayda's email and phone claims are optional and // routinely come back empty, and the PoA step renders an input for whatever // the verification did not supply. So these are askable after all, and are - // reported outstanding once a PoA is required or provided; reporting - // nothing here let a freight forwarder finish onboarding with a - // representative the API's own `REQUIRED_POA_FIELDS` calls incomplete, then - // 400'd their next PoA edit for it. + // reported outstanding once a PoA is declared; reporting nothing here let a + // freight forwarder finish onboarding with a representative the API's own + // `REQUIRED_POA_FIELDS` calls incomplete, then 400'd their next PoA edit. const missingPoaFields = poaDue ? REQUIRED_POA_FIELDS.filter( (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), @@ -2065,10 +2061,15 @@ export class CompaniesService { // replace it before the application counts as complete. const flaggedDelegation = delegationDue && delegation.flagged; - // Mirrors `poaProven` in buildCompanyIdentityState — see the note there. - const poaProven = identity.faydaRequired - ? identity.poa.verified - : identity.poa.verified || Boolean(identity.poa.name?.trim()); + // 5. The single identity. Who proves it is `identity.subject`; how they may + // prove it is nationality-dependent (Fayda always, a passport number as an + // alternative for a foreign company). Both are derived once in + // buildCompanyIdentityState so this list can never disagree with the gate + // `assertIdentityVerified` actually enforces. + const identitySubjectLabel = + identity.subject === "poa" + ? "your Power of Attorney" + : "the person named on your eTrade licence"; const outstanding = [ ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), @@ -2086,58 +2087,38 @@ export class CompaniesService { `Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`, ] : []), - ...(identity.faydaRequired && !identity.owner.verified - ? ["Verify the company owner's identity with Fayda"] + ...(identity.poaDeclared === null + ? ["Tell us whether anyone holds power of attorney for your company"] : []), - // Nationality-aware, exactly like `poaProven` in - // buildCompanyIdentityState and the check in `assertIdentityVerified`: - // Fayda is an Ethiopian national ID, so a foreign company's typed - // representative has to count. Demanding a verification here regardless - // made this list disagree with the rule actually enforced, and left a - // foreign freight forwarder unable to submit — asked for a Fayda - // verification its representative may have no way to obtain. - ...((poaRequired || poaProvided) && !poaProven + ...(identity.poaDeclared !== null && !identity.identityProven ? [ - identity.faydaRequired - ? "Verify your Power of Attorney's identity with Fayda" - : "Name your Power of Attorney, or verify them with Fayda", + identity.passportAccepted + ? `Verify ${identitySubjectLabel} with Fayda, or add their passport number` + : `Verify ${identitySubjectLabel} with Fayda`, ] : []), - ...(identity.passportRequired && !identity.owner.passportNumber - ? ["Add the company owner's passport number"] - : []), ]; // Progress spans every required item the user has to satisfy: company-info - // fields, required documents, one license per operational profile, and the - // PoA details/paper whenever those are mandatory. + // fields, required documents, one license per operational profile, the PoA + // details/paper once declared, and the two identity items — answering the + // declaration, and proving the person it points at. const requiredDocCount = documents.filter((d) => d.isRequired).length; // The delegation paper plus the representative's own required details — // `completed` below subtracts every one of those it is still missing, so // leaving them out of the total would make the bar understate progress. const poaItemCount = (poaDue ? REQUIRED_POA_FIELDS.length : 0) + (delegationDue ? 1 : 0); - // One item per identity credential the company has to prove: the owner - // always (Fayda for Ethiopian, passport for foreign), plus the PoA once - // there is one — Fayda for an Ethiopian company, a named representative - // for a foreign one, same rule as `poaProven` above. Counting a foreign - // company's typed PoA as unproven here left the progress bar permanently - // short of 100% on an item it had already satisfied. - const ownerCredentialDue = - identity.faydaRequired || identity.passportRequired; - const ownerCredentialProven = identity.faydaRequired - ? identity.owner.verified - : Boolean(identity.owner.passportNumber); - const identityItemCount = (ownerCredentialDue ? 1 : 0) + (poaDue ? 1 : 0); const missingIdentityCount = - (ownerCredentialDue && !ownerCredentialProven ? 1 : 0) + - (poaDue && !poaProven ? 1 : 0); + (identity.poaDeclared === null ? 1 : 0) + + (identity.identityProven ? 0 : 1); const total = requiredInfo.length + requiredDocCount + licenseProfiles.length + poaItemCount + - identityItemCount; + // The declaration and the verification it selects. + 2; const completed = total - (missingInfo.length + @@ -2157,8 +2138,12 @@ export class CompaniesService { documents, licenseProfiles, poa: { - required: poaRequired, - provided: poaProvided, + // "Locked" rather than "required": a freight forwarder is not asked the + // question at all, everyone else answers it themselves. + locked: identity.poaDeclared === "yes" && (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ), + declared: identity.poaDeclared, delegationLetterRequired: delegationDue, delegationLetterUploaded: delegation.onFile, delegationLetterFlagged: delegation.flagged, @@ -2689,39 +2674,44 @@ export class CompaniesService { * judged against the files that would survive it (`ignoreFileIds`). */ private async assertPoaDelegationSatisfied( - companyId: string, + company: Company, attributes: Record | null | undefined, - opts: { requirePoa: boolean; ignoreFileIds?: string[] }, + opts: { ignoreFileIds?: string[] } = {}, ): Promise { + // The declaration is the whole gate. A company that says it has no + // representative owes nothing here; one that says it has owes the details + // AND the paper, with no exceptions — including a freight forwarder, for + // whom `readPoaDeclaration` forces "yes" regardless of what is stored. + const declared = readPoaDeclaration({ + attributes: attributes as Company["attributes"], + companyProfiles: company.companyProfiles, + }); + if (declared !== "yes") return; + + const isForwarder = (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ); const read = (key: string) => (attributes?.[key] as string | undefined)?.trim(); - const poaProvided = POA_ATTRIBUTES.some((k) => read(k)); - if (!opts.requirePoa && !poaProvided) return; - if (opts.requirePoa) { - const missing = REQUIRED_POA_FIELDS.filter((f) => !read(f.key)); - if (missing.length > 0) { - throw new BadRequestException( - `A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` + - `Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`, - ); - } + const missing = REQUIRED_POA_FIELDS.filter((f) => !read(f.key)); + if (missing.length > 0) { + throw new BadRequestException( + (isForwarder + ? "A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. " + : "You told us someone holds power of attorney for this company. ") + + `Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`, + ); } - // Nobody delegates to themselves: an owner representing their own company - // has no delegation to evidence, so the DARS paper is not owed. The - // representative's own details are still required above — a forwarder's - // counterparties need someone to contact either way. - if (attributes?.poaSameAsOwner) return; - const { onFile, flagged } = await this.getPoaDelegationState( - companyId, + company.id, opts.ignoreFileIds, ); if (!onFile) { throw new BadRequestException( `Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` + - (opts.requirePoa ? " — it is required for freight forwarders." : "."), + (isForwarder ? " — it is required for freight forwarders." : "."), ); } if (flagged) { @@ -2732,54 +2722,145 @@ export class CompaniesService { } } - /** Does this company operate as a freight forwarder? */ - private async isFreightForwarder(companyId: string): Promise { - const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); - return profiles.some((p) => p.type === ProfileType.freightForwarder); - } - // --------------------------------------------------------------------------- - // Fayda identity verification (owner / PoA) + // Identity verification (one per company) // - // A completed VeriFayda verification proves a person's name, phone, email - // and address — Fayda's userinfo carries no national ID number, so none of - // that is collected here. For an Ethiopian company both the owner and its - // PoA (once named) must be verified before the company can trade. Fayda is - // an Ethiopian national ID system, so a foreign company's owner proves - // identity with a typed passport number instead — required on its own - // terms, not waived by an owner who happens to verify with Fayda too. + // A company proves itself through exactly ONE person. Which one is its own + // declaration: the Power of Attorney when it names a representative, + // otherwise the owner — whoever the eTrade licence names as the business's + // manager. There is no general manager and no "same as owner" copy: an owner + // who represents their own company simply answers "no, nobody holds power of + // attorney", and verifies as the owner. + // + // A completed VeriFayda verification proves that person's name, phone, email + // and address (Fayda's userinfo carries no national ID number, so none is + // collected). Fayda is an Ethiopian national ID, so a foreign company may + // instead type a passport number for the same person — an alternative, not an + // addition. // --------------------------------------------------------------------------- /** - * Verification state for both people, plus whether it is mandatory here. - * `complete` answers the gate question directly so the portal, the onboarding - * requirements and the assertions below all read the same verdict — the - * derivation itself is shared with ProfileResponseDto. + * The company's identity state: both people, who currently carries the + * verification, whether it is proven, and whether the owner the company put + * forward matches the eTrade licence. `complete` answers the gate question + * directly so the portal, the onboarding requirements and the assertions + * above all read the same verdict — the derivation itself is shared with + * ProfileResponseDto and the backoffice company DTO. */ getCompanyIdentityState(company: Company): CompanyIdentityStateDto { return buildCompanyIdentityState(company); } /** - * Complete a Fayda verification and bind the identity to one of the company's - * people. The portal starts the flow through the shared + * Record whether anyone holds power of attorney for this company. + * + * This is the question that decides whose identity gets verified, so it is + * stored rather than inferred from "are any `poa*` keys set" — absence means + * "not asked yet", which is an outstanding onboarding item in its own right. + * + * Answering "no" tears the representative down: their details, their + * verification, their passport number and the DARS paper evidencing them all + * go. Leaving any of it behind would keep the company on the hook for a + * delegation it has just said does not exist. + * + * Refused for a freight forwarder — it signs on other companies' behalf, so a + * representative is non-negotiable. (`readPoaDeclaration` forces "yes" for + * them anyway; this is the honest error rather than a silently ignored write.) + */ + async setPoaDeclared( + userId: string, + declared: PoaDeclaration, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + + if ( + declared === "no" && + (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ) + ) { + throw new BadRequestException( + "A freight forwarder acts on other companies' behalf, so it must have a Power of Attorney. Remove the freight forwarder role first.", + ); + } + + const attributes: Record = { + ...(company.attributes ?? {}), + [POA_DECLARED_KEY]: declared, + }; + + if (declared === "no") { + for (const key of [ + ...POA_ATTRIBUTES, + "poaFaydaSub", + "poaFaydaVerifiedAt", + "poaBirthdate", + "poaGender", + "poaPassportNumber", + ]) { + attributes[key] = null; + } + await this.deletePoaDelegationFiles(company.id); + } + + const updated = await this.companiesRepo.update(company.id, { attributes }); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); + updated.companyProfiles = company.companyProfiles; + return this.getCompanyIdentityState(updated); + } + + /** Drop every DARS paper on file — the delegation it evidenced is gone. */ + private async deletePoaDelegationFiles(companyId: string): Promise { + const records = await this.filesService.findByResource( + companyId, + COMPANY_RESOURCE, + ); + for (const r of records) { + if ( + r.code === POA_DELEGATION_FILE_KEY || + r.code === POA_DELEGATION_PENDING_CODE + ) { + await this.filesService.remove(r.id); + await this.withdrawDocumentIntent(companyId, r.id); + } + } + } + + /** + * Complete a Fayda verification and bind the identity to the company. + * + * The portal starts the flow through the shared * `POST /fayda/verification/start` and only tells us which person it was for * here, at completion — so the verifayda module stays generic and its session * table needs no company-specific column. + * + * The subject has to be the one the company's declaration calls for. A + * verification bound to the other person would sit on the record looking + * proven while the gate — which reads only the declared subject — stayed + * unsatisfied, and nothing in the portal would explain why. */ async completeIdentityVerification( userId: string, dto: CompleteIdentityVerificationDto, - /** - * The signed-in account, used as the owner's fallback contact details. - * Optional so the callers that only have a user id keep compiling — they - * simply get no fallback. - */ - account?: { email?: string; phoneNumber?: string }, ): Promise { const { company } = await this.getCompanyInfoByUserId(userId); + const state = buildCompanyIdentityState(company); const prefix = IDENTITY_PREFIX[dto.subject]; + if (state.subject === null) { + throw new BadRequestException( + "Tell us whether anyone holds power of attorney for this company first — the answer decides whose identity we verify.", + ); + } + if (state.subject !== dto.subject) { + throw new BadRequestException( + state.subject === "poa" + ? "This company is represented by a Power of Attorney, so it is their identity we need — not the owner's." + : "This company has no Power of Attorney, so it is the owner's identity we need.", + ); + } + const result = await this.verifaydaService.completeVerification({ code: dto.code, state: dto.state, @@ -2790,71 +2871,38 @@ export class CompaniesService { ); } - // An owner who is also the company's representative is a supported answer, - // not a conflict — the same way the GM is very often the owner. Small - // companies routinely have one human in all three roles, and the portal's - // "same as owner" cards exist precisely so they can say so. No identity - // here is refused for colliding with another. - - const now = new Date().toISOString(); - - // Fayda's email and phone claims are optional and routinely come back empty. - // For the owner that leaves the company with no contact details at all: the - // step renders no input for them (they are the verification's output), and - // "same as owner" then copies those blanks onto `generalManagerEmail` / - // `generalManagerPhone`, which `REQUIRED_COMPANY_INFO` demands at submit — - // an unfixable dead end. The account doing the onboarding is the one contact - // we always have, and it is already OTP-proven, so it stands in. + // Only what Fayda actually returned is written. Its email and phone claims + // are optional and routinely come back empty — the portal renders an input + // for whatever is missing and the customer fills it in. // - // Owner only: the PoA and the GM are other people, and the registering - // account's address is not theirs to wear. - const isOwner = dto.subject === "owner"; - const email = result.email || (isOwner ? account?.email : undefined); - const phone = - result.phoneNumber || (isOwner ? account?.phoneNumber : undefined); - + // There is deliberately NO fallback to the signed-in account. The person + // onboarding is not necessarily the person on the licence, so stamping + // their address onto the owner turned a required field into a guess that + // looked verified. const identity: VerifiedIdentityAttributes = { [`${prefix}FaydaSub`]: result.sub, - [`${prefix}FaydaVerifiedAt`]: now, + [`${prefix}FaydaVerifiedAt`]: new Date().toISOString(), [`${prefix}Birthdate`]: result.birthdate ?? null, [`${prefix}Gender`]: result.gender ?? null, // The verified payload owns the person's details from here on. ...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}), - ...(email ? { [`${prefix}Email`]: email } : {}), + ...(result.email ? { [`${prefix}Email`]: result.email } : {}), // Fayda returns whatever the national registry holds, which is routinely a // local number ("0911223344"). Every typed phone in this service is stored // E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here // becomes a value the portal reads back and cannot resubmit. - ...(phone ? { [`${prefix}Phone`]: normalizeE164(phone) } : {}), + ...(result.phoneNumber + ? { [`${prefix}Phone`]: normalizeE164(result.phoneNumber) } + : {}), ...(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. - // Verifying the representative in their own right answers the question the - // "same as owner" declaration answered, so the declaration goes. - if (dto.subject === "poa") { - identity.poaSameAsOwner = false; - } - - 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 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") { + // An approved company's identity is what its approval rested on, so + // re-verifying is staged for backoffice review rather than quietly + // rewriting a live record. Both subjects go through review now: whichever + // one the declaration points at IS the company's proof, and the owner is + // additionally the person the reviewer checks against the eTrade licence. + if (company.status === CompanyStatus.Active) { await this.stageIdentityChange(company, userId, identity); return this.getCompanyIdentityState(company); } @@ -2868,261 +2916,6 @@ 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, - /** Same fallback as {@link completeIdentityVerification}, for owners - * verified before that fallback existed — their stored contact details are - * blank, and copying blanks here would block the submit. */ - account?: { email?: string; phoneNumber?: string }, - ): Promise { - 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 ownerEmail = (attrs.ownerEmail as string | undefined) || account?.email || null; - const ownerPhone = (attrs.ownerPhone as string | undefined) || account?.phoneNumber || null; - - const copied: Record = { - gmSameAsOwner: true, - gmFaydaSub: ownerSub, - gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(), - gmName: attrs.ownerName ?? null, - gmEmail: ownerEmail, - gmPhone: ownerPhone ? normalizeE164(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: ownerEmail, - generalManagerPhone: ownerPhone ? normalizeE164(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 { - 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); - } - - /** - * Declare that the company's Power of Attorney is its owner. - * - * An owner representing their own company is the ordinary case for a small - * business, so this is a supported answer rather than the conflict it used to - * be refused as. Two shapes, matching {@link setGmSameAsOwner}: - * - * - A Fayda-verified owner is a proven identity, so it is copied outright — - * the representative inherits the verification instead of the same human - * being sent through Fayda a second time. - * - A foreign company's owner is backed by a typed passport, so there is - * nothing proven to copy. The declaration is still recorded (it is what - * waives the DARS paper) and whatever owner details exist come across; the - * portal types the rest, which `poaProven` accepts for a foreign company. - * - * Refused for an Ethiopian company whose owner is not verified yet: Fayda is - * mandatory for its representative, so a declaration there would record a - * representative that could never satisfy the gate. - */ - async setPoaSameAsOwner( - userId: string, - /** Same fallback as {@link completeIdentityVerification} — an owner whose - * Fayda claims carried no email/phone has none stored, and copying blanks - * onto a freight forwarder's PoA would block the submit on - * `REQUIRED_POA_FIELDS`. */ - account?: { email?: string; phoneNumber?: string }, - ): Promise { - const { company } = await this.getCompanyInfoByUserId(userId); - const attrs = company.attributes ?? {}; - const state = buildCompanyIdentityState(company); - const ownerSub = attrs.ownerFaydaSub as string | undefined; - - if (state.faydaRequired && !ownerSub) { - throw new BadRequestException( - "Verify the company owner with Fayda first — there is no proven identity to reuse yet.", - ); - } - - const ownerEmail = (attrs.ownerEmail as string | undefined) || account?.email; - const ownerPhone = - (attrs.ownerPhone as string | undefined) || account?.phoneNumber; - - // Only non-blank values are copied: a blank here would overwrite something - // the portal typed for a foreign company, whose owner has no verified - // claims to draw on. - const copied: Record = { poaSameAsOwner: true }; - const copy = (key: string, value: unknown) => { - if (value !== null && value !== undefined && value !== "") - copied[key] = value; - }; - copy("poaName", attrs.ownerName); - copy("poaEmail", ownerEmail); - copy("poaPhone", ownerPhone ? normalizeE164(ownerPhone) : undefined); - copy("poaAddress", attrs.ownerAddress); - - if (ownerSub) { - copied.poaFaydaSub = ownerSub; - copied.poaFaydaVerifiedAt = - attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(); - copy("poaBirthdate", attrs.ownerBirthdate); - copy("poaGender", attrs.ownerGender); - } - - 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 PoA "same as owner" declaration, clearing the identity it copied - * so a different representative can be verified (or typed, for a foreign - * company). - * - * Separate from {@link removePoaIdentity}, which drops the representative and - * their paper and is refused to a freight forwarder. Undoing a declaration is - * how a forwarder changes its mind about who represents it, so it must stay - * open to them — the submit gate still refuses a forwarder that never names a - * replacement. The delegation paper is left alone for the same reason: the - * company still owes one, now for whoever comes next. - * - * A no-op when no declaration is in place: a stray call must not wipe a - * representative who verified in their own right. - */ - async clearPoaSameAsOwner(userId: string): Promise { - const { company } = await this.getCompanyInfoByUserId(userId); - const attrs = { ...(company.attributes ?? {}) }; - if (!attrs.poaSameAsOwner) return this.getCompanyIdentityState(company); - - attrs.poaSameAsOwner = false; - for (const key of [ - ...POA_ATTRIBUTES, - "poaFaydaSub", - "poaFaydaVerifiedAt", - "poaBirthdate", - "poaGender", - ]) { - 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. - * - * Only the PoA can go: a company always has an owner, and a freight forwarder - * always has a representative. Once a PoA is Fayda-verified its - * fields are locked, so blanking the form is no longer a way out — without - * this the customer would be stuck with a representative they cannot remove. - */ - async removePoaIdentity(userId: string): Promise { - const { company } = await this.getCompanyInfoByUserId(userId); - if ( - (company.companyProfiles ?? []).some( - (p) => p.type === ProfileType.freightForwarder, - ) - ) { - throw new BadRequestException( - "A freight forwarder must have a Power of Attorney. Remove the freight forwarder role first.", - ); - } - - const cleared: Record = { poaSameAsOwner: false }; - for (const key of [ - ...POA_ATTRIBUTES, - "poaFaydaSub", - "poaFaydaVerifiedAt", - "poaBirthdate", - "poaGender", - ]) { - cleared[key] = null; - } - const attributes = { ...(company.attributes ?? {}), ...cleared }; - - // The paper evidences a representative who no longer exists. - const records = await this.filesService.findByResource( - company.id, - COMPANY_RESOURCE, - ); - for (const r of records) { - if ( - r.code === POA_DELEGATION_FILE_KEY || - r.code === POA_DELEGATION_PENDING_CODE - ) { - await this.filesService.remove(r.id); - await this.withdrawDocumentIntent(company.id, r.id); - } - } - - const updated = await this.companiesRepo.update(company.id, { attributes }); - if (!updated) - throw new NotFoundException(`Company ${company.id} not found`); - updated.companyProfiles = company.companyProfiles; - return this.getCompanyIdentityState(updated); - } - /** Stage a verified identity onto the company's pending change request. */ private async stageIdentityChange( company: Company, @@ -3171,62 +2964,56 @@ export class CompaniesService { } /** - * The gate: an Ethiopian company's owner must be Fayda-verified, and so must - * its Power of Attorney once it has one; a foreign company's owner must carry - * a passport number instead. Called from the same places as - * `assertPoaDelegationSatisfied` — the two rules describe the same moment - * (who may act for this company, and on what evidence) and drifting them - * apart is how one of them ends up unenforced. + * The company as it will be once `type` is one of its roles. + * + * Taking on the freight-forwarder role is checked BEFORE the profile row + * exists, and both assertions below read `companyProfiles` — a forwarder is + * what forces the PoA declaration to "yes". Judging the company as it stands + * would let one that answered "no" pick up the role and skip the very + * requirement the role exists to impose. Read-only; never persisted. */ - private assertIdentityVerified( - company: Company, - opts: { requirePoa: boolean }, - ): void { + private withProfileType(company: Company, type: ProfileType): Company { + const profiles = company.companyProfiles ?? []; + if (profiles.some((p) => p.type === type)) return company; + return { + ...company, + companyProfiles: [...profiles, { type } as CompanyProfile], + } as Company; + } + + /** + * The gate: the company's ONE identity must be proven. + * + * Which person that is comes from the company's own declaration — the + * representative when it names one, otherwise the owner (whoever the eTrade + * licence names as manager). How they prove it depends on nationality: Fayda + * for an Ethiopian company, Fayda *or* a typed passport number for a foreign + * one, whose people may hold no Fayda ID at all. + * + * Called from the same places as `assertPoaDelegationSatisfied` — the two + * rules describe the same moment (who may act for this company, and on what + * evidence) and drifting them apart is how one of them ends up unenforced. + */ + private assertIdentityVerified(company: Company): void { const state = buildCompanyIdentityState(company); - // Only the owner's credential is nationality-specific: Fayda for an - // Ethiopian company, a typed passport number for a foreign one. - if (state.passportRequired) { - if (!state.owner.passportNumber) { - throw new BadRequestException( - "Add the company owner's passport number before continuing.", - ); - } - } else if (!state.owner.verified) { + if (state.poaDeclared === null) { throw new BadRequestException( - "Verify the company owner's identity with Fayda before continuing.", + "Tell us whether anyone holds power of attorney for this company — the answer decides whose identity we verify.", ); } - const poaNamed = POA_ATTRIBUTES.some((k) => - (company.attributes?.[k] as string | undefined)?.trim(), + if (state.identityProven) return; + + const who = + state.subject === "poa" + ? "your Power of Attorney" + : "the person named on your eTrade licence"; + throw new BadRequestException( + state.passportAccepted + ? `Verify ${who} with Fayda, or add their passport number.` + : `Verify ${who} with Fayda before continuing.`, ); - 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 - ? "Verify your Power of Attorney with Fayda — a freight forwarder cannot operate without one." - : "Verify the Power of Attorney you named with Fayda, or remove the representative.", - ); - } } /** @@ -3350,12 +3137,9 @@ export class CompaniesService { // the representative it evidences is gone too (which, for an Active // company, means the clearing edit is already staged). await this.assertPoaDelegationSatisfied( - company.id, + company, await this.effectivePoaAttributes(company), - { - requirePoa: await this.isFreightForwarder(company.id), - ignoreFileIds: [fileId], - }, + { ignoreFileIds: [fileId] }, ); if (record.code === POA_DELEGATION_PENDING_CODE) { @@ -3583,7 +3367,7 @@ export class CompaniesService { */ private async applyEtradeSourcedFields( company: Company, - dto: UpdateProfileDto, + dto: UpdateProfileDto & { etradeManager?: { name: string; phone: string } }, ): Promise { const touched = ETRADE_SOURCED_FIELDS.some( (key) => key !== "tin" && dto[key] !== undefined, @@ -3626,5 +3410,18 @@ export class CompaniesService { // (the onboarding/settings card lets the customer type it directly then). if (value) (dto as Record)[key] = value; } + + // Capture the licence's own manager alongside the registration it belongs + // to. NOT written onto `ownerName`/`ownerPhone`: those are what the company + // asserts (and what a Fayda verification owns), and overwriting them here + // would destroy the very difference the backoffice is asked to check. The + // portal prefills the owner from these, so they agree unless someone made + // them disagree — which is exactly the case worth surfacing. + if (registration.managerName || registration.managerPhone) { + dto.etradeManager = { + name: registration.managerName, + phone: registration.managerPhone, + }; + } } } diff --git a/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts b/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts index 83fa539e3..71b60a268 100644 --- a/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts +++ b/apps/edr-freight-api/src/modules/companies/company-revision-diff.util.ts @@ -23,9 +23,18 @@ export const COMPANY_FIELD_LABELS: Record = { contactPersonPhone: "Contact person phone", contactPersonEmail: "Contact person email", contactPersonPosition: "Contact person position", - generalManagerName: "General manager name", - generalManagerPhone: "General manager phone", - generalManagerEmail: "General manager email", + ownerName: "Owner name", + ownerPhone: "Owner phone", + ownerEmail: "Owner email", + ownerPassportNumber: "Owner passport number", + poaPassportNumber: "PoA passport number", + poaDeclared: "Has a Power of Attorney", + // Nothing writes these any more (the general manager was removed), but + // revisions and change requests filed before that still carry them — without + // the labels those rows render raw attribute keys to a reviewer. + generalManagerName: "General manager name (retired)", + generalManagerPhone: "General manager phone (retired)", + generalManagerEmail: "General manager email (retired)", poaName: "PoA name", poaPhone: "PoA phone", poaEmail: "PoA email", diff --git a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts index df39949d4..6fe1ecc74 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/complete-identity-verification.dto.ts @@ -5,21 +5,61 @@ import { Company, CompanyNationality } from "../entities/company.entity"; import { ProfileType } from "../entities/company-profile.entity"; /** - * The three people a company is verified through — its owner, its Power of - * Attorney and its General Manager. The owner is the person the company's - * existence is proven by; the other two are personnel it names. + * The two people a company can be described through. * - * The GM is very often the owner, which is what the portal's "same as owner" - * copy is for: that path reuses the owner's verified identity outright rather - * than asking the same human to verify twice. + * The **owner** is whoever the eTrade TIN record names as the business's + * manager. Not necessarily the legal owner — eTrade's `ManagerNameEng` is + * simply the person on the licence — but that is the point: whoever the company + * puts forward here has to match the eTrade record, and the backoffice check is + * exactly that comparison (see `ownerMatchesEtrade`). + * + * The **Power of Attorney** is who the company delegates to act for it, when it + * delegates at all. + * + * Exactly ONE of them is identity-verified, and which one is decided by the + * company's own answer (see {@link PoaDeclaration}): the representative if + * there is one, otherwise the owner. There is no general manager — the concept + * was removed; it named who to talk to and gated nothing. */ -export const IDENTITY_SUBJECTS = ["owner", "poa", "gm"] as const; +export const IDENTITY_SUBJECTS = ["owner", "poa"] as const; export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number]; +/** + * The company's answer to "does anyone hold power of attorney for you?". + * + * Explicit rather than derived from "are any `poa*` keys set", because "no" is + * an answer that moves the verification onto the owner, while *absent* is a + * question the customer has not reached yet. Stored on `company.attributes` + * under {@link POA_DECLARED_KEY}. + * + * A freight forwarder never gets to answer: it signs on other companies' + * behalf, so a Power of Attorney (and the DARS paper evidencing it) is + * non-negotiable. {@link readPoaDeclaration} forces "yes" for them, which is + * why the declaration is read through that helper rather than off the blob. + */ +export const POA_DECLARATIONS = ["yes", "no"] as const; +export type PoaDeclaration = (typeof POA_DECLARATIONS)[number]; + +/** `company.attributes` key holding the {@link PoaDeclaration}. */ +export const POA_DECLARED_KEY = "poaDeclared"; + +/** + * `company.attributes` keys holding the eTrade record's own manager, captured + * at lookup time. + * + * Kept apart from `ownerName`/`ownerPhone` — which are what the *company* + * asserts, and what a Fayda verification overwrites — precisely so the two can + * be compared. Storing only one value would leave the reviewer comparing the + * owner field against itself. + */ +export const ETRADE_MANAGER_NAME_KEY = "etradeManagerName"; +export const ETRADE_MANAGER_PHONE_KEY = "etradeManagerPhone"; + export class CompleteIdentityVerificationDto { @ApiProperty({ enum: IDENTITY_SUBJECTS, - description: "Which of the company's people this verification is for.", + description: + "Which of the company's people this verification is for. Must match the company's PoA declaration — the representative when one is named, the owner when not.", }) @IsIn(IDENTITY_SUBJECTS) subject!: IdentitySubject; @@ -35,9 +75,11 @@ export class CompleteIdentityVerificationDto { state!: string; } -/** One person's verification state, as reported back to the portal. */ +/** One person's identity state, as reported back to the portal. */ export class IdentityVerificationStateDto { - @ApiProperty() verified!: boolean; + @ApiProperty({ description: "True once a Fayda verification is bound." }) + verified!: boolean; + @ApiProperty({ nullable: true }) name!: string | null; @ApiProperty({ nullable: true }) phone!: string | null; @ApiProperty({ nullable: true }) email!: string | null; @@ -45,13 +87,11 @@ export class IdentityVerificationStateDto { @ApiProperty({ nullable: true }) verifiedAt!: string | null; @ApiProperty({ nullable: true }) birthdate!: string | null; @ApiProperty({ nullable: true }) gender!: string | null; -} -export class OwnerIdentityStateDto extends IdentityVerificationStateDto { @ApiProperty({ nullable: true, description: - "Typed passport number — the foreign-company identity credential. Independent of Fayda: never written by a verification, and still required even if the owner also verifies.", + "Typed passport number. Fayda is an Ethiopian national ID, so a foreign company proves the identity with either — this is the alternative, not an addition.", }) passportNumber!: string | null; } @@ -59,44 +99,55 @@ export class OwnerIdentityStateDto extends IdentityVerificationStateDto { export class CompanyIdentityStateDto { @ApiProperty({ description: - "True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.", + "True for a foreign company: a typed passport number proves the identity just as a Fayda verification does. An Ethiopian company must use Fayda.", }) - faydaRequired!: boolean; + passportAccepted!: boolean; @ApiProperty({ + enum: POA_DECLARATIONS, + nullable: true, description: - "True when the owner's passport number is mandatory — foreign companies only. Independent of faydaRequired: a foreign owner may verify with Fayda too, but the passport is still required.", + 'Whether the company named a Power of Attorney. Null until the customer answers — which is itself an outstanding onboarding item, since the answer decides who verifies.', }) - passportRequired!: boolean; + poaDeclared!: PoaDeclaration | null; - @ApiProperty({ type: OwnerIdentityStateDto }) - owner!: OwnerIdentityStateDto; + @ApiProperty({ + enum: IDENTITY_SUBJECTS, + nullable: true, + description: + "Who the company's single identity verification belongs to: the PoA when one is named, the owner when not. Null while the declaration is unanswered.", + }) + subject!: IdentitySubject | null; + + @ApiProperty({ type: IdentityVerificationStateDto }) + owner!: IdentityVerificationStateDto; @ApiProperty({ type: IdentityVerificationStateDto }) poa!: IdentityVerificationStateDto; @ApiProperty({ description: - "True when the Power of Attorney is the company's owner, declared through the portal's \"same as owner\" copy. Waives the DARS delegation paper — nobody delegates to themselves.", + "True once the subject is proven — Fayda-verified, or carrying a passport number where that is accepted.", }) - poaSameAsOwner!: boolean; + identityProven!: boolean; @ApiProperty({ - type: IdentityVerificationStateDto, + nullable: true, description: - "General manager. `verified` is true both when the GM verified with Fayda in their own right and when the company declared the GM is the owner — in the latter case the owner's Fayda sub backs it.", + "The manager named on the eTrade licence, captured at lookup. Null when eTrade returned none (ManagerNameEng is frequently blank).", }) - gm!: IdentityVerificationStateDto; + etradeManagerName!: string | null; + + @ApiProperty({ + nullable: true, + description: + "Does the owner the company put forward match the person on the eTrade licence? This is the backoffice's check. Null when there is nothing to compare — no eTrade manager on file, or no owner name yet. Advisory, not a gate: eTrade's Latin transliteration and Fayda's rarely agree character-for-character, so a reviewer decides.", + }) + ownerMatchesEtrade!: boolean | null; @ApiProperty({ description: - "True when the GM's identity is the owner's, declared through the portal's \"same as owner\" copy rather than a separate verification.", - }) - gmSameAsOwner!: boolean; - - @ApiProperty({ - description: - "False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.", + "False while the declaration is unanswered or the subject is unproven. Field-level completeness (owner/PoA details, documents) is reported separately by the onboarding requirements.", }) complete!: boolean; } @@ -105,26 +156,9 @@ export class CompanyIdentityStateDto { const PREFIX: Record = { owner: "owner", poa: "poa", - gm: "gm", }; -/** - * Typed GM fields, kept in step with the Fayda-written ones. - * - * The GM predates this verification: its details are plain company columns - * that three notifier services mail (booking-lifecycle, train-scheduling and - * contract notifiers all read `company.generalManagerEmail`). A verification - * therefore writes BOTH — the `gm*` attributes carry the proof, these carry - * the value everything else already reads — and an unverified company keeps - * showing whatever was typed before this existed. - */ -const GM_TYPED_KEYS = { - name: "generalManagerName", - email: "generalManagerEmail", - phone: "generalManagerPhone", -} as const; - -/** company.attributes keys that together mean "a PoA was entered". */ +/** `company.attributes` keys that together mean "a representative was entered". */ const POA_KEYS = [ "poaName", "poaPhone", @@ -139,7 +173,7 @@ function stateFor( ): IdentityVerificationStateDto { const p = PREFIX[subject]; const read = (key: string) => (attrs[key] as string | undefined) ?? null; - const state: IdentityVerificationStateDto = { + return { verified: Boolean(read(`${p}FaydaSub`)), name: read(`${p}Name`), phone: read(`${p}Phone`), @@ -148,88 +182,111 @@ function stateFor( verifiedAt: read(`${p}FaydaVerifiedAt`), birthdate: read(`${p}Birthdate`), gender: read(`${p}Gender`), - }; - if (subject !== "gm" || state.verified) return state; - - // Companies onboarded before the GM was verifiable have typed details and no - // `gm*` attributes at all. Report those rather than a blank card — they are - // still what the notifiers mail — leaving `verified` false so the portal - // offers the upgrade instead of pretending the identity is proven. - // - // Only for such an unverified GM, which is the whole population this exists - // for. Merging the typed columns into a *verified* manager's state would read - // back the email the portal asked them to type when Fayda supplied none, and - // the input offering it — keyed on that value being absent — would vanish the - // moment it was saved, leaving a typo uncorrectable. - return { - ...state, - name: state.name ?? read(GM_TYPED_KEYS.name), - email: state.email ?? read(GM_TYPED_KEYS.email), - phone: state.phone ?? read(GM_TYPED_KEYS.phone), + passportNumber: read(`${p}PassportNumber`), }; } /** - * Derive both people's verification state from the company row. + * The company's PoA declaration, or null when it hasn't answered yet. * - * Pure and shared: `CompaniesService` gates on it and `ProfileResponseDto` - * renders from it, so the settings page and the onboarding wizard can never - * disagree with the rule the API actually enforces. + * A freight forwarder is never asked: it acts on other companies' behalf, so a + * representative and the DARS paper behind them are mandatory. Forcing it here + * — rather than only disabling the radio in the portal — is what stops a + * forwarder role added *after* onboarding from inheriting an old "no". + */ +export function readPoaDeclaration( + company: Pick, +): PoaDeclaration | null { + if ( + (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ) + ) { + return "yes"; + } + const value = company.attributes?.[POA_DECLARED_KEY]; + if (value === "yes" || value === "no") return value; + + // No explicit answer, but the company holds a representative's details — + // so it has one, and owes everything a representative brings with them. + // + // Covers rows that predate the question (the migration derives the same way) + // and any write that reaches the attributes without going through + // `setPoaDeclared`. Without this, PoA details could be saved with the + // delegation paper silently unowed. Safe against a genuine "no": answering + // it clears these keys, so they cannot outlive the answer. + return POA_KEYS.some((k) => (company.attributes?.[k] as string | undefined)?.trim()) + ? "yes" + : null; +} + +/** + * Do two people's names refer to the same person, as far as a string can tell? + * + * Deliberately loose: eTrade returns uppercase Latin transliterations of + * Amharic names and Fayda returns its own, so exact equality would flag almost + * every company. Case, punctuation, extra whitespace and word ORDER are all + * ignored — "ABEBE KEBEDE TESFA" and "Tesfa, Abebe Kebede" match. Anything + * beyond that is the reviewer's call, which is why the verdict is advisory. + */ +export function ownerNameMatchesEtrade( + ownerName: string | null | undefined, + etradeName: string | null | undefined, +): boolean | null { + const words = (v: string | null | undefined) => + (v ?? "") + .toLowerCase() + .replace(/[^a-z0-9ሀ-፿\s]/g, " ") + .split(/\s+/) + .filter(Boolean) + .sort(); + const a = words(ownerName); + const b = words(etradeName); + if (a.length === 0 || b.length === 0) return null; + return a.length === b.length && a.every((w, i) => w === b[i]); +} + +/** + * Derive the company's identity state from its row. + * + * Pure and shared: `CompaniesService` gates on it, `ProfileResponseDto` and the + * backoffice's company DTO render from it, so the settings page, the onboarding + * wizard and the reviewer can never disagree with the rule the API enforces. */ export function buildCompanyIdentityState( company: Company, ): CompanyIdentityStateDto { const attrs = company.attributes ?? {}; - const read = (key: string) => (attrs[key] as string | undefined) ?? null; - // Fayda is an Ethiopian national ID — a foreign company's owner may not hold - // one, so a typed passport number is the mandatory credential there instead. - // The two are mutually exclusive by nationality but independently tracked, - // since a foreign owner verifying with Fayda doesn't waive the passport. - const foreign = company.nationality === CompanyNationality.Foreign; - const faydaRequired = !foreign; - const passportRequired = foreign; + // Fayda is an Ethiopian national ID. A foreign company's people may hold + // none, so a typed passport number stands in — either one proves the person, + // and holding both is fine. + const passportAccepted = company.nationality === CompanyNationality.Foreign; - const owner: OwnerIdentityStateDto = { - ...stateFor(attrs, "owner"), - passportNumber: read("ownerPassportNumber"), - }; + const owner = stateFor(attrs, "owner"); const poa = stateFor(attrs, "poa"); - const poaDue = - (company.companyProfiles ?? []).some( - (p) => p.type === ProfileType.freightForwarder, - ) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim()); + const poaDeclared = readPoaDeclaration(company); + const subject: IdentitySubject | null = + poaDeclared === "yes" ? "poa" : poaDeclared === "no" ? "owner" : null; - const gm = stateFor(attrs, "gm"); - const gmSameAsOwner = Boolean(attrs.gmSameAsOwner); - const poaSameAsOwner = Boolean(attrs.poaSameAsOwner); + const proven = (s: IdentityVerificationStateDto) => + s.verified || (passportAccepted && Boolean(s.passportNumber?.trim())); - const ownerProven = faydaRequired - ? owner.verified - : !passportRequired || Boolean(owner.passportNumber); + const identityProven = + subject === null ? false : proven(subject === "poa" ? poa : owner); - // Fayda is an Ethiopian national ID, so only an Ethiopian company's - // personnel can be held to it. A foreign company may nominate a - // representative who holds one — and is offered the verification — but a - // typed name has to remain sufficient, or a foreign company whose PoA has no - // Fayda ID could never finish onboarding. - const poaProven = faydaRequired - ? poa.verified - : poa.verified || Boolean(poa.name?.trim()); - - // The GM is deliberately absent from this verdict: it names who to talk to, - // not what the company may do, and it has never gated trading. Capturing it - // through Fayda changes how it is collected, not whether it is required. - const complete = ownerProven && (!poaDue || poaProven); + const etradeManagerName = + (attrs[ETRADE_MANAGER_NAME_KEY] as string | undefined) ?? null; return { - faydaRequired, - passportRequired, + passportAccepted, + poaDeclared, + subject, owner, poa, - poaSameAsOwner, - gm, - gmSameAsOwner, - complete, + identityProven, + etradeManagerName, + ownerMatchesEtrade: ownerNameMatchesEtrade(owner.name, etradeManagerName), + complete: subject !== null && identityProven, }; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index 9b69bb15d..b1baa2553 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -8,7 +8,10 @@ * truth the wizard uses to auto-finish. */ -import { CompanyIdentityStateDto } from "./complete-identity-verification.dto"; +import { + CompanyIdentityStateDto, + PoaDeclaration, +} from "./complete-identity-verification.dto"; export interface OnboardingInfoField { key: string; @@ -38,15 +41,19 @@ export interface OnboardingLicenseProfile { } export interface OnboardingPoaState { - /** True when the company operates as a freight forwarder — PoA is mandatory. */ - required: boolean; - /** True once any PoA detail has been entered. */ - provided: boolean; /** - * True when the DARS delegation paper is owed — a PoA exists (or is - * mandatory) and is not the owner themselves. An owner representing their own - * company delegates to nobody, so there is no delegation to evidence. + * True when the company operates as a freight forwarder: it signs on other + * companies' behalf, so a Power of Attorney is non-negotiable and the portal + * renders the question answered and locked rather than asking it. */ + locked: boolean; + /** + * The company's answer to "does anyone hold power of attorney for you?". + * Null until it answers — which is itself outstanding, since the answer + * decides whose identity is verified. + */ + declared: PoaDeclaration | null; + /** True when the DARS delegation paper is owed — i.e. `declared === "yes"`. */ delegationLetterRequired: boolean; /** True when the DARS delegation paper is stored for the company. */ delegationLetterUploaded: boolean; diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index a70c52b5d..dc2ddc4c3 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -42,9 +42,10 @@ export class ProfileResponseDto { contactPersonPhone: string | null; /** Phone that passed SMS OTP verification (drives the verify-step resume). */ contactVerifiedPhone: string | null; - generalManagerName: string | null; - generalManagerEmail: string | null; - generalManagerPhone: string | null; + /** The owner — whoever the eTrade licence names as the business's manager. */ + ownerName: string | null; + ownerEmail: string | null; + ownerPhone: string | null; poaName: string | null; poaPhone: string | null; @@ -55,12 +56,13 @@ export class ProfileResponseDto { profileId: string; /** - * Fayda verification state for the company's owner and PoA — not the general - * manager, which is a separate typed role. The settings tabs and the - * onboarding wizard render from `identity.faydaRequired` / - * `identity.passportRequired`: an Ethiopian company verifies the owner (and - * PoA) instead of typing their details; a foreign one requires a typed - * passport number instead. + * The company's single identity verification, plus who it belongs to. + * + * `identity.subject` follows the company's PoA declaration — the + * representative when one is named, otherwise the owner. The settings tabs + * and the onboarding wizard render from it: `passportAccepted` says whether a + * typed passport number is an alternative to Fayda (foreign companies only), + * and `ownerMatchesEtrade` is the check the backoffice makes. */ identity: CompanyIdentityStateDto; @@ -113,9 +115,9 @@ export class ProfileResponseDto { this.contactPersonEmail = attrs.contactPersonEmail ?? null; this.contactPersonPhone = attrs.contactPersonPhone ?? null; this.contactVerifiedPhone = attrs.contactVerifiedPhone ?? null; - this.generalManagerName = attrs.generalManagerName ?? null; - this.generalManagerEmail = attrs.generalManagerEmail ?? null; - this.generalManagerPhone = attrs.generalManagerPhone ?? null; + this.ownerName = attrs.ownerName ?? null; + this.ownerEmail = attrs.ownerEmail ?? null; + this.ownerPhone = attrs.ownerPhone ?? null; this.poaName = attrs.poaName ?? null; this.poaPhone = attrs.poaPhone ?? null; this.poaEmail = attrs.poaEmail ?? null; diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index d705e7323..b78925285 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -89,9 +89,14 @@ export class ResponseCompanyDto { houseNo?: string | null; /** - * Owner/PoA Fayda verification state, shared with the portal - * (`buildCompanyIdentityState`) so backoffice never re-derives — or - * disagrees with — the rule the API actually enforces. + * The company's single identity verification, shared with the portal + * (`buildCompanyIdentityState`) so backoffice never re-derives — or disagrees + * with — the rule the API actually enforces. + * + * `subject` names whose verification it is (the PoA when one is declared, + * otherwise the owner), and `ownerMatchesEtrade` is the reviewer's check: + * does the owner the company put forward match the manager on the eTrade + * licence? Advisory — see the note on that field. */ identity: CompanyIdentityStateDto; diff --git a/apps/edr-freight-api/src/modules/companies/dto/set-poa-declared.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/set-poa-declared.dto.ts new file mode 100644 index 000000000..37c54b254 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/set-poa-declared.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index c89326fa1..b7b814da1 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -45,11 +45,11 @@ export class UpdateProfileDto { @Matches(/^\d{10,11}$/, { message: "VAT number must be 10 or 11 digits" }) vatNumber?: string; - // `fanNumber` is deliberately absent: the FAN is the Fayda number of the - // company's PoA (or its general manager), so it is derived from a completed - // Fayda verification rather than typed. The global validation pipe runs with - // forbidNonWhitelisted, so a client that still sends it gets a 400 telling it - // so — see CompaniesService.completeIdentityVerification. + // `fanNumber` is deliberately absent: the FAN is a Fayda number, so it would + // have to come from a completed verification rather than be typed — and + // Fayda's userinfo carries no national ID number, so nothing produces one. + // The global validation pipe runs with forbidNonWhitelisted, so a client that + // still sends it gets a 400 telling it so. @IsOptional() @IsString() @@ -78,18 +78,31 @@ export class UpdateProfileDto { @IsValidPhone() contactVerifiedPhone?: string; + /** + * The owner — whoever the eTrade licence names as the business's manager. + * + * All three are required before onboarding can be submitted, whatever their + * source: the eTrade lookup prefills the name and phone, a Fayda + * verification can supply all three, and the portal renders an input for + * whatever neither did (eTrade returns no email at all, and Fayda's email + * claim is optional, so that one is usually typed). + * + * Locked once a Fayda verification supplied them — see + * `IDENTITY_OWNED_FIELDS` — but only field by field: a claim that came back + * empty owns nothing and stays typeable. + */ @IsOptional() @IsString() - generalManagerName?: string; + ownerName?: string; @IsOptional() @IsEmail() - generalManagerEmail?: string; + ownerEmail?: string; @IsOptional() @IsString() @IsValidPhone() - generalManagerPhone?: string; + ownerPhone?: string; @IsOptional() @IsString() @@ -113,15 +126,22 @@ export class UpdateProfileDto { poaAddress?: string; /** - * The owner's passport number — the identity credential for a foreign - * company, since Fayda is an Ethiopian national ID. Plain typed field, never - * written or locked by a Fayda verification: still required even if the - * owner also verifies. + * Passport numbers — the alternative identity credential for a foreign + * company, since Fayda is an Ethiopian national ID. Plain typed fields, never + * written or locked by a Fayda verification. + * + * Only the one belonging to the company's declared identity subject matters: + * the PoA's when a representative is named, the owner's otherwise. An + * Ethiopian company is not offered either — it must use Fayda. */ @IsOptional() @IsString() ownerPassportNumber?: string; + @IsOptional() + @IsString() + poaPassportNumber?: string; + @IsOptional() @IsString() @MaxLength(100) diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index f254e0121..2ecfa3deb 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -112,29 +112,11 @@ export class Company extends BaseEntity { }) contactPersonPhone?: string | null; - @Column({ - name: "general_manager_name", - type: "varchar", - length: 100, - nullable: true, - }) - generalManagerName?: string | null; - - @Column({ - name: "general_manager_email", - type: "varchar", - length: 150, - nullable: true, - }) - generalManagerEmail?: string | null; - - @Column({ - name: "general_manager_phone", - type: "varchar", - length: 20, - nullable: true, - }) - generalManagerPhone?: string | null; + // The general manager used to live here as three columns. It named who to + // talk to, gated nothing, and nothing ever populated the columns — the write + // path put the values in `attributes`. Removed in RemoveGeneralManager; the + // company's people are now its owner (whoever the eTrade licence names) and + // its Power of Attorney, both in `attributes`. @Column({ name: "website", type: "varchar", length: 200, nullable: true }) website?: string | null; diff --git a/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts b/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts index 886aa8b85..104456384 100644 --- a/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts +++ b/apps/edr-freight-api/src/modules/notifications/resolve-company-phone.util.ts @@ -12,8 +12,8 @@ import { DataSource, EntityManager } from "typeorm"; * `companies.contact_person_phone` is deliberately NOT consulted: the live write * path stores that value in the `attributes` jsonb and has never populated the * column, so every reader of it was silently falling through to `phone` anyway. - * `companies.general_manager_email` is the same trap on the email side — see - * {@link companyNotifyEmailExpr}. + * The retired `general_manager_email` column was the same trap on the email + * side — see {@link companyNotifyEmailExpr}. */ /** @@ -50,24 +50,30 @@ export function companyNotifyPhoneExpr(alias: string): string { * SQL expression for the company's notification address, given the joined `pc` * alias. * - * `companies.email` alone is not enough: it is written from ONE place — a - * Fayda-verified owner's email claim — so a foreign company, whose owner proves - * identity by passport instead, never gets one. Readers papered over that with - * `COALESCE(email, general_manager_email)`, but that column has the same problem - * `contact_person_phone` has above: onboarding writes the value into the - * `attributes` jsonb and nothing has ever populated the column, so the fallback - * could not fire and the mail was dropped in silence. + * `companies.email` is now the owner's email, written on every profile save + * whether or not the owner verified with Fayda — and the owner's email is a + * required onboarding field, so a company that finished onboarding has one. + * (It used to be written ONLY for a Fayda-verified owner, which meant every + * foreign company had none; the gap was papered over with a + * `general_manager_email` leg that could never fire, because onboarding wrote + * that value into `attributes` and nothing ever populated the column.) * - * So: the company address, then the two the customer actually filled in during - * onboarding, then the account that registered them — which always has one, - * signup requires it. `NULLIF` because a blank jsonb key is not an address and - * `COALESCE` would happily stop on it. + * The `generalManagerEmail` attribute is still consulted, after the contact + * person: the general manager was removed, but companies onboarded before that + * may carry an address there and nowhere else. RemoveGeneralManager backfills + * `companies.email` from it, so this is belt-and-braces for rows that migration + * could not resolve. + * + * `NULLIF` because a blank jsonb key is not an address and `COALESCE` would + * happily stop on it. The account that registered the company is the last + * resort — signup guarantees it has one. */ export function companyNotifyEmailExpr(alias: string): string { return `COALESCE( NULLIF(${alias}.email, ''), - NULLIF(${alias}.attributes->>'generalManagerEmail', ''), + NULLIF(${alias}.attributes->>'ownerEmail', ''), NULLIF(${alias}.attributes->>'contactPersonEmail', ''), + NULLIF(${alias}.attributes->>'generalManagerEmail', ''), NULLIF(pc.email, '') )`; } diff --git a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts index 9cf1ef0cd..174a32341 100644 --- a/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts +++ b/apps/edr-freight-api/src/scripts/seed-negad-indode-arrived-train.ts @@ -157,9 +157,6 @@ async function main() { email: 'negad-indode-demo@edr.local', contactPersonName: 'Marshalling Demo', contactPersonPhone: '251900000202', - generalManagerName: 'Demo Manager', - generalManagerEmail: 'negad-indode-demo@edr.local', - generalManagerPhone: '251900000202', }), )); diff --git a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts index 2e234bcf0..91848dff4 100644 --- a/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/approved-first-lastmile-demo-bookings.seeder.ts @@ -247,9 +247,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder { website: null, contactPersonName: 'First Last Mile Demo', contactPersonPhone: '251900000101', - generalManagerName: 'Demo Manager', - generalManagerEmail: COMPANY_EMAIL, - generalManagerPhone: '251900000101', }, { conflictPaths: { tin: true } }, ); diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 20d2a3f8b..4f259c28c 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -324,9 +324,6 @@ export class DemoBookingsSeeder { website: null, contactPersonName: "Train Scheduling", contactPersonPhone: "251900000001", - generalManagerName: "Demo Manager", - generalManagerEmail: COMPANY_EMAIL, - generalManagerPhone: "251900000001", }, { conflictPaths: { tin: true } }, ); diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts index 654f4f77f..a658ec13d 100644 --- a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -175,9 +175,6 @@ export class PaidImportExportMileDemoSeeder { website: null, contactPersonName: 'Paid Mile Demo', contactPersonPhone: '251900000202', - generalManagerName: 'Demo Manager', - generalManagerEmail: COMPANY_EMAIL, - generalManagerPhone: '251900000202', }, { conflictPaths: { tin: true } }, );