saas-platform-patterns · diff
git:20260914.5e8ef0f to git:20261018.e46f918
19 added, 233 removed. Audit A to A.
---
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
+ ## Apply
- | 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 |
+ - Activate per Stage B monetization task; use discovery and the roadmap to choose the business model before implementing plans or entitlements.
+ - Treat subscription state as authoritative for access decisions. Make billing event processing idempotent and reconcile state with the provider.
+ - Separate license issuance and validation from checkout and portal flows. Keep provider credentials and webhook verification at integration boundaries.
- ## Integration with Other Skills
+ ## Model and integration decisions
- - **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`.
+ | Model | Pricing need and fit | Complexity | Route |
+ | --- | --- | --- | --- |
+ | Freemium | Free tier with paid gates; developer tools and productivity apps | Medium | [Freemium model](references/freemium-model.md) |
+ | Trial-to-paid | Time-limited full access; B2B and enterprise software | Low | [Freemium model](references/freemium-model.md) for trial handling |
+ | Usage-based | API calls, storage, or compute; APIs and infrastructure | High | Define metering before provider integration |
+ | Seat-based | Per-user team pricing; collaboration and project management | Medium | Count active seats in the domain model |
- ## Anti-Patterns
+ Models can be combined; start with the simplest model that validates pricing. Keep plan definitions in static configuration, enforce feature gates on the server, and use locally synchronized subscription state instead of calling the payment API on every request.
- | 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) |
+ The webhook architecture skill owns the HTTP and verification layers; this skill's subscription service is its domain handler. The backend patterns skill owns the abstract payment client and provider adapters. Use the workspace architect for placement: licensing in `shared/licensing`, payment adapters in `shared/payments`.
- ## References
+ Deduplicate payment events before issuing licenses or changing subscriptions. Keep business rules behind the payment client interface so changing provider does not change domain logic.
- Load these for detailed implementation guidance:
+ ## Load by task
- - [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
+ - For plan tiers, feature limits, and upgrades, load [freemium model](references/freemium-model.md).
+ - For issuing, validating, renewing, and revoking licenses, load [license lifecycle](references/license-lifecycle.md).
+ - For subscription transitions and provider events, load [subscription state machine](references/subscription-state-machine.md).
+ - For hosted checkout and customer self-service, load [checkout and portal](references/checkout-and-portal.md).