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:
alexpaynex
2026-03-18 19:32:34 +00:00
parent e5bdae7159
commit dfc9ecdc7b
15 changed files with 587 additions and 38 deletions

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)

View 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;
}

View 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;
}

View File

@@ -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;
}

View 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;

View File

@@ -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";

View 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';

View File

@@ -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(),
});