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..3080f147656 --- /dev/null +++ b/desktop/macos/Desktop/Tests/SettingsContentViewBillingFallbackTests.swift @@ -0,0 +1,31 @@ +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. +@MainActor +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/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" +} 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', diff --git a/docs/agents/pricing-scheme-touchpoint-inventory.md b/docs/agents/pricing-scheme-touchpoint-inventory.md new file mode 100644 index 00000000000..9366f50edd0 --- /dev/null +++ b/docs/agents/pricing-scheme-touchpoint-inventory.md @@ -0,0 +1,169 @@ +# 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` (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` (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/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: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 | +| `TrialBannerService.swift:88` | hardcoded_amount | Yes — "3-day premium trial" string doesn't interpolate from `trialDurationSeconds` | +| `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` | +| `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? | +|---|---|---| +| `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 | +| `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 | + +## 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` (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 | +| `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 | +| `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? | +|---|---|---| +| `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 | +| `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 | +| `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` (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` (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` (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 | + +## Repository documentation and agent guides + +| Location | Kind | Needs update when prices/tiers change? | +|---|---|---| +| `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` (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 + +| 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 + +- **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. +- 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. 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: