Skip to content

Commit 77863cc

Browse files
committed
feat(api): create subscription validation utilities (SSC-16)
- Implement hasActiveSubscription() for status checking - Implement canAccessFeature() for feature gating - Implement getSubscriptionLimits() for usage tracking - Implement hasReachedLimit() for limit enforcement - Implement getSubscriptionStatus() for display - Add trial period checking functions - Add student email validation (.edu domains) - Add tier change validation logic - Support graceful degradation to FREE tier on expiration This provides utility functions for subscription management throughout the app. Relates to SSC-16: Payment Processing & Subscription Management
1 parent a96d84e commit 77863cc

1 file changed

Lines changed: 282 additions & 0 deletions

File tree

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import { PrismaClient, SubscriptionStatus, SubscriptionTier } from '@prisma/client';
2+
import { getPlanByTier, getFeatureLimit, hasFeature } from '../config/pricing';
3+
4+
const prisma = new PrismaClient();
5+
6+
/**
7+
* Check if user has an active subscription
8+
*/
9+
export async function hasActiveSubscription(userId: string): Promise<boolean> {
10+
try {
11+
const user = await prisma.user.findUnique({
12+
where: { id: userId },
13+
select: { subscriptionStatus: true, subscriptionEnd: true },
14+
});
15+
16+
if (!user) return false;
17+
18+
// Check if status is active and not expired
19+
const isActive = user.subscriptionStatus === SubscriptionStatus.ACTIVE ||
20+
user.subscriptionStatus === SubscriptionStatus.TRIALING;
21+
22+
if (!isActive) return false;
23+
24+
// Check if subscription has ended
25+
if (user.subscriptionEnd && new Date(user.subscriptionEnd) < new Date()) {
26+
return false;
27+
}
28+
29+
return true;
30+
} catch (error) {
31+
console.error('Error checking active subscription:', error);
32+
return false;
33+
}
34+
}
35+
36+
/**
37+
* Check if user can access a specific feature
38+
*/
39+
export async function canAccessFeature(
40+
userId: string,
41+
feature: string
42+
): Promise<boolean> {
43+
try {
44+
const user = await prisma.user.findUnique({
45+
where: { id: userId },
46+
select: { subscriptionTier: true, subscriptionStatus: true },
47+
});
48+
49+
if (!user) return false;
50+
51+
// FREE tier users need to have specific features enabled
52+
if (user.subscriptionTier === SubscriptionTier.FREE) {
53+
return hasFeature('FREE', feature as any);
54+
}
55+
56+
// Check if subscription is active
57+
const isActive = await hasActiveSubscription(userId);
58+
if (!isActive) {
59+
// Subscription expired, fall back to FREE tier
60+
return hasFeature('FREE', feature as any);
61+
}
62+
63+
return hasFeature(user.subscriptionTier, feature as any);
64+
} catch (error) {
65+
console.error('Error checking feature access:', error);
66+
return false;
67+
}
68+
}
69+
70+
/**
71+
* Get user's current subscription limits
72+
*/
73+
export async function getSubscriptionLimits(userId: string): Promise<{
74+
maxCourses: number | null;
75+
maxFlashcardSets: number | null;
76+
maxQuizzes: number | null;
77+
currentCourses: number;
78+
currentFlashcardSets: number;
79+
currentQuizzes: number;
80+
}> {
81+
try {
82+
const user = await prisma.user.findUnique({
83+
where: { id: userId },
84+
select: {
85+
subscriptionTier: true,
86+
subscriptionStatus: true,
87+
flashcardSets: true,
88+
quizzes: true,
89+
},
90+
});
91+
92+
if (!user) {
93+
throw new Error('User not found');
94+
}
95+
96+
// Determine effective tier (FREE if subscription expired)
97+
let effectiveTier = user.subscriptionTier;
98+
const isActive = await hasActiveSubscription(userId);
99+
if (!isActive && effectiveTier !== SubscriptionTier.FREE) {
100+
effectiveTier = SubscriptionTier.FREE;
101+
}
102+
103+
const maxCourses = getFeatureLimit(effectiveTier, 'maxCourses');
104+
const maxFlashcardSets = getFeatureLimit(effectiveTier, 'maxFlashcardSets');
105+
const maxQuizzes = getFeatureLimit(effectiveTier, 'maxQuizzes');
106+
107+
// Count current usage
108+
const currentFlashcardSets = user.flashcardSets.length;
109+
const currentQuizzes = user.quizzes.length;
110+
111+
// For courses, we need to count unique uploads (simplified)
112+
const currentCourses = 0; // TODO: Implement course counting logic
113+
114+
return {
115+
maxCourses,
116+
maxFlashcardSets,
117+
maxQuizzes,
118+
currentCourses,
119+
currentFlashcardSets,
120+
currentQuizzes,
121+
};
122+
} catch (error) {
123+
console.error('Error getting subscription limits:', error);
124+
throw error;
125+
}
126+
}
127+
128+
/**
129+
* Check if user has reached a specific limit
130+
*/
131+
export async function hasReachedLimit(
132+
userId: string,
133+
limitType: 'courses' | 'flashcardSets' | 'quizzes'
134+
): Promise<boolean> {
135+
try {
136+
const limits = await getSubscriptionLimits(userId);
137+
138+
switch (limitType) {
139+
case 'courses':
140+
return limits.maxCourses !== null && limits.currentCourses >= limits.maxCourses;
141+
case 'flashcardSets':
142+
return limits.maxFlashcardSets !== null && limits.currentFlashcardSets >= limits.maxFlashcardSets;
143+
case 'quizzes':
144+
return limits.maxQuizzes !== null && limits.currentQuizzes >= limits.maxQuizzes;
145+
default:
146+
return false;
147+
}
148+
} catch (error) {
149+
console.error('Error checking limit:', error);
150+
return false;
151+
}
152+
}
153+
154+
/**
155+
* Get user's subscription status for display
156+
*/
157+
export async function getSubscriptionStatus(userId: string): Promise<{
158+
tier: SubscriptionTier;
159+
status: SubscriptionStatus;
160+
isActive: boolean;
161+
daysUntilRenewal: number | null;
162+
cancelAtPeriodEnd: boolean;
163+
}> {
164+
try {
165+
const user = await prisma.user.findUnique({
166+
where: { id: userId },
167+
select: {
168+
subscriptionTier: true,
169+
subscriptionStatus: true,
170+
subscriptionEnd: true,
171+
subscriptions: {
172+
where: { status: { in: ['ACTIVE', 'TRIALING', 'PAST_DUE'] } },
173+
orderBy: { createdAt: 'desc' },
174+
take: 1,
175+
},
176+
},
177+
});
178+
179+
if (!user) {
180+
throw new Error('User not found');
181+
}
182+
183+
const isActive = await hasActiveSubscription(userId);
184+
const currentSubscription = user.subscriptions[0];
185+
186+
let daysUntilRenewal: number | null = null;
187+
if (user.subscriptionEnd) {
188+
const now = new Date();
189+
const end = new Date(user.subscriptionEnd);
190+
daysUntilRenewal = Math.ceil((end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
191+
}
192+
193+
return {
194+
tier: user.subscriptionTier,
195+
status: user.subscriptionStatus,
196+
isActive,
197+
daysUntilRenewal,
198+
cancelAtPeriodEnd: currentSubscription?.cancelAtPeriodEnd || false,
199+
};
200+
} catch (error) {
201+
console.error('Error getting subscription status:', error);
202+
throw error;
203+
}
204+
}
205+
206+
/**
207+
* Check if user is on trial
208+
*/
209+
export async function isOnTrial(userId: string): Promise<boolean> {
210+
try {
211+
const user = await prisma.user.findUnique({
212+
where: { id: userId },
213+
select: { subscriptionStatus: true, trialEndsAt: true },
214+
});
215+
216+
if (!user) return false;
217+
218+
if (user.subscriptionStatus !== SubscriptionStatus.TRIALING) {
219+
return false;
220+
}
221+
222+
if (user.trialEndsAt && new Date(user.trialEndsAt) > new Date()) {
223+
return true;
224+
}
225+
226+
return false;
227+
} catch (error) {
228+
console.error('Error checking trial status:', error);
229+
return false;
230+
}
231+
}
232+
233+
/**
234+
* Get days remaining in trial
235+
*/
236+
export async function getTrialDaysRemaining(userId: string): Promise<number | null> {
237+
try {
238+
const user = await prisma.user.findUnique({
239+
where: { id: userId },
240+
select: { trialEndsAt: true },
241+
});
242+
243+
if (!user || !user.trialEndsAt) return null;
244+
245+
const now = new Date();
246+
const trialEnd = new Date(user.trialEndsAt);
247+
const daysRemaining = Math.ceil((trialEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
248+
249+
return daysRemaining > 0 ? daysRemaining : 0;
250+
} catch (error) {
251+
console.error('Error getting trial days:', error);
252+
return null;
253+
}
254+
}
255+
256+
/**
257+
* Check if user's email is a student email (.edu)
258+
*/
259+
export function isStudentEmail(email: string): boolean {
260+
return email.toLowerCase().endsWith('.edu');
261+
}
262+
263+
/**
264+
* Validate subscription tier upgrade/downgrade
265+
*/
266+
export function canChangeTier(
267+
currentTier: SubscriptionTier,
268+
newTier: SubscriptionTier
269+
): { allowed: boolean; reason?: string } {
270+
// Can't "upgrade" to FREE
271+
if (newTier === SubscriptionTier.FREE) {
272+
return { allowed: false, reason: 'Cannot downgrade to free tier. Please cancel subscription instead.' };
273+
}
274+
275+
// Already on this tier
276+
if (currentTier === newTier) {
277+
return { allowed: false, reason: 'Already subscribed to this tier.' };
278+
}
279+
280+
// All other changes are allowed
281+
return { allowed: true };
282+
}

0 commit comments

Comments
 (0)