Stripe Integration

Complete guide to integrating Stripe for subscription billing and payments in Dopamine Starter Kit

Stripe powers the subscription billing system in Dopamine Starter Kit, providing robust payment processing, subscription management, and per-seat billing capabilities. This guide covers the complete integration from setup to advanced features.

Architecture Overview

Billing System Components

The Dopamine billing system consists of several integrated components:

┌─────────────────┐     ┌──────────────────┐     ┌─────────────┐
│ Frontend (Web)  │ ──▶ │ API Controller   │ ──▶ │ Stripe API  │
└─────────────────┘     └──────────────────┘     └─────────────┘
        │                        │                       │
        ▼                        ▼                       ▼
┌─────────────────┐     ┌──────────────────┐     ┌─────────────┐
│ Checkout UI     │     │ Stripe Service   │     │ Webhooks    │
└─────────────────┘     └──────────────────┘     └─────────────┘
                                 │
                                 ▼
                        ┌──────────────────┐
                        │ Database Models  │
                        └──────────────────┘

Database Schema

The subscription system uses these core models:

  • Subscription: Main subscription records with Stripe IDs
  • Workspace: Organizations that hold subscriptions
  • WorkspaceMember: Per-seat billing calculation basis
  • User: Subscription owners and workspace members

Configuration

Environment Variables

Configure Stripe credentials in your API environment:

# apps/api/.env
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_endpoint_secret
 
# Price IDs from Stripe Dashboard
STRIPE_PRO_MONTHLY_PRICE_ID=price_pro_monthly_id
STRIPE_PRO_YEARLY_PRICE_ID=price_pro_yearly_id
STRIPE_BUSINESS_MONTHLY_PRICE_ID=price_business_monthly_id
STRIPE_BUSINESS_YEARLY_PRICE_ID=price_business_yearly_id
 
# Application settings
APP_URL=http://localhost:3000

Stripe Service Configuration

The StripeService initializes with your configuration:

// apps/api/src/billing/stripe.service.ts
@Injectable()
export class StripeService {
  private stripe: Stripe;
  private readonly plans: Record<SubscriptionPlan, { monthly: string; yearly: string }>;
 
  constructor(private readonly configService: ConfigService) {
    const apiKey = this.configService.get<string>('STRIPE_SECRET_KEY');
 
    this.plans = {
      PRO: {
        monthly: this.configService.get<string>('STRIPE_PRO_MONTHLY_PRICE_ID')!,
        yearly: this.configService.get<string>('STRIPE_PRO_YEARLY_PRICE_ID')!,
      },
      BUSINESS: {
        monthly: this.configService.get<string>('STRIPE_BUSINESS_MONTHLY_PRICE_ID')!,
        yearly: this.configService.get<string>('STRIPE_BUSINESS_YEARLY_PRICE_ID')!,
      },
    };
 
    this.stripe = new Stripe(apiKey, {
      apiVersion: '2025-04-30.basil',
    });
  }
}

Subscription Management

Creating Checkout Sessions

The checkout flow handles subscription creation:

// Create a checkout session for workspace subscription
async createCheckoutSession({
  user,
  workspace,
  plan,
  interval = 'monthly',
}) {
  const priceId = this.plans[plan][interval];
  const seats = await this.workspaceService.countPaidMembers(workspace.id);
 
  return await this.stripe.checkout.sessions.create({
    line_items: [{
      price: priceId,
      quantity: seats, // Per-seat billing
    }],
    mode: 'subscription',
    allow_promotion_codes: true,
    billing_address_collection: 'auto',
    tax_id_collection: { enabled: true },
    customer_email: user.email,
    success_url: `${this.appUrl}/${workspace.id}/settings/workspace/billing`,
    cancel_url: `${this.appUrl}/${workspace.id}/settings/workspace/billing`,
    metadata: {
      workspaceId: workspace.id,
      userId: user.id,
    },
  });
}

Customer Portal Sessions

Enable customers to manage their subscriptions:

async createPortalSession(workspace: Workspace) {
  const subscription = await this.getActiveSubscription(workspace.id);
 
  return await this.stripe.billingPortal.sessions.create({
    customer: subscription.stripeCustomerId,
    return_url: `${this.appUrl}/${workspace.id}/settings/workspace/billing`,
  });
}

Subscription Cancellation

Handle subscription cancellations gracefully:

async cancelSubscription(workspaceId: string) {
  const subscription = await this.subscriptionRepository.findFirst({
    where: { workspaceId, status: 'ACTIVE' },
  });
 
  // Cancel at period end in Stripe
  await this.stripe.subscriptions.update(subscription.stripeSubscriptionId, {
    cancel_at_period_end: true,
  });
 
  // Update database record
  return await this.subscriptionRepository.update({
    where: { id: subscription.id },
    data: { cancelAtPeriodEnd: true },
  });
}

Webhook Integration

Webhook Security

Stripe webhooks ensure data consistency and handle subscription lifecycle events:

async handleWebhook(signature: string, payload: Buffer) {
  const webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET');
 
  let event: Stripe.Event;
 
  try {
    event = this.stripe.webhooks.constructEvent(payload, signature, webhookSecret);
  } catch (err) {
    throw new Error(`Webhook signature verification failed: ${err.message}`);
  }
 
  switch (event.type) {
    case 'checkout.session.completed':
      await this.handleCheckoutCompleted(event.data.object);
      break;
    case 'customer.subscription.updated':
      await this.handleSubscriptionUpdated(event.data.object);
      break;
    case 'customer.subscription.deleted':
      await this.handleSubscriptionDeleted(event.data.object);
      break;
  }
}

Checkout Completion Handler

Creates subscription records when checkout completes:

private async handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  const subscriptionId = session.subscription as string;
  const workspaceId = session.metadata?.workspaceId;
  const customerId = session.customer as string;
 
  const subscriptionData = await this.stripe.subscriptions.retrieve(subscriptionId);
  const item = subscriptionData.items.data[0];
  const priceId = item.price.id;
  const plan = this.getPlanFromPriceId(priceId);
 
  await this.subscriptionRepository.create({
    data: {
      workspaceId,
      stripeCustomerId: customerId,
      stripeSubscriptionId: subscriptionId,
      status: 'ACTIVE',
      priceId,
      quantity: item.quantity,
      plan,
      currentPeriodStart: new Date(item.current_period_start * 1000),
      currentPeriodEnd: new Date(item.current_period_end * 1000),
      cancelAtPeriodEnd: subscriptionData.cancel_at_period_end,
    },
  });
}

Subscription Update Handler

Handles subscription changes and renewals:

private async handleSubscriptionUpdated(subscription: Stripe.Subscription) {
  const item = subscription.items.data[0];
  const status = subscription.status === 'active' ? 'ACTIVE' : 'INACTIVE';
  const priceId = item.price.id;
  const plan = this.getPlanFromPriceId(priceId);
 
  await this.subscriptionRepository.updateMany({
    where: { stripeSubscriptionId: subscription.id },
    data: {
      status,
      priceId,
      plan,
      quantity: item.quantity,
      currentPeriodStart: new Date(item.current_period_start * 1000),
      currentPeriodEnd: new Date(item.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
      canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null,
    },
  });
}

Per-Seat Billing

Seat Calculation

Dopamine automatically calculates seats based on workspace members:

// The checkout session uses actual member count
const seats = await this.workspaceService.countPaidMembers(workspace.id);
 
return await this.stripe.checkout.sessions.create({
  line_items: [
    {
      price: priceId,
      quantity: seats, // Automatic seat calculation
    },
  ],
  // ... other options
});

Dynamic Seat Updates

When workspace membership changes, subscriptions automatically update via webhooks to reflect the new seat count.

Invoice Management

Retrieving Invoices

Get customer invoices for billing history:

async getInvoices(workspaceId: string): Promise<Stripe.Invoice[]> {
  const subscription = await this.getActiveSubscription(workspaceId);
 
  if (!subscription) return [];
 
  const list = await this.stripe.invoices.list({
    customer: subscription.stripeCustomerId,
  });
 
  return list.data;
}

Security Best Practices

Webhook Verification

Always verify webhook signatures to prevent fraud:

const event = this.stripe.webhooks.constructEvent(payload, signature, webhookSecret);

API Key Protection

Store Stripe keys securely in environment variables and never expose them client-side.

Workspace Isolation

Ensure users can only access billing information for workspaces they belong to through the @CurrentWorkspace() decorator and guards.

Testing

Test Environment

Use Stripe's test mode for development:

# Test keys start with sk_test_ and pk_test_
STRIPE_SECRET_KEY=sk_test_your_test_key

Webhook Testing

Use Stripe CLI for local webhook testing:

# Forward webhooks to local development
stripe listen --forward-to localhost:8080/billing/webhook

Conclusion

This comprehensive Stripe integration provides a solid foundation for subscription billing with automatic per-seat calculation, robust webhook handling, and excellent customer experience.