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

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

View File

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

View File

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