feat: allow owner to represent company as power of attorney

This commit is contained in:
ghost2023
2026-08-10 12:31:13 +03:00
parent 10af12bfc6
commit d07d35e438
12 changed files with 583 additions and 92 deletions

View File

@@ -456,6 +456,36 @@ export class CompaniesController {
return this.companiesService.clearGmIdentity(user.id); 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") @Delete("identity/fayda/poa")
@PortalCustomer() @PortalCustomer()
@ApiOperation({ @ApiOperation({

View File

@@ -229,8 +229,10 @@ describe("Fayda identity verification binds a person to the company", () => {
expect(state.owner.verified).toBe(true); expect(state.owner.verified).toBe(true);
}); });
it("refuses to make one identity both owner and PoA", async () => { it("lets one identity be both owner and PoA", async () => {
const { service } = makeService({ // An owner who represents their own company is the ordinary small-business
// case, not a conflict — the same answer the GM has always been allowed.
const { service, ctx } = makeService({
attributes: { ownerFaydaSub: "same-person" }, attributes: { ownerFaydaSub: "same-person" },
verification: { verification: {
purpose: "VERIFY", purpose: "VERIFY",
@@ -240,13 +242,14 @@ describe("Fayda identity verification binds a person to the company", () => {
}, },
}); });
await expect( const state = await service.completeIdentityVerification("user-1", {
service.completeIdentityVerification("user-1", { subject: "poa",
subject: "poa", code: "c",
code: "c", state: "s",
state: "s", });
}),
).rejects.toBeInstanceOf(BadRequestException); expect(state.poa.verified).toBe(true);
expect(ctx.attributes.poaFaydaSub).toBe("same-person");
}); });
it("stages an owner re-verification for review on an approved company", async () => { it("stages an owner re-verification for review on an approved company", async () => {
@@ -701,9 +704,8 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
}); });
it("lets the GM verify as the same human as the owner", async () => { it("lets the GM verify as the same human as the owner", async () => {
// The owner/PoA collision check exists because self-delegation is not // One human in every role is the ordinary small-business shape, so
// delegation. It must not fire here: the GM being the owner is a supported // verifying with the owner's own Fayda sub has to succeed.
// answer, so verifying with the owner's own Fayda sub has to succeed.
const { service, ctx } = makeService({ const { service, ctx } = makeService({
attributes: { ...OWNER_VERIFIED }, attributes: { ...OWNER_VERIFIED },
verification: { verification: {
@@ -727,25 +729,42 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
expect(ctx.attributes.generalManagerName).toBe("Abebe Bikila"); expect(ctx.attributes.generalManagerName).toBe("Abebe Bikila");
}); });
it("still refuses a PoA who is the owner", async () => { it("declares the PoA is the owner, copying the verified identity across", async () => {
// The GM exemption above must not have widened into the PoA. const { service, ctx } = makeService({ attributes: { ...OWNER_VERIFIED } });
const { service } = makeService({
attributes: { ...OWNER_VERIFIED }, const state = await service.setPoaSameAsOwner("user-1");
verification: {
purpose: "VERIFY", expect(state.poaSameAsOwner).toBe(true);
verified: true, expect(state.poa.verified).toBe(true);
sub: "owner-sub", expect(ctx.attributes.poaFaydaSub).toBe(OWNER_VERIFIED.ownerFaydaSub);
fullName: "Abebe Bikila", expect(ctx.attributes.poaName).toBe(OWNER_VERIFIED.ownerName);
}, });
it("refuses to declare the PoA is the owner while an Ethiopian owner is unverified", async () => {
// Its representative must be Fayda-verified, so a declaration here would
// record one that could never satisfy the gate.
const { service } = makeService({ attributes: {} });
await expect(service.setPoaSameAsOwner("user-1")).rejects.toBeInstanceOf(
BadRequestException,
);
});
it("undoes the PoA \"same as owner\" declaration without touching a real verification", async () => {
const { service, ctx } = makeService({
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
}); });
await expect( // No declaration in place: the verified representative must survive.
service.completeIdentityVerification("user-1", { await service.clearPoaSameAsOwner("user-1");
subject: "poa", expect(ctx.attributes.poaFaydaSub).toBe(POA_VERIFIED.poaFaydaSub);
code: "c",
state: "s", await service.setPoaSameAsOwner("user-1");
}), const state = await service.clearPoaSameAsOwner("user-1");
).rejects.toBeInstanceOf(BadRequestException);
expect(state.poaSameAsOwner).toBe(false);
expect(state.poa.verified).toBe(false);
expect(ctx.attributes.poaFaydaSub).toBeNull();
}); });
it("reports a pre-existing typed GM as unverified rather than blank", async () => { it("reports a pre-existing typed GM as unverified rather than blank", async () => {

View File

@@ -174,6 +174,31 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
).resolves.toBeDefined(); ).resolves.toBeDefined();
}); });
it("waives the paper when the owner represents the company themselves", async () => {
// Nobody delegates to themselves, so a self-declared PoA owes no DARS
// paper — the representative's own details are still required.
const { service } = makeService({
attributes: { ...VERIFIED_IDENTITIES, poaSameAsOwner: true },
});
await expect(
service.updateProfile("user-1", POA as never),
).resolves.toBeDefined();
});
it("grants the forwarder role to a self-represented company with no paper", async () => {
const { service } = makeService({
attributes: { ...VERIFIED_IDENTITIES, ...POA, poaSameAsOwner: true },
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
});
it("rejects a paper the reviewer sent back for correction", async () => { it("rejects a paper the reviewer sent back for correction", async () => {
const { service } = makeService({ files: [paper("change_requested")] }); const { service } = makeService({ files: [paper("change_requested")] });

View File

@@ -133,12 +133,6 @@ const IDENTITY_PREFIX: Record<IdentitySubject, string> = {
gm: "gm", gm: "gm",
}; };
const IDENTITY_LABEL: Record<IdentitySubject, string> = {
owner: "owner",
poa: "Power of Attorney",
gm: "General Manager",
};
/** /**
* Typed GM columns a GM verification also writes. Three notifier services mail * Typed GM columns a GM verification also writes. Three notifier services mail
* `company.generalManagerEmail` directly, so leaving these behind would mean a * `company.generalManagerEmail` directly, so leaving these behind would mean a
@@ -2046,7 +2040,13 @@ export class CompaniesService {
(company.attributes?.[k] as string | undefined)?.trim(), (company.attributes?.[k] as string | undefined)?.trim(),
); );
const delegation = await this.getPoaDelegationState(company.id); const delegation = await this.getPoaDelegationState(company.id);
const delegationDue = poaRequired || poaProvided; // "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;
// The representative's details normally arrive from their Fayda // The representative's details normally arrive from their Fayda
// verification — but Fayda's email and phone claims are optional and // verification — but Fayda's email and phone claims are optional and
// routinely come back empty, and the PoA step renders an input for whatever // routinely come back empty, and the PoA step renders an input for whatever
@@ -2055,7 +2055,7 @@ export class CompaniesService {
// nothing here let a freight forwarder finish onboarding with a // nothing here let a freight forwarder finish onboarding with a
// representative the API's own `REQUIRED_POA_FIELDS` calls incomplete, then // representative the API's own `REQUIRED_POA_FIELDS` calls incomplete, then
// 400'd their next PoA edit for it. // 400'd their next PoA edit for it.
const missingPoaFields = delegationDue const missingPoaFields = poaDue
? REQUIRED_POA_FIELDS.filter( ? REQUIRED_POA_FIELDS.filter(
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
) )
@@ -2115,7 +2115,8 @@ export class CompaniesService {
// The delegation paper plus the representative's own required details — // The delegation paper plus the representative's own required details —
// `completed` below subtracts every one of those it is still missing, so // `completed` below subtracts every one of those it is still missing, so
// leaving them out of the total would make the bar understate progress. // leaving them out of the total would make the bar understate progress.
const poaItemCount = delegationDue ? 1 + REQUIRED_POA_FIELDS.length : 0; const poaItemCount =
(poaDue ? REQUIRED_POA_FIELDS.length : 0) + (delegationDue ? 1 : 0);
// One item per identity credential the company has to prove: the owner // One item per identity credential the company has to prove: the owner
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once // always (Fayda for Ethiopian, passport for foreign), plus the PoA once
// there is one — Fayda for an Ethiopian company, a named representative // there is one — Fayda for an Ethiopian company, a named representative
@@ -2127,11 +2128,10 @@ export class CompaniesService {
const ownerCredentialProven = identity.faydaRequired const ownerCredentialProven = identity.faydaRequired
? identity.owner.verified ? identity.owner.verified
: Boolean(identity.owner.passportNumber); : Boolean(identity.owner.passportNumber);
const identityItemCount = const identityItemCount = (ownerCredentialDue ? 1 : 0) + (poaDue ? 1 : 0);
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
const missingIdentityCount = const missingIdentityCount =
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) + (ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
(delegationDue && !poaProven ? 1 : 0); (poaDue && !poaProven ? 1 : 0);
const total = const total =
requiredInfo.length + requiredInfo.length +
requiredDocCount + requiredDocCount +
@@ -2159,6 +2159,7 @@ export class CompaniesService {
poa: { poa: {
required: poaRequired, required: poaRequired,
provided: poaProvided, provided: poaProvided,
delegationLetterRequired: delegationDue,
delegationLetterUploaded: delegation.onFile, delegationLetterUploaded: delegation.onFile,
delegationLetterFlagged: delegation.flagged, delegationLetterFlagged: delegation.flagged,
missingFields: missingPoaFields, missingFields: missingPoaFields,
@@ -2707,6 +2708,12 @@ export class CompaniesService {
} }
} }
// 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( const { onFile, flagged } = await this.getPoaDelegationState(
companyId, companyId,
opts.ignoreFileIds, opts.ignoreFileIds,
@@ -2783,21 +2790,11 @@ export class CompaniesService {
); );
} }
// The owner delegating power of attorney to themselves is not a // An owner who is also the company's representative is a supported answer,
// delegation — it would let one identity satisfy both halves of the check. // not a conflict — the same way the GM is very often the owner. Small
// Only owner/PoA collide this way: the GM is very often the owner, and // companies routinely have one human in all three roles, and the portal's
// saying so is a supported answer rather than a conflict, so it is left out // "same as owner" cards exist precisely so they can say so. No identity
// of this check entirely. // here is refused for colliding with another.
if (dto.subject === "owner" || dto.subject === "poa") {
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
const otherSub =
company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
if (otherSub && otherSub === result.sub) {
throw new BadRequestException(
`This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
);
}
}
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -2837,6 +2834,12 @@ export class CompaniesService {
// mail `generalManagerEmail`), and clears any earlier "same as owner" // mail `generalManagerEmail`), and clears any earlier "same as owner"
// declaration — verifying in their own right is the GM answering for // declaration — verifying in their own right is the GM answering for
// themselves. // 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") { if (dto.subject === "gm") {
identity.gmSameAsOwner = false; identity.gmSameAsOwner = false;
if (result.fullName) identity.generalManagerName = result.fullName; if (result.fullName) identity.generalManagerName = result.fullName;
@@ -2953,6 +2956,118 @@ export class CompaniesService {
return this.getCompanyIdentityState(updated); 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<CompanyIdentityStateDto> {
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<string, unknown> = { 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<CompanyIdentityStateDto> {
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 * Drop the Power of Attorney entirely — the verified identity, the details it
* wrote and the delegation paper together. * wrote and the delegation paper together.
@@ -2974,7 +3089,7 @@ export class CompaniesService {
); );
} }
const cleared: Record<string, unknown> = {}; const cleared: Record<string, unknown> = { poaSameAsOwner: false };
for (const key of [ for (const key of [
...POA_ATTRIBUTES, ...POA_ATTRIBUTES,
"poaFaydaSub", "poaFaydaSub",

View File

@@ -75,6 +75,12 @@ export class CompanyIdentityStateDto {
@ApiProperty({ type: IdentityVerificationStateDto }) @ApiProperty({ type: IdentityVerificationStateDto })
poa!: 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.",
})
poaSameAsOwner!: boolean;
@ApiProperty({ @ApiProperty({
type: IdentityVerificationStateDto, type: IdentityVerificationStateDto,
description: description:
@@ -196,6 +202,7 @@ export function buildCompanyIdentityState(
const gm = stateFor(attrs, "gm"); const gm = stateFor(attrs, "gm");
const gmSameAsOwner = Boolean(attrs.gmSameAsOwner); const gmSameAsOwner = Boolean(attrs.gmSameAsOwner);
const poaSameAsOwner = Boolean(attrs.poaSameAsOwner);
const ownerProven = faydaRequired const ownerProven = faydaRequired
? owner.verified ? owner.verified
@@ -220,6 +227,7 @@ export function buildCompanyIdentityState(
passportRequired, passportRequired,
owner, owner,
poa, poa,
poaSameAsOwner,
gm, gm,
gmSameAsOwner, gmSameAsOwner,
complete, complete,

View File

@@ -42,6 +42,12 @@ export interface OnboardingPoaState {
required: boolean; required: boolean;
/** True once any PoA detail has been entered. */ /** True once any PoA detail has been entered. */
provided: boolean; 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.
*/
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;
/** True when a reviewer sent the paper back for correction. */ /** True when a reviewer sent the paper back for correction. */

View File

@@ -355,28 +355,33 @@ export default function CompanyProfileForm({
// in flight), so the initial state above freezes at `false` — adopt the // in flight), so the initial state above freezes at `false` — adopt the
// server's declaration the moment it lands, or a resumed draft shows an // server's declaration the moment it lands, or a resumed draft shows an
// unticked box over a GM that is linked server-side. // unticked box over a GM that is linked server-side.
const [poaSameAsOwner, setPoaSameAsOwner] = useState(
identity?.poaSameAsOwner ?? false,
);
const identityLoaded = useRef(false); const identityLoaded = useRef(false);
useEffect(() => { useEffect(() => {
if (!identity || identityLoaded.current) return; if (!identity || identityLoaded.current) return;
identityLoaded.current = true; identityLoaded.current = true;
setGmSameAsOwner(identity.gmSameAsOwner); setGmSameAsOwner(identity.gmSameAsOwner);
setPoaSameAsOwner(identity.poaSameAsOwner);
}, [identity]); }, [identity]);
const [contactSameAsGm, setContactSameAsGm] = useState(false); const [contactSameAsGm, setContactSameAsGm] = useState(false);
// A Fayda-verified owner outranks eTrade's registered owner — it's the // Where the owner's details come from when they are copied onto someone else
// higher-trust source, and the whole point of proving identity is to stop // — the GM, or the representative. A Fayda-verified owner outranks eTrade's
// trusting typed/looked-up data for this. // registered owner: it's the higher-trust source, and the whole point of
const gmSourceName = firstPresent( // proving identity is to stop trusting typed/looked-up data for this.
const ownerSourceName = firstPresent(
identity?.owner.name, identity?.owner.name,
etradeOwner?.name, etradeOwner?.name,
user.name?.en, user.name?.en,
); );
const gmSourceEmail = firstValidEmail(identity?.owner.email, user.email); const ownerSourceEmail = firstValidEmail(identity?.owner.email, user.email);
// Same reason as `derivedPhone`: this value is written into // Same reason as `derivedPhone`: this value is written into
// `generalManagerPhone`, which the API validates with `@IsValidPhone()`, so an // `generalManagerPhone` / `poaPhone`, which the API validates with
// unusable eTrade number here 400s the personnel step instead. // `@IsValidPhone()`, so an unusable eTrade number here 400s the step instead.
const gmSourcePhone = firstValidPhone( const ownerSourcePhone = firstValidPhone(
identity?.owner.phone, identity?.owner.phone,
etradeOwner?.phone, etradeOwner?.phone,
user.phoneNumber, user.phoneNumber,
@@ -388,13 +393,30 @@ export default function CompanyProfileForm({
// `identity.gm`; mirroring it into form fields here would send typed // `identity.gm`; mirroring it into form fields here would send typed
// values for something the API already owns. // values for something the API already owns.
if (identity?.owner.verified) return; if (identity?.owner.verified) return;
setValue("generalManagerName", gmSourceName, { shouldValidate: true }); setValue("generalManagerName", ownerSourceName, { shouldValidate: true });
setValue("generalManagerEmail", gmSourceEmail, { shouldValidate: true }); setValue("generalManagerEmail", ownerSourceEmail, { shouldValidate: true });
setValue("generalManagerPhone", gmSourcePhone, { setValue("generalManagerPhone", ownerSourcePhone, {
shouldValidate: true, shouldValidate: true,
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [gmSameAsOwner, gmSourceName, gmSourceEmail, gmSourcePhone]); }, [gmSameAsOwner, ownerSourceName, ownerSourceEmail, ownerSourcePhone]);
// The representative's half of the same copy. A verified owner's identity is
// copied server-side and read back from `identity.poa`, so only an owner
// backed by a typed passport is mirrored into form fields here — the same
// split the GM makes above, for the same reason.
//
// Only non-empty sources are written. A source the owner does not have is a
// gap the step renders an input for (see `poaGaps`), and this effect re-runs
// whenever any *other* source changes — so blanking here would wipe what the
// customer is typing into that input the moment an eTrade lookup lands.
useEffect(() => {
if (!poaSameAsOwner || identity?.owner.verified) return;
if (ownerSourceName) setValue("poaName", ownerSourceName, { shouldValidate: true });
if (ownerSourceEmail) setValue("poaEmail", ownerSourceEmail, { shouldValidate: true });
if (ownerSourcePhone) setValue("poaPhone", ownerSourcePhone, { shouldValidate: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [poaSameAsOwner, ownerSourceName, ownerSourceEmail, ownerSourcePhone]);
/** /**
* "Same as owner" has two meanings depending on what backs the owner. * "Same as owner" has two meanings depending on what backs the owner.
@@ -439,6 +461,44 @@ export default function CompanyProfileForm({
} }
}; };
/**
* The representative is the owner. Unlike the GM's card this always goes to
* the API, whichever backs the owner: the declaration itself is what waives
* the DARS delegation paper, so it has to be recorded server-side even when
* there is no proven identity to copy and the details are mirrored locally.
*/
const [poaLinkPending, setPoaLinkPending] = useState(false);
const togglePoaSameAsOwner = async (checked: boolean) => {
setSaveError(null);
setPoaSameAsOwner(checked);
setPoaLinkPending(true);
try {
if (checked) await verifaydaService.setPoaSameAsOwner();
else {
await verifaydaService.clearPoaSameAsOwner();
// Only the locally mirrored values are ours to clear; a copied identity
// is cleared by the call above.
if (!identity?.owner.verified) {
setValue("poaName", "");
setValue("poaEmail", "");
setValue("poaPhone", "");
}
}
onIdentityChange?.();
} catch (err) {
setPoaSameAsOwner(!checked);
setSaveError(
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
(err instanceof Error
? err.message
: "Could not update the Power of Attorney"),
);
} finally {
setPoaLinkPending(false);
}
};
// Where the GM's details come from depends on how they were established: a // Where the GM's details come from depends on how they were established: a
// Fayda verification (or a "same as owner" declaration) owns them outright, // Fayda verification (or a "same as owner" declaration) owns them outright,
// and only a company that may still type them falls back to form state. // and only a company that may still type them falls back to form state.
@@ -704,7 +764,12 @@ export default function CompanyProfileForm({
// verify with Fayda — the API demands it at completion either way. Keying // verify with Fayda — the API demands it at completion either way. Keying
// this on the verification alone hid the upload from a foreign forwarder and // this on the verification alone hid the upload from a foreign forwarder and
// then failed them on submit for a file they were never shown. // then failed them on submit for a file they were never shown.
const delegationRequired = poaProvided || requirePoa; //
// Unless the owner represents the company themselves: nobody delegates to
// themselves, so there is no delegation to evidence. Mirrors the API's own
// waiver in `assertPoaDelegationSatisfied` — the two must agree, or this
// demands a file the server would accept the submission without.
const delegationRequired = (poaProvided || requirePoa) && !poaSameAsOwner;
const delegationPresent = const delegationPresent =
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) || (uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
(() => { (() => {
@@ -735,12 +800,38 @@ export default function CompanyProfileForm({
// which is also the only case the API refuses to let anyone overwrite. // which is also the only case the API refuses to let anyone overwrite.
const poaGap = (v?: string | null) => const poaGap = (v?: string | null) =>
poaTypedAllowed && (!identity?.poa.verified || !v?.trim()); poaTypedAllowed && (!identity?.poa.verified || !v?.trim());
const poaGaps = { /**
name: poaGap(identity?.poa.name), * "Same as owner" answers each field only as far as the owner actually has
email: poaGap(identity?.poa.email), * one. Fayda's name, email and phone claims are all optional, the account and
phone: poaGap(identity?.poa.phone), * eTrade fallbacks can be empty or unusable, and `REQUIRED_POA_FIELDS` still
address: poaGap(identity?.poa.address), * demands a name, an email and a phone — so anything the copy could not
}; * supply stays askable. Assuming the copy filled everything is what dead-ends
* the submit on "Add your poa phone" with no input anywhere to satisfy it.
*
* Keyed on the *source*, never on the field's current value: an input that
* disappears the moment the first character is typed into it is unusable.
* A verified owner's identity is copied server-side, so `identity.poa` is the
* source there; otherwise it is the same owner-derived values the mirror
* effect writes.
*/
const poaCopyGap = (copied?: string | null, mirrored?: string | null) =>
identity?.owner.verified ? !copied?.trim() : !mirrored?.trim();
const poaGaps = poaSameAsOwner
? {
name: poaCopyGap(identity?.poa.name, ownerSourceName),
email: poaCopyGap(identity?.poa.email, ownerSourceEmail),
phone: poaCopyGap(identity?.poa.phone, ownerSourcePhone),
// The location is the one detail the API never demands, so a blank one
// dead-ends nothing — and asking for the owner's city under a card that
// says "same as owner" reads as a contradiction.
address: false,
}
: {
name: poaGap(identity?.poa.name),
email: poaGap(identity?.poa.email),
phone: poaGap(identity?.poa.phone),
address: poaGap(identity?.poa.address),
};
// The GM's own verification never falls back to the signed-in account — that // The GM's own verification never falls back to the signed-in account — that
// account is the person onboarding, not necessarily the manager — so a GM // account is the person onboarding, not necessarily the manager — so a GM
// verified with no email claim has nowhere else for one to come from. The // verified with no email claim has nowhere else for one to come from. The
@@ -1024,6 +1115,10 @@ export default function CompanyProfileForm({
form={form} form={form}
identity={identity} identity={identity}
requirePoa={requirePoa} requirePoa={requirePoa}
poaSameAsOwner={poaSameAsOwner}
onTogglePoaSameAsOwner={togglePoaSameAsOwner}
poaLinkPending={poaLinkPending}
etradeOwner={etradeOwner}
gaps={poaGaps} gaps={poaGaps}
onRemovePoa={removePoa} onRemovePoa={removePoa}
removePending={poaRemovePending} removePending={poaRemovePending}

View File

@@ -10,20 +10,27 @@ export function LinkCheckboxCard({
onToggle, onToggle,
title, title,
description, description,
disabled = false,
}: { }: {
checked: boolean; checked: boolean;
onToggle: (checked: boolean) => void; onToggle: (checked: boolean) => void;
title: string; title: string;
description: string; description: string;
/** Greys the card out and refuses the toggle — the link is not available yet. */
disabled?: boolean;
}) { }) {
return ( return (
<UnstyledButton <UnstyledButton
onClick={() => onToggle(!checked)} onClick={() => !disabled && onToggle(!checked)}
role="checkbox" role="checkbox"
aria-checked={checked} aria-checked={checked}
className={`w-full rounded-lg border! p-3! text-left transition-colors! ${checked aria-disabled={disabled}
className={`w-full rounded-lg border! p-3! text-left transition-colors! ${disabled ? "cursor-not-allowed! opacity-60!" : ""
} ${checked
? "border-[var(--mantine-color-edr-green-6)]! bg-[var(--mantine-color-edr-green-0)]!" ? "border-[var(--mantine-color-edr-green-6)]! bg-[var(--mantine-color-edr-green-0)]!"
: "border-[var(--mantine-color-gray-3)]! hover:border-[var(--mantine-color-edr-green-4)]! hover:bg-[var(--mantine-color-edr-green-0)]!" : disabled
? "border-[var(--mantine-color-gray-3)]!"
: "border-[var(--mantine-color-gray-3)]! hover:border-[var(--mantine-color-edr-green-4)]! hover:bg-[var(--mantine-color-edr-green-0)]!"
}`} }`}
> >
<Group gap="sm" wrap="nowrap" align="flex-start"> <Group gap="sm" wrap="nowrap" align="flex-start">

View File

@@ -9,12 +9,20 @@ import type { FileUploadSetting } from "@/types/fileUploadSettings";
import type { CompanyIdentityState } from "@/services/verifayda.service"; import type { CompanyIdentityState } from "@/services/verifayda.service";
import type { FormData } from "../schema"; import type { FormData } from "../schema";
import { LinkCheckboxCard } from "../LinkCheckboxCard";
export interface PoaStepProps { export interface PoaStepProps {
form: UseFormReturn<FormData>; form: UseFormReturn<FormData>;
identity?: CompanyIdentityState; identity?: CompanyIdentityState;
/** This company holds a freight-forwarder profile, so the PoA is mandatory. */ /** This company holds a freight-forwarder profile, so the PoA is mandatory. */
requirePoa: boolean; requirePoa: boolean;
/** The owner represents the company themselves. */
poaSameAsOwner: boolean;
onTogglePoaSameAsOwner: (checked: boolean) => void;
/** A server-side "same as owner" declaration is in flight. */
poaLinkPending: boolean;
/** eTrade-registered owner, once a TIN lookup has succeeded. */
etradeOwner: { name: string; phone: string } | null;
/** /**
* Which of the representative's details the Fayda verification did not * Which of the representative's details the Fayda verification did not
* supply, and are therefore typed here. Computed by CompanyProfileForm, which * supply, and are therefore typed here. Computed by CompanyProfileForm, which
@@ -39,6 +47,10 @@ export default function PoaStep({
form, form,
identity, identity,
requirePoa, requirePoa,
poaSameAsOwner,
onTogglePoaSameAsOwner,
poaLinkPending,
etradeOwner,
gaps, gaps,
onRemovePoa, onRemovePoa,
removePending, removePending,
@@ -65,26 +77,64 @@ export default function PoaStep({
const needsEmail = gaps.email; const needsEmail = gaps.email;
const needsPhone = gaps.phone; const needsPhone = gaps.phone;
// Fayda is mandatory for an Ethiopian company's representative, so there the
// link can only reuse a proven owner — with none there would be nothing to
// copy and the declaration could never satisfy the gate. A foreign company's
// owner is backed by a typed passport, so it prefills instead.
const linkNeedsVerifiedOwner =
(identity?.faydaRequired ?? false) && !identity?.owner.verified;
return ( return (
<> <>
<Text size="sm" c="edr-muted"> <Text size="sm" c="edr-muted">
{requirePoa {requirePoa
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required." ? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details are required."
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."}{" "} : "Power of Attorney details are optional. Fill them in if you have them, or skip to continue."}{" "}
{/* The API refuses an owner who delegates to themselves — say so here, {poaSameAsOwner
or the customer only finds out after being sent to Fayda and back. */} ? "You represent the company yourself, so no delegation paper is needed."
The representative must be someone other than the company's owner. : "If the representative is someone other than the owner, upload the delegation paper authenticated by DARS."}
</Text> </Text>
{/* An owner who represents their own company is the ordinary
small-business case. Where the owner is Fayda-verified this reuses
that proven identity outright rather than sending the same human
through Fayda twice; where they are backed by a typed passport there
is nothing proven to copy, so it stays a local prefill. Either way it
is the declaration that waives the DARS paper. */}
{identity && (
<LinkCheckboxCard
checked={poaSameAsOwner}
onToggle={onTogglePoaSameAsOwner}
disabled={poaLinkPending || (linkNeedsVerifiedOwner && !poaSameAsOwner)}
title={
identity.owner.verified
? "Same as verified owner"
: "Same as business owner"
}
description={
linkNeedsVerifiedOwner
? "Verify the company owner with Fayda first — then you can reuse that identity here."
: identity.owner.verified
? "You represent the company yourself. Reuses the Fayda-verified owner's identity, and no DARS delegation paper is needed. Uncheck to name someone else."
: etradeOwner
? "You represent the company yourself. Reuses the eTrade-registered owner's name plus the company email and phone as you entered them, and no DARS delegation paper is needed. Uncheck to name someone else."
: "You represent the company yourself. Reuses your account's name, email and phone, and no DARS delegation paper is needed. Uncheck to name someone else."
}
/>
)}
{/* A representative acts for the company inside Ethiopia {/* A representative acts for the company inside Ethiopia
whoever owns it, so the PoA is proven with Fayda regardless of whoever owns it, so the PoA is proven with Fayda regardless of
nationality — their name, email, phone and address all come nationality — their name, email, phone and address all come
from the verification and are never typed here. */} from the verification and are never typed here. Verifying a second
{identity && ( person is only meaningful when the representative is not the owner. */}
{identity && !poaSameAsOwner && (
<FaydaVerifyPanel <FaydaVerifyPanel
subject="poa" subject="poa"
title="Power of Attorney" title="Power of Attorney"
state={identity.poa} state={identity.poa}
required={requirePoa} required={requirePoa}
disabled={poaLinkPending}
/> />
)} )}
{/* A verification cannot be undone by clearing the form — it owns those {/* A verification cannot be undone by clearing the form — it owns those
@@ -92,7 +142,7 @@ export default function PoaStep({
then blocks the submit. So an optional representative needs a way then blocks the submit. So an optional representative needs a way
back out, here rather than only in settings (unreachable until back out, here rather than only in settings (unreachable until
onboarding finishes). */} onboarding finishes). */}
{identity?.poa.verified && !requirePoa && ( {identity?.poa.verified && !requirePoa && !poaSameAsOwner && (
<Group justify="flex-end"> <Group justify="flex-end">
<Button <Button
type="button" type="button"

View File

@@ -41,6 +41,7 @@ import {
} from "@/services/companies.service"; } from "@/services/companies.service";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import { verifaydaService } from "@/services/verifayda.service"; import { verifaydaService } from "@/services/verifayda.service";
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
// The representative's name, email, phone and address all come from their // The representative's name, email, phone and address all come from their
@@ -135,13 +136,21 @@ export default function TabPowerOfAttorney({
// company inside Ethiopia either way. A PoA therefore exists exactly when one // company inside Ethiopia either way. A PoA therefore exists exactly when one
// has been verified. // has been verified.
const identity = profile.identity; const identity = profile.identity;
const owner = identity?.owner;
const poaProvided = identity?.poa.verified ?? false; const poaProvided = identity?.poa.verified ?? false;
const [poaSameAsOwner, setPoaSameAsOwner] = useState(
identity?.poaSameAsOwner ?? false,
);
// The paper authorises the representative named above, so there is nothing // The paper authorises the representative named above, so there is nothing
// for it to authorise until one has been verified — the upload is hidden // for it to authorise until one has been verified — the upload is hidden
// until then, and requiring it while hidden would block the save on a // until then, and requiring it while hidden would block the save on a
// control the customer cannot see. A freight forwarder is still held to // control the customer cannot see. A freight forwarder is still held to
// having a PoA at all, by the verification gate on the panel and by the API. // having a PoA at all, by the verification gate on the panel and by the API.
const letterRequired = poaProvided; //
// And nobody delegates to themselves: an owner representing their own company
// has no delegation to evidence, which is the same waiver the API applies in
// `assertPoaDelegationSatisfied`.
const letterRequired = poaProvided && !poaSameAsOwner;
const letterMissing = letterRequired && !hasLetterAfterSave; const letterMissing = letterRequired && !hasLetterAfterSave;
const fileDirty = Boolean(pickedFile) || removeIds.length > 0; const fileDirty = Boolean(pickedFile) || removeIds.length > 0;
@@ -198,6 +207,46 @@ export default function TabPowerOfAttorney({
}, },
}); });
/**
* "Same as owner": the owner represents the company themselves. Always goes
* to the API, whichever credential backs the owner — the declaration is what
* waives the DARS paper, so it has to be recorded server-side even when there
* is no proven identity to copy.
*
* Unchecking undoes the declaration only. It leaves the paper on file and is
* allowed for a freight forwarder, which is how one changes who represents
* it; "Remove representative" below is the harder action that takes the paper
* with it and is refused to a forwarder.
*/
const [linkPending, setLinkPending] = useState(false);
const [linkError, setLinkError] = useState<string | null>(null);
const togglePoaSameAsOwner = async (checked: boolean) => {
setPoaSameAsOwner(checked);
setLinkError(null);
setLinkPending(true);
try {
if (checked) await verifaydaService.setPoaSameAsOwner();
else await verifaydaService.clearPoaSameAsOwner();
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
queryClient.invalidateQueries({
queryKey: api.companies.poaDelegation.queryKey(),
});
} catch (err) {
setPoaSameAsOwner(!checked);
setLinkError(
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
(err instanceof Error
? err.message
: "Could not update the Power of Attorney"),
);
} finally {
setLinkPending(false);
}
};
const onSubmit = (data: FormData) => { const onSubmit = (data: FormData) => {
// The letter lives outside the form state, so it's gated here rather than // The letter lives outside the form state, so it's gated here rather than
// in the zod resolver. // in the zod resolver.
@@ -247,17 +296,60 @@ export default function TabPowerOfAttorney({
</Group> </Group>
<Text c="edr-muted" size="sm" mb="lg"> <Text c="edr-muted" size="sm" mb="lg">
{requirePoa {requirePoa
? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney and its DARS delegation paper are required." ? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney is required."
: "Power of Attorney details are optional. If you name a representative, upload the DARS delegation paper authorising them."} : "Power of Attorney details are optional."}{" "}
{poaSameAsOwner
? "You represent the company yourself, so no delegation paper is needed."
: "If you name a representative, upload the DARS delegation paper authorising them."}
</Text> </Text>
{/* The owner representing their own company is the ordinary
small-business case: a verified owner's identity is reused outright,
and either way the declaration waives the DARS paper. Where Fayda is
mandatory it needs a verified owner first — there would be nothing
proven to copy, and a representative who could never satisfy the
gate. */}
{identity && ( {identity && (
<LinkCheckboxCard
checked={poaSameAsOwner}
onToggle={togglePoaSameAsOwner}
disabled={
linkPending ||
mutation.isPending ||
(!poaSameAsOwner &&
(identity.faydaRequired ?? false) &&
!owner?.verified)
}
title={
owner?.verified
? "Same as verified owner"
: "Same as business owner"
}
description={
(identity.faydaRequired ?? false) && !owner?.verified
? "Verify the company owner with Fayda first — then you can reuse that identity here."
: owner?.verified
? "You represent the company yourself. Reuses the Fayda-verified owner's identity, and no DARS delegation paper is needed. Uncheck to name someone else."
: "You represent the company yourself. Reuses the owner's name, email and phone, and no DARS delegation paper is needed. Uncheck to name someone else."
}
/>
)}
{linkError && (
<Alert color="red" variant="light" icon={<XCircle size={18} />} mt="md">
{linkError}
</Alert>
)}
{/* Verifying a second person only means something when the
representative is someone other than the owner. */}
{identity && !poaSameAsOwner && (
<FaydaVerifyPanel <FaydaVerifyPanel
subject="poa" subject="poa"
title="Power of Attorney" title="Power of Attorney"
state={identity.poa} state={identity.poa}
required={requirePoa} required={requirePoa}
disabled={mutation.isPending} disabled={mutation.isPending || linkPending}
/> />
)} )}
@@ -284,7 +376,7 @@ export default function TabPowerOfAttorney({
{/* ------------------------ Delegation letter ------------------------ */} {/* ------------------------ Delegation letter ------------------------ */}
{/* The paper authorises the representative the verification named, {/* The paper authorises the representative the verification named,
so it only has meaning once one exists. */} so it only has meaning once one exists. */}
{poaProvided && ( {poaProvided && !poaSameAsOwner && (
<Stack gap="sm" mt="xl"> <Stack gap="sm" mt="xl">
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Group gap="sm"> <Group gap="sm">
@@ -459,8 +551,12 @@ export default function TabPowerOfAttorney({
)} )}
</Group> </Group>
<Group gap="md"> <Group gap="md">
{/* Not offered against a "same as owner" declaration: unchecking
the card above is the way out of that one, and it leaves the
paper alone. */}
{mode === "edit" && {mode === "edit" &&
identity?.poa.verified && identity?.poa.verified &&
!poaSameAsOwner &&
!requirePoa && ( !requirePoa && (
<Button <Button
type="button" type="button"

View File

@@ -162,6 +162,11 @@ export interface OnboardingLicenseProfile {
export interface OnboardingPoaState { export interface OnboardingPoaState {
required: boolean; required: boolean;
provided: boolean; provided: boolean;
/**
* True when the DARS delegation paper is owed. False when the owner
* represents the company themselves — nobody delegates to themselves.
*/
delegationLetterRequired: boolean;
delegationLetterUploaded: boolean; delegationLetterUploaded: boolean;
/** True when a reviewer sent the DARS delegation paper back for correction. */ /** True when a reviewer sent the DARS delegation paper back for correction. */
delegationLetterFlagged: boolean; delegationLetterFlagged: boolean;

View File

@@ -41,6 +41,13 @@ export interface CompanyIdentityState {
passportRequired: boolean; passportRequired: boolean;
owner: OwnerIdentityState; owner: OwnerIdentityState;
poa: IdentityVerificationState; poa: IdentityVerificationState;
/**
* True when the representative is the owner themselves, declared through
* "same as owner". Waives the DARS delegation paper — nobody delegates to
* themselves — and, where the owner is Fayda-verified, backs `poa.verified`
* with the owner's sub.
*/
poaSameAsOwner: boolean;
/** /**
* General manager. `verified` covers both routes: the GM verifying in their * General manager. `verified` covers both routes: the GM verifying in their
* own right, and the company declaring the GM is the owner (in which case * own right, and the company declaring the GM is the owner (in which case
@@ -143,6 +150,34 @@ export const verifaydaService = {
return unwrap(response.data); return unwrap(response.data);
}, },
/**
* Declare the Power of Attorney is the company's owner. A Fayda-verified
* owner's identity is copied server-side (the portal never supplies it); a
* foreign company's owner has nothing proven to copy, so the API records the
* declaration and the form types the representative's details as usual.
*
* Either way the declaration is what waives the DARS delegation paper.
*/
setPoaSameAsOwner: async (): Promise<CompanyIdentityState> => {
const response = await client.post<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/poa/same-as-owner",
);
return unwrap(response.data);
},
/**
* Undo that declaration and the identity it copied, leaving the
* representative open to be verified in their own right. Unlike
* {@link removePoa} this is allowed for a freight forwarder — it is how they
* change who represents them — and leaves the delegation paper on file.
*/
clearPoaSameAsOwner: async (): Promise<CompanyIdentityState> => {
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/poa/same-as-owner",
);
return unwrap(response.data);
},
/** /**
* Drop the Power of Attorney — verified identity, details and delegation * Drop the Power of Attorney — verified identity, details and delegation
* paper together. A verified person's fields are locked, so blanking the form * paper together. A verified person's fields are locked, so blanking the form