mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
Merge pull request #1334 from Tria-plc/freight/feat/foreign-investors
fix: approval window
This commit is contained in:
@@ -0,0 +1,187 @@
|
|||||||
|
import { CompaniesService } from "./companies.service";
|
||||||
|
import { CompanyStatus } from "./entities/company.entity";
|
||||||
|
import {
|
||||||
|
ProfileStatus,
|
||||||
|
ProfileType,
|
||||||
|
} from "./entities/company-profile.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploading a business licence only ever adds a row — nothing overwrites. So a
|
||||||
|
* customer answering a rejection or a document correction used to end up with
|
||||||
|
* the refused licence still listed beside the new one, in the portal and in the
|
||||||
|
* backoffice, with nothing saying which is current. An upload that answers a
|
||||||
|
* reviewer now retires what it answers; an upload with nothing outstanding is a
|
||||||
|
* genuine addition and still just adds.
|
||||||
|
*/
|
||||||
|
interface StoredFile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
createdAt: Date;
|
||||||
|
reviewStatus?: string | null;
|
||||||
|
removed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const T0 = new Date("2026-01-01T00:00:00Z");
|
||||||
|
const REJECTED_AT = new Date("2026-02-01T00:00:00Z");
|
||||||
|
const T2 = new Date("2026-03-01T00:00:00Z");
|
||||||
|
|
||||||
|
function makeService(
|
||||||
|
status: ProfileStatus,
|
||||||
|
files: StoredFile[],
|
||||||
|
reviewedAt: Date | null = null,
|
||||||
|
) {
|
||||||
|
const stored = [...files];
|
||||||
|
const profile = {
|
||||||
|
id: "profile-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
type: ProfileType.importer,
|
||||||
|
status,
|
||||||
|
reviewedAt,
|
||||||
|
};
|
||||||
|
const company = {
|
||||||
|
id: "company-1",
|
||||||
|
status:
|
||||||
|
status === ProfileStatus.Active
|
||||||
|
? CompanyStatus.Active
|
||||||
|
: CompanyStatus.Pending,
|
||||||
|
companyProfiles: [profile],
|
||||||
|
};
|
||||||
|
|
||||||
|
const live = () => stored.filter((f) => !f.removed);
|
||||||
|
const filesService = {
|
||||||
|
upload: jest.fn(async (input: { code: string; file: { originalname: string } }) => {
|
||||||
|
const record = {
|
||||||
|
id: `file-${stored.length + 1}`,
|
||||||
|
name: input.file.originalname,
|
||||||
|
code: input.code,
|
||||||
|
createdAt: T2,
|
||||||
|
size: 1,
|
||||||
|
mimeType: "application/pdf",
|
||||||
|
};
|
||||||
|
stored.push(record);
|
||||||
|
return record;
|
||||||
|
}),
|
||||||
|
findByResource: jest.fn(async () => live()),
|
||||||
|
findWithOpenChangeRequest: jest.fn(async () =>
|
||||||
|
live().filter((f) => f.reviewStatus === "change_requested"),
|
||||||
|
),
|
||||||
|
findById: jest.fn(async (id: string) => ({
|
||||||
|
...stored.find((f) => f.id === id),
|
||||||
|
resource: "company_profiles",
|
||||||
|
resourceId: "profile-1",
|
||||||
|
})),
|
||||||
|
remove: jest.fn(async (id: string) => {
|
||||||
|
const found = stored.find((f) => f.id === id);
|
||||||
|
if (found) found.removed = true;
|
||||||
|
}),
|
||||||
|
clearReview: jest.fn(async (id: string) => {
|
||||||
|
const found = stored.find((f) => f.id === id);
|
||||||
|
if (found) found.reviewStatus = null;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeRequestRepo = {
|
||||||
|
findPendingByCompanyId: jest.fn(async () => null),
|
||||||
|
create: jest.fn(async (row: Record<string, unknown>) => ({ id: "cr-1", ...row })),
|
||||||
|
update: jest.fn(async () => ({ id: "cr-1" })),
|
||||||
|
findByCompanyId: jest.fn(async () => []),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new CompaniesService(
|
||||||
|
{ findById: jest.fn(async () => company) } as never,
|
||||||
|
{ findByCompanyId: jest.fn(async () => [profile]) } as never,
|
||||||
|
changeRequestRepo as never,
|
||||||
|
{} as never,
|
||||||
|
{ findByCompanyId: jest.fn(async () => []) } as never,
|
||||||
|
{} as never,
|
||||||
|
filesService as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ changeRequestSubmitted: jest.fn() } as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(service, "getCompanyInfoByUserId")
|
||||||
|
.mockImplementation(
|
||||||
|
async () => ({ profile: { id: "external-1" }, company }) as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, stored, live, filesService, changeRequestRepo };
|
||||||
|
}
|
||||||
|
|
||||||
|
const upload = (service: CompaniesService) =>
|
||||||
|
service.addProfileLicenseFiles("user-1", "profile-1", [
|
||||||
|
{ originalname: "new-licence.pdf" } as never,
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe("a business licence uploaded to answer a reviewer", () => {
|
||||||
|
it("retires the file the reviewer flagged for correction", async () => {
|
||||||
|
const { service, live } = makeService(ProfileStatus.Pending, [
|
||||||
|
{ id: "file-old", name: "old.pdf", code: "business_license", createdAt: T0, reviewStatus: "change_requested" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await upload(service);
|
||||||
|
|
||||||
|
expect(live().map((f) => f.name)).toEqual(["new-licence.pdf"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retires what was on file when the role was rejected, but not the customer's own fix so far", async () => {
|
||||||
|
// Two uploads answering one rejection (a second page, or a re-pick) must not
|
||||||
|
// cannibalise each other — only what the reviewer actually refused goes.
|
||||||
|
const { service, live } = makeService(
|
||||||
|
ProfileStatus.Rejected,
|
||||||
|
[
|
||||||
|
{ id: "file-refused", name: "refused.pdf", code: "business_license", createdAt: T0 },
|
||||||
|
{ id: "file-fix-1", name: "fix-page-1.pdf", code: "business_license", createdAt: T2 },
|
||||||
|
],
|
||||||
|
REJECTED_AT,
|
||||||
|
);
|
||||||
|
|
||||||
|
await upload(service);
|
||||||
|
|
||||||
|
expect(live().map((f) => f.name)).toEqual([
|
||||||
|
"fix-page-1.pdf",
|
||||||
|
"new-licence.pdf",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an ordinary addition alone when nothing was asked for", async () => {
|
||||||
|
const { service, live } = makeService(ProfileStatus.Pending, [
|
||||||
|
{ id: "file-old", name: "existing.pdf", code: "business_license", createdAt: T0 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await upload(service);
|
||||||
|
|
||||||
|
expect(live().map((f) => f.name)).toEqual([
|
||||||
|
"existing.pdf",
|
||||||
|
"new-licence.pdf",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stages the swap for review on an approved role instead of deleting", async () => {
|
||||||
|
// A live role's licence is not the customer's to remove unilaterally: the
|
||||||
|
// old file stays until a reviewer approves the swap.
|
||||||
|
const { service, live, changeRequestRepo } = makeService(
|
||||||
|
ProfileStatus.Active,
|
||||||
|
[
|
||||||
|
{ id: "file-old", name: "old.pdf", code: "business_license", createdAt: T0, reviewStatus: "change_requested" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await upload(service);
|
||||||
|
|
||||||
|
expect(live().map((f) => f.name)).toEqual(["old.pdf", "new-licence.pdf"]);
|
||||||
|
const intents = changeRequestRepo.create.mock.calls.flatMap(
|
||||||
|
([row]) => (row as any).documents.licenseChanges,
|
||||||
|
);
|
||||||
|
expect(intents).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ op: "add", fileId: "file-2" }),
|
||||||
|
expect.objectContaining({ op: "remove", fileId: "file-old" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { CompaniesService } from "./companies.service";
|
||||||
|
import { Company, CompanyStatus } from "./entities/company.entity";
|
||||||
|
import {
|
||||||
|
CompanyProfile,
|
||||||
|
ProfileStatus,
|
||||||
|
ProfileType,
|
||||||
|
} from "./entities/company-profile.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rejection hands the role back to the customer: they fix what was flagged
|
||||||
|
* and resubmit (`reapplyCompanyProfile` → Pending). The reviewer used to be
|
||||||
|
* able to skip that entirely and approve straight out of Rejected — granting
|
||||||
|
* the role over the documents that were just refused, while the customer's
|
||||||
|
* "please fix this" note was still on their screen.
|
||||||
|
*/
|
||||||
|
function makeService(status: ProfileStatus) {
|
||||||
|
const profile: Partial<CompanyProfile> = {
|
||||||
|
id: "profile-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
type: ProfileType.importer,
|
||||||
|
status,
|
||||||
|
reference: null,
|
||||||
|
reviewNote: status === ProfileStatus.Rejected ? "Licence expired" : null,
|
||||||
|
};
|
||||||
|
const company = {
|
||||||
|
id: "company-1",
|
||||||
|
status: CompanyStatus.Pending,
|
||||||
|
attributes: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const written: Partial<CompanyProfile>[] = [];
|
||||||
|
const profileRepo = {
|
||||||
|
update: jest.fn(async (_id: string, patch: Partial<CompanyProfile>) => {
|
||||||
|
written.push(patch);
|
||||||
|
Object.assign(profile, patch);
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
findOne: jest.fn(async () => profile),
|
||||||
|
};
|
||||||
|
const companyRepo = { findOne: jest.fn(async () => company), update: jest.fn() };
|
||||||
|
|
||||||
|
const companyProfilesRepo = {
|
||||||
|
findById: jest.fn(async () => profile),
|
||||||
|
generateReference: jest.fn(async () => "IM-A00001"),
|
||||||
|
};
|
||||||
|
const profilesRepo = {
|
||||||
|
// Onboarding submitted — the other gate in this method is not what these
|
||||||
|
// tests are about.
|
||||||
|
findByCompanyId: jest.fn(async () => [{ onboardingCompleted: true }]),
|
||||||
|
};
|
||||||
|
const filesService = { findWithOpenChangeRequest: jest.fn(async () => []) };
|
||||||
|
const dataSource = {
|
||||||
|
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) =>
|
||||||
|
cb({
|
||||||
|
findOne: jest.fn(async () => company),
|
||||||
|
getRepository: (entity: unknown) =>
|
||||||
|
entity === Company ? companyRepo : profileRepo,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const companyNotifier = { profileStatusChanged: jest.fn(), companyApproved: jest.fn() };
|
||||||
|
|
||||||
|
const service = new CompaniesService(
|
||||||
|
{} as never,
|
||||||
|
companyProfilesRepo as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
profilesRepo as never,
|
||||||
|
{} as never,
|
||||||
|
filesService as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
companyNotifier as never,
|
||||||
|
dataSource as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, profile, written, companyProfilesRepo };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("approving an operational role", () => {
|
||||||
|
it("refuses to approve a role the customer has not resubmitted", async () => {
|
||||||
|
const { service, companyProfilesRepo } = makeService(ProfileStatus.Rejected);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.setCompanyProfileStatus("profile-1", ProfileStatus.Active),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
// Refused before any reference could be minted against the rejected role.
|
||||||
|
expect(companyProfilesRepo.generateReference).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a reviewer undo their own rejection, and drops the note with it", async () => {
|
||||||
|
const { service, written } = makeService(ProfileStatus.Rejected);
|
||||||
|
|
||||||
|
await service.setCompanyProfileStatus("profile-1", ProfileStatus.Pending);
|
||||||
|
|
||||||
|
expect(written[0]).toMatchObject({
|
||||||
|
status: ProfileStatus.Pending,
|
||||||
|
reviewNote: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still approves a role that is awaiting its first decision", async () => {
|
||||||
|
const { service, written } = makeService(ProfileStatus.Pending);
|
||||||
|
|
||||||
|
await service.setCompanyProfileStatus("profile-1", ProfileStatus.Active);
|
||||||
|
|
||||||
|
expect(written[0]).toMatchObject({
|
||||||
|
status: ProfileStatus.Active,
|
||||||
|
reference: "IM-A00001",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1830,6 +1830,24 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A rejected role is waiting on the customer, not on the reviewer: nothing
|
||||||
|
// has been resubmitted, and the note telling them what to fix is still on
|
||||||
|
// their screen. Approving straight out of Rejected grants the very role that
|
||||||
|
// was refused, over the documents that were refused with it. The way back is
|
||||||
|
// the customer's own resubmission (`reapplyCompanyProfile` → Pending); a
|
||||||
|
// rejection made in error is undone by moving the role back to pending
|
||||||
|
// review first — the same shape as "withdraw the change request first" on
|
||||||
|
// the document gate below.
|
||||||
|
if (
|
||||||
|
status === ProfileStatus.Active &&
|
||||||
|
existing.status === ProfileStatus.Rejected
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"This role was rejected — the customer has to fix what was flagged and resubmit it before it can be approved. " +
|
||||||
|
"If the rejection was a mistake, move the role back to pending review first.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// A self-registered company is only reviewable once its owner submits the
|
// A self-registered company is only reviewable once its owner submits the
|
||||||
// onboarding wizard (markOnboardingComplete) — until then its profiles are
|
// onboarding wizard (markOnboardingComplete) — until then its profiles are
|
||||||
// half-filled drafts and approving one would mint a reference against an
|
// half-filled drafts and approving one would mint a reference against an
|
||||||
@@ -1958,7 +1976,14 @@ export class CompaniesService {
|
|||||||
status === ProfileStatus.Suspended
|
status === ProfileStatus.Suspended
|
||||||
) {
|
) {
|
||||||
patch.reviewNote = note ?? null;
|
patch.reviewNote = note ?? null;
|
||||||
} else if (status === ProfileStatus.Active) {
|
} else if (
|
||||||
|
status === ProfileStatus.Active ||
|
||||||
|
status === ProfileStatus.Pending
|
||||||
|
) {
|
||||||
|
// Pending only reaches here when a reviewer withdraws their own rejection
|
||||||
|
// (the customer's resubmission clears the note in `reapplyCompanyProfile`),
|
||||||
|
// so the reason they gave goes with it — leaving it would keep telling the
|
||||||
|
// customer to fix something nobody is waiting on any more.
|
||||||
patch.reviewNote = null;
|
patch.reviewNote = null;
|
||||||
}
|
}
|
||||||
if (status !== ProfileStatus.Pending) {
|
if (status !== ProfileStatus.Pending) {
|
||||||
@@ -2675,6 +2700,9 @@ export class CompaniesService {
|
|||||||
* with the role itself. Only for an already-approved role are they staged under
|
* with the role itself. Only for an already-approved role are they staged under
|
||||||
* the pending code and recorded as `add` intents on a pending change request —
|
* the pending code and recorded as `add` intents on a pending change request —
|
||||||
* a licence swap on a live role is a change; a licence on a new role is not.
|
* a licence swap on a live role is a change; a licence on a new role is not.
|
||||||
|
*
|
||||||
|
* An upload that answers a reviewer also retires the licence it answers (see
|
||||||
|
* below), so a correction never leaves both copies on file.
|
||||||
*/
|
*/
|
||||||
async addProfileLicenseFiles(
|
async addProfileLicenseFiles(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -2686,6 +2714,28 @@ export class CompaniesService {
|
|||||||
const gated = profile.status === ProfileStatus.Active;
|
const gated = profile.status === ProfileStatus.Active;
|
||||||
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
|
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
|
||||||
|
|
||||||
|
// An upload that answers the reviewer replaces what they refused; it does
|
||||||
|
// not sit next to it. Uploading only ever adds a row, so without this the
|
||||||
|
// refused licence stays listed in the portal and the backoffice beside the
|
||||||
|
// new one and nothing says which is current. Two things count as refused:
|
||||||
|
// the file the reviewer flagged for correction, and — when the whole role
|
||||||
|
// came back rejected — every licence that was already on file when they
|
||||||
|
// rejected it. Anything the customer uploaded *since* that decision is part
|
||||||
|
// of the same fix (a second page, a re-pick), so it survives, and an upload
|
||||||
|
// with nothing outstanding is a genuine addition and is left alone.
|
||||||
|
const rejectedAt =
|
||||||
|
profile.status === ProfileStatus.Rejected
|
||||||
|
? (profile.reviewedAt ?? null)
|
||||||
|
: null;
|
||||||
|
const superseded = rejectedAt
|
||||||
|
? (
|
||||||
|
await this.filesService.findByResource(profileId, LICENSE_RESOURCE)
|
||||||
|
).filter((f) => f.createdAt < rejectedAt)
|
||||||
|
: await this.filesService.findWithOpenChangeRequest(
|
||||||
|
[profileId],
|
||||||
|
LICENSE_RESOURCE,
|
||||||
|
);
|
||||||
|
|
||||||
const uploaded = await Promise.all(
|
const uploaded = await Promise.all(
|
||||||
files.map((file) =>
|
files.map((file) =>
|
||||||
this.filesService.upload({
|
this.filesService.upload({
|
||||||
@@ -2710,6 +2760,13 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retire what the upload supersedes, through the normal removal path so an
|
||||||
|
// approved role stages a `remove` intent (reviewed as a swap) while an
|
||||||
|
// unapproved one just drops the file.
|
||||||
|
for (const stale of superseded) {
|
||||||
|
await this.removeProfileLicenseFile(userId, profileId, stale.id);
|
||||||
|
}
|
||||||
|
|
||||||
// A fresh licence upload answers any correction the reviewer asked for on the
|
// A fresh licence upload answers any correction the reviewer asked for on the
|
||||||
// previous one, so the old row must stop blocking approval.
|
// previous one, so the old row must stop blocking approval.
|
||||||
await this.resolveDocumentChangeRequests(
|
await this.resolveDocumentChangeRequests(
|
||||||
|
|||||||
@@ -338,9 +338,11 @@ export function InvoiceStatusBadge({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline approval action buttons for a profile row.
|
* Inline approval action buttons for a profile row.
|
||||||
* Transitions: pending → approve / reject-with-note | rejected → approve (override) |
|
* Transitions: pending → approve / reject-with-note | rejected → undo-rejection (→ pending) |
|
||||||
* active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate.
|
* active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate.
|
||||||
* Rejecting captures a note the customer sees so they can fix and reapply.
|
* Rejecting captures a note the customer sees so they can fix and reapply — a
|
||||||
|
* rejected role is theirs to resubmit, so it cannot be approved from here until
|
||||||
|
* they do (the API refuses it); undoing the rejection is the only way back.
|
||||||
*
|
*
|
||||||
* `locked` (customer hasn't submitted onboarding) withholds the review decision
|
* `locked` (customer hasn't submitted onboarding) withholds the review decision
|
||||||
* only — there's no application to judge yet, and the API rejects the call
|
* only — there's no application to judge yet, and the API rejects the call
|
||||||
@@ -526,18 +528,33 @@ export function ProfileApprovalActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === "rejected") {
|
if (status === "rejected") {
|
||||||
if (!canSet("active")) return null;
|
// No Approve here: the role is waiting on the customer to fix what was
|
||||||
|
// flagged and resubmit it, and the API refuses rejected → active outright.
|
||||||
|
// All that's left is undoing a rejection that shouldn't have happened,
|
||||||
|
// which puts the role back in the queue rather than into service.
|
||||||
|
if (!canSet("pending")) return null;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Group gap={8} wrap="nowrap">
|
||||||
size="xs"
|
<Text size="xs" c="dimmed" fs="italic">
|
||||||
variant="light"
|
Awaiting customer resubmission
|
||||||
color="edr-green"
|
</Text>
|
||||||
radius="md"
|
<Tooltip
|
||||||
loading={isPending}
|
multiline
|
||||||
onClick={() => act("active")}
|
w={260}
|
||||||
>
|
label="Puts the role back in the pending queue and clears the rejection note. Use only if the rejection itself was a mistake — it does not approve the role."
|
||||||
Approve
|
>
|
||||||
</Button>
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
loading={isPending}
|
||||||
|
onClick={() => act("pending")}
|
||||||
|
>
|
||||||
|
Undo rejection
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user