From 431bd3c98b9e69b48078dba138c8fbcd3042884f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 18 Aug 2026 11:37:43 +0000 Subject: [PATCH] feat(companies): let a co-operative switch back to eTrade as well MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both manual-registration routes exist for one reason — eTrade holds no record to fetch — so leaving one is the same act whichever it is. A co-operative that has since taken out a trade licence had no way out at all: the flag is chosen in the wizard, and an onboarded company can no longer reach it. revert-to-etrade now accepts either flag and strips both, and a co-operative's ACTIVE roles go back to Pending with it: those approvals were granted without a per-role business licence, because a co-op owes none, and the moment it stops being one that licence is due. Rejected, suspended and blacklisted roles are left alone — promoting a blocked role to "awaiting approval" would launder a staff decision away. An investor's roles are untouched; their licences were always due and already reviewed. Switching INTO a route stays with the wizard's nationality/role step, which owns the mutually-exclusive rules; a second entry point would restate all three. --- .../modules/companies/companies.controller.ts | 2 +- .../companies.investor-licence.spec.ts | 89 ++++++++++++++++++- .../modules/companies/companies.service.ts | 52 ++++++++++- .../pages/settings/RegistrationSourceCard.tsx | 39 ++++++-- 4 files changed, 165 insertions(+), 17 deletions(-) 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 4139424c1..5d29b32f6 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -297,7 +297,7 @@ export class CompaniesController { @PortalCustomer() @ApiOperation({ summary: - "Drop the foreign investment-licence route: clear the typed registration and reopen onboarding so the TIN is verified against eTrade", + "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, 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 index 9623945d1..9de99186f 100644 --- 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 @@ -6,7 +6,10 @@ import { CompanyStatus, CompanyType, } from "./entities/company.entity"; -import { ProfileType } from "./entities/company-profile.entity"; +import { + ProfileStatus, + ProfileType, +} from "./entities/company-profile.entity"; /** * A foreign company on an Investment Commission licence has no eTrade record, @@ -14,6 +17,9 @@ import { ProfileType } from "./entities/company-profile.entity"; * 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 | null) { const companiesRepo = { @@ -26,7 +32,8 @@ function makeService(company: Record | null) { existsByTin: jest.fn(async () => false), }; const companyProfilesRepo = { - findByCompanyId: jest.fn(async () => []), + findByCompanyId: jest.fn(async (): Promise[]> => []), + updateStatus: jest.fn(async () => null), create: jest.fn(async (row: Record) => ({ id: "cp-1", ...row, @@ -68,7 +75,7 @@ function makeService(company: Record | null) { ({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never, ); - return { service, companiesRepo, profilesRepo }; + return { service, companiesRepo, companyProfilesRepo, profilesRepo }; } const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" }; @@ -202,10 +209,84 @@ describe("the foreign investment-licence route", () => { expect(profilesRepo.update).not.toHaveBeenCalled(); }); - it("refuses to switch a company that never took the investment-licence route", async () => { + 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, + ]; + 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(); + }); }); 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 6dae48f24..1eb611c24 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -2486,8 +2486,14 @@ export class CompaniesService { } /** - * Drop the investment-licence route and send the company back through the - * normal eTrade one. + * 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 @@ -2497,6 +2503,13 @@ export class CompaniesService { * 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, @@ -2509,14 +2522,21 @@ export class CompaniesService { const company = await this.companiesRepo.findById(companyId); if (!company) throw new NotFoundException(`Company ${companyId} not found`); - if (!hasInvestorLicence(company)) { + 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, @@ -2527,6 +2547,32 @@ export class CompaniesService { 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); } diff --git a/apps/edr-freight-web/portal/src/pages/settings/RegistrationSourceCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/RegistrationSourceCard.tsx index 09f4e7195..a814db2b5 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/RegistrationSourceCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/RegistrationSourceCard.tsx @@ -18,15 +18,21 @@ import type { ProfileResponse } from "@/types/profile"; import { extractApiError } from "@/utils/result"; /** - * Leave the foreign investment-licence route and go back to the ordinary eTrade - * one — for a company that has since been registered with the trade registry, - * or that ticked the box by mistake. + * Leave whichever manual-registration route the company is on — a co-operative + * union or farm, or a foreign investment licence — and go back to the ordinary + * eTrade one. For a company that has since been registered with the trade + * registry, or that ticked the box by mistake. * * It is a re-application, not a settings edit: the API clears the registration * the customer typed (nothing on file was ever checked against a licence) and * puts the company back to pending, so the wizard reopens on the company step * and the TIN goes through eTrade this time. Said plainly here rather than * discovered afterwards. + * + * Only the way OUT is here. Moving between the three registration sources in + * the other direction is the wizard's own step, which this reopens — that is + * where the rules about which combinations are legal already live, and a second + * picker would have to restate every one of them. */ export default function RegistrationSourceCard({ profile, @@ -61,7 +67,9 @@ export default function RegistrationSourceCard({ onError: (err) => setError(extractApiError(err).message), }); - if (!profile.investorLicence) return null; + // Which of the two routes this is — wording only; leaving costs the same. + const cooperative = profile.cooperative; + if (!profile.investorLicence && !cooperative) return null; return ( <> @@ -72,9 +80,9 @@ export default function RegistrationSourceCard({ - Your company is registered on a foreign investment licence, so your - registration details were entered by hand instead of being read from - eTrade. Our team reviews them against the documents you uploaded. + {cooperative + ? "Your company is registered as a co-operative union or farm, so your registration details were entered by hand instead of being read from eTrade. Our team reviews them against the documents you uploaded." + : "Your company is registered on a foreign investment licence, so your registration details were entered by hand instead of being read from eTrade. Our team reviews them against the documents you uploaded."} If your company now holds an eTrade trade licence, you can switch @@ -118,12 +126,25 @@ export default function RegistrationSourceCard({ Your company goes back to pending and is reviewed again. - Your documents, owner and contact details stay as they are. + Your documents, owner and contact details stay as they are — but + the papers we ask for change with the registration source, so some + may be listed as outstanding again. + {cooperative && ( + + A business licence becomes due for each of your operational + services — a co-operative owes none, an eTrade-registered + company does — so any that are already approved go back to + awaiting approval until you upload one. Their reference numbers + stay the same. + + )} }> If eTrade holds no record for your TIN you won't be able to finish — - come back here and re-select the investment licence in the wizard. + the wizard reopens on the company step, so go back one step and + re-select {cooperative ? "co-operative" : "the investment licence"}{" "} + there. {error && (