mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix: the fayda and etrade syncing
This commit is contained in:
@@ -280,15 +280,104 @@ describe("Fayda identity verification binds a person to the company", () => {
|
|||||||
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
|
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses to rename a verified person by hand", async () => {
|
it("stages nothing for a verified field an approved company resubmits", async () => {
|
||||||
const { service } = makeService({
|
// Approving it could not move the live row — the verified value is written
|
||||||
|
// back over it — so it must never reach a reviewer as a pending change.
|
||||||
|
const { service, deps } = makeService({
|
||||||
|
status: CompanyStatus.Active,
|
||||||
|
attributes: { ...OWNER_VERIFIED, ownerEmail: "abebe@example.com" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateProfile("user-1", {
|
||||||
|
companyEmail: "someone-else@example.com",
|
||||||
|
} as never),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
expect(deps.changeRequestRepo.create).not.toHaveBeenCalled();
|
||||||
|
expect(deps.changeRequestRepo.update).not.toHaveBeenCalled();
|
||||||
|
expect(deps.companiesRepo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The verified value wins, and it wins by overwriting rather than by
|
||||||
|
// rejecting: nobody types these fields, so a submission that disagrees is a
|
||||||
|
// stale form echoing itself back, not an edit. Failing it would block a save
|
||||||
|
// the customer never made — and leave them no way through, since re-verifying
|
||||||
|
// returns the same value they are being 400'd for.
|
||||||
|
it("overwrites a hand-renamed verified person with the verified name", async () => {
|
||||||
|
const { service, ctx } = makeService({
|
||||||
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
|
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
|
||||||
files: [paper()],
|
files: [paper()],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.updateProfile("user-1", { poaName: "Someone Else" } as never),
|
service.updateProfile("user-1", { poaName: "Someone Else" } as never),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).resolves.toBeDefined();
|
||||||
|
expect(ctx.attributes.poaName).toBe(POA_VERIFIED.poaName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fayda's email and phone claims are optional — a verification can prove the
|
||||||
|
// person and return neither. Holding the company mirrors to "the owner is
|
||||||
|
// verified" rather than to "the verification supplied this value" would
|
||||||
|
// clobber the fallbacks the portal is built to send (account email, eTrade's
|
||||||
|
// registered phone) with nothing at all. OWNER_VERIFIED is exactly that
|
||||||
|
// shape: a sub, no contact details.
|
||||||
|
it("keeps company contact details a Fayda verification never supplied", async () => {
|
||||||
|
const { service, deps } = makeService({
|
||||||
|
attributes: { ...OWNER_VERIFIED },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateProfile("user-1", {
|
||||||
|
companyEmail: "account@example.com",
|
||||||
|
companyPhone: "+251911777777",
|
||||||
|
} as never),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
|
||||||
|
expect(patch.email).toBe("account@example.com");
|
||||||
|
expect(patch.phone).toBe("+251911777777");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overwrites company contact details the verification did supply", async () => {
|
||||||
|
const { service, deps } = makeService({
|
||||||
|
attributes: {
|
||||||
|
...OWNER_VERIFIED,
|
||||||
|
ownerEmail: "abebe@example.com",
|
||||||
|
ownerPhone: "+251911000000",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateProfile("user-1", {
|
||||||
|
companyEmail: "someone-else@example.com",
|
||||||
|
companyPhone: "+251911999999",
|
||||||
|
} as never),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
|
||||||
|
expect(patch.email).toBe("abebe@example.com");
|
||||||
|
expect(patch.phone).toBe("+251911000000");
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Same as owner" copies `ownerEmail ?? null` onto the GM while setting
|
||||||
|
// `gmFaydaSub`. Locking that null made generalManagerEmail required by
|
||||||
|
// onboarding, hidden by the portal's link card and unwritable at once.
|
||||||
|
it("lets the GM's details be typed when the copied owner identity carried none", async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
attributes: {
|
||||||
|
...OWNER_VERIFIED,
|
||||||
|
gmSameAsOwner: true,
|
||||||
|
gmFaydaSub: "owner-sub",
|
||||||
|
generalManagerName: "Abebe Bikila",
|
||||||
|
generalManagerEmail: null,
|
||||||
|
generalManagerPhone: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateProfile("user-1", {
|
||||||
|
generalManagerEmail: "gm@example.com",
|
||||||
|
generalManagerPhone: "+251911888888",
|
||||||
|
} as never),
|
||||||
|
).resolves.toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never locks or gates the general manager — it is not the verified subject", async () => {
|
it("never locks or gates the general manager — it is not the verified subject", async () => {
|
||||||
|
|||||||
@@ -739,6 +739,39 @@ export class CompaniesService {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `UpdateProfileDto` keys this company's completed verifications own — the
|
||||||
|
* ones `mapProfileDtoToCompanyUpdates` overwrites with the verified value
|
||||||
|
* whatever a request submits for them.
|
||||||
|
*
|
||||||
|
* A key only lands here once there is a verified value to hold it to: Fayda's
|
||||||
|
* email and phone claims are optional, and a verification that returned
|
||||||
|
* neither owns nothing to overwrite with.
|
||||||
|
*
|
||||||
|
* The map is the enforcement; this is the list used to keep those keys out of
|
||||||
|
* a change request in the first place. If the two ever drift the map still
|
||||||
|
* wins — the cost is a staged field that approving turns out not to move.
|
||||||
|
*/
|
||||||
|
private faydaOwnedKeys(company: Company): string[] {
|
||||||
|
const attrs = company.attributes ?? {};
|
||||||
|
const held = (key: string) => {
|
||||||
|
const v = attrs[key];
|
||||||
|
return v !== null && v !== undefined && v !== "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const keys: string[] = [];
|
||||||
|
if (attrs.ownerFaydaSub) {
|
||||||
|
// The Company-column mirrors of the owner's verified contact details.
|
||||||
|
if (held("ownerEmail")) keys.push("companyEmail");
|
||||||
|
if (held("ownerPhone")) keys.push("companyPhone");
|
||||||
|
}
|
||||||
|
for (const subject of IDENTITY_SUBJECTS) {
|
||||||
|
if (!attrs[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
||||||
|
keys.push(...IDENTITY_OWNED_FIELDS[subject].filter(held));
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translate an UpdateProfileDto (or a staged change-request snapshot) into a
|
* Translate an UpdateProfileDto (or a staged change-request snapshot) into a
|
||||||
* `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/
|
* `Company` patch: scalar columns plus a merged `attributes` blob (contact/GM/
|
||||||
@@ -829,49 +862,46 @@ export class CompaniesService {
|
|||||||
// lets the customer type them once verified) — lock them the same way
|
// lets the customer type them once verified) — lock them the same way
|
||||||
// ownerEmail/ownerPhone themselves are locked below, once there is a
|
// ownerEmail/ownerPhone themselves are locked below, once there is a
|
||||||
// verified owner to lock them to.
|
// verified owner to lock them to.
|
||||||
|
//
|
||||||
|
// Keyed on the verified VALUE, not on `ownerFaydaSub`: Fayda's email and
|
||||||
|
// phone claims are optional, so a verification can prove the person while
|
||||||
|
// supplying neither (see completeIdentityVerification's conditional
|
||||||
|
// spreads). The portal falls back to the account email / eTrade's
|
||||||
|
// registered phone in exactly that case and submits it on every save of
|
||||||
|
// the company step — locking against an absent value would 400 that
|
||||||
|
// forever, and re-verifying could never clear it because Fayda still has
|
||||||
|
// nothing to return.
|
||||||
if (attrUpdates.ownerFaydaSub) {
|
if (attrUpdates.ownerFaydaSub) {
|
||||||
if (
|
if (attrUpdates.ownerEmail && dto.companyEmail !== undefined)
|
||||||
dto.companyEmail !== undefined &&
|
companyUpdates.email = attrUpdates.ownerEmail;
|
||||||
dto.companyEmail !== attrUpdates.ownerEmail
|
if (attrUpdates.ownerPhone && dto.companyPhone !== undefined)
|
||||||
) {
|
companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone));
|
||||||
throw new BadRequestException(
|
|
||||||
"companyEmail is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
dto.companyPhone !== undefined &&
|
|
||||||
normalizeE164(dto.companyPhone) !==
|
|
||||||
normalizeE164(String(attrUpdates.ownerPhone ?? ""))
|
|
||||||
) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
"companyPhone is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renaming a Fayda-verified person by hand would launder the guarantee
|
// Renaming a Fayda-verified person by hand would launder the guarantee
|
||||||
// away, so the fields the verification owns are refused once it exists.
|
// away, so the verification keeps these fields: a submission that disagrees
|
||||||
|
// is overwritten with the verified value rather than rejected — the same
|
||||||
|
// doctrine `applyEtradeSourcedFields` uses for eTrade's fields, and for the
|
||||||
|
// same reason. The customer never types these (the portal derives them, and
|
||||||
|
// a stale form or a re-render can echo back something else entirely), so a
|
||||||
|
// 400 punishes a save they never made while an overwrite lands the truth.
|
||||||
for (const subject of IDENTITY_SUBJECTS) {
|
for (const subject of IDENTITY_SUBJECTS) {
|
||||||
if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
||||||
for (const field of IDENTITY_OWNED_FIELDS[subject]) {
|
for (const field of IDENTITY_OWNED_FIELDS[subject]) {
|
||||||
const incoming = (dto as Record<string, unknown>)[field];
|
if ((dto as Record<string, unknown>)[field] === undefined) continue;
|
||||||
if (incoming === undefined) continue;
|
// The verification itself is what writes them; it must not be undone by
|
||||||
// The verification itself is allowed to write them; anything else is
|
// the value this same call just copied into the patch.
|
||||||
// compared against what is already stored, not against the value this
|
|
||||||
// same call just copied into the patch. Phones are compared normalized:
|
|
||||||
// a form that re-renders +251911000000 as 0911000000 is echoing the
|
|
||||||
// stored value back, not trying to change it.
|
|
||||||
if (dto.faydaIdentity && field in dto.faydaIdentity) continue;
|
if (dto.faydaIdentity && field in dto.faydaIdentity) continue;
|
||||||
const stored = company.attributes?.[field];
|
const stored = company.attributes?.[field];
|
||||||
const same = field.endsWith("Phone")
|
// A verification that supplied nothing for this field left no guarantee
|
||||||
? normalizeE164(String(incoming)) ===
|
// to protect, so it stays typeable. Matters most for the GM —
|
||||||
normalizeE164(String(stored ?? ""))
|
// `setGmSameAsOwner` copies `ownerEmail ?? null` onto
|
||||||
: incoming === stored;
|
// `generalManagerEmail` while setting `gmFaydaSub`, and
|
||||||
if (!same) {
|
// REQUIRED_COMPANY_INFO still demands that email, so holding a null
|
||||||
throw new BadRequestException(
|
// here makes it required, hidden by the portal's "same as owner" card,
|
||||||
`${field} is set by the Fayda verification of this company's ${IDENTITY_LABEL[subject]} and cannot be edited. Re-verify to change it.`,
|
// and unwritable all at once.
|
||||||
);
|
if (stored === null || stored === undefined || stored === "") continue;
|
||||||
}
|
attrUpdates[field] = stored;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -977,6 +1007,11 @@ export class CompaniesService {
|
|||||||
// for review with the live row left intact.
|
// for review with the live row left intact.
|
||||||
await this.assertTinAvailable(company, dto.tin);
|
await this.assertTinAvailable(company, dto.tin);
|
||||||
const fields = this.pickDefined(dto);
|
const fields = this.pickDefined(dto);
|
||||||
|
// Drop what the verifications own before anything is staged. Approving one
|
||||||
|
// of these could not change the live row — mapProfileDtoToCompanyUpdates
|
||||||
|
// writes the verified value back over it — so showing it to a reviewer
|
||||||
|
// asks them to rule on a change that does not exist.
|
||||||
|
for (const key of this.faydaOwnedKeys(company)) delete fields[key];
|
||||||
const selfService: Record<string, any> = {};
|
const selfService: Record<string, any> = {};
|
||||||
const staged: Record<string, any> = {};
|
const staged: Record<string, any> = {};
|
||||||
for (const [key, value] of Object.entries(fields)) {
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
# Dev server port. Default: 5283.
|
||||||
|
PORT=5283
|
||||||
|
|
||||||
VITE_API_URL=http://localhost:3001
|
VITE_API_URL=http://localhost:3001
|
||||||
VITE_BASE_API_URL=http://localhost:3001
|
VITE_BASE_API_URL=http://localhost:3001
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 5183 --clearScreen false",
|
"dev": "vite --clearScreen false",
|
||||||
"prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"",
|
"prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --port 5183",
|
"preview": "vite preview --port 5183",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import path from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
import { loadEnv } from "vite";
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
@@ -10,7 +11,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const streamBrowserifyPath = require.resolve("stream-browserify");
|
const streamBrowserifyPath = require.resolve("stream-browserify");
|
||||||
|
|
||||||
export default defineConfig(() => {
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, __dirname, "");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
@@ -31,7 +34,7 @@ export default defineConfig(() => {
|
|||||||
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5183,
|
port: Number(env.PORT) || 5283,
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 3000 --clearScreen false",
|
"dev": "vite --clearScreen false",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview --port 5173",
|
"preview": "vite preview --port 5173",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { loadEnv } from "vite";
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
@@ -13,26 +14,30 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const mantineCore = path.resolve(__dirname, "node_modules/@mantine/core");
|
const mantineCore = path.resolve(__dirname, "node_modules/@mantine/core");
|
||||||
const mantineHooks = path.resolve(__dirname, "node_modules/@mantine/hooks");
|
const mantineHooks = path.resolve(__dirname, "node_modules/@mantine/hooks");
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [react(), tailwindcss()],
|
const env = loadEnv(mode, __dirname, "");
|
||||||
resolve: {
|
|
||||||
alias: {
|
return {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
plugins: [react(), tailwindcss()],
|
||||||
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
resolve: {
|
||||||
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
|
alias: {
|
||||||
"@mantine/core": mantineCore,
|
"@": path.resolve(__dirname, "./src"),
|
||||||
"@mantine/hooks": mantineHooks,
|
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
||||||
|
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
|
||||||
|
"@mantine/core": mantineCore,
|
||||||
|
"@mantine/hooks": mantineHooks,
|
||||||
|
},
|
||||||
|
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||||
},
|
},
|
||||||
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
optimizeDeps: {
|
||||||
},
|
include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"],
|
||||||
optimizeDeps: {
|
},
|
||||||
include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"],
|
server: {
|
||||||
},
|
port: Number(env.PORT) || 5273,
|
||||||
server: {
|
host: "0.0.0.0",
|
||||||
port: 5173,
|
},
|
||||||
host: "0.0.0.0",
|
test: {
|
||||||
},
|
environment: "node",
|
||||||
test: {
|
},
|
||||||
environment: "node",
|
};
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user