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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user