feat(companies): let a co-operative switch back to eTrade as well

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.
This commit is contained in:
Nathnael
2026-08-18 11:37:43 +00:00
parent 333232c4d9
commit 431bd3c98b
4 changed files with 165 additions and 17 deletions

View File

@@ -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,

View File

@@ -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<string, unknown> | null) {
const companiesRepo = {
@@ -26,7 +32,8 @@ function makeService(company: Record<string, unknown> | null) {
existsByTin: jest.fn(async () => false),
};
const companyProfilesRepo = {
findByCompanyId: jest.fn(async () => []),
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,
@@ -68,7 +75,7 @@ function makeService(company: Record<string, unknown> | 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<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

@@ -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);
}

View File

@@ -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({
</Group>
<Stack gap="md">
<Text size="sm" c="edr-muted">
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."}
</Text>
<Text size="sm" c="edr-muted">
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.
</List.Item>
<List.Item>
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.
</List.Item>
{cooperative && (
<List.Item>
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.
</List.Item>
)}
</List>
<Alert color="amber" variant="light" icon={<AlertCircle size={18} />}>
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.
</Alert>
{error && (
<Text size="sm" c="red">