mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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.
551 lines
17 KiB
TypeScript
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);
|
|
},
|
|
};
|