From fb0b3daa08879568eea6c2e97122e01d6d2ef984 Mon Sep 17 00:00:00 2001 From: Mohamed Habib Date: Tue, 30 Jun 2026 10:43:44 -0700 Subject: [PATCH] feat: add autumn monthly budget controls --- .../api-edge/src/autumn_webhook.ts | 33 +++++- cloudflare-workers/api-edge/src/dashboard.ts | 35 ++++++ web/src/api/client.ts | 7 ++ web/src/api/schemas.ts | 8 ++ web/src/pages/Billing.tsx | 112 ++++++++++++++++++ 5 files changed, 194 insertions(+), 1 deletion(-) diff --git a/cloudflare-workers/api-edge/src/autumn_webhook.ts b/cloudflare-workers/api-edge/src/autumn_webhook.ts index f58be6063..a68c5a109 100644 --- a/cloudflare-workers/api-edge/src/autumn_webhook.ts +++ b/cloudflare-workers/api-edge/src/autumn_webhook.ts @@ -66,6 +66,13 @@ export interface AutumnAutoTopup { threshold: number; quantity: number; } +export interface AutumnUsageLimit { + feature_id: string; + enabled?: boolean; + limit: number; + interval: "day" | "week" | "month" | "year"; + usage?: number; +} export interface AutumnCustomer { id: string; subscriptions?: AutumnSubscription[]; @@ -75,7 +82,7 @@ export interface AutumnCustomer { // "auto-recharge will fire" signal (Autumn exposes no payment-method field). purchases?: Array<{ plan_id: string }>; balances?: Record; - billing_controls?: { auto_topups?: AutumnAutoTopup[] }; + billing_controls?: { auto_topups?: AutumnAutoTopup[]; usage_limits?: AutumnUsageLimit[] }; } function json(body: unknown, status = 200): Response { @@ -532,6 +539,30 @@ export async function autumnSetAutoTopup( if (!resp.ok) throw new Error(`autumn set auto-topup ${resp.status}: ${await resp.text()}`); } +// autumnSetMonthlyBudget configures a windowed hard cap on the shared `credits` +// balance. Autumn enforces usage_limits during check/track, so this caps monthly +// credit spend even if the customer still has prepaid balance available. +export async function autumnSetMonthlyBudget( + env: AutumnApiEnv, + customerID: string, + cfg: { enabled: boolean; limit: number }, +): Promise { + const base = env.AUTUMN_BASE_URL || DEFAULT_BASE_URL; + const usageLimit = cfg.enabled + ? { feature_id: CREDITS_FEATURE_ID, enabled: true, limit: cfg.limit, interval: "month" } + : { feature_id: CREDITS_FEATURE_ID, enabled: false, limit: 0, interval: "month" }; + const resp = await fetch(`${base}/customers/${encodeURIComponent(customerID)}`, { + method: "POST", + headers: { Authorization: `Bearer ${env.AUTUMN_SECRET_KEY}`, "content-type": "application/json" }, + body: JSON.stringify({ + billing_controls: { + usage_limits: [usageLimit], + }, + }), + }); + if (!resp.ok) throw new Error(`autumn set monthly budget ${resp.status}: ${await resp.text()}`); +} + // ── cell dispatch (mirrors the DO's halt/resume fan-out) ─────────────────── interface CellRow { diff --git a/cloudflare-workers/api-edge/src/dashboard.ts b/cloudflare-workers/api-edge/src/dashboard.ts index 4568132a5..dd9c9d3ea 100644 --- a/cloudflare-workers/api-edge/src/dashboard.ts +++ b/cloudflare-workers/api-edge/src/dashboard.ts @@ -24,6 +24,7 @@ import { autumnHasToppedUp, syncAutumnToD1, autumnSetAutoTopup, + autumnSetMonthlyBudget, } from "./autumn_webhook"; import { handleWebhooksAPI, type WebhookEnv } from "./webhooks"; @@ -1128,6 +1129,9 @@ export async function handleDashboard( if (sub === "/billing/autumn/auto-topup" && method === "POST") { return handleAutumnAutoTopup(req, env, caller); } + if (sub === "/billing/autumn/monthly-budget" && method === "POST") { + return handleAutumnMonthlyBudget(req, env, caller); + } if (sub === "/billing/autumn/finalize-arm" && method === "GET") { return handleAutumnFinalizeArm(req, env, caller); } @@ -1398,6 +1402,9 @@ async function handleAutumnBilling(_req: Request, env: DashboardEnv, caller: { o } const at = r.customer.billing_controls?.auto_topups?.find((a) => a.feature_id === "credits"); + const monthlyBudget = r.customer.billing_controls?.usage_limits?.find( + (l) => l.feature_id === "credits" && l.interval === "month", + ); // Has the customer charged a top-up? That's when auto-recharge becomes armed, // so the UI uses it to tell whether enabling auto-recharge will run a first @@ -1434,6 +1441,9 @@ async function handleAutumnBilling(_req: Request, env: DashboardEnv, caller: { o isHalted: r.halted, hasToppedUp, autoTopup: at ? { enabled: at.enabled, threshold: at.threshold, quantity: at.quantity } : null, + monthlyBudget: monthlyBudget && monthlyBudget.enabled !== false && monthlyBudget.limit > 0 + ? { enabled: true, limit: monthlyBudget.limit, usage: monthlyBudget.usage ?? null } + : null, modelUsage: { enabled: modelStatus === "active", status: modelStatus, @@ -1445,6 +1455,31 @@ async function handleAutumnBilling(_req: Request, env: DashboardEnv, caller: { o }); } +// POST /api/dashboard/billing/autumn/monthly-budget { enabled, limit } +// — configure a monthly hard cap on credits usage. limit is in credits, where +// one credit maps to $1 of prepaid balance in the current Autumn products. +async function handleAutumnMonthlyBudget(req: Request, env: DashboardEnv, caller: { orgID: string }): Promise { + if (!env.AUTUMN_SECRET_KEY) return json({ error: "autumn billing not configured" }, 503); + let body: { enabled?: boolean; limit?: number }; + try { + body = await req.json(); + } catch { + return json({ error: "bad json" }, 400); + } + const enabled = !!body.enabled; + const limit = Math.floor(body.limit ?? 0); + if (enabled && (!Number.isFinite(limit) || limit < 1)) { + return json({ error: "monthly budget must be at least $1 when enabled" }, 400); + } + try { + await autumnSetMonthlyBudget(env, caller.orgID, { enabled, limit }); + return json({ ok: true }); + } catch (e) { + console.error("billing/autumn monthly-budget:", e); + return json({ error: (e as Error).message }, 502); + } +} + // POST /api/dashboard/billing/autumn/auto-topup { enabled, threshold, quantity } // — configure automatic credit recharge. threshold/quantity are in credits ($1 // each). A saved payment method is required for the charge to succeed; the diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1a772926e..c0f3b1ecf 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -21,6 +21,7 @@ export type { Credits, BillingState, AutumnAutoTopup, + AutumnMonthlyBudget, AutumnBilling, StripeInvoice, SandboxUsageRow, @@ -381,6 +382,12 @@ export const setAutumnAutoTopup = (cfg: { body: JSON.stringify(cfg), }) +export const setAutumnMonthlyBudget = (cfg: { enabled: boolean; limit: number }) => + apiFetch<{ ok: boolean }>('/billing/autumn/monthly-budget', { + method: 'POST', + body: JSON.stringify(cfg), + }) + // Per-sandbox usage breakdown (compute cost over a recent window) export const getSandboxUsage = (days = 30) => apiFetch(`/usage/sandboxes?days=${days}`, {}, S.SandboxUsageSchema) diff --git a/web/src/api/schemas.ts b/web/src/api/schemas.ts index 05d8e4799..9cfac5126 100644 --- a/web/src/api/schemas.ts +++ b/web/src/api/schemas.ts @@ -200,6 +200,12 @@ export const AutumnAutoTopupSchema = z.object({ quantity: z.number(), }) +export const AutumnMonthlyBudgetSchema = z.object({ + enabled: z.boolean(), + limit: z.number(), + usage: z.number().nullable().optional(), +}) + export const AutumnModelUsageSchema = z.object({ enabled: z.boolean(), status: z.string(), @@ -216,6 +222,7 @@ export const AutumnBillingSchema = z.object({ isHalted: z.boolean(), hasToppedUp: z.boolean(), autoTopup: AutumnAutoTopupSchema.nullable(), + monthlyBudget: AutumnMonthlyBudgetSchema.nullable().optional(), modelUsage: AutumnModelUsageSchema.optional(), }) @@ -604,6 +611,7 @@ export type OrgInvitation = z.infer export type Credits = z.infer export type BillingState = z.infer export type AutumnAutoTopup = z.infer +export type AutumnMonthlyBudget = z.infer export type AutumnBilling = z.infer export type StripeInvoice = z.infer export type SandboxUsageRow = z.infer diff --git a/web/src/pages/Billing.tsx b/web/src/pages/Billing.tsx index fb40354c7..559b11f40 100644 --- a/web/src/pages/Billing.tsx +++ b/web/src/pages/Billing.tsx @@ -15,6 +15,7 @@ import { getSandboxUsage, redeemPromoCode, setAutumnAutoTopup, + setAutumnMonthlyBudget, type AutumnBilling, type StripeInvoice, } from '@/api/client' @@ -348,6 +349,8 @@ function PrepaidPlan() { hasToppedUp={autumn?.hasToppedUp ?? false} /> + + {/* Concurrency */} @@ -625,6 +628,115 @@ function AutoTopupCard({ ) } +function MonthlyBudgetCard({ + current, +}: { + current: AutumnBilling['monthlyBudget'] | null +}) { + const queryClient = useQueryClient() + const [draft, setDraft] = useState<{ + enabled?: boolean + limit?: number + }>({}) + const enabled = draft.enabled ?? current?.enabled ?? false + const limit = draft.limit ?? current?.limit ?? 100 + const [saved, markSaved] = useTransientFlag(3000) + + const mutation = useMutation({ + mutationFn: () => setAutumnMonthlyBudget({ enabled, limit }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['autumn-billing'] }) + setDraft({}) + markSaved() + }, + onError: (e) => notifyError("Couldn't save monthly budget.", e), + }) + + const usage = current?.usage ?? null + const usagePct = + enabled && usage != null && limit > 0 + ? Math.min(100, Math.round((usage / limit) * 100)) + : null + + return ( + +

Monthly budget

+

+ Set a monthly hard cap on prepaid credit usage. Autumn resets this + window each month. +

+ +
+ + setDraft((d) => ({ ...d, enabled: v === true })) + } + /> + +
+ + {enabled ? ( +
+ +
+ $ + + setDraft((d) => ({ + ...d, + limit: Math.max( + 1, + Math.floor(Number(e.target.value) || 0), + ), + })) + } + className="w-24 font-mono" + /> +
+
+ {usagePct != null ? ( +
+
+ ${usage?.toFixed(2)} used this month ({usagePct}%) +
+
+
+
+
+ ) : null} +
+ ) : null} + +
+ +
+ + {enabled ? ( +

+ New usage pauses once credit spend reaches this cap for the month. +

+ ) : null} + + ) +} + function formatDuration(seconds: number): string { if (seconds < 60) return `${seconds}s` if (seconds < 3600) return `${Math.round(seconds / 60)}m`