saas-payment-patterns

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

422 stars

Best use case

saas-payment-patterns is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

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

Teams using saas-payment-patterns should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/saas-payment-patterns/SKILL.md --create-dirs "https://raw.githubusercontent.com/vibeeval/vibecosystem/main/skills/saas-payment-patterns/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/saas-payment-patterns/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How saas-payment-patterns Compares

Feature / Agentsaas-payment-patternsStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

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

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

Related Guides

SKILL.md Source

# SaaS Payment Patterns

Provider-agnostic payment patterns for subscription-based applications. Works with Stripe, Paddle, LemonSqueezy, or any billing provider.

## Payment Provider Abstraction Layer

```typescript
// 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

```typescript
// 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

```typescript
// 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)

```typescript
// 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

```typescript
// 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

```typescript
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)

```typescript
// 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

```typescript
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

```typescript
// 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.

Related Skills

websocket-patterns

422
from vibeeval/vibecosystem

Connection management, room patterns, reconnection strategies, message buffering, and binary protocol design.

vector-db-patterns

422
from vibeeval/vibecosystem

Embedding strategies, ANN algorithms, hybrid search, RAG chunking strategies, and reranking for semantic search and retrieval.

tracing-patterns

422
from vibeeval/vibecosystem

OpenTelemetry setup, span context propagation, sampling strategies, Jaeger queries

terraform-patterns

422
from vibeeval/vibecosystem

Module composition, state management, workspace strategy, provider versioning, and infrastructure-as-code best practices.

swift-patterns

422
from vibeeval/vibecosystem

SwiftUI view composition, @Observable patterns, async/await concurrency, TCA architecture, and Combine reactive streams.

springboot-patterns

422
from vibeeval/vibecosystem

Spring Boot architecture patterns, REST API design, layered services, data access, caching, async processing, and logging. Use for Java Spring Boot backend work.

seo-patterns

422
from vibeeval/vibecosystem

Meta tag patterns, structured data (JSON-LD), Core Web Vitals optimization, and SSR/SSG strategies for search visibility.

secret-patterns

422
from vibeeval/vibecosystem

30+ service-specific secret detection regex patterns, entropy-based detection, PEM/JWT/Base64 identification, and false positive filtering.

saas-launch-checklist

422
from vibeeval/vibecosystem

Pre-launch verification across infrastructure, security, legal, payment, email, analytics, and performance. Day-1 monitoring, rollback plan, incident response skeleton, and post-launch week-1 checklist.

saas-auth-patterns

422
from vibeeval/vibecosystem

SaaS authentication and authorization patterns including JWT vs session strategies, multi-tenant isolation, RBAC, API key management, passwordless flows, MFA, and secure session handling.

saas-analytics-patterns

422
from vibeeval/vibecosystem

SaaS analytics event taxonomy, metric formulas (MRR, churn, LTV), provider-agnostic tracking, funnel analysis, cohort setup, and privacy-respecting instrumentation.

revenuecat-patterns

422
from vibeeval/vibecosystem

RevenueCat SDK entegrasyon pattern'leri. iOS (Swift), Android (Kotlin), React Native ve Flutter icin setup, offerings, entitlement checking, webhook integration, StoreKit 2 migration ve sandbox testing.