fix: the fayda and etrade syncing

This commit is contained in:
Nathnael
2026-08-05 13:00:43 +00:00
parent a0f0be5be5
commit 8476a6a00e
7 changed files with 197 additions and 62 deletions

View File

@@ -280,15 +280,104 @@ describe("Fayda identity verification binds a person to the company", () => {
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
});
it("refuses to rename a verified person by hand", async () => {
const { service } = makeService({
it("stages nothing for a verified field an approved company resubmits", async () => {
// 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 },
files: [paper()],
});
await expect(
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 () => {

View File

@@ -739,6 +739,39 @@ export class CompaniesService {
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
* `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
// ownerEmail/ownerPhone themselves are locked below, once there is a
// 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 (
dto.companyEmail !== undefined &&
dto.companyEmail !== attrUpdates.ownerEmail
) {
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.",
);
}
if (attrUpdates.ownerEmail && dto.companyEmail !== undefined)
companyUpdates.email = attrUpdates.ownerEmail;
if (attrUpdates.ownerPhone && dto.companyPhone !== undefined)
companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone));
}
// 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) {
if (!attrUpdates[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
for (const field of IDENTITY_OWNED_FIELDS[subject]) {
const incoming = (dto as Record<string, unknown>)[field];
if (incoming === undefined) continue;
// The verification itself is allowed to write them; anything else is
// 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 as Record<string, unknown>)[field] === undefined) continue;
// The verification itself is what writes them; it must not be undone by
// the value this same call just copied into the patch.
if (dto.faydaIdentity && field in dto.faydaIdentity) continue;
const stored = company.attributes?.[field];
const same = field.endsWith("Phone")
? normalizeE164(String(incoming)) ===
normalizeE164(String(stored ?? ""))
: incoming === stored;
if (!same) {
throw new BadRequestException(
`${field} is set by the Fayda verification of this company's ${IDENTITY_LABEL[subject]} and cannot be edited. Re-verify to change it.`,
);
}
// A verification that supplied nothing for this field left no guarantee
// to protect, so it stays typeable. Matters most for the GM —
// `setGmSameAsOwner` copies `ownerEmail ?? null` onto
// `generalManagerEmail` while setting `gmFaydaSub`, and
// REQUIRED_COMPANY_INFO still demands that email, so holding a null
// here makes it required, hidden by the portal's "same as owner" card,
// 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.
await this.assertTinAvailable(company, dto.tin);
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 staged: Record<string, any> = {};
for (const [key, value] of Object.entries(fields)) {

View File

@@ -1,3 +1,6 @@
# Dev server port. Default: 5283.
PORT=5283
VITE_API_URL=http://localhost:3001
VITE_BASE_API_URL=http://localhost:3001

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"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});\"",
"build": "vite build",
"preview": "vite preview --port 5183",

View File

@@ -2,6 +2,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import { loadEnv } from "vite";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
@@ -10,7 +11,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const streamBrowserifyPath = require.resolve("stream-browserify");
export default defineConfig(() => {
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, __dirname, "");
return {
plugins: [react(), tailwindcss()],
resolve: {
@@ -31,7 +34,7 @@ export default defineConfig(() => {
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
},
server: {
port: 5183,
port: Number(env.PORT) || 5283,
host: "0.0.0.0",
},
test: {

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 3000 --clearScreen false",
"dev": "vite --clearScreen false",
"build": "tsc -b && vite build",
"preview": "vite preview --port 5173",
"lint": "eslint src",

View File

@@ -1,6 +1,7 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadEnv } from "vite";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
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 mantineHooks = path.resolve(__dirname, "node_modules/@mantine/hooks");
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
// 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,
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, __dirname, "");
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
// 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"],
},
server: {
port: 5173,
host: "0.0.0.0",
},
test: {
environment: "node",
},
optimizeDeps: {
include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"],
},
server: {
port: Number(env.PORT) || 5273,
host: "0.0.0.0",
},
test: {
environment: "node",
},
};
});