Skip to content
Review Link

#418agent/usage-billing → mainopened by the build agent

Usage-based billing

31 files+357 −1537 hunks

7 of 9 requirements met1 partial · 1 missing

Paste your own diff

Every plan today is a flat monthly price. Teams on Growth and Scale keep hitting their limits and asking to go past them instead of being cut off, and when they upgrade early we lose them at renewal. This moves API calls to usage-based billing: each plan includes a monthly quota, calls past it are billed as overage, and the billing page shows where a workspace stands.

Requirements

TapPick a requirement to see the code that implements it.

Notes

Quotas come from the plan, so a plan change mid-cycle takes effect on the next call. Keep stripe as the source of truth for invoices; we only add line items.

Out of scope

  • Changing plan prices.
  • Usage alerts in Slack.

Not in the doc

2 changes · 9 hunks

All changes · 31 files · 37 hunks

  • package.json

    +1−1
    @@ 21,21
    21 "hono": "^4.6.3",
    22 "pino": "^9.4.0",
    23Removed: "stripe": "^14.21.0",
    23Added: "stripe": "^15.3.0",
    24 "zod": "^3.23.8"
    25 },
  • pnpm-lock.yaml

    +4−4
    @@ 38,38importers:
    38 pino:
    39 specifier: ^9.4.0
    40 version: 9.4.0
    41 stripe:
    42Removed: specifier: ^14.21.0
    43Removed: version: 14.21.0
    42Added: specifier: ^15.3.0
    43Added: version: 15.3.0
    44 zod:
    45 specifier: ^3.23.8
    46 version: 3.23.8
    @@ 1412,1412packages:
    1412Removed: stripe@14.21.0:
    1413Removed: resolution: {integrity: sha512-Gx1Ii8LmrYO3t5V5aPPS3K0Ty9e1zqFtQ1YnYw9wB3aJm9Mg1yZbqgXQ2X6ZgLwW2mR6r4sJ1t0yHk3tGQ6+9Q==}
    1412Added: stripe@15.3.0:
    1413Added: resolution: {integrity: sha512-p8nT4kJ0x0f6a8zCq3Yd7mDg2Vt5r1b0Ff9qR2wA7Y3u4uQm6pWc1vF2Xn0hS8kLh5bTq9e3sJd0rP1cZa7mOw==}
    1414 engines: {node: '>=12.*'}
  • db/schema/plans.ts

    +4
    @@ 8,8export const plans = pgTable("plans", {
    8 id: text("id").primaryKey(),
    9 name: text("name").notNull(),
    10 monthlyPriceCents: integer("monthly_price_cents").notNull(),
    11Added: /** API calls included each month. */
    12Added: includedCalls: integer("included_calls").notNull().default(0),
    13Added: /** Price for every 1,000 calls past the quota. Null means the plan cannot go into overage. */
    14Added: overageCentsPer1k: integer("overage_cents_per_1k"),
    15 createdAt: timestamp("created_at").defaultNow().notNull(),
    16});
  • db/schema/usage.ts

    New+18
    @@ 0,1
    1Added: import { bigint, index, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
    2Added: import { workspaces } from "./workspaces";
    3Added:
    4Added: export const usageCounters = pgTable(
    5Added: "usage_counters",
    6Added: {
    7Added: workspaceId: text("workspace_id")
    8Added: .notNull()
    9Added: .references(() => workspaces.id, { onDelete: "cascade" }),
    10Added: cycleStart: timestamp("cycle_start").notNull(),
    11Added: calls: bigint("calls", { mode: "number" }).notNull().default(0),
    12Added: updatedAt: timestamp("updated_at").defaultNow().notNull(),
    13Added: },
    14Added: (t) => [
    15Added: uniqueIndex("usage_counters_workspace_cycle").on(t.workspaceId, t.cycleStart),
    16Added: index("usage_counters_cycle_start").on(t.cycleStart),
    17Added: ],
    18Added: );
  • db/schema/index.ts

    +1
    @@ 3,3
    3export * from "./invoices";
    4export * from "./plans";
    5Added: export * from "./usage";
    6export * from "./workspaces";
  • db/migrations/0042_usage_billing.sql

    New+12
    @@ 0,1
    1Added: ALTER TABLE "plans" ADD COLUMN "included_calls" integer DEFAULT 0 NOT NULL;
    2Added: ALTER TABLE "plans" ADD COLUMN "overage_cents_per_1k" integer;
    3Added:
    4Added: CREATE TABLE IF NOT EXISTS "usage_counters" (
    5Added: "workspace_id" text NOT NULL REFERENCES "workspaces"("id") ON DELETE cascade,
    6Added: "cycle_start" timestamp NOT NULL,
    7Added: "calls" bigint DEFAULT 0 NOT NULL,
    8Added: "updated_at" timestamp DEFAULT now() NOT NULL
    9Added: );
    10Added:
    11Added: CREATE UNIQUE INDEX IF NOT EXISTS "usage_counters_workspace_cycle" ON "usage_counters" ("workspace_id", "cycle_start");
    12Added: CREATE INDEX IF NOT EXISTS "usage_counters_cycle_start" ON "usage_counters" ("cycle_start");
  • db/seed/plans.ts

    +3−3
    @@ 4,4import { plans } from "../schema";
    4export const seedPlans = [
    5Removed: { id: "free", name: "Free", monthlyPriceCents: 0 },
    6Removed: { id: "growth", name: "Growth", monthlyPriceCents: 4900 },
    7Removed: { id: "scale", name: "Scale", monthlyPriceCents: 29900 },
    5Added: { id: "free", name: "Free", monthlyPriceCents: 0, includedCalls: 10_000, overageCentsPer1k: null },
    6Added: { id: "growth", name: "Growth", monthlyPriceCents: 4900, includedCalls: 250_000, overageCentsPer1k: 40 },
    7Added: { id: "scale", name: "Scale", monthlyPriceCents: 29900, includedCalls: 2_000_000, overageCentsPer1k: 25 },
    8] satisfies (typeof plans.$inferInsert)[];
  • src/billing/cycle.ts

    New+14
    @@ 0,1
    1Added: /** A workspace's billing cycle runs monthly from the day it subscribed (its anchor). */
    2Added: export function cycleBounds(anchor: Date, now: Date = new Date()): { start: Date; end: Date } {
    3Added: const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), anchor.getUTCDate()));
    4Added: if (start > now) start.setUTCMonth(start.getUTCMonth() - 1);
    5Added: const end = new Date(start);
    6Added: end.setUTCMonth(end.getUTCMonth() + 1);
    7Added: return { start, end };
    8Added: }
    9Added:
    10Added: export function cycleHasEnded(cycleStart: Date, now: Date = new Date()): boolean {
    11Added: const end = new Date(cycleStart);
    12Added: end.setUTCMonth(end.getUTCMonth() + 1);
    13Added: return end <= now;
    14Added: }
  • src/billing/quota.ts

    New+16
    @@ 0,1
    1Added: import { eq } from "drizzle-orm";
    2Added: import { db } from "../db";
    3Added: import { plans, workspaces } from "../../db/schema";
    4Added:
    5Added: export type Quota = { includedCalls: number; overageCentsPer1k: number | null; planId: string };
    6Added:
    7Added: /** Read on every call, so a plan change mid-cycle applies to the next request. */
    8Added: export async function getQuota(workspaceId: string): Promise<Quota> {
    9Added: const [row] = await db
    10Added: .select({ includedCalls: plans.includedCalls, overageCentsPer1k: plans.overageCentsPer1k, planId: plans.id })
    11Added: .from(workspaces)
    12Added: .innerJoin(plans, eq(plans.id, workspaces.planId))
    13Added: .where(eq(workspaces.id, workspaceId));
    14Added: if (!row) throw new Error(`workspace ${workspaceId} has no plan`);
    15Added: return row;
    16Added: }
  • src/billing/usage.ts

    New+24
    @@ 0,1
    1Added: import { and, eq, sql } from "drizzle-orm";
    2Added: import { db } from "../db";
    3Added: import { usageCounters } from "../../db/schema";
    4Added:
    5Added: /** One atomic upsert per call; concurrent requests never lose an increment. */
    6Added: export async function recordCall(workspaceId: string, cycleStart: Date): Promise<number> {
    7Added: const [row] = await db
    8Added: .insert(usageCounters)
    9Added: .values({ workspaceId, cycleStart, calls: 1 })
    10Added: .onConflictDoUpdate({
    11Added: target: [usageCounters.workspaceId, usageCounters.cycleStart],
    12Added: set: { calls: sql`${usageCounters.calls} + 1`, updatedAt: new Date() },
    13Added: })
    14Added: .returning({ calls: usageCounters.calls });
    15Added: return row?.calls ?? 0;
    16Added: }
    17Added:
    18Added: export async function getUsage(workspaceId: string, cycleStart: Date): Promise<number> {
    19Added: const [row] = await db
    20Added: .select({ calls: usageCounters.calls })
    21Added: .from(usageCounters)
    22Added: .where(and(eq(usageCounters.workspaceId, workspaceId), eq(usageCounters.cycleStart, cycleStart)));
    23Added: return row?.calls ?? 0;
    24Added: }
  • src/billing/overage.ts

    New+6
    @@ 0,1
    1Added: /** Overage is charged per started block of 1,000 calls past the included quota. */
    2Added: export function computeOverage(calls: number, includedCalls: number, overageCentsPer1k: number | null): { blocks: number; cents: number } {
    3Added: if (overageCentsPer1k === null || calls <= includedCalls) return { blocks: 0, cents: 0 };
    4Added: const blocks = Math.ceil((calls - includedCalls) / 1000);
    5Added: return { blocks, cents: blocks * overageCentsPer1k };
    6Added: }
  • src/billing/close-cycle.ts

    New+24
    @@ 0,1
    1Added: import { cycleBounds } from "./cycle";
    2Added: import { addInvoiceLineItem } from "./invoice";
    3Added: import { computeOverage } from "./overage";
    4Added: import { getQuota } from "./quota";
    5Added: import { getUsage } from "./usage";
    6Added: import { logger } from "../logger";
    7Added:
    8Added: /**
    9Added: * Runs once when a workspace's cycle ends. Overage is settled here, on the
    10Added: * closed cycle's final count, never while calls are still coming in.
    11Added: */
    12Added: export async function closeCycle(workspace: { id: string; billingAnchor: Date; stripeCustomerId: string }, cycleStart: Date) {
    13Added: const [calls, quota] = await Promise.all([getUsage(workspace.id, cycleStart), getQuota(workspace.id)]);
    14Added: const overage = computeOverage(calls, quota.includedCalls, quota.overageCentsPer1k);
    15Added: if (overage.cents > 0) {
    16Added: await addInvoiceLineItem(workspace.stripeCustomerId, {
    17Added: description: `API overage: ${overage.blocks.toLocaleString("en-US")} × 1,000 calls`,
    18Added: amountCents: overage.cents,
    19Added: idempotencyKey: `overage:${workspace.id}:${cycleStart.toISOString()}`,
    20Added: });
    21Added: }
    22Added: logger.info({ workspaceId: workspace.id, calls, overageCents: overage.cents }, "cycle closed");
    23Added: return { next: cycleBounds(workspace.billingAnchor, new Date(cycleStart.getTime() + 32 * 86_400_000)).start };
    24Added: }
  • src/billing/invoice.ts

    +13−2
    @@ 1,1
    1import { stripe } from "./stripe-client";
    2Removed: import { formatCents } from "../../web/src/lib/format";
    2Added: import { formatMoney } from "../../web/src/lib/format";
    @@ 18,18export async function invoiceSummary(customerId: string) {
    18 const upcoming = await stripe.invoices.retrieveUpcoming({ customer: customerId });
    19Removed: return { total: formatCents(upcoming.total), due: new Date(upcoming.period_end * 1000) };
    19Added: return { total: formatMoney(upcoming.total), due: new Date(upcoming.period_end * 1000) };
    20}
    21Added:
    22Added: /** Adds a separate line item to the customer's next invoice. Idempotent per key, so a retried job never bills twice. */
    23Added: export async function addInvoiceLineItem(
    24Added: customerId: string,
    25Added: item: { description: string; amountCents: number; idempotencyKey: string },
    26Added: ) {
    27Added: return stripe.invoiceItems.create(
    28Added: { customer: customerId, amount: item.amountCents, currency: "usd", description: item.description },
    29Added: { idempotencyKey: item.idempotencyKey },
    30Added: );
    31Added: }
  • src/billing/index.ts

    +5
    @@ 1,1
    1Added: export { closeCycle } from "./close-cycle";
    2Added: export { cycleBounds, cycleHasEnded } from "./cycle";
    3export { invoiceSummary } from "./invoice";
    4Added: export { computeOverage } from "./overage";
    5Added: export { getQuota } from "./quota";
    6Added: export { getUsage, recordCall } from "./usage";
  • src/api/middleware/meter.ts

    New+12
    @@ 0,1
    1Added: import { createMiddleware } from "hono/factory";
    2Added: import { cycleBounds, recordCall } from "../../billing";
    3Added: import type { AppEnv } from "../types";
    4Added:
    5Added: /** Counts every authenticated API call against the workspace, as it is served. */
    6Added: export const meter = createMiddleware<AppEnv>(async (c, next) => {
    7Added: await next();
    8Added: const workspace = c.get("workspace");
    9Added: if (!workspace || c.res.status >= 500) return;
    10Added: const { start } = cycleBounds(workspace.billingAnchor);
    11Added: c.executionCtx.waitUntil(recordCall(workspace.id, start));
    12Added: });
  • src/api/middleware/enforce-quota.ts

    New+16
    @@ 0,1
    1Added: import { createMiddleware } from "hono/factory";
    2Added: import { cycleBounds, getQuota, getUsage } from "../../billing";
    3Added: import type { AppEnv } from "../types";
    4Added:
    5Added: /** Plans without an overage rate are blocked once they reach their quota. */
    6Added: export const enforceQuota = createMiddleware<AppEnv>(async (c, next) => {
    7Added: const workspace = c.get("workspace");
    8Added: if (!workspace) return next();
    9Added: const quota = await getQuota(workspace.id);
    10Added: if (quota.overageCentsPer1k !== null) return next();
    11Added: const used = await getUsage(workspace.id, cycleBounds(workspace.billingAnchor).start);
    12Added: if (used >= quota.includedCalls) {
    13Added: return c.json({ error: "quota_exceeded", message: "This workspace has used its free calls for this cycle." }, 402);
    14Added: }
    15Added: return next();
    16Added: });
  • src/api/app.ts

    +5
    @@ 4,4import { cors } from "hono/cors";
    4import { authenticate } from "./middleware/authenticate";
    5Added: import { enforceQuota } from "./middleware/enforce-quota";
    6Added: import { meter } from "./middleware/meter";
    7import { requestId } from "./middleware/request-id";
    @@ 15,17export const app = new Hono<AppEnv>();
    17app.use("*", requestId());
    18app.use("/v1/*", authenticate);
    19Added: app.use("/v1/*", enforceQuota);
    20Added: app.use("/v1/*", meter);
    21
    22app.route("/v1/shipments", shipments);
    23app.route("/v1/rates", rates);
    24Added: app.route("/internal/usage", usage);
  • src/api/routes/usage.ts

    New+13
    @@ 0,1
    1Added: import { Hono } from "hono";
    2Added: import { cycleBounds, getQuota, getUsage } from "../../billing";
    3Added: import type { AppEnv } from "../types";
    4Added:
    5Added: export const usage = new Hono<AppEnv>();
    6Added:
    7Added: /** Calls used this cycle against the plan's quota, for the usage meter. */
    8Added: usage.get("/", async (c) => {
    9Added: const workspace = c.get("workspace");
    10Added: const { start, end } = cycleBounds(workspace.billingAnchor);
    11Added: const [used, quota] = await Promise.all([getUsage(workspace.id, start), getQuota(workspace.id)]);
    12Added: return c.json({ used, included: quota.includedCalls, resetsAt: end.toISOString(), overageCentsPer1k: quota.overageCentsPer1k });
    13Added: });
  • src/jobs/cycle-close.ts

    New+22
    @@ 0,1
    1Added: import { lte, sql } from "drizzle-orm";
    2Added: import { db } from "../db";
    3Added: import { usageCounters, workspaces } from "../../db/schema";
    4Added: import { closeCycle } from "../billing";
    5Added: import { eq } from "drizzle-orm";
    6Added:
    7Added: /**
    8Added: * Hourly. Finds counters whose cycle ended, settles overage, then deletes the
    9Added: * old counter so the new cycle starts from zero.
    10Added: */
    11Added: export async function closeEndedCycles(now = new Date()) {
    12Added: const ended = await db
    13Added: .select({ workspace: workspaces, cycleStart: usageCounters.cycleStart })
    14Added: .from(usageCounters)
    15Added: .innerJoin(workspaces, eq(workspaces.id, usageCounters.workspaceId))
    16Added: .where(lte(sql`${usageCounters.cycleStart} + interval '1 month'`, now));
    17Added: for (const row of ended) {
    18Added: await closeCycle(row.workspace, row.cycleStart);
    19Added: await db.delete(usageCounters).where(eq(usageCounters.workspaceId, row.workspace.id));
    20Added: }
    21Added: return ended.length;
    22Added: }
  • src/jobs/index.ts

    +2
    @@ 1,1
    1Added: import { closeEndedCycles } from "./cycle-close";
    2import { purgeExpiredLabels } from "./purge-labels";
    3
    4export const hourly = [
    5 purgeExpiredLabels,
    6Added: closeEndedCycles,
    7];
  • web/src/api/usage.ts

    New+7
    @@ 0,1
    1Added: import { apiGet } from "./client";
    2Added:
    3Added: export type UsageSummary = { used: number; included: number; resetsAt: string; overageCentsPer1k: number | null };
    4Added:
    5Added: export function fetchUsage(): Promise<UsageSummary> {
    6Added: return apiGet<UsageSummary>("/internal/usage");
    7Added: }
  • web/src/pages/billing/UsageMeter.tsx

    New+27
    @@ 0,1
    1Added: import { useQuery } from "@tanstack/react-query";
    2Added: import { fetchUsage } from "../../api/usage";
    3Added: import { formatMoney } from "../../lib/format";
    4Added:
    5Added: export function UsageMeter() {
    6Added: const { data } = useQuery({ queryKey: ["usage"], queryFn: fetchUsage, refetchInterval: 60_000 });
    7Added: if (!data) return <div className="meter meter--loading" />;
    8Added: const ratio = data.included === 0 ? 0 : data.used / data.included;
    9Added: const over = Math.max(0, data.used - data.included);
    10Added: return (
    11Added: <section className="meter" aria-label="API usage this cycle">
    12Added: <header>
    13Added: <h3>API calls</h3>
    14Added: <span>
    15Added: {data.used.toLocaleString()} of {data.included.toLocaleString()}
    16Added: </span>
    17Added: </header>
    18Added: <progress value={Math.min(ratio, 1)} max={1} />
    19Added: {over > 0 && data.overageCentsPer1k !== null ? (
    20Added: <p className="meter__note">
    21Added: {over.toLocaleString()} calls over quota, about {formatMoney(Math.ceil(over / 1000) * data.overageCentsPer1k)} on your next invoice
    22Added: </p>
    23Added: ) : null}
    24Added: <p className="meter__reset">Resets {new Date(data.resetsAt).toLocaleDateString()}</p>
    25Added: </section>
    26Added: );
    27Added: }
  • web/src/pages/billing/BillingPage.tsx

    +2
    @@ 2,2import { PageHeader } from "../../components/PageHeader";
    2import { InvoiceList } from "./InvoiceList";
    3import { PlanSummary } from "./PlanSummary";
    4Added: import { UsageMeter } from "./UsageMeter";
    @@ 11,12export function BillingPage() {
    12 <PageHeader title="Billing" />
    13 <PlanSummary />
    14Added: <UsageMeter />
    15 <InvoiceList />
  • web/src/pages/billing/billing.css

    +20
    @@ 40,40
    40.invoice-list td.num {
    41 text-align: right;
    42}
    43Added:
    44Added: .meter {
    45Added: display: grid;
    46Added: gap: 8px;
    47Added: padding: 16px 20px;
    48Added: border-radius: 10px;
    49Added: background: var(--surface);
    50Added: }
    51Added:
    52Added: .meter progress {
    53Added: width: 100%;
    54Added: height: 6px;
    55Added: accent-color: var(--accent);
    56Added: }
    57Added:
    58Added: .meter__note,
    59Added: .meter__reset {
    60Added: color: var(--muted);
    61Added: font-size: 13px;
    62Added: }
  • web/src/pages/billing/InvoiceList.tsx

    +2−2
    @@ 3,3import { useInvoices } from "../../api/invoices";
    3Removed: import { formatCents } from "../../lib/format";
    3Added: import { formatMoney } from "../../lib/format";
    @@ 24,24export function InvoiceList() {
    24 <td>{new Date(invoice.created * 1000).toLocaleDateString()}</td>
    25Removed: <td className="num">{formatCents(invoice.total)}</td>
    25Added: <td className="num">{formatMoney(invoice.total)}</td>
    26 <td>{invoice.status}</td>
  • web/src/pages/settings/PlanCard.tsx

    +2−2
    @@ 2,2import type { Plan } from "../../api/plans";
    2Removed: import { formatCents } from "../../lib/format";
    2Added: import { formatMoney } from "../../lib/format";
    @@ 15,15export function PlanCard({ plan, current }: { plan: Plan; current: boolean }) {
    15 <h3>{plan.name}</h3>
    16Removed: <p className="price">{formatCents(plan.monthlyPriceCents)}/mo</p>
    16Added: <p className="price">{formatMoney(plan.monthlyPriceCents)}/mo</p>
    17 {current ? <span className="badge">Current</span> : null}
  • web/src/lib/format.ts

    +1−1
    @@ 1,1
    1Removed: export function formatCents(cents: number, currency = "USD"): string {
    1Added: export function formatMoney(cents: number, currency = "USD"): string {
    2 return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(cents / 100);
    3}
  • tests/billing/overage.test.ts

    New+16
    @@ 0,1
    1Added: import { describe, expect, it } from "vitest";
    2Added: import { computeOverage } from "../../src/billing/overage";
    3Added:
    4Added: describe("computeOverage", () => {
    5Added: it("charges nothing within the quota", () => {
    6Added: expect(computeOverage(250_000, 250_000, 40)).toEqual({ blocks: 0, cents: 0 });
    7Added: });
    8Added:
    9Added: it("prices each started block of 1,000 calls at the plan rate", () => {
    10Added: expect(computeOverage(251_001, 250_000, 40)).toEqual({ blocks: 2, cents: 80 });
    11Added: });
    12Added:
    13Added: it("never bills overage on a plan without a rate", () => {
    14Added: expect(computeOverage(50_000, 10_000, null)).toEqual({ blocks: 0, cents: 0 });
    15Added: });
    16Added: });
  • tests/billing/close-cycle.test.ts

    New+25
    @@ 0,1
    1Added: import { beforeEach, describe, expect, it, vi } from "vitest";
    2Added: import { closeCycle } from "../../src/billing/close-cycle";
    3Added: import * as invoice from "../../src/billing/invoice";
    4Added: import { recordCall } from "../../src/billing/usage";
    5Added: import { createWorkspace, resetDb } from "../helpers";
    6Added:
    7Added: describe("closeCycle", () => {
    8Added: beforeEach(resetDb);
    9Added:
    10Added: it("adds overage to the invoice once, when the cycle ends", async () => {
    11Added: const spy = vi.spyOn(invoice, "addInvoiceLineItem").mockResolvedValue({} as never);
    12Added: const workspace = await createWorkspace({ plan: "growth" });
    13Added: const start = new Date("2026-10-01T00:00:00Z");
    14Added: for (let i = 0; i < 3; i++) await recordCall(workspace.id, start);
    15Added: expect(spy).not.toHaveBeenCalled();
    16Added: await closeCycle(workspace, start);
    17Added: expect(spy).toHaveBeenCalledTimes(1);
    18Added: });
    19Added:
    20Added: it("starts the next cycle from the anchor day", async () => {
    21Added: const workspace = await createWorkspace({ plan: "growth", billingAnchor: new Date("2026-01-14T00:00:00Z") });
    22Added: const { next } = await closeCycle(workspace, new Date("2026-10-14T00:00:00Z"));
    23Added: expect(next.toISOString()).toBe("2026-11-14T00:00:00.000Z");
    24Added: });
    25Added: });
  • tests/api/meter.test.ts

    New+18
    @@ 0,1
    1Added: import { describe, expect, it } from "vitest";
    2Added: import { app } from "../../src/api/app";
    3Added: import { getUsage, cycleBounds } from "../../src/billing";
    4Added: import { authHeaders, createWorkspace } from "../helpers";
    5Added:
    6Added: describe("meter", () => {
    7Added: it("counts each API call against the workspace", async () => {
    8Added: const workspace = await createWorkspace({ plan: "growth" });
    9Added: for (let i = 0; i < 5; i++) await app.request("/v1/rates", { headers: authHeaders(workspace) });
    10Added: expect(await getUsage(workspace.id, cycleBounds(workspace.billingAnchor).start)).toBe(5);
    11Added: });
    12Added:
    13Added: it("does not count calls that fail on our side", async () => {
    14Added: const workspace = await createWorkspace({ plan: "growth" });
    15Added: await app.request("/v1/rates?simulate=500", { headers: authHeaders(workspace) });
    16Added: expect(await getUsage(workspace.id, cycleBounds(workspace.billingAnchor).start)).toBe(0);
    17Added: });
    18Added: });
  • tests/api/enforce-quota.test.ts

    New+22
    @@ 0,1
    1Added: import { describe, expect, it } from "vitest";
    2Added: import { app } from "../../src/api/app";
    3Added: import { recordCall, cycleBounds } from "../../src/billing";
    4Added: import { authHeaders, createWorkspace } from "../helpers";
    5Added:
    6Added: describe("enforceQuota", () => {
    7Added: it("blocks a free workspace at its quota", async () => {
    8Added: const workspace = await createWorkspace({ plan: "free", includedCalls: 2 });
    9Added: const start = cycleBounds(workspace.billingAnchor).start;
    10Added: await recordCall(workspace.id, start);
    11Added: await recordCall(workspace.id, start);
    12Added: const res = await app.request("/v1/rates", { headers: authHeaders(workspace) });
    13Added: expect(res.status).toBe(402);
    14Added: });
    15Added:
    16Added: it("lets paid plans run past their quota", async () => {
    17Added: const workspace = await createWorkspace({ plan: "growth", includedCalls: 1 });
    18Added: await recordCall(workspace.id, cycleBounds(workspace.billingAnchor).start);
    19Added: const res = await app.request("/v1/rates", { headers: authHeaders(workspace) });
    20Added: expect(res.status).toBe(200);
    21Added: });
    22Added: });