Billing & Subscriptions Skill
Multi-provider billing system with Gateway Factory pattern. Supports Stripe, Polar.sh, and future providers through a unified interface.
Architecture Overview
BILLING ARCHITECTURE:
Configuration Layer:
contents/themes/{theme}/config/billing.config.ts
├── provider: 'stripe' | 'polar' # (paddle, lemonsqueezy, mercadopago: type defined, not yet implemented)
├── currency: 'usd' | 'eur' | ...
├── defaultPlan: 'free'
├── features: { featureSlug: FeatureDefinition }
├── limits: { limitSlug: LimitDefinition }
├── plans: PlanDefinition[]
└── actionMappings: ActionMappings
Core Library:
core/lib/billing/
├── config-types.ts # BillingConfig, PlanDefinition interfaces
├── types.ts # PlanType, SubscriptionStatus, PaymentProvider
├── schema.ts # Zod validation schemas
├── gateways/
│ ├── types.ts # Provider-agnostic result types
│ ├── interface.ts # BillingGateway interface (contract)
│ ├── factory.ts # getBillingGateway() factory
│ ├── stripe.ts # StripeGateway implements BillingGateway
│ └── polar.ts # PolarGateway implements BillingGateway
├── queries.ts # Database queries
├── enforcement.ts # Limit/feature enforcement
├── helpers.ts # Utility functions
└── jobs.ts # Background jobs
Services Layer:
core/lib/services/
├── subscription.service.ts # Subscription CRUD (uses factory)
├── plan.service.ts # Plan management (getPriceId generic)
└── usage.service.ts # Usage tracking
API Endpoints:
app/api/v1/billing/
├── checkout/route.ts # Create checkout session (via factory)
├── portal/route.ts # Customer portal access (via factory)
├── plans/route.ts # List available plans
├── cancel/route.ts # Cancel subscription (via factory)
├── change-plan/route.ts # Upgrade/downgrade
├── check-action/route.ts # Permission check
├── webhooks/stripe/route.ts # Stripe webhooks (provider-specific)
└── webhooks/polar/route.ts # Polar webhooks (provider-specific)
When to Use This Skill
- Implementing billing features
- Working with subscription management
- Configuring plans and features
- Adding a new payment provider
- Setting up webhooks (Stripe or Polar)
- Implementing usage limits
- Testing billing flows
Gateway Factory Pattern
Key Principle: Consumers never import from a specific provider. They use getBillingGateway() which returns the correct implementation based on billing.config.ts.
BillingGateway Interface
// core/lib/billing/gateways/interface.ts
export interface BillingGateway {
// Checkout
createCheckoutSession(params: CreateCheckoutParams): Promise<CheckoutSessionResult>
createPortalSession(params: CreatePortalParams): Promise<PortalSessionResult>
// Customers
getCustomer(customerId: string): Promise<CustomerResult>
createCustomer(params: CreateCustomerParams): Promise<CustomerResult>
// Subscription Management
updateSubscriptionPlan(params: UpdateSubscriptionParams): Promise<SubscriptionResult>
cancelSubscriptionAtPeriodEnd(subscriptionId: string): Promise<SubscriptionResult>
cancelSubscriptionImmediately(subscriptionId: string): Promise<SubscriptionResult>
reactivateSubscription(subscriptionId: string): Promise<SubscriptionResult>
// Webhooks (Stripe passes string signature, Polar passes headers Record)
verifyWebhookSignature(payload: string | Buffer, signatureOrHeaders: string | Record<string, string>): WebhookEventResult
// Dashboard & Metadata
getProviderName(): string
getSubscriptionDashboardUrl(externalSubscriptionId: string | null | undefined): string | null
getResourceHintDomains(): { preconnect: string[]; dnsPrefetch: string[] }
}
Provider-Agnostic Types
// core/lib/billing/gateways/types.ts
// Return types - NO Stripe.* or Polar.* imports
export interface CheckoutSessionResult {
id: string
url: string | null
}
export interface PortalSessionResult {
url: string
}
export interface SubscriptionResult {
id: string
status: string
cancelAtPeriodEnd: boolean
}
export interface CustomerResult {
id: string
email: string | null
name: string | null
}
export interface WebhookEventResult {
id: string
type: string
data: Record<string, unknown>
}
Factory Usage
// CORRECT: Use factory everywhere
import { getBillingGateway } from '@nextsparkjs/core/lib/billing/gateways/factory'
const session = await getBillingGateway().createCheckoutSession(params)
const portal = await getBillingGateway().createPortalSession(params)
await getBillingGateway().cancelSubscriptionAtPeriodEnd(subId)
// Provider metadata
const name = getBillingGateway().getProviderName() // "Stripe" or "Polar"
const url = getBillingGateway().getSubscriptionDashboardUrl(id) // Dashboard URL or null
// Resource hints (used in layout.tsx automatically)
import { getBillingResourceHints } from '@nextsparkjs/core/lib/billing/gateways/factory'
const { preconnect, dnsPrefetch } = getBillingResourceHints()
// WRONG: Import from specific provider
import { createCheckoutSession } from '.../gateways/stripe' // DEPRECATED
Plan Price IDs
Plans use providerPriceIds for price configuration (works with any provider):
// PlanDefinition in config-types.ts
{
slug: 'pro',
providerPriceIds: {
monthly: 'price_xxx_monthly',
yearly: 'price_xxx_yearly',
},
}
// PlanService.getPriceId() reads from providerPriceIds
const priceId = PlanService.getPriceId('pro', 'monthly')
Provider: Stripe
SDK & Packages
# Required
pnpm add stripe
Environment Variables
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
StripeGateway Class
// core/lib/billing/gateways/stripe.ts
export class StripeGateway implements BillingGateway {
async createCheckoutSession(params) {
const session = await getStripe().checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: params.successUrl,
cancel_url: params.cancelUrl,
metadata: { teamId, planSlug, billingPeriod },
})
return { id: session.id, url: session.url } // Provider-agnostic
}
async createPortalSession(params) {
const session = await getStripe().billingPortal.sessions.create({
customer: params.customerId,
return_url: params.returnUrl,
})
return { url: session.url }
}
verifyWebhookSignature(payload, signature) {
const event = getStripe().webhooks.constructEvent(payload, signature, secret)
return { id: event.id, type: event.type, data: event.data }
}
// ... other methods
}
Stripe Webhook Events
| Event | Action |
|---|---|
checkout.session.completed |
Create/update subscription with Stripe IDs |
invoice.paid |
Update period dates, sync invoice |
invoice.payment_failed |
Mark subscription as past_due |
customer.subscription.updated |
Sync status and plan changes |
customer.subscription.deleted |
Mark subscription as canceled |
Stripe Webhook Route (Provider-Specific)
// app/api/v1/billing/webhooks/stripe/route.ts
// NOTE: Webhook routes stay provider-specific by design.
// They need raw provider types for proper type narrowing.
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(request: NextRequest) {
const payload = await request.text()
const signature = request.headers.get('stripe-signature')!
const event = stripe.webhooks.constructEvent(
payload, signature, process.env.STRIPE_WEBHOOK_SECRET!
)
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object as Stripe.Checkout.Session
// Handle with full Stripe types
break
}
}
Provider: Polar.sh
SDK & Packages
# Core SDK
pnpm add @polar-sh/sdk
# Next.js adapter (optional - pre-built route handlers)
pnpm add @polar-sh/nextjs
# Better Auth plugin (optional - auth-integrated billing)
pnpm add @polar-sh/better-auth
Environment Variables
POLAR_ACCESS_TOKEN=pat_... # Organization Access Token
POLAR_WEBHOOK_SECRET=whsec_...
POLAR_SERVER=sandbox # 'sandbox' or 'production'
Integration Path A: PolarGateway (via our factory)
This is the primary integration path. Consistent with Stripe, goes through getBillingGateway().
// core/lib/billing/gateways/polar.ts
import { Polar } from '@polar-sh/sdk'
import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks'
import type { BillingGateway } from './interface'
let polarInstance: Polar | null = null
function getPolar(): Polar {
if (!polarInstance) {
polarInstance = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
server: (process.env.POLAR_SERVER as 'sandbox' | 'production') || 'production',
})
}
return polarInstance
}
export class PolarGateway implements BillingGateway {
async createCheckoutSession(params) {
const priceId = getPriceIdFromRegistry(params.planSlug, params.billingPeriod)
const result = await getPolar().checkouts.create({
productPriceId: priceId, // Polar uses productPriceId
successUrl: params.successUrl,
returnUrl: params.cancelUrl, // Polar calls it returnUrl
customerEmail: params.customerEmail,
metadata: { teamId: params.teamId, planSlug: params.planSlug },
})
return { id: result.id, url: result.url }
}
async createPortalSession(params) {
const result = await getPolar().customerSessions.create({
customerId: params.customerId,
returnUrl: params.returnUrl,
})
return { url: result.customerPortalUrl } // Different field name
}
async getCustomer(customerId) {
const customer = await getPolar().customers.get({ id: customerId })
return { id: customer.id, email: customer.email, name: customer.name }
}
async createCustomer(params) {
const customer = await getPolar().customers.create({
email: params.email,
name: params.name,
externalId: params.metadata?.userId, // Map to app userId
})
return { id: customer.id, email: customer.email, name: customer.name }
}
async updateSubscriptionPlan(params) {
const result = await getPolar().subscriptions.update({
id: params.subscriptionId,
subscriptionUpdate: { productPriceId: params.newPriceId },
})
return {
id: result.id,
status: result.status,
cancelAtPeriodEnd: result.cancelAtPeriodEnd,
}
}
async cancelSubscriptionAtPeriodEnd(subscriptionId) {
// Polar: customer portal cancel = cancel at period end
const result = await getPolar().subscriptions.update({
id: subscriptionId,
subscriptionUpdate: { cancelAtPeriodEnd: true },
})
return { id: result.id, status: result.status, cancelAtPeriodEnd: true }
}
async cancelSubscriptionImmediately(subscriptionId) {
// Polar: revoke = immediate cancel
const result = await getPolar().subscriptions.revoke({ id: subscriptionId })
return { id: result.id, status: 'canceled', cancelAtPeriodEnd: false }
}
async reactivateSubscription(subscriptionId) {
const result = await getPolar().subscriptions.update({
id: subscriptionId,
subscriptionUpdate: { cancelAtPeriodEnd: false },
})
return { id: result.id, status: result.status, cancelAtPeriodEnd: false }
}
verifyWebhookSignature(payload, signature) {
// Polar validates against headers object, not a single signature string
// The signature param here carries the headers as JSON for compatibility
const headers = JSON.parse(signature as string)
const event = validateEvent(payload, headers, process.env.POLAR_WEBHOOK_SECRET!)
return { id: event.data.id, type: event.type, data: event.data as any }
}
}
Polar Webhook Events
| Event | Action |
|---|---|
checkout.created |
Checkout started |
checkout.updated |
Checkout updated |
order.created |
Order placed |
order.paid |
Payment confirmed - create/update subscription |
order.refunded |
Refund processed |
subscription.created |
New subscription |
subscription.updated |
Subscription changed |
subscription.active |
Subscription activated |
subscription.canceled |
Subscription canceled |
subscription.revoked |
Subscription immediately revoked |
subscription.uncanceled |
Cancellation reversed |
customer.created |
New customer |
customer.state_changed |
Customer state updated |
Polar Webhook Route (Provider-Specific)
// app/api/v1/billing/webhooks/polar/route.ts
import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks'
export async function POST(request: NextRequest) {
const payload = await request.text()
const headers = Object.fromEntries(request.headers.entries())
try {
const event = validateEvent(payload, headers, process.env.POLAR_WEBHOOK_SECRET!)
switch (event.type) {
case 'order.paid':
await handleOrderPaid(event.data)
break
case 'subscription.active':
await handleSubscriptionActive(event.data)
break
case 'subscription.canceled':
await handleSubscriptionCanceled(event.data)
break
case 'subscription.revoked':
await handleSubscriptionRevoked(event.data)
break
case 'subscription.uncanceled':
await handleSubscriptionUncanceled(event.data)
break
}
return NextResponse.json({ received: true })
} catch (error) {
if (error instanceof WebhookVerificationError) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 })
}
throw error
}
}
Polar Next.js Adapter (Shortcut)
Pre-built route handlers for simple setups:
// app/api/polar/checkout/route.ts
import { Checkout } from '@polar-sh/nextjs'
export const GET = Checkout({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
successUrl: process.env.NEXT_PUBLIC_APP_URL + '/dashboard/settings/billing?success=true',
server: 'sandbox',
})
// app/api/polar/portal/route.ts
import { CustomerPortal } from '@polar-sh/nextjs'
export const GET = CustomerPortal({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
getCustomerId: (req) => getCustomerIdFromSession(req),
returnUrl: process.env.NEXT_PUBLIC_APP_URL + '/dashboard/settings/billing',
})
// app/api/polar/webhooks/route.ts
import { Webhooks } from '@polar-sh/nextjs'
export const POST = Webhooks({
webhookSecret: process.env.POLAR_WEBHOOK_SECRET!,
onOrderPaid: async (payload) => { /* sync subscription */ },
onSubscriptionActive: async (payload) => { /* activate */ },
onSubscriptionCanceled: async (payload) => { /* cancel */ },
onSubscriptionRevoked: async (payload) => { /* revoke */ },
})
Integration Path B: Better Auth Plugin
For projects using Better Auth, Polar provides a first-party plugin that auto-creates customers on signup and provides client-side billing methods.
// lib/auth.ts (server)
import { betterAuth } from 'better-auth'
import { polar, checkout, portal, usage, webhooks } from '@polar-sh/better-auth'
import { Polar } from '@polar-sh/sdk'
const polarClient = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
server: 'sandbox',
})
export const auth = betterAuth({
plugins: [
polar({
client: polarClient,
createCustomerOnSignUp: true, // Auto-create Polar customer
use: [
checkout({
products: [
{ productId: 'prod_xxx', slug: 'pro' },
{ productId: 'prod_yyy', slug: 'enterprise' },
],
successUrl: '/dashboard/settings/billing?success=true',
authenticatedUsersOnly: true,
}),
portal(),
usage(),
webhooks({
secret: process.env.POLAR_WEBHOOK_SECRET!,
onOrderPaid: (payload) => { /* handle */ },
onCustomerStateChanged: (payload) => { /* handle */ },
}),
],
}),
],
})
// lib/auth-client.ts (client)
import { createAuthClient } from 'better-auth/react'
import { polarClient } from '@polar-sh/better-auth/client'
export const authClient = createAuthClient({
plugins: [polarClient()],
})
// Usage in components:
await authClient.checkout({ products: ['prod_xxx'], slug: 'pro' })
await authClient.customer.portal()
const { data: state } = await authClient.customer.state()
const { data: subs } = await authClient.customer.subscriptions.list({ query: { active: true } })
// Usage-based billing
await authClient.usage.ingest({ event: 'api-call', metadata: { endpoint: '/api/v1/products' } })
const { data: meters } = await authClient.usage.meters.list()
When to Use Path A vs Path B
| Path A: PolarGateway | Path B: Better Auth Plugin | |
|---|---|---|
| Use when | Multi-provider support needed | Polar-only project |
| Architecture | Goes through gateway factory | Bypasses factory, auth-integrated |
| Customer creation | Manual via gateway | Automatic on signup |
| Client-side API | Custom hooks + API routes | authClient.checkout() etc. |
| Usage metering | UsageService (our own) | authClient.usage.ingest() |
| Webhook handling | Custom route | Better Auth plugin handles it |
| Recommended for | NextSpark core (multi-provider) | Theme-specific Polar-only apps |
For NextSpark core: Use Path A (PolarGateway) to maintain provider abstraction. Optionally layer Path B for customer auto-creation on signup.
Provider Comparison: API Mapping
| Operation | Stripe | Polar |
|---|---|---|
| SDK init | new Stripe(secretKey) |
new Polar({ accessToken }) |
| Checkout | stripe.checkout.sessions.create() |
polar.checkouts.create() |
| Portal | stripe.billingPortal.sessions.create() |
polar.customerSessions.create() |
| Get customer | stripe.customers.retrieve(id) |
polar.customers.get({ id }) |
| Create customer | stripe.customers.create(params) |
polar.customers.create({ externalId }) |
| Update sub | stripe.subscriptions.update() (needs item ID) |
polar.subscriptions.update({ productPriceId }) |
| Soft cancel | subscriptions.update({ cancel_at_period_end }) |
Customer portal cancel / update |
| Hard cancel | subscriptions.cancel() |
polar.subscriptions.revoke() |
| Verify webhook | constructEvent(body, signature, secret) |
validateEvent(body, headers, secret) |
| Env key | STRIPE_SECRET_KEY |
POLAR_ACCESS_TOKEN |
| Env webhook | STRIPE_WEBHOOK_SECRET |
POLAR_WEBHOOK_SECRET |
| Price ref | priceId (string) |
productPriceId (string) |
Key Differences
- Webhook verification: Stripe validates against a single
stripe-signatureheader. Polar validates against ALL request headers. - Cancel semantics: Stripe uses
cancel_at_period_endflag +cancel(). Polar usesrevoke()for immediate, update for period-end. - Customer identity: Polar supports
externalIdto map customers to your app's userId. Stripe usesmetadata. - Subscription update: Stripe requires finding the subscription item ID first. Polar just takes the new
productPriceIddirectly. - Better Auth: Polar has a first-party Better Auth plugin. Stripe does not.
Three-Layer Permission Model
The billing system uses a three-layer model (provider-agnostic):
RESULT = Permission (RBAC) AND Feature (Plan) AND Quota (Limits)
Layer 1: RBAC Permissions
actionMappings: {
permissions: {
'team.billing.manage': 'team.billing.manage',
'team.settings.edit': 'team.settings.edit',
}
}
Layer 2: Plan Features
features: {
advanced_analytics: { name: 'billing.features.advanced_analytics' },
api_access: { name: 'billing.features.api_access' },
}
actionMappings: {
features: {
'analytics.view_advanced': 'advanced_analytics',
'api.generate_key': 'api_access',
}
}
Layer 3: Usage Limits (Quotas)
limits: {
team_members: { name: 'billing.limits.team_members', unit: 'count', resetPeriod: 'never' },
api_calls: { name: 'billing.limits.api_calls', unit: 'calls', resetPeriod: 'monthly' },
}
actionMappings: {
limits: {
'team.members.invite': 'team_members',
'api.call': 'api_calls',
}
}
Plans Configuration
Plan Definition Structure
// contents/themes/default/config/billing.config.ts
import type { BillingConfig } from '@/core/lib/billing/config-types'
export const billingConfig: BillingConfig = {
provider: 'stripe', // or 'polar'
currency: 'usd',
defaultPlan: 'free',
plans: [
{
slug: 'free',
name: 'billing.plans.free.name',
type: 'free',
visibility: 'public',
price: { monthly: 0, yearly: 0 },
features: ['basic_analytics'],
limits: { team_members: 3, tasks: 50, api_calls: 1000 },
// No price IDs for free plan
},
{
slug: 'pro',
name: 'billing.plans.pro.name',
type: 'paid',
visibility: 'public',
price: { monthly: 2900, yearly: 29000 }, // in cents
trialDays: 14,
features: ['basic_analytics', 'advanced_analytics', 'api_access'],
limits: { team_members: 15, tasks: 1000, api_calls: 100000 },
// Price IDs from your payment provider dashboard
providerPriceIds: {
monthly: 'price_pro_monthly',
yearly: 'price_pro_yearly',
},
},
{
slug: 'enterprise',
name: 'billing.plans.enterprise.name',
type: 'enterprise',
visibility: 'hidden',
features: ['*'],
limits: { team_members: -1, tasks: -1, api_calls: -1 },
},
],
}
Team-Based Subscriptions
Subscriptions are tied to teams, not users (provider-agnostic DB schema):
{
id: string
teamId: string
planId: string
status: SubscriptionStatus // 'active' | 'trialing' | 'past_due' | 'canceled' | 'expired' | 'paused'
billingInterval: 'monthly' | 'yearly'
paymentProvider: PaymentProvider | null // 'stripe' | 'polar' | 'paddle' | 'lemonsqueezy'
externalSubscriptionId?: string // Provider subscription ID
externalCustomerId?: string // Provider customer ID
currentPeriodStart: Date
currentPeriodEnd: Date
cancelAtPeriodEnd: boolean
}
Checkout Flow (Provider-Agnostic)
// app/api/v1/billing/checkout/route.ts
import { getBillingGateway } from '@nextsparkjs/core/lib/billing/gateways/factory'
export async function POST(request: NextRequest) {
// 1. Authenticate + validate + check permissions
// ...
// 2. Create checkout session via gateway (works for any provider)
const session = await getBillingGateway().createCheckoutSession({
teamId,
planSlug,
billingPeriod,
successUrl: `${appUrl}/dashboard/settings/billing?success=true`,
cancelUrl: `${appUrl}/dashboard/settings/billing?canceled=true`,
customerEmail: user.email,
customerId: existingCustomerId,
})
return Response.json({
success: true,
data: { url: session.url, sessionId: session.id }
})
}
Customer Portal (Provider-Agnostic)
// app/api/v1/billing/portal/route.ts
import { getBillingGateway } from '@nextsparkjs/core/lib/billing/gateways/factory'
const session = await getBillingGateway().createPortalSession({
customerId: subscription.externalCustomerId,
returnUrl: `${appUrl}/dashboard/settings/billing`,
})
return Response.json({ success: true, data: { url: session.url } })
Webhook Security
// CRITICAL: Always verify webhook signatures regardless of provider
// Stripe: single signature header
const event = getStripeInstance().webhooks.constructEvent(payload, signature, secret)
// Polar: validates against all headers
import { validateEvent } from '@polar-sh/sdk/webhooks'
const event = validateEvent(payload, headers, secret)
// NOTE: Webhooks bypass RLS (no user context)
// Use direct query() calls, not queryWithRLS()
Database Schema
Why Plans/Subscriptions Use Inline JSONB
| Entity Pattern | Storage | Why |
|---|---|---|
| Regular entities | Separate {entity}_metas table |
Dynamic, user-extensible |
plans |
Inline features JSONB, limits JSONB |
Fixed structure, read-heavy |
subscriptions |
No metas needed | All data is structured |
Migrations
-- Plans table
CREATE TABLE plans (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
type plan_type NOT NULL DEFAULT 'free',
visibility plan_visibility NOT NULL DEFAULT 'public',
"priceMonthly" INTEGER DEFAULT 0,
"priceYearly" INTEGER DEFAULT 0,
"trialDays" INTEGER DEFAULT 0,
features JSONB DEFAULT '[]',
limits JSONB DEFAULT '{}',
-- Price IDs stored in providerPriceIds (via billing.config.ts)
-- Legacy: stripePriceIdMonthly/Yearly columns may exist in older migrations but are unused
"createdAt" TIMESTAMPTZ DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ DEFAULT NOW()
);
-- Subscriptions table (provider-agnostic)
CREATE TABLE subscriptions (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
"teamId" TEXT REFERENCES teams(id) ON DELETE CASCADE,
"planId" TEXT REFERENCES plans(id),
status subscription_status NOT NULL DEFAULT 'active',
"billingInterval" TEXT DEFAULT 'monthly',
"paymentProvider" TEXT, -- 'stripe' | 'polar' | etc.
"externalSubscriptionId" TEXT, -- Provider sub ID
"externalCustomerId" TEXT, -- Provider customer ID
"currentPeriodStart" TIMESTAMPTZ,
"currentPeriodEnd" TIMESTAMPTZ,
"cancelAtPeriodEnd" BOOLEAN DEFAULT false,
"createdAt" TIMESTAMPTZ DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ DEFAULT NOW()
);
Environment Variables
# Provider selection (in billing.config.ts, not env)
# provider: 'stripe' | 'polar'
# Stripe
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Polar
POLAR_ACCESS_TOKEN=pat_...
POLAR_WEBHOOK_SECRET=whsec_...
POLAR_SERVER=sandbox # 'sandbox' or 'production'
Anti-Patterns
// NEVER: Import from specific provider in consumers
import { createCheckoutSession } from '.../gateways/stripe' // REMOVED
// CORRECT: Use factory
import { getBillingGateway } from '.../gateways/factory'
await getBillingGateway().createCheckoutSession(params)
// NEVER: Use Stripe-specific types in consumer code
const session: Stripe.Checkout.Session = ...
// CORRECT: Use provider-agnostic types
const session: CheckoutSessionResult = ...
// NEVER: Use getStripePriceId (removed)
PlanService.getStripePriceId('pro', 'monthly')
// CORRECT: Use generic getPriceId
PlanService.getPriceId('pro', 'monthly')
// NEVER: Hardcode plan prices in frontend
const price = '$29.00'
// CORRECT: Use plan config
const price = formatCurrency(plan.price.monthly / 100)
// NEVER: Check plan features manually
if (plan.slug === 'pro' || plan.slug === 'business')
// CORRECT: Use feature checks
const hasFeature = membership.hasFeature('advanced_analytics')
// NEVER: Skip webhook signature verification
const event = JSON.parse(payload) // UNSAFE!
// CORRECT: Always verify signatures
// Stripe: constructEvent(payload, signature, secret)
// Polar: validateEvent(payload, headers, secret)
// NEVER: Store prices in dollars
price: { monthly: 29.00 }
// CORRECT: Store prices in cents
price: { monthly: 2900 }
// NEVER: Forget to handle -1 (unlimited)
if (current >= limit) return false
// CORRECT: Check for unlimited
if (limit === -1) return true
if (current >= limit) return false
Checklist
General (all providers)
- Provider selected in
billing.config.ts - Plans defined with price IDs (providerPriceIds)
- Features and limits defined
- Action mappings configured
- Team-based subscription created on team creation
- Checkout flow tested (monthly and yearly)
- Portal flow tested
- Webhook endpoint configured in provider dashboard
- All webhook events handled and tested
- Usage tracking implemented
- Limit enforcement working
- Translations added for plan names/descriptions
Stripe-specific
-
STRIPE_SECRET_KEYconfigured -
STRIPE_WEBHOOK_SECRETconfigured - Stripe price IDs set in plan definitions
- Invoice sync working
Polar-specific
-
POLAR_ACCESS_TOKENconfigured (Organization Access Token) -
POLAR_WEBHOOK_SECRETconfigured -
POLAR_SERVERset tosandboxorproduction - Polar product price IDs set in plan definitions
- Customer
externalIdmapping to userId working - (Optional) Better Auth plugin configured for auto customer creation
Testing
Unit Tests
Both gateway implementations have comprehensive Jest unit tests:
packages/core/tests/jest/lib/billing/
├── stripe.test.ts # 38 tests - StripeGateway, factory, deprecated compat
├── polar.test.ts # 26 tests - PolarGateway, getPolarInstance
└── (billing-queries.test.ts) # Billing query tests
Mock pattern: Mock the provider SDK, PlanService, and BILLING_REGISTRY before importing the gateway class.
// Example: Polar test mocks
jest.mock('@polar-sh/sdk', () => ({
Polar: jest.fn().mockImplementation(() => ({
checkouts: { create: mockCheckoutsCreate },
customerSessions: { create: mockCustomerSessionsCreate },
// ...
}))
}))
jest.mock('@polar-sh/sdk/webhooks', () => ({
validateEvent: mockValidateEvent,
WebhookVerificationError: MockWebhookVerificationError,
}))
jest.mock('@/core/lib/services/plan.service', () => ({
PlanService: { getPriceId: mockGetPriceId }
}))
Running tests:
# All billing tests
cd packages/core && npx jest --config jest.config.cjs tests/jest/lib/billing/
# Specific provider
npx jest --config jest.config.cjs tests/jest/lib/billing/polar.test.ts
npx jest --config jest.config.cjs tests/jest/lib/billing/stripe.test.ts
Related Skills
permissions-system- RBAC integrationbetter-auth- Authentication patterns (Polar Better Auth plugin)entity-api- API patterns for billing endpointsservice-layer- Service class patternsdatabase-migrations- Billing table migrations