Vibecosystem saas-payment-patterns

Payment provider abstraction, webhook security, subscription lifecycle, dunning flows, pricing models, invoicing, tax handling, and refund patterns for SaaS applications.

install
source · Clone the upstream repo
git clone https://github.com/vibeeval/vibecosystem
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/vibeeval/vibecosystem "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/saas-payment-patterns" ~/.claude/skills/vibeeval-vibecosystem-saas-payment-patterns && rm -rf "$T"
manifest: skills/saas-payment-patterns/SKILL.md
source content

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 pricing models from a single schema

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   // null = unlimited
  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.pricePerSeatCents
    }

    case 'tiered': {
      let total = 0
      let remaining = quantity
      let previousLimit = 0
      for (const tier of model.tiers) {
        const tierLimit = tier.upTo ?? Infinity
        const tierCapacity = tierLimit - previousLimit
        const unitsInTier = Math.min(remaining, tierCapacity)
        total += unitsInTier * tier.unitPriceCents
        remaining -= unitsInTier
        previousLimit = tierLimit
        if (remaining <= 0) break
      }
      return total
    }

    case 'usage_based':
      return quantity * model.unitPriceCents
  }
}

// Example tier definition
const apiPricing: PricingModel = {
  type: 'tiered',
  tiers: [
    { upTo: 1000, unitPriceCents: 0 },       // first 1K free
    { upTo: 10000, unitPriceCents: 1 },       // next 9K at $0.01/call
    { upTo: null, unitPriceCents: 0.5 }       // above 10K at $0.005/call
  ]
}

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.subscriptions.findUniqueOrThrow({
    where: { id: subscriptionId },
    include: { plan: true, customer: true }
  })

  const lineItems: InvoiceLineItem[] = [{
    description: `${sub.plan.name} (${formatDate(periodStart)} - ${formatDate(periodEnd)})`,
    quantity: 1,
    unitPriceCents: sub.plan.priceCents,
    amountCents: sub.plan.priceCents
  }]

  // Add usage-based line items if applicable
  if (sub.plan.pricing.type === 'usage_based') {
    const usage = await getUsageForPeriod(sub.id, periodStart, periodEnd)
    const usageAmount = calculateAmount(sub.plan.pricing, usage.total)
    lineItems.push({
      description: `API calls: ${usage.total} units`,
      quantity: usage.total,
      unitPriceCents: sub.plan.pricing.unitPriceCents,
      amountCents: usageAmount
    })
  }

  const subtotalCents = lineItems.reduce((sum, li) => sum + li.amountCents, 0)
  const taxDetails = await calculateTax(sub.customer, subtotalCents)

  return db.invoices.create({
    data: {
      customerId: sub.customerId,
      status: 'open',
      lineItems,
      subtotalCents,
      taxCents: taxDetails.taxAmountCents,
      totalCents: subtotalCents + taxDetails.taxAmountCents,
      currency: sub.plan.currency,
      issuedAt: new Date(),
      dueAt: addDays(new Date(), 30),
      paidAt: null,
      taxDetails
    }
  })
}

Tax Handling (VAT/GST)

// GOOD: Tax calculation abstracted, provider handles compliance
interface TaxDetails {
  taxable: boolean
  taxRate: number           // 0.20 for 20% VAT
  taxAmountCents: number
  taxType: 'vat' | 'gst' | 'sales_tax' | 'none'
  jurisdiction: string      // "EU-DE", "AU", "US-CA"
  reverseCharge: boolean    // B2B within EU with valid VAT ID
}

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 has a valid tax ID and is B2B, reverse charge may apply
  if (customer.taxId) {
    const isValid = await taxProvider.validateTaxId(customer.taxId, customer.country)
    if (isValid && isReverseChargeEligible(customer.country, sellerCountry)) {
      return {
        taxable: false,
        taxRate: 0,
        taxAmountCents: 0,
        taxType: 'vat',
        jurisdiction: `EU-${customer.country}`,
        reverseCharge: true
      }
    }
  }

  return taxProvider.calculateTax(customer, amountCents)
}

// Key rules:
// - Store tax details on every invoice (audit trail)
// - B2C in EU: charge VAT at customer country rate
// - B2B in EU with valid VAT ID: reverse charge (0% VAT)
// - US: sales tax varies by state, use a tax API
// - Let the payment provider (Stripe Tax, Paddle) handle compliance when possible

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 }
  })

  // SECURITY: Verify the requesting user owns this subscription
  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
  } else {
    // Prorate: refund unused portion of current period
    const totalDays = differenceInDays(sub.currentPeriodEnd, sub.currentPeriodStart)
    const usedDays = differenceInDays(new Date(), sub.currentPeriodStart)
    const unusedRatio = Math.max(0, (totalDays - usedDays) / totalDays)
    refundAmountCents = Math.round(sub.latestInvoice.totalCents * unusedRatio)
    prorated = true
  }

  const refund = await provider.issueRefund(
    sub.latestInvoice.providerPaymentId,
    refundAmountCents
  )

  await db.refunds.create({
    data: {
      subscriptionId,
      invoiceId: sub.latestInvoice.id,
      amountCents: refundAmountCents,
      reason,
      prorated,
      providerRefundId: refund.id,
      processedAt: new Date()
    }
  })

  return {
    refundId: refund.id,
    amountCents: refundAmountCents,
    reason,
    prorated
  }
}

// Plan change with proration
async function changePlan(
  subscriptionId: string,
  newPlanId: string
): Promise<void> {
  const sub = await db.subscriptions.findUniqueOrThrow({
    where: { id: subscriptionId },
    include: { plan: true }
  })
  const newPlan = await db.plans.findUniqueOrThrow({ where: { id: newPlanId } })

  // Upgrade: charge prorated difference immediately
  // Downgrade: credit prorated difference to next invoice
  const isUpgrade = newPlan.priceCents > sub.plan.priceCents

  await provider.updateSubscription(sub.providerSubscriptionId, {
    planId: newPlan.providerPlanId,
    prorationBehavior: isUpgrade ? 'create_prorations' : 'always_invoice'
  })
}

Webhook Event Routing

// Map provider events to internal handlers — single entry point

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  // Do not throw — acknowledge unknown events to prevent retries
  }

  await handler(event.data, 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.