|
|
|
|
@@ -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);
|
|
|
|
|
|