mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1332 from Tria-plc/freight/feat/foreign-investors
Freight/feat/foreign investors
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) ??
|
||||
[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user