Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
277 changes: 223 additions & 54 deletions apps/console/src/demo/api.ts

Large diffs are not rendered by default.

39 changes: 10 additions & 29 deletions apps/console/src/demo/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import type {
Member,
OnboardingStatus,
PaymentOrder,
PayrollBatch,
PayrollProgressCounts,
PayrollRun,
PayrollRunItem,
ProvisionTreasuryResponse,
TreasuryDeposit,
TreasuryView,
Expand All @@ -41,7 +43,9 @@ export interface DemoDb {
members: Member[];
accounts: Account[];
counterparties: Counterparty[];
payrolls: PayrollBatch[];
payrollRuns: PayrollRun[];
payrollItems: Record<string, PayrollRunItem[]>;
payrollProgress: Record<string, PayrollProgressCounts>;
payments: PaymentOrder[];
invoices: Invoice[];
grants: ViewingGrant[];
Expand Down Expand Up @@ -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[] = [
Expand Down Expand Up @@ -402,7 +381,9 @@ export function createDemoDb(): DemoDb {
members,
accounts,
counterparties,
payrolls: [junePaid, julyPending],
payrollRuns: [],
payrollItems: {},
payrollProgress: {},
payments,
invoices,
grants,
Expand Down Expand Up @@ -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,
};
Expand Down
51 changes: 45 additions & 6 deletions apps/console/src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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[] = [];
Expand Down
148 changes: 95 additions & 53 deletions apps/console/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import type {
CreateOrgResponse,
CreateInvoiceRequest,
CreatePaymentRequest,
CreatePayrollRequest,
CreatePayrollRunRequest,
CreatePayrollRunResponse,
CreateViewingGrantRequest,
DashboardSummary,
Integration,
Expand All @@ -26,11 +27,17 @@ import type {
OnboardingStatusResponse,
OrgSummary,
PaymentOrder,
PayrollBatch,
PausePayrollRunResponse,
PayrollProgressEvent,
PayrollRun,
PayrollRunResponse,
DepositToTreasuryRequest,
DepositToTreasuryResponse,
ProvisionTreasuryResponse,
ResumePayrollRunResponse,
StartPayrollRunResponse,
TreasuryDepositsResponse,
TreasuryUnderfundedError,
StartOnboardingResponse,
TreasuryView,
ViewingGrant,
Expand All @@ -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;
Expand Down Expand Up @@ -229,21 +211,44 @@ 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<T>(path: string, init?: RequestInit): Promise<T> {
const prepared = prepareApiRequest(path, init);
let res: Response | undefined;
try {
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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -438,25 +492,17 @@ const realApi = {
approvePayment: (id: string, body: ApproveRequest & { actorMemberId?: string }) =>
http<PaymentOrder & { progress?: ApprovalProgressView }>(`/payments/${id}/approve`, { method: "POST", body: JSON.stringify(body) }),

payrolls: () => http<PayrollBatch[]>("/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<PayrollBatch>("/payrolls", { method: "POST", body: JSON.stringify(body) }),
approvePayroll: (id: string, body: { decision?: "approved" | "denied"; actorMemberId?: string } = { decision: "approved" }) =>
http<PayrollBatch & { progress?: ApprovalProgressView }>(`/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<PayrollProofResponse>(`/payrolls/${id}/prove-funded`, { method: "POST", body: "{}" }),
// Anonymous approver (Z5): prove >= threshold distinct approvers signed, on-chain (ORGAUTH), who hidden.
proveApproval: (id: string) =>
http<PayrollProofResponse>(`/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<PayrollProofResponse>(`/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<PayrollPolicyProofResponse>(`/payrolls/${id}/prove-policy`, { method: "POST", body: JSON.stringify({ cap }) }),
createPayrollRun: (orgId: string, body: CreatePayrollRunRequest) =>
http<CreatePayrollRunResponse>(`/orgs/${encodeURIComponent(orgId)}/payroll`, { method: "POST", body: JSON.stringify(body) }),
getPayrollRun: (runId: string) =>
http<PayrollRunResponse>(`/payroll/${encodeURIComponent(runId)}`),
subscribePayrollProgress,
startPayrollRun: (runId: string) =>
http<StartPayrollRunResponse>(`/payroll/${encodeURIComponent(runId)}/start`, { method: "POST", body: "{}" }),
pausePayrollRun: (runId: string) =>
http<PausePayrollRunResponse>(`/payroll/${encodeURIComponent(runId)}/pause`, { method: "POST", body: "{}" }),
resumePayrollRun: (runId: string) =>
http<ResumePayrollRunResponse>(`/payroll/${encodeURIComponent(runId)}/resume`, { method: "POST", body: "{}" }),

invoices: () => http<Invoice[]>("/invoices"),
createInvoice: (body: CreateInvoiceRequest) =>
Expand Down Expand Up @@ -490,10 +536,6 @@ const realApi = {
orgHash?: string;
}) =>
http<PrivateAuditAnchorResponse>("/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<Array<{ period: string; contractor: string; gross: string; status: string; txHash?: string; error?: string }>>(`/payrolls/${id}/payslips`),

invites: () => http<OrgInvite[]>("/invites"),
createInvite: (body: { kind: OrgInvite["kind"]; name?: string; email?: string; role?: string; handle?: string }) =>
http<OrgInvite>("/invites", { method: "POST", body: JSON.stringify(body) }),
Expand Down
2 changes: 0 additions & 2 deletions apps/console/src/lib/store.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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([]);
Expand Down
Loading
Loading