Skip to content

Commit cd09f55

Browse files
committed
feat(credits): user credit ledger, subscription plans, and spend gate
Server-owned credits and plans for the Studio product: - nodetool_credit_ledger + nodetool_user_subscriptions tables (migration 20260806_000000, both dialects, test-DB DDL). The ledger stores grants only; balance = sum(grants) - ceil(prediction spend / 1 cent), so spend is never double-booked. - Plan catalog (Free 300/mo, Creator 3000/mo, Pro 10000/mo) with lazy, idempotent monthly accrual keyed plan:<user>:<plan>:<YYYY-MM> — no cron. - trpc.credits.status/setPlan/topup (schemas in protocol). Top-up is an explicit no-payment stub; a payment provider replaces it later. - Spend gate (credit-gate.ts), off by default: with NODETOOL_CREDITS_ENFORCED=1 workflow runs are refused on an empty balance (BUDGET_EXCEEDED, next to the application-budget gate) and the direct generate_media / transcribe_audio RPCs throw. Fails open on gate errors like the app gate. - Studio UI: server-backed credits chip linking to /studio/account — balance, usage, plan cards, test top-up. - Tests: credits model (7), credit gate (3), migration count bump. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011GwuPTt5T1sLkmpwWUt2N2
1 parent a3c7030 commit cd09f55

23 files changed

Lines changed: 998 additions & 65 deletions

docs/agentic-video-product.md

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -57,31 +57,46 @@ Changing the lineup is editing that file. If the product later needs per-plan
5757
lineups (e.g. faster models on the free tier), the same shape can move to a
5858
server-delivered config.
5959

60-
## Credits
61-
62-
Prototype semantics, in `web/src/studio/useStudioCredits.ts`:
63-
64-
- 1 credit = $0.01 of provider spend.
65-
- Balance = flat grant (1,000 credits) minus everything in the prediction
66-
ledger for the last 90 days, read from `costs.dashboard`.
67-
- Display-only: the chip in the Studio header. Nothing is blocked
68-
client-side.
69-
70-
The path to real enforcement already exists in the platform and is the next
71-
step after the prototype validates:
72-
73-
1. **Server gate.** The `application_budgets` machinery
74-
(`packages/models/src/application-budget.ts`, enforced in
75-
`unified-websocket-runner.ts` with `BUDGET_EXCEEDED`) already does
76-
estimate → reserve → settle per invocation. Generalize the key from
77-
`application_id` to a user-scoped budget row and every generation path —
78-
storyboard stills/clips, voicing, timeline generate — is gated by the same
79-
code.
80-
2. **Purchases.** A `credits` tRPC router (alongside `costsRouter`) exposing
81-
balance + top-ups; grants become ledger rows instead of a client constant.
82-
3. **Estimates before spend.** `@nodetool-ai/model-pricing`
60+
## Credits and plans
61+
62+
Server-owned, in `packages/models/src/credits.ts` (`@nodetool-ai/models`):
63+
64+
- **1 credit = $0.01 of provider spend.** Spend is never double-booked: the
65+
balance is `sum(grant ledger) - ceil(prediction spend / 1¢)`, read straight
66+
from the `nodetool_predictions` rows every provider call already writes.
67+
- **Ledger** (`nodetool_credit_ledger`) holds grants only: monthly plan
68+
accruals, top-ups, adjustments. Plan grants use the row id
69+
`plan:<userId>:<planId>:<YYYY-MM>`, so the lazy accrual (run on every
70+
status read) is idempotent by primary key — no cron.
71+
- **Plans** (`nodetool_user_subscriptions`, catalog `CREDIT_PLANS`): Free
72+
300/mo, Creator 3,000/mo ($12), Pro 10,000/mo ($40). Switching is instant
73+
and unbilled; a payment provider integration replaces the `topup` mutation
74+
with a checkout session and writes ledger rows from its webhook.
75+
- **API**: `trpc.credits.status | setPlan | topup`
76+
(`packages/websocket/src/trpc/routers/credits.ts`, schemas in
77+
`packages/protocol/src/api-schemas/credits.ts`).
78+
- **Enforcement** (`packages/websocket/src/credit-gate.ts`): off by default —
79+
the open platform meters cost but never blocks. A Studio deployment sets
80+
`NODETOOL_CREDITS_ENFORCED=1`, and then every spend path refuses on an
81+
empty balance with `BUDGET_EXCEEDED`: workflow runs (`admitCreditRun` in
82+
`unified-websocket-runner.ts`, next to the application-budget gate, using
83+
the same cost estimate as a floor) and the direct `generate_media` /
84+
`transcribe_audio` RPCs the script editor voices through. Like the app
85+
gate, it fails open on gate errors.
86+
- **UI**: the header chip reads `credits.status` and links to
87+
`/studio/account` — balance, usage, plan cards, and the (clearly labeled)
88+
test top-up.
89+
90+
Still open, in order of value:
91+
92+
1. **Per-action estimates.** `@nodetool-ai/model-pricing`
8393
(`getModelUnitPrice`) prices the curated models per unit, so shot cards
8494
and the voice-all button can show "≈ 3 credits" before the click.
95+
2. **Payments.** Stripe (or similar) in front of `setPlan`/`topup`; the
96+
ledger and gate don't change.
97+
3. **Direct-RPC metering.** `generate_media`/`transcribe_audio` are gated but
98+
still write no prediction rows, so their spend doesn't decrement the
99+
balance. Recording a row at the provider's reported cost closes that.
85100

86101
## From prototype to product
87102

packages/models/src/credits.ts

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
/**
2+
* User credits and subscription plans — the Studio product's billing layer.
3+
*
4+
* The ledger stores grants only. Spend is never written here: every provider
5+
* call already lands in `nodetool_predictions` with its USD cost, so
6+
*
7+
* balance = sum(ledger.delta) - ceil(prediction spend / USD_PER_CREDIT)
8+
*
9+
* Plans accrue lazily: the first balance read in a month inserts that month's
10+
* grant, keyed `plan:<userId>:<periodKey>` so the primary key makes double
11+
* accrual impossible. No cron, no payment state — a payment provider webhook
12+
* would write `topup` rows and flip `plan_id`; until then plan switches are
13+
* instant and top-ups are stubbed.
14+
*/
15+
import { eq, sql } from "drizzle-orm";
16+
17+
import { getDb } from "./db.js";
18+
import { creditLedger, userSubscriptions } from "./schema/credits.js";
19+
import { predictions } from "./schema/predictions.js";
20+
import { createTimeOrderedUuid } from "./base-model.js";
21+
22+
/** One credit is one US cent of provider spend. */
23+
export const USD_PER_CREDIT = 0.01;
24+
25+
export interface CreditPlan {
26+
id: string;
27+
name: string;
28+
/** Credits granted at the start of each calendar month (UTC). */
29+
monthlyCredits: number;
30+
/** Display price; billing itself is not implemented. */
31+
priceUsdPerMonth: number;
32+
blurb: string;
33+
}
34+
35+
export const CREDIT_PLANS: readonly CreditPlan[] = [
36+
{
37+
id: "free",
38+
name: "Free",
39+
monthlyCredits: 300,
40+
priceUsdPerMonth: 0,
41+
blurb: "Try both creation paths with a monthly starter allowance."
42+
},
43+
{
44+
id: "creator",
45+
name: "Creator",
46+
monthlyCredits: 3_000,
47+
priceUsdPerMonth: 12,
48+
blurb: "Enough for regular short-form work: stills, clips, and voice."
49+
},
50+
{
51+
id: "pro",
52+
name: "Pro",
53+
monthlyCredits: 10_000,
54+
priceUsdPerMonth: 40,
55+
blurb: "Headroom for daily production and longer cuts."
56+
}
57+
] as const;
58+
59+
export const DEFAULT_PLAN_ID = "free";
60+
61+
export const planById = (planId: string): CreditPlan | null =>
62+
CREDIT_PLANS.find((p) => p.id === planId) ?? null;
63+
64+
/** Calendar-month key, UTC — "2026-08". */
65+
export const periodKeyFor = (now: Date): string =>
66+
`${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
67+
68+
export interface UserSubscription {
69+
userId: string;
70+
planId: string;
71+
status: string;
72+
updatedAt: string;
73+
}
74+
75+
export interface CreditStatus {
76+
userId: string;
77+
plan: CreditPlan;
78+
periodKey: string;
79+
/** All grants ever recorded, in credits. */
80+
grantedCredits: number;
81+
/** All prediction spend ever recorded, rounded up, in credits. */
82+
spentCredits: number;
83+
/** grantedCredits - spentCredits, floored at 0 for display. */
84+
balanceCredits: number;
85+
spentUsd: number;
86+
}
87+
88+
export type CreditDecision =
89+
| { allowed: true; status: CreditStatus }
90+
| { allowed: false; reason: string; status: CreditStatus };
91+
92+
const toSubscription = (row: Record<string, unknown>): UserSubscription => ({
93+
userId: String(row.user_id),
94+
planId: String(row.plan_id ?? DEFAULT_PLAN_ID),
95+
status: String(row.status ?? "active"),
96+
updatedAt: String(row.updated_at ?? "")
97+
});
98+
99+
/** The user's subscription, creating the default (free) row on first read. */
100+
export async function getSubscription(
101+
userId: string
102+
): Promise<UserSubscription> {
103+
const db = getDb();
104+
const rows = await db
105+
.select()
106+
.from(userSubscriptions)
107+
.where(eq(userSubscriptions.user_id, userId))
108+
.limit(1);
109+
if (rows[0]) return toSubscription(rows[0] as Record<string, unknown>);
110+
111+
const now = new Date().toISOString();
112+
try {
113+
await db.insert(userSubscriptions).values({
114+
user_id: userId,
115+
plan_id: DEFAULT_PLAN_ID,
116+
status: "active",
117+
created_at: now,
118+
updated_at: now
119+
});
120+
} catch {
121+
// Concurrent first read created it; fall through to the re-read.
122+
}
123+
const created = await db
124+
.select()
125+
.from(userSubscriptions)
126+
.where(eq(userSubscriptions.user_id, userId))
127+
.limit(1);
128+
return toSubscription(created[0] as Record<string, unknown>);
129+
}
130+
131+
export async function setSubscriptionPlan(
132+
userId: string,
133+
planId: string
134+
): Promise<UserSubscription> {
135+
const plan = planById(planId);
136+
if (!plan) throw new Error(`Unknown plan "${planId}".`);
137+
await getSubscription(userId);
138+
const db = getDb();
139+
await db
140+
.update(userSubscriptions)
141+
.set({ plan_id: plan.id, updated_at: new Date().toISOString() })
142+
.where(eq(userSubscriptions.user_id, userId));
143+
// Accrue the new plan's grant for the current month right away, so an
144+
// upgrade is usable the moment it happens (the id keys on plan too, so an
145+
// upgraded month carries both grants — acceptable, and simpler than
146+
// proration).
147+
await ensureMonthlyGrant(userId, new Date());
148+
return getSubscription(userId);
149+
}
150+
151+
/**
152+
* Insert this month's plan grant if it isn't there yet. Idempotent via the
153+
* primary key `plan:<userId>:<planId>:<periodKey>`.
154+
*/
155+
export async function ensureMonthlyGrant(
156+
userId: string,
157+
now: Date
158+
): Promise<void> {
159+
const subscription = await getSubscription(userId);
160+
const plan = planById(subscription.planId) ?? planById(DEFAULT_PLAN_ID)!;
161+
if (subscription.status !== "active" || plan.monthlyCredits <= 0) return;
162+
163+
const periodKey = periodKeyFor(now);
164+
const id = `plan:${userId}:${plan.id}:${periodKey}`;
165+
const db = getDb();
166+
const existing = await db
167+
.select({ id: creditLedger.id })
168+
.from(creditLedger)
169+
.where(eq(creditLedger.id, id))
170+
.limit(1);
171+
if (existing[0]) return;
172+
try {
173+
await db.insert(creditLedger).values({
174+
id,
175+
user_id: userId,
176+
delta: plan.monthlyCredits,
177+
kind: "plan_grant",
178+
description: `${plan.name} plan — ${periodKey}`,
179+
period_key: periodKey,
180+
created_at: now.toISOString()
181+
});
182+
} catch {
183+
// Lost a race with a concurrent accrual of the same id — already granted.
184+
}
185+
}
186+
187+
/** Record a top-up or manual adjustment. */
188+
export async function grantCredits(
189+
userId: string,
190+
delta: number,
191+
kind: "topup" | "adjustment",
192+
description?: string
193+
): Promise<void> {
194+
if (!Number.isFinite(delta) || delta === 0) {
195+
throw new Error("Credit delta must be a non-zero number.");
196+
}
197+
const db = getDb();
198+
await db.insert(creditLedger).values({
199+
id: createTimeOrderedUuid(),
200+
user_id: userId,
201+
delta: Math.trunc(delta),
202+
kind,
203+
description: description ?? null,
204+
period_key: null,
205+
created_at: new Date().toISOString()
206+
});
207+
}
208+
209+
/** Balance, plan, and totals — accrues the current month's grant first. */
210+
export async function creditStatus(userId: string): Promise<CreditStatus> {
211+
const now = new Date();
212+
await ensureMonthlyGrant(userId, now);
213+
const subscription = await getSubscription(userId);
214+
const plan = planById(subscription.planId) ?? planById(DEFAULT_PLAN_ID)!;
215+
216+
const db = getDb();
217+
const grantRows = await db
218+
.select({
219+
total: sql<number>`COALESCE(SUM(${creditLedger.delta}), 0)`
220+
})
221+
.from(creditLedger)
222+
.where(eq(creditLedger.user_id, userId));
223+
const grantedCredits = Number(grantRows[0]?.total ?? 0);
224+
225+
const spendRows = await db
226+
.select({ total: sql<number>`COALESCE(SUM(${predictions.cost}), 0)` })
227+
.from(predictions)
228+
.where(eq(predictions.user_id, userId));
229+
const spentUsd = Number(spendRows[0]?.total ?? 0);
230+
const spentCredits = Math.ceil(spentUsd / USD_PER_CREDIT);
231+
232+
return {
233+
userId,
234+
plan,
235+
periodKey: periodKeyFor(now),
236+
grantedCredits,
237+
spentCredits,
238+
balanceCredits: Math.max(0, grantedCredits - spentCredits),
239+
spentUsd
240+
};
241+
}
242+
243+
/**
244+
* The pre-spend gate. `estimatedUsd` is a floor (unpriceable nodes estimate
245+
* 0), so the check is: the balance must cover the estimate, and must be
246+
* positive at all. Callers decide whether the gate is on at all
247+
* (NODETOOL_CREDITS_ENFORCED).
248+
*/
249+
export async function checkCredits(
250+
userId: string,
251+
estimatedUsd: number
252+
): Promise<CreditDecision> {
253+
const status = await creditStatus(userId);
254+
const estimatedCredits = Math.ceil(
255+
Math.max(0, estimatedUsd) / USD_PER_CREDIT
256+
);
257+
if (status.balanceCredits <= 0) {
258+
return {
259+
allowed: false,
260+
reason: `Out of credits on the ${status.plan.name} plan. Upgrade or top up to continue.`,
261+
status
262+
};
263+
}
264+
if (estimatedCredits > status.balanceCredits) {
265+
return {
266+
allowed: false,
267+
reason: `This run needs about ${estimatedCredits} credits but ${status.balanceCredits} remain.`,
268+
status
269+
};
270+
}
271+
return { allowed: true, status };
272+
}

packages/models/src/db.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1161,6 +1161,25 @@ function getCreateSchemaSql(): string {
11611161
CREATE INDEX IF NOT EXISTS "idx_application_invocation_created" ON "application_invocations" ("created_at");
11621162
CREATE INDEX IF NOT EXISTS "idx_application_invocation_invocation" ON "application_invocations" ("invocation_id");
11631163
1164+
CREATE TABLE IF NOT EXISTS "nodetool_credit_ledger" (
1165+
"id" text PRIMARY KEY NOT NULL,
1166+
"user_id" text NOT NULL,
1167+
"delta" integer NOT NULL,
1168+
"kind" text NOT NULL,
1169+
"description" text,
1170+
"period_key" text,
1171+
"created_at" text NOT NULL
1172+
);
1173+
CREATE INDEX IF NOT EXISTS "idx_credit_ledger_user" ON "nodetool_credit_ledger" ("user_id");
1174+
1175+
CREATE TABLE IF NOT EXISTS "nodetool_user_subscriptions" (
1176+
"user_id" text PRIMARY KEY NOT NULL,
1177+
"plan_id" text NOT NULL DEFAULT 'free',
1178+
"status" text NOT NULL DEFAULT 'active',
1179+
"created_at" text NOT NULL,
1180+
"updated_at" text NOT NULL
1181+
);
1182+
11641183
CREATE TABLE IF NOT EXISTS "scripts" (
11651184
"id" text PRIMARY KEY NOT NULL,
11661185
"user_id" text NOT NULL,

packages/models/src/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,22 @@ export function getGlobalAdapterResolver():
333333
// Legacy types kept for API compat
334334
export type { IndexSpec } from "./legacy-compat.js";
335335
export type { ModelClass, AdapterResolver } from "./legacy-compat.js";
336+
export {
337+
CREDIT_PLANS,
338+
DEFAULT_PLAN_ID,
339+
USD_PER_CREDIT,
340+
checkCredits,
341+
creditStatus,
342+
ensureMonthlyGrant,
343+
getSubscription,
344+
grantCredits,
345+
periodKeyFor,
346+
planById,
347+
setSubscriptionPlan
348+
} from "./credits.js";
349+
export type {
350+
CreditDecision,
351+
CreditPlan,
352+
CreditStatus,
353+
UserSubscription
354+
} from "./credits.js";

0 commit comments

Comments
 (0)