Skip to content

Commit 71817f2

Browse files
authored
fix(subscription): restrict Neo purchase on web, show full new catalog (#9974)
## What Restrict the deprecated **Neo** (`unlimited`) plan from the **web** purchase catalog, and make web render the full new plan lineup. Web (`X-App-Platform: web`) previously hid nothing from the catalog, so Neo was offered for purchase alongside the new tiers — even though Neo is already hidden on mobile and desktop. This closes that gap and shows the intended catalog on web. ### Backend (`utils/subscription.py`) - Added `WEB_PLATFORMS = {'web'}`. - `should_show_new_plans('web')` → always `True` (web is an always-latest, version-agnostic client) → no legacy adaptation, so plans render under canonical titles. - `_platform_hidden_plans('web')` → `{unlimited}` (Neo only). - Net result: web shows **Plus + Unlimited + Operator + Architect**; Neo is hidden from purchase but stays visible to existing Neo subscribers via `filter_plans_for_user`'s current-plan / ever-purchased escapes (so they can still manage/cancel). ### Web frontend Now that web renders the full new catalog, an Operator/Architect subscriber's current plan reaches web as its real id (Plus/Unlimited still wire to `unlimited`). To avoid mis-showing them as Free: - `types/user.ts` — widened the `Subscription.plan` union. - `lib/api.ts` — `is_unlimited` = any paid tier (for the Manage-vs-Choose-Plan UI gate). - `SettingsPage.tsx` — display labels for the new plans. ## Why Consistency + product intent: Neo is deprecated everywhere; web was the only surface still selling it. Existing Neo subscribers are untouched (migration handled separately). ## Reusable guard Adds `test_neo_hidden_from_purchase_on_every_client_platform` — asserts Neo is hidden from purchase for a new user on **every** real client platform (ios/android/macos/windows/web). The same cause (a platform omitted from the Neo-hidden set) previously shipped for **Windows** (`test_filter_plans_hides_neo_on_windows_for_new_user`); this pins the invariant so the next platform added can't silently reintroduce the deprecated-plan-for-sale bug. ## Testing - Backend: added web catalog tests + the all-platforms guard; inverted the stale `keeps_neo_on_web` test (it conflated web with `platform=None`; real web sends `'web'`). All affected tests pass individually, and the pre-existing mobile/desktop/windows catalog tests still pass. - Note: the whole-file `pytest` run fails locally on a **pre-existing** Prometheus re-registration artifact under the local 3.12 venv (verified identical failures on clean `main` with the same venv); the 3.11 CI runner handles it. - Web: `tsc --noEmit` → exit 0. - Not run end-to-end: the live web→Stripe checkout (needs the running Next.js app + Stripe). The added tests exercise the exact catalog-resolution functions both `/v1/payments/available-plans` and `/v1/users/me/subscription` call. ## Product invariants None affected (`scripts/pr-preflight --suggest` → `none`). Failure-Class: none <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/BasedHardware/omi/pull/9974?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
2 parents 901a9ed + 0030a4e commit 71817f2

5 files changed

Lines changed: 108 additions & 8 deletions

File tree

backend/tests/unit/test_subscription_restructure.py

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,35 @@ def test_filter_plans_shows_neo_on_mobile_for_current_neo_subscriber(load_subscr
184184
assert 'unlimited' in [d['plan_id'] for d in filtered]
185185

186186

187-
def test_filter_plans_keeps_neo_on_web_for_new_user(load_subscription):
188-
"""Web / unknown platform is unaffected — Neo stays in the catalog."""
187+
def test_filter_plans_hides_neo_on_web_for_new_user(load_subscription):
188+
"""Web sells the full new catalog (Plus + Unlimited + Operator + Architect);
189+
deprecated Neo is hidden from the web purchase catalog.
190+
191+
Regression: web (X-App-Platform: web) previously hid nothing, so Neo was
192+
offered for purchase alongside the new tiers. Neo purchase is restricted to
193+
existing subscribers only.
194+
"""
195+
with load_subscription() as sub_mod:
196+
definitions = sub_mod.get_paid_plan_definitions()
197+
filtered = sub_mod.filter_plans_for_user(definitions, PlanType.basic, platform='web', ever_purchased=False)
198+
plan_ids = [d['plan_id'] for d in filtered]
199+
assert 'unlimited' not in plan_ids # Neo hidden
200+
assert 'plus' in plan_ids
201+
assert 'unlimited_v2' in plan_ids
202+
assert 'operator' in plan_ids
203+
assert 'architect' in plan_ids
204+
205+
206+
def test_filter_plans_shows_neo_on_web_for_current_neo_subscriber(load_subscription):
207+
"""Existing Neo subscribers still see Neo on web so they can manage/cancel."""
208+
with load_subscription() as sub_mod:
209+
definitions = sub_mod.get_paid_plan_definitions()
210+
filtered = sub_mod.filter_plans_for_user(definitions, PlanType.unlimited, platform='web', ever_purchased=True)
211+
assert 'unlimited' in [d['plan_id'] for d in filtered]
212+
213+
214+
def test_filter_plans_keeps_neo_for_unknown_platform(load_subscription):
215+
"""A header-less / unknown platform is still unfiltered — Neo stays visible."""
189216
with load_subscription() as sub_mod:
190217
definitions = sub_mod.get_paid_plan_definitions()
191218
filtered = sub_mod.filter_plans_for_user(definitions, PlanType.basic, platform=None, ever_purchased=False)
@@ -208,6 +235,26 @@ def test_filter_plans_hides_neo_on_windows_for_new_user(load_subscription):
208235
assert 'architect' in plan_ids
209236

210237

238+
def test_neo_hidden_from_purchase_on_every_client_platform(load_subscription):
239+
"""Reusable guard: the deprecated Neo plan is never offered for purchase to a
240+
new user on ANY real client platform.
241+
242+
Twice now a platform was omitted from the Neo-hidden set and started offering
243+
Neo: first Windows (only 'macos' was hidden), then web (hid nothing). This
244+
pins the invariant across every X-App-Platform a client actually sends, so the
245+
next platform added can't silently reintroduce the deprecated-plan-for-sale bug.
246+
"""
247+
with load_subscription() as sub_mod:
248+
definitions = sub_mod.get_paid_plan_definitions()
249+
for platform in ('ios', 'android', 'macos', 'windows', 'web'):
250+
filtered = sub_mod.filter_plans_for_user(
251+
definitions, PlanType.basic, platform=platform, ever_purchased=False
252+
)
253+
plan_ids = [d['plan_id'] for d in filtered]
254+
assert 'unlimited' not in plan_ids, (platform, plan_ids)
255+
assert plan_ids, platform # never an empty catalog
256+
257+
211258
def test_windows_full_catalog_matches_macos_canonical(load_subscription):
212259
"""End-to-end catalog resolution for a Windows client (X-App-Platform: windows).
213260
@@ -308,6 +355,40 @@ def test_version_gating_windows_always_new(load_subscription):
308355
assert sub_mod.should_show_new_plans('windows', 'not.a.version') is True
309356

310357

358+
def test_version_gating_web_always_new(load_subscription):
359+
"""Web is an always-latest client: always gets the new catalog, version-agnostic."""
360+
with load_subscription() as sub_mod:
361+
assert 'web' in sub_mod.WEB_PLATFORMS
362+
assert sub_mod.should_show_new_plans('web', None) is True
363+
assert sub_mod.should_show_new_plans('web', '0.0.1') is True
364+
assert sub_mod.should_show_new_plans('web', '99.99.999') is True
365+
assert sub_mod.should_show_new_plans('Web', '1.0.0') is True # case-insensitive
366+
367+
368+
def test_web_full_catalog_shows_new_plans_and_hides_neo(load_subscription):
369+
"""End-to-end catalog resolution for a web client (X-App-Platform: web).
370+
371+
Web renders the full new catalog under canonical titles — Plus + Unlimited
372+
(mobile tiers) AND Operator + Architect (desktop tiers) — never the legacy
373+
'Omi Pro' / 'Unlimited Plan' rename, and never deprecated Neo for a new user.
374+
"""
375+
with load_subscription() as sub_mod:
376+
new_plans_enabled = sub_mod.should_show_new_plans('web', None)
377+
assert new_plans_enabled is True
378+
definitions = sub_mod.get_paid_plan_definitions()
379+
# No legacy adaptation for web (new_plans_enabled) → raw canonical catalog.
380+
filtered = sub_mod.filter_plans_for_user(definitions, PlanType.basic, platform='web', ever_purchased=False)
381+
by_id = {d['plan_id']: d for d in filtered}
382+
assert set(by_id) == {'plus', 'unlimited_v2', 'operator', 'architect'}, by_id
383+
assert 'unlimited' not in by_id # Neo hidden
384+
assert by_id['operator']['title'] == 'Operator'
385+
assert by_id['architect']['title'] == 'Architect'
386+
assert by_id['unlimited_v2']['title'] == 'Unlimited'
387+
titles = [d['title'] for d in filtered]
388+
assert 'Omi Pro' not in titles
389+
assert 'Unlimited Plan' not in titles
390+
391+
311392
def test_version_gating_mobile_requires_version(load_subscription):
312393
"""Mobile requires version header and must meet minimum."""
313394
with load_subscription() as sub_mod:

backend/utils/subscription.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -464,19 +464,27 @@ def get_paid_plan_definitions() -> List[Dict[str, Any]]:
464464
# Platform identifiers for the two mobile clients (X-App-Platform header).
465465
_MOBILE_PLATFORM_TOKENS = {'ios', 'android'}
466466

467+
# The web storefront (X-App-Platform: web). It's an always-latest client that
468+
# renders the full new catalog (Plus + Unlimited + Operator + Architect) and is
469+
# the primary Stripe checkout surface; only deprecated Neo is hidden there.
470+
WEB_PLATFORMS = {'web'}
471+
467472

468473
def _platform_hidden_plans(platform: Optional[str]) -> Set[PlanType]:
469474
"""Plans hidden from the purchase catalog per platform.
470475
471-
Mobile sells Plus + Unlimited; desktop sells Operator + Architect; Neo is
472-
deprecated on both. Web is unfiltered. A subscriber on a hidden plan still
473-
sees it via `filter_plans_for_user`'s current-plan / ever-purchased escapes.
476+
Mobile sells Plus + Unlimited; desktop sells Operator + Architect; web sells
477+
all four. Neo is deprecated everywhere and hidden on every platform. A
478+
subscriber on a hidden plan still sees it via `filter_plans_for_user`'s
479+
current-plan / ever-purchased escapes.
474480
"""
475481
p = (platform or '').lower()
476482
if p in _MOBILE_PLATFORM_TOKENS:
477483
return {PlanType.unlimited, PlanType.operator, PlanType.architect}
478484
if p in DESKTOP_PLATFORMS:
479485
return {PlanType.unlimited, PlanType.plus, PlanType.unlimited_v2}
486+
if p in WEB_PLATFORMS:
487+
return {PlanType.unlimited}
480488
return set()
481489

482490

@@ -561,13 +569,17 @@ def should_show_new_plans(platform: Optional[str], app_version: Optional[str]) -
561569
Mobile (android/ios): any build at or above NEW_PLANS_MIN_MOBILE_VERSION
562570
qualifies; a missing or unparseable version defaults to the legacy catalog
563571
(old mobile builds crash on the operator enum).
572+
Web: always the new catalog (it's an always-latest client, version-agnostic).
564573
Unknown platform: legacy catalog.
565574
"""
566575
if not platform:
567576
return False
568577

569578
platform_lower = platform.lower()
570579

580+
if platform_lower in WEB_PLATFORMS:
581+
return True
582+
571583
if platform_lower in DESKTOP_PLATFORMS:
572584
if not app_version:
573585
return True

web/app/src/components/settings/SettingsPage.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -933,7 +933,10 @@ function UsageSectionContent({
933933
};
934934

935935
const getPlanDisplayName = (plan: string) => {
936-
if (plan === 'unlimited') return 'Unlimited';
936+
if (plan === 'unlimited' || plan === 'unlimited_v2') return 'Unlimited';
937+
if (plan === 'plus') return 'Plus';
938+
if (plan === 'operator') return 'Operator';
939+
if (plan === 'architect') return 'Architect';
937940
if (plan === 'basic') return 'Free';
938941
return plan || 'Free';
939942
};

web/app/src/lib/api.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1397,10 +1397,14 @@ export async function getUserSubscription(): Promise<UserSubscription | null> {
13971397
'/v1/users/me/subscription',
13981398
);
13991399

1400+
// Any paid tier counts as premium for UI gating (Manage vs Choose Plan).
1401+
// Plus / Unlimited arrive wired as 'unlimited'; Operator / Architect arrive
1402+
// as their real plan id now that web renders the full new catalog.
1403+
const paidPlans = ['unlimited', 'plus', 'unlimited_v2', 'operator', 'architect'];
14001404
const result: UserSubscription = {
14011405
plan: response.subscription?.plan || 'basic',
14021406
status: response.subscription?.status || 'active',
1403-
is_unlimited: response.subscription?.plan === 'unlimited',
1407+
is_unlimited: paidPlans.includes(response.subscription?.plan ?? ''),
14041408
current_period_end: response.subscription?.current_period_end,
14051409
cancel_at_period_end: response.subscription?.cancel_at_period_end,
14061410
current_price_id: response.subscription?.current_price_id,

web/app/src/types/user.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export interface UserUsageResponse {
6363

6464
// Subscription details
6565
export interface Subscription {
66-
plan: 'basic' | 'unlimited';
66+
plan: 'basic' | 'unlimited' | 'plus' | 'unlimited_v2' | 'operator' | 'architect';
6767
status: 'active' | 'inactive';
6868
current_period_end?: number;
6969
stripe_subscription_id?: string;

0 commit comments

Comments
 (0)