Back to blog
Engineering #SaaS#billing#subscriptions

SaaS Billing Architecture in 2026: Subscription Payments Done Right

Early billing decisions shape your product forever. A practical guide to subscription architecture, Stripe integration, and separating billing from entitlements.

15 min · January 20, 2026 · Updated January 27, 2026
Topic relevant background image

TL;DR

  • Separate billing infrastructure (Stripe) from product entitlements (your application). Never store feature access in Stripe.
  • Stripe manages Products, Prices, and Subscriptions. Your app manages Plans, Features, and Usage Limits.
  • This separation lets you change pricing models without rewriting your application.
  • Use webhooks to sync subscription state—don’t rely on client-side session data.
  • Build entitlement logic as a separate service that answers “can this user do X?”
  • Early architecture decisions here have lasting impact—get it right from the start.

The Architecture Principle

The most important billing architecture decision:

Stripe handles billing. Your application handles entitlements.

Stripe’s ResponsibilityYour App’s Responsibility
Products and PricesPlans and Features
SubscriptionsEntitlements
Payment processingFeature access control
InvoicingUsage limits
Revenue recoveryUser experience

Why This Matters

Without separation:

  • Changing pricing requires code changes
  • Adding new plans means database migrations
  • Feature flags are scattered across systems
  • Testing becomes painful

With separation:

  • Change pricing in Stripe dashboard
  • Add plans with minimal code changes
  • Centralized entitlement logic
  • Clean, testable architecture

Data Model

Stripe Side

┌─────────────────────┐
│      Product        │ ← What you're selling (e.g., "Pro Plan")
│ (Stripe construct)  │
└──────────┬──────────┘

           │ has many

┌─────────────────────┐
│       Price         │ ← How much it costs ($99/month, $999/year)
│ (Stripe construct)  │
└──────────┬──────────┘

           │ used by

┌─────────────────────┐
│    Subscription     │ ← Customer's active billing
│ (Stripe construct)  │
└─────────────────────┘

Your Application Side

┌─────────────────────┐
│        Plan         │ ← Your plan definition
│  (Your database)    │
└──────────┬──────────┘

           │ has many

┌─────────────────────┐
│      Feature        │ ← What's included
│  (Your database)    │
└──────────┬──────────┘

           │ defines

┌─────────────────────┐
│    Entitlement      │ ← What user can access
│  (Your database)    │
└─────────────────────┘

Linking the Two

// Plan model in your database
interface Plan {
  id: string;
  name: string;                    // "Pro Plan"
  stripePriceIds: string[];        // Links to Stripe prices
  features: Feature[];             // What's included
  limits: UsageLimit[];            // Usage constraints
}

// Subscription model in your database
interface UserSubscription {
  userId: string;
  planId: string;                  // Your plan
  stripeSubscriptionId: string;    // Stripe's subscription
  status: 'active' | 'past_due' | 'canceled';
  currentPeriodEnd: Date;
}

Stripe Integration

Product and Price Setup

// Create product in Stripe
const product = await stripe.products.create({
  name: 'Pro Plan',
  description: 'Everything you need to scale',
  metadata: {
    internal_plan_id: 'plan_pro',  // Link to your system
  },
});

// Create prices for the product
const monthlyPrice = await stripe.prices.create({
  product: product.id,
  unit_amount: 9900,  // $99.00
  currency: 'usd',
  recurring: { interval: 'month' },
  metadata: {
    billing_period: 'monthly',
  },
});

const yearlyPrice = await stripe.prices.create({
  product: product.id,
  unit_amount: 99900,  // $999.00 (2 months free)
  currency: 'usd',
  recurring: { interval: 'year' },
  metadata: {
    billing_period: 'yearly',
  },
});

Subscription Creation

async function createSubscription(
  customerId: string,
  priceId: string,
  userId: string
) {
  // Create subscription in Stripe
  const subscription = await stripe.subscriptions.create({
    customer: customerId,
    items: [{ price: priceId }],
    payment_behavior: 'default_incomplete',
    expand: ['latest_invoice.payment_intent'],
    metadata: {
      user_id: userId,  // Link to your user
    },
  });
  
  // Don't update your database here!
  // Wait for webhook confirmation
  
  return {
    subscriptionId: subscription.id,
    clientSecret: subscription.latest_invoice.payment_intent.client_secret,
  };
}

Webhook Handler

// Always use webhooks to update subscription state
async function handleStripeWebhook(event: Stripe.Event) {
  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated':
      await syncSubscription(event.data.object);
      break;
      
    case 'customer.subscription.deleted':
      await cancelSubscription(event.data.object);
      break;
      
    case 'invoice.payment_failed':
      await handlePaymentFailure(event.data.object);
      break;
      
    case 'invoice.paid':
      await handlePaymentSuccess(event.data.object);
      break;
  }
}

async function syncSubscription(stripeSubscription: Stripe.Subscription) {
  const userId = stripeSubscription.metadata.user_id;
  const planId = getPlanFromPrice(stripeSubscription.items.data[0].price.id);
  
  await db.userSubscription.upsert({
    where: { userId },
    update: {
      planId,
      stripeSubscriptionId: stripeSubscription.id,
      status: mapStripeStatus(stripeSubscription.status),
      currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
    },
    create: {
      userId,
      planId,
      stripeSubscriptionId: stripeSubscription.id,
      status: mapStripeStatus(stripeSubscription.status),
      currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
    },
  });
  
  // Refresh entitlements
  await refreshEntitlements(userId);
}

Entitlement Service

The Core Question

Your entitlement service answers: “Can this user do X?”

class EntitlementService {
  async canAccess(userId: string, feature: string): Promise<boolean> {
    const subscription = await this.getActiveSubscription(userId);
    
    if (!subscription) {
      return this.isFreeTierFeature(feature);
    }
    
    const plan = await this.getPlan(subscription.planId);
    return plan.features.includes(feature);
  }
  
  async getLimit(userId: string, resource: string): Promise<number> {
    const subscription = await this.getActiveSubscription(userId);
    
    if (!subscription) {
      return this.getFreeTierLimit(resource);
    }
    
    const plan = await this.getPlan(subscription.planId);
    return plan.limits[resource] ?? Infinity;
  }
  
  async checkUsage(userId: string, resource: string): Promise<UsageResult> {
    const limit = await this.getLimit(userId, resource);
    const current = await this.getCurrentUsage(userId, resource);
    
    return {
      limit,
      current,
      remaining: limit - current,
      canUse: current < limit,
    };
  }
}

Usage in Application

// Middleware for feature access
async function requireFeature(feature: string) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const canAccess = await entitlementService.canAccess(req.userId, feature);
    
    if (!canAccess) {
      return res.status(403).json({
        error: 'upgrade_required',
        message: 'This feature requires a paid plan',
        upgradeUrl: '/pricing',
      });
    }
    
    next();
  };
}

// Usage in routes
app.post('/api/export', requireFeature('export'), exportController);
app.post('/api/ai', requireFeature('ai_features'), aiController);

Usage-Based Billing

Recording Usage

async function recordUsage(
  userId: string,
  resource: string,
  quantity: number
) {
  // Record in your database
  await db.usage.create({
    data: {
      userId,
      resource,
      quantity,
      timestamp: new Date(),
    },
  });
  
  // If using Stripe metered billing
  const subscription = await getActiveSubscription(userId);
  const subscriptionItem = getMeteredItem(subscription, resource);
  
  if (subscriptionItem) {
    await stripe.subscriptionItems.createUsageRecord(
      subscriptionItem.id,
      {
        quantity,
        timestamp: Math.floor(Date.now() / 1000),
        action: 'increment',
      }
    );
  }
}

Overage Handling

async function checkAndRecordUsage(
  userId: string,
  resource: string,
  quantity: number
): Promise<UsageResult> {
  const usage = await entitlementService.checkUsage(userId, resource);
  
  if (!usage.canUse) {
    const plan = await getActivePlan(userId);
    
    if (plan.allowOverage) {
      // Record overage for billing
      await recordOverage(userId, resource, quantity);
      return { allowed: true, overage: true };
    }
    
    return { allowed: false, upgradeRequired: true };
  }
  
  await recordUsage(userId, resource, quantity);
  return { allowed: true, remaining: usage.remaining - quantity };
}

Pricing Page Integration

Fetching Prices from Stripe

async function getPricingPageData() {
  const plans = await db.plan.findMany({
    include: { features: true },
  });
  
  // Fetch current prices from Stripe
  const pricesWithStripe = await Promise.all(
    plans.map(async (plan) => {
      const stripePrices = await stripe.prices.list({
        product: plan.stripeProductId,
        active: true,
      });
      
      return {
        ...plan,
        prices: stripePrices.data.map((p) => ({
          id: p.id,
          amount: p.unit_amount,
          interval: p.recurring?.interval,
        })),
      };
    })
  );
  
  return pricesWithStripe;
}

Customer Portal

async function createPortalSession(userId: string) {
  const user = await db.user.findUnique({ where: { id: userId } });
  
  const session = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.APP_URL}/settings/billing`,
  });
  
  return session.url;
}

Testing Strategy

Mock Entitlement Service

// Test helper
function createTestEntitlements(overrides: Partial<Entitlements> = {}) {
  return {
    canAccess: jest.fn().mockResolvedValue(true),
    getLimit: jest.fn().mockResolvedValue(100),
    checkUsage: jest.fn().mockResolvedValue({ canUse: true, remaining: 50 }),
    ...overrides,
  };
}

// In tests
it('should block access for unpaid users', async () => {
  const entitlements = createTestEntitlements({
    canAccess: jest.fn().mockResolvedValue(false),
  });
  
  // Test your feature with blocked access
});

Implementation Checklist

Database Design

  • Plans table with features and limits
  • User subscriptions table linking to Stripe
  • Usage tracking table
  • Separate entitlements logic

Stripe Integration

  • Product and price setup
  • Subscription creation flow
  • Webhook handler for all relevant events
  • Customer portal integration

Entitlement Service

  • Feature access checking
  • Usage limit enforcement
  • Overage handling
  • Caching for performance

Testing

  • Mock entitlement service for tests
  • Stripe webhook test mode
  • End-to-end subscription flow tests

FAQ

Should I store prices in my database?

Store plan definitions and feature mappings in your database. Let Stripe be the source of truth for actual prices. This allows you to change prices in Stripe without code changes.

How do I handle plan changes?

Use Stripe’s proration settings. Update your subscription record via webhook, then refresh entitlements.

What about lifetime deals?

Create a special plan with no Stripe subscription. Mark entitlements as “lifetime” with no expiration.

How do I handle failed payments?

Use webhooks to mark subscriptions as past_due. Implement grace periods in your entitlement logic. Use Stripe’s Smart Retries for recovery.

Should I cache entitlements?

Yes, for performance. Cache with short TTL (1-5 minutes). Invalidate on subscription changes.

How do I test locally?

Use Stripe CLI to forward webhooks to localhost. Use test mode API keys. Create test customers and subscriptions.

Sources & Further Reading

Interested in our research?

We share our work openly. If you'd like to collaborate or discuss ideas — we'd love to hear from you.

Get in Touch

Let's build
something real.

No more slide decks. No more "maybe next quarter".
Let's ship your MVP in weeks.

Start Building Now