Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion cloudflare-workers/api-edge/src/autumn_webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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<string, { remaining?: number }>;
billing_controls?: { auto_topups?: AutumnAutoTopup[] };
billing_controls?: { auto_topups?: AutumnAutoTopup[]; usage_limits?: AutumnUsageLimit[] };
}

function json(body: unknown, status = 200): Response {
Expand Down Expand Up @@ -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<void> {
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 {
Expand Down
35 changes: 35 additions & 0 deletions cloudflare-workers/api-edge/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
autumnHasToppedUp,
syncAutumnToD1,
autumnSetAutoTopup,
autumnSetMonthlyBudget,
} from "./autumn_webhook";
import { handleWebhooksAPI, type WebhookEnv } from "./webhooks";

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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<Response> {
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
Expand Down
7 changes: 7 additions & 0 deletions web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type {
Credits,
BillingState,
AutumnAutoTopup,
AutumnMonthlyBudget,
AutumnBilling,
StripeInvoice,
SandboxUsageRow,
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions web/src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
})

Expand Down Expand Up @@ -604,6 +611,7 @@ export type OrgInvitation = z.infer<typeof OrgInvitationSchema>
export type Credits = z.infer<typeof CreditsSchema>
export type BillingState = z.infer<typeof BillingStateSchema>
export type AutumnAutoTopup = z.infer<typeof AutumnAutoTopupSchema>
export type AutumnMonthlyBudget = z.infer<typeof AutumnMonthlyBudgetSchema>
export type AutumnBilling = z.infer<typeof AutumnBillingSchema>
export type StripeInvoice = z.infer<typeof StripeInvoiceSchema>
export type SandboxUsageRow = z.infer<typeof SandboxUsageRowSchema>
Expand Down
112 changes: 112 additions & 0 deletions web/src/pages/Billing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getSandboxUsage,
redeemPromoCode,
setAutumnAutoTopup,
setAutumnMonthlyBudget,
type AutumnBilling,
type StripeInvoice,
} from '@/api/client'
Expand Down Expand Up @@ -348,6 +349,8 @@ function PrepaidPlan() {
hasToppedUp={autumn?.hasToppedUp ?? false}
/>

<MonthlyBudgetCard current={autumn?.monthlyBudget ?? null} />

<ModelUsageCard usage={autumn?.modelUsage ?? null} />

{/* Concurrency */}
Expand Down Expand Up @@ -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 (
<Panel className="p-6">
<h2 className="mb-2 text-sm font-semibold">Monthly budget</h2>
<p className="text-muted-foreground mb-4 text-sm">
Set a monthly hard cap on prepaid credit usage. Autumn resets this
window each month.
</p>

<div className="flex items-center gap-2">
<Checkbox
id="monthly-budget"
checked={enabled}
onCheckedChange={(v) =>
setDraft((d) => ({ ...d, enabled: v === true }))
}
/>
<Label htmlFor="monthly-budget" className="cursor-pointer font-normal">
Enable monthly budget
</Label>
</div>

{enabled ? (
<div className="mt-4 flex flex-wrap items-end gap-5">
<Field label="Monthly cap" htmlFor="monthly-budget-limit">
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground text-sm">$</span>
<Input
id="monthly-budget-limit"
type="number"
min={1}
value={limit}
onChange={(e) =>
setDraft((d) => ({
...d,
limit: Math.max(
1,
Math.floor(Number(e.target.value) || 0),
),
}))
}
className="w-24 font-mono"
/>
</div>
</Field>
{usagePct != null ? (
<div className="min-w-44">
<div className="text-muted-foreground mb-1.5 text-xs">
${usage?.toFixed(2)} used this month ({usagePct}%)
</div>
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
<div
className="bg-foreground h-full"
style={{ width: `${usagePct}%` }}
/>
</div>
</div>
) : null}
</div>
) : null}

<div className="mt-4">
<Button
variant="outline"
disabled={mutation.isPending || (enabled && limit < 1)}
onClick={() => mutation.mutate()}
>
{mutation.isPending ? 'Saving…' : saved ? 'Saved' : 'Save'}
</Button>
</div>

{enabled ? (
<p className="text-muted-foreground mt-3 text-xs">
New usage pauses once credit spend reaches this cap for the month.
</p>
) : null}
</Panel>
)
}

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