Private packages

@repo/billing

Feature catalog, plans, usage metering, and guards for organization billing on Stripe.

@repo/billing holds the feature catalog, the plans, and the guards, including usage metering with per-plan quotas. The Stripe integration itself is the official Better Auth Stripe plugin, configured in @repo/auth next to the other auth plugins: subscriptions belong to organizations, upgrades run through Stripe Checkout, cancellation goes through the billing portal, and webhooks at /api/auth/stripe/webhook keep the subscription table authoritative.

Features

A feature is defined once, with its measurement logic attached. There are three kinds:

// Flag: presence in the plan is the entitlement.
const ai = defineFeature({ name: 'ai', label: 'AI assistant' });

// Counted: usage derived from rows that exist; deletes free capacity.
const members = defineFeature({
  name: 'members',
  label: 'members',
  window: null,
  measure: async (organizationId) => {
    /* members + pending invitations */
  },
});

// Metered: a counter in the usage table, maintained by consume().
const apiRequests = defineFeature({
  name: 'apiRequests',
  label: 'API requests',
  meter: { window: 'month' },
});

window: 'month' buckets usage by calendar month (UTC); null never resets. Counted features receive the window start in their measure function; metered features bucket their counter rows by period.

Plans

Plans compose one entitlement per feature, and the check is compile-time exhaustive: omitting a feature fails the build naming what is missing.

const free = definePlan({
  name: 'free',
  label: 'Free',
  pricePerSeat: 0,
  features: [members.limit(3), apiRequests.limit(1_000), ai.excluded()],
});

const paid = definePlan({
  name: 'paid',
  label: 'Paid',
  pricePerSeat: 10,
  features: [members.limit(25), apiRequests.limit(100_000), ai.included()],
});

export const plans = [free, paid] as const;

Flags offer included() and excluded(); measured features offer limit(n), unlimited(), and excluded(). The first plan is the default for organizations without an active subscription. The paid plan is seat-only: checkout bills one unit per member, and the plugin re-syncs the Stripe quantity whenever members join or leave.

The catalog is server-side (feature definitions close over database queries); UI code types against @repo/billing/types and receives plan and usage data through server functions.

Guards

Guards are methods on the feature objects. The database is ambient (the worker's D1 binding), the organization is always explicit:

import { ai, apiRequests, members, usageReport } from '@repo/billing/server';

await ai.can(organizationId); // boolean
await ai.require(organizationId); // throws BillingError
await members.require(organizationId); // counts pending invites too
await apiRequests.consume(organizationId, 3); // atomic check + record
const usage = await usageReport(organizationId); // [{ feature, used, cap, window }]

BillingError carries a code (feature-unavailable or limit-reached), the plan, and the feature; each layer maps it to its own error shape. The starter enforces checks at five layers out of the box:

  • Better Auth: membershipLimit and a beforeCreateInvitation hook cap members per plan (private/auth).
  • Worker: the AI agent WebSocket route returns 403 when the plan excludes the assistant (apps/web/src/server.ts).
  • Public API: the authenticated middleware consumes one API request per call and returns 429 QUOTA_EXCEEDED over the monthly cap; GET /api/v1/org/billing reports the usage report.
  • Server functions: getOrgBilling resolves the plan, catalog, and usage for route loaders (apps/web/src/organization/functions.ts).
  • UI: the billing card on /app/organization and the upgrade prompt on /chat.

In tests, setDatabase() points the guards at an in-memory sqlite database in place of the D1 binding.

Local development

Plan guards and gated UI need no Stripe credentials: they read the subscription and usage tables, and the local seed puts the seed organization on the paid plan. To run real checkout locally, use the Stripe CLI in sandbox mode: create a per-seat monthly price, run stripe listen --forward-to http://localhost:3000/api/auth/stripe/webhook, and put STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET (printed by stripe listen), and STRIPE_PRICE_PAID in apps/web/.dev.vars.

Configuration

NameKindPurpose
STRIPE_SECRET_KEYsecretStripe API key. Optional locally; without it checkout is disabled.
STRIPE_WEBHOOK_SECRETsecretSigning secret of the Stripe webhook endpoint.
STRIPE_PRICE_PAIDvar in wrangler.jsoncPrice id (per-seat, monthly) for the paid plan.

Production setup (price, webhook endpoint, secrets) is covered in the repository's DEPLOY.md.

On this page