Billing entitlements
Design plans, entitlements, and metered billing for a developer tool — plans as data, a single getEntitlements() resolver, per-account overrides, usage snapshots, and Stripe-as-ledger sync. Use when the user wants to add pricing tiers, gate features by plan, meter usage, pick a billing metric, or wire up Stripe subscriptions. Also use when they mention "free tier limits", "grandfathering", "overage", "seat pricing", or ask "what should we charge for", even if they never say "billing" or "entitlements".From its SKILL.md
npx -y skills add alexpate/devtool-skills --skill billing-entitlementsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
10.2 KB, as published. Nobody here has run it
Billing & Entitlements
Billing is where developer tools quietly lose years: plan logic smeared across the codebase, a metric customers can't predict, and a Stripe integration that is the entitlements system until the first webhook drops. The bar is Stripe-grade bookkeeping with PostHog-grade generosity. Get the architecture right once and every future pricing change is a data edit, not a migration.
Before you start
Check for .agents/devtool-context.md and read it if present (pricing model, metered unit, and stage change everything below). If absent, ask:
- What does one unit of value look like: an email sent, an API call, an active user, a GB stored?
- What plans exist or are planned, and is anyone already paying under terms you'd have to grandfather?
- Where does subscription state live today: Stripe only, your DB, or both?
The design, in order of consequence
Positions below are defaults; deviate only with a reason.
1. Plans are data, not code
The failure mode has a name, plan constants: if (plan === 'pro') scattered through the app. Every pricing change becomes a deploy, every grandfathered customer becomes a special case in code, and within a year nobody can answer "what does the Pro plan actually include?" without grepping.
Instead, plans live in a plans table (or a versioned config file loaded into one). Shipping a new plan, changing a limit, or grandfathering old customers on old terms is an INSERT, not a release:
create table plans (
id text primary key, -- 'free', 'pro-2025-01', 'pro-2026-08'
display_name text not null,
limits jsonb not null, -- {"emails_per_month": 50000, "team_seats": 10, "retention_days": 90}
features jsonb not null, -- {"sso": false, "remove_branding": true, "audit_log": false}
created_at timestamptz not null default now(),
retired_at timestamptz -- retired plans stay forever; existing accounts keep them
);
create table account_overrides (
account_id text not null references accounts(id),
key text not null, -- 'emails_per_month', 'sso', ...
value jsonb not null,
reason text not null, -- 'sales exception #4123', 'beta: audit-log', 'grandfathered 2025 launch pricing'
expires_at timestamptz, -- null = permanent
primary key (account_id, key)
);
Version plan IDs (pro-2026-08) rather than mutating rows in place: repricing means retiring the old row and pointing new signups at the new one. Old customers never notice, which is the entire point.
2. One resolver: getEntitlements(accountId)
The whole app asks one question through one gate. Nothing else (no route handler, no cron job, no dashboard component) reads the plans table or Stripe directly:
interface Entitlements {
planId: string;
limits: {
emailsPerMonth: number;
teamSeats: number;
retentionDays: number;
};
features: {
sso: boolean;
removeBranding: boolean;
auditLog: boolean;
};
trial: { endsAt: Date } | null;
}
// The ONE gate. Resolves plan + overrides + trial state, in that order.
async function getEntitlements(accountId: string): Promise<Entitlements>;
Resolution order is plan → overrides → trial grants, later layers winning per key. Cache it aggressively (per-request memo plus a short TTL; entitlements change on subscription events, not per request) and invalidate on the Stripe webhook (section 7).
The overrides layer is why the resolver must exist. The first sales deal that needs 2× the Pro limit, the first beta customer who gets a feature early, the first grandfathered cohort: each is one row in account_overrides with a reason, instead of a hardcoded account-ID check someone finds three years later. If you skip overrides now, you'll bolt them on during a sales call.
3. Usage snapshots for metered billing
Never bill from live counts. Live counters drift (retries, backfills, deleted rows), and when a customer disputes an invoice, "the counter said so" is not an audit trail. The pipeline is:
- Append-only usage records: one row per billable event, immutable, sourced from the same canonical event taxonomy your analytics use (the
event-taxonomyskill governs the names; billing must not invent a parallel vocabulary). - Periodic snapshots, hourly or daily, aggregated into a
usage_snapshotstable:(account_id, metric, period_start, period_end, quantity, computed_at). - Bill from snapshots. Invoices, dashboard usage graphs, and limit enforcement all read snapshots. A dispute replays the underlying records; the numbers reconcile because they came from the same rows.
Enforcement can afford a near-real-time counter for the "you're at 98%" email, but the invoice never comes from it.
4. Choose one canonical metric
Pick a single usage metric customers can predict before integrating and see plainly in the dashboard. Resend bills emails sent; Clerk bills monthly active users; Vercel rolls infrastructure into legible usage buckets. The test, in order:
- Value-correlated: more of it means the customer is getting more value.
- Predictable pre-integration: a developer reading the pricing page can estimate their bill before writing code. Billing on something they can't estimate (internal compute units, "operations" with no definition) is how you end up famous on Hacker News for the wrong reason.
- Hard to game, measurable at ingestion: counted where the request enters, not reconstructed later.
One metric. A second metric doubles the pricing page's cognitive load and halves trust.
5. Hybrid volume tiers, not pure seats
Pure seat pricing misfits developer tools: the integration is written once by two engineers and then serves millions of requests, so seats measure neither value nor cost. The default model is hybrid:
- Base plan buys the qualitative tier: support level, feature set, team size ceiling.
- Included volume of the canonical metric (Free: 3k emails/mo, Pro: 50k…).
- Overage or tier steps past the included volume: per-unit overage for continuous metrics, tier jumps for lumpy ones.
Seats can still exist as a limit on collaboration plans; they just shouldn't be the axis the bill scales on.
6. What never to gate
Never gate core developer primitives: API access, webhooks, SDKs, docs, and test mode. These are how developers evaluate and integrate; gate them and you kill adoption before revenue exists. A free-tier developer who can't receive webhooks builds a polling hack, ships a worse integration, and churns; test mode behind a paywall means nobody ever reaches production. (See api-keys: test mode is free, forever, unlimited.)
Natural production gates, the things that scale with real usage and real organizations:
| Gate | Why it works |
|---|---|
| Volume of the canonical metric | The bill tracks value delivered |
| Team size | Orgs pay; individuals evaluate |
| Retention windows | Storage is a real cost; 7 vs 90 days is a felt difference |
| SSO/SAML | The enterprise line; companies that need it have budget |
| Remove branding | Free tier markets you; paying removes the badge |
| SLA / support tier | Sold to the org, not the integrator |
PostHog and Supabase are the calibration points: free tiers generous enough that a real product can launch on them, with the paywall sitting at "this is now a business" rather than "this now works."
7. Stripe is the ledger; your DB is the source of truth
Stripe (or Paddle, or Lago) owns money movement: subscriptions, invoices, payment state, tax. It does not own entitlements. The contract:
- Sync subscription state via webhooks (
customer.subscription.updated,invoice.paid,invoice.payment_failed) into your ownsubscriptionstable, mapping Stripe price IDs → your plan IDs. You are now a webhook consumer: verify signatures and dedupe on event ID, exactly aswebhook-deliverytells your customers to. getEntitlementsresolves locally, from your tables. Never call the billing provider on the request path: that makes every API request's latency and availability depend on someone else's API, and Stripe's rate limits were not sized for your traffic.- Handle the gaps deliberately: payment failure starts a grace period (a dated override, not an instant lockout), and a missed webhook is caught by a daily reconciliation job that diffs Stripe subscriptions against your table.
Artifacts to produce
- Schema migration for
plans,account_overrides,subscriptions,usage_records,usage_snapshotsin the user's stack. getEntitlementsimplementation with resolution order, caching, and invalidation, plus a grep of the codebase for existingplan ===/tier ===constants to migrate through it.- Plan matrix document: each plan's limits/features as data, with the never-gate list checked against it.
- Metric decision memo: the chosen canonical metric, the alternatives rejected, and the pre-integration estimation story.
- Stripe sync design: webhook handlers, price-ID → plan-ID map, grace-period policy, reconciliation job.
Related Skills
- event-taxonomy: usage metering consumes the canonical event names; don't invent a billing-only vocabulary.
- webhook-delivery: never plan-gate outbound webhooks, and the Stripe sync makes you a webhook consumer yourself.
- api-keys: test mode is a core primitive, free forever and never metered.
- oss-to-hosted: the honest OSS-to-cloud upsell is an entitlements decision, not a feature-removal decision.
- devtool-context: create the context file first if it doesn't exist; the pricing model and metered unit live there.