Smart reviews uncertain answers. You pay extra only for successful escalations. For example, 1 million input tokens with 50 Smart escalations cost $0.142.
-
Output tokens are free. Input usage includes text, labels and instructions. Retries, fallback routing and Smart model tokens add no separate charges. No automatic top-ups.
+
Output tokens are free. Input usage includes text, labels and instructions. Retries, fallback routing and Smart model tokens add no separate charges. Pay-as-you-go top-ups and optional auto recharge live on the billing page.
diff --git a/src/features/dashboard/app-view.tsx b/src/features/dashboard/app-view.tsx
index d1f9459..67814e7 100644
--- a/src/features/dashboard/app-view.tsx
+++ b/src/features/dashboard/app-view.tsx
@@ -157,7 +157,7 @@ export function DashboardView({
) : pathname === "/app/usage" ? (
) : pathname === "/app/credits" ? (
-
+
) : pathname === "/app/plans" ? (
) : pathname === "/app/team" && snapshot.organizations ? (
diff --git a/src/http/classification.ts b/src/http/classification.ts
index 1e2f6e3..79afb68 100644
--- a/src/http/classification.ts
+++ b/src/http/classification.ts
@@ -11,6 +11,7 @@ import { extendTokenReservation, providerCallBound } from "../server/token-reser
import { refundTokenReservation, settleTokenReservation } from "../server/token-ledger";
import { writeAccountAnalytics } from "../server/analytics/write";
import { typeSafeDecisionCount } from "../typesafe-compat";
+import { rateLimitMultiplier } from "../lib/billing";
import { isLongContextRequest } from "../long-context";
import { documentRequest } from "./document";
@@ -93,7 +94,7 @@ export async function accountClassification(request: Request, env: AppEnv & Part
let response: Response;
try {
response = await worker.fetch(new Request(request.url, { method: "POST", headers: request.headers, body: text }), env as Env, ctx,
- { account: { id: accountId, multiplier: reservation.billingPlan === "free" ? 1 : 10 }, meter });
+ { account: { id: accountId, multiplier: rateLimitMultiplier(reservation.billingPlan, reservation.billingPlan !== "free") }, meter });
await reservationQueue;
if (admissionError) throw admissionError;
} catch (error) {
diff --git a/src/http/spending-classification.ts b/src/http/spending-classification.ts
index fe82540..bfc85c8 100644
--- a/src/http/spending-classification.ts
+++ b/src/http/spending-classification.ts
@@ -10,6 +10,8 @@ import { Permit } from "../spending/permit";
import { boundedRequest } from "../spending";
import { SpendingError, errorResponse, fingerprint, policy } from "../spending/policy";
import { writeAccountAnalytics } from "../server/analytics/write";
+import { maybeAutoTopUp } from "../server/auto-top-up";
+import { rateLimitMultiplier } from "../lib/billing";
import { typeSafeDecisionCount } from "../typesafe-compat";
export async function spendingClassification(request: Request, env: AppEnv & Partial, source: "API" | "MCP", ctx: ExecutionContext): Promise {
@@ -72,7 +74,7 @@ export async function spendingClassification(request: Request, env: AppEnv & Par
if ((scrape && classificationBound + BigInt(SCRAPE_NANODOLLARS) > BigInt(limits.paidRequest)) || quote > maxCredits) throw new SpendingError(402, "request_spending_limit", "This request exceeds the workspace request allowance. Split the batch.");
const idempotencyHash = idem ? await fingerprint(env, `account-idempotency:${idem}`) : null;
const result = await env.APP_DB.prepare(`WITH owner AS (
- SELECT a.id,a.balance,a.paid_balance,(a.paid_balance>0 OR (a.billing_plan IN ('pro','max','scale') AND a.reset_at::timestamptz>now())) AS funded,k.id AS agent_id FROM app_accounts a JOIN app_agents k ON k.account_id=a.id
+ SELECT a.id,a.balance,a.paid_balance,a.billing_plan,(a.paid_balance>0 OR (a.billing_plan IN ('pro','max','scale') AND a.reset_at::timestamptz>now())) AS funded,k.id AS agent_id FROM app_accounts a JOIN app_agents k ON k.account_id=a.id
WHERE k.token_hash=? AND k.status IN ('pending','connected') AND NOT a.billing_hold FOR UPDATE OF a
), held AS (
INSERT INTO app_usage(id,account_id,agent_id,items,credits,status,created_at,paid_credits,usage_type,metering_mode,idempotency_key,classifications)
@@ -84,9 +86,9 @@ export async function spendingClassification(request: Request, env: AppEnv & Par
FROM held h WHERE a.id=h.account_id RETURNING a.id
), agent AS (
UPDATE app_agents a SET used=a.used+h.credits FROM held h,debited d WHERE a.id=h.agent_id AND d.id=h.account_id RETURNING a.id
- ) SELECT h.account_id,h.agent_id,h.credits,o.funded FROM held h JOIN owner o ON o.id=h.account_id JOIN agent k ON k.id=h.agent_id`)
+ ) SELECT h.account_id,h.agent_id,h.credits,o.funded,o.billing_plan FROM held h JOIN owner o ON o.id=h.account_id JOIN agent k ON k.id=h.agent_id`)
.bind(keyHash, id, items, quote, now(), quote, `${source} · ${scrape ? "URL classification" : "Classification"}`, idempotencyHash, decisions, !!scrape, quote, longContext || !!scrape)
- .first<{ account_id: string; agent_id: string; credits: number; funded: boolean }>();
+ .first<{ account_id: string; agent_id: string; credits: number; funded: boolean; billing_plan: string }>();
if (!result) {
const reason = await env.APP_DB.prepare(`SELECT k.status,a.billing_hold,
(a.paid_balance>0 OR (a.billing_plan IN ('pro','max','scale') AND a.reset_at::timestamptz>now())) AS funded,
@@ -119,7 +121,7 @@ export async function spendingClassification(request: Request, env: AppEnv & Par
}
request = new Request(request.url, { method: "POST", headers: request.headers, body: JSON.stringify(body), signal: request.signal });
}
- response = await worker.fetch(request, env as Env, ctx, { meter, account: { id: result.account_id, multiplier: result.funded ? 10 : 1 }, funded: result.funded });
+ response = await worker.fetch(request, env as Env, ctx, { meter, account: { id: result.account_id, multiplier: rateLimitMultiplier(result.billing_plan, result.funded) }, funded: result.funded });
if (permit?.error && !(response.ok && permit.error.code === "request_spending_limit")) response = errorResponse(permit.error);
} catch (error) {
response = error instanceof SpendingError ? errorResponse(error) : Response.json({ error: "Classification failed." }, { status: 502 });
@@ -153,6 +155,9 @@ export async function spendingClassification(request: Request, env: AppEnv & Par
outputTokens: tokens.every(t => t.outputTokens !== null) ? tokens.reduce((sum, t) => sum + t.outputTokens!, 0) : null,
cachedInputTokens: null, model: tokens.map(t => t.model).join(","), providerCostUsd: actual?.unknown || scrapeCharged ? null : (actual?.used ?? 0) / 1e9,
retailCostUsd: charge ? Number(charge.nanodollars) / 1e9 : response.ok ? null : 0, latencyMs: Date.now() - started, escalations: typeof escalations === "number" ? escalations : 0 });
+ // The settled charge may have taken the balance below the owner's
+ // auto top-up threshold; the claim statement enforces every limit.
+ if (response.ok) { try { await maybeAutoTopUp(env, result.account_id); } catch { /* the sweep retries */ } }
return;
} catch { /* Durable pending funds remain unavailable until reconciliation. */ }
}
diff --git a/src/index.ts b/src/index.ts
index f5fe972..65b83a5 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -306,7 +306,7 @@ const agentView = (origin: string) => ({
name: "classifier.dev",
description: "Zero-shot text classification over plain HTTP. No API key, no account.",
version: API_VERSION,
- authentication: { required: false, optional_bearer: "Workspace keys use the workspace balance; Pro workspaces get 10x limits. Partner keys have separately arranged limits.", docs: `${origin}/auth.md` },
+ authentication: { required: false, optional_bearer: "Workspace keys use the workspace balance; Pro raises rate limits 10x and Scale 100x. Partner keys have separately arranged limits.", docs: `${origin}/auth.md` },
api: {
classify: { method: "POST", url: `${origin}/v1/classify`, alias: `${origin}/`, body: { inputs: ["..."], labels: ["a", "b"], tier: "fast|smart", multi: false } },
classify_url: { method: "POST", url: `${origin}/v1/classify`, body: { url: "https://example.com", labels: ["documentation", "news"], include: ["markdown", "html"] }, scrape_usd: 0.0022, authentication: "Funded workspace API key" },
diff --git a/src/lib/billing.ts b/src/lib/billing.ts
index 59f2db1..51a0ac2 100644
--- a/src/lib/billing.ts
+++ b/src/lib/billing.ts
@@ -1,6 +1,6 @@
/** Money stays in integer credits until presentation: one credit is $0.00001. */
export const CREDITS_PER_DOLLAR = 100_000;
-export type BillingPlanId = "free" | "pro" | "max" | "scale";
+export type BillingPlanId = "free" | "pro" | "scale";
export const BILLING_PLANS = {
free: {
id: "free",
@@ -8,6 +8,7 @@ export const BILLING_PLANS = {
priceCents: 0,
seatLimit: 1,
includedCredits: 500_000,
+ rateLimitMultiplier: 1,
description: "Start free. Upgrade your plan when you need more.",
},
pro: {
@@ -16,34 +17,43 @@ export const BILLING_PLANS = {
priceCents: 2_000,
seatLimit: 3,
includedCredits: 2_000_000,
+ rateLimitMultiplier: 10,
description: "For individual developers and personal agents.",
},
- max: {
- id: "max",
- name: "Max",
- priceCents: 10_000,
- seatLimit: 3,
- includedCredits: 13_000_000,
- description: "For growing applications and frequent agent workloads.",
- },
scale: {
id: "scale",
name: "Scale",
- priceCents: 39_900,
+ priceCents: 20_000,
seatLimit: null,
- includedCredits: 60_000_000,
+ includedCredits: 20_000_000,
+ rateLimitMultiplier: 100,
description: "For teams running classification in production.",
},
} as const;
-export const PAID_PLANS = [
- BILLING_PLANS.pro,
- BILLING_PLANS.max,
- BILLING_PLANS.scale,
-];
+export const PAID_PLANS = [BILLING_PLANS.pro, BILLING_PLANS.scale];
export const FREE_ALLOWANCE_CREDITS = BILLING_PLANS.free.includedCredits;
export function isBillingPlanId(value: unknown): value is BillingPlanId {
return typeof value === "string" && Object.hasOwn(BILLING_PLANS, value);
}
+/** Rate limits are the one thing plans change besides included credits, so an
+ * unknown or legacy plan id falls back to the paid Pro multiplier, never free. */
+export function rateLimitMultiplier(plan: string, funded: boolean): number {
+ if (isBillingPlanId(plan) && plan !== "free")
+ return BILLING_PLANS[plan].rateLimitMultiplier;
+ return funded ? BILLING_PLANS.pro.rateLimitMultiplier : 1;
+}
+/** Pay-as-you-go top-ups: whole dollars, bounded so one typo cannot run away. */
+export const TOP_UP_MIN_CENTS = 500;
+export const TOP_UP_MAX_CENTS = 100_000;
+export function isValidTopUpCents(value: unknown): value is number {
+ return (
+ typeof value === "number" &&
+ Number.isSafeInteger(value) &&
+ value >= TOP_UP_MIN_CENTS &&
+ value <= TOP_UP_MAX_CENTS &&
+ value % 100 === 0
+ );
+}
export const creditsToDollars = (credits: number) =>
credits / CREDITS_PER_DOLLAR;
export const centsToCredits = (cents: number) =>
diff --git a/src/openapi.ts b/src/openapi.ts
index aaa6225..cac5f55 100644
--- a/src/openapi.ts
+++ b/src/openapi.ts
@@ -168,7 +168,7 @@ export const OPENAPI = {
info: {
title: "classifier.dev",
version: "1.0.0",
- summary: "Zero-shot text classification with calibrated confidence. Free without a key; Pro for 10x limits.",
+ summary: "Zero-shot text classification with calibrated confidence. Free without a key; paid plans raise rate limits 10-100x.",
description:
"Send text and a list of labels, receive the label that fits, a calibrated confidence " +
"and a score per label. Up to 1,000 texts per request, ~1s. Tiers: fast (default) and " +
@@ -575,7 +575,7 @@ export const OPENAPI = {
summary: "Run the TypeSafe System One contract through classifier.dev.",
description:
"Wire-compatible with TypeSafe's POST /v1/systemone. The official JavaScript and Python SDKs work unchanged when their base URL is https://classifier.dev. " +
- "Use any non-empty placeholder API key for anonymous per-IP limits, or a classifier_agent_ workspace key to use workspace quota, credits and usage history. Free workspaces have the public ceilings and Pro workspaces get 10x limits. " +
+ "Use any non-empty placeholder API key for anonymous per-IP limits, or a classifier_agent_ workspace key to use workspace quota, credits and usage history. Free workspaces have the public ceilings; Pro raises them 10x and Scale 100x. " +
"classifier.dev never forwards caller credentials to TypeSafe. Choice, Noul, Score, structured state, model aliases, usage, validation errors and request IDs retain TypeSafe's shapes. Quota is counted by named questions, not requests. TypeSafe reference: https://docs.typesafe.ai/. " +
"Images: set model to \"dgemma\" and add an images array of data URLs; the same questions are then answered about the images and the state by DiffusionGemma, never by Jev, and a body with images under another model is refused with images_unsupported.",
tags: ["classify"],
@@ -1395,7 +1395,7 @@ export const OPENAPI = {
partnerKey: {
type: "http",
scheme: "bearer",
- description: "Optional for classification. Workspace keys (classifier_agent_...) use the workspace balance and quotas; Pro workspaces get 10x limits. Partner keys have separately arranged access. See https://classifier.dev/auth.md.",
+ description: "Optional for classification. Workspace keys (classifier_agent_...) use the workspace balance and quotas; Pro raises rate limits 10x and Scale 100x. Partner keys have separately arranged access. See https://classifier.dev/auth.md.",
},
},
},
diff --git a/src/pages.ts b/src/pages.ts
index 28d1dd0..f8668b6 100644
--- a/src/pages.ts
+++ b/src/pages.ts
@@ -546,8 +546,20 @@ PRO
Usage is charged to the workspace balance at the published input-token and escalation prices.
Smart costs the same as Fast when no escalation is needed. A paid request
admitted with a positive available balance can finish and leave a negative
- balance. New requests stop at zero or below until funds are added. There are
- no automatic top-ups.
+ balance. New requests stop at zero or below until funds are added.
+
+
+SCALE
+
+ Price $${BILLING_PLANS.scale.priceCents / 100}/month
+ Included usage ${formatCreditsUsd(BILLING_PLANS.scale.includedCredits)} each month
+ Workspace seats unlimited
+ Rate limits 100x Free, shared across workspace keys and agents
+ Billing https://classifier.dev/app/plans
+
+ The same metered prices as Pro with ten times the monthly allowance and
+ rate limits sized for production traffic.
+
Funded workspaces skip the shared free pool and proxy checks. Each request
has a default $10 provider-cost ceiling. Its maximum customer charge is held
@@ -578,6 +590,17 @@ ENTERPRISE
price, and the accuracy or latency you are buying is measured on your
own data before you commit.
+PAY AS YOU GO
+
+ Top-ups $5 to $1,000 per purchase, whole dollars
+ Auto recharge optional; charges the saved payment method when
+ the balance falls below your threshold, with an
+ optional calendar-month maximum
+ Where https://classifier.dev/app/credits
+
+ Purchased funds never expire and are spent after any included plan
+ allowance. Balances update only after the payment is confirmed.
+
USAGE PRICES
diff --git a/src/pricingui.ts b/src/pricingui.ts
index cde0cdb..c5bac6c 100644
--- a/src/pricingui.ts
+++ b/src/pricingui.ts
@@ -11,6 +11,7 @@ const freeLimits = policy({});
export function pricingHtml(signedIn = false) {
const pro = BILLING_PLANS.pro;
+ const scale = BILLING_PLANS.scale;
const description =
"Simple plans with upfront usage for classifier.dev workspaces.";
return page({
@@ -22,7 +23,7 @@ export function pricingHtml(signedIn = false) {
.pricing-intro{padding-block:22px 34px;border-block-end:1px solid var(--rule)}
.pricing-intro h1{font-size:30px;line-height:1.15;letter-spacing:-.025em;text-transform:lowercase}
.pricing-intro p{margin-top:8px;color:var(--muted);font-size:15px}
-.plan-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));border:1px solid var(--line)}
+.plan-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid var(--line)}
.plan{display:flex;min-width:0;min-height:430px;flex-direction:column;padding:28px 26px}
.plan+.plan{border-inline-start:1px solid var(--line)}
.plan h2{font-size:19px;color:var(--bright);text-transform:lowercase}
@@ -47,6 +48,7 @@ export function pricingHtml(signedIn = false) {
.rate-limits table{min-width:0;width:100%}.rate-limits th,.rate-limits td{padding:12px 10px;white-space:normal}
.pricing-note{font-size:12px;color:var(--dim)}
@media(hover:hover){.plan-action:hover{color:var(--bright)}}
+@media(max-width:980px){.plan-grid{grid-template-columns:1fr}.plan{min-height:0}.plan+.plan{border-inline-start:0;border-block-start:1px solid var(--line)}.plan-kicker{min-height:0}}
@media(max-width:760px){.pricing>*+*{margin-top:44px}.plan-grid{grid-template-columns:1fr}.plan{min-height:0;padding:24px 20px}.plan+.plan{border-inline-start:0;border-block-start:1px solid var(--line)}.plan-kicker{min-height:0}.plan-price{margin-top:24px}.plan-action{margin-top:8px}}
@media(max-width:480px){.pricing-intro{padding-block-start:8px}}
`,
@@ -60,10 +62,17 @@ export function pricingHtml(signedIn = false) {
$${pro.priceCents / 100} / month
${feature(`${formatCreditsUsd(pro.includedCredits)} of usage each month`)}${feature(`${pro.seatLimit} workspace seats`)}${feature("10× rate limits")}${feature("Usage by connection and agent")}
${feature(`${formatCreditsUsd(scale.includedCredits)} of usage each month`)}${feature("Unlimited workspace seats")}${feature("100× rate limits")}${feature("Usage by connection and agent")}
for teams that need capacity, infrastructure or a contract
Custom
${feature("Volume-based capacity")}${feature("Dedicated deployment")}${feature("Private inference options")}${feature("Measured accuracy on your data")}
Top up any workspace with $5–$1,000 of usage. Purchased funds never expire and are spent after any included plan allowance. Owners can enable auto recharge: when the balance falls below a chosen threshold, the saved payment method is charged a fixed amount, with an optional calendar-month maximum. Manage both from the billing page.
+
Usage prices
Scrape and classify a public URL with a funded workspace key. Request Markdown and HTML at no extra cost. Provider-billed scrapes remain charged if classification fails; errors disclose the charge. See the URL API.
Usage
Price
@@ -74,7 +83,7 @@ export function pricingHtml(signedIn = false) {
Smart starts with Fast and reviews uncertain answers. You pay the extra charge only for answers that are successfully reviewed. No escalation means no extra charge.
For example, 1 million input tokens with 50 Smart escalations cost $0.142.
-
Output tokens are free. Input usage includes the text, labels and instructions processed by the base classifier. Retries, fallback routing and Smart model tokens add no separate charges. Paid requests already admitted can finish and leave a negative balance. New requests require a positive available balance. No automatic top-ups.
+
Output tokens are free. Input usage includes the text, labels and instructions processed by the base classifier. Retries, fallback routing and Smart model tokens add no separate charges. Paid requests already admitted can finish and leave a negative balance. New requests require a positive available balance. Pay-as-you-go top-ups and optional auto recharge are available from the workspace billing page.
Jev long context starts automatically above 32,000 characters with the default model or explicit Jev. It costs 2 × Jev's $${INPUT_PRICE_PER_MILLION.toFixed(3)} rate: $${(LONG_CONTEXT_PRICING.inputNanodollars / 1000).toFixed(3)} per million original context tokens, counted with cl100k_base once across inputs. Dimensions and actual screening or final-call usage do not multiply this price. 250,000 context tokens cost $${(250000 * LONG_CONTEXT_PRICING.inputNanodollars / 1e9).toFixed(3)}.
Requires paid workspace balance or an active paid subscription; anonymous access and free signup credit do not qualify. Fast only. Synchronous requests allow up to 250,000 original context tokens, 20 documents and 32 decisions within 1 MB. Each document × dimension or multi-label category counts as a decision.
Upload one whole document of up to ${(LONG_CONTEXT_JOB_MAX_TOKENS / 1e6).toFixed(0)} million original tokens (100 MB) at the same rate. Splitting and screening happen automatically. A full job costs $${(LONG_CONTEXT_JOB_MAX_TOKENS * LONG_CONTEXT_PRICING.inputNanodollars / 1e9).toFixed(2)}. The workspace reserves the actual uploaded document's token price, then settles once when final judgment succeeds. Jobs expire after 24 hours; failed or canceled jobs are refunded.
@@ -87,6 +96,7 @@ export function pricingHtml(signedIn = false) {
Plan
Fast
Smart
Free
3,000/min · 20,000/day
200/min · 2,000/day
Pro
30,000/min · 200,000/day
2,000/min · 20,000/day
+
Scale
300,000/min · 2,000,000/day
20,000/min · 200,000/day
Limits count classifications and are shared across workspace keys and agents. Public access is limited per IP. Laya trial limits apply to every plan.
Free access allows up to $${(freeLimits.fastRequest / 1e9).toFixed(2)} of provider cost per Fast request or $${(freeLimits.smartRequest / 1e9).toFixed(2)} per Smart request, with $${(freeLimits.ipDaily / 1e9).toFixed(2)} per IP per UTC day and four requests at once. Smart reviews beyond the request cap retain their Fast answers. A funded workspace has its own allowance and supports larger Smart requests. Signup credit alone uses the free limits.
diff --git a/src/server.ts b/src/server.ts
index fa83b9b..3264b3f 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -9,6 +9,7 @@ import { isAppRequest } from "./http/dispatch";
import { appEnvironment } from "./server/environment";
import { autumnWebhook } from "./http/autumn-webhook";
import { syncAutumnAccounts } from "./server/billing-sync";
+import { sweepAutoTopUps } from "./server/auto-top-up";
import { syncComplimentaryProAccounts } from "./server/complimentary-pro";
import {
mayRenderPublicHtml,
@@ -80,6 +81,7 @@ export default {
if (env.APP_ACCOUNTS_ENABLED === "true") {
ctx.waitUntil(syncAutumnAccounts(env));
ctx.waitUntil(syncComplimentaryProAccounts(env));
+ ctx.waitUntil(sweepAutoTopUps(env));
}
return worker.scheduled(controller, env, ctx);
},
diff --git a/src/server/agents.ts b/src/server/agents.ts
index a8cea3d..082809a 100644
--- a/src/server/agents.ts
+++ b/src/server/agents.ts
@@ -92,7 +92,7 @@ export function validateAppAction(value: unknown): AppAction {
case "billing-auto-top-up":
throw new AppError(
403,
- "Only subscriptions are available. Top-ups and automatic purchases are disabled.",
+ "Top-ups and auto recharge are managed from the billing page, not this endpoint.",
);
case "refresh":
break;
diff --git a/src/server/auto-top-up.ts b/src/server/auto-top-up.ts
new file mode 100644
index 0000000..bc13bea
--- /dev/null
+++ b/src/server/auto-top-up.ts
@@ -0,0 +1,72 @@
+import { chargeAutumnAutoTopUp, type AutumnEnv } from "./autumn";
+import { reconcileAutumnCustomer } from "./billing-sync";
+import { TOP_UP_MAX_CENTS, TOP_UP_MIN_CENTS } from "../lib/billing";
+
+/** One in-flight charge at a time; a stale claim stops blocking after this. */
+const SINGLE_FLIGHT_MS = 15 * 60 * 1000;
+/** A declined card must not be retried on every request. */
+const FAILURE_COOLDOWN_MS = 60 * 60 * 1000;
+
+/** Charge the saved payment method when the balance falls below the owner's
+ * threshold. The attempt row is claimed first inside one statement: it is the
+ * single-flight lock, the failure cooldown and the calendar-month cap ledger,
+ * so no sequence of concurrent settlements can double-charge or exceed the cap.
+ * The wallet itself is only ever credited by invoice-verified reconciliation. */
+export async function maybeAutoTopUp(env: AutumnEnv, accountId: string): Promise {
+ if (env.APP_ACCOUNTS_ENABLED !== "true" || !env.AUTUMN_SECRET_KEY || !env.AUTUMN_TOPUP_PLAN_ID) return false;
+ const now = new Date();
+ const claim = await env.APP_DB.prepare(`INSERT INTO app_auto_topup_attempts(id,account_id,month,amount_cents,created_at)
+ SELECT ?,a.id,?,a.auto_top_up_amount_cents,? FROM app_accounts a
+ WHERE a.id=? AND a.auto_top_up_enabled=1 AND NOT a.billing_hold
+ AND a.balance?)
+ AND NOT EXISTS(SELECT 1 FROM app_auto_topup_attempts f WHERE f.account_id=a.id AND f.status='failed' AND f.created_at>?)
+ AND (a.auto_top_up_cap_cents<=0 OR a.auto_top_up_amount_cents+(SELECT COALESCE(SUM(x.amount_cents),0)
+ FROM app_auto_topup_attempts x WHERE x.account_id=a.id AND x.month=? AND x.status IN ('pending','charged'))<=a.auto_top_up_cap_cents)
+ RETURNING id,amount_cents`)
+ .bind(crypto.randomUUID(), now.toISOString().slice(0, 7), now.toISOString(), accountId,
+ TOP_UP_MIN_CENTS, TOP_UP_MAX_CENTS,
+ new Date(now.getTime() - SINGLE_FLIGHT_MS).toISOString(),
+ new Date(now.getTime() - FAILURE_COOLDOWN_MS).toISOString(),
+ now.toISOString().slice(0, 7))
+ .first<{ id: string; amount_cents: number }>();
+ if (!claim) return false;
+ const settle = (status: "charged" | "failed", invoiceId: string | null, reason: string | null) =>
+ env.APP_DB.prepare("UPDATE app_auto_topup_attempts SET status=?,invoice_id=?,reason=?,updated_at=? WHERE id=? AND status='pending'")
+ .bind(status, invoiceId, reason, new Date().toISOString(), claim.id).run();
+ const mapping = await env.APP_DB.prepare("SELECT customer_id FROM app_autumn_customers WHERE account_id=?")
+ .bind(accountId).first<{ customer_id: string }>();
+ if (!mapping) { await settle("failed", null, "no_billing_identity"); return false; }
+ let charge: Awaited>;
+ try { charge = await chargeAutumnAutoTopUp(env, mapping.customer_id, claim.amount_cents); }
+ catch {
+ // A timeout can mean the provider accepted the charge. The claim stays
+ // pending: it keeps counting against the monthly cap, and if an invoice
+ // exists reconciliation will still verify and credit it.
+ await env.APP_DB.prepare("UPDATE app_auto_topup_attempts SET reason='provider_unavailable',updated_at=? WHERE id=? AND status='pending'")
+ .bind(new Date().toISOString(), claim.id).run();
+ return false;
+ }
+ if (!charge.charged) { await settle("failed", charge.invoiceId, charge.reason); return false; }
+ await env.APP_DB.prepare("UPDATE app_auto_topup_attempts SET invoice_id=?,updated_at=? WHERE id=? AND status='pending'")
+ .bind(charge.invoiceId, new Date().toISOString(), claim.id).run();
+ // Credit promptly; the webhook and the scheduled sweep are the backstops.
+ try { await reconcileAutumnCustomer(env, mapping.customer_id); } catch { /* retryable */ }
+ return true;
+}
+
+/** Scheduled backstop for balances that fell below their threshold without a
+ * settlement trigger. The claim statement enforces every limit again. */
+export async function sweepAutoTopUps(env: AutumnEnv): Promise<{ attempted: number }> {
+ if (env.APP_ACCOUNTS_ENABLED !== "true" || !env.AUTUMN_SECRET_KEY || !env.AUTUMN_TOPUP_PLAN_ID) return { attempted: 0 };
+ const candidates = await env.APP_DB.prepare(`SELECT id FROM app_accounts
+ WHERE auto_top_up_enabled=1 AND NOT billing_hold AND balance();
+ let attempted = 0;
+ for (const candidate of candidates.results) {
+ try { if (await maybeAutoTopUp(env, candidate.id)) attempted++; }
+ catch { /* the next sweep retries */ }
+ }
+ return { attempted };
+}
diff --git a/src/server/autumn.ts b/src/server/autumn.ts
index d44be47..b278492 100644
--- a/src/server/autumn.ts
+++ b/src/server/autumn.ts
@@ -1,3 +1,4 @@
+import { BILLING_PLANS, centsToCredits, isValidTopUpCents } from "../lib/billing";
import { AppError, type AppEnv } from "./db";
import { hasActiveComplimentaryPro } from "./complimentary-pro";
@@ -5,8 +6,30 @@ export type AutumnEnv = AppEnv & {
AUTUMN_SECRET_KEY?: string;
AUTUMN_WEBHOOK_SECRET?: string;
AUTUMN_PRO_PLAN_ID?: string;
+ AUTUMN_SCALE_PLAN_ID?: string;
+ AUTUMN_TOPUP_PLAN_ID?: string;
APP_ORIGIN?: string;
};
+export type SubscriptionPlan = {
+ id: "pro" | "scale";
+ planId: string;
+ amountUsd: number;
+ cents: number;
+ credits: number;
+};
+/** Provider plan ids come from configuration; prices and allowances from the
+ * catalogue. A plan without its configured provider id simply does not exist. */
+export function subscriptionPlans(env: AutumnEnv): SubscriptionPlan[] {
+ const plans: SubscriptionPlan[] = [];
+ for (const id of ["pro", "scale"] as const) {
+ const planId = id === "pro" ? env.AUTUMN_PRO_PLAN_ID : env.AUTUMN_SCALE_PLAN_ID;
+ if (planId) plans.push({
+ id, planId, amountUsd: BILLING_PLANS[id].priceCents / 100,
+ cents: BILLING_PLANS[id].priceCents, credits: BILLING_PLANS[id].includedCredits,
+ });
+ }
+ return plans;
+}
export function billingReturnUrl(env: AutumnEnv) {
const url = new URL(env.APP_ORIGIN || "https://classifier.dev");
if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
@@ -77,27 +100,76 @@ function billingUrl(value: unknown) {
/** Call only after verifying workspace owner and same-origin mutation. Return URL
* must be supplied by server configuration, never copied from request JSON. */
-export async function createAutumnCheckout(env: AutumnEnv, accountId: string, returnUrl: string) {
- if (!env.AUTUMN_PRO_PLAN_ID) throw unavailable();
+export async function createAutumnCheckout(env: AutumnEnv, accountId: string, returnUrl: string, plan: "pro" | "scale" = "pro") {
+ const target = subscriptionPlans(env).find((candidate) => candidate.id === plan);
+ if (!target) throw unavailable();
if (await hasActiveComplimentaryPro(env, accountId))
throw new AppError(409, "Your complimentary Pro plan is active until its displayed end date.");
const customerId = await customerForWorkspace(env, accountId);
const customer = await getAutumnCustomer(env, customerId);
- if (customer.subscriptions.some((subscription) => subscription.plan_id === env.AUTUMN_PRO_PLAN_ID &&
+ const subscriptionPlanIds = subscriptionPlans(env).map((candidate) => candidate.planId);
+ if (customer.subscriptions.some((subscription) => subscriptionPlanIds.includes(subscription.plan_id) &&
!["expired", "canceled"].includes(subscription.status))) {
- // Includes scheduled, past-due and cancel-at-period-end subscriptions.
- // Terminal history permits a new purchase; all other states stay in the
- // existing portal, including unknown states, rather than risk a duplicate.
+ // Includes scheduled, past-due and cancel-at-period-end subscriptions on
+ // any paid plan. Terminal history permits a new purchase; all other states
+ // stay in the existing portal, including unknown states, rather than risk
+ // a duplicate or an unreviewed plan switch.
return createAutumnPortal(env, accountId, returnUrl);
}
const result = await autumnRequest(env, "billing.attach", {
- customer_id: customerId, plan_id: env.AUTUMN_PRO_PLAN_ID,
+ customer_id: customerId, plan_id: target.planId,
redirect_mode: "always", success_url: returnUrl, enable_plan_immediately: false,
});
if (result.customer_id !== customerId) throw unavailable();
return { url: billingUrl(result.payment_url) };
}
+/** Pay-as-you-go purchase through hosted checkout. The wallet is credited only
+ * after reconciliation verifies the paid invoice, never on return. */
+export async function createAutumnTopUpCheckout(env: AutumnEnv, accountId: string, returnUrl: string, amountCents: number) {
+ if (!env.AUTUMN_TOPUP_PLAN_ID) throw unavailable();
+ if (!isValidTopUpCents(amountCents)) throw new AppError(400, "Top-ups are whole dollar amounts between $5 and $1,000.");
+ const customerId = await customerForWorkspace(env, accountId);
+ const result = await autumnRequest(env, "billing.attach", {
+ customer_id: customerId, plan_id: env.AUTUMN_TOPUP_PLAN_ID,
+ redirect_mode: "always", success_url: returnUrl,
+ feature_quantities: [{ feature_id: "credits", quantity: centsToCredits(amountCents) }],
+ });
+ if (result.customer_id !== customerId) throw unavailable();
+ return { url: billingUrl(result.payment_url) };
+}
+
+/** Save or replace a payment method without purchasing anything. Automatic
+ * top-ups charge the saved method, so this is their prerequisite. */
+export async function createAutumnPaymentSetup(env: AutumnEnv, accountId: string, returnUrl: string) {
+ const customerId = await customerForWorkspace(env, accountId);
+ const result = await autumnRequest(env, "billing.setup_payment", { customer_id: customerId, success_url: returnUrl });
+ if (result.customer_id !== customerId) throw unavailable();
+ return { url: billingUrl(result.url) };
+}
+
+/** Charge the saved payment method for an automatic top-up. Callers must have
+ * claimed the attempt first; the wallet is credited only via reconciliation. */
+export async function chargeAutumnAutoTopUp(env: AutumnEnv, customerId: string, amountCents: number):
+ Promise<{ charged: boolean; invoiceId: string | null; reason: string | null }> {
+ if (!env.AUTUMN_TOPUP_PLAN_ID) throw unavailable();
+ if (!isValidTopUpCents(amountCents)) throw new AppError(400, "Top-ups are whole dollar amounts between $5 and $1,000.");
+ const result = await autumnRequest(env, "billing.attach", {
+ customer_id: customerId, plan_id: env.AUTUMN_TOPUP_PLAN_ID,
+ redirect_mode: "never",
+ feature_quantities: [{ feature_id: "credits", quantity: centsToCredits(amountCents) }],
+ });
+ if (result.customer_id !== customerId) throw unavailable();
+ const action = result.required_action as { code?: unknown; reason?: unknown } | null | undefined;
+ const invoice = result.invoice as { status?: unknown; stripe_id?: unknown } | null | undefined;
+ const invoiceId = invoice && typeof invoice.stripe_id === "string" ? invoice.stripe_id : null;
+ if (action && typeof action === "object" && typeof action.code === "string")
+ return { charged: false, invoiceId, reason: action.code };
+ if (invoice && invoice.status === "paid" && invoiceId) return { charged: true, invoiceId, reason: null };
+ // An accepted charge that is still processing settles through reconciliation.
+ return { charged: false, invoiceId, reason: typeof invoice?.status === "string" ? `invoice_${invoice.status}` : "unknown" };
+}
+
export async function createAutumnPortal(env: AutumnEnv, accountId: string, returnUrl: string) {
if (await hasActiveComplimentaryPro(env, accountId))
throw new AppError(409, "Your complimentary Pro plan does not have a paid subscription to manage.");
diff --git a/src/server/billing-sync.ts b/src/server/billing-sync.ts
index 09046a0..75f17f8 100644
--- a/src/server/billing-sync.ts
+++ b/src/server/billing-sync.ts
@@ -1,16 +1,73 @@
-import { autumnRequest, getAutumnCustomer, type AutumnEnv } from "./autumn";
+import { autumnRequest, getAutumnCustomer, subscriptionPlans, type AutumnEnv, type SubscriptionPlan } from "./autumn";
+import { centsToCredits } from "../lib/billing";
import { AppError } from "./db";
import { hasActiveComplimentaryPro } from "./complimentary-pro";
+type Mapping = { revision: number; account_id: string };
+
+/** A paid one-off top-up invoice credits purchased funds exactly once, keyed by
+ * its invoice id. A later refund removes the same amount exactly once; already
+ * consumed usage is not reverse-charged and the balance may go negative. */
+async function reconcileTopUps(env: AutumnEnv, customerId: string, mapping: Mapping, invoices: unknown[]) {
+ if (!env.AUTUMN_TOPUP_PLAN_ID) return;
+ for (const invoice of invoices) {
+ if (!invoice || typeof invoice !== "object") continue;
+ const record = invoice as Record;
+ const items = record.items;
+ if (record.customer_id !== customerId || record.entity_id !== null || record.status !== "paid" ||
+ record.currency !== "usd" || typeof record.stripe_id !== "string" ||
+ !Array.isArray(items) || items.length !== 1) continue;
+ const item = items[0] as Record | null;
+ if (!item || typeof item !== "object" || item.plan_id !== env.AUTUMN_TOPUP_PLAN_ID) continue;
+ // From here on the invoice claims to be a top-up; anything malformed must
+ // surface as a retryable failure rather than being silently skipped.
+ const cents = typeof record.total === "number" ? Math.round(record.total * 100) : NaN;
+ const quantity = typeof item.quantity === "number" ? item.quantity : NaN;
+ if (!Number.isSafeInteger(cents) || cents <= 0 || record.amount_paid !== record.total ||
+ item.amount !== record.total || !Number.isSafeInteger(quantity) || quantity !== centsToCredits(cents) ||
+ typeof record.refunded_amount !== "number")
+ throw new AppError(503, "A top-up invoice needs manual reconciliation.");
+ const timestamp = new Date().toISOString();
+ if (record.refunded_amount > 0) {
+ // The batch is one serializable transaction: the timestamp written by the
+ // revoke is what authorizes the matching wallet deduction, exactly once.
+ await env.APP_DB.batch([
+ env.APP_DB.prepare("SELECT id FROM app_accounts WHERE id=? FOR UPDATE").bind(mapping.account_id),
+ env.APP_DB.prepare(`UPDATE app_autumn_topups SET revoked_at=? WHERE invoice_id=? AND account_id=? AND revoked_at IS NULL
+ AND EXISTS(SELECT 1 FROM app_autumn_customers WHERE customer_id=? AND revision=?)`)
+ .bind(timestamp, record.stripe_id, mapping.account_id, customerId, mapping.revision),
+ env.APP_DB.prepare(`UPDATE app_accounts a SET balance=a.balance-t.credits,paid_balance=a.paid_balance-t.credits,billing_revision=a.billing_revision+1
+ FROM app_autumn_topups t WHERE t.invoice_id=? AND t.revoked_at=? AND a.id=t.account_id`)
+ .bind(record.stripe_id, timestamp),
+ ]);
+ continue;
+ }
+ const operation = crypto.randomUUID();
+ await env.APP_DB.batch([
+ env.APP_DB.prepare("SELECT id FROM app_accounts WHERE id=? FOR UPDATE").bind(mapping.account_id),
+ env.APP_DB.prepare(`INSERT INTO app_autumn_topups(invoice_id,account_id,credits,amount_cents,kind,operation_id,created_at)
+ SELECT ?,?,?,?,CASE WHEN EXISTS(SELECT 1 FROM app_auto_topup_attempts WHERE invoice_id=? AND account_id=?) THEN 'auto_top_up' ELSE 'top_up' END,?,?
+ WHERE EXISTS(SELECT 1 FROM app_autumn_customers WHERE customer_id=? AND revision=?) ON CONFLICT DO NOTHING`)
+ .bind(record.stripe_id, mapping.account_id, quantity, cents, record.stripe_id, mapping.account_id, operation, timestamp, customerId, mapping.revision),
+ env.APP_DB.prepare(`UPDATE app_accounts SET balance=balance+?,paid_balance=paid_balance+?,billing_revision=billing_revision+1
+ WHERE id=? AND EXISTS(SELECT 1 FROM app_autumn_topups WHERE operation_id=?)`)
+ .bind(quantity, quantity, mapping.account_id, operation),
+ env.APP_DB.prepare(`INSERT INTO app_transactions(id,account_id,idempotency_key,kind,amount_cents,credits,created_at,plan_id)
+ SELECT ?,?,?,t.kind,?,?,?,NULL FROM app_autumn_topups t WHERE t.operation_id=? ON CONFLICT DO NOTHING`)
+ .bind(crypto.randomUUID(), mapping.account_id, `autumn:${record.stripe_id}`, cents, quantity, timestamp, operation),
+ env.APP_DB.prepare("UPDATE app_auto_topup_attempts SET status='charged',updated_at=? WHERE invoice_id=? AND account_id=? AND status='pending'")
+ .bind(timestamp, record.stripe_id, mapping.account_id),
+ ]);
+ }
+}
+
/** Fetch current state rather than trusting an out-of-order webhook snapshot.
* A monotonically increasing local revision fences slower concurrent fetches. */
export async function reconcileAutumnCustomer(env: AutumnEnv, customerId: string) {
- if (!env.AUTUMN_PRO_PLAN_ID) throw new AppError(503, "Billing plan is not configured.");
- const existing = await env.APP_DB.prepare("SELECT account_id FROM app_autumn_customers WHERE customer_id=?")
- .bind(customerId).first<{ account_id: string }>();
- if (existing && await hasActiveComplimentaryPro(env, existing.account_id)) return true;
+ const plans = subscriptionPlans(env);
+ if (!plans.length) throw new AppError(503, "Billing plan is not configured.");
const mapping = await env.APP_DB.prepare("UPDATE app_autumn_customers SET revision=revision+1,last_attempt_at=?,reconciliation_required=TRUE WHERE customer_id=? RETURNING revision,account_id")
- .bind(new Date().toISOString(), customerId).first<{ revision: number; account_id: string }>();
+ .bind(new Date().toISOString(), customerId).first();
// Unmapped customers belong to the old deployment or another application.
if (!mapping) return false;
const customer = await getAutumnCustomer(env, customerId);
@@ -24,11 +81,27 @@ export async function reconcileAutumnCustomer(env: AutumnEnv, customerId: string
};
// An overdue payment flag is not cancellation. The current period's invoice
// below determines whether an allowance was paid for, including during dunning.
- const paid = customer.subscriptions.filter((subscription) => subscription.plan_id === env.AUTUMN_PRO_PLAN_ID && subscription.status === "active");
+ const planIds = plans.map((plan) => plan.planId);
+ const paid = customer.subscriptions.filter((subscription) => planIds.includes(subscription.plan_id) && subscription.status === "active");
if (paid.some((subscription) => subscription.current_period_start === null || subscription.current_period_end === null))
throw new AppError(503, "The current billing period is not available yet.");
const active = paid.filter((subscription) => subscription.current_period_end! > Date.now());
if (active.length > 1) throw new AppError(503, "Multiple subscriptions need reconciliation.");
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- provider JSON, narrowed by the matchers below
+ let invoiceList: any[] = [];
+ if (env.AUTUMN_TOPUP_PLAN_ID || active.length) {
+ const invoices = await autumnRequest(env, "invoices.list", { customer_id: customerId, status: ["paid"], limit: 100 });
+ if (!Array.isArray(invoices.list)) throw new AppError(503, "Invalid billing invoice response.");
+ invoiceList = invoices.list;
+ }
+ // Purchased funds are independent of any subscription: verify them first so a
+ // subscription in an unsupported state cannot delay a paid top-up.
+ await reconcileTopUps(env, customerId, mapping, invoiceList);
+ if (await hasActiveComplimentaryPro(env, mapping.account_id)) {
+ // The complimentary grant manages the included allowance on its own cycle.
+ await finish();
+ return true;
+ }
if (!active.length) {
// Do not erase the one-time signup balance on accounts that never subscribed.
// When a paid plan ends, its unused included allowance expires; purchased
@@ -36,25 +109,24 @@ export async function reconcileAutumnCustomer(env: AutumnEnv, customerId: string
const results = await env.APP_DB.batch([
env.APP_DB.prepare("SELECT id FROM app_accounts WHERE id=? FOR UPDATE").bind(mapping.account_id),
env.APP_DB.prepare(`UPDATE app_accounts SET balance=paid_balance,billing_plan='free',scheduled_plan=NULL,cancel_at_period_end=0,billing_revision=billing_revision+1
- WHERE id=? AND billing_plan='pro' AND NOT EXISTS(SELECT 1 FROM app_usage WHERE account_id=? AND status='pending')
+ WHERE id=? AND billing_plan IN ('pro','scale') AND NOT EXISTS(SELECT 1 FROM app_usage WHERE account_id=? AND status='pending')
AND EXISTS(SELECT 1 FROM app_autumn_customers WHERE customer_id=? AND revision=?)`).bind(mapping.account_id, mapping.account_id, customerId, mapping.revision),
env.APP_DB.prepare("SELECT billing_plan FROM app_accounts WHERE id=?").bind(mapping.account_id),
]);
- if (results[2].results[0]?.billing_plan === "pro") throw new AppError(503, "Subscription reconciliation is awaiting pending usage or a newer sync.");
+ if (["pro", "scale"].includes(String(results[2].results[0]?.billing_plan))) throw new AppError(503, "Subscription reconciliation is awaiting pending usage or a newer sync.");
await finish();
return true;
}
const subscription = active[0];
+ const plan = plans.find((candidate) => candidate.planId === subscription.plan_id) as SubscriptionPlan;
const start = subscription.current_period_start, end = subscription.current_period_end;
if (start === null || end === null || start > Date.now() || end <= Date.now() || end <= start)
throw new AppError(503, "The current billing period is not available yet.");
// Paid status alone is insufficient: match the actual recurring base-plan
// line and period. Unsupported discounts/prorations stay for reconciliation.
- const invoices = await autumnRequest(env, "invoices.list", { customer_id: customerId, status: ["paid"], limit: 100 });
- if (!Array.isArray(invoices.list)) throw new AppError(503, "Invalid billing invoice response.");
const grant = await env.APP_DB.prepare("SELECT invoice_id FROM app_autumn_grants WHERE account_id=? AND period_start=?")
.bind(mapping.account_id, start).first<{ invoice_id: string }>();
- const refunded = grant && invoices.list.find((invoice) => invoice?.customer_id === customerId && invoice.stripe_id === grant.invoice_id &&
+ const refunded = grant && invoiceList.find((invoice) => invoice?.customer_id === customerId && invoice.stripe_id === grant.invoice_id &&
typeof invoice.refunded_amount === "number" && invoice.refunded_amount > 0);
if (refunded) {
const results = await env.APP_DB.batch([
@@ -74,12 +146,12 @@ export async function reconcileAutumnCustomer(env: AutumnEnv, customerId: string
await finish();
return true;
}
- const invoice = invoices.list.find((invoice) => invoice && typeof invoice === "object" &&
+ const invoice = invoiceList.find((invoice) => invoice && typeof invoice === "object" &&
invoice.customer_id === customerId && invoice.entity_id === null && invoice.status === "paid" &&
- invoice.currency === "usd" && invoice.amount_paid === 20 && invoice.total === 20 && invoice.refunded_amount === 0 &&
+ invoice.currency === "usd" && invoice.amount_paid === plan.amountUsd && invoice.total === plan.amountUsd && invoice.refunded_amount === 0 &&
typeof invoice.stripe_id === "string" && Array.isArray(invoice.items) && invoice.items.length === 1 &&
- invoice.items[0]?.plan_id === env.AUTUMN_PRO_PLAN_ID && invoice.items[0]?.feature_id === null &&
- invoice.items[0]?.amount === 20 && invoice.items[0]?.period_start === start && invoice.items[0]?.period_end === end);
+ invoice.items[0]?.plan_id === plan.planId && invoice.items[0]?.feature_id === null &&
+ invoice.items[0]?.amount === plan.amountUsd && invoice.items[0]?.period_start === start && invoice.items[0]?.period_end === end);
if (!invoice) throw new AppError(503, "A paid invoice for this billing period is not available yet.");
const operation = crypto.randomUUID(), timestamp = new Date().toISOString();
const results = await env.APP_DB.batch([
@@ -89,21 +161,21 @@ export async function reconcileAutumnCustomer(env: AutumnEnv, customerId: string
AND NOT EXISTS(SELECT 1 FROM app_usage WHERE account_id=? AND status='pending')
AND NOT EXISTS(SELECT 1 FROM app_autumn_grants WHERE account_id=? AND period_start>=?) ON CONFLICT DO NOTHING`)
.bind(invoice.stripe_id, mapping.account_id, start, end, operation, timestamp, customerId, mapping.revision, mapping.account_id, mapping.account_id, start),
- env.APP_DB.prepare(`UPDATE app_accounts SET balance=paid_balance+2000000,billing_hold=FALSE,billing_plan='pro',period_start=?,reset_at=?,
+ env.APP_DB.prepare(`UPDATE app_accounts SET balance=paid_balance+?,billing_hold=FALSE,billing_plan=?,period_start=?,reset_at=?,
scheduled_plan=NULL,cancel_at_period_end=0,billing_revision=billing_revision+1
WHERE id=? AND EXISTS(SELECT 1 FROM app_autumn_grants WHERE operation_id=?)`)
- .bind(new Date(start).toISOString(), new Date(end).toISOString(), mapping.account_id, operation),
+ .bind(plan.credits, plan.id, new Date(start).toISOString(), new Date(end).toISOString(), mapping.account_id, operation),
env.APP_DB.prepare(`INSERT INTO app_transactions(id,account_id,idempotency_key,kind,amount_cents,credits,created_at,plan_id)
- SELECT ?,?,?,'subscription',2000,2000000,?,'pro' WHERE EXISTS(SELECT 1 FROM app_autumn_grants WHERE operation_id=?)`)
- .bind(crypto.randomUUID(), mapping.account_id, `autumn:${invoice.stripe_id}`, timestamp, operation),
+ SELECT ?,?,?,'subscription',?,?,?,? WHERE EXISTS(SELECT 1 FROM app_autumn_grants WHERE operation_id=?)`)
+ .bind(crypto.randomUUID(), mapping.account_id, `autumn:${invoice.stripe_id}`, plan.cents, plan.credits, timestamp, plan.id, operation),
env.APP_DB.prepare("SELECT invoice_id FROM app_autumn_grants WHERE account_id=? AND period_start=?").bind(mapping.account_id, start),
// Schedule changes can occur after this period's allowance was granted.
// Update only the schedule; never refill a spent balance on cancel/resume.
- env.APP_DB.prepare(`UPDATE app_accounts SET cancel_at_period_end=?,scheduled_plan=? WHERE id=? AND billing_plan='pro'
+ env.APP_DB.prepare(`UPDATE app_accounts SET cancel_at_period_end=?,scheduled_plan=? WHERE id=? AND billing_plan=?
AND EXISTS(SELECT 1 FROM app_autumn_customers WHERE customer_id=? AND revision=?)
AND EXISTS(SELECT 1 FROM app_autumn_grants WHERE account_id=? AND period_start=? AND revoked_at IS NULL)`)
.bind(subscription.canceled_at !== null ? 1 : 0, subscription.canceled_at !== null ? "free" : null,
- mapping.account_id, customerId, mapping.revision, mapping.account_id, start),
+ mapping.account_id, plan.id, customerId, mapping.revision, mapping.account_id, start),
]);
if (!results[4].results.length) throw new AppError(503, "Subscription reconciliation is awaiting pending usage or a newer sync.");
await finish();
diff --git a/src/server/billing.ts b/src/server/billing.ts
index 7ba81a9..92b5b7d 100644
--- a/src/server/billing.ts
+++ b/src/server/billing.ts
@@ -14,8 +14,20 @@ export async function billingSnapshot(
billing_plan: BillingPlanId;
scheduled_plan: BillingPlanId | null;
cancel_at_period_end: number;
+ auto_top_up_enabled: number;
+ auto_top_up_amount_cents: number;
+ auto_top_up_threshold_cents: number;
+ auto_top_up_cap_cents: number;
}>();
if (!row) throw new AppError(404, "Account not found.");
+ const month = new Date().toISOString().slice(0, 7);
+ const autoTopUpMonth = await env.APP_DB.prepare(
+ "SELECT COALESCE(SUM(amount_cents),0) AS used FROM app_auto_topup_attempts WHERE account_id=? AND month=? AND status IN ('pending','charged')",
+ ).bind(accountId, month).first<{ used: number }>();
+ const lastAttempt = await env.APP_DB.prepare(
+ "SELECT status,reason,created_at FROM app_auto_topup_attempts WHERE account_id=? ORDER BY created_at DESC LIMIT 1",
+ ).bind(accountId).first<{ status: string; reason: string | null; created_at: string }>();
+ const lastFailure = lastAttempt?.status === "failed" ? lastAttempt : null;
const complimentary = row.billing_plan === "pro"
? await env.APP_DB.prepare("SELECT ends_at FROM app_complimentary_pro WHERE account_id=? AND starts_at<=? AND ends_at>?")
.bind(accountId, new Date().toISOString(), new Date().toISOString()).first<{ ends_at: string }>()
@@ -47,6 +59,20 @@ export async function billingSnapshot(
env.AUTUMN_PRO_PLAN_ID
? "autumn"
: "unconfigured",
+ payAsYouGo:
+ env.APP_ACCOUNTS_ENABLED === "true" &&
+ !!env.AUTUMN_SECRET_KEY &&
+ !!env.AUTUMN_TOPUP_PLAN_ID,
+ autoTopUp: {
+ enabled: !!row.auto_top_up_enabled,
+ amountCents: row.auto_top_up_amount_cents,
+ thresholdCents: row.auto_top_up_threshold_cents,
+ capCents: row.auto_top_up_cap_cents,
+ monthUsedCents: autoTopUpMonth?.used ?? 0,
+ lastFailure: lastFailure
+ ? { reason: lastFailure.reason ?? "unknown", at: lastFailure.created_at }
+ : null,
+ },
transactions: results.map((transaction) => ({
id: transaction.id,
kind: transaction.kind,
diff --git a/src/server/contracts.ts b/src/server/contracts.ts
index 601edd1..99fef01 100644
--- a/src/server/contracts.ts
+++ b/src/server/contracts.ts
@@ -10,6 +10,17 @@ export interface BillingSnapshot {
cancelAtPeriodEnd: boolean;
complimentaryUntil: string | null;
mode: "autumn" | "unconfigured";
+ /** Pay-as-you-go purchases are configured and available for this deployment. */
+ payAsYouGo: boolean;
+ autoTopUp: {
+ enabled: boolean;
+ amountCents: number;
+ thresholdCents: number;
+ /** 0 disables the calendar-month maximum. */
+ capCents: number;
+ monthUsedCents: number;
+ lastFailure: { reason: string; at: string } | null;
+ };
transactions: Array<{
id: string;
createdAt: string;
diff --git a/src/server/db.ts b/src/server/db.ts
index a94a433..1c71c1e 100644
--- a/src/server/db.ts
+++ b/src/server/db.ts
@@ -132,6 +132,8 @@ export interface AppEnv extends AccountAnalyticsEnv {
AUTUMN_SECRET_KEY?: string;
AUTUMN_WEBHOOK_SECRET?: string;
AUTUMN_PRO_PLAN_ID?: string;
+ AUTUMN_SCALE_PLAN_ID?: string;
+ AUTUMN_TOPUP_PLAN_ID?: string;
APP_ORIGIN?: string;
WORKOS_API_KEY?: string;
WORKOS_CLIENT_ID?: string;
diff --git a/src/wellknown.ts b/src/wellknown.ts
index b05921b..b49e6d8 100644
--- a/src/wellknown.ts
+++ b/src/wellknown.ts
@@ -22,7 +22,7 @@ export const SITE_UPDATED = "2026-09-22";
export const SITE = {
name: "classifier.dev",
- tagline: "Zero-shot text classification over plain HTTP. Free without a key; Pro for 10x limits.",
+ tagline: "Zero-shot text classification over plain HTTP. Free without a key; paid plans raise rate limits 10-100x.",
author: { name: "Michael Ryaboy", handle: "michael_chomsky", x: "https://x.com/michael_chomsky", cal: "https://cal.com/michaelsf/coffee" },
repo: "https://github.com/mrmps/classifier-dev",
email: "contact@classifier.dev",
@@ -352,7 +352,7 @@ classifier.dev does not implement that spec's agent registration or token exchan
an apiKey value, so use a non-empty placeholder such as "unused"; it is ignored.
- **service_auth (workspace key)** — classifier_agent_ keys charge the workspace
credit balance. Create and manage keys in /app/keys. Free workspaces have
- the public ceilings, shared across keys. Pro workspaces get 10x limits:
+ the public ceilings, shared across keys. Paid plans raise them: Pro 10x, Scale 100x:
fast 30,000/minute and 200,000/day; smart 2,000/minute and 20,000/day,
shared across keys and agents. Pro accepts up to 1,000 inputs per request.
- **service_auth (partner key)** — separately arranged limits;
diff --git a/test/discovery.test.ts b/test/discovery.test.ts
index 005dd16..1bfd244 100644
--- a/test/discovery.test.ts
+++ b/test/discovery.test.ts
@@ -155,7 +155,7 @@ describe("every discovery document", () => {
const auth = await (await get("/auth.md")).text();
expect(auth).toContain("service_auth (workspace key)");
expect(auth).not.toContain("classifier_pro_");
- expect(auth).toContain("Pro workspaces get 10x limits");
+ expect(auth).toContain("Paid plans raise them: Pro 10x, Scale 100x");
expect(auth).toContain("Authorization: Bearer classifier_agent_");
expect(auth).not.toContain("Pro is $20/month for");
const docs = await (await get("/")).text();
diff --git a/tests/accounts.test.ts b/tests/accounts.test.ts
index 836a37c..a3ea7f5 100644
--- a/tests/accounts.test.ts
+++ b/tests/accounts.test.ts
@@ -33,7 +33,7 @@ beforeEach(async () => {
);
});
describe("account lifecycle", () => {
- test.each(["free", "pro"])("reservation returns the current %s plan with its account debit", async (plan) => {
+ test.each(["free", "pro", "scale"])("reservation returns the current %s plan with its account debit", async (plan) => {
const enrolled = await enroll();
await env.APP_DB.prepare("UPDATE app_accounts SET billing_plan=? WHERE id='local-demo'").bind(plan).run();
const reservation = await authorizeAndReserve(request(enrolled.secret), env, 1);
diff --git a/tests/auto-top-up.test.ts b/tests/auto-top-up.test.ts
new file mode 100644
index 0000000..1a20580
--- /dev/null
+++ b/tests/auto-top-up.test.ts
@@ -0,0 +1,174 @@
+import { afterEach, beforeEach, expect, test } from "bun:test";
+import { database } from "./support/postgres";
+import { type AutumnEnv } from "../src/server/autumn";
+import { maybeAutoTopUp, sweepAutoTopUps } from "../src/server/auto-top-up";
+import { reconcileAutumnCustomer } from "../src/server/billing-sync";
+
+const originalFetch = globalThis.fetch;
+let env: AutumnEnv;
+beforeEach(async () => {
+ env = {
+ APP_DB: database(), APP_ACCOUNTS_ENABLED: "true", AUTUMN_SECRET_KEY: "test-provider-key",
+ AUTUMN_PRO_PLAN_ID: "pro", AUTUMN_SCALE_PLAN_ID: "scale", AUTUMN_TOPUP_PLAN_ID: "top_up",
+ };
+ await env.APP_DB.prepare("INSERT INTO app_accounts(id,email,name,balance,reset_at,created_at,auto_top_up_enabled,auto_top_up_amount_cents,auto_top_up_threshold_cents,auto_top_up_cap_cents) VALUES('a','a@example.test','Test',100000,'2026-01-01','2026-01-01',1,1000,500,3000)").run();
+ await env.APP_DB.prepare("INSERT INTO app_workspaces(account_id,kind,mode,created_at) VALUES('a','personal','hosted','2026-01-01')").run();
+ await env.APP_DB.prepare("INSERT INTO app_autumn_customers(account_id,customer_id) VALUES('a','workspace_a')").run();
+});
+afterEach(() => { globalThis.fetch = originalFetch; });
+
+/** The account starts with $1.00 (100,000 credits), threshold $5, amount $10, cap $30. */
+
+function topUpInvoice(id: string, cents: number, refunded = 0) {
+ return { customer_id: "workspace_a", entity_id: null, status: "paid", currency: "usd",
+ amount_paid: cents / 100, total: cents / 100, refunded_amount: refunded / 100, stripe_id: id,
+ items: [{ plan_id: "top_up", feature_id: "credits", amount: cents / 100, quantity: cents * 1000, period_start: null, period_end: null }] };
+}
+
+function provider(handlers: { attach?: (body: Record) => unknown; invoices?: () => unknown; calls?: string[] }) {
+ globalThis.fetch = (async (input, init) => {
+ const path = new URL(String(input)).pathname;
+ const body = JSON.parse(String(init?.body));
+ handlers.calls?.push(path);
+ if (path.endsWith("billing.attach")) return Response.json(handlers.attach ? handlers.attach(body) : { customer_id: body.customer_id });
+ if (path.endsWith("customers.get")) return Response.json({ id: body.customer_id, subscriptions: [] });
+ if (path.endsWith("invoices.list")) return Response.json(handlers.invoices ? handlers.invoices() : { list: [] });
+ throw new Error(`Unexpected provider call: ${path}`);
+ }) as typeof fetch;
+}
+
+test("a low balance charges the saved card once and credits only the verified invoice", async () => {
+ const attached: Record[] = [];
+ provider({
+ attach: (body) => {
+ attached.push(body);
+ return { customer_id: "workspace_a", invoice: { status: "paid", stripe_id: "topup_inv_1", total: 10, currency: "usd" } };
+ },
+ invoices: () => ({ list: [topUpInvoice("topup_inv_1", 1000)] }),
+ });
+ expect(await maybeAutoTopUp(env, "a")).toBe(true);
+ expect(attached).toEqual([{
+ customer_id: "workspace_a", plan_id: "top_up", redirect_mode: "never",
+ feature_quantities: [{ feature_id: "credits", quantity: 1_000_000 }],
+ }]);
+ const account = await env.APP_DB.prepare("SELECT balance,paid_balance FROM app_accounts WHERE id='a'").first();
+ expect(account).toEqual({ balance: 1_100_000, paid_balance: 1_000_000 });
+ const transaction = await env.APP_DB.prepare("SELECT kind,amount_cents,credits FROM app_transactions").first();
+ expect(transaction).toEqual({ kind: "auto_top_up", amount_cents: 1000, credits: 1_000_000 });
+ expect((await env.APP_DB.prepare("SELECT status,invoice_id FROM app_auto_topup_attempts").first()))
+ .toEqual({ status: "charged", invoice_id: "topup_inv_1" });
+ // The webhook path delivering the same invoice later must not grant again.
+ await reconcileAutumnCustomer(env, "workspace_a");
+ expect((await env.APP_DB.prepare("SELECT balance FROM app_accounts WHERE id='a'").first())?.balance).toBe(1_100_000);
+ expect((await env.APP_DB.prepare("SELECT COUNT(*) AS count FROM app_transactions").first())?.count).toBe(1);
+});
+
+test("no charge above the threshold, when disabled, or during a billing hold", async () => {
+ const calls: string[] = [];
+ provider({ calls });
+ await env.APP_DB.prepare("UPDATE app_accounts SET balance=600000 WHERE id='a'").run();
+ expect(await maybeAutoTopUp(env, "a")).toBe(false);
+ await env.APP_DB.prepare("UPDATE app_accounts SET balance=100000,auto_top_up_enabled=0 WHERE id='a'").run();
+ expect(await maybeAutoTopUp(env, "a")).toBe(false);
+ await env.APP_DB.prepare("UPDATE app_accounts SET auto_top_up_enabled=1,billing_hold=TRUE WHERE id='a'").run();
+ expect(await maybeAutoTopUp(env, "a")).toBe(false);
+ expect(calls).toEqual([]);
+ expect((await env.APP_DB.prepare("SELECT COUNT(*) AS count FROM app_auto_topup_attempts").first())?.count).toBe(0);
+});
+
+test("the calendar-month cap counts pending and charged attempts and refuses the charge that would exceed it", async () => {
+ const month = new Date().toISOString().slice(0, 7);
+ await env.APP_DB.prepare("INSERT INTO app_auto_topup_attempts(id,account_id,month,amount_cents,status,created_at) VALUES('old1','a',?,1000,'charged','2026-01-01'),('old2','a',?,1000,'charged','2026-01-02')")
+ .bind(month, month).run();
+ const calls: string[] = [];
+ provider({ calls, attach: (body) => ({ customer_id: body.customer_id, invoice: { status: "paid", stripe_id: "topup_inv_2", total: 10, currency: "usd" } }),
+ invoices: () => ({ list: [topUpInvoice("topup_inv_2", 1000)] }) });
+ // $20 of $30 used: one more $10 charge fits exactly.
+ expect(await maybeAutoTopUp(env, "a")).toBe(true);
+ // $30 of $30 used: the next attempt must not be claimed, even at low balance.
+ await env.APP_DB.prepare("UPDATE app_accounts SET balance=0 WHERE id='a'").run();
+ expect(await maybeAutoTopUp(env, "a")).toBe(false);
+ expect(calls.filter((path) => path.endsWith("billing.attach")).length).toBe(1);
+ // A failed attempt never consumes the budget of a month it did not charge.
+ const failed = await env.APP_DB.prepare("SELECT COALESCE(SUM(amount_cents),0) AS used FROM app_auto_topup_attempts WHERE month=? AND status IN ('pending','charged')").bind(month).first();
+ expect(failed?.used).toBe(3000);
+});
+
+test("a pending claim is single-flight and a declined card cools down for an hour", async () => {
+ provider({ attach: (body) => ({ customer_id: body.customer_id,
+ invoice: { status: "open", stripe_id: "topup_open_1", total: 10, currency: "usd" },
+ required_action: { code: "payment_method_required", reason: "No payment method found" } }) });
+ expect(await maybeAutoTopUp(env, "a")).toBe(false);
+ expect(await env.APP_DB.prepare("SELECT status,reason FROM app_auto_topup_attempts").first())
+ .toEqual({ status: "failed", reason: "payment_method_required" });
+ // The failure cooldown blocks an immediate retry.
+ expect(await maybeAutoTopUp(env, "a")).toBe(false);
+ expect((await env.APP_DB.prepare("SELECT COUNT(*) AS count FROM app_auto_topup_attempts").first())?.count).toBe(1);
+ // An in-flight pending claim blocks a second concurrent charge.
+ await env.APP_DB.prepare("UPDATE app_auto_topup_attempts SET status='pending',created_at=?").bind(new Date().toISOString()).run();
+ expect(await maybeAutoTopUp(env, "a")).toBe(false);
+ expect((await env.APP_DB.prepare("SELECT COUNT(*) AS count FROM app_auto_topup_attempts").first())?.count).toBe(1);
+});
+
+test("a provider timeout keeps the claim pending and reconciliation still credits a real invoice exactly once", async () => {
+ globalThis.fetch = (async () => new Response(null, { status: 500 })) as typeof fetch;
+ expect(await maybeAutoTopUp(env, "a")).toBe(false);
+ const attempt = await env.APP_DB.prepare("SELECT id,status,reason FROM app_auto_topup_attempts").first<{ id: string; status: string; reason: string }>();
+ expect(attempt?.status).toBe("pending");
+ expect(attempt?.reason).toBe("provider_unavailable");
+ // The charge actually went through: the invoice exists when reconciliation runs.
+ await env.APP_DB.prepare("UPDATE app_auto_topup_attempts SET invoice_id='topup_inv_3'").run();
+ provider({ invoices: () => ({ list: [topUpInvoice("topup_inv_3", 1000)] }) });
+ await reconcileAutumnCustomer(env, "workspace_a");
+ expect((await env.APP_DB.prepare("SELECT balance,paid_balance FROM app_accounts WHERE id='a'").first()))
+ .toEqual({ balance: 1_100_000, paid_balance: 1_000_000 });
+ expect((await env.APP_DB.prepare("SELECT status FROM app_auto_topup_attempts").first())?.status).toBe("charged");
+ expect((await env.APP_DB.prepare("SELECT kind FROM app_transactions").first())?.kind).toBe("auto_top_up");
+});
+
+test("a manual top-up invoice credits purchased funds once and a refund removes them once", async () => {
+ const state = { refunded: 0 };
+ provider({ invoices: () => ({ list: [topUpInvoice("manual_inv_1", 2500, state.refunded)] }) });
+ await reconcileAutumnCustomer(env, "workspace_a");
+ await reconcileAutumnCustomer(env, "workspace_a");
+ expect(await env.APP_DB.prepare("SELECT balance,paid_balance FROM app_accounts WHERE id='a'").first())
+ .toEqual({ balance: 2_600_000, paid_balance: 2_500_000 });
+ expect((await env.APP_DB.prepare("SELECT kind,plan_id FROM app_transactions").first())).toEqual({ kind: "top_up", plan_id: null });
+ await env.APP_DB.prepare("UPDATE app_accounts SET balance=balance-600000 WHERE id='a'").run();
+ state.refunded = 2500;
+ await reconcileAutumnCustomer(env, "workspace_a");
+ await reconcileAutumnCustomer(env, "workspace_a");
+ // Already consumed usage is not reverse-charged; the balance may go negative.
+ expect(await env.APP_DB.prepare("SELECT balance,paid_balance FROM app_accounts WHERE id='a'").first())
+ .toEqual({ balance: -500_000, paid_balance: 0 });
+ expect((await env.APP_DB.prepare("SELECT revoked_at FROM app_autumn_topups WHERE invoice_id='manual_inv_1'").first())?.revoked_at).not.toBeNull();
+});
+
+test("a malformed top-up invoice stays retryable instead of granting", async () => {
+ const invoice = topUpInvoice("bad_inv_1", 1000);
+ invoice.items[0].quantity = 999_999;
+ provider({ invoices: () => ({ list: [invoice] }) });
+ await expect(reconcileAutumnCustomer(env, "workspace_a")).rejects.toThrow("manual reconciliation");
+ expect((await env.APP_DB.prepare("SELECT balance FROM app_accounts WHERE id='a'").first())?.balance).toBe(100000);
+ expect((await env.APP_DB.prepare("SELECT reconciliation_required FROM app_autumn_customers WHERE account_id='a'").first())?.reconciliation_required).toBe(true);
+});
+
+test("the scheduled sweep charges only enabled accounts below their threshold", async () => {
+ await env.APP_DB.prepare("INSERT INTO app_accounts(id,email,name,balance,reset_at,created_at,auto_top_up_enabled) VALUES('b','b@example.test','Rich',90000000,'2026-01-01','2026-01-01',1)").run();
+ await env.APP_DB.prepare("INSERT INTO app_accounts(id,email,name,balance,reset_at,created_at) VALUES('c','c@example.test','Off',0,'2026-01-01','2026-01-01')").run();
+ const attached: Record[] = [];
+ provider({ attach: (body) => { attached.push(body); return { customer_id: body.customer_id,
+ invoice: { status: "paid", stripe_id: "sweep_inv_1", total: 10, currency: "usd" } }; },
+ invoices: () => ({ list: [topUpInvoice("sweep_inv_1", 1000)] }) });
+ expect((await sweepAutoTopUps(env)).attempted).toBe(1);
+ expect(attached.length).toBe(1);
+ expect((await env.APP_DB.prepare("SELECT account_id FROM app_auto_topup_attempts").first())?.account_id).toBe("a");
+});
+
+test("nothing is charged when pay-as-you-go is not configured", async () => {
+ const calls: string[] = [];
+ provider({ calls });
+ expect(await maybeAutoTopUp({ ...env, AUTUMN_TOPUP_PLAN_ID: undefined }, "a")).toBe(false);
+ expect((await sweepAutoTopUps({ ...env, AUTUMN_TOPUP_PLAN_ID: undefined })).attempted).toBe(0);
+ expect(calls).toEqual([]);
+});
diff --git a/tests/autumn.test.ts b/tests/autumn.test.ts
index 85a4ea6..c8a691c 100644
--- a/tests/autumn.test.ts
+++ b/tests/autumn.test.ts
@@ -273,3 +273,64 @@ test("a stale refund cannot mutate a held wallet after a newer sync starts", asy
expect((await env.APP_DB.prepare("SELECT revoked_at FROM app_autumn_grants WHERE invoice_id='invoice_1'").first())?.revoked_at).toBeNull();
expect((await env.APP_DB.prepare("SELECT reconciliation_required FROM app_autumn_customers WHERE account_id='a'").first())?.reconciliation_required).toBe(true);
});
+
+test("a paid Scale period grants its own allowance, plan and transaction exactly once", async () => {
+ const scaleEnv = { ...env, AUTUMN_SCALE_PLAN_ID: "scale" };
+ await env.APP_DB.prepare("INSERT INTO app_autumn_customers(account_id,customer_id) VALUES('a','workspace_a')").run();
+ const start = Date.now() - 60_000, end = Date.now() + 86400_000;
+ provider((path) => path.endsWith("customers.get") ? { id: "workspace_a", subscriptions: [{
+ id: "sub_1", plan_id: "scale", status: "active", past_due: false, current_period_start: start, current_period_end: end,
+ }] } : { list: [{ customer_id: "workspace_a", entity_id: null, status: "paid", currency: "usd", amount_paid: 200, total: 200,
+ refunded_amount: 0, stripe_id: "scale_invoice_1", items: [{ plan_id: "scale", feature_id: null, amount: 200, period_start: start, period_end: end }],
+ }] });
+ await reconcileAutumnCustomer(scaleEnv, "workspace_a");
+ await reconcileAutumnCustomer(scaleEnv, "workspace_a");
+ expect(await env.APP_DB.prepare("SELECT balance,billing_plan FROM app_accounts WHERE id='a'").first())
+ .toEqual({ balance: 20000000, billing_plan: "scale" });
+ expect(await env.APP_DB.prepare("SELECT kind,amount_cents,credits,plan_id FROM app_transactions").first())
+ .toEqual({ kind: "subscription", amount_cents: 20000, credits: 20000000, plan_id: "scale" });
+ // Without the Scale plan configured, the same customer state cannot grant.
+ expect((await env.APP_DB.prepare("SELECT COUNT(*) AS count FROM app_autumn_grants").first())?.count).toBe(1);
+});
+
+test("a Scale subscription never matches a Pro-priced invoice and stays retryable", async () => {
+ const scaleEnv = { ...env, AUTUMN_SCALE_PLAN_ID: "scale" };
+ await env.APP_DB.prepare("INSERT INTO app_autumn_customers(account_id,customer_id) VALUES('a','workspace_a')").run();
+ const start = Date.now() - 60_000, end = Date.now() + 86400_000;
+ provider((path) => path.endsWith("customers.get") ? { id: "workspace_a", subscriptions: [{
+ id: "sub_1", plan_id: "scale", status: "active", past_due: false, current_period_start: start, current_period_end: end,
+ }] } : { list: [{ customer_id: "workspace_a", entity_id: null, status: "paid", currency: "usd", amount_paid: 20, total: 20,
+ refunded_amount: 0, stripe_id: "wrong_invoice", items: [{ plan_id: "pro", feature_id: null, amount: 20, period_start: start, period_end: end }],
+ }] });
+ await expect(reconcileAutumnCustomer(scaleEnv, "workspace_a")).rejects.toThrow("paid invoice");
+ expect((await env.APP_DB.prepare("SELECT balance FROM app_accounts WHERE id='a'").first())?.balance).toBe(500000);
+});
+
+test("an ended Scale plan expires its allowance and checkout routes any active plan to the portal", async () => {
+ const scaleEnv = { ...env, AUTUMN_SCALE_PLAN_ID: "scale" };
+ await env.APP_DB.prepare("INSERT INTO app_autumn_customers(account_id,customer_id) VALUES('a','workspace_a')").run();
+ await env.APP_DB.prepare("UPDATE app_accounts SET billing_plan='scale',balance=1000000,paid_balance=123 WHERE id='a'").run();
+ provider(() => ({ id: "workspace_a", subscriptions: [] }));
+ await reconcileAutumnCustomer(scaleEnv, "workspace_a");
+ expect(await env.APP_DB.prepare("SELECT balance,billing_plan FROM app_accounts WHERE id='a'").first())
+ .toEqual({ balance: 123, billing_plan: "free" });
+ // An active Pro subscription routes a Scale checkout to the portal.
+ provider((path, body) => {
+ if (path.endsWith("get_or_create")) return { id: body.customer_id };
+ if (path.endsWith("customers.get")) return { id: body.customer_id, subscriptions: [{
+ id: "sub_1", plan_id: "pro", status: "active", past_due: false, current_period_start: Date.now() - 1, current_period_end: Date.now() + 86400_000,
+ }] };
+ expect(path.endsWith("open_customer_portal")).toBe(true);
+ return { customer_id: body.customer_id, url: "https://billing.stripe.com/session" };
+ });
+ expect((await createAutumnCheckout(scaleEnv, "a", "https://classifier.dev/app/plans", "scale")).url).toContain("billing.stripe.com");
+ // With no subscriptions, a Scale checkout attaches the Scale plan.
+ provider((path, body) => {
+ if (path.endsWith("get_or_create")) return { id: body.customer_id };
+ if (path.endsWith("customers.get")) return { id: body.customer_id, subscriptions: [] };
+ expect(body.plan_id).toBe("scale");
+ expect(body.enable_plan_immediately).toBe(false);
+ return { customer_id: body.customer_id, payment_url: "https://checkout.stripe.com/scale" };
+ });
+ expect((await createAutumnCheckout(scaleEnv, "a", "https://classifier.dev/app/plans", "scale")).url).toContain("checkout.stripe.com");
+});
diff --git a/tests/dashboard-analytics.test.ts b/tests/dashboard-analytics.test.ts
index be848f8..a480d6b 100644
--- a/tests/dashboard-analytics.test.ts
+++ b/tests/dashboard-analytics.test.ts
@@ -93,5 +93,5 @@ test("hosted dashboard never scans the usage ledger and renders loading rather t
expect(plans).toContain("$0.042");
expect(plans).toContain("+$2.00 / 1,000");
expect(plans).not.toContain("Upgrade to Max");
- expect(plans).not.toContain("Upgrade to Scale");
+ expect(plans).toContain("Upgrade to Scale");
});
diff --git a/tests/spending.e2e.test.ts b/tests/spending.e2e.test.ts
index 91f520f..bfafd7f 100644
--- a/tests/spending.e2e.test.ts
+++ b/tests/spending.e2e.test.ts
@@ -626,3 +626,17 @@ test("paid Smart preserves and bills completed base results when no more reviews
expect(calls).toHaveLength(1);
expect(await env.APP_DB.prepare("SELECT balance::integer AS balance FROM app_accounts WHERE id='local-demo'").first()).toEqual({ balance: -251 });
});
+
+test("plan rate limits scale with the plan: Pro answers with 10x limits and Scale with 100x", async () => {
+ for (const [plan, limit] of [["pro", "30000"], ["scale", "300000"]] as const) {
+ const s = setup();
+ const env = { ...s.env, APP_DB: database(), APP_ACCOUNTS_ENABLED: 'true', API_KEY_ENCRYPTION_KEY: 'test-only-key-encryption-secret-32-characters' } as Env & AppEnv;
+ await provisionTestAccount(new Request('http://localhost/auth/demo', { headers: { origin: 'http://localhost' } }), env);
+ await env.APP_DB.prepare("UPDATE app_accounts SET billing_plan=?,reset_at=?,paid_balance=0 WHERE id='local-demo'").bind(plan, new Date(Date.now()+86400000).toISOString()).run();
+ const key = await performAction('local-demo', { type: 'enroll', client: 'Codex' }, env);
+ providers();
+ const response = await accountClassification(request(undefined, undefined, undefined, { authorization: `Bearer ${key.secret}` }), env, 'API', s.ctx);
+ expect(response?.status).toBe(200); await s.flush();
+ expect(response?.headers.get("ratelimit-limit")).toBe(limit);
+ }
+});
From 7adf8008e9493e876119c517a6ef94dfe841d02b Mon Sep 17 00:00:00 2001
From: Michael Ryaboy
Date: Sat, 26 Sep 2026 23:28:37 -0700
Subject: [PATCH 2/4] Retry prompt crediting when a fresh invoice lags the
provider listing
Co-Authored-By: Claude Fable 5
---
src/server/auto-top-up.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/server/auto-top-up.ts b/src/server/auto-top-up.ts
index bc13bea..64c3825 100644
--- a/src/server/auto-top-up.ts
+++ b/src/server/auto-top-up.ts
@@ -52,7 +52,11 @@ export async function maybeAutoTopUp(env: AutumnEnv, accountId: string): Promise
await env.APP_DB.prepare("UPDATE app_auto_topup_attempts SET invoice_id=?,updated_at=? WHERE id=? AND status='pending'")
.bind(charge.invoiceId, new Date().toISOString(), claim.id).run();
// Credit promptly; the webhook and the scheduled sweep are the backstops.
- try { await reconcileAutumnCustomer(env, mapping.customer_id); } catch { /* retryable */ }
+ // A fresh invoice can lag the provider's invoice listing by a few seconds.
+ try { await reconcileAutumnCustomer(env, mapping.customer_id); } catch {
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+ try { await reconcileAutumnCustomer(env, mapping.customer_id); } catch { /* retryable */ }
+ }
return true;
}
From 0583a335e105b83b1f0ebcdc5c7f3f0c20eba233 Mon Sep 17 00:00:00 2001
From: Michael Ryaboy
Date: Sat, 26 Sep 2026 23:29:34 -0700
Subject: [PATCH 3/4] Pin live Autumn ids for the Scale and top-up plans
Co-Authored-By: Claude Fable 5
---
autumn.config.ts | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/autumn.config.ts b/autumn.config.ts
index d6e2a92..8de72a7 100644
--- a/autumn.config.ts
+++ b/autumn.config.ts
@@ -10,19 +10,19 @@ export default atmn({
name: "10× classification rate limits",
type: "boolean",
}), feature({
- internalId: "fe_3JrMqs92gvWniLPaJPhqiVuPA8X",
+ internalId: "fe_3JtqAMjN11vaz5HeK7iB4G2Ax39",
featureId: "classifier_scale_limits",
name: "100× classification rate limits",
type: "boolean",
}), feature({
- internalId: "fe_3JrMqrgKPokKsoXINHblHh1ohYW",
+ internalId: "fe_3JtqAOKawqnNIF802NDz16CfBAy",
featureId: "credits",
name: "Usage credits",
type: "metered",
consumable: true,
})],
plans: [plan({
- internalId: "prod_3JY06U2NCtuwTdTAEkMlOlt79yA",
+ internalId: "prod_3JY0YnGHp7SRlaypoKnlwzJH4jE",
planId: "pro",
versionSlug: "v1",
active: true,
@@ -30,7 +30,7 @@ export default atmn({
price: { amount: 20, interval: "month" },
items: [{ featureId: "classifier_pro_limits" }],
}), plan({
- internalId: "prod_3JrMqrNNqVvXC9wemjHgPNaoQR5",
+ internalId: "prod_3JtqAKECpKM8l96gDlt3K83BMvA",
planId: "scale",
versionSlug: "v1",
active: true,
@@ -38,7 +38,7 @@ export default atmn({
price: { amount: 200, interval: "month" },
items: [{ featureId: "classifier_scale_limits" }],
}), plan({
- internalId: "prod_3JrMquITKMqYfKzHnUTWZXiRUgA",
+ internalId: "prod_3JtqAPIcSNONBZLSBBVD5eGvvfE",
planId: "top_up",
versionSlug: "v1",
active: true,
From 2b1910c05a0a068e544554f3b6f20103002e20d5 Mon Sep 17 00:00:00 2001
From: Michael Ryaboy
Date: Sat, 26 Sep 2026 23:32:06 -0700
Subject: [PATCH 4/4] Update billing docs for pay-as-you-go and Scale
Co-Authored-By: Claude Fable 5
---
docs/autumn-integration.md | 4 +++-
docs/billing-plan.md | 4 ++--
docs/dashboard.md | 5 +++--
3 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/docs/autumn-integration.md b/docs/autumn-integration.md
index 0aee7a9..e0c625e 100644
--- a/docs/autumn-integration.md
+++ b/docs/autumn-integration.md
@@ -7,6 +7,8 @@ Autumn controls subscription checkout and portal access. Neon is the authoritati
- `AUTUMN_SECRET_KEY`: appropriate sandbox or production key.
- `AUTUMN_WEBHOOK_SECRET`: Svix signing secret for `/webhooks/autumn`.
- `AUTUMN_PRO_PLAN_ID`: the configured $20 monthly Pro plan.
+- `AUTUMN_SCALE_PLAN_ID`: the configured $200 monthly Scale plan (optional).
+- `AUTUMN_TOPUP_PLAN_ID`: the configured one-off prepaid top-up plan (optional; enables pay-as-you-go and auto recharge).
- `APP_ORIGIN`: trusted HTTPS application origin for checkout/portal return URLs; defaults to `https://classifier.dev`. Set an isolated preview origin for sandbox checkout.
Configure `billing.updated` delivery. Scheduled reconciliation repairs missed events with at most ten accounts per invocation and a global maximum of sixty provider calls per UTC day. The budget counts failed attempts; webhook calls are separate. Customers rotate by last attempted reconciliation, including provider failures, so unavailable customers cannot starve the rest of the queue. This repair budget is deliberately small and must be reviewed as the paid customer count grows.
@@ -15,7 +17,7 @@ Configure `billing.updated` delivery. Scheduled reconciliation repairs missed ev
Activation is not proof of payment. The reconciler fetches canonical customer state and `invoices.list`, then matches a paid invoice's base-plan line to the active subscription's current billing period. Invoice ID and account/period constraints prevent a second grant. New allowances wait for pending requests to settle and replace the previous included allowance; they do not stack. An overdue-payment flag does not cancel an active subscription or erase a verified paid allowance: the current period still requires its matching paid invoice.
-The initial implementation supports **exactly $20 USD paid monthly Pro invoices**, with one $20 base-plan line and no refunded amount. Discounts, taxes, prorations, bundled invoices and historical invoices without recorded line items require explicit reconciliation; they do not silently grant credits. Only the first 100 paid invoices are inspected. Unsupported cases return a retryable synchronization failure and retain `reconciliation_required`.
+Subscription grants support **exactly the configured USD monthly plan invoices** ($20 Pro, $200 Scale), with one matching base-plan line and no refunded amount. One-off top-up invoices for the configured top-up plan credit purchased funds once per invoice id, and their refunds revoke once. Discounts, taxes, prorations, bundled invoices and historical invoices without recorded line items require explicit reconciliation; they do not silently grant credits. Only the first 100 paid invoices are inspected. Unsupported cases return a retryable synchronization failure and retain `reconciliation_required`.
Refunds of a previously granted current-period invoice place the account on a billing hold immediately, preventing new reservations. Once pending work settles, the remaining included allowance is removed. The grant is marked revoked and cannot be reissued for the same period. Already consumed usage is not reverse-charged. A verified new paid period clears the hold. Pending requests are not retroactively interrupted. Refund webhooks remain retryable until those requests settle and allowance removal completes. Superseded reconciliations cannot acknowledge completion or mutate refund state.
diff --git a/docs/billing-plan.md b/docs/billing-plan.md
index df22e99..b3eab8b 100644
--- a/docs/billing-plan.md
+++ b/docs/billing-plan.md
@@ -37,7 +37,7 @@ must be recorded as blockers, not bypassed with fabricated success.
- **Signup credit is $5, one-time and personal.** Each user gets only one personal workspace/plan and one initial grant. There is no daily or monthly free replenishment.
- **Teams have independent balances.** New team workspaces start with zero usage credit. Creating or switching to a team neither transfers personal credit nor creates another free grant. Members consume the team's balance; per-key/agent budgets are an optional further control.
- **Subscriptions replenish paid usage.** Pro costs $20/month and includes $20 at the published retail token rates. Paid tiers have higher rate/usage limits. Other tier prices, allowances, seat counts, rollover rules, and exact rate limits in the demo are provisional unless separately approved.
-- **No top-ups for this release.** Neither manual purchases of extra credit nor automatic top-ups are enabled. An exhausted account receives a clear API error and can select an available subscription plan; there are no surprise overage charges.
+- **Top-ups are live.** Manual purchases ($5-$1,000, whole dollars) and owner-enabled automatic recharges credit purchased funds only after their paid invoice is verified. Automatic recharges honor a balance threshold and an optional calendar-month maximum; an exhausted account with auto recharge disabled still receives a clear API error and no surprise overage charges.
- **Anonymous access stays as it is today.** Do not reduce its allowance or require signup as part of this migration. Authenticated free accounts on hosting/datacenter IPs should have stricter limits; paid plans retain their documented limits. Country alone does not identify abuse. No specific Jina residential-versus-datacenter rule has been verified; see [migration research](./migration-research.md).
- **One pricing and limits system for everyone.** Existing customers migrate into the new system at the verified cutover. No grandfathered tier or permanent parallel legacy billing path. Keep the current deployment working until the replacement and customer migration are ready.
- **Use one dollar presentation.** Balances, consumption, budgets, and subscription allowances display USD. The current accounting scale is 100,000 integer credits per dollar; choose rounding and minimum-charge rules explicitly with the retail token schedule before billing is enabled.
@@ -66,7 +66,7 @@ Autumn idempotency keys are not a permanent exactly-once guarantee. Its inspecte
## Product surfaces
-- **Billing (`/app/credits`):** dollar balance, selected subscription, included usage, renewal/cancellation information, and transaction history. No Add funds or auto-top-up flow. Local actions must be labeled simulated.
+- **Billing (`/app/credits`):** dollar balance, selected subscription, included usage, renewal/cancellation information, transaction history, a Top up balance flow and the auto recharge controls. Local actions must be labeled simulated.
- **Usage (`/app/usage`):** Spend / Tokens / Requests, date and credential filters, timeline, and sampling-aware AE aggregates. Show unknown token counts as unavailable and distinguish estimated retail charges from exact wallet balances.
- **Keys and agents:** credentials belong to the selected organization, and any optional spending cap uses the same dollar units and server-side policy as billing.
- **Onboarding and team creation:** one personal signup allowance; teams start unfunded. UI navigation or successful checkout navigation is never payment proof.
diff --git a/docs/dashboard.md b/docs/dashboard.md
index 0465659..720e0fb 100644
--- a/docs/dashboard.md
+++ b/docs/dashboard.md
@@ -19,8 +19,9 @@ configuring development credentials in `.dev.vars`; see
anonymous MCP remains available where a client supports it.
- Usage provides hourly/daily charts and filters. Activity lists recent requests.
Analytics can be delayed or sampled and must not be used as a billing ledger.
-- Billing displays the plan, available funds, usage and upgrade actions. No
- pay-as-you-go purchases or automatic top-ups are offered.
+- Billing displays the plan, available funds, usage and upgrade actions, plus
+ pay-as-you-go top-ups ($5-$1,000) and owner-configured auto recharge with a
+ balance threshold and an optional calendar-month maximum.
- All app pages retain the sidebar. `/app/onboarding` remains directly accessible
for testing, with no ordinary navigation back to the completed onboarding.