Skip to content

Commit 004811b

Browse files
committed
Add script to analyze subscription usage
1 parent 8573955 commit 004811b

File tree

1 file changed

+260
-0
lines changed

1 file changed

+260
-0
lines changed
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
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

Comments
 (0)