quota.ts
1 // ============================================================================ 2 // GapZero — Quota Check & Increment Helpers 3 // Server-side only. Used by API routes to enforce usage limits. 4 // ============================================================================ 5 6 import type { SupabaseClient } from '@supabase/supabase-js'; 7 import type { QuotaType, QuotaCheck, UserQuotaStatus, UserQuotaRow } from './types'; 8 9 /** Column mapping from QuotaType to DB column names */ 10 const USED_COLUMN: Record<QuotaType, keyof UserQuotaRow> = { 11 analysis: 'analyses_used', 12 cv_generation: 'cv_generations_used', 13 cover_letter: 'cover_letters_used', 14 coach_request: 'coach_requests_used', 15 }; 16 17 const LIMIT_COLUMN: Record<QuotaType, keyof UserQuotaRow> = { 18 analysis: 'analyses_limit', 19 cv_generation: 'cv_limit', 20 cover_letter: 'cover_letter_limit', 21 coach_request: 'coach_limit', 22 }; 23 24 /** Pro plan limits */ 25 const PRO_LIMITS = { 26 analyses_limit: 50, 27 cv_limit: 50, 28 cover_letter_limit: 50, 29 coach_limit: 50, 30 } as const; 31 32 /** Free plan limits */ 33 const FREE_LIMITS = { 34 analyses_limit: 1, 35 cv_limit: 1, 36 cover_letter_limit: 1, 37 coach_limit: 0, 38 } as const; 39 40 /** Get next Monday 00:00 UTC as ISO string */ 41 function getNextMonday(): string { 42 const now = new Date(); 43 const dayOfWeek = now.getUTCDay(); // 0 = Sunday 44 const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek; 45 const nextMonday = new Date(now); 46 nextMonday.setUTCDate(now.getUTCDate() + daysUntilMonday); 47 nextMonday.setUTCHours(0, 0, 0, 0); 48 return nextMonday.toISOString(); 49 } 50 51 /** Ensure quota row exists (lazy-create for users created before migration) */ 52 async function ensureQuotaRow( 53 client: SupabaseClient, 54 userId: string 55 ): Promise<UserQuotaRow> { 56 const { data, error } = await client 57 .from('user_quotas') 58 .select('*') 59 .eq('user_id', userId) 60 .single(); 61 62 if (data) return data as UserQuotaRow; 63 64 // Row doesn't exist — create it (handles users from before migration) 65 if (error?.code === 'PGRST116') { 66 const { data: inserted, error: insertErr } = await client 67 .from('user_quotas') 68 .insert({ user_id: userId }) 69 .select('*') 70 .single(); 71 72 if (insertErr) throw new Error(`Failed to create quota row: ${insertErr.message}`); 73 return inserted as UserQuotaRow; 74 } 75 76 throw new Error(`Failed to fetch quota: ${error?.message}`); 77 } 78 79 /** 80 * Check whether the user has quota remaining for the given type. 81 * Does NOT increment — call incrementQuota() after the action succeeds. 82 */ 83 export async function checkQuota( 84 client: SupabaseClient, 85 userId: string, 86 type: QuotaType 87 ): Promise<QuotaCheck> { 88 const row = await ensureQuotaRow(client, userId); 89 90 const used = row[USED_COLUMN[type]] as number; 91 const limit = row[LIMIT_COLUMN[type]] as number; 92 const resetAt = getNextMonday(); 93 94 // Special case: first-ever analysis is free (doesn't count toward limit) 95 if (type === 'analysis' && !row.has_used_initial_analysis) { 96 return { 97 allowed: true, 98 used, 99 limit, 100 plan: row.plan, 101 isInitialAnalysis: true, 102 resetAt, 103 }; 104 } 105 106 return { 107 allowed: used < limit, 108 used, 109 limit, 110 plan: row.plan, 111 resetAt, 112 }; 113 } 114 115 /** 116 * Increment the usage counter after a successful action. 117 * For initial analysis, marks the flag instead of incrementing. 118 */ 119 export async function incrementQuota( 120 client: SupabaseClient, 121 userId: string, 122 type: QuotaType, 123 isInitialAnalysis = false 124 ): Promise<void> { 125 if (type === 'analysis' && isInitialAnalysis) { 126 const { error } = await client 127 .from('user_quotas') 128 .update({ has_used_initial_analysis: true, updated_at: new Date().toISOString() }) 129 .eq('user_id', userId); 130 if (error) throw new Error(`Failed to mark initial analysis used: ${error.message}`); 131 return; 132 } 133 134 const col = USED_COLUMN[type]; 135 136 // Fetch current value and increment (no RPC needed for simple +1) 137 const row = await ensureQuotaRow(client, userId); 138 const currentVal = row[col] as number; 139 140 const { error } = await client 141 .from('user_quotas') 142 .update({ [col]: currentVal + 1, updated_at: new Date().toISOString() }) 143 .eq('user_id', userId); 144 if (error) throw new Error(`Failed to increment quota for ${type}: ${error.message}`); 145 } 146 147 /** 148 * Mark a user's initial analysis as used (without incrementing weekly counter). 149 */ 150 export async function markInitialAnalysisUsed( 151 client: SupabaseClient, 152 userId: string 153 ): Promise<void> { 154 const { error } = await client 155 .from('user_quotas') 156 .update({ has_used_initial_analysis: true, updated_at: new Date().toISOString() }) 157 .eq('user_id', userId); 158 if (error) throw new Error(`Failed to mark initial analysis used: ${error.message}`); 159 } 160 161 /** 162 * Get full quota status for a user (used by GET /api/quota). 163 */ 164 export async function getQuotaStatus( 165 client: SupabaseClient, 166 userId: string 167 ): Promise<UserQuotaStatus> { 168 const row = await ensureQuotaRow(client, userId); 169 170 return { 171 plan: row.plan, 172 weekStart: row.week_start, 173 resetAt: getNextMonday(), 174 analysis: { used: row.analyses_used, limit: row.analyses_limit }, 175 cvGeneration: { used: row.cv_generations_used, limit: row.cv_limit }, 176 coverLetter: { used: row.cover_letters_used, limit: row.cover_letter_limit }, 177 coachRequest: { used: row.coach_requests_used, limit: row.coach_limit }, 178 hasUsedInitialAnalysis: row.has_used_initial_analysis, 179 subscription: row.stripe_subscription_id 180 ? { 181 status: row.subscription_status, 182 periodEnd: row.subscription_period_end, 183 } 184 : null, 185 }; 186 } 187 188 /** 189 * Upgrade a user to Pro plan (called from Stripe webhook). 190 */ 191 export async function upgradeToPro( 192 client: SupabaseClient, 193 userId: string, 194 stripeCustomerId: string, 195 stripeSubscriptionId: string, 196 periodEnd: string 197 ): Promise<void> { 198 const { error } = await client 199 .from('user_quotas') 200 .update({ 201 plan: 'pro', 202 ...PRO_LIMITS, 203 stripe_customer_id: stripeCustomerId, 204 stripe_subscription_id: stripeSubscriptionId, 205 subscription_status: 'active', 206 subscription_period_end: periodEnd, 207 updated_at: new Date().toISOString(), 208 }) 209 .eq('user_id', userId); 210 if (error) throw new Error(`Failed to upgrade user to Pro: ${error.message}`); 211 } 212 213 /** 214 * Downgrade a user to Free plan (called from Stripe webhook on cancel). 215 */ 216 export async function downgradeToFree( 217 client: SupabaseClient, 218 userId: string 219 ): Promise<void> { 220 const { error } = await client 221 .from('user_quotas') 222 .update({ 223 plan: 'free', 224 ...FREE_LIMITS, 225 subscription_status: 'canceled', 226 updated_at: new Date().toISOString(), 227 }) 228 .eq('user_id', userId); 229 if (error) throw new Error(`Failed to downgrade user to Free: ${error.message}`); 230 } 231 232 /** 233 * Update subscription status (called from Stripe webhook). 234 */ 235 export async function updateSubscriptionStatus( 236 client: SupabaseClient, 237 userId: string, 238 status: 'active' | 'past_due' | 'canceled' | 'trialing', 239 periodEnd?: string 240 ): Promise<void> { 241 const update: Record<string, unknown> = { 242 subscription_status: status, 243 updated_at: new Date().toISOString(), 244 }; 245 if (periodEnd) update.subscription_period_end = periodEnd; 246 247 const { error } = await client 248 .from('user_quotas') 249 .update(update) 250 .eq('user_id', userId); 251 if (error) throw new Error(`Failed to update subscription status: ${error.message}`); 252 }