mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 20:40:55 +00:00
refactor(api): extract self-service attributes for immediate application and poa verification improvement
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user