diff --git a/app/changelog/unreleased/20261013-storefront-presentation-v2.json b/app/changelog/unreleased/20261013-storefront-presentation-v2.json new file mode 100644 index 00000000000..005f4da6232 --- /dev/null +++ b/app/changelog/unreleased/20261013-storefront-presentation-v2.json @@ -0,0 +1 @@ +{"kind":"none"} diff --git a/app/lib/backend/schema/gen/storefront_wire.g.dart b/app/lib/backend/schema/gen/storefront_wire.g.dart index a4f35f12202..6c137adec65 100644 --- a/app/lib/backend/schema/gen/storefront_wire.g.dart +++ b/app/lib/backend/schema/gen/storefront_wire.g.dart @@ -2,76 +2,35 @@ // ignore_for_file: unused_element // Generated by backend/scripts/generate_dart_models.py --group storefront from docs/api-reference/app-client-openapi.json. -class GeneratedAnnualSavings { - final int amountMinor; - final String formatted; - final double percent; - - const GeneratedAnnualSavings({ - required this.amountMinor, - required this.formatted, - required this.percent, - }); - - factory GeneratedAnnualSavings.fromJson(Map json) { - return GeneratedAnnualSavings( - amountMinor: _required(_readFieldValue(_readField(json, const ["amount_minor"]), "amount_minor", _readInt, requiredField: true, nullable: false), "amount_minor"), - formatted: _required(_readFieldValue(_readField(json, const ["formatted"]), "formatted", _readString, requiredField: true, nullable: false), "formatted"), - percent: _required(_readFieldValue(_readField(json, const ["percent"]), "percent", _readDouble, requiredField: true, nullable: false), "percent"), - ); - } - - Map toJson() { - return { - 'amount_minor': amountMinor, - 'formatted': formatted, - 'percent': percent, - }; - } -} - -class GeneratedPlanFeature { - final bool included; - final String key; - final String label; - - const GeneratedPlanFeature({ - this.included = true, - required this.key, - required this.label, - }); - - factory GeneratedPlanFeature.fromJson(Map json) { - return GeneratedPlanFeature( - included: _required(_readFieldValue(_readField(json, const ["included"]), "included", _readBool, requiredField: false, nullable: false, defaultValue: true), "included"), - key: _required(_readFieldValue(_readField(json, const ["key"]), "key", _readString, requiredField: true, nullable: false), "key"), - label: _required(_readFieldValue(_readField(json, const ["label"]), "label", _readString, requiredField: true, nullable: false), "label"), - ); - } - - Map toJson() { - return { - 'included': included, - 'key': key, - 'label': label, - }; - } -} - -class GeneratedStorefrontCurrentPlan { +class GeneratedStorefrontCurrent { + final bool cancelAtPeriodEnd; + final String? interval; final bool legacy; + final String? legacyNote; + final String line; + final String name; final String planId; final String? priceId; - const GeneratedStorefrontCurrentPlan({ + const GeneratedStorefrontCurrent({ + this.cancelAtPeriodEnd = false, + this.interval, required this.legacy, + this.legacyNote, + required this.line, + required this.name, required this.planId, this.priceId, }); - factory GeneratedStorefrontCurrentPlan.fromJson(Map json) { - return GeneratedStorefrontCurrentPlan( + factory GeneratedStorefrontCurrent.fromJson(Map json) { + return GeneratedStorefrontCurrent( + cancelAtPeriodEnd: _required(_readFieldValue(_readField(json, const ["cancel_at_period_end"]), "cancel_at_period_end", _readBool, requiredField: false, nullable: false, defaultValue: false), "cancel_at_period_end"), + interval: _readFieldValue(_readField(json, const ["interval"]), "interval", _readString, requiredField: false, nullable: true), legacy: _required(_readFieldValue(_readField(json, const ["legacy"]), "legacy", _readBool, requiredField: true, nullable: false), "legacy"), + legacyNote: _readFieldValue(_readField(json, const ["legacy_note"]), "legacy_note", _readString, requiredField: false, nullable: true), + line: _required(_readFieldValue(_readField(json, const ["line"]), "line", _readString, requiredField: true, nullable: false), "line"), + name: _required(_readFieldValue(_readField(json, const ["name"]), "name", _readString, requiredField: true, nullable: false), "name"), planId: _required(_readFieldValue(_readField(json, const ["plan_id"]), "plan_id", _readString, requiredField: true, nullable: false), "plan_id"), priceId: _readFieldValue(_readField(json, const ["price_id"]), "price_id", _readString, requiredField: false, nullable: true), ); @@ -79,7 +38,12 @@ class GeneratedStorefrontCurrentPlan { Map toJson() { return { + 'cancel_at_period_end': cancelAtPeriodEnd, + 'interval': interval, 'legacy': legacy, + 'legacy_note': legacyNote, + 'line': line, + 'name': name, 'plan_id': planId, 'price_id': priceId, }; @@ -88,76 +52,87 @@ class GeneratedStorefrontCurrentPlan { class GeneratedStorefrontPrice { final int amountMinor; - final GeneratedAnnualSavings? annualSavings; + final String? ctaLabel; final String currency; - final String formattedAmount; + final String headline; final String interval; - final String monthlyEquivalent; + final int? monthsFree; final String? priceId; + final String? renewalLine; + final String? subline; const GeneratedStorefrontPrice({ required this.amountMinor, - this.annualSavings, + this.ctaLabel, required this.currency, - required this.formattedAmount, + required this.headline, required this.interval, - required this.monthlyEquivalent, + this.monthsFree, this.priceId, + this.renewalLine, + this.subline, }); factory GeneratedStorefrontPrice.fromJson(Map json) { return GeneratedStorefrontPrice( amountMinor: _required(_readFieldValue(_readField(json, const ["amount_minor"]), "amount_minor", _readInt, requiredField: true, nullable: false), "amount_minor"), - annualSavings: _readFieldValue(_readField(json, const ["annual_savings"]), "annual_savings", (value) => _readObject(value, GeneratedAnnualSavings.fromJson), requiredField: false, nullable: true), + ctaLabel: _readFieldValue(_readField(json, const ["cta_label"]), "cta_label", _readString, requiredField: false, nullable: true), currency: _required(_readFieldValue(_readField(json, const ["currency"]), "currency", _readString, requiredField: true, nullable: false), "currency"), - formattedAmount: _required(_readFieldValue(_readField(json, const ["formatted_amount"]), "formatted_amount", _readString, requiredField: true, nullable: false), "formatted_amount"), + headline: _required(_readFieldValue(_readField(json, const ["headline"]), "headline", _readString, requiredField: true, nullable: false), "headline"), interval: _required(_readFieldValue(_readField(json, const ["interval"]), "interval", _readString, requiredField: true, nullable: false), "interval"), - monthlyEquivalent: _required(_readFieldValue(_readField(json, const ["monthly_equivalent"]), "monthly_equivalent", _readString, requiredField: true, nullable: false), "monthly_equivalent"), + monthsFree: _readFieldValue(_readField(json, const ["months_free"]), "months_free", _readInt, requiredField: false, nullable: true), priceId: _readFieldValue(_readField(json, const ["price_id"]), "price_id", _readString, requiredField: false, nullable: true), + renewalLine: _readFieldValue(_readField(json, const ["renewal_line"]), "renewal_line", _readString, requiredField: false, nullable: true), + subline: _readFieldValue(_readField(json, const ["subline"]), "subline", _readString, requiredField: false, nullable: true), ); } Map toJson() { return { 'amount_minor': amountMinor, - 'annual_savings': annualSavings?.toJson(), + 'cta_label': ctaLabel, 'currency': currency, - 'formatted_amount': formattedAmount, + 'headline': headline, 'interval': interval, - 'monthly_equivalent': monthlyEquivalent, + 'months_free': monthsFree, 'price_id': priceId, + 'renewal_line': renewalLine, + 'subline': subline, }; } } class GeneratedStorefrontResponse { final int catalogRevision; - final GeneratedStorefrontCurrentPlan? current; - final String? nextOfferTierId; + final GeneratedStorefrontCurrent? current; + final String? header; final int schemaVersion; final bool showSubscriptionUi; final List? tiers; final GeneratedStorefrontUsage? usage; + final String? yearlyToggleLabel; const GeneratedStorefrontResponse({ required this.catalogRevision, this.current, - this.nextOfferTierId, - this.schemaVersion = 1, + this.header, + this.schemaVersion = 2, this.showSubscriptionUi = true, this.tiers, this.usage, + this.yearlyToggleLabel, }); factory GeneratedStorefrontResponse.fromJson(Map json) { return GeneratedStorefrontResponse( catalogRevision: _required(_readFieldValue(_readField(json, const ["catalog_revision"]), "catalog_revision", _readInt, requiredField: true, nullable: false), "catalog_revision"), - current: _readFieldValue(_readField(json, const ["current"]), "current", (value) => _readObject(value, GeneratedStorefrontCurrentPlan.fromJson), requiredField: false, nullable: true), - nextOfferTierId: _readFieldValue(_readField(json, const ["next_offer_tier_id"]), "next_offer_tier_id", _readString, requiredField: false, nullable: true), - schemaVersion: _required(_readFieldValue(_readField(json, const ["schema_version"]), "schema_version", _readInt, requiredField: false, nullable: false, defaultValue: 1), "schema_version"), + current: _readFieldValue(_readField(json, const ["current"]), "current", (value) => _readObject(value, GeneratedStorefrontCurrent.fromJson), requiredField: false, nullable: true), + header: _readFieldValue(_readField(json, const ["header"]), "header", _readString, requiredField: false, nullable: true), + schemaVersion: _required(_readFieldValue(_readField(json, const ["schema_version"]), "schema_version", _readInt, requiredField: false, nullable: false, defaultValue: 2), "schema_version"), showSubscriptionUi: _required(_readFieldValue(_readField(json, const ["show_subscription_ui"]), "show_subscription_ui", _readBool, requiredField: false, nullable: false, defaultValue: true), "show_subscription_ui"), tiers: _readFieldValue>(_readField(json, const ["tiers"]), "tiers", (value) => _readObjectList(value, GeneratedStorefrontTier.fromJson), requiredField: false, nullable: true), usage: _readFieldValue(_readField(json, const ["usage"]), "usage", (value) => _readObject(value, GeneratedStorefrontUsage.fromJson), requiredField: false, nullable: true), + yearlyToggleLabel: _readFieldValue(_readField(json, const ["yearly_toggle_label"]), "yearly_toggle_label", _readString, requiredField: false, nullable: true), ); } @@ -165,51 +140,58 @@ class GeneratedStorefrontResponse { return { 'catalog_revision': catalogRevision, 'current': current?.toJson(), - 'next_offer_tier_id': nextOfferTierId, + 'header': header, 'schema_version': schemaVersion, 'show_subscription_ui': showSubscriptionUi, 'tiers': tiers?.map((value) => value.toJson()).toList(), 'usage': usage?.toJson(), + 'yearly_toggle_label': yearlyToggleLabel, }; } } class GeneratedStorefrontTier { final String action; - final String displayName; - final String entitlementVersion; - final List features; - final bool legacy; + final String? blockedLine; + final List bullets; + final bool compact; + final String? ctaLabel; + final String hoursLine; + final bool isCurrent; + final String name; final String planId; final List prices; final bool recommended; - final String tagline; final String tierId; const GeneratedStorefrontTier({ required this.action, - required this.displayName, - required this.entitlementVersion, - required this.features, - this.legacy = false, + this.blockedLine, + required this.bullets, + this.compact = false, + this.ctaLabel, + required this.hoursLine, + this.isCurrent = false, + required this.name, required this.planId, required this.prices, this.recommended = false, - required this.tagline, required this.tierId, }); factory GeneratedStorefrontTier.fromJson(Map json) { return GeneratedStorefrontTier( action: _required(_readFieldValue(_readField(json, const ["action"]), "action", _readString, requiredField: true, nullable: false), "action"), - displayName: _required(_readFieldValue(_readField(json, const ["display_name"]), "display_name", _readString, requiredField: true, nullable: false), "display_name"), - entitlementVersion: _required(_readFieldValue(_readField(json, const ["entitlement_version"]), "entitlement_version", _readString, requiredField: true, nullable: false), "entitlement_version"), - features: _required(_readFieldValue>(_readField(json, const ["features"]), "features", (value) => _readObjectList(value, GeneratedPlanFeature.fromJson), requiredField: true, nullable: false), "features"), - legacy: _required(_readFieldValue(_readField(json, const ["legacy"]), "legacy", _readBool, requiredField: false, nullable: false, defaultValue: false), "legacy"), + blockedLine: _readFieldValue(_readField(json, const ["blocked_line"]), "blocked_line", _readString, requiredField: false, nullable: true), + bullets: _required(_readFieldValue>(_readField(json, const ["bullets"]), "bullets", _readStringList, requiredField: true, nullable: false), "bullets"), + compact: _required(_readFieldValue(_readField(json, const ["compact"]), "compact", _readBool, requiredField: false, nullable: false, defaultValue: false), "compact"), + ctaLabel: _readFieldValue(_readField(json, const ["cta_label"]), "cta_label", _readString, requiredField: false, nullable: true), + hoursLine: _required(_readFieldValue(_readField(json, const ["hours_line"]), "hours_line", _readString, requiredField: true, nullable: false), "hours_line"), + isCurrent: _required(_readFieldValue(_readField(json, const ["is_current"]), "is_current", _readBool, requiredField: false, nullable: false, defaultValue: false), "is_current"), + name: _required(_readFieldValue(_readField(json, const ["name"]), "name", _readString, requiredField: true, nullable: false), "name"), planId: _required(_readFieldValue(_readField(json, const ["plan_id"]), "plan_id", _readString, requiredField: true, nullable: false), "plan_id"), prices: _required(_readFieldValue>(_readField(json, const ["prices"]), "prices", (value) => _readObjectList(value, GeneratedStorefrontPrice.fromJson), requiredField: true, nullable: false), "prices"), recommended: _required(_readFieldValue(_readField(json, const ["recommended"]), "recommended", _readBool, requiredField: false, nullable: false, defaultValue: false), "recommended"), - tagline: _required(_readFieldValue(_readField(json, const ["tagline"]), "tagline", _readString, requiredField: true, nullable: false), "tagline"), tierId: _required(_readFieldValue(_readField(json, const ["tier_id"]), "tier_id", _readString, requiredField: true, nullable: false), "tier_id"), ); } @@ -217,43 +199,61 @@ class GeneratedStorefrontTier { Map toJson() { return { 'action': action, - 'display_name': displayName, - 'entitlement_version': entitlementVersion, - 'features': features.map((value) => value.toJson()).toList(), - 'legacy': legacy, + 'blocked_line': blockedLine, + 'bullets': bullets, + 'compact': compact, + 'cta_label': ctaLabel, + 'hours_line': hoursLine, + 'is_current': isCurrent, + 'name': name, 'plan_id': planId, 'prices': prices.map((value) => value.toJson()).toList(), 'recommended': recommended, - 'tagline': tagline, 'tier_id': tierId, }; } } class GeneratedStorefrontUsage { - final double? budgetPercent; final String kind; - final int? resetAt; + final String? meterLine; + final String? nudgeLine; + final int? percent; + final String? periodId; + final List? proGates; + final String? resetLabel; const GeneratedStorefrontUsage({ - this.budgetPercent, this.kind = "unavailable", - this.resetAt, + this.meterLine, + this.nudgeLine, + this.percent, + this.periodId, + this.proGates, + this.resetLabel, }); factory GeneratedStorefrontUsage.fromJson(Map json) { return GeneratedStorefrontUsage( - budgetPercent: _readFieldValue(_readField(json, const ["budget_percent"]), "budget_percent", _readDouble, requiredField: false, nullable: true), kind: _required(_readFieldValue(_readField(json, const ["kind"]), "kind", _readString, requiredField: false, nullable: false, defaultValue: "unavailable"), "kind"), - resetAt: _readFieldValue(_readField(json, const ["reset_at"]), "reset_at", _readInt, requiredField: false, nullable: true), + meterLine: _readFieldValue(_readField(json, const ["meter_line"]), "meter_line", _readString, requiredField: false, nullable: true), + nudgeLine: _readFieldValue(_readField(json, const ["nudge_line"]), "nudge_line", _readString, requiredField: false, nullable: true), + percent: _readFieldValue(_readField(json, const ["percent"]), "percent", _readInt, requiredField: false, nullable: true), + periodId: _readFieldValue(_readField(json, const ["period_id"]), "period_id", _readString, requiredField: false, nullable: true), + proGates: _readFieldValue>(_readField(json, const ["pro_gates"]), "pro_gates", _readStringList, requiredField: false, nullable: true), + resetLabel: _readFieldValue(_readField(json, const ["reset_label"]), "reset_label", _readString, requiredField: false, nullable: true), ); } Map toJson() { return { - 'budget_percent': budgetPercent, 'kind': kind, - 'reset_at': resetAt, + 'meter_line': meterLine, + 'nudge_line': nudgeLine, + 'percent': percent, + 'period_id': periodId, + 'pro_gates': proGates, + 'reset_label': resetLabel, }; } } diff --git a/app/test/backend/storefront_wire_test.dart b/app/test/backend/storefront_wire_test.dart index 07c2614af88..c7437b5a8fc 100644 --- a/app/test/backend/storefront_wire_test.dart +++ b/app/test/backend/storefront_wire_test.dart @@ -5,29 +5,22 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:omi/backend/schema/gen/storefront_wire.g.dart'; void main() { - Map fixture() => - jsonDecode(File('../backend/tests/unit/fixtures/storefront/ladder_v1_free.json').readAsStringSync()) - as Map; + test('storefront v2 golden decodes through the generated client', () { + final raw = File('../backend/tests/fixtures/storefront_v2/free_settings.json').readAsStringSync(); + final decoded = GeneratedStorefrontResponse.fromJson(jsonDecode(raw) as Map); - test('generated DTO decodes the backend fixture without client plan rules', () { - final json = fixture(); - final sheet = GeneratedStorefrontResponse.fromJson(json); - final tiers = sheet.tiers!; - expect(sheet.schemaVersion, 1); - expect(sheet.nextOfferTierId, 'plus:ladder_v1'); - expect(tiers.map((tier) => tier.displayName), ['Free', 'Plus', 'Pro', 'Architect']); - expect(tiers[1].prices[1].annualSavings!.amountMinor, 3998); - expect(tiers[1].prices[1].monthlyEquivalent, r'$16.66/mo'); - expect(GeneratedStorefrontResponse.fromJson(sheet.toJson()).toJson(), sheet.toJson()); - }); + expect(decoded.schemaVersion, 2); + expect(decoded.header, 'Choose your plan'); + final tiers = decoded.tiers!; + expect(tiers.map((tier) => tier.name), ['Plus', 'Free', 'Pro', 'Architect']); + expect(tiers.first.recommended, isTrue); + expect(decoded.yearlyToggleLabel, 'Yearly · 2 months free'); + final free = tiers.firstWhere((tier) => tier.name == 'Free'); + expect(free.compact, isTrue); + expect(free.blockedLine, isNull); + expect(free.ctaLabel, isNull); - test('wire tier identity tolerates a future backend plan and additive fields', () { - final json = fixture(); - final tiers = json['tiers'] as List; - final first = tiers.first as Map; - first['plan_id'] = 'future_backend_plan'; - first['future_field'] = true; - final sheet = GeneratedStorefrontResponse.fromJson(json); - expect(sheet.tiers!.first.planId, 'future_backend_plan'); + final roundTrip = GeneratedStorefrontResponse.fromJson(decoded.toJson()); + expect(roundTrip.toJson(), decoded.toJson()); }); } diff --git a/backend/config/storefront_copy_v1.json b/backend/config/storefront_copy_v1.json new file mode 100644 index 00000000000..fe5f94d08d0 --- /dev/null +++ b/backend/config/storefront_copy_v1.json @@ -0,0 +1,107 @@ +{ + "schema_version": 1, + "default_locale": "en", + "recording": { + "units_per_recorded_hour": 130, + "recording_share": 0.85, + "free_hours": 15, + "note": "units_per_recorded_hour is provisional (~$0.13 fully loaded: transcription $1.83 per 1,000 wall-clock minutes + ~19% extraction) from evidence/2026-10-12-unit-economics-and-usage.md §1/§3. Replace with shadow-ledger p50 (#21369)." + }, + "budget_units_note": "Plan-catalog usage budgets in units per month (Plus 16360, Pro 44090, Architect 189463). plan_catalog.json has no usage-units allocation; this rate card is what the hours formula reads until the catalog exposes one. Free hours are fixed.", + "budget_units": { + "plus": 16360, + "operator": 44090, + "architect": 189463 + }, + "plan_names": { + "unlimited": "Neo", + "unlimited_v2": "Unlimited" + }, + "locales": { + "en": { + "headers": { + "default": "Choose your plan", + "budget_80": "You've used 80% of this month's usage", + "budget_100": "You've used all of this month's usage", + "proactive_agent": "Reminders and follow-ups come with Pro", + "screen_context": "Screen help comes with Pro", + "mcp_daily_cap": "You've used today's {n} MCP requests", + "legacy": "Your plan", + "cancel_scheduled": "Your {plan} plan ends {date}" + }, + "current": { + "free": "You're on Free", + "paid": "You're on {plan} · {price}/{period} · renews {date}", + "cancel": "You're on {plan} · {price}/{period}", + "cancel_ends": "You're on {plan} · ends {date}", + "paid_no_date": "You're on {plan} · {price}/{period}", + "name_only": "You're on {plan}", + "legacy_note": "We don't sell this plan anymore, but yours stays exactly as it is." + }, + "hours_lines": { + "basic": "About {hours} hours of recording a month", + "plus": "About {hours} hours of recording a month, around {per_day} hours a day", + "operator": "About {hours} hours of recording a month, enough to wear it all day", + "architect": "About {hours} hours of recording a month, for all-day use on more than one device" + }, + "per_day": { + "under_6_one": "about {n} hour a day", + "under_6": "about {n} hours a day", + "all_day": "enough to wear it all day", + "multi_device": "for wearing it all day, every day, on more than one device" + }, + "bullets": { + "basic": [ + "Transcripts, summaries and memories of your conversations", + "Ask Omi anything about your day", + "100 MCP requests a day" + ], + "plus": [ + "Everything in Free, with much more room", + "Use Omi on your Mac as well as your phone", + "500 MCP requests a day" + ], + "operator": [ + "Omi reminds you of things you said you'd do", + "On your Mac, Omi can see what you're working on and help with it", + "5,000 MCP requests a day" + ], + "architect": [ + "Everything in Pro, with over 4 times the usage", + "At least 5,000 MCP requests a day" + ] + }, + "bullet_focus": { + "mcp_marker": "MCP requests", + "proactive_agent": "Omi reminds you of things you said you'd do", + "screen_context": "On your Mac, Omi can see what you're working on and help with it" + }, + "cta": { + "get_plus": "Get Plus", + "get_pro": "Get Pro", + "get_architect": "Get Architect", + "upgrade_pro": "Upgrade to Pro", + "upgrade_architect": "Upgrade to Architect", + "resubscribe": "Resubscribe", + "switch_yearly": "Switch to yearly · save {saving}" + }, + "blocked_line": "Resubscribe first to change plans", + "prices": { + "headline_month": "{amount}/month", + "headline_year": "{amount}/month", + "subline_year": "{amount} billed yearly · {free}", + "subline_year_no_free": "{amount} billed yearly", + "months_free_one": "1 month free", + "months_free_many": "{n} months free", + "renewal_month": "Renews monthly until you cancel. Cancel anytime in settings.", + "renewal_year": "Renews yearly until you cancel. Cancel anytime in settings.", + "toggle": "Yearly · {free}" + }, + "meter": { + "line": "{percent}% used · about {hours} hours of recording left · {reset}", + "nudge_80": "You've used 80% of this month's usage · {reset}" + }, + "pro_gates": ["proactive_agent", "screen_context"] + } + } +} diff --git a/backend/config/storefront_ladder.json b/backend/config/storefront_ladder.json index a2806064207..d78c3cddc23 100644 --- a/backend/config/storefront_ladder.json +++ b/backend/config/storefront_ladder.json @@ -1,10 +1,10 @@ { "schema_version": 1, "tiers": [ - {"plan_id": "basic", "display_name": "Free", "tagline": "Start building your memory", "recommended": false, "mcp_daily_cap": 100}, - {"plan_id": "plus", "display_name": "Plus", "tagline": "Your everyday memory companion", "recommended": true, "mcp_daily_cap": 500, "proposed_prices": {"month": 1999, "year": 19990}}, - {"plan_id": "operator", "display_name": "Pro", "tagline": "Proactive help with screen context", "recommended": false, "mcp_daily_cap": 5000, "proposed_prices": {"month": 4999, "year": 49990}}, - {"plan_id": "architect", "display_name": "Architect", "tagline": "For demanding workflows", "recommended": false, "mcp_daily_cap": 5000} + {"plan_id": "basic", "display_name": "Free", "mcp_daily_cap": 100}, + {"plan_id": "plus", "display_name": "Plus", "mcp_daily_cap": 500, "proposed_prices": {"month": 1999, "year": 19990}}, + {"plan_id": "operator", "display_name": "Pro", "mcp_daily_cap": 5000, "proposed_prices": {"month": 4999, "year": 49990}}, + {"plan_id": "architect", "display_name": "Architect", "mcp_daily_cap": 5000} ], "currency": "usd", "entitlement_version": "ladder_v1" diff --git a/backend/docs/storefront-contract.md b/backend/docs/storefront-contract.md index 77895137b03..afcf7504f59 100644 --- a/backend/docs/storefront-contract.md +++ b/backend/docs/storefront-contract.md @@ -1,124 +1,122 @@ # First-party storefront presentation contract -`GET /v2/payments/plans` uses Firebase authentication (including the existing -no-BYOK-validation payment dependency), with the shared per-UID `storefront:plans` -rate limit (12 requests/minute, boost-exempt, fail-open on Redis errors). Its response is `StorefrontResponse`, -`schema_version: 1`. A new endpoint preserves installed-client wire shapes and -version gates. Clients adopting this endpoint render server order, names, -feature labels, price strings and actions without plan-specific branching. -W6 owns client rendering; these DTOs do not install the new sheet by themselves. +`GET /v2/payments/plans?source=&trigger=&locale=` uses Firebase authentication +(including the existing no-BYOK-validation payment dependency), with the shared +per-UID `storefront:plans` rate limit (12 requests/minute, boost-exempt, +fail-open on Redis errors). Its response is `StorefrontResponse`, +`schema_version: 2`. Schema 1 on this route never shipped (the ladder flag +stayed off); the compatibility gate allows only that unpublished 1→2 response +replacement. Clients render server strings verbatim. Sheet chrome +(badges, toggle chrome, empty/error) stays in the client. W6 owns rendering; +these DTOs do not install the sheet by themselves. `STOREFRONT_LADDER_V1_ENABLED` defaults to `false`; only exact `true` admits the -new endpoint. Off returns 404 before database, App Review config or Stripe reads. +endpoint. Off returns 404 before database, App Review config or Stripe reads. No client header enables it. Agents must not change deployment configuration. -When admitted, the existing `should_hide_subscription_ui(uid, platform, version)` -rule runs before subscription, usage or price reads. Hidden callers receive -`show_subscription_ui: false`, no tiers and no current plan. The existing rule's -platform scope is unchanged. A lookup error does not expose the sheet. +When admitted, `should_hide_subscription_ui(uid, platform, version)` runs before +subscription, usage or price reads. Hidden callers receive +`show_subscription_ui: false`, no tiers and no current plan. A lookup error does +not expose the sheet. + +`locale` is accepted and falls back to `en`. Copy lives in +`backend/config/storefront_copy_v1.json`. ## Schema -- `catalog_revision`, `schema_version`, `show_subscription_ui`. -- `tiers[]`: unique `tier_id` (plan plus entitlement version), `plan_id`, - `entitlement_version`, `display_name`, `tagline`, `features[]` (key, label, - included), `prices[]`, `action`, `recommended`, `legacy`. -- `prices[]`: nullable `price_id` (Free has none), interval, currency, - amount in minor units, formatted amount, monthly-equivalent string, - optional annual savings (amount in minor units, percent, formatted copy). -- `next_offer_tier_id`: first offered upgrade tier, or null (never a client-computed ranking). -- `current`: plan id, nullable stored price id, legacy bool. Absent only when hidden. -- `usage`: kind (`transcription`, `usage_budget`, `unavailable`), nullable - `budget_percent`, nullable Unix UTC `reset_at`. Never raw unit amounts. - -Actions: `current`, `upgrade`, `downgrade_not_offered`, `contact`. -A current legacy row is always `current`, even when Stripe is unavailable or the -price is archived. Existing subscribers never receive an automatic migration -or an upgrade checkout from this sheet: higher tiers use contact until migration -policy is separately approved. Lower tiers are explicitly not offered. - -## Contextual recommendation - -Requests may pass `source` using the #21337 `Paywall Opened` vocabulary and -`trigger` (`proactive_agent`, `screen_context`, `usage_budget`, `mcp_daily_cap`). -Source describes the opening surface; trigger describes the gate on that surface. -Pro-only gates recommend Pro, including for Free callers. Free budget/MCP-cap -callers recommend Plus. Plus callers recommend Pro. Neo/Unlimited holders may receive Pro as an -explicit higher contact offer; Free and Plus are never offered to those holders. -Pro/Architect callers receive no Pro badge because it is current or lower. -Without gate context, Free gets Plus and eligible paid callers get Pro. Unknown sources use the same default; -invalid triggers return 422. - -`recommended` is a backend-authored badge, never a selection or checkout action. -It appears only on higher upgrade/contact offers, never a current or lower tier. -Legacy migration actions stay contact. -If the recommended tier has no usable registered price and is hidden, no badge -is returned. `next_offer_tier_id` remains the first offered upgrade, independent -of the badge. Clients must never preselect a recommended tier. - -## Prices and audience - -The v2 ladder is platform independent: Free, Plus, Pro (`operator`), Architect. -Neo/Unlimited appear only as an existing subscriber's separate current row. -Every current plan id has exactly one card. Legacy rows use existing-benefits -copy, never ladder-v1 gate claims; no second ladder card is shown for that id. -Non-ladder paid plans stay legacy even if their price is mistakenly activated. -Only Pro and Architect are explicit higher contact offers for Neo/Unlimited. - -The W3 generated catalog's `LADDER_V1_STRIPE_PRICES` is the only activation list. -It joins recognized-price plan/interval/version metadata and the existing -`is_purchasable_price_id` checkout boundary. Missing W3 declarations act as an -empty list. Prices must be active, USD, recurring month/year with interval_count -1; unsupported billing shapes are omitted. No new price is inferred from env -configuration or the proposal config's amounts. - -With no usable ladder-v1 price, Plus/Pro are hidden. Architect remains a visible -contact tier (no invented amount), and Free has the backend-authored zero price. -The current paid row is preserved independently of purchase availability. -This is the brief's hide-unpurchasable-tiers option, rather than selling legacy -prices with new entitlement claims. Architect does not become purchasable until -its price is explicitly registered as ladder-v1 and its amount verified. - -Price reads share the user-subscription endpoint's `stripe_price:{price_id}` -Redis cache and 24-hour TTL, including archived current prices. Cache failures -remain fail-open. Savings use the retrieved monthly and annual prices: `12 * monthly - annual`; -percent is that difference divided by `12 * monthly`. No savings copy is emitted -if the difference is zero/negative or a matching monthly price is unavailable. -Monthly equivalent divides the annual amount by 12, formatted to two decimals. - -The old endpoints and `_platform_hidden_plans` remain unchanged for installed -clients; partition retirement happens through v2 adoption. Sixty plan/platform/cancellation -combinations compare old response bytes with the flag unset, false and true. -This deliberately does not change released-client purchase or entitlement rules. - -## Usage projection seam - -If W3 exposes `get_usage_units(uid)` on `utils.subscription`, the adapter accepts -its display-ready `budget_percent` and Unix `reset_at` fields only for Free or -ladder-v1 subscribers. Legacy paid callers never receive that budget label. No such function -exists on current main; the seam is optional and needs W3 coordination before -activation. Ladder-v1 paid prices without that projection return unavailable -usage instead of showing a legacy transcription cap as a shared budget. -Legacy/Free callers may show the existing monthly speech allowance percentage -and next UTC calendar month reset. Chat-metered legacy plans without a speech -allowance return `unavailable`; this schema has no legacy chat-meter projection. -Provider errors omit usage with fallback telemetry. - -## Dry-run proposal and Architect evidence - -`backend/scripts/plan_prices_dry_run.py` is fixture driven, has no credential or -network path, and refuses `--execute` outright. The presentation config's -`proposed_prices` are tooling inputs only. The output lists exact Stripe create -arguments plus both the recognized-price registrations and activation-ID diff. -No catalog or Stripe object is modified. - -Code-read findings: `plan_catalog.json` has Architect chat allocation 40,000 USD -cents ($400), an overage-enabled legacy spend limit, and proactivity monthly -reference 19,900 cents ($199), also documented in -`backend/docs/proactivity_v2/CONTRACT.md`. These are distinct policy/reference amounts, not -live Stripe price evidence. `test_overage_catalog.py`, `test_chat_quota.py`, -`ChatQuotaBannerTests.swift` and `FloatingBarUsageLimiterTests.swift` exercise the -$400 cap. `test_subscription_restructure.py`'s header and -`APIClient+Settings.swift`'s enum comment still say Architect $400/month. -No prices or legacy limits were changed. Live Stripe verification was not run: -no read-only credential path was established without reading secrets. +- `catalog_revision`, `schema_version` (2), `show_subscription_ui`, `header`, + `yearly_toggle_label`. +- `current`: `plan_id`, `name`, nullable `price_id`, nullable `interval`, + `legacy`, `cancel_at_period_end`, `line`, nullable `legacy_note`. Absent only + when the sheet is hidden. +- `usage`: `kind` (`usage_budget`, `transcription`, `unavailable`), nullable + `percent`, nullable `period_id`, nullable `reset_label`, nullable + `meter_line`, nullable `nudge_line`, `pro_gates`. Never raw unit amounts. +- `tiers[]` in display order (recommended first, then ascending rank; while a + cancellation is scheduled, the current tier is first and nothing is + recommended): `tier_id`, `plan_id`, `name`, `recommended`, `is_current`, + `hours_line`, `bullets`, `action`, nullable `cta_label`, nullable + `blocked_line`, `compact`, `prices`. +- `prices[]`: nullable `price_id`, `interval`, `amount_minor`, `currency`, + `headline`, nullable `subline`, nullable `renewal_line`, `months_free` only + when the live yearly price gives at least one free month, and `cta_label` + only when that price overrides the tier button. + +Actions: `checkout`, `current`, `none`. `recommended` is a badge, never a +selection or a checkout. + +## Policy + +Rank is `basic < plus < operator < architect`. Neo (`unlimited`) and Unlimited +(`unlimited_v2`) have no rank. A non-free plan is legacy when it has no ladder +rank or its stored price is not in `LADDER_V1_STRIPE_PRICES`. + +Legacy subscribers get no `checkout` on any card, including when cancellation +is scheduled. Neo/Unlimited may see Pro and Architect as higher offers with +`action: none`. Lower tiers are `none`. Each current plan id has one card. +Every `is_current` card sends `bullets: []`. The current line already says +what they have, so clients do not use `compact` to drop bullets. Legacy cards +do not repeat ladder hours; the legacy sentence is `current.legacy_note`. + +Same tier, monthly, and not cancel-scheduled: the current card is +`action: checkout` with tier `cta_label: null`. Only its yearly price is +offered, with price-level `cta_label` `Switch to yearly · save {saving}` +(`saving` is 12× the monthly amount minus the annual amount, formatted in the +price currency). A price-level `cta_label` overrides the tier label. The +monthly price is not on that card. Same tier, annual: the monthly price is +never offered, the card stays `action: current`, and there is no price CTA. +`compact` is true only when the current card has no tier button and no price +CTA. + +`cancel_at_period_end` on a non-legacy paid plan offers checkout only on the +current tier, with tier `cta_label` `Resubscribe` and only that tier's current +`price_id`. `yearly_toggle_label` is null. There is no switch-to-yearly +label, no recommended badge, and the current tier is first. Every other tier +is `action: none`, `cta_label: null`, and +`blocked_line: "Resubscribe first to change plans"`. The header is +`Your {Plan} plan ends {date}` and the current line is +`You're on {Plan} · {$price}/mo` (yearly: `{$price}/yr`). Free cancellation +does not open checkout. Legacy cancellation still has no checkout anywhere. + +If the current price cannot be retrieved, `current.interval` is null and the +current card offers no prices, so a yearly subscriber is not shown a monthly +price. + +`months_free` copy uses `1 month free` when the count is 1 and `{n} months free` +otherwise, in both the yearly subline and `yearly_toggle_label`. + +Pro-only triggers recommend Pro, including for Free. Free budget and MCP-cap +callers recommend Plus. Other paid callers below Pro recommend Pro. Pro and +Architect receive no badge. Unknown sources use the default header. Invalid +triggers return 422. A legacy subscriber's header is always `Your plan`. + +## Prices, hours, and usage + +Only active USD recurring month/year prices with `interval_count` 1 are sold. +The current archived price is still shown. With no usable ladder price, Plus +and Pro are hidden and Architect stays visible with `action: none`. Free is the +zero monthly price. No amount is copied out of env or `proposed_prices`. + +`months_free` is `floor(12 − annual/monthly)` when that value is at least 1. +`yearly_toggle_label` uses the smallest such value across tiers, or null. It +is also null while a non-legacy paid cancellation is scheduled. +Yearly headlines are the annual amount divided by 12, half-up to cents. + +Recording hours are `floor_to_nice(budget × 0.85 / 130)`. Free is fixed at 15. +Budgets are the plan-catalog usage units when the catalog declares them, and +otherwise the rate card in the copy config (Plus 16360, Pro 44090, Architect +189463). `units_per_recorded_hour` is provisional; replace it with the shadow +ledger p50 (#21369). Meter hours apply that definition to remaining units. +The client does not hardcode hours. + +Price reads use `stripe_price:{price_id}` with a 24-hour TTL. Cache failures +stay fail-open. Provider errors omit the price and record fallback telemetry. + +If `get_usage_units(uid)` exists, its `budget_percent` and Unix `reset_at` are +accepted only for Free or ladder-v1 subscribers. Legacy callers keep the speech +allowance (`kind: transcription`) or `unavailable` when that allowance is +absent. Ladder-v1 callers without the projection return `unavailable` rather +than a speech cap labelled as the shared budget. + +The v1 payment endpoints are unchanged. This route does not write Stripe or +grant entitlements. diff --git a/backend/models/storefront.py b/backend/models/storefront.py index 8e279d883fa..14041aa5b85 100644 --- a/backend/models/storefront.py +++ b/backend/models/storefront.py @@ -1,62 +1,76 @@ -"""Versioned backend presentation contract; clients need no tier policy.""" +"""Versioned backend presentation contract; clients render fields verbatim.""" from typing import Literal -from pydantic import BaseModel, Field - - -class PlanFeature(BaseModel): - key: str - label: str - included: bool = True - - -class AnnualSavings(BaseModel): - amount_minor: int - percent: float - formatted: str +from pydantic import BaseModel, Field, model_serializer class StorefrontPrice(BaseModel): price_id: str | None = None interval: Literal['month', 'year'] - currency: str amount_minor: int - formatted_amount: str - monthly_equivalent: str - annual_savings: AnnualSavings | None = None + currency: str + headline: str + subline: str | None = None + renewal_line: str | None = None + months_free: int | None = None + # Present only when this price overrides the tier cta_label (switch to yearly). + cta_label: str | None = None + + @model_serializer(mode='wrap') + def _omit_empty_optionals(self, handler): + data = handler(self) + if data.get('months_free') is None: + data.pop('months_free', None) + if data.get('cta_label') is None: + data.pop('cta_label', None) + return data class StorefrontTier(BaseModel): tier_id: str plan_id: str - entitlement_version: str - display_name: str - tagline: str - features: list[PlanFeature] - prices: list[StorefrontPrice] - action: Literal['current', 'upgrade', 'downgrade_not_offered', 'contact'] + name: str recommended: bool = False - legacy: bool = False + is_current: bool = False + hours_line: str + bullets: list[str] + action: Literal['checkout', 'current', 'none'] + cta_label: str | None = None + blocked_line: str | None = None + # True when this is the current plan and it has no purchase button. + # Current cards send bullets: [] whether or not compact is true. + compact: bool = False + prices: list[StorefrontPrice] -class StorefrontCurrentPlan(BaseModel): +class StorefrontCurrent(BaseModel): plan_id: str + name: str price_id: str | None = None + interval: Literal['month', 'year'] | None = None legacy: bool + cancel_at_period_end: bool = False + line: str + legacy_note: str | None = None class StorefrontUsage(BaseModel): - kind: Literal['transcription', 'usage_budget', 'unavailable'] = 'unavailable' - budget_percent: float | None = None - reset_at: int | None = None + kind: Literal['usage_budget', 'transcription', 'unavailable'] = 'unavailable' + percent: int | None = None + period_id: str | None = None + reset_label: str | None = None + meter_line: str | None = None + nudge_line: str | None = None + pro_gates: list[str] = Field(default_factory=list) class StorefrontResponse(BaseModel): - schema_version: Literal[1] = 1 + schema_version: Literal[2] = 2 catalog_revision: int show_subscription_ui: bool = True - tiers: list[StorefrontTier] = Field(default_factory=list) - next_offer_tier_id: str | None = None - current: StorefrontCurrentPlan | None = None + header: str | None = None + current: StorefrontCurrent | None = None usage: StorefrontUsage = Field(default_factory=StorefrontUsage) + tiers: list[StorefrontTier] = Field(default_factory=list) + yearly_toggle_label: str | None = None diff --git a/backend/routers/storefront.py b/backend/routers/storefront.py index d86afd0b995..c92ca2736de 100644 --- a/backend/routers/storefront.py +++ b/backend/routers/storefront.py @@ -16,6 +16,7 @@ from utils import storefront_flags, subscription as subscription_utils from utils.observability.fallback import record_fallback from utils.storefront import build_storefront, load_ladder +from utils.storefront_copy import present_usage router = APIRouter() @@ -39,7 +40,14 @@ def _retrieve_price(price_id: str) -> dict[str, Any]: raise -def _usage(uid: str, subscription: Subscription) -> StorefrontUsage: +def _usage(uid: str, subscription: Subscription, locale: str | None = None) -> StorefrontUsage: + plan_id = subscription.plan.value + + def rendered(kind: str, percent: float | None = None, reset_at: int | None = None) -> StorefrontUsage: + return StorefrontUsage( + **present_usage(plan_id=plan_id, kind=kind, percent=percent, reset_at=reset_at, locale=locale) + ) + try: # W3 may provide a display-ready projection on the shared facade. Do not # guess units or denominators; only accept percentage/reset fields. @@ -60,30 +68,31 @@ def _usage(uid: str, subscription: Subscription) -> StorefrontUsage: and type(reset) is int and reset > 0 ): - return StorefrontUsage(kind='usage_budget', budget_percent=percent, reset_at=reset) + return rendered('usage_budget', float(percent), reset) if ladder_subscriber: - return StorefrontUsage() + return rendered('unavailable') # Existing legacy speech allowance; explicitly labelled so clients never # mistake it for the new shared-unit budget. limit = subscription_utils.transcription_allowance_seconds(subscription.plan) if limit is None or limit <= 0: - return StorefrontUsage() + return rendered('unavailable') usage = subscription_utils.get_monthly_usage_for_subscription(uid) used = usage.get('transcription_seconds') if type(used) is not int or used < 0: - return StorefrontUsage() + return rendered('unavailable') now = datetime.now(timezone.utc) reset = datetime(now.year + (now.month == 12), now.month % 12 + 1, 1, tzinfo=timezone.utc) - return StorefrontUsage(kind='transcription', budget_percent=used * 100 / limit, reset_at=int(reset.timestamp())) + return rendered('transcription', used * 100 / limit, int(reset.timestamp())) except Exception: _degraded('storefront_usage', 'unavailable_usage') - return StorefrontUsage() + return rendered('unavailable') @router.get('/v2/payments/plans', response_model=StorefrontResponse, tags=['payments']) def get_storefront( source: Optional[str] = Query(None, description='Paywall Opened source vocabulary (#21337)'), trigger: Optional[Literal['proactive_agent', 'screen_context', 'usage_budget', 'mcp_daily_cap']] = Query(None), + locale: Optional[str] = Query(None, description='BCP 47 tag. Unknown locales fall back to en.'), uid: str = Depends(auth.with_rate_limit(auth.get_current_user_uid_no_byok_validation, 'storefront:plans')), x_app_platform: Optional[str] = Header(None, alias='X-App-Platform'), x_app_version: Optional[str] = Header(None, alias='X-App-Version'), @@ -118,7 +127,8 @@ def get_storefront( subscription=subscription, retrieve_price=_retrieve_price, purchasable=subscription_utils.is_purchasable_price_id, - usage=_usage(uid, subscription), + usage=_usage(uid, subscription, locale), source=source, trigger=trigger, + locale=locale, ) diff --git a/backend/scripts/check_app_client_openapi_compatibility.py b/backend/scripts/check_app_client_openapi_compatibility.py index 7297ad557c4..a814ab80c6b 100755 --- a/backend/scripts/check_app_client_openapi_compatibility.py +++ b/backend/scripts/check_app_client_openapi_compatibility.py @@ -46,6 +46,14 @@ } ) +# GET /v2/payments/plans schema_version 1 landed on main in #21367 behind +# STOREFRONT_LADDER_V1_ENABLED (default off) and never reached a released +# decoder. The first consumer is #21363, which accepts only schema_version 2 +# on this same route. The predicate below ignores response drift only for that +# 1→2 replacement. Once main serves schema 2, any later response break on this +# route fails the gate again. Do not widen this to other routes. +UNRELEASED_STOREFRONT_V1_ROUTE = '/v2/payments/plans' + # Released-client status restorations. The merge-base spec is normally the # released-clients truth, but a spec-only wire-contract regression can ship to # main without clients being rebuilt: e22938ac78 (2026-07-04) flipped @@ -149,12 +157,21 @@ def check(self) -> list[CompatibilityIssue]: operation_path, ) self._check_request_body(base_operation, head_operation, operation_path) - self._check_responses(base_operation, head_operation, operation_path) + if not self._unreleased_storefront_v1_to_v2(route, method, base_operation, head_operation): + self._check_responses(base_operation, head_operation, operation_path) return sorted(set(self.issues)) def _issue(self, path: str, message: str) -> None: self.issues.append(CompatibilityIssue(path, message)) + def _unreleased_storefront_v1_to_v2(self, route: str, method: str, base_op: Schema, head_op: Schema) -> bool: + """True only while this route's published response moves from schema 1 to 2.""" + if route != UNRELEASED_STOREFRONT_V1_ROUTE or method != 'get': + return False + return ( + _schema_version_const(base_op, self.base_refs) == 1 and _schema_version_const(head_op, self.head_refs) == 2 + ) + def _check_security(self, base_op: Schema, head_op: Schema, path: str) -> None: if 'security' not in head_op: return @@ -606,6 +623,20 @@ def _mapping(value: object) -> Schema: return cast(Schema, value) if isinstance(value, dict) else {} +def _schema_version_const(operation: Schema, resolver: LocalReferenceResolver) -> object: + """Return the 200 JSON body's schema_version const, or None when it is absent.""" + response = _mapping(_mapping(operation.get('responses')).get('200')) + media = _mapping(_mapping(response.get('content')).get('application/json')) + schema = media.get('schema') + if schema is None: + return None + resolved = _mapping(resolver.resolve(schema)) + version = _mapping(resolved.get('properties')).get('schema_version') + if version is None: + return None + return _mapping(resolver.resolve(version)).get('const') + + def _list(value: object) -> list[Any]: return cast(list[Any], value) if isinstance(value, list) else [] diff --git a/backend/scripts/dart_schema_groups.py b/backend/scripts/dart_schema_groups.py index 4b737d4a065..d828e5a3747 100644 --- a/backend/scripts/dart_schema_groups.py +++ b/backend/scripts/dart_schema_groups.py @@ -20,9 +20,7 @@ def additional_schema_groups(output_dir: Path) -> dict[str, dict[str, Any]]: 'storefront': { 'output': output_dir / 'storefront_wire.g.dart', 'schemas': ( - 'AnnualSavings', - 'PlanFeature', - 'StorefrontCurrentPlan', + 'StorefrontCurrent', 'StorefrontPrice', 'StorefrontResponse', 'StorefrontTier', diff --git a/backend/tests/fixtures/storefront_v2/architect_settings.json b/backend/tests/fixtures/storefront_v2/architect_settings.json new file mode 100644 index 00000000000..26795dfdf3e --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/architect_settings.json @@ -0,0 +1,161 @@ +{ + "schema_version": 2, + "catalog_revision": 7, + "show_subscription_ui": true, + "header": "Choose your plan", + "current": { + "plan_id": "architect", + "name": "Architect", + "price_id": "price_architect_month", + "interval": "month", + "legacy": false, + "cancel_at_period_end": false, + "line": "You're on Architect · $199.00/mo · renews Nov 1", + "legacy_note": null + }, + "usage": { + "kind": "unavailable", + "percent": null, + "period_id": null, + "reset_label": null, + "meter_line": null, + "nudge_line": null, + "pro_gates": [ + "proactive_agent", + "screen_context" + ] + }, + "tiers": [ + { + "tier_id": "basic:ladder_v1", + "plan_id": "basic", + "name": "Free", + "recommended": false, + "is_current": false, + "hours_line": "About 15 hours of recording a month", + "bullets": [ + "Transcripts, summaries and memories of your conversations", + "Ask Omi anything about your day", + "100 MCP requests a day" + ], + "action": "none", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": null, + "interval": "month", + "amount_minor": 0, + "currency": "usd", + "headline": "$0.00/month", + "subline": null, + "renewal_line": null + } + ] + }, + { + "tier_id": "plus:ladder_v1", + "plan_id": "plus", + "name": "Plus", + "recommended": false, + "is_current": false, + "hours_line": "About 100 hours of recording a month, around 3 hours a day", + "bullets": [ + "Everything in Free, with much more room", + "Use Omi on your Mac as well as your phone", + "500 MCP requests a day" + ], + "action": "none", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_plus_month", + "interval": "month", + "amount_minor": 1999, + "currency": "usd", + "headline": "$19.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_plus_year", + "interval": "year", + "amount_minor": 19990, + "currency": "usd", + "headline": "$16.66/month", + "subline": "$199.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "operator:ladder_v1", + "plan_id": "operator", + "name": "Pro", + "recommended": false, + "is_current": false, + "hours_line": "About 280 hours of recording a month, enough to wear it all day", + "bullets": [ + "Omi reminds you of things you said you'd do", + "On your Mac, Omi can see what you're working on and help with it", + "5,000 MCP requests a day" + ], + "action": "none", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_operator_month", + "interval": "month", + "amount_minor": 4999, + "currency": "usd", + "headline": "$49.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_operator_year", + "interval": "year", + "amount_minor": 49990, + "currency": "usd", + "headline": "$41.66/month", + "subline": "$499.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "architect:ladder_v1", + "plan_id": "architect", + "name": "Architect", + "recommended": false, + "is_current": true, + "hours_line": "About 1,200 hours of recording a month, for all-day use on more than one device", + "bullets": [], + "action": "checkout", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_architect_year", + "interval": "year", + "amount_minor": 199000, + "currency": "usd", + "headline": "$165.83/month", + "subline": "$1990.00 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2, + "cta_label": "Switch to yearly · save $398.00" + } + ] + } + ], + "yearly_toggle_label": "Yearly · 2 months free" +} diff --git a/backend/tests/fixtures/storefront_v2/cancel_scheduled_plus.json b/backend/tests/fixtures/storefront_v2/cancel_scheduled_plus.json new file mode 100644 index 00000000000..2fc2fd8dfc7 --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/cancel_scheduled_plus.json @@ -0,0 +1,158 @@ +{ + "schema_version": 2, + "catalog_revision": 7, + "show_subscription_ui": true, + "header": "Your Plus plan ends Nov 1", + "current": { + "plan_id": "plus", + "name": "Plus", + "price_id": "price_plus_month", + "interval": "month", + "legacy": false, + "cancel_at_period_end": true, + "line": "You're on Plus · $19.99/mo", + "legacy_note": null + }, + "usage": { + "kind": "unavailable", + "percent": null, + "period_id": null, + "reset_label": null, + "meter_line": null, + "nudge_line": null, + "pro_gates": [ + "proactive_agent", + "screen_context" + ] + }, + "tiers": [ + { + "tier_id": "plus:ladder_v1", + "plan_id": "plus", + "name": "Plus", + "recommended": false, + "is_current": true, + "hours_line": "About 100 hours of recording a month, around 3 hours a day", + "bullets": [], + "action": "checkout", + "cta_label": "Resubscribe", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_plus_month", + "interval": "month", + "amount_minor": 1999, + "currency": "usd", + "headline": "$19.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + } + ] + }, + { + "tier_id": "basic:ladder_v1", + "plan_id": "basic", + "name": "Free", + "recommended": false, + "is_current": false, + "hours_line": "About 15 hours of recording a month", + "bullets": [ + "Transcripts, summaries and memories of your conversations", + "Ask Omi anything about your day", + "100 MCP requests a day" + ], + "action": "none", + "cta_label": null, + "blocked_line": "Resubscribe first to change plans", + "compact": false, + "prices": [ + { + "price_id": null, + "interval": "month", + "amount_minor": 0, + "currency": "usd", + "headline": "$0.00/month", + "subline": null, + "renewal_line": null + } + ] + }, + { + "tier_id": "operator:ladder_v1", + "plan_id": "operator", + "name": "Pro", + "recommended": false, + "is_current": false, + "hours_line": "About 280 hours of recording a month, enough to wear it all day", + "bullets": [ + "Omi reminds you of things you said you'd do", + "On your Mac, Omi can see what you're working on and help with it", + "5,000 MCP requests a day" + ], + "action": "none", + "cta_label": null, + "blocked_line": "Resubscribe first to change plans", + "compact": false, + "prices": [ + { + "price_id": "price_operator_month", + "interval": "month", + "amount_minor": 4999, + "currency": "usd", + "headline": "$49.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_operator_year", + "interval": "year", + "amount_minor": 49990, + "currency": "usd", + "headline": "$41.66/month", + "subline": "$499.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "architect:ladder_v1", + "plan_id": "architect", + "name": "Architect", + "recommended": false, + "is_current": false, + "hours_line": "About 1,200 hours of recording a month, for all-day use on more than one device", + "bullets": [ + "Everything in Pro, with over 4 times the usage", + "At least 5,000 MCP requests a day" + ], + "action": "none", + "cta_label": null, + "blocked_line": "Resubscribe first to change plans", + "compact": false, + "prices": [ + { + "price_id": "price_architect_month", + "interval": "month", + "amount_minor": 19900, + "currency": "usd", + "headline": "$199.00/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_architect_year", + "interval": "year", + "amount_minor": 199000, + "currency": "usd", + "headline": "$165.83/month", + "subline": "$1990.00 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + } + ], + "yearly_toggle_label": null +} diff --git a/backend/tests/fixtures/storefront_v2/free_budget80.json b/backend/tests/fixtures/storefront_v2/free_budget80.json new file mode 100644 index 00000000000..5fade9a5410 --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/free_budget80.json @@ -0,0 +1,168 @@ +{ + "schema_version": 2, + "catalog_revision": 7, + "show_subscription_ui": true, + "header": "You've used 80% of this month's usage", + "current": { + "plan_id": "basic", + "name": "Free", + "price_id": null, + "interval": null, + "legacy": false, + "cancel_at_period_end": false, + "line": "You're on Free", + "legacy_note": null + }, + "usage": { + "kind": "usage_budget", + "percent": 80, + "period_id": "2026-10", + "reset_label": "resets Nov 1", + "meter_line": "80% used · about 3 hours of recording left · resets Nov 1", + "nudge_line": "You've used 80% of this month's usage · resets Nov 1", + "pro_gates": [ + "proactive_agent", + "screen_context" + ] + }, + "tiers": [ + { + "tier_id": "plus:ladder_v1", + "plan_id": "plus", + "name": "Plus", + "recommended": true, + "is_current": false, + "hours_line": "About 100 hours of recording a month, around 3 hours a day", + "bullets": [ + "Everything in Free, with much more room", + "Use Omi on your Mac as well as your phone", + "500 MCP requests a day" + ], + "action": "checkout", + "cta_label": "Get Plus", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_plus_month", + "interval": "month", + "amount_minor": 1999, + "currency": "usd", + "headline": "$19.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_plus_year", + "interval": "year", + "amount_minor": 19990, + "currency": "usd", + "headline": "$16.66/month", + "subline": "$199.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "basic:ladder_v1", + "plan_id": "basic", + "name": "Free", + "recommended": false, + "is_current": true, + "hours_line": "About 15 hours of recording a month", + "bullets": [], + "action": "current", + "cta_label": null, + "blocked_line": null, + "compact": true, + "prices": [ + { + "price_id": null, + "interval": "month", + "amount_minor": 0, + "currency": "usd", + "headline": "$0.00/month", + "subline": null, + "renewal_line": null + } + ] + }, + { + "tier_id": "operator:ladder_v1", + "plan_id": "operator", + "name": "Pro", + "recommended": false, + "is_current": false, + "hours_line": "About 280 hours of recording a month, enough to wear it all day", + "bullets": [ + "Omi reminds you of things you said you'd do", + "On your Mac, Omi can see what you're working on and help with it", + "5,000 MCP requests a day" + ], + "action": "checkout", + "cta_label": "Get Pro", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_operator_month", + "interval": "month", + "amount_minor": 4999, + "currency": "usd", + "headline": "$49.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_operator_year", + "interval": "year", + "amount_minor": 49990, + "currency": "usd", + "headline": "$41.66/month", + "subline": "$499.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "architect:ladder_v1", + "plan_id": "architect", + "name": "Architect", + "recommended": false, + "is_current": false, + "hours_line": "About 1,200 hours of recording a month, for all-day use on more than one device", + "bullets": [ + "Everything in Pro, with over 4 times the usage", + "At least 5,000 MCP requests a day" + ], + "action": "checkout", + "cta_label": "Get Architect", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_architect_month", + "interval": "month", + "amount_minor": 19900, + "currency": "usd", + "headline": "$199.00/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_architect_year", + "interval": "year", + "amount_minor": 199000, + "currency": "usd", + "headline": "$165.83/month", + "subline": "$1990.00 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + } + ], + "yearly_toggle_label": "Yearly · 2 months free" +} diff --git a/backend/tests/fixtures/storefront_v2/free_mcp_cap.json b/backend/tests/fixtures/storefront_v2/free_mcp_cap.json new file mode 100644 index 00000000000..4adbb6bc590 --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/free_mcp_cap.json @@ -0,0 +1,168 @@ +{ + "schema_version": 2, + "catalog_revision": 7, + "show_subscription_ui": true, + "header": "You've used today's 100 MCP requests", + "current": { + "plan_id": "basic", + "name": "Free", + "price_id": null, + "interval": null, + "legacy": false, + "cancel_at_period_end": false, + "line": "You're on Free", + "legacy_note": null + }, + "usage": { + "kind": "unavailable", + "percent": null, + "period_id": null, + "reset_label": null, + "meter_line": null, + "nudge_line": null, + "pro_gates": [ + "proactive_agent", + "screen_context" + ] + }, + "tiers": [ + { + "tier_id": "plus:ladder_v1", + "plan_id": "plus", + "name": "Plus", + "recommended": true, + "is_current": false, + "hours_line": "About 100 hours of recording a month, around 3 hours a day", + "bullets": [ + "500 MCP requests a day", + "Everything in Free, with much more room", + "Use Omi on your Mac as well as your phone" + ], + "action": "checkout", + "cta_label": "Get Plus", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_plus_month", + "interval": "month", + "amount_minor": 1999, + "currency": "usd", + "headline": "$19.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_plus_year", + "interval": "year", + "amount_minor": 19990, + "currency": "usd", + "headline": "$16.66/month", + "subline": "$199.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "basic:ladder_v1", + "plan_id": "basic", + "name": "Free", + "recommended": false, + "is_current": true, + "hours_line": "About 15 hours of recording a month", + "bullets": [], + "action": "current", + "cta_label": null, + "blocked_line": null, + "compact": true, + "prices": [ + { + "price_id": null, + "interval": "month", + "amount_minor": 0, + "currency": "usd", + "headline": "$0.00/month", + "subline": null, + "renewal_line": null + } + ] + }, + { + "tier_id": "operator:ladder_v1", + "plan_id": "operator", + "name": "Pro", + "recommended": false, + "is_current": false, + "hours_line": "About 280 hours of recording a month, enough to wear it all day", + "bullets": [ + "5,000 MCP requests a day", + "Omi reminds you of things you said you'd do", + "On your Mac, Omi can see what you're working on and help with it" + ], + "action": "checkout", + "cta_label": "Get Pro", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_operator_month", + "interval": "month", + "amount_minor": 4999, + "currency": "usd", + "headline": "$49.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_operator_year", + "interval": "year", + "amount_minor": 49990, + "currency": "usd", + "headline": "$41.66/month", + "subline": "$499.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "architect:ladder_v1", + "plan_id": "architect", + "name": "Architect", + "recommended": false, + "is_current": false, + "hours_line": "About 1,200 hours of recording a month, for all-day use on more than one device", + "bullets": [ + "At least 5,000 MCP requests a day", + "Everything in Pro, with over 4 times the usage" + ], + "action": "checkout", + "cta_label": "Get Architect", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_architect_month", + "interval": "month", + "amount_minor": 19900, + "currency": "usd", + "headline": "$199.00/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_architect_year", + "interval": "year", + "amount_minor": 199000, + "currency": "usd", + "headline": "$165.83/month", + "subline": "$1990.00 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + } + ], + "yearly_toggle_label": "Yearly · 2 months free" +} diff --git a/backend/tests/fixtures/storefront_v2/free_pro_gate_proactive.json b/backend/tests/fixtures/storefront_v2/free_pro_gate_proactive.json new file mode 100644 index 00000000000..f2745229a62 --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/free_pro_gate_proactive.json @@ -0,0 +1,168 @@ +{ + "schema_version": 2, + "catalog_revision": 7, + "show_subscription_ui": true, + "header": "Reminders and follow-ups come with Pro", + "current": { + "plan_id": "basic", + "name": "Free", + "price_id": null, + "interval": null, + "legacy": false, + "cancel_at_period_end": false, + "line": "You're on Free", + "legacy_note": null + }, + "usage": { + "kind": "unavailable", + "percent": null, + "period_id": null, + "reset_label": null, + "meter_line": null, + "nudge_line": null, + "pro_gates": [ + "proactive_agent", + "screen_context" + ] + }, + "tiers": [ + { + "tier_id": "operator:ladder_v1", + "plan_id": "operator", + "name": "Pro", + "recommended": true, + "is_current": false, + "hours_line": "About 280 hours of recording a month, enough to wear it all day", + "bullets": [ + "Omi reminds you of things you said you'd do", + "On your Mac, Omi can see what you're working on and help with it", + "5,000 MCP requests a day" + ], + "action": "checkout", + "cta_label": "Get Pro", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_operator_month", + "interval": "month", + "amount_minor": 4999, + "currency": "usd", + "headline": "$49.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_operator_year", + "interval": "year", + "amount_minor": 49990, + "currency": "usd", + "headline": "$41.66/month", + "subline": "$499.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "basic:ladder_v1", + "plan_id": "basic", + "name": "Free", + "recommended": false, + "is_current": true, + "hours_line": "About 15 hours of recording a month", + "bullets": [], + "action": "current", + "cta_label": null, + "blocked_line": null, + "compact": true, + "prices": [ + { + "price_id": null, + "interval": "month", + "amount_minor": 0, + "currency": "usd", + "headline": "$0.00/month", + "subline": null, + "renewal_line": null + } + ] + }, + { + "tier_id": "plus:ladder_v1", + "plan_id": "plus", + "name": "Plus", + "recommended": false, + "is_current": false, + "hours_line": "About 100 hours of recording a month, around 3 hours a day", + "bullets": [ + "Everything in Free, with much more room", + "Use Omi on your Mac as well as your phone", + "500 MCP requests a day" + ], + "action": "checkout", + "cta_label": "Get Plus", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_plus_month", + "interval": "month", + "amount_minor": 1999, + "currency": "usd", + "headline": "$19.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_plus_year", + "interval": "year", + "amount_minor": 19990, + "currency": "usd", + "headline": "$16.66/month", + "subline": "$199.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "architect:ladder_v1", + "plan_id": "architect", + "name": "Architect", + "recommended": false, + "is_current": false, + "hours_line": "About 1,200 hours of recording a month, for all-day use on more than one device", + "bullets": [ + "Everything in Pro, with over 4 times the usage", + "At least 5,000 MCP requests a day" + ], + "action": "checkout", + "cta_label": "Get Architect", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_architect_month", + "interval": "month", + "amount_minor": 19900, + "currency": "usd", + "headline": "$199.00/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_architect_year", + "interval": "year", + "amount_minor": 199000, + "currency": "usd", + "headline": "$165.83/month", + "subline": "$1990.00 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + } + ], + "yearly_toggle_label": "Yearly · 2 months free" +} diff --git a/backend/tests/fixtures/storefront_v2/free_pro_gate_screen.json b/backend/tests/fixtures/storefront_v2/free_pro_gate_screen.json new file mode 100644 index 00000000000..2874562dec3 --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/free_pro_gate_screen.json @@ -0,0 +1,168 @@ +{ + "schema_version": 2, + "catalog_revision": 7, + "show_subscription_ui": true, + "header": "Screen help comes with Pro", + "current": { + "plan_id": "basic", + "name": "Free", + "price_id": null, + "interval": null, + "legacy": false, + "cancel_at_period_end": false, + "line": "You're on Free", + "legacy_note": null + }, + "usage": { + "kind": "unavailable", + "percent": null, + "period_id": null, + "reset_label": null, + "meter_line": null, + "nudge_line": null, + "pro_gates": [ + "proactive_agent", + "screen_context" + ] + }, + "tiers": [ + { + "tier_id": "operator:ladder_v1", + "plan_id": "operator", + "name": "Pro", + "recommended": true, + "is_current": false, + "hours_line": "About 280 hours of recording a month, enough to wear it all day", + "bullets": [ + "On your Mac, Omi can see what you're working on and help with it", + "Omi reminds you of things you said you'd do", + "5,000 MCP requests a day" + ], + "action": "checkout", + "cta_label": "Get Pro", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_operator_month", + "interval": "month", + "amount_minor": 4999, + "currency": "usd", + "headline": "$49.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_operator_year", + "interval": "year", + "amount_minor": 49990, + "currency": "usd", + "headline": "$41.66/month", + "subline": "$499.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "basic:ladder_v1", + "plan_id": "basic", + "name": "Free", + "recommended": false, + "is_current": true, + "hours_line": "About 15 hours of recording a month", + "bullets": [], + "action": "current", + "cta_label": null, + "blocked_line": null, + "compact": true, + "prices": [ + { + "price_id": null, + "interval": "month", + "amount_minor": 0, + "currency": "usd", + "headline": "$0.00/month", + "subline": null, + "renewal_line": null + } + ] + }, + { + "tier_id": "plus:ladder_v1", + "plan_id": "plus", + "name": "Plus", + "recommended": false, + "is_current": false, + "hours_line": "About 100 hours of recording a month, around 3 hours a day", + "bullets": [ + "Everything in Free, with much more room", + "Use Omi on your Mac as well as your phone", + "500 MCP requests a day" + ], + "action": "checkout", + "cta_label": "Get Plus", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_plus_month", + "interval": "month", + "amount_minor": 1999, + "currency": "usd", + "headline": "$19.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_plus_year", + "interval": "year", + "amount_minor": 19990, + "currency": "usd", + "headline": "$16.66/month", + "subline": "$199.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "architect:ladder_v1", + "plan_id": "architect", + "name": "Architect", + "recommended": false, + "is_current": false, + "hours_line": "About 1,200 hours of recording a month, for all-day use on more than one device", + "bullets": [ + "Everything in Pro, with over 4 times the usage", + "At least 5,000 MCP requests a day" + ], + "action": "checkout", + "cta_label": "Get Architect", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_architect_month", + "interval": "month", + "amount_minor": 19900, + "currency": "usd", + "headline": "$199.00/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_architect_year", + "interval": "year", + "amount_minor": 199000, + "currency": "usd", + "headline": "$165.83/month", + "subline": "$1990.00 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + } + ], + "yearly_toggle_label": "Yearly · 2 months free" +} diff --git a/backend/tests/fixtures/storefront_v2/free_settings.json b/backend/tests/fixtures/storefront_v2/free_settings.json new file mode 100644 index 00000000000..af9e0eafa65 --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/free_settings.json @@ -0,0 +1,168 @@ +{ + "schema_version": 2, + "catalog_revision": 7, + "show_subscription_ui": true, + "header": "Choose your plan", + "current": { + "plan_id": "basic", + "name": "Free", + "price_id": null, + "interval": null, + "legacy": false, + "cancel_at_period_end": false, + "line": "You're on Free", + "legacy_note": null + }, + "usage": { + "kind": "unavailable", + "percent": null, + "period_id": null, + "reset_label": null, + "meter_line": null, + "nudge_line": null, + "pro_gates": [ + "proactive_agent", + "screen_context" + ] + }, + "tiers": [ + { + "tier_id": "plus:ladder_v1", + "plan_id": "plus", + "name": "Plus", + "recommended": true, + "is_current": false, + "hours_line": "About 100 hours of recording a month, around 3 hours a day", + "bullets": [ + "Everything in Free, with much more room", + "Use Omi on your Mac as well as your phone", + "500 MCP requests a day" + ], + "action": "checkout", + "cta_label": "Get Plus", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_plus_month", + "interval": "month", + "amount_minor": 1999, + "currency": "usd", + "headline": "$19.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_plus_year", + "interval": "year", + "amount_minor": 19990, + "currency": "usd", + "headline": "$16.66/month", + "subline": "$199.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "basic:ladder_v1", + "plan_id": "basic", + "name": "Free", + "recommended": false, + "is_current": true, + "hours_line": "About 15 hours of recording a month", + "bullets": [], + "action": "current", + "cta_label": null, + "blocked_line": null, + "compact": true, + "prices": [ + { + "price_id": null, + "interval": "month", + "amount_minor": 0, + "currency": "usd", + "headline": "$0.00/month", + "subline": null, + "renewal_line": null + } + ] + }, + { + "tier_id": "operator:ladder_v1", + "plan_id": "operator", + "name": "Pro", + "recommended": false, + "is_current": false, + "hours_line": "About 280 hours of recording a month, enough to wear it all day", + "bullets": [ + "Omi reminds you of things you said you'd do", + "On your Mac, Omi can see what you're working on and help with it", + "5,000 MCP requests a day" + ], + "action": "checkout", + "cta_label": "Get Pro", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_operator_month", + "interval": "month", + "amount_minor": 4999, + "currency": "usd", + "headline": "$49.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_operator_year", + "interval": "year", + "amount_minor": 49990, + "currency": "usd", + "headline": "$41.66/month", + "subline": "$499.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "architect:ladder_v1", + "plan_id": "architect", + "name": "Architect", + "recommended": false, + "is_current": false, + "hours_line": "About 1,200 hours of recording a month, for all-day use on more than one device", + "bullets": [ + "Everything in Pro, with over 4 times the usage", + "At least 5,000 MCP requests a day" + ], + "action": "checkout", + "cta_label": "Get Architect", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_architect_month", + "interval": "month", + "amount_minor": 19900, + "currency": "usd", + "headline": "$199.00/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_architect_year", + "interval": "year", + "amount_minor": 199000, + "currency": "usd", + "headline": "$165.83/month", + "subline": "$1990.00 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + } + ], + "yearly_toggle_label": "Yearly · 2 months free" +} diff --git a/backend/tests/fixtures/storefront_v2/legacy_neo.json b/backend/tests/fixtures/storefront_v2/legacy_neo.json new file mode 100644 index 00000000000..c1941e0ea9c --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/legacy_neo.json @@ -0,0 +1,196 @@ +{ + "schema_version": 2, + "catalog_revision": 7, + "show_subscription_ui": true, + "header": "Your plan", + "current": { + "plan_id": "unlimited", + "name": "Neo", + "price_id": "price_legacy_neo", + "interval": "month", + "legacy": true, + "cancel_at_period_end": false, + "line": "You're on Neo · $19.99/mo · renews Nov 1", + "legacy_note": "We don't sell this plan anymore, but yours stays exactly as it is." + }, + "usage": { + "kind": "unavailable", + "percent": null, + "period_id": null, + "reset_label": null, + "meter_line": null, + "nudge_line": null, + "pro_gates": [ + "proactive_agent", + "screen_context" + ] + }, + "tiers": [ + { + "tier_id": "operator:ladder_v1", + "plan_id": "operator", + "name": "Pro", + "recommended": true, + "is_current": false, + "hours_line": "About 280 hours of recording a month, enough to wear it all day", + "bullets": [ + "Omi reminds you of things you said you'd do", + "On your Mac, Omi can see what you're working on and help with it", + "5,000 MCP requests a day" + ], + "action": "none", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_operator_month", + "interval": "month", + "amount_minor": 4999, + "currency": "usd", + "headline": "$49.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_operator_year", + "interval": "year", + "amount_minor": 49990, + "currency": "usd", + "headline": "$41.66/month", + "subline": "$499.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "basic:ladder_v1", + "plan_id": "basic", + "name": "Free", + "recommended": false, + "is_current": false, + "hours_line": "About 15 hours of recording a month", + "bullets": [ + "Transcripts, summaries and memories of your conversations", + "Ask Omi anything about your day", + "100 MCP requests a day" + ], + "action": "none", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": null, + "interval": "month", + "amount_minor": 0, + "currency": "usd", + "headline": "$0.00/month", + "subline": null, + "renewal_line": null + } + ] + }, + { + "tier_id": "plus:ladder_v1", + "plan_id": "plus", + "name": "Plus", + "recommended": false, + "is_current": false, + "hours_line": "About 100 hours of recording a month, around 3 hours a day", + "bullets": [ + "Everything in Free, with much more room", + "Use Omi on your Mac as well as your phone", + "500 MCP requests a day" + ], + "action": "none", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_plus_month", + "interval": "month", + "amount_minor": 1999, + "currency": "usd", + "headline": "$19.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_plus_year", + "interval": "year", + "amount_minor": 19990, + "currency": "usd", + "headline": "$16.66/month", + "subline": "$199.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "architect:ladder_v1", + "plan_id": "architect", + "name": "Architect", + "recommended": false, + "is_current": false, + "hours_line": "About 1,200 hours of recording a month, for all-day use on more than one device", + "bullets": [ + "Everything in Pro, with over 4 times the usage", + "At least 5,000 MCP requests a day" + ], + "action": "none", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_architect_month", + "interval": "month", + "amount_minor": 19900, + "currency": "usd", + "headline": "$199.00/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_architect_year", + "interval": "year", + "amount_minor": 199000, + "currency": "usd", + "headline": "$165.83/month", + "subline": "$1990.00 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "unlimited:legacy", + "plan_id": "unlimited", + "name": "Neo", + "recommended": false, + "is_current": true, + "hours_line": "", + "bullets": [], + "action": "current", + "cta_label": null, + "blocked_line": null, + "compact": true, + "prices": [ + { + "price_id": "price_legacy_neo", + "interval": "month", + "amount_minor": 1999, + "currency": "usd", + "headline": "$19.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + } + ] + } + ], + "yearly_toggle_label": "Yearly · 2 months free" +} diff --git a/backend/tests/fixtures/storefront_v2/legacy_unlimited.json b/backend/tests/fixtures/storefront_v2/legacy_unlimited.json new file mode 100644 index 00000000000..883376b5b42 --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/legacy_unlimited.json @@ -0,0 +1,196 @@ +{ + "schema_version": 2, + "catalog_revision": 7, + "show_subscription_ui": true, + "header": "Your plan", + "current": { + "plan_id": "unlimited_v2", + "name": "Unlimited", + "price_id": "price_legacy_unlimited_v2", + "interval": "month", + "legacy": true, + "cancel_at_period_end": false, + "line": "You're on Unlimited · $29.99/mo · renews Nov 1", + "legacy_note": "We don't sell this plan anymore, but yours stays exactly as it is." + }, + "usage": { + "kind": "unavailable", + "percent": null, + "period_id": null, + "reset_label": null, + "meter_line": null, + "nudge_line": null, + "pro_gates": [ + "proactive_agent", + "screen_context" + ] + }, + "tiers": [ + { + "tier_id": "operator:ladder_v1", + "plan_id": "operator", + "name": "Pro", + "recommended": true, + "is_current": false, + "hours_line": "About 280 hours of recording a month, enough to wear it all day", + "bullets": [ + "Omi reminds you of things you said you'd do", + "On your Mac, Omi can see what you're working on and help with it", + "5,000 MCP requests a day" + ], + "action": "none", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_operator_month", + "interval": "month", + "amount_minor": 4999, + "currency": "usd", + "headline": "$49.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_operator_year", + "interval": "year", + "amount_minor": 49990, + "currency": "usd", + "headline": "$41.66/month", + "subline": "$499.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "basic:ladder_v1", + "plan_id": "basic", + "name": "Free", + "recommended": false, + "is_current": false, + "hours_line": "About 15 hours of recording a month", + "bullets": [ + "Transcripts, summaries and memories of your conversations", + "Ask Omi anything about your day", + "100 MCP requests a day" + ], + "action": "none", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": null, + "interval": "month", + "amount_minor": 0, + "currency": "usd", + "headline": "$0.00/month", + "subline": null, + "renewal_line": null + } + ] + }, + { + "tier_id": "plus:ladder_v1", + "plan_id": "plus", + "name": "Plus", + "recommended": false, + "is_current": false, + "hours_line": "About 100 hours of recording a month, around 3 hours a day", + "bullets": [ + "Everything in Free, with much more room", + "Use Omi on your Mac as well as your phone", + "500 MCP requests a day" + ], + "action": "none", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_plus_month", + "interval": "month", + "amount_minor": 1999, + "currency": "usd", + "headline": "$19.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_plus_year", + "interval": "year", + "amount_minor": 19990, + "currency": "usd", + "headline": "$16.66/month", + "subline": "$199.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "architect:ladder_v1", + "plan_id": "architect", + "name": "Architect", + "recommended": false, + "is_current": false, + "hours_line": "About 1,200 hours of recording a month, for all-day use on more than one device", + "bullets": [ + "Everything in Pro, with over 4 times the usage", + "At least 5,000 MCP requests a day" + ], + "action": "none", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_architect_month", + "interval": "month", + "amount_minor": 19900, + "currency": "usd", + "headline": "$199.00/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_architect_year", + "interval": "year", + "amount_minor": 199000, + "currency": "usd", + "headline": "$165.83/month", + "subline": "$1990.00 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "unlimited_v2:legacy", + "plan_id": "unlimited_v2", + "name": "Unlimited", + "recommended": false, + "is_current": true, + "hours_line": "", + "bullets": [], + "action": "current", + "cta_label": null, + "blocked_line": null, + "compact": true, + "prices": [ + { + "price_id": "price_legacy_unlimited_v2", + "interval": "month", + "amount_minor": 2999, + "currency": "usd", + "headline": "$29.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + } + ] + } + ], + "yearly_toggle_label": "Yearly · 2 months free" +} diff --git a/backend/tests/fixtures/storefront_v2/plus_annual_current.json b/backend/tests/fixtures/storefront_v2/plus_annual_current.json new file mode 100644 index 00000000000..894235c566a --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/plus_annual_current.json @@ -0,0 +1,159 @@ +{ + "schema_version": 2, + "catalog_revision": 7, + "show_subscription_ui": true, + "header": "Choose your plan", + "current": { + "plan_id": "plus", + "name": "Plus", + "price_id": "price_plus_year", + "interval": "year", + "legacy": false, + "cancel_at_period_end": false, + "line": "You're on Plus · $199.90/yr · renews Nov 1", + "legacy_note": null + }, + "usage": { + "kind": "unavailable", + "percent": null, + "period_id": null, + "reset_label": null, + "meter_line": null, + "nudge_line": null, + "pro_gates": [ + "proactive_agent", + "screen_context" + ] + }, + "tiers": [ + { + "tier_id": "operator:ladder_v1", + "plan_id": "operator", + "name": "Pro", + "recommended": true, + "is_current": false, + "hours_line": "About 280 hours of recording a month, enough to wear it all day", + "bullets": [ + "Omi reminds you of things you said you'd do", + "On your Mac, Omi can see what you're working on and help with it", + "5,000 MCP requests a day" + ], + "action": "checkout", + "cta_label": "Upgrade to Pro", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_operator_month", + "interval": "month", + "amount_minor": 4999, + "currency": "usd", + "headline": "$49.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_operator_year", + "interval": "year", + "amount_minor": 49990, + "currency": "usd", + "headline": "$41.66/month", + "subline": "$499.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "basic:ladder_v1", + "plan_id": "basic", + "name": "Free", + "recommended": false, + "is_current": false, + "hours_line": "About 15 hours of recording a month", + "bullets": [ + "Transcripts, summaries and memories of your conversations", + "Ask Omi anything about your day", + "100 MCP requests a day" + ], + "action": "none", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": null, + "interval": "month", + "amount_minor": 0, + "currency": "usd", + "headline": "$0.00/month", + "subline": null, + "renewal_line": null + } + ] + }, + { + "tier_id": "plus:ladder_v1", + "plan_id": "plus", + "name": "Plus", + "recommended": false, + "is_current": true, + "hours_line": "About 100 hours of recording a month, around 3 hours a day", + "bullets": [], + "action": "current", + "cta_label": null, + "blocked_line": null, + "compact": true, + "prices": [ + { + "price_id": "price_plus_year", + "interval": "year", + "amount_minor": 19990, + "currency": "usd", + "headline": "$16.66/month", + "subline": "$199.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "architect:ladder_v1", + "plan_id": "architect", + "name": "Architect", + "recommended": false, + "is_current": false, + "hours_line": "About 1,200 hours of recording a month, for all-day use on more than one device", + "bullets": [ + "Everything in Pro, with over 4 times the usage", + "At least 5,000 MCP requests a day" + ], + "action": "checkout", + "cta_label": "Upgrade to Architect", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_architect_month", + "interval": "month", + "amount_minor": 19900, + "currency": "usd", + "headline": "$199.00/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_architect_year", + "interval": "year", + "amount_minor": 199000, + "currency": "usd", + "headline": "$165.83/month", + "subline": "$1990.00 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + } + ], + "yearly_toggle_label": "Yearly · 2 months free" +} diff --git a/backend/tests/fixtures/storefront_v2/plus_budget80.json b/backend/tests/fixtures/storefront_v2/plus_budget80.json new file mode 100644 index 00000000000..e5a0f982524 --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/plus_budget80.json @@ -0,0 +1,160 @@ +{ + "schema_version": 2, + "catalog_revision": 7, + "show_subscription_ui": true, + "header": "You've used 80% of this month's usage", + "current": { + "plan_id": "plus", + "name": "Plus", + "price_id": "price_plus_month", + "interval": "month", + "legacy": false, + "cancel_at_period_end": false, + "line": "You're on Plus · $19.99/mo · renews Nov 1", + "legacy_note": null + }, + "usage": { + "kind": "usage_budget", + "percent": 80, + "period_id": "2026-10", + "reset_label": "resets Nov 1", + "meter_line": "80% used · about 20 hours of recording left · resets Nov 1", + "nudge_line": "You've used 80% of this month's usage · resets Nov 1", + "pro_gates": [ + "proactive_agent", + "screen_context" + ] + }, + "tiers": [ + { + "tier_id": "operator:ladder_v1", + "plan_id": "operator", + "name": "Pro", + "recommended": true, + "is_current": false, + "hours_line": "About 280 hours of recording a month, enough to wear it all day", + "bullets": [ + "Omi reminds you of things you said you'd do", + "On your Mac, Omi can see what you're working on and help with it", + "5,000 MCP requests a day" + ], + "action": "checkout", + "cta_label": "Upgrade to Pro", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_operator_month", + "interval": "month", + "amount_minor": 4999, + "currency": "usd", + "headline": "$49.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_operator_year", + "interval": "year", + "amount_minor": 49990, + "currency": "usd", + "headline": "$41.66/month", + "subline": "$499.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "basic:ladder_v1", + "plan_id": "basic", + "name": "Free", + "recommended": false, + "is_current": false, + "hours_line": "About 15 hours of recording a month", + "bullets": [ + "Transcripts, summaries and memories of your conversations", + "Ask Omi anything about your day", + "100 MCP requests a day" + ], + "action": "none", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": null, + "interval": "month", + "amount_minor": 0, + "currency": "usd", + "headline": "$0.00/month", + "subline": null, + "renewal_line": null + } + ] + }, + { + "tier_id": "plus:ladder_v1", + "plan_id": "plus", + "name": "Plus", + "recommended": false, + "is_current": true, + "hours_line": "About 100 hours of recording a month, around 3 hours a day", + "bullets": [], + "action": "checkout", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_plus_year", + "interval": "year", + "amount_minor": 19990, + "currency": "usd", + "headline": "$16.66/month", + "subline": "$199.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2, + "cta_label": "Switch to yearly · save $39.98" + } + ] + }, + { + "tier_id": "architect:ladder_v1", + "plan_id": "architect", + "name": "Architect", + "recommended": false, + "is_current": false, + "hours_line": "About 1,200 hours of recording a month, for all-day use on more than one device", + "bullets": [ + "Everything in Pro, with over 4 times the usage", + "At least 5,000 MCP requests a day" + ], + "action": "checkout", + "cta_label": "Upgrade to Architect", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_architect_month", + "interval": "month", + "amount_minor": 19900, + "currency": "usd", + "headline": "$199.00/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_architect_year", + "interval": "year", + "amount_minor": 199000, + "currency": "usd", + "headline": "$165.83/month", + "subline": "$1990.00 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + } + ], + "yearly_toggle_label": "Yearly · 2 months free" +} diff --git a/backend/tests/fixtures/storefront_v2/plus_monthly_yearly_toggle.json b/backend/tests/fixtures/storefront_v2/plus_monthly_yearly_toggle.json new file mode 100644 index 00000000000..af61ee068e2 --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/plus_monthly_yearly_toggle.json @@ -0,0 +1,160 @@ +{ + "schema_version": 2, + "catalog_revision": 7, + "show_subscription_ui": true, + "header": "Choose your plan", + "current": { + "plan_id": "plus", + "name": "Plus", + "price_id": "price_plus_month", + "interval": "month", + "legacy": false, + "cancel_at_period_end": false, + "line": "You're on Plus · $19.99/mo · renews Nov 1", + "legacy_note": null + }, + "usage": { + "kind": "unavailable", + "percent": null, + "period_id": null, + "reset_label": null, + "meter_line": null, + "nudge_line": null, + "pro_gates": [ + "proactive_agent", + "screen_context" + ] + }, + "tiers": [ + { + "tier_id": "operator:ladder_v1", + "plan_id": "operator", + "name": "Pro", + "recommended": true, + "is_current": false, + "hours_line": "About 280 hours of recording a month, enough to wear it all day", + "bullets": [ + "Omi reminds you of things you said you'd do", + "On your Mac, Omi can see what you're working on and help with it", + "5,000 MCP requests a day" + ], + "action": "checkout", + "cta_label": "Upgrade to Pro", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_operator_month", + "interval": "month", + "amount_minor": 4999, + "currency": "usd", + "headline": "$49.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_operator_year", + "interval": "year", + "amount_minor": 49990, + "currency": "usd", + "headline": "$41.66/month", + "subline": "$499.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "basic:ladder_v1", + "plan_id": "basic", + "name": "Free", + "recommended": false, + "is_current": false, + "hours_line": "About 15 hours of recording a month", + "bullets": [ + "Transcripts, summaries and memories of your conversations", + "Ask Omi anything about your day", + "100 MCP requests a day" + ], + "action": "none", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": null, + "interval": "month", + "amount_minor": 0, + "currency": "usd", + "headline": "$0.00/month", + "subline": null, + "renewal_line": null + } + ] + }, + { + "tier_id": "plus:ladder_v1", + "plan_id": "plus", + "name": "Plus", + "recommended": false, + "is_current": true, + "hours_line": "About 100 hours of recording a month, around 3 hours a day", + "bullets": [], + "action": "checkout", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_plus_year", + "interval": "year", + "amount_minor": 19990, + "currency": "usd", + "headline": "$16.66/month", + "subline": "$199.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2, + "cta_label": "Switch to yearly · save $39.98" + } + ] + }, + { + "tier_id": "architect:ladder_v1", + "plan_id": "architect", + "name": "Architect", + "recommended": false, + "is_current": false, + "hours_line": "About 1,200 hours of recording a month, for all-day use on more than one device", + "bullets": [ + "Everything in Pro, with over 4 times the usage", + "At least 5,000 MCP requests a day" + ], + "action": "checkout", + "cta_label": "Upgrade to Architect", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_architect_month", + "interval": "month", + "amount_minor": 19900, + "currency": "usd", + "headline": "$199.00/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_architect_year", + "interval": "year", + "amount_minor": 199000, + "currency": "usd", + "headline": "$165.83/month", + "subline": "$1990.00 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + } + ], + "yearly_toggle_label": "Yearly · 2 months free" +} diff --git a/backend/tests/fixtures/storefront_v2/pro_settings.json b/backend/tests/fixtures/storefront_v2/pro_settings.json new file mode 100644 index 00000000000..d3ca9499fd3 --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/pro_settings.json @@ -0,0 +1,160 @@ +{ + "schema_version": 2, + "catalog_revision": 7, + "show_subscription_ui": true, + "header": "Choose your plan", + "current": { + "plan_id": "operator", + "name": "Pro", + "price_id": "price_operator_month", + "interval": "month", + "legacy": false, + "cancel_at_period_end": false, + "line": "You're on Pro · $49.99/mo · renews Nov 1", + "legacy_note": null + }, + "usage": { + "kind": "unavailable", + "percent": null, + "period_id": null, + "reset_label": null, + "meter_line": null, + "nudge_line": null, + "pro_gates": [ + "proactive_agent", + "screen_context" + ] + }, + "tiers": [ + { + "tier_id": "basic:ladder_v1", + "plan_id": "basic", + "name": "Free", + "recommended": false, + "is_current": false, + "hours_line": "About 15 hours of recording a month", + "bullets": [ + "Transcripts, summaries and memories of your conversations", + "Ask Omi anything about your day", + "100 MCP requests a day" + ], + "action": "none", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": null, + "interval": "month", + "amount_minor": 0, + "currency": "usd", + "headline": "$0.00/month", + "subline": null, + "renewal_line": null + } + ] + }, + { + "tier_id": "plus:ladder_v1", + "plan_id": "plus", + "name": "Plus", + "recommended": false, + "is_current": false, + "hours_line": "About 100 hours of recording a month, around 3 hours a day", + "bullets": [ + "Everything in Free, with much more room", + "Use Omi on your Mac as well as your phone", + "500 MCP requests a day" + ], + "action": "none", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_plus_month", + "interval": "month", + "amount_minor": 1999, + "currency": "usd", + "headline": "$19.99/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_plus_year", + "interval": "year", + "amount_minor": 19990, + "currency": "usd", + "headline": "$16.66/month", + "subline": "$199.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + }, + { + "tier_id": "operator:ladder_v1", + "plan_id": "operator", + "name": "Pro", + "recommended": false, + "is_current": true, + "hours_line": "About 280 hours of recording a month, enough to wear it all day", + "bullets": [], + "action": "checkout", + "cta_label": null, + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_operator_year", + "interval": "year", + "amount_minor": 49990, + "currency": "usd", + "headline": "$41.66/month", + "subline": "$499.90 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2, + "cta_label": "Switch to yearly · save $99.98" + } + ] + }, + { + "tier_id": "architect:ladder_v1", + "plan_id": "architect", + "name": "Architect", + "recommended": false, + "is_current": false, + "hours_line": "About 1,200 hours of recording a month, for all-day use on more than one device", + "bullets": [ + "Everything in Pro, with over 4 times the usage", + "At least 5,000 MCP requests a day" + ], + "action": "checkout", + "cta_label": "Upgrade to Architect", + "blocked_line": null, + "compact": false, + "prices": [ + { + "price_id": "price_architect_month", + "interval": "month", + "amount_minor": 19900, + "currency": "usd", + "headline": "$199.00/month", + "subline": null, + "renewal_line": "Renews monthly until you cancel. Cancel anytime in settings." + }, + { + "price_id": "price_architect_year", + "interval": "year", + "amount_minor": 199000, + "currency": "usd", + "headline": "$165.83/month", + "subline": "$1990.00 billed yearly · 2 months free", + "renewal_line": "Renews yearly until you cancel. Cancel anytime in settings.", + "months_free": 2 + } + ] + } + ], + "yearly_toggle_label": "Yearly · 2 months free" +} diff --git a/backend/tests/unit/test_app_client_openapi_compatibility.py b/backend/tests/unit/test_app_client_openapi_compatibility.py index 3a165c4f319..ac45e18c1b1 100644 --- a/backend/tests/unit/test_app_client_openapi_compatibility.py +++ b/backend/tests/unit/test_app_client_openapi_compatibility.py @@ -439,6 +439,63 @@ def test_changed_composed_schema_fails_conservatively_instead_of_being_ignored() ) +def _storefront_contract(version: int, *, keep_display_name: bool) -> dict: + properties: dict = {'schema_version': {'type': 'integer', 'const': version}} + required = ['schema_version'] + if keep_display_name: + properties['display_name'] = {'type': 'string'} + required.append('display_name') + return { + 'openapi': '3.1.0', + 'paths': { + '/v2/payments/plans': { + 'get': { + 'parameters': [ + {'name': 'source', 'in': 'query', 'required': False, 'schema': {'type': 'string'}}, + ], + 'responses': { + '200': { + 'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Storefront'}}} + } + }, + } + } + }, + 'components': { + 'schemas': { + 'Storefront': {'type': 'object', 'properties': properties, 'required': required}, + } + }, + } + + +def test_unreleased_storefront_schema_v1_to_v2_skips_only_that_response_replacement(): + """Schema 1 never shipped. The exception must not cover requests or later response breaks.""" + checker = load_checker() + base = _storefront_contract(1, keep_display_name=True) + head = _storefront_contract(2, keep_display_name=False) + + assert checker.compare_specs(base, head) == [] + + required_source = copy.deepcopy(head) + required_source['paths']['/v2/payments/plans']['get']['parameters'][0]['required'] = True + request_failures = messages(checker, base, required_source) + assert any('optional request parameter became required' in item for item in request_failures) + + shipped = _storefront_contract(2, keep_display_name=True) + later = _storefront_contract(2, keep_display_name=False) + later_failures = messages(checker, shipped, later) + assert any('display_name' in item and 'removed' in item for item in later_failures) + + other_base = copy.deepcopy(base) + other_head = copy.deepcopy(head) + other_base['paths']['/v1/other'] = copy.deepcopy(base['paths']['/v2/payments/plans']) + other_head['paths']['/v1/other'] = copy.deepcopy(head['paths']['/v2/payments/plans']) + other_failures = messages(checker, other_base, other_head) + assert any(item.startswith('paths./v1/other.') for item in other_failures) + assert not any(item.startswith('paths./v2/payments/plans.') for item in other_failures) + + def test_cli_reports_precise_breaking_paths(tmp_path, capsys): checker = load_checker() base = contract() diff --git a/backend/tests/unit/test_storefront.py b/backend/tests/unit/test_storefront.py index 40a172baab9..249590e1dfe 100644 --- a/backend/tests/unit/test_storefront.py +++ b/backend/tests/unit/test_storefront.py @@ -1,7 +1,6 @@ """Executable presentation policy, live-price arithmetic, and dark-route contract.""" import json -from pathlib import Path from unittest.mock import Mock import pytest @@ -12,7 +11,8 @@ from models.storefront import StorefrontUsage from routers import storefront as route from utils.rate_limit_config import get_effective_limit -from utils.storefront import build_storefront, load_ladder, present_prices +from utils.storefront import _valid_price_rows, build_storefront, load_ladder +from utils.storefront_copy import months_free def price(identifier, amount, interval='month', **overrides): @@ -43,7 +43,7 @@ def build(plan='basic', current_price=None, rows=None, bindings=None, recognized def test_empty_bindings_hide_unpurchasable_tiers_but_keep_architect_contact(): sheet = build() - assert [(tier.plan_id, tier.action) for tier in sheet.tiers] == [('basic', 'current'), ('architect', 'contact')] + assert [(tier.plan_id, tier.action) for tier in sheet.tiers] == [('basic', 'current'), ('architect', 'none')] assert sheet.current.legacy is False @@ -52,10 +52,10 @@ def test_legacy_subscriber_has_exactly_one_current_row_even_without_stripe(plan) sheet = build(plan, 'price_old') current = [tier for tier in sheet.tiers if tier.action == 'current'] assert len(current) == 1 - assert current[0].plan_id == plan and current[0].legacy + assert current[0].plan_id == plan and sheet.current.legacy assert current[0].prices == [] assert sheet.current.price_id == 'price_old' and sheet.current.legacy - assert not any(tier.action == 'upgrade' for tier in sheet.tiers) + assert not any(tier.action == 'checkout' for tier in sheet.tiers) def test_registered_purchasable_ladder_is_ordered_and_computes_live_prices(): @@ -68,36 +68,38 @@ def test_registered_purchasable_ladder_is_ordered_and_computes_live_prices(): {'price_id': key, 'plan_id': plan, 'interval': interval, 'entitlement_version': 'ladder_v1'} ) sheet = build(rows=rows, bindings=list(rows), recognized=recognized) - assert [tier.plan_id for tier in sheet.tiers] == ['basic', 'plus', 'operator', 'architect'] - assert [tier.action for tier in sheet.tiers] == ['current', 'upgrade', 'upgrade', 'upgrade'] - plus = sheet.tiers[1] - assert sheet.next_offer_tier_id == 'plus:ladder_v1' - assert plus.recommended - assert plus.prices[1].monthly_equivalent == '$16.66/mo' - assert plus.prices[1].annual_savings.amount_minor == 3998 - assert plus.prices[1].annual_savings.percent == 16.67 - assert {f.key: f.included for f in plus.features}['screen_context'] is False - pro = sheet.tiers[2] - assert {f.key: f.included for f in pro.features}['proactive_agent'] is True + assert [tier.plan_id for tier in sheet.tiers] == ['plus', 'basic', 'operator', 'architect'] + assert [tier.action for tier in sheet.tiers] == ['checkout', 'current', 'checkout', 'checkout'] + plus = next(tier for tier in sheet.tiers if tier.plan_id == 'plus') + assert plus.recommended and plus.cta_label == 'Get Plus' + yearly = next(item for item in plus.prices if item.interval == 'year') + assert yearly.headline == '$16.66/month' + assert yearly.subline == '$199.90 billed yearly · 2 months free' + assert yearly.months_free == 2 + pro = next(tier for tier in sheet.tiers if tier.plan_id == 'operator') + assert pro.cta_label == 'Get Pro' + assert pro.bullets[0] == "Omi reminds you of things you said you'd do" paid = build('operator', 'price_operator_month', rows, list(rows), recognized) - assert paid.next_offer_tier_id == 'architect:ladder_v1' - assert [tier.action for tier in paid.tiers] == [ - 'downgrade_not_offered', - 'downgrade_not_offered', - 'current', - 'upgrade', - ] - - -def test_price_currency_interval_and_negative_savings_validation(): - assert not present_prices([price('bad', 1999, currency='jpy')]) - assert not present_prices([price('bad', 1999, active=False)]) - assert not present_prices([price('bad', 1999, recurring={'interval': 'month', 'interval_count': 3})]) - rows = present_prices([price('month', 2100), price('year', 25000, 'year')]) - assert rows[1].annual_savings.amount_minor == 200 - assert rows[1].annual_savings.percent == 0.79 - assert present_prices([price('month', 1000), price('year', 13000, 'year')])[1].annual_savings is None - assert present_prices([price('old', 1999, active=False)], current=True) + assert [tier.plan_id for tier in paid.tiers] == ['basic', 'plus', 'operator', 'architect'] + assert [tier.action for tier in paid.tiers] == ['none', 'none', 'checkout', 'checkout'] + operator = next(tier for tier in paid.tiers if tier.plan_id == 'operator') + assert operator.cta_label is None and operator.bullets == [] + assert [item.interval for item in operator.prices] == ['year'] + assert operator.prices[0].cta_label == 'Switch to yearly · save $99.98' + assert not any(tier.recommended for tier in paid.tiers) + assert next(tier for tier in paid.tiers if tier.plan_id == 'architect').cta_label == 'Upgrade to Architect' + + +def test_price_currency_interval_and_months_free_validation(): + assert not _valid_price_rows([price('bad', 1999, currency='jpy')]) + assert not _valid_price_rows([price('bad', 1999, active=False)]) + assert not _valid_price_rows([price('bad', 1999, recurring={'interval': 'month', 'interval_count': 3})]) + assert _valid_price_rows([price('old', 1999, active=False)], current=True) + # 12 - 25000/2100 is under one month, so the free-months line stays off. + assert months_free(2100, 25000) is None + assert months_free(1000, 13000) is None + assert months_free(1999, 19990) == 2 + assert months_free(1000, 11000) == 1 @pytest.fixture @@ -161,7 +163,9 @@ def test_every_surface_has_identical_presentation_and_read_only_subscription(cli for platform in ['ios', 'android', 'macos', 'windows', 'web'] ] assert all(body == bodies[0] for body in bodies) - assert [tier['display_name'] for tier in json.loads(bodies[0])['tiers']] == ['Free', 'Plus', 'Pro', 'Architect'] + body = json.loads(bodies[0]) + assert body['schema_version'] == 2 + assert [tier['name'] for tier in body['tiers']] == ['Plus', 'Free', 'Pro', 'Architect'] assert all(call.kwargs == {'provision': False} for call in read.call_args_list) @@ -169,7 +173,7 @@ def test_new_current_row_survives_missing_purchasable_prices(): sheet = build('plus', 'price_new', bindings=['price_new']) current = [tier for tier in sheet.tiers if tier.action == 'current'] assert len(current) == 1 and current[0].plan_id == 'plus' - assert not current[0].legacy + assert sheet.current.legacy is False def test_legacy_current_price_is_preserved_even_when_archived(): @@ -183,7 +187,7 @@ def test_usage_absence_and_bad_projection_never_invent_budget(monkeypatch): monkeypatch.setattr( route.subscription_utils, 'get_monthly_usage_for_subscription', lambda uid: {'transcription_seconds': 62} ) - assert route._usage('fixture', Subscription()).model_dump()['budget_percent'] == 62 + assert route._usage('fixture', Subscription()).model_dump()['percent'] == 62 monkeypatch.setattr( route.subscription_utils, 'get_usage_units', @@ -205,12 +209,20 @@ def test_api_openapi_names_the_versioned_presentation_contract(client): assert operation['responses']['200']['content']['application/json']['schema']['$ref'].endswith( '/StorefrontResponse' ) + version = schema['components']['schemas']['StorefrontResponse']['properties']['schema_version'] + assert version.get('const', version.get('enum')) in (2, [2]) assert schema['components']['schemas']['StorefrontTier']['properties']['action']['enum'] == [ + 'checkout', 'current', - 'upgrade', - 'downgrade_not_offered', - 'contact', + 'none', ] + assert 'tagline' not in schema['components']['schemas']['StorefrontTier']['properties'] + assert 'hours_line' in schema['components']['schemas']['StorefrontTier']['properties'] + assert 'blocked_line' in schema['components']['schemas']['StorefrontTier']['properties'] + assert 'compact' in schema['components']['schemas']['StorefrontTier']['properties'] + assert 'cta_label' in schema['components']['schemas']['StorefrontPrice']['properties'] + parameters = {item['name'] for item in operation['parameters']} + assert {'source', 'trigger', 'locale'} <= parameters def test_registration_does_not_bypass_existing_checkout_guard(): @@ -232,16 +244,10 @@ def test_registration_does_not_bypass_existing_checkout_guard(): def test_shared_client_fixture_matches_production_presentation(): - rows, recognized = {}, [] - for plan, monthly in [('plus', 1999), ('operator', 4999), ('architect', 19900)]: - for interval, amount in [('month', monthly), ('year', monthly * 10)]: - key = f'price_fixture_{plan}_{interval}' - rows[key] = price(key, amount, interval) - recognized.append( - {'price_id': key, 'plan_id': plan, 'interval': interval, 'entitlement_version': 'ladder_v1'} - ) - expected = json.loads((Path(__file__).parent / 'fixtures/storefront/ladder_v1_free.json').read_text()) - assert build(rows=rows, bindings=list(rows), recognized=recognized).model_dump() == expected + # Schema v2 goldens live in test_storefront_presentation.py. + assert load_ladder()['tiers'][1]['plan_id'] == 'plus' + assert 'tagline' not in load_ladder()['tiers'][1] + assert 'recommended' not in load_ladder()['tiers'][1] @pytest.mark.parametrize('plan', ['basic', 'plus', 'operator', 'architect', 'unlimited', 'unlimited_v2']) @@ -274,7 +280,7 @@ def test_contextual_recommendation_per_subscription_and_gate(plan, trigger): ) assert [tier.plan_id for tier in sheet.tiers if tier.recommended] == expected if plan != 'basic': - assert not any(tier.action == 'upgrade' for tier in sheet.tiers) + assert not any(tier.action == 'checkout' for tier in sheet.tiers) @pytest.mark.parametrize( @@ -337,17 +343,17 @@ def test_non_ladder_paid_plans_only_offer_explicit_higher_tiers(plan, registered ) sheet = build(plan, 'price_current', rows, bindings, recognized) by_plan = {tier.plan_id: tier for tier in sheet.tiers} - assert by_plan['basic'].action == 'downgrade_not_offered' + assert by_plan['basic'].action == 'none' if offers: - assert by_plan['plus'].action == 'downgrade_not_offered' + assert by_plan['plus'].action == 'none' assert not by_plan['plus'].recommended - assert by_plan['operator'].action == by_plan['architect'].action == 'contact' + assert by_plan['operator'].action == by_plan['architect'].action == 'none' assert by_plan['operator'].recommended assert len([tier for tier in sheet.tiers if tier.action == 'current']) == 1 - assert by_plan[plan].action == 'current' and by_plan[plan].legacy + assert by_plan[plan].action == 'current' and sheet.current.legacy assert by_plan[plan].prices[0].amount_minor == 2999 - assert [feature.key for feature in by_plan[plan].features] == ['legacy_entitlements'] - assert sheet.next_offer_tier_id is None + assert by_plan[plan].bullets == [] + assert not any(tier.action == 'checkout' for tier in sheet.tiers) @pytest.mark.parametrize('plan', ['plus', 'operator', 'architect']) @@ -362,8 +368,9 @@ def test_legacy_ladder_plan_has_one_card_and_only_existing_entitlement_copy(plan ) cards = [tier for tier in sheet.tiers if tier.plan_id == plan] assert len(cards) == 1 - assert cards[0].action == 'current' and cards[0].legacy - assert [feature.key for feature in cards[0].features] == ['legacy_entitlements'] + assert cards[0].action == 'current' and sheet.current.legacy + assert cards[0].bullets == [] + assert cards[0].hours_line == '' assert not cards[0].recommended @@ -436,7 +443,7 @@ def test_legacy_usage_never_accepts_shared_budget_projection(monkeypatch, plan): route.subscription_utils, 'get_monthly_usage_for_subscription', lambda uid: {'transcription_seconds': 62} ) result = route._usage('fixture', Subscription(plan=PlanType(plan), current_price_id='price_legacy')) - assert result.kind == 'transcription' and result.budget_percent == 62 + assert result.kind == 'transcription' and result.percent == 62 projection.assert_not_called() @@ -449,7 +456,7 @@ def test_legacy_chat_meter_without_speech_allowance_is_explicitly_unavailable(mo ) monkeypatch.setattr(route.subscription_utils, 'transcription_allowance_seconds', lambda plan: None) result = route._usage('fixture', Subscription(plan=PlanType.operator, current_price_id='price_legacy')) - assert result.kind == 'unavailable' and result.budget_percent is None + assert result.kind == 'unavailable' and result.percent is None @pytest.mark.parametrize('plan', ['basic', 'plus']) @@ -458,5 +465,5 @@ def test_shared_budget_projection_is_only_for_free_or_ladder_subscribers(monkeyp projection = Mock(return_value={'budget_percent': 80, 'reset_at': 1800000000}) monkeypatch.setattr(route.subscription_utils, 'get_usage_units', projection, raising=False) result = route._usage('fixture', Subscription(plan=PlanType(plan), current_price_id='price_new')) - assert result.kind == 'usage_budget' and result.budget_percent == 80 + assert result.kind == 'usage_budget' and result.percent == 80 projection.assert_called_once_with('fixture') diff --git a/backend/tests/unit/test_storefront_presentation.py b/backend/tests/unit/test_storefront_presentation.py new file mode 100644 index 00000000000..c8457b4f97b --- /dev/null +++ b/backend/tests/unit/test_storefront_presentation.py @@ -0,0 +1,549 @@ +"""Schema v2 copy, hours, policy matrix, and golden plans-sheet fixtures.""" + +import json +import os +from datetime import datetime, timezone +from decimal import Decimal +from itertools import product +from pathlib import Path + +import pytest + +import config.plan_catalog_generated as catalog +from models.storefront import StorefrontUsage +from models.users import PlanType, Subscription +from utils.storefront import ( + build_storefront, + is_legacy_plan, + load_ladder, + offered_intervals, + tier_action, +) +from utils.storefront_copy import ( + cta_label, + floor_to_nice, + hours_line, + money, + months_free, + months_free_phrase, + per_day_phrase, + present_usage, + recording_hours, + recording_hours_left, + render_price, + yearly_switch_label, + yearly_toggle_label, +) + +PERIOD_END = int(datetime(2026, 11, 1, tzinfo=timezone.utc).timestamp()) +GOLDEN_DIR = Path(__file__).resolve().parents[1] / 'fixtures' / 'storefront_v2' +PLANS = ('basic', 'plus', 'operator', 'architect', 'unlimited', 'unlimited_v2') +LADDER = ('basic', 'plus', 'operator', 'architect') +INTERVALS = ('month', 'year') + + +def price(identifier, amount, interval='month', **overrides): + return { + 'id': identifier, + 'currency': 'usd', + 'unit_amount': amount, + 'active': True, + 'recurring': {'interval': interval, 'interval_count': 1}, + **overrides, + } + + +def ladder_catalog(): + rows, recognized = {}, [] + for plan, monthly in (('plus', 1999), ('operator', 4999), ('architect', 19900)): + for interval, amount in (('month', monthly), ('year', monthly * 10)): + key = f'price_{plan}_{interval}' + rows[key] = price(key, amount, interval) + recognized.append( + {'price_id': key, 'plan_id': plan, 'interval': interval, 'entitlement_version': 'ladder_v1'} + ) + return rows, recognized + + +def sheet_for( + plan, + *, + price_id=None, + rows=None, + bindings=None, + recognized=None, + cancel=False, + trigger=None, + source=None, + locale=None, + percent=None, + period_end=None, +): + usage = StorefrontUsage( + **present_usage( + plan_id=plan, + kind='usage_budget' if percent is not None else 'unavailable', + percent=percent, + reset_at=PERIOD_END if percent is not None else None, + locale=locale, + ) + ) + return build_storefront( + catalog_revision=catalog.CATALOG_REVISION, + ladder=load_ladder(), + bindings=list(bindings or []), + recognized=recognized or [], + subscription=Subscription( + plan=PlanType(plan), + current_price_id=price_id, + cancel_at_period_end=cancel, + current_period_end=period_end, + ), + retrieve_price=lambda key: (rows or {})[key], + purchasable=lambda key: True, + usage=usage, + source=source, + trigger=trigger, + locale=locale, + ) + + +def test_recording_hours_match_the_deck(): + assert not hasattr(catalog, 'USAGE_BUDGET_UNITS') + assert ( + recording_hours('basic'), + recording_hours('plus'), + recording_hours('operator'), + recording_hours('architect'), + ) == ( + 15, + 100, + 280, + 1200, + ) + assert hours_line('basic') == 'About 15 hours of recording a month' + assert hours_line('plus') == 'About 100 hours of recording a month, around 3 hours a day' + assert hours_line('operator') == 'About 280 hours of recording a month, enough to wear it all day' + assert hours_line('architect') == 'About 1,200 hours of recording a month, for all-day use on more than one device' + + +def test_floor_to_nice_and_per_day_phrasing(): + assert floor_to_nice(Decimal('15.6')) == 15 + assert floor_to_nice(Decimal('17.5')) == 20 + assert floor_to_nice(Decimal('30')) == 30 + assert floor_to_nice(Decimal('106.969230769')) == 100 + assert floor_to_nice(Decimal('288.280769230')) == 280 + assert floor_to_nice(Decimal('299.9')) == 290 + assert floor_to_nice(Decimal('300')) == 300 + assert floor_to_nice(Decimal('1238.796153846')) == 1200 + assert per_day_phrase(15) == 'about 1 hour a day' + assert per_day_phrase(60) == 'about 2 hours a day' + assert per_day_phrase(100) == 'about 3 hours a day' + assert per_day_phrase(179) == 'about 6 hours a day' + assert per_day_phrase(180) == 'enough to wear it all day' + assert per_day_phrase(280) == 'enough to wear it all day' + assert per_day_phrase(480) == 'enough to wear it all day' + assert per_day_phrase(481) == 'for wearing it all day, every day, on more than one device' + assert per_day_phrase(1200) == 'for wearing it all day, every day, on more than one device' + assert recording_hours_left('plus', 80) == 20 + assert recording_hours_left('plus', 100) == 0 + assert recording_hours_left('plus', 150) == 0 + assert recording_hours_left('basic', 80) == 3 + assert recording_hours_left('basic', 120) == 0 + assert months_free(4999, 49990) == 2 + + +def test_one_month_free_is_singular_and_yearly_switch_uses_the_price_currency(): + assert months_free(1000, 11000) == 1 + assert months_free_phrase(1) == '1 month free' + assert months_free_phrase(2) == '2 months free' + rendered = render_price( + price_id='price_year', + interval='year', + amount_minor=11000, + currency='usd', + monthly_minor=1000, + ) + assert rendered['subline'] == '$110.00 billed yearly · 1 month free' + assert rendered['months_free'] == 1 + assert yearly_toggle_label(1) == 'Yearly · 1 month free' + assert yearly_toggle_label(2) == 'Yearly · 2 months free' + assert yearly_switch_label(monthly_minor=1999, annual_minor=19990, currency='usd') == ( + 'Switch to yearly · save $39.98' + ) + assert yearly_switch_label(monthly_minor=1999, annual_minor=1999 * 12, currency='usd') is None + euro = yearly_switch_label(monthly_minor=1999, annual_minor=19990, currency='eur') + assert euro == 'Switch to yearly · save 39.98 EUR' + + +def expected_action(current, tier, legacy, cancel): + rank = {'basic': 0, 'plus': 1, 'operator': 2, 'architect': 3} + if tier == current: + if legacy or current == 'basic' or not cancel: + return 'current' + return 'checkout' + if legacy or (cancel and current in rank and current != 'basic'): + return 'none' + if current not in rank or tier not in rank or rank[tier] <= rank[current]: + return 'none' + return 'checkout' + + +def expected_intervals(current, tier, interval, available, *, resubscribe_only=False, legacy=False): + ordered = [item for item in ('month', 'year') if item in available] + if tier != current or current == 'basic': + return ordered + if interval is None: + return [] + if resubscribe_only: + return [interval] if interval in ordered else [] + if interval == 'year': + return [item for item in ordered if item != 'month'] + if not legacy and 'year' in ordered: + return ['year'] + return ordered + + +def expected_cta(current, tier, action): + if action != 'checkout': + return None + if tier == current: + return 'Resubscribe' + if tier == 'plus': + return 'Get Plus' + if tier == 'operator': + return 'Get Pro' if current == 'basic' else 'Upgrade to Pro' + if tier == 'architect': + return 'Get Architect' if current == 'basic' else 'Upgrade to Architect' + return None + + +@pytest.mark.parametrize('current', PLANS) +@pytest.mark.parametrize('interval', INTERVALS) +@pytest.mark.parametrize('cancel', (False, True)) +@pytest.mark.parametrize('legacy', (False, True)) +@pytest.mark.parametrize('tier', PLANS) +def test_policy_matrix(current, interval, cancel, legacy, tier): + action = tier_action(current_plan=current, tier_plan=tier, legacy=legacy, cancel=cancel) + assert action == expected_action(current, tier, legacy, cancel) + if tier == 'basic': + available = ['month'] + elif tier in {'plus', 'operator', 'architect'}: + available = ['month', 'year'] + else: + available = [interval] + resubscribe_only = cancel and not legacy and current in LADDER and current != 'basic' + assert offered_intervals( + current_plan=current, + tier_plan=tier, + current_interval=interval, + available=available, + resubscribe_only=resubscribe_only, + legacy=legacy, + ) == expected_intervals(current, tier, interval, available, resubscribe_only=resubscribe_only, legacy=legacy) + assert cta_label(action=action, tier_plan=tier, current_plan=current) == expected_cta(current, tier, action) + if legacy: + assert action != 'checkout' + + +def test_built_sheets_match_the_policy_matrix(): + base_rows, recognized = ladder_catalog() + cases = [] + for cancel in (False, True): + cases.append(('basic', 'month', cancel, False, None)) + for plan in ('unlimited', 'unlimited_v2'): + for interval, cancel in product(INTERVALS, (False, True)): + cases.append((plan, interval, cancel, True, f'price_legacy_{plan}_{interval}')) + for plan in ('plus', 'operator', 'architect'): + for interval, cancel, legacy in product(INTERVALS, (False, True), (False, True)): + price_id = f'price_old_{plan}_{interval}' if legacy else f'price_{plan}_{interval}' + cases.append((plan, interval, cancel, legacy, price_id)) + for plan, interval, cancel, legacy, price_id in cases: + rows = dict(base_rows) + if price_id and price_id not in rows: + rows[price_id] = price(price_id, 2999, interval) + built = sheet_for( + plan, + price_id=price_id, + rows=rows, + bindings=list(base_rows), + recognized=recognized, + cancel=cancel, + period_end=None if plan == 'basic' else PERIOD_END, + ) + assert is_legacy_plan(plan, price_id, set(base_rows)) is legacy + assert built.current.legacy is legacy + assert not (legacy and any(tier.action == 'checkout' for tier in built.tiers)) + resubscribe_only = cancel and not legacy and plan in LADDER and plan != 'basic' + switching = not legacy and not cancel and interval == 'month' and plan in LADDER and plan != 'basic' + for tier in built.tiers: + expected = expected_action(plan, tier.plan_id, legacy, cancel) + if switching and tier.is_current: + expected = 'checkout' + assert tier.action == expected + if tier.is_current: + assert tier.bullets == [] + if not tier.is_current: + continue + if plan == 'basic': + available = ['month'] + elif legacy: + available = [interval] + else: + available = ['month', 'year'] + assert [item.interval for item in tier.prices] == expected_intervals( + plan, plan, interval, available, resubscribe_only=resubscribe_only, legacy=legacy + ) + if resubscribe_only: + assert built.yearly_toggle_label is None + assert built.header == f"Your {built.current.name} plan ends Nov 1" + current_amount = next(item.amount_minor for item in built.tiers[0].prices if item.interval == interval) + period = 'mo' if interval == 'month' else 'yr' + assert built.current.line == f"You're on {built.current.name} · {money(current_amount)}/{period}" + assert built.tiers[0].is_current + assert not any(tier.recommended for tier in built.tiers) + for tier in built.tiers: + if tier.is_current: + assert tier.action == 'checkout' and tier.cta_label == 'Resubscribe' + assert [item.price_id for item in tier.prices] == [price_id] + assert [item.interval for item in tier.prices] == [interval] + assert tier.blocked_line is None and tier.compact is False + assert all(item.cta_label is None for item in tier.prices) + else: + assert tier.action == 'none' and tier.cta_label is None + assert tier.blocked_line == 'Resubscribe first to change plans' + assert tier.compact is False + elif not legacy and plan in {'plus', 'operator', 'architect'}: + current_tier = next(tier for tier in built.tiers if tier.is_current) + if interval == 'month': + assert [item.interval for item in current_tier.prices] == ['year'] + yearly = current_tier.prices[0] + assert yearly.cta_label is not None and yearly.cta_label.startswith('Switch to yearly · save $') + assert current_tier.action == 'checkout' + assert current_tier.compact is False and current_tier.cta_label is None + else: + assert [item.interval for item in current_tier.prices] == ['year'] + assert current_tier.action == 'current' + assert current_tier.compact is True + assert all(item.cta_label is None for item in current_tier.prices) + if plan == 'basic': + assert next(tier for tier in built.tiers if tier.is_current).compact is True + + +def test_yearly_subscriber_with_failed_price_retrieve_offers_no_prices(): + rows, recognized = ladder_catalog() + + def retrieve(key): + if key == 'price_plus_year': + raise RuntimeError('stripe down') + return rows[key] + + built = build_storefront( + catalog_revision=catalog.CATALOG_REVISION, + ladder=load_ladder(), + bindings=list(rows), + recognized=recognized, + subscription=Subscription( + plan=PlanType.plus, + current_price_id='price_plus_year', + cancel_at_period_end=False, + current_period_end=PERIOD_END, + ), + retrieve_price=retrieve, + purchasable=lambda key: True, + usage=StorefrontUsage(), + ) + current = next(tier for tier in built.tiers if tier.is_current) + assert built.current.interval is None + assert current.plan_id == 'plus' + assert current.action == 'current' + assert current.cta_label is None + assert current.prices == [] + assert current.bullets == [] + + +def _scenario(name): + rows, recognized = ladder_catalog() + common = {'rows': rows, 'bindings': list(rows), 'recognized': recognized, 'period_end': PERIOD_END} + if name == 'free_settings': + return sheet_for( + 'basic', source='settings', period_end=None, rows=rows, bindings=list(rows), recognized=recognized + ) + if name == 'free_budget80': + return sheet_for( + 'basic', + trigger='usage_budget', + percent=80, + period_end=None, + **{k: common[k] for k in ('rows', 'bindings', 'recognized')}, + ) + if name == 'free_pro_gate_proactive': + return sheet_for( + 'basic', + trigger='proactive_agent', + period_end=None, + **{k: common[k] for k in ('rows', 'bindings', 'recognized')}, + ) + if name == 'free_pro_gate_screen': + return sheet_for( + 'basic', + trigger='screen_context', + period_end=None, + **{k: common[k] for k in ('rows', 'bindings', 'recognized')}, + ) + if name == 'free_mcp_cap': + return sheet_for( + 'basic', + trigger='mcp_daily_cap', + period_end=None, + **{k: common[k] for k in ('rows', 'bindings', 'recognized')}, + ) + if name == 'plus_budget80': + return sheet_for('plus', price_id='price_plus_month', trigger='usage_budget', percent=80, **common) + if name == 'plus_monthly_yearly_toggle': + return sheet_for('plus', price_id='price_plus_month', source='settings', **common) + if name == 'plus_annual_current': + return sheet_for('plus', price_id='price_plus_year', source='settings', **common) + if name == 'pro_settings': + return sheet_for('operator', price_id='price_operator_month', source='settings', **common) + if name == 'architect_settings': + return sheet_for('architect', price_id='price_architect_month', source='settings', **common) + if name == 'legacy_unlimited': + rows = dict(rows) + rows['price_legacy_unlimited_v2'] = price('price_legacy_unlimited_v2', 2999) + return sheet_for( + 'unlimited_v2', + price_id='price_legacy_unlimited_v2', + rows=rows, + bindings=list(recognized and common['bindings']), + recognized=recognized, + source='settings', + period_end=PERIOD_END, + ) + if name == 'legacy_neo': + rows = dict(rows) + rows['price_legacy_neo'] = price('price_legacy_neo', 1999) + return sheet_for( + 'unlimited', + price_id='price_legacy_neo', + rows=rows, + bindings=list(recognized and common['bindings']), + recognized=recognized, + source='settings', + period_end=PERIOD_END, + ) + if name == 'cancel_scheduled_plus': + return sheet_for('plus', price_id='price_plus_month', cancel=True, source='settings', **common) + raise AssertionError(name) + + +SCENARIOS = ( + 'free_settings', + 'free_budget80', + 'free_pro_gate_proactive', + 'free_pro_gate_screen', + 'free_mcp_cap', + 'plus_budget80', + 'plus_monthly_yearly_toggle', + 'plus_annual_current', + 'pro_settings', + 'architect_settings', + 'legacy_unlimited', + 'legacy_neo', + 'cancel_scheduled_plus', +) + + +def _dump(sheet) -> str: + return json.dumps(sheet.model_dump(mode='json'), indent=2, ensure_ascii=False) + '\n' + + +def test_golden_storefront_scenarios(): + rendered = {name: _scenario(name) for name in SCENARIOS} + free = rendered['free_settings'] + assert free.header == 'Choose your plan' + assert free.current.line == "You're on Free" + assert [tier.plan_id for tier in free.tiers] == ['plus', 'basic', 'operator', 'architect'] + free_card = next(tier for tier in free.tiers if tier.is_current) + assert free_card.compact is True and free_card.bullets == [] + assert rendered['free_budget80'].header == "You've used 80% of this month's usage" + assert rendered['free_budget80'].usage.meter_line == ('80% used · about 3 hours of recording left · resets Nov 1') + assert rendered['free_budget80'].usage.nudge_line == ("You've used 80% of this month's usage · resets Nov 1") + assert rendered['free_pro_gate_proactive'].header == 'Reminders and follow-ups come with Pro' + proactive = next(tier for tier in rendered['free_pro_gate_proactive'].tiers if tier.plan_id == 'operator') + assert proactive.bullets[0] == "Omi reminds you of things you said you'd do" + assert rendered['free_pro_gate_screen'].header == 'Screen help comes with Pro' + screen = next(tier for tier in rendered['free_pro_gate_screen'].tiers if tier.plan_id == 'operator') + assert screen.bullets[0] == "On your Mac, Omi can see what you're working on and help with it" + assert rendered['free_mcp_cap'].header == "You've used today's 100 MCP requests" + assert all('MCP requests' in tier.bullets[0] for tier in rendered['free_mcp_cap'].tiers if not tier.is_current) + plus80 = rendered['plus_budget80'] + assert plus80.current.line == "You're on Plus · $19.99/mo · renews Nov 1" + assert plus80.usage.meter_line == '80% used · about 20 hours of recording left · resets Nov 1' + annual = rendered['plus_annual_current'] + assert annual.current.line == "You're on Plus · $199.90/yr · renews Nov 1" + plus_card = next(tier for tier in annual.tiers if tier.plan_id == 'plus') + assert [item.interval for item in plus_card.prices] == ['year'] + assert plus_card.compact is True and plus_card.cta_label is None and plus_card.bullets == [] + assert all(item.cta_label is None for item in plus_card.prices) + switched = rendered['plus_monthly_yearly_toggle'] + assert switched.header == 'Choose your plan' + plus_month = next(tier for tier in switched.tiers if tier.plan_id == 'plus') + assert plus_month.action == 'checkout' and plus_month.cta_label is None and plus_month.compact is False + assert plus_month.bullets == [] + assert [item.interval for item in plus_month.prices] == ['year'] + assert plus_month.prices[0].cta_label == 'Switch to yearly · save $39.98' + assert rendered['pro_settings'].current.line == "You're on Pro · $49.99/mo · renews Nov 1" + assert rendered['architect_settings'].current.line == "You're on Architect · $199.00/mo · renews Nov 1" + legacy = rendered['legacy_unlimited'] + assert legacy.header == 'Your plan' + assert legacy.current.plan_id == 'unlimited_v2' + assert legacy.current.name == 'Unlimited' + assert legacy.current.line == "You're on Unlimited · $29.99/mo · renews Nov 1" + assert legacy.current.legacy_note == "We don't sell this plan anymore, but yours stays exactly as it is." + assert not any(tier.action == 'checkout' for tier in legacy.tiers) + assert {tier.plan_id for tier in legacy.tiers if tier.action == 'none'} >= { + 'operator', + 'architect', + 'basic', + 'plus', + } + neo = rendered['legacy_neo'] + assert neo.header == 'Your plan' + assert neo.current.plan_id == 'unlimited' + assert neo.current.name == 'Neo' + assert neo.current.line == "You're on Neo · $19.99/mo · renews Nov 1" + assert neo.current.legacy_note == legacy.current.legacy_note + assert not any(tier.action == 'checkout' for tier in neo.tiers) + cancelled = rendered['cancel_scheduled_plus'] + assert cancelled.header == 'Your Plus plan ends Nov 1' + assert cancelled.current.line == "You're on Plus · $19.99/mo" + assert [tier.plan_id for tier in cancelled.tiers][0] == 'plus' + assert not any(tier.recommended for tier in cancelled.tiers) + current_plus = next(tier for tier in cancelled.tiers if tier.is_current) + assert current_plus.action == 'checkout' and current_plus.cta_label == 'Resubscribe' + assert current_plus.blocked_line is None and current_plus.compact is False and current_plus.bullets == [] + assert [item.price_id for item in current_plus.prices] == ['price_plus_month'] + assert all(item.cta_label is None for item in current_plus.prices) + assert cancelled.yearly_toggle_label is None + assert all( + tier.action == 'none' and tier.cta_label is None and tier.blocked_line == 'Resubscribe first to change plans' + for tier in cancelled.tiers + if not tier.is_current + ) + assert all(tier.bullets == [] for sheet in rendered.values() for tier in sheet.tiers if tier.is_current) + assert all( + rendered[name].yearly_toggle_label == 'Yearly · 2 months free' + for name in SCENARIOS + if name != 'cancel_scheduled_plus' + ) + french = sheet_for('basic', locale='fr-FR', rows={}, bindings=[], recognized=[]) + assert french.header == 'Choose your plan' + + GOLDEN_DIR.mkdir(parents=True, exist_ok=True) + for name in SCENARIOS: + path = GOLDEN_DIR / f'{name}.json' + payload = _dump(rendered[name]) + if os.environ.get('UPDATE_STOREFRONT_GOLDENS') == '1': + path.write_text(payload, encoding='utf-8') + assert path.read_text(encoding='utf-8') == payload diff --git a/backend/utils/storefront.py b/backend/utils/storefront.py index 7ebf1be8be5..3a0e969b086 100644 --- a/backend/utils/storefront.py +++ b/backend/utils/storefront.py @@ -1,33 +1,124 @@ """Backend-only presentation policy. Does not grant entitlements or mutate billing.""" -from decimal import Decimal, ROUND_HALF_UP +from functools import lru_cache from pathlib import Path import json -from typing import Any, Callable +from typing import Any, Callable, Literal from models.storefront import ( - AnnualSavings, - PlanFeature, - StorefrontCurrentPlan, + StorefrontCurrent, StorefrontPrice, StorefrontResponse, StorefrontTier, StorefrontUsage, ) from models.users import Subscription +from utils.storefront_copy import ( + blocked_line, + bullets, + cta_label, + current_line, + header, + hours_line, + plan_name, + render_price, + yearly_switch_label, + yearly_toggle_label, +) + +RANK = {'basic': 0, 'plus': 1, 'operator': 2, 'architect': 3} +UNRANKED_HIGHER = {'operator', 'architect'} +@lru_cache(maxsize=1) def load_ladder() -> dict[str, Any]: return json.loads((Path(__file__).resolve().parents[1] / 'config/storefront_ladder.json').read_text()) -def _money(amount_minor: Decimal | int) -> str: - dollars = (Decimal(amount_minor) / 100).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) - return f'${dollars:.2f}' +def is_legacy_plan(plan: str, price_id: str | None, bindings: set[str]) -> bool: + if plan == 'basic': + return False + if plan not in RANK: + return True + return price_id not in bindings + + +def cancel_resubscribe_only(*, current_plan: str, legacy: bool, cancel: bool) -> bool: + """Paid non-legacy cancellation: the payment API rejects every price except resubscribe.""" + return cancel and not legacy and current_plan in RANK and current_plan != 'basic' + + +def tier_action( + *, current_plan: str, tier_plan: str, legacy: bool, cancel: bool +) -> Literal['checkout', 'current', 'none']: + """checkout, current, or none. Legacy never checks out. Cancel offers Resubscribe on the current tier only.""" + if tier_plan == current_plan: + if legacy or current_plan == 'basic' or not cancel: + return 'current' + return 'checkout' + if legacy or cancel_resubscribe_only(current_plan=current_plan, legacy=legacy, cancel=cancel): + return 'none' + current_rank = RANK.get(current_plan) + tier_rank = RANK.get(tier_plan) + if current_rank is None or tier_rank is None: + return 'none' + if tier_rank <= current_rank: + return 'none' + return 'checkout' + + +def is_higher_offer(current_plan: str, tier_plan: str) -> bool: + if tier_plan == current_plan: + return False + if current_plan not in RANK: + return tier_plan in UNRANKED_HIGHER + current_rank = RANK.get(current_plan) + tier_rank = RANK.get(tier_plan) + if current_rank is None or tier_rank is None: + return False + return tier_rank > current_rank + + +def recommended_plan_id(current_plan: str, trigger: str | None) -> str | None: + """Badge only. Pro and Architect are never badged; a badge is not a selection.""" + if current_plan in {'operator', 'architect'}: + return None + if trigger in {'proactive_agent', 'screen_context'} or current_plan != 'basic': + return 'operator' + return 'plus' + + +def offered_intervals( + *, + current_plan: str, + tier_plan: str, + current_interval: str | None, + available: list[str], + resubscribe_only: bool = False, + legacy: bool = False, +) -> list[str]: + """Same tier: annual → monthly is never offered. + + Monthly → annual is allowed, and that yearly price is the only one on the + switch card. Resubscribe offers only the current interval. When the current + price cannot be retrieved, the paid current card offers no interval, so a + yearly subscriber is not shown the monthly price. + """ + ordered = [interval for interval in ('month', 'year') if interval in available] + if tier_plan != current_plan or current_plan == 'basic': + return ordered + if current_interval is None: + return [] + if resubscribe_only: + return [current_interval] if current_interval in ordered else [] + if current_interval == 'year': + return [interval for interval in ordered if interval != 'month'] + if not legacy and 'year' in ordered: + return ['year'] + return ordered -def present_prices(rows: list[dict[str, Any]], *, current: bool = False) -> list[StorefrontPrice]: - """USD recurring monthly/yearly prices only; omit unsupported billing shapes.""" +def _valid_price_rows(rows: list[dict[str, Any]], *, current: bool = False) -> list[dict[str, Any]]: valid = [ row for row in rows @@ -40,45 +131,90 @@ def present_prices(rows: list[dict[str, Any]], *, current: bool = False) -> list and row['recurring'].get('interval_count', 1) == 1 ] valid.sort(key=lambda row: (row['recurring']['interval'] != 'month', row['id'])) + return valid + + +def _prices_for( + rows: list[dict[str, Any]], + *, + offered: list[str], + locale: str | None, + current: bool = False, + switch_yearly: bool = False, +) -> list[StorefrontPrice]: + valid = _valid_price_rows(rows, current=current) monthly = next((row for row in valid if row['recurring']['interval'] == 'month'), None) - result = [] - for row in valid: - interval = row['recurring']['interval'] - amount = row['unit_amount'] - savings = None - if interval == 'year' and monthly: - saved = monthly['unit_amount'] * 12 - amount - if saved > 0: - percent = round(saved * 100 / (monthly['unit_amount'] * 12), 2) - savings = AnnualSavings( - amount_minor=saved, percent=percent, formatted=f'Save {_money(saved)} ({percent:.2f}%) per year' - ) - result.append( - StorefrontPrice( - price_id=row['id'], - interval=interval, + monthly_minor = monthly['unit_amount'] if monthly else None + rendered = [] + for interval in offered: + row = next((item for item in valid if item['recurring']['interval'] == interval), None) + if row is None: + continue + payload = render_price( + price_id=row['id'], + interval=interval, + amount_minor=row['unit_amount'], + currency=row['currency'], + monthly_minor=monthly_minor, + locale=locale, + ) + if switch_yearly and interval == 'year' and monthly_minor is not None: + payload['cta_label'] = yearly_switch_label( + monthly_minor=monthly_minor, + annual_minor=row['unit_amount'], currency=row['currency'], - amount_minor=amount, - formatted_amount=f'{_money(amount)}/{"mo" if interval == "month" else "yr"}', - monthly_equivalent=f'{_money(Decimal(amount) / (12 if interval == "year" else 1))}/mo', - annual_savings=savings, + locale=locale, ) - ) - return result + rendered.append(StorefrontPrice(**payload)) + return rendered -def _features(tier: dict[str, Any]) -> list[PlanFeature]: - pro = tier['plan_id'] in {'operator', 'architect'} - return [ - PlanFeature(key='core_desktop', label='Capture, conversations, chat, memories and search'), - PlanFeature(key='managed_usage_budget', label='Managed AI uses your shared monthly budget'), - PlanFeature( - key='mcp_daily_cap', - label=f'MCP + developer API: {"at least " if tier["plan_id"] == "architect" else ""}{tier["mcp_daily_cap"]:,} requests/day', - ), - PlanFeature(key='proactive_agent', label='Proactive agent', included=pro), - PlanFeature(key='screen_context', label='Screen context', included=pro), - ] +def _compact(*, is_current: bool, action: str, prices: list[StorefrontPrice]) -> bool: + """Current card with no purchase button. Current cards already send no bullets.""" + if not is_current or action == 'checkout': + return False + return not any(price.cta_label for price in prices) + + +def _free_price(currency: str, locale: str | None) -> StorefrontPrice: + rendered = render_price( + price_id=None, interval='month', amount_minor=0, currency=currency, monthly_minor=None, locale=locale + ) + # The paid renewal sentence does not apply to the zero price. + rendered['renewal_line'] = None + return StorefrontPrice(**rendered) + + +def _retrieve(retrieve_price: Callable[[str], dict[str, Any]], price_id: str | None) -> dict[str, Any] | None: + if not price_id: + return None + try: + row = retrieve_price(price_id) + except Exception: + return None + if row.get('id') == price_id: + return row + return None + + +def _mcp_cap(ladder: dict[str, Any], plan_id: str) -> int | None: + for tier in ladder.get('tiers', []): + if tier.get('plan_id') == plan_id and type(tier.get('mcp_daily_cap')) is int: + return tier['mcp_daily_cap'] + return None + + +def _sheet_months_free(tiers: list[StorefrontTier]) -> int | None: + found: list[int] = [] + for tier in tiers: + yearly = next((price for price in tier.prices if price.interval == 'year'), None) + if yearly is None or yearly.months_free is None: + continue + if yearly.months_free >= 1: + found.append(yearly.months_free) + if not found: + return None + return min(found) def build_storefront( @@ -93,21 +229,27 @@ def build_storefront( usage: StorefrontUsage, source: str | None = None, trigger: str | None = None, + locale: str | None = None, ) -> StorefrontResponse: + del source # Opening surface is accepted; header selection uses trigger, legacy, and usage. current_plan = subscription.plan.value - # Sources use the Paywall Opened vocabulary (#21337). A separate trigger - # carries gate semantics because a surface (e.g. settings) is not a gate. - recommended_plan = ( - 'operator' if trigger in {'proactive_agent', 'screen_context'} or current_plan != 'basic' else 'plus' + current_price_id = subscription.current_price_id + binding_set = set(bindings) + legacy = is_legacy_plan(current_plan, current_price_id, binding_set) + current_row = _retrieve(retrieve_price, current_price_id) + current_interval = None + current_amount = None + if current_row and (current_row.get('recurring') or {}).get('interval') in {'month', 'year'}: + current_interval = current_row['recurring']['interval'] + if type(current_row.get('unit_amount')) is int: + current_amount = current_row['unit_amount'] + resubscribe_only = cancel_resubscribe_only( + current_plan=current_plan, legacy=legacy, cancel=subscription.cancel_at_period_end ) - current_price = subscription.current_price_id - ranks = {tier['plan_id']: rank for rank, tier in enumerate(ladder['tiers'])} - current_rank = ranks.get(current_plan) - # A price binding cannot turn a non-ladder plan into ladder-v1 entitlements. - legacy = current_plan != 'basic' and (current_rank is None or current_price not in bindings) - # Neo/Unlimited have no ladder rank. Only these explicitly higher plans may - # be offered for contact; absence of rank must never authorize a downgrade. - higher_offers = {'operator', 'architect'} if current_plan in {'unlimited', 'unlimited_v2'} else set() + badge = None if resubscribe_only else recommended_plan_id(current_plan, trigger) + blocked = blocked_line(locale) if resubscribe_only else None + raw_currency = ladder.get('currency') + currency = raw_currency if isinstance(raw_currency, str) else 'usd' tiers: list[StorefrontTier] = [] for tier in ladder['tiers']: plan = tier['plan_id'] @@ -117,97 +259,145 @@ def build_storefront( for registration in recognized: price_id = registration['price_id'] if ( - price_id in bindings + price_id in binding_set and registration['plan_id'] == plan and registration.get('entitlement_version') == 'ladder_v1' and purchasable(price_id) ): - try: - row = retrieve_price(price_id) - if ( - row.get('id') == price_id - and (row.get('recurring') or {}).get('interval') == registration['interval'] - ): - rows.append(row) - except Exception: - # Caller owns provider-error telemetry; omit unusable prices. - continue - prices = present_prices(rows) + row = _retrieve(retrieve_price, price_id) + if row and (row.get('recurring') or {}).get('interval') == registration['interval']: + rows.append(row) + available = [row['recurring']['interval'] for row in _valid_price_rows(rows)] if plan == 'basic': - prices = [ - StorefrontPrice( - interval='month', - currency='usd', - amount_minor=0, - formatted_amount='$0.00/mo', - monthly_equivalent='$0.00/mo', - ) - ] - # Do not sell legacy prices with ladder-v1 feature claims. Architect remains - # a visible contact tier until its new prices and live amount are approved. + available = ['month'] + is_current = plan == current_plan and not legacy + switch = False + if plan == 'basic': + prices = [_free_price(currency, locale)] + else: + resubscribe_this = resubscribe_only and is_current + offered = offered_intervals( + current_plan=current_plan, + tier_plan=plan, + current_interval=current_interval, + available=available, + resubscribe_only=resubscribe_this, + legacy=legacy, + ) + want_switch = is_current and current_interval == 'month' and not resubscribe_only and 'year' in offered + prices = _prices_for(rows, offered=offered, locale=locale, switch_yearly=want_switch) + if resubscribe_this and current_price_id: + prices = [item for item in prices if item.price_id == current_price_id] + switch = want_switch and any(item.cta_label for item in prices) + if want_switch and not switch: + month_offered = ['month'] if 'month' in available else [] + prices = _prices_for(rows, offered=month_offered, locale=locale) if plan != 'basic' and not prices and plan != 'architect': continue - if plan == current_plan and not legacy: - action = 'current' - elif current_rank is None and plan not in higher_offers: - action = 'downgrade_not_offered' - elif current_rank is not None and ranks[plan] <= current_rank: - action = 'downgrade_not_offered' - elif legacy or not prices: - action = 'contact' - else: - action = 'upgrade' + action = tier_action( + current_plan=current_plan, + tier_plan=plan, + legacy=legacy, + cancel=subscription.cancel_at_period_end, + ) + if action == 'checkout' and not prices and plan != current_plan: + action = 'none' + if switch: + action = 'checkout' tiers.append( StorefrontTier( tier_id=f'{plan}:ladder_v1', plan_id=plan, - entitlement_version='ladder_v1', - display_name=tier['display_name'], - tagline=tier['tagline'], - features=_features(tier), - prices=prices, + name=plan_name(plan, ladder), + recommended=bool(badge) and plan == badge and is_higher_offer(current_plan, plan), + is_current=is_current, + hours_line=hours_line(plan, locale), + bullets=[] if is_current else bullets(plan, trigger, locale), action=action, - recommended=plan == recommended_plan and action in {'upgrade', 'contact'}, + cta_label=( + None + if switch + else cta_label(action=action, tier_plan=plan, current_plan=current_plan, locale=locale) + ), + blocked_line=None if is_current or blocked is None else blocked, + compact=_compact(is_current=is_current, action=action, prices=prices), + prices=prices, ) ) - if legacy or (current_plan != 'basic' and not any(tier.action == 'current' for tier in tiers)): - rows = [] - if current_price: - try: - row = retrieve_price(current_price) - if row.get('id') == current_price: - rows.append(row) - except Exception: - pass - current_tier = next((tier for tier in ladder['tiers'] if tier['plan_id'] == current_plan), None) - name = ( - current_tier['display_name'] - if current_tier - else {'unlimited': 'Neo', 'unlimited_v2': 'Unlimited'}.get(current_plan, current_plan) + if legacy or (current_plan != 'basic' and not any(tier.is_current for tier in tiers)): + action = tier_action( + current_plan=current_plan, + tier_plan=current_plan, + legacy=legacy, + cancel=subscription.cancel_at_period_end, ) + current_prices: list[StorefrontPrice] = [] + if current_row and current_interval: + available = [current_interval] + offered = offered_intervals( + current_plan=current_plan, + tier_plan=current_plan, + current_interval=current_interval, + available=available, + resubscribe_only=resubscribe_only, + legacy=legacy, + ) + current_prices = _prices_for([current_row], offered=offered, locale=locale, current=True) + if resubscribe_only and current_price_id: + current_prices = [item for item in current_prices if item.price_id == current_price_id] tiers.append( StorefrontTier( tier_id=f'{current_plan}:{"legacy" if legacy else "ladder_v1"}', plan_id=current_plan, - entitlement_version='legacy' if legacy else 'ladder_v1', - display_name=name, - tagline='Your current plan', - legacy=legacy, - action='current', - features=( - [PlanFeature(key='legacy_entitlements', label='Your existing benefits stay the same')] - if legacy or current_tier is None - else _features(current_tier) - ), - prices=present_prices(rows, current=True), + name=plan_name(current_plan, ladder), + recommended=False, + is_current=True, + hours_line='' if legacy or current_plan not in RANK else hours_line(current_plan, locale), + bullets=[], + action=action, + cta_label=cta_label(action=action, tier_plan=current_plan, current_plan=current_plan, locale=locale), + blocked_line=None, + compact=_compact(is_current=True, action=action, prices=current_prices), + prices=current_prices, ) ) - tiers.sort(key=lambda tier: (ranks.get(tier.plan_id, len(ranks)), not tier.legacy)) - next_offer = next((tier.tier_id for tier in tiers if tier.action == 'upgrade'), None) + if resubscribe_only: + tiers.sort(key=lambda tier: (not tier.is_current, RANK.get(tier.plan_id, len(RANK)), tier.plan_id)) + else: + tiers.sort(key=lambda tier: (not tier.recommended, RANK.get(tier.plan_id, len(RANK)), tier.plan_id)) + line, note = current_line( + plan_id=current_plan, + plan_name_text=plan_name(current_plan, ladder), + amount_minor=current_amount, + interval=current_interval, + period_end=subscription.current_period_end, + cancel=subscription.cancel_at_period_end, + legacy=legacy, + locale=locale, + ) return StorefrontResponse( catalog_revision=catalog_revision, + header=header( + legacy=legacy, + trigger=trigger, + percent=usage.percent, + mcp_daily_cap=_mcp_cap(ladder, current_plan), + locale=locale, + cancel_scheduled=resubscribe_only, + plan_name_text=plan_name(current_plan, ladder), + period_end=subscription.current_period_end, + ), tiers=tiers, - next_offer_tier_id=next_offer, - current=StorefrontCurrentPlan(plan_id=current_plan, price_id=current_price, legacy=legacy), + current=StorefrontCurrent( + plan_id=current_plan, + name=plan_name(current_plan, ladder), + price_id=current_price_id, + interval=current_interval, + legacy=legacy, + cancel_at_period_end=subscription.cancel_at_period_end, + line=line, + legacy_note=note, + ), usage=usage, + yearly_toggle_label=None if resubscribe_only else yearly_toggle_label(_sheet_months_free(tiers), locale), ) diff --git a/backend/utils/storefront_copy.py b/backend/utils/storefront_copy.py new file mode 100644 index 00000000000..82ab78aa634 --- /dev/null +++ b/backend/utils/storefront_copy.py @@ -0,0 +1,384 @@ +"""English plans-sheet copy and the recording-hours formula. Clients do not hardcode either.""" + +from datetime import datetime, timedelta, timezone +from decimal import Decimal, ROUND_DOWN, ROUND_HALF_UP +from functools import lru_cache +from pathlib import Path +import json +from typing import Any + +import config.plan_catalog_generated as plan_catalog + +# units_per_recorded_hour is provisional. See storefront_copy_v1.json "recording.note": +# evidence/2026-10-12-unit-economics-and-usage.md §1/§3. Replace with shadow-ledger p50 (#21369). +_MONTHS = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec') + + +@lru_cache(maxsize=1) +def load_copy() -> dict[str, Any]: + return json.loads((Path(__file__).resolve().parents[1] / 'config/storefront_copy_v1.json').read_text()) + + +def copy_for(locale: str | None) -> dict[str, Any]: + payload = load_copy() + locales = payload['locales'] + default = payload['default_locale'] + if not locale: + return locales[default] + key = locale.lower() + if key in locales: + return locales[key] + short = key.split('-', 1)[0] + if short in locales: + return locales[short] + return locales[default] + + +def _recording() -> dict[str, Any]: + return load_copy()['recording'] + + +def _share() -> Decimal: + # str() keeps the JSON decimal ("0.85") exact; a binary float would not. + return Decimal(str(_recording()['recording_share'])) + + +def _units_per_hour() -> Decimal: + return Decimal(_recording()['units_per_recorded_hour']) + + +def budget_units_for(plan_id: str) -> int | None: + """Plan-catalog usage budget when declared, otherwise the presentation rate card.""" + for name in ('USAGE_BUDGET_UNITS', 'PLAN_USAGE_BUDGET_UNITS'): + declared = getattr(plan_catalog, name, None) + if isinstance(declared, dict) and plan_id in declared: + return int(declared[plan_id]) + value = load_copy().get('budget_units', {}).get(plan_id) + if type(value) is int: + return value + return None + + +def floor_to_nice(value: Decimal) -> int: + """<30 → nearest 5; <300 → nearest 10 down; else nearest 100 down.""" + if value < 30: + steps = (value / Decimal(5)).quantize(Decimal('1'), rounding=ROUND_HALF_UP) + return int(steps * 5) + if value < 300: + return int(value // 10 * 10) + return int(value // 100 * 100) + + +def recording_hours(plan_id: str) -> int: + """floor_to_nice(budget_units × recording_share / units_per_recorded_hour). Free is fixed.""" + if plan_id == 'basic': + return int(_recording()['free_hours']) + units = budget_units_for(plan_id) + if units is None: + return 0 + raw = Decimal(units) * _share() / _units_per_hour() + return floor_to_nice(raw) + + +def per_day_count(monthly_hours: int) -> int: + return int((Decimal(monthly_hours) / Decimal(30)).quantize(Decimal('1'), rounding=ROUND_HALF_UP)) + + +def per_day_phrase(monthly_hours: int, locale: str | None = None) -> str: + """monthly ÷ 30. <6 → about N hour(s) a day; 6–16 → all day; >16 → more than one device. + + Shipped cards use the per-tier hours_line templates, not this phrase. + """ + phrases = copy_for(locale)['per_day'] + daily = Decimal(monthly_hours) / Decimal(30) + if daily < 6: + count = per_day_count(monthly_hours) + template = phrases['under_6_one'] if count == 1 else phrases['under_6'] + return template.format(n=count) + if daily <= 16: + return phrases['all_day'] + return phrases['multi_device'] + + +def hours_line(plan_id: str, locale: str | None = None) -> str: + templates = copy_for(locale)['hours_lines'] + template = templates.get(plan_id) + if not template: + return '' + hours = recording_hours(plan_id) + return template.format(hours=f'{hours:,}', per_day=per_day_count(hours)) + + +def recording_hours_left(plan_id: str, percent: float) -> int: + """Remaining wall-clock hours, never below zero. + + Paid plans reuse the recording-hours definition on the unused units + (remaining × recording_share / units_per_recorded_hour, then floor_to_nice). + Free has no unit budget; scale its fixed hours by the unused share. + A budget percent at or above 100 clamps to 0. + """ + fraction = (Decimal(100) - Decimal(str(percent))) / Decimal(100) + if fraction <= 0: + return 0 + if plan_id == 'basic': + remaining = Decimal(recording_hours('basic')) * fraction + return int(remaining.quantize(Decimal('1'), rounding=ROUND_HALF_UP)) + units = budget_units_for(plan_id) + if units is None: + return 0 + remaining_units = Decimal(units) * fraction + return floor_to_nice(remaining_units * _share() / _units_per_hour()) + + +def bullets(plan_id: str, trigger: str | None, locale: str | None = None) -> list[str]: + text = copy_for(locale) + items = list(text['bullets'].get(plan_id, [])) + focus = text['bullet_focus'] + if trigger == 'mcp_daily_cap': + marker = focus['mcp_marker'] + match = next((item for item in items if marker in item), None) + if match is not None: + items.remove(match) + items.insert(0, match) + return items + if trigger in {'proactive_agent', 'screen_context'} and plan_id == 'operator': + target = focus[trigger] + if target in items: + items.remove(target) + items.insert(0, target) + return items + + +def header( + *, + legacy: bool, + trigger: str | None, + percent: float | None, + mcp_daily_cap: int | None, + locale: str | None = None, + cancel_scheduled: bool = False, + plan_name_text: str | None = None, + period_end: int | None = None, +) -> str: + headers = copy_for(locale)['headers'] + if legacy: + return headers['legacy'] + if cancel_scheduled and plan_name_text and type(period_end) is int and period_end > 0: + return headers['cancel_scheduled'].format(plan=plan_name_text, date=format_date(period_end)) + if trigger == 'proactive_agent': + return headers['proactive_agent'] + if trigger == 'screen_context': + return headers['screen_context'] + if trigger == 'mcp_daily_cap': + if mcp_daily_cap is None: + return headers['default'] + return headers['mcp_daily_cap'].format(n=f'{mcp_daily_cap:,}') + if trigger == 'usage_budget': + if percent is not None and percent >= 100: + return headers['budget_100'] + return headers['budget_80'] + return headers['default'] + + +def format_date(timestamp: int) -> str: + moment = datetime.fromtimestamp(timestamp, timezone.utc) + return f'{_MONTHS[moment.month - 1]} {moment.day}' + + +def reset_parts(reset_at: int) -> tuple[str, str]: + moment = datetime.fromtimestamp(reset_at, timezone.utc) + reset_label = f'resets {format_date(reset_at)}' + period = moment + if moment.day == 1 and moment.hour == 0 and moment.minute == 0 and moment.second == 0: + period = moment - timedelta(days=1) + return f'{period.year:04d}-{period.month:02d}', reset_label + + +def money(amount_minor: int) -> str: + return format_money(amount_minor, 'usd') + + +def format_money(amount_minor: int, currency: str) -> str: + """Format a minor-unit amount in its price currency. USD uses the dollar sign.""" + amount = (Decimal(amount_minor) / Decimal(100)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + if currency.lower() == 'usd': + return f'${amount:.2f}' + return f'{amount:.2f} {currency.upper()}' + + +def months_free_phrase(count: int, locale: str | None = None) -> str: + prices = copy_for(locale)['prices'] + if count == 1: + return prices['months_free_one'] + return prices['months_free_many'].format(n=count) + + +def months_free(monthly_minor: int, annual_minor: int) -> int | None: + """12 − annual/monthly, only when the live prices give at least one month.""" + if monthly_minor <= 0: + return None + value = Decimal(12) - (Decimal(annual_minor) / Decimal(monthly_minor)) + if value < 1: + return None + return int(value.to_integral_value(rounding=ROUND_DOWN)) + + +def plan_name(plan_id: str, ladder: dict[str, Any]) -> str: + for tier in ladder.get('tiers', []): + if tier.get('plan_id') == plan_id and isinstance(tier.get('display_name'), str): + return tier['display_name'] + override = load_copy().get('plan_names', {}).get(plan_id) + if isinstance(override, str): + return override + return plan_id + + +def current_line( + *, + plan_id: str, + plan_name_text: str, + amount_minor: int | None, + interval: str | None, + period_end: int | None, + cancel: bool, + legacy: bool, + locale: str | None = None, +) -> tuple[str, str | None]: + text = copy_for(locale)['current'] + note = text['legacy_note'] if legacy else None + if plan_id == 'basic': + return text['free'], note + date = format_date(period_end) if type(period_end) is int and period_end > 0 else None + # Paid cancel-scheduled: price only. The end date moves to the header. + # Legacy keeps the previous "ends {date}" line; resubscribe is not offered. + if cancel and not legacy and amount_minor is not None and interval in {'month', 'year'}: + period = 'mo' if interval == 'month' else 'yr' + return text['cancel'].format(plan=plan_name_text, price=money(amount_minor), period=period), note + if cancel and date: + return text['cancel_ends'].format(plan=plan_name_text, date=date), note + if amount_minor is not None and interval in {'month', 'year'}: + period = 'mo' if interval == 'month' else 'yr' + price = money(amount_minor) + if date: + return text['paid'].format(plan=plan_name_text, price=price, period=period, date=date), note + return text['paid_no_date'].format(plan=plan_name_text, price=price, period=period), note + return text['name_only'].format(plan=plan_name_text), note + + +def cta_label(*, action: str, tier_plan: str, current_plan: str, locale: str | None = None) -> str | None: + if action != 'checkout': + return None + labels = copy_for(locale)['cta'] + if tier_plan == current_plan: + return labels['resubscribe'] + if tier_plan == 'plus': + return labels['get_plus'] + if tier_plan == 'operator': + return labels['get_pro'] if current_plan == 'basic' else labels['upgrade_pro'] + if tier_plan == 'architect': + return labels['get_architect'] if current_plan == 'basic' else labels['upgrade_architect'] + return None + + +def render_price( + *, + price_id: str | None, + interval: str, + amount_minor: int, + currency: str, + monthly_minor: int | None, + locale: str | None = None, +) -> dict[str, Any]: + text = copy_for(locale)['prices'] + if interval == 'year': + equivalent = (Decimal(amount_minor) / Decimal(100) / Decimal(12)).quantize( + Decimal('0.01'), rounding=ROUND_HALF_UP + ) + headline = text['headline_year'].format(amount=f'${equivalent:.2f}') + free = months_free(monthly_minor, amount_minor) if monthly_minor else None + annual = money(amount_minor) + if free is not None: + subline = text['subline_year'].format(amount=annual, free=months_free_phrase(free, locale)) + else: + subline = text['subline_year_no_free'].format(amount=annual) + free = None + return { + 'price_id': price_id, + 'interval': 'year', + 'amount_minor': amount_minor, + 'currency': currency, + 'headline': headline, + 'subline': subline, + 'renewal_line': text['renewal_year'], + 'months_free': free, + } + return { + 'price_id': price_id, + 'interval': 'month', + 'amount_minor': amount_minor, + 'currency': currency, + 'headline': text['headline_month'].format(amount=money(amount_minor)), + 'subline': None, + 'renewal_line': text['renewal_month'] if amount_minor else None, + 'months_free': None, + } + + +def yearly_toggle_label(months: int | None, locale: str | None = None) -> str | None: + if months is None or months < 1: + return None + return copy_for(locale)['prices']['toggle'].format(free=months_free_phrase(months, locale)) + + +def blocked_line(locale: str | None = None) -> str: + return copy_for(locale)['blocked_line'] + + +def yearly_switch_label( + *, monthly_minor: int, annual_minor: int, currency: str, locale: str | None = None +) -> str | None: + """12× monthly − annual, in the price currency. Absent when the yearly price does not save.""" + saved = monthly_minor * 12 - annual_minor + if saved <= 0: + return None + return copy_for(locale)['cta']['switch_yearly'].format(saving=format_money(saved, currency)) + + +def _wire_percent(percent: float) -> int: + return int(Decimal(str(percent)).quantize(Decimal('1'), rounding=ROUND_HALF_UP)) + + +def present_usage( + *, + plan_id: str, + kind: str, + percent: float | None, + reset_at: int | None, + locale: str | None = None, +) -> dict[str, Any]: + text = copy_for(locale) + payload: dict[str, Any] = { + 'kind': kind, + 'percent': None, + 'period_id': None, + 'reset_label': None, + 'meter_line': None, + 'nudge_line': None, + 'pro_gates': list(text['pro_gates']), + } + if kind == 'unavailable' or percent is None: + return payload + shown = _wire_percent(percent) + payload['percent'] = shown + if type(reset_at) is int and reset_at > 0: + period_id, reset_label = reset_parts(reset_at) + payload['period_id'] = period_id + payload['reset_label'] = reset_label + if kind == 'usage_budget' and payload['reset_label']: + left = recording_hours_left(plan_id, shown) + payload['meter_line'] = text['meter']['line'].format( + percent=shown, hours=f'{left:,}', reset=payload['reset_label'] + ) + if 80 <= shown < 100: + payload['nudge_line'] = text['meter']['nudge_80'].format(reset=payload['reset_label']) + return payload diff --git a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift index b5b9667db8a..b5bbd4e3568 100644 --- a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift +++ b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift @@ -722,32 +722,6 @@ public enum OmiAPI { } - public struct AnnualSavings: Codable { - public let amountMinor: Int - public let formatted: String - public let percent: Double - - private enum CodingKeys: String, CodingKey { - case amountMinor = "amount_minor" - case formatted - case percent - } - - public init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - amountMinor = try c.decode(Int.self, forKey: .amountMinor) - formatted = try c.decode(String.self, forKey: .formatted) - percent = try c.decode(Double.self, forKey: .percent) - } - - public init(amountMinor: Int, formatted: String, percent: Double) { - self.amountMinor = amountMinor - self.formatted = formatted - self.percent = percent - } - } - - public struct AppResult: Codable { public let appId: String? public let content: String @@ -4414,26 +4388,6 @@ public enum OmiAPI { } - public struct PlanFeature: Codable { - public let included: Bool? - public let key: String - public let label: String - - public init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - included = try c.decodeIfPresent(Bool.self, forKey: .included) - key = try c.decode(String.self, forKey: .key) - label = try c.decode(String.self, forKey: .label) - } - - public init(included: Bool? = nil, key: String, label: String) { - self.included = included - self.key = key - self.label = label - } - } - - public struct PluginResult: Codable { public let content: String public let pluginId: String? @@ -5093,26 +5047,46 @@ public enum OmiAPI { } - public struct StorefrontCurrentPlan: Codable { + public struct StorefrontCurrent: Codable { + public let cancelAtPeriodEnd: Bool? + public let interval: String? public let legacy: Bool + public let legacyNote: String? + public let line: String + public let name: String public let planId: String public let priceId: String? private enum CodingKeys: String, CodingKey { + case cancelAtPeriodEnd = "cancel_at_period_end" + case interval case legacy + case legacyNote = "legacy_note" + case line + case name case planId = "plan_id" case priceId = "price_id" } public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) + cancelAtPeriodEnd = try c.decodeIfPresent(Bool.self, forKey: .cancelAtPeriodEnd) + interval = try c.decodeIfPresent(String.self, forKey: .interval) legacy = try c.decode(Bool.self, forKey: .legacy) + legacyNote = try c.decodeIfPresent(String.self, forKey: .legacyNote) + line = try c.decode(String.self, forKey: .line) + name = try c.decode(String.self, forKey: .name) planId = try c.decode(String.self, forKey: .planId) priceId = try c.decodeIfPresent(String.self, forKey: .priceId) } - public init(legacy: Bool, planId: String, priceId: String? = nil) { + public init(cancelAtPeriodEnd: Bool? = nil, interval: String? = nil, legacy: Bool, legacyNote: String? = nil, line: String, name: String, planId: String, priceId: String? = nil) { + self.cancelAtPeriodEnd = cancelAtPeriodEnd + self.interval = interval self.legacy = legacy + self.legacyNote = legacyNote + self.line = line + self.name = name self.planId = planId self.priceId = priceId } @@ -5121,164 +5095,200 @@ public enum OmiAPI { public struct StorefrontPrice: Codable { public let amountMinor: Int - public let annualSavings: AnnualSavings? + public let ctaLabel: String? public let currency: String - public let formattedAmount: String + public let headline: String public let interval: String - public let monthlyEquivalent: String + public let monthsFree: Int? public let priceId: String? + public let renewalLine: String? + public let subline: String? private enum CodingKeys: String, CodingKey { case amountMinor = "amount_minor" - case annualSavings = "annual_savings" + case ctaLabel = "cta_label" case currency - case formattedAmount = "formatted_amount" + case headline case interval - case monthlyEquivalent = "monthly_equivalent" + case monthsFree = "months_free" case priceId = "price_id" + case renewalLine = "renewal_line" + case subline } public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) amountMinor = try c.decode(Int.self, forKey: .amountMinor) - annualSavings = try c.decodeIfPresent(AnnualSavings.self, forKey: .annualSavings) + ctaLabel = try c.decodeIfPresent(String.self, forKey: .ctaLabel) currency = try c.decode(String.self, forKey: .currency) - formattedAmount = try c.decode(String.self, forKey: .formattedAmount) + headline = try c.decode(String.self, forKey: .headline) interval = try c.decode(String.self, forKey: .interval) - monthlyEquivalent = try c.decode(String.self, forKey: .monthlyEquivalent) + monthsFree = try c.decodeIfPresent(Int.self, forKey: .monthsFree) priceId = try c.decodeIfPresent(String.self, forKey: .priceId) + renewalLine = try c.decodeIfPresent(String.self, forKey: .renewalLine) + subline = try c.decodeIfPresent(String.self, forKey: .subline) } - public init(amountMinor: Int, annualSavings: AnnualSavings? = nil, currency: String, formattedAmount: String, interval: String, monthlyEquivalent: String, priceId: String? = nil) { + public init(amountMinor: Int, ctaLabel: String? = nil, currency: String, headline: String, interval: String, monthsFree: Int? = nil, priceId: String? = nil, renewalLine: String? = nil, subline: String? = nil) { self.amountMinor = amountMinor - self.annualSavings = annualSavings + self.ctaLabel = ctaLabel self.currency = currency - self.formattedAmount = formattedAmount + self.headline = headline self.interval = interval - self.monthlyEquivalent = monthlyEquivalent + self.monthsFree = monthsFree self.priceId = priceId + self.renewalLine = renewalLine + self.subline = subline } } public struct StorefrontResponse: Codable { public let catalogRevision: Int - public let current: StorefrontCurrentPlan? - public let nextOfferTierId: String? + public let current: StorefrontCurrent? + public let header: String? public let schemaVersion: Int? public let showSubscriptionUi: Bool? public let tiers: [StorefrontTier]? public let usage: StorefrontUsage? + public let yearlyToggleLabel: String? private enum CodingKeys: String, CodingKey { case catalogRevision = "catalog_revision" case current - case nextOfferTierId = "next_offer_tier_id" + case header case schemaVersion = "schema_version" case showSubscriptionUi = "show_subscription_ui" case tiers case usage + case yearlyToggleLabel = "yearly_toggle_label" } public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) catalogRevision = try c.decode(Int.self, forKey: .catalogRevision) - current = try c.decodeIfPresent(StorefrontCurrentPlan.self, forKey: .current) - nextOfferTierId = try c.decodeIfPresent(String.self, forKey: .nextOfferTierId) + current = try c.decodeIfPresent(StorefrontCurrent.self, forKey: .current) + header = try c.decodeIfPresent(String.self, forKey: .header) schemaVersion = try c.decodeIfPresent(Int.self, forKey: .schemaVersion) showSubscriptionUi = try c.decodeIfPresent(Bool.self, forKey: .showSubscriptionUi) tiers = try c.decodeIfPresent([StorefrontTier].self, forKey: .tiers) usage = try c.decodeIfPresent(StorefrontUsage.self, forKey: .usage) + yearlyToggleLabel = try c.decodeIfPresent(String.self, forKey: .yearlyToggleLabel) } - public init(catalogRevision: Int, current: StorefrontCurrentPlan? = nil, nextOfferTierId: String? = nil, schemaVersion: Int? = nil, showSubscriptionUi: Bool? = nil, tiers: [StorefrontTier]? = nil, usage: StorefrontUsage? = nil) { + public init(catalogRevision: Int, current: StorefrontCurrent? = nil, header: String? = nil, schemaVersion: Int? = nil, showSubscriptionUi: Bool? = nil, tiers: [StorefrontTier]? = nil, usage: StorefrontUsage? = nil, yearlyToggleLabel: String? = nil) { self.catalogRevision = catalogRevision self.current = current - self.nextOfferTierId = nextOfferTierId + self.header = header self.schemaVersion = schemaVersion self.showSubscriptionUi = showSubscriptionUi self.tiers = tiers self.usage = usage + self.yearlyToggleLabel = yearlyToggleLabel } } public struct StorefrontTier: Codable { public let action: String - public let displayName: String - public let entitlementVersion: String - public let features: [PlanFeature] - public let legacy: Bool? + public let blockedLine: String? + public let bullets: [String] + public let compact: Bool? + public let ctaLabel: String? + public let hoursLine: String + public let isCurrent: Bool? + public let name: String public let planId: String public let prices: [StorefrontPrice] public let recommended: Bool? - public let tagline: String public let tierId: String private enum CodingKeys: String, CodingKey { case action - case displayName = "display_name" - case entitlementVersion = "entitlement_version" - case features - case legacy + case blockedLine = "blocked_line" + case bullets + case compact + case ctaLabel = "cta_label" + case hoursLine = "hours_line" + case isCurrent = "is_current" + case name case planId = "plan_id" case prices case recommended - case tagline case tierId = "tier_id" } public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) action = try c.decode(String.self, forKey: .action) - displayName = try c.decode(String.self, forKey: .displayName) - entitlementVersion = try c.decode(String.self, forKey: .entitlementVersion) - features = try c.decode([PlanFeature].self, forKey: .features) - legacy = try c.decodeIfPresent(Bool.self, forKey: .legacy) + blockedLine = try c.decodeIfPresent(String.self, forKey: .blockedLine) + bullets = try c.decode([String].self, forKey: .bullets) + compact = try c.decodeIfPresent(Bool.self, forKey: .compact) + ctaLabel = try c.decodeIfPresent(String.self, forKey: .ctaLabel) + hoursLine = try c.decode(String.self, forKey: .hoursLine) + isCurrent = try c.decodeIfPresent(Bool.self, forKey: .isCurrent) + name = try c.decode(String.self, forKey: .name) planId = try c.decode(String.self, forKey: .planId) prices = try c.decode([StorefrontPrice].self, forKey: .prices) recommended = try c.decodeIfPresent(Bool.self, forKey: .recommended) - tagline = try c.decode(String.self, forKey: .tagline) tierId = try c.decode(String.self, forKey: .tierId) } - public init(action: String, displayName: String, entitlementVersion: String, features: [PlanFeature], legacy: Bool? = nil, planId: String, prices: [StorefrontPrice], recommended: Bool? = nil, tagline: String, tierId: String) { + public init(action: String, blockedLine: String? = nil, bullets: [String], compact: Bool? = nil, ctaLabel: String? = nil, hoursLine: String, isCurrent: Bool? = nil, name: String, planId: String, prices: [StorefrontPrice], recommended: Bool? = nil, tierId: String) { self.action = action - self.displayName = displayName - self.entitlementVersion = entitlementVersion - self.features = features - self.legacy = legacy + self.blockedLine = blockedLine + self.bullets = bullets + self.compact = compact + self.ctaLabel = ctaLabel + self.hoursLine = hoursLine + self.isCurrent = isCurrent + self.name = name self.planId = planId self.prices = prices self.recommended = recommended - self.tagline = tagline self.tierId = tierId } } public struct StorefrontUsage: Codable { - public let budgetPercent: Double? public let kind: String? - public let resetAt: Int? + public let meterLine: String? + public let nudgeLine: String? + public let percent: Int? + public let periodId: String? + public let proGates: [String]? + public let resetLabel: String? private enum CodingKeys: String, CodingKey { - case budgetPercent = "budget_percent" case kind - case resetAt = "reset_at" + case meterLine = "meter_line" + case nudgeLine = "nudge_line" + case percent + case periodId = "period_id" + case proGates = "pro_gates" + case resetLabel = "reset_label" } public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) - budgetPercent = try c.decodeIfPresent(Double.self, forKey: .budgetPercent) kind = try c.decodeIfPresent(String.self, forKey: .kind) - resetAt = try c.decodeIfPresent(Int.self, forKey: .resetAt) + meterLine = try c.decodeIfPresent(String.self, forKey: .meterLine) + nudgeLine = try c.decodeIfPresent(String.self, forKey: .nudgeLine) + percent = try c.decodeIfPresent(Int.self, forKey: .percent) + periodId = try c.decodeIfPresent(String.self, forKey: .periodId) + proGates = try c.decodeIfPresent([String].self, forKey: .proGates) + resetLabel = try c.decodeIfPresent(String.self, forKey: .resetLabel) } - public init(budgetPercent: Double? = nil, kind: String? = nil, resetAt: Int? = nil) { - self.budgetPercent = budgetPercent + public init(kind: String? = nil, meterLine: String? = nil, nudgeLine: String? = nil, percent: Int? = nil, periodId: String? = nil, proGates: [String]? = nil, resetLabel: String? = nil) { self.kind = kind - self.resetAt = resetAt + self.meterLine = meterLine + self.nudgeLine = nudgeLine + self.percent = percent + self.periodId = periodId + self.proGates = proGates + self.resetLabel = resetLabel } } @@ -18838,7 +18848,7 @@ public enum OmiAPI { return try JSONDecoder().decode(OmiAnyCodable.self, from: data) } - public static func getStorefrontV2PaymentsPlansGet(client: OmiApiClient, source: String? = nil, trigger: String? = nil, xAppPlatform: String? = nil, xAppVersion: String? = nil, authorization: String? = nil, xDeviceIdHash: String? = nil) async throws -> StorefrontResponse { + public static func getStorefrontV2PaymentsPlansGet(client: OmiApiClient, source: String? = nil, trigger: String? = nil, locale: String? = nil, xAppPlatform: String? = nil, xAppVersion: String? = nil, authorization: String? = nil, xDeviceIdHash: String? = nil) async throws -> StorefrontResponse { let _path = "/v2/payments/plans" guard var components = URLComponents(string: client.baseURL + _path) else { throw OmiApiError.invalidURL @@ -18850,6 +18860,9 @@ public enum OmiAPI { if let trigger { queryItems.append(URLQueryItem(name: "trigger", value: String(trigger))) } + if let locale { + queryItems.append(URLQueryItem(name: "locale", value: String(locale))) + } if !queryItems.isEmpty { components.queryItems = queryItems } guard let url = components.url else { throw OmiApiError.invalidURL } var req = URLRequest(url: url) diff --git a/desktop/windows/src/renderer/src/lib/omiApi.generated.ts b/desktop/windows/src/renderer/src/lib/omiApi.generated.ts index 533ecd8aea9..f7a241086a2 100644 --- a/desktop/windows/src/renderer/src/lib/omiApi.generated.ts +++ b/desktop/windows/src/renderer/src/lib/omiApi.generated.ts @@ -232,12 +232,6 @@ export interface AnnouncementDeleteResponse { export type AnnouncementType = "changelog" | "feature" | "announcement"; -export interface AnnualSavings { - amount_minor: number; - formatted: string; - percent: number; -} - export interface App { approved?: boolean; author: string; @@ -3829,12 +3823,6 @@ export interface PhoneNumbersResponse { numbers: Array; } -export interface PlanFeature { - included?: boolean; - key: string; - label: string; -} - export interface PlanIdentitySubscription { cancel_at_period_end?: boolean; current_period_end?: number | null; @@ -4949,49 +4937,63 @@ export interface StoreRecordingPermissionResponse { store_recording_permission: boolean; } -export interface StorefrontCurrentPlan { +export interface StorefrontCurrent { + cancel_at_period_end?: boolean; + interval?: "month" | "year" | null; legacy: boolean; + legacy_note?: string | null; + line: string; + name: string; plan_id: string; price_id?: string | null; } export interface StorefrontPrice { amount_minor: number; - annual_savings?: AnnualSavings | null; + cta_label?: string | null; currency: string; - formatted_amount: string; + headline: string; interval: "month" | "year"; - monthly_equivalent: string; + months_free?: number | null; price_id?: string | null; + renewal_line?: string | null; + subline?: string | null; } export interface StorefrontResponse { catalog_revision: number; - current?: StorefrontCurrentPlan | null; - next_offer_tier_id?: string | null; - schema_version?: 1; + current?: StorefrontCurrent | null; + header?: string | null; + schema_version?: 2; show_subscription_ui?: boolean; tiers?: Array; usage?: StorefrontUsage; + yearly_toggle_label?: string | null; } export interface StorefrontTier { - action: "current" | "upgrade" | "downgrade_not_offered" | "contact"; - display_name: string; - entitlement_version: string; - features: Array; - legacy?: boolean; + action: "checkout" | "current" | "none"; + blocked_line?: string | null; + bullets: Array; + compact?: boolean; + cta_label?: string | null; + hours_line: string; + is_current?: boolean; + name: string; plan_id: string; prices: Array; recommended?: boolean; - tagline: string; tier_id: string; } export interface StorefrontUsage { - budget_percent?: number | null; - kind?: "transcription" | "usage_budget" | "unavailable"; - reset_at?: number | null; + kind?: "usage_budget" | "transcription" | "unavailable"; + meter_line?: string | null; + nudge_line?: string | null; + percent?: number | null; + period_id?: string | null; + pro_gates?: Array; + reset_label?: string | null; } export interface StripeConnectAccountResponse { @@ -6013,7 +6015,6 @@ export interface OmiApiSchemas { "Announcement": Announcement; "AnnouncementDeleteResponse": AnnouncementDeleteResponse; "AnnouncementType": AnnouncementType; - "AnnualSavings": AnnualSavings; "App": App; "AppApiKeyResponse": AppApiKeyResponse; "AppBaseModel": AppBaseModel; @@ -6490,7 +6491,6 @@ export interface OmiApiSchemas { "PhoneMutationResponse": PhoneMutationResponse; "PhoneNumberResponse": PhoneNumberResponse; "PhoneNumbersResponse": PhoneNumbersResponse; - "PlanFeature": PlanFeature; "PlanIdentitySubscription": PlanIdentitySubscription; "PlanIdentityUserSubscriptionResponse": PlanIdentityUserSubscriptionResponse; "PlanLimits": PlanLimits; @@ -6646,7 +6646,7 @@ export interface OmiApiSchemas { "StoreMeetingRequest": StoreMeetingRequest; "StoreMeetingResponse": StoreMeetingResponse; "StoreRecordingPermissionResponse": StoreRecordingPermissionResponse; - "StorefrontCurrentPlan": StorefrontCurrentPlan; + "StorefrontCurrent": StorefrontCurrent; "StorefrontPrice": StorefrontPrice; "StorefrontResponse": StorefrontResponse; "StorefrontTier": StorefrontTier; @@ -21301,7 +21301,7 @@ export async function report_message_v2_messages__message_id__report_post(path: return _res.status === 204 ? (undefined as any) : await _res.json(); } -export async function get_storefront_v2_payments_plans_get(query: { source?: string | null, trigger?: "proactive_agent" | "screen_context" | "usage_budget" | "mcp_daily_cap" | null }, header: { X_App_Platform?: string, X_App_Version?: string, authorization?: string, X_Device_Id_Hash?: string }, init?: OmiApiClientInit): Promise { +export async function get_storefront_v2_payments_plans_get(query: { source?: string | null, trigger?: "proactive_agent" | "screen_context" | "usage_budget" | "mcp_daily_cap" | null, locale?: string | null }, header: { X_App_Platform?: string, X_App_Version?: string, authorization?: string, X_Device_Id_Hash?: string }, init?: OmiApiClientInit): Promise { const _base = init?.baseURL ?? ""; const _path = `/v2/payments/plans`; const _params = query ? Object.entries(query) diff --git a/docs/api-reference/app-client-openapi.json b/docs/api-reference/app-client-openapi.json index 9f7f009ca3b..c073fef3bde 100644 --- a/docs/api-reference/app-client-openapi.json +++ b/docs/api-reference/app-client-openapi.json @@ -1674,29 +1674,6 @@ "title": "AnnouncementType", "type": "string" }, - "AnnualSavings": { - "properties": { - "amount_minor": { - "title": "Amount Minor", - "type": "integer" - }, - "formatted": { - "title": "Formatted", - "type": "string" - }, - "percent": { - "title": "Percent", - "type": "number" - } - }, - "required": [ - "amount_minor", - "percent", - "formatted" - ], - "title": "AnnualSavings", - "type": "object" - }, "App": { "description": "Full App model - includes large/internal fields for detail views.", "properties": { @@ -23723,29 +23700,6 @@ "title": "PhoneNumbersResponse", "type": "object" }, - "PlanFeature": { - "properties": { - "included": { - "default": true, - "title": "Included", - "type": "boolean" - }, - "key": { - "title": "Key", - "type": "string" - }, - "label": { - "title": "Label", - "type": "string" - } - }, - "required": [ - "key", - "label" - ], - "title": "PlanFeature", - "type": "object" - }, "PlanIdentitySubscription": { "properties": { "cancel_at_period_end": { @@ -30042,12 +29996,51 @@ "title": "StoreRecordingPermissionResponse", "type": "object" }, - "StorefrontCurrentPlan": { + "StorefrontCurrent": { "properties": { + "cancel_at_period_end": { + "default": false, + "title": "Cancel At Period End", + "type": "boolean" + }, + "interval": { + "anyOf": [ + { + "enum": [ + "month", + "year" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Interval" + }, "legacy": { "title": "Legacy", "type": "boolean" }, + "legacy_note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Legacy Note" + }, + "line": { + "title": "Line", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, "plan_id": { "title": "Plan Id", "type": "string" @@ -30066,9 +30059,11 @@ }, "required": [ "plan_id", - "legacy" + "name", + "legacy", + "line" ], - "title": "StorefrontCurrentPlan", + "title": "StorefrontCurrent", "type": "object" }, "StorefrontPrice": { @@ -30077,22 +30072,23 @@ "title": "Amount Minor", "type": "integer" }, - "annual_savings": { + "cta_label": { "anyOf": [ { - "$ref": "#/components/schemas/AnnualSavings" + "type": "string" }, { "type": "null" } - ] + ], + "title": "Cta Label" }, "currency": { "title": "Currency", "type": "string" }, - "formatted_amount": { - "title": "Formatted Amount", + "headline": { + "title": "Headline", "type": "string" }, "interval": { @@ -30103,9 +30099,16 @@ "title": "Interval", "type": "string" }, - "monthly_equivalent": { - "title": "Monthly Equivalent", - "type": "string" + "months_free": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Months Free" }, "price_id": { "anyOf": [ @@ -30117,14 +30120,35 @@ } ], "title": "Price Id" + }, + "renewal_line": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Renewal Line" + }, + "subline": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Subline" } }, "required": [ "interval", - "currency", "amount_minor", - "formatted_amount", - "monthly_equivalent" + "currency", + "headline" ], "title": "StorefrontPrice", "type": "object" @@ -30138,14 +30162,14 @@ "current": { "anyOf": [ { - "$ref": "#/components/schemas/StorefrontCurrentPlan" + "$ref": "#/components/schemas/StorefrontCurrent" }, { "type": "null" } ] }, - "next_offer_tier_id": { + "header": { "anyOf": [ { "type": "string" @@ -30154,11 +30178,11 @@ "type": "null" } ], - "title": "Next Offer Tier Id" + "title": "Header" }, "schema_version": { - "const": 1, - "default": 1, + "const": 2, + "default": 2, "title": "Schema Version", "type": "integer" }, @@ -30176,6 +30200,17 @@ }, "usage": { "$ref": "#/components/schemas/StorefrontUsage" + }, + "yearly_toggle_label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Yearly Toggle Label" } }, "required": [ @@ -30188,34 +30223,60 @@ "properties": { "action": { "enum": [ + "checkout", "current", - "upgrade", - "downgrade_not_offered", - "contact" + "none" ], "title": "Action", "type": "string" }, - "display_name": { - "title": "Display Name", - "type": "string" - }, - "entitlement_version": { - "title": "Entitlement Version", - "type": "string" + "blocked_line": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Blocked Line" }, - "features": { + "bullets": { "items": { - "$ref": "#/components/schemas/PlanFeature" + "type": "string" }, - "title": "Features", + "title": "Bullets", "type": "array" }, - "legacy": { + "compact": { "default": false, - "title": "Legacy", + "title": "Compact", + "type": "boolean" + }, + "cta_label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cta Label" + }, + "hours_line": { + "title": "Hours Line", + "type": "string" + }, + "is_current": { + "default": false, + "title": "Is Current", "type": "boolean" }, + "name": { + "title": "Name", + "type": "string" + }, "plan_id": { "title": "Plan Id", "type": "string" @@ -30232,10 +30293,6 @@ "title": "Recommended", "type": "boolean" }, - "tagline": { - "title": "Tagline", - "type": "string" - }, "tier_id": { "title": "Tier Id", "type": "string" @@ -30244,40 +30301,50 @@ "required": [ "tier_id", "plan_id", - "entitlement_version", - "display_name", - "tagline", - "features", - "prices", - "action" + "name", + "hours_line", + "bullets", + "action", + "prices" ], "title": "StorefrontTier", "type": "object" }, "StorefrontUsage": { "properties": { - "budget_percent": { + "kind": { + "default": "unavailable", + "enum": [ + "usage_budget", + "transcription", + "unavailable" + ], + "title": "Kind", + "type": "string" + }, + "meter_line": { "anyOf": [ { - "type": "number" + "type": "string" }, { "type": "null" } ], - "title": "Budget Percent" + "title": "Meter Line" }, - "kind": { - "default": "unavailable", - "enum": [ - "transcription", - "usage_budget", - "unavailable" + "nudge_line": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } ], - "title": "Kind", - "type": "string" + "title": "Nudge Line" }, - "reset_at": { + "percent": { "anyOf": [ { "type": "integer" @@ -30286,7 +30353,36 @@ "type": "null" } ], - "title": "Reset At" + "title": "Percent" + }, + "period_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Period Id" + }, + "pro_gates": { + "items": { + "type": "string" + }, + "title": "Pro Gates", + "type": "array" + }, + "reset_label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reset Label" } }, "title": "StorefrontUsage", @@ -78799,6 +78895,24 @@ "title": "Trigger" } }, + { + "description": "BCP 47 tag. Unknown locales fall back to en.", + "in": "query", + "name": "locale", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "BCP 47 tag. Unknown locales fall back to en.", + "title": "Locale" + } + }, { "in": "header", "name": "X-App-Platform", diff --git a/web/admin/lib/services/omi-api/omiApi.generated.ts b/web/admin/lib/services/omi-api/omiApi.generated.ts index 533ecd8aea9..f7a241086a2 100644 --- a/web/admin/lib/services/omi-api/omiApi.generated.ts +++ b/web/admin/lib/services/omi-api/omiApi.generated.ts @@ -232,12 +232,6 @@ export interface AnnouncementDeleteResponse { export type AnnouncementType = "changelog" | "feature" | "announcement"; -export interface AnnualSavings { - amount_minor: number; - formatted: string; - percent: number; -} - export interface App { approved?: boolean; author: string; @@ -3829,12 +3823,6 @@ export interface PhoneNumbersResponse { numbers: Array; } -export interface PlanFeature { - included?: boolean; - key: string; - label: string; -} - export interface PlanIdentitySubscription { cancel_at_period_end?: boolean; current_period_end?: number | null; @@ -4949,49 +4937,63 @@ export interface StoreRecordingPermissionResponse { store_recording_permission: boolean; } -export interface StorefrontCurrentPlan { +export interface StorefrontCurrent { + cancel_at_period_end?: boolean; + interval?: "month" | "year" | null; legacy: boolean; + legacy_note?: string | null; + line: string; + name: string; plan_id: string; price_id?: string | null; } export interface StorefrontPrice { amount_minor: number; - annual_savings?: AnnualSavings | null; + cta_label?: string | null; currency: string; - formatted_amount: string; + headline: string; interval: "month" | "year"; - monthly_equivalent: string; + months_free?: number | null; price_id?: string | null; + renewal_line?: string | null; + subline?: string | null; } export interface StorefrontResponse { catalog_revision: number; - current?: StorefrontCurrentPlan | null; - next_offer_tier_id?: string | null; - schema_version?: 1; + current?: StorefrontCurrent | null; + header?: string | null; + schema_version?: 2; show_subscription_ui?: boolean; tiers?: Array; usage?: StorefrontUsage; + yearly_toggle_label?: string | null; } export interface StorefrontTier { - action: "current" | "upgrade" | "downgrade_not_offered" | "contact"; - display_name: string; - entitlement_version: string; - features: Array; - legacy?: boolean; + action: "checkout" | "current" | "none"; + blocked_line?: string | null; + bullets: Array; + compact?: boolean; + cta_label?: string | null; + hours_line: string; + is_current?: boolean; + name: string; plan_id: string; prices: Array; recommended?: boolean; - tagline: string; tier_id: string; } export interface StorefrontUsage { - budget_percent?: number | null; - kind?: "transcription" | "usage_budget" | "unavailable"; - reset_at?: number | null; + kind?: "usage_budget" | "transcription" | "unavailable"; + meter_line?: string | null; + nudge_line?: string | null; + percent?: number | null; + period_id?: string | null; + pro_gates?: Array; + reset_label?: string | null; } export interface StripeConnectAccountResponse { @@ -6013,7 +6015,6 @@ export interface OmiApiSchemas { "Announcement": Announcement; "AnnouncementDeleteResponse": AnnouncementDeleteResponse; "AnnouncementType": AnnouncementType; - "AnnualSavings": AnnualSavings; "App": App; "AppApiKeyResponse": AppApiKeyResponse; "AppBaseModel": AppBaseModel; @@ -6490,7 +6491,6 @@ export interface OmiApiSchemas { "PhoneMutationResponse": PhoneMutationResponse; "PhoneNumberResponse": PhoneNumberResponse; "PhoneNumbersResponse": PhoneNumbersResponse; - "PlanFeature": PlanFeature; "PlanIdentitySubscription": PlanIdentitySubscription; "PlanIdentityUserSubscriptionResponse": PlanIdentityUserSubscriptionResponse; "PlanLimits": PlanLimits; @@ -6646,7 +6646,7 @@ export interface OmiApiSchemas { "StoreMeetingRequest": StoreMeetingRequest; "StoreMeetingResponse": StoreMeetingResponse; "StoreRecordingPermissionResponse": StoreRecordingPermissionResponse; - "StorefrontCurrentPlan": StorefrontCurrentPlan; + "StorefrontCurrent": StorefrontCurrent; "StorefrontPrice": StorefrontPrice; "StorefrontResponse": StorefrontResponse; "StorefrontTier": StorefrontTier; @@ -21301,7 +21301,7 @@ export async function report_message_v2_messages__message_id__report_post(path: return _res.status === 204 ? (undefined as any) : await _res.json(); } -export async function get_storefront_v2_payments_plans_get(query: { source?: string | null, trigger?: "proactive_agent" | "screen_context" | "usage_budget" | "mcp_daily_cap" | null }, header: { X_App_Platform?: string, X_App_Version?: string, authorization?: string, X_Device_Id_Hash?: string }, init?: OmiApiClientInit): Promise { +export async function get_storefront_v2_payments_plans_get(query: { source?: string | null, trigger?: "proactive_agent" | "screen_context" | "usage_budget" | "mcp_daily_cap" | null, locale?: string | null }, header: { X_App_Platform?: string, X_App_Version?: string, authorization?: string, X_Device_Id_Hash?: string }, init?: OmiApiClientInit): Promise { const _base = init?.baseURL ?? ""; const _path = `/v2/payments/plans`; const _params = query ? Object.entries(query) diff --git a/web/app/src/lib/omiApi.generated.ts b/web/app/src/lib/omiApi.generated.ts index 533ecd8aea9..f7a241086a2 100644 --- a/web/app/src/lib/omiApi.generated.ts +++ b/web/app/src/lib/omiApi.generated.ts @@ -232,12 +232,6 @@ export interface AnnouncementDeleteResponse { export type AnnouncementType = "changelog" | "feature" | "announcement"; -export interface AnnualSavings { - amount_minor: number; - formatted: string; - percent: number; -} - export interface App { approved?: boolean; author: string; @@ -3829,12 +3823,6 @@ export interface PhoneNumbersResponse { numbers: Array; } -export interface PlanFeature { - included?: boolean; - key: string; - label: string; -} - export interface PlanIdentitySubscription { cancel_at_period_end?: boolean; current_period_end?: number | null; @@ -4949,49 +4937,63 @@ export interface StoreRecordingPermissionResponse { store_recording_permission: boolean; } -export interface StorefrontCurrentPlan { +export interface StorefrontCurrent { + cancel_at_period_end?: boolean; + interval?: "month" | "year" | null; legacy: boolean; + legacy_note?: string | null; + line: string; + name: string; plan_id: string; price_id?: string | null; } export interface StorefrontPrice { amount_minor: number; - annual_savings?: AnnualSavings | null; + cta_label?: string | null; currency: string; - formatted_amount: string; + headline: string; interval: "month" | "year"; - monthly_equivalent: string; + months_free?: number | null; price_id?: string | null; + renewal_line?: string | null; + subline?: string | null; } export interface StorefrontResponse { catalog_revision: number; - current?: StorefrontCurrentPlan | null; - next_offer_tier_id?: string | null; - schema_version?: 1; + current?: StorefrontCurrent | null; + header?: string | null; + schema_version?: 2; show_subscription_ui?: boolean; tiers?: Array; usage?: StorefrontUsage; + yearly_toggle_label?: string | null; } export interface StorefrontTier { - action: "current" | "upgrade" | "downgrade_not_offered" | "contact"; - display_name: string; - entitlement_version: string; - features: Array; - legacy?: boolean; + action: "checkout" | "current" | "none"; + blocked_line?: string | null; + bullets: Array; + compact?: boolean; + cta_label?: string | null; + hours_line: string; + is_current?: boolean; + name: string; plan_id: string; prices: Array; recommended?: boolean; - tagline: string; tier_id: string; } export interface StorefrontUsage { - budget_percent?: number | null; - kind?: "transcription" | "usage_budget" | "unavailable"; - reset_at?: number | null; + kind?: "usage_budget" | "transcription" | "unavailable"; + meter_line?: string | null; + nudge_line?: string | null; + percent?: number | null; + period_id?: string | null; + pro_gates?: Array; + reset_label?: string | null; } export interface StripeConnectAccountResponse { @@ -6013,7 +6015,6 @@ export interface OmiApiSchemas { "Announcement": Announcement; "AnnouncementDeleteResponse": AnnouncementDeleteResponse; "AnnouncementType": AnnouncementType; - "AnnualSavings": AnnualSavings; "App": App; "AppApiKeyResponse": AppApiKeyResponse; "AppBaseModel": AppBaseModel; @@ -6490,7 +6491,6 @@ export interface OmiApiSchemas { "PhoneMutationResponse": PhoneMutationResponse; "PhoneNumberResponse": PhoneNumberResponse; "PhoneNumbersResponse": PhoneNumbersResponse; - "PlanFeature": PlanFeature; "PlanIdentitySubscription": PlanIdentitySubscription; "PlanIdentityUserSubscriptionResponse": PlanIdentityUserSubscriptionResponse; "PlanLimits": PlanLimits; @@ -6646,7 +6646,7 @@ export interface OmiApiSchemas { "StoreMeetingRequest": StoreMeetingRequest; "StoreMeetingResponse": StoreMeetingResponse; "StoreRecordingPermissionResponse": StoreRecordingPermissionResponse; - "StorefrontCurrentPlan": StorefrontCurrentPlan; + "StorefrontCurrent": StorefrontCurrent; "StorefrontPrice": StorefrontPrice; "StorefrontResponse": StorefrontResponse; "StorefrontTier": StorefrontTier; @@ -21301,7 +21301,7 @@ export async function report_message_v2_messages__message_id__report_post(path: return _res.status === 204 ? (undefined as any) : await _res.json(); } -export async function get_storefront_v2_payments_plans_get(query: { source?: string | null, trigger?: "proactive_agent" | "screen_context" | "usage_budget" | "mcp_daily_cap" | null }, header: { X_App_Platform?: string, X_App_Version?: string, authorization?: string, X_Device_Id_Hash?: string }, init?: OmiApiClientInit): Promise { +export async function get_storefront_v2_payments_plans_get(query: { source?: string | null, trigger?: "proactive_agent" | "screen_context" | "usage_budget" | "mcp_daily_cap" | null, locale?: string | null }, header: { X_App_Platform?: string, X_App_Version?: string, authorization?: string, X_Device_Id_Hash?: string }, init?: OmiApiClientInit): Promise { const _base = init?.baseURL ?? ""; const _path = `/v2/payments/plans`; const _params = query ? Object.entries(query) diff --git a/web/personas-open-source/src/lib/omiApi.generated.ts b/web/personas-open-source/src/lib/omiApi.generated.ts index 533ecd8aea9..f7a241086a2 100644 --- a/web/personas-open-source/src/lib/omiApi.generated.ts +++ b/web/personas-open-source/src/lib/omiApi.generated.ts @@ -232,12 +232,6 @@ export interface AnnouncementDeleteResponse { export type AnnouncementType = "changelog" | "feature" | "announcement"; -export interface AnnualSavings { - amount_minor: number; - formatted: string; - percent: number; -} - export interface App { approved?: boolean; author: string; @@ -3829,12 +3823,6 @@ export interface PhoneNumbersResponse { numbers: Array; } -export interface PlanFeature { - included?: boolean; - key: string; - label: string; -} - export interface PlanIdentitySubscription { cancel_at_period_end?: boolean; current_period_end?: number | null; @@ -4949,49 +4937,63 @@ export interface StoreRecordingPermissionResponse { store_recording_permission: boolean; } -export interface StorefrontCurrentPlan { +export interface StorefrontCurrent { + cancel_at_period_end?: boolean; + interval?: "month" | "year" | null; legacy: boolean; + legacy_note?: string | null; + line: string; + name: string; plan_id: string; price_id?: string | null; } export interface StorefrontPrice { amount_minor: number; - annual_savings?: AnnualSavings | null; + cta_label?: string | null; currency: string; - formatted_amount: string; + headline: string; interval: "month" | "year"; - monthly_equivalent: string; + months_free?: number | null; price_id?: string | null; + renewal_line?: string | null; + subline?: string | null; } export interface StorefrontResponse { catalog_revision: number; - current?: StorefrontCurrentPlan | null; - next_offer_tier_id?: string | null; - schema_version?: 1; + current?: StorefrontCurrent | null; + header?: string | null; + schema_version?: 2; show_subscription_ui?: boolean; tiers?: Array; usage?: StorefrontUsage; + yearly_toggle_label?: string | null; } export interface StorefrontTier { - action: "current" | "upgrade" | "downgrade_not_offered" | "contact"; - display_name: string; - entitlement_version: string; - features: Array; - legacy?: boolean; + action: "checkout" | "current" | "none"; + blocked_line?: string | null; + bullets: Array; + compact?: boolean; + cta_label?: string | null; + hours_line: string; + is_current?: boolean; + name: string; plan_id: string; prices: Array; recommended?: boolean; - tagline: string; tier_id: string; } export interface StorefrontUsage { - budget_percent?: number | null; - kind?: "transcription" | "usage_budget" | "unavailable"; - reset_at?: number | null; + kind?: "usage_budget" | "transcription" | "unavailable"; + meter_line?: string | null; + nudge_line?: string | null; + percent?: number | null; + period_id?: string | null; + pro_gates?: Array; + reset_label?: string | null; } export interface StripeConnectAccountResponse { @@ -6013,7 +6015,6 @@ export interface OmiApiSchemas { "Announcement": Announcement; "AnnouncementDeleteResponse": AnnouncementDeleteResponse; "AnnouncementType": AnnouncementType; - "AnnualSavings": AnnualSavings; "App": App; "AppApiKeyResponse": AppApiKeyResponse; "AppBaseModel": AppBaseModel; @@ -6490,7 +6491,6 @@ export interface OmiApiSchemas { "PhoneMutationResponse": PhoneMutationResponse; "PhoneNumberResponse": PhoneNumberResponse; "PhoneNumbersResponse": PhoneNumbersResponse; - "PlanFeature": PlanFeature; "PlanIdentitySubscription": PlanIdentitySubscription; "PlanIdentityUserSubscriptionResponse": PlanIdentityUserSubscriptionResponse; "PlanLimits": PlanLimits; @@ -6646,7 +6646,7 @@ export interface OmiApiSchemas { "StoreMeetingRequest": StoreMeetingRequest; "StoreMeetingResponse": StoreMeetingResponse; "StoreRecordingPermissionResponse": StoreRecordingPermissionResponse; - "StorefrontCurrentPlan": StorefrontCurrentPlan; + "StorefrontCurrent": StorefrontCurrent; "StorefrontPrice": StorefrontPrice; "StorefrontResponse": StorefrontResponse; "StorefrontTier": StorefrontTier; @@ -21301,7 +21301,7 @@ export async function report_message_v2_messages__message_id__report_post(path: return _res.status === 204 ? (undefined as any) : await _res.json(); } -export async function get_storefront_v2_payments_plans_get(query: { source?: string | null, trigger?: "proactive_agent" | "screen_context" | "usage_budget" | "mcp_daily_cap" | null }, header: { X_App_Platform?: string, X_App_Version?: string, authorization?: string, X_Device_Id_Hash?: string }, init?: OmiApiClientInit): Promise { +export async function get_storefront_v2_payments_plans_get(query: { source?: string | null, trigger?: "proactive_agent" | "screen_context" | "usage_budget" | "mcp_daily_cap" | null, locale?: string | null }, header: { X_App_Platform?: string, X_App_Version?: string, authorization?: string, X_Device_Id_Hash?: string }, init?: OmiApiClientInit): Promise { const _base = init?.baseURL ?? ""; const _path = `/v2/payments/plans`; const _params = query ? Object.entries(query)