diff --git a/apps/console/src/app/Onboarding.tsx b/apps/console/src/app/Onboarding.tsx index eedb775..fe69225 100644 --- a/apps/console/src/app/Onboarding.tsx +++ b/apps/console/src/app/Onboarding.tsx @@ -4,17 +4,19 @@ * the org's managed treasury → land in the workspace. On Avalanche the KYB * decision and proof receipts are coordinated by services/api. */ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { motion } from "framer-motion"; import { BadgeCheck, Building2, Check, FileCheck2, Landmark, Loader2, ScanSearch, ShieldCheck, Sparkles, Users, Wallet } from "lucide-react"; import { useAccount, useConnect, useDisconnect, useSignMessage, useSwitchChain } from "wagmi"; -import { api, type OnboardingDraft, type SiweNonceResponse } from "../lib/api"; +import type { OnboardingStatus, OrgSummary, ProvisionTreasuryResponse } from "@benzo/types"; +import { ACTIVE_ORG_KEY, api, type OnboardingStatusSubscription, type SiweNonceResponse } from "../lib/api"; import { friendlyError } from "../lib/format"; import { CHAIN_ID, NETWORK_LABEL } from "../lib/network"; +import { useConsole } from "../lib/store"; import { Logo } from "../ui/Logo"; import { StageVideo } from "../ui/StageVideo"; import { EASE } from "../ui/motion"; -import { Button, Card, Pill } from "../ui/primitives"; +import { Button, Card } from "../ui/primitives"; import { Field, Input, Select, useToast } from "../ui/controls"; // Team is intentionally NOT a step: it collected nothing and gated nothing (a @@ -32,7 +34,8 @@ const STEPS = [ type StepKey = (typeof STEPS)[number]["key"]; export function Onboarding({ onDone }: { onDone: () => void }) { - return ; + const { session } = useConsole(); + return session ? : ; } // ----------------------------------------------------------------- auth / SIWE @@ -130,52 +133,144 @@ function AuthShell({ onAuthed }: { onAuthed: () => void }) { } // ----------------------------------------------------------------- wizard +interface BusinessDraft { + name?: string; + legalName?: string; + country?: string; + entityType?: string; + registrationNumber?: string; + taxId?: string; + complianceZoneId?: string; +} + +type BusyState = "org" | "onboarding" | "treasury" | "finish" | null; + +const EERC_STEPS = [ + { key: "kyc", label: "KYC approval", icon: FileCheck2 }, + { key: "allowlist", label: "Address allowlist", icon: ShieldCheck }, + { key: "gas", label: "Gas drip", icon: Landmark }, + { key: "registration", label: "eERC registration", icon: Wallet }, +] as const; + export function Wizard({ onDone }: { onDone: () => void }) { const toast = useToast(); + const { refresh } = useConsole(); + const streamRef = useRef(null); const [stepIdx, setStepIdx] = useState(0); - const [draft, setDraft] = useState({ country: "US", entityType: "C-Corp", complianceZoneId: "zone_us" }); - const [kyb, setKyb] = useState(null); - const [mvk, setMvk] = useState(null); - const [busy, setBusy] = useState(false); + const [draft, setDraft] = useState({ country: "US", entityType: "C-Corp", complianceZoneId: "zone_us" }); + const [createdOrg, setCreatedOrg] = useState(null); + const [onboarding, setOnboarding] = useState(null); + const [onboardingError, setOnboardingError] = useState(null); + const [treasury, setTreasury] = useState(null); + const [busy, setBusy] = useState(null); const step = STEPS[stepIdx]; - const set = (p: Partial) => setDraft((d) => ({ ...d, ...p })); + const set = (p: Partial) => setDraft((d) => ({ ...d, ...p })); + const slug = slugify(draft.name ?? ""); + const onboardingComplete = onboarding?.status === "complete"; const canNext = - step.key === "org" ? !!draft.name && !!draft.legalName : - step.key === "kyb" ? kyb?.status === "approved" : - step.key === "treasury" ? !!mvk : + step.key === "org" ? !!draft.name?.trim() : + step.key === "kyb" ? onboardingComplete : + step.key === "treasury" ? !!treasury?.address : true; + useEffect(() => () => streamRef.current?.close(), []); + + async function createOrg() { + if (createdOrg) return true; + const name = draft.name?.trim(); + if (!name) return false; + setBusy("org"); + try { + const { org } = await api.createOrg({ name, slug }); + localStorage.setItem(ACTIVE_ORG_KEY, org.id); + setCreatedOrg(org); + toast({ title: "Workspace created", tone: "success" }); + return true; + } catch (e) { + toast({ title: friendlyError(e), tone: "danger" }); + return false; + } finally { + setBusy(null); + } + } + async function next() { + if (step.key === "org") { + const ok = await createOrg(); + if (!ok) return; + } if (stepIdx < STEPS.length - 1) { - // persist draft as we go (resumable) - if the save fails, let them keep going - // but warn so they know progress might not be picked up if they reload. - void api.saveOnboarding(draft).catch(() => - toast({ title: "Couldn't save your progress - you can keep going, but it may not resume if you reload.", tone: "danger" }), - ); setStepIdx((i) => i + 1); } else { - setBusy(true); + setBusy("finish"); try { - await api.finishOnboarding(); + await refresh(); onDone(); } catch (e) { toast({ title: friendlyError(e), tone: "danger" }); - setBusy(false); + } finally { + setBusy(null); } } } - async function registerMvk() { - setBusy(true); + async function runOnboarding() { + streamRef.current?.close(); + setBusy("onboarding"); + setOnboardingError(null); try { - const r = await api.provisionTreasury(); - setMvk(r); - toast({ title: r.onChain ? "Your managed treasury is ready" : "Treasury request prepared, but network registration did not complete", tone: r.onChain ? "success" : "danger" }); + const started = await api.startOnboarding({ name: draft.legalName?.trim() || draft.name?.trim(), country: draft.country }); + setOnboarding(started.onboarding); + if (started.onboarding.status === "failed") { + setOnboardingError(started.onboarding.error ?? "eERC onboarding failed."); + setBusy(null); + return; + } + if (started.onboarding.status === "complete") { + setBusy(null); + return; + } + streamRef.current = api.subscribeOnboardingStatus( + (status) => { + setOnboarding(status); + if (status.status === "failed") { + setOnboardingError(status.error ?? "eERC onboarding failed."); + setBusy(null); + streamRef.current = null; + } + if (status.status === "complete") { + setBusy(null); + streamRef.current = null; + } + }, + (error) => setOnboardingError(friendlyError(error, "Waiting for eERC onboarding status.")), + ); + } catch (e) { + toast({ title: friendlyError(e), tone: "danger" }); + setBusy(null); + } + } + + async function provisionTreasury() { + const orgId = createdOrg?.id; + if (!orgId) { + setStepIdx(0); + toast({ title: "Create the workspace before provisioning treasury.", tone: "danger" }); + return; + } + setBusy("treasury"); + try { + const response = await api.provisionTreasury(orgId); + setTreasury(response); + toast({ + title: response.registered ? "Your managed treasury is ready" : "Treasury provisioned, registration is still pending", + tone: response.registered ? "success" : "danger", + }); } catch (e) { toast({ title: friendlyError(e), tone: "danger" }); } finally { - setBusy(false); + setBusy(null); } } @@ -206,24 +301,32 @@ export function Wizard({ onDone }: { onDone: () => void }) { {step.key === "org" ? ( - set({ name: e.target.value })} placeholder="Company name" data-testid="org-name" /> - set({ legalName: e.target.value })} placeholder="Legal company name" data-testid="org-legal" /> + set({ name: e.target.value })} placeholder="Company name" data-testid="org-name" disabled={!!createdOrg} /> + set({ legalName: e.target.value })} placeholder="Legal company name" data-testid="org-legal" disabled={!!createdOrg} />
- set({ country: e.target.value })} disabled={!!createdOrg}> - set({ entityType: e.target.value })} disabled={!!createdOrg}>
+
+ Workspace slug {slug || "company-name"} +
+ {createdOrg ? ( +
+ {createdOrg.name} is active for this setup. +
+ ) : null}
) : step.key === "kyb" ? ( - +
- set({ registrationNumber: e.target.value })} placeholder="Registration number" disabled={kyb?.status === "approved"} /> - set({ taxId: e.target.value })} placeholder="Tax identifier" disabled={kyb?.status === "approved"} /> + set({ registrationNumber: e.target.value })} placeholder="Registration number" disabled={busy === "onboarding" || onboardingComplete} /> + set({ taxId: e.target.value })} placeholder="Tax identifier" disabled={busy === "onboarding" || onboardingComplete} />
- toast({ title: m, tone: "danger" })} /> +
) : step.key === "zone" ? ( @@ -236,13 +339,14 @@ export function Wizard({ onDone }: { onDone: () => void }) { ) : step.key === "treasury" ? ( - {mvk ? ( + {treasury ? (
-
Your managed treasury is ready{mvk.onChain ? "" : " · network registration pending"}
- {mvk.txHash ?
ref {mvk.txHash}
: null} +
Your managed treasury is ready{treasury.registered ? "" : " · registration pending"}
+
address {treasury.address}
+ {treasury.registrationTxHash ?
eERC registration {treasury.registrationTxHash}
: null}
) : ( - + )}
) : ( @@ -251,9 +355,10 @@ export function Wizard({ onDone }: { onDone: () => void }) { - + - + +
@@ -265,7 +370,7 @@ export function Wizard({ onDone }: { onDone: () => void }) {
-
@@ -275,114 +380,98 @@ export function Wizard({ onDone }: { onDone: () => void }) { ); } -// ----------------------------------------------------------------- KYB (on-chain) -// The crafted verification moment. The checks animate while the REAL on-chain -// attestation is posted (org_account, issuer-signed); the verified panel reflects -// the on-chain decision read back from chain. Honest: no backend flag, a real tx. -const KYB_STEPS = [ - { label: "Business registration", icon: FileCheck2 }, - { label: "Beneficial owners", icon: Users }, - { label: "Sanctions / OFAC screen", icon: ScanSearch }, - { label: "Posting decision on-chain", icon: Landmark }, -] as const; - -function KybVerify({ - draft, kyb, onVerified, onError, +// ----------------------------------------------------------------- eERC onboarding +function EercOnboarding({ + onboarding, busy, error, onRun, }: { - draft: OnboardingDraft; - kyb: OnboardingDraft["kyb"] | null; - onVerified: (k: NonNullable) => void; - onError: (m: string) => void; + onboarding: OnboardingStatus | null; + busy: boolean; + error: string | null; + onRun: () => void; }) { - const [phase, setPhase] = useState<"idle" | "verifying" | "done">(kyb?.status === "approved" ? "done" : "idle"); - const [lit, setLit] = useState(0); // how many of the first 3 screening checks have completed - - async function run() { - setPhase("verifying"); - setLit(0); - // Play the first three screening checks on a cadence; the 4th (on-chain) stays - // pending until the real attestation tx resolves. - const timers = [0, 1, 2].map((i) => setTimeout(() => setLit(i + 1), 650 * (i + 1))); - try { - const r = await api.submitKyb(draft); - timers.forEach(clearTimeout); - setLit(3); - if (r.status !== "approved") { - setPhase("idle"); - onError("Verification did not pass. Please check the details and try again."); - return; - } - onVerified(r); - // brief beat so the on-chain step visibly completes before the reveal - setTimeout(() => setPhase("done"), 360); - } catch { - timers.forEach(clearTimeout); - setPhase("idle"); - onError("Couldn't post the verification on-chain. Please try again."); - } - } - - if (phase === "done" && kyb?.status === "approved") { - const ref = kyb.txHash ? `${kyb.txHash.slice(0, 8)}…${kyb.txHash.slice(-6)}` : kyb.inquiryRef; - return ( - -
- -
-
Verified {kyb.onChain ? "on-chain" : ""}
-
{kyb.provider}
-
-
-
{kyb.checks.map((c) => {c.replace(/_/g, " ")})}
- {kyb.onChain ? ( -
- org_account · ref {ref} -
- ) : null} -
- Signed by the KYB issuer key and recorded in org_account. The console reads this decision from the chain, not from a backend flag. -
-
- ); - } - - if (phase === "verifying") { + if (!onboarding) { return ( -
-
- {KYB_STEPS.map((s, i) => { - const isChain = i === 3; - const done = isChain ? false : i < lit; - const active = isChain ? lit >= 3 : i === lit; - return ( -
- - {done ? : active ? : } - - {s.label} -
- ); - })} -
-
Recording the decision in org_account. This is a real transaction and takes a few seconds.
+
+
Start the eERC setup for your signed-in wallet.
+ + {error ?

{error}

: null}
); } + const activeIndex = EERC_STEPS.findIndex((s) => !stepDone(onboarding, s.key)); + const done = onboarding.status === "complete"; + return ( -
-
We screen your registration and owners, then record the decision on-chain.
- +
+
+ + {done ? : busy ? : } + +
+
{onboardingStatusLabel(onboarding)}
+
{onboarding.address} · chain {onboarding.chainId}
+
+
+
+ {EERC_STEPS.map((s, i) => { + const complete = stepDone(onboarding, s.key); + const active = !done && onboarding.status !== "failed" && (activeIndex === -1 ? i === EERC_STEPS.length - 1 : i === activeIndex); + const txHash = stepTx(onboarding, s.key); + return ( +
+ + {complete ? : active ? : } + +
+
{s.label}
+ {txHash ?
tx {txHash}
: null} +
+
+ ); + })} +
+ {error || onboarding.error ?

{error ?? onboarding.error}

: null} + {done ?
The wallet is approved for eERC private transfers. Treasury provisioning can now register the managed account.
: null}
); } +function slugify(input: string): string { + return input + .trim() + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-") || "workspace"; +} + +function onboardingStatusLabel(onboarding: OnboardingStatus | null): string { + if (!onboarding) return "Not started"; + return { + pending_kyc: "KYC pending", + kyc_approved: "KYC approved", + allowlisted: "Wallet allowlisted", + gas_dripped: "Gas dripped", + awaiting_registration: "Awaiting registration", + complete: "Complete", + failed: "Failed", + }[onboarding.status]; +} + +function stepDone(onboarding: OnboardingStatus, key: (typeof EERC_STEPS)[number]["key"]): boolean { + return !!onboarding.steps[key].completedAt; +} + +function stepTx(onboarding: OnboardingStatus, key: (typeof EERC_STEPS)[number]["key"]): string | null { + return key === "allowlist" || key === "gas" ? onboarding.steps[key].txHash : null; +} + function Step({ title, hint, children }: { title: string; hint: string; children: React.ReactNode }) { return (
diff --git a/apps/console/src/app/RootGate.tsx b/apps/console/src/app/RootGate.tsx index 93874dc..b3a1c46 100644 --- a/apps/console/src/app/RootGate.tsx +++ b/apps/console/src/app/RootGate.tsx @@ -1,9 +1,9 @@ /** - * RootGate - decides between SIWE sign-in and the workspace. Real mode trusts the - * cookie-backed /auth/me probe in the store; demo mode keeps the old local - * onboarding flag so the seeded showcase still boots straight into the shell. + * RootGate - decides between SIWE sign-in, no-org onboarding, and the workspace. + * Real mode trusts the cookie-backed /auth/me probe in the store; demo mode keeps + * the old local onboarding flag so the seeded showcase still boots into the shell. */ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Shell } from "./Shell"; import { Onboarding } from "./Onboarding"; import { DesktopOnly, useIsDesktop } from "./DesktopOnly"; @@ -16,11 +16,23 @@ export function RootGate() { // Demo mode skips SIWE onboarding entirely and boots into the Shell (the // desktop-only gate below still applies — this stays a desktop product). const [onboarded, setOnboarded] = useState(() => DEMO_MODE || localStorage.getItem("benzo.console.onboarded") === "1"); + const [orgOnboarding, setOrgOnboarding] = useState(false); function finish() { localStorage.setItem("benzo.console.onboarded", "1"); setOnboarded(true); void refresh(); } + function finishOrgOnboarding() { + setOrgOnboarding(false); + void refresh(); + } + useEffect(() => { + if (!session) { + setOrgOnboarding(false); + return; + } + if (!session.activeOrg) setOrgOnboarding(true); + }, [session]); if (!isDesktop) return ; if (DEMO_MODE) return onboarded ? : ; if (loading && !session) { @@ -30,5 +42,6 @@ export function RootGate() {
); } - return session ? : void refresh()} />; + if (session && (orgOnboarding || !session.activeOrg)) return ; + return session?.activeOrg ? : void refresh()} />; } diff --git a/apps/console/src/demo/api.ts b/apps/console/src/demo/api.ts index cbe46d1..3ea5223 100644 --- a/apps/console/src/demo/api.ts +++ b/apps/console/src/demo/api.ts @@ -8,9 +8,14 @@ */ import type { Counterparty, + CreateOrgResponse, Invoice, + OnboardingStatus, + OnboardingStatusResponse, PaymentOrder, PayrollBatch, + ProvisionTreasuryResponse, + StartOnboardingResponse, ViewingGrant, } from "@benzo/types"; import type { @@ -30,6 +35,7 @@ import { createDemoDb, dashboardSummary, treasuryView, usd } from "./data"; // PURE-annotated so a normal build (DEMO_MODE folds to false → demoApi unused) // tree-shakes the entire demo graph away, leaving the production bundle unchanged. const db = /* @__PURE__ */ createDemoDb(); +let demoMockKyc: { name?: string; country?: string } | undefined; // ---- fake-chain helpers --------------------------------------------------- function randHex(len: number): string { @@ -65,6 +71,7 @@ const byId = (arr: T[], id: string) => arr.find((x) => const READ = 140; const PROVE = 640; const SETTLE = 720; +const ONBOARDING_STEP = 520; function activeOrg() { const org = db.session.activeOrg; @@ -72,6 +79,38 @@ function activeOrg() { return org; } +const ONBOARDING_STATUSES = ["pending_kyc", "kyc_approved", "allowlisted", "gas_dripped", "awaiting_registration", "complete"] as const; + +function setDemoOnboardingStatus(status: OnboardingStatus["status"], mockKycPayload?: { name?: string; country?: string }): OnboardingStatus { + const order = ONBOARDING_STATUSES.indexOf(status as (typeof ONBOARDING_STATUSES)[number]); + const timestamp = now(); + const kycDone = order >= 1; + const allowlistDone = order >= 2; + const gasDone = order >= 3; + const registrationChecked = order >= 4; + const registrationDone = order >= 5; + db.onboardingStatus = { + ...db.onboardingStatus, + status, + error: status === "failed" ? db.onboardingStatus.error ?? "Onboarding failed." : null, + updatedAt: timestamp, + mockKyc: kycDone + ? { + approvedAt: timestamp, + payload: mockKycPayload ?? db.onboardingStatus.mockKyc?.payload ?? {}, + provider: "demo", + } + : null, + steps: { + kyc: { completedAt: kycDone ? timestamp : null, provider: kycDone ? "demo" : null }, + allowlist: { completedAt: allowlistDone ? timestamp : null, result: allowlistDone ? { ok: true } : null, txHash: allowlistDone ? fakeTx() : null }, + gas: { completedAt: gasDone ? timestamp : null, result: gasDone ? { ok: true } : null, txHash: gasDone ? fakeTx() : null }, + registration: { completedAt: registrationDone ? timestamp : null, lastCheckedAt: registrationChecked ? timestamp : null }, + }, + }; + return clone(db.onboardingStatus); +} + export const demoApi = { // ---- session / status --------------------------------------------------- session: async () => (await delay(READ), clone(db.session)), @@ -380,14 +419,54 @@ export const demoApi = { return clone(p); }, - // ---- onboarding / auth (unused: demo boots straight into the Shell) ----- - onboarding: async () => ({}), - saveOnboarding: async (patch: unknown) => patch as Record, - submitKyb: async () => ({ status: "approved" as const, provider: "demo", inquiryRef: `kyb_${randHex(6)}`, checks: ["identity", "sanctions"], onChain: true, txHash: fakeTx() }), - kybStatus: async () => ({ status: "approved" as const, inquiryRef: `kyb_${randHex(6)}`, onChain: true }), - provisionTreasury: async () => ({ onChain: true, txHash: fakeTx(), mvkRoot: fakeRoot(), treasuryAddress: db.publicAddress }), - registerOwnerMvk: async () => ({ onChain: true, txHash: fakeTx(), mvkRoot: fakeRoot() }), - finishOnboarding: async () => clone(db.session), + // ---- onboarding / auth ------------------------------------------------- + createOrg: async (body: { name: string; slug: string }): Promise => { + await delay(READ); + const org = { id: `org_${randHex(6)}`, name: body.name, slug: body.slug, role: "owner" as const, createdAt: now() }; + db.session.orgs = [org, ...db.session.orgs.filter((existing) => existing.id !== org.id)]; + db.session.activeOrg = org; + db.session.role = "owner"; + return { org: clone(org), role: "owner" }; + }, + startOnboarding: async (mockKyc?: { name?: string; country?: string }): Promise => { + await delay(READ); + demoMockKyc = mockKyc; + db.onboardingStatus = { + ...db.onboardingStatus, + id: `onb_${randHex(6)}`, + status: "pending_kyc", + error: null, + createdAt: now(), + updatedAt: now(), + mockKyc: null, + steps: { + kyc: { completedAt: null, provider: null }, + allowlist: { completedAt: null, result: null, txHash: null }, + gas: { completedAt: null, result: null, txHash: null }, + registration: { completedAt: null, lastCheckedAt: null }, + }, + }; + return { jobId: `job_${randHex(8)}`, onboarding: clone(db.onboardingStatus) }; + }, + onboardingStatus: async (): Promise => (await delay(READ), { onboarding: clone(db.onboardingStatus) }), + subscribeOnboardingStatus: (onStatus: (onboarding: OnboardingStatus) => void): { close: () => void } => { + let closed = false; + const timers = ONBOARDING_STATUSES.map((status, index) => window.setTimeout(() => { + if (closed) return; + onStatus(setDemoOnboardingStatus(status, demoMockKyc)); + }, index * ONBOARDING_STEP)); + return { + close: () => { + closed = true; + timers.forEach((timer) => window.clearTimeout(timer)); + }, + }; + }, + provisionTreasury: async (_orgId: string): Promise => { + await delay(SETTLE); + db.treasuryProvision = { ...db.treasuryProvision, registered: true, consented: true, registrationTxHash: fakeTx() }; + return clone(db.treasuryProvision); + }, orgs: async () => ({ orgs: clone(db.session.orgs) }), siweNonce: async () => ({ nonce: randHex(16) }), siweVerify: async () => ({ user: clone(db.session.user) }), diff --git a/apps/console/src/demo/data.ts b/apps/console/src/demo/data.ts index fe5524e..3a9386d 100644 --- a/apps/console/src/demo/data.ts +++ b/apps/console/src/demo/data.ts @@ -15,8 +15,10 @@ import type { LedgerEntry, LiveStatusResponse, Member, + OnboardingStatus, PaymentOrder, PayrollBatch, + ProvisionTreasuryResponse, TreasuryView, ViewingGrant, } from "@benzo/types"; @@ -46,6 +48,8 @@ export interface DemoDb { integrations: Integration[]; invites: OrgInvite[]; ledger: LedgerEntry[]; + onboardingStatus: OnboardingStatus; + treasuryProvision: ProvisionTreasuryResponse; /** Private (shielded) pool total, minor units — grows when "Make private" runs. */ privateTotal: string; /** Public liquid USDC balance, minor units. */ @@ -64,6 +68,7 @@ const shielded = (handle: string, spend: string) => ({ }); export function createDemoDb(): DemoDb { + const publicAddress = "0x9Fb2c7A11e4D3f6B8a0C15e2D9f4A7c3B6e1D8a2"; const owner: Member = { id: "mem_owner", orgId: ORG_ID, @@ -338,6 +343,33 @@ export function createDemoDb(): DemoDb { { id: "vg_1", kind: "grant", title: "Grant Thornton LLP · Q2", status: "active", amountLabel: "—", at: ISO("2026-07-01T00:00:00Z") }, ]; + const onboardingStatus: OnboardingStatus = { + id: "onb_demo", + userId: owner.id, + address: DEMO_OWNER_ADDRESS, + chainEnv: "demo", + chainId: 43113, + status: "complete", + error: null, + createdAt: ISO("2026-02-03"), + updatedAt: ISO("2026-02-03T00:05:00Z"), + mockKyc: { approvedAt: ISO("2026-02-03T00:01:00Z"), payload: { name: org.name, country: org.country }, provider: "demo" }, + steps: { + kyc: { completedAt: ISO("2026-02-03T00:01:00Z"), provider: "demo" }, + allowlist: { completedAt: ISO("2026-02-03T00:02:00Z"), result: { ok: true }, txHash: fakeHash("a110") }, + gas: { completedAt: ISO("2026-02-03T00:03:00Z"), result: { ok: true }, txHash: fakeHash("9a5") }, + registration: { completedAt: ISO("2026-02-03T00:04:00Z"), lastCheckedAt: ISO("2026-02-03T00:04:00Z") }, + }, + }; + + const treasuryProvision: ProvisionTreasuryResponse = { + address: publicAddress, + custody: "managed", + registered: true, + consented: true, + registrationTxHash: fakeHash("eerc"), + }; + return { session, live: { live: true, mode: "live", missing: [] }, @@ -352,9 +384,11 @@ export function createDemoDb(): DemoDb { integrations, invites, ledger, + onboardingStatus, + treasuryProvision, privateTotal: usd(842300), publicUnits: usd(48250), - publicAddress: "0x9Fb2c7A11e4D3f6B8a0C15e2D9f4A7c3B6e1D8a2", + publicAddress, usdcIssuer: "0x5425890298aed601595a70AB815c96711a31Bc65", dashboardActivity, }; diff --git a/apps/console/src/lib/api.test.ts b/apps/console/src/lib/api.test.ts index c0513da..1869af1 100644 --- a/apps/console/src/lib/api.test.ts +++ b/apps/console/src/lib/api.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import type { CreatePaymentRequest } from "@benzo/types"; +import type { CreatePaymentRequest, OnboardingStatus } from "@benzo/types"; import { ACTIVE_ORG_KEY, api, apiHref } from "./api"; function jsonResponse(body: unknown, status = 200): Response { @@ -15,6 +15,27 @@ function callHeaders(call: unknown[]): Headers { : new Headers(); } +function onboardingStatus(status: OnboardingStatus["status"]): OnboardingStatus { + return { + id: "onb_1", + userId: "usr_1", + address: "0x1234567890abcdef1234567890abcdef12345678", + chainEnv: "testnet", + chainId: 43113, + status, + error: status === "failed" ? "failed" : null, + createdAt: "2026-07-10T00:00:00.000Z", + updatedAt: "2026-07-10T00:00:02.000Z", + mockKyc: null, + steps: { + kyc: { completedAt: status === "pending_kyc" ? null : "2026-07-10T00:00:01.000Z", provider: status === "pending_kyc" ? null : "mock" }, + allowlist: { completedAt: ["allowlisted", "gas_dripped", "awaiting_registration", "complete"].includes(status) ? "2026-07-10T00:00:02.000Z" : null, result: null, txHash: null }, + gas: { completedAt: ["gas_dripped", "awaiting_registration", "complete"].includes(status) ? "2026-07-10T00:00:03.000Z" : null, result: null, txHash: null }, + registration: { completedAt: status === "complete" ? "2026-07-10T00:00:04.000Z" : null, lastCheckedAt: ["awaiting_registration", "complete"].includes(status) ? "2026-07-10T00:00:04.000Z" : null }, + }, + }; +} + describe("console API idempotency", () => { afterEach(() => { localStorage.clear(); @@ -73,12 +94,11 @@ describe("console API idempotency", () => { amount: { amount: "10000000", assetCode: "USDC" }, } satisfies CreatePaymentRequest; const actions: Array<() => Promise> = [ - () => api.saveOnboarding({ name: "Acme" }), + () => api.createOrg({ name: "Acme", slug: "acme" }), + () => api.startOnboarding({ name: "Acme Inc.", country: "US" }), () => api.siweVerify("m", "0xsig"), () => api.logout(), - () => api.submitKyb({ legalName: "Acme Inc." }), - () => api.provisionTreasury(), - () => api.finishOnboarding(), + () => api.provisionTreasury("org_1"), () => api.proveBalance("1"), () => api.proveTotal(), () => api.proveSolvency(), @@ -129,6 +149,73 @@ describe("console API idempotency", () => { expect(callHeaders(fetchMock.mock.calls[1]).get("idempotency-key")).toBe(firstKey); }); + it("uses the real org and eERC onboarding endpoints", async () => { + const complete = onboardingStatus("complete"); + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ org: { id: "org_1", name: "Acme", slug: "acme", role: "owner", createdAt: "2026-07-10T00:00:00.000Z" }, role: "owner" })) + .mockResolvedValueOnce(jsonResponse({ jobId: "job_1", onboarding: onboardingStatus("pending_kyc") }, 202)) + .mockResolvedValueOnce(jsonResponse({ onboarding: complete })) + .mockResolvedValueOnce(jsonResponse({ address: "0xtreasury", custody: "managed", registered: true, consented: true, registrationTxHash: "0xreg" }, 201)); + vi.stubGlobal("fetch", fetchMock); + + await expect(api.createOrg({ name: "Acme", slug: "acme" })).resolves.toMatchObject({ role: "owner", org: { id: "org_1" } }); + await expect(api.startOnboarding({ name: "Acme Inc.", country: "US" })).resolves.toMatchObject({ jobId: "job_1" }); + await expect(api.onboardingStatus()).resolves.toMatchObject({ onboarding: { status: "complete" } }); + await expect(api.provisionTreasury("org_1")).resolves.toMatchObject({ address: "0xtreasury", custody: "managed" }); + + expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ + apiHref("/orgs"), + apiHref("/onboarding/start"), + apiHref("/onboarding/status"), + apiHref("/orgs/org_1/treasury"), + ]); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ method: "POST", body: JSON.stringify({ name: "Acme", slug: "acme" }), credentials: "include" }); + expect(fetchMock.mock.calls[1][1]).toMatchObject({ method: "POST", body: JSON.stringify({ mockKyc: { name: "Acme Inc.", country: "US" } }), credentials: "include" }); + expect(callHeaders(fetchMock.mock.calls[2]).get("idempotency-key")).toBeNull(); + expect(fetchMock.mock.calls[3][1]).toMatchObject({ method: "POST", body: JSON.stringify({ consent: true }), credentials: "include" }); + }); + + it("subscribes to onboarding status with credentialed EventSource", () => { + class FakeEventSource { + static instances: FakeEventSource[] = []; + url: string | URL; + init?: EventSourceInit; + private listeners = new Map) => void>>(); + + constructor(url: string | URL, init?: EventSourceInit) { + this.url = url; + this.init = init; + FakeEventSource.instances.push(this); + } + + addEventListener(type: string, listener: EventListenerOrEventListenerObject | null) { + if (typeof listener !== "function") return; + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener as (event: MessageEvent) => void); + this.listeners.set(type, listeners); + } + + close() {} + + emit(type: string, data: unknown) { + const event = new MessageEvent(type, { data: JSON.stringify(data) }); + for (const listener of this.listeners.get(type) ?? []) listener(event); + } + } + vi.stubGlobal("EventSource", FakeEventSource as unknown as typeof EventSource); + const seen: OnboardingStatus["status"][] = []; + + const subscription = api.subscribeOnboardingStatus((status) => seen.push(status.status)); + const source = FakeEventSource.instances[0]; + source.emit("status", { onboarding: onboardingStatus("kyc_approved") }); + source.emit("status", { onboarding: onboardingStatus("complete") }); + subscription.close(); + + expect(source.url).toBe(apiHref("/onboarding/status/stream")); + expect(source.init).toMatchObject({ withCredentials: true }); + expect(seen).toEqual(["kyc_approved", "complete"]); + }); + it("loads proof receipts through the authenticated API without mutation idempotency", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse([{ id: "prf_1", action: "payroll.policy.cap", vkId: "SPENDCAP", verified: true, createdAt: "2026-06-26T00:00:00.000Z" }])); vi.stubGlobal("fetch", fetchMock); diff --git a/apps/console/src/lib/api.ts b/apps/console/src/lib/api.ts index 327dd18..b88b491 100644 --- a/apps/console/src/lib/api.ts +++ b/apps/console/src/lib/api.ts @@ -10,6 +10,8 @@ import type { ApproveRequest, AuthSession, Counterparty, + CreateOrgRequest, + CreateOrgResponse, CreateInvoiceRequest, CreatePaymentRequest, CreatePayrollRequest, @@ -20,9 +22,13 @@ import type { LedgerEntry, LiveStatusResponse, Member, + OnboardingStatus, + OnboardingStatusResponse, OrgSummary, PaymentOrder, PayrollBatch, + ProvisionTreasuryResponse, + StartOnboardingResponse, TreasuryView, ViewingGrant, } from "@benzo/types"; @@ -242,18 +248,101 @@ async function http(path: string, init?: RequestInit): Promise { } } -export interface OnboardingDraft { - name?: string; - legalName?: string; - country?: string; - entityType?: string; - registrationNumber?: string; - taxId?: string; - beneficialOwners?: Array<{ name: string; ownership?: string }>; - complianceZoneId?: string; - team?: Array<{ email: string; role: string }>; - kyb?: { status: "approved" | "pending" | "rejected" | "unverified"; provider: string; inquiryRef: string; checks: string[]; onChain: boolean; txHash?: string }; - mvk?: { onChain: boolean; txHash?: string; mvkRoot?: string }; +export interface OnboardingStatusSubscription { + close: () => void; +} + +export type OnboardingStatusHandler = (onboarding: OnboardingStatus) => void; +export type OnboardingStatusErrorHandler = (error: Error) => void; + +function terminalOnboardingStatus(status: OnboardingStatus["status"]): boolean { + return status === "complete" || status === "failed"; +} + +function parseOnboardingEvent(data: string): OnboardingStatus | null { + if (!data) return null; + const parsed = JSON.parse(data) as OnboardingStatus | OnboardingStatusResponse; + return "onboarding" in parsed ? parsed.onboarding : parsed; +} + +function subscribeOnboardingStatus( + onStatus: OnboardingStatusHandler, + onError?: OnboardingStatusErrorHandler, +): OnboardingStatusSubscription { + let closed = false; + let source: EventSource | null = null; + let pollTimer: number | null = null; + + const close = () => { + closed = true; + source?.close(); + source = null; + if (pollTimer) window.clearTimeout(pollTimer); + pollTimer = null; + }; + + const emit = (onboarding: OnboardingStatus) => { + onStatus(onboarding); + if (terminalOnboardingStatus(onboarding.status)) close(); + }; + + const poll = () => { + if (closed) return; + void realApi.onboardingStatus().then( + ({ onboarding }) => { + if (closed) return; + emit(onboarding); + if (!terminalOnboardingStatus(onboarding.status)) { + pollTimer = window.setTimeout(poll, 2_000); + } + }, + (error) => { + if (closed) return; + onError?.(error instanceof Error ? error : new Error("Onboarding status polling failed.")); + pollTimer = window.setTimeout(poll, 2_000); + }, + ); + }; + + const startPolling = () => { + source?.close(); + source = null; + poll(); + }; + + if (typeof EventSource === "undefined") { + startPolling(); + return { close }; + } + + try { + source = new EventSource(apiHref("/onboarding/status/stream"), { withCredentials: true }); + source.addEventListener("status", (event) => { + try { + const onboarding = parseOnboardingEvent((event as MessageEvent).data); + if (onboarding) emit(onboarding); + } catch (error) { + onError?.(error instanceof Error ? error : new Error("Onboarding status stream returned malformed data.")); + } + }); + source.addEventListener("terminal", (event) => { + try { + const onboarding = parseOnboardingEvent((event as MessageEvent).data); + if (onboarding) emit(onboarding); + } catch { + close(); + } + close(); + }); + source.onerror = () => { + if (closed) return; + startPolling(); + }; + } catch { + startPolling(); + } + + return { close }; } export interface SiweNonceResponse { @@ -314,18 +403,14 @@ const realApi = { siweVerify: (message: string, signature: string) => http("/auth/verify", { method: "POST", body: JSON.stringify({ message, signature }) }), logout: () => http<{ ok: true }>("/auth/logout", { method: "POST", body: "{}" }).finally(clearHostedAuthState), - onboarding: () => http("/onboarding"), - saveOnboarding: (patch: OnboardingDraft) => - http("/onboarding", { method: "PATCH", body: JSON.stringify(patch) }), - submitKyb: (patch: OnboardingDraft) => - http>("/onboarding/kyb", { method: "POST", body: JSON.stringify(patch) }), - kybStatus: () => - http<{ status: "unverified" | "pending" | "approved" | "rejected"; inquiryRef: string; onChain: boolean }>("/onboarding/kyb-status"), - provisionTreasury: () => - http<{ onChain: boolean; txHash?: string; mvkRoot?: string; treasuryAddress?: string }>("/treasury/provision", { method: "POST", body: "{}" }), - registerOwnerMvk: () => - http<{ onChain: boolean; txHash?: string; mvkRoot?: string }>("/treasury/provision", { method: "POST", body: "{}" }), - finishOnboarding: () => http("/onboarding/finish", { method: "POST", body: "{}" }), + createOrg: (body: CreateOrgRequest) => + http("/orgs", { method: "POST", body: JSON.stringify(body) }), + startOnboarding: (mockKyc?: { name?: string; country?: string }) => + http("/onboarding/start", { method: "POST", body: JSON.stringify(mockKyc ? { mockKyc } : {}) }), + onboardingStatus: () => http("/onboarding/status"), + subscribeOnboardingStatus, + provisionTreasury: (orgId: string) => + http(`/orgs/${encodeURIComponent(orgId)}/treasury`, { method: "POST", body: JSON.stringify({ consent: true }) }), live: () => http("/live"), dashboard: () => http("/dashboard"), treasury: () => http("/treasury"), diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index aa8926a..b749757 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -7,7 +7,7 @@ import type { Account, Counterparty } from "./accounts.js"; import type { Approval, ApprovalPolicy } from "./approvals.js"; import type { ComplianceZone, DisclosureTier, GrantScope, ViewingGrant } from "./compliance.js"; -import type { Money, Timestamp } from "./common.js"; +import type { EvmAddress, Money, Timestamp } from "./common.js"; import type { Integration, IntegrationProvider } from "./integrations.js"; import type { Invoice, LineItem } from "./invoices.js"; import type { LedgerEntry } from "./ledger.js"; @@ -32,6 +32,67 @@ export interface AppSession { export type AuthSession = AppSession; +// ---- org creation / eERC onboarding -------------------------------------- + +export interface CreateOrgRequest { + name: string; + slug: string; +} + +export interface CreateOrgResponse { + org: OrgSummary; + role: "owner"; +} + +export type OnboardingLifecycleStatus = + | "pending_kyc" + | "kyc_approved" + | "allowlisted" + | "gas_dripped" + | "awaiting_registration" + | "complete" + | "failed"; + +export interface OnboardingStatus { + id: string; + userId: string; + address: EvmAddress; + chainEnv: string; + chainId: number; + status: OnboardingLifecycleStatus; + error: string | null; + createdAt: Timestamp; + updatedAt: Timestamp; + mockKyc: { + approvedAt: Timestamp; + payload: Record; + provider: string; + } | null; + steps: { + kyc: { completedAt: Timestamp | null; provider: string | null }; + allowlist: { completedAt: Timestamp | null; result: unknown | null; txHash: string | null }; + gas: { completedAt: Timestamp | null; result: unknown | null; txHash: string | null }; + registration: { completedAt: Timestamp | null; lastCheckedAt: Timestamp | null }; + }; +} + +export interface StartOnboardingResponse { + jobId: string; + onboarding: OnboardingStatus; +} + +export interface OnboardingStatusResponse { + onboarding: OnboardingStatus; +} + +export interface ProvisionTreasuryResponse { + address: EvmAddress; + custody: "managed"; + registered: boolean; + consented: boolean; + registrationTxHash: string | null; +} + // ---- dashboard / treasury (read-optimized projections) -------------------- export interface ActivityItem { @@ -142,6 +203,10 @@ export interface InviteMemberRequest { role: Role; } +export interface ProvisionTreasuryRequest { + consent: true; +} + export interface CreateViewingGrantRequest { auditorName: string; auditorPubKey: string; @@ -184,7 +249,12 @@ export const ENDPOINTS = { session: { method: "GET", path: "/api/auth/me" }, authLogout: { method: "POST", path: "/api/auth/logout" }, orgs: { method: "GET", path: "/api/orgs" }, + createOrg: { method: "POST", path: "/api/orgs" }, org: { method: "GET", path: "/api/orgs/:id" }, + onboardingStart: { method: "POST", path: "/api/onboarding/start" }, + onboardingStatus: { method: "GET", path: "/api/onboarding/status" }, + onboardingStatusStream: { method: "GET", path: "/api/onboarding/status/stream" }, + provisionTreasury: { method: "POST", path: "/api/orgs/:id/treasury" }, dashboard: { method: "GET", path: "/api/dashboard" }, treasury: { method: "GET", path: "/api/treasury" }, proveBalance: { method: "POST", path: "/api/treasury/prove-balance" },