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);
}
@Post("identity/poa/same-as-owner")
@PortalCustomer()
@ApiOperation({
summary:
"Declare the Power of Attorney is the company's owner, copying the owner's identity across. " +
"Waives the DARS delegation paper — nobody delegates to themselves. " +
"Refused for an Ethiopian company whose owner is not Fayda-verified yet: its representative must be verified, and there would be nothing proven to copy.",
})
async setPoaSameAsOwner(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.setPoaSameAsOwner(user.id, {
email: user.email,
phoneNumber: user.phoneNumber,
});
}
@Delete("identity/poa/same-as-owner")
@PortalCustomer()
@ApiOperation({
summary:
"Undo the Power of Attorney \"same as owner\" declaration and the identity it copied, leaving the representative open to be verified in their own right. " +
"Unlike DELETE identity/fayda/poa this is allowed for a freight forwarder — it is how they change who represents them — and leaves the delegation paper on file.",
})
async clearPoaSameAsOwner(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.clearPoaSameAsOwner(user.id);
}
@Delete("identity/fayda/poa")
@PortalCustomer()
@ApiOperation({

View File

@@ -229,8 +229,10 @@ describe("Fayda identity verification binds a person to the company", () => {
expect(state.owner.verified).toBe(true);
});
it("refuses to make one identity both owner and PoA", async () => {
const { service } = makeService({
it("lets one identity be both owner and PoA", async () => {
// 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" },
verification: {
purpose: "VERIFY",
@@ -240,13 +242,14 @@ describe("Fayda identity verification binds a person to the company", () => {
},
});
await expect(
service.completeIdentityVerification("user-1", {
subject: "poa",
code: "c",
state: "s",
}),
).rejects.toBeInstanceOf(BadRequestException);
const state = await service.completeIdentityVerification("user-1", {
subject: "poa",
code: "c",
state: "s",
});
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 () => {
@@ -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 () => {
// The owner/PoA collision check exists because self-delegation is not
// delegation. It must not fire here: the GM being the owner is a supported
// answer, so verifying with the owner's own Fayda sub has to succeed.
// One human in every role is the ordinary small-business shape, so
// verifying with the owner's own Fayda sub has to succeed.
const { service, ctx } = makeService({
attributes: { ...OWNER_VERIFIED },
verification: {
@@ -727,25 +729,42 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
expect(ctx.attributes.generalManagerName).toBe("Abebe Bikila");
});
it("still refuses a PoA who is the owner", async () => {
// The GM exemption above must not have widened into the PoA.
const { service } = makeService({
attributes: { ...OWNER_VERIFIED },
verification: {
purpose: "VERIFY",
verified: true,
sub: "owner-sub",
fullName: "Abebe Bikila",
},
it("declares the PoA is the owner, copying the verified identity across", async () => {
const { service, ctx } = makeService({ attributes: { ...OWNER_VERIFIED } });
const state = await service.setPoaSameAsOwner("user-1");
expect(state.poaSameAsOwner).toBe(true);
expect(state.poa.verified).toBe(true);
expect(ctx.attributes.poaFaydaSub).toBe(OWNER_VERIFIED.ownerFaydaSub);
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(
service.completeIdentityVerification("user-1", {
subject: "poa",
code: "c",
state: "s",
}),
).rejects.toBeInstanceOf(BadRequestException);
// No declaration in place: the verified representative must survive.
await service.clearPoaSameAsOwner("user-1");
expect(ctx.attributes.poaFaydaSub).toBe(POA_VERIFIED.poaFaydaSub);
await service.setPoaSameAsOwner("user-1");
const state = await service.clearPoaSameAsOwner("user-1");
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 () => {

View File

@@ -174,6 +174,31 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
).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 () => {
const { service } = makeService({ files: [paper("change_requested")] });

View File

@@ -133,12 +133,6 @@ const IDENTITY_PREFIX: Record<IdentitySubject, string> = {
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
* `company.generalManagerEmail` directly, so leaving these behind would mean a
@@ -2046,7 +2040,13 @@ export class CompaniesService {
(company.attributes?.[k] as string | undefined)?.trim(),
);
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
// verification — but Fayda's email and phone claims are optional and
// 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
// representative the API's own `REQUIRED_POA_FIELDS` calls incomplete, then
// 400'd their next PoA edit for it.
const missingPoaFields = delegationDue
const missingPoaFields = poaDue
? REQUIRED_POA_FIELDS.filter(
(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 —
// `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 = 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
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once
// there is one — Fayda for an Ethiopian company, a named representative
@@ -2127,11 +2128,10 @@ export class CompaniesService {
const ownerCredentialProven = identity.faydaRequired
? identity.owner.verified
: Boolean(identity.owner.passportNumber);
const identityItemCount =
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
const identityItemCount = (ownerCredentialDue ? 1 : 0) + (poaDue ? 1 : 0);
const missingIdentityCount =
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
(delegationDue && !poaProven ? 1 : 0);
(poaDue && !poaProven ? 1 : 0);
const total =
requiredInfo.length +
requiredDocCount +
@@ -2159,6 +2159,7 @@ export class CompaniesService {
poa: {
required: poaRequired,
provided: poaProvided,
delegationLetterRequired: delegationDue,
delegationLetterUploaded: delegation.onFile,
delegationLetterFlagged: delegation.flagged,
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(
companyId,
opts.ignoreFileIds,
@@ -2783,21 +2790,11 @@ export class CompaniesService {
);
}
// The owner delegating power of attorney to themselves is not a
// delegation — it would let one identity satisfy both halves of the check.
// Only owner/PoA collide this way: the GM is very often the owner, and
// saying so is a supported answer rather than a conflict, so it is left out
// of this check entirely.
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.`,
);
}
}
// 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();
@@ -2837,6 +2834,12 @@ export class CompaniesService {
// 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;
@@ -2953,6 +2956,118 @@ export class CompaniesService {
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
* 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 [
...POA_ATTRIBUTES,
"poaFaydaSub",

View File

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

View File

@@ -42,6 +42,12 @@ export interface OnboardingPoaState {
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.
*/
delegationLetterRequired: boolean;
/** True when the DARS delegation paper is stored for the company. */
delegationLetterUploaded: boolean;
/** 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
// server's declaration the moment it lands, or a resumed draft shows an
// unticked box over a GM that is linked server-side.
const [poaSameAsOwner, setPoaSameAsOwner] = useState(
identity?.poaSameAsOwner ?? false,
);
const identityLoaded = useRef(false);
useEffect(() => {
if (!identity || identityLoaded.current) return;
identityLoaded.current = true;
setGmSameAsOwner(identity.gmSameAsOwner);
setPoaSameAsOwner(identity.poaSameAsOwner);
}, [identity]);
const [contactSameAsGm, setContactSameAsGm] = useState(false);
// A Fayda-verified owner outranks eTrade's registered owner — it's the
// higher-trust source, and the whole point of proving identity is to stop
// trusting typed/looked-up data for this.
const gmSourceName = firstPresent(
// Where the owner's details come from when they are copied onto someone else
// — the GM, or the representative. A Fayda-verified owner outranks eTrade's
// registered owner: it's the higher-trust source, and the whole point of
// proving identity is to stop trusting typed/looked-up data for this.
const ownerSourceName = firstPresent(
identity?.owner.name,
etradeOwner?.name,
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
// `generalManagerPhone`, which the API validates with `@IsValidPhone()`, so an
// unusable eTrade number here 400s the personnel step instead.
const gmSourcePhone = firstValidPhone(
// `generalManagerPhone` / `poaPhone`, which the API validates with
// `@IsValidPhone()`, so an unusable eTrade number here 400s the step instead.
const ownerSourcePhone = firstValidPhone(
identity?.owner.phone,
etradeOwner?.phone,
user.phoneNumber,
@@ -388,13 +393,30 @@ export default function CompanyProfileForm({
// `identity.gm`; mirroring it into form fields here would send typed
// values for something the API already owns.
if (identity?.owner.verified) return;
setValue("generalManagerName", gmSourceName, { shouldValidate: true });
setValue("generalManagerEmail", gmSourceEmail, { shouldValidate: true });
setValue("generalManagerPhone", gmSourcePhone, {
setValue("generalManagerName", ownerSourceName, { shouldValidate: true });
setValue("generalManagerEmail", ownerSourceEmail, { shouldValidate: true });
setValue("generalManagerPhone", ownerSourcePhone, {
shouldValidate: true,
});
// 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.
@@ -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
// 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.
@@ -704,7 +764,12 @@ export default function CompanyProfileForm({
// 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
// 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 =
(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.
const poaGap = (v?: string | null) =>
poaTypedAllowed && (!identity?.poa.verified || !v?.trim());
const poaGaps = {
name: poaGap(identity?.poa.name),
email: poaGap(identity?.poa.email),
phone: poaGap(identity?.poa.phone),
address: poaGap(identity?.poa.address),
};
/**
* "Same as owner" answers each field only as far as the owner actually has
* one. Fayda's name, email and phone claims are all optional, the account and
* eTrade fallbacks can be empty or unusable, and `REQUIRED_POA_FIELDS` still
* 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
// 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
@@ -1024,6 +1115,10 @@ export default function CompanyProfileForm({
form={form}
identity={identity}
requirePoa={requirePoa}
poaSameAsOwner={poaSameAsOwner}
onTogglePoaSameAsOwner={togglePoaSameAsOwner}
poaLinkPending={poaLinkPending}
etradeOwner={etradeOwner}
gaps={poaGaps}
onRemovePoa={removePoa}
removePending={poaRemovePending}

View File

@@ -10,20 +10,27 @@ export function LinkCheckboxCard({
onToggle,
title,
description,
disabled = false,
}: {
checked: boolean;
onToggle: (checked: boolean) => void;
title: string;
description: string;
/** Greys the card out and refuses the toggle — the link is not available yet. */
disabled?: boolean;
}) {
return (
<UnstyledButton
onClick={() => onToggle(!checked)}
onClick={() => !disabled && onToggle(!checked)}
role="checkbox"
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-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">

View File

@@ -9,12 +9,20 @@ import type { FileUploadSetting } from "@/types/fileUploadSettings";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import type { FormData } from "../schema";
import { LinkCheckboxCard } from "../LinkCheckboxCard";
export interface PoaStepProps {
form: UseFormReturn<FormData>;
identity?: CompanyIdentityState;
/** This company holds a freight-forwarder profile, so the PoA is mandatory. */
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
* supply, and are therefore typed here. Computed by CompanyProfileForm, which
@@ -39,6 +47,10 @@ export default function PoaStep({
form,
identity,
requirePoa,
poaSameAsOwner,
onTogglePoaSameAsOwner,
poaLinkPending,
etradeOwner,
gaps,
onRemovePoa,
removePending,
@@ -65,26 +77,64 @@ export default function PoaStep({
const needsEmail = gaps.email;
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 (
<>
<Text size="sm" c="edr-muted">
{requirePoa
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper 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."}{" "}
{/* The API refuses an owner who delegates to themselves — say so here,
or the customer only finds out after being sent to Fayda and back. */}
The representative must be someone other than the company's owner.
? "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."}{" "}
{poaSameAsOwner
? "You represent the company yourself, so no delegation paper is needed."
: "If the representative is someone other than the owner, upload the delegation paper authenticated by DARS."}
</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
whoever owns it, so the PoA is proven with Fayda regardless of
nationality — their name, email, phone and address all come
from the verification and are never typed here. */}
{identity && (
from the verification and are never typed here. Verifying a second
person is only meaningful when the representative is not the owner. */}
{identity && !poaSameAsOwner && (
<FaydaVerifyPanel
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={requirePoa}
disabled={poaLinkPending}
/>
)}
{/* 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
back out, here rather than only in settings (unreachable until
onboarding finishes). */}
{identity?.poa.verified && !requirePoa && (
{identity?.poa.verified && !requirePoa && !poaSameAsOwner && (
<Group justify="flex-end">
<Button
type="button"

View File

@@ -41,6 +41,7 @@ import {
} from "@/services/companies.service";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import { verifaydaService } from "@/services/verifayda.service";
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
import type { ProfileResponse } from "@/types/profile";
// 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
// has been verified.
const identity = profile.identity;
const owner = identity?.owner;
const poaProvided = identity?.poa.verified ?? false;
const [poaSameAsOwner, setPoaSameAsOwner] = useState(
identity?.poaSameAsOwner ?? false,
);
// The paper authorises the representative named above, so there is nothing
// 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
// 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.
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 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) => {
// The letter lives outside the form state, so it's gated here rather than
// in the zod resolver.
@@ -247,17 +296,60 @@ export default function TabPowerOfAttorney({
</Group>
<Text c="edr-muted" size="sm" mb="lg">
{requirePoa
? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney and its DARS delegation paper are required."
: "Power of Attorney details are optional. If you name a representative, upload the DARS delegation paper authorising them."}
? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney is required."
: "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>
{/* 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 && (
<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
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={requirePoa}
disabled={mutation.isPending}
disabled={mutation.isPending || linkPending}
/>
)}
@@ -284,7 +376,7 @@ export default function TabPowerOfAttorney({
{/* ------------------------ Delegation letter ------------------------ */}
{/* The paper authorises the representative the verification named,
so it only has meaning once one exists. */}
{poaProvided && (
{poaProvided && !poaSameAsOwner && (
<Stack gap="sm" mt="xl">
<Group justify="space-between" align="center">
<Group gap="sm">
@@ -459,8 +551,12 @@ export default function TabPowerOfAttorney({
)}
</Group>
<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" &&
identity?.poa.verified &&
!poaSameAsOwner &&
!requirePoa && (
<Button
type="button"

View File

@@ -162,6 +162,11 @@ export interface OnboardingLicenseProfile {
export interface OnboardingPoaState {
required: 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;
/** True when a reviewer sent the DARS delegation paper back for correction. */
delegationLetterFlagged: boolean;

View File

@@ -41,6 +41,13 @@ export interface CompanyIdentityState {
passportRequired: boolean;
owner: OwnerIdentityState;
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
* 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);
},
/**
* 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
* paper together. A verified person's fields are locked, so blanking the form