refactor(api): extract self-service attributes for immediate application and poa verification improvement

This commit is contained in:
Nathnael
2026-07-31 07:38:49 +00:00
parent 378b9b6b21
commit 5a02da91d2
9 changed files with 237 additions and 333 deletions

View File

@@ -10,12 +10,17 @@ import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.
* come from the verified payload, not typed. Fayda's userinfo carries no
* national ID number, so none is collected or derived here.
*
* - Ethiopian company: the owner (and its PoA, once named) is verified through
* Fayda, and their details can't be edited afterwards.
* Only the OWNER's credential varies by nationality:
* - Ethiopian company: the owner is verified through Fayda.
* - Foreign company: Fayda is an Ethiopian national ID, so the owner instead
* supplies a typed passport number — required on its own, whether or not the
* owner also completes a (purely optional) Fayda verification.
*
* The PoA does not vary. A representative acts for the company inside Ethiopia
* whoever owns it, so a PoA is always an Ethiopian holding a Fayda ID: once one
* is named, both nationalities must verify them, and their details come from
* the verified payload rather than the form.
*
* The owner is NOT the general manager — GM is a separate, plain typed role
* the portal offers a "same as owner" copy for, but it is never itself
* Fayda-verified or gated on.
@@ -107,6 +112,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
},
changeRequestRepo: {
findPendingByCompanyId: jest.fn(async () => null),
findLatestOpenByCompanyId: jest.fn(async () => null),
findByCompanyId: jest.fn(async () => []),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "cr-1",
@@ -229,9 +235,27 @@ describe("Fayda identity verification binds a person to the company", () => {
).rejects.toBeInstanceOf(BadRequestException);
});
it("stages the change for review on an approved company", async () => {
// Swapping the person who can act for a live company is exactly what the
// backoffice review exists for, so it must not rewrite the row directly.
it("stages an owner re-verification for review on an approved company", async () => {
// The owner is the live company's identity proof, so re-verifying one is
// exactly what the backoffice review exists for: it must not rewrite the
// row directly.
const { service, ctx, deps } = makeService({
status: CompanyStatus.Active,
});
await service.completeIdentityVerification("user-1", {
subject: "owner",
code: "c",
state: "s",
});
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
expect(ctx.attributes.ownerFaydaSub).toBeUndefined();
});
it("applies a PoA verification live on an approved company", async () => {
// The PoA is personnel the company names for itself — the delegation paper
// is what a reviewer actually judges — so it does not go to review.
const { service, ctx, deps } = makeService({
status: CompanyStatus.Active,
});
@@ -242,8 +266,8 @@ describe("Fayda identity verification binds a person to the company", () => {
state: "s",
});
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
expect(ctx.attributes.poaFaydaSub).toBeUndefined();
expect(deps.changeRequestRepo.create).not.toHaveBeenCalled();
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
});
it("refuses to rename a verified person by hand", async () => {
@@ -367,7 +391,29 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
).rejects.toBeInstanceOf(BadRequestException);
});
it("grants the forwarder role to a foreign company with an owner passport and no Fayda at all", async () => {
it("grants the forwarder role to a foreign company whose owner has a passport and whose PoA is Fayda-verified", async () => {
const { service } = makeService({
profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign,
attributes: {
ownerPassportNumber: "P1234567",
...POA_VERIFIED,
},
files: [paper()],
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
});
it("still requires a Fayda-verified PoA from a foreign company", async () => {
// The owner's credential is nationality-specific; the representative's is
// not. A PoA acts for the company inside Ethiopia whoever owns it, so a
// typed foreign name is not a representative the platform can accept.
const { service } = makeService({
profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign,
@@ -385,7 +431,7 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
).rejects.toBeInstanceOf(BadRequestException);
});
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {

View File

@@ -81,6 +81,9 @@ function makeService(overrides: Partial<Ctx> = {}) {
findPendingByCompanyId: jest.fn(async () =>
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
),
findLatestOpenByCompanyId: jest.fn(async () =>
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
),
findByCompanyId: jest.fn(async () => []),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "cr-1",

View File

@@ -81,6 +81,28 @@ const POA_ATTRIBUTES = [
"poaLocation",
"poaAddress",
] as const;
/**
* Personnel an approved company maintains itself: its contact person, its
* general manager and its Power of Attorney. These name who to talk to, not
* what the company is allowed to do, so freezing the settings page until a
* reviewer gets to a new phone number costs more than it protects. They write
* straight to the live row even for an active company.
*
* The PoA's *delegation letter* is deliberately not here — the paper is the
* thing that actually evidences the delegation, so it still goes through
* review (see `uploadPoaDelegationLetter`), as does the owner's own identity.
*/
const SELF_SERVICE_ATTRIBUTES: readonly string[] = [
"contactPersonName",
"contactPersonPosition",
"contactPersonEmail",
"contactPersonPhone",
"contactVerifiedPhone",
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
...POA_ATTRIBUTES,
];
/** Mandatory once the company operates as a freight forwarder. */
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
{ key: "poaName", label: "PoA name" },
@@ -844,10 +866,11 @@ export class CompaniesService {
*
* - Company not yet approved (onboarding) → write straight to the Company row,
* as before. The company/role pending→approve gate already covers first-run.
* - Company already `active` → do NOT touch the live Company. Stage the edit in
* a pending change request (merging into any open one) so a backoffice
* reviewer can approve (apply) or reject (with a note). This locks the
* customer until the review resolves.
* - Company already `active` → personnel details (`SELF_SERVICE_ATTRIBUTES`)
* still write straight through; everything else does NOT touch the live
* Company but is staged in a pending change request (merging into any open
* one) so a backoffice reviewer can approve (apply) or reject (with a
* note). Only the staged half locks the customer until the review resolves.
*/
async updateProfile(
userId: string,
@@ -882,9 +905,37 @@ export class CompaniesService {
return new ProfileResponseDto(profile, updated);
}
// Approved company: stage the change for review, leaving the live row intact.
// Approved company: personnel details apply immediately, the rest is staged
// for review with the live row left intact.
await this.assertTinAvailable(company, dto.tin);
const fields = this.pickDefined(dto);
const selfService: Record<string, any> = {};
const staged: Record<string, any> = {};
for (const [key, value] of Object.entries(fields)) {
if (SELF_SERVICE_ATTRIBUTES.includes(key)) selfService[key] = value;
else staged[key] = value;
}
let live = company;
if (Object.keys(selfService).length > 0) {
live =
(await this.companiesRepo.update(
company.id,
this.mapProfileDtoToCompanyUpdates(company, selfService),
)) ?? company;
live.companyProfiles = company.companyProfiles;
}
if (Object.keys(staged).length === 0) {
// Nothing a reviewer needs to see. Any request already open (a document
// upload, an owner verification) still surfaces so its banner survives —
// it just no longer gains fields it was never asked to review.
return new ProfileResponseDto(
profile,
live,
await this.changeRequestRepo.findLatestOpenByCompanyId(company.id),
);
}
const existing = await this.changeRequestRepo.findPendingByCompanyId(
company.id,
@@ -894,7 +945,7 @@ export class CompaniesService {
if (existing) {
request =
(await this.changeRequestRepo.update(existing.id, {
snapshot: { ...(existing.snapshot ?? {}), ...fields },
snapshot: { ...(existing.snapshot ?? {}), ...staged },
submittedBy: userId,
submittedAt: now,
note: null,
@@ -910,7 +961,7 @@ export class CompaniesService {
);
request = await this.changeRequestRepo.create({
companyId: company.id,
snapshot: fields,
snapshot: staged,
status: ChangeRequestStatus.Pending,
submittedBy: userId,
submittedAt: now,
@@ -922,8 +973,9 @@ export class CompaniesService {
);
}
// Live company is unchanged; surface the pending state for the settings page.
return new ProfileResponseDto(profile, company, request);
// Only the personnel half (if any) landed; surface the pending state for
// the settings page.
return new ProfileResponseDto(profile, live, request);
}
/** List a company's change requests, newest first (backoffice review). */
@@ -1720,16 +1772,11 @@ export class CompaniesService {
const poaProvided = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(),
);
// An Ethiopian company does not type its PoA details at all — they arrive
// from the Fayda verification — so reporting them as missing fields would
// ask for something the form no longer offers. The identity block below
// No company types its PoA details — they arrive from the Fayda
// verification whatever the nationality — so reporting them as missing
// fields would ask for something no form offers. The identity block below
// reports "verify your PoA" instead.
const missingPoaFields =
poaRequired && !identity.faydaRequired
? REQUIRED_POA_FIELDS.filter(
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
)
: [];
const missingPoaFields: typeof REQUIRED_POA_FIELDS = [];
const delegation = await this.getPoaDelegationState(company.id);
const delegationDue = poaRequired || poaProvided;
const missingDelegation = delegationDue && !delegation.onFile;
@@ -1754,9 +1801,7 @@ export class CompaniesService {
...(identity.faydaRequired && !identity.owner.verified
? ["Verify the company owner's identity with Fayda"]
: []),
...(identity.faydaRequired &&
(poaRequired || poaProvided) &&
!identity.poa.verified
...((poaRequired || poaProvided) && !identity.poa.verified
? ["Verify your Power of Attorney's identity with Fayda"]
: []),
...(identity.passportRequired && !identity.owner.passportNumber
@@ -1768,26 +1813,20 @@ export class CompaniesService {
// fields, required documents, one license per operational profile, and the
// PoA details/paper whenever those are mandatory.
const requiredDocCount = documents.filter((d) => d.isRequired).length;
const poaItemCount =
(poaRequired && !identity.faydaRequired
? REQUIRED_POA_FIELDS.length
: 0) + (delegationDue ? 1 : 0);
const poaItemCount = delegationDue ? 1 : 0;
// One item per identity credential the company has to prove: the owner
// always (Fayda for Ethiopian, passport for foreign), the PoA once there
// is one and Fayda is what's mandatory here.
const identityItemCount = identity.faydaRequired
? delegationDue
? 2
: 1
: identity.passportRequired
? 1
: 0;
const missingIdentityCount = identity.faydaRequired
? (identity.owner.verified ? 0 : 1) +
(delegationDue && !identity.poa.verified ? 1 : 0)
: identity.passportRequired && !identity.owner.passportNumber
? 1
: 0;
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once
// there is one — that one is Fayda whatever the nationality.
const ownerCredentialDue =
identity.faydaRequired || identity.passportRequired;
const ownerCredentialProven = identity.faydaRequired
? identity.owner.verified
: Boolean(identity.owner.passportNumber);
const identityItemCount =
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
const missingIdentityCount =
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
(delegationDue && !identity.poa.verified ? 1 : 0);
const total =
requiredInfo.length +
requiredDocCount +
@@ -2453,11 +2492,13 @@ export class CompaniesService {
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
};
// An approved company's profile edits are staged for backoffice review, and
// swapping the person who can act for the company is exactly the kind of
// edit that review exists for — so a verification lands the same way an
// ordinary edit does, rather than quietly rewriting a live record.
if (company.status === CompanyStatus.Active) {
// An approved company's *owner* is its identity proof, so re-verifying one
// is staged for backoffice review rather than quietly rewriting a live
// record. The PoA is personnel — the company names its own representative,
// and the delegation letter backing them is what the reviewer sees — so a
// PoA verification lands live, matching the typed PoA fields in
// `SELF_SERVICE_ATTRIBUTES`.
if (company.status === CompanyStatus.Active && dto.subject !== "poa") {
await this.stageIdentityChange(company, userId, identity);
return this.getCompanyIdentityState(company);
}
@@ -2586,21 +2627,23 @@ export class CompaniesService {
): void {
const state = buildCompanyIdentityState(company);
// Only the owner's credential is nationality-specific: Fayda for an
// Ethiopian company, a typed passport number for a foreign one.
if (state.passportRequired) {
if (!state.owner.passportNumber) {
throw new BadRequestException(
"Add the company owner's passport number before continuing.",
);
}
return;
}
if (!state.owner.verified) {
} else if (!state.owner.verified) {
throw new BadRequestException(
"Verify the company owner's identity with Fayda before continuing.",
);
}
// The representative is not. A PoA acts for the company inside Ethiopia
// whoever owns it, so they are always an Ethiopian holding a Fayda ID —
// a foreign company nominates one rather than typing a name.
const poaNamed = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(),
);

View File

@@ -144,9 +144,14 @@ export function buildCompanyIdentityState(
(p) => p.type === ProfileType.freightForwarder,
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
const complete = faydaRequired
? owner.verified && (!poaDue || poa.verified)
// Only the *owner's* credential is nationality-specific. A Power of Attorney
// acts for the company inside Ethiopia whoever owns it, so the PoA is always
// proven with Fayda — a foreign company nominates a representative who holds
// one rather than typing a name nothing backs.
const ownerProven = faydaRequired
? owner.verified
: !passportRequired || Boolean(owner.passportNumber);
const complete = ownerProven && (!poaDue || poa.verified);
return { faydaRequired, passportRequired, owner, poa, complete };
}