Back to Journal
SaaS Engineering

Complete Guide to Subscription Billing Systems with Typescript

A comprehensive guide to implementing Subscription Billing Systems using Typescript, covering architecture, code examples, and production-ready patterns.

Muneer Puthiya Purayil 14 min read

TypeScript's type system catches billing logic bugs at compile time — incorrect plan calculations, missing webhook event handling, and type mismatches between Stripe's API responses and your domain models. This guide builds a subscription billing system in TypeScript with Stripe, Prisma, and Zod, covering the data layer through webhook processing and self-service API endpoints.

Type-Safe Stripe Integration

typescript
1// types.ts
2export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'cancelled' | 'paused';
3export type BillingInterval = 'month' | 'year';
4 
5export interface Plan {
6 id: string;
7 name: string;
8 slug: string;
9 active: boolean;
10 interval: BillingInterval;
11 basePriceCents: number;
12 pricePerSeatCents: number;
13 includedSeats: number;
14 features: string[];
15 limits: PlanLimits;
16 stripePriceId: string;
17}
18 
19export interface PlanLimits {
20 maxProjects: number | null;
21 maxStorageMb: number;
22 maxApiCalls: number | null;
23 maxTeamMembers: number | null;
24}
25 
26export interface Subscription {
27 id: string;
28 customerId: string;
29 planId: string;
30 stripeSubscriptionId: string;
31 stripeCustomerId: string;
32 status: SubscriptionStatus;
33 quantity: number;
34 currentPeriodStart: Date;
35 currentPeriodEnd: Date;
36 cancelAtPeriodEnd: boolean;
37 trialEndsAt: Date | null;
38 cancelledAt: Date | null;
39 createdAt: Date;
40 updatedAt: Date;
41}
42 

Prisma Schema

prisma
1model Plan {
2 id String @id @default(cuid())
3 name String
4 slug String @unique
5 active Boolean @default(true)
6 billingInterval String @map("billing_interval")
7 basePriceCents Int @map("base_price_cents")
8 pricePerSeatCents Int @map("price_per_seat_cents") @default(0)
9 includedSeats Int @map("included_seats") @default(1)
10 features Json @default("[]")
11 limits Json @default("{}")
12 stripePriceId String @map("stripe_price_id")
13 subscriptions Subscription[]
14 createdAt DateTime @default(now()) @map("created_at")
15}
16 
17model Subscription {
18 id String @id @default(cuid())
19 customerId String @map("customer_id")
20 planId String @map("plan_id")
21 plan Plan @relation(fields: [planId], references: [id])
22 stripeSubscriptionId String @unique @map("stripe_subscription_id")
23 stripeCustomerId String @map("stripe_customer_id")
24 status String @default("active")
25 quantity Int @default(1)
26 currentPeriodStart DateTime @map("current_period_start")
27 currentPeriodEnd DateTime @map("current_period_end")
28 cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end")
29 trialEndsAt DateTime? @map("trial_ends_at")
30 cancelledAt DateTime? @map("cancelled_at")
31 usageEvents UsageEvent[]
32 createdAt DateTime @default(now()) @map("created_at")
33 updatedAt DateTime @updatedAt @map("updated_at")
34 
35 @@index([customerId])
36}
37 
38model UsageEvent {
39 id String @id @default(cuid())
40 idempotencyKey String @unique @map("idempotency_key")
41 subscriptionId String @map("subscription_id")
42 subscription Subscription @relation(fields: [subscriptionId], references: [id])
43 metricId String @map("metric_id")
44 quantity Int
45 timestamp DateTime
46 metadata Json @default("{}")
47 createdAt DateTime @default(now()) @map("created_at")
48 
49 @@index([subscriptionId, metricId, timestamp])
50}
51 
52model WebhookEvent {
53 stripeEventId String @id @map("stripe_event_id")
54 eventType String @map("event_type")
55 processedAt DateTime @default(now()) @map("processed_at")
56}
57 

Billing Service

typescript
1import Stripe from 'stripe';
2import { PrismaClient } from '@prisma/client';
3import type { Plan, Subscription } from './types';
4 
5const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
6 
7export class BillingService {
8 constructor(private readonly prisma: PrismaClient) {}
9 
10 async createSubscription(
11 customerId: string,
12 planSlug: string,
13 quantity: number,
14 stripeCustomerId: string,
15 ): Promise<Subscription> {
16 const plan = await this.prisma.plan.findUniqueOrThrow({
17 where: { slug: planSlug },
18 });
19 
20 if (!plan.active) throw new Error(`Plan ${planSlug} is not active`);
21 
22 const stripeSub = await stripe.subscriptions.create({
23 customer: stripeCustomerId,
24 items: [{ price: plan.stripePriceId, quantity }],
25 });
26 
27 return this.prisma.subscription.create({
28 data: {
29 customerId,
30 planId: plan.id,
31 stripeSubscriptionId: stripeSub.id,
32 stripeCustomerId,
33 status: 'active',
34 quantity,
35 currentPeriodStart: new Date(stripeSub.current_period_start * 1000),
36 currentPeriodEnd: new Date(stripeSub.current_period_end * 1000),
37 },
38 }) as unknown as Subscription;
39 }
40 
41 async changePlan(subscriptionId: string, newPlanSlug: string) {
42 const sub = await this.prisma.subscription.findUniqueOrThrow({
43 where: { id: subscriptionId },
44 include: { plan: true },
45 });
46 
47 const newPlan = await this.prisma.plan.findUniqueOrThrow({
48 where: { slug: newPlanSlug },
49 });
50 
51 const proration = calculateProration(
52 sub.plan as unknown as Plan,
53 newPlan as unknown as Plan,
54 sub.quantity, sub.quantity,
55 sub.currentPeriodStart, sub.currentPeriodEnd,
56 );
57 
58 const stripeItems = await stripe.subscriptionItems.list({
59 subscription: sub.stripeSubscriptionId,
60 });
61 
62 await stripe.subscriptions.update(sub.stripeSubscriptionId, {
63 items: [{
64 id: stripeItems.data[0].id,
65 price: newPlan.stripePriceId,
66 }],
67 proration_behavior: 'create_prorations',
68 });
69 
70 const updated = await this.prisma.subscription.update({
71 where: { id: subscriptionId },
72 data: { planId: newPlan.id },
73 });
74 
75 return { subscription: updated, proration };
76 }
77 
78 async updateSeats(subscriptionId: string, newQuantity: number) {
79 const sub = await this.prisma.subscription.findUniqueOrThrow({
80 where: { id: subscriptionId },
81 });
82 
83 const stripeItems = await stripe.subscriptionItems.list({
84 subscription: sub.stripeSubscriptionId,
85 });
86 
87 await stripe.subscriptions.update(sub.stripeSubscriptionId, {
88 items: [{
89 id: stripeItems.data[0].id,
90 quantity: newQuantity,
91 }],
92 proration_behavior: 'create_prorations',
93 });
94 
95 return this.prisma.subscription.update({
96 where: { id: subscriptionId },
97 data: { quantity: newQuantity },
98 });
99 }
100 
101 async cancelSubscription(subscriptionId: string, immediately = false) {
102 const sub = await this.prisma.subscription.findUniqueOrThrow({
103 where: { id: subscriptionId },
104 });
105 
106 if (immediately) {
107 await stripe.subscriptions.cancel(sub.stripeSubscriptionId);
108 return this.prisma.subscription.update({
109 where: { id: subscriptionId },
110 data: { status: 'cancelled', cancelledAt: new Date() },
111 });
112 }
113 
114 await stripe.subscriptions.update(sub.stripeSubscriptionId, {
115 cancel_at_period_end: true,
116 });
117 
118 return this.prisma.subscription.update({
119 where: { id: subscriptionId },
120 data: { cancelAtPeriodEnd: true },
121 });
122 }
123 
124 async recordUsage(
125 subscriptionId: string,
126 metricId: string,
127 quantity: number,
128 idempotencyKey: string,
129 ): Promise<void> {
130 await this.prisma.usageEvent.upsert({
131 where: { idempotencyKey },
132 create: {
133 idempotencyKey,
134 subscriptionId,
135 metricId,
136 quantity,
137 timestamp: new Date(),
138 },
139 update: {}, // No-op on duplicate
140 });
141 }
142 
143 async getUsageSummary(
144 subscriptionId: string,
145 metricId: string,
146 periodStart: Date,
147 periodEnd: Date,
148 ): Promise<number> {
149 const result = await this.prisma.usageEvent.aggregate({
150 where: {
151 subscriptionId,
152 metricId,
153 timestamp: { gte: periodStart, lt: periodEnd },
154 },
155 _sum: { quantity: true },
156 });
157 return result._sum.quantity ?? 0;
158 }
159 
160 async checkFeatureAccess(customerId: string, feature: string): Promise<boolean> {
161 const sub = await this.prisma.subscription.findFirst({
162 where: { customerId, status: 'active' },
163 include: { plan: true },
164 });
165 
166 if (!sub) return false;
167 const features = sub.plan.features as string[];
168 return features.includes(feature);
169 }
170 
171 async checkLimit(
172 customerId: string,
173 resource: keyof PlanLimits,
174 currentCount: number,
175 ): Promise<{ allowed: boolean; limit: number | null; current: number }> {
176 const sub = await this.prisma.subscription.findFirst({
177 where: { customerId, status: 'active' },
178 include: { plan: true },
179 });
180 
181 if (!sub) return { allowed: false, limit: 0, current: currentCount };
182 
183 const limits = sub.plan.limits as PlanLimits;
184 const limit = limits[resource];
185 
186 return {
187 allowed: limit === null || currentCount < limit,
188 limit,
189 current: currentCount,
190 };
191 }
192}
193 

Proration Calculator

typescript
1interface ProrationResult {
2 creditCents: number;
3 chargeCents: number;
4 netCents: number;
5 effectiveAt: Date;
6 description: string;
7}
8 
9function calculateProration(
10 currentPlan: Plan,
11 newPlan: Plan,
12 currentQty: number,
13 newQty: number,
14 periodStart: Date,
15 periodEnd: Date,
16): ProrationResult {
17 const now = new Date();
18 const totalMs = periodEnd.getTime() - periodStart.getTime();
19 const remainingMs = periodEnd.getTime() - now.getTime();
20 const ratio = remainingMs / totalMs;
21 
22 const currentMonthly = currentPlan.basePriceCents +
23 currentPlan.pricePerSeatCents * Math.max(0, currentQty - currentPlan.includedSeats);
24 const creditCents = Math.round(currentMonthly * ratio);
25 
26 const newMonthly = newPlan.basePriceCents +
27 newPlan.pricePerSeatCents * Math.max(0, newQty - newPlan.includedSeats);
28 const chargeCents = Math.round(newMonthly * ratio);
29 
30 return {
31 creditCents,
32 chargeCents,
33 netCents: chargeCents - creditCents,
34 effectiveAt: now,
35 description: `${currentPlan.name}${newPlan.name}, prorated for remaining period`,
36 };
37}
38 

Need a second opinion on your saas engineering architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Webhook Handler

typescript
1import { z } from 'zod';
2 
3const WebhookEventSchema = z.object({
4 id: z.string(),
5 type: z.string(),
6 data: z.object({ object: z.record(z.unknown()) }),
7 created: z.number(),
8});
9 
10export class WebhookProcessor {
11 constructor(private readonly prisma: PrismaClient) {}
12 
13 async process(event: Stripe.Event): Promise<void> {
14 // Idempotency check
15 const existing = await this.prisma.webhookEvent.findUnique({
16 where: { stripeEventId: event.id },
17 });
18 if (existing) return;
19 
20 const handlers: Record<string, (data: any) => Promise<void>> = {
21 'customer.subscription.updated': (data) => this.handleSubscriptionUpdated(data),
22 'customer.subscription.deleted': (data) => this.handleSubscriptionDeleted(data),
23 'invoice.payment_failed': (data) => this.handlePaymentFailed(data),
24 'invoice.paid': (data) => this.handleInvoicePaid(data),
25 };
26 
27 const handler = handlers[event.type];
28 if (handler) {
29 await handler(event.data.object);
30 }
31 
32 await this.prisma.webhookEvent.create({
33 data: { stripeEventId: event.id, eventType: event.type },
34 });
35 }
36 
37 private async handleSubscriptionUpdated(data: Stripe.Subscription) {
38 const sub = await this.prisma.subscription.findUnique({
39 where: { stripeSubscriptionId: data.id },
40 });
41 if (!sub) return;
42 
43 await this.prisma.subscription.update({
44 where: { stripeSubscriptionId: data.id },
45 data: {
46 status: this.mapStatus(data.status),
47 quantity: data.items.data[0]?.quantity ?? 1,
48 currentPeriodStart: new Date(data.current_period_start * 1000),
49 currentPeriodEnd: new Date(data.current_period_end * 1000),
50 cancelAtPeriodEnd: data.cancel_at_period_end,
51 },
52 });
53 }
54 
55 private async handleSubscriptionDeleted(data: Stripe.Subscription) {
56 await this.prisma.subscription.update({
57 where: { stripeSubscriptionId: data.id },
58 data: { status: 'cancelled', cancelledAt: new Date() },
59 });
60 }
61 
62 private async handlePaymentFailed(data: Stripe.Invoice) {
63 if (!data.subscription) return;
64 const subId = typeof data.subscription === 'string' ? data.subscription : data.subscription.id;
65 
66 await this.prisma.subscription.update({
67 where: { stripeSubscriptionId: subId },
68 data: { status: 'past_due' },
69 });
70 }
71 
72 private async handleInvoicePaid(data: Stripe.Invoice) {
73 if (!data.subscription) return;
74 const subId = typeof data.subscription === 'string' ? data.subscription : data.subscription.id;
75 
76 await this.prisma.subscription.update({
77 where: { stripeSubscriptionId: subId },
78 data: { status: 'active' },
79 });
80 }
81 
82 private mapStatus(status: Stripe.Subscription.Status): string {
83 const map: Record<string, string> = {
84 active: 'active',
85 trialing: 'trialing',
86 past_due: 'past_due',
87 canceled: 'cancelled',
88 paused: 'paused',
89 };
90 return map[status] ?? 'active';
91 }
92}
93 

Feature Gating Middleware

typescript
1import { Request, Response, NextFunction } from 'express';
2 
3export function requireFeature(feature: string) {
4 return async (req: Request, res: Response, next: NextFunction) => {
5 const billing = new BillingService(prisma);
6 const hasAccess = await billing.checkFeatureAccess(req.user!.id, feature);
7 
8 if (!hasAccess) {
9 res.status(403).json({
10 error: 'Feature not available on your current plan',
11 feature,
12 upgradeUrl: '/billing/upgrade',
13 });
14 return;
15 }
16 
17 next();
18 };
19}
20 
21export function requireLimit(resource: keyof PlanLimits, getCurrentCount: (userId: string) => Promise<number>) {
22 return async (req: Request, res: Response, next: NextFunction) => {
23 const billing = new BillingService(prisma);
24 const currentCount = await getCurrentCount(req.user!.id);
25 const { allowed, limit } = await billing.checkLimit(req.user!.id, resource, currentCount);
26 
27 if (!allowed) {
28 res.status(403).json({
29 error: `You have reached your plan limit for ${resource}`,
30 current: currentCount,
31 limit,
32 upgradeUrl: '/billing/upgrade',
33 });
34 return;
35 }
36 
37 next();
38 };
39}
40 
41// Usage
42app.post('/api/projects',
43 requireFeature('projects'),
44 requireLimit('maxProjects', async (userId) => {
45 return prisma.project.count({ where: { ownerId: userId } });
46 }),
47 createProjectHandler,
48);
49 

Testing

typescript
1import { describe, it, expect, beforeEach } from 'vitest';
2 
3describe('BillingService', () => {
4 let service: BillingService;
5 
6 beforeEach(async () => {
7 service = new BillingService(prisma);
8 await seedTestPlan();
9 });
10 
11 it('records usage idempotently', async () => {
12 const sub = await createTestSubscription();
13 
14 await service.recordUsage(sub.id, 'api_calls', 50, 'key-1');
15 await service.recordUsage(sub.id, 'api_calls', 50, 'key-1'); // duplicate
16 
17 const total = await service.getUsageSummary(
18 sub.id, 'api_calls',
19 sub.currentPeriodStart, sub.currentPeriodEnd,
20 );
21 expect(total).toBe(50);
22 });
23 
24 it('checks feature access correctly', async () => {
25 const sub = await createTestSubscription({ features: ['analytics', 'api'] });
26 
27 expect(await service.checkFeatureAccess(sub.customerId, 'analytics')).toBe(true);
28 expect(await service.checkFeatureAccess(sub.customerId, 'sso')).toBe(false);
29 });
30 
31 it('calculates proration for upgrade', () => {
32 const current: Plan = {
33 ...basePlan, basePriceCents: 4900, name: 'Starter',
34 };
35 const upgrade: Plan = {
36 ...basePlan, basePriceCents: 9900, name: 'Pro',
37 };
38 
39 const result = calculateProration(
40 current, upgrade, 1, 1,
41 new Date('2024-01-01'), new Date('2024-02-01'),
42 );
43 
44 expect(result.netCents).toBeGreaterThan(0);
45 expect(result.chargeCents).toBeGreaterThan(result.creditCents);
46 });
47});
48 

Conclusion

TypeScript's type system provides compile-time safety for billing logic that other languages enforce only at runtime. The Plan interface with discriminated PlanComponent types ensures pricing calculations handle every component type. Prisma's generated client types catch schema mismatches between your code and database. And Zod schemas validate webhook payloads before processing, preventing runtime errors from unexpected Stripe event formats.

The billing service pattern — a single class that owns all billing mutations — creates a clear audit boundary. Every plan change, seat update, cancellation, and usage record flows through methods that can be logged, tested, and monitored independently. Combined with idempotent webhook processing and atomic Prisma transactions, this architecture handles the failure modes that billing systems encounter in production: duplicate webhooks, partial state updates, and concurrent modifications.

Start with the core: subscription CRUD, webhook sync, and feature gating. Add usage metering when you introduce usage-based pricing. Add proration when customers request mid-cycle plan changes. Each component is independent and can be added incrementally without restructuring the existing billing logic.

FAQ

Need expert help?

Building with saas engineering?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026