mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
@@ -112,7 +112,7 @@ import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-r
|
|||||||
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
||||||
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||||
import { AiModule } from "./modules/ai/ai.module";
|
import { AiModule } from "./modules/ai/ai.module";
|
||||||
import { LoggerMiddleware } from "./logger.middleware";
|
import { RequestLogMiddleware } from "@edr/api-common";
|
||||||
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
||||||
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
|
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
|
||||||
|
|
||||||
@@ -376,7 +376,9 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
consumer.apply(LoggerMiddleware).forRoutes("*");
|
// FIRST: opens the request log context every later middleware/guard/service
|
||||||
|
// writes into via logCtx(). Anything applied above it logs into the void.
|
||||||
|
consumer.apply(RequestLogMiddleware).forRoutes("*");
|
||||||
consumer
|
consumer
|
||||||
.apply(LoginAudienceMiddleware)
|
.apply(LoginAudienceMiddleware)
|
||||||
.forRoutes(
|
.forRoutes(
|
||||||
|
|||||||
102
apps/edr-freight-api/src/common/request-log-context.spec.ts
Normal file
102
apps/edr-freight-api/src/common/request-log-context.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
RequestLogMiddleware,
|
||||||
|
getLogContext,
|
||||||
|
logCtx,
|
||||||
|
runWithLogContext,
|
||||||
|
} from "@edr/api-common";
|
||||||
|
|
||||||
|
describe("logCtx", () => {
|
||||||
|
it("is a no-op outside a request", () => {
|
||||||
|
expect(() => logCtx({ bookingId: "b1" })).not.toThrow();
|
||||||
|
expect(getLogContext()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects data points across the request and isolates concurrent ones", async () => {
|
||||||
|
const collect = async (id: string) =>
|
||||||
|
runWithLogContext({ requestId: id }, async () => {
|
||||||
|
logCtx({ bookingId: id });
|
||||||
|
await Promise.resolve();
|
||||||
|
logCtx({ from: "DRAFT", to: "SUBMITTED" }, { path: "booking.status" });
|
||||||
|
logCtx({ wagonId: "w1" }, { path: "wagons", mode: "push" });
|
||||||
|
logCtx({ wagonId: "w2" }, { path: "wagons", mode: "push" });
|
||||||
|
logCtx(1, { path: "smsSent", mode: "count" });
|
||||||
|
logCtx(1, { path: "smsSent", mode: "count" });
|
||||||
|
logCtx("PAID", { path: "payment.state", mode: "set" });
|
||||||
|
logCtx({ ignored: true }, (ctx) => {
|
||||||
|
ctx.custom = "yes";
|
||||||
|
});
|
||||||
|
return getLogContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
const [a, b] = await Promise.all([collect("r1"), collect("r2")]);
|
||||||
|
|
||||||
|
expect(a).toEqual({
|
||||||
|
requestId: "r1",
|
||||||
|
bookingId: "r1",
|
||||||
|
booking: { status: { from: "DRAFT", to: "SUBMITTED" } },
|
||||||
|
wagons: [{ wagonId: "w1" }, { wagonId: "w2" }],
|
||||||
|
smsSent: 2,
|
||||||
|
payment: { state: "PAID" },
|
||||||
|
custom: "yes",
|
||||||
|
});
|
||||||
|
expect(b?.requestId).toBe("r2");
|
||||||
|
expect(b?.bookingId).toBe("r2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RequestLogMiddleware", () => {
|
||||||
|
it("emits one canonical JSON line carrying the collected context", () => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
jest
|
||||||
|
.spyOn(Logger.prototype, "warn")
|
||||||
|
.mockImplementation((m) => lines.push(String(m)));
|
||||||
|
jest.spyOn(Logger.prototype, "log").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const listeners: Record<string, () => void> = {};
|
||||||
|
const req = {
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/bookings/1/submit",
|
||||||
|
originalUrl: "/api/bookings/1/submit?dry=1",
|
||||||
|
baseUrl: "/api/bookings",
|
||||||
|
route: { path: "/:id/submit" },
|
||||||
|
headers: { "user-agent": "jest", "x-request-id": "req-42" },
|
||||||
|
ip: "10.0.0.1",
|
||||||
|
query: { dry: "1" },
|
||||||
|
user: { id: "u-7" },
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
statusCode: 409,
|
||||||
|
writableEnded: true,
|
||||||
|
setHeader: jest.fn(),
|
||||||
|
on: (event: string, fn: () => void) => {
|
||||||
|
listeners[event] = fn;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
new RequestLogMiddleware().use(req, res, () => {
|
||||||
|
logCtx({ bookingId: "b-1" });
|
||||||
|
logCtx("REJECTED", { path: "booking.outcome", mode: "set" });
|
||||||
|
});
|
||||||
|
listeners.finish();
|
||||||
|
listeners.close(); // aborts/close after finish must not double-log
|
||||||
|
|
||||||
|
expect(lines).toHaveLength(1);
|
||||||
|
expect(JSON.parse(lines[0])).toMatchObject({
|
||||||
|
type: "http_request",
|
||||||
|
requestId: "req-42",
|
||||||
|
method: "POST",
|
||||||
|
route: "/api/bookings/:id/submit",
|
||||||
|
url: "/api/bookings/1/submit?dry=1",
|
||||||
|
status: 409,
|
||||||
|
userId: "u-7",
|
||||||
|
ip: "10.0.0.1",
|
||||||
|
userAgent: "jest",
|
||||||
|
query: { dry: "1" },
|
||||||
|
bookingId: "b-1",
|
||||||
|
booking: { outcome: "REJECTED" },
|
||||||
|
});
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42");
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Injectable, NestMiddleware, Logger } from "@nestjs/common";
|
|
||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class LoggerMiddleware implements NestMiddleware {
|
|
||||||
private readonly logger = new Logger("HTTP");
|
|
||||||
|
|
||||||
use(req: Request, res: Response, next: NextFunction) {
|
|
||||||
const start = Date.now();
|
|
||||||
|
|
||||||
res.on("finish", () => {
|
|
||||||
const duration = Date.now() - start;
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -256,11 +256,15 @@ export class CompaniesService {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** The nationality-based document setting code for a company. */
|
/**
|
||||||
private documentSettingCodeFor(
|
* The document setting code for a company: one of three mutually exclusive
|
||||||
nationality: CompanyNationality | null | undefined,
|
* sets. A co-operative union or farm resolves to its own set regardless of
|
||||||
): string {
|
* nationality — it holds no business licence, so it owes a different list of
|
||||||
return nationality === CompanyNationality.Foreign
|
* papers rather than the nationality list plus extras.
|
||||||
|
*/
|
||||||
|
private documentSettingCodeFor(company: Company): string {
|
||||||
|
if (isCooperative(company)) return COOPERATIVE_ONBOARDING_CODE;
|
||||||
|
return company.nationality === CompanyNationality.Foreign
|
||||||
? "company_onboarding_documents_foreign"
|
? "company_onboarding_documents_foreign"
|
||||||
: "company_onboarding_documents_ethiopian";
|
: "company_onboarding_documents_ethiopian";
|
||||||
}
|
}
|
||||||
@@ -387,13 +391,16 @@ export class CompaniesService {
|
|||||||
const current = needsCompany
|
const current = needsCompany
|
||||||
? await this.companiesRepo.findById(companyId)
|
? await this.companiesRepo.findById(companyId)
|
||||||
: null;
|
: null;
|
||||||
this.assertRolesAllowedForCooperative(
|
const isCoop = cooperative ?? isCooperative(current);
|
||||||
cooperative ?? isCooperative(current),
|
this.assertRolesAllowedForCooperative(isCoop, roles);
|
||||||
roles,
|
this.assertNationalityAllowedForCooperative(isCoop, nationality);
|
||||||
);
|
|
||||||
await this.syncCompanyProfiles(companyId, companyType, roles);
|
await this.syncCompanyProfiles(companyId, companyType, roles);
|
||||||
const updates: Partial<Company> = {};
|
const updates: Partial<Company> = {};
|
||||||
if (nationality) updates.nationality = nationality;
|
if (nationality) updates.nationality = nationality;
|
||||||
|
// Ticking the box on a draft that was saved as foreign has to correct the
|
||||||
|
// stored nationality too, or the company keeps resolving to the foreign
|
||||||
|
// document set.
|
||||||
|
if (isCoop) updates.nationality = CompanyNationality.Ethiopian;
|
||||||
if (cooperative !== undefined) {
|
if (cooperative !== undefined) {
|
||||||
updates.attributes = {
|
updates.attributes = {
|
||||||
...(current?.attributes ?? {}),
|
...(current?.attributes ?? {}),
|
||||||
@@ -407,6 +414,7 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.assertRolesAllowedForCooperative(cooperative === true, roles);
|
this.assertRolesAllowedForCooperative(cooperative === true, roles);
|
||||||
|
this.assertNationalityAllowedForCooperative(cooperative === true, nationality);
|
||||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||||
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||||
|
|
||||||
@@ -458,6 +466,24 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A co-operative union or farm is registered in Ethiopia by the co-operative
|
||||||
|
* promotion agency, so it is always an Ethiopian company — "foreign" is not a
|
||||||
|
* combination that exists, and allowing it would resolve the company to a
|
||||||
|
* document set built around an investment licence it cannot hold.
|
||||||
|
*/
|
||||||
|
private assertNationalityAllowedForCooperative(
|
||||||
|
cooperative: boolean,
|
||||||
|
nationality: CompanyNationality | undefined,
|
||||||
|
): void {
|
||||||
|
if (!cooperative) return;
|
||||||
|
if (nationality === CompanyNationality.Foreign) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"A co-operative union or farm is registered in Ethiopia — it cannot onboard as a foreign company.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reconcile the company's operational profiles with the roles the user has
|
* Reconcile the company's operational profiles with the roles the user has
|
||||||
* selected: create the missing ones, drop the ones they deselected.
|
* selected: create the missing ones, drop the ones they deselected.
|
||||||
@@ -1252,7 +1278,7 @@ export class CompaniesService {
|
|||||||
uploaded: FileRecord[],
|
uploaded: FileRecord[],
|
||||||
): Promise<CompanyRevisionChange[]> {
|
): Promise<CompanyRevisionChange[]> {
|
||||||
const setting = await this.fileUploadSettingsService
|
const setting = await this.fileUploadSettingsService
|
||||||
.getByCode(this.documentSettingCodeFor(company.nationality))
|
.getByCode(this.documentSettingCodeFor(company))
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
const fields = setting?.fields ?? [];
|
const fields = setting?.fields ?? [];
|
||||||
const singleFileCodes = new Set(
|
const singleFileCodes = new Set(
|
||||||
@@ -2036,35 +2062,20 @@ export class CompaniesService {
|
|||||||
.filter((f) => !f.get(company))
|
.filter((f) => !f.get(company))
|
||||||
.map((f) => ({ key: f.key, label: f.label }));
|
.map((f) => ({ key: f.key, label: f.label }));
|
||||||
|
|
||||||
// 2. Nationality-based company documents + which are already uploaded. A
|
// 2. Company documents + which are already uploaded. One set applies: the
|
||||||
// co-operative adds its own set on top: it provides everything its
|
// company's nationality set, or the co-operative set in its place — a union
|
||||||
// nationality demands, plus the papers standing in for the business licence
|
// or farm holds no business licence, so it owes its own list rather than the
|
||||||
// it does not hold.
|
// nationality list plus extras.
|
||||||
const cooperative = isCooperative(company);
|
const cooperative = isCooperative(company);
|
||||||
const documentSettingCode = this.documentSettingCodeFor(
|
const documentSettingCode = this.documentSettingCodeFor(company);
|
||||||
company.nationality,
|
const [setting, uploadedFiles] = await Promise.all([
|
||||||
);
|
|
||||||
const [setting, coopSetting, uploadedFiles] = await Promise.all([
|
|
||||||
this.fileUploadSettingsService
|
this.fileUploadSettingsService
|
||||||
.getByCode(documentSettingCode)
|
.getByCode(documentSettingCode)
|
||||||
.catch(() => null),
|
.catch(() => null),
|
||||||
cooperative
|
|
||||||
? this.fileUploadSettingsService
|
|
||||||
.getByCode(COOPERATIVE_ONBOARDING_CODE)
|
|
||||||
.catch(() => null)
|
|
||||||
: Promise.resolve(null),
|
|
||||||
this.filesService.findByResource(company.id, "companies"),
|
this.filesService.findByResource(company.id, "companies"),
|
||||||
]);
|
]);
|
||||||
const uploadedCodes = new Set(uploadedFiles.map((f) => f.code));
|
const uploadedCodes = new Set(uploadedFiles.map((f) => f.code));
|
||||||
// The co-op set is admin-managed and could name a fileKey the nationality
|
const documents = (setting?.fields ?? [])
|
||||||
// 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()
|
.slice()
|
||||||
.sort((a, b) => a.displayOrder - b.displayOrder)
|
.sort((a, b) => a.displayOrder - b.displayOrder)
|
||||||
.map((f) => ({
|
.map((f) => ({
|
||||||
@@ -2203,9 +2214,6 @@ export class CompaniesService {
|
|||||||
|
|
||||||
return new OnboardingRequirementsResponseDto({
|
return new OnboardingRequirementsResponseDto({
|
||||||
documentSettingCode,
|
documentSettingCode,
|
||||||
cooperativeDocumentSettingCode: cooperative
|
|
||||||
? COOPERATIVE_ONBOARDING_CODE
|
|
||||||
: null,
|
|
||||||
nationality: company.nationality ?? CompanyNationality.Ethiopian,
|
nationality: company.nationality ?? CompanyNationality.Ethiopian,
|
||||||
cooperative,
|
cooperative,
|
||||||
companyInfo: {
|
companyInfo: {
|
||||||
|
|||||||
@@ -66,15 +66,11 @@ export interface OnboardingPoaState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class OnboardingRequirementsResponseDto {
|
export class OnboardingRequirementsResponseDto {
|
||||||
/** Resolved document setting code (by nationality) the docs were drawn from. */
|
|
||||||
documentSettingCode: string;
|
|
||||||
/**
|
/**
|
||||||
* The co-operative document set, merged on top of the nationality one — null
|
* Resolved document setting code the docs were drawn from: the company's
|
||||||
* for every other company. `documents` below already carries the merged
|
* nationality set, or the co-operative set in its place.
|
||||||
* result; this is only so the portal can fetch the same extra fields when it
|
|
||||||
* renders the pickers from the file-settings endpoint.
|
|
||||||
*/
|
*/
|
||||||
cooperativeDocumentSettingCode: string | null;
|
documentSettingCode: string;
|
||||||
nationality: string;
|
nationality: string;
|
||||||
/**
|
/**
|
||||||
* The company trades as a co-operative: no business licence, so no eTrade
|
* The company trades as a co-operative: no business licence, so no eTrade
|
||||||
@@ -118,7 +114,6 @@ export class OnboardingRequirementsResponseDto {
|
|||||||
|
|
||||||
constructor(init: Omit<OnboardingRequirementsResponseDto, never>) {
|
constructor(init: Omit<OnboardingRequirementsResponseDto, never>) {
|
||||||
this.documentSettingCode = init.documentSettingCode;
|
this.documentSettingCode = init.documentSettingCode;
|
||||||
this.cooperativeDocumentSettingCode = init.cooperativeDocumentSettingCode;
|
|
||||||
this.nationality = init.nationality;
|
this.nationality = init.nationality;
|
||||||
this.cooperative = init.cooperative;
|
this.cooperative = init.cooperative;
|
||||||
this.companyInfo = init.companyInfo;
|
this.companyInfo = init.companyInfo;
|
||||||
|
|||||||
@@ -63,6 +63,21 @@ describe('ETradeService business selection', () => {
|
|||||||
expect(fetched).toEqual(['MT/AA/14/670/11551235/2017']);
|
expect(fetched).toEqual(['MT/AA/14/670/11551235/2017']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('survives the null entries eTrade puts in SubGroups', () => {
|
||||||
|
const { service } = build();
|
||||||
|
const info = companyInfo();
|
||||||
|
(info.Businesses[0] as any).SubGroups = [
|
||||||
|
null,
|
||||||
|
{ Code: 66331, Description: 'Export trade in minerals' },
|
||||||
|
{ Code: 1, Description: null },
|
||||||
|
];
|
||||||
|
const data = service.extractRegistrationData(
|
||||||
|
{ LicenceNumber: 'x' } as ETradeBusinessInfo,
|
||||||
|
info,
|
||||||
|
);
|
||||||
|
expect(data.businesses?.[0].activity).toBe('Export trade in minerals');
|
||||||
|
});
|
||||||
|
|
||||||
it('lists every licence for the picker, code prefixes stripped', () => {
|
it('lists every licence for the picker, code prefixes stripped', () => {
|
||||||
const { service } = build();
|
const { service } = build();
|
||||||
const data = service.extractRegistrationData(
|
const data = service.extractRegistrationData(
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ export class ETradeService {
|
|||||||
tradeName: b.TradesName?.trim() || "",
|
tradeName: b.TradesName?.trim() || "",
|
||||||
activity: (b.SubGroups ?? [])
|
activity: (b.SubGroups ?? [])
|
||||||
// Some descriptions repeat the code inline ("(65611)Import trade …").
|
// Some descriptions repeat the code inline ("(65611)Import trade …").
|
||||||
.map((g) => g.Description?.replace(/^\(\d+\)\s*/, "").trim())
|
// eTrade also puts null entries in this array, so every hop is optional.
|
||||||
|
.map((g) => g?.Description?.replace(/^\(\d+\)\s*/, "").trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(", "),
|
.join(", "),
|
||||||
renewedTo: b.RenewedTo || "",
|
renewedTo: b.RenewedTo || "",
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
} from "./interfaces/file-upload-settings.repository.interface";
|
} from "./interfaces/file-upload-settings.repository.interface";
|
||||||
import {
|
import {
|
||||||
COMPANY_ONBOARDING_CODE_PREFIX,
|
COMPANY_ONBOARDING_CODE_PREFIX,
|
||||||
COOPERATIVE_ONBOARDING_CODE,
|
|
||||||
POA_DELEGATION_FILE_KEY,
|
POA_DELEGATION_FILE_KEY,
|
||||||
poaDelegationField,
|
poaDelegationField,
|
||||||
} from "./poa-delegation.constants";
|
} from "./poa-delegation.constants";
|
||||||
@@ -57,10 +56,6 @@ export class FileUploadSettingsService {
|
|||||||
*/
|
*/
|
||||||
private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting {
|
private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting {
|
||||||
if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting;
|
if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting;
|
||||||
// The co-operative set is merged ON TOP of a nationality set that already
|
|
||||||
// carries the paper; injecting it here too would hand the portal the same
|
|
||||||
// slot twice.
|
|
||||||
if (setting.code === COOPERATIVE_ONBOARDING_CODE) return setting;
|
|
||||||
const fields = setting.fields ?? [];
|
const fields = setting.fields ?? [];
|
||||||
if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting;
|
if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting;
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,11 @@ export const POA_DELEGATION_LABEL = "DARS Delegation Paper";
|
|||||||
export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_";
|
export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The co-operative onboarding set. Unlike the nationality sets it is ADDITIVE —
|
* The co-operative onboarding set — the third alternative to `_ethiopian` and
|
||||||
* merged on top of the company's `_ethiopian`/`_foreign` set rather than
|
* `_foreign`, not an addition to them: a union or farm resolves to this set
|
||||||
* replacing it — which is why the delegation paper is not injected into it: the
|
* INSTEAD of its nationality's, because it holds no business licence and so
|
||||||
* set it is merged onto already carries one.
|
* owes a different list of papers. The delegation paper is injected into it
|
||||||
|
* like any other company onboarding set.
|
||||||
*/
|
*/
|
||||||
export const COOPERATIVE_ONBOARDING_CODE = `${COMPANY_ONBOARDING_CODE_PREFIX}cooperative`;
|
export const COOPERATIVE_ONBOARDING_CODE = `${COMPANY_ONBOARDING_CODE_PREFIX}cooperative`;
|
||||||
|
|
||||||
|
|||||||
@@ -155,16 +155,27 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
|
|||||||
// ];
|
// ];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extra documents a co-operative union or farm provides, merged on top of its
|
* Documents required from a co-operative union or farm — the third alternative
|
||||||
* nationality set. It has a TIN but no business licence, so the papers that
|
* to the two nationality sets, not an addition to them. A co-op is always
|
||||||
* evidence the co-operative itself stand in for the trade licence every other
|
* registered in Ethiopia and has a TIN but no business licence, so its
|
||||||
* company uploads.
|
* registration certificate stands in for the commercial registration every
|
||||||
|
* other Ethiopian company uploads.
|
||||||
*
|
*
|
||||||
* Only the registration certificate is seeded, and the set is admin-managed
|
* Admin-managed like every other onboarding set: what these members must
|
||||||
* like every other onboarding set — what these members must actually produce
|
* actually produce is a backoffice decision, edited in the file-settings editor.
|
||||||
* is a backoffice decision, edited in the file-settings editor.
|
|
||||||
*/
|
*/
|
||||||
const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
|
const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||||
|
{
|
||||||
|
fileKey: "tin_certificate",
|
||||||
|
fileLabel: "TIN Certificate",
|
||||||
|
helpText: "Verified against the TIN registry during registration.",
|
||||||
|
isRequired: true,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: DOC_EXTENSIONS,
|
||||||
|
maxSizeMb: 50,
|
||||||
|
displayOrder: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
fileKey: "cooperative_registration_certificate",
|
fileKey: "cooperative_registration_certificate",
|
||||||
fileLabel: "Co-operative Union / Farm Registration Certificate",
|
fileLabel: "Co-operative Union / Farm Registration Certificate",
|
||||||
@@ -175,8 +186,20 @@ const COOPERATIVE_ONBOARDING_FIELDS: OnboardingField[] = [
|
|||||||
maxFiles: 1,
|
maxFiles: 1,
|
||||||
allowedExtensions: DOC_EXTENSIONS,
|
allowedExtensions: DOC_EXTENSIONS,
|
||||||
maxSizeMb: 50,
|
maxSizeMb: 50,
|
||||||
displayOrder: 1,
|
displayOrder: 2,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
fileKey: "national_id",
|
||||||
|
fileLabel: "National ID",
|
||||||
|
helpText: "Verified against the National ID API during registration.",
|
||||||
|
isRequired: true,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: DOC_EXTENSIONS,
|
||||||
|
maxSizeMb: 50,
|
||||||
|
displayOrder: 3,
|
||||||
|
},
|
||||||
|
poaDelegationDefault(4),
|
||||||
];
|
];
|
||||||
|
|
||||||
interface OnboardingDocumentSetting {
|
interface OnboardingDocumentSetting {
|
||||||
@@ -201,11 +224,11 @@ const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [
|
|||||||
entity: "customer",
|
entity: "customer",
|
||||||
fields: FOREIGN_ONBOARDING_FIELDS,
|
fields: FOREIGN_ONBOARDING_FIELDS,
|
||||||
},
|
},
|
||||||
// Additive, not a nationality of its own: a union or farm still uploads
|
// The third set: a union or farm resolves here INSTEAD of a nationality set
|
||||||
// everything its nationality set demands, and these on top.
|
// (it is always Ethiopian, and holds no business licence).
|
||||||
{
|
{
|
||||||
code: "company_onboarding_documents_cooperative",
|
code: "company_onboarding_documents_cooperative",
|
||||||
label: "Co-operative union / farm onboarding documents (additional)",
|
label: "Co-operative union / farm onboarding documents",
|
||||||
entity: "customer",
|
entity: "customer",
|
||||||
fields: COOPERATIVE_ONBOARDING_FIELDS,
|
fields: COOPERATIVE_ONBOARDING_FIELDS,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ export function mapEtradeBusinessLicenses(
|
|||||||
const tradeName = String(business.TradesName ?? "").trim();
|
const tradeName = String(business.TradesName ?? "").trim();
|
||||||
const tradeNameAmh = String(business.TradeNameAmh ?? "").trim();
|
const tradeNameAmh = String(business.TradeNameAmh ?? "").trim();
|
||||||
const activities = (business.SubGroups ?? [])
|
const activities = (business.SubGroups ?? [])
|
||||||
.map((group) => String(group.Description ?? "").trim())
|
// eTrade returns null entries in this array, not just a null array.
|
||||||
|
.map((group) => String(group?.Description ?? "").trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const displayTradeName =
|
const displayTradeName =
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import type {
|
|||||||
} from "@/services/companies.service";
|
} from "@/services/companies.service";
|
||||||
import { companiesService } from "@/services/companies.service";
|
import { companiesService } from "@/services/companies.service";
|
||||||
import type { UpdateProfilePayload } from "@/types/profile";
|
import type { UpdateProfilePayload } from "@/types/profile";
|
||||||
|
import { documentSettingCode } from "@/utils/documentSettingCode";
|
||||||
import { extractApiError } from "@/utils/result";
|
import { extractApiError } from "@/utils/result";
|
||||||
|
|
||||||
/** Form steps rendered by CompanyProfileForm. */
|
/** Form steps rendered by CompanyProfileForm. */
|
||||||
@@ -116,13 +117,6 @@ function companyTypeForRoles(_roles: string[]): string {
|
|||||||
return "customer";
|
return "customer";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Document upload setting code per company nationality. */
|
|
||||||
function documentSettingCode(nationality: CompanyNationality): string {
|
|
||||||
return nationality === "foreign"
|
|
||||||
? "company_onboarding_documents_foreign"
|
|
||||||
: "company_onboarding_documents_ethiopian";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* First-run onboarding wizard with a "draft-first" flow: picking the role(s)
|
* First-run onboarding wizard with a "draft-first" flow: picking the role(s)
|
||||||
* immediately creates a draft company + profile on the backend, so every
|
* immediately creates a draft company + profile on the backend, so every
|
||||||
@@ -168,11 +162,15 @@ export default function OnboardingWizardDialog({
|
|||||||
const [cooperative, setCooperative] = useState<boolean>(
|
const [cooperative, setCooperative] = useState<boolean>(
|
||||||
company?.company?.attributes?.cooperative === true,
|
company?.company?.attributes?.cooperative === true,
|
||||||
);
|
);
|
||||||
// Ticking the box drops a role the company can no longer hold, rather than
|
// Ticking the box drops the selections the company can no longer hold, rather
|
||||||
// letting Continue fail on a selection the API refuses.
|
// than letting Continue fail on ones the API refuses: a co-op cannot forward
|
||||||
|
// freight, and is registered in Ethiopia so it is never foreign.
|
||||||
const handleCooperativeChange = useCallback((checked: boolean) => {
|
const handleCooperativeChange = useCallback((checked: boolean) => {
|
||||||
setCooperative(checked);
|
setCooperative(checked);
|
||||||
if (checked) setRoles((prev) => prev.filter((r) => r !== "freight_forwarder"));
|
if (checked) {
|
||||||
|
setRoles((prev) => prev.filter((r) => r !== "freight_forwarder"));
|
||||||
|
setNationality((prev) => (prev === "foreign" ? "ethiopian" : prev));
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
const [documentFiles, setDocumentFiles] = useState<
|
const [documentFiles, setDocumentFiles] = useState<
|
||||||
Record<string, File | File[] | null>
|
Record<string, File | File[] | null>
|
||||||
@@ -419,7 +417,7 @@ export default function OnboardingWizardDialog({
|
|||||||
// after the draft — and thus the requirements — exist).
|
// after the draft — and thus the requirements — exist).
|
||||||
const resolvedDocumentSettingCode =
|
const resolvedDocumentSettingCode =
|
||||||
requirementsQuery.data?.documentSettingCode ??
|
requirementsQuery.data?.documentSettingCode ??
|
||||||
documentSettingCode(effectiveNationality);
|
documentSettingCode(effectiveNationality, cooperative);
|
||||||
|
|
||||||
// Server-confirmed document state, used both to badge already-uploaded fields
|
// Server-confirmed document state, used both to badge already-uploaded fields
|
||||||
// and to keep a refreshed resume from over-shooting the documents step.
|
// and to keep a refreshed resume from over-shooting the documents step.
|
||||||
@@ -480,8 +478,6 @@ export default function OnboardingWizardDialog({
|
|||||||
// startOnboarding has persisted it, and the form's whole company step
|
// startOnboarding has persisted it, and the form's whole company step
|
||||||
// branches on it.
|
// branches on it.
|
||||||
cooperative: requirementsQuery.data?.cooperative ?? cooperative,
|
cooperative: requirementsQuery.data?.cooperative ?? cooperative,
|
||||||
extraDocumentSettingCode:
|
|
||||||
requirementsQuery.data?.cooperativeDocumentSettingCode ?? null,
|
|
||||||
// A freight forwarder cannot answer the power-of-attorney question — the
|
// A freight forwarder cannot answer the power-of-attorney question — the
|
||||||
// API forces "yes" — so the step offers no way to change it.
|
// API forces "yes" — so the step offers no way to change it.
|
||||||
declarationLocked: requirementsQuery.data?.poa?.locked ?? false,
|
declarationLocked: requirementsQuery.data?.poa?.locked ?? false,
|
||||||
@@ -551,6 +547,10 @@ export default function OnboardingWizardDialog({
|
|||||||
value={nationality}
|
value={nationality}
|
||||||
onChange={setNationality}
|
onChange={setNationality}
|
||||||
embedded
|
embedded
|
||||||
|
// A co-op is registered in Ethiopia by the co-operative
|
||||||
|
// promotion agency — foreign is not on offer rather than
|
||||||
|
// refused later.
|
||||||
|
excludeForeign={cooperative}
|
||||||
/>
|
/>
|
||||||
{/* A co-operative union or farm registers on a TIN alone. It
|
{/* A co-operative union or farm registers on a TIN alone. It
|
||||||
changes what the next step asks for (typed registration, no
|
changes what the next step asks for (typed registration, no
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ export default function CompanyProfileForm({
|
|||||||
onIdentityChange,
|
onIdentityChange,
|
||||||
cooperative = false,
|
cooperative = false,
|
||||||
declarationLocked = false,
|
declarationLocked = false,
|
||||||
extraDocumentSettingCode,
|
|
||||||
}: {
|
}: {
|
||||||
documentSettingCode: string;
|
documentSettingCode: string;
|
||||||
documentFiles?: Record<string, File | File[] | null>;
|
documentFiles?: Record<string, File | File[] | null>;
|
||||||
@@ -115,8 +114,8 @@ export default function CompanyProfileForm({
|
|||||||
/**
|
/**
|
||||||
* The company trades as a co-operative: a TIN but no business licence, so the
|
* The company trades as a co-operative: a TIN but no business licence, so the
|
||||||
* eTrade lookup is replaced by typed registration details, the per-role
|
* eTrade lookup is replaced by typed registration details, the per-role
|
||||||
* licence upload is not owed, and its own document set applies on top of the
|
* licence upload is not owed, and its own document set applies instead of the
|
||||||
* nationality one.
|
* nationality one (resolved by the caller into `documentSettingCode`).
|
||||||
*/
|
*/
|
||||||
cooperative?: boolean;
|
cooperative?: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -124,8 +123,6 @@ export default function CompanyProfileForm({
|
|||||||
* answer is forced to "yes" and cannot be changed here.
|
* answer is forced to "yes" and cannot be changed here.
|
||||||
*/
|
*/
|
||||||
declarationLocked?: boolean;
|
declarationLocked?: boolean;
|
||||||
/** Additional document set merged in (the co-operative one), if any. */
|
|
||||||
extraDocumentSettingCode?: string | null;
|
|
||||||
}) {
|
}) {
|
||||||
// A Fayda claim carries the phone as the national registry holds it, which is
|
// A Fayda claim carries the phone as the national registry holds it, which is
|
||||||
// often a local number the form's E.164 validation (and the API's
|
// often a local number the form's E.164 validation (and the API's
|
||||||
@@ -206,36 +203,15 @@ export default function CompanyProfileForm({
|
|||||||
const documentFiles = controlledFiles ?? internalFiles;
|
const documentFiles = controlledFiles ?? internalFiles;
|
||||||
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
|
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
|
||||||
|
|
||||||
const { data: nationalitySetting, isLoading: loadingDocuments } = useQuery(
|
// One set applies: the company's nationality set, or the co-operative one in
|
||||||
|
// its place — the caller resolves which (the API resolves the same way when
|
||||||
|
// it decides what is outstanding).
|
||||||
|
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
|
||||||
api.fileUploadSettings.getByCode.queryOptions({
|
api.fileUploadSettings.getByCode.queryOptions({
|
||||||
input: { code: documentSettingCode },
|
input: { code: documentSettingCode },
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// A co-operative's own documents come as a second, additive set — it uploads
|
|
||||||
// everything its nationality demands, plus the papers standing in for the
|
|
||||||
// business licence it does not hold. The API merges the same two sets when it
|
|
||||||
// decides what is outstanding.
|
|
||||||
const { data: extraSetting } = useQuery(
|
|
||||||
api.fileUploadSettings.getByCode.queryOptions({
|
|
||||||
input: { code: extraDocumentSettingCode ?? "" },
|
|
||||||
enabled: Boolean(extraDocumentSettingCode),
|
|
||||||
refetchOnMount: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const uploadSetting = useMemo(() => {
|
|
||||||
if (!nationalitySetting) return nationalitySetting;
|
|
||||||
if (!extraSetting?.fields?.length) return nationalitySetting;
|
|
||||||
// Nationality wins a fileKey collision, so a slot is never rendered twice.
|
|
||||||
const seen = new Set(nationalitySetting.fields.map((f) => f.fileKey));
|
|
||||||
return {
|
|
||||||
...nationalitySetting,
|
|
||||||
fields: [
|
|
||||||
...nationalitySetting.fields,
|
|
||||||
...extraSetting.fields.filter((f) => !seen.has(f.fileKey)),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}, [nationalitySetting, extraSetting]);
|
|
||||||
|
|
||||||
// Which fields the current step renders an input for and therefore requires.
|
// Which fields the current step renders an input for and therefore requires.
|
||||||
// Filled in further down (it depends on values this form owns), and read at
|
// Filled in further down (it depends on values this form owns), and read at
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { type UseFormReturn } from "react-hook-form";
|
|||||||
|
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { documentSettingCode } from "@/utils/documentSettingCode";
|
||||||
import {
|
import {
|
||||||
operationToProfileType,
|
operationToProfileType,
|
||||||
type BookingDocuments,
|
type BookingDocuments,
|
||||||
@@ -20,13 +21,6 @@ type BookingForm = UseFormReturn<
|
|||||||
BookingFormValues
|
BookingFormValues
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/** Onboarding document setting code for the company's nationality. */
|
|
||||||
function documentSettingCode(nationality: string | null | undefined): string {
|
|
||||||
return nationality === "foreign"
|
|
||||||
? "company_onboarding_documents_foreign"
|
|
||||||
: "company_onboarding_documents_ethiopian";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSize(bytes?: number): string {
|
function formatSize(bytes?: number): string {
|
||||||
if (!bytes) return "";
|
if (!bytes) return "";
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
@@ -48,14 +42,17 @@ function formatSize(bytes?: number): string {
|
|||||||
export function StepDocuments({ form }: { form: BookingForm }) {
|
export function StepDocuments({ form }: { form: BookingForm }) {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
|
||||||
const nationality = auth.company?.company?.nationality as
|
const company = auth.company?.company;
|
||||||
| string
|
const nationality = company?.nationality as string | null | undefined;
|
||||||
| null
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
const docSettingQuery = useQuery(
|
const docSettingQuery = useQuery(
|
||||||
api.fileUploadSettings.getByCode.queryOptions({
|
api.fileUploadSettings.getByCode.queryOptions({
|
||||||
input: { code: documentSettingCode(nationality) },
|
input: {
|
||||||
|
code: documentSettingCode(
|
||||||
|
nationality,
|
||||||
|
company?.attributes?.cooperative === true,
|
||||||
|
),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,9 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
|
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { documentSettingCode } from "@/utils/documentSettingCode";
|
||||||
|
|
||||||
/** Onboarding document setting code for the company's nationality. */
|
export { documentSettingCode };
|
||||||
export function documentSettingCode(
|
|
||||||
nationality: string | null | undefined,
|
|
||||||
): string {
|
|
||||||
return nationality === "foreign"
|
|
||||||
? "company_onboarding_documents_foreign"
|
|
||||||
: "company_onboarding_documents_ethiopian";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the FileUploadSetting that describes the documents a booking requires
|
* Fetches the FileUploadSetting that describes the documents a booking requires
|
||||||
@@ -22,14 +16,17 @@ export function documentSettingCode(
|
|||||||
*/
|
*/
|
||||||
export function useBookingDocumentSetting() {
|
export function useBookingDocumentSetting() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const nationality = auth.company?.company?.nationality as
|
const company = auth.company?.company;
|
||||||
| string
|
const nationality = company?.nationality as string | null | undefined;
|
||||||
| null
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
return useQuery(
|
return useQuery(
|
||||||
api.fileUploadSettings.getByCode.queryOptions({
|
api.fileUploadSettings.getByCode.queryOptions({
|
||||||
input: { code: documentSettingCode(nationality) },
|
input: {
|
||||||
|
code: documentSettingCode(
|
||||||
|
nationality,
|
||||||
|
company?.attributes?.cooperative === true,
|
||||||
|
),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { Freight } from "@edr/types";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { documentSettingCode } from "@/utils/documentSettingCode";
|
||||||
import type { CompanyDocument } from "@/services/companies.service";
|
import type { CompanyDocument } from "@/services/companies.service";
|
||||||
import { downloadStoredFile } from "@/services/files.service";
|
import { downloadStoredFile } from "@/services/files.service";
|
||||||
import { labelForDocCode } from "@/pages/bookings/resubmit/resubmitDocs";
|
import { labelForDocCode } from "@/pages/bookings/resubmit/resubmitDocs";
|
||||||
@@ -15,13 +16,6 @@ import { BORDER, GREEN, INK } from "../contract-ui";
|
|||||||
type DocumentsValue = Record<string, File | File[] | null>;
|
type DocumentsValue = Record<string, File | File[] | null>;
|
||||||
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
|
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
|
||||||
|
|
||||||
/** Onboarding document setting code for the company's nationality. */
|
|
||||||
function documentSettingCode(nationality: string | null | undefined): string {
|
|
||||||
return nationality === "foreign"
|
|
||||||
? "company_onboarding_documents_foreign"
|
|
||||||
: "company_onboarding_documents_ethiopian";
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasFile(value: File | File[] | null | undefined): boolean {
|
function hasFile(value: File | File[] | null | undefined): boolean {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
return Array.isArray(value) ? value.length > 0 : true;
|
return Array.isArray(value) ? value.length > 0 : true;
|
||||||
@@ -95,13 +89,16 @@ export function ContractDocsEditor({
|
|||||||
}) {
|
}) {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
|
||||||
const nationality = auth.company?.company?.nationality as
|
const company = auth.company?.company;
|
||||||
| string
|
const nationality = company?.nationality as string | null | undefined;
|
||||||
| null
|
|
||||||
| undefined;
|
|
||||||
const settingQuery = useQuery(
|
const settingQuery = useQuery(
|
||||||
api.fileUploadSettings.getByCode.queryOptions({
|
api.fileUploadSettings.getByCode.queryOptions({
|
||||||
input: { code: documentSettingCode(nationality) },
|
input: {
|
||||||
|
code: documentSettingCode(
|
||||||
|
nationality,
|
||||||
|
company?.attributes?.cooperative === true,
|
||||||
|
),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { type UseFormReturn } from "react-hook-form";
|
|||||||
|
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { documentSettingCode } from "@/utils/documentSettingCode";
|
||||||
import {
|
import {
|
||||||
type ContractDocuments,
|
type ContractDocuments,
|
||||||
type ContractFormInputValues,
|
type ContractFormInputValues,
|
||||||
@@ -21,12 +22,6 @@ type ContractForm = UseFormReturn<
|
|||||||
ContractFormValues
|
ContractFormValues
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function documentSettingCode(nationality: string | null | undefined): string {
|
|
||||||
return nationality === "foreign"
|
|
||||||
? "company_onboarding_documents_foreign"
|
|
||||||
: "company_onboarding_documents_ethiopian";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSize(bytes?: number): string {
|
function formatSize(bytes?: number): string {
|
||||||
if (!bytes) return "";
|
if (!bytes) return "";
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
@@ -61,14 +56,17 @@ export function StepDocuments({
|
|||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const nationality = auth.company?.company?.nationality as
|
const company = auth.company?.company;
|
||||||
| string
|
const nationality = company?.nationality as string | null | undefined;
|
||||||
| null
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
const docSettingQuery = useQuery(
|
const docSettingQuery = useQuery(
|
||||||
api.fileUploadSettings.getByCode.queryOptions({
|
api.fileUploadSettings.getByCode.queryOptions({
|
||||||
input: { code: documentSettingCode(nationality) },
|
input: {
|
||||||
|
code: documentSettingCode(
|
||||||
|
nationality,
|
||||||
|
company?.attributes?.cooperative === true,
|
||||||
|
),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface NationalitySelectProps {
|
|||||||
onChange: (next: CompanyNationality) => void;
|
onChange: (next: CompanyNationality) => void;
|
||||||
/** Render only the option grid — the wizard supplies its own header/card. */
|
/** Render only the option grid — the wizard supplies its own header/card. */
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
|
/** Hide the foreign option (a co-operative union or farm is always Ethiopian). */
|
||||||
|
excludeForeign?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,9 +23,10 @@ export default function NationalitySelect({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
|
excludeForeign = false,
|
||||||
}: NationalitySelectProps) {
|
}: NationalitySelectProps) {
|
||||||
const grid = (
|
const grid = (
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: excludeForeign ? 1 : 2 }} spacing="md">
|
||||||
<RoleCard
|
<RoleCard
|
||||||
label="Ethiopian Company"
|
label="Ethiopian Company"
|
||||||
description="Registered in Ethiopia. You'll provide a TIN certificate, commercial registration and national ID."
|
description="Registered in Ethiopia. You'll provide a TIN certificate, commercial registration and national ID."
|
||||||
@@ -31,13 +34,15 @@ export default function NationalitySelect({
|
|||||||
selected={value === "ethiopian"}
|
selected={value === "ethiopian"}
|
||||||
onClick={() => onChange("ethiopian")}
|
onClick={() => onChange("ethiopian")}
|
||||||
/>
|
/>
|
||||||
<RoleCard
|
{!excludeForeign && (
|
||||||
label="Foreign Company"
|
<RoleCard
|
||||||
description="Registered abroad. You'll provide a passport and investment license."
|
label="Foreign Company"
|
||||||
icon={<Globe2 size={22} />}
|
description="Registered abroad. You'll provide a passport and investment license."
|
||||||
selected={value === "foreign"}
|
icon={<Globe2 size={22} />}
|
||||||
onClick={() => onChange("foreign")}
|
selected={value === "foreign"}
|
||||||
/>
|
onClick={() => onChange("foreign")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type LicenseFileStatus,
|
type LicenseFileStatus,
|
||||||
} from "@/services/companies.service";
|
} from "@/services/companies.service";
|
||||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
import { getMinFiles } from "@/types/fileUploadSettings";
|
||||||
|
import { documentSettingCode } from "@/utils/documentSettingCode";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
import {
|
import {
|
||||||
SmartFileInput,
|
SmartFileInput,
|
||||||
@@ -57,14 +58,8 @@ interface TabDocumentsProps {
|
|||||||
onContinue?: () => void;
|
onContinue?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function documentSettingCode(nationality: string | null | undefined): string {
|
|
||||||
return nationality === "foreign"
|
|
||||||
? "company_onboarding_documents_foreign"
|
|
||||||
: "company_onboarding_documents_ethiopian";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The DARS delegation paper ships in the same nationality document set, but it is
|
* The DARS delegation paper ships in the same document set, but it is
|
||||||
* edited on the Power of Attorney tab (where it is staged for review alongside
|
* edited on the Power of Attorney tab (where it is staged for review alongside
|
||||||
* the PoA details), so it is excluded from this tab's uploader.
|
* the PoA details), so it is excluded from this tab's uploader.
|
||||||
*/
|
*/
|
||||||
@@ -83,7 +78,9 @@ export default function TabDocuments({
|
|||||||
|
|
||||||
const docSettingQuery = useQuery(
|
const docSettingQuery = useQuery(
|
||||||
api.fileUploadSettings.getByCode.queryOptions({
|
api.fileUploadSettings.getByCode.queryOptions({
|
||||||
input: { code: documentSettingCode(profile.nationality) },
|
input: {
|
||||||
|
code: documentSettingCode(profile.nationality, profile.cooperative),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -183,14 +183,8 @@ export interface OnboardingPoaState {
|
|||||||
* outstanding, so the client never hardcodes required fields or document sets.
|
* outstanding, so the client never hardcodes required fields or document sets.
|
||||||
*/
|
*/
|
||||||
export interface OnboardingRequirements {
|
export interface OnboardingRequirements {
|
||||||
|
/** The set the docs came from: the nationality one, or the co-operative one. */
|
||||||
documentSettingCode: string;
|
documentSettingCode: string;
|
||||||
/**
|
|
||||||
* Extra document set merged on top of the nationality one for a co-operative,
|
|
||||||
* null otherwise. `documents` already carries the merged list; this is only
|
|
||||||
* so the pickers, which render from the file-settings endpoint, can fetch the
|
|
||||||
* same extra fields.
|
|
||||||
*/
|
|
||||||
cooperativeDocumentSettingCode: string | null;
|
|
||||||
nationality: string;
|
nationality: string;
|
||||||
/** No business licence: registration typed by hand, no eTrade lookup. */
|
/** No business licence: registration typed by hand, no eTrade lookup. */
|
||||||
cooperative: boolean;
|
cooperative: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { documentSettingCode } from "./documentSettingCode";
|
||||||
|
|
||||||
|
describe("documentSettingCode", () => {
|
||||||
|
it("resolves the nationality set", () => {
|
||||||
|
expect(documentSettingCode("ethiopian")).toBe(
|
||||||
|
"company_onboarding_documents_ethiopian",
|
||||||
|
);
|
||||||
|
expect(documentSettingCode("foreign")).toBe(
|
||||||
|
"company_onboarding_documents_foreign",
|
||||||
|
);
|
||||||
|
expect(documentSettingCode(null)).toBe(
|
||||||
|
"company_onboarding_documents_ethiopian",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the nationality set for a co-operative", () => {
|
||||||
|
expect(documentSettingCode("ethiopian", true)).toBe(
|
||||||
|
"company_onboarding_documents_cooperative",
|
||||||
|
);
|
||||||
|
// A co-op is never foreign, but a stale flag must not fall back to the
|
||||||
|
// foreign set — the co-op answer wins.
|
||||||
|
expect(documentSettingCode("foreign", true)).toBe(
|
||||||
|
"company_onboarding_documents_cooperative",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
18
apps/edr-freight-web/portal/src/utils/documentSettingCode.ts
Normal file
18
apps/edr-freight-web/portal/src/utils/documentSettingCode.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* The company document set a company resolves to — one of three, never a
|
||||||
|
* combination: a co-operative union or farm uploads its own papers INSTEAD of
|
||||||
|
* its nationality's (it holds no business licence), and is always Ethiopian.
|
||||||
|
*
|
||||||
|
* Mirrors `CompaniesService.documentSettingCodeFor` on the API; prefer the
|
||||||
|
* server-resolved `documentSettingCode` from onboarding requirements where one
|
||||||
|
* is available, and use this where only the company is at hand.
|
||||||
|
*/
|
||||||
|
export function documentSettingCode(
|
||||||
|
nationality: string | null | undefined,
|
||||||
|
cooperative?: boolean | null,
|
||||||
|
): string {
|
||||||
|
if (cooperative) return "company_onboarding_documents_cooperative";
|
||||||
|
return nationality === "foreign"
|
||||||
|
? "company_onboarding_documents_foreign"
|
||||||
|
: "company_onboarding_documents_ethiopian";
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
|
||||||
|
import { logCtx } from "../logging/request-context";
|
||||||
|
|
||||||
interface ErrorResponseBody {
|
interface ErrorResponseBody {
|
||||||
success: false;
|
success: false;
|
||||||
statusCode: number;
|
statusCode: number;
|
||||||
@@ -120,6 +122,18 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
path: request.url,
|
path: request.url,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Land the failure on the request's canonical log line too — the middleware
|
||||||
|
// only sees a status code, not what threw. No-op outside a request.
|
||||||
|
logCtx(
|
||||||
|
{
|
||||||
|
name: exception instanceof Error ? exception.name : "Error",
|
||||||
|
message,
|
||||||
|
status,
|
||||||
|
stack: status >= 500 ? (exception as Error)?.stack : undefined,
|
||||||
|
},
|
||||||
|
{ path: "error", mode: "set" },
|
||||||
|
);
|
||||||
|
|
||||||
if (status >= 500) {
|
if (status >= 500) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`${request.method} ${request.url} -> ${status}`,
|
`${request.method} ${request.url} -> ${status}`,
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ export * from "./decorators/public.decorator";
|
|||||||
// Filters
|
// Filters
|
||||||
export * from "./filters/http-exception.filter";
|
export * from "./filters/http-exception.filter";
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
export * from "./logging/request-context";
|
||||||
|
export * from "./logging/request-log.middleware";
|
||||||
|
|
||||||
// Interceptors
|
// Interceptors
|
||||||
export * from "./interceptors/response-transform.interceptor";
|
export * from "./interceptors/response-transform.interceptor";
|
||||||
|
|
||||||
|
|||||||
109
packages/api-common/src/logging/request-context.ts
Normal file
109
packages/api-common/src/logging/request-context.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-request bag of data points that end up on the canonical request log line
|
||||||
|
* (see request-log.middleware.ts). Anything written here is flattened into one
|
||||||
|
* JSON object at the end of the request, so keep it to values that are safe to
|
||||||
|
* ship to a log aggregator: ids, states, counts, outcomes — never secrets,
|
||||||
|
* tokens, or raw uploads.
|
||||||
|
*/
|
||||||
|
export type RequestLogContext = Record<string, unknown>;
|
||||||
|
|
||||||
|
const storage = new AsyncLocalStorage<RequestLogContext>();
|
||||||
|
|
||||||
|
export const getLogContext = (): RequestLogContext | undefined =>
|
||||||
|
storage.getStore();
|
||||||
|
|
||||||
|
export const runWithLogContext = <T>(
|
||||||
|
seed: RequestLogContext,
|
||||||
|
fn: () => T,
|
||||||
|
): T => storage.run(seed, fn);
|
||||||
|
|
||||||
|
export interface LogCtxOptions {
|
||||||
|
/**
|
||||||
|
* Dot path to write under, e.g. "booking.transition". Namespacing is just a
|
||||||
|
* path with one segment ("payment"). Root object when omitted.
|
||||||
|
*/
|
||||||
|
path?: string;
|
||||||
|
/**
|
||||||
|
* How the value lands:
|
||||||
|
* - `merge` (default) — shallow-merge objects into the target
|
||||||
|
* - `set` — overwrite the target
|
||||||
|
* - `push` — append to an array at the target
|
||||||
|
* - `count` — add the value (default 1) to a numeric counter at the target
|
||||||
|
*/
|
||||||
|
mode?: "merge" | "set" | "push" | "count";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape hatch when the four modes don't fit: mutate the context yourself. */
|
||||||
|
export type LogCtxMutator = (ctx: RequestLogContext, value: unknown) => void;
|
||||||
|
|
||||||
|
// ponytail: fixed cap so a loop calling logCtx in `push` mode can't grow an
|
||||||
|
// unbounded array in memory / a megabyte log line. Per-path caps if one ever
|
||||||
|
// legitimately needs more.
|
||||||
|
const MAX_PUSHED = 100;
|
||||||
|
|
||||||
|
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
||||||
|
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a data point to the current request's canonical log line.
|
||||||
|
*
|
||||||
|
* No-op outside an HTTP request (bootstrap, cron, queue consumers) — callers
|
||||||
|
* never need to guard.
|
||||||
|
*
|
||||||
|
* logCtx({ bookingId, status }); // root merge
|
||||||
|
* logCtx(intentId, { path: "payment.intentId", mode: "set" });
|
||||||
|
* logCtx({ from, to }, { path: "booking.transition" }); // namespaced
|
||||||
|
* logCtx({ wagonId }, { path: "wagons", mode: "push" }); // array
|
||||||
|
* logCtx(1, { path: "smsSent", mode: "count" }); // counter
|
||||||
|
* logCtx(err, (ctx, e) => { ctx.lastError = String(e); }); // custom
|
||||||
|
*/
|
||||||
|
export function logCtx(
|
||||||
|
value: unknown,
|
||||||
|
opts: LogCtxOptions | LogCtxMutator = {},
|
||||||
|
): void {
|
||||||
|
const ctx = storage.getStore();
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
if (typeof opts === "function") {
|
||||||
|
opts(ctx, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { path, mode = "merge" } = opts;
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
if (isPlainObject(value)) Object.assign(ctx, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = path.split(".");
|
||||||
|
const leaf = keys.pop() as string;
|
||||||
|
let node: Record<string, unknown> = ctx;
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!isPlainObject(node[key])) node[key] = {};
|
||||||
|
node = node[key] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case "set":
|
||||||
|
node[leaf] = value;
|
||||||
|
break;
|
||||||
|
case "push": {
|
||||||
|
const arr = Array.isArray(node[leaf]) ? (node[leaf] as unknown[]) : [];
|
||||||
|
if (arr.length < MAX_PUSHED) arr.push(value);
|
||||||
|
node[leaf] = arr;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "count":
|
||||||
|
node[leaf] =
|
||||||
|
((node[leaf] as number) ?? 0) + (typeof value === "number" ? value : 1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
node[leaf] =
|
||||||
|
isPlainObject(node[leaf]) && isPlainObject(value)
|
||||||
|
? Object.assign(node[leaf] as Record<string, unknown>, value)
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
}
|
||||||
137
packages/api-common/src/logging/request-log.middleware.ts
Normal file
137
packages/api-common/src/logging/request-log.middleware.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { Injectable, Logger, NestMiddleware } from "@nestjs/common";
|
||||||
|
|
||||||
|
import {
|
||||||
|
RequestLogContext,
|
||||||
|
logCtx,
|
||||||
|
runWithLogContext,
|
||||||
|
} from "./request-context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural request/response shapes — express types are not a dependency of
|
||||||
|
* this package (they arrive under @nestjs/platform-express in the apps).
|
||||||
|
*/
|
||||||
|
interface LoggedRequest {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
originalUrl?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
route?: { path?: string };
|
||||||
|
headers: Record<string, string | string[] | undefined>;
|
||||||
|
ip?: string;
|
||||||
|
query?: Record<string, unknown>;
|
||||||
|
user?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoggedResponse {
|
||||||
|
statusCode: number;
|
||||||
|
writableEnded?: boolean;
|
||||||
|
setHeader(name: string, value: string): void;
|
||||||
|
on(event: string, listener: () => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = (req: LoggedRequest, name: string): string | undefined => {
|
||||||
|
const value = req.headers[name];
|
||||||
|
return Array.isArray(value) ? value[0] : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const userId = (req: LoggedRequest): string | undefined => {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) return undefined;
|
||||||
|
const id = user.id ?? user.sub ?? user.userId;
|
||||||
|
return typeof id === "string" || typeof id === "number"
|
||||||
|
? String(id)
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens an AsyncLocalStorage context for the request (so anything downstream
|
||||||
|
* can `logCtx(...)` into it) and emits ONE canonical JSON line per request when
|
||||||
|
* the response ends — request metadata plus every data point collected during
|
||||||
|
* the flow, on the same Nest logger transport as the rest of the app.
|
||||||
|
*
|
||||||
|
* Base fields win over collected context on key collisions, so a stray
|
||||||
|
* `logCtx({ status })` can never corrupt the fields dashboards filter on.
|
||||||
|
*
|
||||||
|
* Apply FIRST in `configure()`: middleware registered before this one runs
|
||||||
|
* outside the context and its `logCtx` calls are silently dropped.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RequestLogMiddleware implements NestMiddleware {
|
||||||
|
private readonly logger = new Logger("HTTP");
|
||||||
|
private readonly canonical = new Logger("request");
|
||||||
|
|
||||||
|
use(req: LoggedRequest, res: LoggedResponse, next: () => void): void {
|
||||||
|
const start = Date.now();
|
||||||
|
const requestId = header(req, "x-request-id") ?? randomUUID();
|
||||||
|
res.setHeader("x-request-id", requestId);
|
||||||
|
|
||||||
|
// Held by reference, NOT read back through the ALS at emit time: response
|
||||||
|
// "finish"/"close" listeners are plain EventEmitter callbacks, which Node
|
||||||
|
// does not bind to the async context they were registered in — getStore()
|
||||||
|
// there returns whatever context happened to call res.end().
|
||||||
|
const ctx: RequestLogContext = {};
|
||||||
|
|
||||||
|
runWithLogContext(ctx, () => {
|
||||||
|
logCtx({ requestId });
|
||||||
|
|
||||||
|
let emitted = false;
|
||||||
|
// "finish" covers normal responses; "close" catches client aborts, where
|
||||||
|
// "finish" never fires and the request would vanish from the logs.
|
||||||
|
const emit = () => {
|
||||||
|
if (emitted) return;
|
||||||
|
emitted = true;
|
||||||
|
|
||||||
|
const url = req.originalUrl ?? req.url;
|
||||||
|
const status = res.statusCode;
|
||||||
|
const durationMs = Date.now() - start;
|
||||||
|
|
||||||
|
this.logger.log(`${req.method} ${url} ${status} ${durationMs}ms`);
|
||||||
|
|
||||||
|
const line = {
|
||||||
|
...ctx,
|
||||||
|
type: "http_request",
|
||||||
|
requestId,
|
||||||
|
method: req.method,
|
||||||
|
route: req.route?.path
|
||||||
|
? `${req.baseUrl ?? ""}${req.route.path}`
|
||||||
|
: undefined,
|
||||||
|
url,
|
||||||
|
status,
|
||||||
|
durationMs,
|
||||||
|
userId: userId(req),
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: header(req, "user-agent"),
|
||||||
|
query:
|
||||||
|
req.query && Object.keys(req.query).length ? req.query : undefined,
|
||||||
|
aborted: res.writableEnded === false ? true : undefined,
|
||||||
|
};
|
||||||
|
// A circular value pushed into the context must not throw inside a
|
||||||
|
// response listener — that would take the process down, not the log.
|
||||||
|
let json: string;
|
||||||
|
try {
|
||||||
|
json = JSON.stringify(line);
|
||||||
|
} catch {
|
||||||
|
json = JSON.stringify({
|
||||||
|
type: "http_request",
|
||||||
|
requestId,
|
||||||
|
method: req.method,
|
||||||
|
url,
|
||||||
|
status,
|
||||||
|
durationMs,
|
||||||
|
contextSerializationFailed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status >= 500) this.canonical.error(json);
|
||||||
|
else if (status >= 400) this.canonical.warn(json);
|
||||||
|
else this.canonical.log(json);
|
||||||
|
};
|
||||||
|
|
||||||
|
res.on("finish", emit);
|
||||||
|
res.on("close", emit);
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,8 @@ export interface ETradeCompanyInfo {
|
|||||||
RenewedFrom: string;
|
RenewedFrom: string;
|
||||||
RenewedTo: string;
|
RenewedTo: string;
|
||||||
BusinessLicensingGroupMain: string | null;
|
BusinessLicensingGroupMain: string | null;
|
||||||
SubGroups: Array<{ Code: number; Description: string }> | null;
|
/** eTrade returns null entries in this array as well as a null array. */
|
||||||
|
SubGroups: Array<{ Code: number; Description: string | null } | null> | null;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2022
pnpm-lock.yaml
generated
2022
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user