From 30b1b46be181ba96c05ab835f236df5274db5a65 Mon Sep 17 00:00:00 2001 From: Michael Ryaboy Date: Sat, 26 Sep 2026 02:56:50 -0700 Subject: [PATCH 1/4] Add Scale plan, pay-as-you-go top-ups and auto recharge Plans now change only rate limits and included credits: Pro stays $20/10x and Scale is $200/month with $200 of usage and 100x limits. Pay-as-you-go top-ups ($5-$1,000) credit purchased funds only after invoice-verified reconciliation, and owners can enable auto recharge with an amount, a balance threshold and an optional calendar-month maximum enforced by a claimed-attempt ledger. Co-Authored-By: Claude Fable 5 --- BILLING.md | 29 +- autumn.config.ts | 40 ++- migrations/postgres/0016_pay_as_you_go.sql | 27 ++ src/docs.ts | 2 +- src/features/billing/billing.functions.ts | 97 +++++- src/features/billing/checkout.ts | 8 +- src/features/billing/credits.tsx | 363 ++++++++++++++++++++- src/features/billing/plans.tsx | 17 +- src/features/dashboard/app-view.tsx | 2 +- src/http/classification.ts | 3 +- src/http/spending-classification.ts | 13 +- src/index.ts | 2 +- src/lib/billing.ts | 42 ++- src/openapi.ts | 6 +- src/pages.ts | 27 +- src/pricingui.ts | 14 +- src/server.ts | 2 + src/server/agents.ts | 2 +- src/server/auto-top-up.ts | 72 ++++ src/server/autumn.ts | 86 ++++- src/server/billing-sync.ts | 116 +++++-- src/server/billing.ts | 26 ++ src/server/contracts.ts | 11 + src/server/db.ts | 2 + src/wellknown.ts | 4 +- test/discovery.test.ts | 2 +- tests/accounts.test.ts | 2 +- tests/auto-top-up.test.ts | 174 ++++++++++ tests/autumn.test.ts | 61 ++++ tests/dashboard-analytics.test.ts | 2 +- tests/spending.e2e.test.ts | 14 + 31 files changed, 1156 insertions(+), 112 deletions(-) create mode 100644 migrations/postgres/0016_pay_as_you_go.sql create mode 100644 src/server/auto-top-up.ts create mode 100644 tests/auto-top-up.test.ts diff --git a/BILLING.md b/BILLING.md index afb1f01..3031739 100644 --- a/BILLING.md +++ b/BILLING.md @@ -1,22 +1,36 @@ WORKSPACE BILLING -Pro is $20/month with $20 of usage, three seats and 10x classification quotas. +Plans change rate limits and included credits, nothing else. Pro is $20/month +with $20 of usage, three seats and 10x classification quotas. Scale is +$200/month with $200 of usage, unlimited seats and 100x quotas. WorkOS handles sign-in. Workspace API keys use the classifier_agent_ prefix. REST and MCP share the workspace balance and quota bucket. +Pay-as-you-go top-ups ($5-$1,000, whole dollars) add purchased funds that +never expire. Owners may enable auto recharge: when the balance falls below +their threshold, the saved payment method is charged their recharge amount, +bounded by an optional calendar-month maximum. Auto charges are claimed in +app_auto_topup_attempts first (single-flight lock, one-hour failure cooldown, +monthly-cap ledger) and the wallet is only ever credited by invoice-verified +reconciliation, exactly once per invoice id. + Autumn manages Stripe checkout and subscriptions. Verified paid invoices grant -each period's allowance once; returning from checkout does not activate Pro. -Customers manage subscriptions at /app/plans and credentials at /app/keys. +each period's allowance once; returning from checkout does not activate a plan. +Customers manage subscriptions at /app/plans, top-ups and auto recharge at +/app/credits, and credentials at /app/keys. SETUP -- Set AUTUMN_SECRET_KEY, AUTUMN_WEBHOOK_SECRET and AUTUMN_PRO_PLAN_ID as Worker - secrets. The runtime key needs customer and billing read/write permissions. +- Set AUTUMN_SECRET_KEY, AUTUMN_WEBHOOK_SECRET, AUTUMN_PRO_PLAN_ID, + AUTUMN_SCALE_PLAN_ID and AUTUMN_TOPUP_PLAN_ID as Worker secrets. The runtime + key needs customer and billing read/write permissions. Plan ids are the + autumn.config.ts slugs (pro, scale, top_up); push config with npx atmn push. - Keep BILLING_SIGNING_KEY stable: it derives personal billing customer IDs from verified email addresses. Customer mappings and subscriptions remain in place when credentials rotate. - Configure /webhooks/autumn for billing.updated events. Scheduled reconciliation - repairs missed events. Billing state and credit reservations live in Neon. + repairs missed events and sweeps auto recharges. Billing state and credit + reservations live in Neon. - Use npm run dev with sandbox credentials in ignored .dev.vars. Production builds and deployments use wrangler.example.toml through CI. @@ -24,4 +38,5 @@ VERIFICATION Run npm test, npm run typecheck and the CLI tests. Account integration checks are documented in docs/account-verification.md. Verify checkout, invoice grants, -repeat reconciliation, cancellation and key revocation with sandbox accounts. +top-up purchases, auto recharge caps, repeat reconciliation, cancellation and +key revocation with sandbox accounts. diff --git a/autumn.config.ts b/autumn.config.ts index 913c43b..d6e2a92 100644 --- a/autumn.config.ts +++ b/autumn.config.ts @@ -1,19 +1,57 @@ import { atmn, feature, plan } from "atmn"; +// Credits are Autumn's purchase unit for pay-as-you-go top-ups: 100,000 credits +// equal one dollar (src/lib/billing.ts). The wallet itself stays in Neon; a +// purchase only becomes spendable after its paid invoice is verified. export default atmn({ features: [feature({ internalId: "fe_3JY0YuagHNTmVmJE9LZYjGWnMZJ", featureId: "classifier_pro_limits", name: "10× classification rate limits", type: "boolean", + }), feature({ + internalId: "fe_3JrMqs92gvWniLPaJPhqiVuPA8X", + featureId: "classifier_scale_limits", + name: "100× classification rate limits", + type: "boolean", + }), feature({ + internalId: "fe_3JrMqrgKPokKsoXINHblHh1ohYW", + featureId: "credits", + name: "Usage credits", + type: "metered", + consumable: true, })], plans: [plan({ - internalId: "prod_3JY0YnGHp7SRlaypoKnlwzJH4jE", + internalId: "prod_3JY06U2NCtuwTdTAEkMlOlt79yA", planId: "pro", versionSlug: "v1", active: true, name: "Pro", price: { amount: 20, interval: "month" }, items: [{ featureId: "classifier_pro_limits" }], + }), plan({ + internalId: "prod_3JrMqrNNqVvXC9wemjHgPNaoQR5", + planId: "scale", + versionSlug: "v1", + active: true, + name: "Scale", + price: { amount: 200, interval: "month" }, + items: [{ featureId: "classifier_scale_limits" }], + }), plan({ + internalId: "prod_3JrMquITKMqYfKzHnUTWZXiRUgA", + planId: "top_up", + versionSlug: "v1", + active: true, + addOn: true, + name: "Credit top-up", + items: [{ + featureId: "credits", + price: { + amount: 1, + billingUnits: 100_000, + billingMethod: "prepaid", + interval: "one_off", + }, + }], })], }); diff --git a/migrations/postgres/0016_pay_as_you_go.sql b/migrations/postgres/0016_pay_as_you_go.sql new file mode 100644 index 0000000..322629b --- /dev/null +++ b/migrations/postgres/0016_pay_as_you_go.sql @@ -0,0 +1,27 @@ +-- Pay-as-you-go top-ups. A purchase becomes spendable only after its paid +-- invoice is verified; the invoice id is the idempotency key for the grant. +CREATE TABLE app_autumn_topups ( + invoice_id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES app_accounts(id), + credits BIGINT NOT NULL CHECK(credits > 0), + amount_cents BIGINT NOT NULL CHECK(amount_cents > 0), + kind TEXT NOT NULL CHECK(kind IN ('top_up','auto_top_up')), + operation_id TEXT NOT NULL, + created_at TEXT NOT NULL, + revoked_at TEXT +); +CREATE INDEX app_autumn_topups_account ON app_autumn_topups(account_id,created_at); +-- Every automatic charge is claimed here first: the claim is the single-flight +-- lock, the failure cooldown, and the calendar-month spending-cap ledger. +CREATE TABLE app_auto_topup_attempts ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES app_accounts(id), + month TEXT NOT NULL, + amount_cents BIGINT NOT NULL CHECK(amount_cents > 0), + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','charged','failed')), + invoice_id TEXT, + reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT +); +CREATE INDEX app_auto_topup_attempts_account ON app_auto_topup_attempts(account_id,month,created_at); diff --git a/src/docs.ts b/src/docs.ts index 6cf505d..88f12ff 100644 --- a/src/docs.ts +++ b/src/docs.ts @@ -127,7 +127,7 @@ TYPESAFE SDK COMPATIBILITY classifier_agent_... A workspace key from https://classifier.dev/app/keys. Requests use the workspace's shared quota and credit balance. Free workspaces keep the - same ceilings; Pro workspaces get 10x limits. Charges use TypeSafe's + same ceilings; Pro workspaces get 10x limits and Scale 100x. Charges use TypeSafe's returned token usage and appear in workspace usage history. Do not put a real TypeSafe API key here: classifier.dev never forwards caller diff --git a/src/features/billing/billing.functions.ts b/src/features/billing/billing.functions.ts index 8dfac11..004e36f 100644 --- a/src/features/billing/billing.functions.ts +++ b/src/features/billing/billing.functions.ts @@ -1,27 +1,88 @@ import { createServerFn } from "@tanstack/react-start"; import { appEnvironment } from "../../server/environment"; +import { isValidTopUpCents } from "../../lib/billing"; + +type BillingLinkRequest = + | { action: "checkout"; plan: "pro" | "scale" } + | { action: "portal" } + | { action: "top-up"; amountCents: number } + | { action: "setup-payment" }; + +/** Every billing mutation shares one gate: verified session, owner role on the + * selected workspace, same-origin request, server-derived return URL. */ +async function ownerBillingContext() { + const { env } = await import("cloudflare:workers"); + const { getRequest, setResponseHeader } = await import("@tanstack/react-start/server"); + const { requireAccount, assertSameOrigin } = await import("../../server/auth"); + const { getOrganizationContext, selectedWorkspace } = await import("../../server/organizations"); + const { AppError } = await import("../../server/db"); + const request = getRequest(), bindings = appEnvironment(env); + assertSameOrigin(request); + setResponseHeader("Cache-Control", "no-store"); + const context = await getOrganizationContext(await requireAccount(request, bindings), selectedWorkspace(request), bindings); + if (context.active.role !== "owner") throw new AppError(403, "Only the workspace owner can manage billing."); + return { bindings, accountId: context.active.id }; +} export const getBillingLink = createServerFn({ method: "POST" }) - .validator((value: unknown): { action: "checkout" | "portal" } => { - if (!value || typeof value !== "object" || !("action" in value) || - (value.action !== "checkout" && value.action !== "portal")) throw new Error("Invalid billing action."); - return { action: value.action }; + .validator((value: unknown): BillingLinkRequest => { + if (!value || typeof value !== "object" || !("action" in value)) throw new Error("Invalid billing action."); + const request = value as Record; + if (request.action === "portal" || request.action === "setup-payment") return { action: request.action }; + if (request.action === "checkout") { + if (request.plan !== "pro" && request.plan !== "scale") throw new Error("Invalid billing plan."); + return { action: "checkout", plan: request.plan }; + } + if (request.action === "top-up") { + if (!isValidTopUpCents(request.amountCents)) throw new Error("Top-ups are whole dollar amounts between $5 and $1,000."); + return { action: "top-up", amountCents: request.amountCents }; + } + throw new Error("Invalid billing action."); }) .handler(async ({ data }) => { - const { env } = await import("cloudflare:workers"); - const { getRequest, setResponseHeader } = await import("@tanstack/react-start/server"); - const { requireAccount, assertSameOrigin } = await import("../../server/auth"); - const { getOrganizationContext, selectedWorkspace } = await import("../../server/organizations"); - const { AppError } = await import("../../server/db"); - const { createAutumnCheckout, createAutumnPortal, billingReturnUrl } = await import("../../server/autumn"); - const request = getRequest(), bindings = appEnvironment(env); - assertSameOrigin(request); - setResponseHeader("Cache-Control", "no-store"); - const context = await getOrganizationContext(await requireAccount(request, bindings), selectedWorkspace(request), bindings); - if (context.active.role !== "owner") throw new AppError(403, "Only the workspace owner can manage billing."); + const { createAutumnCheckout, createAutumnPortal, createAutumnTopUpCheckout, createAutumnPaymentSetup, billingReturnUrl } = + await import("../../server/autumn"); + const { bindings, accountId } = await ownerBillingContext(); // The dashboard is hosted on our application origin; no caller supplied URL. const returnUrl = billingReturnUrl(bindings); - return data.action === "checkout" - ? createAutumnCheckout(bindings, context.active.id, returnUrl) - : createAutumnPortal(bindings, context.active.id, returnUrl); + const creditsReturnUrl = `${returnUrl.replace(/\/app\/plans$/, "/app/credits")}?billing=refresh`; + switch (data.action) { + case "checkout": return createAutumnCheckout(bindings, accountId, returnUrl, data.plan); + case "portal": return createAutumnPortal(bindings, accountId, returnUrl); + case "top-up": return createAutumnTopUpCheckout(bindings, accountId, creditsReturnUrl, data.amountCents); + case "setup-payment": return createAutumnPaymentSetup(bindings, accountId, creditsReturnUrl); + } }); + +export const updateAutoTopUp = createServerFn({ method: "POST" }) + .validator((value: unknown): { enabled: boolean; amountCents: number; thresholdCents: number; capCents: number } => { + if (!value || typeof value !== "object") throw new Error("Invalid auto top-up settings."); + const settings = value as Record; + if (typeof settings.enabled !== "boolean") throw new Error("Invalid auto top-up settings."); + if (!isValidTopUpCents(settings.amountCents)) throw new Error("Recharge amounts are whole dollars between $5 and $1,000."); + const threshold = settings.thresholdCents, cap = settings.capCents; + if (typeof threshold !== "number" || !Number.isSafeInteger(threshold) || threshold < 100 || threshold > 100_000 || threshold % 100 !== 0) + throw new Error("The balance threshold is a whole dollar amount between $1 and $1,000."); + if (typeof cap !== "number" || !Number.isSafeInteger(cap) || cap < 0 || cap > 1_000_000 || cap % 100 !== 0) + throw new Error("The monthly maximum is a whole dollar amount up to $10,000, or 0 for no maximum."); + return { enabled: settings.enabled, amountCents: settings.amountCents, thresholdCents: threshold, capCents: cap }; + }) + .handler(async ({ data }) => { + const { bindings, accountId } = await ownerBillingContext(); + await bindings.APP_DB.prepare( + "UPDATE app_accounts SET auto_top_up_enabled=?,auto_top_up_amount_cents=?,auto_top_up_threshold_cents=?,auto_top_up_cap_cents=? WHERE id=?", + ).bind(data.enabled ? 1 : 0, data.amountCents, data.thresholdCents, data.capCents, accountId).run(); + return { saved: true as const }; + }); + +/** Called when returning from a top-up checkout so the purchase shows without + * waiting for a webhook. Reconciliation is idempotent and invoice-verified. */ +export const refreshBilling = createServerFn({ method: "POST" }).handler(async () => { + const { reconcileAutumnCustomer } = await import("../../server/billing-sync"); + const { bindings, accountId } = await ownerBillingContext(); + const mapping = await bindings.APP_DB.prepare("SELECT customer_id FROM app_autumn_customers WHERE account_id=?") + .bind(accountId).first<{ customer_id: string }>(); + if (!mapping) return { refreshed: false as const }; + try { await reconcileAutumnCustomer(bindings, mapping.customer_id); return { refreshed: true as const }; } + catch { return { refreshed: false as const }; } +}); diff --git a/src/features/billing/checkout.ts b/src/features/billing/checkout.ts index 6009537..8b44374 100644 --- a/src/features/billing/checkout.ts +++ b/src/features/billing/checkout.ts @@ -1,9 +1,13 @@ /** The server derives the workspace and return URL from the authenticated session. */ export async function billingRedirect( - action: "checkout" | "portal", + request: + | { action: "checkout"; plan: "pro" | "scale" } + | { action: "portal" } + | { action: "top-up"; amountCents: number } + | { action: "setup-payment" }, ): Promise { const { getBillingLink } = await import("./billing.functions"); - const result = await getBillingLink({ data: { action } }); + const result = await getBillingLink({ data: request }); if (typeof result.url !== "string") throw new Error("Payment management returned an invalid link."); const url = new URL(result.url); diff --git a/src/features/billing/credits.tsx b/src/features/billing/credits.tsx index 2f12299..f242cdf 100644 --- a/src/features/billing/credits.tsx +++ b/src/features/billing/credits.tsx @@ -1,10 +1,28 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { PageHeader } from "@/components/page-header"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { ArrowRight, CreditCard } from "@/components/ui/icons"; -import { BILLING_PLANS, formatCents, formatCreditsUsd } from "@/lib/billing"; -import type { AppSnapshot } from "@/server/contracts"; +import { + BILLING_PLANS, + TOP_UP_MAX_CENTS, + TOP_UP_MIN_CENTS, + formatCents, + formatCreditsUsd, + isValidTopUpCents, +} from "@/lib/billing"; +import type { AppAction, AppSnapshot } from "@/server/contracts"; const date = (value: string) => new Date(value).toLocaleDateString("en-US", { @@ -14,33 +32,154 @@ const date = (value: string) => timeZone: "UTC", }); +const TOP_UP_PRESETS_CENTS = [500, 1_000, 2_500, 5_000, 10_000]; + +function dollarsToCents(value: string): number | null { + if (!/^\d{1,4}$/.test(value.trim())) return null; + return Number(value.trim()) * 100; +} + +function failureMessage(reason: string) { + if (reason === "payment_method_required") + return "Your last automatic recharge needs a saved payment method."; + if (reason === "payment_failed" || reason === "3ds_required") + return "Your last automatic recharge could not charge the saved payment method."; + return "Your last automatic recharge did not complete."; +} + export function Credits({ snapshot, navigate, + act, }: { snapshot: AppSnapshot; navigate: (path: string) => void; + act?: (action: AppAction) => Promise; }) { const { billing } = snapshot; const [portalError, setPortalError] = useState(""); const [openingPortal, setOpeningPortal] = useState(false); - async function openPortal() { + const [topUpOpen, setTopUpOpen] = useState(false); + const [topUpAmount, setTopUpAmount] = useState("25"); + const [topUpBusy, setTopUpBusy] = useState(false); + const [topUpError, setTopUpError] = useState(""); + const [autoEnabled, setAutoEnabled] = useState(billing.autoTopUp.enabled); + const [autoAmount, setAutoAmount] = useState( + String(billing.autoTopUp.amountCents / 100), + ); + const [autoThreshold, setAutoThreshold] = useState( + String(billing.autoTopUp.thresholdCents / 100), + ); + const [autoCap, setAutoCap] = useState( + billing.autoTopUp.capCents > 0 ? String(billing.autoTopUp.capCents / 100) : "", + ); + const [autoBusy, setAutoBusy] = useState(false); + const [autoError, setAutoError] = useState(""); + const [autoSaved, setAutoSaved] = useState(false); + const refreshed = useRef(false); + useEffect(() => { + // Returning from a top-up checkout: verify the paid invoice right away + // instead of waiting for a webhook, then reload the snapshot once. + if (refreshed.current || !act) return; + if (!new URLSearchParams(window.location.search).has("billing")) return; + refreshed.current = true; + window.history.replaceState(null, "", window.location.pathname); + void (async () => { + try { + const { refreshBilling } = await import("./billing.functions"); + await refreshBilling(); + } catch { + /* reconciliation retries on the next sync */ + } + await act({ type: "refresh" }).catch(() => {}); + })(); + }, [act]); + async function openPortal(action: "portal" | "setup-payment" = "portal") { setOpeningPortal(true); setPortalError(""); try { const { billingRedirect } = await import("./checkout"); - window.location.assign(await billingRedirect("portal")); + window.location.assign(await billingRedirect({ action })); } catch { setPortalError("Payment management is unavailable. Try again shortly."); } finally { setOpeningPortal(false); } } + async function startTopUp() { + const cents = dollarsToCents(topUpAmount); + if (cents === null || !isValidTopUpCents(cents)) { + setTopUpError( + `Enter a whole dollar amount between ${formatCents(TOP_UP_MIN_CENTS)} and ${formatCents(TOP_UP_MAX_CENTS)}.`, + ); + return; + } + setTopUpBusy(true); + setTopUpError(""); + try { + const { billingRedirect } = await import("./checkout"); + window.location.assign( + await billingRedirect({ action: "top-up", amountCents: cents }), + ); + } catch (cause) { + setTopUpError( + cause instanceof Error + ? cause.message + : "Checkout is unavailable. Try again shortly.", + ); + } finally { + setTopUpBusy(false); + } + } + async function saveAutoTopUp() { + const amount = dollarsToCents(autoAmount); + const threshold = dollarsToCents(autoThreshold); + const cap = autoCap.trim() === "" ? 0 : dollarsToCents(autoCap); + if (amount === null || !isValidTopUpCents(amount)) { + setAutoError( + `The recharge amount is a whole dollar amount between ${formatCents(TOP_UP_MIN_CENTS)} and ${formatCents(TOP_UP_MAX_CENTS)}.`, + ); + return; + } + if (threshold === null || threshold < 100) { + setAutoError("The balance threshold is a whole dollar amount of at least $1."); + return; + } + if (cap === null) { + setAutoError("The monthly maximum is a whole dollar amount, or blank for no maximum."); + return; + } + setAutoBusy(true); + setAutoError(""); + setAutoSaved(false); + try { + const { updateAutoTopUp } = await import("./billing.functions"); + await updateAutoTopUp({ + data: { + enabled: autoEnabled, + amountCents: amount, + thresholdCents: threshold, + capCents: cap, + }, + }); + setAutoSaved(true); + await act?.({ type: "refresh" }).catch(() => {}); + } catch (cause) { + setAutoError( + cause instanceof Error + ? cause.message + : "Auto recharge settings could not be saved. Try again.", + ); + } finally { + setAutoBusy(false); + } + } const plan = BILLING_PLANS[billing.plan]; const scheduledPlan = billing.scheduledPlan ? BILLING_PLANS[billing.scheduledPlan] : null; const canManage = snapshot.organizations?.active.role === "owner"; + const payAsYouGo = billing.payAsYouGo && billing.mode === "autumn"; const free = billing.plan === "free"; const allowance = snapshot.credits.included; const allowanceDescription = free @@ -54,7 +193,7 @@ export function Credits({ title="Billing" description={ free - ? "Your plan and one-time signup balance." + ? "Your plan, balance and pay-as-you-go top-ups." : "Your plan and usage for this billing period." } /> @@ -94,12 +233,25 @@ export function Credits({ {formatCreditsUsd(billing.availableCredits)}

-

- - {formatCreditsUsd(allowance)} - {" "} - {allowanceDescription} -

+
+ {payAsYouGo && canManage && ( + + )} +

+ + {formatCreditsUsd(allowance)} + {" "} + {allowanceDescription} +

+

@@ -142,6 +294,131 @@ export function Credits({ + {payAsYouGo && ( +

+

+ Auto recharge +

+ + +
+
+ +

+ Once your balance falls below the threshold, the saved + payment method is charged the recharge amount. Purchased + funds never expire. +

+
+ setAutoEnabled(event.target.checked)} + /> +
+ {billing.autoTopUp.lastFailure && ( + + Automatic recharge needs attention + +

+ {failureMessage(billing.autoTopUp.lastFailure.reason)}{" "} + Retries pause for an hour after a failed charge. +

+ {canManage && ( + + )} +
+
+ )} +
+
+ + setAutoAmount(event.target.value)} + /> +
+
+ + setAutoThreshold(event.target.value)} + /> +
+
+ + setAutoCap(event.target.value)} + /> +
+
+
+

+ {formatCents(billing.autoTopUp.monthUsedCents)} recharged + automatically this month + {billing.autoTopUp.capCents > 0 + ? ` of the ${formatCents(billing.autoTopUp.capCents)} maximum.` + : "."} +

+
+ {autoSaved && !autoError && ( +

Saved.

+ )} + +
+
+ {autoError && ( +

+ {autoError} +

+ )} + {!canManage && ( +

+ Your workspace owner manages top-ups and auto recharge. +

+ )} +
+
+
+ )} +
+ + { + if (!topUpBusy) setTopUpOpen(open); + }} + > + + + Top up balance + + Add pay-as-you-go funds to your workspace. Purchased funds never + expire and are used after your included plan allowance. + + +
+ {TOP_UP_PRESETS_CENTS.map((cents) => ( + + ))} +
+
+ + setTopUpAmount(event.target.value)} + /> +

+ Whole dollar amounts between {formatCents(TOP_UP_MIN_CENTS)} and{" "} + {formatCents(TOP_UP_MAX_CENTS)}. Your balance updates after the + payment is confirmed. +

+
+ {topUpError && ( +

+ {topUpError} +

+ )} + + + + +
+
); } diff --git a/src/features/billing/plans.tsx b/src/features/billing/plans.tsx index 93a971d..89e6c1b 100644 --- a/src/features/billing/plans.tsx +++ b/src/features/billing/plans.tsx @@ -15,6 +15,7 @@ import { import { ArrowRight, ArrowUpRight, Check } from "@/components/ui/icons"; import { BILLING_PLANS, + PAID_PLANS, formatCents, formatCreditsUsd, creditsToDollars, @@ -68,9 +69,9 @@ export function Plans({ const { billingRedirect } = await import("./checkout"); window.location.assign( await billingRedirect( - selected.id === "free" || current.id === "pro" - ? "portal" - : "checkout", + selected.id === "free" || current.id !== "free" + ? { action: "portal" } + : { action: "checkout", plan: selected.id }, ), ); } catch (cause) { @@ -182,8 +183,11 @@ export function Plans({ -
- {[BILLING_PLANS.pro].map((plan) => ( +
+ {PAID_PLANS.map((plan) => (
    {[ + `${plan.rateLimitMultiplier}× classification rate limits`, plan.seatLimit === null ? "Unlimited workspace seats" : `${plan.seatLimit} workspace seats`, @@ -271,7 +276,7 @@ export function Plans({

    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")}
Choose Pro +

${esc(scale.name)}

for teams running classification in production

+

$${scale.priceCents / 100} / month

+
    ${feature(`${formatCreditsUsd(scale.includedCredits)} of usage each month`)}${feature("Unlimited workspace seats")}${feature("100× rate limits")}${feature("Usage by connection and agent")}
+ Choose Scale

Enterprise

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")}
Contact sales
+

Pay as you go

+

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.

@@ -74,7 +83,7 @@ export function pricingHtml(signedIn = false) {
UsagePrice

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) {
+
PlanFastSmart
Free3,000/min · 20,000/day200/min · 2,000/day
Pro30,000/min · 200,000/day2,000/min · 20,000/day
Scale300,000/min · 2,000,000/day20,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.