mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
They hold a TIN but no business licence, so there is no eTrade record to look their registration up in. A checkbox on the first wizard step marks them, and everything that assumed a trade licence bends around it: - The company step replaces the eTrade lookup with typed registration details — name, region, zone, woreda, kebele, house number — required exactly because they are now on screen. applyEtradeSourcedFields skips the lookup rather than failing it, so what the customer sends is what is stored. - No freight-forwarder role. Forwarding is licensed work, so the option is not offered, and the API refuses it at start-onboarding and at every later role-add rather than letting approval fail on a document they cannot produce. - No per-role business-licence upload, client-side or in the completion gate. - Their own document set (company_onboarding_documents_cooperative) merges on top of the nationality one, admin-managed like every other set. Nationality wins a fileKey collision so no slot renders twice, and the DARS paper is not injected into it — the set it merges onto already carries one. - The owner is typed in full; with no eTrade manager on file the licence comparison reports "nothing to compare against", which backoffice now explains rather than leaving as a bare dash. Stored as an attributes flag, not a column: everything it changes is behavioural, and nothing queries or joins on it.
3512 lines
132 KiB
TypeScript
3512 lines
132 KiB
TypeScript
import {
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
ConflictException,
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
} from "@nestjs/common";
|
|
import { DataSource, EntityManager } from "typeorm";
|
|
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,
|
|
isCooperative,
|
|
} 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 nationality-based document setting code for a company. */
|
|
private documentSettingCodeFor(
|
|
nationality: CompanyNationality | null | undefined,
|
|
): string {
|
|
return 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,
|
|
): 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 ||
|
|
roles.includes(ProfileType.freightForwarder);
|
|
const current = needsCompany
|
|
? await this.companiesRepo.findById(companyId)
|
|
: null;
|
|
this.assertRolesAllowedForCooperative(
|
|
cooperative ?? isCooperative(current),
|
|
roles,
|
|
);
|
|
await this.syncCompanyProfiles(companyId, companyType, roles);
|
|
const updates: Partial<Company> = {};
|
|
if (nationality) updates.nationality = nationality;
|
|
if (cooperative !== undefined) {
|
|
updates.attributes = {
|
|
...(current?.attributes ?? {}),
|
|
[COOPERATIVE_KEY]: cooperative,
|
|
};
|
|
}
|
|
if (Object.keys(updates).length > 0) {
|
|
await this.companiesRepo.update(companyId, updates);
|
|
}
|
|
return this.getCompanyInfoByUserId(identity.userId);
|
|
}
|
|
|
|
this.assertRolesAllowedForCooperative(cooperative === true, roles);
|
|
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 ? { attributes: { [COOPERATIVE_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.",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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). */
|
|
async listChangeRequests(companyId: string): Promise<CompanyChangeRequest[]> {
|
|
await this.findCompanyById(companyId);
|
|
return this.changeRequestRepo.findByCompanyId(companyId);
|
|
}
|
|
|
|
/** Onboarding-phase edit history (see {@link recordCompanyRevision}), newest first. */
|
|
async listCompanyRevisions(companyId: string): Promise<CompanyRevision[]> {
|
|
await this.findCompanyById(companyId);
|
|
return this.revisionRepo.findByCompanyId(companyId);
|
|
}
|
|
|
|
/**
|
|
* 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.nationality))
|
|
.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);
|
|
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}`,
|
|
);
|
|
}
|
|
return (
|
|
(await this.changeRequestRepo.update(id, {
|
|
status: ChangeRequestStatus.ChangesRequested,
|
|
note,
|
|
reviewedBy: reviewerId ?? null,
|
|
reviewedAt: new Date(),
|
|
})) ?? request
|
|
);
|
|
}
|
|
|
|
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. Nationality-based company documents + which are already uploaded. A
|
|
// co-operative adds its own set on top: it provides everything its
|
|
// nationality demands, plus the papers standing in for the business licence
|
|
// it does not hold.
|
|
const cooperative = isCooperative(company);
|
|
const documentSettingCode = this.documentSettingCodeFor(
|
|
company.nationality,
|
|
);
|
|
const [setting, coopSetting, uploadedFiles] = await Promise.all([
|
|
this.fileUploadSettingsService
|
|
.getByCode(documentSettingCode)
|
|
.catch(() => null),
|
|
cooperative
|
|
? this.fileUploadSettingsService
|
|
.getByCode(COOPERATIVE_ONBOARDING_CODE)
|
|
.catch(() => null)
|
|
: Promise.resolve(null),
|
|
this.filesService.findByResource(company.id, "companies"),
|
|
]);
|
|
const uploadedCodes = new Set(uploadedFiles.map((f) => f.code));
|
|
// The co-op set is admin-managed and could name a fileKey the nationality
|
|
// set already carries; the nationality field wins so the same slot is never
|
|
// rendered (or required) twice.
|
|
const baseFields = setting?.fields ?? [];
|
|
const baseKeys = new Set(baseFields.map((f) => f.fileKey));
|
|
const documents = [
|
|
...baseFields,
|
|
...(coopSetting?.fields ?? []).filter((f) => !baseKeys.has(f.fileKey)),
|
|
]
|
|
.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,
|
|
cooperativeDocumentSettingCode: cooperative
|
|
? COOPERATIVE_ONBOARDING_CODE
|
|
: null,
|
|
nationality: company.nationality ?? CompanyNationality.Ethiopian,
|
|
cooperative,
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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 (isCooperative(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,
|
|
};
|
|
}
|
|
}
|
|
}
|