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: {},
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