feat(freight-api): gate poa paper, fayda identity, foreign passport

- dars delegation paper mandatory wherever poa state changes (named,
  removed, forwarder role applied for/approved), not just onboarding
- ethiopian companies verify owner (and poa, once named) via fayda;
  identity, not general manager, is the verified subject
- foreign companies require a typed owner passport number instead,
  independent of an optional fayda verification
- fanNumber removed from client-writable dtos; server-derived only

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nathnael
2026-07-28 13:44:23 +00:00
parent dcda8d7d37
commit 68f6ec8c5f
10 changed files with 871 additions and 64 deletions

View File

@@ -35,6 +35,10 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
import {
CompanyIdentityStateDto,
CompleteIdentityVerificationDto,
} from "./dto/complete-identity-verification.dto";
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
@@ -378,6 +382,32 @@ export class CompaniesController {
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
}
@Post("identity/fayda/complete")
@ApiOperation({
summary:
"Bind a completed Fayda verification to the company's owner or Power of Attorney. " +
"Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " +
"The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.",
})
async completeIdentityVerification(
@CurrentUser() user: CurrentIamUser,
@Body() dto: CompleteIdentityVerificationDto,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.completeIdentityVerification(user.id, dto);
}
@Delete("identity/fayda/poa")
@ApiOperation({
summary:
"Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " +
"Refused while the company holds a freight forwarder role, which cannot operate without a representative.",
})
async removePoaIdentity(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.removePoaIdentity(user.id);
}
@Patch("onboarding-step")
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
@HttpCode(HttpStatus.NO_CONTENT)

View File

@@ -20,6 +20,7 @@ import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
import { VerifaydaModule } from "../verifayda/verifayda.module";
@Module({
imports: [
@@ -38,6 +39,8 @@ import { CompanyNotifierService } from "./company-notifier.service";
// imports this module back for portal recipient targeting, hence forwardRef.
NotificationsModule,
forwardRef(() => NotificationInboxModule),
// Fayda identity verification for the company's owner and PoA.
VerifaydaModule,
],
controllers: [CompaniesController],
providers: [

View File

@@ -17,6 +17,18 @@ import {
import { FilesService } from "../files/files.service";
import { FileRecord } from "../files/entities/file.entity";
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
import {
POA_DELEGATION_FILE_KEY,
POA_DELEGATION_LABEL,
POA_DELEGATION_PENDING_CODE,
} from "../file-upload-settings/poa-delegation.constants";
import { VerifaydaService } from "../verifayda/verifayda.service";
import {
buildCompanyIdentityState,
CompanyIdentityStateDto,
CompleteIdentityVerificationDto,
IdentitySubject,
} from "./dto/complete-identity-verification.dto";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
@@ -58,10 +70,6 @@ const LICENSE_CODE = "business_license";
/** Code for a license file staged in an open change request (not yet live). */
const LICENSE_PENDING_CODE = "business_license_pending";
/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */
const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
/** Code for a PoA letter staged in an open change request (not yet live). */
const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
/** FileRecord resource that company-level documents are stored under. */
const COMPANY_RESOURCE = "companies";
/** company.attributes keys that together mean "a PoA was entered". */
@@ -79,6 +87,40 @@ const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
{ key: "poaPhone", label: "PoA phone" },
];
/**
* `attributes` key prefix per verifiable person. The owner is NOT the general
* manager — GM is a plain typed role (the portal offers a "same as owner" copy
* once the owner is verified), while the owner is who this verification
* actually proves. They're very often the same human; that's what the copy is
* for.
*/
const IDENTITY_PREFIX: Record<IdentitySubject, "owner" | "poa"> = {
owner: "owner",
poa: "poa",
};
const IDENTITY_LABEL: Record<IdentitySubject, string> = {
owner: "owner",
poa: "Power of Attorney",
};
/**
* Identity fields a Fayda verification owns outright, per person. Once verified
* these can no longer be typed — the government IdP is the source, so an edit
* that disagrees with it is either a mistake or an attempt to launder the
* guarantee away. The GM fields are deliberately absent: GM is never itself
* Fayda-verified, so it stays freely editable regardless of the owner's state.
*/
const IDENTITY_OWNED_FIELDS: Record<IdentitySubject, string[]> = {
owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"],
poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"],
};
/** The attributes a verification writes, for one person. */
interface VerifiedIdentityAttributes {
[key: string]: unknown;
}
export interface UserIdentity {
userId: string;
firstName: string;
@@ -100,6 +142,7 @@ export class CompaniesService {
private readonly etradeService: ETradeService,
private readonly companyNotifier: CompanyNotifierService,
private readonly dataSource: DataSource,
private readonly verifaydaService: VerifaydaService,
) { }
/**
@@ -599,7 +642,9 @@ export class CompaniesService {
*/
private mapProfileDtoToCompanyUpdates(
company: Company,
dto: Partial<UpdateProfileDto>,
dto: Partial<UpdateProfileDto> & {
faydaIdentity?: VerifiedIdentityAttributes;
},
): Record<string, any> {
const companyUpdates: Record<string, any> = {};
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
@@ -617,7 +662,6 @@ export class CompaniesService {
if (dto.tin !== undefined && dto.tin !== company.tin)
companyUpdates.tin = dto.tin;
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
if (dto.fanNumber !== undefined) companyUpdates.fanNumber = dto.fanNumber;
if (dto.contactPersonName !== undefined)
attrUpdates.contactPersonName = dto.contactPersonName;
@@ -661,6 +705,47 @@ export class CompaniesService {
if (dto.etradePhone !== undefined)
companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
// A plain typed field — never Fayda-verified, so no lock ever applies to
// it. Independent of the owner's verification: still required for a
// foreign company even if the owner also verifies with Fayda.
if (dto.ownerPassportNumber !== undefined)
attrUpdates.ownerPassportNumber = dto.ownerPassportNumber;
// A verified identity overwrites the person's details. `faydaIdentity`
// never comes off the wire — the global validation pipe runs with
// forbidNonWhitelisted, so a client that sends it is rejected outright; it
// only reaches here from completeIdentityVerification, directly or through
// a staged snapshot.
if (dto.faydaIdentity) {
Object.assign(attrUpdates, dto.faydaIdentity);
}
// Renaming a Fayda-verified person by hand would launder the guarantee
// away, so the fields the verification owns are refused once it exists.
for (const subject of ["owner", "poa"] as IdentitySubject[]) {
if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
for (const field of IDENTITY_OWNED_FIELDS[subject]) {
const incoming = (dto as Record<string, unknown>)[field];
if (incoming === undefined) continue;
// The verification itself is allowed to write them; anything else is
// compared against what is already stored, not against the value this
// same call just copied into the patch. Phones are compared normalized:
// a form that re-renders +251911000000 as 0911000000 is echoing the
// stored value back, not trying to change it.
if (dto.faydaIdentity && field in dto.faydaIdentity) continue;
const stored = company.attributes?.[field];
const same = field.endsWith("Phone")
? normalizeE164(String(incoming)) ===
normalizeE164(String(stored ?? ""))
: incoming === stored;
if (!same) {
throw new BadRequestException(
`${field} is set by the Fayda verification of this company's ${IDENTITY_LABEL[subject]} and cannot be edited. Re-verify to change it.`,
);
}
}
}
companyUpdates.attributes = attrUpdates;
return companyUpdates;
}
@@ -702,6 +787,19 @@ export class CompaniesService {
): Promise<ProfileResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
// Naming (or renaming) a Power of Attorney is one of the writes that can
// leave the company with a representative and nothing evidencing them, so
// it is gated here. Edits that don't touch the PoA are left alone — a
// company carrying legacy details must not be locked out of every other
// field until it produces a paper.
if (POA_ATTRIBUTES.some((k) => dto[k] !== undefined)) {
const attributes = this.mapProfileDtoToCompanyUpdates(company, dto)
.attributes as Record<string, unknown>;
await this.assertPoaDelegationSatisfied(company.id, attributes, {
requirePoa: await this.isFreightForwarder(company.id),
});
}
if (company.status !== CompanyStatus.Active) {
await this.assertTinAvailable(company, dto.tin);
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto);
@@ -1127,11 +1225,24 @@ export class CompaniesService {
// blacklist skip all this — staff must always be able to act against a bad
// account.
return this.dataSource.transaction(async (manager) => {
await manager.findOne(Company, {
const company = await manager.findOne(Company, {
where: { id: existing.companyId },
lock: { mode: "pessimistic_write" },
});
// Putting a forwarder into service without a Power of Attorney backed by
// a DARS paper is the thing EDRFREIGHT-358 forbids, so the approval is
// the last place it has to be checked — the role may have been applied
// for before the paper was withdrawn.
if (company && existing.type === ProfileType.freightForwarder) {
this.assertIdentityVerified(company, { requirePoa: true });
await this.assertPoaDelegationSatisfied(
company.id,
company.attributes,
{ requirePoa: true },
);
}
const [companyDocs, profileDocs] = await Promise.all([
this.filesService.findWithOpenChangeRequest(
[existing.companyId],
@@ -1382,6 +1493,18 @@ export class CompaniesService {
);
if (existing) continue;
// A forwarder signs on other companies' behalf, so it cannot be taken on
// without a Power of Attorney and its DARS paper — checked here so the
// customer is told at the point of asking, not at review.
if (type === ProfileType.freightForwarder) {
this.assertIdentityVerified(company, { requirePoa: true });
await this.assertPoaDelegationSatisfied(
companyId,
await this.effectivePoaAttributes(company),
{ requirePoa: true },
);
}
// Self-service role adds start Pending and carry no reference — a reference
// is minted only when a backoffice reviewer approves the role.
await this.companyProfilesRepo.create({
@@ -1419,6 +1542,14 @@ export class CompaniesService {
}
let created = await this.companyProfilesRepo.findByType(companyId, type);
if (!created && type === ProfileType.freightForwarder) {
this.assertIdentityVerified(company, { requirePoa: true });
await this.assertPoaDelegationSatisfied(
companyId,
await this.effectivePoaAttributes(company),
{ requirePoa: true },
);
}
if (!created) {
// New self-service roles start Pending (awaiting backoffice approval) and
// carry no reference until approved.
@@ -1453,11 +1584,17 @@ export class CompaniesService {
userId: string,
): Promise<OnboardingRequirementsResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
const identity = this.getCompanyIdentityState(company);
// 1. Required company-information fields.
const missingInfo = this.REQUIRED_COMPANY_INFO.filter(
(f) => !f.get(company),
).map((f) => ({ key: f.key, label: f.label }));
// 1. Required company-information fields. The FAN is never one of them —
// Fayda verification doesn't produce a FAN, so it's never collected as
// part of onboarding at all (see the identity block below).
const requiredInfo = this.REQUIRED_COMPANY_INFO.filter(
(f) => f.key !== "fanNumber",
);
const missingInfo = requiredInfo
.filter((f) => !f.get(company))
.map((f) => ({ key: f.key, label: f.label }));
// 2. Nationality-based company documents + which are already uploaded.
const documentSettingCode = this.documentSettingCodeFor(company.nationality);
@@ -1504,26 +1641,31 @@ export class CompaniesService {
// 4. Power of Attorney. Optional in general, but a freight forwarder acts on
// other companies' behalf so its PoA is mandatory. Either way, a PoA that
// has been entered must be evidenced by the delegation letter.
// has been entered must be evidenced by the DARS delegation paper — a legal
// requirement, so unlike the documents above it does not depend on the
// upload set carrying a field for it (see poa-delegation.constants.ts).
const poaRequired = (company.companyProfiles ?? []).some(
(p) => p.type === ProfileType.freightForwarder,
);
const poaProvided = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(),
);
const missingPoaFields = poaRequired
? REQUIRED_POA_FIELDS.filter(
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
)
: [];
// Only gate on the letter once the document set actually carries the field.
const delegationField = (setting?.fields ?? []).find(
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
);
const missingDelegation =
Boolean(delegationField) &&
(poaRequired || poaProvided) &&
!uploadedCodes.has(POA_DELEGATION_FILE_KEY);
// 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
// reports "verify your PoA" instead.
const missingPoaFields =
poaRequired && !identity.faydaRequired
? REQUIRED_POA_FIELDS.filter(
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
)
: [];
const delegation = await this.getPoaDelegationState(company.id);
const delegationDue = poaRequired || poaProvided;
const missingDelegation = delegationDue && !delegation.onFile;
// A paper the reviewer sent back is not evidence — the customer has to
// replace it before the application counts as complete.
const flaggedDelegation = delegationDue && delegation.flagged;
const outstanding = [
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
@@ -1534,29 +1676,62 @@ export class CompaniesService {
),
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
...(missingDelegation
? ["Upload the delegation letter for your Power of Attorney"]
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
: []),
...(flaggedDelegation
? [`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`]
: []),
...(identity.faydaRequired && !identity.owner.verified
? ["Verify the company owner's identity with Fayda"]
: []),
...(identity.faydaRequired &&
(poaRequired || poaProvided) &&
!identity.poa.verified
? ["Verify your Power of Attorney's identity with Fayda"]
: []),
...(identity.passportRequired && !identity.owner.passportNumber
? ["Add the company owner's passport number"]
: []),
];
// Progress spans every required item the user has to satisfy: company-info
// fields, required documents, one license per operational profile, and the
// PoA details/letter whenever those are mandatory.
// PoA details/paper whenever those are mandatory.
const requiredDocCount = documents.filter((d) => d.isRequired).length;
const poaItemCount =
(poaRequired ? REQUIRED_POA_FIELDS.length : 0) +
(delegationField && (poaRequired || poaProvided) ? 1 : 0);
(poaRequired && !identity.faydaRequired
? 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), 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;
const total =
this.REQUIRED_COMPANY_INFO.length +
requiredInfo.length +
requiredDocCount +
licenseProfiles.length +
poaItemCount;
poaItemCount +
identityItemCount;
const completed =
total -
(missingInfo.length +
missingDocs.length +
missingLicenses.length +
missingPoaFields.length +
(missingDelegation ? 1 : 0));
(missingDelegation || flaggedDelegation ? 1 : 0) +
missingIdentityCount);
return new OnboardingRequirementsResponseDto({
documentSettingCode,
@@ -1567,10 +1742,15 @@ export class CompaniesService {
poa: {
required: poaRequired,
provided: poaProvided,
delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY),
delegationLetterUploaded: delegation.onFile,
delegationLetterFlagged: delegation.flagged,
missingFields: missingPoaFields,
complete: missingPoaFields.length === 0 && !missingDelegation,
complete:
missingPoaFields.length === 0 &&
!missingDelegation &&
!flaggedDelegation,
},
identity,
progress: { completed, total },
isComplete: outstanding.length === 0,
onboardingCompleted: profile.onboardingCompleted,
@@ -2044,15 +2224,350 @@ export class CompaniesService {
}
// ---------------------------------------------------------------------------
// Power of Attorney delegation letter
// Power of Attorney delegation paper (DARS)
//
// A company-level document that follows the same staged-review model as the
// business license: on an approved (Active) company an upload lands under the
// pending code and the live letter is flagged for removal, so the reviewer
// pending code and the live paper is flagged for removal, so the reviewer
// sees both and approval swaps them atomically. During onboarding it goes live.
// ---------------------------------------------------------------------------
/** The company's PoA letter(s), with each file's review status resolved. */
/**
* What the company has on file towards its DARS delegation paper. A paper
* staged for review counts as "on file" — it is the customer's whole
* obligation discharged; whether it is good enough is the reviewer's call,
* recorded as `flagged`.
*/
private async getPoaDelegationState(
companyId: string,
ignoreFileIds: string[] = [],
): Promise<{ onFile: boolean; flagged: boolean }> {
const records = (
await this.filesService.findByResource(companyId, COMPANY_RESOURCE)
).filter(
(r) =>
(r.code === POA_DELEGATION_FILE_KEY ||
r.code === POA_DELEGATION_PENDING_CODE) &&
!ignoreFileIds.includes(r.id),
);
return {
onFile: records.length > 0,
flagged: records.some((r) => r.reviewStatus === "change_requested"),
};
}
/**
* The rule behind EDRFREIGHT-358: a company that names a Power of Attorney
* must evidence it with a DARS delegation paper, and a freight forwarder —
* which signs on other companies' behalf — must have both, verified.
*
* This is enforced at every write that can break the pairing (PoA details
* saved, paper removed, forwarder role applied for or approved) rather than
* only at onboarding submission, which is what let a company that finished
* onboarding as an importer pick up the forwarder role with neither.
*
* `attributes` is the state being written, which is not always the state on
* the row yet — a staged change request carries it, and a removal has to be
* judged against the files that would survive it (`ignoreFileIds`).
*/
private async assertPoaDelegationSatisfied(
companyId: string,
attributes: Record<string, unknown> | null | undefined,
opts: { requirePoa: boolean; ignoreFileIds?: string[] },
): Promise<void> {
const read = (key: string) =>
(attributes?.[key] as string | undefined)?.trim();
const poaProvided = POA_ATTRIBUTES.some((k) => read(k));
if (!opts.requirePoa && !poaProvided) return;
if (opts.requirePoa) {
const missing = REQUIRED_POA_FIELDS.filter((f) => !read(f.key));
if (missing.length > 0) {
throw new BadRequestException(
`A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` +
`Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`,
);
}
}
const { onFile, flagged } = await this.getPoaDelegationState(
companyId,
opts.ignoreFileIds,
);
if (!onFile) {
throw new BadRequestException(
`Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` +
(opts.requirePoa ? " — it is required for freight forwarders." : "."),
);
}
if (flagged) {
throw new BadRequestException(
`The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` +
`Re-upload it before continuing.`,
);
}
}
/** Does this company operate as a freight forwarder? */
private async isFreightForwarder(companyId: string): Promise<boolean> {
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
return profiles.some((p) => p.type === ProfileType.freightForwarder);
}
// ---------------------------------------------------------------------------
// Fayda identity verification (owner / PoA)
//
// A completed VeriFayda verification proves a person's name, phone, email
// and address — Fayda's userinfo carries no national ID number, so none of
// that is collected here. For an Ethiopian company both the owner and its
// PoA (once named) must be verified before the company can trade. Fayda is
// an Ethiopian national ID system, so a foreign company's owner proves
// identity with a typed passport number instead — required on its own
// terms, not waived by an owner who happens to verify with Fayda too.
// ---------------------------------------------------------------------------
/**
* Verification state for both people, plus whether it is mandatory here.
* `complete` answers the gate question directly so the portal, the onboarding
* requirements and the assertions below all read the same verdict — the
* derivation itself is shared with ProfileResponseDto.
*/
getCompanyIdentityState(company: Company): CompanyIdentityStateDto {
return buildCompanyIdentityState(company);
}
/**
* Complete a Fayda verification and bind the identity to one of the company's
* people. The portal starts the flow through the shared
* `POST /fayda/verification/start` and only tells us which person it was for
* here, at completion — so the verifayda module stays generic and its session
* table needs no company-specific column.
*/
async completeIdentityVerification(
userId: string,
dto: CompleteIdentityVerificationDto,
): Promise<CompanyIdentityStateDto> {
const { company } = await this.getCompanyInfoByUserId(userId);
const prefix = IDENTITY_PREFIX[dto.subject];
const result = await this.verifaydaService.completeVerification({
code: dto.code,
state: dto.state,
});
if (!result.verified || !result.sub) {
throw new BadRequestException(
"Fayda could not verify this identity. Start the verification again.",
);
}
// The owner delegating power of attorney to themselves is not a
// delegation — it would let one identity satisfy both halves of the check.
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
if (otherSub && otherSub === result.sub) {
throw new BadRequestException(
`This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
);
}
const now = new Date().toISOString();
const identity: VerifiedIdentityAttributes = {
[`${prefix}FaydaSub`]: result.sub,
[`${prefix}FaydaVerifiedAt`]: now,
[`${prefix}Birthdate`]: result.birthdate ?? null,
[`${prefix}Gender`]: result.gender ?? null,
// The verified payload owns the person's details from here on.
...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}),
...(result.email ? { [`${prefix}Email`]: result.email } : {}),
...(result.phoneNumber ? { [`${prefix}Phone`]: result.phoneNumber } : {}),
...(dto.subject === "poa" && result.address
? { poaAddress: 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) {
await this.stageIdentityChange(company, userId, identity);
return this.getCompanyIdentityState(company);
}
const updated = await this.companiesRepo.update(company.id, {
attributes: { ...(company.attributes ?? {}), ...identity },
});
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.
*
* Only the PoA can go: a company always has an owner, and a freight forwarder
* always has a representative. Once a PoA is Fayda-verified its
* fields are locked, so blanking the form is no longer a way out — without
* this the customer would be stuck with a representative they cannot remove.
*/
async removePoaIdentity(userId: string): Promise<CompanyIdentityStateDto> {
const { company } = await this.getCompanyInfoByUserId(userId);
if (
(company.companyProfiles ?? []).some(
(p) => p.type === ProfileType.freightForwarder,
)
) {
throw new BadRequestException(
"A freight forwarder must have a Power of Attorney. Remove the freight forwarder role first.",
);
}
const cleared: Record<string, unknown> = {};
for (const key of [
...POA_ATTRIBUTES,
"poaFaydaSub",
"poaFaydaVerifiedAt",
"poaBirthdate",
"poaGender",
]) {
cleared[key] = null;
}
const attributes = { ...(company.attributes ?? {}), ...cleared };
// The paper evidences a representative who no longer exists.
const records = await this.filesService.findByResource(
company.id,
COMPANY_RESOURCE,
);
for (const r of records) {
if (
r.code === POA_DELEGATION_FILE_KEY ||
r.code === POA_DELEGATION_PENDING_CODE
) {
await this.filesService.remove(r.id);
await this.withdrawDocumentIntent(company.id, r.id);
}
}
const updated = await this.companiesRepo.update(company.id, { attributes });
if (!updated)
throw new NotFoundException(`Company ${company.id} not found`);
updated.companyProfiles = company.companyProfiles;
return this.getCompanyIdentityState(updated);
}
/** Stage a verified identity onto the company's pending change request. */
private async stageIdentityChange(
company: Company,
userId: string,
identity: VerifiedIdentityAttributes,
): Promise<void> {
const existing = await this.changeRequestRepo.findPendingByCompanyId(
company.id,
);
const now = new Date();
const snapshot = {
...(existing?.snapshot ?? {}),
faydaIdentity: {
...(((existing?.snapshot ?? {}) as Record<string, any>)
.faydaIdentity ?? {}),
...identity,
},
};
if (existing) {
await this.changeRequestRepo.update(existing.id, {
snapshot,
submittedBy: userId,
submittedAt: now,
note: null,
});
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
return;
}
const history = await this.changeRequestRepo.findByCompanyId(company.id);
const resubmitted = history.some(
(r) => r.status === ChangeRequestStatus.Rejected,
);
const request = await this.changeRequestRepo.create({
companyId: company.id,
snapshot,
status: ChangeRequestStatus.Pending,
submittedBy: userId,
submittedAt: now,
});
this.companyNotifier.changeRequestSubmitted(
company,
request.id,
resubmitted,
);
}
/**
* The gate: an Ethiopian company's owner must be Fayda-verified, and so must
* its Power of Attorney once it has one; a foreign company's owner must carry
* a passport number instead. Called from the same places as
* `assertPoaDelegationSatisfied` — the two rules describe the same moment
* (who may act for this company, and on what evidence) and drifting them
* apart is how one of them ends up unenforced.
*/
private assertIdentityVerified(
company: Company,
opts: { requirePoa: boolean },
): void {
const state = buildCompanyIdentityState(company);
if (state.passportRequired) {
if (!state.owner.passportNumber) {
throw new BadRequestException(
"Add the company owner's passport number before continuing.",
);
}
return;
}
if (!state.owner.verified) {
throw new BadRequestException(
"Verify the company owner's identity with Fayda before continuing.",
);
}
const poaNamed = POA_ATTRIBUTES.some((k) =>
(company.attributes?.[k] as string | undefined)?.trim(),
);
if (!opts.requirePoa && !poaNamed) return;
if (!state.poa.verified) {
throw new BadRequestException(
opts.requirePoa
? "Verify your Power of Attorney with Fayda — a freight forwarder cannot operate without one."
: "Verify the Power of Attorney you named with Fayda, or remove the representative.",
);
}
}
/**
* The PoA details the company is heading for: its live attributes with any
* pending change-request snapshot laid over them. An Active company's edits
* are staged rather than written, so the live row on its own would judge the
* customer against details they have already asked to change.
*/
private async effectivePoaAttributes(
company: Company,
): Promise<Record<string, unknown>> {
const pending = await this.changeRequestRepo.findPendingByCompanyId(
company.id,
);
const snapshot = (pending?.snapshot ?? {}) as Record<string, unknown>;
const staged: Record<string, unknown> = {};
for (const key of POA_ATTRIBUTES) {
if (key in snapshot) staged[key] = snapshot[key];
}
return { ...(company.attributes ?? {}), ...staged };
}
/** The company's PoA paper(s), with each file's review status resolved. */
async listPoaDelegationFiles(
userId: string,
): Promise<CompanyDocumentFileView[]> {
@@ -2149,6 +2664,18 @@ export class CompaniesService {
throw new NotFoundException(`Delegation letter ${fileId} not found`);
}
// Taking the paper away is the other half of the pairing: allowed only once
// the representative it evidences is gone too (which, for an Active
// company, means the clearing edit is already staged).
await this.assertPoaDelegationSatisfied(
company.id,
await this.effectivePoaAttributes(company),
{
requirePoa: await this.isFreightForwarder(company.id),
ignoreFileIds: [fileId],
},
);
if (record.code === POA_DELEGATION_PENDING_CODE) {
await this.filesService.remove(fileId);
await this.withdrawDocumentIntent(company.id, fileId);

View File

@@ -0,0 +1,148 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsIn, IsString, IsNotEmpty } from "class-validator";
import { Company, CompanyNationality } from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity";
/**
* The two people a company is verified through — its owner and its Power of
* Attorney. "Owner" is not the same as the General Manager: a company's GM is
* a plain typed role (with a "same as owner" copy the portal offers), while
* the owner is the person this verification proves. They're very often the
* same human, which is exactly what the copy is for.
*/
export const IDENTITY_SUBJECTS = ["owner", "poa"] as const;
export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
export class CompleteIdentityVerificationDto {
@ApiProperty({
enum: IDENTITY_SUBJECTS,
description: "Which of the company's people this verification is for.",
})
@IsIn(IDENTITY_SUBJECTS)
subject!: IdentitySubject;
@ApiProperty({ description: "Authorization code from the Fayda redirect." })
@IsString()
@IsNotEmpty()
code!: string;
@ApiProperty({ description: "CSRF state from the Fayda redirect." })
@IsString()
@IsNotEmpty()
state!: string;
}
/** One person's verification state, as reported back to the portal. */
export class IdentityVerificationStateDto {
@ApiProperty() verified!: boolean;
@ApiProperty({ nullable: true }) name!: string | null;
@ApiProperty({ nullable: true }) phone!: string | null;
@ApiProperty({ nullable: true }) email!: string | null;
@ApiProperty({ nullable: true }) address!: string | null;
@ApiProperty({ nullable: true }) verifiedAt!: string | null;
}
export class OwnerIdentityStateDto extends IdentityVerificationStateDto {
@ApiProperty({
nullable: true,
description:
"Typed passport number — the foreign-company identity credential. Independent of Fayda: never written by a verification, and still required even if the owner also verifies.",
})
passportNumber!: string | null;
}
export class CompanyIdentityStateDto {
@ApiProperty({
description:
"True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.",
})
faydaRequired!: boolean;
@ApiProperty({
description:
"True when the owner's passport number is mandatory — foreign companies only. Independent of faydaRequired: a foreign owner may verify with Fayda too, but the passport is still required.",
})
passportRequired!: boolean;
@ApiProperty({ type: OwnerIdentityStateDto })
owner!: OwnerIdentityStateDto;
@ApiProperty({ type: IdentityVerificationStateDto })
poa!: IdentityVerificationStateDto;
@ApiProperty({
description:
"False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.",
})
complete!: boolean;
}
/** `attributes` key prefix per person. */
const PREFIX: Record<IdentitySubject, "owner" | "poa"> = {
owner: "owner",
poa: "poa",
};
/** company.attributes keys that together mean "a PoA was entered". */
const POA_KEYS = [
"poaName",
"poaPhone",
"poaEmail",
"poaLocation",
"poaAddress",
] as const;
function stateFor(
attrs: Record<string, unknown>,
subject: IdentitySubject,
): IdentityVerificationStateDto {
const p = PREFIX[subject];
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
return {
verified: Boolean(read(`${p}FaydaSub`)),
name: read(`${p}Name`),
phone: read(`${p}Phone`),
email: read(`${p}Email`),
address: read(`${p}Address`),
verifiedAt: read(`${p}FaydaVerifiedAt`),
};
}
/**
* Derive both people's verification state from the company row.
*
* Pure and shared: `CompaniesService` gates on it and `ProfileResponseDto`
* renders from it, so the settings page and the onboarding wizard can never
* disagree with the rule the API actually enforces.
*/
export function buildCompanyIdentityState(
company: Company,
): CompanyIdentityStateDto {
const attrs = company.attributes ?? {};
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
// Fayda is an Ethiopian national ID — a foreign company's owner may not hold
// one, so a typed passport number is the mandatory credential there instead.
// The two are mutually exclusive by nationality but independently tracked,
// since a foreign owner verifying with Fayda doesn't waive the passport.
const foreign = company.nationality === CompanyNationality.Foreign;
const faydaRequired = !foreign;
const passportRequired = foreign;
const owner: OwnerIdentityStateDto = {
...stateFor(attrs, "owner"),
passportNumber: read("ownerPassportNumber"),
};
const poa = stateFor(attrs, "poa");
const poaDue =
(company.companyProfiles ?? []).some(
(p) => p.type === ProfileType.freightForwarder,
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
const complete = faydaRequired
? owner.verified && (!poaDue || poa.verified)
: !passportRequired || Boolean(owner.passportNumber);
return { faydaRequired, passportRequired, owner, poa, complete };
}

View File

@@ -8,6 +8,8 @@
* truth the wizard uses to auto-finish.
*/
import { CompanyIdentityStateDto } from "./complete-identity-verification.dto";
export interface OnboardingInfoField {
key: string;
label: string;
@@ -40,11 +42,13 @@ export interface OnboardingPoaState {
required: boolean;
/** True once any PoA detail has been entered. */
provided: boolean;
/** True when the delegation letter is stored for the company. */
/** True when the DARS delegation paper is stored for the company. */
delegationLetterUploaded: boolean;
/** True when a reviewer sent the paper back for correction. */
delegationLetterFlagged: boolean;
/** PoA details still missing (only populated when `required`). */
missingFields: OnboardingInfoField[];
/** False while the PoA step still owes details or a delegation letter. */
/** False while the PoA step still owes details or an uncorrected paper. */
complete: boolean;
}
@@ -68,6 +72,13 @@ export class OnboardingRequirementsResponseDto {
/** Power of Attorney state, so the wizard needn't re-derive the rule. */
poa: OnboardingPoaState;
/**
* Fayda verification state for the company's people. `required` is false for
* a foreign company, which is never gated on it — the portal renders the
* typed personnel forms in that case and the verify panels otherwise.
*/
identity: CompanyIdentityStateDto;
/** Overall setup progress across fields + documents + licenses. */
progress: { completed: number; total: number };
@@ -87,6 +98,7 @@ export class OnboardingRequirementsResponseDto {
this.documents = init.documents;
this.licenseProfiles = init.licenseProfiles;
this.poa = init.poa;
this.identity = init.identity;
this.progress = init.progress;
this.isComplete = init.isComplete;
this.onboardingCompleted = init.onboardingCompleted;

View File

@@ -1,3 +1,7 @@
import {
buildCompanyIdentityState,
CompanyIdentityStateDto,
} from "./complete-identity-verification.dto";
import { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity';
import {
@@ -52,6 +56,16 @@ export class ProfileResponseDto {
profileId: string;
/**
* Fayda verification state for the company's owner and PoA — not the general
* manager, which is a separate typed role. The settings tabs and the
* onboarding wizard render from `identity.faydaRequired` /
* `identity.passportRequired`: an Ethiopian company verifies the owner (and
* PoA) instead of typing their details; a foreign one requires a typed
* passport number instead.
*/
identity: CompanyIdentityStateDto;
/**
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
* settings page; `"rejected"` surfaces the note and prefills the (declined)
@@ -124,5 +138,6 @@ export class ProfileResponseDto {
: null;
this.reviewNote = openReview?.note ?? null;
this.pendingChanges = openReview?.snapshot ?? null;
this.identity = buildCompanyIdentityState(company);
}
}

View File

@@ -44,10 +44,11 @@ export class UpdateProfileDto {
@MaxLength(50)
vatNumber?: string;
@IsOptional()
@IsString()
@MaxLength(16)
fanNumber?: string;
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
// company's PoA (or its general manager), so it is derived from a completed
// Fayda verification rather than typed. The global validation pipe runs with
// forbidNonWhitelisted, so a client that still sends it gets a 400 telling it
// so — see CompaniesService.completeIdentityVerification.
@IsOptional()
@IsString()
@@ -110,6 +111,16 @@ export class UpdateProfileDto {
@IsString()
poaAddress?: string;
/**
* The owner's passport number — the identity credential for a foreign
* company, since Fayda is an Ethiopian national ID. Plain typed field, never
* written or locked by a Fayda verification: still required even if the
* owner also verifies.
*/
@IsOptional()
@IsString()
ownerPassportNumber?: string;
@IsOptional()
@IsString()
@MaxLength(100)

View File

@@ -15,6 +15,11 @@ import {
FILE_UPLOAD_SETTINGS_REPOSITORY,
IFileUploadSettingsRepository,
} from "./interfaces/file-upload-settings.repository.interface";
import {
COMPANY_ONBOARDING_CODE_PREFIX,
POA_DELEGATION_FILE_KEY,
poaDelegationField,
} from "./poa-delegation.constants";
@Injectable()
export class FileUploadSettingsService {
@@ -40,6 +45,22 @@ export class FileUploadSettingsService {
async getByCode(code: string): Promise<FileUploadSetting> {
const setting = await this.repository.findByCode(code);
if (!setting) throw new NotFoundException(`Setting "${code}" not found`);
return this.withPoaDelegationField(setting);
}
/**
* Company onboarding sets always carry the DARS delegation paper, whether or
* not anyone configured a row for it — see poa-delegation.constants.ts. Every
* consumer (the portal's PoA step, the onboarding gate) reads the set through
* here, so this is the single place the field can be guaranteed.
*/
private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting {
if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting;
const fields = setting.fields ?? [];
if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting;
const lastOrder = fields.reduce((max, f) => Math.max(max, f.displayOrder), 0);
setting.fields = [...fields, poaDelegationField(lastOrder + 1)];
return setting;
}

View File

@@ -0,0 +1,52 @@
import { FileUploadField } from "./entities/file-upload-field.entity";
/**
* The DARS delegation paper — the document that evidences a company's Power of
* Attorney (EDRFREIGHT-358).
*
* Every other onboarding document is admin-managed: the rows in
* `file_upload_fields` are edited from the backoffice file-settings editor and
* the seeder deliberately inserts none. This one is different — a company that
* names a PoA must produce a delegation paper authenticated by the Documents
* Authentication and Registration Service, and that is a legal requirement
* rather than a configuration choice. So the field is defined here in code and
* injected into the company onboarding sets on read: no row to forget to seed,
* and deleting one in the editor cannot silently switch the requirement off.
*/
/** FileRecord `code` (and upload field key) of the live delegation paper. */
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
/** Code for a delegation paper staged in an open change request (not yet live). */
export const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
/** Customer-facing name of the document, used by the API and both web apps. */
export const POA_DELEGATION_LABEL = "DARS Delegation Paper";
/** Prefix of the setting codes the field is injected into. */
export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_";
const POA_DELEGATION_HELP =
"Delegation paper issued by the Documents Authentication and Registration " +
"Service (DARS) delegating the representative named above. Upload the " +
"authenticated copy — a plain letter is not accepted.";
/**
* The field descriptor. `isRequired` stays false because the paper is only due
* once a PoA has actually been named (or the company operates as a freight
* forwarder) — a rule that spans form fields as well as files, so it is
* enforced in CompaniesService rather than by this flag.
*/
export function poaDelegationField(displayOrder: number): FileUploadField {
return {
fileKey: POA_DELEGATION_FILE_KEY,
fileLabel: POA_DELEGATION_LABEL,
helpText: POA_DELEGATION_HELP,
isRequired: false,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 10,
displayOrder,
} as FileUploadField;
}

View File

@@ -2,6 +2,7 @@ import { Injectable, Logger } from "@nestjs/common";
import { DataSource } from "typeorm";
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
import { poaDelegationField } from "../modules/file-upload-settings/poa-delegation.constants";
interface OnboardingField {
fileKey: string;
@@ -17,27 +18,14 @@ interface OnboardingField {
const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"];
/** fileKey of the delegation letter attached to the Power of Attorney step. */
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
/**
* Seeded as optional: the delegation letter is only mandatory once a PoA has
* been entered, or when the company operates as a freight forwarder. That rule
* spans form fields as well as files, so it lives in the onboarding gate
* (companies.service.getOnboardingRequirements) rather than in `isRequired`.
* Listed in the sets below only so the reference defaults stay a complete
* picture of a company onboarding form. Unlike every other field here, the DARS
* delegation paper is not admin-managed: `FileUploadSettingsService.getByCode`
* injects it from poa-delegation.constants.ts whether or not a row exists.
*/
const poaDelegationField = (displayOrder: number): OnboardingField => ({
fileKey: POA_DELEGATION_FILE_KEY,
fileLabel: "PoA Delegation Letter",
helpText:
"Signed letter in which the General Manager delegates the representative named above.",
isRequired: false,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder,
});
const poaDelegationDefault = (displayOrder: number): OnboardingField =>
poaDelegationField(displayOrder) as unknown as OnboardingField;
/** Documents required from an Ethiopian company at onboarding. */
const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
@@ -75,7 +63,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
maxSizeMb: 10,
displayOrder: 3,
},
poaDelegationField(4),
poaDelegationDefault(4),
];
/** Documents required from a Foreign company at onboarding. */
@@ -124,7 +112,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
maxSizeMb: 10,
displayOrder: 4,
},
poaDelegationField(5),
poaDelegationDefault(5),
];
/** Legacy combined set, kept for the older per-company-type codes. */