From 0dbe55d50281b7816968fa40dad0d2845f1dd96a Mon Sep 17 00:00:00 2001 From: Hitakshi Arora Date: Sat, 11 Jul 2026 09:23:58 +0530 Subject: [PATCH] feat(console): wire payroll to the real CSV run lifecycle Rewrite the Payroll screen around the backend's real lifecycle: compose a CSV (handles/addresses + amounts) -> validate (POST /orgs/:id/payroll, showing the summary + per-row resolved/failed items) -> start (POST /payroll/:runId/start) -> live progress via 2s polling of GET /payroll/:runId (pending -> proving -> submitted -> confirmed counts) with pause/resume, and clear treasury-underfunded guidance. Persists the active run per org and reattaches on reload. Removed the demo-era contractor/rate-card + 4-proof model and the nonexistent payroll list read. Demo mode animates a full run. Epic #58 step E. Closes #66. --- apps/console/src/demo/api.ts | 277 ++++- apps/console/src/demo/data.ts | 39 +- apps/console/src/lib/api.test.ts | 51 +- apps/console/src/lib/api.ts | 148 ++- apps/console/src/lib/store.test.tsx | 2 - apps/console/src/lib/store.tsx | 12 +- apps/console/src/screens/Dashboard.test.tsx | 3 +- apps/console/src/screens/Dashboard.tsx | 4 +- apps/console/src/screens/Payroll.test.tsx | 363 +++--- apps/console/src/screens/Payroll.tsx | 1203 +++++++------------ apps/console/src/ui/primitives.tsx | 7 +- packages/types/src/api.ts | 25 +- packages/types/src/payroll.ts | 182 +-- 13 files changed, 1098 insertions(+), 1218 deletions(-) diff --git a/apps/console/src/demo/api.ts b/apps/console/src/demo/api.ts index 066e9a3..65fdea1 100644 --- a/apps/console/src/demo/api.ts +++ b/apps/console/src/demo/api.ts @@ -15,8 +15,17 @@ import type { OnboardingStatus, OnboardingStatusResponse, PaymentOrder, - PayrollBatch, + CreatePayrollRunRequest, + CreatePayrollRunResponse, + PayrollProgressCounts, + PayrollRun, + PayrollRunItem, + PayrollRunResponse, + PayrollToken, + PausePayrollRunResponse, ProvisionTreasuryResponse, + ResumePayrollRunResponse, + StartPayrollRunResponse, StartOnboardingResponse, TreasuryDepositsResponse, ViewingGrant, @@ -25,8 +34,6 @@ import type { ApprovalProgressView, OnChainRef, OrgInvite, - PayrollPolicyProofResponse, - PayrollProofResponse, PrivateAuditAnchorResponse, PrivateAuditPacketResponse, RecoveryStatus, @@ -75,6 +82,14 @@ const READ = 140; const PROVE = 640; const SETTLE = 720; const ONBOARDING_STEP = 520; +const PAYROLL_STEP = 420; + +const TOKEN_ID: Record = { + usdc: "avalanche-fuji:usdc", + eurc: "avalanche-fuji:eurc", +}; + +const payrollTimers = new Map>>(); function activeOrg() { const org = db.session.activeOrg; @@ -82,6 +97,125 @@ function activeOrg() { return org; } +function parseTokenAmount(value: string): bigint { + const clean = value.trim().replace(/,/g, ""); + const [whole = "0", frac = ""] = clean.split("."); + return BigInt(whole || "0") * 1_000_000n + BigInt(frac.padEnd(6, "0").slice(0, 6) || "0"); +} + +function formatTokenAmount(minor: bigint): string { + const whole = minor / 1_000_000n; + const frac = (minor % 1_000_000n).toString().padStart(6, "0").replace(/0+$/, ""); + return `${whole}${frac ? `.${frac}` : ""}`; +} + +function normalizePayrollAmount(raw: string): string | null { + const value = raw.trim().replace(/,/g, ""); + if (!/^(0|[1-9]\d*)(\.\d{1,6})?$/.test(value)) return null; + if (parseTokenAmount(value) <= 0n) return null; + return formatTokenAmount(parseTokenAmount(value)); +} + +function resolvePayrollRecipient(input: string): string | null { + const recipient = input.trim(); + if (/^0x[a-fA-F0-9]{40}$/.test(recipient)) return recipient; + const match = db.counterparties.find((c) => c.paymentAddress?.shielded?.toLowerCase() === recipient.toLowerCase()); + return match?.paymentAddress?.spendPub ?? null; +} + +function payrollProgress(items: PayrollRunItem[]): PayrollProgressCounts { + const confirmed = items.filter((item) => item.status === "confirmed").length; + return { + total: items.length, + pending: items.filter((item) => item.status === "pending").length, + proving: items.filter((item) => item.status === "proving").length, + submitted: items.filter((item) => item.status === "submitted").length, + confirmed, + failed: items.filter((item) => item.status === "failed").length, + proved: confirmed, + }; +} + +function clearPayrollTimers(runId: string) { + for (const timer of payrollTimers.get(runId) ?? []) clearTimeout(timer); + payrollTimers.delete(runId); +} + +function payrollSnapshot(runId: string): PayrollRunResponse { + const run = byId(db.payrollRuns, runId); + if (!run) throw new Error("not found"); + return { + run: clone(run), + progress: clone(db.payrollProgress[runId] ?? payrollProgress(db.payrollItems[runId] ?? [])), + items: clone(db.payrollItems[runId] ?? []), + }; +} + +function setPayrollItemStatus(runId: string, rowIndex: number, status: PayrollRunItem["status"]) { + const run = byId(db.payrollRuns, runId); + if (!run || run.status !== "running") return; + const item = (db.payrollItems[runId] ?? []).find((i) => i.rowIndex === rowIndex); + if (!item || item.status === "failed" || item.status === "confirmed") return; + item.status = status; + run.updatedAt = now(); + db.payrollProgress[runId] = payrollProgress(db.payrollItems[runId] ?? []); + if (db.payrollProgress[runId].confirmed + db.payrollProgress[runId].failed >= db.payrollProgress[runId].total) { + run.status = db.payrollProgress[runId].failed > 0 ? "failed" : "complete"; + run.updatedAt = now(); + if (run.status === "complete") db.privateTotal = (BigInt(db.privateTotal) - parseTokenAmount(run.totalAmount)).toString(); + clearPayrollTimers(runId); + } +} + +function schedulePayrollProgress(runId: string) { + clearPayrollTimers(runId); + const run = byId(db.payrollRuns, runId); + if (!run || run.status !== "running") return; + const timers: Array> = []; + const remaining = (db.payrollItems[runId] ?? []).filter((item) => item.status !== "confirmed" && item.status !== "failed"); + remaining.forEach((item, index) => { + const base = index * PAYROLL_STEP * 3; + timers.push(setTimeout(() => setPayrollItemStatus(runId, item.rowIndex, "proving"), base + PAYROLL_STEP)); + timers.push(setTimeout(() => setPayrollItemStatus(runId, item.rowIndex, "submitted"), base + PAYROLL_STEP * 2)); + timers.push(setTimeout(() => setPayrollItemStatus(runId, item.rowIndex, "confirmed"), base + PAYROLL_STEP * 3)); + }); + payrollTimers.set(runId, timers); +} + +function parsePayrollCsv(csv: string, token: PayrollToken): Pick { + const rows = csv.split(/\r?\n/) + .map((line, index) => ({ line: line.trim(), rowIndex: index + 1 })) + .filter((row) => row.line.length > 0); + const dataRows = rows[0] && /^recipient\s*,\s*amount/i.test(rows[0].line) ? rows.slice(1) : rows; + const items = dataRows.map(({ line, rowIndex }) => { + const cells = line.split(",").map((cell) => cell.trim()); + const [recipientInput = "", rawAmount = ""] = cells; + const amount = normalizePayrollAmount(rawAmount); + const resolvedAddress = recipientInput ? resolvePayrollRecipient(recipientInput) : null; + let error: string | null = null; + if (cells.length !== 2 || !recipientInput || !rawAmount) error = "Expected recipient,amount."; + else if (!amount) error = "Amount must be a positive decimal with up to 6 places."; + else if (!resolvedAddress) error = "Recipient did not resolve to a Benzo handle or address."; + return { + rowIndex, + recipientInput, + resolvedAddress, + amount: amount ?? rawAmount, + status: error ? "failed" as const : "pending" as const, + error, + }; + }); + const totalAmount = formatTokenAmount(items.reduce((sum, item) => item.status === "failed" ? sum : sum + parseTokenAmount(item.amount), 0n)); + const invalid = items.filter((item) => item.status === "failed").length; + return { + status: invalid > 0 || items.length === 0 ? "failed" : "ready", + token, + tokenId: TOKEN_ID[token], + summary: { total: items.length, valid: items.length - invalid, invalid, totalAmount, token, tokenId: TOKEN_ID[token] }, + items, + }; +} + 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 { @@ -130,7 +264,6 @@ export const demoApi = { members: async () => (await delay(READ), clone(db.members)), counterparties: async () => (await delay(READ), clone(db.counterparties)), payments: async () => (await delay(READ), clone(db.payments)), - payrolls: async () => (await delay(READ), clone(db.payrolls)), invoices: async () => (await delay(READ), clone(db.invoices)), grants: async () => (await delay(READ), clone(db.grants)), policies: async () => (await delay(READ), clone(db.policies)), @@ -170,7 +303,7 @@ export const demoApi = { proveKyb: async () => (await delay(PROVE), { ok: true, onChain: true, jurisdiction: "US", tier: "verified", ref: fakeRef("KYB credential", "KYB") }), periodTotalAttestation: async (period: string) => { await delay(PROVE); - const total = db.payrolls.filter((p) => p.status === "completed").reduce((s, p) => s + BigInt(p.total.amount), 0n).toString(); + const total = db.payrollRuns.filter((p) => p.status === "complete").reduce((s, p) => s + parseTokenAmount(p.totalAmount), 0n).toString(); return { live: true, org: activeOrg().name, period, total, onChain: true, vkId: "ORGSUM", verifier: fakeVerifier(), network: NETWORK, root: fakeRoot(), proof: {}, publicInputs: [total], issuedAt: now() }; }, @@ -263,62 +396,98 @@ export const demoApi = { return { ...clone(p), progress }; }, - // ---- payroll (the flagship cinematic) ----------------------------------- - createPayroll: async (body: { period: string; source: PayrollBatch["source"]; lines: Array<{ counterpartyId: string }> }) => { + // ---- payroll ------------------------------------------------------------ + createPayrollRun: async (_orgId: string, body: CreatePayrollRunRequest): Promise => { await delay(SETTLE); - const lines = body.lines.map((l) => { - const c = byId(db.counterparties, l.counterpartyId); - return { counterpartyId: l.counterpartyId, amount: c?.payRate?.amount ?? "0", rate: c?.payRate?.amount ?? "0", status: "pending" as const }; - }); - const total = lines.reduce((s, l) => s + BigInt(l.amount), 0n).toString(); - const batch: PayrollBatch = { id: `pr_${randHex(6)}`, orgId: activeOrg().id, period: body.period, source: body.source, status: "needs_approval", lines, total: { amount: total, assetCode: "USDC" }, createdAt: now() }; - db.payrolls.unshift(batch); - return clone(batch); - }, - proveFunded: async (id: string): Promise => { - await delay(PROVE); - const b = byId(db.payrolls, id); - const runTotal = b?.total.amount ?? "0"; - return { funded: true, onChain: true, runTotal, cap: usd(5000), provenAt: now(), ref: fakeRef("Payroll funded", "ORGBAL", [{ k: "Covers run total", v: "yes" }]) }; - }, - provePolicy: async (id: string, cap: string): Promise => { - await delay(PROVE); - const b = byId(db.payrolls, id); - const lines = (b?.lines ?? []).map((l) => ({ counterpartyId: l.counterpartyId, capProof: { withinCap: true, onChain: true }, screenProof: { innocent: true, onChain: true } })); - return { ok: true, onChain: true, lines, ref: fakeRef("Spending policy", "SPENDCAP", [{ k: "Per-payout cap", v: cap }]) }; + const parsed = parsePayrollCsv(body.csv, body.token ?? "usdc"); + const timestamp = now(); + const runId = `pr_${randHex(6)}`; + const run: PayrollRun = { + id: runId, + orgId: activeOrg().id, + status: parsed.status, + itemCount: parsed.summary.total, + totalAmount: parsed.summary.totalAmount, + token: parsed.token, + tokenId: parsed.tokenId, + createdBy: db.session.user.id, + error: parsed.status === "failed" ? "CSV validation failed." : null, + createdAt: timestamp, + updatedAt: timestamp, + }; + db.payrollRuns.unshift(run); + db.payrollItems[runId] = parsed.items; + db.payrollProgress[runId] = payrollProgress(parsed.items); + return { runId, ...clone(parsed) }; }, - proveComputation: async (id: string): Promise => { - await delay(PROVE); - const b = byId(db.payrolls, id); - return { ok: true, onChain: true, runTotal: b?.total.amount ?? "0", provenAt: now(), ref: fakeRef("Payroll computation", "PAYCOMP") }; + getPayrollRun: async (runId: string): Promise => { + await delay(READ); + return payrollSnapshot(runId); }, - proveApproval: async (id: string): Promise => { - await delay(PROVE); - return { approved: true, onChain: true, approvers: 2, threshold: 2, memberCount: db.members.length, provenAt: now(), ref: fakeRef("Anonymous approval", "ORGAUTH", [{ k: "Approvers", v: "2-of-4" }]) }; + subscribePayrollProgress: (runId: string, onProgress: (event: { runId: string; status: PayrollRun["status"]; progress: PayrollProgressCounts }) => void, onError?: (error: Error) => void) => { + let closed = false; + let timer: ReturnType | null = null; + const close = () => { + closed = true; + if (timer) clearTimeout(timer); + }; + const tick = () => { + if (closed) return; + void demoApi.getPayrollRun(runId).then( + ({ run, progress }) => { + if (closed) return; + onProgress({ runId: run.id, status: run.status, progress }); + if (run.status === "complete" || run.status === "failed") { + close(); + return; + } + timer = setTimeout(tick, 450); + }, + (error) => { + if (closed) return; + onError?.(error instanceof Error ? error : new Error("Payroll progress polling failed.")); + timer = setTimeout(tick, 450); + }, + ); + }; + tick(); + return { close }; }, - // One click settles the run: mutate every line to paid + on-chain, attach the - // three proofs, and return with a satisfied release gate so the ceremony settles. - approvePayroll: async (id: string) => { + startPayrollRun: async (runId: string): Promise => { await delay(SETTLE); - const b = byId(db.payrolls, id); - if (!b) throw new Error("not found"); - b.status = "completed"; - b.lines = b.lines.map((l) => { - const payable = !!byId(db.counterparties, l.counterpartyId)?.paymentAddress?.shielded; - return payable - ? { ...l, status: "paid" as const, onChain: true, txHash: fakeTx(), capProof: { withinCap: true, onChain: true }, screenProof: { innocent: true, onChain: true } } - : { ...l, status: "paid" as const, onChain: true, txHash: fakeTx() }; - }); - b.fundedProof = { funded: true, onChain: true, provenAt: now() }; - b.approvalProof = { approved: true, onChain: true, approvers: 2, threshold: 2, memberCount: db.members.length, provenAt: now() }; - b.computationProof = { ok: true, onChain: true, runTotal: b.total.amount, provenAt: now() }; - // No `progress` field => Payroll treats this click as the final step and settles. - return clone(b); + const run = byId(db.payrollRuns, runId); + if (!run) throw new Error("not found"); + if (run.status !== "ready" && run.status !== "paused") throw new Error("Run is not ready to start."); + run.status = "running"; + run.updatedAt = now(); + for (const item of db.payrollItems[runId] ?? []) { + if (item.status !== "failed" && item.status !== "confirmed") item.status = "pending"; + } + db.payrollProgress[runId] = payrollProgress(db.payrollItems[runId] ?? []); + schedulePayrollProgress(runId); + const progress = clone(db.payrollProgress[runId]); + return { runId, status: "running", enqueued: true, totalPending: progress.pending, progress }; }, - payslips: async (id: string) => { + pausePayrollRun: async (runId: string): Promise => { await delay(READ); - const b = byId(db.payrolls, id); - return (b?.lines ?? []).map((l) => ({ period: b?.period ?? "", contractor: byId(db.counterparties, l.counterpartyId)?.name ?? "Unknown", gross: l.amount, status: l.status, txHash: l.txHash })); + const run = byId(db.payrollRuns, runId); + if (!run) throw new Error("not found"); + clearPayrollTimers(runId); + run.status = "paused"; + run.updatedAt = now(); + db.payrollProgress[runId] = payrollProgress(db.payrollItems[runId] ?? []); + return { runId, status: "paused", progress: clone(db.payrollProgress[runId]) }; + }, + resumePayrollRun: async (runId: string): Promise => { + await delay(SETTLE); + const run = byId(db.payrollRuns, runId); + if (!run) throw new Error("not found"); + if (run.status !== "paused") throw new Error("Run is not paused."); + run.status = "running"; + run.updatedAt = now(); + schedulePayrollProgress(runId); + const progress = clone(db.payrollProgress[runId] ?? payrollProgress(db.payrollItems[runId] ?? [])); + return { runId, status: "running", enqueued: true, totalPending: progress.pending, progress }; }, // ---- invoices ----------------------------------------------------------- diff --git a/apps/console/src/demo/data.ts b/apps/console/src/demo/data.ts index d863e3e..d9afb0d 100644 --- a/apps/console/src/demo/data.ts +++ b/apps/console/src/demo/data.ts @@ -17,7 +17,9 @@ import type { Member, OnboardingStatus, PaymentOrder, - PayrollBatch, + PayrollProgressCounts, + PayrollRun, + PayrollRunItem, ProvisionTreasuryResponse, TreasuryDeposit, TreasuryView, @@ -41,7 +43,9 @@ export interface DemoDb { members: Member[]; accounts: Account[]; counterparties: Counterparty[]; - payrolls: PayrollBatch[]; + payrollRuns: PayrollRun[]; + payrollItems: Record; + payrollProgress: Record; payments: PaymentOrder[]; invoices: Invoice[]; grants: ViewingGrant[]; @@ -162,31 +166,6 @@ export function createDemoDb(): DemoDb { const runTotal = payableIds.reduce((s, id) => s + BigInt(rateOf(id)), 0n).toString(); const fakeHash = (seed: string) => `0x${seed.repeat(8).slice(0, 64)}`; - const junePaid: PayrollBatch = { - id: "pr_jun", - orgId: ORG_ID, - period: "2026-06", - source: "manual", - status: "completed", - total: { amount: runTotal, assetCode: "USDC" }, - createdAt: ISO("2026-06-01"), - lines: payableIds.map((id) => ({ counterpartyId: id, amount: rateOf(id), rate: rateOf(id), status: "paid" as const, onChain: true, txHash: fakeHash(id.slice(3)), capProof: { withinCap: true, onChain: true }, screenProof: { innocent: true, onChain: true } })), - fundedProof: { funded: true, onChain: true, provenAt: ISO("2026-06-01") }, - approvalProof: { approved: true, onChain: true, approvers: 2, threshold: 2, memberCount: members.length, provenAt: ISO("2026-06-01") }, - computationProof: { ok: true, onChain: true, runTotal, provenAt: ISO("2026-06-01") }, - }; - - const julyPending: PayrollBatch = { - id: "pr_jul", - orgId: ORG_ID, - period: "2026-07", - source: "manual", - status: "needs_approval", - total: { amount: runTotal, assetCode: "USDC" }, - createdAt: ISO("2026-07-01"), - lines: payableIds.map((id) => ({ counterpartyId: id, amount: rateOf(id), rate: rateOf(id), status: "pending" as const })), - }; - const privacy = (amountHidden: boolean) => ({ amountHidden, counterpartyHidden: true, visibleTo: ["mem_owner"] }); const payments: PaymentOrder[] = [ @@ -402,7 +381,9 @@ export function createDemoDb(): DemoDb { members, accounts, counterparties, - payrolls: [junePaid, julyPending], + payrollRuns: [], + payrollItems: {}, + payrollProgress: {}, payments, invoices, grants, @@ -446,7 +427,7 @@ export function dashboardSummary(db: DemoDb): DashboardSummary { totalPosition: { amount: db.privateTotal, assetCode: "USDC" }, pendingApprovals: db.payments.filter((p) => p.status === "needs_approval").length, openInvoices: db.invoices.filter((i) => i.status !== "paid" && i.status !== "cancelled").length, - scheduledPayrolls: db.payrolls.filter((p) => p.status === "needs_approval" || p.status === "approved").length, + scheduledPayrolls: db.payrollRuns.filter((p) => p.status === "ready" || p.status === "running" || p.status === "paused").length, recentActivity: db.dashboardActivity, live: true, }; diff --git a/apps/console/src/lib/api.test.ts b/apps/console/src/lib/api.test.ts index b1b1d7e..28d22a6 100644 --- a/apps/console/src/lib/api.test.ts +++ b/apps/console/src/lib/api.test.ts @@ -106,12 +106,10 @@ describe("console API idempotency", () => { () => api.importRoster("name,handle,rate\\nA,@a,1"), () => api.createPayment(payment), () => api.approvePayment("po_1", { decision: "approved", actorMemberId: "mem_1" }), - () => api.createPayroll({ period: "2026-06", source: "manual", lines: [] }), - () => api.approvePayroll("pr_1", { decision: "approved", actorMemberId: "mem_1" }), - () => api.proveFunded("pr_1"), - () => api.proveApproval("pr_1"), - () => api.proveComputation("pr_1"), - () => api.provePolicy("pr_1", "5000"), + () => api.createPayrollRun("org_1", { csv: "recipient,amount\\n@a,1", token: "usdc" }), + () => api.startPayrollRun("pr_1"), + () => api.pausePayrollRun("pr_1"), + () => api.resumePayrollRun("pr_1"), () => api.createInvoice({ number: "INV-1", counterpartyId: "cp_1", lineItems: [], assetCode: "USDC", dueDate: "2026-07-01" }), () => api.payInvoice("inv_1"), () => api.netInvoices("10", "7"), @@ -207,6 +205,47 @@ describe("console API idempotency", () => { expect(callHeaders(fetchMock.mock.calls[2]).get("idempotency-key")).toBeNull(); }); + it("uses the real payroll CSV run lifecycle endpoints", async () => { + const progress = { total: 1, pending: 1, proving: 0, submitted: 0, confirmed: 0, failed: 0, proved: 0 }; + const run = { + id: "pr_1", + orgId: "org_1", + status: "ready", + itemCount: 1, + totalAmount: "1", + token: "usdc", + tokenId: "avax-usdc", + createdBy: "usr_1", + error: null, + createdAt: "2026-07-10T00:00:00.000Z", + updatedAt: "2026-07-10T00:00:00.000Z", + }; + const item = { rowIndex: 2, recipientInput: "@a", resolvedAddress: "0x1234567890abcdef1234567890abcdef12345678", amount: "1", status: "pending", error: null }; + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ runId: "pr_1", status: "ready", token: "usdc", tokenId: "avax-usdc", summary: { total: 1, valid: 1, invalid: 0, totalAmount: "1", token: "usdc", tokenId: "avax-usdc" }, items: [item] }, 201)) + .mockResolvedValueOnce(jsonResponse({ run, progress, items: [item] })) + .mockResolvedValueOnce(jsonResponse({ runId: "pr_1", status: "running", enqueued: true, totalPending: 1, progress }, 202)); + vi.stubGlobal("fetch", fetchMock); + + await expect(api.createPayrollRun("org_1", { csv: "recipient,amount\n@a,1", token: "usdc" })).resolves.toMatchObject({ runId: "pr_1" }); + await expect(api.getPayrollRun("pr_1")).resolves.toMatchObject({ run: { id: "pr_1" }, progress }); + await expect(api.startPayrollRun("pr_1")).resolves.toMatchObject({ status: "running", enqueued: true }); + + expect(fetchMock.mock.calls.map((call) => call[0])).toEqual([ + apiHref("/orgs/org_1/payroll"), + apiHref("/payroll/pr_1"), + apiHref("/payroll/pr_1/start"), + ]); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + method: "POST", + body: JSON.stringify({ csv: "recipient,amount\n@a,1", token: "usdc" }), + credentials: "include", + }); + expect(callHeaders(fetchMock.mock.calls[0]).get("idempotency-key")).toMatch(/^idem_/); + expect(callHeaders(fetchMock.mock.calls[1]).get("idempotency-key")).toBeNull(); + expect(callHeaders(fetchMock.mock.calls[2]).get("idempotency-key")).toMatch(/^idem_/); + }); + it("subscribes to onboarding status with credentialed EventSource", () => { class FakeEventSource { static instances: FakeEventSource[] = []; diff --git a/apps/console/src/lib/api.ts b/apps/console/src/lib/api.ts index fc9d6a7..a88f373 100644 --- a/apps/console/src/lib/api.ts +++ b/apps/console/src/lib/api.ts @@ -14,7 +14,8 @@ import type { CreateOrgResponse, CreateInvoiceRequest, CreatePaymentRequest, - CreatePayrollRequest, + CreatePayrollRunRequest, + CreatePayrollRunResponse, CreateViewingGrantRequest, DashboardSummary, Integration, @@ -26,11 +27,17 @@ import type { OnboardingStatusResponse, OrgSummary, PaymentOrder, - PayrollBatch, + PausePayrollRunResponse, + PayrollProgressEvent, + PayrollRun, + PayrollRunResponse, DepositToTreasuryRequest, DepositToTreasuryResponse, ProvisionTreasuryResponse, + ResumePayrollRunResponse, + StartPayrollRunResponse, TreasuryDepositsResponse, + TreasuryUnderfundedError, StartOnboardingResponse, TreasuryView, ViewingGrant, @@ -55,31 +62,6 @@ export interface OrgInvite { createdAt: string; } -export interface PayrollProofResponse { - funded?: boolean; - approved?: boolean; - ok?: boolean; - onChain: boolean; - runTotal?: string; - cap?: string; - approvers?: number; - threshold?: number; - memberCount?: number; - lines?: unknown[]; - provenAt?: string; - ref?: OnChainRef; -} - -export interface PayrollPolicyProofLine { - counterpartyId: string; - capProof?: { withinCap: boolean; onChain?: boolean }; - screenProof?: { innocent: boolean; onChain?: boolean }; -} - -export interface PayrollPolicyProofResponse extends PayrollProofResponse { - lines: PayrollPolicyProofLine[]; -} - /** Maker-checker progress the BFF returns alongside an approve action. */ export interface ApprovalProgressView { required: boolean; @@ -229,6 +211,26 @@ function prepareApiRequest(path: string, init?: RequestInit): { url: string; ini }; } +export class ApiError extends Error { + readonly status: number; + readonly body: unknown; + + constructor(message: string, status: number, body: unknown) { + super(message); + this.name = "ApiError"; + this.status = status; + this.body = body; + } +} + +export function isTreasuryUnderfundedError(error: unknown): error is ApiError & { body: TreasuryUnderfundedError } { + return error instanceof ApiError + && typeof error.body === "object" + && error.body !== null + && "error" in error.body + && (error.body as { error?: unknown }).error === "treasury_underfunded"; +} + async function http(path: string, init?: RequestInit): Promise { const prepared = prepareApiRequest(path, init); let res: Response | undefined; @@ -236,14 +238,17 @@ async function http(path: string, init?: RequestInit): Promise { res = await fetch(prepared.url, prepared.init); if (!res.ok) { let detail = `HTTP ${res.status}`; + let body: unknown; try { - const body = (await res.json()) as { error?: string }; - if (body.error) detail = body.error; + body = await res.json(); + if (typeof body === "object" && body !== null && "error" in body && typeof (body as { error?: unknown }).error === "string") { + detail = (body as { error: string }).error; + } } catch { /* ignore */ } if (res.status === 401 && path !== "/auth/verify") notifyAuthRequired(); - throw new Error(detail); + throw new ApiError(detail, res.status, body); } return (await res.json()) as T; } finally { @@ -258,10 +263,21 @@ export interface OnboardingStatusSubscription { export type OnboardingStatusHandler = (onboarding: OnboardingStatus) => void; export type OnboardingStatusErrorHandler = (error: Error) => void; +export interface PayrollProgressSubscription { + close: () => void; +} + +export type PayrollProgressHandler = (event: PayrollProgressEvent) => void; +export type PayrollProgressErrorHandler = (error: Error) => void; + function terminalOnboardingStatus(status: OnboardingStatus["status"]): boolean { return status === "complete" || status === "failed"; } +function terminalPayrollStatus(status: PayrollRun["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; @@ -348,6 +364,44 @@ function subscribeOnboardingStatus( return { close }; } +function subscribePayrollProgress( + runId: string, + onProgress: PayrollProgressHandler, + onError?: PayrollProgressErrorHandler, +): PayrollProgressSubscription { + let closed = false; + let pollTimer: number | null = null; + + const close = () => { + closed = true; + if (pollTimer) window.clearTimeout(pollTimer); + pollTimer = null; + }; + + const poll = () => { + if (closed) return; + void realApi.getPayrollRun(runId).then( + ({ run, progress }) => { + if (closed) return; + onProgress({ runId: run.id, status: run.status, progress }); + if (terminalPayrollStatus(run.status)) { + close(); + return; + } + pollTimer = window.setTimeout(poll, 2_000); + }, + (error) => { + if (closed) return; + onError?.(error instanceof Error ? error : new Error("Payroll progress polling failed.")); + pollTimer = window.setTimeout(poll, 2_000); + }, + ); + }; + + poll(); + return { close }; +} + export interface SiweNonceResponse { nonce: string; expiresAt: string; @@ -438,25 +492,17 @@ const realApi = { approvePayment: (id: string, body: ApproveRequest & { actorMemberId?: string }) => http(`/payments/${id}/approve`, { method: "POST", body: JSON.stringify(body) }), - payrolls: () => http("/payrolls"), - // Amounts are COMPUTED server-side from each contractor's rate card; the caller - // only chooses WHO is in the run. - createPayroll: (body: { period: string; source: CreatePayrollRequest["source"]; lines: Array<{ counterpartyId: string }> }) => - http("/payrolls", { method: "POST", body: JSON.stringify(body) }), - approvePayroll: (id: string, body: { decision?: "approved" | "denied"; actorMemberId?: string } = { decision: "approved" }) => - http(`/payrolls/${id}/approve`, { method: "POST", body: JSON.stringify(body) }), - // "Payroll funded ✓" - prove ON-CHAIN (ORGBAL) the treasury covers this run's total. - proveFunded: (id: string) => - http(`/payrolls/${id}/prove-funded`, { method: "POST", body: "{}" }), - // Anonymous approver (Z5): prove >= threshold distinct approvers signed, on-chain (ORGAUTH), who hidden. - proveApproval: (id: string) => - http(`/payrolls/${id}/prove-approval`, { method: "POST", body: "{}" }), - // Verifiable payroll computation (Z6): prove run total derived from the rate card, on-chain (PAYCOMP), rate card private. - proveComputation: (id: string) => - http(`/payrolls/${id}/prove-computation`, { method: "POST", body: "{}" }), - // Compliance pre-flight (Z3 cap + Z4 sanctions screen) per line, on-chain, amounts/recipients hidden. - provePolicy: (id: string, cap: string) => - http(`/payrolls/${id}/prove-policy`, { method: "POST", body: JSON.stringify({ cap }) }), + createPayrollRun: (orgId: string, body: CreatePayrollRunRequest) => + http(`/orgs/${encodeURIComponent(orgId)}/payroll`, { method: "POST", body: JSON.stringify(body) }), + getPayrollRun: (runId: string) => + http(`/payroll/${encodeURIComponent(runId)}`), + subscribePayrollProgress, + startPayrollRun: (runId: string) => + http(`/payroll/${encodeURIComponent(runId)}/start`, { method: "POST", body: "{}" }), + pausePayrollRun: (runId: string) => + http(`/payroll/${encodeURIComponent(runId)}/pause`, { method: "POST", body: "{}" }), + resumePayrollRun: (runId: string) => + http(`/payroll/${encodeURIComponent(runId)}/resume`, { method: "POST", body: "{}" }), invoices: () => http("/invoices"), createInvoice: (body: CreateInvoiceRequest) => @@ -490,10 +536,6 @@ const realApi = { orgHash?: string; }) => http("/audit/private-events/anchor", { method: "POST", body: JSON.stringify(body ?? {}) }), - // Per-contractor payslips for one run (gross, status, on-chain receipt). - payslips: (id: string) => - http>(`/payrolls/${id}/payslips`), - invites: () => http("/invites"), createInvite: (body: { kind: OrgInvite["kind"]; name?: string; email?: string; role?: string; handle?: string }) => http("/invites", { method: "POST", body: JSON.stringify(body) }), diff --git a/apps/console/src/lib/store.test.tsx b/apps/console/src/lib/store.test.tsx index c17818e..3d2af73 100644 --- a/apps/console/src/lib/store.test.tsx +++ b/apps/console/src/lib/store.test.tsx @@ -8,7 +8,6 @@ const apiMock = vi.hoisted(() => ({ dashboard: vi.fn(), orgTreasury: vi.fn(), payments: vi.fn(), - payrolls: vi.fn(), invoices: vi.fn(), grants: vi.fn(), counterparties: vi.fn(), @@ -52,7 +51,6 @@ describe("ConsoleProvider treasury read model", () => { balances: [], }); apiMock.payments.mockResolvedValue([]); - apiMock.payrolls.mockResolvedValue([]); apiMock.invoices.mockResolvedValue([]); apiMock.grants.mockResolvedValue([]); apiMock.counterparties.mockResolvedValue([]); diff --git a/apps/console/src/lib/store.tsx b/apps/console/src/lib/store.tsx index f0f3f73..a13eccf 100644 --- a/apps/console/src/lib/store.tsx +++ b/apps/console/src/lib/store.tsx @@ -14,7 +14,6 @@ import type { LiveStatusResponse, Member, PaymentOrder, - PayrollBatch, TreasuryView, ViewingGrant, } from "@benzo/types"; @@ -27,7 +26,6 @@ interface ConsoleState { dashboard: DashboardSummary | null; treasury: TreasuryView | null; payments: PaymentOrder[]; - payrolls: PayrollBatch[]; invoices: Invoice[]; grants: ViewingGrant[]; counterparties: Counterparty[]; @@ -78,7 +76,6 @@ export function ConsoleProvider({ children }: { children: ReactNode }) { const [dashboard, setDashboard] = useState(null); const [treasury, setTreasury] = useState(null); const [payments, setPayments] = useState([]); - const [payrolls, setPayrolls] = useState([]); const [invoices, setInvoices] = useState([]); const [grants, setGrants] = useState([]); const [counterparties, setCounterparties] = useState([]); @@ -98,7 +95,6 @@ export function ConsoleProvider({ children }: { children: ReactNode }) { setDashboard(null); setTreasury(null); setPayments([]); - setPayrolls([]); setInvoices([]); setGrants([]); setCounterparties([]); @@ -154,7 +150,6 @@ export function ConsoleProvider({ children }: { children: ReactNode }) { readModel("dashboard", api.dashboard, CHAIN_READ_TIMEOUT_MS), readModel("treasury", () => api.orgTreasury(activeOrgId), CHAIN_READ_TIMEOUT_MS), readModel("payments", api.payments), - readModel("payrolls", api.payrolls), readModel("invoices", api.invoices), readModel("grants", api.grants), readModel("counterparties", api.counterparties), @@ -162,12 +157,11 @@ export function ConsoleProvider({ children }: { children: ReactNode }) { readModel("members", api.members), readModel("policies", api.policies), ]); - const [l, d, t, p, pr, inv, g, c, a, m, pol] = results; + const [l, d, t, p, inv, g, c, a, m, pol] = results; if (l.status === "fulfilled") setLiveStatus(l.value); if (d.status === "fulfilled") setDashboard(d.value); if (t.status === "fulfilled") setTreasury(t.value); if (p.status === "fulfilled") setPayments(p.value); - if (pr.status === "fulfilled") setPayrolls(pr.value); if (inv.status === "fulfilled") setInvoices(inv.value); if (g.status === "fulfilled") setGrants(g.value); if (c.status === "fulfilled") setCounterparties(c.value); @@ -228,8 +222,8 @@ export function ConsoleProvider({ children }: { children: ReactNode }) { }, []); return ( - {children} diff --git a/apps/console/src/screens/Dashboard.test.tsx b/apps/console/src/screens/Dashboard.test.tsx index 210e1c0..f39a463 100644 --- a/apps/console/src/screens/Dashboard.test.tsx +++ b/apps/console/src/screens/Dashboard.test.tsx @@ -33,7 +33,6 @@ describe("Dashboard", () => { members: [], policies: [], counterparties: [], - payrolls: [], masked: true, loading: false, error: null, @@ -69,7 +68,7 @@ describe("Dashboard", () => { { id: "m_approver", role: "approver", status: "active" }, ], counterparties: [{ id: "cp1", type: "contractor" }], - payrolls: [{ id: "pr1" }], + dashboard: { ...stateRef.current.dashboard, scheduledPayrolls: 1 }, policies: [], masked: false, }; diff --git a/apps/console/src/screens/Dashboard.tsx b/apps/console/src/screens/Dashboard.tsx index bb24b29..1da4335 100644 --- a/apps/console/src/screens/Dashboard.tsx +++ b/apps/console/src/screens/Dashboard.tsx @@ -71,7 +71,7 @@ function primaryTreasuryMinor(treasury: TreasuryView | null | undefined, fallbac */ function SetupBanner() { const nav = useNavigate(); - const { treasury, members, policies, counterparties, payrolls, loading } = useConsole(); + const { treasury, dashboard, members, policies, counterparties, loading } = useConsole(); const [dismissed, setDismissed] = useState(() => localStorage.getItem("benzo.console.firstrun.dismissed") === "1"); const [showDone, setShowDone] = useState(false); @@ -83,7 +83,7 @@ function SetupBanner() { const hasApprover = members.length > 1 && members.some((m) => m.status !== "suspended" && canApprove(m.role)); const hasPolicy = policies.length > 0; const hasContractor = counterparties.some((c) => c.type === "contractor"); - const ranPayroll = payrolls.length > 0; + const ranPayroll = (dashboard?.scheduledPayrolls ?? 0) > 0 || !!dashboard?.recentActivity.some((a) => a.kind === "payroll"); const items = [ { key: "fund", done: funded, title: "Fund your treasury", prompt: "Add USDC so you can run your first payout", cta: "Fund treasury", to: "/treasury" }, diff --git a/apps/console/src/screens/Payroll.test.tsx b/apps/console/src/screens/Payroll.test.tsx index cafd34d..b164999 100644 --- a/apps/console/src/screens/Payroll.test.tsx +++ b/apps/console/src/screens/Payroll.test.tsx @@ -1,274 +1,181 @@ -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MemoryRouter } from "react-router-dom"; import { Payroll } from "./Payroll"; const apiMock = vi.hoisted(() => ({ - proveFunded: vi.fn(), - provePolicy: vi.fn(), - proveComputation: vi.fn(), - proveApproval: vi.fn(), - approvePayroll: vi.fn(), - createPayroll: vi.fn(), + createPayrollRun: vi.fn(), + getPayrollRun: vi.fn(), + subscribePayrollProgress: vi.fn(), + startPayrollRun: vi.fn(), + pausePayrollRun: vi.fn(), + resumePayrollRun: vi.fn(), })); -const refreshMock = vi.hoisted(() => vi.fn(async () => {})); -// Mutable store the mocked hooks read, so each test picks its own fixtures. + const store = vi.hoisted(() => ({ - value: { payrolls: [] as unknown[], counterparties: [] as unknown[], policies: [] as unknown[], masked: false, refresh: refreshMock, loading: false }, + value: { + session: { + activeOrg: { id: "org_1", name: "Acme", slug: "acme", role: "owner", createdAt: "2026-07-01T00:00:00.000Z" }, + }, + masked: false, + }, +})); + +vi.mock("../lib/api", () => ({ + api: apiMock, + isTreasuryUnderfundedError: (error: unknown) => + typeof error === "object" + && error !== null + && "body" in error + && (error as { body?: { error?: string } }).body?.error === "treasury_underfunded", })); -vi.mock("../lib/api", () => ({ api: apiMock })); vi.mock("../lib/store", () => ({ useConsole: () => store.value, - useCounterpartyName: () => (id?: string) => - (store.value.counterparties as Array<{ id: string; name: string }>).find((c) => c.id === id)?.name ?? "Unknown", })); -const shielded = (handle: string) => ({ paymentAddress: { shielded: handle } }); -const batch = { +const progressReady = { total: 1, pending: 1, proving: 0, submitted: 0, confirmed: 0, failed: 0, proved: 0 }; +const progressRunning = { total: 1, pending: 0, proving: 1, submitted: 0, confirmed: 0, failed: 0, proved: 0 }; +const progressComplete = { total: 1, pending: 0, proving: 0, submitted: 0, confirmed: 1, failed: 0, proved: 1 }; +const item = { + rowIndex: 2, + recipientInput: "@aisha", + resolvedAddress: "0x1234567890abcdef1234567890abcdef12345678", + amount: "8500", + status: "pending", + error: null, +}; +const runReady = { id: "pr_1", orgId: "org_1", - period: "2026-06", - source: "manual", - status: "needs_approval", - total: { amount: "9000000000", assetCode: "USDC" }, - createdAt: "2026-06-01T00:00:00.000Z", - lines: [ - { counterpartyId: "cp_1", amount: "5000000000", status: "pending" }, - { counterpartyId: "cp_2", amount: "4000000000", status: "pending" }, - ], + status: "ready", + itemCount: 1, + totalAmount: "8500", + token: "usdc", + tokenId: "avalanche-fuji:usdc", + createdBy: "usr_1", + error: null, + createdAt: "2026-07-10T00:00:00.000Z", + updatedAt: "2026-07-10T00:00:00.000Z", }; -const payableCounterparties = [ - { id: "cp_1", type: "contractor", name: "Ava", status: "allowlisted", payRate: { amount: "5000000000", assetCode: "USDC" }, ...shielded("@ava") }, - { id: "cp_2", type: "contractor", name: "Ben", status: "allowlisted", payRate: { amount: "4000000000", assetCode: "USDC" }, ...shielded("@ben") }, -]; -const settledBatch = { - ...batch, - status: "completed", - lines: [ - { counterpartyId: "cp_1", amount: "5000000000", status: "paid", txHash: "0xaaa", onChain: true }, - { counterpartyId: "cp_2", amount: "4000000000", status: "paid", txHash: "0xbbb", onChain: true }, - ], - progress: { required: true, satisfied: true, nextRole: null, nextKind: null, steps: [] }, +const readySnapshot = { run: runReady, progress: progressReady, items: [item] }; +const createResponse = { + runId: "pr_1", + status: "ready", + token: "usdc", + tokenId: "avalanche-fuji:usdc", + summary: { total: 1, valid: 1, invalid: 0, totalAmount: "8500", token: "usdc", tokenId: "avalanche-fuji:usdc" }, + items: [item], }; -// A settled run that carries all three folded proofs — used to assert the proof -// detail lives behind the "Technical details" disclosure in the run drawer. -const provenBatch = { - ...batch, - id: "pr_done", - period: "2026-05", - status: "completed", - lines: [ - { counterpartyId: "cp_1", amount: "5000000000", status: "paid", txHash: "0xaaa", onChain: true, capProof: { withinCap: true, onChain: true }, screenProof: { innocent: true, onChain: true } }, - { counterpartyId: "cp_2", amount: "4000000000", status: "paid", txHash: "0xbbb", onChain: true, capProof: { withinCap: true, onChain: true }, screenProof: { innocent: true, onChain: true } }, - ], - fundedProof: { funded: true, onChain: true, provenAt: "2026-05-01T00:00:00.000Z" }, - approvalProof: { approved: true, onChain: true, approvers: 2, threshold: 2, memberCount: 4, provenAt: "2026-05-01T00:00:00.000Z" }, - computationProof: { ok: true, onChain: true, runTotal: "9000000000", provenAt: "2026-05-01T00:00:00.000Z" }, -}; -// Two distinct approvers required, none recorded yet → this approval is NOT final. -const twoStepPolicy = { - id: "pol_1", - orgId: "org_1", - name: "Payroll", - conditions: [], - steps: [{ role: "approver", mode: "all", minApprovers: 2 }], - reApprovalTriggers: [], - createdAt: "2026-01-01T00:00:00.000Z", -}; -const ref = (label: string, vkId: string) => ({ label, vkId, verified: true, network: "fuji", txHash: "0xproof", publics: [] }); -function reducedMotion(on: boolean) { - window.matchMedia = vi.fn().mockImplementation((q: string) => ({ - matches: on && q.includes("reduced-motion"), - media: q, - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - addListener: vi.fn(), - removeListener: vi.fn(), - dispatchEvent: vi.fn(), - })); -} - -function openConfirmAndRun() { - fireEvent.click(screen.getByTestId("run-payroll")); - fireEvent.click(screen.getByTestId("run-payroll-confirm")); +function renderPayroll() { + return render( + + + , + ); } describe("Payroll", () => { beforeEach(() => { + localStorage.clear(); vi.clearAllMocks(); - reducedMotion(false); - store.value = { ...store.value, policies: [] }; - apiMock.proveFunded.mockResolvedValue({ onChain: true, funded: true, ref: ref("Payroll funded", "ORGBAL") }); - apiMock.provePolicy.mockResolvedValue({ onChain: true, lines: [] }); - apiMock.proveComputation.mockResolvedValue({ onChain: true, ok: true, ref: ref("Computed from rate card", "PAYCOMP") }); - apiMock.proveApproval.mockResolvedValue({ onChain: true, approved: true, approvers: 2, memberCount: 3, ref: ref("Anonymous approval", "ORGAUTH") }); - }); - afterEach(() => vi.clearAllMocks()); - - it("renders a run as a dense table row: human period + one primary status, not the proof-badge cluster", () => { - store.value = { ...store.value, payrolls: [batch], counterparties: payableCounterparties }; - render(); - - // "2026-06" is humanised, and the process line replaces the long protocol sentence. - expect(screen.getByText("June 2026 payroll")).toBeInTheDocument(); - expect(screen.getByText("Payroll runs after funding and approval checks pass.")).toBeInTheDocument(); - - // One primary approval status per row (awaiting approval) — NOT the three purple - // proof badges, which now live in the detail drawer's Technical details. - expect(screen.getByText("awaiting approval")).toBeInTheDocument(); - expect(screen.queryByTestId("funded-badge")).toBeNull(); - expect(screen.queryByTestId("approval-badge")).toBeNull(); - expect(screen.queryByTestId("computation-badge")).toBeNull(); - - // Details is a real row action, and the settling action is precisely labelled. - expect(screen.getByTestId("open-details")).toBeInTheDocument(); - expect(screen.getByTestId("run-payroll")).toHaveTextContent("Approve & run"); - }); - - it("labels the action 'Approve' (not 'Approve & run') when this approval isn't the final one", () => { - // Policy needs two approvers, none recorded — so this click can't settle. - store.value = { ...store.value, payrolls: [batch], counterparties: payableCounterparties, policies: [twoStepPolicy] }; - render(); - - const btn = screen.getByTestId("run-payroll"); - expect(btn).toHaveTextContent("Approve"); - expect(btn).not.toHaveTextContent("Approve & run"); + store.value = { + session: { + activeOrg: { id: "org_1", name: "Acme", slug: "acme", role: "owner", createdAt: "2026-07-01T00:00:00.000Z" }, + }, + masked: false, + }; + apiMock.createPayrollRun.mockResolvedValue(createResponse); + apiMock.getPayrollRun.mockResolvedValue(readySnapshot); + apiMock.startPayrollRun.mockResolvedValue({ runId: "pr_1", status: "running", enqueued: true, totalPending: 1, progress: progressRunning }); + apiMock.pausePayrollRun.mockResolvedValue({ runId: "pr_1", status: "paused", progress: progressRunning }); + apiMock.resumePayrollRun.mockResolvedValue({ runId: "pr_1", status: "running", enqueued: true, totalPending: 0, progress: progressRunning }); + apiMock.subscribePayrollProgress.mockReturnValue({ close: vi.fn() }); }); - it("keeps the proof + anonymous-approval detail behind a Technical details disclosure in the drawer", async () => { - store.value = { ...store.value, payrolls: [provenBatch], counterparties: payableCounterparties }; - render(); + it("validates CSV through the org-scoped backend and persists the run id", async () => { + renderPayroll(); - // Collapsed by default and gated behind the drawer — no proof claims on the page. - expect(screen.queryByText("Funding confirmed")).toBeNull(); + fireEvent.change(screen.getByTestId("payroll-csv"), { target: { value: "recipient,amount\n@aisha,8500" } }); + fireEvent.click(screen.getByTestId("validate-payroll")); - fireEvent.click(screen.getByTestId("open-details")); - // The disclosure header exists, but the crypto detail stays folded until opened. - const disclosure = await screen.findByTestId("technical-details"); - expect(screen.queryByText("Funding confirmed")).toBeNull(); - - fireEvent.click(disclosure); - // The three claims that used to crowd the row are here, and only here. - expect(await screen.findByText("Funding confirmed")).toBeInTheDocument(); - expect(screen.getByText("Approval policy satisfied")).toBeInTheDocument(); - expect(screen.getByText("Amounts calculated from rate cards")).toBeInTheDocument(); - // Anonymous approver detail is disclosed here (2-of-4), not on the row. - expect(screen.getByText(/2-of-4 distinct approvers signed anonymously/)).toBeInTheDocument(); + await waitFor(() => expect(apiMock.createPayrollRun).toHaveBeenCalledWith("org_1", { csv: "recipient,amount\n@aisha,8500", token: "usdc" })); + expect(localStorage.getItem("benzo.console.payroll.currentRun:org_1")).toBe("pr_1"); + expect(await screen.findByTestId("payroll-row-2")).toHaveTextContent("@aisha"); + expect(screen.getByTestId("payroll-row-2")).toHaveTextContent("8500 USDC"); + expect(screen.getByText("Valid")).toBeInTheDocument(); }); - it("folds the four manual proofs into one animated pass and drops the standalone proof buttons", async () => { - store.value = { ...store.value, payrolls: [batch], counterparties: payableCounterparties }; - // Hold settlement so the ceremony stays in its proving pass while we assert. - apiMock.approvePayroll.mockReturnValue(new Promise(() => {})); - render(); - - // No standalone proof buttons survive on the runnable row. - expect(screen.queryByTestId("check-policy")).toBeNull(); - expect(screen.queryByTestId("check-funded")).toBeNull(); - expect(screen.queryByTestId("check-approval")).toBeNull(); - expect(screen.queryByTestId("check-computation")).toBeNull(); - - openConfirmAndRun(); + it("reattaches to a persisted running run and subscribes to progress", async () => { + localStorage.setItem("benzo.console.payroll.currentRun:org_1", "pr_1"); + apiMock.getPayrollRun.mockResolvedValue({ + run: { ...runReady, status: "running" }, + progress: progressRunning, + items: [{ ...item, status: "proving" }], + }); - const ceremony = await screen.findByTestId("send-ceremony"); - expect(ceremony).toHaveTextContent("Encrypting your payment"); - // Per-recipient progress is driven from the run register. - expect(within(ceremony).getByText("Ava")).toBeInTheDocument(); - expect(within(ceremony).getByText("Ben")).toBeInTheDocument(); + renderPayroll(); - // The single pass proves funded + policy(cap) + computation before approving. - await waitFor(() => expect(apiMock.proveFunded).toHaveBeenCalledWith("pr_1")); - expect(apiMock.provePolicy).toHaveBeenCalledWith("pr_1", "5000.00"); - expect(apiMock.proveComputation).toHaveBeenCalledWith("pr_1"); - await waitFor(() => expect(apiMock.approvePayroll).toHaveBeenCalledWith("pr_1")); + await waitFor(() => expect(apiMock.getPayrollRun).toHaveBeenCalledWith("pr_1")); + await waitFor(() => expect(apiMock.subscribePayrollProgress).toHaveBeenCalledWith("pr_1", expect.any(Function), expect.any(Function))); + expect(screen.getByTestId("payroll-progress")).toHaveTextContent("Proving"); }); - it("settles honestly and ends on the re-verifiable on-chain receipt", async () => { - store.value = { ...store.value, payrolls: [batch], counterparties: payableCounterparties }; - apiMock.approvePayroll.mockResolvedValue(settledBatch); - render(); + it("starts a ready run and reflects live progress counts", async () => { + localStorage.setItem("benzo.console.payroll.currentRun:org_1", "pr_1"); + let onProgress: ((event: { runId: string; status: string; progress: typeof progressComplete }) => void) | undefined; + apiMock.subscribePayrollProgress.mockImplementation((_runId, handler) => { + onProgress = handler; + return { close: vi.fn() }; + }); - openConfirmAndRun(); + renderPayroll(); + await screen.findByTestId("payroll-row-2"); + fireEvent.click(screen.getByTestId("start-payroll")); - const ceremony = await screen.findByTestId("send-ceremony"); - await waitFor(() => expect(apiMock.approvePayroll).toHaveBeenCalledWith("pr_1")); - // Final approval also proves the anonymous approver threshold. - await waitFor(() => expect(apiMock.proveApproval).toHaveBeenCalledWith("pr_1")); + await waitFor(() => expect(apiMock.startPayrollRun).toHaveBeenCalledWith("pr_1")); + await waitFor(() => expect(apiMock.subscribePayrollProgress).toHaveBeenCalled()); + act(() => onProgress?.({ runId: "pr_1", status: "running", progress: progressComplete })); - // The ceremony walks its honest encrypt -> settle -> verify floors, then - // reveals the receipt with re-verifiable on-chain drill-downs. - await within(ceremony).findByText("2 paid privately", {}, { timeout: 4000 }); - expect(within(ceremony).getByText("Funded proof")).toBeInTheDocument(); - expect(within(ceremony).getByText("Computation proof")).toBeInTheDocument(); - expect(within(ceremony).getByText("Approval proof")).toBeInTheDocument(); - // Per-recipient progress reads the SETTLED batch, not the pending draft: both - // lines settled, so the roster reports 2/2 paid (would be 0/2 if stale). - expect(within(ceremony).getByText("2/2 paid")).toBeInTheDocument(); + expect(screen.getByTestId("progress-confirmed")).toHaveTextContent("1/1"); }); - it("blocks a policy-violating run before it settles", async () => { - store.value = { ...store.value, payrolls: [batch], counterparties: payableCounterparties }; - // One line proves OVER the cap on-chain - a provable hard block. - apiMock.provePolicy.mockResolvedValue({ - onChain: true, - lines: [ - { counterpartyId: "cp_1", capProof: { withinCap: false, onChain: true } }, - { counterpartyId: "cp_2", capProof: { withinCap: true, onChain: true } }, - ], + it("pauses and resumes an in-flight run", async () => { + localStorage.setItem("benzo.console.payroll.currentRun:org_1", "pr_1"); + apiMock.getPayrollRun.mockResolvedValue({ + run: { ...runReady, status: "running" }, + progress: progressRunning, + items: [{ ...item, status: "proving" }], }); - render(); - openConfirmAndRun(); + renderPayroll(); + await waitFor(() => expect(apiMock.subscribePayrollProgress).toHaveBeenCalled()); + fireEvent.click(screen.getByTestId("pause-payroll")); - const ceremony = await screen.findByTestId("send-ceremony"); - await waitFor(() => expect(apiMock.provePolicy).toHaveBeenCalledWith("pr_1", "5000.00")); - // The policy result is surfaced (not silently dropped) AND settlement is stopped. - await waitFor(() => expect(within(ceremony).getAllByText(/Policy blocked/i).length).toBeGreaterThan(0)); - expect(apiMock.approvePayroll).not.toHaveBeenCalled(); - expect(apiMock.proveApproval).not.toHaveBeenCalled(); + await waitFor(() => expect(apiMock.pausePayrollRun).toHaveBeenCalledWith("pr_1")); + fireEvent.click(screen.getByTestId("resume-payroll")); + await waitFor(() => expect(apiMock.resumePayrollRun).toHaveBeenCalledWith("pr_1")); }); - it("records a non-final approval without claiming a settlement", async () => { - store.value = { ...store.value, payrolls: [batch], counterparties: payableCounterparties }; - apiMock.approvePayroll.mockResolvedValue({ - ...batch, - progress: { required: true, satisfied: false, nextRole: "controller", nextKind: "approve", steps: [] }, + it("surfaces treasury_underfunded with the shortfall and treasury link", async () => { + localStorage.setItem("benzo.console.payroll.currentRun:org_1", "pr_1"); + apiMock.startPayrollRun.mockRejectedValue({ + body: { + error: "treasury_underfunded", + availableAmount: "10", + requiredAmount: "12.5", + token: "usdc", + tokenId: "avalanche-fuji:usdc", + }, }); - render(); - - openConfirmAndRun(); - - // The pass ran the pre-flight proofs and the approval, then closed the - // ceremony instead of animating a settlement that never happened. - await waitFor(() => expect(apiMock.approvePayroll).toHaveBeenCalledWith("pr_1")); - expect(apiMock.proveApproval).not.toHaveBeenCalled(); - await waitFor(() => expect(screen.queryByTestId("send-ceremony")).toBeNull()); - }); - - it("New run pulls the allowlisted roster and computes amounts server-side", async () => { - store.value = { - ...store.value, - payrolls: [], - counterparties: [ - ...payableCounterparties, - { id: "cp_3", type: "contractor", name: "Cleo", status: "draft", payRate: { amount: "3000000000", assetCode: "USDC" } }, - { id: "cp_4", type: "customer", name: "Corp", status: "allowlisted", payRate: { amount: "1", assetCode: "USDC" } }, - ], - }; - apiMock.createPayroll.mockResolvedValue({ ...batch, status: "draft" }); - render(); - fireEvent.click(screen.getByTestId("new-run")); + renderPayroll(); + await screen.findByTestId("payroll-row-2"); + fireEvent.click(screen.getByTestId("start-payroll")); - const expectedPeriod = new Date().toISOString().slice(0, 7); - await waitFor(() => - expect(apiMock.createPayroll).toHaveBeenCalledWith({ - period: expectedPeriod, - source: "manual", - lines: [{ counterpartyId: "cp_1" }, { counterpartyId: "cp_2" }], - }), - ); + const alert = await screen.findByTestId("underfunded-alert"); + expect(alert).toHaveTextContent("short by 2.5 USDC"); + expect(screen.getByRole("link", { name: /fund treasury/i })).toHaveAttribute("href", "/treasury"); }); }); diff --git a/apps/console/src/screens/Payroll.tsx b/apps/console/src/screens/Payroll.tsx index 3d53a74..e61b7f6 100644 --- a/apps/console/src/screens/Payroll.tsx +++ b/apps/console/src/screens/Payroll.tsx @@ -1,818 +1,537 @@ -/** - * Payroll - confidential batch runs. Each batch hides individual salaries on-chain - * (one shielded transfer per person) while the employer can still prove the total. - * - * Presentation is calm dense enterprise-finance: every run is a TABLE ROW - * (Period · People · Total · Approval · Settlement · Actions), not a giant card. - * Crypto/proof detail lives behind a "Technical details" disclosure inside the - * per-run detail drawer. - * - * The run flow is unchanged: "Approve & run" is ONE animated pass that proves - * funded (ORGBAL) + policy (SPENDCAP/screen) + computation (PAYCOMP) + anonymous - * approval (ORGAUTH) and settles the batch, shown through the shared full-screen - * send ceremony with per-recipient progress. The row action reads "Approve" when - * this operator's approval is NOT the final required one (nothing settles yet) and - * "Approve & run" only when it settles the run. Run creation lives here too. - */ -import { useEffect, useReducer, useState, type ReactNode } from "react"; -import { Check, CheckCheck, ChevronDown, Download, Plus, ReceiptText, ShieldCheck, Users } from "lucide-react"; -import { useReducedMotion } from "framer-motion"; -import type { ApprovalPolicy, PayrollBatch, PayrollLine } from "@benzo/types"; -import { initialPaymentState, type PaymentPhase, paymentReducer } from "@benzo/ui/payment-state"; -import { api, type OnChainRef } from "../lib/api"; -import { useConsole, useCounterpartyName } from "../lib/store"; -import { explorerTxUrl, fmtUsd, friendlyError } from "../lib/format"; -import { AnimatePresence, EASE, Screen, motion, spring } from "../ui/motion"; -import { OnChainDetail } from "../ui/onchain"; -import { SendCeremony } from "../ui/SendCeremony"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { AlertTriangle, CheckCircle2, Pause, Play, Plus, RefreshCw, ShieldCheck } from "lucide-react"; +import { Link } from "react-router-dom"; +import type { + CreatePayrollRunResponse, + PayrollProgressCounts, + PayrollRunItem, + PayrollRunResponse, + PayrollRunStatus, + PayrollToken, + TreasuryUnderfundedError, +} from "@benzo/types"; +import { api, isTreasuryUnderfundedError, type PayrollProgressSubscription } from "../lib/api"; +import { formatAddress, friendlyError } from "../lib/format"; +import { useConsole } from "../lib/store"; +import { Screen } from "../ui/motion"; import { - Amount, Button, EmptyState, Input, Modal, PageHeader, Pill, - Skeleton, StatusPill, Table, Td, Th, Tr, useToast, + Button, + Card, + EmptyState, + Input, + PageHeader, + Select, + Skeleton, + StatusPill, + Table, + Td, + Textarea, + Th, + Tr, + useToast, } from "../ui/primitives"; -/** On-chain refs captured from the automated pass, per proof, for the receipt drill-down. */ -type RunRefs = { funded?: OnChainRef; approval?: OnChainRef; computation?: OnChainRef }; -/** What actually settled, surfaced in the ceremony receipt. */ -type RunOutcome = { total: number; paid: number; failed: number; onChain: boolean; txHash?: string; unverifiedPolicy?: number }; +const PAYROLL_RUN_KEY_PREFIX = "benzo.console.payroll.currentRun"; +const DEFAULT_CSV = "recipient,amount\n@aisha,8500\n@diego,6200\n@priya,9000"; -const period = () => new Date().toISOString().slice(0, 7); // e.g. 2026-06 +const TOKEN_LABEL: Record = { + usdc: "USDC", + eurc: "EURC", +}; -const MONTHS_LONG = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; +function storageKey(orgId: string): string { + return `${PAYROLL_RUN_KEY_PREFIX}:${orgId}`; +} + +function terminalStatus(status?: PayrollRunStatus): boolean { + return status === "complete" || status === "failed"; +} + +function progressFromValidation(validation: CreatePayrollRunResponse | null): PayrollProgressCounts | null { + if (!validation) return null; + return { + total: validation.summary.total, + pending: validation.summary.valid, + proving: 0, + submitted: 0, + confirmed: 0, + failed: validation.summary.invalid, + proved: 0, + }; +} + +function summaryFromRun(runState: PayrollRunResponse | null): CreatePayrollRunResponse["summary"] | null { + if (!runState) return null; + const invalid = runState.items.filter((item) => item.status === "failed").length; + return { + total: runState.run.itemCount, + valid: Math.max(0, runState.run.itemCount - invalid), + invalid, + totalAmount: runState.run.totalAmount, + token: runState.run.token, + tokenId: runState.run.tokenId, + }; +} -/** "2026-07" (or "2026-07 payroll") → "July 2026". Unrecognised labels pass through. */ -function monthYear(label: string): string { - const m = /(\d{4})-(\d{1,2})/.exec(label); - if (!m) return label.replace(/payroll/gi, "").trim() || label; - return `${MONTHS_LONG[Number(m[2]) - 1] ?? m[2]} ${m[1]}`; +function parseTokenAmount(value: string): bigint { + const clean = value.trim().replace(/,/g, ""); + const [whole = "0", frac = ""] = clean.split("."); + return BigInt(whole || "0") * 1_000_000n + BigInt(frac.padEnd(6, "0").slice(0, 6) || "0"); } -const approvedCount = (b: PayrollBatch) => (b.approvals ?? []).filter((a) => a.decision === "approved").length; -const hasFailed = (b: PayrollBatch) => b.lines.some((l) => l.status === "failed"); - -/** - * Best-effort required-approver count so the row action can predict whether THIS - * approval settles the run. Prefers a proven threshold, then the org's approval - * policy (stage minimums + release gate), and defaults to a single approval. The - * runtime stays honest regardless: a non-final approval closes the ceremony - * without ever claiming a settlement it didn't do. - */ -function requiredApprovers(b: PayrollBatch, policies: ApprovalPolicy[]): number { - if (b.approvalProof?.threshold) return b.approvalProof.threshold; - const list = policies ?? []; - // No per-batch policy link exists, so predict from the policy that ALWAYS - // applies (empty conditions = catch-all) rather than arbitrary array order. - // The runtime still enforces the real threshold regardless. - const pol = list.find((p) => p.conditions.length === 0) ?? list[0]; - if (pol) return Math.max(1, pol.steps.reduce((s, st) => s + st.minApprovers, 0) + (pol.releaseGate?.minApprovers ?? 0)); - return 1; +function formatTokenAmount(minor: bigint): string { + const neg = minor < 0n; + const abs = neg ? -minor : minor; + const whole = abs / 1_000_000n; + const frac = (abs % 1_000_000n).toString().padStart(6, "0").replace(/0+$/, ""); + return `${neg ? "-" : ""}${whole}${frac ? `.${frac}` : ""}`; } -/** Does this operator's approval settle the run (approvals already met, or this one reaches the threshold)? */ -function settlesOnApprove(b: PayrollBatch, policies: ApprovalPolicy[]): boolean { - return b.status === "approved" || approvedCount(b) + 1 >= requiredApprovers(b, policies); + +function underfundedShortfall(body: TreasuryUnderfundedError): string | null { + try { + const diff = parseTokenAmount(body.requiredAmount) - parseTokenAmount(body.availableAmount); + return diff > 0n ? formatTokenAmount(diff) : null; + } catch { + return null; + } } -/** Precise row action label: "Retry failed" · "Approve" (not final) · "Approve & run" (settles). */ -function rowAction(b: PayrollBatch, policies: ApprovalPolicy[]): string { - if (b.status === "processing" && hasFailed(b)) return "Retry failed"; - return settlesOnApprove(b, policies) ? "Approve & run" : "Approve"; + +function amountLabel(amount: string, token: PayrollToken, masked = false): string { + return masked ? "••••" : `${amount} ${TOKEN_LABEL[token]}`; } -/** One primary status per dimension, in plain money-movement language. */ -function approvalStatus(b: PayrollBatch): string { - if (b.status === "draft") return "draft"; - if (b.status === "cancelled") return "cancelled"; - if (b.status === "needs_approval") return "awaiting_approval"; - return "approved"; // approved · processing · completed +function currentStatus(runState: PayrollRunResponse | null, validation: CreatePayrollRunResponse | null): PayrollRunStatus | null { + return runState?.run.status ?? validation?.status ?? null; } -function settlementStatus(b: PayrollBatch): string { - if (b.status === "completed") return "completed"; - if (b.status === "cancelled") return "cancelled"; - if (b.status === "processing") return hasFailed(b) ? "failed" : "processing"; - return "not_started"; // draft · needs_approval · approved + +function currentRunId(runState: PayrollRunResponse | null, validation: CreatePayrollRunResponse | null): string | null { + return runState?.run.id ?? validation?.runId ?? null; +} + +function updateRunState( + current: PayrollRunResponse | null, + status: PayrollRunStatus, + progress: PayrollProgressCounts, +): PayrollRunResponse | null { + if (!current) return current; + return { + ...current, + run: { ...current.run, status, updatedAt: new Date().toISOString() }, + progress, + }; } export function Payroll() { const toast = useToast(); - const { payrolls, counterparties, policies, masked, refresh, loading } = useConsole(); - const name = useCounterpartyName(); - // Count recipients with no on-chain payout material on file - those lines can't - // settle privately, so the approver sees it BEFORE an irreversible run, not after. - const unpayableIds = (b: PayrollBatch) => - new Set(b.lines.filter((l) => !counterparties.find((c) => c.id === l.counterpartyId)?.paymentAddress?.shielded).map((l) => l.counterpartyId)); - const unpayableCount = (b: PayrollBatch) => unpayableIds(b).size; - const visiblePayrolls = [...payrolls].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); - // Allowlisted contractors with a rate card = the roster a New run pulls from. - const payableContractors = counterparties.filter((c) => c.type === "contractor" && c.payRate && c.status === "allowlisted"); - - const [cap, setCap] = useState("5000.00"); - const [creating, setCreating] = useState(false); - // Detail drawer target (batch id) - opened by the row "Details" action and by the - // ceremony's "View register". - const [open, setOpen] = useState(null); - // Confirm gate for the highest-value irreversible action (Approve & run). - const [confirmRun, setConfirmRun] = useState(null); - // On-chain refs captured from this session's automated pass, keyed by batch id. - const [refs, setRefs] = useState>({}); - // The single full-screen ceremony, driven by the shared payment-state machine. - const [paymentState, dispatchPayment] = useReducer(paymentReducer, initialPaymentState); - const [ceremonyBatch, setCeremonyBatch] = useState(null); - const [runOutcome, setRunOutcome] = useState(null); - const ceremonyOpen = paymentState.phase !== "idle"; - const activeRefs = ceremonyBatch ? refs[ceremonyBatch.id] : undefined; - const drawerBatch = open ? visiblePayrolls.find((b) => b.id === open) ?? null : null; - - // One click = one approval step, but that approval automatically proves - // funded + policy + computation + anonymous-approval and settles when it's the - // final required step. The whole thing is a single animated pass; the ceremony - // fails clearly if settlement doesn't happen, never claiming a settle it didn't do. - async function approveAndRun(b: PayrollBatch) { - setCeremonyBatch(b); - setRunOutcome(null); - dispatchPayment({ type: "START" }); // building - dispatchPayment({ type: "WITNESS_READY" }); // proving each salary private - const captured: RunRefs = {}; + const { session, masked } = useConsole(); + const activeOrg = session?.activeOrg ?? null; + const activeOrgId = activeOrg?.id ?? null; + + const [csv, setCsv] = useState(DEFAULT_CSV); + const [token, setToken] = useState("usdc"); + const [rowRecipient, setRowRecipient] = useState(""); + const [rowAmount, setRowAmount] = useState(""); + const [validation, setValidation] = useState(null); + const [runState, setRunState] = useState(null); + const [underfunded, setUnderfunded] = useState(null); + const [error, setError] = useState(null); + const [validating, setValidating] = useState(false); + const [hydrating, setHydrating] = useState(false); + const [starting, setStarting] = useState(false); + const [pausing, setPausing] = useState(false); + const [resuming, setResuming] = useState(false); + const subscriptionRef = useRef(null); + + const runId = currentRunId(runState, validation); + const status = currentStatus(runState, validation); + const summary = validation?.summary ?? summaryFromRun(runState); + const items = runState?.items ?? validation?.items ?? []; + const progress = runState?.progress ?? progressFromValidation(validation); + const displayToken = summary?.token ?? token; + const canStart = !!runId && status === "ready" && (summary?.invalid ?? 0) === 0; + const canPause = !!runId && status === "running"; + const canResume = !!runId && status === "paused"; + + const closeSubscription = useCallback(() => { + subscriptionRef.current?.close(); + subscriptionRef.current = null; + }, []); + + const hydrateRun = useCallback(async (id: string, silent = false) => { + if (!silent) setHydrating(true); try { - // Fold the four manual proofs into the pass. funded/policy/computation are - // independent on-chain pre-flight proofs - prove them together. - const [funded, policy, computation] = await Promise.all([ - api.proveFunded(b.id), - api.provePolicy(b.id, cap), - api.proveComputation(b.id), - ]); - if (funded.ref) captured.funded = funded.ref; - if (computation.ref) captured.computation = computation.ref; - - // In-ZK spending policy (Z3 cap + Z4 sanctions), proven per line. A provable - // hard block - over the cap or sanctioned - stops the run BEFORE it settles - // rather than quietly paying it; proofs that didn't reach the chain are - // carried into the receipt as a note. Amounts/recipients stay hidden. - const policyLines = policy.lines ?? []; - const over = policyLines.filter((l) => l.capProof?.onChain && !l.capProof.withinCap).length; - const flagged = policyLines.filter((l) => l.screenProof?.onChain && !l.screenProof.innocent).length; - const unverifiedPolicy = policyLines.filter((l) => (l.capProof && !l.capProof.onChain) || (l.screenProof && !l.screenProof.onChain)).length; - if (over || flagged) { - const summary = [over ? `${over} over the ${cap} cap` : "", flagged ? `${flagged} sanctioned` : ""].filter(Boolean).join(", "); - setRefs((m) => ({ ...m, [b.id]: { ...m[b.id], ...captured } })); - toast({ title: `Policy blocked this run: ${summary}`, tone: "danger" }); - dispatchPayment({ type: "FAIL", error: `Policy blocked this run: ${summary}. No payouts settled - fix the flagged lines and retry.` }); - await refresh(); - return; - } - - // The click is this operator's approval (proposer != approver, enforced - // server-side). It settles only when every step + the release gate pass. - const approved = await api.approvePayroll(b.id); - const prog = approved.progress; - if (prog && !prog.satisfied) { - // Not the final step - an approval was recorded, nothing settled. Be honest: - // close the ceremony rather than animate a settlement that didn't occur. - setRefs((m) => ({ ...m, [b.id]: { ...m[b.id], ...captured } })); - dispatchPayment({ type: "RESET" }); - toast({ title: `Approved · now needs ${prog.nextRole}${prog.nextKind === "release" ? " to release" : ""}`, tone: "success" }); - await refresh(); - return; - } - - // Final approval - prove the anonymous approver threshold (ORGAUTH), then - // reflect the real settlement outcome through the ceremony. - const approval = await api.proveApproval(b.id); - if (approval.ref) captured.approval = approval.ref; - setRefs((m) => ({ ...m, [b.id]: { ...m[b.id], ...captured } })); - - // Point the ceremony at the SETTLED batch, not the pending draft we opened - // with, so the per-recipient roster reflects real paid/failed states. - setCeremonyBatch(approved); - dispatchPayment({ type: "PROOF_READY" }); // submitting N transfers - const settledTx = approved.lines.find((l) => l.txHash)?.txHash ?? ""; - dispatchPayment({ type: "SUBMITTED", txHash: settledTx }); - - const failed = approved.lines.filter((l) => l.status === "failed").length; - const paid = approved.lines.filter((l) => l.status === "paid").length; - const onChain = approved.lines.some((l) => l.onChain); - setRunOutcome({ total: approved.lines.length, paid, failed, onChain, txHash: settledTx || undefined, unverifiedPolicy }); - - if (failed || !onChain) { - dispatchPayment({ - type: "FAIL", - error: failed ? `${failed} payout${failed === 1 ? "" : "s"} didn't settle. Fix and retry.` : "Payroll did not settle on-chain.", - }); - } else { - dispatchPayment({ type: "CONFIRMED", result: approved }); - } - await refresh(); + const next = await api.getPayrollRun(id); + setRunState(next); + setValidation(null); + setToken(next.run.token); + setError(null); + return next; } catch (e) { - dispatchPayment({ type: "FAIL", error: friendlyError(e) }); - await refresh(); + const msg = friendlyError(e, "Could not load this payroll run."); + setError(msg); + if (!silent) toast({ title: msg, tone: "danger" }); + return null; + } finally { + if (!silent) setHydrating(false); } - } + }, [toast]); - function closeCeremony() { - dispatchPayment({ type: "RESET" }); - setCeremonyBatch(null); - setRunOutcome(null); - } + useEffect(() => { + closeSubscription(); + setValidation(null); + setRunState(null); + setUnderfunded(null); + setError(null); + if (!activeOrgId) return undefined; + const stored = localStorage.getItem(storageKey(activeOrgId)); + if (stored) void hydrateRun(stored); + return closeSubscription; + }, [activeOrgId, closeSubscription, hydrateRun]); - // New run - amounts are COMPUTED server-side from each rate card; we only pick who's in. - async function createRun() { - if (payableContractors.length === 0) { - toast({ title: "No payable contractors on the roster yet. Add rates in Contractors first.", tone: "danger" }); - return; - } - setCreating(true); + useEffect(() => { + closeSubscription(); + if (!runId || status !== "running") return closeSubscription; + subscriptionRef.current = api.subscribePayrollProgress( + runId, + (event) => { + setRunState((current) => updateRunState(current, event.status, event.progress)); + if (terminalStatus(event.status)) void hydrateRun(runId, true); + }, + (e) => setError(friendlyError(e, "Could not update payroll progress.")), + ); + return closeSubscription; + }, [runId, status, closeSubscription, hydrateRun]); + + const addRow = useCallback(() => { + const recipient = rowRecipient.trim(); + const amount = rowAmount.trim(); + if (!recipient || !amount) return; + setCsv((current) => { + const trimmed = current.trim(); + const prefix = trimmed ? `${trimmed}\n` : "recipient,amount\n"; + return `${prefix}${recipient},${amount}`; + }); + setRowRecipient(""); + setRowAmount(""); + }, [rowAmount, rowRecipient]); + + const validateRun = useCallback(async () => { + if (!activeOrgId) return; + setValidating(true); + setUnderfunded(null); + setError(null); + setRunState(null); + closeSubscription(); try { - const batch = await api.createPayroll({ - period: period(), - source: "manual", - lines: payableContractors.map((c) => ({ counterpartyId: c.id })), + const created = await api.createPayrollRun(activeOrgId, { csv, token }); + setValidation(created); + setToken(created.token); + localStorage.setItem(storageKey(activeOrgId), created.runId); + toast({ + title: created.status === "ready" + ? `Validated ${created.summary.valid} row${created.summary.valid === 1 ? "" : "s"}` + : `Validation found ${created.summary.invalid} issue${created.summary.invalid === 1 ? "" : "s"}`, + tone: created.status === "ready" ? "success" : "danger", }); - toast({ title: `${monthYear(period())} run drafted · ${batch.lines.length} contractor${batch.lines.length === 1 ? "" : "s"} · ${fmtUsd(batch.total.amount)}`, tone: "success" }); - await refresh(); + void hydrateRun(created.runId, true); } catch (e) { - toast({ title: friendlyError(e), tone: "danger" }); + const msg = friendlyError(e, "Could not validate this payroll CSV."); + setError(msg); + toast({ title: msg, tone: "danger" }); } finally { - setCreating(false); + setValidating(false); } - } + }, [activeOrgId, closeSubscription, csv, hydrateRun, toast, token]); - function download(fileName: string, text: string, type: string) { - const blob = new Blob([text], { type }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = fileName; - a.click(); - URL.revokeObjectURL(url); - } + const startRun = useCallback(async () => { + if (!runId) return; + setStarting(true); + setUnderfunded(null); + setError(null); + try { + const started = await api.startPayrollRun(runId); + setRunState((current) => updateRunState(current, started.status, started.progress)); + if (!runState) void hydrateRun(runId, true); + toast({ title: `Payroll started · ${started.totalPending} pending`, tone: "success" }); + } catch (e) { + if (isTreasuryUnderfundedError(e)) { + setUnderfunded(e.body); + setError(null); + } else { + const msg = friendlyError(e, "Could not start this payroll run."); + setError(msg); + toast({ title: msg, tone: "danger" }); + } + } finally { + setStarting(false); + } + }, [hydrateRun, runId, runState, toast]); - function downloadPayslips(b: PayrollBatch) { - const rows = b.lines.map((l) => ({ - period: b.period, - contractor: name(l.counterpartyId), - gross: l.amount, - status: l.status, - txHash: l.txHash, - error: l.error, - })); - download(`benzo-payslips-${b.period}.json`, JSON.stringify(rows, null, 2), "application/json"); - } + const pauseRun = useCallback(async () => { + if (!runId) return; + setPausing(true); + try { + const paused = await api.pausePayrollRun(runId); + closeSubscription(); + setRunState((current) => updateRunState(current, paused.status, paused.progress)); + toast({ title: "Payroll paused", tone: "warning" }); + } catch (e) { + const msg = friendlyError(e, "Could not pause this payroll run."); + setError(msg); + toast({ title: msg, tone: "danger" }); + } finally { + setPausing(false); + } + }, [closeSubscription, runId, toast]); - function exportCsv(b: PayrollBatch) { - const esc = (v: unknown) => `"${String(v ?? "").replace(/"/g, '""')}"`; - const rows = [ - ["period", "contractor", "amount_units", "status", "tx_hash", "error"], - ...b.lines.map((l) => [b.period, name(l.counterpartyId), l.amount, l.status, l.txHash ?? "", l.error ?? ""]), - ]; - download(`benzo-payroll-${b.period}.csv`, rows.map((r) => r.map(esc).join(",")).join("\n"), "text/csv"); - } + const resumeRun = useCallback(async () => { + if (!runId) return; + setResuming(true); + setUnderfunded(null); + try { + const resumed = await api.resumePayrollRun(runId); + setRunState((current) => updateRunState(current, resumed.status, resumed.progress)); + toast({ title: `Payroll resumed · ${resumed.totalPending} pending`, tone: "success" }); + } catch (e) { + if (isTreasuryUnderfundedError(e)) { + setUnderfunded(e.body); + setError(null); + } else { + const msg = friendlyError(e, "Could not resume this payroll run."); + setError(msg); + toast({ title: msg, tone: "danger" }); + } + } finally { + setResuming(false); + } + }, [runId, toast]); + + const runLabel = useMemo(() => { + if (!runId) return "No run yet"; + return `Run ${runId.slice(0, 10)}${runId.length > 10 ? "..." : ""}`; + }, [runId]); return ( - - ) : undefined - } - receipt={ - runOutcome ? ( -
-
- {runOutcome.failed ? `${runOutcome.paid}/${runOutcome.total} settled` : `${runOutcome.paid} paid privately`} -
-

- {runOutcome.onChain - ? "Each salary settled privately on-chain. The total stays provable to an auditor; the individual amounts don't leak." - : "No on-chain settlement was recorded for this run."} -

- {runOutcome.unverifiedPolicy ? ( -

- {runOutcome.unverifiedPolicy} policy proof{runOutcome.unverifiedPolicy === 1 ? "" : "s"} didn't verify on-chain - re-check before you rely on this run. -

- ) : null} - {activeRefs && (activeRefs.funded || activeRefs.approval || activeRefs.computation) ? ( -
- {activeRefs.funded ? : null} - {activeRefs.approval ? : null} - {activeRefs.computation ? : null} -
- ) : null} -
- ) : undefined - } - primaryAction={ - paymentState.phase === "confirmed" || paymentState.phase === "failed" - ? { - label: paymentState.phase === "confirmed" ? "Done" : "Close", - onClick: closeCeremony, - variant: paymentState.phase === "failed" ? "danger" : "primary", - } - : undefined - } - secondaryAction={ - (paymentState.phase === "confirmed" || paymentState.phase === "failed") && ceremonyBatch - ? { - label: "View register", - onClick: () => { - const id = ceremonyBatch.id; - closeCeremony(); - setOpen(id); - }, - variant: "outline", - } - : undefined - } - /> - - New run - - } + subtitle="CSV payroll, server-side proving, live encrypted settlement progress" + action={status ? : null} /> - {loading ? ( - - - - - - - - - - - - - {[0, 1, 2].map((i) => ( - - - - - - - - - ))} - -
PeriodPeopleTotalApprovalSettlementActions
- ) : payrolls.length === 0 ? ( - + {!activeOrg ? ( + ) : ( - <> -
- - Payroll runs after funding and approval checks pass. -
- - - - - - - - - - - - - {visiblePayrolls.map((b) => { - const runnable = b.status === "needs_approval" || b.status === "approved" || b.status === "processing"; - const partialApprovals = b.status === "needs_approval" && approvedCount(b) > 0; - return ( - - - - - - - - - ); - })} - -
PeriodPeopleTotalApprovalSettlementActions
-
{monthYear(b.period)} payroll
-
via {b.source}
-
{b.lines.length} - - {masked ? •••• : } - - - - {partialApprovals ? ( -
{approvedCount(b)} of {requiredApprovers(b, policies)} signed
- ) : null} -
- - -
- {runnable ? ( - - ) : null} - -
-
- - )} - - {/* Per-run detail drawer: full register + downloads + the crypto/proof detail - folded behind a "Technical details" disclosure. */} - setOpen(null)} - width="max-w-2xl" - title={ - drawerBatch ? ( - - {monthYear(drawerBatch.period)} payroll - - ) : ( - "Payroll run" - ) - } - > - {drawerBatch ? ( -
-
-
- - - {drawerBatch.status === "processing" && hasFailed(drawerBatch) ? ( - Some payouts didn't settle - retry from the run row. - ) : null} -
-
- - +
+
+ +
+
+
Compose CSV
+

Rows use recipient handle or address, then decimal amount.

+
+
-
-
- - •••• : } /> - {drawerBatch.source}} /> -
+