Receber por email
← Voltar para o catálogo

saas-payment-patterns

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

507estrelas
Atualizado há 4 meses

Ver no GitHub ↗ Licença: MIT

Como adicionar

/plugin marketplace add vibeeval/vibecosystem

O comando exato pode variar conforme o repositório. Confira o README no GitHub.

Para o autor da skill

Cole no README do seu repo

Mostra que sua skill está catalogada na Skillteca, gera backlink e tráfego rastreável.

[![Skillteca](https://www.skillteca.com.br/api/badge/saas-payment-patterns/svg)](https://www.skillteca.com.br/skills/saas-payment-patterns)
#ai

Skills relacionadas

Ver todas de DevOps e Infra →

Alerta por categoria

Receba novas skills de DevOps e Infra toda segunda

1 email curto, só com as skills novas de DevOps e Infra. 4 minutos de leitura, sem spam, cancela com 1 clique.

Você confirma o email no primeiro envio. Sem spam. Cancela com 1 clique.

Ler descrição completa

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 
Compartilhar X LinkedIn