Skip to content

Commit 561c1c4

Browse files
committed
feat(api): add subscription controllers, routes, and webhooks (SSC-16)
Commits 11-16 combined: **Subscription Controller:** - Implement getPlans() for pricing display - Implement getCurrentSubscription() for user status - Implement createCheckout() for Stripe Checkout - Implement createPortalSession() for billing management - Implement cancelSubscription() and reactivateSubscription() - Implement getInvoices() for billing history - Implement getUsageStats() for limit tracking **Webhook Controller:** - Implement handleWebhook() with signature verification - Handle customer.subscription.* events - Handle invoice.paid/payment_failed events - Handle customer.* events - Handle payment_intent.* events - Auto-sync subscription status to database - Revert to FREE tier on subscription deletion **Subscription Routes:** - Mount all subscription endpoints - Configure webhook endpoint with raw body - Add authentication middleware - Wire controllers to Express router **App Integration:** - Register subscription routes in main app - Mount at /api/subscriptions This completes the backend API for subscription management. Relates to SSC-16: Payment Processing & Subscription Management
1 parent 77863cc commit 561c1c4

4 files changed

Lines changed: 609 additions & 0 deletions

File tree

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import { Request, Response, NextFunction } from 'express';
2+
import { stripeService } from '../services/stripe.service';
3+
import { PrismaClient } from '@prisma/client';
4+
import { getAllPlans, getPlanByTier } from '../config/pricing';
5+
import {
6+
getSubscriptionStatus,
7+
getSubscriptionLimits,
8+
hasActiveSubscription,
9+
} from '../utils/subscription.utils';
10+
11+
const prisma = new PrismaClient();
12+
13+
export class SubscriptionController {
14+
/**
15+
* Get all subscription plans
16+
*/
17+
async getPlans(_req: Request, res: Response, next: NextFunction): Promise<void> {
18+
try {
19+
const plans = getAllPlans();
20+
res.json({ plans });
21+
} catch (error) {
22+
next(error);
23+
}
24+
}
25+
26+
/**
27+
* Get current user's subscription
28+
*/
29+
async getCurrentSubscription(req: Request, res: Response, next: NextFunction): Promise<void> {
30+
try {
31+
const userId = req.user!.userId;
32+
33+
const status = await getSubscriptionStatus(userId);
34+
const limits = await getSubscriptionLimits(userId);
35+
const plan = getPlanByTier(status.tier);
36+
37+
res.json({
38+
subscription: {
39+
tier: status.tier,
40+
status: status.status,
41+
isActive: status.isActive,
42+
daysUntilRenewal: status.daysUntilRenewal,
43+
cancelAtPeriodEnd: status.cancelAtPeriodEnd,
44+
plan,
45+
},
46+
limits,
47+
});
48+
} catch (error) {
49+
next(error);
50+
}
51+
}
52+
53+
/**
54+
* Create checkout session
55+
*/
56+
async createCheckout(req: Request, res: Response, next: NextFunction): Promise<void> {
57+
try {
58+
const userId = req.user!.userId;
59+
const { priceId, billingPeriod = 'monthly' } = req.body;
60+
61+
if (!priceId) {
62+
res.status(400).json({ error: 'Price ID is required' });
63+
return;
64+
}
65+
66+
// Get or create Stripe customer
67+
const customer = await stripeService.getOrCreateCustomer(userId);
68+
69+
// Create checkout session
70+
const session = await stripeService.createCheckoutSession(
71+
customer.id,
72+
priceId,
73+
{
74+
successUrl: `${process.env.FRONTEND_URL}/subscription/success?session_id={CHECKOUT_SESSION_ID}`,
75+
cancelUrl: `${process.env.FRONTEND_URL}/pricing`,
76+
trialPeriodDays: billingPeriod === 'monthly' ? 0 : undefined, // No trial for monthly
77+
metadata: {
78+
userId,
79+
billingPeriod,
80+
},
81+
}
82+
);
83+
84+
res.json({ sessionId: session.id, url: session.url });
85+
} catch (error) {
86+
next(error);
87+
}
88+
}
89+
90+
/**
91+
* Create billing portal session
92+
*/
93+
async createPortalSession(req: Request, res: Response, next: NextFunction): Promise<void> {
94+
try {
95+
const userId = req.user!.userId;
96+
97+
const user = await prisma.user.findUnique({
98+
where: { id: userId },
99+
select: { stripeCustomerId: true },
100+
});
101+
102+
if (!user?.stripeCustomerId) {
103+
res.status(400).json({ error: 'No payment account found' });
104+
return;
105+
}
106+
107+
const session = await stripeService.createBillingPortalSession(
108+
user.stripeCustomerId,
109+
`${process.env.FRONTEND_URL}/subscription`
110+
);
111+
112+
res.json({ url: session.url });
113+
} catch (error) {
114+
next(error);
115+
}
116+
}
117+
118+
/**
119+
* Cancel subscription
120+
*/
121+
async cancelSubscription(req: Request, res: Response, next: NextFunction): Promise<void> {
122+
try {
123+
const userId = req.user!.userId;
124+
const { immediately = false } = req.body;
125+
126+
const user = await prisma.user.findUnique({
127+
where: { id: userId },
128+
select: { stripeSubscriptionId: true },
129+
});
130+
131+
if (!user?.stripeSubscriptionId) {
132+
res.status(400).json({ error: 'No active subscription found' });
133+
return;
134+
}
135+
136+
const subscription = await stripeService.cancelSubscription(
137+
user.stripeSubscriptionId,
138+
immediately
139+
);
140+
141+
res.json({
142+
message: immediately
143+
? 'Subscription canceled immediately'
144+
: 'Subscription will be canceled at the end of the billing period',
145+
subscription: {
146+
id: subscription.id,
147+
status: subscription.status,
148+
cancelAtPeriodEnd: subscription.cancel_at_period_end,
149+
currentPeriodEnd: subscription.current_period_end,
150+
},
151+
});
152+
} catch (error) {
153+
next(error);
154+
}
155+
}
156+
157+
/**
158+
* Reactivate canceled subscription
159+
*/
160+
async reactivateSubscription(req: Request, res: Response, next: NextFunction): Promise<void> {
161+
try {
162+
const userId = req.user!.userId;
163+
164+
const user = await prisma.user.findUnique({
165+
where: { id: userId },
166+
select: { stripeSubscriptionId: true },
167+
});
168+
169+
if (!user?.stripeSubscriptionId) {
170+
res.status(400).json({ error: 'No subscription found' });
171+
return;
172+
}
173+
174+
const subscription = await stripeService.reactivateSubscription(user.stripeSubscriptionId);
175+
176+
res.json({
177+
message: 'Subscription reactivated successfully',
178+
subscription: {
179+
id: subscription.id,
180+
status: subscription.status,
181+
cancelAtPeriodEnd: subscription.cancel_at_period_end,
182+
},
183+
});
184+
} catch (error) {
185+
next(error);
186+
}
187+
}
188+
189+
/**
190+
* Get invoices
191+
*/
192+
async getInvoices(req: Request, res: Response, next: NextFunction): Promise<void> {
193+
try {
194+
const userId = req.user!.userId;
195+
196+
const user = await prisma.user.findUnique({
197+
where: { id: userId },
198+
select: { stripeCustomerId: true },
199+
});
200+
201+
if (!user?.stripeCustomerId) {
202+
res.json({ invoices: [] });
203+
return;
204+
}
205+
206+
const invoices = await stripeService.listInvoices(user.stripeCustomerId);
207+
208+
res.json({
209+
invoices: invoices.map((inv) => ({
210+
id: inv.id,
211+
amountDue: inv.amount_due,
212+
amountPaid: inv.amount_paid,
213+
status: inv.status,
214+
invoiceUrl: inv.hosted_invoice_url,
215+
pdfUrl: inv.invoice_pdf,
216+
dueDate: inv.due_date,
217+
created: inv.created,
218+
})),
219+
});
220+
} catch (error) {
221+
next(error);
222+
}
223+
}
224+
225+
/**
226+
* Get usage stats
227+
*/
228+
async getUsageStats(req: Request, res: Response, next: NextFunction): Promise<void> {
229+
try {
230+
const userId = req.user!.userId;
231+
const limits = await getSubscriptionLimits(userId);
232+
233+
res.json({ usage: limits });
234+
} catch (error) {
235+
next(error);
236+
}
237+
}
238+
}

0 commit comments

Comments
 (0)