mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user