From 221c1857cecb26e43ace0860f854d52862a2113d Mon Sep 17 00:00:00 2001 From: Forged2Form <9682873+formed2forge@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:13:42 -0400 Subject: [PATCH 1/7] docs: add pricing scheme touchpoint inventory Maps every place across mobile/desktop/web/backend/docs where plan names, prices, or feature-limit numbers are displayed, hardcoded, or tested, ahead of the new pricing scheme. Distinguishes live-read values (no update needed) from hardcoded duplicates, and lists surfaces (App Store/Play listings, ad copy, socials) confirmed absent from this repo that need a separate non-code audit. Co-Authored-By: Claude Sonnet 5 --- .../pricing-scheme-touchpoint-inventory.md | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 docs/agents/pricing-scheme-touchpoint-inventory.md diff --git a/docs/agents/pricing-scheme-touchpoint-inventory.md b/docs/agents/pricing-scheme-touchpoint-inventory.md new file mode 100644 index 00000000000..8c515d8858b --- /dev/null +++ b/docs/agents/pricing-scheme-touchpoint-inventory.md @@ -0,0 +1,172 @@ +# Pricing Scheme Touchpoint Inventory + +This repo already has an in-flight catalog migration — `docs/agents/plan-source-of-truth.md` and `docs/agents/plan-catalog.md` — which owns plan identity, entitlement numbers, and the Stripe price/product ledger; Stripe remains the sole authority for actual dollar amounts. This document is the broader map: every place across the codebase where pricing, plan names, or feature-limit numbers surface at all, including non-architectural touchpoints (docs, tests, marketing copy, assets) that the catalog migration doesn't itself track. Within each table, hardcoded/copy items that will need a manual touch are listed above `reads_live_no_update_needed` items, which are included for completeness/confirmation only. + +## Flutter mobile app (app/) — pricing, plans, and paywall + +| Location | Kind | Needs update when prices/tiers change? | +|---|---|---| +| `app/lib/l10n/app_en.arb:10581` `planDeprecationMessage` (+ `app_fr.arb:2941`, other locales) | hardcoded_amount | Yes — literal "$49/mo" Operator price baked into localized copy; currently no Dart call sites found (may be dead) | +| `app/lib/l10n/app_en.arb:5843` `monthlyPayoutsDescription` | hardcoded_amount | Yes, if the $10 payout threshold changes (app-developer payout copy, not subscriber pricing) | +| `app/lib/pages/settings/widgets/plans_sheet.dart:2083-2094` `_tierGrantsDesktop` | config_value | Yes — client-side copy of desktop-entitled plan set; must mirror backend `DESKTOP_ENTITLED_PLAN_TYPES` | +| `app/lib/models/subscription.dart:86-88` `PlanType.grantsDesktop` | config_value | Yes — second independent copy of the same desktop-entitlement mapping | +| `app/lib/models/subscription.dart:77-79` doc comment | feature_limit_number | Yes (comment only) — "1500 min/month" for Plus will read stale if the real limit changes; runtime value itself is live | +| `app/test/utils/plan_pricing_test.dart:17-27,47-56` | test_fixture | Yes (comments/docs) — fixtures encode today's real Plus/Unlimited/Neo prices; math functions themselves are price-agnostic | +| `app/test/unit/plans_sheet_l10n_test.dart:60-63,186,193-198` | test_fixture | Yes — one assertion hardcodes formatted string "$161.91" (today's real Plus annual price) | +| `app/lib/l10n/app_en.arb:10668,10676,10684` `neoSubtitle`/`operatorSubtitle`/`architectSubtitle` | plan_name_or_tier_copy | Yes, if resurrected — currently no call sites found outside generated l10n files | +| `app/lib/pages/settings/widgets/plans_sheet.dart:1569,2086-2094` `tierOrder` list | plan_name_or_tier_copy | Yes — hardcoded canonical plan-ID list; new/retired plan IDs require updating this literal | +| `app/lib/pages/settings/widgets/plans_sheet.dart` plan titles/prices/features (~2043-2075, 1671-1697) | reads_live_no_update_needed | No — sourced live from backend availablePlans/planData | +| `app/lib/utils/plan_pricing.dart` (whole file); `app/lib/pages/settings/usage_page.dart:1030-1059` | reads_live_no_update_needed | No — discount badges and usage/quota figures derive from live monthly/yearly unit amounts and subscription response | + +## macOS desktop app (desktop/macos/) + +| Location | Kind | Needs update when prices/tiers change? | +|---|---|---| +| `Desktop/Sources/MainWindow/QueryShell/QueryShellHome.swift:198-206` | hardcoded_amount | Likely dead, but Yes if reachable — stale "$199/month Omi Pro" alert, plan name doesn't match any current tier | +| `Desktop/Sources/Providers/ChatProvider.swift:2959,4341,5340` | hardcoded_amount | Yes — literal $50 free-tier spend cap duplicated at 3 call sites | +| `...Settings/Components/SettingsContentView+BillingHelpers.swift:30` (comment) | hardcoded_amount | Yes (comment) — "Neo ($20) \| Operator ($49) \| Architect ($200)" | +| `...BillingHelpers.swift:137-148` `planSubtitle` | feature_limit_number | Yes — fallback subtitle question counts, only shown when catalog omits `subtitle` | +| `...BillingHelpers.swift:191-202` `planDescription` | feature_limit_number | Yes — fallback descriptions; also fix internal inconsistency (100 vs 200 for "unlimited") while updating | +| `...BillingHelpers.swift:239-265` `fallbackFeatures` | feature_limit_number | Yes — includes literal "~$400 of monthly AI compute" and question counts | +| `...Sections/SettingsContentView+AccountBilling.swift:342-345` | hardcoded_amount | Yes — fallback deprecation banner hardcodes "$49/mo" Operator price | +| `...BillingHelpers.swift:178-189` `planEyebrow` | plan_name_or_tier_copy | Yes — fallback marketing eyebrow text per plan id | +| `...BillingHelpers.swift:284-320` `planCatalog(from:)` + `normalizedPlanId` (267-282) | plan_name_or_tier_copy | Yes — fallback title mapping/keyword matching for plan display names | +| `...Sections/SettingsContentView+AccountBilling.swift:102-128` | other | Only if resurrected — dead/commented "Upgrade to Pro" card with stale marketing URL | +| `Services/APIClient/APIClient+Settings.swift:370` (comment) | hardcoded_amount | Yes (comment only) — "$400/mo" example for Architect | +| `FloatingControlBar/FloatingBarUsageLimiter.swift:15-27` `proactiveBudgetMultiplier` | config_value | Yes, if tiers/entitlements restructured — plan-tier-keyed multiplier constants | +| `TrialBannerService.swift:88` | hardcoded_amount | Yes — "3-day premium trial" string doesn't interpolate from `trialDurationSeconds` | +| `AppState/AppState+TrialPaywall.swift:121-166` (#if DEBUG) | test_fixture | Yes, if kept accurate — debug-only trial mock, not shipped to release | +| `MainWindow/SettingsSidebar.swift:187-198` | plan_name_or_tier_copy | Yes — settings-search subtitles/keywords name "Operator"/"Architect"/"unlimited" | +| `MainWindow/SettingsSidebar.swift:200-204` | plan_name_or_tier_copy | Yes — referral search subtitle names "Operator" | +| `MainWindow/Referrals/ReferralProgramView.swift:64,69` | plan_name_or_tier_copy | Yes — referral header text hardcodes "Operator" and "one month" | +| `Desktop/Tests/SubscriptionPlanCatalogMergerTests.swift:1-58` | test_fixture | No — arbitrary test doubles, not real prices | +| `Desktop/Tests/SubscriptionPlanPresentationTests.swift:1-27` | test_fixture | No functionally, but coincidentally matches real Operator price — worth a glance | +| `Desktop/Tests/SubscriptionInfoDecoderTests.swift:1-186` | test_fixture | Only the deprecation-message fixture needs to track the real fallback string | +| `Desktop/Tests/FloatingBarUsageLimiterTests.swift:41-238` | test_fixture | Yes — quota fixtures (Architect $400 cap) should track real limits to stay meaningful | +| `Desktop/Sources/VADGateService.swift:483` | config_value | Yes, if Deepgram's per-minute cost changes — unrelated to Omi plan pricing | +| `...AccountBilling.swift:414,422-435,479-494` overage card | reads_live_no_update_needed | No — all figures from live `OverageInfoResponse` | +| `Services/APIClient/APIClient+Settings.swift:411-609` displayName/price types | reads_live_no_update_needed | No — price fields populated live; only plan renames/new IDs touch `displayName` | +| `Desktop/Sources/UsageLimitPopupView.swift` | reads_live_no_update_needed | No — generic copy, no numbers | +| `FloatingControlBar/FloatingBarUsageLimiter.swift:147-155` `limitDescription` | reads_live_no_update_needed | No — reads live server quota object | + +## Windows desktop app (desktop/windows/) + +| Location | Kind | Needs update when prices/tiers change? | +|---|---|---| +| `src/renderer/src/lib/billing.ts:358-395` `PLAN_FALLBACKS` | feature_limit_number | Yes — fallback eyebrow/subtitle/description/features (fix the 100-vs-200 "unlimited" inconsistency while updating) | +| `.../components/settings/tabs/PlanUsageTab.tsx:227-229` | hardcoded_amount | Yes — fallback deprecation banner hardcodes "$49/mo" | +| `src/renderer/src/lib/billing.test.ts:54-68,167-176,252,358-375,537-538,559` | test_fixture | Yes — full mock catalog with plan titles/prices baked into assertions | +| `src/renderer/src/lib/chatQuotaGate.test.ts:16,46,59,187` | test_fixture | Yes, if display names/quota model change | +| `src/renderer/src/lib/billingPlans.ts:94-101` `PLAN_DISPLAY_NAMES` | plan_name_or_tier_copy | Yes — client-side authority for plan display names | +| `src/renderer/src/lib/billingPlans.ts:20,80-86` alias/paid-ID sets | config_value | Yes, if plan IDs/aliases change | +| `src/renderer/src/lib/billing.ts:314-320,434-437` `PLAN_ORDER`/`canPurchasePlan` | config_value | Yes — hardcoded display order and downgrade-block business rule | +| `src/renderer/src/lib/billing.ts:68` `LEGACY_PLAN_TITLES` | plan_name_or_tier_copy | Only if the legacy-catalog canary titles themselves change | +| `.../components/apps/AppDetailSheet.tsx:160,279,304` | other | No — marketplace app price is live, distinct pricing surface (not subscription plans) | +| `billing.ts` fetch* functions + `PlanGrid`/`CurrentPlanCard`/`ChatUsageCard`/`OverageCard` render paths | reads_live_no_update_needed | No — plan titles, prices, usage, trial, overage all read live from backend | + +## web/app/ (consumer web app) and web/admin/ (internal admin) + +| Location | Kind | Needs update when prices/tiers change? | +|---|---|---| +| `web/app/src/components/settings/SettingsPage.tsx:968-974,1192` `limits` object | feature_limit_number | Yes — hardcoded Basic free-tier caps instead of reading from `UserSubscriptionResponse` | +| `SettingsPage.tsx:1178,1183-1184,1210` | feature_limit_number | Yes — literal "1,200 min" duplicated 3x for free-tier listening limit | +| `SettingsPage.tsx:1140-1142,1150,1219-1241` | plan_name_or_tier_copy | Yes — Basic-plan "what's included" marketing copy is a silent duplicate of catalog entitlements | +| `SettingsPage.tsx:1002-1008` `defaultFeatures` | plan_name_or_tier_copy | Yes — generic feature list shown for every paid plan, not per-plan sourced | +| `web/app/src/components/settings/PlansSheet.tsx:221-226` `defaultFeatures` | plan_name_or_tier_copy | Yes — second independent copy of the same generic feature list | +| `web/app/src/types/user.ts:134-147` `planDisplayName` | plan_name_or_tier_copy | Yes — must stay in sync with plan_catalog.json naming | +| `web/app/src/lib/api.ts:2014-2017` `SegmentEditPlanRequiredError` | plan_name_or_tier_copy | Yes — hardcodes "Unlimited plan" as the gating tier | +| `web/app/src/app/login/LoginClient.tsx:234` | plan_name_or_tier_copy | Yes — referral headline hardcodes "Operator", independent of backend grant logic | +| `web/admin/lib/stripe-subscriptions.ts:49-56` `OMI_PLAN_PRODUCTS` | plan_name_or_tier_copy | Yes — sole source of plan identity for admin revenue/subscription metrics; new Stripe product invisible until added | +| `web/admin/app/api/omi/stats/subscriptions/route.ts:64`, `.../stats/revenue/route.ts:63` | other | Yes — both depend on the same `OMI_PLAN_PRODUCTS` map above | +| `web/admin/lib/__tests__/stripe-subscriptions.test.ts:293-302` | test_fixture | Yes — independent literal array of 6 plan display names must track the map | +| `web/app/src/types/__tests__/userPlan.test.ts:51-62` | test_fixture | Yes — literal array of plan IDs; adding a 7th plan requires updating | +| `web/app/src/types/user.ts:65-72,96-102` `CATALOG_PLAN_IDS`/`PAID_CATALOG_PLAN_IDS` | reads_live_no_update_needed* | Yes, but this IS the canonical single-source client update point (not a stray duplicate) | +| `web/app/src/lib/api.ts` (getAvailablePlans, getUserSubscription, etc.) | reads_live_no_update_needed | No — all fetched live from backend/Stripe | +| `web/admin/.../dashboard/subscriptions/page.tsx` | reads_live_no_update_needed | No — MRR/ARR/amounts computed live | +| `web/admin/app/api/omi/stats/*` routes | reads_live_no_update_needed | No, except shared dependency on `OMI_PLAN_PRODUCTS` noted above | +| `web/app/src/lib/omiApi.generated.ts` | reads_live_no_update_needed | No, provided codegen is re-run when the backend schema changes (unverified — see gaps) | +| `web/admin/lib/utils/user-subscription.ts` | reads_live_no_update_needed | No — generic passthrough helpers | + +## web/frontend/ (public omi.me marketing/marketplace Next.js site) + +| Location | Kind | Needs update when prices/tiers change? | +|---|---|---| +| `src/app/components/product-banner/types.ts:23` `PRODUCT_INFO.price` | hardcoded_amount | Yes, if the $89 hardware price changes (device price, not subscription) | +| `src/app/apps/utils/metadata.ts:104` | hardcoded_amount | Yes — independent duplicate of the $89 device price for SEO JSON-LD | +| `src/app/apps/[id]/page.tsx:118` | hardcoded_amount | Yes — third independent duplicate of the $89 device price | +| `src/app/unlimited/page.tsx:6,39,41` | plan_name_or_tier_copy | Yes — stale "Omi Unlimited" landing page name vs. current catalog naming (unlimited_v2 vs deprecated Neo) | +| `src/__tests__/wrapped-unlimited-deeplink-parity.test.mjs` | test_fixture | Yes — static string tripwire on the `/unlimited` route name, breaks if route is renamed | +| `src/app/apps/utils/metadata.ts:214-215` `generateAppListSchema` | other | No — correctly hardcoded $0 for free-to-list marketplace apps | +| `src/app/create-app/page.tsx:49,294,310,366,774-859` | reads_live_no_update_needed | No — third-party developer's own app-pricing form field, unrelated to Omi plans | +| `public/` images (omi_1.webp, etc.) | other | Unknown — not visually inspected for baked-in price text | +| `src/app/page.tsx` | reads_live_no_update_needed | No — redirects to /apps, no pricing content | + +## backend/ and top-level config/ (pricing/plan catalog surface) + +| Location | Kind | Needs update when prices/tiers change? | +|---|---|---| +| `backend/config/plan_catalog.json:115-570` allocations, `:12-76` allocation_profiles | feature_limit_number | Yes — this IS the canonical place to edit finite quotas/budgets | +| `backend/utils/subscription.py:474-535` `get_paid_plan_definitions` | plan_name_or_tier_copy | Yes — hardcoded storefront titles/eyebrows/subtitle/description text | +| `backend/utils/subscription.py:484,496,508,520,532` `annual_description` | hardcoded_amount | Yes — "Save ~17%" baked into copy, not computed from live Stripe monthly/annual ratio | +| `backend/utils/subscription.py:987-1002` `_chat_allowance_text` | config_value | Yes (via editing plan_catalog.json) — derives "$400" from catalog usd_cent value; live-read but easy to mistake for Stripe-sourced | +| `backend/routers/payment.py:196` docstring example | localized_copy | Cosmetic only — illustrative comment, not runtime | +| `backend/charts/backend-listen/{dev,prod}_*_values.yaml`, `backend/charts/pusher/{dev,prod}_*_values.yaml` | config_value | Yes — Helm env vars duplicate quota overlays and Stripe price IDs for legacy plans; must update both dev+prod together | +| Chart files' `SUBSCRIPTION_LAUNCH_DATE` | config_value | Yes, if the cutover date changes — duplicated across 4 files | +| `backend/deploy/runtime_env/prod.overlay.yaml:185-192`, `backend/deploy/runtime_env.yaml:1603-1610` | config_value | Yes — literal Plus/Unlimited-v2 Stripe price IDs (prod only), must track plan_catalog.json's recognized_stripe_prices | +| `backend/deploy/runtime_env/dev.overlay.yaml` (absence) | config_value | Confirm intentional — no dev price IDs for Plus/Unlimited-v2 today | +| `config/deployment-setting-classification.json:172-175` | config_value | Yes, if new plan price env-var names are introduced (need a classification entry) | +| `backend/config/plan_catalog.json:571-712` recognized_stripe_prices/products | config_value | Yes — append-only ledger; new Stripe prices for new/changed tiers must be appended here | +| `docs/agents/plan-source-of-truth.md:405-421` (open ledger gap) | other | Yes — pre-existing unresolved discrepancy between a test fixture and the dev ledger for Architect | +| `backend/tests/unit/test_available_plans_resilience.py:19-22,111` | test_fixture | Yes, if the dev ledger gap above is resolved | +| `backend/tests/unit/test_overage_catalog.py:8-16,29-42` | test_fixture | Yes — hardcoded per-plan hard-cap-vs-overage policy and quota numbers (500/$400/200) mirror catalog | +| `backend/tests/unit/test_subscription_restructure.py:1-2,88-98,510-515` | test_fixture | Yes — stale $49/$400 figures in docstring, plus hardcoded display-name assertions | +| `backend/utils/subscription.py:620-630,684-685` version gates | config_value | Yes, if a pricing change ships alongside a client capability gate | +| `backend/routers/payment.py:478-522` price_string/unit_amount | reads_live_no_update_needed | No — live `stripe.Price.retrieve` at request time | +| `backend/utils/subscription.py:1038-1039` `get_plan_display_name` | reads_live_no_update_needed | No — reads generated `PLAN_DISPLAY_NAMES` | +| `backend/config/plan_catalog_generated.py` | reads_live_no_update_needed | No — generated artifact, never hand-edited | +| `backend/utils/stripe.py`, `backend/scripts/support/find_stripe_entitlement_mismatches.py`, `backend/config/plan_catalog.py` | reads_live_no_update_needed | No — generic helpers/facades with no hardcoded literals | + +## Repository documentation and agent guides + +| Location | Kind | Needs update when prices/tiers change? | +|---|---|---| +| `docs/api-reference/app-client-openapi.json:50708` (from `backend/routers/payment.py` docstring) | plan_name_or_tier_copy | Yes — stale "Unlimited→Pro" example; fix the source docstring, not the generated doc | +| `web/admin/docs/data-contracts.md:20` | config_value | Yes — procedural instruction to add a line to `OMI_PLAN_PRODUCTS` when launching a plan | +| `docs/doc/developer/mcp/tools.mdx:225` | feature_limit_number | Yes, if the free-tier preview length changes — hardcoded "70 characters" | +| `docs/doc/developer/mcp/tools.mdx:202,223`, `docs/doc/developer/mcp/troubleshooting.mdx:59-61` | plan_name_or_tier_copy | Only if the free/paid gating boundary itself changes — generic "paid plan" language | +| `docs/doc/developer/apps/Oauth.mdx:220` | other | Only if marketplace-app monetization scope changes — separate pricing system from the 6-plan catalog | +| `docs/agents/web-app-destinations.md:14` | other | No — pointer doc only, no names/prices | +| `docs/api-reference/app-client-openapi.json` (other endpoint descriptions) | reads_live_no_update_needed | No — auto-generated mirrors of backend docstrings | +| `backend/AGENTS.md:16` | reads_live_no_update_needed | No — describes live-validation behavior | +| `docs/doc/developer/backend/Backend_Setup.mdx:86` | reads_live_no_update_needed | No — generic setup instructions | + +## In-repo assets/imagery and app-store-adjacent files + +| Location | Kind | Needs update when prices/tiers change? | +|---|---|---| +| `app/assets/images/neo_one.webp` | other | No (probable false positive) — appears to be device-pairing art, not the "Neo" plan; worth a quick visual double-check | +| `app/assets/images/ic_dollar.svg`, `ic_clone_plus.svg`, `ic_clone_chat.svg` | other | No — decorative icons, no baked-in numbers | +| `app/assets/competitor-logos/limitless-logo.jpg`, `app/assets/images/limitless.png` | other | No — competitor branding, unrelated to Omi pricing | +| `web/app/public/app-store-badge.svg`, `google-play-badge.png` (+ web/frontend, docs equivalents) | other | No — generic store-badge artwork | +| `.github/issue-assets/6559-gh-plan-usage*.png` | other | No — historical issue screenshots, not shipping product, unreferenced in-repo | + +## Outside this repository + +| Surface | Confirmed absent from repo | System of record to check | +|---|---|---| +| App Store listing copy (subtitle, description, price tier mentions) | No fastlane/, App Store Connect metadata, or `app-store`/`appstoreconnect` directories found anywhere | App Store Connect | +| Google Play listing copy (short/long description, pricing mentions) | No `play-store`/`playstore` directories or Android fastlane/metadata found | Google Play Console | +| Paid ad creative mentioning price points | No `ads/` directory anywhere in the repo | Whatever ad platform(s) run Omi's paid campaigns (e.g. Meta/Google Ads) | +| Social media copy/captions mentioning pricing | No `socials/` or marketing-copy directory anywhere in the repo | Whatever social scheduling tool the growth/marketing team uses | +| Blog/CMS content mentioning pricing | No MDX/blog content directory found under `web/frontend/src/app` or `docs/` | Whatever CMS or blog platform hosts Omi's blog, if one exists outside this repo | + +## Suggested next steps + +- Fix the two internal inconsistencies already found in *shipped* fallback copy now, independent of the new pricing decision: `SettingsContentView+BillingHelpers.swift` (100 vs 200 questions/month for "unlimited") and its Windows twin `PLAN_FALLBACKS` in `billing.ts` — these are bugs today, not migration work. +- Wire a single source read for the three duplicated hardcoded "$49/mo Operator" deprecation-banner strings (mobile ARB, macOS `AccountBilling.swift:342-345`, Windows `PlanUsageTab.tsx:227-229`) into one shared constant or backend-supplied fallback, so a future price change is a one-line edit instead of a three-repo grep — do this behind the fallback path itself (it only fires when the API omits `deprecation_message`), no final numbers required yet. +- Consolidate the two independent copies of the generic `defaultFeatures` list in `web/app/` (`SettingsPage.tsx` and `PlansSheet.tsx`) into one shared constant now — pure refactor, no pricing decision needed. +- Do not touch actual dollar literals yet (Stripe price IDs, `usd_cent` allocations in `plan_catalog.json`, chart/env price IDs) until final tier numbers are decided — those are single-edit-point changes by design and premature edits risk drifting from the still-open Architect dev-ledger gap tracked in `plan-source-of-truth.md`. +- Resolve the open Architect dev Stripe price-ID ledger gap (`test_available_plans_resilience.py` vs `plan_catalog.json`) before shipping any new pricing that touches dev testing — verify against the dev Stripe dashboard first, per the doc's own caveat. +- Flag the stale "Unlimited→Pro" example in the `backend/routers/payment.py` docstring as a quick fix independent of the pricing rollout — it's leaking a non-existent plan name into public OpenAPI docs today. +- Before finalizing new prices, confirm whether `web/app/src/lib/omiApi.generated.ts`'s codegen step actually runs on `plan_catalog.json` changes — if it's manual, add it to the release checklist now so the rollout doesn't ship stale generated types. +- Treat the $89 hardware price and App Store/Play Store/ad/social copy as explicitly out of scope for the *subscription* pricing migration, but assign an owner to sweep the three duplicated $89 code locations and the external consoles/tools listed above in the same rollout window so nothing is silently missed. From 0c108d3771b6bb131065b02084e8beddff7f5761 Mon Sep 17 00:00:00 2001 From: Forged2Form <9682873+formed2forge@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:49:39 -0400 Subject: [PATCH 2/7] fix(desktop): correct stale Neo fallback chat-quota copy, extract Operator deprecation price MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS and Windows each fell back to two different question-count numbers (200 vs 100) for the same "unlimited" (Neo) plan when the backend catalog response omitted subtitle/description — verified against the canonical 200/month allocation in backend/config/plan_catalog.json. Fixed both, and made BillingHelpers' fallback copy functions static so they're directly testable without constructing a full SettingsContentView. Also extracted the hardcoded "$49/mo" in each platform's deprecation-banner fallback into a single named constant (operatorDeprecationFallbackPrice / OPERATOR_DEPRECATION_FALLBACK_PRICE), so a future Operator price change is a one-line edit instead of a search through prose strings. These fallbacks only render when the API omits deprecation_message, so no pricing decision is required for this change. Failure-Class: none Co-Authored-By: Claude Sonnet 5 --- .../SettingsContentView+BillingHelpers.swift | 22 ++++++++------ .../SettingsContentView+AccountBilling.swift | 2 +- ...tingsContentViewBillingFallbackTests.swift | 30 +++++++++++++++++++ .../components/settings/tabs/PlanUsageTab.tsx | 5 ++-- .../src/renderer/src/lib/billing.test.ts | 9 ++++++ .../windows/src/renderer/src/lib/billing.ts | 6 +++- 6 files changed, 61 insertions(+), 13 deletions(-) create mode 100644 desktop/macos/Desktop/Tests/SettingsContentViewBillingFallbackTests.swift diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Components/SettingsContentView+BillingHelpers.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Components/SettingsContentView+BillingHelpers.swift index b4a3572b282..ee88672011a 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Components/SettingsContentView+BillingHelpers.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Components/SettingsContentView+BillingHelpers.swift @@ -4,6 +4,10 @@ import SwiftUI import UniformTypeIdentifiers import WebKit +/// Single edit point for the Operator price quoted in the deprecation-banner +/// fallback (used only when the backend omits `deprecationMessage`). +let operatorDeprecationFallbackPrice = "$49/mo" + enum SubscriptionPlanPresentation { static func selectionLabel(planTitle: String, startingPrice: String?) -> String { guard let startingPrice, !startingPrice.isEmpty else { @@ -134,7 +138,7 @@ extension SettingsContentView { return "\(prefix) on \(formatter.string(from: date))" } - func planSubtitle(for planId: String) -> String? { + static func planSubtitle(for planId: String) -> String? { switch planId { case "unlimited": return "200 questions per month" @@ -175,7 +179,7 @@ extension SettingsContentView { return prices.first } - func planEyebrow(for planId: String) -> String { + static func planEyebrow(for planId: String) -> String { switch planId { case "unlimited": return "Starter" @@ -188,10 +192,10 @@ extension SettingsContentView { } } - func planDescription(for planId: String) -> String { + static func planDescription(for planId: String) -> String { switch planId { case "unlimited": - return "100 chat questions per month. Shared with mobile and web." + return "200 chat questions per month. Shared with mobile and web." case "operator": return "500 chat questions per month. Shared with mobile and web." case "architect": @@ -236,7 +240,7 @@ extension SettingsContentView { SubscriptionPlanCatalogMerger.merge(primary: primary, fallback: fallback) } - func fallbackFeatures(for planId: String) -> [String] { + static func fallbackFeatures(for planId: String) -> [String] { switch planId { case "architect": return [ @@ -313,7 +317,7 @@ extension SettingsContentView { return SubscriptionPlanOption( id: planId, title: title, - features: fallbackFeatures(for: planId), + features: Self.fallbackFeatures(for: planId), prices: mappedPrices ) } @@ -342,7 +346,7 @@ extension SettingsContentView { Circle() .fill(accent) .frame(width: 6, height: 6) - Text((plan.eyebrow ?? planEyebrow(for: plan.id)).uppercased()) + Text((plan.eyebrow ?? Self.planEyebrow(for: plan.id)).uppercased()) .scaledFont(size: OmiType.micro, weight: .bold) .foregroundColor(Ink.secondary) .tracking(0.8) @@ -352,7 +356,7 @@ extension SettingsContentView { .scaledFont(size: OmiType.heading, weight: .bold) .foregroundColor(Ink.primary) - if let subtitle = plan.subtitle ?? planSubtitle(for: plan.id) { + if let subtitle = plan.subtitle ?? Self.planSubtitle(for: plan.id) { Text(subtitle) .scaledFont(size: OmiType.caption) .foregroundColor(Ink.secondary) @@ -379,7 +383,7 @@ extension SettingsContentView { .fixedSize(horizontal: true, vertical: false) } - Text(plan.description ?? planDescription(for: plan.id)) + Text(plan.description ?? Self.planDescription(for: plan.id)) .scaledFont(size: OmiType.body) .foregroundColor(Ink.secondary) diff --git a/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+AccountBilling.swift b/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+AccountBilling.swift index a10a25a770c..baece6de677 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+AccountBilling.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Pages/Settings/Sections/SettingsContentView+AccountBilling.swift @@ -341,7 +341,7 @@ extension SettingsContentView { Text( subscription.deprecationMessage - ?? "Your Unlimited plan is being retired. Try the new Operator plan — same great features at $49/mo." + ?? "Your Unlimited plan is being retired. Try the new Operator plan — same great features at \(operatorDeprecationFallbackPrice)." ) .scaledFont(size: OmiType.body) .foregroundColor(Ink.secondary) diff --git a/desktop/macos/Desktop/Tests/SettingsContentViewBillingFallbackTests.swift b/desktop/macos/Desktop/Tests/SettingsContentViewBillingFallbackTests.swift new file mode 100644 index 00000000000..8a7c024e090 --- /dev/null +++ b/desktop/macos/Desktop/Tests/SettingsContentViewBillingFallbackTests.swift @@ -0,0 +1,30 @@ +import XCTest + +@testable import Omi_Computer + +/// Fallback billing copy shown only when the backend catalog omits a plan's +/// subtitle/description (`SettingsContentView.planSubtitle`/`planDescription`). +/// These shipped disagreeing for the "unlimited" (Neo) plan id — the subtitle +/// said 200 questions/month while the description said 100 — so a user could +/// see either number depending which card region they read. +final class SettingsContentViewBillingFallbackTests: XCTestCase { + private func leadingQuestionCount(in text: String?) -> String? { + guard let first = text?.split(separator: " ").first, first.allSatisfy(\.isNumber) else { + return nil + } + return String(first) + } + + func testUnlimitedFallbackSubtitleAndDescriptionAgreeOnQuestionCount() { + let subtitle = SettingsContentView.planSubtitle(for: "unlimited") + let description = SettingsContentView.planDescription(for: "unlimited") + XCTAssertEqual(leadingQuestionCount(in: subtitle), leadingQuestionCount(in: description)) + XCTAssertEqual(leadingQuestionCount(in: description), "200") + } + + func testOperatorFallbackSubtitleAndDescriptionAgreeOnQuestionCount() { + let subtitle = SettingsContentView.planSubtitle(for: "operator") + let description = SettingsContentView.planDescription(for: "operator") + XCTAssertEqual(leadingQuestionCount(in: subtitle), leadingQuestionCount(in: description)) + } +} diff --git a/desktop/windows/src/renderer/src/components/settings/tabs/PlanUsageTab.tsx b/desktop/windows/src/renderer/src/components/settings/tabs/PlanUsageTab.tsx index 7a804435764..8bdda75db10 100644 --- a/desktop/windows/src/renderer/src/components/settings/tabs/PlanUsageTab.tsx +++ b/desktop/windows/src/renderer/src/components/settings/tabs/PlanUsageTab.tsx @@ -18,7 +18,8 @@ import { startCheckout, createCheckoutSession, upgradeSubscription, - openCustomerPortal + openCustomerPortal, + OPERATOR_DEPRECATION_FALLBACK_PRICE } from '../../../lib/billing' import type { UserSubscriptionResponse, @@ -225,7 +226,7 @@ export function PlanUsageTab(): React.JSX.Element { title="Plan Retiring" subtitle={ subscription.deprecation_message ?? - 'Your Unlimited plan is being retired. Try the new Operator plan — same great features at $49/mo.' + `Your Unlimited plan is being retired. Try the new Operator plan — same great features at ${OPERATOR_DEPRECATION_FALLBACK_PRICE}.` } trailing={ showCatalog ? ( diff --git a/desktop/windows/src/renderer/src/lib/billing.test.ts b/desktop/windows/src/renderer/src/lib/billing.test.ts index 0ba97fee27d..2439579a06a 100644 --- a/desktop/windows/src/renderer/src/lib/billing.test.ts +++ b/desktop/windows/src/renderer/src/lib/billing.test.ts @@ -370,6 +370,15 @@ describe('plan catalog helpers', () => { 'd' ]) }) + it('keeps the Neo (unlimited) fallback subtitle and description question counts in sync', () => { + // Regression: subtitle said 200 questions/month while description said 100 for the + // same plan id, so a user could see either number depending which card region they read. + const p: SubscriptionPlan = { id: 'unlimited', title: 'Neo' } + const subtitleCount = planSubtitle(p).match(/\d+/)?.[0] + const descriptionCount = planDescription(p).match(/\d+/)?.[0] + expect(descriptionCount).toBe(subtitleCount) + expect(descriptionCount).toBe('200') + }) it('sorts prices month-first and reads the starting price', () => { expect(sortedPrices(CATALOG[1]).map((p) => p.title)).toEqual(['Monthly', 'Annual']) expect(planStartingPrice(CATALOG[1])).toBe('$49/mo') diff --git a/desktop/windows/src/renderer/src/lib/billing.ts b/desktop/windows/src/renderer/src/lib/billing.ts index 0c61fa047ed..5f69173c08e 100644 --- a/desktop/windows/src/renderer/src/lib/billing.ts +++ b/desktop/windows/src/renderer/src/lib/billing.ts @@ -353,6 +353,10 @@ export function orderedCatalog( .map(({ p }) => p) } +// Single edit point for the Operator price quoted in the deprecation-banner +// fallback (used only when the API omits `deprecation_message`). +export const OPERATOR_DEPRECATION_FALLBACK_PRICE = '$49/mo' + // Per-plan-id fallbacks (BillingHelpers planEyebrow/planSubtitle/planDescription/ // fallback features), used only when the catalog omits the field. const PLAN_FALLBACKS: Record< @@ -362,7 +366,7 @@ const PLAN_FALLBACKS: Record< unlimited: { eyebrow: 'Starter', subtitle: '200 questions per month', - description: '100 chat questions per month. Shared with mobile and web.', + description: '200 chat questions per month. Shared with mobile and web.', features: [ '200 chat questions per month', 'Unlimited listening and transcription', From c049cc082cbeaad61167eb199aab80264c7e5593 Mon Sep 17 00:00:00 2001 From: Forged2Form <9682873+formed2forge@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:49:45 -0400 Subject: [PATCH 3/7] refactor(web): dedupe default plan feature list into shared constant SettingsPage.tsx and PlansSheet.tsx each hardcoded their own identical copy of the generic paid-plan feature bullets. Extracted to web/app/src/lib/planFeatures.ts so the two surfaces can't silently drift when the copy changes. Co-Authored-By: Claude Sonnet 5 --- web/app/src/components/settings/PlansSheet.tsx | 11 ++--------- web/app/src/components/settings/SettingsPage.tsx | 11 ++--------- web/app/src/lib/planFeatures.ts | 9 +++++++++ 3 files changed, 13 insertions(+), 18 deletions(-) create mode 100644 web/app/src/lib/planFeatures.ts diff --git a/web/app/src/components/settings/PlansSheet.tsx b/web/app/src/components/settings/PlansSheet.tsx index 4ff2ea91f5a..194301e87b3 100644 --- a/web/app/src/components/settings/PlansSheet.tsx +++ b/web/app/src/components/settings/PlansSheet.tsx @@ -20,6 +20,7 @@ import type { } from '@/types/user'; import { decodePlan, planGrantsPaidCapability } from '@/types/user'; import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; +import { DEFAULT_PLAN_FEATURES } from '@/lib/planFeatures'; interface PlansSheetProps { open: boolean; @@ -217,14 +218,6 @@ export function PlansSheet({ // Get the selected option const selectedOption = pricingOptions.find((p) => p.id === selectedPriceId); - // Default features for unlimited plan - const defaultFeatures = [ - 'Unlimited conversations', - 'Unlimited memories', - 'Priority processing', - 'Advanced insights', - ]; - return ( <> @@ -373,7 +366,7 @@ export function PlansSheet({ Features:
    - {defaultFeatures.map((feature, idx) => ( + {DEFAULT_PLAN_FEATURES.map((feature, idx) => (
  • diff --git a/web/app/src/components/settings/SettingsPage.tsx b/web/app/src/components/settings/SettingsPage.tsx index e92c41d3ba2..f566f3cdcb6 100644 --- a/web/app/src/components/settings/SettingsPage.tsx +++ b/web/app/src/components/settings/SettingsPage.tsx @@ -54,6 +54,7 @@ import { import { useAuth } from '@/components/auth/AuthProvider'; import { useToast } from '@/components/ui/Toast'; import { cn } from '@/lib/utils'; +import { DEFAULT_PLAN_FEATURES } from '@/lib/planFeatures'; import { PageHeader } from '@/components/layout/PageHeader'; import { CLAUDE_CONNECTOR_OAUTH, @@ -999,14 +1000,6 @@ function UsageSectionContent({ const selectedOption = cachedPlans?.find((p) => p.id === selectedPriceId); - // Default features for unlimited plan - const defaultFeatures = [ - 'Unlimited conversations', - 'Unlimited memories', - 'Priority processing', - 'Advanced insights', - ]; - const handleSubscribe = async () => { if (!selectedPriceId) return; @@ -1489,7 +1482,7 @@ function UsageSectionContent({

    Features:

      - {defaultFeatures.map((feature, idx) => ( + {DEFAULT_PLAN_FEATURES.map((feature, idx) => (
    • {feature} diff --git a/web/app/src/lib/planFeatures.ts b/web/app/src/lib/planFeatures.ts new file mode 100644 index 00000000000..b5f8d1c464c --- /dev/null +++ b/web/app/src/lib/planFeatures.ts @@ -0,0 +1,9 @@ +// Generic feature bullets shown for a paid plan when the catalog response +// doesn't supply per-plan feature copy. Single source so SettingsPage and +// PlansSheet can't drift from each other. +export const DEFAULT_PLAN_FEATURES = [ + 'Unlimited conversations', + 'Unlimited memories', + 'Priority processing', + 'Advanced insights', +]; From 2c489a254530bcec5d9f8042ee7da110b46e4a90 Mon Sep 17 00:00:00 2001 From: Forged2Form <9682873+formed2forge@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:05:50 -0400 Subject: [PATCH 4/7] docs: fix path references in pricing inventory for agent-doc-references Backticked references need to be plain repo paths so the checker can verify they resolve; line numbers now sit outside the backticks as plain text, and a few component-relative shorthand paths (Desktop/..., src/renderer/..., src/app/...) are now full repo-relative paths. Co-Authored-By: Claude Sonnet 5 --- .../pricing-scheme-touchpoint-inventory.md | 146 +++++++++--------- 1 file changed, 73 insertions(+), 73 deletions(-) diff --git a/docs/agents/pricing-scheme-touchpoint-inventory.md b/docs/agents/pricing-scheme-touchpoint-inventory.md index 8c515d8858b..43e2cd403c1 100644 --- a/docs/agents/pricing-scheme-touchpoint-inventory.md +++ b/docs/agents/pricing-scheme-touchpoint-inventory.md @@ -6,24 +6,24 @@ This repo already has an in-flight catalog migration — `docs/agents/plan-sourc | Location | Kind | Needs update when prices/tiers change? | |---|---|---| -| `app/lib/l10n/app_en.arb:10581` `planDeprecationMessage` (+ `app_fr.arb:2941`, other locales) | hardcoded_amount | Yes — literal "$49/mo" Operator price baked into localized copy; currently no Dart call sites found (may be dead) | -| `app/lib/l10n/app_en.arb:5843` `monthlyPayoutsDescription` | hardcoded_amount | Yes, if the $10 payout threshold changes (app-developer payout copy, not subscriber pricing) | -| `app/lib/pages/settings/widgets/plans_sheet.dart:2083-2094` `_tierGrantsDesktop` | config_value | Yes — client-side copy of desktop-entitled plan set; must mirror backend `DESKTOP_ENTITLED_PLAN_TYPES` | -| `app/lib/models/subscription.dart:86-88` `PlanType.grantsDesktop` | config_value | Yes — second independent copy of the same desktop-entitlement mapping | -| `app/lib/models/subscription.dart:77-79` doc comment | feature_limit_number | Yes (comment only) — "1500 min/month" for Plus will read stale if the real limit changes; runtime value itself is live | -| `app/test/utils/plan_pricing_test.dart:17-27,47-56` | test_fixture | Yes (comments/docs) — fixtures encode today's real Plus/Unlimited/Neo prices; math functions themselves are price-agnostic | -| `app/test/unit/plans_sheet_l10n_test.dart:60-63,186,193-198` | test_fixture | Yes — one assertion hardcodes formatted string "$161.91" (today's real Plus annual price) | -| `app/lib/l10n/app_en.arb:10668,10676,10684` `neoSubtitle`/`operatorSubtitle`/`architectSubtitle` | plan_name_or_tier_copy | Yes, if resurrected — currently no call sites found outside generated l10n files | -| `app/lib/pages/settings/widgets/plans_sheet.dart:1569,2086-2094` `tierOrder` list | plan_name_or_tier_copy | Yes — hardcoded canonical plan-ID list; new/retired plan IDs require updating this literal | +| `app/lib/l10n/app_en.arb` (line 10581) `planDeprecationMessage` (+ `app_fr.arb:2941`, other locales) | hardcoded_amount | Yes — literal "$49/mo" Operator price baked into localized copy; currently no Dart call sites found (may be dead) | +| `app/lib/l10n/app_en.arb` (line 5843) `monthlyPayoutsDescription` | hardcoded_amount | Yes, if the $10 payout threshold changes (app-developer payout copy, not subscriber pricing) | +| `app/lib/pages/settings/widgets/plans_sheet.dart` (line 2083-2094) `_tierGrantsDesktop` | config_value | Yes — client-side copy of desktop-entitled plan set; must mirror backend `DESKTOP_ENTITLED_PLAN_TYPES` | +| `app/lib/models/subscription.dart` (line 86-88) `PlanType.grantsDesktop` | config_value | Yes — second independent copy of the same desktop-entitlement mapping | +| `app/lib/models/subscription.dart` (line 77-79) doc comment | feature_limit_number | Yes (comment only) — "1500 min/month" for Plus will read stale if the real limit changes; runtime value itself is live | +| `app/test/utils/plan_pricing_test.dart` (line 17-27,47-56) | test_fixture | Yes (comments/docs) — fixtures encode today's real Plus/Unlimited/Neo prices; math functions themselves are price-agnostic | +| `app/test/unit/plans_sheet_l10n_test.dart` (line 60-63,186,193-198) | test_fixture | Yes — one assertion hardcodes formatted string "$161.91" (today's real Plus annual price) | +| `app/lib/l10n/app_en.arb` (line 10668,10676,10684) `neoSubtitle`/`operatorSubtitle`/`architectSubtitle` | plan_name_or_tier_copy | Yes, if resurrected — currently no call sites found outside generated l10n files | +| `app/lib/pages/settings/widgets/plans_sheet.dart` (line 1569,2086-2094) `tierOrder` list | plan_name_or_tier_copy | Yes — hardcoded canonical plan-ID list; new/retired plan IDs require updating this literal | | `app/lib/pages/settings/widgets/plans_sheet.dart` plan titles/prices/features (~2043-2075, 1671-1697) | reads_live_no_update_needed | No — sourced live from backend availablePlans/planData | -| `app/lib/utils/plan_pricing.dart` (whole file); `app/lib/pages/settings/usage_page.dart:1030-1059` | reads_live_no_update_needed | No — discount badges and usage/quota figures derive from live monthly/yearly unit amounts and subscription response | +| `app/lib/utils/plan_pricing.dart` (whole file); `app/lib/pages/settings/usage_page.dart` (line 1030-1059) | reads_live_no_update_needed | No — discount badges and usage/quota figures derive from live monthly/yearly unit amounts and subscription response | ## macOS desktop app (desktop/macos/) | Location | Kind | Needs update when prices/tiers change? | |---|---|---| -| `Desktop/Sources/MainWindow/QueryShell/QueryShellHome.swift:198-206` | hardcoded_amount | Likely dead, but Yes if reachable — stale "$199/month Omi Pro" alert, plan name doesn't match any current tier | -| `Desktop/Sources/Providers/ChatProvider.swift:2959,4341,5340` | hardcoded_amount | Yes — literal $50 free-tier spend cap duplicated at 3 call sites | +| `desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryShellHome.swift` (line 198-206) | hardcoded_amount | Likely dead, but Yes if reachable — stale "$199/month Omi Pro" alert, plan name doesn't match any current tier | +| `desktop/macos/Desktop/Sources/Providers/ChatProvider.swift` (line 2959,4341,5340) | hardcoded_amount | Yes — literal $50 free-tier spend cap duplicated at 3 call sites | | `...Settings/Components/SettingsContentView+BillingHelpers.swift:30` (comment) | hardcoded_amount | Yes (comment) — "Neo ($20) \| Operator ($49) \| Architect ($200)" | | `...BillingHelpers.swift:137-148` `planSubtitle` | feature_limit_number | Yes — fallback subtitle question counts, only shown when catalog omits `subtitle` | | `...BillingHelpers.swift:191-202` `planDescription` | feature_limit_number | Yes — fallback descriptions; also fix internal inconsistency (100 vs 200 for "unlimited") while updating | @@ -32,35 +32,35 @@ This repo already has an in-flight catalog migration — `docs/agents/plan-sourc | `...BillingHelpers.swift:178-189` `planEyebrow` | plan_name_or_tier_copy | Yes — fallback marketing eyebrow text per plan id | | `...BillingHelpers.swift:284-320` `planCatalog(from:)` + `normalizedPlanId` (267-282) | plan_name_or_tier_copy | Yes — fallback title mapping/keyword matching for plan display names | | `...Sections/SettingsContentView+AccountBilling.swift:102-128` | other | Only if resurrected — dead/commented "Upgrade to Pro" card with stale marketing URL | -| `Services/APIClient/APIClient+Settings.swift:370` (comment) | hardcoded_amount | Yes (comment only) — "$400/mo" example for Architect | -| `FloatingControlBar/FloatingBarUsageLimiter.swift:15-27` `proactiveBudgetMultiplier` | config_value | Yes, if tiers/entitlements restructured — plan-tier-keyed multiplier constants | +| `desktop/macos/Desktop/Sources/Services/APIClient/APIClient+Settings.swift` (line 370) (comment) | hardcoded_amount | Yes (comment only) — "$400/mo" example for Architect | +| `desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarUsageLimiter.swift` (line 15-27) `proactiveBudgetMultiplier` | config_value | Yes, if tiers/entitlements restructured — plan-tier-keyed multiplier constants | | `TrialBannerService.swift:88` | hardcoded_amount | Yes — "3-day premium trial" string doesn't interpolate from `trialDurationSeconds` | -| `AppState/AppState+TrialPaywall.swift:121-166` (#if DEBUG) | test_fixture | Yes, if kept accurate — debug-only trial mock, not shipped to release | -| `MainWindow/SettingsSidebar.swift:187-198` | plan_name_or_tier_copy | Yes — settings-search subtitles/keywords name "Operator"/"Architect"/"unlimited" | -| `MainWindow/SettingsSidebar.swift:200-204` | plan_name_or_tier_copy | Yes — referral search subtitle names "Operator" | -| `MainWindow/Referrals/ReferralProgramView.swift:64,69` | plan_name_or_tier_copy | Yes — referral header text hardcodes "Operator" and "one month" | -| `Desktop/Tests/SubscriptionPlanCatalogMergerTests.swift:1-58` | test_fixture | No — arbitrary test doubles, not real prices | -| `Desktop/Tests/SubscriptionPlanPresentationTests.swift:1-27` | test_fixture | No functionally, but coincidentally matches real Operator price — worth a glance | -| `Desktop/Tests/SubscriptionInfoDecoderTests.swift:1-186` | test_fixture | Only the deprecation-message fixture needs to track the real fallback string | -| `Desktop/Tests/FloatingBarUsageLimiterTests.swift:41-238` | test_fixture | Yes — quota fixtures (Architect $400 cap) should track real limits to stay meaningful | -| `Desktop/Sources/VADGateService.swift:483` | config_value | Yes, if Deepgram's per-minute cost changes — unrelated to Omi plan pricing | +| `desktop/macos/Desktop/Sources/AppState/AppState+TrialPaywall.swift` (line 121-166) (#if DEBUG) | test_fixture | Yes, if kept accurate — debug-only trial mock, not shipped to release | +| `desktop/macos/Desktop/Sources/MainWindow/SettingsSidebar.swift` (line 187-198) | plan_name_or_tier_copy | Yes — settings-search subtitles/keywords name "Operator"/"Architect"/"unlimited" | +| `desktop/macos/Desktop/Sources/MainWindow/SettingsSidebar.swift` (line 200-204) | plan_name_or_tier_copy | Yes — referral search subtitle names "Operator" | +| `desktop/macos/Desktop/Sources/MainWindow/Referrals/ReferralProgramView.swift` (line 64,69) | plan_name_or_tier_copy | Yes — referral header text hardcodes "Operator" and "one month" | +| `desktop/macos/Desktop/Tests/SubscriptionPlanCatalogMergerTests.swift` (line 1-58) | test_fixture | No — arbitrary test doubles, not real prices | +| `desktop/macos/Desktop/Tests/SubscriptionPlanPresentationTests.swift` (line 1-27) | test_fixture | No functionally, but coincidentally matches real Operator price — worth a glance | +| `desktop/macos/Desktop/Tests/SubscriptionInfoDecoderTests.swift` (line 1-186) | test_fixture | Only the deprecation-message fixture needs to track the real fallback string | +| `desktop/macos/Desktop/Tests/FloatingBarUsageLimiterTests.swift` (line 41-238) | test_fixture | Yes — quota fixtures (Architect $400 cap) should track real limits to stay meaningful | +| `desktop/macos/Desktop/Sources/VADGateService.swift` (line 483) | config_value | Yes, if Deepgram's per-minute cost changes — unrelated to Omi plan pricing | | `...AccountBilling.swift:414,422-435,479-494` overage card | reads_live_no_update_needed | No — all figures from live `OverageInfoResponse` | -| `Services/APIClient/APIClient+Settings.swift:411-609` displayName/price types | reads_live_no_update_needed | No — price fields populated live; only plan renames/new IDs touch `displayName` | -| `Desktop/Sources/UsageLimitPopupView.swift` | reads_live_no_update_needed | No — generic copy, no numbers | -| `FloatingControlBar/FloatingBarUsageLimiter.swift:147-155` `limitDescription` | reads_live_no_update_needed | No — reads live server quota object | +| `desktop/macos/Desktop/Sources/Services/APIClient/APIClient+Settings.swift` (line 411-609) displayName/price types | reads_live_no_update_needed | No — price fields populated live; only plan renames/new IDs touch `displayName` | +| `desktop/macos/Desktop/Sources/UsageLimitPopupView.swift` | reads_live_no_update_needed | No — generic copy, no numbers | +| `desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarUsageLimiter.swift` (line 147-155) `limitDescription` | reads_live_no_update_needed | No — reads live server quota object | ## Windows desktop app (desktop/windows/) | Location | Kind | Needs update when prices/tiers change? | |---|---|---| -| `src/renderer/src/lib/billing.ts:358-395` `PLAN_FALLBACKS` | feature_limit_number | Yes — fallback eyebrow/subtitle/description/features (fix the 100-vs-200 "unlimited" inconsistency while updating) | +| `desktop/windows/src/renderer/src/lib/billing.ts` (line 358-395) `PLAN_FALLBACKS` | feature_limit_number | Yes — fallback eyebrow/subtitle/description/features (fix the 100-vs-200 "unlimited" inconsistency while updating) | | `.../components/settings/tabs/PlanUsageTab.tsx:227-229` | hardcoded_amount | Yes — fallback deprecation banner hardcodes "$49/mo" | -| `src/renderer/src/lib/billing.test.ts:54-68,167-176,252,358-375,537-538,559` | test_fixture | Yes — full mock catalog with plan titles/prices baked into assertions | -| `src/renderer/src/lib/chatQuotaGate.test.ts:16,46,59,187` | test_fixture | Yes, if display names/quota model change | -| `src/renderer/src/lib/billingPlans.ts:94-101` `PLAN_DISPLAY_NAMES` | plan_name_or_tier_copy | Yes — client-side authority for plan display names | -| `src/renderer/src/lib/billingPlans.ts:20,80-86` alias/paid-ID sets | config_value | Yes, if plan IDs/aliases change | -| `src/renderer/src/lib/billing.ts:314-320,434-437` `PLAN_ORDER`/`canPurchasePlan` | config_value | Yes — hardcoded display order and downgrade-block business rule | -| `src/renderer/src/lib/billing.ts:68` `LEGACY_PLAN_TITLES` | plan_name_or_tier_copy | Only if the legacy-catalog canary titles themselves change | +| `desktop/windows/src/renderer/src/lib/billing.test.ts` (line 54-68,167-176,252,358-375,537-538,559) | test_fixture | Yes — full mock catalog with plan titles/prices baked into assertions | +| `desktop/windows/src/renderer/src/lib/chatQuotaGate.test.ts` (line 16,46,59,187) | test_fixture | Yes, if display names/quota model change | +| `desktop/windows/src/renderer/src/lib/billingPlans.ts` (line 94-101) `PLAN_DISPLAY_NAMES` | plan_name_or_tier_copy | Yes — client-side authority for plan display names | +| `desktop/windows/src/renderer/src/lib/billingPlans.ts` (line 20,80-86) alias/paid-ID sets | config_value | Yes, if plan IDs/aliases change | +| `desktop/windows/src/renderer/src/lib/billing.ts` (line 314-320,434-437) `PLAN_ORDER`/`canPurchasePlan` | config_value | Yes — hardcoded display order and downgrade-block business rule | +| `desktop/windows/src/renderer/src/lib/billing.ts` (line 68) `LEGACY_PLAN_TITLES` | plan_name_or_tier_copy | Only if the legacy-catalog canary titles themselves change | | `.../components/apps/AppDetailSheet.tsx:160,279,304` | other | No — marketplace app price is live, distinct pricing surface (not subscription plans) | | `billing.ts` fetch* functions + `PlanGrid`/`CurrentPlanCard`/`ChatUsageCard`/`OverageCard` render paths | reads_live_no_update_needed | No — plan titles, prices, usage, trial, overage all read live from backend | @@ -68,19 +68,19 @@ This repo already has an in-flight catalog migration — `docs/agents/plan-sourc | Location | Kind | Needs update when prices/tiers change? | |---|---|---| -| `web/app/src/components/settings/SettingsPage.tsx:968-974,1192` `limits` object | feature_limit_number | Yes — hardcoded Basic free-tier caps instead of reading from `UserSubscriptionResponse` | +| `web/app/src/components/settings/SettingsPage.tsx` (line 968-974,1192) `limits` object | feature_limit_number | Yes — hardcoded Basic free-tier caps instead of reading from `UserSubscriptionResponse` | | `SettingsPage.tsx:1178,1183-1184,1210` | feature_limit_number | Yes — literal "1,200 min" duplicated 3x for free-tier listening limit | | `SettingsPage.tsx:1140-1142,1150,1219-1241` | plan_name_or_tier_copy | Yes — Basic-plan "what's included" marketing copy is a silent duplicate of catalog entitlements | | `SettingsPage.tsx:1002-1008` `defaultFeatures` | plan_name_or_tier_copy | Yes — generic feature list shown for every paid plan, not per-plan sourced | -| `web/app/src/components/settings/PlansSheet.tsx:221-226` `defaultFeatures` | plan_name_or_tier_copy | Yes — second independent copy of the same generic feature list | -| `web/app/src/types/user.ts:134-147` `planDisplayName` | plan_name_or_tier_copy | Yes — must stay in sync with plan_catalog.json naming | -| `web/app/src/lib/api.ts:2014-2017` `SegmentEditPlanRequiredError` | plan_name_or_tier_copy | Yes — hardcodes "Unlimited plan" as the gating tier | -| `web/app/src/app/login/LoginClient.tsx:234` | plan_name_or_tier_copy | Yes — referral headline hardcodes "Operator", independent of backend grant logic | -| `web/admin/lib/stripe-subscriptions.ts:49-56` `OMI_PLAN_PRODUCTS` | plan_name_or_tier_copy | Yes — sole source of plan identity for admin revenue/subscription metrics; new Stripe product invisible until added | -| `web/admin/app/api/omi/stats/subscriptions/route.ts:64`, `.../stats/revenue/route.ts:63` | other | Yes — both depend on the same `OMI_PLAN_PRODUCTS` map above | -| `web/admin/lib/__tests__/stripe-subscriptions.test.ts:293-302` | test_fixture | Yes — independent literal array of 6 plan display names must track the map | -| `web/app/src/types/__tests__/userPlan.test.ts:51-62` | test_fixture | Yes — literal array of plan IDs; adding a 7th plan requires updating | -| `web/app/src/types/user.ts:65-72,96-102` `CATALOG_PLAN_IDS`/`PAID_CATALOG_PLAN_IDS` | reads_live_no_update_needed* | Yes, but this IS the canonical single-source client update point (not a stray duplicate) | +| `web/app/src/components/settings/PlansSheet.tsx` (line 221-226) `defaultFeatures` | plan_name_or_tier_copy | Yes — second independent copy of the same generic feature list | +| `web/app/src/types/user.ts` (line 134-147) `planDisplayName` | plan_name_or_tier_copy | Yes — must stay in sync with plan_catalog.json naming | +| `web/app/src/lib/api.ts` (line 2014-2017) `SegmentEditPlanRequiredError` | plan_name_or_tier_copy | Yes — hardcodes "Unlimited plan" as the gating tier | +| `web/app/src/app/login/LoginClient.tsx` (line 234) | plan_name_or_tier_copy | Yes — referral headline hardcodes "Operator", independent of backend grant logic | +| `web/admin/lib/stripe-subscriptions.ts` (line 49-56) `OMI_PLAN_PRODUCTS` | plan_name_or_tier_copy | Yes — sole source of plan identity for admin revenue/subscription metrics; new Stripe product invisible until added | +| `web/admin/app/api/omi/stats/subscriptions/route.ts` (line 64), `.../stats/revenue/route.ts:63` | other | Yes — both depend on the same `OMI_PLAN_PRODUCTS` map above | +| `web/admin/lib/__tests__/stripe-subscriptions.test.ts` (line 293-302) | test_fixture | Yes — independent literal array of 6 plan display names must track the map | +| `web/app/src/types/__tests__/userPlan.test.ts` (line 51-62) | test_fixture | Yes — literal array of plan IDs; adding a 7th plan requires updating | +| `web/app/src/types/user.ts` (line 65-72,96-102) `CATALOG_PLAN_IDS`/`PAID_CATALOG_PLAN_IDS` | reads_live_no_update_needed* | Yes, but this IS the canonical single-source client update point (not a stray duplicate) | | `web/app/src/lib/api.ts` (getAvailablePlans, getUserSubscription, etc.) | reads_live_no_update_needed | No — all fetched live from backend/Stripe | | `web/admin/.../dashboard/subscriptions/page.tsx` | reads_live_no_update_needed | No — MRR/ARR/amounts computed live | | `web/admin/app/api/omi/stats/*` routes | reads_live_no_update_needed | No, except shared dependency on `OMI_PLAN_PRODUCTS` noted above | @@ -91,38 +91,38 @@ This repo already has an in-flight catalog migration — `docs/agents/plan-sourc | Location | Kind | Needs update when prices/tiers change? | |---|---|---| -| `src/app/components/product-banner/types.ts:23` `PRODUCT_INFO.price` | hardcoded_amount | Yes, if the $89 hardware price changes (device price, not subscription) | -| `src/app/apps/utils/metadata.ts:104` | hardcoded_amount | Yes — independent duplicate of the $89 device price for SEO JSON-LD | -| `src/app/apps/[id]/page.tsx:118` | hardcoded_amount | Yes — third independent duplicate of the $89 device price | -| `src/app/unlimited/page.tsx:6,39,41` | plan_name_or_tier_copy | Yes — stale "Omi Unlimited" landing page name vs. current catalog naming (unlimited_v2 vs deprecated Neo) | +| `web/frontend/src/app/components/product-banner/types.ts` (line 23) `PRODUCT_INFO.price` | hardcoded_amount | Yes, if the $89 hardware price changes (device price, not subscription) | +| `web/frontend/src/app/apps/utils/metadata.ts` (line 104) | hardcoded_amount | Yes — independent duplicate of the $89 device price for SEO JSON-LD | +| `web/frontend/src/app/apps/[id]/page.tsx` (line 118) | hardcoded_amount | Yes — third independent duplicate of the $89 device price | +| `web/frontend/src/app/unlimited/page.tsx` (line 6,39,41) | plan_name_or_tier_copy | Yes — stale "Omi Unlimited" landing page name vs. current catalog naming (unlimited_v2 vs deprecated Neo) | | `src/__tests__/wrapped-unlimited-deeplink-parity.test.mjs` | test_fixture | Yes — static string tripwire on the `/unlimited` route name, breaks if route is renamed | -| `src/app/apps/utils/metadata.ts:214-215` `generateAppListSchema` | other | No — correctly hardcoded $0 for free-to-list marketplace apps | -| `src/app/create-app/page.tsx:49,294,310,366,774-859` | reads_live_no_update_needed | No — third-party developer's own app-pricing form field, unrelated to Omi plans | +| `web/frontend/src/app/apps/utils/metadata.ts` (line 214-215) `generateAppListSchema` | other | No — correctly hardcoded $0 for free-to-list marketplace apps | +| `web/frontend/src/app/create-app/page.tsx` (line 49,294,310,366,774-859) | reads_live_no_update_needed | No — third-party developer's own app-pricing form field, unrelated to Omi plans | | `public/` images (omi_1.webp, etc.) | other | Unknown — not visually inspected for baked-in price text | -| `src/app/page.tsx` | reads_live_no_update_needed | No — redirects to /apps, no pricing content | +| `web/frontend/src/app/page.tsx` | reads_live_no_update_needed | No — redirects to /apps, no pricing content | ## backend/ and top-level config/ (pricing/plan catalog surface) | Location | Kind | Needs update when prices/tiers change? | |---|---|---| -| `backend/config/plan_catalog.json:115-570` allocations, `:12-76` allocation_profiles | feature_limit_number | Yes — this IS the canonical place to edit finite quotas/budgets | -| `backend/utils/subscription.py:474-535` `get_paid_plan_definitions` | plan_name_or_tier_copy | Yes — hardcoded storefront titles/eyebrows/subtitle/description text | -| `backend/utils/subscription.py:484,496,508,520,532` `annual_description` | hardcoded_amount | Yes — "Save ~17%" baked into copy, not computed from live Stripe monthly/annual ratio | -| `backend/utils/subscription.py:987-1002` `_chat_allowance_text` | config_value | Yes (via editing plan_catalog.json) — derives "$400" from catalog usd_cent value; live-read but easy to mistake for Stripe-sourced | -| `backend/routers/payment.py:196` docstring example | localized_copy | Cosmetic only — illustrative comment, not runtime | +| `backend/config/plan_catalog.json` (line 115-570) allocations, `:12-76` allocation_profiles | feature_limit_number | Yes — this IS the canonical place to edit finite quotas/budgets | +| `backend/utils/subscription.py` (line 474-535) `get_paid_plan_definitions` | plan_name_or_tier_copy | Yes — hardcoded storefront titles/eyebrows/subtitle/description text | +| `backend/utils/subscription.py` (line 484,496,508,520,532) `annual_description` | hardcoded_amount | Yes — "Save ~17%" baked into copy, not computed from live Stripe monthly/annual ratio | +| `backend/utils/subscription.py` (line 987-1002) `_chat_allowance_text` | config_value | Yes (via editing plan_catalog.json) — derives "$400" from catalog usd_cent value; live-read but easy to mistake for Stripe-sourced | +| `backend/routers/payment.py` (line 196) docstring example | localized_copy | Cosmetic only — illustrative comment, not runtime | | `backend/charts/backend-listen/{dev,prod}_*_values.yaml`, `backend/charts/pusher/{dev,prod}_*_values.yaml` | config_value | Yes — Helm env vars duplicate quota overlays and Stripe price IDs for legacy plans; must update both dev+prod together | | Chart files' `SUBSCRIPTION_LAUNCH_DATE` | config_value | Yes, if the cutover date changes — duplicated across 4 files | -| `backend/deploy/runtime_env/prod.overlay.yaml:185-192`, `backend/deploy/runtime_env.yaml:1603-1610` | config_value | Yes — literal Plus/Unlimited-v2 Stripe price IDs (prod only), must track plan_catalog.json's recognized_stripe_prices | +| `backend/deploy/runtime_env/prod.overlay.yaml` (line 185-192), `backend/deploy/runtime_env.yaml` (line 1603-1610) | config_value | Yes — literal Plus/Unlimited-v2 Stripe price IDs (prod only), must track plan_catalog.json's recognized_stripe_prices | | `backend/deploy/runtime_env/dev.overlay.yaml` (absence) | config_value | Confirm intentional — no dev price IDs for Plus/Unlimited-v2 today | -| `config/deployment-setting-classification.json:172-175` | config_value | Yes, if new plan price env-var names are introduced (need a classification entry) | -| `backend/config/plan_catalog.json:571-712` recognized_stripe_prices/products | config_value | Yes — append-only ledger; new Stripe prices for new/changed tiers must be appended here | -| `docs/agents/plan-source-of-truth.md:405-421` (open ledger gap) | other | Yes — pre-existing unresolved discrepancy between a test fixture and the dev ledger for Architect | -| `backend/tests/unit/test_available_plans_resilience.py:19-22,111` | test_fixture | Yes, if the dev ledger gap above is resolved | -| `backend/tests/unit/test_overage_catalog.py:8-16,29-42` | test_fixture | Yes — hardcoded per-plan hard-cap-vs-overage policy and quota numbers (500/$400/200) mirror catalog | -| `backend/tests/unit/test_subscription_restructure.py:1-2,88-98,510-515` | test_fixture | Yes — stale $49/$400 figures in docstring, plus hardcoded display-name assertions | -| `backend/utils/subscription.py:620-630,684-685` version gates | config_value | Yes, if a pricing change ships alongside a client capability gate | -| `backend/routers/payment.py:478-522` price_string/unit_amount | reads_live_no_update_needed | No — live `stripe.Price.retrieve` at request time | -| `backend/utils/subscription.py:1038-1039` `get_plan_display_name` | reads_live_no_update_needed | No — reads generated `PLAN_DISPLAY_NAMES` | +| `config/deployment-setting-classification.json` (line 172-175) | config_value | Yes, if new plan price env-var names are introduced (need a classification entry) | +| `backend/config/plan_catalog.json` (line 571-712) recognized_stripe_prices/products | config_value | Yes — append-only ledger; new Stripe prices for new/changed tiers must be appended here | +| `docs/agents/plan-source-of-truth.md` (line 405-421) (open ledger gap) | other | Yes — pre-existing unresolved discrepancy between a test fixture and the dev ledger for Architect | +| `backend/tests/unit/test_available_plans_resilience.py` (line 19-22,111) | test_fixture | Yes, if the dev ledger gap above is resolved | +| `backend/tests/unit/test_overage_catalog.py` (line 8-16,29-42) | test_fixture | Yes — hardcoded per-plan hard-cap-vs-overage policy and quota numbers (500/$400/200) mirror catalog | +| `backend/tests/unit/test_subscription_restructure.py` (line 1-2,88-98,510-515) | test_fixture | Yes — stale $49/$400 figures in docstring, plus hardcoded display-name assertions | +| `backend/utils/subscription.py` (line 620-630,684-685) version gates | config_value | Yes, if a pricing change ships alongside a client capability gate | +| `backend/routers/payment.py` (line 478-522) price_string/unit_amount | reads_live_no_update_needed | No — live `stripe.Price.retrieve` at request time | +| `backend/utils/subscription.py` (line 1038-1039) `get_plan_display_name` | reads_live_no_update_needed | No — reads generated `PLAN_DISPLAY_NAMES` | | `backend/config/plan_catalog_generated.py` | reads_live_no_update_needed | No — generated artifact, never hand-edited | | `backend/utils/stripe.py`, `backend/scripts/support/find_stripe_entitlement_mismatches.py`, `backend/config/plan_catalog.py` | reads_live_no_update_needed | No — generic helpers/facades with no hardcoded literals | @@ -130,15 +130,15 @@ This repo already has an in-flight catalog migration — `docs/agents/plan-sourc | Location | Kind | Needs update when prices/tiers change? | |---|---|---| -| `docs/api-reference/app-client-openapi.json:50708` (from `backend/routers/payment.py` docstring) | plan_name_or_tier_copy | Yes — stale "Unlimited→Pro" example; fix the source docstring, not the generated doc | -| `web/admin/docs/data-contracts.md:20` | config_value | Yes — procedural instruction to add a line to `OMI_PLAN_PRODUCTS` when launching a plan | -| `docs/doc/developer/mcp/tools.mdx:225` | feature_limit_number | Yes, if the free-tier preview length changes — hardcoded "70 characters" | -| `docs/doc/developer/mcp/tools.mdx:202,223`, `docs/doc/developer/mcp/troubleshooting.mdx:59-61` | plan_name_or_tier_copy | Only if the free/paid gating boundary itself changes — generic "paid plan" language | -| `docs/doc/developer/apps/Oauth.mdx:220` | other | Only if marketplace-app monetization scope changes — separate pricing system from the 6-plan catalog | -| `docs/agents/web-app-destinations.md:14` | other | No — pointer doc only, no names/prices | +| `docs/api-reference/app-client-openapi.json` (line 50708) (from `backend/routers/payment.py` docstring) | plan_name_or_tier_copy | Yes — stale "Unlimited→Pro" example; fix the source docstring, not the generated doc | +| `web/admin/docs/data-contracts.md` (line 20) | config_value | Yes — procedural instruction to add a line to `OMI_PLAN_PRODUCTS` when launching a plan | +| `docs/doc/developer/mcp/tools.mdx` (line 225) | feature_limit_number | Yes, if the free-tier preview length changes — hardcoded "70 characters" | +| `docs/doc/developer/mcp/tools.mdx` (line 202,223), `docs/doc/developer/mcp/troubleshooting.mdx` (line 59-61) | plan_name_or_tier_copy | Only if the free/paid gating boundary itself changes — generic "paid plan" language | +| `docs/doc/developer/apps/Oauth.mdx` (line 220) | other | Only if marketplace-app monetization scope changes — separate pricing system from the 6-plan catalog | +| `docs/agents/web-app-destinations.md` (line 14) | other | No — pointer doc only, no names/prices | | `docs/api-reference/app-client-openapi.json` (other endpoint descriptions) | reads_live_no_update_needed | No — auto-generated mirrors of backend docstrings | -| `backend/AGENTS.md:16` | reads_live_no_update_needed | No — describes live-validation behavior | -| `docs/doc/developer/backend/Backend_Setup.mdx:86` | reads_live_no_update_needed | No — generic setup instructions | +| `backend/AGENTS.md` (line 16) | reads_live_no_update_needed | No — describes live-validation behavior | +| `docs/doc/developer/backend/Backend_Setup.mdx` (line 86) | reads_live_no_update_needed | No — generic setup instructions | ## In-repo assets/imagery and app-store-adjacent files From 9387cc194d2c625743e391c7daaeb735d64b345a Mon Sep 17 00:00:00 2001 From: Forged2Form <9682873+formed2forge@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:07:33 -0400 Subject: [PATCH 5/7] chore(desktop): add changelog fragment for Neo plan question-count fix Co-Authored-By: Claude Sonnet 5 --- .../20260829-fix-neo-plan-question-count-fallback.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 desktop/macos/changelog/unreleased/20260829-fix-neo-plan-question-count-fallback.json diff --git a/desktop/macos/changelog/unreleased/20260829-fix-neo-plan-question-count-fallback.json b/desktop/macos/changelog/unreleased/20260829-fix-neo-plan-question-count-fallback.json new file mode 100644 index 00000000000..ba058e8e590 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20260829-fix-neo-plan-question-count-fallback.json @@ -0,0 +1,3 @@ +{ + "change": "Fixed the Unlimited plan's fallback description showing the wrong monthly question count" +} From afa998fa65d573d510e49fae90bf20ae02fed5b9 Mon Sep 17 00:00:00 2001 From: Forged2Form <9682873+formed2forge@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:24:23 -0400 Subject: [PATCH 6/7] docs: correct pricing inventory citations shifted by this PR's own edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six code files this PR modifies (BillingHelpers.swift, AccountBilling.swift, billing.ts, PlanUsageTab.tsx, billing.test.ts, SettingsPage.tsx, PlansSheet.tsx) had line numbers cited in the inventory doc; the edits shifted several of them. Recomputed each against the actual pre/post-edit blobs. Also updated the three findings this PR fixes (100-vs-200 inconsistency, hardcoded $49/mo, duplicated defaultFeatures) from open findings to "fixed in this PR", and removed the now-redundant duplicate defaultFeatures row in favor of pointing at the new shared file — the "Suggested next steps" section was telling the reader to fix things this same PR already fixes. Co-Authored-By: Claude Sonnet 5 --- .../pricing-scheme-touchpoint-inventory.md | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/docs/agents/pricing-scheme-touchpoint-inventory.md b/docs/agents/pricing-scheme-touchpoint-inventory.md index 43e2cd403c1..9366f50edd0 100644 --- a/docs/agents/pricing-scheme-touchpoint-inventory.md +++ b/docs/agents/pricing-scheme-touchpoint-inventory.md @@ -24,13 +24,13 @@ This repo already has an in-flight catalog migration — `docs/agents/plan-sourc |---|---|---| | `desktop/macos/Desktop/Sources/MainWindow/QueryShell/QueryShellHome.swift` (line 198-206) | hardcoded_amount | Likely dead, but Yes if reachable — stale "$199/month Omi Pro" alert, plan name doesn't match any current tier | | `desktop/macos/Desktop/Sources/Providers/ChatProvider.swift` (line 2959,4341,5340) | hardcoded_amount | Yes — literal $50 free-tier spend cap duplicated at 3 call sites | -| `...Settings/Components/SettingsContentView+BillingHelpers.swift:30` (comment) | hardcoded_amount | Yes (comment) — "Neo ($20) \| Operator ($49) \| Architect ($200)" | -| `...BillingHelpers.swift:137-148` `planSubtitle` | feature_limit_number | Yes — fallback subtitle question counts, only shown when catalog omits `subtitle` | -| `...BillingHelpers.swift:191-202` `planDescription` | feature_limit_number | Yes — fallback descriptions; also fix internal inconsistency (100 vs 200 for "unlimited") while updating | -| `...BillingHelpers.swift:239-265` `fallbackFeatures` | feature_limit_number | Yes — includes literal "~$400 of monthly AI compute" and question counts | -| `...Sections/SettingsContentView+AccountBilling.swift:342-345` | hardcoded_amount | Yes — fallback deprecation banner hardcodes "$49/mo" Operator price | -| `...BillingHelpers.swift:178-189` `planEyebrow` | plan_name_or_tier_copy | Yes — fallback marketing eyebrow text per plan id | -| `...BillingHelpers.swift:284-320` `planCatalog(from:)` + `normalizedPlanId` (267-282) | plan_name_or_tier_copy | Yes — fallback title mapping/keyword matching for plan display names | +| `...Settings/Components/SettingsContentView+BillingHelpers.swift:34` (comment) | hardcoded_amount | Yes (comment) — "Neo ($20) \| Operator ($49) \| Architect ($200)" | +| `...BillingHelpers.swift:141-152` `planSubtitle` | feature_limit_number | Yes — fallback subtitle question counts, only shown when catalog omits `subtitle`. Now `static` for testability | +| `...BillingHelpers.swift:195-206` `planDescription` | feature_limit_number | Yes — fallback descriptions; internal inconsistency (100 vs 200 for "unlimited") fixed in this PR. Now `static` for testability | +| `...BillingHelpers.swift:243-269` `fallbackFeatures` | feature_limit_number | Yes — includes literal "~$400 of monthly AI compute" and question counts. Now `static` for testability | +| `...Sections/SettingsContentView+AccountBilling.swift:342-345` | hardcoded_amount | Fixed in this PR — was a bare "$49/mo" literal, now reads `operatorDeprecationFallbackPrice`, a single named constant | +| `...BillingHelpers.swift:182-193` `planEyebrow` | plan_name_or_tier_copy | Yes — fallback marketing eyebrow text per plan id. Now `static` for testability | +| `...BillingHelpers.swift:288-324` `planCatalog(from:)` + `normalizedPlanId` (271-286) | plan_name_or_tier_copy | Yes — fallback title mapping/keyword matching for plan display names | | `...Sections/SettingsContentView+AccountBilling.swift:102-128` | other | Only if resurrected — dead/commented "Upgrade to Pro" card with stale marketing URL | | `desktop/macos/Desktop/Sources/Services/APIClient/APIClient+Settings.swift` (line 370) (comment) | hardcoded_amount | Yes (comment only) — "$400/mo" example for Architect | | `desktop/macos/Desktop/Sources/FloatingControlBar/FloatingBarUsageLimiter.swift` (line 15-27) `proactiveBudgetMultiplier` | config_value | Yes, if tiers/entitlements restructured — plan-tier-keyed multiplier constants | @@ -53,9 +53,9 @@ This repo already has an in-flight catalog migration — `docs/agents/plan-sourc | Location | Kind | Needs update when prices/tiers change? | |---|---|---| -| `desktop/windows/src/renderer/src/lib/billing.ts` (line 358-395) `PLAN_FALLBACKS` | feature_limit_number | Yes — fallback eyebrow/subtitle/description/features (fix the 100-vs-200 "unlimited" inconsistency while updating) | -| `.../components/settings/tabs/PlanUsageTab.tsx:227-229` | hardcoded_amount | Yes — fallback deprecation banner hardcodes "$49/mo" | -| `desktop/windows/src/renderer/src/lib/billing.test.ts` (line 54-68,167-176,252,358-375,537-538,559) | test_fixture | Yes — full mock catalog with plan titles/prices baked into assertions | +| `desktop/windows/src/renderer/src/lib/billing.ts` (line 362-399) `PLAN_FALLBACKS` | feature_limit_number | Yes — fallback eyebrow/subtitle/description/features; 100-vs-200 "unlimited" inconsistency fixed in this PR | +| `.../components/settings/tabs/PlanUsageTab.tsx:228-230` | hardcoded_amount | Fixed in this PR — was a bare "$49/mo" literal, now reads `OPERATOR_DEPRECATION_FALLBACK_PRICE`, a single named constant | +| `desktop/windows/src/renderer/src/lib/billing.test.ts` (line 54-68,167-176,252,358-372,546-547,568) | test_fixture | Yes — full mock catalog with plan titles/prices baked into assertions | | `desktop/windows/src/renderer/src/lib/chatQuotaGate.test.ts` (line 16,46,59,187) | test_fixture | Yes, if display names/quota model change | | `desktop/windows/src/renderer/src/lib/billingPlans.ts` (line 94-101) `PLAN_DISPLAY_NAMES` | plan_name_or_tier_copy | Yes — client-side authority for plan display names | | `desktop/windows/src/renderer/src/lib/billingPlans.ts` (line 20,80-86) alias/paid-ID sets | config_value | Yes, if plan IDs/aliases change | @@ -68,11 +68,10 @@ This repo already has an in-flight catalog migration — `docs/agents/plan-sourc | Location | Kind | Needs update when prices/tiers change? | |---|---|---| -| `web/app/src/components/settings/SettingsPage.tsx` (line 968-974,1192) `limits` object | feature_limit_number | Yes — hardcoded Basic free-tier caps instead of reading from `UserSubscriptionResponse` | -| `SettingsPage.tsx:1178,1183-1184,1210` | feature_limit_number | Yes — literal "1,200 min" duplicated 3x for free-tier listening limit | -| `SettingsPage.tsx:1140-1142,1150,1219-1241` | plan_name_or_tier_copy | Yes — Basic-plan "what's included" marketing copy is a silent duplicate of catalog entitlements | -| `SettingsPage.tsx:1002-1008` `defaultFeatures` | plan_name_or_tier_copy | Yes — generic feature list shown for every paid plan, not per-plan sourced | -| `web/app/src/components/settings/PlansSheet.tsx` (line 221-226) `defaultFeatures` | plan_name_or_tier_copy | Yes — second independent copy of the same generic feature list | +| `web/app/src/components/settings/SettingsPage.tsx` (line 969-975,1185) `limits` object | feature_limit_number | Yes — hardcoded Basic free-tier caps instead of reading from `UserSubscriptionResponse` | +| `SettingsPage.tsx:1171,1176-1177,1203` | feature_limit_number | Yes — literal "1,200 min" duplicated 3x for free-tier listening limit | +| `SettingsPage.tsx:1133-1135,1143,1212-1234` | plan_name_or_tier_copy | Yes — Basic-plan "what's included" marketing copy is a silent duplicate of catalog entitlements | +| `web/app/src/lib/planFeatures.ts` `DEFAULT_PLAN_FEATURES` | plan_name_or_tier_copy | Fixed in this PR — was two independent hardcoded copies (`SettingsPage.tsx` and `PlansSheet.tsx`), now one shared constant here | | `web/app/src/types/user.ts` (line 134-147) `planDisplayName` | plan_name_or_tier_copy | Yes — must stay in sync with plan_catalog.json naming | | `web/app/src/lib/api.ts` (line 2014-2017) `SegmentEditPlanRequiredError` | plan_name_or_tier_copy | Yes — hardcodes "Unlimited plan" as the gating tier | | `web/app/src/app/login/LoginClient.tsx` (line 234) | plan_name_or_tier_copy | Yes — referral headline hardcodes "Operator", independent of backend grant logic | @@ -162,9 +161,7 @@ This repo already has an in-flight catalog migration — `docs/agents/plan-sourc ## Suggested next steps -- Fix the two internal inconsistencies already found in *shipped* fallback copy now, independent of the new pricing decision: `SettingsContentView+BillingHelpers.swift` (100 vs 200 questions/month for "unlimited") and its Windows twin `PLAN_FALLBACKS` in `billing.ts` — these are bugs today, not migration work. -- Wire a single source read for the three duplicated hardcoded "$49/mo Operator" deprecation-banner strings (mobile ARB, macOS `AccountBilling.swift:342-345`, Windows `PlanUsageTab.tsx:227-229`) into one shared constant or backend-supplied fallback, so a future price change is a one-line edit instead of a three-repo grep — do this behind the fallback path itself (it only fires when the API omits `deprecation_message`), no final numbers required yet. -- Consolidate the two independent copies of the generic `defaultFeatures` list in `web/app/` (`SettingsPage.tsx` and `PlansSheet.tsx`) into one shared constant now — pure refactor, no pricing decision needed. +- **Done in this PR:** the two internal inconsistencies in *shipped* fallback copy (`SettingsContentView+BillingHelpers.swift` and its Windows twin `PLAN_FALLBACKS` in `billing.ts` both said 100 in one place and 200 in another for "unlimited"/Neo's monthly question count — 200 is correct per `plan_catalog.json`); the hardcoded "$49/mo" in macOS `AccountBilling.swift` and Windows `PlanUsageTab.tsx` deprecation-banner fallbacks, now each a single named constant; and the two independent copies of `web/app`'s generic `defaultFeatures` list, now `web/app/src/lib/planFeatures.ts`. The mobile ARB `planDeprecationMessage` string was deliberately left as-is — no confirmed Dart call site, and rewriting it would touch ~49 untranslated locale files. - Do not touch actual dollar literals yet (Stripe price IDs, `usd_cent` allocations in `plan_catalog.json`, chart/env price IDs) until final tier numbers are decided — those are single-edit-point changes by design and premature edits risk drifting from the still-open Architect dev-ledger gap tracked in `plan-source-of-truth.md`. - Resolve the open Architect dev Stripe price-ID ledger gap (`test_available_plans_resilience.py` vs `plan_catalog.json`) before shipping any new pricing that touches dev testing — verify against the dev Stripe dashboard first, per the doc's own caveat. - Flag the stale "Unlimited→Pro" example in the `backend/routers/payment.py` docstring as a quick fix independent of the pricing rollout — it's leaking a non-existent plan name into public OpenAPI docs today. From c34764a784747505790e8b5ea234aca015077059 Mon Sep 17 00:00:00 2001 From: Forged2Form <9682873+formed2forge@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:33:27 -0400 Subject: [PATCH 7/7] fix(desktop/macos): mark billing fallback test class @MainActor SettingsContentView's extension methods (planSubtitle/planDescription) inherit MainActor isolation from the view type, so calling the newly static helpers from a plain XCTestCase synchronously failed to compile under Xcode 16.4's actor-isolation checking. Verified via `xcrun swift test --filter SettingsContentViewBillingFallbackTests`, which failed before this fix and passes after (2/2). Failure-Class: none Co-Authored-By: Claude Sonnet 5 --- .../Desktop/Tests/SettingsContentViewBillingFallbackTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/desktop/macos/Desktop/Tests/SettingsContentViewBillingFallbackTests.swift b/desktop/macos/Desktop/Tests/SettingsContentViewBillingFallbackTests.swift index 8a7c024e090..3080f147656 100644 --- a/desktop/macos/Desktop/Tests/SettingsContentViewBillingFallbackTests.swift +++ b/desktop/macos/Desktop/Tests/SettingsContentViewBillingFallbackTests.swift @@ -7,6 +7,7 @@ import XCTest /// These shipped disagreeing for the "unlimited" (Neo) plan id — the subtitle /// said 200 questions/month while the description said 100 — so a user could /// see either number depending which card region they read. +@MainActor final class SettingsContentViewBillingFallbackTests: XCTestCase { private func leadingQuestionCount(in text: String?) -> String? { guard let first = text?.split(separator: " ").first, first.allSatisfy(\.isNumber) else {