Merge pull request #1332 from Tria-plc/freight/feat/foreign-investors

Freight/feat/foreign investors
This commit is contained in:
Nathnael Wondisha
2026-08-18 14:49:28 +03:00
committed by GitHub
37 changed files with 2788 additions and 412 deletions

View File

@@ -288,10 +288,25 @@ export class CompaniesController {
dto.roles,
dto.nationality,
dto.cooperative,
dto.investorLicence,
);
return new CompanyInfoResponseDto(profile, company);
}
@Post("onboarding/revert-to-etrade")
@PortalCustomer()
@ApiOperation({
summary:
"Drop the manual-registration route (co-operative or foreign investment licence): clear the typed registration and reopen onboarding so the TIN is verified against eTrade",
})
async revertToRegularCompany(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyInfoResponseDto> {
const { profile, company } =
await this.companiesService.revertToRegularCompany(user.id);
return new CompanyInfoResponseDto(profile, company);
}
@Post("company-profile")
@PortalCustomer()
@ApiOperation({

View File

@@ -0,0 +1,292 @@
import { BadRequestException } from "@nestjs/common";
import { CompaniesService } from "./companies.service";
import {
CompanyNationality,
CompanyStatus,
CompanyType,
} from "./entities/company.entity";
import {
ProfileStatus,
ProfileType,
} from "./entities/company-profile.entity";
/**
* A foreign company on an Investment Commission licence has no eTrade record,
* so it types its registration — and the flag saying so is what makes the
* backoffice treat those fields as unverified. Two things must hold: only a
* foreign company can carry it, and dropping it must not leave the typed
* registration behind looking like eTrade's.
*
* The dropping half is shared with the co-operative route, which has the same
* "eTrade holds nothing" shape, so it is exercised here for both.
*/
function makeService(company: Record<string, unknown> | null) {
const companiesRepo = {
findById: jest.fn(async () => company),
update: jest.fn(async () => null),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "company-1",
...row,
})),
existsByTin: jest.fn(async () => false),
};
const companyProfilesRepo = {
findByCompanyId: jest.fn(async (): Promise<Record<string, unknown>[]> => []),
updateStatus: jest.fn(async () => null),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "cp-1",
...row,
})),
softDelete: jest.fn(async () => undefined),
};
const profilesRepo = {
findByUserId: jest.fn(async () =>
company
? { id: "external-1", companyId: "company-1", company: { id: "company-1" } }
: null,
),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "external-1",
...row,
})),
update: jest.fn(async () => null),
};
const service = new CompaniesService(
companiesRepo as never,
companyProfilesRepo as never,
{} as never,
{} as never,
profilesRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
jest
.spyOn(service, "getCompanyInfoByUserId")
.mockImplementation(
async () =>
({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never,
);
return { service, companiesRepo, companyProfilesRepo, profilesRepo };
}
const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" };
const start = (
service: CompaniesService,
nationality: CompanyNationality | undefined,
cooperative: boolean,
investorLicence: boolean,
) =>
service.startOnboarding(
identity as never,
CompanyType.Customer,
[ProfileType.importer],
nationality,
cooperative,
investorLicence,
);
describe("the foreign investment-licence route", () => {
it("refuses the flag for an Ethiopian company", async () => {
const { service } = makeService(null);
await expect(
start(service, CompanyNationality.Ethiopian, false, true),
).rejects.toBeInstanceOf(BadRequestException);
});
it("refuses the flag alongside the co-operative one", async () => {
const { service } = makeService(null);
await expect(
start(service, CompanyNationality.Foreign, true, true),
).rejects.toBeInstanceOf(BadRequestException);
});
it("stores the flag on a new foreign draft", async () => {
const { service, companiesRepo } = makeService(null);
await start(service, CompanyNationality.Foreign, false, true);
expect(companiesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
nationality: CompanyNationality.Foreign,
attributes: { investorLicence: true },
}),
);
});
it("clears the typed registration and reopens onboarding when switching back to eTrade", async () => {
const { service, companiesRepo, profilesRepo } = makeService({
id: "company-1",
attributes: { investorLicence: true, etradeManagerName: "Typed Name" },
});
await service.revertToRegularCompany("user-1");
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
string,
Record<string, unknown>,
];
expect(updates.attributes).toEqual({});
expect(updates.status).toBe(CompanyStatus.Pending);
// The wizard treats a populated registration as a passed lookup, so leaving
// any of it behind would walk the customer straight past the eTrade step.
expect(updates.licenceNumber).toBeNull();
expect(updates.region).toBeNull();
expect(profilesRepo.update).toHaveBeenCalledWith("external-1", {
onboardingCompleted: false,
onboardingStep: "company",
});
});
it("clears the typed registration when the box is un-ticked on the way back", async () => {
const { service, companiesRepo, profilesRepo } = makeService({
id: "company-1",
nationality: CompanyNationality.Foreign,
attributes: { investorLicence: true, etradeManagerName: "Typed Name" },
region: "Addis Ababa",
licenceNumber: "TYPED-1",
});
await start(service, CompanyNationality.Foreign, false, false);
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
string,
Record<string, unknown>,
];
// The wizard sends both flags; the typed manager does not survive.
expect(updates.attributes).toEqual({
cooperative: false,
investorLicence: false,
});
expect(updates.licenceNumber).toBeNull();
expect(updates.region).toBeNull();
// Resume must land back on the company step, or the customer never reaches
// the eTrade lookup they just opted back into.
expect(profilesRepo.update).toHaveBeenCalledWith("external-1", {
onboardingStep: "company",
});
});
it("does the same for a co-operative that stops being one", async () => {
const { service, companiesRepo } = makeService({
id: "company-1",
nationality: CompanyNationality.Ethiopian,
attributes: { cooperative: true },
region: "Oromia",
});
await start(service, CompanyNationality.Ethiopian, false, false);
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
string,
Record<string, unknown>,
];
expect(updates.region).toBeNull();
});
it("leaves the registration alone while the flag stays on", async () => {
const { service, companiesRepo, profilesRepo } = makeService({
id: "company-1",
nationality: CompanyNationality.Foreign,
attributes: { investorLicence: true },
region: "Addis Ababa",
});
await start(service, CompanyNationality.Foreign, false, true);
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
string,
Record<string, unknown>,
];
expect(updates).not.toHaveProperty("region");
expect(profilesRepo.update).not.toHaveBeenCalled();
});
it("refuses to switch a company that never took a manual-registration route", async () => {
const { service } = makeService({ id: "company-1", attributes: {} });
await expect(service.revertToRegularCompany("user-1")).rejects.toBeInstanceOf(
BadRequestException,
);
});
/**
* The switch belongs to both manual-registration routes, not just this one. A
* co-operative that has since taken out a trade licence had no way back at
* all: the wizard is where the flag is chosen, and an onboarded company can no
* longer reach it.
*/
it("switches a co-operative back to eTrade on the same terms", async () => {
const { service, companiesRepo, profilesRepo } = makeService({
id: "company-1",
nationality: CompanyNationality.Ethiopian,
attributes: { cooperative: true, etradeManagerName: "Typed Name" },
region: "Oromia",
licenceNumber: "TYPED-1",
});
await service.revertToRegularCompany("user-1");
const [, updates] = companiesRepo.update.mock.calls[0] as unknown as [
string,
Record<string, unknown>,
];
expect(updates.attributes).toEqual({});
expect(updates.status).toBe(CompanyStatus.Pending);
expect(updates.licenceNumber).toBeNull();
expect(updates.region).toBeNull();
// A co-op owes no per-role business licence; once it stops being one it
// does, so the application has to be re-opened and re-reviewed.
expect(profilesRepo.update).toHaveBeenCalledWith("external-1", {
onboardingCompleted: false,
onboardingStep: "company",
});
});
/**
* Approval of a co-op's role was granted without a business licence, because
* a co-op owes none. Leaving makes one due, so the approval no longer stands
* for what it said.
*/
it("sends a co-operative's approved roles back for approval", async () => {
const { service, companyProfilesRepo } = makeService({
id: "company-1",
attributes: { cooperative: true },
});
companyProfilesRepo.findByCompanyId.mockResolvedValue([
{ id: "role-active", status: ProfileStatus.Active },
{ id: "role-blocked", status: ProfileStatus.Blacklisted },
{ id: "role-pending", status: ProfileStatus.Pending },
]);
await service.revertToRegularCompany("user-1");
expect(companyProfilesRepo.updateStatus).toHaveBeenCalledWith(
"role-active",
ProfileStatus.Pending,
);
// A staff decision is not the customer's to undo by switching registration:
// promoting a blocked role to "awaiting approval" would launder the block.
expect(companyProfilesRepo.updateStatus).toHaveBeenCalledTimes(1);
});
it("leaves an investor's roles alone — their licences were always due", async () => {
const { service, companyProfilesRepo } = makeService({
id: "company-1",
attributes: { investorLicence: true },
});
companyProfilesRepo.findByCompanyId.mockResolvedValue([
{ id: "role-active", status: ProfileStatus.Active },
]);
await service.revertToRegularCompany("user-1");
expect(companyProfilesRepo.updateStatus).not.toHaveBeenCalled();
});
});

View File

@@ -63,7 +63,10 @@ import {
CompanyStatus,
CompanyType,
COOPERATIVE_KEY,
INVESTOR_LICENCE_KEY,
hasInvestorLicence,
isCooperative,
usesManualRegistration,
} from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import {
@@ -378,6 +381,7 @@ export class CompaniesService {
roles: ProfileType[],
nationality?: CompanyNationality,
cooperative?: boolean,
investorLicence?: boolean,
): Promise<{ profile: ExternalProfile; company: Company }> {
// Already started — reuse the existing draft, just ensure roles exist and
// keep the nationality up to date if it was (re)selected.
@@ -388,13 +392,20 @@ export class CompaniesService {
// flag into `attributes`, or to read a stored one the caller didn't send.
const needsCompany =
cooperative !== undefined ||
investorLicence !== undefined ||
roles.includes(ProfileType.freightForwarder);
const current = needsCompany
? await this.companiesRepo.findById(companyId)
: null;
const isCoop = cooperative ?? isCooperative(current);
const isInvestor = investorLicence ?? hasInvestorLicence(current);
this.assertRolesAllowedForCooperative(isCoop, roles);
this.assertNationalityAllowedForCooperative(isCoop, nationality);
this.assertInvestorLicenceAllowed(
isInvestor,
isCoop,
nationality ?? current?.nationality ?? undefined,
);
await this.syncCompanyProfiles(companyId, companyType, roles);
const updates: Partial<Company> = {};
if (nationality) updates.nationality = nationality;
@@ -402,20 +413,49 @@ export class CompaniesService {
// 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 || investorLicence !== undefined) {
updates.attributes = {
...(current?.attributes ?? {}),
[COOPERATIVE_KEY]: cooperative,
...(cooperative !== undefined
? { [COOPERATIVE_KEY]: cooperative }
: {}),
...(investorLicence !== undefined
? { [INVESTOR_LICENCE_KEY]: investorLicence }
: {}),
};
}
// Going back and un-ticking the box is the same act as the settings
// switch, so it has to cost the same: the registration the customer typed
// goes, and onboarding drops back to the company step. Without this the
// draft keeps the typed values, `hasRegistrationDetails` reads as a passed
// lookup, resume lands past the company step entirely — and the company
// finishes onboarding on unverified data with no flag left to say so.
const backToEtrade =
usesManualRegistration(current) && !isCoop && !isInvestor;
if (backToEtrade) {
Object.assign(updates, CompaniesService.CLEARED_REGISTRATION);
updates.attributes = this.withoutTypedEtradeManager(
updates.attributes ?? current?.attributes,
);
}
if (Object.keys(updates).length > 0) {
await this.companiesRepo.update(companyId, updates);
}
if (backToEtrade) {
await this.profilesRepo.update(existing.id, {
onboardingStep: "company",
});
}
return this.getCompanyInfoByUserId(identity.userId);
}
this.assertRolesAllowedForCooperative(cooperative === true, roles);
this.assertNationalityAllowedForCooperative(cooperative === true, nationality);
this.assertInvestorLicenceAllowed(
investorLicence === true,
cooperative === true,
nationality,
);
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
@@ -428,7 +468,14 @@ export class CompaniesService {
country: "Ethiopia",
nationality: nationality ?? CompanyNationality.Ethiopian,
status: CompanyStatus.Pending,
...(cooperative ? { attributes: { [COOPERATIVE_KEY]: true } } : {}),
...(cooperative || investorLicence
? {
attributes: {
...(cooperative ? { [COOPERATIVE_KEY]: true } : {}),
...(investorLicence ? { [INVESTOR_LICENCE_KEY]: true } : {}),
},
}
: {}),
});
await this.profilesRepo.create({
@@ -485,6 +532,73 @@ export class CompaniesService {
}
}
/**
* The registration block as it must look when nobody has verified it.
*
* Used wherever a company stops being one eTrade cannot answer for: whatever
* sits in these columns was the customer's own statement, and the wizard
* treats a populated registration as a lookup that already passed
* (`hasRegistrationDetails`). Leaving it behind would hand the company an
* eTrade-verified record eTrade never supplied — and, once the flag is gone,
* a backoffice screen that says so.
*/
private static readonly CLEARED_REGISTRATION: Partial<Company> = {
licenceNumber: null,
statusDescription: null,
dateRegistered: null,
renewedFrom: null,
renewalDate: null,
renewedTo: null,
region: null,
zone: null,
woreda: null,
kebele: null,
houseNo: null,
etradePhone: null,
};
/**
* The company's own `attributes`, minus the manager captured alongside a
* typed registration. It never came from a licence, so it must not outlive
* the registration it belonged to.
*/
private withoutTypedEtradeManager(
attributes: Record<string, unknown> | null | undefined,
): Record<string, unknown> {
const next = { ...(attributes ?? {}) };
delete next.etradeManagerName;
delete next.etradeManagerPhone;
return next;
}
/**
* An investment licence belongs to a foreign company and to nothing else.
*
* It is the Ethiopian Investment Commission's licence, issued to a foreign
* investor — an Ethiopian company registers with the trade registry, which is
* exactly the eTrade record this flag says does not exist. A co-operative
* cannot hold one either: it is Ethiopian by construction, and the two flags
* resolve to different document sets, so a company carrying both would owe an
* incoherent list of papers.
*/
private assertInvestorLicenceAllowed(
investorLicence: boolean,
cooperative: boolean,
nationality: CompanyNationality | undefined,
): void {
if (!investorLicence) return;
if (cooperative) {
throw new BadRequestException(
"A co-operative union or farm is registered in Ethiopia — it cannot also onboard on a foreign investment licence.",
);
}
if (nationality !== CompanyNationality.Foreign) {
throw new BadRequestException(
"Only a foreign company can onboard on an investment licence.",
);
}
}
/**
* Reconcile the company's operational profiles with the roles the user has
* selected: create the missing ones, drop the ones they deselected.
@@ -2143,6 +2257,7 @@ export class CompaniesService {
// or farm holds no business licence, so it owes its own list rather than the
// nationality list plus extras.
const cooperative = isCooperative(company);
const investorLicence = hasInvestorLicence(company);
const documentSettingCode = this.documentSettingCodeFor(company);
const [setting, uploadedFiles] = await Promise.all([
this.fileUploadSettingsService
@@ -2292,6 +2407,7 @@ export class CompaniesService {
documentSettingCode,
nationality: company.nationality ?? CompanyNationality.Ethiopian,
cooperative,
investorLicence,
companyInfo: {
complete: missingInfo.length === 0,
missingFields: missingInfo,
@@ -2369,6 +2485,97 @@ export class CompaniesService {
return this.getCompanyInfoByUserId(userId);
}
/**
* Drop whichever manual-registration route the company is on and send it back
* through the normal eTrade one.
*
* Both routes exist for the same reason — eTrade holds no record to fetch —
* so leaving one is the same act whichever it is, and it is the only way back
* to eTrade for either. A co-operative union or farm that has since taken out
* a trade licence had no exit at all before this; its only route was the
* wizard, which an onboarded company can no longer reach.
*
* Everything the flag let the customer type is cleared, not kept: the
* registration block on file was their own statement, and leaving it there
* would let the wizard treat the company as already looked-up
* (`hasRegistrationDetails` is what stands in for a verified TIN on a
* resume) and walk straight past the eTrade step this switch exists to
* reach. Onboarding reopens at the company step and the company goes back to
* pending — an approval granted against typed data cannot silently carry over
* to a record that now claims to be eTrade's.
*
* Switching the other way — INTO a co-operative or an investment licence — is
* deliberately not here. It is the wizard's nationality/role step, which this
* reopens, and which is the one place the mutually-exclusive rules live
* (`assertInvestorLicenceAllowed`, `assertRolesAllowedForCooperative`,
* `assertNationalityAllowedForCooperative`). A second entry point would have
* to restate all three.
*/
async revertToRegularCompany(
userId: string,
): Promise<{ profile: ExternalProfile; company: Company }> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
const companyId = profile.company?.id ?? profile.companyId;
const company = await this.companiesRepo.findById(companyId);
if (!company)
throw new NotFoundException(`Company ${companyId} not found`);
if (!usesManualRegistration(company)) {
throw new BadRequestException(
"This company is already registered through eTrade — there is nothing to switch.",
);
}
const wasCooperative = isCooperative(company);
// Both flags go, not just the one that was set: they are mutually exclusive
// and a company can only ever hold one, but the destination is "neither",
// so stripping only the one we happened to check for would leave the other
// behind if the pair ever did coexist.
const attributes = this.withoutTypedEtradeManager(company.attributes);
delete attributes[INVESTOR_LICENCE_KEY];
delete attributes[COOPERATIVE_KEY];
await this.companiesRepo.update(companyId, {
...CompaniesService.CLEARED_REGISTRATION,
attributes,
status: CompanyStatus.Pending,
});
await this.profilesRepo.update(profile.id, {
onboardingCompleted: false,
onboardingStep: "company",
});
// A co-operative owes no per-role business licence — that is the whole
// reason its own document set stands in for one. The moment it stops being
// one, every role owes a licence that was never uploaded, so an approval
// granted without one no longer means what it said: back to Pending, and
// the reviewer sees the licence with the rest of the re-application.
//
// Only Active roles move. Rejected, Suspended and Blacklisted are the
// backoffice's own decisions, and quietly promoting a blocked role to
// "awaiting approval" would launder the block away. The reference survives
// either way — it is minted once (`setCompanyProfileStatus`) and re-approval
// reuses it, so bookings that cite it keep citing the same number.
//
// An investor is untouched: it always held a licence per role, so nothing
// becomes due that was not already reviewed.
if (wasCooperative) {
const roles = await this.companyProfilesRepo.findByCompanyId(companyId);
for (const role of roles) {
if (role.status !== ProfileStatus.Active) continue;
await this.companyProfilesRepo.updateStatus(
role.id,
ProfileStatus.Pending,
);
}
}
return this.getCompanyInfoByUserId(userId);
}
/**
* Block a self-service action when the company account isn't active, naming
* the actual status — a suspended customer told "awaiting approval" has no
@@ -3535,7 +3742,7 @@ export class CompaniesService {
// the registered address themselves, and what they send IS the data. The
// check is skipped rather than failed: running the lookup would 400 every
// save with "no registration found for this TIN".
if (isCooperative(company)) return;
if (usesManualRegistration(company)) return;
const touched = ETRADE_SOURCED_FIELDS.some(
(key) => key !== "tin" && dto[key] !== undefined,

View File

@@ -78,6 +78,14 @@ export class OnboardingRequirementsResponseDto {
*/
cooperative: boolean;
/**
* The company is a foreign investor on an investment licence: no eTrade
* record, so the registration was typed. The nationality document set still
* applies (it already asks for the investment licence itself), and so does
* the per-role business licence.
*/
investorLicence: boolean;
/** Required company-information fields and whether each is filled. */
companyInfo: {
complete: boolean;
@@ -116,6 +124,7 @@ export class OnboardingRequirementsResponseDto {
this.documentSettingCode = init.documentSettingCode;
this.nationality = init.nationality;
this.cooperative = init.cooperative;
this.investorLicence = init.investorLicence;
this.companyInfo = init.companyInfo;
this.documents = init.documents;
this.licenseProfiles = init.licenseProfiles;

View File

@@ -2,7 +2,11 @@ import {
buildCompanyIdentityState,
CompanyIdentityStateDto,
} from "./complete-identity-verification.dto";
import { Company, isCooperative } from "../entities/company.entity";
import {
Company,
hasInvestorLicence,
isCooperative,
} from "../entities/company.entity";
import { ExternalProfile } from "../entities/external-profile.entity";
import {
ChangeRequestStatus,
@@ -21,6 +25,12 @@ export class ProfileResponseDto {
* it from eTrade.
*/
cooperative: boolean;
/**
* The company is a foreign investor on an investment licence: eTrade holds
* no record, so the company step collects the registration by hand. Drives
* the settings card that switches back to the eTrade route.
*/
investorLicence: boolean;
companyLocation: string;
companyAddress: string | null;
tinNumber: string;
@@ -93,6 +103,7 @@ export class ProfileResponseDto {
this.companyType = company.type;
this.nationality = company.nationality ?? null;
this.cooperative = isCooperative(company);
this.investorLicence = hasInvestorLicence(company);
this.companyProfiles =
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
[];

View File

@@ -3,6 +3,7 @@ import {
CompanyType,
CompanyStatus,
CompanyNationality,
hasInvestorLicence,
isCooperative,
} from '../entities/company.entity';
import {
@@ -62,6 +63,13 @@ export class ResponseCompanyDto {
* eTrade manager to check the owner against.
*/
cooperative: boolean;
/**
* The company onboarded as a foreign investor on an investment licence:
* eTrade holds no record for its TIN, so its registration below was typed by
* the customer rather than fetched — nothing here has been checked against a
* licence, and the reviewer is the check.
*/
investorLicence: boolean;
tin: string;
vatNumber?: string | null;
fanNumber?: string | null;
@@ -118,6 +126,7 @@ export class ResponseCompanyDto {
this.status = company.status;
this.nationality = company.nationality ?? null;
this.cooperative = isCooperative(company);
this.investorLicence = hasInvestorLicence(company);
this.tin = company.tin;
this.vatNumber = company.vatNumber;
this.fanNumber = company.fanNumber;

View File

@@ -31,4 +31,15 @@ export class StartOnboardingDto {
@IsOptional()
@IsBoolean()
cooperative?: boolean;
/**
* The company is a foreign investor: it operates on an investment licence
* issued by the Ethiopian Investment Commission, so eTrade holds no record
* for its TIN and the registration is typed here instead. Chosen on the same
* step for the same reason as the co-operative flag — it decides what the
* company step asks for. Only a foreign company can hold one.
*/
@IsOptional()
@IsBoolean()
investorLicence?: boolean;
}

View File

@@ -51,6 +51,37 @@ export function isCooperative(
return company?.attributes?.[COOPERATIVE_KEY] === true;
}
/**
* `attributes` key marking a foreign company onboarding on an investment
* licence.
*
* The Ethiopian Investment Commission registers it, not the trade registry, so
* eTrade holds no record for its TIN: the registration is typed and the eTrade
* authenticity check is skipped rather than failed — exactly as for a
* co-operative. What does NOT change is the licence: the company still holds
* one per operational role, so that requirement stands.
*/
export const INVESTOR_LICENCE_KEY = "investorLicence";
/** Is this a foreign company registered on an investment licence? */
export function hasInvestorLicence(
company: Pick<Company, "attributes"> | null | undefined,
): boolean {
return company?.attributes?.[INVESTOR_LICENCE_KEY] === true;
}
/**
* eTrade holds nothing for this company, so its registration was typed by hand
* rather than fetched — and the backoffice is told so. Two different companies
* reach it (a co-operative has no licence at all; a foreign investor's is not
* the trade registry's), and every consequence they share hangs off this.
*/
export function usesManualRegistration(
company: Pick<Company, "attributes"> | null | undefined,
): boolean {
return isCooperative(company) || hasInvestorLicence(company);
}
@Entity({ schema: "freight", name: "companies" })
@Index(["tin"])
@Index(["type"])