Skip to content

Commit 1f22025

Browse files
authored
fix: keep feature flag overrides when upgrading (#4934)
Currently any plan change results in the override of the plan flags to the default values of the new plan. Any override that was set is discarded. (ie: an enabled flag or a manually increased limit will be lost) This PR is ensuring flags aren't reset if more generous that the new plan default when upgrading or migrating. Downgrading always results in applying default flags for new plan. <!-- Summary by @propel-code-bot --> --- **Preserve feature-flag overrides on plan upgrades/migrations** Adds logic so that when a team upgrades or migrates to a different billing plan, any flag that was previously overridden to a *more generous* value is retained. A full reset to new-plan defaults still happens on downgrades. Key pieces are the new `mergeFlags` helper in `packages/shared/lib/services/plans/plans.ts` and the downgrade-detection utility `isPotentialDowngrade` in `packages/shared/lib/services/plans/definitions.ts`. `handlePlanChanged()` now calls `mergeFlags` and applies the merged set instead of blindly spreading `newPlan.flags`. A comprehensive Vitest suite validates merge behaviour across upgrade, migration and downgrade scenarios. <details> <summary><strong>Key Changes</strong></summary> • Introduced `mergeFlags` to combine `currentPlan` and `newPlanDefinition.flags` while preserving overrides when appropriate • Injected `mergedFlags` into `updatePlanByTeam()` inside `handlePlanChanged()` instead of raw `newPlan.flags` • Added downgrade detection via `isPotentialDowngrade` with an explicit downgrade matrix • Exposed helper `getPlanDefinition` for tests and internal lookup • Added 100+ line unit test `plans.unit.test.ts` covering downgrade, upgrade and migration paths • Minor type additions (`PlanDefinition`) and import adjustments </details> <details> <summary><strong>Affected Areas</strong></summary> • `packages/shared/lib/services/plans/plans.ts` (plan update flow) • `packages/shared/lib/services/plans/definitions.ts` (plan metadata helpers) • `packages/shared/lib/services/plans/plans.unit.test.ts` (new tests) </details> --- *This summary was automatically generated by @propel-code-bot*
1 parent e1f7598 commit 1f22025

File tree

3 files changed

+358
-3
lines changed

3 files changed

+358
-3
lines changed

packages/shared/lib/services/plans/definitions.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,3 +303,119 @@ export const plansList: PlanDefinition[] = [
303303
scaleLegacyPlan,
304304
growthLegacyPlan
305305
];
306+
307+
export function getPlanDefinition(code: PlanDefinition['code']): PlanDefinition | null {
308+
const plan = plansList.find((p) => p.code === code);
309+
return plan || null;
310+
}
311+
export function isPotentialDowngrade({ from, to }: { from: PlanDefinition['code']; to: PlanDefinition['code'] }): boolean {
312+
// Matrix defining whether moving from one plan to another is a downgrade
313+
// true = downgrade, false = not a downgrade
314+
// Plan hierarchy: free < starter < growth < enterprise
315+
// Account for all possible combinations, not just the acceptable transitions defined in nextPlan/prevPlan (ie: manual changes in billing system)
316+
// v2 plans are equivalent to their non-v2 counterparts (lateral moves = not downgrades)
317+
// legacy plans are equivalent to their current counterparts (lateral moves = not downgrades)
318+
// scale-legacy is positioned between growth and enterprise
319+
const downgradeMatrix = {
320+
free: {
321+
free: false,
322+
'starter-v2': false,
323+
'growth-v2': false,
324+
starter: false,
325+
growth: false,
326+
enterprise: false,
327+
'starter-legacy': false,
328+
'scale-legacy': false,
329+
'growth-legacy': false
330+
},
331+
'starter-v2': {
332+
free: true,
333+
'starter-v2': false,
334+
'growth-v2': false,
335+
starter: false,
336+
growth: false,
337+
enterprise: false,
338+
'starter-legacy': false,
339+
'scale-legacy': false,
340+
'growth-legacy': false
341+
},
342+
'growth-v2': {
343+
free: true,
344+
'starter-v2': true,
345+
'growth-v2': false,
346+
starter: true,
347+
growth: false,
348+
enterprise: false,
349+
'starter-legacy': true,
350+
'scale-legacy': false,
351+
'growth-legacy': false
352+
},
353+
starter: {
354+
free: true,
355+
'starter-v2': false,
356+
'growth-v2': false,
357+
starter: false,
358+
growth: false,
359+
enterprise: false,
360+
'starter-legacy': false,
361+
'scale-legacy': false,
362+
'growth-legacy': false
363+
},
364+
growth: {
365+
free: true,
366+
'starter-v2': true,
367+
'growth-v2': false,
368+
starter: true,
369+
growth: false,
370+
enterprise: false,
371+
'starter-legacy': true,
372+
'scale-legacy': false,
373+
'growth-legacy': false
374+
},
375+
enterprise: {
376+
free: true,
377+
'starter-v2': true,
378+
'growth-v2': true,
379+
starter: true,
380+
growth: true,
381+
enterprise: false,
382+
'starter-legacy': true,
383+
'scale-legacy': true,
384+
'growth-legacy': true
385+
},
386+
'starter-legacy': {
387+
free: true,
388+
'starter-v2': false,
389+
'growth-v2': false,
390+
starter: false,
391+
growth: false,
392+
enterprise: false,
393+
'starter-legacy': false,
394+
'scale-legacy': false,
395+
'growth-legacy': false
396+
},
397+
'growth-legacy': {
398+
free: true,
399+
'starter-v2': true,
400+
'growth-v2': false,
401+
starter: true,
402+
growth: false,
403+
enterprise: false,
404+
'starter-legacy': true,
405+
'scale-legacy': false,
406+
'growth-legacy': false
407+
},
408+
'scale-legacy': {
409+
free: true,
410+
'starter-v2': true,
411+
'growth-v2': true,
412+
starter: true,
413+
growth: true,
414+
enterprise: false,
415+
'starter-legacy': true,
416+
'scale-legacy': false,
417+
'growth-legacy': true
418+
}
419+
} satisfies Record<PlanDefinition['code'], Record<PlanDefinition['code'], boolean>>;
420+
return downgradeMatrix[from][to];
421+
}

packages/shared/lib/services/plans/plans.ts

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import ms from 'ms';
22

33
import { Err, Ok, flagHasPlan } from '@nangohq/utils';
44

5-
import { freePlan, plansList } from './definitions.js';
5+
import { freePlan, isPotentialDowngrade, plansList } from './definitions.js';
66
import { productTracking } from '../../utils/productTracking.js';
77

8-
import type { DBPlan, DBTeam } from '@nangohq/types';
8+
import type { DBPlan, DBTeam, PlanDefinition } from '@nangohq/types';
99
import type { Result } from '@nangohq/utils';
1010
import type { Knex } from 'knex';
1111

@@ -148,6 +148,9 @@ export async function handlePlanChanged(
148148
return Ok(true);
149149
}
150150

151+
// Merge current plan flags with new plan defaults
152+
const mergedFlags = mergeFlags({ currentPlan: currentPlan.value, newPlanDefinition: newPlan });
153+
151154
// Only update subscription date from free to paid (undefined = no update)
152155
const isCurrentFree = currentPlan.value.name === freePlan.code;
153156
const isNewPaid = newPlan.code !== freePlan.code;
@@ -160,7 +163,7 @@ export async function handlePlanChanged(
160163
orb_future_plan_at: null,
161164
...(orbCustomerId ? { orb_customer_id: orbCustomerId } : {}),
162165
...(isCurrentFree && isNewPaid ? { orb_subscribed_at: new Date() } : {}),
163-
...newPlan.flags
166+
...mergedFlags
164167
});
165168

166169
if (updated.isErr()) {
@@ -175,3 +178,118 @@ export async function handlePlanChanged(
175178

176179
return Ok(true);
177180
}
181+
182+
export function mergeFlags({ currentPlan, newPlanDefinition }: { currentPlan: DBPlan; newPlanDefinition: PlanDefinition }): PlanDefinition['flags'] {
183+
// Downgrades always use new plan defaults and reset any overrides
184+
if (isPotentialDowngrade({ from: currentPlan.name, to: newPlanDefinition.code })) {
185+
return newPlanDefinition.flags;
186+
}
187+
188+
const overrides: Partial<PlanDefinition['flags']> = {};
189+
const keys = Object.keys(currentPlan) as (keyof DBPlan)[];
190+
for (const key of keys) {
191+
const isFlagKey = ((key: keyof DBPlan): key is keyof PlanDefinition['flags'] & keyof DBPlan => {
192+
return key in newPlanDefinition.flags;
193+
})(key);
194+
195+
// Skip keys that are not plan flags
196+
if (!isFlagKey) continue;
197+
198+
// Skip undefined values in new plan flags
199+
if (newPlanDefinition.flags[key] === undefined) continue;
200+
201+
switch (key) {
202+
// These are not plan flags, skip them
203+
case 'stripe_customer_id':
204+
case 'stripe_payment_id':
205+
case 'orb_customer_id':
206+
case 'orb_subscription_id':
207+
case 'orb_future_plan':
208+
case 'orb_future_plan_at':
209+
case 'orb_subscribed_at':
210+
case 'trial_start_at':
211+
case 'trial_end_at':
212+
case 'trial_extension_count':
213+
case 'trial_end_notified_at':
214+
case 'trial_expired':
215+
case 'created_at':
216+
case 'updated_at':
217+
break;
218+
// BOOLEAN FLAGS - keep override if false
219+
case 'auto_idle': {
220+
overrides[key] = !currentPlan[key] ? false : newPlanDefinition.flags[key];
221+
break;
222+
}
223+
// BOOLEAN FLAGS - keep override if true
224+
case 'has_otel':
225+
case 'has_sync_variants':
226+
case 'has_webhooks_script':
227+
case 'has_webhooks_forward':
228+
case 'can_disable_connect_ui_watermark':
229+
case 'can_override_docs_connect_url':
230+
case 'can_customize_connect_ui_theme': {
231+
overrides[key] = currentPlan[key] ? true : newPlanDefinition.flags[key];
232+
break;
233+
}
234+
// NUMBER FLAGS - keep override if higher, null means unlimited
235+
case 'webhook_forwards_max':
236+
case 'monthly_actions_max':
237+
case 'monthly_active_records_max':
238+
case 'connections_max':
239+
case 'records_max':
240+
case 'proxy_max':
241+
case 'function_executions_max':
242+
case 'function_compute_gbms_max':
243+
case 'function_logs_max': {
244+
const currentValue = currentPlan[key];
245+
const newValue = newPlanDefinition.flags[key] || 0;
246+
if (currentValue === null || currentValue > newValue) {
247+
overrides[key] = null;
248+
}
249+
break;
250+
}
251+
// NUMBER FLAGS - keep override if higher
252+
case 'environments_max': {
253+
const currentValue = currentPlan[key];
254+
const newValue = newPlanDefinition.flags[key] || 0;
255+
if (currentValue > newValue) {
256+
overrides[key] = currentValue;
257+
}
258+
break;
259+
}
260+
// NUMBER FLAGS - keep override if lower
261+
case 'sync_frequency_secs_min': {
262+
const currentValue = currentPlan[key];
263+
const newValue = newPlanDefinition.flags[key] || 0;
264+
if (currentValue < newValue) {
265+
overrides[key] = currentValue;
266+
}
267+
break;
268+
}
269+
// SPECIAL CASES
270+
case 'api_rate_limit_size': {
271+
const sizeIndex: Record<DBPlan['api_rate_limit_size'], number> = {
272+
s: 1,
273+
m: 2,
274+
l: 3,
275+
xl: 4,
276+
'2xl': 5,
277+
'3xl': 6,
278+
'4xl': 7
279+
};
280+
const currentIndex = sizeIndex[currentPlan[key]];
281+
const newIndex = sizeIndex[newPlanDefinition.flags[key]];
282+
if (currentIndex > newIndex) {
283+
overrides[key] = currentPlan[key];
284+
}
285+
break;
286+
}
287+
default:
288+
((_exhaustiveCheck: never) => {
289+
throw new Error(`Unhandled plan flag key in mergeFlags: ${key}`);
290+
})(key);
291+
}
292+
}
293+
294+
return { ...newPlanDefinition.flags, ...overrides };
295+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { getPlanDefinition } from './definitions.js';
4+
import { mergeFlags } from './plans.js';
5+
6+
import type { DBPlan, PlanDefinition } from '@nangohq/types';
7+
8+
describe('mergeFlags', () => {
9+
describe('when downgrading', () => {
10+
it('should reset all flags to new plan default values, including overrides', () => {
11+
const currentPlan = makePlan({
12+
code: 'starter-v2',
13+
flagOverrides: {
14+
environments_max: 99,
15+
api_rate_limit_size: 'xl',
16+
has_otel: true,
17+
proxy_max: 99_999_999
18+
}
19+
});
20+
const newPlanDefinition = getPlanDefinition('free')!;
21+
const newFlags = mergeFlags({
22+
currentPlan,
23+
newPlanDefinition
24+
});
25+
26+
expect(newFlags).toMatchObject(newPlanDefinition.flags);
27+
});
28+
});
29+
30+
describe.each([
31+
{ from: 'free', to: 'starter-v2' }, // upgrade from free
32+
{ from: 'starter-v2', to: 'growth-v2' }, // upgrade from paid
33+
{ from: 'starter', to: 'starter-v2' }, // migration
34+
{ from: 'starter-legacy', to: 'starter-v2' }, // migration
35+
{ from: 'starter', to: 'growth-v2' }, // upgrade and migration
36+
{ from: 'starter-legacy', to: 'growth-v2' } // upgrade and migration
37+
] as { from: PlanDefinition['code']; to: PlanDefinition['code'] }[])('when upgrading/migrating from $from to $to', ({ from, to }) => {
38+
it('should apply new plan defaults if no overrides', () => {
39+
const currentPlan = makePlan({ code: from, flagOverrides: {} });
40+
const newPlanDefinition = getPlanDefinition(to)!;
41+
const newFlags = mergeFlags({
42+
currentPlan,
43+
newPlanDefinition
44+
});
45+
expect(newFlags).toMatchObject(newPlanDefinition.flags);
46+
});
47+
it('should apply new plan defaults and keep more generous overrides', () => {
48+
const currentPlan = makePlan({
49+
code: from,
50+
flagOverrides: {
51+
environments_max: 50,
52+
has_otel: true,
53+
api_rate_limit_size: '2xl',
54+
proxy_max: 99_999_999,
55+
auto_idle: true,
56+
can_disable_connect_ui_watermark: false
57+
}
58+
});
59+
const newPlanDefinition = getPlanDefinition(to)!;
60+
const newFlags = mergeFlags({
61+
currentPlan,
62+
newPlanDefinition
63+
});
64+
65+
expect(newFlags).toMatchObject({
66+
...newPlanDefinition.flags,
67+
environments_max: 50, // Keep override
68+
has_otel: true, // Keep override
69+
api_rate_limit_size: '2xl' // Keep override
70+
// proxy_max: new plan more generous default (null)
71+
// auto_idle: new plan more generous default (false)
72+
// can_disable_connect_ui_watermark: new plan more generous default (true)
73+
});
74+
});
75+
});
76+
});
77+
78+
function makePlan({ code, flagOverrides }: { code: DBPlan['name']; flagOverrides: PlanDefinition['flags'] }): DBPlan {
79+
const defaultPlanDefinition = getPlanDefinition(code)!;
80+
return {
81+
id: 1,
82+
account_id: 1,
83+
name: code,
84+
created_at: new Date(),
85+
updated_at: new Date(),
86+
stripe_customer_id: null,
87+
stripe_payment_id: null,
88+
orb_customer_id: null,
89+
orb_subscription_id: null,
90+
orb_future_plan: null,
91+
orb_future_plan_at: null,
92+
orb_subscribed_at: null,
93+
trial_start_at: null,
94+
trial_end_at: null,
95+
trial_extension_count: 0,
96+
trial_end_notified_at: null,
97+
trial_expired: null,
98+
api_rate_limit_size: 'm',
99+
monthly_actions_max: null,
100+
monthly_active_records_max: null,
101+
sync_frequency_secs_min: 3600,
102+
auto_idle: false,
103+
has_sync_variants: false,
104+
has_otel: false,
105+
has_webhooks_forward: false,
106+
has_webhooks_script: false,
107+
can_customize_connect_ui_theme: false,
108+
can_override_docs_connect_url: false,
109+
can_disable_connect_ui_watermark: false,
110+
environments_max: 2,
111+
connections_max: null,
112+
records_max: null,
113+
proxy_max: null,
114+
function_executions_max: null,
115+
function_compute_gbms_max: null,
116+
webhook_forwards_max: null,
117+
function_logs_max: null,
118+
...defaultPlanDefinition,
119+
...flagOverrides
120+
};
121+
}

0 commit comments

Comments
 (0)