Skip to content
Review Link

Reference

Design system

Every token, primitive, and state Review Link is built from. A new demo starts here and adds one feature on top.

Theme

One accent color re-skins everything. The engine walks lightness in OKLCH until every text pair passes WCAG AA, in both modes.

Brand

Appearance

Pick Custom to try any color. Ship one with pnpm theme.

Saved

Body text sits on the canvas. Links use the accent.

Type

Schibsted Grotesk for words, IBM Plex Mono for data only. Use the utilities; never a raw size.

display

Small apps

heading

Section heading

lede

A lede leads into the content below it.

title

Card title

section

Group label

ui

Default interface text, 14px.

ui-sm

Captions and help text.

micro

Column header

num

1,284 · 23 ms · 0x3f2a

Components

Base UI underneath, restyled to the tokens. Every control has hover, focus, pressed, disabled, and loading.

Buttons

Fields

We only use it to reply.

Choices

Overlays

Status

LiveNeeds reviewWaiting on youFailedDraft

Data

1,284

Events this week

23 ms

Median round trip

Storage used72%

Tabs

Routep50p95
/18 ms41 ms
write24 ms66 ms
room · join31 ms80 ms

Motion · useFlip

  1. Atlas
  2. Bolt
  3. Cosmo
  4. Dash
  5. Echo
  6. Flux

Messages

Demo data older than a day is cleared every hour.
Saved. Everyone with this page open sees it.
You are offline. Changes wait and send when you reconnect.

States

Each feature rendered in every state it can be in, with fixture data and no network. None is an afterthought.

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

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: });