saas-platform-patterns · git:20260914.5e8ef0f · 2026-09-14 · sha256 39bdbbddb558af25

saas-platform-patterns git:20260914.5e8ef0fA

Immutable. This exact content never changes and is served at /api/v1/blob/39bdbbddb558af25.

---
name: saas-platform-patterns
description: 'SaaS monetization patterns for NestJS. Use when designing free, trial and paid tiers, license keys, subscription state machines, checkout and customer portal flows, or pricing configuration. Not for webhook plumbing — use webhook-architecture.'
---

# SaaS Platform Patterns

Patterns for turning a NestJS backend into a monetizable SaaS platform. These patterns cover the **business layer** that sits above payment provider integration (Stripe, Paddle, LemonSqueezy, etc.) and below the API surface. Provider-agnostic by design -- swap payment providers without rewriting business logic.

## Activation scope

This skill activates **per-module during Stage B** of the SaaS bootstrap, against Billing & Monetization roadmap items (`billing-*`, `licensing-*`, `subscription-*`, `checkout-*`). `saas-workspace-initializer` deliberately keeps billing out of the Stage A foundation — pull this skill in only when working a specific monetization roadmap item in its own session.

## Monetization Models

| Model         | Description                                | Complexity | Best For                               |
| ------------- | ------------------------------------------ | ---------- | -------------------------------------- |
| Freemium      | Free tier + paid tiers with feature gating | Medium     | Developer tools, productivity apps     |
| Trial-to-Paid | Time-limited full access, then paywall     | Low        | B2B, enterprise software               |
| Usage-Based   | Pay per API call, storage, or compute      | High       | APIs, infrastructure, cloud tools      |
| Seat-Based    | Per-user pricing within an organization    | Medium     | Team collaboration, project management |

Most SaaS products combine models (e.g., freemium + seat-based). Start with the simplest model that validates your pricing, then layer complexity.

## Architecture Overview

```
+-------------------------------------------------------+
|  Plan Configuration                                    |
|  Static tier definitions, feature flags, pricing       |
|  plans.config.ts                                       |
+---------------------------+---------------------------+
                            |
+---------------------------v---------------------------+
|  License System                                       |
|  Key generation, verification, status tracking        |
|  license.service.ts + License model                   |
+---------------------------+---------------------------+
                            |
+---------------------------v---------------------------+
|  Subscription Engine                                  |
|  State machine driven by payment webhooks             |
|  subscription.service.ts + Subscription model         |
+---------------------------+---------------------------+
                            |
+---------------------------v---------------------------+
|  Checkout Flow                                        |
|  Pre-checkout validation, portal session, price map   |
|  checkout.service.ts                                  |
+---------------------------+---------------------------+
                            |
+---------------------------v---------------------------+
|  Trial Manager                                        |
|  Period tracking, reminder emails, auto-downgrade     |
|  trial.service.ts (or within subscription.service)    |
+-------------------------------------------------------+
```

**Data flows downward through webhooks**: Payment provider emits events, the webhook layer (see `webhook-architecture` skill) routes them into the subscription engine, which updates licenses and triggers side-effects (emails, SSE pushes, audit logs).

## Quick Start: Freemium Model

### 1. Define Plans

```typescript
// src/config/plans.config.ts
export interface PlanDefinition {
  readonly name: string;
  readonly slug: string;
  readonly monthlyPrice: number;
  readonly yearlyPrice: number;
  readonly features: readonly string[];
  readonly limits: Readonly<Record<string, number>>;
}

export const PLANS = {
  community: {
    name: 'Community',
    slug: 'community',
    monthlyPrice: 0,
    yearlyPrice: 0,
    features: ['basic_chat', 'community_support'],
    limits: { messages_per_day: 50, storage_mb: 100 },
  },
  pro: {
    name: 'Pro',
    slug: 'pro',
    monthlyPrice: 9,
    yearlyPrice: 90,
    features: ['basic_chat', 'advanced_chat', 'priority_support', 'api_access'],
    limits: { messages_per_day: 1000, storage_mb: 10_000 },
  },
  enterprise: {
    name: 'Enterprise',
    slug: 'enterprise',
    monthlyPrice: 49,
    yearlyPrice: 490,
    features: ['basic_chat', 'advanced_chat', 'priority_support', 'api_access', 'sso', 'audit_log', 'custom_branding'],
    limits: { messages_per_day: -1, storage_mb: -1 }, // -1 = unlimited
  },
} as const satisfies Record<string, PlanDefinition>;

export type PlanSlug = keyof typeof PLANS;
```

### 2. Generate License Keys

```typescript
// src/services/license.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { randomBytes } from 'crypto';

@Injectable()
export class LicenseService {
  private readonly logger = new Logger(LicenseService.name);

  constructor(private readonly prisma: PrismaService) {}

  async createLicense(userId: string, plan: string, createdBy: string): Promise<string> {
    // Revoke existing active licenses
    await this.prisma.license.updateMany({
      where: { userId, status: 'active' },
      data: { status: 'revoked' },
    });

    const licenseKey = `lic_${randomBytes(32).toString('hex')}`;

    await this.prisma.license.create({
      data: { userId, licenseKey, plan, status: 'active', createdBy },
    });

    this.logger.log(`License created for user ${userId}, plan: ${plan}`);
    return licenseKey;
  }

  async verify(licenseKey: string) {
    const license = await this.prisma.license.findUnique({
      where: { licenseKey },
    });

    if (!license || license.status !== 'active') {
      return { valid: false, plan: 'community' };
    }

    if (license.expiresAt && license.expiresAt < new Date()) {
      await this.prisma.license.update({
        where: { id: license.id },
        data: { status: 'expired' },
      });
      return { valid: false, plan: 'community' };
    }

    return { valid: true, plan: license.plan, expiresAt: license.expiresAt };
  }
}
```

### 3. Handle Subscription Webhooks

```typescript
// src/services/subscription.service.ts (excerpt)
async handleSubscriptionCreated(data: SubscriptionEventData): Promise<void> {
  const plan = this.mapPriceIdToPlan(data.priceId);

  // Upsert user
  const user = await this.prisma.user.upsert({
    where: { email: data.customerEmail },
    update: { externalCustomerId: data.customerId },
    create: { email: data.customerEmail, externalCustomerId: data.customerId },
  });

  // Create subscription record
  await this.prisma.subscription.create({
    data: {
      userId: user.id,
      externalSubscriptionId: data.subscriptionId,
      externalCustomerId: data.customerId,
      status: data.trialEnd ? 'trialing' : 'active',
      priceId: data.priceId,
      currentPeriodEnd: new Date(data.currentPeriodEnd),
      trialEnd: data.trialEnd ? new Date(data.trialEnd) : null,
    },
  });

  // Provision license
  await this.licenseService.createLicense(user.id, plan, `webhook_${data.eventId}`);
}
```

## Module Structure

```
libs/
├── shared/
│   ├── licensing/
│   │   ├── config/
│   │   │   └── plans.config.ts         # Tier definitions
│   │   ├── services/
│   │   │   ├── license.service.ts       # Key generation and verification
│   │   │   ├── subscription.service.ts  # State machine and webhook handlers
│   │   │   ├── checkout.service.ts      # Pre-checkout and portal
│   │   │   └── trial.service.ts         # Trial period management
│   │   ├── controllers/
│   │   │   ├── license.controller.ts    # Public verification endpoint
│   │   │   └── subscription.controller.ts # Checkout and portal endpoints
│   │   ├── guards/
│   │   │   └── plan.guard.ts            # @RequirePlan() decorator guard
│   │   └── licensing.module.ts
│   └── payments/                        # Payment provider abstraction
│       └── ...                          # (see nestjs-backend-patterns)
```

## Decision Matrix

| Need                              | Pattern                             | Reference                                                                 |
| --------------------------------- | ----------------------------------- | ------------------------------------------------------------------------- |
| Define tiers, features, limits    | Plan configuration + feature gating | [freemium-model.md](references/freemium-model.md)                         |
| Generate and verify license keys  | License lifecycle + status machine  | [license-lifecycle.md](references/license-lifecycle.md)                   |
| React to payment webhooks         | Subscription state machine          | [subscription-state-machine.md](references/subscription-state-machine.md) |
| Checkout, portal, price mapping   | Checkout flow + reconciliation      | [checkout-and-portal.md](references/checkout-and-portal.md)               |
| Webhook processing infrastructure | 3-layer webhook architecture        | See `webhook-architecture` skill                                          |
| Payment provider abstraction      | Provider pattern                    | See `nestjs-backend-patterns` skill                                       |

## Integration with Other Skills

- **webhook-architecture**: Provides the HTTP layer that receives payment events. The subscription state machine in this skill is the **business service** (Layer 3) that webhook routing invokes.
- **nestjs-backend-patterns**: Provides the provider pattern for swappable payment SDKs. This skill consumes the abstract payment client.
- **nx-workspace-architect**: Guides library placement. Licensing lives in `shared/licensing`, payment adapters in `shared/payments`.

## Anti-Patterns

| Anti-Pattern                                  | Why It Fails                          | Do Instead                                          |
| --------------------------------------------- | ------------------------------------- | --------------------------------------------------- |
| Storing plan details in DB                    | Schema migrations for pricing changes | Static config file, deploy to change plans          |
| Trusting client-side feature flags            | Users can modify frontend flags       | Always verify plan server-side via guard or service |
| Checking subscription status on every request | Hammers payment API, adds latency     | Cache locally, sync on webhook events               |
| Coupling business logic to payment provider   | Cannot switch providers               | Abstract via interface, provider-specific adapters  |
| Skipping idempotency on webhooks              | Duplicate charges, duplicate licenses | Deduplicate by event ID (see webhook-architecture)  |

## References

Load these for detailed implementation guidance:

- [freemium-model.md](references/freemium-model.md) - Tier architecture, feature gating, trial management, upgrade/downgrade flows
- [license-lifecycle.md](references/license-lifecycle.md) - Key generation, Prisma schema, status machine, verification endpoint, provisioning
- [subscription-state-machine.md](references/subscription-state-machine.md) - Subscription model, state transitions, webhook handlers, status resolution
- [checkout-and-portal.md](references/checkout-and-portal.md) - Pre-checkout validation, checkout flow, portal integration, price mapping, reconciliation