1
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