Skip to content

Commit 5ad0a70

Browse files
committed
feat: script to suggest plans
1 parent eb11a74 commit 5ad0a70

File tree

3 files changed

+433
-2
lines changed

3 files changed

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

Comments
 (0)