skills/saas-payment-patterns/SKILL.md
Payment provider abstraction, webhook security, subscription lifecycle, dunning flows, pricing models, invoicing, tax handling, and refund patterns for SaaS applications.
npx skillsauth add rubicanjr/FinCognis saas-payment-patternsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Provider-agnostic payment patterns for subscription-based applications. Works with Stripe, Paddle, LemonSqueezy, or any billing provider.
// 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
})
}
}
// 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
}
// 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 = 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)
}
}
// 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
]
}
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
}
})
}
// 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
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'
})
}
// 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)
}
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.
development
Goal-based workflow orchestration - routes tasks to specialist agents based on user goals
tools
Wiring Verification
development
Connection management, room patterns, reconnection strategies, message buffering, and binary protocol design.
development
Screenshot comparison QA for frontend development. Takes a screenshot of the current implementation, scores it across multiple visual dimensions, and returns a structured PASS/REVISE/FAIL verdict with concrete fixes. Use when implementing UI from a design reference or verifying visual correctness.