mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 09:00:57 +00:00
feat: allow owner to represent company as power of attorney
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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")] });
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user