SaaS Payment Patterns
Provider-agnostic payment patterns for subscription-based applications. Works with Stripe, Paddle, LemonSqueezy, or any billing provider.
Payment Provider Abstraction Layer
// Abstract away the provider — swap Stripe for Paddle without touching business logic
interface PaymentProvider {
createCustomer(data: CreateCustomerDto): Promise<Customer>
createSubscription(data: CreateSubscriptionDto): Promise<Subscription>
cancelSubscription(subscriptionId: string, immediate?: boolean): Promise<void>
createCheckoutSession(data: CheckoutDto): Promise<{ url: string }>
issueRefund(paymentId: string, amountCents?: number): Promise<Refund>
getInvoice(invoiceId: string): Promise<Invoice>
verifyWebhookSignature(payload: string, signature: string): boolean
}
interface Customer { id: string; email: string; providerCustomerId: string }
interface Subscription {
id: string
status: SubscriptionStatus
planId: string
currentPeriodEnd: Date
cancelAtPeriodEnd: boolean
}
type SubscriptionStatus = 'trialing' | 'active' | 'past_due' | 'canceled' | 'expired'
// Provider implementation (Stripe example)
class StripePaymentProvider implements PaymentProvider {
constructor(private stripe: Stripe) {}
async createCustomer(data: CreateCustomerDto): Promise<Customer> {
const stripeCustomer = await this.stripe.customers.create({
email: data.email,
metadata: { internalUserId: data.userId }
})
return {
id: data.userId,
email: data.email,
providerCustomerId: stripeCustomer.id
}
}
// ... other methods follow the same translation pattern
}
// Usage — business logic never imports Stripe/Paddle directly
class BillingService {
constructor(private provider: PaymentProvider, private db: Database) {}
async startSubscription(userId: string, planId: string): Promise<Subscription> {
const customer = await this.db.customers.findByUserId(userId)
return this.provider.createSubscription({
customerId: customer.providerCustomerId,
planId,
trialDays: 14
})
}
}
Webhook Security
// GOOD: Verify signature, enforce idempotency, protect against replay
async function handleWebhook(req: Request): Promise<Response> {
const payload = await req.text()
const signature = req.headers.get('x-webhook-signature') ?? ''
const eventId = req.headers.get('x-webhook-id') ?? ''
const timestamp = req.headers.get('x-webhook-timestamp') ?? ''
// 1. Verify cryptographic signature
if (!provider.verifyWebhookSignature(payload, signature)) {
return new Response('Invalid signature', { status: 401 })
}
// 2. Replay protection — reject events older than 5 minutes
const timestampMs = new Date(timestamp).getTime()
if (isNaN(timestampMs)) {
return new Response('Invalid timestamp', { status: 400 })
}
const eventAge = Date.now() - timestampMs
if (eventAge > 5 * 60 * 1000 || eventAge < -60 * 1000) {
return new Response('Event too old or clock skew', { status: 400 })
}
// 3. Idempotency — process each event exactly once
const alreadyProcessed = await db.webhookEvents.findUnique({
where: { eventId }
})
if (alreadyProcessed) {
return new Response('Already processed', { status: 200 })
}
// 4. Process inside a transaction
await db.$transaction(async (tx) => {
await tx.webhookEvents.create({
data: { eventId, payload, processedAt: new Date() }
})
const event = JSON.parse(payload)
await routeWebhookEvent(event, tx)
})
return new Response('OK', { status: 200 })
}
// BAD: Fire-and-forget webhook handler
async function handleWebhookBad(req: Request): Promise<Response> {
const event = await req.json() // No signature verification
await processEvent(event) // No idempotency check
return new Response('OK') // No replay protection
// Result: anyone can POST fake events, duplicate processing, replay attacks
}
Subscription Lifecycle
// State machine: trialing -> active -> past_due -> canceled -> expired
// \-> canceled (voluntary)
type LifecycleEvent =
| { type: 'trial_started'; trialEndsAt: Date }
| { type: 'trial_converted' }
| { type: 'payment_succeeded' }
| { type: 'payment_failed'; attemptNumber: number }
| { type: 'subscription_canceled'; reason: string }
| { type: 'subscription_expired' }
async function handleLifecycleEvent(
subscriptionId: string,
event: LifecycleEvent,
tx: Transaction
): Promise<void> {
const sub = await tx.subscriptions.findUniqueOrThrow({
where: { id: subscriptionId }
})
switch (event.type) {
case 'trial_started':
await tx.subscriptions.update({
where: { id: subscriptionId },
data: { status: 'trialing', trialEndsAt: event.trialEndsAt }
})
await scheduleEmail(sub.userId, 'trial-welcome')
await scheduleEmail(sub.userId, 'trial-ending-soon', {
sendAt: subDays(event.trialEndsAt, 3)
})
break
case 'payment_succeeded':
await tx.subscriptions.update({
where: { id: subscriptionId },
data: { status: 'active', pastDueAt: null }
})
await clearDunningState(sub.userId, tx)
break
case 'payment_failed':
await tx.subscriptions.update({
where: { id: subscriptionId },
data: { status: 'past_due', pastDueAt: new Date() }
})
await startDunningFlow(sub, event.attemptNumber, tx)
break
case 'subscription_canceled':
await tx.subscriptions.update({
where: { id: subscriptionId },
data: { status: 'canceled', canceledAt: new Date(), cancelReason: event.reason }
})
await revokeAccess(sub.userId, sub.currentPeriodEnd, tx) // access until period end
await scheduleEmail(sub.userId, 'cancellation-feedback')
break
case 'subscription_expired':
await tx.subscriptions.update({
where: { id: subscriptionId },
data: { status: 'expired' }
})
await revokeAccessImmediate(sub.userId, tx)
await scheduleEmail(sub.userId, 'win-back', { sendAt: addDays(new Date(), 7) })
break
}
}
Dunning Flow (Failed Payment Recovery)
// Dunning = systematic retry and communication when payment fails
// Goal: recover revenue before canceling
interface DunningConfig {
retrySchedule: number[] // days between retries
gracePeriodDays: number // total days before cancellation
downgradeAfterDays: number // days before downgrading to free tier
}
const defaultDunning: DunningConfig = {
retrySchedule: [1, 3, 5, 7], // retry on day 1, 3, 5, 7
gracePeriodDays: 14, // cancel after 14 days
downgradeAfterDays: 7 // downgrade to free on day 7
}
async function startDunningFlow(
sub: Subscription,
attemptNumber: number,
tx: Transaction
): Promise<void> {
const config = defaultDunning
// Email sequence based on attempt number
const emailTemplates = [
'payment-failed-soft', // Attempt 1: "Your payment didn't go through"
'payment-failed-update-card', // Attempt 2: "Please update your card"
'payment-failed-urgent', // Attempt 3: "You will lose access soon"
'payment-failed-final' // Attempt 4: "Last chance before cancellation"
]
const template = emailTemplates[Math.min(attemptNumber - 1, emailTemplates.length - 1)]
await scheduleEmail(sub.userId, template)
// Downgrade after threshold
const daysSinceFailure = differenceInDays(new Date(), sub.pastDueAt!)
if (daysSinceFailure >= config.downgradeAfterDays) {
await downgradeToFree(sub.userId, tx)
await scheduleEmail(sub.userId, 'downgraded-to-free')
}
// Cancel after grace period
if (daysSinceFailure >= config.gracePeriodDays) {
await provider.cancelSubscription(sub.providerSubscriptionId, true)
}
}
Pricing Model Patterns
// Support multiple