| name | saas-payment-patterns |
| description | Payment provider abstraction, webhook security, subscription lifecycle, dunning flows, pricing models, invoicing, tax handling, and refund patterns for SaaS applications. |
SaaS Payment Patterns
Provider-agnostic payment patterns for subscription-based applications. Works with Stripe, Paddle, LemonSqueezy, or any billing provider.
Payment Provider Abstraction Layer
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'
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
}
}
}
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
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') ?? ''
if (!provider.verifyWebhookSignature(payload, signature)) {
return new Response('Invalid signature', { status: 401 })
}
const timestampMs = new Date(timestamp).getTime()
if (isNaN(timestampMs)) {
return new Response('Invalid timestamp', { status: 400 })
}
eventAge = .() - timestampMs
(eventAge > * * || eventAge < - * ) {
(, { : })
}
alreadyProcessed = db..({
: { eventId }
})
(alreadyProcessed) {
(, { : })
}
db.$transaction( (tx) => {
tx..({
: { eventId, payload, : () }
})
event = .(payload)
(event, tx)
})
(, { : })
}
(): <> {
event = req.()
(event)
()
}
Subscription Lifecycle
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: { : , : event. }
})
(sub., )
(sub., , {
: (event., )
})
:
tx..({
: { : subscriptionId },
: { : , : }
})
(sub., tx)
:
tx..({
: { : subscriptionId },
: { : , : () }
})
(sub, event., tx)
:
tx..({
: { : subscriptionId },
: { : , : (), : event. }
})
(sub., sub., tx)
(sub., )
:
tx..({
: { : subscriptionId },
: { : }
})
(sub., tx)
(sub., , { : ( (), ) })
}
}
Dunning Flow (Failed Payment Recovery)
interface DunningConfig {
retrySchedule: number[]
gracePeriodDays: number
downgradeAfterDays: number
}
const defaultDunning: DunningConfig = {
retrySchedule: [1, 3, 5, 7],
gracePeriodDays: 14,
downgradeAfterDays: 7
}
async function startDunningFlow(
sub: Subscription,
attemptNumber: number,
tx: Transaction
): Promise<void> {
const config = defaultDunning
const emailTemplates = [
'payment-failed-soft',
'payment-failed-update-card',
,
]
template = emailTemplates[.(attemptNumber - , emailTemplates. - )]
(sub., template)
daysSinceFailure = ( (), sub.!)
(daysSinceFailure >= config.) {
(sub., tx)
(sub., )
}
(daysSinceFailure >= config.) {
provider.(sub., )
}
}
Pricing Model Patterns
type PricingModel =
| { type: 'flat'; amountCents: number }
| { type: 'tiered'; tiers: PricingTier[] }
| { type: 'per_seat'; pricePerSeatCents: number; includedSeats: number }
| { type: 'usage_based'; unitPriceCents: number; metricKey: string }
interface PricingTier {
upTo: number | null
unitPriceCents: number
}
function calculateAmount(model: PricingModel, quantity: number): number {
switch (model.type) {
case 'flat':
return model.amountCents
case 'per_seat': {
const billableSeats = Math.max(0, quantity - model.includedSeats)
return billableSeats * model.
}
: {
total =
remaining = quantity
previousLimit =
( tier model.) {
tierLimit = tier. ??
tierCapacity = tierLimit - previousLimit
unitsInTier = .(remaining, tierCapacity)
total += unitsInTier * tier.
remaining -= unitsInTier
previousLimit = tierLimit
(remaining <= )
}
total
}
:
quantity * model.
}
}
: = {
: ,
: [
{ : , : },
{ : , : },
{ : , : }
]
}
Invoice and Receipt Generation
interface InvoiceLineItem {
description: string
quantity: number
unitPriceCents: number
amountCents: number
}
interface Invoice {
id: string
customerId: string
status: 'draft' | 'open' | 'paid' | 'void'
lineItems: InvoiceLineItem[]
subtotalCents: number
taxCents: number
totalCents: number
currency: string
issuedAt: Date
dueAt: Date
paidAt: Date | null
taxDetails: TaxDetails | null
}
async function generateInvoice(
subscriptionId: string,
periodStart: Date,
periodEnd: Date
): Promise<Invoice> {
const sub = await db..({
: { : subscriptionId },
: { : , : }
})
: [] = [{
: ,
: ,
: sub..,
: sub..
}]
(sub... === ) {
usage = (sub., periodStart, periodEnd)
usageAmount = (sub.., usage.)
lineItems.({
: ,
: usage.,
: sub...,
: usageAmount
})
}
subtotalCents = lineItems.( sum + li., )
taxDetails = (sub., subtotalCents)
db..({
: {
: sub.,
: ,
lineItems,
subtotalCents,
: taxDetails.,
: subtotalCents + taxDetails.,
: sub..,
: (),
: ( (), ),
: ,
taxDetails
}
})
}
Tax Handling (VAT/GST)
interface TaxDetails {
taxable: boolean
taxRate: number
taxAmountCents: number
taxType: 'vat' | 'gst' | 'sales_tax' | 'none'
jurisdiction: string
reverseCharge: boolean
}
interface TaxProvider {
calculateTax(customer: Customer, amountCents: number): Promise<TaxDetails>
validateTaxId(taxId: string, country: string): Promise<boolean>
}
async function calculateTax(
customer: Customer,
amountCents: number
): Promise<TaxDetails> {
if (customer.taxId) {
isValid = taxProvider.(customer., customer.)
(isValid && (customer., sellerCountry)) {
{
: ,
: ,
: ,
: ,
: ,
:
}
}
}
taxProvider.(customer, amountCents)
}
Refund and Proration
interface RefundResult {
refundId: string
amountCents: number
reason: string
prorated: boolean
}
async function processRefund(
subscriptionId: string,
requestingUserId: string,
reason: string,
fullRefund: boolean
): Promise<RefundResult> {
const sub = await db.subscriptions.findUniqueOrThrow({
where: { id: subscriptionId },
include: { latestInvoice: true, customer: true }
})
if (sub.customer.userId !== requestingUserId) {
throw new AuthError('Not authorized to refund this subscription')
}
let refundAmountCents: number
let prorated = false
if (fullRefund) {
refundAmountCents = sub.latestInvoice.totalCents
} {
totalDays = (sub., sub.)
usedDays = ( (), sub.)
unusedRatio = .(, (totalDays - usedDays) / totalDays)
refundAmountCents = .(sub.. * unusedRatio)
prorated =
}
refund = provider.(
sub..,
refundAmountCents
)
db..({
: {
subscriptionId,
: sub..,
: refundAmountCents,
reason,
prorated,
: refund.,
: ()
}
})
{
: refund.,
: refundAmountCents,
reason,
prorated
}
}
(): <> {
sub = db..({
: { : subscriptionId },
: { : }
})
newPlan = db..({ : { : newPlanId } })
isUpgrade = newPlan. > sub..
provider.(sub., {
: newPlan.,
: isUpgrade ? :
})
}
Webhook Event Routing
type WebhookHandler = (data: unknown, tx: Transaction) => Promise<void>
const webhookHandlers: Record<string, WebhookHandler> = {
'subscription.created': handleSubscriptionCreated,
'subscription.updated': handleSubscriptionUpdated,
'subscription.canceled': handleSubscriptionCanceled,
'invoice.paid': handleInvoicePaid,
'invoice.payment_failed': handlePaymentFailed,
'customer.updated': handleCustomerUpdated,
'refund.created': handleRefundCreated
}
async function routeWebhookEvent(
event: { type: string; data: unknown },
tx: Transaction
): Promise<void> {
const handler = webhookHandlers[event.type]
if (!handler) {
logger.warn(`Unhandled webhook event type: ${event.type}`)
return
}
await handler(event., tx)
}
Common Pitfalls
Missing idempotency on webhooks:
Providers retry failed deliveries. Without dedup, you charge or provision twice.
-> Store eventId before processing, skip duplicates.
Trusting client-side plan selection:
Never let the frontend decide the price. Always resolve plan + price server-side.
-> Client sends planId, server looks up price from DB.
Forgetting to handle past_due:
Users with failed payments keep accessing paid features indefinitely.
-> Enforce access checks against subscription status, not just "has subscription."
Hardcoding tax rates:
Tax rates change, new jurisdictions appear, VAT thresholds shift.
-> Use a tax API or let your payment provider handle calculation.
No grace period on cancellation:
Canceling immediately frustrates users who paid for a full period.
-> Cancel at period end by default, revoke access only after the paid period.
Remember: Your payment provider is a dependency, not your architecture. Abstract it behind an interface so you can switch providers, run in test mode, or support multiple providers for different regions without rewriting business logic.