From a9763a541a4d9c8fe3594e35c1cbcc1ee1950996 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 18 Aug 2026 08:45:15 +0000 Subject: [PATCH] feat(companies): support foreign investors onboarding on an investment licence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A foreign company licensed by the Ethiopian Investment Commission is not on the trade registry, so eTrade holds no record for its TIN and the lookup the company step is built around returns nothing. Those customers could not get past onboarding at all. They now take the same route a co-operative does: an `investorLicence` flag in `attributes`, and `applyEtradeSourcedFields` skips the eTrade re-check for any company `usesManualRegistration` covers, so the registration they type is persisted as sent instead of 400'ing "no registration found for this TIN". Unlike a co-operative they still hold a business licence per operational role, so that requirement is untouched, and the foreign document set already asks for the investment licence itself — no new set. Only a foreign company may carry the flag, and never alongside the co-operative one: the two resolve to different document sets. `POST /companies/onboarding/revert-to-etrade` gives it back. It clears the typed registration rather than keeping it — the wizard treats a populated registration block as a passed lookup, so leaving it would walk the customer straight past the eTrade step the switch exists to reach — and returns the company to pending, since an approval granted against typed data must not carry over to a record that now claims to be eTrade's. --- .../modules/companies/companies.controller.ts | 15 ++ .../companies.investor-licence.spec.ts | 147 ++++++++++++++++++ .../modules/companies/companies.service.ts | 126 ++++++++++++++- .../onboarding-requirements-response.dto.ts | 9 ++ .../companies/dto/profile-response.dto.ts | 13 +- .../companies/dto/response-company.dto.ts | 9 ++ .../companies/dto/start-onboarding.dto.ts | 11 ++ .../companies/entities/company.entity.ts | 31 ++++ 8 files changed, 356 insertions(+), 5 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 175533c6d..4139424c1 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -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 foreign investment-licence route: clear the typed registration and reopen onboarding so the TIN is verified against eTrade", + }) + async revertToRegularCompany( + @CurrentUser() user: CurrentIamUser, + ): Promise { + const { profile, company } = + await this.companiesService.revertToRegularCompany(user.id); + return new CompanyInfoResponseDto(profile, company); + } + @Post("company-profile") @PortalCustomer() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts new file mode 100644 index 000000000..4a17d9594 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts @@ -0,0 +1,147 @@ +import { BadRequestException } from "@nestjs/common"; + +import { CompaniesService } from "./companies.service"; +import { + CompanyNationality, + CompanyStatus, + CompanyType, +} from "./entities/company.entity"; +import { 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. + */ +function makeService(company: Record | null) { + const companiesRepo = { + findById: jest.fn(async () => company), + update: jest.fn(async () => null), + create: jest.fn(async (row: Record) => ({ + id: "company-1", + ...row, + })), + existsByTin: jest.fn(async () => false), + }; + const companyProfilesRepo = { + findByCompanyId: jest.fn(async () => []), + create: jest.fn(async (row: Record) => ({ + 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) => ({ + 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, 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, + ]; + 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("refuses to switch a company that never took the investment-licence route", async () => { + const { service } = makeService({ id: "company-1", attributes: {} }); + await expect(service.revertToRegularCompany("user-1")).rejects.toBeInstanceOf( + BadRequestException, + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index bf8184833..f9090f288 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 = {}; if (nationality) updates.nationality = nationality; @@ -402,10 +413,15 @@ 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 } + : {}), }; } if (Object.keys(updates).length > 0) { @@ -416,6 +432,11 @@ export class CompaniesService { 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 +449,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 +513,34 @@ export class CompaniesService { } } + /** + * 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 +2199,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 +2349,7 @@ export class CompaniesService { documentSettingCode, nationality: company.nationality ?? CompanyNationality.Ethiopian, cooperative, + investorLicence, companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo, @@ -2369,6 +2427,66 @@ export class CompaniesService { return this.getCompanyInfoByUserId(userId); } + /** + * Drop the investment-licence route and send the company back through the + * normal eTrade one. + * + * 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. + */ + 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 (!hasInvestorLicence(company)) { + throw new BadRequestException( + "This company is already registered through eTrade — there is nothing to switch.", + ); + } + + const attributes = { ...(company.attributes ?? {}) }; + delete attributes[INVESTOR_LICENCE_KEY]; + // The manager captured alongside the (typed) registration goes with it — + // it never came from a licence, so it must not survive as one. + delete attributes.etradeManagerName; + delete attributes.etradeManagerPhone; + + await this.companiesRepo.update(companyId, { + attributes, + status: CompanyStatus.Pending, + licenceNumber: null, + statusDescription: null, + dateRegistered: null, + renewedFrom: null, + renewalDate: null, + renewedTo: null, + region: null, + zone: null, + woreda: null, + kebele: null, + houseNo: null, + etradePhone: null, + }); + await this.profilesRepo.update(profile.id, { + onboardingCompleted: false, + onboardingStep: "company", + }); + 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 +3653,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, diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index 20bd0ab06..49dad4b8d 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index 0d7293ab0..14753f8ef 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -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)) ?? []; diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index e75182889..7c4348e4f 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts index 91fcb44a1..0eac35a5f 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index 54c36bf41..68bf73cdf 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -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 | 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 | null | undefined, +): boolean { + return isCooperative(company) || hasInvestorLicence(company); +} + @Entity({ schema: "freight", name: "companies" }) @Index(["tin"]) @Index(["type"])