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.