Files
timmy-tower/lib/db/src/schema/jobs.ts
alexpaynex 4c3a0e867a Task #27: Cost-routing + free-tier gate
## What was built

### DB schema
- `timmy_config` table: key/value store for the generosity pool balance
- `free_tier_grants` table: immutable audit log of every Timmy-absorbed request
- `jobs.free_tier` (boolean) + `jobs.absorbed_sats` (integer) columns

### FreeTierService (`lib/free-tier.ts`)
- Per-tier daily sats budgets (new=0, established=50, trusted=200, elite=1000)
  — all env-var overridable
- `decide(pubkey, estimatedSats)` → `{ serve: free|partial|gate, absorbSats, chargeSats }`
  — checks pool balance AND identity daily budget atomically
- `credit(paidSats)` — credits POOL_CREDIT_PCT (default 10%) of every paid
  work invoice back to the generosity pool
- `recordGrant(pubkey, reqHash, absorbSats)` — DB transaction: deducts pool,
  updates identity daily absorption counter, writes audit row
- `poolStatus()` — snapshot for metrics/monitoring

### Route integration
- `POST /api/jobs` (eval → work flow): after eval passes, `freeTierService.decide()`
  intercepts. Free → skip invoice, fire work directly. Partial → discounted invoice.
  Gate (anonymous/new tier/pool empty) → unchanged full-price flow.
- `POST /api/sessions/:id/request`: after compute, free-tier discount applied to
  balance debit. Session balance only reduced by `chargeSats`; absorbed portion
  comes from pool.
- Pool credited on every paid work completion (both jobs and session paths).
- Response fields: `free_tier: true`, `absorbed_sats: N` when applicable.

### GET /api/estimate
- Lightweight pre-flight cost estimator; no payment required
- Returns: estimatedSats, btcPriceUsd, tokenEstimate, identity.free_tier decision
  (if valid nostr_token provided), pool.balanceSats, pool.dailyBudgets

### Tests
- All 29 existing testkit tests pass (0 failures)
- Anonymous/new-tier users hit gate path correctly (verified manually)
- Pool seeds to 10,000 sats on first boot

## Architecture notes
- Free tier decision happens BEFORE invoice creation for jobs (save user the click)
- Partial grant recorded at invoice creation time (reserves pool capacity proactively)
- Free tier for sessions decided AFTER compute (actual cost known, applied to debit)
- Pool crediting is fire-and-forget (non-blocking)
2026-03-19 16:34:05 +00:00

66 lines
2.7 KiB
TypeScript

import { pgTable, text, timestamp, integer, real, boolean } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
import { nostrIdentities } from "./nostr-identities";
export const JOB_STATES = [
"awaiting_eval_payment",
"evaluating",
"rejected",
"awaiting_work_payment",
"executing",
"complete",
"failed",
] as const;
export type JobState = (typeof JOB_STATES)[number];
export const jobs = pgTable("jobs", {
id: text("id").primaryKey(),
request: text("request").notNull(),
state: text("state").$type<JobState>().notNull().default("awaiting_eval_payment"),
evalInvoiceId: text("eval_invoice_id"),
workInvoiceId: text("work_invoice_id"),
evalAmountSats: integer("eval_amount_sats").notNull(),
workAmountSats: integer("work_amount_sats"),
rejectionReason: text("rejection_reason"),
result: text("result"),
errorMessage: text("error_message"),
// ── Cost-based pricing (set when work invoice is created) ───────────────
estimatedCostUsd: real("estimated_cost_usd"),
marginPct: real("margin_pct"),
btcPriceUsd: real("btc_price_usd"),
// ── Actual token usage (set after work executes) ────────────────────────
actualInputTokens: integer("actual_input_tokens"),
actualOutputTokens: integer("actual_output_tokens"),
actualCostUsd: real("actual_cost_usd"),
// Optional Nostr identity bound at job creation (FK → nostr_identities.pubkey)
nostrPubkey: text("nostr_pubkey").references(() => nostrIdentities.pubkey),
// ── Free-tier routing (Task #27) ─────────────────────────────────────────
// freeTier=true: Timmy absorbed the work cost from the generosity pool.
// absorbedSats: how many sats Timmy absorbed (0 for fully-paid jobs).
freeTier: boolean("free_tier").notNull().default(false),
absorbedSats: integer("absorbed_sats"),
// ── 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(),
});
export const insertJobSchema = createInsertSchema(jobs).omit({
createdAt: true,
updatedAt: true,
});
export type Job = typeof jobs.$inferSelect;
export type InsertJob = z.infer<typeof insertJobSchema>;