Back to Journal
SaaS Engineering

How to Build Subscription Billing Systems Using Nestjs

Step-by-step tutorial for building Subscription Billing Systems with Nestjs, from project setup through deployment.

Muneer Puthiya Purayil 19 min read

This tutorial builds a subscription billing system in NestJS with Stripe — covering module architecture, TypeORM entities, webhook processing, billing service with dependency injection, and self-service API endpoints. NestJS's module system keeps billing logic isolated and testable.

Project Setup

bash
1nest new billing-service
2cd billing-service
3npm install @nestjs/typeorm typeorm pg stripe class-validator class-transformer uuid
4npm install -D @types/uuid
5 

Step 1: Configuration

typescript
1// src/config/billing.config.ts
2import { registerAs } from '@nestjs/config';
3 
4export default registerAs('billing', () => ({
5 stripeSecretKey: process.env.STRIPE_SECRET_KEY,
6 stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
7 appUrl: process.env.APP_URL || 'http://localhost:3000',
8}));
9 

Step 2: Entities

typescript
1// src/billing/entities/plan.entity.ts
2import { Entity, PrimaryColumn, Column, CreateDateColumn, OneToMany } from 'typeorm';
3 
4@Entity('plans')
5export class PlanEntity {
6 @PrimaryColumn() id: string;
7 @Column() name: string;
8 @Column({ unique: true }) slug: string;
9 @Column({ default: true }) active: boolean;
10 @Column({ name: 'billing_interval' }) billingInterval: string;
11 @Column({ name: 'base_price_cents', type: 'bigint' }) basePriceCents: number;
12 @Column({ name: 'price_per_seat_cents', type: 'bigint', default: 0 }) pricePerSeatCents: number;
13 @Column({ name: 'included_seats', default: 1 }) includedSeats: number;
14 @Column({ type: 'jsonb', default: [] }) features: string[];
15 @Column({ type: 'jsonb', default: {} }) limits: Record<string, number | null>;
16 @Column({ name: 'stripe_price_id' }) stripePriceId: string;
17 @CreateDateColumn({ name: 'created_at' }) createdAt: Date;
18 @OneToMany(() => SubscriptionEntity, (s) => s.plan) subscriptions: SubscriptionEntity[];
19}
20 
21// src/billing/entities/subscription.entity.ts
22import { Entity, PrimaryColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
23import { PlanEntity } from './plan.entity';
24 
25@Entity('subscriptions')
26export class SubscriptionEntity {
27 @PrimaryColumn() id: string;
28 @Column({ name: 'customer_id' }) customerId: string;
29 @Column({ name: 'plan_id' }) planId: string;
30 @ManyToOne(() => PlanEntity) @JoinColumn({ name: 'plan_id' }) plan: PlanEntity;
31 @Column({ name: 'stripe_subscription_id', unique: true }) stripeSubscriptionId: string;
32 @Column({ name: 'stripe_customer_id' }) stripeCustomerId: string;
33 @Column({ default: 'active' }) status: string;
34 @Column({ default: 1 }) quantity: number;
35 @Column({ name: 'current_period_start', type: 'timestamptz' }) currentPeriodStart: Date;
36 @Column({ name: 'current_period_end', type: 'timestamptz' }) currentPeriodEnd: Date;
37 @Column({ name: 'cancel_at_period_end', default: false }) cancelAtPeriodEnd: boolean;
38 @Column({ name: 'trial_ends_at', type: 'timestamptz', nullable: true }) trialEndsAt: Date | null;
39 @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) cancelledAt: Date | null;
40 @CreateDateColumn({ name: 'created_at' }) createdAt: Date;
41 @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date;
42}
43 
44// src/billing/entities/usage-event.entity.ts
45import { Entity, PrimaryColumn, Column, CreateDateColumn, Index } from 'typeorm';
46 
47@Entity('usage_events')
48@Index(['subscriptionId', 'metricId', 'timestamp'])
49export class UsageEventEntity {
50 @PrimaryColumn() id: string;
51 @Column({ name: 'idempotency_key', unique: true }) idempotencyKey: string;
52 @Column({ name: 'subscription_id' }) subscriptionId: string;
53 @Column({ name: 'metric_id' }) metricId: string;
54 @Column({ type: 'bigint' }) quantity: number;
55 @Column({ type: 'timestamptz' }) timestamp: Date;
56 @Column({ type: 'jsonb', default: {} }) metadata: Record<string, unknown>;
57 @CreateDateColumn({ name: 'created_at' }) createdAt: Date;
58}
59 
60// src/billing/entities/webhook-event.entity.ts
61import { Entity, PrimaryColumn, Column, CreateDateColumn } from 'typeorm';
62 
63@Entity('webhook_events')
64export class WebhookEventEntity {
65 @PrimaryColumn({ name: 'stripe_event_id' }) stripeEventId: string;
66 @Column({ name: 'event_type' }) eventType: string;
67 @CreateDateColumn({ name: 'processed_at' }) processedAt: Date;
68}
69 

Step 3: Billing Service

typescript
1// src/billing/billing.service.ts
2import { Injectable, Logger, NotFoundException } from '@nestjs/common';
3import { InjectRepository } from '@nestjs/typeorm';
4import { Repository } from 'typeorm';
5import { ConfigService } from '@nestjs/config';
6import Stripe from 'stripe';
7import { v4 as uuid } from 'uuid';
8import { PlanEntity } from './entities/plan.entity';
9import { SubscriptionEntity } from './entities/subscription.entity';
10import { UsageEventEntity } from './entities/usage-event.entity';
11 
12@Injectable()
13export class BillingService {
14 private readonly stripe: Stripe;
15 private readonly logger = new Logger(BillingService.name);
16 
17 constructor(
18 @InjectRepository(PlanEntity) private planRepo: Repository<PlanEntity>,
19 @InjectRepository(SubscriptionEntity) private subRepo: Repository<SubscriptionEntity>,
20 @InjectRepository(UsageEventEntity) private usageRepo: Repository<UsageEventEntity>,
21 private config: ConfigService,
22 ) {
23 this.stripe = new Stripe(this.config.get('billing.stripeSecretKey')!);
24 }
25 
26 async createSubscription(
27 customerId: string, planSlug: string,
28 quantity: number, stripeCustomerId: string,
29 ): Promise<SubscriptionEntity> {
30 const plan = await this.planRepo.findOneBy({ slug: planSlug, active: true });
31 if (!plan) throw new NotFoundException(`Plan ${planSlug} not found`);
32 
33 const stripeSub = await this.stripe.subscriptions.create({
34 customer: stripeCustomerId,
35 items: [{ price: plan.stripePriceId, quantity }],
36 });
37 
38 const sub = this.subRepo.create({
39 id: uuid(), customerId, planId: plan.id,
40 stripeSubscriptionId: stripeSub.id, stripeCustomerId,
41 quantity,
42 currentPeriodStart: new Date(stripeSub.current_period_start * 1000),
43 currentPeriodEnd: new Date(stripeSub.current_period_end * 1000),
44 });
45 
46 return this.subRepo.save(sub);
47 }
48 
49 async changePlan(subscriptionId: string, newPlanSlug: string) {
50 const sub = await this.subRepo.findOneByOrFail({ id: subscriptionId });
51 const newPlan = await this.planRepo.findOneByOrFail({ slug: newPlanSlug });
52 
53 const items = await this.stripe.subscriptionItems.list({
54 subscription: sub.stripeSubscriptionId,
55 });
56 
57 await this.stripe.subscriptions.update(sub.stripeSubscriptionId, {
58 items: [{ id: items.data[0].id, price: newPlan.stripePriceId }],
59 proration_behavior: 'create_prorations',
60 });
61 
62 sub.planId = newPlan.id;
63 return this.subRepo.save(sub);
64 }
65 
66 async updateSeats(subscriptionId: string, newQuantity: number) {
67 const sub = await this.subRepo.findOneByOrFail({ id: subscriptionId });
68 
69 const items = await this.stripe.subscriptionItems.list({
70 subscription: sub.stripeSubscriptionId,
71 });
72 
73 await this.stripe.subscriptions.update(sub.stripeSubscriptionId, {
74 items: [{ id: items.data[0].id, quantity: newQuantity }],
75 proration_behavior: 'create_prorations',
76 });
77 
78 sub.quantity = newQuantity;
79 return this.subRepo.save(sub);
80 }
81 
82 async cancelSubscription(subscriptionId: string, immediately = false) {
83 const sub = await this.subRepo.findOneByOrFail({ id: subscriptionId });
84 
85 if (immediately) {
86 await this.stripe.subscriptions.cancel(sub.stripeSubscriptionId);
87 sub.status = 'cancelled';
88 sub.cancelledAt = new Date();
89 } else {
90 await this.stripe.subscriptions.update(sub.stripeSubscriptionId, {
91 cancel_at_period_end: true,
92 });
93 sub.cancelAtPeriodEnd = true;
94 }
95 
96 return this.subRepo.save(sub);
97 }
98 
99 async recordUsage(
100 subscriptionId: string, metricId: string,
101 quantity: number, idempotencyKey: string,
102 ): Promise<void> {
103 const existing = await this.usageRepo.findOneBy({ idempotencyKey });
104 if (existing) return;
105 
106 await this.usageRepo.save({
107 id: uuid(), idempotencyKey, subscriptionId,
108 metricId, quantity, timestamp: new Date(),
109 });
110 }
111 
112 async getUsage(
113 subscriptionId: string, metricId: string,
114 periodStart: Date, periodEnd: Date,
115 ): Promise<number> {
116 const result = await this.usageRepo
117 .createQueryBuilder('u')
118 .select('COALESCE(SUM(u.quantity), 0)', 'total')
119 .where('u.subscription_id = :subscriptionId', { subscriptionId })
120 .andWhere('u.metric_id = :metricId', { metricId })
121 .andWhere('u.timestamp >= :periodStart', { periodStart })
122 .andWhere('u.timestamp < :periodEnd', { periodEnd })
123 .getRawOne();
124 return parseInt(result.total);
125 }
126 
127 async checkFeature(customerId: string, feature: string): Promise<boolean> {
128 const sub = await this.subRepo.findOne({
129 where: { customerId, status: 'active' },
130 relations: ['plan'],
131 });
132 if (!sub) return false;
133 return (sub.plan.features || []).includes(feature);
134 }
135 
136 async getSubscriptionByCustomer(customerId: string) {
137 return this.subRepo.findOne({
138 where: { customerId, status: 'active' },
139 relations: ['plan'],
140 });
141 }
142}
143 

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

Step 4: Webhook Service

typescript
1// src/billing/webhook.service.ts
2import { Injectable, Logger } from '@nestjs/common';
3import { InjectRepository } from '@nestjs/typeorm';
4import { Repository } from 'typeorm';
5import Stripe from 'stripe';
6import { SubscriptionEntity } from './entities/subscription.entity';
7import { WebhookEventEntity } from './entities/webhook-event.entity';
8 
9@Injectable()
10export class WebhookService {
11 private readonly logger = new Logger(WebhookService.name);
12 
13 constructor(
14 @InjectRepository(SubscriptionEntity) private subRepo: Repository<SubscriptionEntity>,
15 @InjectRepository(WebhookEventEntity) private webhookRepo: Repository<WebhookEventEntity>,
16 ) {}
17 
18 async processEvent(event: Stripe.Event): Promise<void> {
19 const existing = await this.webhookRepo.findOneBy({ stripeEventId: event.id });
20 if (existing) return;
21 
22 switch (event.type) {
23 case 'customer.subscription.updated':
24 await this.handleSubUpdated(event.data.object as Stripe.Subscription);
25 break;
26 case 'customer.subscription.deleted':
27 await this.handleSubDeleted(event.data.object as Stripe.Subscription);
28 break;
29 case 'invoice.payment_failed':
30 await this.handlePaymentFailed(event.data.object as Stripe.Invoice);
31 break;
32 case 'invoice.paid':
33 await this.handleInvoicePaid(event.data.object as Stripe.Invoice);
34 break;
35 }
36 
37 await this.webhookRepo.save({ stripeEventId: event.id, eventType: event.type });
38 }
39 
40 private async handleSubUpdated(data: Stripe.Subscription) {
41 const sub = await this.subRepo.findOneBy({ stripeSubscriptionId: data.id });
42 if (!sub) return;
43 
44 const statusMap: Record<string, string> = {
45 active: 'active', trialing: 'trialing',
46 past_due: 'past_due', canceled: 'cancelled',
47 };
48 
49 sub.status = statusMap[data.status] ?? 'active';
50 sub.quantity = data.items.data[0]?.quantity ?? 1;
51 sub.currentPeriodStart = new Date(data.current_period_start * 1000);
52 sub.currentPeriodEnd = new Date(data.current_period_end * 1000);
53 sub.cancelAtPeriodEnd = data.cancel_at_period_end;
54 await this.subRepo.save(sub);
55 }
56 
57 private async handleSubDeleted(data: Stripe.Subscription) {
58 await this.subRepo.update(
59 { stripeSubscriptionId: data.id },
60 { status: 'cancelled', cancelledAt: new Date() },
61 );
62 }
63 
64 private async handlePaymentFailed(data: Stripe.Invoice) {
65 if (!data.subscription) return;
66 const subId = typeof data.subscription === 'string' ? data.subscription : data.subscription.id;
67 await this.subRepo.update({ stripeSubscriptionId: subId }, { status: 'past_due' });
68 }
69 
70 private async handleInvoicePaid(data: Stripe.Invoice) {
71 if (!data.subscription) return;
72 const subId = typeof data.subscription === 'string' ? data.subscription : data.subscription.id;
73 const sub = await this.subRepo.findOneBy({ stripeSubscriptionId: subId });
74 if (sub?.status === 'past_due') {
75 sub.status = 'active';
76 await this.subRepo.save(sub);
77 }
78 }
79}
80 

Step 5: Controllers

typescript
1// src/billing/billing.controller.ts
2import { Controller, Get, Post, Put, Body, Req, Res, RawBodyRequest, HttpCode, UseGuards } from '@nestjs/common';
3import { Request, Response } from 'express';
4import { ConfigService } from '@nestjs/config';
5import Stripe from 'stripe';
6import { IsString, IsNumber, IsOptional, Min } from 'class-validator';
7import { BillingService } from './billing.service';
8import { WebhookService } from './webhook.service';
9import { AuthGuard } from '../auth/auth.guard';
10 
11class CreateSubDto {
12 @IsString() planSlug: string;
13 @IsNumber() @Min(1) quantity: number = 1;
14}
15class ChangePlanDto { @IsString() planSlug: string; }
16class UpdateSeatsDto { @IsNumber() @Min(1) quantity: number; }
17class CancelDto { @IsOptional() immediately?: boolean = false; }
18 
19@Controller('api/billing')
20@UseGuards(AuthGuard)
21export class BillingController {
22 constructor(private readonly billing: BillingService) {}
23 
24 @Get('subscription')
25 async getSubscription(@Req() req: Request) {
26 const sub = await this.billing.getSubscriptionByCustomer(req.user!.id);
27 if (!sub) return { error: 'No active subscription' };
28 return {
29 id: sub.id, plan: sub.plan.name, status: sub.status,
30 seats: sub.quantity, periodEnd: sub.currentPeriodEnd,
31 };
32 }
33 
34 @Post('subscription')
35 @HttpCode(201)
36 async create(@Req() req: Request, @Body() dto: CreateSubDto) {
37 const sub = await this.billing.createSubscription(
38 req.user!.id, dto.planSlug, dto.quantity, req.user!.stripeCustomerId,
39 );
40 return { subscriptionId: sub.id, status: sub.status };
41 }
42 
43 @Put('subscription/plan')
44 async changePlan(@Req() req: Request, @Body() dto: ChangePlanDto) {
45 const sub = await this.billing.getSubscriptionByCustomer(req.user!.id);
46 const updated = await this.billing.changePlan(sub!.id, dto.planSlug);
47 return { planId: updated.planId };
48 }
49 
50 @Put('subscription/seats')
51 async updateSeats(@Req() req: Request, @Body() dto: UpdateSeatsDto) {
52 const sub = await this.billing.getSubscriptionByCustomer(req.user!.id);
53 const updated = await this.billing.updateSeats(sub!.id, dto.quantity);
54 return { seats: updated.quantity };
55 }
56 
57 @Post('subscription/cancel')
58 async cancel(@Req() req: Request, @Body() dto: CancelDto) {
59 const sub = await this.billing.getSubscriptionByCustomer(req.user!.id);
60 const updated = await this.billing.cancelSubscription(sub!.id, dto.immediately);
61 return { status: updated.status };
62 }
63}
64 
65@Controller('webhooks')
66export class WebhookController {
67 private readonly stripe: Stripe;
68 
69 constructor(
70 private readonly webhookService: WebhookService,
71 private readonly config: ConfigService,
72 ) {
73 this.stripe = new Stripe(this.config.get('billing.stripeSecretKey')!);
74 }
75 
76 @Post('stripe')
77 @HttpCode(200)
78 async handleWebhook(@Req() req: RawBodyRequest<Request>) {
79 const sig = req.headers['stripe-signature'] as string;
80 const event = this.stripe.webhooks.constructEvent(
81 req.rawBody!, sig, this.config.get('billing.stripeWebhookSecret')!,
82 );
83 await this.webhookService.processEvent(event);
84 return { received: true };
85 }
86}
87 

Step 6: Module

typescript
1// src/billing/billing.module.ts
2import { Module } from '@nestjs/common';
3import { TypeOrmModule } from '@nestjs/typeorm';
4import { ConfigModule } from '@nestjs/config';
5import { PlanEntity } from './entities/plan.entity';
6import { SubscriptionEntity } from './entities/subscription.entity';
7import { UsageEventEntity } from './entities/usage-event.entity';
8import { WebhookEventEntity } from './entities/webhook-event.entity';
9import { BillingService } from './billing.service';
10import { WebhookService } from './webhook.service';
11import { BillingController, WebhookController } from './billing.controller';
12import billingConfig from '../config/billing.config';
13 
14@Module({
15 imports: [
16 ConfigModule.forFeature(billingConfig),
17 TypeOrmModule.forFeature([PlanEntity, SubscriptionEntity, UsageEventEntity, WebhookEventEntity]),
18 ],
19 providers: [BillingService, WebhookService],
20 controllers: [BillingController, WebhookController],
21 exports: [BillingService],
22})
23export class BillingModule {}
24 

Step 7: Feature Guard

typescript
1// src/billing/guards/feature.guard.ts
2import { CanActivate, ExecutionContext, Injectable, SetMetadata } from '@nestjs/common';
3import { Reflector } from '@nestjs/core';
4import { BillingService } from '../billing.service';
5 
6export const REQUIRED_FEATURE = 'required_feature';
7export const RequireFeature = (feature: string) => SetMetadata(REQUIRED_FEATURE, feature);
8 
9@Injectable()
10export class FeatureGuard implements CanActivate {
11 constructor(
12 private reflector: Reflector,
13 private billing: BillingService,
14 ) {}
15 
16 async canActivate(context: ExecutionContext): Promise<boolean> {
17 const feature = this.reflector.get<string>(REQUIRED_FEATURE, context.getHandler());
18 if (!feature) return true;
19 
20 const request = context.switchToHttp().getRequest();
21 return this.billing.checkFeature(request.user.id, feature);
22 }
23}
24 

Conclusion

NestJS's module system creates a clean boundary around billing logic. The BillingModule encapsulates entities, services, and controllers, exporting only the BillingService for other modules to consume. Feature gating through custom guards (FeatureGuard) integrates billing checks into the request pipeline without coupling controllers to billing internals.

The webhook controller uses NestJS's RawBodyRequest type to access the raw request body for Stripe signature verification — a common gotcha where parsed JSON bodies fail signature validation. The WebhookService processes events idempotently, making the handler safe for Stripe's retry mechanism.

For production, enable NestJS's raw body parsing in main.ts with app.useBodyParser('raw', { type: 'application/json' }) for the webhook endpoint. Add health checks via @nestjs/terminus and metrics via a Prometheus module to monitor webhook processing latency and subscription state transitions.

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