Add honest accounting and automatic refund mechanism for completed jobs
Implement honest accounting post-job completion, calculating actual costs, adding margin, and enabling automatic refunds for overpayments via a new endpoint. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 418bf6f8-212b-4bb0-a7a5-8231a061da4e Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: c6386de2-d5f4-47cc-a557-73416f09e118 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/9f85e954-647c-46a5-90a7-396e495a805a/418bf6f8-212b-4bb0-a7a5-8231a061da4e/sPDHkg8 Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -26,6 +26,8 @@ export class LNbitsService {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inbound invoices ─────────────────────────────────────────────────────
|
||||
|
||||
async createInvoice(amountSats: number, memo: string): Promise<LNbitsInvoice> {
|
||||
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<string> {
|
||||
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<string, string> {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -50,7 +50,6 @@ async function advanceJob(job: Job): Promise<Job | null> {
|
||||
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<Job | null> {
|
||||
|
||||
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<Job | null> {
|
||||
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<Job | null> {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<ClaimRefundResponse> => {
|
||||
return customFetch<ClaimRefundResponse>(getClaimRefundUrl(id), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(claimRefundRequest),
|
||||
});
|
||||
};
|
||||
|
||||
export const getClaimRefundMutationOptions = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof claimRefund>>,
|
||||
TError,
|
||||
{ id: string; data: BodyType<ClaimRefundRequest> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof claimRefund>>,
|
||||
TError,
|
||||
{ id: string; data: BodyType<ClaimRefundRequest> },
|
||||
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<ReturnType<typeof claimRefund>>,
|
||||
{ id: string; data: BodyType<ClaimRefundRequest> }
|
||||
> = (props) => {
|
||||
const { id, data } = props ?? {};
|
||||
|
||||
return claimRefund(id, data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ClaimRefundMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof claimRefund>>
|
||||
>;
|
||||
export type ClaimRefundMutationBody = BodyType<ClaimRefundRequest>;
|
||||
export type ClaimRefundMutationError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Claim a refund for overpayment
|
||||
*/
|
||||
export const useClaimRefund = <
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof claimRefund>>,
|
||||
TError,
|
||||
{ id: string; data: BodyType<ClaimRefundRequest> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof claimRefund>>,
|
||||
TError,
|
||||
{ id: string; data: BodyType<ClaimRefundRequest> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getClaimRefundMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs the agent without payment. Limited to 5 requests per IP per hour.
|
||||
* @summary Free demo (rate-limited)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
12
lib/api-zod/src/generated/types/claimRefundRequest.ts
Normal file
12
lib/api-zod/src/generated/types/claimRefundRequest.ts
Normal file
@@ -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;
|
||||
}
|
||||
14
lib/api-zod/src/generated/types/claimRefundResponse.ts
Normal file
14
lib/api-zod/src/generated/types/claimRefundResponse.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
19
lib/api-zod/src/generated/types/costLedgerRefundState.ts
Normal file
19
lib/api-zod/src/generated/types/costLedgerRefundState.ts
Normal file
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
13
lib/db/migrations/0003_refund_mechanism.sql
Normal file
13
lib/db/migrations/0003_refund_mechanism.sql
Normal file
@@ -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';
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
11
replit.md
11
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 |
|
||||
|
||||
Reference in New Issue
Block a user