Files
edr-platform/apps/edr-freight-web/portal/src/services/companies.service.ts
Nathnael d1584ee708 feat(portal): offer the investment-licence path in the onboarding wizard
A foreign company can now say it operates on an investment licence, on the
same step as its nationality and roles. The box only appears for a foreign
company, and moving the nationality answer back to Ethiopian drops it — the
API refuses both pairings.

The company step's eTrade gate now reads `manualRegistration`
(co-operative OR investment licence): the TIN lookup still runs, but finding
nothing is an expected outcome rather than a blocker, and the registration
section is typed instead. What stays keyed to `cooperative` alone is the
per-role business licence — an investor holds one, a co-operative does not —
so the licence cards and their validation are unchanged for investors.

Also carries the client plumbing for the revert endpoint the settings card
uses next.
2026-08-18 08:45:29 +00:00

551 lines
17 KiB
TypeScript

import { client } from "@/utils/api";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { ApiResponse } from "@/types/apiResponse";
import type { CompanyIdentityState } from "./verifayda.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import { isAxiosError } from "axios";
export type ProfileTypeValue =
| "importer"
| "exporter"
| "freight_forwarder"
| "dj_freight_forwarder"
| "transporter";
export type CompanyNationality = "ethiopian" | "foreign";
/** Review state of a business-license file (mirrors the API's ProfileLicenseFileView). */
export type LicenseFileStatus = "live" | "pending_add" | "pending_remove";
export interface LicenseFile {
id: string;
name: string;
size: number;
mimeType: string;
/** `live` = approved; `pending_add`/`pending_remove` = awaiting backoffice review. */
status: LicenseFileStatus;
/**
* A reviewer's verdict on this specific document. `change_requested` means the
* customer must upload a corrected version before the role can be approved —
* orthogonal to `status`, which tracks the staged add/remove workflow.
*/
reviewStatus?: "change_requested" | "approved" | null;
/** The reviewer's reason, shown to the customer verbatim. */
reviewNote?: string | null;
}
export interface ExternalProfileResponse {
id: string;
userId: string;
companyId: string;
firstName: string;
lastName: string;
email: string;
phone: string | null;
nationalId: string | null;
jobTitle: string | null;
isPrimaryContact: boolean;
onboardingStep: string | null;
onboardingCompleted: boolean;
createdAt: string;
updatedAt: string;
}
export interface CompanyResponse {
id: string;
name: string;
type: string;
status: string;
nationality: CompanyNationality | null;
tin: string;
vatNumber: string | null;
businessLicense: string | null;
fanNumber: string | null;
country: string;
address: string | null;
phone: string | null;
email: string | null;
website: string | null;
attributes: Record<string, any> | null;
companyProfiles?: CompanyProfileResponse[];
createdAt: string;
updatedAt: string;
}
export interface CompanyProfileResponse {
id: string;
type: string;
reference: string;
status: string;
/** @deprecated Superseded by licenseFiles (file model). */
businessLicense: string | null;
/** Business-license documents uploaded for this profile. */
licenseFiles: LicenseFile[];
attributes: Record<string, any> | null;
/** Reviewer note when the role is rejected (drives the reapply prompt). */
reviewNote?: string | null;
createdAt: string;
updatedAt: string;
}
/**
* Which kind of account is signed in.
*
* Read this instead of inferring from a missing `company`: a failed or
* in-flight company fetch also leaves `company` empty, and treating that as
* "shipping line" would skip onboarding for customers whenever the request
* failed. Absent (older responses) means `customer`.
*/
export type AccountKind = "customer" | "shipping_line";
export interface CompanyInfoResponse {
accountKind?: AccountKind;
profile: ExternalProfileResponse;
company: CompanyResponse;
/**
* Open profile-edit review, if any. `pending` locks the settings page + new
* contract/booking creation; `rejected`/`changes_requested` both surface the
* note for reapply — `changes_requested` just means the edit appends to the
* same request instead of starting a fresh one.
*/
review?: {
status: "pending" | "rejected" | "changes_requested";
note: string | null;
} | null;
}
/**
* A signed-in shipping line. It has no company, no external profile and no
* onboarding — the carrier record itself is the account.
*/
export interface ShippingLineInfoResponse {
accountKind: "shipping_line";
id: string;
name: string;
email: string;
phoneNumber: string | null;
scacCode: string | null;
status: string;
company: null;
profile: null;
review: null;
}
/** `GET /companies/getInfo` serves both portal audiences. */
export type AccountInfoResponse = CompanyInfoResponse | ShippingLineInfoResponse;
export const isShippingLineAccount = (
info: AccountInfoResponse | null | undefined,
): info is ShippingLineInfoResponse => info?.accountKind === "shipping_line";
/** A staged profile-edit review request (portal view). */
export interface ChangeRequestResponse {
id: string;
companyId: string;
status: "pending" | "approved" | "rejected" | "changes_requested";
snapshot: Record<string, any>;
documentFileIds: string[];
note: string | null;
submittedAt: string | null;
reviewedAt: string | null;
createdAt: string;
updatedAt: string;
}
/** A single company-level document uploaded against a `file_upload_settings` field. */
export interface CompanyDocument {
id: string;
name: string;
/** The `fileKey` of the setting field it was uploaded against. */
code: string;
mimeType: string;
size: number;
uploadedAt: string;
url: string;
/**
* `change_requested` means a reviewer has asked for a corrected version of
* this document; the role cannot be approved until it is re-uploaded.
*/
reviewStatus?: "change_requested" | "approved" | null;
/** The reviewer's reason, shown to the customer verbatim. */
reviewNote?: string | null;
}
/** A single onboarding document field, as resolved and described by the backend. */
export interface OnboardingDocumentField {
fileKey: string;
fileLabel: string;
helpText: string | null;
isRequired: boolean;
isMultiple: boolean;
maxFiles: number;
allowedExtensions: string[];
maxSizeMb: number;
displayOrder: number;
uploaded: boolean;
}
export interface OnboardingLicenseProfile {
profileId: string;
type: string;
reference: string;
uploaded: boolean;
}
/** Power of Attorney state, driven by the company's own declaration. */
export interface OnboardingPoaState {
/**
* True for a freight forwarder: it signs on other companies' behalf, so a
* representative is non-negotiable and the question is shown answered rather
* than asked.
*/
locked: boolean;
/** The company's answer. Null until it answers — itself an outstanding item. */
declared: "yes" | "no" | null;
/** True when the DARS delegation paper is owed — i.e. `declared === "yes"`. */
delegationLetterRequired: boolean;
delegationLetterUploaded: boolean;
/** True when a reviewer sent the DARS delegation paper back for correction. */
delegationLetterFlagged: boolean;
missingFields: { key: string; label: string }[];
complete: boolean;
}
/**
* Server-driven onboarding requirements. The portal renders this verbatim: the
* backend decides which documents apply (by nationality) and what is still
* outstanding, so the client never hardcodes required fields or document sets.
*/
export interface OnboardingRequirements {
/** The set the docs came from: the nationality one, or the co-operative one. */
documentSettingCode: string;
nationality: string;
/** No business licence: registration typed by hand, no eTrade lookup. */
cooperative: boolean;
/** Foreign investment licence: registration typed by hand, no eTrade record. */
investorLicence: boolean;
companyInfo: {
complete: boolean;
missingFields: { key: string; label: string }[];
};
documents: OnboardingDocumentField[];
licenseProfiles: OnboardingLicenseProfile[];
poa: OnboardingPoaState;
/** The company's single identity verification, and whose it is. */
identity: CompanyIdentityState;
progress: { completed: number; total: number };
isComplete: boolean;
onboardingCompleted: boolean;
outstanding: string[];
}
export interface CompanyProfileInput {
type:
| "importer"
| "exporter"
| "freight_forwarder"
| "dj_freight_forwarder"
| "transporter";
businessLicense?: string;
}
export interface CreateCompanyPayload {
companyType?: string;
nationality?: CompanyNationality;
companyName: string;
companyLocation?: string;
companyAddress?: string;
tin?: string;
vatNumber?: string;
fanNumber?: string;
jobTitle?: string;
isPrimaryContact?: boolean;
attributes?: Record<string, any>;
companyProfiles?: CompanyProfileInput[];
}
export interface FreightVolumePoint {
month: string;
tonnes: number;
}
export interface DashboardSummary {
deliveredCount: number;
completionRate: number;
spendYtd: number;
spendCurrency: string;
spendYtdChangePct: number;
freightVolume: {
totalTonnes: number;
totalValue: number;
currency: string;
ytdChangePct: number;
monthly: FreightVolumePoint[];
};
}
export const companiesService = {
getInfo: async (): Promise<AccountInfoResponse | null> => {
try {
const response = await client.get<ApiResponse<AccountInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.GET_INFO,
);
return unwrap(response.data);
} catch (e) {
if (isAxiosError(e) && e.response?.status === 404) {
return null;
}
throw e;
}
},
create: async (
payload: CreateCompanyPayload,
): Promise<CompanyInfoResponse> => {
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.CREATE,
payload,
);
return unwrap(response.data);
},
getProfile: async (): Promise<ProfileResponse> => {
const response = await client.get<ApiResponse<ProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.PROFILE,
);
return unwrap(response.data);
},
updateProfile: async (
payload: UpdateProfilePayload,
): Promise<ProfileResponse> => {
const response = await client.patch<ApiResponse<ProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.PROFILE,
payload,
);
return unwrap(response.data);
},
getDashboard: async (
companyProfileId?: string,
): Promise<DashboardSummary> => {
const response = await client.get<ApiResponse<DashboardSummary>>(
URL_CONSTANTS.COMPANIES_API.DASHBOARD,
{ params: companyProfileId ? { companyProfileId } : undefined },
);
return unwrap(response.data);
},
addCompanyProfiles: async (payload: {
types: string[];
}): Promise<CompanyProfileResponse[]> => {
const response = await client.post<ApiResponse<CompanyProfileResponse[]>>(
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILES,
payload,
);
return unwrap(response.data);
},
/** Create a single operational profile and make it the active mode. */
createCompanyProfile: async (payload: {
type: ProfileTypeValue;
businessLicense?: string;
}): Promise<CompanyProfileResponse> => {
const response = await client.post<ApiResponse<CompanyProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILE,
payload,
);
return unwrap(response.data);
},
/** Begin onboarding — create the draft company + profile + role(s) up front. */
startOnboarding: async (payload: {
companyType: string;
roles: ProfileTypeValue[];
nationality?: CompanyNationality;
cooperative?: boolean;
investorLicence?: boolean;
}): Promise<CompanyInfoResponse> => {
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.ONBOARDING_START,
payload,
);
return unwrap(response.data);
},
/**
* Give up the foreign investment-licence route and go back through eTrade.
* The API clears the typed registration and reopens onboarding at the company
* step, so the caller must refresh the company info afterwards.
*/
revertToRegularCompany: async (): Promise<CompanyInfoResponse> => {
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.ONBOARDING_REVERT_TO_ETRADE,
);
return unwrap(response.data);
},
setOnboardingStep: async (payload: { step: string }): Promise<void> => {
await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload);
},
completeOnboarding: async (): Promise<CompanyInfoResponse> => {
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
URL_CONSTANTS.COMPANIES_API.ONBOARDING_COMPLETE,
);
return unwrap(response.data);
},
/** Server-driven list of outstanding onboarding requirements + completeness. */
getOnboardingRequirements: async (): Promise<OnboardingRequirements> => {
const response = await client.get<ApiResponse<OnboardingRequirements>>(
URL_CONSTANTS.COMPANIES_API.ONBOARDING_REQUIREMENTS,
);
return unwrap(response.data);
},
uploadDocuments: async (
companyId: string,
files: Record<string, File | File[] | null>,
): Promise<void> => {
const formData = new FormData();
for (const [fieldName, fileOrFiles] of Object.entries(files)) {
if (!fileOrFiles) continue;
if (Array.isArray(fileOrFiles)) {
for (const f of fileOrFiles) {
formData.append(fieldName, f);
}
} else {
formData.append(fieldName, fileOrFiles);
}
}
await client.post(
URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId),
formData,
);
},
/** List documents already uploaded for a company (settings-driven, by fileKey). */
getDocuments: async (companyId: string): Promise<CompanyDocument[]> => {
const response = await client.get<ApiResponse<CompanyDocument[]>>(
URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId),
);
return unwrap(response.data);
},
/**
* Add business-license document(s) to a company profile. For an approved
* company the upload is staged for backoffice review; during onboarding it
* goes live immediately. Returns the profile's full license list with state.
*/
uploadProfileLicense: async (
profileId: string,
files: File[],
code = "business_license",
): Promise<LicenseFile[]> => {
const formData = new FormData();
for (const f of files) formData.append(code, f);
const response = await client.post<ApiResponse<LicenseFile[]>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId),
formData,
);
return unwrap(response.data);
},
/** Replace a license file with a newly uploaded one (staged for review). */
replaceProfileLicense: async (
profileId: string,
fileId: string,
file: File,
): Promise<LicenseFile[]> => {
const formData = new FormData();
formData.append("business_license", file);
const response = await client.post<ApiResponse<LicenseFile[]>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE_REPLACE(profileId, fileId),
formData,
);
return unwrap(response.data);
},
/** Remove a license file (staged for review on an approved company). */
removeProfileLicense: async (
profileId: string,
fileId: string,
): Promise<LicenseFile[]> => {
const response = await client.delete<ApiResponse<LicenseFile[]>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE_FILE(profileId, fileId),
);
return unwrap(response.data);
},
/** The PoA delegation letter on file, with its review state. */
getPoaDelegation: async (): Promise<LicenseFile[]> => {
const response = await client.get<ApiResponse<LicenseFile[]>>(
URL_CONSTANTS.COMPANIES_API.POA_DELEGATION,
);
return unwrap(response.data);
},
/**
* Upload the PoA delegation letter, replacing any existing one. On an approved
* company the upload is staged for backoffice review; during onboarding it
* goes live immediately.
*/
uploadPoaDelegation: async (file: File): Promise<LicenseFile[]> => {
const formData = new FormData();
formData.append("poa_delegation_letter", file);
const response = await client.post<ApiResponse<LicenseFile[]>>(
URL_CONSTANTS.COMPANIES_API.POA_DELEGATION,
formData,
);
return unwrap(response.data);
},
/** Remove the PoA delegation letter (staged for review on an approved company). */
removePoaDelegation: async (fileId: string): Promise<LicenseFile[]> => {
const response = await client.delete<ApiResponse<LicenseFile[]>>(
URL_CONSTANTS.COMPANIES_API.POA_DELEGATION_FILE(fileId),
);
return unwrap(response.data);
},
/** List business-license document(s) (with review state) for a company profile. */
getProfileLicense: async (profileId: string): Promise<LicenseFile[]> => {
const response = await client.get<ApiResponse<LicenseFile[]>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId),
);
return unwrap(response.data);
},
/** The current company's open profile change request (pending/rejected), or null. */
getChangeRequest: async (): Promise<ChangeRequestResponse | null> => {
const response = await client.get<
ApiResponse<ChangeRequestResponse | null>
>(URL_CONSTANTS.COMPANIES_API.PROFILE_CHANGE_REQUEST);
return unwrap(response.data);
},
/** Resubmit a rejected operational role for approval (→ pending). */
reapplyProfile: async (
profileId: string,
): Promise<CompanyProfileResponse> => {
const response = await client.post<ApiResponse<CompanyProfileResponse>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_REAPPLY(profileId),
);
return unwrap(response.data);
},
/** Fetch company registration data from eTrade by TIN. */
fetchETradeInfo: async (payload: {
tin: string;
licenceNumber?: string;
}): Promise<any> => {
const response = await client.post<ApiResponse<any>>(
URL_CONSTANTS.COMPANIES_API.FETCH_ETRADE_INFO,
payload,
);
return unwrap(response.data);
},
};