|
| 1 | +import { PROFIT_MARGIN } from '@codebuff/common/constants/limits' |
| 2 | +import { SUBSCRIPTION_TIERS } from '@codebuff/common/constants/subscription-plans' |
| 3 | +import { db } from '@codebuff/internal/db' |
| 4 | +import * as schema from '@codebuff/internal/db/schema' |
| 5 | +import { and, eq, gte, inArray, sql } from 'drizzle-orm' |
| 6 | + |
| 7 | +const WEEKS_PER_MONTH = 4.33 |
| 8 | +const COST_PER_CREDIT = 1 / ((1 + PROFIT_MARGIN) * 100) // ~$0.009479 |
| 9 | +const EXCLUDED_EMAILS = ['jahooma@gmail.com'] |
| 10 | + |
| 11 | +interface TierAnalysis { |
| 12 | + tier: number |
| 13 | + monthlyPrice: number |
| 14 | + subscriberCount: number |
| 15 | + avgWeeklyCredits: number |
| 16 | + medianWeeklyCredits: number |
| 17 | + maxWeeklyCredits: number |
| 18 | + projectedMonthlyCredits: number |
| 19 | + projectedMonthlyCost: number |
| 20 | + monthlyRevenue: number |
| 21 | + projectedMonthlyProfit: number |
| 22 | + breakEvenCreditsPerMonth: number |
| 23 | + weeklyLimit: number |
| 24 | + avgUtilization: number |
| 25 | + subscribers: Array<{ |
| 26 | + email: string |
| 27 | + weeklyCredits: number |
| 28 | + projectedMonthlyProfit: number |
| 29 | + utilization: number |
| 30 | + }> |
| 31 | +} |
| 32 | + |
| 33 | +async function analyzeSubscriberProfitability() { |
| 34 | + const lookbackDays = Math.max(1, parseInt(process.argv[2] || '7')) |
| 35 | + const lookbackDate = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000) |
| 36 | + |
| 37 | + console.log(`\n${'='.repeat(80)}`) |
| 38 | + console.log(` SUBSCRIBER PROFITABILITY ANALYSIS`) |
| 39 | + console.log(` Lookback: ${lookbackDays} days (since ${lookbackDate.toISOString().split('T')[0]})`) |
| 40 | + console.log(` Cost per credit: $${COST_PER_CREDIT.toFixed(6)} (PROFIT_MARGIN=${PROFIT_MARGIN})`) |
| 41 | + console.log(`${'='.repeat(80)}\n`) |
| 42 | + |
| 43 | + try { |
| 44 | + // Get all active subscribers with their tier |
| 45 | + const activeSubscribers = await db |
| 46 | + .select({ |
| 47 | + userId: schema.subscription.user_id, |
| 48 | + tier: schema.subscription.tier, |
| 49 | + email: schema.user.email, |
| 50 | + billingPeriodStart: schema.subscription.billing_period_start, |
| 51 | + billingPeriodEnd: schema.subscription.billing_period_end, |
| 52 | + }) |
| 53 | + .from(schema.subscription) |
| 54 | + .leftJoin(schema.user, eq(schema.subscription.user_id, schema.user.id)) |
| 55 | + .where(eq(schema.subscription.status, 'active')) |
| 56 | + |
| 57 | + // Exclude internal emails |
| 58 | + const filteredSubscribers = activeSubscribers.filter( |
| 59 | + (s) => !EXCLUDED_EMAILS.includes(s.email ?? ''), |
| 60 | + ) |
| 61 | + |
| 62 | + console.log(`Found ${activeSubscribers.length} active subscribers (${activeSubscribers.length - filteredSubscribers.length} excluded)\n`) |
| 63 | + |
| 64 | + if (filteredSubscribers.length === 0) { |
| 65 | + console.log('No active subscribers found (after exclusions).') |
| 66 | + return |
| 67 | + } |
| 68 | + |
| 69 | + // Get subscription credit usage from the credit_ledger |
| 70 | + // Usage = principal - balance (how much of each subscription grant has been consumed) |
| 71 | + const subscriberUserIds = filteredSubscribers |
| 72 | + .filter((s) => s.userId) |
| 73 | + .map((s) => s.userId!) |
| 74 | + |
| 75 | + const usageByUser = subscriberUserIds.length > 0 |
| 76 | + ? await db |
| 77 | + .select({ |
| 78 | + userId: schema.creditLedger.user_id, |
| 79 | + totalCredits: sql<number>`COALESCE(SUM(${schema.creditLedger.principal} - ${schema.creditLedger.balance}), 0)`, |
| 80 | + }) |
| 81 | + .from(schema.creditLedger) |
| 82 | + .where( |
| 83 | + and( |
| 84 | + eq(schema.creditLedger.type, 'subscription'), |
| 85 | + gte(schema.creditLedger.created_at, lookbackDate), |
| 86 | + inArray(schema.creditLedger.user_id, subscriberUserIds), |
| 87 | + ), |
| 88 | + ) |
| 89 | + .groupBy(schema.creditLedger.user_id) |
| 90 | + : [] |
| 91 | + |
| 92 | + const usageMap = new Map( |
| 93 | + usageByUser.map((u) => [u.userId, { credits: u.totalCredits }]), |
| 94 | + ) |
| 95 | + |
| 96 | + // Group subscribers by tier and analyze |
| 97 | + const tierGroups = new Map<number, typeof filteredSubscribers>() |
| 98 | + for (const sub of filteredSubscribers) { |
| 99 | + const tier = sub.tier ?? 200 // default tier |
| 100 | + if (!tierGroups.has(tier)) tierGroups.set(tier, []) |
| 101 | + tierGroups.get(tier)!.push(sub) |
| 102 | + } |
| 103 | + |
| 104 | + const tierAnalyses: TierAnalysis[] = [] |
| 105 | + |
| 106 | + for (const [tierPrice, subscribers] of [...tierGroups.entries()].sort((a, b) => a[0] - b[0])) { |
| 107 | + const tierConfig = SUBSCRIPTION_TIERS[tierPrice as keyof typeof SUBSCRIPTION_TIERS] |
| 108 | + if (!tierConfig) { |
| 109 | + console.log(`Unknown tier: $${tierPrice} (${subscribers.length} subscribers) — skipping`) |
| 110 | + continue |
| 111 | + } |
| 112 | + |
| 113 | + const subscriberData = subscribers.map((sub) => { |
| 114 | + const usage = usageMap.get(sub.userId!) ?? { credits: 0 } |
| 115 | + // Normalize to 7-day usage for weekly projection |
| 116 | + const weeklyCredits = (usage.credits / lookbackDays) * 7 |
| 117 | + const projectedMonthlyCredits = weeklyCredits * WEEKS_PER_MONTH |
| 118 | + const projectedMonthlyCost = projectedMonthlyCredits * COST_PER_CREDIT |
| 119 | + const projectedMonthlyProfit = tierConfig.monthlyPrice - projectedMonthlyCost |
| 120 | + const utilization = tierConfig.weeklyCreditsLimit > 0 |
| 121 | + ? (weeklyCredits / tierConfig.weeklyCreditsLimit) * 100 |
| 122 | + : 0 |
| 123 | + |
| 124 | + return { |
| 125 | + email: sub.email ?? sub.userId ?? 'Unknown', |
| 126 | + weeklyCredits: Math.round(weeklyCredits), |
| 127 | + projectedMonthlyProfit: Math.round(projectedMonthlyProfit * 100) / 100, |
| 128 | + utilization: Math.round(utilization * 10) / 10, |
| 129 | + |
| 130 | + } |
| 131 | + }) |
| 132 | + |
| 133 | + // Sort by usage descending |
| 134 | + subscriberData.sort((a, b) => b.weeklyCredits - a.weeklyCredits) |
| 135 | + |
| 136 | + const weeklyCreditsArr = subscriberData.map((s) => s.weeklyCredits).sort((a, b) => a - b) |
| 137 | + const avgWeeklyCredits = weeklyCreditsArr.reduce((a, b) => a + b, 0) / (weeklyCreditsArr.length || 1) |
| 138 | + const medianWeeklyCredits = weeklyCreditsArr.length > 0 |
| 139 | + ? weeklyCreditsArr[Math.floor(weeklyCreditsArr.length / 2)] |
| 140 | + : 0 |
| 141 | + const maxWeeklyCredits = weeklyCreditsArr.length > 0 |
| 142 | + ? weeklyCreditsArr[weeklyCreditsArr.length - 1] |
| 143 | + : 0 |
| 144 | + |
| 145 | + const projectedMonthlyCredits = avgWeeklyCredits * WEEKS_PER_MONTH |
| 146 | + const projectedMonthlyCost = projectedMonthlyCredits * COST_PER_CREDIT |
| 147 | + const breakEvenCreditsPerMonth = tierConfig.monthlyPrice / COST_PER_CREDIT |
| 148 | + |
| 149 | + const analysis: TierAnalysis = { |
| 150 | + tier: tierPrice, |
| 151 | + monthlyPrice: tierConfig.monthlyPrice, |
| 152 | + subscriberCount: subscribers.length, |
| 153 | + avgWeeklyCredits: Math.round(avgWeeklyCredits), |
| 154 | + medianWeeklyCredits, |
| 155 | + maxWeeklyCredits, |
| 156 | + projectedMonthlyCredits: Math.round(projectedMonthlyCredits), |
| 157 | + projectedMonthlyCost: Math.round(projectedMonthlyCost * 100) / 100, |
| 158 | + monthlyRevenue: tierConfig.monthlyPrice * subscribers.length, |
| 159 | + projectedMonthlyProfit: Math.round((tierConfig.monthlyPrice - projectedMonthlyCost) * 100) / 100, |
| 160 | + breakEvenCreditsPerMonth: Math.round(breakEvenCreditsPerMonth), |
| 161 | + weeklyLimit: tierConfig.weeklyCreditsLimit, |
| 162 | + avgUtilization: Math.round( |
| 163 | + (avgWeeklyCredits / tierConfig.weeklyCreditsLimit) * 1000, |
| 164 | + ) / 10, |
| 165 | + subscribers: subscriberData, |
| 166 | + } |
| 167 | + |
| 168 | + tierAnalyses.push(analysis) |
| 169 | + } |
| 170 | + |
| 171 | + // Print tier-level summary |
| 172 | + console.log(`${'─'.repeat(80)}`) |
| 173 | + console.log(` TIER SUMMARY (projected from ${lookbackDays}-day usage → monthly)`) |
| 174 | + console.log(`${'─'.repeat(80)}\n`) |
| 175 | + |
| 176 | + for (const t of tierAnalyses) { |
| 177 | + const profitIcon = t.projectedMonthlyProfit >= 0 ? '✅' : '❌' |
| 178 | + const maxMonthlyCredits = t.weeklyLimit * WEEKS_PER_MONTH |
| 179 | + const maxMonthlyCost = maxMonthlyCredits * COST_PER_CREDIT |
| 180 | + |
| 181 | + console.log(` ┌─ $${t.tier}/mo Tier (${t.subscriberCount} subscriber${t.subscriberCount !== 1 ? 's' : ''})`) |
| 182 | + console.log(` │ Weekly limit: ${t.weeklyLimit.toLocaleString()} credits`) |
| 183 | + console.log(` │ Break-even: ${t.breakEvenCreditsPerMonth.toLocaleString()} credits/mo (${((t.breakEvenCreditsPerMonth / (maxMonthlyCredits)) * 100).toFixed(1)}% utilization)`) |
| 184 | + console.log(` │ Max monthly cost: $${maxMonthlyCost.toFixed(2)} (at 100% utilization)`) |
| 185 | + console.log(` │`) |
| 186 | + console.log(` │ Avg weekly usage: ${t.avgWeeklyCredits.toLocaleString()} credits (${t.avgUtilization}% of limit)`) |
| 187 | + console.log(` │ Median weekly usage: ${t.medianWeeklyCredits.toLocaleString()} credits`) |
| 188 | + console.log(` │ Max weekly usage: ${t.maxWeeklyCredits.toLocaleString()} credits`) |
| 189 | + console.log(` │`) |
| 190 | + console.log(` │ Projected avg monthly cost: $${t.projectedMonthlyCost.toFixed(2)}`) |
| 191 | + console.log(` │ ${profitIcon} Projected avg monthly profit: $${t.projectedMonthlyProfit.toFixed(2)} per subscriber`) |
| 192 | + console.log(` │ Total tier revenue: $${t.monthlyRevenue.toLocaleString()}/mo`) |
| 193 | + |
| 194 | + const totalTierCost = t.subscribers.reduce( |
| 195 | + (sum, s) => sum + (s.weeklyCredits * WEEKS_PER_MONTH * COST_PER_CREDIT), |
| 196 | + 0, |
| 197 | + ) |
| 198 | + const totalTierProfit = t.monthlyRevenue - totalTierCost |
| 199 | + const tierProfitIcon = totalTierProfit >= 0 ? '✅' : '❌' |
| 200 | + console.log(` │ ${tierProfitIcon} Total tier profit: $${totalTierProfit.toFixed(2)}/mo`) |
| 201 | + |
| 202 | + // Count profitable vs unprofitable subscribers |
| 203 | + const profitable = t.subscribers.filter((s) => s.projectedMonthlyProfit >= 0).length |
| 204 | + const unprofitable = t.subscribers.length - profitable |
| 205 | + console.log(` │ Profitable: ${profitable} | Unprofitable: ${unprofitable}`) |
| 206 | + console.log(` │`) |
| 207 | + |
| 208 | + // Show per-subscriber detail |
| 209 | + console.log(` │ Per-subscriber breakdown:`) |
| 210 | + console.log(` │ ${'Email'.padEnd(35)} ${'Wk Credits'.padStart(12)} ${'Util %'.padStart(8)} ${'Mo Profit'.padStart(12)}`) |
| 211 | + console.log(` │ ${'─'.repeat(67)}`) |
| 212 | + for (const s of t.subscribers) { |
| 213 | + const icon = s.projectedMonthlyProfit >= 0 ? '✅' : '❌' |
| 214 | + const emailTrunc = s.email.length > 33 ? s.email.slice(0, 30) + '...' : s.email |
| 215 | + console.log( |
| 216 | + ` │ ${icon} ${emailTrunc.padEnd(33)} ${s.weeklyCredits.toLocaleString().padStart(12)} ${(s.utilization + '%').padStart(8)} ${('$' + s.projectedMonthlyProfit.toFixed(2)).padStart(12)}`, |
| 217 | + ) |
| 218 | + } |
| 219 | + console.log(` └${'─'.repeat(78)}\n`) |
| 220 | + } |
| 221 | + |
| 222 | + // Overall summary |
| 223 | + console.log(`${'═'.repeat(80)}`) |
| 224 | + console.log(` OVERALL SUMMARY`) |
| 225 | + console.log(`${'═'.repeat(80)}\n`) |
| 226 | + |
| 227 | + const totalSubscribers = tierAnalyses.reduce((s, t) => s + t.subscriberCount, 0) |
| 228 | + const totalRevenue = tierAnalyses.reduce((s, t) => s + t.monthlyRevenue, 0) |
| 229 | + const totalProjectedCost = tierAnalyses.reduce((s, t) => { |
| 230 | + return s + t.subscribers.reduce( |
| 231 | + (sum, sub) => sum + (sub.weeklyCredits * WEEKS_PER_MONTH * COST_PER_CREDIT), |
| 232 | + 0, |
| 233 | + ) |
| 234 | + }, 0) |
| 235 | + const totalProfit = totalRevenue - totalProjectedCost |
| 236 | + const profitableCount = tierAnalyses.reduce( |
| 237 | + (s, t) => s + t.subscribers.filter((sub) => sub.projectedMonthlyProfit >= 0).length, |
| 238 | + 0, |
| 239 | + ) |
| 240 | + const unprofitableCount = totalSubscribers - profitableCount |
| 241 | + |
| 242 | + console.log(` Total subscribers: ${totalSubscribers}`) |
| 243 | + console.log(` Total monthly revenue: $${totalRevenue.toLocaleString()}`) |
| 244 | + console.log(` Total projected cost: $${totalProjectedCost.toFixed(2)}`) |
| 245 | + console.log(` ${totalProfit >= 0 ? '✅' : '❌'} Net projected profit: $${totalProfit.toFixed(2)}/mo`) |
| 246 | + console.log(` Profitable subscribers: ${profitableCount}/${totalSubscribers} (${((profitableCount / (totalSubscribers || 1)) * 100).toFixed(0)}%)`) |
| 247 | + console.log(` Unprofitable subscribers: ${unprofitableCount}/${totalSubscribers}`) |
| 248 | + console.log(` Avg profit margin: ${totalRevenue > 0 ? ((totalProfit / totalRevenue) * 100).toFixed(1) : 0}%`) |
| 249 | + console.log() |
| 250 | + } catch (error) { |
| 251 | + console.error('Error analyzing subscriber profitability:', error) |
| 252 | + } |
| 253 | +} |
| 254 | + |
| 255 | +analyzeSubscriberProfitability() |
| 256 | + .then(() => process.exit(0)) |
| 257 | + .catch((error) => { |
| 258 | + console.error('Failed:', error) |
| 259 | + process.exit(1) |
| 260 | + }) |
0 commit comments