diff --git a/artifacts/api-server/src/lib/lnbits.ts b/artifacts/api-server/src/lib/lnbits.ts index 07dcc56..9ec576d 100644 --- a/artifacts/api-server/src/lib/lnbits.ts +++ b/artifacts/api-server/src/lib/lnbits.ts @@ -26,6 +26,8 @@ export class LNbitsService { } } + // ── Inbound invoices ───────────────────────────────────────────────────── + async createInvoice(amountSats: number, memo: string): Promise { if (this.stubMode) { const paymentHash = randomBytes(32).toString("hex"); @@ -34,7 +36,7 @@ export class LNbitsService { return { paymentHash, paymentRequest }; } - const response = await fetch(`${this.url.replace(/\/$/, "")}/api/v1/payments`, { + const response = await fetch(`${this.base()}/api/v1/payments`, { method: "POST", headers: this.headers(), body: JSON.stringify({ out: false, amount: amountSats, memo }), @@ -58,7 +60,7 @@ export class LNbitsService { } const response = await fetch( - `${this.url.replace(/\/$/, "")}/api/v1/payments/${paymentHash}`, + `${this.base()}/api/v1/payments/${paymentHash}`, { method: "GET", headers: this.headers() }, ); @@ -71,7 +73,68 @@ export class LNbitsService { return data.paid; } - /** Stub-only helper: mark an invoice as paid for testing/dev flows. */ + // ── Outbound payments (refunds) ────────────────────────────────────────── + + /** + * Decode a BOLT11 invoice and return its amount in satoshis. + * Stub mode: parses the embedded amount from our known stub format (lnbcrtXu1stub_...), + * or returns null for externally-generated invoices. + */ + async decodeInvoice(bolt11: string): Promise<{ amountSats: number } | null> { + if (this.stubMode) { + // Our stub format: lnbcrtNNNu1stub_... where NNN is the amount in sats + const m = bolt11.match(/^lnbcrt(\d+)u1stub_/i); + if (m) return { amountSats: parseInt(m[1], 10) }; + // Unknown format in stub mode — accept blindly (simulated environment) + return null; + } + + const response = await fetch(`${this.base()}/api/v1/payments/decode`, { + method: "POST", + headers: this.headers(), + body: JSON.stringify({ data: bolt11 }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`LNbits decodeInvoice failed (${response.status}): ${body}`); + } + + const data = (await response.json()) as { amount_msat?: number }; + if (!data.amount_msat) return null; + return { amountSats: Math.floor(data.amount_msat / 1000) }; + } + + /** + * Pay an outgoing BOLT11 invoice (e.g. to return a refund to a user). + * Returns the payment hash. + * Stub mode: simulates a successful payment. + */ + async payInvoice(bolt11: string): Promise { + if (this.stubMode) { + const paymentHash = randomBytes(32).toString("hex"); + console.log(`[stub] Paid outgoing invoice — fake hash=${paymentHash}`); + return paymentHash; + } + + const response = await fetch(`${this.base()}/api/v1/payments`, { + method: "POST", + headers: this.headers(), + body: JSON.stringify({ out: true, bolt11 }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`LNbits payInvoice failed (${response.status}): ${body}`); + } + + const data = (await response.json()) as { payment_hash: string }; + return data.payment_hash; + } + + // ── Stub helpers ───────────────────────────────────────────────────────── + + /** Stub-only: mark an inbound invoice as paid for testing/dev flows. */ stubMarkPaid(paymentHash: string): void { if (!this.stubMode) { throw new Error("stubMarkPaid called on a real LNbitsService instance"); @@ -80,6 +143,12 @@ export class LNbitsService { console.log(`[stub] Marked invoice paid: hash=${paymentHash}`); } + // ── Private helpers ────────────────────────────────────────────────────── + + private base(): string { + return this.url.replace(/\/$/, ""); + } + private headers(): Record { return { "Content-Type": "application/json", diff --git a/artifacts/api-server/src/lib/pricing.ts b/artifacts/api-server/src/lib/pricing.ts index 653452d..193d671 100644 --- a/artifacts/api-server/src/lib/pricing.ts +++ b/artifacts/api-server/src/lib/pricing.ts @@ -158,6 +158,35 @@ export class PricingService { const amountSats = usdToSats(estimatedCostUsd, btcPriceUsd); return { amountSats, estimatedCostUsd, marginPct: this.marginPct, btcPriceUsd }; } + + // ── Post-work honest accounting ────────────────────────────────────────── + + /** + * Full actual charge in USD: raw Anthropic token cost + DO infra amortisation + margin. + * Pass in the already-computed actualCostUsd (raw token cost, no extras). + */ + calculateActualChargeUsd(actualCostUsd: number): number { + const rawCostUsd = actualCostUsd + DO_INFRA_PER_REQUEST_USD; + return rawCostUsd * (1 + this.marginPct / 100); + } + + /** + * Convert the actual charge to satoshis using the BTC price that was locked + * at invoice-creation time. This keeps pre- and post-work accounting in the + * same BTC denomination without a second oracle call. + */ + calculateActualChargeSats(actualCostUsd: number, lockedBtcPriceUsd: number): number { + const chargeUsd = this.calculateActualChargeUsd(actualCostUsd); + return usdToSats(chargeUsd, lockedBtcPriceUsd); + } + + /** + * Refund amount in sats: what was overpaid by the user. + * Always >= 0 (clamped — never ask the user to top up due to BTC price swings). + */ + calculateRefundSats(workAmountSats: number, actualAmountSats: number): number { + return Math.max(0, workAmountSats - actualAmountSats); + } } export const pricingService = new PricingService(); diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index 511c0da..744e504 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -50,7 +50,6 @@ async function advanceJob(job: Job): Promise { const evalResult = await agentService.evaluateRequest(job.request); if (evalResult.accepted) { - // Cost-based work fee: estimate tokens → fetch BTC price → invoice sats const inputEst = pricingService.estimateInputTokens(job.request); const outputEst = pricingService.estimateOutputTokens(job.request); const breakdown = await pricingService.calculateWorkFeeSats( @@ -129,12 +128,28 @@ async function advanceJob(job: Job): Promise { try { const workResult = await agentService.executeWork(job.request); + + // ── Honest post-work accounting ─────────────────────────────────────── const actualCostUsd = pricingService.calculateActualCostUsd( workResult.inputTokens, workResult.outputTokens, agentService.workModel, ); + // Re-use the BTC price locked at invoice time so sats arithmetic is + // consistent. Fall back to the cached oracle price only if the column + // is somehow null (should not happen in normal flow). + const lockedBtcPrice = job.btcPriceUsd ?? 100_000; + const actualAmountSats = pricingService.calculateActualChargeSats( + actualCostUsd, + lockedBtcPrice, + ); + const refundAmountSats = pricingService.calculateRefundSats( + job.workAmountSats ?? 0, + actualAmountSats, + ); + const refundState = refundAmountSats > 0 ? "pending" : "not_applicable"; + await db .update(jobs) .set({ @@ -143,6 +158,9 @@ async function advanceJob(job: Job): Promise { actualInputTokens: workResult.inputTokens, actualOutputTokens: workResult.outputTokens, actualCostUsd, + actualAmountSats, + refundAmountSats, + refundState, updatedAt: new Date(), }) .where(eq(jobs.id, job.id)); @@ -160,6 +178,8 @@ async function advanceJob(job: Job): Promise { return job; } +// ── POST /jobs ──────────────────────────────────────────────────────────────── + router.post("/jobs", async (req: Request, res: Response) => { const parseResult = CreateJobBody.safeParse(req.body); if (!parseResult.success) { @@ -177,18 +197,10 @@ router.post("/jobs", async (req: Request, res: Response) => { const jobId = randomUUID(); const invoiceId = randomUUID(); - const lnbitsInvoice = await lnbitsService.createInvoice( - evalFee, - `Eval fee for job ${jobId}`, - ); + const lnbitsInvoice = await lnbitsService.createInvoice(evalFee, `Eval fee for job ${jobId}`); await db.transaction(async (tx) => { - await tx.insert(jobs).values({ - id: jobId, - request, - state: "awaiting_eval_payment", - evalAmountSats: evalFee, - }); + await tx.insert(jobs).values({ id: jobId, request, state: "awaiting_eval_payment", evalAmountSats: evalFee }); await tx.insert(invoices).values({ id: invoiceId, jobId, @@ -198,18 +210,12 @@ router.post("/jobs", async (req: Request, res: Response) => { type: "eval", paid: false, }); - await tx - .update(jobs) - .set({ evalInvoiceId: invoiceId, updatedAt: new Date() }) - .where(eq(jobs.id, jobId)); + await tx.update(jobs).set({ evalInvoiceId: invoiceId, updatedAt: new Date() }).where(eq(jobs.id, jobId)); }); res.status(201).json({ jobId, - evalInvoice: { - paymentRequest: lnbitsInvoice.paymentRequest, - amountSats: evalFee, - }, + evalInvoice: { paymentRequest: lnbitsInvoice.paymentRequest, amountSats: evalFee }, }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to create job"; @@ -217,20 +223,16 @@ router.post("/jobs", async (req: Request, res: Response) => { } }); +// ── GET /jobs/:id ───────────────────────────────────────────────────────────── + router.get("/jobs/:id", async (req: Request, res: Response) => { const paramResult = GetJobParams.safeParse(req.params); - if (!paramResult.success) { - res.status(400).json({ error: "Invalid job id" }); - return; - } + if (!paramResult.success) { res.status(400).json({ error: "Invalid job id" }); return; } const { id } = paramResult.data; try { let job = await getJobById(id); - if (!job) { - res.status(404).json({ error: "Job not found" }); - return; - } + if (!job) { res.status(404).json({ error: "Job not found" }); return; } const advanced = await advanceJob(job); if (advanced) job = advanced; @@ -264,7 +266,6 @@ router.get("/jobs/:id", async (req: Request, res: Response) => { ...(lnbitsService.stubMode ? { paymentHash: inv.paymentHash } : {}), }, } : {}), - // Cost breakdown so callers understand the invoice amount ...(job.estimatedCostUsd != null ? { pricingBreakdown: { estimatedCostUsd: job.estimatedCostUsd, @@ -284,14 +285,25 @@ router.get("/jobs/:id", async (req: Request, res: Response) => { res.json({ ...base, result: job.result ?? undefined, - // Actual token usage + cost ledger ...(job.actualCostUsd != null ? { costLedger: { + // Token usage actualInputTokens: job.actualInputTokens, actualOutputTokens: job.actualOutputTokens, totalTokens: (job.actualInputTokens ?? 0) + (job.actualOutputTokens ?? 0), + // USD costs actualCostUsd: job.actualCostUsd, + actualChargeUsd: job.actualAmountSats != null && job.btcPriceUsd + ? (job.actualAmountSats / 1e8) * job.btcPriceUsd + : undefined, estimatedCostUsd: job.estimatedCostUsd, + // Sat amounts + actualAmountSats: job.actualAmountSats, + workAmountSats: job.workAmountSats, + // Refund + refundAmountSats: job.refundAmountSats, + refundState: job.refundState, + // Rate context marginPct: job.marginPct, btcPriceUsd: job.btcPriceUsd, }, @@ -312,4 +324,72 @@ router.get("/jobs/:id", async (req: Request, res: Response) => { } }); +// ── POST /jobs/:id/refund ───────────────────────────────────────────────────── + +router.post("/jobs/:id/refund", async (req: Request, res: Response) => { + const paramResult = GetJobParams.safeParse(req.params); + if (!paramResult.success) { res.status(400).json({ error: "Invalid job id" }); return; } + const { id } = paramResult.data; + + const { invoice } = req.body as { invoice?: string }; + if (!invoice || typeof invoice !== "string" || invoice.trim().length === 0) { + res.status(400).json({ error: "Body must include 'invoice' (BOLT11 string)" }); + return; + } + const bolt11 = invoice.trim(); + + try { + const job = await getJobById(id); + if (!job) { res.status(404).json({ error: "Job not found" }); return; } + if (job.state !== "complete") { + res.status(409).json({ error: `Job is in state '${job.state}', not 'complete'` }); + return; + } + if (job.refundState !== "pending") { + if (job.refundState === "not_applicable") { + res.status(409).json({ error: "No refund is owed for this job (actual cost matched or exceeded the invoice amount)" }); + } else if (job.refundState === "paid") { + res.status(409).json({ error: "Refund has already been sent", refundPaymentHash: job.refundPaymentHash }); + } else { + res.status(409).json({ error: "Refund is not available for this job" }); + } + return; + } + + const refundSats = job.refundAmountSats ?? 0; + if (refundSats <= 0) { + res.status(409).json({ error: "Refund amount is zero — nothing to return" }); + return; + } + + // ── Validate invoice amount ─────────────────────────────────────────── + const decoded = await lnbitsService.decodeInvoice(bolt11); + if (decoded !== null && decoded.amountSats !== refundSats) { + res.status(400).json({ + error: `Invoice amount (${decoded.amountSats} sats) does not match refund owed (${refundSats} sats)`, + refundAmountSats: refundSats, + }); + return; + } + + // ── Send refund ─────────────────────────────────────────────────────── + const paymentHash = await lnbitsService.payInvoice(bolt11); + + await db + .update(jobs) + .set({ refundState: "paid", refundPaymentHash: paymentHash, updatedAt: new Date() }) + .where(and(eq(jobs.id, id), eq(jobs.refundState, "pending"))); + + res.json({ + ok: true, + refundAmountSats: refundSats, + paymentHash, + message: `Refund of ${refundSats} sats sent successfully`, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to process refund"; + res.status(500).json({ error: message }); + } +}); + export default router; diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 7771eb9..3a2f8e7 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -53,7 +53,19 @@ export interface PricingBreakdown { } /** - * Actual cost record stored after the job completes + * Lifecycle of the refund for this job + */ +export type CostLedgerRefundState = + (typeof CostLedgerRefundState)[keyof typeof CostLedgerRefundState]; + +export const CostLedgerRefundState = { + not_applicable: "not_applicable", + pending: "pending", + paid: "paid", +} as const; + +/** + * Honest post-work accounting stored after the job completes */ export interface CostLedger { actualInputTokens?: number; @@ -62,8 +74,20 @@ export interface CostLedger { totalTokens?: number; /** Raw Anthropic token cost (no infra, no margin) */ actualCostUsd?: number; + /** What we honestly charged in USD (actual token cost + DO infra + margin) */ + actualChargeUsd?: number; + /** Original estimate used to create the work invoice */ estimatedCostUsd?: number; + /** Honest sats charge (actual cost converted at the locked BTC price) */ + actualAmountSats?: number; + /** Amount the user originally paid in sats */ + workAmountSats?: number; + /** Sats owed back to the user (workAmountSats - actualAmountSats, >= 0) */ + refundAmountSats?: number; + /** Lifecycle of the refund for this job */ + refundState?: CostLedgerRefundState; marginPct?: number; + /** BTC/USD price locked at invoice creation time */ btcPriceUsd?: number; } @@ -79,6 +103,18 @@ export interface JobStatusResponse { errorMessage?: string; } +export interface ClaimRefundRequest { + /** BOLT11 invoice for exactly refundAmountSats */ + invoice: string; +} + +export interface ClaimRefundResponse { + ok: boolean; + refundAmountSats: number; + paymentHash: string; + message: string; +} + export interface DemoResponse { result: string; } diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 63374e3..64f851d 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -17,6 +17,8 @@ import type { } from "@tanstack/react-query"; import type { + ClaimRefundRequest, + ClaimRefundResponse, CreateJobRequest, CreateJobResponse, DemoResponse, @@ -274,6 +276,98 @@ export function useGetJob< return { ...query, queryKey: queryOptions.queryKey }; } +/** + * After a job completes, if the actual cost (tokens used + infra + margin) was +less than the work invoice amount, the difference is owed back to the user. +Submit a BOLT11 invoice for exactly `refundAmountSats` to receive the payment. +Idempotent: returns 409 if already paid or if no refund is owed. + + * @summary Claim a refund for overpayment + */ +export const getClaimRefundUrl = (id: string) => { + return `/api/jobs/${id}/refund`; +}; + +export const claimRefund = async ( + id: string, + claimRefundRequest: ClaimRefundRequest, + options?: RequestInit, +): Promise => { + return customFetch(getClaimRefundUrl(id), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(claimRefundRequest), + }); +}; + +export const getClaimRefundMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: string; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: string; data: BodyType }, + TContext +> => { + const mutationKey = ["claimRefund"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: string; data: BodyType } + > = (props) => { + const { id, data } = props ?? {}; + + return claimRefund(id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ClaimRefundMutationResult = NonNullable< + Awaited> +>; +export type ClaimRefundMutationBody = BodyType; +export type ClaimRefundMutationError = ErrorType; + +/** + * @summary Claim a refund for overpayment + */ +export const useClaimRefund = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: string; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: string; data: BodyType }, + TContext +> => { + return useMutation(getClaimRefundMutationOptions(options)); +}; + /** * Runs the agent without payment. Limited to 5 requests per IP per hour. * @summary Free demo (rate-limited) diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 390eee7..45e2d48 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -90,6 +90,59 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + /jobs/{id}/refund: + post: + operationId: claimRefund + tags: [jobs] + summary: Claim a refund for overpayment + description: | + After a job completes, if the actual cost (tokens used + infra + margin) was + less than the work invoice amount, the difference is owed back to the user. + Submit a BOLT11 invoice for exactly `refundAmountSats` to receive the payment. + Idempotent: returns 409 if already paid or if no refund is owed. + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ClaimRefundRequest" + responses: + "200": + description: Refund sent + content: + application/json: + schema: + $ref: "#/components/schemas/ClaimRefundResponse" + "400": + description: Missing invoice, wrong amount, or invalid BOLT11 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Job not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: Job not complete, refund already paid, or no refund owed + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + description: Server error (e.g. Lightning payment failure) + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" /demo: get: operationId: runDemo @@ -196,7 +249,7 @@ components: description: BTC/USD spot price used to convert the invoice to sats CostLedger: type: object - description: Actual cost record stored after the job completes + description: Honest post-work accounting stored after the job completes properties: actualInputTokens: type: integer @@ -208,12 +261,30 @@ components: actualCostUsd: type: number description: Raw Anthropic token cost (no infra, no margin) + actualChargeUsd: + type: number + description: What we honestly charged in USD (actual token cost + DO infra + margin) estimatedCostUsd: type: number + description: Original estimate used to create the work invoice + actualAmountSats: + type: integer + description: Honest sats charge (actual cost converted at the locked BTC price) + workAmountSats: + type: integer + description: Amount the user originally paid in sats + refundAmountSats: + type: integer + description: Sats owed back to the user (workAmountSats - actualAmountSats, >= 0) + refundState: + type: string + enum: [not_applicable, pending, paid] + description: Lifecycle of the refund for this job marginPct: type: number btcPriceUsd: type: number + description: BTC/USD price locked at invoice creation time JobStatusResponse: type: object required: @@ -238,6 +309,30 @@ components: $ref: "#/components/schemas/CostLedger" errorMessage: type: string + ClaimRefundRequest: + type: object + required: + - invoice + properties: + invoice: + type: string + description: BOLT11 invoice for exactly refundAmountSats + ClaimRefundResponse: + type: object + required: + - ok + - refundAmountSats + - paymentHash + - message + properties: + ok: + type: boolean + refundAmountSats: + type: integer + paymentHash: + type: string + message: + type: string DemoResponse: type: object required: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 8789c90..63f5c74 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -90,15 +90,70 @@ export const GetJobResponse = zod.object({ .number() .optional() .describe("Raw Anthropic token cost (no infra, no margin)"), - estimatedCostUsd: zod.number().optional(), + actualChargeUsd: zod + .number() + .optional() + .describe( + "What we honestly charged in USD (actual token cost + DO infra + margin)", + ), + estimatedCostUsd: zod + .number() + .optional() + .describe("Original estimate used to create the work invoice"), + actualAmountSats: zod + .number() + .optional() + .describe( + "Honest sats charge (actual cost converted at the locked BTC price)", + ), + workAmountSats: zod + .number() + .optional() + .describe("Amount the user originally paid in sats"), + refundAmountSats: zod + .number() + .optional() + .describe( + "Sats owed back to the user (workAmountSats - actualAmountSats, >= 0)", + ), + refundState: zod + .enum(["not_applicable", "pending", "paid"]) + .optional() + .describe("Lifecycle of the refund for this job"), marginPct: zod.number().optional(), - btcPriceUsd: zod.number().optional(), + btcPriceUsd: zod + .number() + .optional() + .describe("BTC\/USD price locked at invoice creation time"), }) .optional() - .describe("Actual cost record stored after the job completes"), + .describe("Honest post-work accounting stored after the job completes"), errorMessage: zod.string().optional(), }); +/** + * After a job completes, if the actual cost (tokens used + infra + margin) was +less than the work invoice amount, the difference is owed back to the user. +Submit a BOLT11 invoice for exactly `refundAmountSats` to receive the payment. +Idempotent: returns 409 if already paid or if no refund is owed. + + * @summary Claim a refund for overpayment + */ +export const ClaimRefundParams = zod.object({ + id: zod.coerce.string(), +}); + +export const ClaimRefundBody = zod.object({ + invoice: zod.string().describe("BOLT11 invoice for exactly refundAmountSats"), +}); + +export const ClaimRefundResponse = zod.object({ + ok: zod.boolean(), + refundAmountSats: zod.number(), + paymentHash: zod.string(), + message: zod.string(), +}); + /** * Runs the agent without payment. Limited to 5 requests per IP per hour. * @summary Free demo (rate-limited) diff --git a/lib/api-zod/src/generated/types/claimRefundRequest.ts b/lib/api-zod/src/generated/types/claimRefundRequest.ts new file mode 100644 index 0000000..cdd7eae --- /dev/null +++ b/lib/api-zod/src/generated/types/claimRefundRequest.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.5.3 🍺 + * Do not edit manually. + * Api + * API specification + * OpenAPI spec version: 0.1.0 + */ + +export interface ClaimRefundRequest { + /** BOLT11 invoice for exactly refundAmountSats */ + invoice: string; +} diff --git a/lib/api-zod/src/generated/types/claimRefundResponse.ts b/lib/api-zod/src/generated/types/claimRefundResponse.ts new file mode 100644 index 0000000..8643ece --- /dev/null +++ b/lib/api-zod/src/generated/types/claimRefundResponse.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.5.3 🍺 + * Do not edit manually. + * Api + * API specification + * OpenAPI spec version: 0.1.0 + */ + +export interface ClaimRefundResponse { + ok: boolean; + refundAmountSats: number; + paymentHash: string; + message: string; +} diff --git a/lib/api-zod/src/generated/types/costLedger.ts b/lib/api-zod/src/generated/types/costLedger.ts index 264683a..6b2e39e 100644 --- a/lib/api-zod/src/generated/types/costLedger.ts +++ b/lib/api-zod/src/generated/types/costLedger.ts @@ -5,9 +5,10 @@ * API specification * OpenAPI spec version: 0.1.0 */ +import type { CostLedgerRefundState } from "./costLedgerRefundState"; /** - * Actual cost record stored after the job completes + * Honest post-work accounting stored after the job completes */ export interface CostLedger { actualInputTokens?: number; @@ -16,7 +17,19 @@ export interface CostLedger { totalTokens?: number; /** Raw Anthropic token cost (no infra, no margin) */ actualCostUsd?: number; + /** What we honestly charged in USD (actual token cost + DO infra + margin) */ + actualChargeUsd?: number; + /** Original estimate used to create the work invoice */ estimatedCostUsd?: number; + /** Honest sats charge (actual cost converted at the locked BTC price) */ + actualAmountSats?: number; + /** Amount the user originally paid in sats */ + workAmountSats?: number; + /** Sats owed back to the user (workAmountSats - actualAmountSats, >= 0) */ + refundAmountSats?: number; + /** Lifecycle of the refund for this job */ + refundState?: CostLedgerRefundState; marginPct?: number; + /** BTC/USD price locked at invoice creation time */ btcPriceUsd?: number; } diff --git a/lib/api-zod/src/generated/types/costLedgerRefundState.ts b/lib/api-zod/src/generated/types/costLedgerRefundState.ts new file mode 100644 index 0000000..7f44b92 --- /dev/null +++ b/lib/api-zod/src/generated/types/costLedgerRefundState.ts @@ -0,0 +1,19 @@ +/** + * Generated by orval v8.5.3 🍺 + * Do not edit manually. + * Api + * API specification + * OpenAPI spec version: 0.1.0 + */ + +/** + * Lifecycle of the refund for this job + */ +export type CostLedgerRefundState = + (typeof CostLedgerRefundState)[keyof typeof CostLedgerRefundState]; + +export const CostLedgerRefundState = { + not_applicable: "not_applicable", + pending: "pending", + paid: "paid", +} as const; diff --git a/lib/api-zod/src/generated/types/index.ts b/lib/api-zod/src/generated/types/index.ts index 5c7ac17..9547d3e 100644 --- a/lib/api-zod/src/generated/types/index.ts +++ b/lib/api-zod/src/generated/types/index.ts @@ -6,7 +6,10 @@ * OpenAPI spec version: 0.1.0 */ +export * from "./claimRefundRequest"; +export * from "./claimRefundResponse"; export * from "./costLedger"; +export * from "./costLedgerRefundState"; export * from "./createJobRequest"; export * from "./createJobResponse"; export * from "./demoResponse"; diff --git a/lib/db/migrations/0003_refund_mechanism.sql b/lib/db/migrations/0003_refund_mechanism.sql new file mode 100644 index 0000000..573fc66 --- /dev/null +++ b/lib/db/migrations/0003_refund_mechanism.sql @@ -0,0 +1,13 @@ +-- Migration: Add refund/honest-accounting columns to jobs table +-- Users are refunded the overpayment (work invoice amount - actual cost) after job completion. + +ALTER TABLE jobs + ADD COLUMN IF NOT EXISTS actual_amount_sats INTEGER, + ADD COLUMN IF NOT EXISTS refund_amount_sats INTEGER, + ADD COLUMN IF NOT EXISTS refund_state TEXT, + ADD COLUMN IF NOT EXISTS refund_payment_hash TEXT; + +COMMENT ON COLUMN jobs.actual_amount_sats IS 'Actual charge in sats (raw token cost + DO infra + margin), computed post-execution using the locked BTC price'; +COMMENT ON COLUMN jobs.refund_amount_sats IS 'Overpayment in sats to return to the user (work_amount_sats - actual_amount_sats, clamped >= 0)'; +COMMENT ON COLUMN jobs.refund_state IS 'Refund lifecycle: not_applicable | pending | paid'; +COMMENT ON COLUMN jobs.refund_payment_hash IS 'Payment hash of the outgoing Lightning refund payment once sent'; diff --git a/lib/db/src/schema/jobs.ts b/lib/db/src/schema/jobs.ts index 161bbb9..535c651 100644 --- a/lib/db/src/schema/jobs.ts +++ b/lib/db/src/schema/jobs.ts @@ -36,6 +36,12 @@ export const jobs = pgTable("jobs", { actualOutputTokens: integer("actual_output_tokens"), actualCostUsd: real("actual_cost_usd"), + // ── Post-work honest accounting & refund ───────────────────────────────── + actualAmountSats: integer("actual_amount_sats"), + refundAmountSats: integer("refund_amount_sats"), + refundState: text("refund_state").$type<"not_applicable" | "pending" | "paid">(), + refundPaymentHash: text("refund_payment_hash"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }); diff --git a/replit.md b/replit.md index dca698a..c6e6de0 100644 --- a/replit.md +++ b/replit.md @@ -93,6 +93,17 @@ Every package extends `tsconfig.base.json` which sets `composite: true`. The roo Work fee flow: estimate tokens → fetch BTC price from CoinGecko (60s cache) → `(token_cost + DO_infra) × (1 + margin%)` → convert USD → sats. After work runs, actual token counts and raw Anthropic spend are stored in `jobs` as `actual_input_tokens`, `actual_output_tokens`, `actual_cost_usd`. +### Honest accounting and automatic refunds + +After every job completes, Timmy re-prices the job using **actual** token counts, adds DO infra amortisation and margin, converts back to sats using the **same BTC price locked at invoice time**, and computes the overpayment: + +``` +actual_amount_sats = ceil((actual_cost_usd + DO_infra) × (1 + margin%) / btc_price_usd × 1e8) +refund_amount_sats = max(0, work_amount_sats - actual_amount_sats) +``` + +The `costLedger` in `GET /api/jobs/:id` shows all figures side-by-side. If `refundState = "pending"` and `refundAmountSats > 0`, the user submits a BOLT11 invoice for that exact amount to `POST /api/jobs/:id/refund` to receive the Lightning payment. The endpoint is idempotent — subsequent calls return 409 with the original `refundPaymentHash`. + ### Node bootstrap secrets (for `POST /api/bootstrap`) | Secret | Description | Default |