|
| 1 | +/** |
| 2 | + * Analyzes which platform subscription catalog tiers best match usage for hosts with an active |
| 3 | + * PlatformSubscription, for a chosen calendar month (default: previous month, UTC). |
| 4 | + * |
| 5 | + * Usage: |
| 6 | + * npx ts-node scripts/billing/analyze-platform-plan-fit.ts |
| 7 | + * npx ts-node scripts/billing/analyze-platform-plan-fit.ts --month 2026-03 |
| 8 | + */ |
| 9 | + |
| 10 | +import '../../server/env'; |
| 11 | + |
| 12 | +import { Command } from 'commander'; |
| 13 | +import { merge } from 'lodash'; |
| 14 | +import moment from 'moment'; |
| 15 | +import { Op } from 'sequelize'; |
| 16 | + |
| 17 | +import { CollectiveType } from '../../server/constants/collectives'; |
| 18 | +import { PlatformSubscriptionPlan, PlatformSubscriptionTiers } from '../../server/constants/plans'; |
| 19 | +import logger from '../../server/lib/logger'; |
| 20 | +import { |
| 21 | + BillingMonth, |
| 22 | + BillingPeriod, |
| 23 | + estimateMonthlyPriceForPlan, |
| 24 | + getCostOptimalPlatformTiersForUtilization, |
| 25 | + getPlanChangeType, |
| 26 | + PeriodUtilization, |
| 27 | + PlatformPlanSuggestion, |
| 28 | +} from '../../server/models/PlatformSubscription'; |
| 29 | +import { Collective, PlatformSubscription, sequelize } from '../../server/models'; |
| 30 | + |
| 31 | +function parseMonthOption(value: string): BillingPeriod { |
| 32 | + const m = moment.utc(value, 'YYYY-MM', true); |
| 33 | + if (!m.isValid()) { |
| 34 | + throw new Error(`Invalid --month "${value}". Use YYYY-MM (e.g. 2026-03).`); |
| 35 | + } |
| 36 | + return { year: m.year(), month: m.month() as BillingMonth }; |
| 37 | +} |
| 38 | + |
| 39 | +function defaultPreviousMonth(): BillingPeriod { |
| 40 | + const ref = moment.utc().subtract(1, 'month'); |
| 41 | + return { year: ref.year(), month: ref.month() as BillingMonth }; |
| 42 | +} |
| 43 | + |
| 44 | +function resolveStoredPlan(plan: Partial<PlatformSubscriptionPlan>): PlatformSubscriptionPlan | null { |
| 45 | + const key = plan.basePlanId ?? plan.id; |
| 46 | + if (!key) { |
| 47 | + return null; |
| 48 | + } |
| 49 | + const catalog = PlatformSubscriptionTiers.find(t => t.id === key); |
| 50 | + if (!catalog) { |
| 51 | + return null; |
| 52 | + } |
| 53 | + return merge({}, catalog, plan, { basePlanId: catalog.id }) as PlatformSubscriptionPlan; |
| 54 | +} |
| 55 | + |
| 56 | +function pickPrimarySuggestion(suggestions: PlatformPlanSuggestion[]): PlatformPlanSuggestion { |
| 57 | + return suggestions.reduce((best, cur) => { |
| 58 | + const ib = PlatformSubscriptionTiers.findIndex(t => t.id === best.plan.id); |
| 59 | + const ic = PlatformSubscriptionTiers.findIndex(t => t.id === cur.plan.id); |
| 60 | + if (ib === -1) { |
| 61 | + return cur; |
| 62 | + } |
| 63 | + if (ic === -1) { |
| 64 | + return best; |
| 65 | + } |
| 66 | + return ic < ib ? cur : best; |
| 67 | + }); |
| 68 | +} |
| 69 | + |
| 70 | +type Bucket = 'ok' | 'downgrade' | 'upgrade' | 'review'; |
| 71 | + |
| 72 | +function classifyRow(args: { |
| 73 | + suggestions: PlatformPlanSuggestion[]; |
| 74 | + currentPlan: Partial<PlatformSubscriptionPlan>; |
| 75 | + utilization: PeriodUtilization; |
| 76 | +}): { bucket: Bucket; reason?: string } { |
| 77 | + const { suggestions, currentPlan, utilization } = args; |
| 78 | + if (suggestions.length === 0) { |
| 79 | + return { bucket: 'review', reason: 'no_suggestions' }; |
| 80 | + } |
| 81 | + |
| 82 | + const suggestionIds = new Set(suggestions.map(s => s.plan.id)); |
| 83 | + const currentKey = currentPlan.basePlanId ?? currentPlan.id; |
| 84 | + const minPrice = suggestions[0].estimatedPricePerMonth; |
| 85 | + const merged = resolveStoredPlan(currentPlan); |
| 86 | + |
| 87 | + let isRight = false; |
| 88 | + if (currentKey !== undefined && suggestionIds.has(String(currentKey))) { |
| 89 | + isRight = true; |
| 90 | + } else if (merged) { |
| 91 | + const currentEst = estimateMonthlyPriceForPlan(merged, utilization); |
| 92 | + isRight = currentEst === minPrice; |
| 93 | + } |
| 94 | + |
| 95 | + const primary = pickPrimarySuggestion(suggestions); |
| 96 | + const currentForCompare = merged ?? currentPlan; |
| 97 | + const change = getPlanChangeType(currentForCompare, primary.plan); |
| 98 | + |
| 99 | + const types = new Set(suggestions.map(s => s.plan.type)); |
| 100 | + const ambiguousTiers = types.size > 1; |
| 101 | + |
| 102 | + if (isRight) { |
| 103 | + return ambiguousTiers ? { bucket: 'ok', reason: 'optimal_cost_tier_tie' } : { bucket: 'ok' }; |
| 104 | + } |
| 105 | + |
| 106 | + if (change === 'CUSTOM') { |
| 107 | + return { bucket: 'review', reason: 'custom_or_unknown_tier' }; |
| 108 | + } |
| 109 | + |
| 110 | + if (ambiguousTiers) { |
| 111 | + return { bucket: 'review', reason: 'multiple_tier_types_at_min_price' }; |
| 112 | + } |
| 113 | + |
| 114 | + if (change === 'DOWNGRADE') { |
| 115 | + return { bucket: 'downgrade' }; |
| 116 | + } |
| 117 | + if (change === 'UPGRADE') { |
| 118 | + return { bucket: 'upgrade' }; |
| 119 | + } |
| 120 | + |
| 121 | + return { bucket: 'review', reason: 'no_change_but_not_optimal' }; |
| 122 | +} |
| 123 | + |
| 124 | +export async function loadHostsWithActivePlatformSubscription(): Promise<Collective[]> { |
| 125 | + const subs = await PlatformSubscription.findAll({ |
| 126 | + where: { |
| 127 | + deletedAt: null, |
| 128 | + period: { [Op.contains]: new Date() }, |
| 129 | + }, |
| 130 | + include: [ |
| 131 | + { |
| 132 | + model: Collective, |
| 133 | + as: 'collective', |
| 134 | + required: true, |
| 135 | + where: { |
| 136 | + [Op.and]: [ |
| 137 | + { type: CollectiveType.ORGANIZATION, hasMoneyManagement: true, deletedAt: null }, |
| 138 | + sequelize.literal(`(data->>'isFirstPartyHost')::boolean IS NOT TRUE`), |
| 139 | + ], |
| 140 | + }, |
| 141 | + }, |
| 142 | + ], |
| 143 | + }); |
| 144 | + |
| 145 | + return subs.map(s => s.collective).filter((c): c is Collective => Boolean(c)); |
| 146 | +} |
| 147 | + |
| 148 | +export async function main(): Promise<void> { |
| 149 | + const program = new Command(); |
| 150 | + program |
| 151 | + .option('--month <YYYY-MM>', 'Calendar month in UTC (default: previous month)') |
| 152 | + .parse(process.argv); |
| 153 | + |
| 154 | + const opts = program.opts<{ month?: string }>(); |
| 155 | + const billingPeriod = opts.month ? parseMonthOption(opts.month) : defaultPreviousMonth(); |
| 156 | + |
| 157 | + const monthLabel = `${billingPeriod.year}-${String(billingPeriod.month + 1).padStart(2, '0')}`; |
| 158 | + logger.info(`=== Platform plan fit analysis (${monthLabel} UTC) ===`); |
| 159 | + |
| 160 | + const hosts = await loadHostsWithActivePlatformSubscription(); |
| 161 | + logger.info(`Hosts with active platform subscription (non–first-party orgs, money management): ${hosts.length}`); |
| 162 | + |
| 163 | + if (hosts.length === 0) { |
| 164 | + return; |
| 165 | + } |
| 166 | + |
| 167 | + const ids = hosts.map(h => h.id); |
| 168 | + const utilizationById = await PlatformSubscription.calculateUtilizationForCollectives(ids, billingPeriod); |
| 169 | + const suggestionsById = await PlatformSubscription.suggestPlans(ids, billingPeriod); |
| 170 | + |
| 171 | + const sections: Record<Bucket, Collective[]> = { |
| 172 | + ok: [], |
| 173 | + downgrade: [], |
| 174 | + upgrade: [], |
| 175 | + review: [], |
| 176 | + }; |
| 177 | + |
| 178 | + const rows: Array<{ |
| 179 | + bucket: Bucket; |
| 180 | + slug: string; |
| 181 | + id: number; |
| 182 | + utilization: PeriodUtilization; |
| 183 | + currentKey: string | number; |
| 184 | + suggestions: PlatformPlanSuggestion[]; |
| 185 | + reason?: string; |
| 186 | + }> = []; |
| 187 | + |
| 188 | + for (const host of hosts) { |
| 189 | + const sub = await PlatformSubscription.getCurrentSubscription(host.id); |
| 190 | + if (!sub) { |
| 191 | + rows.push({ |
| 192 | + bucket: 'review', |
| 193 | + slug: host.slug, |
| 194 | + id: host.id, |
| 195 | + utilization: utilizationById.get(host.id) ?? { activeCollectives: 0, expensesPaid: 0 }, |
| 196 | + currentKey: 'none', |
| 197 | + suggestions: suggestionsById.get(host.id) ?? [], |
| 198 | + reason: 'no_current_subscription', |
| 199 | + }); |
| 200 | + sections.review.push(host); |
| 201 | + continue; |
| 202 | + } |
| 203 | + |
| 204 | + const utilization = utilizationById.get(host.id) ?? { activeCollectives: 0, expensesPaid: 0 }; |
| 205 | + const suggestions = suggestionsById.get(host.id) ?? getCostOptimalPlatformTiersForUtilization(utilization); |
| 206 | + const currentKey = sub.plan.basePlanId ?? sub.plan.id ?? 'unknown'; |
| 207 | + const { bucket, reason } = classifyRow({ |
| 208 | + suggestions, |
| 209 | + currentPlan: sub.plan, |
| 210 | + utilization, |
| 211 | + }); |
| 212 | + |
| 213 | + rows.push({ |
| 214 | + bucket, |
| 215 | + slug: host.slug, |
| 216 | + id: host.id, |
| 217 | + utilization, |
| 218 | + currentKey, |
| 219 | + suggestions, |
| 220 | + reason, |
| 221 | + }); |
| 222 | + sections[bucket].push(host); |
| 223 | + } |
| 224 | + |
| 225 | + const printSection = (title: string, bucket: Bucket) => { |
| 226 | + const list = rows.filter(r => r.bucket === bucket); |
| 227 | + logger.info(''); |
| 228 | + logger.info(`── ${title} (${list.length}) ──`); |
| 229 | + for (const r of list) { |
| 230 | + const sug = r.suggestions.map(s => `${s.plan.id} ($${(s.estimatedPricePerMonth / 100).toFixed(2)}/mo)`).join(', '); |
| 231 | + const util = `collectives=${r.utilization.activeCollectives}, expenses=${r.utilization.expensesPaid}`; |
| 232 | + const extra = r.reason ? ` [${r.reason}]` : ''; |
| 233 | + logger.info(` @${r.slug} id=${r.id} ${util} current=${r.currentKey} → ${sug}${extra}`); |
| 234 | + } |
| 235 | + }; |
| 236 | + |
| 237 | + printSection('Right plan (optimal or matching tier)', 'ok'); |
| 238 | + printSection('Should ideally be downgraded', 'downgrade'); |
| 239 | + printSection('Should ideally be upgraded', 'upgrade'); |
| 240 | + printSection('Review (custom tier, ties, or ambiguous)', 'review'); |
| 241 | + |
| 242 | + logger.info(''); |
| 243 | + logger.info( |
| 244 | + `Summary: ok=${sections.ok.length} downgrade=${sections.downgrade.length} upgrade=${sections.upgrade.length} review=${sections.review.length}`, |
| 245 | + ); |
| 246 | +} |
| 247 | + |
| 248 | +if (module === require.main) { |
| 249 | + main().catch(e => { |
| 250 | + logger.error(e); |
| 251 | + process.exit(1); |
| 252 | + }); |
| 253 | +} |
0 commit comments