mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Going back in the wizard and un-ticking co-operative or investment licence used to write the flag and nothing else. The registration the customer had typed stayed on the company row, so `hasRegistrationDetails` still read as a passed eTrade lookup, resume dropped them at their furthest step rather than the company one, and the application could be finished on unverified data with no flag left on it for the backoffice to show. That transition now costs what the settings switch costs: the eTrade-sourced columns and the manager captured beside them are cleared, and onboarding drops back to the company step so the TIN actually goes through eTrade. Both the reset payload and the attribute strip are now shared with `revertToRegularCompany`, which did this correctly already.
3757 lines
141 KiB
TypeScript
3757 lines
141 KiB
TypeScript
import {
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
ConflictException,
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
} from "@nestjs/common";
|
|
import { DataSource, EntityManager } from "typeorm";
|
|
import { resolveIamUserNames } from "../../common/utils/iam-user-name.util";
|
|
import { CompaniesRepository } from "./companies.repository";
|
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
|
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
|
import { CompanyRevisionRepository } from "./company-revision.repository";
|
|
import {
|
|
diffCompanyUpdate,
|
|
summarizeCompanyChanges,
|
|
} from "./company-revision-diff.util";
|
|
import { ExternalProfileRepository } from "./external-profile.repository";
|
|
import {
|
|
CompanyDashboardRepository,
|
|
DashboardScope,
|
|
} from "./company-dashboard.repository";
|
|
import { FilesService } from "../files/files.service";
|
|
import { FileRecord } from "../files/entities/file.entity";
|
|
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
|
import {
|
|
COOPERATIVE_ONBOARDING_CODE,
|
|
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,
|
|
ETRADE_MANAGER_NAME_KEY,
|
|
ETRADE_MANAGER_PHONE_KEY,
|
|
IDENTITY_SUBJECTS,
|
|
IdentitySubject,
|
|
POA_DECLARED_KEY,
|
|
PoaDeclaration,
|
|
readPoaDeclaration,
|
|
} 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";
|
|
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
|
import type { CompanyRegistrationData } from "@edr/types";
|
|
import { CreateCompanyDto } from "./dto/create-company.dto";
|
|
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
|
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
|
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
|
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
|
import { ProfileResponseDto } from "./dto/profile-response.dto";
|
|
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
|
|
import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
|
|
import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
|
|
import {
|
|
Company,
|
|
CompanyNationality,
|
|
CompanyStatus,
|
|
CompanyType,
|
|
COOPERATIVE_KEY,
|
|
INVESTOR_LICENCE_KEY,
|
|
hasInvestorLicence,
|
|
isCooperative,
|
|
usesManualRegistration,
|
|
} from "./entities/company.entity";
|
|
import { ExternalProfile } from "./entities/external-profile.entity";
|
|
import {
|
|
BusinessLicenseFile,
|
|
CompanyDocumentFileView,
|
|
CompanyProfile,
|
|
ProfileLicenseFileView,
|
|
ProfileType,
|
|
ProfileStatus,
|
|
} from "./entities/company-profile.entity";
|
|
import {
|
|
ChangeRequestStatus,
|
|
CompanyChangeRequest,
|
|
DocumentChangeIntent,
|
|
LicenseChangeIntent,
|
|
} from "./entities/company-change-request.entity";
|
|
import {
|
|
CompanyRevision,
|
|
CompanyRevisionChange,
|
|
} from "./entities/company-revision.entity";
|
|
|
|
/** FileRecord `resource` + `code` slots for business-license documents. */
|
|
const LICENSE_RESOURCE = "company_profiles";
|
|
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";
|
|
|
|
/** FileRecord resource that company-level documents are stored under. */
|
|
const COMPANY_RESOURCE = "companies";
|
|
/** company.attributes keys that together mean "a PoA was entered". */
|
|
const POA_ATTRIBUTES = [
|
|
"poaName",
|
|
"poaPhone",
|
|
"poaEmail",
|
|
"poaLocation",
|
|
"poaAddress",
|
|
] as const;
|
|
/**
|
|
* The one person an approved company maintains itself: its contact person.
|
|
* That names 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. It writes straight to the live row even for an active company.
|
|
*
|
|
* The owner and the Power of Attorney are deliberately NOT here. Between them
|
|
* they carry the company's only identity verification — the owner is who the
|
|
* eTrade licence names, the PoA is who may act for the company — so an edit to
|
|
* either is exactly the kind of change a reviewer exists to see. Their
|
|
* delegation letter has always gone through review (`uploadPoaDelegationLetter`).
|
|
*/
|
|
const SELF_SERVICE_ATTRIBUTES: readonly string[] = [
|
|
"contactPersonName",
|
|
"contactPersonPosition",
|
|
"contactPersonEmail",
|
|
"contactPersonPhone",
|
|
"contactVerifiedPhone",
|
|
];
|
|
/** Mandatory once the company names a Power of Attorney. */
|
|
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
|
|
{ key: "poaName", label: "PoA name" },
|
|
{ key: "poaEmail", label: "PoA email" },
|
|
{ key: "poaPhone", label: "PoA phone" },
|
|
];
|
|
|
|
/**
|
|
* `attributes` key prefix per person. The owner is whoever the eTrade licence
|
|
* names as the business's manager; the PoA is whoever the company delegates to.
|
|
* Exactly one of them carries the company's identity verification — which one
|
|
* is the company's own declaration (`poaDeclared`).
|
|
*/
|
|
const IDENTITY_PREFIX: Record<IdentitySubject, string> = {
|
|
owner: "owner",
|
|
poa: "poa",
|
|
};
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
const IDENTITY_OWNED_FIELDS: Record<IdentitySubject, string[]> = {
|
|
owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerAddress"],
|
|
poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"],
|
|
};
|
|
|
|
/**
|
|
* `UpdateProfileDto` fields eTrade is the sole source of truth for. A request
|
|
* touching any of these must be re-checked against a fresh eTrade lookup —
|
|
* see `assertEtradeFieldsAuthentic`.
|
|
*/
|
|
const ETRADE_SOURCED_FIELDS = [
|
|
"companyName",
|
|
"tin",
|
|
"licenceNumber",
|
|
"statusDescription",
|
|
"dateRegistered",
|
|
"renewedFrom",
|
|
"renewalDate",
|
|
"renewedTo",
|
|
"region",
|
|
"zone",
|
|
"woreda",
|
|
"kebele",
|
|
"houseNo",
|
|
"etradePhone",
|
|
] as const satisfies readonly (keyof UpdateProfileDto)[];
|
|
|
|
/** The attributes a verification writes, for one person. */
|
|
interface VerifiedIdentityAttributes {
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface UserIdentity {
|
|
userId: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
email: string;
|
|
phone: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class CompaniesService {
|
|
private readonly logger = new Logger(CompaniesService.name);
|
|
|
|
constructor(
|
|
private readonly companiesRepo: CompaniesRepository,
|
|
private readonly companyProfilesRepo: CompanyProfileRepository,
|
|
private readonly changeRequestRepo: CompanyChangeRequestRepository,
|
|
private readonly revisionRepo: CompanyRevisionRepository,
|
|
private readonly profilesRepo: ExternalProfileRepository,
|
|
private readonly dashboardRepo: CompanyDashboardRepository,
|
|
private readonly filesService: FilesService,
|
|
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
|
private readonly etradeService: ETradeService,
|
|
private readonly companyNotifier: CompanyNotifierService,
|
|
private readonly dataSource: DataSource,
|
|
private readonly verifaydaService: VerifaydaService,
|
|
) { }
|
|
|
|
/**
|
|
* Required company-information fields that must be filled before onboarding can
|
|
* be submitted. The backend owns this list so the portal never has to know
|
|
* which fields are mandatory — it just renders what's reported outstanding.
|
|
* `get` reads the value from the company (some live in the attributes blob).
|
|
*/
|
|
private readonly REQUIRED_COMPANY_INFO: {
|
|
key: string;
|
|
label: string;
|
|
get: (company: Company) => unknown;
|
|
}[] = [
|
|
{
|
|
key: "tinNumber",
|
|
label: "Company TIN",
|
|
get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null),
|
|
},
|
|
{ key: "companyAddress", label: "Company address", get: (c) => c.address },
|
|
{ key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber },
|
|
{
|
|
key: "contactPersonName",
|
|
label: "Contact person name",
|
|
get: (c) => c.attributes?.contactPersonName,
|
|
},
|
|
{
|
|
key: "contactPersonPhone",
|
|
label: "Contact person phone",
|
|
get: (c) => c.attributes?.contactPersonPhone,
|
|
},
|
|
// The owner — whoever the eTrade licence names as the business's manager.
|
|
// All three are required whatever their source: the eTrade lookup fills
|
|
// the name and phone, a Fayda verification can fill all three, and the
|
|
// portal renders an input for whatever neither supplied. eTrade never
|
|
// returns an email and Fayda's email claim is optional, so in practice
|
|
// that field is usually typed — which is fine, because there IS an input
|
|
// for it. What there is no longer is a fallback to the signed-in account:
|
|
// the person onboarding is not necessarily the person on the licence, and
|
|
// silently stamping their address onto the owner made the record a guess.
|
|
{
|
|
key: "ownerName",
|
|
label: "Owner name",
|
|
get: (c) => c.attributes?.ownerName,
|
|
},
|
|
{
|
|
key: "ownerEmail",
|
|
label: "Owner email",
|
|
get: (c) => c.attributes?.ownerEmail,
|
|
},
|
|
{
|
|
key: "ownerPhone",
|
|
label: "Owner phone",
|
|
get: (c) => c.attributes?.ownerPhone,
|
|
},
|
|
];
|
|
|
|
/**
|
|
* The document setting code for a company: one of three mutually exclusive
|
|
* sets. A co-operative union or farm resolves to its own set regardless of
|
|
* nationality — it holds no business licence, so it owes a different list of
|
|
* papers rather than the nationality list plus extras.
|
|
*/
|
|
private documentSettingCodeFor(company: Company): string {
|
|
if (isCooperative(company)) return COOPERATIVE_ONBOARDING_CODE;
|
|
return company.nationality === CompanyNationality.Foreign
|
|
? "company_onboarding_documents_foreign"
|
|
: "company_onboarding_documents_ethiopian";
|
|
}
|
|
|
|
|
|
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
|
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
|
if (exists) {
|
|
throw new ConflictException(`Company with TIN ${dto.tin} already exists`);
|
|
}
|
|
return this.companiesRepo.create(dto);
|
|
}
|
|
|
|
async createCompanyWithProfile(
|
|
identity: UserIdentity,
|
|
dto: CreateCompanyWithProfileDto,
|
|
): Promise<{ company: Company; profile: ExternalProfile }> {
|
|
if (dto.tin) {
|
|
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
|
if (exists) {
|
|
throw new ConflictException(
|
|
`Company with TIN ${dto.tin} already exists`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const existingProfile = await this.profilesRepo.findByUserId(
|
|
identity.userId,
|
|
);
|
|
if (existingProfile) {
|
|
throw new ConflictException(
|
|
`Profile for user ${identity.userId} already exists`,
|
|
);
|
|
}
|
|
|
|
const company = await this.companiesRepo.create({
|
|
name: dto.companyName,
|
|
type: dto.companyType,
|
|
tin: dto.tin ?? "",
|
|
vatNumber: dto.vatNumber ?? null,
|
|
fanNumber: dto.fanNumber ?? null,
|
|
country: dto.companyLocation ?? "Ethiopia",
|
|
address: dto.companyAddress ?? null,
|
|
attributes: dto.attributes ?? null,
|
|
});
|
|
|
|
const profile = await this.profilesRepo.create({
|
|
userId: identity.userId,
|
|
companyId: company.id,
|
|
firstName: identity.firstName,
|
|
lastName: identity.lastName,
|
|
jobTitle: dto.jobTitle ?? null,
|
|
isPrimaryContact: dto.isPrimaryContact ?? true,
|
|
onboardingStep: "company",
|
|
});
|
|
|
|
// Persist the operational role(s) chosen during onboarding. Types are
|
|
// already constrained to the company type on the client; any that don't
|
|
// match are skipped defensively rather than failing the whole signup.
|
|
if (dto.companyProfiles?.length) {
|
|
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
|
for (const input of dto.companyProfiles) {
|
|
if (!allowedTypes.includes(input.type)) continue;
|
|
const existing = await this.companyProfilesRepo.findByType(
|
|
company.id,
|
|
input.type,
|
|
);
|
|
if (existing) continue;
|
|
// No reference yet — these profiles await backoffice approval, which
|
|
// is when the reference is minted (see setCompanyProfileStatus).
|
|
await this.companyProfilesRepo.create({
|
|
companyId: company.id,
|
|
type: input.type,
|
|
businessLicense: input.businessLicense ?? null,
|
|
status: ProfileStatus.Pending,
|
|
});
|
|
}
|
|
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
|
|
company.id,
|
|
);
|
|
}
|
|
|
|
return { company, profile };
|
|
}
|
|
|
|
async listCompanies(
|
|
query: ListCompaniesQueryDto,
|
|
): Promise<{ items: Company[]; total: number }> {
|
|
return this.companiesRepo.findPaginated(query);
|
|
}
|
|
|
|
async getCompanyStats(): Promise<CompanyStatsResponseDto> {
|
|
return this.companiesRepo.getStats();
|
|
}
|
|
|
|
/**
|
|
* Begin onboarding: create a DRAFT company + the user's external profile + the
|
|
* chosen operational role(s) up front, so every subsequent wizard step can
|
|
* save incrementally (PATCH /profile, /onboarding-step) against existing rows.
|
|
*
|
|
* Idempotent: if the user already has a profile, returns it unchanged, with
|
|
* the operational profiles reconciled against the roles just chosen (added
|
|
* and — for still-pending ones — removed). The draft company carries a placeholder TIN
|
|
* (the real one is filled on the Company Information step) and stays
|
|
* status=pending / onboardingCompleted=false until the wizard finishes.
|
|
*/
|
|
async startOnboarding(
|
|
identity: UserIdentity,
|
|
companyType: CompanyType,
|
|
roles: ProfileType[],
|
|
nationality?: CompanyNationality,
|
|
cooperative?: boolean,
|
|
investorLicence?: boolean,
|
|
): Promise<{ profile: ExternalProfile; company: Company }> {
|
|
// Already started — reuse the existing draft, just ensure roles exist and
|
|
// keep the nationality up to date if it was (re)selected.
|
|
const existing = await this.profilesRepo.findByUserId(identity.userId);
|
|
if (existing) {
|
|
const companyId = existing.company?.id ?? existing.companyId;
|
|
// Only load the row when the answer actually depends on it: to merge the
|
|
// flag into `attributes`, or to read a stored one the caller didn't send.
|
|
const needsCompany =
|
|
cooperative !== undefined ||
|
|
investorLicence !== undefined ||
|
|
roles.includes(ProfileType.freightForwarder);
|
|
const current = needsCompany
|
|
? await this.companiesRepo.findById(companyId)
|
|
: null;
|
|
const isCoop = cooperative ?? isCooperative(current);
|
|
const isInvestor = investorLicence ?? hasInvestorLicence(current);
|
|
this.assertRolesAllowedForCooperative(isCoop, roles);
|
|
this.assertNationalityAllowedForCooperative(isCoop, nationality);
|
|
this.assertInvestorLicenceAllowed(
|
|
isInvestor,
|
|
isCoop,
|
|
nationality ?? current?.nationality ?? undefined,
|
|
);
|
|
await this.syncCompanyProfiles(companyId, companyType, roles);
|
|
const updates: Partial<Company> = {};
|
|
if (nationality) updates.nationality = nationality;
|
|
// Ticking the box on a draft that was saved as foreign has to correct the
|
|
// stored nationality too, or the company keeps resolving to the foreign
|
|
// document set.
|
|
if (isCoop) updates.nationality = CompanyNationality.Ethiopian;
|
|
if (cooperative !== undefined || investorLicence !== undefined) {
|
|
updates.attributes = {
|
|
...(current?.attributes ?? {}),
|
|
...(cooperative !== undefined
|
|
? { [COOPERATIVE_KEY]: cooperative }
|
|
: {}),
|
|
...(investorLicence !== undefined
|
|
? { [INVESTOR_LICENCE_KEY]: investorLicence }
|
|
: {}),
|
|
};
|
|
}
|
|
// Going back and un-ticking the box is the same act as the settings
|
|
// switch, so it has to cost the same: the registration the customer typed
|
|
// goes, and onboarding drops back to the company step. Without this the
|
|
// draft keeps the typed values, `hasRegistrationDetails` reads as a passed
|
|
// lookup, resume lands past the company step entirely — and the company
|
|
// finishes onboarding on unverified data with no flag left to say so.
|
|
const backToEtrade =
|
|
usesManualRegistration(current) && !isCoop && !isInvestor;
|
|
if (backToEtrade) {
|
|
Object.assign(updates, CompaniesService.CLEARED_REGISTRATION);
|
|
updates.attributes = this.withoutTypedEtradeManager(
|
|
updates.attributes ?? current?.attributes,
|
|
);
|
|
}
|
|
if (Object.keys(updates).length > 0) {
|
|
await this.companiesRepo.update(companyId, updates);
|
|
}
|
|
if (backToEtrade) {
|
|
await this.profilesRepo.update(existing.id, {
|
|
onboardingStep: "company",
|
|
});
|
|
}
|
|
return this.getCompanyInfoByUserId(identity.userId);
|
|
}
|
|
|
|
this.assertRolesAllowedForCooperative(cooperative === true, roles);
|
|
this.assertNationalityAllowedForCooperative(cooperative === true, nationality);
|
|
this.assertInvestorLicenceAllowed(
|
|
investorLicence === true,
|
|
cooperative === true,
|
|
nationality,
|
|
);
|
|
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
|
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
|
|
|
const company = await this.companiesRepo.create({
|
|
name: identity.firstName
|
|
? `${identity.firstName}'s company`
|
|
: "New company",
|
|
type: companyType,
|
|
tin: await this.generateDraftTin(),
|
|
country: "Ethiopia",
|
|
nationality: nationality ?? CompanyNationality.Ethiopian,
|
|
status: CompanyStatus.Pending,
|
|
...(cooperative || investorLicence
|
|
? {
|
|
attributes: {
|
|
...(cooperative ? { [COOPERATIVE_KEY]: true } : {}),
|
|
...(investorLicence ? { [INVESTOR_LICENCE_KEY]: true } : {}),
|
|
},
|
|
}
|
|
: {}),
|
|
});
|
|
|
|
await this.profilesRepo.create({
|
|
userId: identity.userId,
|
|
companyId: company.id,
|
|
firstName: identity.firstName,
|
|
lastName: identity.lastName,
|
|
isPrimaryContact: true,
|
|
onboardingStep: "company",
|
|
onboardingCompleted: false,
|
|
});
|
|
|
|
await this.syncCompanyProfiles(company.id, companyType, chosenTypes);
|
|
|
|
return this.getCompanyInfoByUserId(identity.userId);
|
|
}
|
|
|
|
/**
|
|
* A co-operative union or farm cannot hold the freight-forwarder role.
|
|
*
|
|
* Forwarding is licensed work — the forwarder signs on other companies'
|
|
* behalf, which is why the role carries a mandatory Power of Attorney and a
|
|
* DARS delegation paper. A co-op is here precisely because it has no business
|
|
* licence, so the role is refused at the door rather than left to fail later
|
|
* at approval with a document it can never produce.
|
|
*/
|
|
private assertRolesAllowedForCooperative(
|
|
cooperative: boolean,
|
|
roles: ProfileType[],
|
|
): void {
|
|
if (!cooperative) return;
|
|
if (roles.includes(ProfileType.freightForwarder)) {
|
|
throw new BadRequestException(
|
|
"A co-operative union or farm cannot register as a freight forwarder — that role requires a business licence.",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A co-operative union or farm is registered in Ethiopia by the co-operative
|
|
* promotion agency, so it is always an Ethiopian company — "foreign" is not a
|
|
* combination that exists, and allowing it would resolve the company to a
|
|
* document set built around an investment licence it cannot hold.
|
|
*/
|
|
private assertNationalityAllowedForCooperative(
|
|
cooperative: boolean,
|
|
nationality: CompanyNationality | undefined,
|
|
): void {
|
|
if (!cooperative) return;
|
|
if (nationality === CompanyNationality.Foreign) {
|
|
throw new BadRequestException(
|
|
"A co-operative union or farm is registered in Ethiopia — it cannot onboard as a foreign company.",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The registration block as it must look when nobody has verified it.
|
|
*
|
|
* Used wherever a company stops being one eTrade cannot answer for: whatever
|
|
* sits in these columns was the customer's own statement, and the wizard
|
|
* treats a populated registration as a lookup that already passed
|
|
* (`hasRegistrationDetails`). Leaving it behind would hand the company an
|
|
* eTrade-verified record eTrade never supplied — and, once the flag is gone,
|
|
* a backoffice screen that says so.
|
|
*/
|
|
private static readonly CLEARED_REGISTRATION: Partial<Company> = {
|
|
licenceNumber: null,
|
|
statusDescription: null,
|
|
dateRegistered: null,
|
|
renewedFrom: null,
|
|
renewalDate: null,
|
|
renewedTo: null,
|
|
region: null,
|
|
zone: null,
|
|
woreda: null,
|
|
kebele: null,
|
|
houseNo: null,
|
|
etradePhone: null,
|
|
};
|
|
|
|
/**
|
|
* The company's own `attributes`, minus the manager captured alongside a
|
|
* typed registration. It never came from a licence, so it must not outlive
|
|
* the registration it belonged to.
|
|
*/
|
|
private withoutTypedEtradeManager(
|
|
attributes: Record<string, unknown> | null | undefined,
|
|
): Record<string, unknown> {
|
|
const next = { ...(attributes ?? {}) };
|
|
delete next.etradeManagerName;
|
|
delete next.etradeManagerPhone;
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* An investment licence belongs to a foreign company and to nothing else.
|
|
*
|
|
* It is the Ethiopian Investment Commission's licence, issued to a foreign
|
|
* investor — an Ethiopian company registers with the trade registry, which is
|
|
* exactly the eTrade record this flag says does not exist. A co-operative
|
|
* cannot hold one either: it is Ethiopian by construction, and the two flags
|
|
* resolve to different document sets, so a company carrying both would owe an
|
|
* incoherent list of papers.
|
|
*/
|
|
private assertInvestorLicenceAllowed(
|
|
investorLicence: boolean,
|
|
cooperative: boolean,
|
|
nationality: CompanyNationality | undefined,
|
|
): void {
|
|
if (!investorLicence) return;
|
|
if (cooperative) {
|
|
throw new BadRequestException(
|
|
"A co-operative union or farm is registered in Ethiopia — it cannot also onboard on a foreign investment licence.",
|
|
);
|
|
}
|
|
if (nationality !== CompanyNationality.Foreign) {
|
|
throw new BadRequestException(
|
|
"Only a foreign company can onboard on an investment licence.",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reconcile the company's operational profiles with the roles the user has
|
|
* selected: create the missing ones, drop the ones they deselected.
|
|
*
|
|
* Dropping matters because every role-driven onboarding requirement — the
|
|
* per-profile business license, the freight-forwarder PoA rule, the license
|
|
* cards in the wizard — is derived from these rows. A row left behind after
|
|
* the user went back and unticked a role keeps asking for that role's
|
|
* documents (EDRFREIGHT-416). Only still-pending profiles are removed: an
|
|
* approved one is live (it can carry bookings and contracts) and re-running
|
|
* role selection must never delete it.
|
|
*/
|
|
private async syncCompanyProfiles(
|
|
companyId: string,
|
|
companyType: CompanyType,
|
|
roles: ProfileType[],
|
|
): Promise<void> {
|
|
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
|
const chosen = roles.filter((t) => allowedTypes.includes(t));
|
|
const existing = await this.companyProfilesRepo.findByCompanyId(companyId);
|
|
|
|
for (const profile of existing) {
|
|
if (chosen.includes(profile.type)) continue;
|
|
if (profile.status !== ProfileStatus.Pending) continue;
|
|
// The license files uploaded against this profile go with it: they are
|
|
// only ever read per company_profile id, so a soft-deleted profile
|
|
// leaves nothing behind to prompt for. Re-picking the role creates a
|
|
// fresh profile the user uploads against again.
|
|
await this.companyProfilesRepo.softDelete(profile.id);
|
|
}
|
|
|
|
for (const type of chosen) {
|
|
if (existing.some((p) => p.type === type)) continue;
|
|
// No reference yet — minted on backoffice approval (setCompanyProfileStatus).
|
|
await this.companyProfilesRepo.create({
|
|
companyId,
|
|
type,
|
|
status: ProfileStatus.Pending,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A unique 10-char placeholder TIN for a draft company (the column is
|
|
* NOT NULL + unique). Overwritten with the real TIN on the company step.
|
|
*/
|
|
private async generateDraftTin(): Promise<string> {
|
|
for (let i = 0; i < 10; i++) {
|
|
const candidate =
|
|
"D" +
|
|
Math.floor(Math.random() * 1_000_000_000)
|
|
.toString()
|
|
.padStart(9, "0");
|
|
if (!(await this.companiesRepo.existsByTin(candidate))) return candidate;
|
|
}
|
|
// Extremely unlikely; fall back to a timestamp-derived value.
|
|
return ("D" + Date.now().toString()).slice(0, 10);
|
|
}
|
|
|
|
async findAllCompanies(): Promise<Company[]> {
|
|
return this.companiesRepo.findAll({ order: { name: "ASC" } });
|
|
}
|
|
|
|
async findCompanyById(id: string): Promise<Company> {
|
|
const company = await this.companiesRepo.findById(id);
|
|
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
|
company.companyProfiles =
|
|
await this.companyProfilesRepo.findByCompanyId(id);
|
|
// External profiles carry the onboarding flag the backoffice gates
|
|
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
|
|
company.profiles = await this.profilesRepo.findByCompanyId(id);
|
|
return company;
|
|
}
|
|
|
|
/**
|
|
* Validate an explicitly-chosen company profile for a booking: it must belong
|
|
* to the booking's company and be Active. Used for government bookings (staff
|
|
* pick the profile) and any staff booking that pins a profile directly.
|
|
*/
|
|
async getActiveCompanyProfileForBooking(
|
|
companyId: string,
|
|
profileId: string,
|
|
): Promise<CompanyProfile> {
|
|
const profile = await this.companyProfilesRepo.findById(profileId);
|
|
if (!profile || profile.companyId !== companyId) {
|
|
throw new BadRequestException(
|
|
"Selected company profile does not belong to the chosen company",
|
|
);
|
|
}
|
|
if (profile.status !== ProfileStatus.Active) {
|
|
throw new BadRequestException("Selected company profile is not active");
|
|
}
|
|
return profile;
|
|
}
|
|
|
|
async getCompanyInfoByUserId(
|
|
userId: string,
|
|
): Promise<{ profile: ExternalProfile; company: Company }> {
|
|
const profile = await this.profilesRepo.findByUserId(userId);
|
|
if (!profile)
|
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
|
|
|
const company = profile.company;
|
|
if (!company)
|
|
throw new NotFoundException(
|
|
`Company for profile ${profile.id} not found`,
|
|
);
|
|
|
|
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
|
|
company.id,
|
|
);
|
|
|
|
return { profile, company };
|
|
}
|
|
|
|
/**
|
|
* Dashboard KPIs for the portal home (MyPortalPage), aggregated from the
|
|
* current user's company bookings. All figures are scoped to that company.
|
|
*
|
|
* Note: delivered/spend/volume all derive from the bookings table — there is
|
|
* no separate data source for them. On-time delivery rate is replaced by
|
|
* completion rate (delivered ÷ committed): the schema has no ETA /
|
|
* promised-delivery date, so on-time cannot be computed.
|
|
*
|
|
* Period attribution uses booking.created_at: there is no delivery-date
|
|
* column, so "delivered YTD" counts bookings created this year that reached a
|
|
* delivered/completed status.
|
|
*/
|
|
async getDashboardSummary(
|
|
userId: string,
|
|
companyProfileId?: string,
|
|
): Promise<DashboardSummaryResponseDto> {
|
|
// A user without a company profile has no bookings — return an empty summary
|
|
// rather than 404, so the portal home still renders.
|
|
const profile = await this.profilesRepo.findByUserId(userId);
|
|
const companyId = profile?.company?.id ?? profile?.companyId ?? null;
|
|
if (!companyId) return this.emptyDashboardSummary();
|
|
|
|
// Company-wide by default (all services' data). An optional companyProfileId
|
|
// (from the per-page service filter) narrows to one operational profile —
|
|
// but only after we confirm it belongs to this user's company, since the
|
|
// dashboard scope has no company guard at the repository layer.
|
|
let scope: DashboardScope = { companyId };
|
|
if (companyProfileId) {
|
|
const owned = await this.companyProfilesRepo.findByCompanyId(companyId);
|
|
if (owned.some((p) => p.id === companyProfileId)) {
|
|
scope = { companyProfileId };
|
|
}
|
|
}
|
|
|
|
const now = new Date();
|
|
const yearStart = new Date(now.getFullYear(), 0, 1);
|
|
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
|
|
// Same point in the previous year, so YoY compares like-for-like windows.
|
|
const prevYearToDate = new Date(
|
|
prevYearStart.getTime() + (now.getTime() - yearStart.getTime()),
|
|
);
|
|
|
|
const [
|
|
deliveredThis,
|
|
committedThis,
|
|
spendThisByCcy,
|
|
spendPrevByCcy,
|
|
tonnageThis,
|
|
tonnagePrev,
|
|
monthlyRows,
|
|
] = await Promise.all([
|
|
this.dashboardRepo.countDelivered(scope, yearStart, now),
|
|
this.dashboardRepo.countCommitted(scope, yearStart, now),
|
|
this.dashboardRepo.sumPaidSpendByCurrency(scope, yearStart, now),
|
|
this.dashboardRepo.sumPaidSpendByCurrency(
|
|
scope,
|
|
prevYearStart,
|
|
prevYearToDate,
|
|
),
|
|
this.dashboardRepo.sumCommittedTonnage(scope, yearStart, now),
|
|
this.dashboardRepo.sumCommittedTonnage(
|
|
scope,
|
|
prevYearStart,
|
|
prevYearToDate,
|
|
),
|
|
this.dashboardRepo.monthlyCommittedTonnage(
|
|
scope,
|
|
this.monthsAgo(now, 5),
|
|
now,
|
|
),
|
|
]);
|
|
|
|
// Spend can span currencies; report the dominant one (prefer ETB on ties).
|
|
const spend = this.pickCurrencyTotal(spendThisByCcy);
|
|
const spendPrev =
|
|
spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
|
|
|
|
return {
|
|
deliveredCount: deliveredThis,
|
|
// Share of committed bookings that reached delivered/completed.
|
|
completionRate:
|
|
committedThis > 0
|
|
? Math.round((deliveredThis / committedThis) * 100)
|
|
: 0,
|
|
spendYtd: spend.total,
|
|
spendCurrency: spend.currency,
|
|
spendYtdChangePct: this.changePct(spend.total, spendPrev),
|
|
freightVolume: {
|
|
totalTonnes: Math.round(tonnageThis),
|
|
totalValue: spend.total,
|
|
currency: spend.currency,
|
|
ytdChangePct: this.changePct(tonnageThis, tonnagePrev),
|
|
monthly: this.buildMonthlySeries(now, monthlyRows),
|
|
},
|
|
};
|
|
}
|
|
|
|
private emptyDashboardSummary(): DashboardSummaryResponseDto {
|
|
const now = new Date();
|
|
return {
|
|
deliveredCount: 0,
|
|
completionRate: 0,
|
|
spendYtd: 0,
|
|
spendCurrency: "ETB",
|
|
spendYtdChangePct: 0,
|
|
freightVolume: {
|
|
totalTonnes: 0,
|
|
totalValue: 0,
|
|
currency: "ETB",
|
|
ytdChangePct: 0,
|
|
monthly: this.buildMonthlySeries(now, []),
|
|
},
|
|
};
|
|
}
|
|
|
|
/** First day of the month `n` months before `from`. */
|
|
private monthsAgo(from: Date, n: number): Date {
|
|
return new Date(from.getFullYear(), from.getMonth() - n, 1);
|
|
}
|
|
|
|
/** Pick the currency with the largest total, preferring ETB on ties / when empty. */
|
|
private pickCurrencyTotal(totals: { currency: string; total: number }[]): {
|
|
currency: string;
|
|
total: number;
|
|
} {
|
|
if (totals.length === 0) return { currency: "ETB", total: 0 };
|
|
return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
|
|
}
|
|
|
|
/** Percentage change vs a prior value, rounded; 0 when there is no prior base. */
|
|
private changePct(current: number, previous: number): number {
|
|
if (previous <= 0) return 0;
|
|
return Math.round(((current - previous) / previous) * 100);
|
|
}
|
|
|
|
/** Build a fixed 6-month tonnage series ending on `now`, zero-filling gaps. */
|
|
private buildMonthlySeries(
|
|
now: Date,
|
|
rows: { year: number; month: number; tonnes: number }[],
|
|
): { month: string; tonnes: number }[] {
|
|
const labels = [
|
|
"Jan",
|
|
"Feb",
|
|
"Mar",
|
|
"Apr",
|
|
"May",
|
|
"Jun",
|
|
"Jul",
|
|
"Aug",
|
|
"Sep",
|
|
"Oct",
|
|
"Nov",
|
|
"Dec",
|
|
];
|
|
const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes]));
|
|
const series: { month: string; tonnes: number }[] = [];
|
|
for (let i = 5; i >= 0; i--) {
|
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
|
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
|
|
series.push({
|
|
month: labels[d.getMonth()],
|
|
tonnes: Math.round(byKey.get(key) ?? 0),
|
|
});
|
|
}
|
|
return series;
|
|
}
|
|
|
|
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
|
const before = await this.findCompanyById(id);
|
|
const patch: UpdateCompanyDto & { approvedAt?: Date } = { ...dto };
|
|
// Staff can also promote Pending -> Active directly through this generic
|
|
// endpoint (not just via the first-profile-approval path), so stamp it here too.
|
|
if (
|
|
dto.status === CompanyStatus.Active &&
|
|
before.status !== CompanyStatus.Active
|
|
) {
|
|
patch.approvedAt = new Date();
|
|
}
|
|
const updated = await this.companiesRepo.update(id, patch);
|
|
if (!updated) throw new NotFoundException(`Company ${id} not found`);
|
|
|
|
// Suspending or blacklisting locks the customer out, so they must be told.
|
|
// This is the only path that writes those statuses.
|
|
this.companyNotifier.statusChanged(updated, before.status);
|
|
return updated;
|
|
}
|
|
|
|
/** Keep only the keys that were actually provided (drop `undefined`). */
|
|
private pickDefined(dto: Record<string, any>): Record<string, any> {
|
|
const out: Record<string, any> = {};
|
|
for (const [k, v] of Object.entries(dto)) {
|
|
if (v !== undefined) out[k] = v;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* `UpdateProfileDto` keys this company's completed verifications own — the
|
|
* ones `mapProfileDtoToCompanyUpdates` overwrites with the verified value
|
|
* whatever a request submits for them.
|
|
*
|
|
* A key only lands here once there is a verified value to hold it to: Fayda's
|
|
* email and phone claims are optional, and a verification that returned
|
|
* neither owns nothing to overwrite with.
|
|
*
|
|
* The map is the enforcement; this is the list used to keep those keys out of
|
|
* a change request in the first place. If the two ever drift the map still
|
|
* wins — the cost is a staged field that approving turns out not to move.
|
|
*/
|
|
private faydaOwnedKeys(company: Company): string[] {
|
|
const attrs = company.attributes ?? {};
|
|
const held = (key: string) => {
|
|
const v = attrs[key];
|
|
return v !== null && v !== undefined && v !== "";
|
|
};
|
|
|
|
const keys: string[] = [];
|
|
for (const subject of IDENTITY_SUBJECTS) {
|
|
if (!attrs[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
|
keys.push(...IDENTITY_OWNED_FIELDS[subject].filter(held));
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
/**
|
|
* Translate an UpdateProfileDto (or a staged change-request snapshot) into a
|
|
* `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/
|
|
* PoA live there). Pure — the caller runs the async TIN-uniqueness check.
|
|
*/
|
|
private mapProfileDtoToCompanyUpdates(
|
|
company: Company,
|
|
dto: Partial<UpdateProfileDto> & {
|
|
faydaIdentity?: VerifiedIdentityAttributes;
|
|
etradeManager?: { name: string; phone: string };
|
|
},
|
|
): Record<string, any> {
|
|
const companyUpdates: Record<string, any> = {};
|
|
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
|
|
|
|
if (dto.nationality !== undefined)
|
|
companyUpdates.nationality = dto.nationality;
|
|
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
|
|
if (dto.companyLocation !== undefined)
|
|
companyUpdates.country = dto.companyLocation;
|
|
if (dto.companyAddress !== undefined)
|
|
companyUpdates.address = dto.companyAddress;
|
|
if (dto.tin !== undefined && dto.tin !== company.tin)
|
|
companyUpdates.tin = dto.tin;
|
|
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
|
|
|
if (dto.contactPersonName !== undefined)
|
|
attrUpdates.contactPersonName = dto.contactPersonName;
|
|
if (dto.contactPersonPosition !== undefined)
|
|
attrUpdates.contactPersonPosition = dto.contactPersonPosition;
|
|
if (dto.contactPersonEmail !== undefined)
|
|
attrUpdates.contactPersonEmail = dto.contactPersonEmail;
|
|
if (dto.contactPersonPhone !== undefined)
|
|
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
|
|
if (dto.contactVerifiedPhone !== undefined)
|
|
attrUpdates.contactVerifiedPhone = normalizeE164(
|
|
dto.contactVerifiedPhone,
|
|
);
|
|
if (dto.ownerName !== undefined) attrUpdates.ownerName = dto.ownerName;
|
|
if (dto.ownerEmail !== undefined) attrUpdates.ownerEmail = dto.ownerEmail;
|
|
if (dto.ownerPhone !== undefined)
|
|
attrUpdates.ownerPhone = normalizeE164(dto.ownerPhone);
|
|
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
|
|
if (dto.poaPhone !== undefined)
|
|
attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
|
|
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
|
|
if (dto.poaLocation !== undefined)
|
|
attrUpdates.poaLocation = dto.poaLocation;
|
|
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
|
|
|
|
if (dto.licenceNumber !== undefined)
|
|
companyUpdates.licenceNumber = dto.licenceNumber;
|
|
if (dto.statusDescription !== undefined)
|
|
companyUpdates.statusDescription = dto.statusDescription;
|
|
if (dto.dateRegistered !== undefined)
|
|
companyUpdates.dateRegistered = dto.dateRegistered;
|
|
if (dto.renewedFrom !== undefined)
|
|
companyUpdates.renewedFrom = dto.renewedFrom;
|
|
if (dto.renewalDate !== undefined)
|
|
companyUpdates.renewalDate = dto.renewalDate;
|
|
if (dto.renewedTo !== undefined) companyUpdates.renewedTo = dto.renewedTo;
|
|
if (dto.region !== undefined) companyUpdates.region = dto.region;
|
|
if (dto.zone !== undefined) companyUpdates.zone = dto.zone;
|
|
if (dto.woreda !== undefined) companyUpdates.woreda = dto.woreda;
|
|
if (dto.kebele !== undefined) companyUpdates.kebele = dto.kebele;
|
|
if (dto.houseNo !== undefined) companyUpdates.houseNo = dto.houseNo;
|
|
if (dto.etradePhone !== undefined)
|
|
companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
|
|
|
|
// Plain typed fields — never Fayda-verified, so no lock ever applies. For a
|
|
// foreign company a passport number proves the person just as a Fayda
|
|
// verification does, so it is collected for whichever of the two carries
|
|
// the company's identity.
|
|
if (dto.ownerPassportNumber !== undefined)
|
|
attrUpdates.ownerPassportNumber = dto.ownerPassportNumber;
|
|
if (dto.poaPassportNumber !== undefined)
|
|
attrUpdates.poaPassportNumber = dto.poaPassportNumber;
|
|
|
|
// eTrade's own manager, captured at lookup by `applyEtradeSourcedFields`.
|
|
// Never off the wire — the global pipe runs `forbidNonWhitelisted`, so this
|
|
// reaches us only from that method, the same guarantee `faydaIdentity` has.
|
|
// Stored apart from `ownerName`/`ownerPhone` so the two can be COMPARED:
|
|
// the company asserts an owner, eTrade states a manager, and the backoffice
|
|
// check is whether they are the same person (`ownerMatchesEtrade`).
|
|
if (dto.etradeManager) {
|
|
if (dto.etradeManager.name)
|
|
attrUpdates[ETRADE_MANAGER_NAME_KEY] = dto.etradeManager.name;
|
|
if (dto.etradeManager.phone)
|
|
attrUpdates[ETRADE_MANAGER_PHONE_KEY] = normalizeE164(
|
|
dto.etradeManager.phone,
|
|
);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// The company's own contact columns follow the owner, verified or not.
|
|
//
|
|
// This used to be gated on `ownerFaydaSub`, which meant `companies.email`
|
|
// was only ever written for a Fayda-verified owner — so every foreign
|
|
// company (passport instead of Fayda) had none, and the notification
|
|
// resolver papered over it by falling through to the general manager's
|
|
// address. The GM is gone and the owner's email is now required outright,
|
|
// so this is simply where it lands.
|
|
if (attrUpdates.ownerEmail) companyUpdates.email = attrUpdates.ownerEmail;
|
|
if (attrUpdates.ownerPhone)
|
|
companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone));
|
|
|
|
// Renaming a Fayda-verified person by hand would launder the guarantee
|
|
// away, so the verification keeps these fields: a submission that disagrees
|
|
// is overwritten with the verified value rather than rejected — the same
|
|
// doctrine `applyEtradeSourcedFields` uses for eTrade's fields, and for the
|
|
// same reason. The customer never types these (the portal derives them, and
|
|
// a stale form or a re-render can echo back something else entirely), so a
|
|
// 400 punishes a save they never made while an overwrite lands the truth.
|
|
for (const subject of IDENTITY_SUBJECTS) {
|
|
if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
|
for (const field of IDENTITY_OWNED_FIELDS[subject]) {
|
|
if ((dto as Record<string, unknown>)[field] === undefined) continue;
|
|
// The verification itself is what writes them; it must not be undone by
|
|
// the value this same call just copied into the patch.
|
|
if (dto.faydaIdentity && field in dto.faydaIdentity) continue;
|
|
const stored = company.attributes?.[field];
|
|
// A verification that supplied nothing for this field left no guarantee
|
|
// to protect, so it stays typeable. This is what makes the required
|
|
// owner email reachable: Fayda's email claim is optional, so a verified
|
|
// owner routinely has none stored — locking against that absence would
|
|
// make `REQUIRED_COMPANY_INFO` demand a field nobody could ever fill.
|
|
if (stored === null || stored === undefined || stored === "") continue;
|
|
attrUpdates[field] = stored;
|
|
}
|
|
}
|
|
|
|
companyUpdates.attributes = attrUpdates;
|
|
return companyUpdates;
|
|
}
|
|
|
|
/**
|
|
* Append a version-history entry for an onboarding-phase edit (the company
|
|
* is not yet Active, so the change went straight to the live row with no
|
|
* approval gate to carry a record of it). Best-effort: a no-op patch or a
|
|
* failure to write history must never break the edit that triggered it.
|
|
*/
|
|
private async recordCompanyRevision(
|
|
before: Company,
|
|
patch: Record<string, any>,
|
|
actorId?: string | null,
|
|
extraChanges: CompanyRevisionChange[] = [],
|
|
): Promise<void> {
|
|
try {
|
|
const changes = [...diffCompanyUpdate(before, patch), ...extraChanges];
|
|
if (changes.length === 0) return;
|
|
await this.revisionRepo.create({
|
|
companyId: before.id,
|
|
actorId: actorId ?? null,
|
|
summary: summarizeCompanyChanges(changes),
|
|
changes,
|
|
});
|
|
} catch (err) {
|
|
this.logger.error(
|
|
`Failed to record company revision for ${before.id}: ${String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Reject a TIN already registered to a *different* company. */
|
|
private async assertTinAvailable(
|
|
company: Company,
|
|
tin: string | undefined,
|
|
): Promise<void> {
|
|
if (tin === undefined || tin === company.tin) return;
|
|
const owner = await this.companiesRepo.findByTin(tin);
|
|
if (owner && owner.id !== company.id) {
|
|
throw new ConflictException(
|
|
`This TIN (${tin}) is already registered to another company. Please check the number and try again.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** The company's open (pending or last-rejected) profile change request. */
|
|
async getOpenChangeRequestForCompany(
|
|
companyId: string,
|
|
): Promise<CompanyChangeRequest | null> {
|
|
return this.changeRequestRepo.findLatestOpenByCompanyId(companyId);
|
|
}
|
|
|
|
/**
|
|
* Update the current user's profile.
|
|
*
|
|
* - 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` → 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,
|
|
dto: UpdateProfileDto,
|
|
): Promise<ProfileResponseDto> {
|
|
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
|
|
|
await this.applyEtradeSourcedFields(company, dto);
|
|
|
|
// 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, attributes);
|
|
}
|
|
|
|
if (company.status !== CompanyStatus.Active) {
|
|
await this.assertTinAvailable(company, dto.tin);
|
|
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, dto);
|
|
const updated = await this.companiesRepo.update(
|
|
company.id,
|
|
companyUpdates,
|
|
);
|
|
if (!updated)
|
|
throw new NotFoundException(`Company ${company.id} not found`);
|
|
await this.recordCompanyRevision(company, companyUpdates, userId);
|
|
return new ProfileResponseDto(profile, updated);
|
|
}
|
|
|
|
// 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);
|
|
// Drop what the verifications own before anything is staged. Approving one
|
|
// of these could not change the live row — mapProfileDtoToCompanyUpdates
|
|
// writes the verified value back over it — so showing it to a reviewer
|
|
// asks them to rule on a change that does not exist.
|
|
for (const key of this.faydaOwnedKeys(company)) delete fields[key];
|
|
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,
|
|
);
|
|
const now = new Date();
|
|
let request: CompanyChangeRequest;
|
|
if (existing) {
|
|
request =
|
|
(await this.changeRequestRepo.update(existing.id, {
|
|
// Note is left untouched: if this request was ChangesRequested, the
|
|
// reviewer's ask stays visible on the resubmitted (Pending) row —
|
|
// clearing it here would hide what was asked for right when the
|
|
// reviewer comes back to check whether it was actually addressed.
|
|
snapshot: { ...(existing.snapshot ?? {}), ...staged },
|
|
submittedBy: userId,
|
|
submittedAt: now,
|
|
status: ChangeRequestStatus.Pending,
|
|
})) ?? existing;
|
|
this.companyNotifier.changeRequestSubmitted(company, request.id, false);
|
|
} else {
|
|
// Rejecting a request leaves it Rejected rather than reopening it, so a
|
|
// customer amending after a rejection lands here with a fresh Pending row.
|
|
// That is the resubmission case the reviewer needs flagged.
|
|
const history = await this.changeRequestRepo.findByCompanyId(company.id);
|
|
const resubmitted = history.some(
|
|
(r) => r.status === ChangeRequestStatus.Rejected,
|
|
);
|
|
request = await this.changeRequestRepo.create({
|
|
companyId: company.id,
|
|
snapshot: staged,
|
|
status: ChangeRequestStatus.Pending,
|
|
submittedBy: userId,
|
|
submittedAt: now,
|
|
});
|
|
this.companyNotifier.changeRequestSubmitted(
|
|
company,
|
|
request.id,
|
|
resubmitted,
|
|
);
|
|
}
|
|
|
|
// 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). Actor
|
|
* ids are resolved to display names here — the history screen has to say who
|
|
* asked for a change and who sent it back, not print two uuids.
|
|
*/
|
|
async listChangeRequests(companyId: string): Promise<CompanyChangeRequest[]> {
|
|
await this.findCompanyById(companyId);
|
|
const requests = await this.changeRequestRepo.findByCompanyId(companyId);
|
|
const names = await this.resolveActorNames(
|
|
requests.flatMap((r) => [r.submittedBy, r.reviewedBy]),
|
|
);
|
|
for (const request of requests) {
|
|
request.submittedByName = request.submittedBy
|
|
? (names.get(request.submittedBy) ?? null)
|
|
: null;
|
|
request.reviewedByName = request.reviewedBy
|
|
? (names.get(request.reviewedBy) ?? null)
|
|
: null;
|
|
}
|
|
return requests;
|
|
}
|
|
|
|
/** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */
|
|
async listCompanyRevisions(companyId: string): Promise<CompanyRevision[]> {
|
|
await this.findCompanyById(companyId);
|
|
const revisions = await this.revisionRepo.findByCompanyId(companyId);
|
|
const names = await this.resolveActorNames(revisions.map((r) => r.actorId));
|
|
for (const revision of revisions) {
|
|
revision.actorName = revision.actorId
|
|
? (names.get(revision.actorId) ?? null)
|
|
: null;
|
|
}
|
|
return revisions;
|
|
}
|
|
|
|
/**
|
|
* Display names for actor ids, one query for the whole list. A lookup failure
|
|
* degrades the history to ids rather than failing the request — the entry is
|
|
* still worth showing without the name.
|
|
*/
|
|
private async resolveActorNames(
|
|
actorIds: (string | null | undefined)[],
|
|
): Promise<Map<string, string>> {
|
|
try {
|
|
return await resolveIamUserNames(this.dataSource, actorIds);
|
|
} catch (err) {
|
|
this.logger.warn(`Could not resolve actor names: ${String(err)}`);
|
|
return new Map();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pair adjacent remove-then-add intents into one before/after revision
|
|
* change — that's exactly how a "replace" is staged (see
|
|
* `replaceProfileLicenseFile`: `[{op:'remove',...}, {op:'add',...}]`
|
|
* pushed together, and later merges only ever append after that pair, so
|
|
* adjacency is preserved). A remove or add with no adjacent partner (a pure
|
|
* add, or a pure removal) stands alone.
|
|
*/
|
|
private pairReplaceIntents<
|
|
T extends { op: "add" | "remove"; fileId: string; fileName?: string },
|
|
>(intents: T[], labelFor: (intent: T) => string): CompanyRevisionChange[] {
|
|
const changes: CompanyRevisionChange[] = [];
|
|
let i = 0;
|
|
while (i < intents.length) {
|
|
const current = intents[i];
|
|
const next = intents[i + 1];
|
|
if (current.op === "remove" && next?.op === "add") {
|
|
changes.push({
|
|
field: `document:${current.fileId}`,
|
|
label: labelFor(next),
|
|
from: current.fileName ?? null,
|
|
to: next.fileName ?? null,
|
|
fromFileId: current.fileId,
|
|
toFileId: next.fileId,
|
|
});
|
|
i += 2;
|
|
continue;
|
|
}
|
|
changes.push({
|
|
field: `document:${current.fileId}`,
|
|
label: labelFor(current),
|
|
from: current.op === "remove" ? (current.fileName ?? null) : null,
|
|
to: current.op === "add" ? (current.fileName ?? null) : null,
|
|
fromFileId: current.op === "remove" ? current.fileId : null,
|
|
toFileId: current.op === "add" ? current.fileId : null,
|
|
});
|
|
i += 1;
|
|
}
|
|
return changes;
|
|
}
|
|
|
|
/**
|
|
* Approve a pending change request: apply its snapshot to the live Company and
|
|
* mark the request approved. Any staged documents are already attached to the
|
|
* company, so nothing else needs promoting.
|
|
*/
|
|
async approveChangeRequest(
|
|
id: string,
|
|
reviewerId?: string,
|
|
): Promise<CompanyChangeRequest> {
|
|
const request = await this.changeRequestRepo.findById(id);
|
|
if (!request) throw new NotFoundException(`Change request ${id} not found`);
|
|
if (request.status !== ChangeRequestStatus.Pending) {
|
|
throw new BadRequestException(
|
|
`Change request ${id} is already ${request.status}`,
|
|
);
|
|
}
|
|
|
|
const company = await this.companiesRepo.findById(request.companyId);
|
|
if (!company)
|
|
throw new NotFoundException(`Company ${request.companyId} not found`);
|
|
|
|
const snapshot = (request.snapshot ?? {}) as Partial<UpdateProfileDto>;
|
|
await this.assertTinAvailable(company, snapshot.tin);
|
|
const companyUpdates = this.mapProfileDtoToCompanyUpdates(
|
|
company,
|
|
snapshot,
|
|
);
|
|
await this.companiesRepo.update(company.id, companyUpdates);
|
|
await this.applyLicenseChanges(request);
|
|
await this.applyDocumentChanges(request);
|
|
|
|
// This is the ONLY place post-approval FIELD/license/PoA-document changes
|
|
// land on the live row — without this call, everything the #419
|
|
// change-request flow does to those is invisible in Version History.
|
|
// `documentFileIds` (the general bulk company-documents upload) is
|
|
// deliberately NOT re-recorded here — those documents go live immediately
|
|
// at upload time and are already recorded there (see
|
|
// `uploadCompanyDocuments`); redoing it here would double the entry.
|
|
const documentChanges: CompanyRevisionChange[] = [
|
|
...this.pairReplaceIntents(
|
|
request.documents?.licenseChanges ?? [],
|
|
() => "Business license",
|
|
),
|
|
...this.pairReplaceIntents(
|
|
request.documents?.documentChanges ?? [],
|
|
(intent) => intent.code,
|
|
),
|
|
];
|
|
await this.recordCompanyRevision(
|
|
company,
|
|
companyUpdates,
|
|
reviewerId,
|
|
documentChanges,
|
|
);
|
|
|
|
return (
|
|
(await this.changeRequestRepo.update(id, {
|
|
status: ChangeRequestStatus.Approved,
|
|
reviewedBy: reviewerId ?? null,
|
|
reviewedAt: new Date(),
|
|
note: null,
|
|
})) ?? request
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A fresh upload under a single-file document slot (`isMultiple: false`)
|
|
* replaces whatever was there, not adds to it — soft-delete the prior live
|
|
* file(s) for that code, and describe each replacement (plus each genuinely
|
|
* new upload) as a revision change carrying both file ids, so the reviewer
|
|
* can open the previous and current file. Multi-file slots are left alone
|
|
* (genuinely additive, no single "the" document to diff against). Unrecognised
|
|
* codes (no matching field in the nationality's document setting) are also
|
|
* left alone — safer to under-clean than to guess wrong. Independent of the
|
|
* change-request review outcome: nothing else in this flow ever retires a
|
|
* superseded document, on approve OR reject — these documents go live the
|
|
* moment they're uploaded.
|
|
*/
|
|
private async replaceSingleFileCompanyDocuments(
|
|
company: Company,
|
|
before: FileRecord[],
|
|
uploaded: FileRecord[],
|
|
): Promise<CompanyRevisionChange[]> {
|
|
const setting = await this.fileUploadSettingsService
|
|
.getByCode(this.documentSettingCodeFor(company))
|
|
.catch(() => null);
|
|
const fields = setting?.fields ?? [];
|
|
const singleFileCodes = new Set(
|
|
fields.filter((f) => !f.isMultiple).map((f) => f.fileKey),
|
|
);
|
|
const labelByCode = new Map(fields.map((f) => [f.fileKey, f.fileLabel]));
|
|
const uploadedIds = new Set(uploaded.map((f) => f.id));
|
|
|
|
const changes: CompanyRevisionChange[] = [];
|
|
const toRemove: FileRecord[] = [];
|
|
for (const file of uploaded) {
|
|
if (!singleFileCodes.has(file.code)) continue;
|
|
const prior = before.find(
|
|
(f) => f.code === file.code && !uploadedIds.has(f.id),
|
|
);
|
|
changes.push({
|
|
field: `document:${file.code}`,
|
|
label: labelByCode.get(file.code) ?? file.code,
|
|
from: prior?.name ?? null,
|
|
to: file.name,
|
|
fromFileId: prior?.id ?? null,
|
|
toFileId: file.id,
|
|
});
|
|
if (prior) toRemove.push(prior);
|
|
}
|
|
await Promise.all(toRemove.map((f) => this.filesService.remove(f.id)));
|
|
return changes;
|
|
}
|
|
|
|
/**
|
|
* Upload company documents. For an approved company this also opens/updates a
|
|
* pending change request (recording the uploaded file ids) so the upload is
|
|
* reviewed and the customer is locked until it clears — consistent with the
|
|
* field-edit review. During onboarding (company not yet active) it's a plain
|
|
* upload with no review. Either way the documents go live immediately, so
|
|
* the revision history is recorded right away too, not gated on a decision.
|
|
*/
|
|
async uploadCompanyDocuments(
|
|
companyId: string,
|
|
files: Express.Multer.File[],
|
|
submittedBy?: string,
|
|
): Promise<FileRecord[]> {
|
|
const company = await this.findCompanyById(companyId);
|
|
const before = await this.filesService.findByResource(
|
|
companyId,
|
|
"companies",
|
|
);
|
|
const uploaded = await this.filesService.uploadMany(
|
|
companyId,
|
|
"companies",
|
|
files,
|
|
);
|
|
const documentChanges = await this.replaceSingleFileCompanyDocuments(
|
|
company,
|
|
before,
|
|
uploaded,
|
|
);
|
|
await this.resolveDocumentChangeRequests(
|
|
companyId,
|
|
"companies",
|
|
uploaded.map((f) => f.code),
|
|
uploaded.map((f) => f.id),
|
|
);
|
|
if (company.status === CompanyStatus.Active) {
|
|
await this.stageDocumentChange(
|
|
company.id,
|
|
uploaded.map((f) => f.id),
|
|
submittedBy,
|
|
);
|
|
}
|
|
if (documentChanges.length > 0) {
|
|
await this.recordCompanyRevision(
|
|
company,
|
|
{},
|
|
submittedBy,
|
|
documentChanges,
|
|
);
|
|
}
|
|
return uploaded;
|
|
}
|
|
|
|
/**
|
|
* Clear the `change_requested` flag from the documents a fresh upload replaces.
|
|
*
|
|
* Uploading does not overwrite the old row — it adds a new one under the same
|
|
* `code` — so the flagged original would otherwise linger and keep the approval
|
|
* gate closed even after the customer did exactly what was asked. Only rows of
|
|
* the same code are touched, and never the newly uploaded ones.
|
|
*/
|
|
private async resolveDocumentChangeRequests(
|
|
resourceId: string,
|
|
resource: string,
|
|
codes: string[],
|
|
uploadedIds: string[],
|
|
): Promise<void> {
|
|
if (codes.length === 0) return;
|
|
const replaced = new Set(codes);
|
|
const fresh = new Set(uploadedIds);
|
|
const open = await this.filesService.findWithOpenChangeRequest(
|
|
[resourceId],
|
|
resource,
|
|
);
|
|
await Promise.all(
|
|
open
|
|
.filter((f) => replaced.has(f.code) && !fresh.has(f.id))
|
|
.map((f) => this.filesService.clearReview(f.id)),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Backoffice: ask the customer to correct one specific document, instead of
|
|
* rejecting their whole role over it. Mirrors the contract change-request
|
|
* flow — a note the customer sees verbatim, plus a block on approval until
|
|
* they re-upload.
|
|
*/
|
|
async requestDocumentChange(
|
|
fileId: string,
|
|
note: string,
|
|
reviewerId?: string,
|
|
): Promise<FileRecord> {
|
|
const file = await this.filesService.findById(fileId);
|
|
const companyId = await this.resolveDocumentCompanyId(file);
|
|
const company = await this.findCompanyById(companyId);
|
|
|
|
// Flag the document while holding a write lock on its company row. The
|
|
// approval gate takes the same lock before it reads the flags, so the two
|
|
// serialize: a change request can never land in the window between the gate
|
|
// checking "any open corrections?" and writing the profile Active.
|
|
const updated = await this.dataSource.transaction(async (manager) => {
|
|
await manager.findOne(Company, {
|
|
where: { id: companyId },
|
|
lock: { mode: "pessimistic_write" },
|
|
});
|
|
return this.filesService.setReviewStatus(
|
|
file.id,
|
|
"change_requested",
|
|
note,
|
|
reviewerId,
|
|
);
|
|
});
|
|
this.companyNotifier.documentChangeRequested(
|
|
company,
|
|
file.name,
|
|
note,
|
|
file.id,
|
|
);
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Which company a stored document belongs to. Company documents are keyed by
|
|
* the company id directly; profile licences and POA letters hang off a company
|
|
* profile, so those resolve through it.
|
|
*/
|
|
private async resolveDocumentCompanyId(file: FileRecord): Promise<string> {
|
|
if (file.resource === "companies") return file.resourceId;
|
|
if (file.resource === "company_profiles") {
|
|
const profile = await this.companyProfilesRepo.findById(file.resourceId);
|
|
if (!profile) {
|
|
throw new NotFoundException(
|
|
`Company profile ${file.resourceId} not found`,
|
|
);
|
|
}
|
|
return profile.companyId;
|
|
}
|
|
throw new BadRequestException(
|
|
`Documents on "${file.resource}" do not support change requests`,
|
|
);
|
|
}
|
|
|
|
/** Open or append a pending change request recording staged document uploads. */
|
|
private async stageDocumentChange(
|
|
companyId: string,
|
|
fileIds: string[],
|
|
submittedBy?: string,
|
|
): Promise<void> {
|
|
if (fileIds.length === 0) return;
|
|
const now = new Date();
|
|
const existing =
|
|
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
|
const company = await this.companiesRepo.findById(companyId);
|
|
if (existing) {
|
|
const prev = existing.documents?.documentFileIds ?? [];
|
|
await this.changeRequestRepo.update(existing.id, {
|
|
// Spread the existing documents blob: a bare object would drop any
|
|
// licenseChanges/documentChanges already staged on this request.
|
|
documents: {
|
|
...existing.documents,
|
|
documentFileIds: [...prev, ...fileIds],
|
|
},
|
|
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
|
submittedAt: now,
|
|
// Note left untouched — see the comment in updateProfile's merge branch.
|
|
status: ChangeRequestStatus.Pending,
|
|
});
|
|
if (company) {
|
|
this.companyNotifier.changeRequestSubmitted(
|
|
company,
|
|
existing.id,
|
|
false,
|
|
);
|
|
}
|
|
} else {
|
|
const history = await this.changeRequestRepo.findByCompanyId(companyId);
|
|
const resubmitted = history.some(
|
|
(r) => r.status === ChangeRequestStatus.Rejected,
|
|
);
|
|
const created = await this.changeRequestRepo.create({
|
|
companyId,
|
|
snapshot: {},
|
|
documents: { documentFileIds: fileIds },
|
|
status: ChangeRequestStatus.Pending,
|
|
submittedBy: submittedBy ?? null,
|
|
submittedAt: now,
|
|
});
|
|
if (company) {
|
|
this.companyNotifier.changeRequestSubmitted(
|
|
company,
|
|
created.id,
|
|
resubmitted,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Reject a pending change request with a note (customer amends & resubmits). */
|
|
async rejectChangeRequest(
|
|
id: string,
|
|
note: string,
|
|
reviewerId?: string,
|
|
): Promise<CompanyChangeRequest> {
|
|
const request = await this.changeRequestRepo.findById(id);
|
|
if (!request) throw new NotFoundException(`Change request ${id} not found`);
|
|
if (request.status !== ChangeRequestStatus.Pending) {
|
|
throw new BadRequestException(
|
|
`Change request ${id} is already ${request.status}`,
|
|
);
|
|
}
|
|
await this.discardLicenseChanges(request);
|
|
await this.discardDocumentChanges(request);
|
|
await this.notifyChangeRequestReturned(request, "rejected", note, reviewerId);
|
|
return (
|
|
(await this.changeRequestRepo.update(id, {
|
|
status: ChangeRequestStatus.Rejected,
|
|
// Staged license/document uploads were just discarded; drop their intents
|
|
// so an amended resubmit never re-references deleted files.
|
|
documents: {
|
|
...request.documents,
|
|
licenseChanges: [],
|
|
documentChanges: [],
|
|
},
|
|
note,
|
|
reviewedBy: reviewerId ?? null,
|
|
reviewedAt: new Date(),
|
|
})) ?? request
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Ask for specific fixes without rejecting outright: unlike
|
|
* {@link rejectChangeRequest}, staged license/document intents are kept (the
|
|
* row stays open), so the customer's next edit is appended to this SAME
|
|
* request — via the merge branches in `updateProfile`/`stageDocumentChange`/
|
|
* `stageLicenseChange`/`stageDocumentIntent`/`stageIdentityChange` — instead
|
|
* of starting a fresh cycle.
|
|
*/
|
|
async requestChangeRequestChanges(
|
|
id: string,
|
|
note: string,
|
|
reviewerId?: string,
|
|
): Promise<CompanyChangeRequest> {
|
|
const request = await this.changeRequestRepo.findById(id);
|
|
if (!request) throw new NotFoundException(`Change request ${id} not found`);
|
|
if (request.status !== ChangeRequestStatus.Pending) {
|
|
throw new BadRequestException(
|
|
`Change request ${id} is already ${request.status}`,
|
|
);
|
|
}
|
|
await this.notifyChangeRequestReturned(
|
|
request,
|
|
"changes_requested",
|
|
note,
|
|
reviewerId,
|
|
);
|
|
return (
|
|
(await this.changeRequestRepo.update(id, {
|
|
status: ChangeRequestStatus.ChangesRequested,
|
|
note,
|
|
reviewedBy: reviewerId ?? null,
|
|
reviewedAt: new Date(),
|
|
})) ?? request
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Tell the customer desk a change request came back unapproved. Best-effort:
|
|
* a missing company or an unresolvable reviewer name must not fail the
|
|
* reviewer's decision, which is already the point of the try/catch.
|
|
*/
|
|
private async notifyChangeRequestReturned(
|
|
request: CompanyChangeRequest,
|
|
outcome: "rejected" | "changes_requested",
|
|
note: string,
|
|
reviewerId?: string,
|
|
): Promise<void> {
|
|
try {
|
|
const company = await this.companiesRepo.findById(request.companyId);
|
|
if (!company) return;
|
|
const names = await this.resolveActorNames([reviewerId]);
|
|
this.companyNotifier.changeRequestReturned(
|
|
company,
|
|
request.id,
|
|
outcome,
|
|
note,
|
|
reviewerId ? (names.get(reviewerId) ?? null) : null,
|
|
);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Could not notify the customer desk about ${request.id}: ${String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async deleteCompany(id: string): Promise<void> {
|
|
await this.findCompanyById(id);
|
|
await this.companiesRepo.softDelete(id);
|
|
}
|
|
|
|
async createProfile(dto: CreateExternalProfileDto): Promise<ExternalProfile> {
|
|
await this.findCompanyById(dto.companyId);
|
|
|
|
const existing = await this.profilesRepo.findByUserId(dto.userId);
|
|
if (existing) {
|
|
throw new ConflictException(
|
|
`Profile for user ${dto.userId} already exists`,
|
|
);
|
|
}
|
|
|
|
return this.profilesRepo.create(dto);
|
|
}
|
|
|
|
async findProfileByUserId(userId: string): Promise<ExternalProfile> {
|
|
const profile = await this.profilesRepo.findByUserId(userId);
|
|
if (!profile)
|
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
|
return profile;
|
|
}
|
|
|
|
async findProfilesByCompany(companyId: string): Promise<ExternalProfile[]> {
|
|
return this.profilesRepo.findByCompanyId(companyId);
|
|
}
|
|
|
|
private getProfileTypeForCompanyType(companyType: string): ProfileType[] {
|
|
switch (companyType) {
|
|
case "customer":
|
|
// A customer can operate as an importer and/or exporter, and may also
|
|
// add a freight-forwarder service profile under the same company.
|
|
return [
|
|
ProfileType.importer,
|
|
ProfileType.exporter,
|
|
ProfileType.freightForwarder,
|
|
];
|
|
case "freight_forwarder":
|
|
return [ProfileType.freightForwarder];
|
|
case "dj_freight_forwarder":
|
|
return [ProfileType.djFreightForwarder];
|
|
case "transporter":
|
|
return [ProfileType.transporter];
|
|
default:
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async setCompanyProfileStatus(
|
|
profileId: string,
|
|
status: ProfileStatus,
|
|
note?: string,
|
|
reviewerId?: string,
|
|
): Promise<CompanyProfile> {
|
|
const existing = await this.companyProfilesRepo.findById(profileId);
|
|
if (!existing)
|
|
throw new NotFoundException(`Company profile ${profileId} not found`);
|
|
|
|
// Suspension and reactivation must carry a staff explanation — the customer
|
|
// sees it, so "why" can never be left blank. Reactivation is the
|
|
// active-write that leaves Suspended; a first approval stays note-free.
|
|
const reactivating =
|
|
status === ProfileStatus.Active &&
|
|
existing.status === ProfileStatus.Suspended;
|
|
if ((status === ProfileStatus.Suspended || reactivating) && !note?.trim()) {
|
|
throw new BadRequestException(
|
|
status === ProfileStatus.Suspended
|
|
? "A message explaining the suspension is required — the customer will see it."
|
|
: "A message explaining the reactivation is required — the customer will see it.",
|
|
);
|
|
}
|
|
|
|
// A self-registered company is only reviewable once its owner submits the
|
|
// onboarding wizard (markOnboardingComplete) — until then its profiles are
|
|
// half-filled drafts and approving one would mint a reference against an
|
|
// application that doesn't exist yet. Staff-created companies have no
|
|
// external profiles and are exempt.
|
|
//
|
|
// Only the review decision itself is gated (a profile still awaiting one:
|
|
// Pending, or Rejected and awaiting re-approval). Profiles already in
|
|
// service stay managable so staff can suspend/blacklist them — including to
|
|
// undo an approval granted before this guard existed.
|
|
const awaitingReview =
|
|
existing.status === ProfileStatus.Pending ||
|
|
existing.status === ProfileStatus.Rejected;
|
|
if (awaitingReview) {
|
|
const owners = await this.profilesRepo.findByCompanyId(
|
|
existing.companyId,
|
|
);
|
|
if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) {
|
|
throw new BadRequestException(
|
|
"This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.",
|
|
);
|
|
}
|
|
}
|
|
|
|
// Anything other than approval has no document gate and no concurrency
|
|
// hazard — no row lock, just the write.
|
|
if (status !== ProfileStatus.Active) {
|
|
return this.dataSource.transaction((manager) =>
|
|
this.applyProfileStatus(manager, existing, status, note, reviewerId),
|
|
);
|
|
}
|
|
|
|
// Approving over an outstanding document correction would silently accept the
|
|
// very document a reviewer just rejected, and would strand the customer's
|
|
// "please fix this" banner with nothing left to fix. The gate check and the
|
|
// status write share a write lock on the company row — `requestDocumentChange`
|
|
// takes the same lock, so a fresh correction can never land in the window
|
|
// between "any open corrections?" and the profile going Active. Suspend and
|
|
// blacklist skip all this — staff must always be able to act against a bad
|
|
// account.
|
|
return this.dataSource.transaction(async (manager) => {
|
|
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) {
|
|
// The row was loaded FOR UPDATE, so its relations are not populated —
|
|
// and `readPoaDeclaration` reads `companyProfiles` to force "yes" for a
|
|
// forwarder. This IS the forwarder profile being approved, so naming it
|
|
// is enough (and truthful) for both assertions below.
|
|
company.companyProfiles = company.companyProfiles ?? [existing];
|
|
this.assertIdentityVerified(company);
|
|
await this.assertPoaDelegationSatisfied(company, company.attributes);
|
|
}
|
|
|
|
const [companyDocs, profileDocs] = await Promise.all([
|
|
this.filesService.findWithOpenChangeRequest(
|
|
[existing.companyId],
|
|
"companies",
|
|
),
|
|
this.filesService.findWithOpenChangeRequest(
|
|
[existing.id],
|
|
"company_profiles",
|
|
),
|
|
]);
|
|
const pending = [...companyDocs, ...profileDocs];
|
|
if (pending.length > 0) {
|
|
const names = pending.map((f) => f.name).join(", ");
|
|
throw new BadRequestException(
|
|
`This role has ${pending.length} document(s) awaiting customer correction (${names}). ` +
|
|
`Approve it once the customer has re-uploaded them, or withdraw the change request first.`,
|
|
);
|
|
}
|
|
|
|
return this.applyProfileStatus(
|
|
manager,
|
|
existing,
|
|
status,
|
|
note,
|
|
reviewerId,
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Write a reviewed profile status (reference minting, note handling, reviewer
|
|
* stamp) and promote the company if this is its first approved role. Split out
|
|
* of `setCompanyProfileStatus` so the approval path can run it inside the gate
|
|
* transaction while every other status skips that overhead.
|
|
*/
|
|
private async applyProfileStatus(
|
|
manager: EntityManager,
|
|
existing: CompanyProfile,
|
|
status: ProfileStatus,
|
|
note?: string,
|
|
reviewerId?: string,
|
|
): Promise<CompanyProfile> {
|
|
// Every write below goes through `manager`. The approval path holds a
|
|
// pessimistic_write lock on the company row, and the injected repositories
|
|
// are bound to the DataSource's default pool — writing the same row through
|
|
// one of them would block on a lock this very transaction holds, hanging the
|
|
// request until the statement timed out. That deadlocked the first approval
|
|
// of any customer: the profile went Active on its own connection while the
|
|
// company stayed Pending and the caller never got a response.
|
|
const profileRepo = manager.getRepository(CompanyProfile);
|
|
const companyRepo = manager.getRepository(Company);
|
|
// A reference number is only minted the first time a profile is approved
|
|
// (status → Active). Pending/unapproved profiles carry no reference.
|
|
const patch: Partial<CompanyProfile> = { status };
|
|
if (status === ProfileStatus.Active && !existing.reference) {
|
|
patch.reference = await this.companyProfilesRepo.generateReference(
|
|
existing.type,
|
|
);
|
|
}
|
|
|
|
// Track the review outcome. Rejection and suspension keep the note so the
|
|
// customer knows why; approval/reactivation clears it. Any decision stamps
|
|
// the reviewer + time.
|
|
if (
|
|
status === ProfileStatus.Rejected ||
|
|
status === ProfileStatus.Suspended
|
|
) {
|
|
patch.reviewNote = note ?? null;
|
|
} else if (status === ProfileStatus.Active) {
|
|
patch.reviewNote = null;
|
|
}
|
|
if (status !== ProfileStatus.Pending) {
|
|
patch.reviewedBy = reviewerId ?? null;
|
|
patch.reviewedAt = new Date();
|
|
}
|
|
|
|
await profileRepo.update(existing.id, patch);
|
|
const updated = await profileRepo.findOne({ where: { id: existing.id } });
|
|
if (!updated)
|
|
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
|
|
|
// Every reviewed transition that changes what the customer can do is told
|
|
// to them, carrying the staff message so they know why. Approval has no
|
|
// message (the note is cleared); the others require one.
|
|
const change =
|
|
status === ProfileStatus.Suspended
|
|
? "suspended"
|
|
: status === ProfileStatus.Rejected
|
|
? "rejected"
|
|
: status === ProfileStatus.Active
|
|
? existing.status === ProfileStatus.Suspended
|
|
? "reactivated"
|
|
: "approved"
|
|
: null;
|
|
if (change) {
|
|
const company = await companyRepo.findOne({
|
|
where: { id: updated.companyId },
|
|
});
|
|
if (company) {
|
|
this.companyNotifier.profileStatusChanged(
|
|
company,
|
|
updated.type,
|
|
change,
|
|
note ?? "",
|
|
);
|
|
// The first approved role promotes a pending company to active — a
|
|
// bigger event (the account itself goes live), so tell them that too.
|
|
if (
|
|
status === ProfileStatus.Active &&
|
|
company.status === CompanyStatus.Pending
|
|
) {
|
|
await companyRepo.update(updated.companyId, {
|
|
status: CompanyStatus.Active,
|
|
approvedAt: new Date(),
|
|
});
|
|
this.companyNotifier.companyApproved(company);
|
|
}
|
|
}
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Customer reapplies for a rejected or suspended operational role (after
|
|
* fixing whatever the reviewer flagged, e.g. re-uploading a license): flip it
|
|
* back to Pending and clear the review note so it re-enters the approval
|
|
* queue. Suspension is a staff lockout, so resubmitting is an appeal — the
|
|
* backoffice still has to approve before the role goes live again.
|
|
*/
|
|
async reapplyCompanyProfile(
|
|
userId: string,
|
|
profileId: string,
|
|
): Promise<CompanyProfile> {
|
|
const profile = await this.profilesRepo.findByUserId(userId);
|
|
if (!profile)
|
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
|
const companyId = profile.company?.id ?? profile.companyId;
|
|
|
|
const target = await this.companyProfilesRepo.findById(profileId);
|
|
if (!target || target.companyId !== companyId) {
|
|
throw new NotFoundException(`Company profile ${profileId} not found`);
|
|
}
|
|
if (
|
|
target.status !== ProfileStatus.Rejected &&
|
|
target.status !== ProfileStatus.Suspended
|
|
) {
|
|
throw new BadRequestException(
|
|
"Only a rejected or suspended role can be resubmitted for approval",
|
|
);
|
|
}
|
|
|
|
const updated = await this.companyProfilesRepo.update(profileId, {
|
|
status: ProfileStatus.Pending,
|
|
reviewNote: null,
|
|
reviewedBy: null,
|
|
reviewedAt: null,
|
|
});
|
|
if (!updated)
|
|
throw new NotFoundException(`Company profile ${profileId} not found`);
|
|
|
|
// The role is back in the pending queue — tell the reviewers, otherwise the
|
|
// resubmission is invisible until someone happens to reopen the customer.
|
|
const company = await this.companiesRepo.findById(companyId);
|
|
if (company) {
|
|
this.companyNotifier.roleReapplied(company, updated.id, updated.type);
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
async createCompanyProfile(
|
|
companyId: string,
|
|
profileType?: ProfileType,
|
|
): Promise<CompanyProfile> {
|
|
const company = await this.findCompanyById(companyId);
|
|
|
|
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
|
|
|
const type = profileType ?? allowedTypes[0];
|
|
if (!allowedTypes.includes(type)) {
|
|
throw new BadRequestException(
|
|
`Profile type "${type}" is not allowed for company type "${company.type}"`,
|
|
);
|
|
}
|
|
|
|
const existing = await this.companyProfilesRepo.findByType(companyId, type);
|
|
if (existing) {
|
|
throw new ConflictException(
|
|
`Company already has a ${type} profile (${existing.reference ?? "pending approval"})`,
|
|
);
|
|
}
|
|
|
|
// No reference is minted here: it is issued by setCompanyProfileStatus when
|
|
// a reviewer approves the role. Creating it Active would bypass that review.
|
|
return this.companyProfilesRepo.create({
|
|
companyId,
|
|
type,
|
|
status: ProfileStatus.Pending,
|
|
});
|
|
}
|
|
|
|
async createDefaultProfilesForCompany(
|
|
companyId: string,
|
|
): Promise<CompanyProfile[]> {
|
|
const company = await this.findCompanyById(companyId);
|
|
const types = this.getProfileTypeForCompanyType(company.type);
|
|
|
|
const profiles: CompanyProfile[] = [];
|
|
for (const type of types) {
|
|
const existing = await this.companyProfilesRepo.findByType(
|
|
companyId,
|
|
type,
|
|
);
|
|
if (!existing) {
|
|
profiles.push(await this.createCompanyProfile(companyId, type));
|
|
}
|
|
}
|
|
|
|
if (profiles.length === 0) {
|
|
throw new BadRequestException(
|
|
`Company of type "${company.type}" must have at least one operational profile`,
|
|
);
|
|
}
|
|
|
|
return profiles;
|
|
}
|
|
|
|
/**
|
|
* Add operational profile(s) to the current user's company (portal settings).
|
|
* Add-only and idempotent: each requested type must be allowed for the
|
|
* company's type, profiles that already exist are skipped (not re-created or
|
|
* rejected), and the full updated list is returned.
|
|
*/
|
|
async addCompanyProfilesForUser(
|
|
userId: string,
|
|
types: ProfileType[],
|
|
): Promise<CompanyProfile[]> {
|
|
const profile = await this.profilesRepo.findByUserId(userId);
|
|
if (!profile)
|
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
|
|
|
const companyId = profile.company?.id ?? profile.companyId;
|
|
const company = await this.findCompanyById(companyId);
|
|
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
|
|
|
for (const type of types) {
|
|
if (!allowedTypes.includes(type)) {
|
|
throw new BadRequestException(
|
|
`Profile type "${type}" is not allowed for company type "${company.type}"`,
|
|
);
|
|
}
|
|
|
|
const existing = await this.companyProfilesRepo.findByType(
|
|
companyId,
|
|
type,
|
|
);
|
|
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.assertRolesAllowedForCooperative(isCooperative(company), [type]);
|
|
const asForwarder = this.withProfileType(company, type);
|
|
this.assertIdentityVerified(asForwarder);
|
|
await this.assertPoaDelegationSatisfied(
|
|
asForwarder,
|
|
await this.effectivePoaAttributes(company),
|
|
);
|
|
}
|
|
|
|
// 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({
|
|
companyId,
|
|
type,
|
|
status: ProfileStatus.Pending,
|
|
});
|
|
}
|
|
|
|
return this.companyProfilesRepo.findByCompanyId(companyId);
|
|
}
|
|
|
|
/**
|
|
* Create a single operational profile for the current user's company. The new
|
|
* role starts Pending and carries no reference until a backoffice reviewer
|
|
* approves it; a booking/contract resolves its profile from the trade
|
|
* direction at creation time, so no "active mode" is stored.
|
|
*/
|
|
async createCompanyProfileForUser(
|
|
userId: string,
|
|
type: ProfileType,
|
|
businessLicense?: string,
|
|
): Promise<CompanyProfile> {
|
|
const profile = await this.profilesRepo.findByUserId(userId);
|
|
if (!profile)
|
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
|
|
|
const companyId = profile.company?.id ?? profile.companyId;
|
|
const company = await this.findCompanyById(companyId);
|
|
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
|
if (!allowedTypes.includes(type)) {
|
|
throw new BadRequestException(
|
|
`Profile type "${type}" is not allowed for company type "${company.type}"`,
|
|
);
|
|
}
|
|
|
|
let created = await this.companyProfilesRepo.findByType(companyId, type);
|
|
if (!created && type === ProfileType.freightForwarder) {
|
|
this.assertRolesAllowedForCooperative(isCooperative(company), [type]);
|
|
const asForwarder = this.withProfileType(company, type);
|
|
this.assertIdentityVerified(asForwarder);
|
|
await this.assertPoaDelegationSatisfied(
|
|
asForwarder,
|
|
await this.effectivePoaAttributes(company),
|
|
);
|
|
}
|
|
if (!created) {
|
|
// New self-service roles start Pending (awaiting backoffice approval) and
|
|
// carry no reference until approved.
|
|
created = await this.companyProfilesRepo.create({
|
|
companyId,
|
|
type,
|
|
businessLicense: businessLicense ?? null,
|
|
status: ProfileStatus.Pending,
|
|
});
|
|
}
|
|
|
|
return created;
|
|
}
|
|
|
|
async setOnboardingStep(userId: string, step: string): Promise<void> {
|
|
const profile = await this.profilesRepo.findByUserId(userId);
|
|
if (!profile)
|
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
|
await this.profilesRepo.update(profile.id, { onboardingStep: step });
|
|
}
|
|
|
|
/**
|
|
* Server-driven onboarding requirements for the current user's company.
|
|
*
|
|
* The backend resolves the nationality-based document set, checks which
|
|
* company documents and per-profile licenses are already uploaded, and reports
|
|
* exactly what is still outstanding. The portal renders this list verbatim and
|
|
* relies on `isComplete` to decide when to auto-finish — it never decides for
|
|
* itself which documents apply or which fields are mandatory.
|
|
*/
|
|
async getOnboardingRequirements(
|
|
userId: string,
|
|
): Promise<OnboardingRequirementsResponseDto> {
|
|
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
|
const identity = this.getCompanyIdentityState(company);
|
|
|
|
// 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. Company documents + which are already uploaded. One set applies: the
|
|
// company's nationality set, or the co-operative set in its place — a union
|
|
// or farm holds no business licence, so it owes its own list rather than the
|
|
// nationality list plus extras.
|
|
const cooperative = isCooperative(company);
|
|
const investorLicence = hasInvestorLicence(company);
|
|
const documentSettingCode = this.documentSettingCodeFor(company);
|
|
const [setting, uploadedFiles] = await Promise.all([
|
|
this.fileUploadSettingsService
|
|
.getByCode(documentSettingCode)
|
|
.catch(() => null),
|
|
this.filesService.findByResource(company.id, "companies"),
|
|
]);
|
|
const uploadedCodes = new Set(uploadedFiles.map((f) => f.code));
|
|
const documents = (setting?.fields ?? [])
|
|
.slice()
|
|
.sort((a, b) => a.displayOrder - b.displayOrder)
|
|
.map((f) => ({
|
|
fileKey: f.fileKey,
|
|
fileLabel: f.fileLabel,
|
|
helpText: f.helpText ?? null,
|
|
isRequired: f.isRequired,
|
|
isMultiple: f.isMultiple,
|
|
maxFiles: f.maxFiles,
|
|
allowedExtensions: f.allowedExtensions,
|
|
maxSizeMb: f.maxSizeMb,
|
|
displayOrder: f.displayOrder,
|
|
uploaded: uploadedCodes.has(f.fileKey),
|
|
}));
|
|
const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
|
|
|
|
// 3. Per-operational-profile business licenses (FileRecord-backed).
|
|
const licenseProfiles = await Promise.all(
|
|
(company.companyProfiles ?? []).map(async (p) => {
|
|
const records = await this.filesService.findByResource(
|
|
p.id,
|
|
LICENSE_RESOURCE,
|
|
);
|
|
return {
|
|
profileId: p.id,
|
|
type: p.type,
|
|
reference: p.reference ?? "",
|
|
uploaded: records.some((r) => r.code === LICENSE_CODE),
|
|
};
|
|
}),
|
|
);
|
|
// A co-operative holds no business licence — that is the whole reason it
|
|
// skips the eTrade lookup — so the per-role licence is not owed. Its own
|
|
// document set (merged above) is what stands in for it. The profiles are
|
|
// still reported so the portal can show them; only the requirement lifts.
|
|
const missingLicenses = cooperative
|
|
? []
|
|
: licenseProfiles.filter((p) => !p.uploaded);
|
|
|
|
// 4. Power of Attorney. Whether there is one at all is the company's own
|
|
// declaration — the question the wizard asks outright — and that answer is
|
|
// what decides whose identity gets verified, so an unanswered one is itself
|
|
// outstanding. A freight forwarder never gets to answer: it signs on other
|
|
// companies' behalf, so `readPoaDeclaration` forces "yes".
|
|
//
|
|
// Once there IS a representative, their details and the DARS delegation
|
|
// paper are both due. The paper is 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 poaDue = identity.poaDeclared === "yes";
|
|
const delegation = await this.getPoaDelegationState(company.id);
|
|
const delegationDue = poaDue;
|
|
// The representative's details normally arrive from their Fayda
|
|
// verification — but Fayda's email and phone claims are optional and
|
|
// routinely come back empty, and the PoA step renders an input for whatever
|
|
// the verification did not supply. So these are askable after all, and are
|
|
// reported outstanding once a PoA is declared; reporting nothing here let a
|
|
// freight forwarder finish onboarding with a representative the API's own
|
|
// `REQUIRED_POA_FIELDS` calls incomplete, then 400'd their next PoA edit.
|
|
const missingPoaFields = poaDue
|
|
? REQUIRED_POA_FIELDS.filter(
|
|
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
|
|
)
|
|
: [];
|
|
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;
|
|
|
|
// 5. The single identity. Who proves it is `identity.subject`; how they may
|
|
// prove it is nationality-dependent (Fayda always, a passport number as an
|
|
// alternative for a foreign company). Both are derived once in
|
|
// buildCompanyIdentityState so this list can never disagree with the gate
|
|
// `assertIdentityVerified` actually enforces.
|
|
const identitySubjectLabel =
|
|
identity.subject === "poa"
|
|
? "your Power of Attorney"
|
|
: "the person named on your eTrade licence";
|
|
|
|
const outstanding = [
|
|
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
|
|
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
|
|
...missingLicenses.map(
|
|
(p) =>
|
|
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
|
|
),
|
|
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
|
|
...(missingDelegation
|
|
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
|
|
: []),
|
|
...(flaggedDelegation
|
|
? [
|
|
`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`,
|
|
]
|
|
: []),
|
|
...(identity.poaDeclared === null
|
|
? ["Tell us whether anyone holds power of attorney for your company"]
|
|
: []),
|
|
...(identity.poaDeclared !== null && !identity.identityProven
|
|
? [
|
|
identity.passportAccepted
|
|
? `Verify ${identitySubjectLabel} with Fayda, or add their passport number`
|
|
: `Verify ${identitySubjectLabel} with Fayda`,
|
|
]
|
|
: []),
|
|
];
|
|
|
|
// Progress spans every required item the user has to satisfy: company-info
|
|
// fields, required documents, one license per operational profile, the PoA
|
|
// details/paper once declared, and the two identity items — answering the
|
|
// declaration, and proving the person it points at.
|
|
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
|
// The delegation paper plus the representative's own required details —
|
|
// `completed` below subtracts every one of those it is still missing, so
|
|
// leaving them out of the total would make the bar understate progress.
|
|
const poaItemCount =
|
|
(poaDue ? REQUIRED_POA_FIELDS.length : 0) + (delegationDue ? 1 : 0);
|
|
const missingIdentityCount =
|
|
(identity.poaDeclared === null ? 1 : 0) +
|
|
(identity.identityProven ? 0 : 1);
|
|
const total =
|
|
requiredInfo.length +
|
|
requiredDocCount +
|
|
(cooperative ? 0 : licenseProfiles.length) +
|
|
poaItemCount +
|
|
// The declaration and the verification it selects.
|
|
2;
|
|
const completed =
|
|
total -
|
|
(missingInfo.length +
|
|
missingDocs.length +
|
|
missingLicenses.length +
|
|
missingPoaFields.length +
|
|
(missingDelegation || flaggedDelegation ? 1 : 0) +
|
|
missingIdentityCount);
|
|
|
|
return new OnboardingRequirementsResponseDto({
|
|
documentSettingCode,
|
|
nationality: company.nationality ?? CompanyNationality.Ethiopian,
|
|
cooperative,
|
|
investorLicence,
|
|
companyInfo: {
|
|
complete: missingInfo.length === 0,
|
|
missingFields: missingInfo,
|
|
},
|
|
documents,
|
|
licenseProfiles,
|
|
poa: {
|
|
// "Locked" rather than "required": a freight forwarder is not asked the
|
|
// question at all, everyone else answers it themselves.
|
|
locked: identity.poaDeclared === "yes" && (company.companyProfiles ?? []).some(
|
|
(p) => p.type === ProfileType.freightForwarder,
|
|
),
|
|
declared: identity.poaDeclared,
|
|
delegationLetterRequired: delegationDue,
|
|
delegationLetterUploaded: delegation.onFile,
|
|
delegationLetterFlagged: delegation.flagged,
|
|
missingFields: missingPoaFields,
|
|
complete:
|
|
missingPoaFields.length === 0 &&
|
|
!missingDelegation &&
|
|
!flaggedDelegation,
|
|
},
|
|
identity,
|
|
progress: { completed, total },
|
|
isComplete: outstanding.length === 0,
|
|
onboardingCompleted: profile.onboardingCompleted,
|
|
outstanding,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Submit onboarding for review. Validation is delegated entirely to
|
|
* getOnboardingRequirements (the same source of truth the portal renders), so
|
|
* the gate can never drift from what the UI shows. On success the company and
|
|
* all its operational profiles move to PENDING — the backoffice approves each
|
|
* profile before it can be used (see setCompanyProfileStatus).
|
|
*/
|
|
async markOnboardingComplete(
|
|
userId: string,
|
|
): Promise<{ profile: ExternalProfile; company: Company }> {
|
|
const profile = await this.profilesRepo.findByUserId(userId);
|
|
if (!profile)
|
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
|
|
|
const companyId = profile.company?.id ?? profile.companyId;
|
|
|
|
const requirements = await this.getOnboardingRequirements(userId);
|
|
if (!requirements.isComplete) {
|
|
throw new BadRequestException(
|
|
requirements.outstanding[0] ??
|
|
"Your onboarding is incomplete. Please complete all required steps before submitting.",
|
|
);
|
|
}
|
|
|
|
// Send every operational profile in for approval; the company itself becomes
|
|
// active once the backoffice approves at least one profile.
|
|
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
|
for (const cp of profiles) {
|
|
if (cp.status !== ProfileStatus.Pending) {
|
|
await this.companyProfilesRepo.updateStatus(
|
|
cp.id,
|
|
ProfileStatus.Pending,
|
|
);
|
|
}
|
|
}
|
|
|
|
await this.profilesRepo.update(profile.id, {
|
|
onboardingCompleted: true,
|
|
onboardingStep: "done",
|
|
});
|
|
// Awaiting backoffice approval — stays Pending until an admin activates it.
|
|
await this.companiesRepo.update(companyId, {
|
|
status: CompanyStatus.Pending,
|
|
});
|
|
return this.getCompanyInfoByUserId(userId);
|
|
}
|
|
|
|
/**
|
|
* Drop the investment-licence route and send the company back through the
|
|
* normal eTrade one.
|
|
*
|
|
* Everything the flag let the customer type is cleared, not kept: the
|
|
* registration block on file was their own statement, and leaving it there
|
|
* would let the wizard treat the company as already looked-up
|
|
* (`hasRegistrationDetails` is what stands in for a verified TIN on a
|
|
* resume) and walk straight past the eTrade step this switch exists to
|
|
* reach. Onboarding reopens at the company step and the company goes back to
|
|
* pending — an approval granted against typed data cannot silently carry over
|
|
* to a record that now claims to be eTrade's.
|
|
*/
|
|
async revertToRegularCompany(
|
|
userId: string,
|
|
): Promise<{ profile: ExternalProfile; company: Company }> {
|
|
const profile = await this.profilesRepo.findByUserId(userId);
|
|
if (!profile)
|
|
throw new NotFoundException(`Profile for user ${userId} not found`);
|
|
|
|
const companyId = profile.company?.id ?? profile.companyId;
|
|
const company = await this.companiesRepo.findById(companyId);
|
|
if (!company)
|
|
throw new NotFoundException(`Company ${companyId} not found`);
|
|
if (!hasInvestorLicence(company)) {
|
|
throw new BadRequestException(
|
|
"This company is already registered through eTrade — there is nothing to switch.",
|
|
);
|
|
}
|
|
|
|
const attributes = this.withoutTypedEtradeManager(company.attributes);
|
|
delete attributes[INVESTOR_LICENCE_KEY];
|
|
|
|
await this.companiesRepo.update(companyId, {
|
|
...CompaniesService.CLEARED_REGISTRATION,
|
|
attributes,
|
|
status: CompanyStatus.Pending,
|
|
});
|
|
await this.profilesRepo.update(profile.id, {
|
|
onboardingCompleted: false,
|
|
onboardingStep: "company",
|
|
});
|
|
return this.getCompanyInfoByUserId(userId);
|
|
}
|
|
|
|
/**
|
|
* Block a self-service action when the company account isn't active, naming
|
|
* the actual status — a suspended customer told "awaiting approval" has no
|
|
* idea what happened or who to call.
|
|
*/
|
|
assertCompanyActiveFor(company: Company, action: string): void {
|
|
if (company.status === CompanyStatus.Active) return;
|
|
switch (company.status) {
|
|
case CompanyStatus.Suspended:
|
|
throw new ForbiddenException(
|
|
`Your company account is suspended — you can't create ${action} right now. ` +
|
|
`Please contact EDR support for details.`,
|
|
);
|
|
case CompanyStatus.Blacklisted:
|
|
throw new ForbiddenException(
|
|
`Your company account is blacklisted — you can't create ${action}. ` +
|
|
`Please contact EDR support.`,
|
|
);
|
|
default:
|
|
throw new ForbiddenException(
|
|
`Your company is awaiting approval — you can't create ${action} yet.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Block a customer from booking under a profile that isn't approved yet — or
|
|
* that a reviewer has since suspended. Called from the booking/contract
|
|
* create path for self-service actions; staff- and government-initiated ones
|
|
* bypass this. No-op when the profile can't be found (defensive — resolution
|
|
* is best-effort upstream). The message names the profile's real status:
|
|
* suspension in particular is per-role, so the customer must learn which
|
|
* operation is blocked (their other roles still work).
|
|
*/
|
|
async assertCompanyProfileApprovedForBooking(
|
|
companyProfileId: string,
|
|
): Promise<void> {
|
|
const profile = await this.companyProfilesRepo.findById(companyProfileId);
|
|
if (!profile) return;
|
|
if (profile.status === ProfileStatus.Active) return;
|
|
|
|
const role = profile.type.replace(/_/g, " ");
|
|
switch (profile.status) {
|
|
case ProfileStatus.Suspended:
|
|
throw new ForbiddenException(
|
|
`Your ${role} role is suspended${profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
|
}. Your other roles are unaffected. Please contact EDR support to resolve this.`,
|
|
);
|
|
case ProfileStatus.Blacklisted:
|
|
throw new ForbiddenException(
|
|
`Your ${role} role is blacklisted. Please contact EDR support.`,
|
|
);
|
|
case ProfileStatus.Rejected:
|
|
throw new ForbiddenException(
|
|
`Your ${role} role was rejected${profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
|
}. Amend and resubmit it from your settings page.`,
|
|
);
|
|
default:
|
|
throw new ForbiddenException(
|
|
`Your ${role} profile is awaiting approval. You'll be able to proceed once it has been approved.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Authorize and resolve a company_profile that must belong to the current
|
|
* user's company — used before accepting/returning its license files.
|
|
*/
|
|
async resolveOwnedProfile(
|
|
userId: string,
|
|
profileId: string,
|
|
): Promise<CompanyProfile> {
|
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
|
const owned = (company.companyProfiles ?? []).find(
|
|
(p) => p.id === profileId,
|
|
);
|
|
if (!owned) {
|
|
throw new NotFoundException(`Profile ${profileId} not found`);
|
|
}
|
|
return owned;
|
|
}
|
|
|
|
// ─── Business-license files ────────────────────────────────────────────────
|
|
//
|
|
// License documents live in the FileRecord model (`freight.files`) with
|
|
// `resource = "company_profiles"`, `resourceId = <profileId>`. Live files use
|
|
// code `LICENSE_CODE`; files staged inside an open change request (add /
|
|
// replacement) use `LICENSE_PENDING_CODE` and only become live on approval.
|
|
// Preview streams through `GET /api/files/:id` (server-side proxy) — the same
|
|
// path regular documents use — so it never hits MinIO directly from the
|
|
// browser (which fails on the internal bucket endpoint).
|
|
|
|
/**
|
|
* Upload business-license file(s) for one of the user's profiles. For a role
|
|
* not yet approved (a fresh onboarding profile, or a newly added service on an
|
|
* already-active company) they go live immediately and are reviewed together
|
|
* with the role itself. Only for an already-approved role are they staged under
|
|
* the pending code and recorded as `add` intents on a pending change request —
|
|
* a licence swap on a live role is a change; a licence on a new role is not.
|
|
*/
|
|
async addProfileLicenseFiles(
|
|
userId: string,
|
|
profileId: string,
|
|
files: Express.Multer.File[],
|
|
): Promise<ProfileLicenseFileView[]> {
|
|
const profile = await this.resolveOwnedProfile(userId, profileId);
|
|
const company = await this.findCompanyById(profile.companyId);
|
|
const gated = profile.status === ProfileStatus.Active;
|
|
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
|
|
|
|
const uploaded = await Promise.all(
|
|
files.map((file) =>
|
|
this.filesService.upload({
|
|
resourceId: profileId,
|
|
resource: LICENSE_RESOURCE,
|
|
code,
|
|
file,
|
|
}),
|
|
),
|
|
);
|
|
|
|
if (gated) {
|
|
await this.stageLicenseChange(
|
|
company.id,
|
|
uploaded.map((r) => ({
|
|
profileId,
|
|
op: "add" as const,
|
|
fileId: r.id,
|
|
fileName: r.name,
|
|
})),
|
|
userId,
|
|
);
|
|
}
|
|
|
|
// A fresh licence upload answers any correction the reviewer asked for on the
|
|
// previous one, so the old row must stop blocking approval.
|
|
await this.resolveDocumentChangeRequests(
|
|
profileId,
|
|
LICENSE_RESOURCE,
|
|
[LICENSE_CODE, LICENSE_PENDING_CODE],
|
|
uploaded.map((r) => r.id),
|
|
);
|
|
|
|
return this.getProfileLicenseView(profileId, company.id);
|
|
}
|
|
|
|
/**
|
|
* Remove a license file. A staged (pending) file is withdrawn outright
|
|
* (soft-deleted, its `add` intent dropped). A live file on an already-approved
|
|
* role is kept and recorded as a `remove` intent for review; on a role still
|
|
* awaiting approval it is deleted immediately.
|
|
*/
|
|
async removeProfileLicenseFile(
|
|
userId: string,
|
|
profileId: string,
|
|
fileId: string,
|
|
): Promise<ProfileLicenseFileView[]> {
|
|
const profile = await this.resolveOwnedProfile(userId, profileId);
|
|
const record = await this.filesService.findById(fileId);
|
|
if (
|
|
record.resource !== LICENSE_RESOURCE ||
|
|
record.resourceId !== profileId
|
|
) {
|
|
throw new NotFoundException(`License file ${fileId} not found`);
|
|
}
|
|
const company = await this.findCompanyById(profile.companyId);
|
|
const gated = profile.status === ProfileStatus.Active;
|
|
|
|
if (record.code === LICENSE_PENDING_CODE) {
|
|
// Withdraw a not-yet-approved upload: delete it and drop its add intent.
|
|
await this.filesService.remove(fileId);
|
|
await this.withdrawLicenseIntent(company.id, fileId);
|
|
} else if (gated) {
|
|
await this.stageLicenseChange(
|
|
company.id,
|
|
[{ profileId, op: "remove", fileId, fileName: record.name }],
|
|
userId,
|
|
);
|
|
} else {
|
|
await this.filesService.remove(fileId);
|
|
}
|
|
|
|
return this.getProfileLicenseView(profileId, company.id);
|
|
}
|
|
|
|
/**
|
|
* Replace a live license file with a freshly uploaded one — recorded as a
|
|
* `remove` of the old file plus an `add` of the new, so approval swaps them
|
|
* atomically. On a role still awaiting approval the swap is applied immediately.
|
|
*/
|
|
async replaceProfileLicenseFile(
|
|
userId: string,
|
|
profileId: string,
|
|
fileId: string,
|
|
file: Express.Multer.File,
|
|
): Promise<ProfileLicenseFileView[]> {
|
|
const profile = await this.resolveOwnedProfile(userId, profileId);
|
|
const old = await this.filesService.findById(fileId);
|
|
if (old.resource !== LICENSE_RESOURCE || old.resourceId !== profileId) {
|
|
throw new NotFoundException(`License file ${fileId} not found`);
|
|
}
|
|
const company = await this.findCompanyById(profile.companyId);
|
|
const gated = profile.status === ProfileStatus.Active;
|
|
|
|
const created = await this.filesService.upload({
|
|
resourceId: profileId,
|
|
resource: LICENSE_RESOURCE,
|
|
code: gated ? LICENSE_PENDING_CODE : LICENSE_CODE,
|
|
file,
|
|
});
|
|
|
|
if (gated) {
|
|
await this.stageLicenseChange(
|
|
company.id,
|
|
[
|
|
{ profileId, op: "remove", fileId, fileName: old.name },
|
|
{ profileId, op: "add", fileId: created.id, fileName: created.name },
|
|
],
|
|
userId,
|
|
);
|
|
} else {
|
|
await this.filesService.remove(fileId);
|
|
}
|
|
|
|
await this.resolveDocumentChangeRequests(
|
|
profileId,
|
|
LICENSE_RESOURCE,
|
|
[LICENSE_CODE, LICENSE_PENDING_CODE],
|
|
[created.id],
|
|
);
|
|
|
|
return this.getProfileLicenseView(profileId, company.id);
|
|
}
|
|
|
|
/** License files for one profile, with each file's review status resolved. */
|
|
async listProfileLicenseFiles(
|
|
userId: string,
|
|
profileId: string,
|
|
): Promise<ProfileLicenseFileView[]> {
|
|
const profile = await this.resolveOwnedProfile(userId, profileId);
|
|
return this.getProfileLicenseView(profileId, profile.companyId);
|
|
}
|
|
|
|
/**
|
|
* Live license files for a profile, shaped for by-reference reuse (bookings /
|
|
* contracts snapshot these). No ownership check — internal callers only.
|
|
* Returns the raw stored URLs; pending (unapproved) files are excluded.
|
|
*/
|
|
async getProfileOnboardingFiles(
|
|
profileId: string,
|
|
): Promise<BusinessLicenseFile[]> {
|
|
const records = await this.filesService.findByResource(
|
|
profileId,
|
|
LICENSE_RESOURCE,
|
|
);
|
|
return records
|
|
.filter((r) => r.code === LICENSE_CODE)
|
|
.map((r) => ({
|
|
name: r.name,
|
|
url: r.url,
|
|
size: r.size,
|
|
mimeType: r.mimeType,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Assemble the review-aware license view for a set of profiles in one pass
|
|
* (single change-request lookup). Used to enrich company/profile responses.
|
|
*/
|
|
async assembleLicenseFilesByProfile(
|
|
companyId: string,
|
|
profileIds: string[],
|
|
): Promise<Record<string, ProfileLicenseFileView[]>> {
|
|
const pending =
|
|
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
|
const removeIds = new Set(
|
|
(pending?.documents?.licenseChanges ?? [])
|
|
.filter((c) => c.op === "remove")
|
|
.map((c) => c.fileId),
|
|
);
|
|
const result: Record<string, ProfileLicenseFileView[]> = {};
|
|
await Promise.all(
|
|
profileIds.map(async (pid) => {
|
|
result[pid] = await this.mapLicenseRecords(pid, removeIds);
|
|
}),
|
|
);
|
|
return result;
|
|
}
|
|
|
|
/** Single-profile license view (fetches the company's pending request once). */
|
|
private async getProfileLicenseView(
|
|
profileId: string,
|
|
companyId: string,
|
|
): Promise<ProfileLicenseFileView[]> {
|
|
const pending =
|
|
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
|
const removeIds = new Set(
|
|
(pending?.documents?.licenseChanges ?? [])
|
|
.filter((c) => c.op === "remove")
|
|
.map((c) => c.fileId),
|
|
);
|
|
return this.mapLicenseRecords(profileId, removeIds);
|
|
}
|
|
|
|
private async mapLicenseRecords(
|
|
profileId: string,
|
|
pendingRemoveIds: Set<string>,
|
|
): Promise<ProfileLicenseFileView[]> {
|
|
const records = await this.filesService.findByResource(
|
|
profileId,
|
|
LICENSE_RESOURCE,
|
|
);
|
|
return records
|
|
.filter((r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE)
|
|
.map((r) => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
size: r.size,
|
|
mimeType: r.mimeType,
|
|
status:
|
|
r.code === LICENSE_PENDING_CODE
|
|
? ("pending_add" as const)
|
|
: pendingRemoveIds.has(r.id)
|
|
? ("pending_remove" as const)
|
|
: ("live" as const),
|
|
reviewStatus: r.reviewStatus,
|
|
reviewNote: r.reviewNote,
|
|
}));
|
|
}
|
|
|
|
/** Open or append a pending change request recording license add/remove intents. */
|
|
private async stageLicenseChange(
|
|
companyId: string,
|
|
changes: LicenseChangeIntent[],
|
|
submittedBy?: string,
|
|
): Promise<void> {
|
|
if (changes.length === 0) return;
|
|
const now = new Date();
|
|
const existing =
|
|
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
|
if (existing) {
|
|
const prev = existing.documents?.licenseChanges ?? [];
|
|
await this.changeRequestRepo.update(existing.id, {
|
|
documents: {
|
|
...existing.documents,
|
|
licenseChanges: [...prev, ...changes],
|
|
},
|
|
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
|
submittedAt: now,
|
|
// Note left untouched — see the comment in updateProfile's merge branch.
|
|
status: ChangeRequestStatus.Pending,
|
|
});
|
|
} else {
|
|
await this.changeRequestRepo.create({
|
|
companyId,
|
|
snapshot: {},
|
|
documents: { licenseChanges: changes },
|
|
status: ChangeRequestStatus.Pending,
|
|
submittedBy: submittedBy ?? null,
|
|
submittedAt: now,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drop a staged license intent (add or remove) referencing `fileId` from the
|
|
* company's open request. If that empties the request entirely, delete it so
|
|
* the customer's settings page unlocks.
|
|
*/
|
|
private async withdrawLicenseIntent(
|
|
companyId: string,
|
|
fileId: string,
|
|
): Promise<void> {
|
|
const existing =
|
|
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
|
if (!existing) return;
|
|
const remaining = (existing.documents?.licenseChanges ?? []).filter(
|
|
(c) => c.fileId !== fileId,
|
|
);
|
|
const docs = existing.documents ?? {};
|
|
const stillHasWork =
|
|
remaining.length > 0 ||
|
|
(docs.documentFileIds?.length ?? 0) > 0 ||
|
|
Object.keys(existing.snapshot ?? {}).length > 0;
|
|
|
|
if (stillHasWork) {
|
|
await this.changeRequestRepo.update(existing.id, {
|
|
documents: { ...docs, licenseChanges: remaining },
|
|
});
|
|
} else {
|
|
await this.changeRequestRepo.softDelete(existing.id);
|
|
}
|
|
}
|
|
|
|
/** Apply a request's staged license changes: promote adds, delete removes. */
|
|
private async applyLicenseChanges(
|
|
request: CompanyChangeRequest,
|
|
): Promise<void> {
|
|
for (const change of request.documents?.licenseChanges ?? []) {
|
|
if (change.op === "add") {
|
|
await this.filesService.setCode(change.fileId, LICENSE_CODE);
|
|
} else {
|
|
await this.filesService.remove(change.fileId);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Discard a rejected request's staged license uploads (adds only). */
|
|
private async discardLicenseChanges(
|
|
request: CompanyChangeRequest,
|
|
): Promise<void> {
|
|
for (const change of request.documents?.licenseChanges ?? []) {
|
|
if (change.op === "add") {
|
|
await this.filesService.remove(change.fileId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 paper is flagged for removal, so the reviewer
|
|
// sees both and approval swaps them atomically. During onboarding it goes live.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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(
|
|
company: Company,
|
|
attributes: Record<string, unknown> | null | undefined,
|
|
opts: { ignoreFileIds?: string[] } = {},
|
|
): Promise<void> {
|
|
// The declaration is the whole gate. A company that says it has no
|
|
// representative owes nothing here; one that says it has owes the details
|
|
// AND the paper, with no exceptions — including a freight forwarder, for
|
|
// whom `readPoaDeclaration` forces "yes" regardless of what is stored.
|
|
const declared = readPoaDeclaration({
|
|
attributes: attributes as Company["attributes"],
|
|
companyProfiles: company.companyProfiles,
|
|
});
|
|
if (declared !== "yes") return;
|
|
|
|
const isForwarder = (company.companyProfiles ?? []).some(
|
|
(p) => p.type === ProfileType.freightForwarder,
|
|
);
|
|
const read = (key: string) =>
|
|
(attributes?.[key] as string | undefined)?.trim();
|
|
|
|
const missing = REQUIRED_POA_FIELDS.filter((f) => !read(f.key));
|
|
if (missing.length > 0) {
|
|
throw new BadRequestException(
|
|
(isForwarder
|
|
? "A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. "
|
|
: "You told us someone holds power of attorney for this company. ") +
|
|
`Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`,
|
|
);
|
|
}
|
|
|
|
const { onFile, flagged } = await this.getPoaDelegationState(
|
|
company.id,
|
|
opts.ignoreFileIds,
|
|
);
|
|
if (!onFile) {
|
|
throw new BadRequestException(
|
|
`Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` +
|
|
(isForwarder ? " — 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.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Identity verification (one per company)
|
|
//
|
|
// A company proves itself through exactly ONE person. Which one is its own
|
|
// declaration: the Power of Attorney when it names a representative,
|
|
// otherwise the owner — whoever the eTrade licence names as the business's
|
|
// manager. There is no general manager and no "same as owner" copy: an owner
|
|
// who represents their own company simply answers "no, nobody holds power of
|
|
// attorney", and verifies as the owner.
|
|
//
|
|
// A completed VeriFayda verification proves that person's name, phone, email
|
|
// and address (Fayda's userinfo carries no national ID number, so none is
|
|
// collected). Fayda is an Ethiopian national ID, so a foreign company may
|
|
// instead type a passport number for the same person — an alternative, not an
|
|
// addition.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* The company's identity state: both people, who currently carries the
|
|
* verification, whether it is proven, and whether the owner the company put
|
|
* forward matches the eTrade licence. `complete` answers the gate question
|
|
* directly so the portal, the onboarding requirements and the assertions
|
|
* above all read the same verdict — the derivation itself is shared with
|
|
* ProfileResponseDto and the backoffice company DTO.
|
|
*/
|
|
getCompanyIdentityState(company: Company): CompanyIdentityStateDto {
|
|
return buildCompanyIdentityState(company);
|
|
}
|
|
|
|
/**
|
|
* Record whether anyone holds power of attorney for this company.
|
|
*
|
|
* This is the question that decides whose identity gets verified, so it is
|
|
* stored rather than inferred from "are any `poa*` keys set" — absence means
|
|
* "not asked yet", which is an outstanding onboarding item in its own right.
|
|
*
|
|
* Answering "no" tears the representative down: their details, their
|
|
* verification, their passport number and the DARS paper evidencing them all
|
|
* go. Leaving any of it behind would keep the company on the hook for a
|
|
* delegation it has just said does not exist.
|
|
*
|
|
* Refused for a freight forwarder — it signs on other companies' behalf, so a
|
|
* representative is non-negotiable. (`readPoaDeclaration` forces "yes" for
|
|
* them anyway; this is the honest error rather than a silently ignored write.)
|
|
*/
|
|
async setPoaDeclared(
|
|
userId: string,
|
|
declared: PoaDeclaration,
|
|
): Promise<CompanyIdentityStateDto> {
|
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
|
|
|
if (
|
|
declared === "no" &&
|
|
(company.companyProfiles ?? []).some(
|
|
(p) => p.type === ProfileType.freightForwarder,
|
|
)
|
|
) {
|
|
throw new BadRequestException(
|
|
"A freight forwarder acts on other companies' behalf, so it must have a Power of Attorney. Remove the freight forwarder role first.",
|
|
);
|
|
}
|
|
|
|
const attributes: Record<string, unknown> = {
|
|
...(company.attributes ?? {}),
|
|
[POA_DECLARED_KEY]: declared,
|
|
};
|
|
|
|
if (declared === "no") {
|
|
for (const key of [
|
|
...POA_ATTRIBUTES,
|
|
"poaFaydaSub",
|
|
"poaFaydaVerifiedAt",
|
|
"poaBirthdate",
|
|
"poaGender",
|
|
"poaPassportNumber",
|
|
]) {
|
|
attributes[key] = null;
|
|
}
|
|
await this.deletePoaDelegationFiles(company.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);
|
|
}
|
|
|
|
/** Drop every DARS paper on file — the delegation it evidenced is gone. */
|
|
private async deletePoaDelegationFiles(companyId: string): Promise<void> {
|
|
const records = await this.filesService.findByResource(
|
|
companyId,
|
|
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(companyId, r.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Complete a Fayda verification and bind the identity to the company.
|
|
*
|
|
* 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.
|
|
*
|
|
* The subject has to be the one the company's declaration calls for. A
|
|
* verification bound to the other person would sit on the record looking
|
|
* proven while the gate — which reads only the declared subject — stayed
|
|
* unsatisfied, and nothing in the portal would explain why.
|
|
*/
|
|
async completeIdentityVerification(
|
|
userId: string,
|
|
dto: CompleteIdentityVerificationDto,
|
|
): Promise<CompanyIdentityStateDto> {
|
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
|
const state = buildCompanyIdentityState(company);
|
|
const prefix = IDENTITY_PREFIX[dto.subject];
|
|
|
|
if (state.subject === null) {
|
|
throw new BadRequestException(
|
|
"Tell us whether anyone holds power of attorney for this company first — the answer decides whose identity we verify.",
|
|
);
|
|
}
|
|
if (state.subject !== dto.subject) {
|
|
throw new BadRequestException(
|
|
state.subject === "poa"
|
|
? "This company is represented by a Power of Attorney, so it is their identity we need — not the owner's."
|
|
: "This company has no Power of Attorney, so it is the owner's identity we need.",
|
|
);
|
|
}
|
|
|
|
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.",
|
|
);
|
|
}
|
|
|
|
// Only what Fayda actually returned is written. Its email and phone claims
|
|
// are optional and routinely come back empty — the portal renders an input
|
|
// for whatever is missing and the customer fills it in.
|
|
//
|
|
// There is deliberately NO fallback to the signed-in account. The person
|
|
// onboarding is not necessarily the person on the licence, so stamping
|
|
// their address onto the owner turned a required field into a guess that
|
|
// looked verified.
|
|
const identity: VerifiedIdentityAttributes = {
|
|
[`${prefix}FaydaSub`]: result.sub,
|
|
[`${prefix}FaydaVerifiedAt`]: new Date().toISOString(),
|
|
[`${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 } : {}),
|
|
// Fayda returns whatever the national registry holds, which is routinely a
|
|
// local number ("0911223344"). Every typed phone in this service is stored
|
|
// E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here
|
|
// becomes a value the portal reads back and cannot resubmit.
|
|
...(result.phoneNumber
|
|
? { [`${prefix}Phone`]: normalizeE164(result.phoneNumber) }
|
|
: {}),
|
|
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
|
};
|
|
|
|
// An approved company's identity is what its approval rested on, so
|
|
// re-verifying is staged for backoffice review rather than quietly
|
|
// rewriting a live record. Both subjects go through review now: whichever
|
|
// one the declaration points at IS the company's proof, and the owner is
|
|
// additionally the person the reviewer checks against the eTrade licence.
|
|
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);
|
|
}
|
|
|
|
/** 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 left untouched — see the comment in updateProfile's merge branch.
|
|
status: ChangeRequestStatus.Pending,
|
|
});
|
|
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 company as it will be once `type` is one of its roles.
|
|
*
|
|
* Taking on the freight-forwarder role is checked BEFORE the profile row
|
|
* exists, and both assertions below read `companyProfiles` — a forwarder is
|
|
* what forces the PoA declaration to "yes". Judging the company as it stands
|
|
* would let one that answered "no" pick up the role and skip the very
|
|
* requirement the role exists to impose. Read-only; never persisted.
|
|
*/
|
|
private withProfileType(company: Company, type: ProfileType): Company {
|
|
const profiles = company.companyProfiles ?? [];
|
|
if (profiles.some((p) => p.type === type)) return company;
|
|
return {
|
|
...company,
|
|
companyProfiles: [...profiles, { type } as CompanyProfile],
|
|
} as Company;
|
|
}
|
|
|
|
/**
|
|
* The gate: the company's ONE identity must be proven.
|
|
*
|
|
* Which person that is comes from the company's own declaration — the
|
|
* representative when it names one, otherwise the owner (whoever the eTrade
|
|
* licence names as manager). How they prove it depends on nationality: Fayda
|
|
* for an Ethiopian company, Fayda *or* a typed passport number for a foreign
|
|
* one, whose people may hold no Fayda ID at all.
|
|
*
|
|
* 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): void {
|
|
const state = buildCompanyIdentityState(company);
|
|
|
|
if (state.poaDeclared === null) {
|
|
throw new BadRequestException(
|
|
"Tell us whether anyone holds power of attorney for this company — the answer decides whose identity we verify.",
|
|
);
|
|
}
|
|
|
|
if (state.identityProven) return;
|
|
|
|
const who =
|
|
state.subject === "poa"
|
|
? "your Power of Attorney"
|
|
: "the person named on your eTrade licence";
|
|
throw new BadRequestException(
|
|
state.passportAccepted
|
|
? `Verify ${who} with Fayda, or add their passport number.`
|
|
: `Verify ${who} with Fayda before continuing.`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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[]> {
|
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
|
return this.getPoaDelegationView(company.id);
|
|
}
|
|
|
|
/**
|
|
* Upload the PoA delegation letter, replacing whatever is already on file.
|
|
* On an Active company this stages an `add` for the new file plus a `remove`
|
|
* for each live one; a letter still awaiting approval is withdrawn outright
|
|
* rather than stacking a second pending upload.
|
|
*/
|
|
async uploadPoaDelegationLetter(
|
|
userId: string,
|
|
file: Express.Multer.File,
|
|
): Promise<CompanyDocumentFileView[]> {
|
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
|
const gated = company.status === CompanyStatus.Active;
|
|
|
|
const records = await this.filesService.findByResource(
|
|
company.id,
|
|
COMPANY_RESOURCE,
|
|
);
|
|
const live = records.filter((r) => r.code === POA_DELEGATION_FILE_KEY);
|
|
const staged = records.filter(
|
|
(r) => r.code === POA_DELEGATION_PENDING_CODE,
|
|
);
|
|
|
|
// Supersede an unreviewed upload instead of queueing another one.
|
|
for (const r of staged) {
|
|
await this.filesService.remove(r.id);
|
|
await this.withdrawDocumentIntent(company.id, r.id);
|
|
}
|
|
|
|
const created = await this.filesService.upload({
|
|
resourceId: company.id,
|
|
resource: COMPANY_RESOURCE,
|
|
code: gated ? POA_DELEGATION_PENDING_CODE : POA_DELEGATION_FILE_KEY,
|
|
file,
|
|
});
|
|
|
|
if (gated) {
|
|
await this.stageDocumentIntent(
|
|
company.id,
|
|
[
|
|
...live.map((r) => ({
|
|
op: "remove" as const,
|
|
fileId: r.id,
|
|
code: POA_DELEGATION_FILE_KEY,
|
|
fileName: r.name,
|
|
})),
|
|
{
|
|
op: "add" as const,
|
|
fileId: created.id,
|
|
code: POA_DELEGATION_FILE_KEY,
|
|
fileName: created.name,
|
|
},
|
|
],
|
|
userId,
|
|
);
|
|
} else {
|
|
// Onboarding: no review, so the old letter is simply replaced.
|
|
for (const r of live) await this.filesService.remove(r.id);
|
|
}
|
|
|
|
await this.resolveDocumentChangeRequests(
|
|
company.id,
|
|
COMPANY_RESOURCE,
|
|
[POA_DELEGATION_FILE_KEY, POA_DELEGATION_PENDING_CODE],
|
|
[created.id],
|
|
);
|
|
|
|
return this.getPoaDelegationView(company.id);
|
|
}
|
|
|
|
/**
|
|
* Remove the PoA letter. A staged upload is withdrawn outright; a live file on
|
|
* an Active company is kept and flagged for deletion on approval; during
|
|
* onboarding it is deleted immediately.
|
|
*/
|
|
async removePoaDelegationLetter(
|
|
userId: string,
|
|
fileId: string,
|
|
): Promise<CompanyDocumentFileView[]> {
|
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
|
const record = await this.filesService.findById(fileId);
|
|
if (
|
|
record.resource !== COMPANY_RESOURCE ||
|
|
record.resourceId !== company.id ||
|
|
(record.code !== POA_DELEGATION_FILE_KEY &&
|
|
record.code !== POA_DELEGATION_PENDING_CODE)
|
|
) {
|
|
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,
|
|
await this.effectivePoaAttributes(company),
|
|
{ ignoreFileIds: [fileId] },
|
|
);
|
|
|
|
if (record.code === POA_DELEGATION_PENDING_CODE) {
|
|
await this.filesService.remove(fileId);
|
|
await this.withdrawDocumentIntent(company.id, fileId);
|
|
} else if (company.status === CompanyStatus.Active) {
|
|
await this.stageDocumentIntent(
|
|
company.id,
|
|
[
|
|
{
|
|
op: "remove",
|
|
fileId,
|
|
code: POA_DELEGATION_FILE_KEY,
|
|
fileName: record.name,
|
|
},
|
|
],
|
|
userId,
|
|
);
|
|
} else {
|
|
await this.filesService.remove(fileId);
|
|
}
|
|
|
|
return this.getPoaDelegationView(company.id);
|
|
}
|
|
|
|
private async getPoaDelegationView(
|
|
companyId: string,
|
|
): Promise<CompanyDocumentFileView[]> {
|
|
const pending =
|
|
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
|
const removeIds = new Set(
|
|
(pending?.documents?.documentChanges ?? [])
|
|
.filter((c) => c.op === "remove")
|
|
.map((c) => c.fileId),
|
|
);
|
|
const records = await this.filesService.findByResource(
|
|
companyId,
|
|
COMPANY_RESOURCE,
|
|
);
|
|
return records
|
|
.filter(
|
|
(r) =>
|
|
r.code === POA_DELEGATION_FILE_KEY ||
|
|
r.code === POA_DELEGATION_PENDING_CODE,
|
|
)
|
|
.map((r) => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
size: r.size,
|
|
mimeType: r.mimeType,
|
|
status:
|
|
r.code === POA_DELEGATION_PENDING_CODE
|
|
? ("pending_add" as const)
|
|
: removeIds.has(r.id)
|
|
? ("pending_remove" as const)
|
|
: ("live" as const),
|
|
reviewStatus: r.reviewStatus,
|
|
reviewNote: r.reviewNote,
|
|
}));
|
|
}
|
|
|
|
/** Open or append a pending change request recording document add/remove intents. */
|
|
private async stageDocumentIntent(
|
|
companyId: string,
|
|
changes: DocumentChangeIntent[],
|
|
submittedBy?: string,
|
|
): Promise<void> {
|
|
if (changes.length === 0) return;
|
|
const now = new Date();
|
|
const existing =
|
|
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
|
if (existing) {
|
|
const prev = existing.documents?.documentChanges ?? [];
|
|
// Re-uploading twice before review would otherwise stage a second `remove`
|
|
// for the same live file, and the duplicate would fail on approval.
|
|
const seen = new Set(prev.map((c) => `${c.op}:${c.fileId}`));
|
|
const fresh = changes.filter((c) => !seen.has(`${c.op}:${c.fileId}`));
|
|
if (fresh.length === 0) return;
|
|
await this.changeRequestRepo.update(existing.id, {
|
|
documents: {
|
|
...existing.documents,
|
|
documentChanges: [...prev, ...fresh],
|
|
},
|
|
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
|
submittedAt: now,
|
|
// Note left untouched — see the comment in updateProfile's merge branch.
|
|
status: ChangeRequestStatus.Pending,
|
|
});
|
|
} else {
|
|
await this.changeRequestRepo.create({
|
|
companyId,
|
|
snapshot: {},
|
|
documents: { documentChanges: changes },
|
|
status: ChangeRequestStatus.Pending,
|
|
submittedBy: submittedBy ?? null,
|
|
submittedAt: now,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drop a staged document intent referencing `fileId`. If that empties the
|
|
* request entirely, delete it so the customer's settings page unlocks.
|
|
*/
|
|
private async withdrawDocumentIntent(
|
|
companyId: string,
|
|
fileId: string,
|
|
): Promise<void> {
|
|
const existing =
|
|
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
|
if (!existing) return;
|
|
const remaining = (existing.documents?.documentChanges ?? []).filter(
|
|
(c) => c.fileId !== fileId,
|
|
);
|
|
const docs = existing.documents ?? {};
|
|
const stillHasWork =
|
|
remaining.length > 0 ||
|
|
(docs.licenseChanges?.length ?? 0) > 0 ||
|
|
(docs.documentFileIds?.length ?? 0) > 0 ||
|
|
Object.keys(existing.snapshot ?? {}).length > 0;
|
|
|
|
if (stillHasWork) {
|
|
await this.changeRequestRepo.update(existing.id, {
|
|
documents: { ...docs, documentChanges: remaining },
|
|
});
|
|
} else {
|
|
await this.changeRequestRepo.softDelete(existing.id);
|
|
}
|
|
}
|
|
|
|
/** Apply a request's staged document changes: promote adds, delete removes. */
|
|
private async applyDocumentChanges(
|
|
request: CompanyChangeRequest,
|
|
): Promise<void> {
|
|
for (const change of request.documents?.documentChanges ?? []) {
|
|
if (change.op === "add") {
|
|
await this.filesService.setCode(change.fileId, change.code);
|
|
} else {
|
|
await this.filesService.remove(change.fileId);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Discard a rejected request's staged document uploads (adds only). */
|
|
private async discardDocumentChanges(
|
|
request: CompanyChangeRequest,
|
|
): Promise<void> {
|
|
for (const change of request.documents?.documentChanges ?? []) {
|
|
if (change.op === "add") {
|
|
await this.filesService.remove(change.fileId);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve which company_profile a new booking belongs to, from the company
|
|
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
|
* exporter profile; for DOMESTIC (or when the natural profile doesn't exist,
|
|
* e.g. a freight forwarder) it falls back to the company's first profile.
|
|
* Callers that need a specific role (a forwarder) pass an explicit
|
|
* companyProfileId instead. Returns null when the company has no profiles.
|
|
*/
|
|
async resolveCompanyProfileIdForBooking(
|
|
companyId: string,
|
|
tradeDirection: string,
|
|
): Promise<string | null> {
|
|
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
|
if (profiles.length === 0) return null;
|
|
|
|
const naturalType =
|
|
tradeDirection === "IMPORT"
|
|
? ProfileType.importer
|
|
: tradeDirection === "EXPORT"
|
|
? ProfileType.exporter
|
|
: null;
|
|
|
|
const match =
|
|
(naturalType && profiles.find((p) => p.type === naturalType)) ??
|
|
profiles[0];
|
|
return match?.id ?? null;
|
|
}
|
|
|
|
/** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */
|
|
private async resolveEtradeRegistration(
|
|
tin: string,
|
|
licenceNumber?: string,
|
|
): Promise<CompanyRegistrationData> {
|
|
const { businessInfo, companyInfo } =
|
|
await this.etradeService.resolveCompanyData(tin, licenceNumber);
|
|
if (!businessInfo) {
|
|
throw new BadRequestException(
|
|
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
|
|
);
|
|
}
|
|
return this.etradeService.extractRegistrationData(
|
|
businessInfo,
|
|
companyInfo,
|
|
);
|
|
}
|
|
|
|
async fetchETradeData(
|
|
tin: string,
|
|
excludeCompanyId?: string,
|
|
licenceNumber?: string,
|
|
) {
|
|
const registrationData = await this.resolveEtradeRegistration(
|
|
tin,
|
|
licenceNumber,
|
|
);
|
|
const tinTaken = await this.companiesRepo.existsByTin(
|
|
tin,
|
|
excludeCompanyId,
|
|
);
|
|
return { ...registrationData, tinTaken };
|
|
}
|
|
|
|
/**
|
|
* An eTrade-sourced field can only ever hold what a fresh eTrade lookup for
|
|
* this TIN actually returns — the portal never lets the customer type these
|
|
* once eTrade has supplied them. Rather than trust the client's copy (stale
|
|
* cache, hand-crafted request, or just a formatting mismatch) and reject it,
|
|
* refetch eTrade ourselves and overwrite the touched fields with whatever it
|
|
* says now — the client's submitted values for these keys only matter as a
|
|
* "this field is part of the save" flag, never as data we persist.
|
|
*/
|
|
private async applyEtradeSourcedFields(
|
|
company: Company,
|
|
dto: UpdateProfileDto & { etradeManager?: { name: string; phone: string } },
|
|
): Promise<void> {
|
|
// A co-operative union or farm has a TIN but no business licence, so eTrade holds no
|
|
// record to check these against — the customer types the company name and
|
|
// the registered address themselves, and what they send IS the data. The
|
|
// check is skipped rather than failed: running the lookup would 400 every
|
|
// save with "no registration found for this TIN".
|
|
if (usesManualRegistration(company)) return;
|
|
|
|
const touched = ETRADE_SOURCED_FIELDS.some(
|
|
(key) => key !== "tin" && dto[key] !== undefined,
|
|
);
|
|
if (!touched) return;
|
|
|
|
const tin = dto.tin ?? company.tin;
|
|
// Re-verify the licence the customer actually chose. Without it a TIN
|
|
// holding several licences would silently snap back to eTrade's first one on
|
|
// every save, overwriting the selection with a different business's record.
|
|
const registration = await this.resolveEtradeRegistration(
|
|
tin,
|
|
dto.licenceNumber ?? company.licenceNumber ?? undefined,
|
|
);
|
|
const fresh: Partial<
|
|
Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>
|
|
> = {
|
|
companyName: registration.companyName,
|
|
licenceNumber: registration.licenceNumber,
|
|
statusDescription: registration.statusDescription,
|
|
dateRegistered: registration.dateRegistered,
|
|
renewedFrom: registration.renewedFrom,
|
|
renewalDate: registration.renewalDate,
|
|
renewedTo: registration.renewedTo,
|
|
region: registration.region,
|
|
zone: registration.zone,
|
|
woreda: registration.woreda,
|
|
kebele: registration.kebele,
|
|
houseNo: registration.houseNo,
|
|
etradePhone:
|
|
registration.managerPhone ||
|
|
registration.regularPhone ||
|
|
registration.mobilePhone,
|
|
};
|
|
|
|
for (const key of ETRADE_SOURCED_FIELDS) {
|
|
if (key === "tin" || dto[key] === undefined) continue;
|
|
const value = fresh[key];
|
|
// eTrade left this field blank — fall back to whatever the client sent
|
|
// (the onboarding/settings card lets the customer type it directly then).
|
|
if (value) (dto as Record<string, unknown>)[key] = value;
|
|
}
|
|
|
|
// Capture the licence's own manager alongside the registration it belongs
|
|
// to. NOT written onto `ownerName`/`ownerPhone`: those are what the company
|
|
// asserts (and what a Fayda verification owns), and overwriting them here
|
|
// would destroy the very difference the backoffice is asked to check. The
|
|
// portal prefills the owner from these, so they agree unless someone made
|
|
// them disagree — which is exactly the case worth surfacing.
|
|
if (registration.managerName || registration.managerPhone) {
|
|
dto.etradeManager = {
|
|
name: registration.managerName,
|
|
phone: registration.managerPhone,
|
|
};
|
|
}
|
|
}
|
|
}
|