From c86778279e52b43a811c163e603b3302f807436f Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 13 Oct 2026 12:52:23 +0700 Subject: [PATCH 01/14] feat(billing): add dark versioned unified storefront contract --- .github/agent-docs/plan-catalog.md | 10 +- .../backend/schema/gen/storefront_wire.g.dart | 385 ++++++++++++++++++ app/test/backend/storefront_wire_test.dart | 33 ++ backend/config/storefront_ladder.json | 11 + backend/docs/feature-flag-registry.md | 2 + backend/docs/storefront-contract.md | 96 +++++ backend/main.py | 2 + backend/models/storefront.py | 62 +++ backend/route_policy_manifest.yaml | 14 + backend/routers/storefront.py | 111 +++++ backend/scripts/export_openapi.py | 1 + backend/scripts/generate_dart_models.py | 12 + .../scripts/generate_swift_openapi_types.py | 1 + .../fixtures/storefront/ladder_v1_free.json | 249 +++++++++++ backend/tests/unit/test_storefront.py | 239 +++++++++++ .../tests/unit/test_storefront_off_path.py | 55 +++ backend/utils/storefront.py | 199 +++++++++ backend/utils/storefront_flags.py | 8 + config/feature-flags.yaml | 1 + .../Sources/Generated/OmiApi.generated.swift | 262 +++++++++++- .../src/renderer/src/lib/omiApi.generated.ts | 95 ++++- docs/api-reference/app-client-openapi.json | 373 +++++++++++++++++ .../lib/services/omi-api/omiApi.generated.ts | 95 ++++- web/app/src/lib/omiApi.generated.ts | 95 ++++- .../src/lib/omiApi.generated.ts | 95 ++++- 25 files changed, 2500 insertions(+), 6 deletions(-) create mode 100644 app/lib/backend/schema/gen/storefront_wire.g.dart create mode 100644 app/test/backend/storefront_wire_test.dart create mode 100644 backend/config/storefront_ladder.json create mode 100644 backend/docs/storefront-contract.md create mode 100644 backend/models/storefront.py create mode 100644 backend/routers/storefront.py create mode 100644 backend/tests/unit/fixtures/storefront/ladder_v1_free.json create mode 100644 backend/tests/unit/test_storefront.py create mode 100644 backend/tests/unit/test_storefront_off_path.py create mode 100644 backend/utils/storefront.py create mode 100644 backend/utils/storefront_flags.py diff --git a/.github/agent-docs/plan-catalog.md b/.github/agent-docs/plan-catalog.md index 50c1ff8f020..9c9b2208998 100644 --- a/.github/agent-docs/plan-catalog.md +++ b/.github/agent-docs/plan-catalog.md @@ -10,7 +10,15 @@ Locked purchase-catalog rules. Tests in filter because a paid user "might want to resubscribe" or because the phone sheet looks empty. -## Storefronts +## Versioned ladder-v1 sheet + +`GET /v2/payments/plans` retires the platform partition for adopting clients, +behind `STOREFRONT_LADDER_V1_ENABLED` (default off). The old endpoints keep the +installed-client rules below unchanged. Missing W3 ladder prices hide Plus/Pro; +Architect remains contact-only and legacy subscribers keep a current row. +See `backend/docs/storefront-contract.md` for schema, price admission and usage. + +## Storefronts (installed-client APIs) | Surface | Sells | Does not sell | |---|---|---| diff --git a/app/lib/backend/schema/gen/storefront_wire.g.dart b/app/lib/backend/schema/gen/storefront_wire.g.dart new file mode 100644 index 00000000000..a4f35f12202 --- /dev/null +++ b/app/lib/backend/schema/gen/storefront_wire.g.dart @@ -0,0 +1,385 @@ +// GENERATED CODE - DO NOT EDIT. +// 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 { + final bool legacy; + final String planId; + final String? priceId; + + const GeneratedStorefrontCurrentPlan({ + required this.legacy, + required this.planId, + this.priceId, + }); + + factory GeneratedStorefrontCurrentPlan.fromJson(Map json) { + return GeneratedStorefrontCurrentPlan( + legacy: _required(_readFieldValue(_readField(json, const ["legacy"]), "legacy", _readBool, requiredField: true, nullable: false), "legacy"), + 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), + ); + } + + Map toJson() { + return { + 'legacy': legacy, + 'plan_id': planId, + 'price_id': priceId, + }; + } +} + +class GeneratedStorefrontPrice { + final int amountMinor; + final GeneratedAnnualSavings? annualSavings; + final String currency; + final String formattedAmount; + final String interval; + final String monthlyEquivalent; + final String? priceId; + + const GeneratedStorefrontPrice({ + required this.amountMinor, + this.annualSavings, + required this.currency, + required this.formattedAmount, + required this.interval, + required this.monthlyEquivalent, + this.priceId, + }); + + 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), + 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"), + 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"), + priceId: _readFieldValue(_readField(json, const ["price_id"]), "price_id", _readString, requiredField: false, nullable: true), + ); + } + + Map toJson() { + return { + 'amount_minor': amountMinor, + 'annual_savings': annualSavings?.toJson(), + 'currency': currency, + 'formatted_amount': formattedAmount, + 'interval': interval, + 'monthly_equivalent': monthlyEquivalent, + 'price_id': priceId, + }; + } +} + +class GeneratedStorefrontResponse { + final int catalogRevision; + final GeneratedStorefrontCurrentPlan? current; + final String? nextOfferTierId; + final int schemaVersion; + final bool showSubscriptionUi; + final List? tiers; + final GeneratedStorefrontUsage? usage; + + const GeneratedStorefrontResponse({ + required this.catalogRevision, + this.current, + this.nextOfferTierId, + this.schemaVersion = 1, + this.showSubscriptionUi = true, + this.tiers, + this.usage, + }); + + 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"), + 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), + ); + } + + Map toJson() { + return { + 'catalog_revision': catalogRevision, + 'current': current?.toJson(), + 'next_offer_tier_id': nextOfferTierId, + 'schema_version': schemaVersion, + 'show_subscription_ui': showSubscriptionUi, + 'tiers': tiers?.map((value) => value.toJson()).toList(), + 'usage': usage?.toJson(), + }; + } +} + +class GeneratedStorefrontTier { + final String action; + final String displayName; + final String entitlementVersion; + final List features; + final bool legacy; + 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, + 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"), + 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"), + ); + } + + Map toJson() { + return { + 'action': action, + 'display_name': displayName, + 'entitlement_version': entitlementVersion, + 'features': features.map((value) => value.toJson()).toList(), + 'legacy': legacy, + '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; + + const GeneratedStorefrontUsage({ + this.budgetPercent, + this.kind = "unavailable", + this.resetAt, + }); + + 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), + ); + } + + Map toJson() { + return { + 'budget_percent': budgetPercent, + 'kind': kind, + 'reset_at': resetAt, + }; + } +} + +class _WireField { + final bool present; + final dynamic value; + + const _WireField(this.present, this.value); +} + +_WireField _readField(Map json, List names) { + for (final name in names) { + if (json.containsKey(name)) return _WireField(true, json[name]); + } + return const _WireField(false, null); +} + +String? _readString(dynamic value) => value is String ? value : null; + +int? _readInt(dynamic value) { + if (value is int) return value; + if (value is String) return int.tryParse(value); + return null; +} + +double? _readDouble(dynamic value) { + if (value is num) return value.toDouble(); + if (value is String) return double.tryParse(value); + return null; +} + +bool? _readBool(dynamic value) { + if (value is bool) return value; + return null; +} + +T _required(T? value, String name) { + if (value == null) { + throw FormatException('Missing required field: $name'); + } + return value; +} + +T? _readFieldValue( + _WireField field, + String name, + T? Function(dynamic) read, { + required bool requiredField, + required bool nullable, + T? defaultValue, +}) { + if (!field.present) { + if (requiredField) { + throw FormatException('Missing required field: $name'); + } + return defaultValue; + } + if (field.value == null) { + if (nullable) return null; + throw FormatException('Null field: $name'); + } + final value = read(field.value); + if (value == null) { + throw FormatException('Invalid field: $name'); + } + return value; +} + +DateTime? _readDateTime(dynamic value) { + if (value == null) return null; + if (value is String) return DateTime.tryParse(value)?.toLocal(); + return null; +} + +List? _readDateTimeList(dynamic value) { + if (value is! List) return null; + return [ + for (final item in value) _required(_readDateTime(item), 'list item') + ]; +} + +Map? _readMap(dynamic value) { + if (value is Map) return value; + if (value is Map) return Map.from(value); + return null; +} + +T? _readObject(dynamic value, T Function(Map) fromJson) { + final map = _readMap(value); + return map == null ? null : fromJson(map); +} + +List? _readObjectList(dynamic value, T Function(Map) fromJson) { + if (value is! List) return null; + return [ + for (final item in value) fromJson(_required(_readMap(item), 'list item')) + ]; +} + +List? _readStringList(dynamic value) { + if (value is! List) return null; + return [ + for (final item in value) _required(_readString(item), 'list item') + ]; +} + +List? _readDoubleList(dynamic value) { + if (value is! List) return null; + return [ + for (final item in value) _required(_readDouble(item), 'list item') + ]; +} + +List? _readIntList(dynamic value) { + if (value is! List) return null; + return [ + for (final item in value) _required(_readInt(item), 'list item') + ]; +} + +List>? _readMapList(dynamic value) { + if (value is! List) return null; + return [ + for (final item in value) _required(_readMap(item), 'list item') + ]; +} + +List? _readDynamicList(dynamic value) => value is List ? value : null; diff --git a/app/test/backend/storefront_wire_test.dart b/app/test/backend/storefront_wire_test.dart new file mode 100644 index 00000000000..07c2614af88 --- /dev/null +++ b/app/test/backend/storefront_wire_test.dart @@ -0,0 +1,33 @@ +import 'dart:convert'; +import 'dart:io'; + +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('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()); + }); + + 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'); + }); +} diff --git a/backend/config/storefront_ladder.json b/backend/config/storefront_ladder.json new file mode 100644 index 00000000000..a2806064207 --- /dev/null +++ b/backend/config/storefront_ladder.json @@ -0,0 +1,11 @@ +{ + "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} + ], + "currency": "usd", + "entitlement_version": "ladder_v1" +} diff --git a/backend/docs/feature-flag-registry.md b/backend/docs/feature-flag-registry.md index 2042ad1202d..1aac9789745 100644 --- a/backend/docs/feature-flag-registry.md +++ b/backend/docs/feature-flag-registry.md @@ -224,6 +224,7 @@ and an explicit empty literal renders as `''`. | `SONIOX_WIRE_LEDGER` | Account every emitted managed Soniox sample on the compact axis; unknown capture origins reserve unplaceable intervals; reserve reported finalize padding as provider-only holes and refuse raced intervals; preserve every control frame; default off | backend | env | closed | {value: 'false', category: rollout} | false (backend-listen (chart), pusher (chart)); {value: 'false', category: rollout} (cloud_run/backend, cloud_run/backend-integration, cloud_run/backend-sync, cloud_run/backend-sync-backfill, gke/backend-listen, gke/pusher) | true (backend-listen (chart), pusher (chart)); {value: 'false', category: rollout} (cloud_run/backend, cloud_run/backend-integration, cloud_run/backend-sync, cloud_run/backend-sync-backfill); {value: 'true', category: rollout} (gke/backend-listen, gke/pusher) | — | pending | 2026-11-04 | dazheng | | `SPEAKER_GROUPING_MODE` | incumbent\|provider_strict\|provider_cannot_link; owner_link is shadow-only and rejected; default incumbent everywhere; David gates promotion | backend | env | open | — | — | incumbent (backend-listen (chart), pusher (chart)); {value: 'incumbent', category: rollout} (cloud_run/backend, cloud_run/backend-integration, cloud_run/backend-sync, cloud_run/backend-sync-backfill, gke/backend-listen, gke/pusher) | — | pending | 2026-11-08 | dazheng | | `SPEAKER_MATCH_SCORES_ENABLED` | Persist bounded internal voice-match evidence on existing conversation writes; dev/local/offline default on; production hosts explicitly on | backend | env | closed | — | — | true (backend-listen (chart), cloud_run/backend, cloud_run/backend-integration, cloud_run/backend-sync, cloud_run/backend-sync-backfill, gke/backend-listen, gke/pusher, pusher (chart)) | — | pending | 2026-11-04 | dazheng | +| `STOREFRONT_LADDER_V1_ENABLED` | Default-off versioned unified plans presentation; exact true admits v2 only; installed-client endpoints unchanged | backend | env | closed | — | — | — | — | pending | 2026-11-13 | dazheng | | `STT_FAILOVER_RECOVERY_ENABLED` | Enable live-STT failover recovery on prod listen | backend | env | closed | false | false (backend-listen (chart), gke/backend-listen) | true (backend-listen (chart), gke/backend-listen) | — | pending | 2026-11-03 | backend | | `STT_LEARNED_LANGUAGE_PROFILE` | Use a bounded per-user spoken-language history for live STT routing and Soniox hints | backend | env | closed | false | true (backend-listen (chart), gke/backend-listen) | true (backend-listen (chart), gke/backend-listen) | — | pending | 2026-10-28 | backend | | `STT_MULTI_LANGUAGE_HINTS` | Send primary and English hints to Soniox in non-English multilingual live sessions | backend | env | closed | true | true (backend-listen (chart), gke/backend-listen) | true (backend-listen (chart), gke/backend-listen) | — | pending | 2026-10-27 | backend | @@ -439,6 +440,7 @@ their code default (`fail` tells you which way a missing value resolves). - `SELFHEAL_MODE` — Conversation self-heal sweeper mode: off/detect-only/nudge/heal (fail: closed) - `SONIOX_ELAPSED_AXIS` — Measure Soniox elapsed timestamps before enabling speaker windows (fail: closed) - `SPEAKER_GROUPING_CROSS_SCOPE_THRESHOLD` — G2 cross-scope cosine-distance threshold; default 0.60; invalid configuration uses 0.60 (fail: closed) +- `STOREFRONT_LADDER_V1_ENABLED` — Default-off versioned unified plans presentation; exact true admits v2 only; installed-client endpoints unchanged (fail: closed) - `STT_NO_TEXT_RESCUE_SECONDS` — Maximum paid wall and admitted audio seconds per no-text rescue; default 60 bounded 5-120 (fail: closed) - `STT_PAID_SPILLOVER_BUDGET_ENABLED` — Default-off fleet budget for paid router promotions after Parakeet capacity refusal; denial restores static order (fail: closed) - `STT_PAID_SPILLOVER_DEEPGRAM_PER_MINUTE` — Deepgram fleet paid router promotion cap per UTC minute; default 30; zero refuses promotions (fail: closed) diff --git a/backend/docs/storefront-contract.md b/backend/docs/storefront-contract.md new file mode 100644 index 00000000000..13533ea68a1 --- /dev/null +++ b/backend/docs/storefront-contract.md @@ -0,0 +1,96 @@ +# First-party storefront presentation contract + +`GET /v2/payments/plans` uses Firebase authentication (including the existing +no-BYOK-validation payment dependency). 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. + +`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. +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. + +## 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. + +## 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. +Legacy rows use existing-benefits copy, never ladder-v1 gate claims. + +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. + +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. 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. 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. diff --git a/backend/main.py b/backend/main.py index 903f7b0e87a..f6edadd6e6d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -54,6 +54,7 @@ sync, apps, payment, + storefront, integration, conversations, conversation_mutations, @@ -284,6 +285,7 @@ app.include_router(payment.router) +app.include_router(storefront.router) app.include_router(api_key_management.mcp_router) app.include_router(mcp.router) app.include_router(mcp_sse.router) diff --git a/backend/models/storefront.py b/backend/models/storefront.py new file mode 100644 index 00000000000..8e279d883fa --- /dev/null +++ b/backend/models/storefront.py @@ -0,0 +1,62 @@ +"""Versioned backend presentation contract; clients need no tier policy.""" + +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 + + +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 + + +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'] + recommended: bool = False + legacy: bool = False + + +class StorefrontCurrentPlan(BaseModel): + plan_id: str + price_id: str | None = None + legacy: bool + + +class StorefrontUsage(BaseModel): + kind: Literal['transcription', 'usage_budget', 'unavailable'] = 'unavailable' + budget_percent: float | None = None + reset_at: int | None = None + + +class StorefrontResponse(BaseModel): + schema_version: Literal[1] = 1 + 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 + usage: StorefrontUsage = Field(default_factory=StorefrontUsage) diff --git a/backend/route_policy_manifest.yaml b/backend/route_policy_manifest.yaml index 5a180bee856..8ec27d8a7fb 100644 --- a/backend/route_policy_manifest.yaml +++ b/backend/route_policy_manifest.yaml @@ -6,6 +6,20 @@ schema_version: 1 service: backend-main routes: + - route_type: http + method: GET + path: /v2/payments/plans + policy: + review_status: reviewed + auth: {mechanisms: [firebase_id_token], placement: dependency, scopes: []} + byok: skipped_for_key_rotation + rate_limit: {policy_name: none, key_subject: none, enforcement: none, placement: none} + timeout_class: default_method + surface: first_party_app + visibility: first_party + data_domain: billing + deprecation: {state: active} + owner: backend - {route_type: http, method: POST, path: "/v1/messaging/webhooks/{adapter_id}", policy: {review_status: reviewed, auth: {mechanisms: [webhook_signature], placement: handler, scopes: []}, byok: not_applicable, rate_limit: {policy_name: none, key_subject: none, enforcement: none, placement: none}, timeout_class: default_method, surface: webhook, visibility: internal, data_domain: chat, deprecation: {state: active}, owner: backend}} - {route_type: http, method: POST, path: "/v1/messaging/link-proofs", policy: {review_status: reviewed, auth: {mechanisms: [firebase_id_token], placement: dependency, scopes: []}, byok: validated_when_headers_present, rate_limit: {policy_name: "chat:send_message", key_subject: uid, enforcement: fail_open, placement: dependency}, timeout_class: default_method, surface: first_party_app, visibility: first_party, data_domain: chat, deprecation: {state: active}, owner: backend}} - {route_type: http, method: GET, path: "/v1/messaging/links", policy: {review_status: reviewed, auth: {mechanisms: [firebase_id_token], placement: dependency, scopes: []}, byok: validated_when_headers_present, rate_limit: {policy_name: none, key_subject: none, enforcement: none, placement: none}, timeout_class: default_method, surface: first_party_app, visibility: first_party, data_domain: chat, deprecation: {state: active}, owner: backend}} diff --git a/backend/routers/storefront.py b/backend/routers/storefront.py new file mode 100644 index 00000000000..cb312465b4a --- /dev/null +++ b/backend/routers/storefront.py @@ -0,0 +1,111 @@ +"""Versioned first-party plans sheet. Old payment and user APIs remain unchanged.""" + +from datetime import datetime, timezone +import math +from typing import Any, Optional + +from fastapi import APIRouter, Depends, Header, HTTPException +import stripe + +import config.plan_catalog_generated as catalog +from database import users as users_db +from database.app_review_config import should_hide_subscription_ui +from models.users import Subscription +from models.storefront import StorefrontResponse, StorefrontUsage +from utils.other import endpoints as auth +from utils import storefront_flags, subscription as subscription_utils +from utils.observability.fallback import record_fallback +from utils.storefront import build_storefront, load_ladder + +router = APIRouter() + + +def _degraded(from_mode: str, to_mode: str) -> None: + record_fallback( + component='other', from_mode=from_mode, to_mode=to_mode, reason='provider_error', outcome='degraded' + ) + + +def _retrieve_price(price_id: str) -> dict[str, Any]: + try: + return stripe.Price.retrieve(price_id).to_dict() + except Exception: + _degraded('storefront_price', 'hidden_price') + raise + + +def _usage(uid: str, subscription: Subscription) -> StorefrontUsage: + try: + # W3 may provide a display-ready projection on the shared facade. Do not + # guess units or denominators; only accept percentage/reset fields. + projection = getattr(subscription_utils, 'get_usage_units', None) + if callable(projection): + snapshot = projection(uid) + percent = snapshot.get('budget_percent') if isinstance(snapshot, dict) else None + reset = snapshot.get('reset_at') if isinstance(snapshot, dict) else None + if ( + isinstance(percent, (int, float)) + and not isinstance(percent, bool) + and math.isfinite(percent) + and percent >= 0 + and type(reset) is int + and reset > 0 + ): + return StorefrontUsage(kind='usage_budget', budget_percent=percent, reset_at=reset) + if subscription.current_price_id in getattr(catalog, 'LADDER_V1_STRIPE_PRICES', []): + return StorefrontUsage() + # 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() + 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() + 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())) + except Exception: + _degraded('storefront_usage', 'unavailable_usage') + return StorefrontUsage() + + +@router.get('/v2/payments/plans', response_model=StorefrontResponse, tags=['payments']) +def get_storefront( + uid: str = Depends(auth.get_current_user_uid_no_byok_validation), + x_app_platform: Optional[str] = Header(None, alias='X-App-Platform'), + x_app_version: Optional[str] = Header(None, alias='X-App-Version'), +): + if not storefront_flags.enabled(): + raise HTTPException(status_code=404, detail='Storefront is not enabled') + if should_hide_subscription_ui(uid, x_app_platform, x_app_version): + return StorefrontResponse(catalog_revision=catalog.CATALOG_REVISION, show_subscription_ui=False) + # Never provision a default record from a presentation endpoint. + subscription = users_db.get_user_valid_subscription(uid, provision=False) + if subscription is None: + subscription = subscription_utils.get_default_basic_subscription() + # W3's generated declaration is absent until #21351 merges. No env prices + # can accidentally become ladder-v1 just because they exist. + bindings = getattr(catalog, 'LADDER_V1_STRIPE_PRICES', []) + recognized = [ + { + 'price_id': price_id, + 'plan_id': plan.value, + 'interval': catalog.RECOGNIZED_STRIPE_PRICE_INTERVALS[price_id], + 'entitlement_version': getattr(catalog, 'RECOGNIZED_STRIPE_ENTITLEMENT_VERSIONS', {}).get( + price_id, 'legacy' + ), + } + for price_id, plan in catalog.RECOGNIZED_STRIPE_PRICE_PLAN_TYPES.items() + ] + return build_storefront( + catalog_revision=catalog.CATALOG_REVISION, + ladder=load_ladder(), + bindings=bindings, + recognized=recognized, + subscription=subscription, + retrieve_price=_retrieve_price, + purchasable=subscription_utils.is_purchasable_price_id, + usage=_usage(uid, subscription), + ) diff --git a/backend/scripts/export_openapi.py b/backend/scripts/export_openapi.py index 73694438b87..aabc3dab77a 100644 --- a/backend/scripts/export_openapi.py +++ b/backend/scripts/export_openapi.py @@ -88,6 +88,7 @@ '/v1/memories', '/v1/payment-methods', '/v1/payments', + '/v2/payments', '/v1/paypal', '/v1/persons', '/v1/phone', diff --git a/backend/scripts/generate_dart_models.py b/backend/scripts/generate_dart_models.py index 6f794c50300..306b70a1ad9 100644 --- a/backend/scripts/generate_dart_models.py +++ b/backend/scripts/generate_dart_models.py @@ -531,6 +531,18 @@ 'AudioUrlsResponse', ), }, + 'storefront': { + 'output': DEFAULT_OUTPUT_DIR / 'storefront_wire.g.dart', + 'schemas': ( + 'AnnualSavings', + 'PlanFeature', + 'StorefrontCurrentPlan', + 'StorefrontPrice', + 'StorefrontResponse', + 'StorefrontTier', + 'StorefrontUsage', + ), + }, 'payments': { 'output': DEFAULT_OUTPUT_DIR / 'payments_wire.g.dart', 'schemas': ( diff --git a/backend/scripts/generate_swift_openapi_types.py b/backend/scripts/generate_swift_openapi_types.py index b57fc831686..154b7fb0910 100644 --- a/backend/scripts/generate_swift_openapi_types.py +++ b/backend/scripts/generate_swift_openapi_types.py @@ -35,6 +35,7 @@ def source_label_for_path(path: PurePath, root_dir: PurePath = ROOT_DIR) -> str: # as the desktop Codable migration progresses. Each entry pulls in transitive # $ref dependencies automatically. TARGET_SCHEMAS = ( + 'StorefrontResponse', 'ProactivityTarget', 'ProactivityFeedItem', 'ProactivityFeedResponse', diff --git a/backend/tests/unit/fixtures/storefront/ladder_v1_free.json b/backend/tests/unit/fixtures/storefront/ladder_v1_free.json new file mode 100644 index 00000000000..df0faba2ef4 --- /dev/null +++ b/backend/tests/unit/fixtures/storefront/ladder_v1_free.json @@ -0,0 +1,249 @@ +{ + "schema_version": 1, + "catalog_revision": 6, + "show_subscription_ui": true, + "tiers": [ + { + "tier_id": "basic:ladder_v1", + "plan_id": "basic", + "entitlement_version": "ladder_v1", + "display_name": "Free", + "tagline": "Start building your memory", + "features": [ + { + "key": "core_desktop", + "label": "Capture, conversations, chat, memories and search", + "included": true + }, + { + "key": "managed_usage_budget", + "label": "Managed AI uses your shared monthly budget", + "included": true + }, + { + "key": "mcp_daily_cap", + "label": "MCP + developer API: 100 requests/day", + "included": true + }, + { + "key": "proactive_agent", + "label": "Proactive agent", + "included": false + }, + { + "key": "screen_context", + "label": "Screen context", + "included": false + } + ], + "prices": [ + { + "price_id": null, + "interval": "month", + "currency": "usd", + "amount_minor": 0, + "formatted_amount": "$0.00/mo", + "monthly_equivalent": "$0.00/mo", + "annual_savings": null + } + ], + "action": "current", + "recommended": false, + "legacy": false + }, + { + "tier_id": "plus:ladder_v1", + "plan_id": "plus", + "entitlement_version": "ladder_v1", + "display_name": "Plus", + "tagline": "Your everyday memory companion", + "features": [ + { + "key": "core_desktop", + "label": "Capture, conversations, chat, memories and search", + "included": true + }, + { + "key": "managed_usage_budget", + "label": "Managed AI uses your shared monthly budget", + "included": true + }, + { + "key": "mcp_daily_cap", + "label": "MCP + developer API: 500 requests/day", + "included": true + }, + { + "key": "proactive_agent", + "label": "Proactive agent", + "included": false + }, + { + "key": "screen_context", + "label": "Screen context", + "included": false + } + ], + "prices": [ + { + "price_id": "price_fixture_plus_month", + "interval": "month", + "currency": "usd", + "amount_minor": 1999, + "formatted_amount": "$19.99/mo", + "monthly_equivalent": "$19.99/mo", + "annual_savings": null + }, + { + "price_id": "price_fixture_plus_year", + "interval": "year", + "currency": "usd", + "amount_minor": 19990, + "formatted_amount": "$199.90/yr", + "monthly_equivalent": "$16.66/mo", + "annual_savings": { + "amount_minor": 3998, + "percent": 16.67, + "formatted": "Save $39.98 (16.67%) per year" + } + } + ], + "action": "upgrade", + "recommended": true, + "legacy": false + }, + { + "tier_id": "operator:ladder_v1", + "plan_id": "operator", + "entitlement_version": "ladder_v1", + "display_name": "Pro", + "tagline": "Proactive help with screen context", + "features": [ + { + "key": "core_desktop", + "label": "Capture, conversations, chat, memories and search", + "included": true + }, + { + "key": "managed_usage_budget", + "label": "Managed AI uses your shared monthly budget", + "included": true + }, + { + "key": "mcp_daily_cap", + "label": "MCP + developer API: 5,000 requests/day", + "included": true + }, + { + "key": "proactive_agent", + "label": "Proactive agent", + "included": true + }, + { + "key": "screen_context", + "label": "Screen context", + "included": true + } + ], + "prices": [ + { + "price_id": "price_fixture_operator_month", + "interval": "month", + "currency": "usd", + "amount_minor": 4999, + "formatted_amount": "$49.99/mo", + "monthly_equivalent": "$49.99/mo", + "annual_savings": null + }, + { + "price_id": "price_fixture_operator_year", + "interval": "year", + "currency": "usd", + "amount_minor": 49990, + "formatted_amount": "$499.90/yr", + "monthly_equivalent": "$41.66/mo", + "annual_savings": { + "amount_minor": 9998, + "percent": 16.67, + "formatted": "Save $99.98 (16.67%) per year" + } + } + ], + "action": "upgrade", + "recommended": false, + "legacy": false + }, + { + "tier_id": "architect:ladder_v1", + "plan_id": "architect", + "entitlement_version": "ladder_v1", + "display_name": "Architect", + "tagline": "For demanding workflows", + "features": [ + { + "key": "core_desktop", + "label": "Capture, conversations, chat, memories and search", + "included": true + }, + { + "key": "managed_usage_budget", + "label": "Managed AI uses your shared monthly budget", + "included": true + }, + { + "key": "mcp_daily_cap", + "label": "MCP + developer API: at least 5,000 requests/day", + "included": true + }, + { + "key": "proactive_agent", + "label": "Proactive agent", + "included": true + }, + { + "key": "screen_context", + "label": "Screen context", + "included": true + } + ], + "prices": [ + { + "price_id": "price_fixture_architect_month", + "interval": "month", + "currency": "usd", + "amount_minor": 19900, + "formatted_amount": "$199.00/mo", + "monthly_equivalent": "$199.00/mo", + "annual_savings": null + }, + { + "price_id": "price_fixture_architect_year", + "interval": "year", + "currency": "usd", + "amount_minor": 199000, + "formatted_amount": "$1990.00/yr", + "monthly_equivalent": "$165.83/mo", + "annual_savings": { + "amount_minor": 39800, + "percent": 16.67, + "formatted": "Save $398.00 (16.67%) per year" + } + } + ], + "action": "upgrade", + "recommended": false, + "legacy": false + } + ], + "next_offer_tier_id": "plus:ladder_v1", + "current": { + "plan_id": "basic", + "price_id": null, + "legacy": false + }, + "usage": { + "kind": "unavailable", + "budget_percent": null, + "reset_at": null + } +} diff --git a/backend/tests/unit/test_storefront.py b/backend/tests/unit/test_storefront.py new file mode 100644 index 00000000000..fe38233a0a9 --- /dev/null +++ b/backend/tests/unit/test_storefront.py @@ -0,0 +1,239 @@ +"""Executable presentation policy, live-price arithmetic, and dark-route contract.""" + +import json +from pathlib import Path +from unittest.mock import Mock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from models.users import PlanType, Subscription +from models.storefront import StorefrontUsage +from routers import storefront as route +from utils.storefront import build_storefront, load_ladder, present_prices + + +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 build(plan='basic', current_price=None, rows=None, bindings=None, recognized=None): + return build_storefront( + catalog_revision=6, + ladder=load_ladder(), + bindings=bindings or [], + recognized=recognized or [], + subscription=Subscription(plan=PlanType(plan), current_price_id=current_price), + retrieve_price=lambda key: (rows or {})[key], + purchasable=lambda key: True, + usage=StorefrontUsage(), + ) + + +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 sheet.current.legacy is False + + +@pytest.mark.parametrize('plan', ['plus', 'operator', 'architect', 'unlimited', 'unlimited_v2']) +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].prices == [] + assert sheet.current.price_id == 'price_old' and sheet.current.legacy + assert not any(tier.action == 'upgrade' for tier in sheet.tiers) + + +def test_registered_purchasable_ladder_is_ordered_and_computes_live_prices(): + 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'} + ) + 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 + 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) + + +@pytest.fixture +def client(monkeypatch): + app = FastAPI() + app.include_router(route.router) + app.dependency_overrides[route.auth.get_current_user_uid_no_byok_validation] = lambda: 'fixture-user' + monkeypatch.setenv('STOREFRONT_LADDER_V1_ENABLED', 'true') + monkeypatch.setattr(route, 'should_hide_subscription_ui', lambda *args: False) + monkeypatch.setattr(route.users_db, 'get_user_valid_subscription', lambda uid, **kwargs: Subscription()) + monkeypatch.setattr(route, '_usage', lambda *args: StorefrontUsage()) + return TestClient(app) + + +def test_route_dark_by_default_and_bad_flag_cannot_read_data(client, monkeypatch): + read = Mock(side_effect=AssertionError('dark route must not read')) + monkeypatch.setattr(route.users_db, 'get_user_valid_subscription', read) + for flag in [None, 'false', 'TRUE', '1', 'typo']: + if flag is None: + monkeypatch.delenv('STOREFRONT_LADDER_V1_ENABLED') + else: + monkeypatch.setenv('STOREFRONT_LADDER_V1_ENABLED', flag) + assert client.get('/v2/payments/plans').status_code == 404 + read.assert_not_called() + + +def test_review_hidden_response_reads_no_subscription_price_or_usage(client, monkeypatch): + monkeypatch.setattr(route, 'should_hide_subscription_ui', lambda *args: True) + read = Mock(side_effect=AssertionError('hidden route must not read')) + monkeypatch.setattr(route.users_db, 'get_user_valid_subscription', read) + monkeypatch.setattr(route, '_retrieve_price', read) + monkeypatch.setattr(route, '_usage', read) + response = client.get('/v2/payments/plans', headers={'X-App-Platform': 'ios', 'X-App-Version': '1.0.1'}) + assert response.status_code == 200 + assert response.json()['show_subscription_ui'] is False + assert response.json()['tiers'] == [] and response.json()['current'] is None + read.assert_not_called() + + +def test_every_surface_has_identical_presentation_and_read_only_subscription(client, monkeypatch): + read = Mock(return_value=Subscription()) + monkeypatch.setattr(route.users_db, 'get_user_valid_subscription', read) + rows = { + f'price_{plan}_month': price(f'price_{plan}_month', amount) + for plan, amount in [('plus', 1999), ('operator', 4999), ('architect', 19900)] + } + monkeypatch.setattr(route.catalog, 'LADDER_V1_STRIPE_PRICES', list(rows), raising=False) + monkeypatch.setattr( + route.catalog, 'RECOGNIZED_STRIPE_ENTITLEMENT_VERSIONS', {key: 'ladder_v1' for key in rows}, raising=False + ) + monkeypatch.setattr( + route.catalog, 'RECOGNIZED_STRIPE_PRICE_PLAN_TYPES', {key: PlanType(key.split('_')[1]) for key in rows} + ) + monkeypatch.setattr(route.catalog, 'RECOGNIZED_STRIPE_PRICE_INTERVALS', {key: 'month' for key in rows}) + monkeypatch.setattr(route.subscription_utils, 'is_purchasable_price_id', lambda key: True) + monkeypatch.setattr(route, '_retrieve_price', lambda key: rows[key]) + bodies = [ + client.get('/v2/payments/plans', headers={'X-App-Platform': platform}).content + 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'] + assert all(call.kwargs == {'provision': False} for call in read.call_args_list) + + +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 + + +def test_legacy_current_price_is_preserved_even_when_archived(): + sheet = build('plus', 'price_old', rows={'price_old': price('price_old', 1799, active=False)}) + current = next(tier for tier in sheet.tiers if tier.action == 'current') + assert current.prices[0].amount_minor == 1799 + + +def test_usage_absence_and_bad_projection_never_invent_budget(monkeypatch): + monkeypatch.setattr(route.subscription_utils, 'transcription_allowance_seconds', lambda plan: 100) + 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 + monkeypatch.setattr( + route.subscription_utils, + 'get_usage_units', + lambda uid: {'budget_percent': 80, 'reset_at': 1800000000}, + raising=False, + ) + assert route._usage('fixture', Subscription()).kind == 'usage_budget' + monkeypatch.setattr( + route.subscription_utils, 'get_usage_units', lambda uid: {'budget_percent': float('nan'), 'reset_at': 1} + ) + assert route._usage('fixture', Subscription()).kind == 'transcription' + monkeypatch.setattr(route.catalog, 'LADDER_V1_STRIPE_PRICES', ['price_new'], raising=False) + assert route._usage('fixture', Subscription(current_price_id='price_new')).kind == 'unavailable' + + +def test_api_openapi_names_the_versioned_presentation_contract(client): + schema = client.get('/openapi.json').json() + operation = schema['paths']['/v2/payments/plans']['get'] + assert operation['responses']['200']['content']['application/json']['schema']['$ref'].endswith( + '/StorefrontResponse' + ) + assert schema['components']['schemas']['StorefrontTier']['properties']['action']['enum'] == [ + 'current', + 'upgrade', + 'downgrade_not_offered', + 'contact', + ] + + +def test_registration_does_not_bypass_existing_checkout_guard(): + read = Mock(side_effect=AssertionError('non-purchasable price must not be retrieved')) + sheet = build_storefront( + catalog_revision=6, + ladder=load_ladder(), + bindings=['price_new'], + recognized=[ + {'price_id': 'price_new', 'plan_id': 'plus', 'interval': 'month', 'entitlement_version': 'ladder_v1'} + ], + subscription=Subscription(), + retrieve_price=read, + purchasable=lambda key: False, + usage=StorefrontUsage(), + ) + assert [tier.plan_id for tier in sheet.tiers] == ['basic', 'architect'] + read.assert_not_called() + + +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 diff --git a/backend/tests/unit/test_storefront_off_path.py b/backend/tests/unit/test_storefront_off_path.py new file mode 100644 index 00000000000..b3ebdaf0666 --- /dev/null +++ b/backend/tests/unit/test_storefront_off_path.py @@ -0,0 +1,55 @@ +"""Legacy endpoint remains byte-identical regardless of the new flag.""" + +from unittest.mock import MagicMock + +import pytest +import stripe +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from routers import payment +from models.users import PlanType, Subscription, SubscriptionStatus + + +@pytest.mark.parametrize('platform', ['ios', 'android', 'macos', 'windows', 'web']) +@pytest.mark.parametrize('cancel_at_period_end', [False, True]) +@pytest.mark.parametrize('plan', ['basic', 'plus', 'operator', 'architect', 'unlimited', 'unlimited_v2']) +def test_v1_response_bytes_do_not_change_with_storefront_flag(monkeypatch, platform, plan, cancel_at_period_end): + monkeypatch.setattr( + payment.users_db, + 'get_user_subscription', + lambda uid: Subscription( + plan=PlanType(plan), + status=SubscriptionStatus.active, + stripe_subscription_id='sub_fixture' if plan != 'basic' else None, + cancel_at_period_end=cancel_at_period_end, + ), + ) + for name in ['UNLIMITED', 'PLUS', 'OPERATOR', 'ARCHITECT', 'UNLIMITED_V2']: + monkeypatch.setenv(f'STRIPE_{name}_MONTHLY_PRICE_ID', f'price_{name}_month') + monkeypatch.setenv(f'STRIPE_{name}_ANNUAL_PRICE_ID', f'price_{name}_year') + + def retrieve(identifier): + row = MagicMock() + row.id = identifier + row.unit_amount = 1900 if identifier.endswith('month') else 19000 + row.recurring.interval = 'month' if identifier.endswith('month') else 'year' + return row + + monkeypatch.setattr(stripe.Price, 'retrieve', retrieve) + stripe_sub = MagicMock() + stripe_sub.to_dict.return_value = {'items': {'data': [{'price': {'id': f'price_{plan.upper()}_month'}}]}} + monkeypatch.setattr(stripe.Subscription, 'retrieve', lambda identifier: stripe_sub) + app = FastAPI() + app.include_router(payment.router) + app.dependency_overrides[payment.auth.get_current_user_uid_no_byok_validation] = lambda: 'fixture-user' + client = TestClient(app) + headers = {'X-App-Platform': platform, 'X-App-Version': '99.0.0'} + monkeypatch.delenv('STOREFRONT_LADDER_V1_ENABLED', raising=False) + before = client.get('/v1/payments/available-plans', headers=headers) + monkeypatch.setenv('STOREFRONT_LADDER_V1_ENABLED', 'false') + off = client.get('/v1/payments/available-plans', headers=headers) + monkeypatch.setenv('STOREFRONT_LADDER_V1_ENABLED', 'true') + on = client.get('/v1/payments/available-plans', headers=headers) + assert before.status_code == off.status_code == on.status_code == 200 + assert before.content == off.content == on.content diff --git a/backend/utils/storefront.py b/backend/utils/storefront.py new file mode 100644 index 00000000000..1f74b5217f9 --- /dev/null +++ b/backend/utils/storefront.py @@ -0,0 +1,199 @@ +"""Backend-only presentation policy. Does not grant entitlements or mutate billing.""" + +from decimal import Decimal, ROUND_HALF_UP +from pathlib import Path +import json +from typing import Any, Callable + +from models.storefront import ( + AnnualSavings, + PlanFeature, + StorefrontCurrentPlan, + StorefrontPrice, + StorefrontResponse, + StorefrontTier, + StorefrontUsage, +) +from models.users import Subscription + + +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 present_prices(rows: list[dict[str, Any]], *, current: bool = False) -> list[StorefrontPrice]: + """USD recurring monthly/yearly prices only; omit unsupported billing shapes.""" + valid = [ + row + for row in rows + if isinstance(row.get('id'), str) + and row.get('currency') == 'usd' + and type(row.get('unit_amount')) is int + and row['unit_amount'] > 0 + and (current or row.get('active') is True) + and (row.get('recurring') or {}).get('interval') in {'month', 'year'} + and row['recurring'].get('interval_count', 1) == 1 + ] + valid.sort(key=lambda row: (row['recurring']['interval'] != 'month', row['id'])) + 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, + 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, + ) + ) + return result + + +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 build_storefront( + *, + catalog_revision: int, + ladder: dict[str, Any], + bindings: list[str], + recognized: list[dict[str, Any]], + subscription: Subscription, + retrieve_price: Callable[[str], dict[str, Any]], + purchasable: Callable[[str], bool], + usage: StorefrontUsage, +) -> StorefrontResponse: + current_plan = subscription.plan.value + current_price = subscription.current_price_id + # Missing historical price never implies the new entitlement version. + legacy = current_plan != 'basic' and current_price not in bindings + ranks = {tier['plan_id']: rank for rank, tier in enumerate(ladder['tiers'])} + current_rank = ranks.get(current_plan) + tiers: list[StorefrontTier] = [] + for tier in ladder['tiers']: + plan = tier['plan_id'] + rows: list[dict[str, Any]] = [] + for registration in recognized: + price_id = registration['price_id'] + if ( + price_id in bindings + 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) + 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. + if plan != 'basic' and not prices and plan != 'architect': + continue + if plan == current_plan and not legacy: + action = 'current' + elif legacy and plan == current_plan: + action = 'contact' + 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' + 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, + action=action, + recommended=tier['recommended'] and action == 'upgrade', + ) + ) + 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 + name = next( + (tier['display_name'] for tier in ladder['tiers'] if tier['plan_id'] == current_plan), + {'unlimited': 'Neo', 'unlimited_v2': 'Unlimited'}.get(current_plan, current_plan), + ) + 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 + else _features(next(tier for tier in ladder['tiers'] if tier['plan_id'] == current_plan)) + ), + prices=present_prices(rows, current=True), + ) + ) + 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) + return StorefrontResponse( + catalog_revision=catalog_revision, + tiers=tiers, + next_offer_tier_id=next_offer, + current=StorefrontCurrentPlan(plan_id=current_plan, price_id=current_price, legacy=legacy), + usage=usage, + ) diff --git a/backend/utils/storefront_flags.py b/backend/utils/storefront_flags.py new file mode 100644 index 00000000000..813b04a37a7 --- /dev/null +++ b/backend/utils/storefront_flags.py @@ -0,0 +1,8 @@ +"""Dark backend storefront admission. No caller header can enable it.""" + +# LIFECYCLE: permanent +import os + + +def enabled() -> bool: + return os.getenv('STOREFRONT_LADDER_V1_ENABLED', 'false') == 'true' diff --git a/config/feature-flags.yaml b/config/feature-flags.yaml index 571d4054baf..4dea00cc107 100644 --- a/config/feature-flags.yaml +++ b/config/feature-flags.yaml @@ -1,4 +1,5 @@ flags: + - {"key":"STOREFRONT_LADDER_V1_ENABLED","aliases":[],"kind":"env","lifecycle":"rollout","surfaces":["backend"],"summary":"Default-off versioned unified plans presentation; exact true admits v2 only; installed-client endpoints unchanged","fail":"closed","owner":"dazheng","created":"2026-10-13","review_by":"2026-11-13","decision":"pending"} - {"key":"DREAM_AGENT_CONCURRENCY","aliases":[],"kind":"env","lifecycle":"config_switch","surfaces":["backend"],"summary":"Parallel Dream workers and shared reserved background slots; default 2; bounded 1 to 8","fail":"closed","owner":"dazheng","created":"2026-10-12","decision":"pending"} - {"key":"DREAM_AGENT_BACKFILL_SHARE","aliases":[],"kind":"env","lifecycle":"config_switch","surfaces":["backend"],"summary":"Fraction of eligible Dream passes attempting older cleanup; default 0.1","fail":"closed","owner":"dazheng","created":"2026-10-12","decision":"pending"} - {"key":"DREAM_AGENT_RANDOM_SEED_SHARE","aliases":[],"kind":"env","lifecycle":"config_switch","surfaces":["backend"],"summary":"Uniform sample fraction of cleanup attempts; default 0.1; offsets can bill linear reads","fail":"closed","owner":"dazheng","created":"2026-10-12","decision":"pending"} diff --git a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift index 8225e643e72..cb7a5566784 100644 --- a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift +++ b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift @@ -722,6 +722,32 @@ 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 @@ -4293,6 +4319,26 @@ 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? @@ -4952,6 +4998,196 @@ public enum OmiAPI { } + public struct StorefrontCurrentPlan: Codable { + public let legacy: Bool + public let planId: String + public let priceId: String? + + private enum CodingKeys: String, CodingKey { + case legacy + case planId = "plan_id" + case priceId = "price_id" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + legacy = try c.decode(Bool.self, forKey: .legacy) + 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) { + self.legacy = legacy + self.planId = planId + self.priceId = priceId + } + } + + + public struct StorefrontPrice: Codable { + public let amountMinor: Int + public let annualSavings: AnnualSavings? + public let currency: String + public let formattedAmount: String + public let interval: String + public let monthlyEquivalent: String + public let priceId: String? + + private enum CodingKeys: String, CodingKey { + case amountMinor = "amount_minor" + case annualSavings = "annual_savings" + case currency + case formattedAmount = "formatted_amount" + case interval + case monthlyEquivalent = "monthly_equivalent" + case priceId = "price_id" + } + + 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) + currency = try c.decode(String.self, forKey: .currency) + formattedAmount = try c.decode(String.self, forKey: .formattedAmount) + interval = try c.decode(String.self, forKey: .interval) + monthlyEquivalent = try c.decode(String.self, forKey: .monthlyEquivalent) + priceId = try c.decodeIfPresent(String.self, forKey: .priceId) + } + + public init(amountMinor: Int, annualSavings: AnnualSavings? = nil, currency: String, formattedAmount: String, interval: String, monthlyEquivalent: String, priceId: String? = nil) { + self.amountMinor = amountMinor + self.annualSavings = annualSavings + self.currency = currency + self.formattedAmount = formattedAmount + self.interval = interval + self.monthlyEquivalent = monthlyEquivalent + self.priceId = priceId + } + } + + + public struct StorefrontResponse: Codable { + public let catalogRevision: Int + public let current: StorefrontCurrentPlan? + public let nextOfferTierId: String? + public let schemaVersion: Int? + public let showSubscriptionUi: Bool? + public let tiers: [StorefrontTier]? + public let usage: StorefrontUsage? + + private enum CodingKeys: String, CodingKey { + case catalogRevision = "catalog_revision" + case current + case nextOfferTierId = "next_offer_tier_id" + case schemaVersion = "schema_version" + case showSubscriptionUi = "show_subscription_ui" + case tiers + case usage + } + + 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) + 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) + } + + public init(catalogRevision: Int, current: StorefrontCurrentPlan? = nil, nextOfferTierId: String? = nil, schemaVersion: Int? = nil, showSubscriptionUi: Bool? = nil, tiers: [StorefrontTier]? = nil, usage: StorefrontUsage? = nil) { + self.catalogRevision = catalogRevision + self.current = current + self.nextOfferTierId = nextOfferTierId + self.schemaVersion = schemaVersion + self.showSubscriptionUi = showSubscriptionUi + self.tiers = tiers + self.usage = usage + } + } + + + 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 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 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) + 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) { + self.action = action + self.displayName = displayName + self.entitlementVersion = entitlementVersion + self.features = features + self.legacy = legacy + 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? + + private enum CodingKeys: String, CodingKey { + case budgetPercent = "budget_percent" + case kind + case resetAt = "reset_at" + } + + 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) + } + + public init(budgetPercent: Double? = nil, kind: String? = nil, resetAt: Int? = nil) { + self.budgetPercent = budgetPercent + self.kind = kind + self.resetAt = resetAt + } + } + + public struct Structured: Codable { public let actionItems: [ActionItem]? public let category: CategoryEnum? @@ -18455,6 +18691,30 @@ public enum OmiAPI { return try JSONDecoder().decode(OmiAnyCodable.self, from: data) } + public static func getStorefrontV2PaymentsPlansGet(client: OmiApiClient, xAppPlatform: String? = nil, xAppVersion: String? = nil, authorization: String? = nil, xDeviceIdHash: String? = nil) async throws -> StorefrontResponse { + let _path = "/v2/payments/plans" + guard let components = URLComponents(string: client.baseURL + _path) else { + throw OmiApiError.invalidURL + } + guard let url = components.url else { throw OmiApiError.invalidURL } + var req = URLRequest(url: url) + req.httpMethod = "GET" + for (name, value) in client.headers { req.setValue(value, forHTTPHeaderField: name) } + if let token = client.token { + req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") + } + if let xAppPlatform { req.setValue(String(xAppPlatform), forHTTPHeaderField: "X-App-Platform") } + if let xAppVersion { req.setValue(String(xAppVersion), forHTTPHeaderField: "X-App-Version") } + if let authorization { req.setValue(String(authorization), forHTTPHeaderField: "authorization") } + if let xDeviceIdHash { req.setValue(String(xDeviceIdHash), forHTTPHeaderField: "X-Device-Id-Hash") } + let (data, resp) = try await URLSession.shared.data(for: req) + guard let http = resp as? HTTPURLResponse else { throw OmiApiError.invalidURL } + guard (200..<300).contains(http.statusCode) else { + throw OmiApiError.httpError(status: http.statusCode, data: data) + } + return try JSONDecoder().decode(StorefrontResponse.self, from: data) + } + public static func getReviewItemsV2V2ReviewItemsGet(client: OmiApiClient, authorization: String? = nil, xAppPlatform: String? = nil, xDeviceIdHash: String? = nil, xAppVersion: String? = nil) async throws -> OmiAnyCodable { let _path = "/v2/review/items" guard let components = URLComponents(string: client.baseURL + _path) else { @@ -19293,5 +19553,5 @@ public enum OmiAPI { return try JSONDecoder().decode(OmiAnyCodable.self, from: data) } - // Total: 499 Swift client methods generated. + // Total: 500 Swift client methods generated. } diff --git a/desktop/windows/src/renderer/src/lib/omiApi.generated.ts b/desktop/windows/src/renderer/src/lib/omiApi.generated.ts index 3a83a6e7d6b..c8d8bd3a354 100644 --- a/desktop/windows/src/renderer/src/lib/omiApi.generated.ts +++ b/desktop/windows/src/renderer/src/lib/omiApi.generated.ts @@ -232,6 +232,12 @@ 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; @@ -3798,6 +3804,12 @@ 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; @@ -4910,6 +4922,51 @@ export interface StoreRecordingPermissionResponse { store_recording_permission: boolean; } +export interface StorefrontCurrentPlan { + legacy: boolean; + plan_id: string; + price_id?: string | null; +} + +export interface StorefrontPrice { + amount_minor: number; + annual_savings?: AnnualSavings | null; + currency: string; + formatted_amount: string; + interval: "month" | "year"; + monthly_equivalent: string; + price_id?: string | null; +} + +export interface StorefrontResponse { + catalog_revision: number; + current?: StorefrontCurrentPlan | null; + next_offer_tier_id?: string | null; + schema_version?: 1; + show_subscription_ui?: boolean; + tiers?: Array; + usage?: StorefrontUsage; +} + +export interface StorefrontTier { + action: "current" | "upgrade" | "downgrade_not_offered" | "contact"; + display_name: string; + entitlement_version: string; + features: Array; + legacy?: boolean; + 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; +} + export interface StripeConnectAccountResponse { account_id: string; url: string; @@ -5929,6 +5986,7 @@ export interface OmiApiSchemas { "Announcement": Announcement; "AnnouncementDeleteResponse": AnnouncementDeleteResponse; "AnnouncementType": AnnouncementType; + "AnnualSavings": AnnualSavings; "App": App; "AppApiKeyResponse": AppApiKeyResponse; "AppBaseModel": AppBaseModel; @@ -6401,6 +6459,7 @@ export interface OmiApiSchemas { "PhoneMutationResponse": PhoneMutationResponse; "PhoneNumberResponse": PhoneNumberResponse; "PhoneNumbersResponse": PhoneNumbersResponse; + "PlanFeature": PlanFeature; "PlanIdentitySubscription": PlanIdentitySubscription; "PlanIdentityUserSubscriptionResponse": PlanIdentityUserSubscriptionResponse; "PlanLimits": PlanLimits; @@ -6556,6 +6615,11 @@ export interface OmiApiSchemas { "StoreMeetingRequest": StoreMeetingRequest; "StoreMeetingResponse": StoreMeetingResponse; "StoreRecordingPermissionResponse": StoreRecordingPermissionResponse; + "StorefrontCurrentPlan": StorefrontCurrentPlan; + "StorefrontPrice": StorefrontPrice; + "StorefrontResponse": StorefrontResponse; + "StorefrontTier": StorefrontTier; + "StorefrontUsage": StorefrontUsage; "StripeConnectAccountResponse": StripeConnectAccountResponse; "StripeOnboardingStatusResponse": StripeOnboardingStatusResponse; "StripeSupportedCountryResponse": StripeSupportedCountryResponse; @@ -11454,6 +11518,16 @@ export interface OmiApiPaths { }; }; }; + "/v2/payments/plans": { + get: { + operationId: "get_storefront_v2_payments_plans_get"; + responses: { + "200": StorefrontResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v2/review/items": { get: { operationId: "get_review_items_v2_v2_review_items_get"; @@ -21136,6 +21210,25 @@ 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(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 _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "GET", + headers: { + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + }, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function get_review_items_v2_v2_review_items_get(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { const _base = init?.baseURL ?? ""; const _path = `/v2/review/items`; @@ -21767,4 +21860,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 499 client methods generated. +// Total: 500 client methods generated. diff --git a/docs/api-reference/app-client-openapi.json b/docs/api-reference/app-client-openapi.json index f28193e2242..87834a48d98 100644 --- a/docs/api-reference/app-client-openapi.json +++ b/docs/api-reference/app-client-openapi.json @@ -1674,6 +1674,29 @@ "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": { @@ -23576,6 +23599,29 @@ "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": { @@ -29850,6 +29896,256 @@ "title": "StoreRecordingPermissionResponse", "type": "object" }, + "StorefrontCurrentPlan": { + "properties": { + "legacy": { + "title": "Legacy", + "type": "boolean" + }, + "plan_id": { + "title": "Plan Id", + "type": "string" + }, + "price_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Price Id" + } + }, + "required": [ + "plan_id", + "legacy" + ], + "title": "StorefrontCurrentPlan", + "type": "object" + }, + "StorefrontPrice": { + "properties": { + "amount_minor": { + "title": "Amount Minor", + "type": "integer" + }, + "annual_savings": { + "anyOf": [ + { + "$ref": "#/components/schemas/AnnualSavings" + }, + { + "type": "null" + } + ] + }, + "currency": { + "title": "Currency", + "type": "string" + }, + "formatted_amount": { + "title": "Formatted Amount", + "type": "string" + }, + "interval": { + "enum": [ + "month", + "year" + ], + "title": "Interval", + "type": "string" + }, + "monthly_equivalent": { + "title": "Monthly Equivalent", + "type": "string" + }, + "price_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Price Id" + } + }, + "required": [ + "interval", + "currency", + "amount_minor", + "formatted_amount", + "monthly_equivalent" + ], + "title": "StorefrontPrice", + "type": "object" + }, + "StorefrontResponse": { + "properties": { + "catalog_revision": { + "title": "Catalog Revision", + "type": "integer" + }, + "current": { + "anyOf": [ + { + "$ref": "#/components/schemas/StorefrontCurrentPlan" + }, + { + "type": "null" + } + ] + }, + "next_offer_tier_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Next Offer Tier Id" + }, + "schema_version": { + "const": 1, + "default": 1, + "title": "Schema Version", + "type": "integer" + }, + "show_subscription_ui": { + "default": true, + "title": "Show Subscription Ui", + "type": "boolean" + }, + "tiers": { + "items": { + "$ref": "#/components/schemas/StorefrontTier" + }, + "title": "Tiers", + "type": "array" + }, + "usage": { + "$ref": "#/components/schemas/StorefrontUsage" + } + }, + "required": [ + "catalog_revision" + ], + "title": "StorefrontResponse", + "type": "object" + }, + "StorefrontTier": { + "properties": { + "action": { + "enum": [ + "current", + "upgrade", + "downgrade_not_offered", + "contact" + ], + "title": "Action", + "type": "string" + }, + "display_name": { + "title": "Display Name", + "type": "string" + }, + "entitlement_version": { + "title": "Entitlement Version", + "type": "string" + }, + "features": { + "items": { + "$ref": "#/components/schemas/PlanFeature" + }, + "title": "Features", + "type": "array" + }, + "legacy": { + "default": false, + "title": "Legacy", + "type": "boolean" + }, + "plan_id": { + "title": "Plan Id", + "type": "string" + }, + "prices": { + "items": { + "$ref": "#/components/schemas/StorefrontPrice" + }, + "title": "Prices", + "type": "array" + }, + "recommended": { + "default": false, + "title": "Recommended", + "type": "boolean" + }, + "tagline": { + "title": "Tagline", + "type": "string" + }, + "tier_id": { + "title": "Tier Id", + "type": "string" + } + }, + "required": [ + "tier_id", + "plan_id", + "entitlement_version", + "display_name", + "tagline", + "features", + "prices", + "action" + ], + "title": "StorefrontTier", + "type": "object" + }, + "StorefrontUsage": { + "properties": { + "budget_percent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Budget Percent" + }, + "kind": { + "default": "unavailable", + "enum": [ + "transcription", + "usage_budget", + "unavailable" + ], + "title": "Kind", + "type": "string" + }, + "reset_at": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Reset At" + } + }, + "title": "StorefrontUsage", + "type": "object" + }, "StripeConnectAccountResponse": { "properties": { "account_id": { @@ -78132,6 +78428,83 @@ ] } }, + "/v2/payments/plans": { + "get": { + "operationId": "get_storefront_v2_payments_plans_get", + "parameters": [ + { + "in": "header", + "name": "X-App-Platform", + "required": false, + "schema": { + "title": "X-App-Platform", + "type": "string" + } + }, + { + "in": "header", + "name": "X-App-Version", + "required": false, + "schema": { + "title": "X-App-Version", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "title": "Authorization", + "type": "string" + } + }, + { + "in": "header", + "name": "X-Device-Id-Hash", + "required": false, + "schema": { + "title": "X-Device-Id-Hash", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorefrontResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "$ref": "#/components/responses/Error401" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "firebaseBearer": [] + } + ], + "summary": "Get Storefront", + "tags": [ + "payments" + ] + } + }, "/v2/review/items": { "get": { "operationId": "get_review_items_v2_v2_review_items_get", diff --git a/web/admin/lib/services/omi-api/omiApi.generated.ts b/web/admin/lib/services/omi-api/omiApi.generated.ts index 3a83a6e7d6b..c8d8bd3a354 100644 --- a/web/admin/lib/services/omi-api/omiApi.generated.ts +++ b/web/admin/lib/services/omi-api/omiApi.generated.ts @@ -232,6 +232,12 @@ 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; @@ -3798,6 +3804,12 @@ 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; @@ -4910,6 +4922,51 @@ export interface StoreRecordingPermissionResponse { store_recording_permission: boolean; } +export interface StorefrontCurrentPlan { + legacy: boolean; + plan_id: string; + price_id?: string | null; +} + +export interface StorefrontPrice { + amount_minor: number; + annual_savings?: AnnualSavings | null; + currency: string; + formatted_amount: string; + interval: "month" | "year"; + monthly_equivalent: string; + price_id?: string | null; +} + +export interface StorefrontResponse { + catalog_revision: number; + current?: StorefrontCurrentPlan | null; + next_offer_tier_id?: string | null; + schema_version?: 1; + show_subscription_ui?: boolean; + tiers?: Array; + usage?: StorefrontUsage; +} + +export interface StorefrontTier { + action: "current" | "upgrade" | "downgrade_not_offered" | "contact"; + display_name: string; + entitlement_version: string; + features: Array; + legacy?: boolean; + 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; +} + export interface StripeConnectAccountResponse { account_id: string; url: string; @@ -5929,6 +5986,7 @@ export interface OmiApiSchemas { "Announcement": Announcement; "AnnouncementDeleteResponse": AnnouncementDeleteResponse; "AnnouncementType": AnnouncementType; + "AnnualSavings": AnnualSavings; "App": App; "AppApiKeyResponse": AppApiKeyResponse; "AppBaseModel": AppBaseModel; @@ -6401,6 +6459,7 @@ export interface OmiApiSchemas { "PhoneMutationResponse": PhoneMutationResponse; "PhoneNumberResponse": PhoneNumberResponse; "PhoneNumbersResponse": PhoneNumbersResponse; + "PlanFeature": PlanFeature; "PlanIdentitySubscription": PlanIdentitySubscription; "PlanIdentityUserSubscriptionResponse": PlanIdentityUserSubscriptionResponse; "PlanLimits": PlanLimits; @@ -6556,6 +6615,11 @@ export interface OmiApiSchemas { "StoreMeetingRequest": StoreMeetingRequest; "StoreMeetingResponse": StoreMeetingResponse; "StoreRecordingPermissionResponse": StoreRecordingPermissionResponse; + "StorefrontCurrentPlan": StorefrontCurrentPlan; + "StorefrontPrice": StorefrontPrice; + "StorefrontResponse": StorefrontResponse; + "StorefrontTier": StorefrontTier; + "StorefrontUsage": StorefrontUsage; "StripeConnectAccountResponse": StripeConnectAccountResponse; "StripeOnboardingStatusResponse": StripeOnboardingStatusResponse; "StripeSupportedCountryResponse": StripeSupportedCountryResponse; @@ -11454,6 +11518,16 @@ export interface OmiApiPaths { }; }; }; + "/v2/payments/plans": { + get: { + operationId: "get_storefront_v2_payments_plans_get"; + responses: { + "200": StorefrontResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v2/review/items": { get: { operationId: "get_review_items_v2_v2_review_items_get"; @@ -21136,6 +21210,25 @@ 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(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 _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "GET", + headers: { + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + }, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function get_review_items_v2_v2_review_items_get(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { const _base = init?.baseURL ?? ""; const _path = `/v2/review/items`; @@ -21767,4 +21860,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 499 client methods generated. +// Total: 500 client methods generated. diff --git a/web/app/src/lib/omiApi.generated.ts b/web/app/src/lib/omiApi.generated.ts index 3a83a6e7d6b..c8d8bd3a354 100644 --- a/web/app/src/lib/omiApi.generated.ts +++ b/web/app/src/lib/omiApi.generated.ts @@ -232,6 +232,12 @@ 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; @@ -3798,6 +3804,12 @@ 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; @@ -4910,6 +4922,51 @@ export interface StoreRecordingPermissionResponse { store_recording_permission: boolean; } +export interface StorefrontCurrentPlan { + legacy: boolean; + plan_id: string; + price_id?: string | null; +} + +export interface StorefrontPrice { + amount_minor: number; + annual_savings?: AnnualSavings | null; + currency: string; + formatted_amount: string; + interval: "month" | "year"; + monthly_equivalent: string; + price_id?: string | null; +} + +export interface StorefrontResponse { + catalog_revision: number; + current?: StorefrontCurrentPlan | null; + next_offer_tier_id?: string | null; + schema_version?: 1; + show_subscription_ui?: boolean; + tiers?: Array; + usage?: StorefrontUsage; +} + +export interface StorefrontTier { + action: "current" | "upgrade" | "downgrade_not_offered" | "contact"; + display_name: string; + entitlement_version: string; + features: Array; + legacy?: boolean; + 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; +} + export interface StripeConnectAccountResponse { account_id: string; url: string; @@ -5929,6 +5986,7 @@ export interface OmiApiSchemas { "Announcement": Announcement; "AnnouncementDeleteResponse": AnnouncementDeleteResponse; "AnnouncementType": AnnouncementType; + "AnnualSavings": AnnualSavings; "App": App; "AppApiKeyResponse": AppApiKeyResponse; "AppBaseModel": AppBaseModel; @@ -6401,6 +6459,7 @@ export interface OmiApiSchemas { "PhoneMutationResponse": PhoneMutationResponse; "PhoneNumberResponse": PhoneNumberResponse; "PhoneNumbersResponse": PhoneNumbersResponse; + "PlanFeature": PlanFeature; "PlanIdentitySubscription": PlanIdentitySubscription; "PlanIdentityUserSubscriptionResponse": PlanIdentityUserSubscriptionResponse; "PlanLimits": PlanLimits; @@ -6556,6 +6615,11 @@ export interface OmiApiSchemas { "StoreMeetingRequest": StoreMeetingRequest; "StoreMeetingResponse": StoreMeetingResponse; "StoreRecordingPermissionResponse": StoreRecordingPermissionResponse; + "StorefrontCurrentPlan": StorefrontCurrentPlan; + "StorefrontPrice": StorefrontPrice; + "StorefrontResponse": StorefrontResponse; + "StorefrontTier": StorefrontTier; + "StorefrontUsage": StorefrontUsage; "StripeConnectAccountResponse": StripeConnectAccountResponse; "StripeOnboardingStatusResponse": StripeOnboardingStatusResponse; "StripeSupportedCountryResponse": StripeSupportedCountryResponse; @@ -11454,6 +11518,16 @@ export interface OmiApiPaths { }; }; }; + "/v2/payments/plans": { + get: { + operationId: "get_storefront_v2_payments_plans_get"; + responses: { + "200": StorefrontResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v2/review/items": { get: { operationId: "get_review_items_v2_v2_review_items_get"; @@ -21136,6 +21210,25 @@ 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(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 _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "GET", + headers: { + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + }, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function get_review_items_v2_v2_review_items_get(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { const _base = init?.baseURL ?? ""; const _path = `/v2/review/items`; @@ -21767,4 +21860,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 499 client methods generated. +// Total: 500 client methods generated. diff --git a/web/personas-open-source/src/lib/omiApi.generated.ts b/web/personas-open-source/src/lib/omiApi.generated.ts index 3a83a6e7d6b..c8d8bd3a354 100644 --- a/web/personas-open-source/src/lib/omiApi.generated.ts +++ b/web/personas-open-source/src/lib/omiApi.generated.ts @@ -232,6 +232,12 @@ 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; @@ -3798,6 +3804,12 @@ 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; @@ -4910,6 +4922,51 @@ export interface StoreRecordingPermissionResponse { store_recording_permission: boolean; } +export interface StorefrontCurrentPlan { + legacy: boolean; + plan_id: string; + price_id?: string | null; +} + +export interface StorefrontPrice { + amount_minor: number; + annual_savings?: AnnualSavings | null; + currency: string; + formatted_amount: string; + interval: "month" | "year"; + monthly_equivalent: string; + price_id?: string | null; +} + +export interface StorefrontResponse { + catalog_revision: number; + current?: StorefrontCurrentPlan | null; + next_offer_tier_id?: string | null; + schema_version?: 1; + show_subscription_ui?: boolean; + tiers?: Array; + usage?: StorefrontUsage; +} + +export interface StorefrontTier { + action: "current" | "upgrade" | "downgrade_not_offered" | "contact"; + display_name: string; + entitlement_version: string; + features: Array; + legacy?: boolean; + 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; +} + export interface StripeConnectAccountResponse { account_id: string; url: string; @@ -5929,6 +5986,7 @@ export interface OmiApiSchemas { "Announcement": Announcement; "AnnouncementDeleteResponse": AnnouncementDeleteResponse; "AnnouncementType": AnnouncementType; + "AnnualSavings": AnnualSavings; "App": App; "AppApiKeyResponse": AppApiKeyResponse; "AppBaseModel": AppBaseModel; @@ -6401,6 +6459,7 @@ export interface OmiApiSchemas { "PhoneMutationResponse": PhoneMutationResponse; "PhoneNumberResponse": PhoneNumberResponse; "PhoneNumbersResponse": PhoneNumbersResponse; + "PlanFeature": PlanFeature; "PlanIdentitySubscription": PlanIdentitySubscription; "PlanIdentityUserSubscriptionResponse": PlanIdentityUserSubscriptionResponse; "PlanLimits": PlanLimits; @@ -6556,6 +6615,11 @@ export interface OmiApiSchemas { "StoreMeetingRequest": StoreMeetingRequest; "StoreMeetingResponse": StoreMeetingResponse; "StoreRecordingPermissionResponse": StoreRecordingPermissionResponse; + "StorefrontCurrentPlan": StorefrontCurrentPlan; + "StorefrontPrice": StorefrontPrice; + "StorefrontResponse": StorefrontResponse; + "StorefrontTier": StorefrontTier; + "StorefrontUsage": StorefrontUsage; "StripeConnectAccountResponse": StripeConnectAccountResponse; "StripeOnboardingStatusResponse": StripeOnboardingStatusResponse; "StripeSupportedCountryResponse": StripeSupportedCountryResponse; @@ -11454,6 +11518,16 @@ export interface OmiApiPaths { }; }; }; + "/v2/payments/plans": { + get: { + operationId: "get_storefront_v2_payments_plans_get"; + responses: { + "200": StorefrontResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v2/review/items": { get: { operationId: "get_review_items_v2_v2_review_items_get"; @@ -21136,6 +21210,25 @@ 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(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 _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "GET", + headers: { + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + ...(header.X_App_Platform !== undefined ? { "X-App-Platform": String(header.X_App_Platform) } : {}), + ...(header.X_App_Version !== undefined ? { "X-App-Version": String(header.X_App_Version) } : {}), + ...(header.authorization !== undefined ? { "authorization": String(header.authorization) } : {}), + ...(header.X_Device_Id_Hash !== undefined ? { "X-Device-Id-Hash": String(header.X_Device_Id_Hash) } : {}), + }, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function get_review_items_v2_v2_review_items_get(header: { authorization?: string, X_App_Platform?: string, X_Device_Id_Hash?: string, X_App_Version?: string }, init?: OmiApiClientInit): Promise { const _base = init?.baseURL ?? ""; const _path = `/v2/review/items`; @@ -21767,4 +21860,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 499 client methods generated. +// Total: 500 client methods generated. From 062434bacc9d68987cd37572d8420b66454ee366 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 13 Oct 2026 12:55:53 +0700 Subject: [PATCH 02/14] refactor(codegen): extract storefront and goals schema-group metadata --- backend/scripts/dart_schema_groups.py | 32 +++++++++++++++++++++++++ backend/scripts/generate_dart_models.py | 29 +++++----------------- 2 files changed, 38 insertions(+), 23 deletions(-) create mode 100644 backend/scripts/dart_schema_groups.py diff --git a/backend/scripts/dart_schema_groups.py b/backend/scripts/dart_schema_groups.py new file mode 100644 index 00000000000..4b737d4a065 --- /dev/null +++ b/backend/scripts/dart_schema_groups.py @@ -0,0 +1,32 @@ +"""Schema-group metadata kept outside the oversized Dart generator.""" + +from pathlib import Path +from typing import Any + + +def additional_schema_groups(output_dir: Path) -> dict[str, dict[str, Any]]: + return { + 'goals': { + 'output': output_dir / 'goals_wire.g.dart', + 'schemas': ( + 'GoalMetric', + 'GoalResponse', + 'GoalSuggestionResponse', + 'AdviceResponse', + 'GoalHistoryEntryResponse', + 'GoalDeleteResponse', + ), + }, + 'storefront': { + 'output': output_dir / 'storefront_wire.g.dart', + 'schemas': ( + 'AnnualSavings', + 'PlanFeature', + 'StorefrontCurrentPlan', + 'StorefrontPrice', + 'StorefrontResponse', + 'StorefrontTier', + 'StorefrontUsage', + ), + }, + } diff --git a/backend/scripts/generate_dart_models.py b/backend/scripts/generate_dart_models.py index 306b70a1ad9..15b1dae3a6a 100644 --- a/backend/scripts/generate_dart_models.py +++ b/backend/scripts/generate_dart_models.py @@ -10,6 +10,11 @@ from pathlib import Path from typing import Any, cast +if __package__: + from .dart_schema_groups import additional_schema_groups +else: + from dart_schema_groups import additional_schema_groups + ROOT_DIR = Path(__file__).resolve().parents[2] DEFAULT_SPEC_PATH = ROOT_DIR / 'docs' / 'api-reference' / 'app-client-openapi.json' DEFAULT_OUTPUT_DIR = ROOT_DIR / 'app' / 'lib' / 'backend' / 'schema' / 'gen' @@ -531,18 +536,6 @@ 'AudioUrlsResponse', ), }, - 'storefront': { - 'output': DEFAULT_OUTPUT_DIR / 'storefront_wire.g.dart', - 'schemas': ( - 'AnnualSavings', - 'PlanFeature', - 'StorefrontCurrentPlan', - 'StorefrontPrice', - 'StorefrontResponse', - 'StorefrontTier', - 'StorefrontUsage', - ), - }, 'payments': { 'output': DEFAULT_OUTPUT_DIR / 'payments_wire.g.dart', 'schemas': ( @@ -578,17 +571,7 @@ 'MemoryRevertRequest', ), }, - 'goals': { - 'output': DEFAULT_OUTPUT_DIR / 'goals_wire.g.dart', - 'schemas': ( - 'GoalMetric', - 'GoalResponse', - 'GoalSuggestionResponse', - 'AdviceResponse', - 'GoalHistoryEntryResponse', - 'GoalDeleteResponse', - ), - }, + **additional_schema_groups(DEFAULT_OUTPUT_DIR), } ALIASES = { 'Structured': {'action_items': ('actionItems',)}, From 269820c7388c1ea63028958291f3370e3d9c52a2 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 13 Oct 2026 12:59:20 +0700 Subject: [PATCH 03/14] chore(mobile): record internal storefront wire contract --- app/changelog/unreleased/20261013-storefront-wire-contract.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 app/changelog/unreleased/20261013-storefront-wire-contract.json diff --git a/app/changelog/unreleased/20261013-storefront-wire-contract.json b/app/changelog/unreleased/20261013-storefront-wire-contract.json new file mode 100644 index 00000000000..005f4da6232 --- /dev/null +++ b/app/changelog/unreleased/20261013-storefront-wire-contract.json @@ -0,0 +1 @@ +{"kind":"none"} From 5f714cc6ab82bf2e07a8d5a48c00c120bd5d87b2 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 13 Oct 2026 13:17:50 +0700 Subject: [PATCH 04/14] test(billing): reuse payment router for byte-identity cases --- backend/tests/unit/test_storefront_off_path.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/backend/tests/unit/test_storefront_off_path.py b/backend/tests/unit/test_storefront_off_path.py index b3ebdaf0666..9ae00c3a88b 100644 --- a/backend/tests/unit/test_storefront_off_path.py +++ b/backend/tests/unit/test_storefront_off_path.py @@ -11,10 +11,21 @@ from models.users import PlanType, Subscription, SubscriptionStatus +@pytest.fixture(scope='module') +def client(): + app = FastAPI() + app.include_router(payment.router) + app.dependency_overrides[payment.auth.get_current_user_uid_no_byok_validation] = lambda: 'fixture-user' + with TestClient(app) as client: + yield client + + @pytest.mark.parametrize('platform', ['ios', 'android', 'macos', 'windows', 'web']) @pytest.mark.parametrize('cancel_at_period_end', [False, True]) @pytest.mark.parametrize('plan', ['basic', 'plus', 'operator', 'architect', 'unlimited', 'unlimited_v2']) -def test_v1_response_bytes_do_not_change_with_storefront_flag(monkeypatch, platform, plan, cancel_at_period_end): +def test_v1_response_bytes_do_not_change_with_storefront_flag( + client, monkeypatch, platform, plan, cancel_at_period_end +): monkeypatch.setattr( payment.users_db, 'get_user_subscription', @@ -40,10 +51,6 @@ def retrieve(identifier): stripe_sub = MagicMock() stripe_sub.to_dict.return_value = {'items': {'data': [{'price': {'id': f'price_{plan.upper()}_month'}}]}} monkeypatch.setattr(stripe.Subscription, 'retrieve', lambda identifier: stripe_sub) - app = FastAPI() - app.include_router(payment.router) - app.dependency_overrides[payment.auth.get_current_user_uid_no_byok_validation] = lambda: 'fixture-user' - client = TestClient(app) headers = {'X-App-Platform': platform, 'X-App-Version': '99.0.0'} monkeypatch.delenv('STOREFRONT_LADDER_V1_ENABLED', raising=False) before = client.get('/v1/payments/available-plans', headers=headers) From 012d3589fdf5f946e388c52e8d47252165654cbe Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 13 Oct 2026 14:11:31 +0700 Subject: [PATCH 05/14] feat(billing): recommend storefront tier from opening context --- backend/docs/storefront-contract.md | 17 +++++ backend/routers/storefront.py | 8 ++- backend/tests/unit/test_storefront.py | 76 +++++++++++++++++++++- backend/utils/storefront.py | 9 ++- docs/api-reference/app-client-openapi.json | 40 ++++++++++++ 5 files changed, 146 insertions(+), 4 deletions(-) diff --git a/backend/docs/storefront-contract.md b/backend/docs/storefront-contract.md index 13533ea68a1..30bd6d970af 100644 --- a/backend/docs/storefront-contract.md +++ b/backend/docs/storefront-contract.md @@ -36,6 +36,23 @@ 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 and all other paid callers (including legacy +subscribers priced at or above Plus) recommend Pro. Without gate context, Free +gets Plus and everyone else gets Pro. Unknown sources use the same default; +invalid triggers return 422. + +`recommended` is a backend-authored badge, never a selection or checkout action. +It may appear on a current/contact 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. diff --git a/backend/routers/storefront.py b/backend/routers/storefront.py index cb312465b4a..7f8d012e8fd 100644 --- a/backend/routers/storefront.py +++ b/backend/routers/storefront.py @@ -2,9 +2,9 @@ from datetime import datetime, timezone import math -from typing import Any, Optional +from typing import Any, Literal, Optional -from fastapi import APIRouter, Depends, Header, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException, Query import stripe import config.plan_catalog_generated as catalog @@ -73,6 +73,8 @@ def _usage(uid: str, subscription: Subscription) -> StorefrontUsage: @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), uid: str = Depends(auth.get_current_user_uid_no_byok_validation), x_app_platform: Optional[str] = Header(None, alias='X-App-Platform'), x_app_version: Optional[str] = Header(None, alias='X-App-Version'), @@ -108,4 +110,6 @@ def get_storefront( retrieve_price=_retrieve_price, purchasable=subscription_utils.is_purchasable_price_id, usage=_usage(uid, subscription), + source=source, + trigger=trigger, ) diff --git a/backend/tests/unit/test_storefront.py b/backend/tests/unit/test_storefront.py index fe38233a0a9..44f7597b4ec 100644 --- a/backend/tests/unit/test_storefront.py +++ b/backend/tests/unit/test_storefront.py @@ -25,7 +25,7 @@ def price(identifier, amount, interval='month', **overrides): } -def build(plan='basic', current_price=None, rows=None, bindings=None, recognized=None): +def build(plan='basic', current_price=None, rows=None, bindings=None, recognized=None, source=None, trigger=None): return build_storefront( catalog_revision=6, ladder=load_ladder(), @@ -35,6 +35,8 @@ def build(plan='basic', current_price=None, rows=None, bindings=None, recognized retrieve_price=lambda key: (rows or {})[key], purchasable=lambda key: True, usage=StorefrontUsage(), + source=source, + trigger=trigger, ) @@ -237,3 +239,75 @@ def test_shared_client_fixture_matches_production_presentation(): ) 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 + + +@pytest.mark.parametrize('plan', ['basic', 'plus', 'operator', 'architect', 'unlimited', 'unlimited_v2']) +@pytest.mark.parametrize('trigger', [None, 'proactive_agent', 'screen_context', 'usage_budget', 'mcp_daily_cap']) +def test_contextual_recommendation_per_subscription_and_gate(plan, trigger): + rows = {f'price_{p}': price(f'price_{p}', amount) for p, amount in [('plus', 1999), ('operator', 4999)]} + recognized = [ + { + 'price_id': key, + 'plan_id': key.removeprefix('price_'), + 'interval': 'month', + 'entitlement_version': 'ladder_v1', + } + for key in rows + ] + # Non-basic includes legacy plans; their recommendation must not authorize migration. + sheet = build( + plan, + None if plan == 'basic' else 'price_legacy', + rows, + list(rows), + recognized, + source='macos_usage_limit', + trigger=trigger, + ) + expected = 'operator' if plan != 'basic' or trigger in {'proactive_agent', 'screen_context'} else 'plus' + 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) + + +@pytest.mark.parametrize( + 'trigger,expected', + [ + (None, 'plus'), + ('proactive_agent', 'operator'), + ('screen_context', 'operator'), + ('usage_budget', 'plus'), + ('mcp_daily_cap', 'plus'), + ], +) +def test_endpoint_forwards_opening_context(client, monkeypatch, trigger, expected): + rows = {f'price_{p}': price(f'price_{p}', amount) for p, amount in [('plus', 1999), ('operator', 4999)]} + monkeypatch.setattr(route.catalog, 'LADDER_V1_STRIPE_PRICES', list(rows), raising=False) + monkeypatch.setattr( + route.catalog, 'RECOGNIZED_STRIPE_ENTITLEMENT_VERSIONS', {key: 'ladder_v1' for key in rows}, raising=False + ) + monkeypatch.setattr( + route.catalog, 'RECOGNIZED_STRIPE_PRICE_PLAN_TYPES', {key: PlanType(key.removeprefix('price_')) for key in rows} + ) + monkeypatch.setattr(route.catalog, 'RECOGNIZED_STRIPE_PRICE_INTERVALS', {key: 'month' for key in rows}) + monkeypatch.setattr(route.subscription_utils, 'is_purchasable_price_id', lambda key: True) + monkeypatch.setattr(route, '_retrieve_price', lambda key: rows[key]) + real_build = route.build_storefront + seen = {} + + def capture(**kwargs): + seen.update(kwargs) + return real_build(**kwargs) + + monkeypatch.setattr(route, 'build_storefront', capture) + params = {'source': 'macos_usage_limit'} + if trigger: + params['trigger'] = trigger + response = client.get('/v2/payments/plans', params=params) + assert response.status_code == 200 + assert seen['source'] == 'macos_usage_limit' and seen['trigger'] == trigger + assert [tier['plan_id'] for tier in response.json()['tiers'] if tier['recommended']] == [expected] + + +def test_invalid_gate_is_rejected(client): + assert client.get('/v2/payments/plans?trigger=typo').status_code == 422 diff --git a/backend/utils/storefront.py b/backend/utils/storefront.py index 1f74b5217f9..631fd281418 100644 --- a/backend/utils/storefront.py +++ b/backend/utils/storefront.py @@ -91,8 +91,15 @@ def build_storefront( retrieve_price: Callable[[str], dict[str, Any]], purchasable: Callable[[str], bool], usage: StorefrontUsage, + source: str | None = None, + trigger: str | None = None, ) -> StorefrontResponse: 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 = subscription.current_price_id # Missing historical price never implies the new entitlement version. legacy = current_plan != 'basic' and current_price not in bindings @@ -155,7 +162,7 @@ def build_storefront( features=_features(tier), prices=prices, action=action, - recommended=tier['recommended'] and action == 'upgrade', + recommended=plan == recommended_plan, ) ) if legacy or (current_plan != 'basic' and not any(tier.action == 'current' for tier in tiers)): diff --git a/docs/api-reference/app-client-openapi.json b/docs/api-reference/app-client-openapi.json index 87834a48d98..da64b5e03d8 100644 --- a/docs/api-reference/app-client-openapi.json +++ b/docs/api-reference/app-client-openapi.json @@ -78432,6 +78432,46 @@ "get": { "operationId": "get_storefront_v2_payments_plans_get", "parameters": [ + { + "description": "Paywall Opened source vocabulary (#21337)", + "in": "query", + "name": "source", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Paywall Opened source vocabulary (#21337)", + "title": "Source" + } + }, + { + "in": "query", + "name": "trigger", + "required": false, + "schema": { + "anyOf": [ + { + "enum": [ + "proactive_agent", + "screen_context", + "usage_budget", + "mcp_daily_cap" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Trigger" + } + }, { "in": "header", "name": "X-App-Platform", From 1d2236bd160f1228b5668b22c746ab7533a35a99 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 13 Oct 2026 14:18:02 +0700 Subject: [PATCH 06/14] chore(codegen): refresh storefront context projections --- .../Desktop/Sources/Generated/OmiApi.generated.swift | 12 ++++++++++-- .../windows/src/renderer/src/lib/omiApi.generated.ts | 7 +++++-- web/admin/lib/services/omi-api/omiApi.generated.ts | 7 +++++-- web/app/src/lib/omiApi.generated.ts | 7 +++++-- web/personas-open-source/src/lib/omiApi.generated.ts | 7 +++++-- 5 files changed, 30 insertions(+), 10 deletions(-) diff --git a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift index cb7a5566784..8f1effde064 100644 --- a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift +++ b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift @@ -18691,11 +18691,19 @@ public enum OmiAPI { return try JSONDecoder().decode(OmiAnyCodable.self, from: data) } - public static func getStorefrontV2PaymentsPlansGet(client: OmiApiClient, 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, xAppPlatform: String? = nil, xAppVersion: String? = nil, authorization: String? = nil, xDeviceIdHash: String? = nil) async throws -> StorefrontResponse { let _path = "/v2/payments/plans" - guard let components = URLComponents(string: client.baseURL + _path) else { + guard var components = URLComponents(string: client.baseURL + _path) else { throw OmiApiError.invalidURL } + var queryItems: [URLQueryItem] = [] + if let source { + queryItems.append(URLQueryItem(name: "source", value: String(source))) + } + if let trigger { + queryItems.append(URLQueryItem(name: "trigger", value: String(trigger))) + } + if !queryItems.isEmpty { components.queryItems = queryItems } guard let url = components.url else { throw OmiApiError.invalidURL } var req = URLRequest(url: url) req.httpMethod = "GET" diff --git a/desktop/windows/src/renderer/src/lib/omiApi.generated.ts b/desktop/windows/src/renderer/src/lib/omiApi.generated.ts index c8d8bd3a354..d6b42b5db35 100644 --- a/desktop/windows/src/renderer/src/lib/omiApi.generated.ts +++ b/desktop/windows/src/renderer/src/lib/omiApi.generated.ts @@ -21210,10 +21210,13 @@ 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(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 }, 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 _search = ""; + const _params = query ? Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&') : ''; + const _search = _params ? `?${_params}` : ""; const _res = await fetch(`${_base}${_path}${_search}`, { method: "GET", headers: { diff --git a/web/admin/lib/services/omi-api/omiApi.generated.ts b/web/admin/lib/services/omi-api/omiApi.generated.ts index c8d8bd3a354..d6b42b5db35 100644 --- a/web/admin/lib/services/omi-api/omiApi.generated.ts +++ b/web/admin/lib/services/omi-api/omiApi.generated.ts @@ -21210,10 +21210,13 @@ 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(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 }, 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 _search = ""; + const _params = query ? Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&') : ''; + const _search = _params ? `?${_params}` : ""; const _res = await fetch(`${_base}${_path}${_search}`, { method: "GET", headers: { diff --git a/web/app/src/lib/omiApi.generated.ts b/web/app/src/lib/omiApi.generated.ts index c8d8bd3a354..d6b42b5db35 100644 --- a/web/app/src/lib/omiApi.generated.ts +++ b/web/app/src/lib/omiApi.generated.ts @@ -21210,10 +21210,13 @@ 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(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 }, 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 _search = ""; + const _params = query ? Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&') : ''; + const _search = _params ? `?${_params}` : ""; const _res = await fetch(`${_base}${_path}${_search}`, { method: "GET", headers: { diff --git a/web/personas-open-source/src/lib/omiApi.generated.ts b/web/personas-open-source/src/lib/omiApi.generated.ts index c8d8bd3a354..d6b42b5db35 100644 --- a/web/personas-open-source/src/lib/omiApi.generated.ts +++ b/web/personas-open-source/src/lib/omiApi.generated.ts @@ -21210,10 +21210,13 @@ 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(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 }, 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 _search = ""; + const _params = query ? Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join('&') : ''; + const _search = _params ? `?${_params}` : ""; const _res = await fetch(`${_base}${_path}${_search}`, { method: "GET", headers: { From 9c05a4fadfc5fc0822e772da7f09e86e25cda12d Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 13 Oct 2026 14:57:38 +0700 Subject: [PATCH 07/14] fix(billing): block lower storefront offers for legacy subscribers --- backend/docs/storefront-contract.md | 29 +++-- backend/route_policy_manifest.yaml | 2 +- backend/routers/storefront.py | 19 +++- backend/tests/unit/test_storefront.py | 153 +++++++++++++++++++++++++- backend/utils/rate_limit_config.py | 4 +- backend/utils/storefront.py | 27 +++-- 6 files changed, 206 insertions(+), 28 deletions(-) diff --git a/backend/docs/storefront-contract.md b/backend/docs/storefront-contract.md index 30bd6d970af..77895137b03 100644 --- a/backend/docs/storefront-contract.md +++ b/backend/docs/storefront-contract.md @@ -1,7 +1,8 @@ # First-party storefront presentation contract `GET /v2/payments/plans` uses Firebase authentication (including the existing -no-BYOK-validation payment dependency). Its response is `StorefrontResponse`, +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. @@ -42,13 +43,15 @@ 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 and all other paid callers (including legacy -subscribers priced at or above Plus) recommend Pro. Without gate context, Free -gets Plus and everyone else gets Pro. Unknown sources use the same default; +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 may appear on a current/contact tier; legacy migration actions stay contact. +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. @@ -57,7 +60,10 @@ of the badge. Clients must never preselect a recommended tier. The v2 ladder is platform independent: Free, Plus, Pro (`operator`), Architect. Neo/Unlimited appear only as an existing subscriber's separate current row. -Legacy rows use existing-benefits copy, never ladder-v1 gate claims. +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 @@ -73,7 +79,9 @@ 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. -Savings use the retrieved monthly and annual prices: `12 * monthly - annual`; +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. @@ -86,12 +94,15 @@ 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. No such function +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. Provider errors omit usage with fallback telemetry. +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 diff --git a/backend/route_policy_manifest.yaml b/backend/route_policy_manifest.yaml index 8ec27d8a7fb..9059ec28b94 100644 --- a/backend/route_policy_manifest.yaml +++ b/backend/route_policy_manifest.yaml @@ -13,7 +13,7 @@ routes: review_status: reviewed auth: {mechanisms: [firebase_id_token], placement: dependency, scopes: []} byok: skipped_for_key_rotation - rate_limit: {policy_name: none, key_subject: none, enforcement: none, placement: none} + rate_limit: {policy_name: "storefront:plans", key_subject: uid, enforcement: fail_open, placement: dependency} timeout_class: default_method surface: first_party_app visibility: first_party diff --git a/backend/routers/storefront.py b/backend/routers/storefront.py index 7f8d012e8fd..d86afd0b995 100644 --- a/backend/routers/storefront.py +++ b/backend/routers/storefront.py @@ -8,7 +8,7 @@ import stripe import config.plan_catalog_generated as catalog -from database import users as users_db +from database import redis_db, users as users_db from database.app_review_config import should_hide_subscription_ui from models.users import Subscription from models.storefront import StorefrontResponse, StorefrontUsage @@ -28,7 +28,12 @@ def _degraded(from_mode: str, to_mode: str) -> None: def _retrieve_price(price_id: str) -> dict[str, Any]: try: - return stripe.Price.retrieve(price_id).to_dict() + cache_key = f'stripe_price:{price_id}' + price_data = redis_db.get_generic_cache(cache_key) + if not price_data: + price_data = stripe.Price.retrieve(price_id).to_dict_recursive() + redis_db.set_generic_cache(cache_key, price_data, ttl=3600 * 24) + return price_data except Exception: _degraded('storefront_price', 'hidden_price') raise @@ -39,7 +44,11 @@ def _usage(uid: str, subscription: Subscription) -> StorefrontUsage: # W3 may provide a display-ready projection on the shared facade. Do not # guess units or denominators; only accept percentage/reset fields. projection = getattr(subscription_utils, 'get_usage_units', None) - if callable(projection): + ladder_subscriber = subscription.plan.value not in { + 'unlimited', + 'unlimited_v2', + } and subscription.current_price_id in getattr(catalog, 'LADDER_V1_STRIPE_PRICES', []) + if callable(projection) and (subscription.plan.value == 'basic' or ladder_subscriber): snapshot = projection(uid) percent = snapshot.get('budget_percent') if isinstance(snapshot, dict) else None reset = snapshot.get('reset_at') if isinstance(snapshot, dict) else None @@ -52,7 +61,7 @@ def _usage(uid: str, subscription: Subscription) -> StorefrontUsage: and reset > 0 ): return StorefrontUsage(kind='usage_budget', budget_percent=percent, reset_at=reset) - if subscription.current_price_id in getattr(catalog, 'LADDER_V1_STRIPE_PRICES', []): + if ladder_subscriber: return StorefrontUsage() # Existing legacy speech allowance; explicitly labelled so clients never # mistake it for the new shared-unit budget. @@ -75,7 +84,7 @@ def _usage(uid: str, subscription: Subscription) -> StorefrontUsage: 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), - uid: str = Depends(auth.get_current_user_uid_no_byok_validation), + 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'), ): diff --git a/backend/tests/unit/test_storefront.py b/backend/tests/unit/test_storefront.py index 44f7597b4ec..40a172baab9 100644 --- a/backend/tests/unit/test_storefront.py +++ b/backend/tests/unit/test_storefront.py @@ -11,6 +11,7 @@ from models.users import PlanType, Subscription 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 @@ -104,6 +105,8 @@ def client(monkeypatch): app = FastAPI() app.include_router(route.router) app.dependency_overrides[route.auth.get_current_user_uid_no_byok_validation] = lambda: 'fixture-user' + monkeypatch.setattr(route.auth, 'check_rate_limit', lambda *args: (True, 12, 0)) + monkeypatch.setattr(route.auth, 'RATE_LIMIT_SHADOW', False) monkeypatch.setenv('STOREFRONT_LADDER_V1_ENABLED', 'true') monkeypatch.setattr(route, 'should_hide_subscription_ui', lambda *args: False) monkeypatch.setattr(route.users_db, 'get_user_valid_subscription', lambda uid, **kwargs: Subscription()) @@ -264,8 +267,12 @@ def test_contextual_recommendation_per_subscription_and_gate(plan, trigger): source='macos_usage_limit', trigger=trigger, ) - expected = 'operator' if plan != 'basic' or trigger in {'proactive_agent', 'screen_context'} else 'plus' - assert [tier.plan_id for tier in sheet.tiers if tier.recommended] == [expected] + expected = ( + [] + if plan in {'operator', 'architect'} + else ['operator'] if plan != 'basic' or trigger in {'proactive_agent', 'screen_context'} else ['plus'] + ) + 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) @@ -311,3 +318,145 @@ def capture(**kwargs): def test_invalid_gate_is_rejected(client): assert client.get('/v2/payments/plans?trigger=typo').status_code == 422 + + +@pytest.mark.parametrize('plan', ['unlimited', 'unlimited_v2']) +@pytest.mark.parametrize('registered_current', [False, True]) +@pytest.mark.parametrize('offers', [False, True]) +def test_non_ladder_paid_plans_only_offer_explicit_higher_tiers(plan, registered_current, offers): + rows = {'price_current': price('price_current', 2999)} + bindings = ['price_current'] if registered_current else [] + recognized = [] + if offers: + for offer, amount in [('plus', 1999), ('operator', 4999), ('architect', 19900)]: + key = f'price_{offer}' + rows[key] = price(key, amount) + bindings.append(key) + recognized.append( + {'price_id': key, 'plan_id': offer, 'interval': 'month', 'entitlement_version': 'ladder_v1'} + ) + 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' + if offers: + assert by_plan['plus'].action == 'downgrade_not_offered' + assert not by_plan['plus'].recommended + assert by_plan['operator'].action == by_plan['architect'].action == 'contact' + 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].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 + + +@pytest.mark.parametrize('plan', ['plus', 'operator', 'architect']) +def test_legacy_ladder_plan_has_one_card_and_only_existing_entitlement_copy(plan): + key = f'price_{plan}' + sheet = build( + plan, + 'price_old', + {key: price(key, 4999)}, + [key], + [{'price_id': key, 'plan_id': plan, 'interval': 'month', 'entitlement_version': 'ladder_v1'}], + ) + 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 not cards[0].recommended + + +@pytest.mark.parametrize('plan', ['operator', 'architect']) +@pytest.mark.parametrize('trigger', [None, 'proactive_agent', 'screen_context', 'usage_budget', 'mcp_daily_cap']) +def test_ladder_subscriber_never_recommends_current_or_lower_plan(plan, trigger): + rows, recognized = {}, [] + for offer, amount in [('plus', 1999), ('operator', 4999), ('architect', 19900)]: + key = f'price_{offer}' + rows[key] = price(key, amount) + recognized.append({'price_id': key, 'plan_id': offer, 'interval': 'month', 'entitlement_version': 'ladder_v1'}) + sheet = build(plan, f'price_{plan}', rows, list(rows), recognized, trigger=trigger) + assert not any(tier.recommended for tier in sheet.tiers) + + +def test_price_retrieval_reuses_subscription_cache_and_24_hour_ttl(monkeypatch): + cache = {} + writes = [] + monkeypatch.setattr(route.redis_db, 'get_generic_cache', cache.get) + + def save(key, value, ttl): + cache[key] = value + writes.append((key, ttl)) + + monkeypatch.setattr(route.redis_db, 'set_generic_cache', save) + retrieved = Mock() + retrieved.to_dict_recursive.return_value = price('price_fixture', 1999) + retrieve = Mock(return_value=retrieved) + monkeypatch.setattr(route.stripe.Price, 'retrieve', retrieve) + assert route._retrieve_price('price_fixture') == route._retrieve_price('price_fixture') + retrieve.assert_called_once_with('price_fixture') + assert writes == [('stripe_price:price_fixture', 86400)] + cache['stripe_price:price_existing'] = price('price_existing', 2999, active=False) + assert route._retrieve_price('price_existing')['active'] is False + retrieve.assert_called_once() + + +def test_price_provider_failure_is_not_cached_and_emits_fallback(monkeypatch): + monkeypatch.setattr(route.redis_db, 'get_generic_cache', lambda key: None) + save = Mock() + monkeypatch.setattr(route.redis_db, 'set_generic_cache', save) + monkeypatch.setattr(route.stripe.Price, 'retrieve', Mock(side_effect=RuntimeError('offline fixture'))) + fallback = Mock() + monkeypatch.setattr(route, '_degraded', fallback) + with pytest.raises(RuntimeError, match='offline fixture'): + route._retrieve_price('price_bad') + save.assert_not_called() + fallback.assert_called_once_with('storefront_price', 'hidden_price') + + +def test_storefront_rate_limit_runs_before_data_reads(client, monkeypatch): + assert get_effective_limit('storefront:plans', boost=100) == (12, 60) + + limited = Mock(return_value=(False, 0, 60)) + monkeypatch.setattr(route.auth, 'check_rate_limit', limited) + read = Mock(side_effect=AssertionError('limited request must not read data')) + monkeypatch.setattr(route.users_db, 'get_user_valid_subscription', read) + response = client.get('/v2/payments/plans') + assert response.status_code == 429 and response.headers['Retry-After'] == '60' + limited.assert_called_once_with('fixture-user', 'storefront:plans', 12, 60) + read.assert_not_called() + + +@pytest.mark.parametrize('plan', ['plus', 'operator', 'architect', 'unlimited', 'unlimited_v2']) +def test_legacy_usage_never_accepts_shared_budget_projection(monkeypatch, plan): + projection = Mock(return_value={'budget_percent': 80, 'reset_at': 1800000000}) + monkeypatch.setattr(route.subscription_utils, 'get_usage_units', projection, raising=False) + monkeypatch.setattr(route.subscription_utils, 'transcription_allowance_seconds', lambda plan: 100) + monkeypatch.setattr( + 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 + projection.assert_not_called() + + +def test_legacy_chat_meter_without_speech_allowance_is_explicitly_unavailable(monkeypatch): + monkeypatch.setattr( + route.subscription_utils, + 'get_usage_units', + lambda uid: {'budget_percent': 80, 'reset_at': 1800000000}, + raising=False, + ) + 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 + + +@pytest.mark.parametrize('plan', ['basic', 'plus']) +def test_shared_budget_projection_is_only_for_free_or_ladder_subscribers(monkeypatch, plan): + monkeypatch.setattr(route.catalog, 'LADDER_V1_STRIPE_PRICES', ['price_new'], raising=False) + 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 + projection.assert_called_once_with('fixture') diff --git a/backend/utils/rate_limit_config.py b/backend/utils/rate_limit_config.py index 28c7f496c55..1dd67b8d552 100644 --- a/backend/utils/rate_limit_config.py +++ b/backend/utils/rate_limit_config.py @@ -76,7 +76,7 @@ def _hot_client_max() -> int: # hourly caps boosted into no-ops. # Transcript uploads keep their storage budget even on boosted environments. _BOOST_EXEMPT_DEFAULT = ( - "import:upload,action_items:list,action_items:list_hot_client,static_map:get," + "storefront:plans,import:upload,action_items:list,action_items:list_hot_client,static_map:get," "dev:memories,dev:memories_write_burst,dev:conversations,dev:conversations_from_segments," "mcp:oauth_url_client,mcp:oauth_url_client_global,screen_task:gate,screen_task:gate_daily" ) @@ -91,6 +91,8 @@ def _hot_client_max() -> int: # --------------------------------------------------------------------------- RATE_POLICIES: dict[str, tuple[int, int]] = { + # A plans sheet opens on user intent; bound accidental polling before provider reads. + "storefront:plans": (12, 60), "proactivity:api": (120, 60), # Conversations — each triggers ~22 OpenAI calls "conversations:create": (10, 3600), diff --git a/backend/utils/storefront.py b/backend/utils/storefront.py index 631fd281418..7ebf1be8be5 100644 --- a/backend/utils/storefront.py +++ b/backend/utils/storefront.py @@ -101,13 +101,18 @@ def build_storefront( 'operator' if trigger in {'proactive_agent', 'screen_context'} or current_plan != 'basic' else 'plus' ) current_price = subscription.current_price_id - # Missing historical price never implies the new entitlement version. - legacy = current_plan != 'basic' and current_price not in bindings 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() tiers: list[StorefrontTier] = [] for tier in ladder['tiers']: plan = tier['plan_id'] + if legacy and plan == current_plan: + continue rows: list[dict[str, Any]] = [] for registration in recognized: price_id = registration['price_id'] @@ -144,8 +149,8 @@ def build_storefront( continue if plan == current_plan and not legacy: action = 'current' - elif legacy and plan == current_plan: - action = 'contact' + 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: @@ -162,7 +167,7 @@ def build_storefront( features=_features(tier), prices=prices, action=action, - recommended=plan == recommended_plan, + recommended=plan == recommended_plan and action in {'upgrade', 'contact'}, ) ) if legacy or (current_plan != 'basic' and not any(tier.action == 'current' for tier in tiers)): @@ -174,9 +179,11 @@ def build_storefront( rows.append(row) except Exception: pass - name = next( - (tier['display_name'] for tier in ladder['tiers'] if tier['plan_id'] == current_plan), - {'unlimited': 'Neo', 'unlimited_v2': 'Unlimited'}.get(current_plan, current_plan), + 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) ) tiers.append( StorefrontTier( @@ -189,8 +196,8 @@ def build_storefront( action='current', features=( [PlanFeature(key='legacy_entitlements', label='Your existing benefits stay the same')] - if legacy - else _features(next(tier for tier in ladder['tiers'] if tier['plan_id'] == current_plan)) + if legacy or current_tier is None + else _features(current_tier) ), prices=present_prices(rows, current=True), ) From 3e832eaec2c58169310e22efb71acea7785b6ce6 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 13 Oct 2026 16:30:24 +0700 Subject: [PATCH 08/14] feat(billing): render the plans sheet as schema_version 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dark storefront now returns the copy-deck contract — hours, headers, and checkout policy — from one English copy module, still behind the same flag. Co-authored-by: Cursor --- .../20261013-storefront-presentation-v2.json | 1 + .../backend/schema/gen/storefront_wire.g.dart | 204 +++++----- app/test/backend/storefront_wire_test.dart | 32 +- backend/config/storefront_copy_v1.json | 100 +++++ backend/config/storefront_ladder.json | 8 +- backend/docs/storefront-contract.md | 199 ++++------ backend/models/storefront.py | 72 ++-- backend/routers/storefront.py | 26 +- backend/scripts/dart_schema_groups.py | 4 +- .../storefront_v2/architect_settings.json | 164 ++++++++ .../storefront_v2/cancel_scheduled_plus.json | 164 ++++++++ .../fixtures/storefront_v2/free_budget80.json | 164 ++++++++ .../fixtures/storefront_v2/free_mcp_cap.json | 164 ++++++++ .../free_pro_gate_proactive.json | 164 ++++++++ .../storefront_v2/free_pro_gate_screen.json | 164 ++++++++ .../fixtures/storefront_v2/free_settings.json | 164 ++++++++ .../storefront_v2/legacy_unlimited.json | 186 +++++++++ .../storefront_v2/plus_annual_current.json | 155 ++++++++ .../fixtures/storefront_v2/plus_budget80.json | 164 ++++++++ .../fixtures/storefront_v2/pro_settings.json | 164 ++++++++ .../fixtures/storefront/ladder_v1_free.json | 249 ------------ backend/tests/unit/test_storefront.py | 120 +++--- .../unit/test_storefront_presentation.py | 375 ++++++++++++++++++ backend/utils/storefront.py | 356 +++++++++++------ backend/utils/storefront_copy.py | 338 ++++++++++++++++ .../Sources/Generated/OmiApi.generated.swift | 199 +++++----- .../src/renderer/src/lib/omiApi.generated.ts | 61 ++- docs/api-reference/app-client-openapi.json | 309 +++++++++------ .../lib/services/omi-api/omiApi.generated.ts | 61 ++- web/app/src/lib/omiApi.generated.ts | 61 ++- .../src/lib/omiApi.generated.ts | 61 ++- 31 files changed, 3590 insertions(+), 1063 deletions(-) create mode 100644 app/changelog/unreleased/20261013-storefront-presentation-v2.json create mode 100644 backend/config/storefront_copy_v1.json create mode 100644 backend/tests/fixtures/storefront_v2/architect_settings.json create mode 100644 backend/tests/fixtures/storefront_v2/cancel_scheduled_plus.json create mode 100644 backend/tests/fixtures/storefront_v2/free_budget80.json create mode 100644 backend/tests/fixtures/storefront_v2/free_mcp_cap.json create mode 100644 backend/tests/fixtures/storefront_v2/free_pro_gate_proactive.json create mode 100644 backend/tests/fixtures/storefront_v2/free_pro_gate_screen.json create mode 100644 backend/tests/fixtures/storefront_v2/free_settings.json create mode 100644 backend/tests/fixtures/storefront_v2/legacy_unlimited.json create mode 100644 backend/tests/fixtures/storefront_v2/plus_annual_current.json create mode 100644 backend/tests/fixtures/storefront_v2/plus_budget80.json create mode 100644 backend/tests/fixtures/storefront_v2/pro_settings.json delete mode 100644 backend/tests/unit/fixtures/storefront/ladder_v1_free.json create mode 100644 backend/tests/unit/test_storefront_presentation.py create mode 100644 backend/utils/storefront_copy.py 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..ea2f748f8e3 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,83 @@ class GeneratedStorefrontCurrentPlan { class GeneratedStorefrontPrice { final int amountMinor; - final GeneratedAnnualSavings? annualSavings; 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, 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), 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(), '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 +136,52 @@ 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 List bullets; + 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, + required this.bullets, + 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"), + bullets: _required(_readFieldValue>(_readField(json, const ["bullets"]), "bullets", _readStringList, requiredField: true, nullable: false), "bullets"), + 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 +189,59 @@ class GeneratedStorefrontTier { Map toJson() { return { 'action': action, - 'display_name': displayName, - 'entitlement_version': entitlementVersion, - 'features': features.map((value) => value.toJson()).toList(), - 'legacy': legacy, + 'bullets': bullets, + '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..60efbbe8fea 100644 --- a/app/test/backend/storefront_wire_test.dart +++ b/app/test/backend/storefront_wire_test.dart @@ -5,29 +5,17 @@ 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'); + expect(decoded.tiers.map((tier) => tier.name), ['Plus', 'Free', 'Pro', 'Architect']); + expect(decoded.tiers.first.recommended, isTrue); + expect(decoded.yearlyToggleLabel, 'Yearly · 2 months free'); - 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..a73101ce270 --- /dev/null +++ b/backend/config/storefront_copy_v1.json @@ -0,0 +1,100 @@ +{ + "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" + }, + "current": { + "free": "You're on Free", + "paid": "You're on {plan} · {price}/{period} · renews {date}", + "cancel": "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 a month, enough to wear it all day", + "architect": "About {hours} hours a month, for all-day use on more than one device" + }, + "per_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" + }, + "prices": { + "headline_month": "{amount}/month", + "headline_year": "{amount}/month", + "subline_year": "{amount} billed yearly · {n} months free", + "subline_year_no_free": "{amount} billed yearly", + "renewal_month": "Renews monthly until you cancel. Cancel anytime in settings.", + "renewal_year": "Renews yearly until you cancel. Cancel anytime in settings.", + "toggle": "Yearly · {n} months 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..8fec3f2219f 100644 --- a/backend/docs/storefront-contract.md +++ b/backend/docs/storefront-contract.md @@ -1,124 +1,93 @@ # 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`. 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): + `tier_id`, `plan_id`, `name`, `recommended`, `is_current`, `hours_line`, + `bullets`, `action`, nullable `cta_label`, `prices`. +- `prices[]`: nullable `price_id`, `interval`, `amount_minor`, `currency`, + `headline`, nullable `subline`, nullable `renewal_line`, and `months_free` + only when the live yearly price gives at least one free month. + +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. +Legacy cards do not repeat ladder hours or bullets; the legacy sentence is +`current.legacy_note`. + +Same tier: an annual subscriber is not offered the monthly price. A monthly +subscriber may still see the annual price. `cancel_at_period_end` on a +non-legacy paid tier sets that tier to `checkout` with `cta_label` +`Resubscribe` on its current price. Free cancellation does not open checkout. + +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. +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..d9ab31989c1 100644 --- a/backend/models/storefront.py +++ b/backend/models/storefront.py @@ -1,62 +1,68 @@ -"""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 + + @model_serializer(mode='wrap') + def _omit_empty_months_free(self, handler): + data = handler(self) + if data.get('months_free') is None: + data.pop('months_free', 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 + 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/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..9d24c06438d --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/architect_settings.json @@ -0,0 +1,164 @@ +{ + "schema_version": 2, + "catalog_revision": 6, + "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, + "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, + "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 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, + "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 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": "current", + "cta_label": null, + "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/cancel_scheduled_plus.json b/backend/tests/fixtures/storefront_v2/cancel_scheduled_plus.json new file mode 100644 index 00000000000..56351baeeb8 --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/cancel_scheduled_plus.json @@ -0,0 +1,164 @@ +{ + "schema_version": 2, + "catalog_revision": 6, + "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": true, + "line": "You're on Plus · ends 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 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", + "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, + "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": [ + "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": "Resubscribe", + "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 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", + "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_budget80.json b/backend/tests/fixtures/storefront_v2/free_budget80.json new file mode 100644 index 00000000000..ec649a6972b --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/free_budget80.json @@ -0,0 +1,164 @@ +{ + "schema_version": 2, + "catalog_revision": 6, + "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", + "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": [ + "Transcripts, summaries and memories of your conversations", + "Ask Omi anything about your day", + "100 MCP requests a day" + ], + "action": "current", + "cta_label": null, + "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 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", + "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 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", + "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..2a79563d849 --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/free_mcp_cap.json @@ -0,0 +1,164 @@ +{ + "schema_version": 2, + "catalog_revision": 6, + "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", + "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": [ + "100 MCP requests a day", + "Transcripts, summaries and memories of your conversations", + "Ask Omi anything about your day" + ], + "action": "current", + "cta_label": null, + "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 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", + "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 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", + "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..c83fab682e1 --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/free_pro_gate_proactive.json @@ -0,0 +1,164 @@ +{ + "schema_version": 2, + "catalog_revision": 6, + "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 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", + "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": [ + "Transcripts, summaries and memories of your conversations", + "Ask Omi anything about your day", + "100 MCP requests a day" + ], + "action": "current", + "cta_label": null, + "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", + "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 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", + "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..91695efcfdb --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/free_pro_gate_screen.json @@ -0,0 +1,164 @@ +{ + "schema_version": 2, + "catalog_revision": 6, + "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 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", + "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": [ + "Transcripts, summaries and memories of your conversations", + "Ask Omi anything about your day", + "100 MCP requests a day" + ], + "action": "current", + "cta_label": null, + "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", + "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 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", + "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..194ed8f9c73 --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/free_settings.json @@ -0,0 +1,164 @@ +{ + "schema_version": 2, + "catalog_revision": 6, + "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", + "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": [ + "Transcripts, summaries and memories of your conversations", + "Ask Omi anything about your day", + "100 MCP requests a day" + ], + "action": "current", + "cta_label": null, + "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 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", + "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 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", + "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_unlimited.json b/backend/tests/fixtures/storefront_v2/legacy_unlimited.json new file mode 100644 index 00000000000..e0f30199e46 --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/legacy_unlimited.json @@ -0,0 +1,186 @@ +{ + "schema_version": 2, + "catalog_revision": 6, + "show_subscription_ui": true, + "header": "Your plan", + "current": { + "plan_id": "unlimited", + "name": "Neo", + "price_id": "price_legacy_unlimited", + "interval": "month", + "legacy": true, + "cancel_at_period_end": false, + "line": "You're on Neo · $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 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, + "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, + "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, + "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 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, + "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, + "prices": [ + { + "price_id": "price_legacy_unlimited", + "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..509eca9bc2e --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/plus_annual_current.json @@ -0,0 +1,155 @@ +{ + "schema_version": 2, + "catalog_revision": 6, + "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 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", + "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, + "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": [ + "Everything in Free, with much more room", + "Use Omi on your Mac as well as your phone", + "500 MCP requests a day" + ], + "action": "current", + "cta_label": null, + "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 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", + "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..1c4995d46e4 --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/plus_budget80.json @@ -0,0 +1,164 @@ +{ + "schema_version": 2, + "catalog_revision": 6, + "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 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", + "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, + "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": [ + "Everything in Free, with much more room", + "Use Omi on your Mac as well as your phone", + "500 MCP requests a day" + ], + "action": "current", + "cta_label": null, + "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 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", + "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..ca3723b78dc --- /dev/null +++ b/backend/tests/fixtures/storefront_v2/pro_settings.json @@ -0,0 +1,164 @@ +{ + "schema_version": 2, + "catalog_revision": 6, + "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, + "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, + "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 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": "current", + "cta_label": null, + "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 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", + "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/fixtures/storefront/ladder_v1_free.json b/backend/tests/unit/fixtures/storefront/ladder_v1_free.json deleted file mode 100644 index df0faba2ef4..00000000000 --- a/backend/tests/unit/fixtures/storefront/ladder_v1_free.json +++ /dev/null @@ -1,249 +0,0 @@ -{ - "schema_version": 1, - "catalog_revision": 6, - "show_subscription_ui": true, - "tiers": [ - { - "tier_id": "basic:ladder_v1", - "plan_id": "basic", - "entitlement_version": "ladder_v1", - "display_name": "Free", - "tagline": "Start building your memory", - "features": [ - { - "key": "core_desktop", - "label": "Capture, conversations, chat, memories and search", - "included": true - }, - { - "key": "managed_usage_budget", - "label": "Managed AI uses your shared monthly budget", - "included": true - }, - { - "key": "mcp_daily_cap", - "label": "MCP + developer API: 100 requests/day", - "included": true - }, - { - "key": "proactive_agent", - "label": "Proactive agent", - "included": false - }, - { - "key": "screen_context", - "label": "Screen context", - "included": false - } - ], - "prices": [ - { - "price_id": null, - "interval": "month", - "currency": "usd", - "amount_minor": 0, - "formatted_amount": "$0.00/mo", - "monthly_equivalent": "$0.00/mo", - "annual_savings": null - } - ], - "action": "current", - "recommended": false, - "legacy": false - }, - { - "tier_id": "plus:ladder_v1", - "plan_id": "plus", - "entitlement_version": "ladder_v1", - "display_name": "Plus", - "tagline": "Your everyday memory companion", - "features": [ - { - "key": "core_desktop", - "label": "Capture, conversations, chat, memories and search", - "included": true - }, - { - "key": "managed_usage_budget", - "label": "Managed AI uses your shared monthly budget", - "included": true - }, - { - "key": "mcp_daily_cap", - "label": "MCP + developer API: 500 requests/day", - "included": true - }, - { - "key": "proactive_agent", - "label": "Proactive agent", - "included": false - }, - { - "key": "screen_context", - "label": "Screen context", - "included": false - } - ], - "prices": [ - { - "price_id": "price_fixture_plus_month", - "interval": "month", - "currency": "usd", - "amount_minor": 1999, - "formatted_amount": "$19.99/mo", - "monthly_equivalent": "$19.99/mo", - "annual_savings": null - }, - { - "price_id": "price_fixture_plus_year", - "interval": "year", - "currency": "usd", - "amount_minor": 19990, - "formatted_amount": "$199.90/yr", - "monthly_equivalent": "$16.66/mo", - "annual_savings": { - "amount_minor": 3998, - "percent": 16.67, - "formatted": "Save $39.98 (16.67%) per year" - } - } - ], - "action": "upgrade", - "recommended": true, - "legacy": false - }, - { - "tier_id": "operator:ladder_v1", - "plan_id": "operator", - "entitlement_version": "ladder_v1", - "display_name": "Pro", - "tagline": "Proactive help with screen context", - "features": [ - { - "key": "core_desktop", - "label": "Capture, conversations, chat, memories and search", - "included": true - }, - { - "key": "managed_usage_budget", - "label": "Managed AI uses your shared monthly budget", - "included": true - }, - { - "key": "mcp_daily_cap", - "label": "MCP + developer API: 5,000 requests/day", - "included": true - }, - { - "key": "proactive_agent", - "label": "Proactive agent", - "included": true - }, - { - "key": "screen_context", - "label": "Screen context", - "included": true - } - ], - "prices": [ - { - "price_id": "price_fixture_operator_month", - "interval": "month", - "currency": "usd", - "amount_minor": 4999, - "formatted_amount": "$49.99/mo", - "monthly_equivalent": "$49.99/mo", - "annual_savings": null - }, - { - "price_id": "price_fixture_operator_year", - "interval": "year", - "currency": "usd", - "amount_minor": 49990, - "formatted_amount": "$499.90/yr", - "monthly_equivalent": "$41.66/mo", - "annual_savings": { - "amount_minor": 9998, - "percent": 16.67, - "formatted": "Save $99.98 (16.67%) per year" - } - } - ], - "action": "upgrade", - "recommended": false, - "legacy": false - }, - { - "tier_id": "architect:ladder_v1", - "plan_id": "architect", - "entitlement_version": "ladder_v1", - "display_name": "Architect", - "tagline": "For demanding workflows", - "features": [ - { - "key": "core_desktop", - "label": "Capture, conversations, chat, memories and search", - "included": true - }, - { - "key": "managed_usage_budget", - "label": "Managed AI uses your shared monthly budget", - "included": true - }, - { - "key": "mcp_daily_cap", - "label": "MCP + developer API: at least 5,000 requests/day", - "included": true - }, - { - "key": "proactive_agent", - "label": "Proactive agent", - "included": true - }, - { - "key": "screen_context", - "label": "Screen context", - "included": true - } - ], - "prices": [ - { - "price_id": "price_fixture_architect_month", - "interval": "month", - "currency": "usd", - "amount_minor": 19900, - "formatted_amount": "$199.00/mo", - "monthly_equivalent": "$199.00/mo", - "annual_savings": null - }, - { - "price_id": "price_fixture_architect_year", - "interval": "year", - "currency": "usd", - "amount_minor": 199000, - "formatted_amount": "$1990.00/yr", - "monthly_equivalent": "$165.83/mo", - "annual_savings": { - "amount_minor": 39800, - "percent": 16.67, - "formatted": "Save $398.00 (16.67%) per year" - } - } - ], - "action": "upgrade", - "recommended": false, - "legacy": false - } - ], - "next_offer_tier_id": "plus:ladder_v1", - "current": { - "plan_id": "basic", - "price_id": null, - "legacy": false - }, - "usage": { - "kind": "unavailable", - "budget_percent": null, - "reset_at": null - } -} diff --git a/backend/tests/unit/test_storefront.py b/backend/tests/unit/test_storefront.py index 40a172baab9..18d4b10a1dd 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,34 @@ 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', - ] + assert [tier.plan_id for tier in paid.tiers] == ['basic', 'plus', 'operator', 'architect'] + assert [tier.action for tier in paid.tiers] == ['none', 'none', 'current', 'checkout'] + 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_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) +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 +159,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 +169,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 +183,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 +205,17 @@ 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'] + parameters = {item['name'] for item in operation['parameters']} + assert {'source', 'trigger', 'locale'} <= parameters def test_registration_does_not_bypass_existing_checkout_guard(): @@ -232,16 +237,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 +273,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 +336,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 +361,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 +436,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 +449,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 +458,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..c47feb23ad0 --- /dev/null +++ b/backend/tests/unit/test_storefront_presentation.py @@ -0,0 +1,375 @@ +"""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, + months_free, + per_day_phrase, + present_usage, + recording_hours, + recording_hours_left, +) + +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 a month, enough to wear it all day' + assert hours_line('architect') == 'About 1,200 hours 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 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('basic', 80) == 3 + assert months_free(4999, 49990) == 2 + + +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: + 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): + ordered = [item for item in ('month', 'year') if item in available] + if tier == current and interval == 'year': + return [item for item in ordered if item != 'month'] + 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] + assert offered_intervals( + current_plan=current, tier_plan=tier, current_interval=interval, available=available + ) == expected_intervals(current, tier, interval, available) + 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)) + for tier in built.tiers: + assert tier.action == expected_action(plan, tier.plan_id, legacy, cancel) + 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) + + +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_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'] = price('price_legacy_unlimited', 2999) + return sheet_for( + 'unlimited', + price_id='price_legacy_unlimited', + 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_annual_current', + 'pro_settings', + 'architect_settings', + 'legacy_unlimited', + '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'] + 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) + 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 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.line == "You're on Neo · $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', + } + cancelled = rendered['cancel_scheduled_plus'] + assert cancelled.current.line == "You're on Plus · ends Nov 1" + 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 all(rendered[name].yearly_toggle_label == 'Yearly · 2 months free' for name in SCENARIOS) + 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..8543e4f19ca 100644 --- a/backend/utils/storefront.py +++ b/backend/utils/storefront.py @@ -1,33 +1,95 @@ """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 models.storefront import ( - AnnualSavings, - PlanFeature, - StorefrontCurrentPlan, + StorefrontCurrent, StorefrontPrice, StorefrontResponse, StorefrontTier, StorefrontUsage, ) from models.users import Subscription +from utils.storefront_copy import ( + bullets, + cta_label, + current_line, + header, + hours_line, + plan_name, + render_price, + 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 tier_action(*, current_plan: str, tier_plan: str, legacy: bool, cancel: bool) -> str: + """checkout, current, or none. Legacy never checks out. Cancel offers Resubscribe on the current tier.""" + if tier_plan == current_plan: + if legacy or current_plan == 'basic' or not cancel: + return 'current' + return 'checkout' + if legacy: + 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 present_prices(rows: list[dict[str, Any]], *, current: bool = False) -> list[StorefrontPrice]: - """USD recurring monthly/yearly prices only; omit unsupported billing shapes.""" +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] +) -> list[str]: + """Same tier: annual → monthly is not offered; monthly → annual stays available.""" + ordered = [interval for interval in ('month', 'year') if interval in available] + if tier_plan == current_plan and current_interval == 'year': + return [interval for interval in ordered if interval != 'month'] + return ordered + + +def _valid_price_rows(rows: list[dict[str, Any]], *, current: bool = False) -> list[dict[str, Any]]: valid = [ row for row in rows @@ -40,45 +102,78 @@ 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, +) -> 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( + 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 + rendered.append( StorefrontPrice( - price_id=row['id'], - interval=interval, - 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, + **render_price( + price_id=row['id'], + interval=interval, + amount_minor=row['unit_amount'], + currency=row['currency'], + monthly_minor=monthly_minor, + locale=locale, + ) ) ) - return result + 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 _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 isinstance(row, dict) and 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 +188,22 @@ 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 = 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() + 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'] + badge = recommended_plan_id(current_plan, trigger) + currency = ladder.get('currency') if isinstance(ladder.get('currency'), str) else 'usd' tiers: list[StorefrontTier] = [] for tier in ladder['tiers']: plan = tier['plan_id'] @@ -117,97 +213,111 @@ 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'] + offered = offered_intervals( + current_plan=current_plan, tier_plan=plan, current_interval=current_interval, available=available + ) + if plan == 'basic': + prices = [_free_price(currency, locale)] + else: + prices = _prices_for(rows, offered=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' + is_current = plan == current_plan and not legacy 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=plan == badge and is_higher_offer(current_plan, plan), + is_current=is_current, + hours_line=hours_line(plan, locale), + bullets=bullets(plan, trigger, locale), action=action, - recommended=plan == recommended_plan and action in {'upgrade', 'contact'}, + cta_label=cta_label(action=action, tier_plan=plan, current_plan=current_plan, locale=locale), + 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: + available = [current_interval] if current_interval else [] + offered = offered_intervals( + current_plan=current_plan, + tier_plan=current_plan, + current_interval=current_interval, + available=available, + ) + current_prices = _prices_for([current_row], offered=offered, locale=locale, current=True) 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=[] if legacy or current_plan not in RANK else bullets(current_plan, trigger, locale), + action=action, + cta_label=cta_label(action=action, tier_plan=current_plan, current_plan=current_plan, locale=locale), + 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) + 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, + ), 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=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..505fed9da1b --- /dev/null +++ b/backend/utils/storefront_copy.py @@ -0,0 +1,338 @@ +"""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 hours a day; 6–16 → all day; >16 → more than one device.""" + phrases = copy_for(locale)['per_day'] + daily = Decimal(monthly_hours) / Decimal(30) + if daily < 6: + return phrases['under_6'].format(n=per_day_count(monthly_hours)) + 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. + + 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. + """ + fraction = (Decimal(100) - Decimal(str(percent))) / Decimal(100) + 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, +) -> str: + headers = copy_for(locale)['headers'] + if legacy: + return headers['legacy'] + 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: + dollars = (Decimal(amount_minor) / Decimal(100)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) + return f'${dollars:.2f}' + + +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 + if cancel and date: + return text['cancel'].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, n=free) + 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(n=months) + + +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 8f1effde064..0b4550146fc 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 @@ -4319,26 +4293,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? @@ -4998,26 +4952,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 } @@ -5026,164 +5000,188 @@ public enum OmiAPI { public struct StorefrontPrice: Codable { public let amountMinor: Int - public let annualSavings: AnnualSavings? 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 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) 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, 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.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 bullets: [String] + 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 bullets + 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) + bullets = try c.decode([String].self, forKey: .bullets) + 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, bullets: [String], 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.bullets = bullets + 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 } } @@ -18691,7 +18689,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 @@ -18703,6 +18701,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 d6b42b5db35..35d71b28bc2 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; @@ -3804,12 +3798,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; @@ -4922,49 +4910,60 @@ 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; 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"; + bullets: Array; + 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 { @@ -5986,7 +5985,6 @@ export interface OmiApiSchemas { "Announcement": Announcement; "AnnouncementDeleteResponse": AnnouncementDeleteResponse; "AnnouncementType": AnnouncementType; - "AnnualSavings": AnnualSavings; "App": App; "AppApiKeyResponse": AppApiKeyResponse; "AppBaseModel": AppBaseModel; @@ -6459,7 +6457,6 @@ export interface OmiApiSchemas { "PhoneMutationResponse": PhoneMutationResponse; "PhoneNumberResponse": PhoneNumberResponse; "PhoneNumbersResponse": PhoneNumbersResponse; - "PlanFeature": PlanFeature; "PlanIdentitySubscription": PlanIdentitySubscription; "PlanIdentityUserSubscriptionResponse": PlanIdentityUserSubscriptionResponse; "PlanLimits": PlanLimits; @@ -6615,7 +6612,7 @@ export interface OmiApiSchemas { "StoreMeetingRequest": StoreMeetingRequest; "StoreMeetingResponse": StoreMeetingResponse; "StoreRecordingPermissionResponse": StoreRecordingPermissionResponse; - "StorefrontCurrentPlan": StorefrontCurrentPlan; + "StorefrontCurrent": StorefrontCurrent; "StorefrontPrice": StorefrontPrice; "StorefrontResponse": StorefrontResponse; "StorefrontTier": StorefrontTier; @@ -21210,7 +21207,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 da64b5e03d8..72dd1c27a50 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": { @@ -23599,29 +23576,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": { @@ -29896,12 +29850,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" @@ -29920,9 +29913,11 @@ }, "required": [ "plan_id", - "legacy" + "name", + "legacy", + "line" ], - "title": "StorefrontCurrentPlan", + "title": "StorefrontCurrent", "type": "object" }, "StorefrontPrice": { @@ -29931,22 +29926,12 @@ "title": "Amount Minor", "type": "integer" }, - "annual_savings": { - "anyOf": [ - { - "$ref": "#/components/schemas/AnnualSavings" - }, - { - "type": "null" - } - ] - }, "currency": { "title": "Currency", "type": "string" }, - "formatted_amount": { - "title": "Formatted Amount", + "headline": { + "title": "Headline", "type": "string" }, "interval": { @@ -29957,9 +29942,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": [ @@ -29971,14 +29963,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" @@ -29992,14 +30005,14 @@ "current": { "anyOf": [ { - "$ref": "#/components/schemas/StorefrontCurrentPlan" + "$ref": "#/components/schemas/StorefrontCurrent" }, { "type": "null" } ] }, - "next_offer_tier_id": { + "header": { "anyOf": [ { "type": "string" @@ -30008,11 +30021,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" }, @@ -30030,6 +30043,17 @@ }, "usage": { "$ref": "#/components/schemas/StorefrontUsage" + }, + "yearly_toggle_label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Yearly Toggle Label" } }, "required": [ @@ -30042,34 +30066,44 @@ "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" - }, - "features": { + "bullets": { "items": { - "$ref": "#/components/schemas/PlanFeature" + "type": "string" }, - "title": "Features", + "title": "Bullets", "type": "array" }, - "legacy": { + "cta_label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cta Label" + }, + "hours_line": { + "title": "Hours Line", + "type": "string" + }, + "is_current": { "default": false, - "title": "Legacy", + "title": "Is Current", "type": "boolean" }, + "name": { + "title": "Name", + "type": "string" + }, "plan_id": { "title": "Plan Id", "type": "string" @@ -30086,10 +30120,6 @@ "title": "Recommended", "type": "boolean" }, - "tagline": { - "title": "Tagline", - "type": "string" - }, "tier_id": { "title": "Tier Id", "type": "string" @@ -30098,40 +30128,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" @@ -30140,7 +30180,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", @@ -78472,6 +78541,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 d6b42b5db35..35d71b28bc2 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; @@ -3804,12 +3798,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; @@ -4922,49 +4910,60 @@ 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; 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"; + bullets: Array; + 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 { @@ -5986,7 +5985,6 @@ export interface OmiApiSchemas { "Announcement": Announcement; "AnnouncementDeleteResponse": AnnouncementDeleteResponse; "AnnouncementType": AnnouncementType; - "AnnualSavings": AnnualSavings; "App": App; "AppApiKeyResponse": AppApiKeyResponse; "AppBaseModel": AppBaseModel; @@ -6459,7 +6457,6 @@ export interface OmiApiSchemas { "PhoneMutationResponse": PhoneMutationResponse; "PhoneNumberResponse": PhoneNumberResponse; "PhoneNumbersResponse": PhoneNumbersResponse; - "PlanFeature": PlanFeature; "PlanIdentitySubscription": PlanIdentitySubscription; "PlanIdentityUserSubscriptionResponse": PlanIdentityUserSubscriptionResponse; "PlanLimits": PlanLimits; @@ -6615,7 +6612,7 @@ export interface OmiApiSchemas { "StoreMeetingRequest": StoreMeetingRequest; "StoreMeetingResponse": StoreMeetingResponse; "StoreRecordingPermissionResponse": StoreRecordingPermissionResponse; - "StorefrontCurrentPlan": StorefrontCurrentPlan; + "StorefrontCurrent": StorefrontCurrent; "StorefrontPrice": StorefrontPrice; "StorefrontResponse": StorefrontResponse; "StorefrontTier": StorefrontTier; @@ -21210,7 +21207,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 d6b42b5db35..35d71b28bc2 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; @@ -3804,12 +3798,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; @@ -4922,49 +4910,60 @@ 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; 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"; + bullets: Array; + 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 { @@ -5986,7 +5985,6 @@ export interface OmiApiSchemas { "Announcement": Announcement; "AnnouncementDeleteResponse": AnnouncementDeleteResponse; "AnnouncementType": AnnouncementType; - "AnnualSavings": AnnualSavings; "App": App; "AppApiKeyResponse": AppApiKeyResponse; "AppBaseModel": AppBaseModel; @@ -6459,7 +6457,6 @@ export interface OmiApiSchemas { "PhoneMutationResponse": PhoneMutationResponse; "PhoneNumberResponse": PhoneNumberResponse; "PhoneNumbersResponse": PhoneNumbersResponse; - "PlanFeature": PlanFeature; "PlanIdentitySubscription": PlanIdentitySubscription; "PlanIdentityUserSubscriptionResponse": PlanIdentityUserSubscriptionResponse; "PlanLimits": PlanLimits; @@ -6615,7 +6612,7 @@ export interface OmiApiSchemas { "StoreMeetingRequest": StoreMeetingRequest; "StoreMeetingResponse": StoreMeetingResponse; "StoreRecordingPermissionResponse": StoreRecordingPermissionResponse; - "StorefrontCurrentPlan": StorefrontCurrentPlan; + "StorefrontCurrent": StorefrontCurrent; "StorefrontPrice": StorefrontPrice; "StorefrontResponse": StorefrontResponse; "StorefrontTier": StorefrontTier; @@ -21210,7 +21207,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 d6b42b5db35..35d71b28bc2 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; @@ -3804,12 +3798,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; @@ -4922,49 +4910,60 @@ 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; 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"; + bullets: Array; + 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 { @@ -5986,7 +5985,6 @@ export interface OmiApiSchemas { "Announcement": Announcement; "AnnouncementDeleteResponse": AnnouncementDeleteResponse; "AnnouncementType": AnnouncementType; - "AnnualSavings": AnnualSavings; "App": App; "AppApiKeyResponse": AppApiKeyResponse; "AppBaseModel": AppBaseModel; @@ -6459,7 +6457,6 @@ export interface OmiApiSchemas { "PhoneMutationResponse": PhoneMutationResponse; "PhoneNumberResponse": PhoneNumberResponse; "PhoneNumbersResponse": PhoneNumbersResponse; - "PlanFeature": PlanFeature; "PlanIdentitySubscription": PlanIdentitySubscription; "PlanIdentityUserSubscriptionResponse": PlanIdentityUserSubscriptionResponse; "PlanLimits": PlanLimits; @@ -6615,7 +6612,7 @@ export interface OmiApiSchemas { "StoreMeetingRequest": StoreMeetingRequest; "StoreMeetingResponse": StoreMeetingResponse; "StoreRecordingPermissionResponse": StoreRecordingPermissionResponse; - "StorefrontCurrentPlan": StorefrontCurrentPlan; + "StorefrontCurrent": StorefrontCurrent; "StorefrontPrice": StorefrontPrice; "StorefrontResponse": StorefrontResponse; "StorefrontTier": StorefrontTier; @@ -21210,7 +21207,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) From 959c9d2252cae65198951f112c87b608f8b02f4c Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 13 Oct 2026 17:11:05 +0700 Subject: [PATCH 09/14] fix(billing): satisfy the storefront typecheck Pyright rejected an always-true dict check, an unnarrowed currency, and checkout actions typed as plain strings. Co-authored-by: Cursor --- backend/utils/storefront.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/utils/storefront.py b/backend/utils/storefront.py index 8543e4f19ca..f991743da28 100644 --- a/backend/utils/storefront.py +++ b/backend/utils/storefront.py @@ -3,7 +3,7 @@ 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 ( StorefrontCurrent, @@ -41,7 +41,9 @@ def is_legacy_plan(plan: str, price_id: str | None, bindings: set[str]) -> bool: return price_id not in bindings -def tier_action(*, current_plan: str, tier_plan: str, legacy: bool, cancel: bool) -> str: +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.""" if tier_plan == current_plan: if legacy or current_plan == 'basic' or not cancel: @@ -151,7 +153,7 @@ def _retrieve(retrieve_price: Callable[[str], dict[str, Any]], price_id: str | N row = retrieve_price(price_id) except Exception: return None - if isinstance(row, dict) and row.get('id') == price_id: + if row.get('id') == price_id: return row return None @@ -203,7 +205,8 @@ def build_storefront( if type(current_row.get('unit_amount')) is int: current_amount = current_row['unit_amount'] badge = recommended_plan_id(current_plan, trigger) - currency = ladder.get('currency') if isinstance(ladder.get('currency'), str) else 'usd' + 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'] From 5ab792de3befa5873c0cef349eafc426afef0473 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 13 Oct 2026 20:35:07 +0700 Subject: [PATCH 10/14] test(mobile): decode nullable storefront tiers in the v2 golden Co-authored-by: Cursor --- app/test/backend/storefront_wire_test.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/test/backend/storefront_wire_test.dart b/app/test/backend/storefront_wire_test.dart index 5888f7d7b79..c7437b5a8fc 100644 --- a/app/test/backend/storefront_wire_test.dart +++ b/app/test/backend/storefront_wire_test.dart @@ -11,10 +11,11 @@ void main() { expect(decoded.schemaVersion, 2); expect(decoded.header, 'Choose your plan'); - expect(decoded.tiers.map((tier) => tier.name), ['Plus', 'Free', 'Pro', 'Architect']); - expect(decoded.tiers.first.recommended, isTrue); + 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 = decoded.tiers.firstWhere((tier) => tier.name == 'Free'); + final free = tiers.firstWhere((tier) => tier.name == 'Free'); expect(free.compact, isTrue); expect(free.blockedLine, isNull); expect(free.ctaLabel, isNull); From 5b1ce0035bc8ce8a1d93465bd692c574110f0d1a Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 13 Oct 2026 21:05:35 +0700 Subject: [PATCH 11/14] fix(billing): allow the unpublished plans schema 1 to become schema 2 The plans route never shipped a decoder, and the first client accepts only schema 2 on that same path. Co-authored-by: Cursor --- backend/docs/storefront-contract.md | 4 +- .../check_app_client_openapi_compatibility.py | 33 ++++++++++- .../test_app_client_openapi_compatibility.py | 57 +++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/backend/docs/storefront-contract.md b/backend/docs/storefront-contract.md index 4c9af15ec0a..19094866766 100644 --- a/backend/docs/storefront-contract.md +++ b/backend/docs/storefront-contract.md @@ -4,7 +4,9 @@ (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`. Clients render server strings verbatim. Sheet chrome +`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. diff --git a/backend/scripts/check_app_client_openapi_compatibility.py b/backend/scripts/check_app_client_openapi_compatibility.py index 59fd3949cb4..5e218a93294 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' + class OpenAPICompatibilityError(RuntimeError): """The contracts cannot be compared safely.""" @@ -128,12 +136,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 @@ -578,6 +595,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/tests/unit/test_app_client_openapi_compatibility.py b/backend/tests/unit/test_app_client_openapi_compatibility.py index ff14b599892..7e9b5f235cd 100644 --- a/backend/tests/unit/test_app_client_openapi_compatibility.py +++ b/backend/tests/unit/test_app_client_openapi_compatibility.py @@ -397,6 +397,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() From 17cd544fab001f1eb357dae3043e4703fa647438 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 13 Oct 2026 22:31:46 +0700 Subject: [PATCH 12/14] Restore Dart files the merge hook reformatted The merge of origin/main reformatted files this branch does not own, which grew the line-count ratchet against main. Co-authored-by: Cursor From 2d51ee558d01e3fb9d67d9b22c32873c060eb168 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 13 Oct 2026 22:35:17 +0700 Subject: [PATCH 13/14] Restore files the merge hook reformatted Those paths belong to origin/main. The pinned Dart formatter rewrapped them during the merge, which the line-count ratchet counts as growth. Co-authored-by: Cursor --- app/lib/backend/http/api/conversations.dart | 63 +- app/lib/backend/http/api_fallback.dart | 24 +- app/lib/backend/preferences.dart | 3 +- .../provider_capture_external_actions.dart | 9 +- .../pages/apps/widgets/category_section.dart | 18 +- .../pages/conversation_capturing/page.dart | 127 ++- .../conversations/conversations_page.dart | 47 +- .../widgets/empty_conversations.dart | 12 +- .../memories/widgets/memory_graph_page.dart | 9 +- .../onboarding/guided_voice_controller.dart | 104 ++- app/lib/pages/onboarding/guided_voice_io.dart | 35 +- .../pages/onboarding/guided_voice_payoff.dart | 60 +- .../onboarding/guided_voice_requests.dart | 53 +- .../onboarding/speech_profile_widget.dart | 460 +++++------ app/lib/pages/referral/referral_page.dart | 42 +- .../pages/settings/ai_app_generator_page.dart | 53 +- app/lib/pages/settings/integrations_page.dart | 9 +- app/lib/pages/settings/settings_drawer.dart | 13 +- .../settings/task_integrations_page.dart | 13 +- app/lib/providers/capture_provider.dart | 18 +- app/lib/providers/conversation_provider.dart | 147 ++-- app/lib/providers/integration_provider.dart | 26 +- app/lib/providers/onboarding_provider.dart | 37 +- app/lib/providers/phone_call_provider.dart | 12 +- .../providers/task_integration_provider.dart | 14 +- app/lib/services/bridges/ble_bridge.dart | 6 +- .../services/capture/capture_controller.dart | 354 ++++---- .../recording_lifecycle_telemetry.dart | 59 +- app/lib/services/devices.dart | 50 +- .../devices/connectors/device_connection.dart | 3 +- .../base_integration_service.dart | 2 +- app/lib/services/wals/local_wal_sync.dart | 183 ++--- app/lib/services/wals/sync_upload_batch.dart | 8 +- .../utils/analytics/analytics_manager.dart | 757 +++++++++--------- app/lib/utils/auth/sign_in_failure.dart | 6 +- .../onboarding_permission_decision_test.dart | 16 +- .../providers/guided_voice_requests_test.dart | 97 +-- .../integration_status_failure_test.dart | 69 +- .../providers/phone_call_provider_test.dart | 31 +- .../empty_recording_controller_test.dart | 15 +- .../integrations/integration_oauth_test.dart | 13 +- .../support/capture/capture_replay_world.dart | 59 +- app/test/unit/local_wal_sync_test.dart | 695 ++++++++-------- .../unit/sync_manifest_claim_drain_test.dart | 39 +- .../guided_intro_analytics_test.dart | 30 +- .../onboarding_funnel_analytics_test.dart | 12 +- app/test/utils/auth/sign_in_failure_test.dart | 5 +- ...apps_category_section_large_text_test.dart | 35 +- .../capturing_finish_swipe_back_test.dart | 22 +- .../widgets/guided_voice_render_test.dart | 52 +- .../memory_graph_fit_to_bounds_test.dart | 12 +- ...rding_resume_and_voice_retention_test.dart | 8 +- .../process_now_conversations_tab_test.dart | 36 +- .../ai_app_generator_large_text_test.dart | 19 +- .../settings/android_hanging_flows_test.dart | 52 +- ...settings_search_header_stability_test.dart | 4 +- .../speech_profile_onboarding_test.dart | 5 +- 57 files changed, 2044 insertions(+), 2118 deletions(-) diff --git a/app/lib/backend/http/api/conversations.dart b/app/lib/backend/http/api/conversations.dart index b1c4fc5cfd0..5bf4729b5a9 100644 --- a/app/lib/backend/http/api/conversations.dart +++ b/app/lib/backend/http/api/conversations.dart @@ -321,8 +321,8 @@ String conversationCollectionUrl( /// stay for unmigrated callers; 403/503/missing are distinct here instead of null. class ConversationApi { ConversationApi({required String baseUrl, ApiSend? send}) - : _baseUrl = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/', - _send = send; + : _baseUrl = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/', + _send = send; final String _baseUrl; final ApiSend? _send; @@ -358,17 +358,17 @@ class ConversationApi { return switch (sent) { ApiFailure(:final problem) => ApiFailure(problem), ApiSuccess(:final data, :final truncated) => switch (decodeApiRows( - data, - ServerConversation.fromJson, - fallback: recordFallback, - )) { - ApiSuccess(:final data, :final rejectedRows) => ApiSuccess( data, - rejectedRows: rejectedRows, - truncated: truncated, - ), - ApiFailure(:final problem) => ApiFailure(problem), - }, + ServerConversation.fromJson, + fallback: recordFallback, + )) { + ApiSuccess(:final data, :final rejectedRows) => ApiSuccess( + data, + rejectedRows: rejectedRows, + truncated: truncated, + ), + ApiFailure(:final problem) => ApiFailure(problem), + }, }; } @@ -516,7 +516,10 @@ Future assignBulkConversationTranscriptSegments( var response = await makeApiCall( url: speakerId == null ? '${Env.apiBaseUrl}v1/conversations/$conversationId/segments/assign-bulk' - : '${Env.apiBaseUrl}v1/conversations/$conversationId/assign-speaker/$speakerId?${Uri(queryParameters: {'assign_type': assignType, 'value': value ?? 'null'}).query}', + : '${Env.apiBaseUrl}v1/conversations/$conversationId/assign-speaker/$speakerId?${Uri(queryParameters: { + 'assign_type': assignType, + 'value': value ?? 'null' + }).query}', headers: {}, method: 'PATCH', body: jsonEncode({'segment_ids': segmentIds, 'assign_type': assignType, 'value': value}), @@ -805,14 +808,14 @@ bool isSyncContentInProgressResponse(http.Response response) { @visibleForTesting UploadFilesResult? syncContentInProgressResult(http.Response response) { if (!isSyncContentInProgressResponse(response)) return null; - final body = wire.GeneratedSyncContentInProgressResponse.fromJson(jsonDecode(response.body) as Map); + final body = wire.GeneratedSyncContentInProgressResponse.fromJson( + jsonDecode(response.body) as Map, + ); final id = body.jobId; if (id == null || id.trim().isEmpty) return null; final pollAfterMs = body.pollAfterMs ?? 20000; - return UploadFilesResult.queued( - id, - pollAfterMs: max(20000, max(pollAfterMs, (_parseRetryAfterSeconds(response) ?? 10) * 1000)), - ); + return UploadFilesResult.queued(id, + pollAfterMs: max(20000, max(pollAfterMs, (_parseRetryAfterSeconds(response) ?? 10) * 1000))); } /// Upload-only: POST files and return as soon as the server acknowledges @@ -933,7 +936,9 @@ class SyncJobFetch { final SyncJobStatusResponse? status; final SyncRateLimitedException? rateLimit; const SyncJobFetch(this.outcome, [this.status]) : rateLimit = null; - const SyncJobFetch.throttled(this.rateLimit) : outcome = SyncJobFetchOutcome.throttled, status = null; + const SyncJobFetch.throttled(this.rateLimit) + : outcome = SyncJobFetchOutcome.throttled, + status = null; } /// Single GET of a sync job's status — no polling loop. The reconciler owns @@ -951,13 +956,11 @@ Future fetchSyncJobStatus(String jobId) async { } final outcome = syncJobFetchOutcomeForStatusCode(response.statusCode); if (outcome == SyncJobFetchOutcome.throttled) { - return SyncJobFetch.throttled( - SyncRateLimitedException( - kind: syncRateLimitKindForResponse(response), - retryAfterSeconds: _parseRetryAfterSeconds(response), - reasonCode: syncRateLimitReasonCode(response), - ), - ); + return SyncJobFetch.throttled(SyncRateLimitedException( + kind: syncRateLimitKindForResponse(response), + retryAfterSeconds: _parseRetryAfterSeconds(response), + reasonCode: syncRateLimitReasonCode(response), + )); } if (outcome == SyncJobFetchOutcome.notFound) { DebugLogManager.logEvent('fetch_sync_job_status', { @@ -1014,10 +1017,10 @@ class ConversationSearchResult { }); const ConversationSearchResult.failure({this.statusCode}) - : items = const [], - currentPage = 0, - totalPages = 0, - outcome = ConversationSearchResultOutcome.failure; + : items = const [], + currentPage = 0, + totalPages = 0, + outcome = ConversationSearchResultOutcome.failure; bool get isSuccess => outcome == ConversationSearchResultOutcome.success; } diff --git a/app/lib/backend/http/api_fallback.dart b/app/lib/backend/http/api_fallback.dart index 18d47783f29..b609cbb74df 100644 --- a/app/lib/backend/http/api_fallback.dart +++ b/app/lib/backend/http/api_fallback.dart @@ -13,18 +13,18 @@ class ApiFallbackEvent { final ApiFallbackReason reason; final ApiFallbackOutcome outcome; Map toFields() => { - 'component': 'other', - 'area': 'other', - 'from': 'none', - 'to': 'none', - 'reason': switch (reason) { - ApiFallbackReason.timeout => 'timeout', - ApiFallbackReason.error => 'error', - ApiFallbackReason.offline => 'offline', - _ => 'other', - }, - 'outcome': outcome.name, - }; + 'component': 'other', + 'area': 'other', + 'from': 'none', + 'to': 'none', + 'reason': switch (reason) { + ApiFallbackReason.timeout => 'timeout', + ApiFallbackReason.error => 'error', + ApiFallbackReason.offline => 'offline', + _ => 'other', + }, + 'outcome': outcome.name, + }; } /// Default sink is AnalyticsManager's existing track method; injected sink is diff --git a/app/lib/backend/preferences.dart b/app/lib/backend/preferences.dart index 2b564382087..86ea9708abe 100644 --- a/app/lib/backend/preferences.dart +++ b/app/lib/backend/preferences.dart @@ -125,8 +125,7 @@ class SharedPreferencesUtil { final lastVersion = prefs.get('lastKnownAppVersion'); final bootSchema = prefs.get(BootRecovery.schemaKey); - final existingInstall = - prefs.get('onboardingCompleted') == true || + final existingInstall = prefs.get('onboardingCompleted') == true || (lastVersion is String && lastVersion.trim().isNotEmpty) || (bootSchema is int && bootSchema > 0); final savedDefault = prefs.get(appearanceDefaultMigrationKey); diff --git a/app/lib/coordinators/provider_capture_external_actions.dart b/app/lib/coordinators/provider_capture_external_actions.dart index 87a6522b733..e50f55fb9fe 100644 --- a/app/lib/coordinators/provider_capture_external_actions.dart +++ b/app/lib/coordinators/provider_capture_external_actions.dart @@ -86,13 +86,8 @@ class ProviderCaptureExternalActions implements CaptureExternalActions { @override Future assignSpeaker(String conversationId, List segmentIds, String personId, {int? speakerId}) => - assignBulkConversationTranscriptSegments( - conversationId, - segmentIds, - isUser: personId == 'user', - personId: personId == 'user' ? null : personId, - speakerId: speakerId, - ); + assignBulkConversationTranscriptSegments(conversationId, segmentIds, + isUser: personId == 'user', personId: personId == 'user' ? null : personId, speakerId: speakerId); @override Future refreshPeople() { diff --git a/app/lib/pages/apps/widgets/category_section.dart b/app/lib/pages/apps/widgets/category_section.dart index 80182dc2674..dc69d3fca51 100644 --- a/app/lib/pages/apps/widgets/category_section.dart +++ b/app/lib/pages/apps/widgets/category_section.dart @@ -45,11 +45,8 @@ class CategorySection extends StatelessWidget { final scaler = MediaQuery.textScalerOf(context); final direction = Directionality.of(context); double lineOf(TextStyle style) { - final painter = TextPainter( - text: TextSpan(text: 'Ag', style: style), - textScaler: scaler, - textDirection: direction, - )..layout(); + final painter = + TextPainter(text: TextSpan(text: 'Ag', style: style), textScaler: scaler, textDirection: direction)..layout(); final height = painter.height; painter.dispose(); return height; @@ -83,7 +80,10 @@ class CategorySection extends StatelessWidget { padding: const EdgeInsets.fromLTRB(20, 24, 20, 16), child: Row( children: [ - Text(categoryName, style: OmiType.title3), + Text( + categoryName, + style: OmiType.title3, + ), const Spacer(), if (showViewAll) GestureDetector( @@ -96,10 +96,8 @@ class CategorySection extends StatelessWidget { decoration: BoxDecoration(color: OmiColors.surface3, borderRadius: OmiRadius.smAll), child: Text( context.l10n.all, - style: OmiType.caption.copyWith( - color: OmiColors.textSecondary, - fontWeight: FontWeight.w600, - ), + style: + OmiType.caption.copyWith(color: OmiColors.textSecondary, fontWeight: FontWeight.w600), ), ), // const SizedBox(width: 8), diff --git a/app/lib/pages/conversation_capturing/page.dart b/app/lib/pages/conversation_capturing/page.dart index 1fcdf9d2023..2770a36cffc 100644 --- a/app/lib/pages/conversation_capturing/page.dart +++ b/app/lib/pages/conversation_capturing/page.dart @@ -197,26 +197,26 @@ class _ConversationCapturingPageState extends State { ), ) : provider.photos.isNotEmpty - ? _buildChronologicalTimeline( - provider, - transcriptSessionId, - transcriptScrollState, - widget.topConversationId ?? provider.topConversationId, - ) - : getTranscriptWidget( - false, - provider.segments, - provider.photos, - deviceProvider.connectedDevice, - bottomMargin: 0, - taggingSegmentIds: provider.taggingSegmentIds, - transcriptKey: ValueKey('live-transcript-$transcriptSessionId'), - followLatest: true, - scrollState: transcriptScrollState, - jumpToLatestButtonBottom: MediaQuery.paddingOf(context).bottom + 84, - contentVersion: provider.segmentsPhotosVersion, - editSegment: (segmentId, speakerId) => _nameSpeaker(segmentId, speakerId, provider), - ), + ? _buildChronologicalTimeline( + provider, + transcriptSessionId, + transcriptScrollState, + widget.topConversationId ?? provider.topConversationId, + ) + : getTranscriptWidget( + false, + provider.segments, + provider.photos, + deviceProvider.connectedDevice, + bottomMargin: 0, + taggingSegmentIds: provider.taggingSegmentIds, + transcriptKey: ValueKey('live-transcript-$transcriptSessionId'), + followLatest: true, + scrollState: transcriptScrollState, + jumpToLatestButtonBottom: MediaQuery.paddingOf(context).bottom + 84, + contentVersion: provider.segmentsPhotosVersion, + editSegment: (segmentId, speakerId) => _nameSpeaker(segmentId, speakerId, provider), + ), ), ], ), @@ -224,31 +224,31 @@ class _ConversationCapturingPageState extends State { // Pause/Resume (a pause glyph: mics belong to Ask Omi) and Finish, the one stop. floatingActionButton: (provider.liveCaptureSource != null || provider.segments.isNotEmpty || provider.photos.isNotEmpty) - ? Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (provider.liveCaptureSource != null && - LiveCaptureCard.canPause(provider.recordingDevice, source: provider.liveCaptureSource)) ...[ - OmiIconButton.filled( - key: const Key('capture_pause_button'), - icon: Icon(effectivelyMuted ? Icons.play_arrow_rounded : Icons.pause_rounded, size: 26), - label: effectivelyMuted ? context.l10n.resume : context.l10n.pause, - diameter: 52, - fillColor: OmiColors.surface3, - onPressed: _mutePending || provider.isCallActive ? null : () => _toggleMute(provider), - ), - const SizedBox(width: OmiSpacing.sm), - ], - OmiButton( - key: const Key('process_now_button'), - label: context.l10n.finish, - leading: const Icon(Icons.check_rounded), - onPressed: () => _stopConversation(provider), - ), - ], - ) - : null, + ? Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (provider.liveCaptureSource != null && + LiveCaptureCard.canPause(provider.recordingDevice, source: provider.liveCaptureSource)) ...[ + OmiIconButton.filled( + key: const Key('capture_pause_button'), + icon: Icon(effectivelyMuted ? Icons.play_arrow_rounded : Icons.pause_rounded, size: 26), + label: effectivelyMuted ? context.l10n.resume : context.l10n.pause, + diameter: 52, + fillColor: OmiColors.surface3, + onPressed: _mutePending || provider.isCallActive ? null : () => _toggleMute(provider), + ), + const SizedBox(width: OmiSpacing.sm), + ], + OmiButton( + key: const Key('process_now_button'), + label: context.l10n.finish, + leading: const Icon(Icons.check_rounded), + onPressed: () => _stopConversation(provider), + ), + ], + ) + : null, ); }, ); @@ -514,8 +514,7 @@ class _ConversationCapturingPageState extends State { ); if (!mounted || provider.activeCaptureSessionId != sessionId || - (widget.topConversationId ?? provider.topConversationId) != conversationId) - return; + (widget.topConversationId ?? provider.topConversationId) != conversationId) return; if (result is! ApiSuccess) { OmiFeedback.error(context, context.l10n.speakerTagPromptAnswerFailed); return; @@ -564,24 +563,24 @@ class _ConversationCapturingPageState extends State { final bool isUser = segment.isUser; final name = names.forSegment(segment, person: personById(people, segment.personId)); Widget avatar() => Semantics( - button: true, - label: context.l10n.identifySpeaker, - excludeSemantics: true, - onTap: () => _editSegmentSpeaker(segment, provider), - child: GestureDetector( - onTap: () => _editSegmentSpeaker(segment, provider), - child: Column( - children: [ - CircleAvatar( - radius: 16, - backgroundColor: OmiColors.surface2, - child: Icon(Icons.person, size: 16, color: OmiColors.textSecondary), + button: true, + label: context.l10n.identifySpeaker, + excludeSemantics: true, + onTap: () => _editSegmentSpeaker(segment, provider), + child: GestureDetector( + onTap: () => _editSegmentSpeaker(segment, provider), + child: Column( + children: [ + CircleAvatar( + radius: 16, + backgroundColor: OmiColors.surface2, + child: Icon(Icons.person, size: 16, color: OmiColors.textSecondary), + ), + const SizedBox(height: 2), + ], ), - const SizedBox(height: 2), - ], - ), - ), - ); + ), + ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Row( diff --git a/app/lib/pages/conversations/conversations_page.dart b/app/lib/pages/conversations/conversations_page.dart index 879cc6dfb9c..459cdd6984b 100644 --- a/app/lib/pages/conversations/conversations_page.dart +++ b/app/lib/pages/conversations/conversations_page.dart @@ -40,7 +40,14 @@ String _conversationDateRangeLabel(BuildContext context, DateTime start, DateTim return '${dates.date(start)} – ${dates.date(end)}'; } -enum _ConversationListRowKind { topSpacer, dateHeader, processing, conversation, recording, groupSpacer } +enum _ConversationListRowKind { + topSpacer, + dateHeader, + processing, + conversation, + recording, + groupSpacer, +} typedef _ConversationListRow = ({ _ConversationListRowKind kind, @@ -82,7 +89,8 @@ bool shouldReleaseConversationLoadMoreLatch({ required String? currentRequestKey, required String requestKey, required bool succeeded, -}) => !succeeded && currentRequestKey == requestKey; +}) => + !succeeded && currentRequestKey == requestKey; String conversationLoadMoreFilterKey({ required String query, @@ -94,17 +102,18 @@ String conversationLoadMoreFilterKey({ required bool discarded, required bool shortOnly, required int shortThreshold, -}) => [ - query, - folderId ?? '', - speakerId ?? '', - startDate?.toIso8601String() ?? '', - endDate?.toIso8601String() ?? '', - starredOnly, - discarded, - shortOnly, - shortThreshold, -].join('|'); +}) => + [ + query, + folderId ?? '', + speakerId ?? '', + startDate?.toIso8601String() ?? '', + endDate?.toIso8601String() ?? '', + starredOnly, + discarded, + shortOnly, + shortThreshold, + ].join('|'); _ConversationPageSnapshot _conversationPageSnapshot( ConversationProvider conversations, @@ -468,8 +477,7 @@ class _ConversationsPageState extends State with AutomaticKee // Unsynced local recordings (batch/offline mode) shown inline with conversations, // grouped into the same date buckets. Only in the default view (no search/folder/ // starred/daily-summaries filter). - final bool showRecordings = - convoProvider.previousQuery.isEmpty && + final bool showRecordings = convoProvider.previousQuery.isEmpty && convoProvider.selectedFolderId == null && !convoProvider.showStarredOnly; final recordingsByDate = >{}; @@ -490,15 +498,13 @@ class _ConversationsPageState extends State with AutomaticKee conversationLocalDayKey(processingNewest.startedAt ?? processingNewest.createdAt): processingNewest, }; final apiPhase = snapshot.apiViewPhase; - final bool showTypedStatus = - apiPhase == ApiViewPhase.error || + final bool showTypedStatus = apiPhase == ApiViewPhase.error || apiPhase == ApiViewPhase.locked || apiPhase == ApiViewPhase.terminal || apiPhase == ApiViewPhase.authenticationRequired || apiPhase == ApiViewPhase.empty; final bool isWaitingForInitialData = _isBootstrapping && snapshot.conversations.isEmpty && !hasRecordings; - final bool isShowingConversationSkeleton = - isWaitingForInitialData || + final bool isShowingConversationSkeleton = isWaitingForInitialData || convoProvider.isLoadingConversations || convoProvider.isFetchingConversations || convoProvider.isAwaitingInitialFetchRetry; @@ -506,7 +512,8 @@ class _ConversationsPageState extends State with AutomaticKee ...convoProvider.groupedConversations.keys, ...recordingsByDate.keys, ...processingByDate.keys, - }.toList()..sort((a, b) => b.compareTo(a)); + }.toList() + ..sort((a, b) => b.compareTo(a)); final conversationRows = _buildConversationListRows( dates: mergedDates, conversationsByDate: convoProvider.groupedConversations, diff --git a/app/lib/pages/conversations/widgets/empty_conversations.dart b/app/lib/pages/conversations/widgets/empty_conversations.dart index 02cdf1bbfcc..eeaf6d90fee 100644 --- a/app/lib/pages/conversations/widgets/empty_conversations.dart +++ b/app/lib/pages/conversations/widgets/empty_conversations.dart @@ -42,12 +42,12 @@ class EmptyConversationsWidget extends StatelessWidget { child: dateFilterLabel != null ? OmiEmptyState(icon: Icons.forum_rounded, title: l10n.noConversationsOnDate(dateFilterLabel!)) : isStarredFilterActive - ? OmiEmptyState( - glyph: const FaIcon(FontAwesomeIcons.star), - title: l10n.noStarredConversations, - message: l10n.starConversationHint, - ) - : OmiEmptyState(icon: Icons.forum_rounded, title: l10n.noConversationsYet), + ? OmiEmptyState( + glyph: const FaIcon(FontAwesomeIcons.star), + title: l10n.noStarredConversations, + message: l10n.starConversationHint, + ) + : OmiEmptyState(icon: Icons.forum_rounded, title: l10n.noConversationsYet), ); } } diff --git a/app/lib/pages/memories/widgets/memory_graph_page.dart b/app/lib/pages/memories/widgets/memory_graph_page.dart index bb78515b850..66a10edb407 100644 --- a/app/lib/pages/memories/widgets/memory_graph_page.dart +++ b/app/lib/pages/memories/widgets/memory_graph_page.dart @@ -43,9 +43,9 @@ class GraphNode3D { required this.baseColor, required v.Vector3 initialPosition, this.isFixed = false, - }) : position = initialPosition, - velocity = v.Vector3.zero(), - force = v.Vector3.zero(); + }) : position = initialPosition, + velocity = v.Vector3.zero(), + force = v.Vector3.zero(); } class GraphEdge3D { @@ -1105,7 +1105,8 @@ class GraphPainter3D extends CustomPainter { OmiColors.active == OmiPalette.light ? node.baseColor : Colors.white, node.baseColor, 0.5, - )!.withValues(alpha: p.alpha), + )! + .withValues(alpha: p.alpha), node.baseColor.withValues(alpha: p.alpha), ], [0.0, 0.3, 1.0], diff --git a/app/lib/pages/onboarding/guided_voice_controller.dart b/app/lib/pages/onboarding/guided_voice_controller.dart index c4dfd197a55..e60ca50b6c4 100644 --- a/app/lib/pages/onboarding/guided_voice_controller.dart +++ b/app/lib/pages/onboarding/guided_voice_controller.dart @@ -170,20 +170,17 @@ class GuidedVoiceController extends ChangeNotifier { int _promptDurationMs() => _promptViewedAt == null ? 0 : DateTime.now().difference(_promptViewedAt!).inMilliseconds; void _recordCompletedPrompt({required String result, required bool transcriptPresent, String? failureClass}) { - PlatformManager.instance.analytics.track( - 'Guided Intro Prompt Completed', - properties: { - 'session_id': _sessionId, - 'source': flowSource, - 'variant': flowVariant, - 'prompt_index': promptIndex, - 'result': result, - 'duration_ms': _promptDurationMs(), - 'transcript_present': transcriptPresent, - 'transcription_path': transcriptionPath, - if (failureClass != null) 'failure_class': failureClass, - }, - ); + PlatformManager.instance.analytics.track('Guided Intro Prompt Completed', properties: { + 'session_id': _sessionId, + 'source': flowSource, + 'variant': flowVariant, + 'prompt_index': promptIndex, + 'result': result, + 'duration_ms': _promptDurationMs(), + 'transcript_present': transcriptPresent, + 'transcription_path': transcriptionPath, + if (failureClass != null) 'failure_class': failureClass, + }); } void _recordTerminal({required String completionMode}) { @@ -199,16 +196,16 @@ class GuidedVoiceController extends ChangeNotifier { voiceResult: voiceSaved ? 'success' : completionMode == 'saved' && _voiceAttempts > 0 - ? 'failed' - : 'skipped', + ? 'failed' + : 'skipped', memorySaved: savedMemoryCount, goalResult: goal == null ? 'not_present' : goal.saved - ? 'saved' - : goal.keep - ? 'failed' - : 'skipped', + ? 'saved' + : goal.keep + ? 'failed' + : 'skipped', elapsedMs: _startedAt == null ? 0 : DateTime.now().difference(_startedAt!).inMilliseconds, ); } @@ -261,10 +258,8 @@ class GuidedVoiceController extends ChangeNotifier { if (!_preflightPassed && io is GuidedVoiceExperienceIO) { // A slow health check is inconclusive; the final transcription still // has the same-clip retry and typed recovery paths. - final available = await (io as GuidedVoiceExperienceIO).preflight().timeout( - preflightTimeout, - onTimeout: () => true, - ); + final available = + await (io as GuidedVoiceExperienceIO).preflight().timeout(preflightTimeout, onTimeout: () => true); if (!_current(generation)) return; if (!available) throw const GuidedVoiceFailure('transcription_unavailable'); _preflightPassed = true; @@ -316,19 +311,16 @@ class GuidedVoiceController extends ChangeNotifier { error = failure is GuidedVoiceFailure ? failure.failureClass : !_preflightPassed && io is GuidedVoiceExperienceIO - ? 'transcription_unavailable' - : 'engine_start_failed'; - PlatformManager.instance.analytics.track( - 'Guided Intro Recording Failed', - properties: { - 'session_id': _sessionId, - 'source': flowSource, - 'variant': flowVariant, - 'prompt_index': promptIndex, - 'failure_class': error!, - 'transcription_path': transcriptionPath, - }, - ); + ? 'transcription_unavailable' + : 'engine_start_failed'; + PlatformManager.instance.analytics.track('Guided Intro Recording Failed', properties: { + 'session_id': _sessionId, + 'source': flowSource, + 'variant': flowVariant, + 'prompt_index': promptIndex, + 'failure_class': error!, + 'transcription_path': transcriptionPath, + }); await io.stop(); } _emit(); @@ -386,8 +378,8 @@ class GuidedVoiceController extends ChangeNotifier { error = failure is GuidedVoiceFailure ? failure.failureClass : failure is TimeoutException - ? 'transcribe_timeout' - : 'transcribe_http_error'; + ? 'transcribe_timeout' + : 'transcribe_http_error'; _recordCompletedPrompt(result: 'transcription_failed', transcriptPresent: false, failureClass: error); _emit(); } @@ -739,16 +731,14 @@ class GuidedVoiceController extends ChangeNotifier { payoffSource = 'chat'; } catch (failure) { final kind = classifyChatStreamFailure(failure).kind; - recordFallback( - ApiFallbackEvent( - reason: kind == ChatStreamFailureClass.timeout - ? ApiFallbackReason.timeout - : kind == ChatStreamFailureClass.offline - ? ApiFallbackReason.offline - : ApiFallbackReason.error, - outcome: ApiFallbackOutcome.degraded, - ), - ); + recordFallback(ApiFallbackEvent( + reason: kind == ChatStreamFailureClass.timeout + ? ApiFallbackReason.timeout + : kind == ChatStreamFailureClass.offline + ? ApiFallbackReason.offline + : ApiFallbackReason.error, + outcome: ApiFallbackOutcome.degraded, + )); // Saved cards and a short acknowledgment need no network or repeated text. } finally { if (!_disposed && !_payoffContinued) { @@ -762,15 +752,13 @@ class GuidedVoiceController extends ChangeNotifier { void _recordPayoffShown() { if (_payoffShown) return; _payoffShown = true; - const TypedEvents().emit( - GuidedIntroPayoffShown( - memoriesCount: savedAnswers.length, - answerSource: payoffSource == 'chat' - ? GuidedIntroPayoffShownAnswerSource.chat - : GuidedIntroPayoffShownAnswerSource.fallback, - latencyMs: _payoffStartedAt == null ? 0 : DateTime.now().difference(_payoffStartedAt!).inMilliseconds, - ), - ); + const TypedEvents().emit(GuidedIntroPayoffShown( + memoriesCount: savedAnswers.length, + answerSource: payoffSource == 'chat' + ? GuidedIntroPayoffShownAnswerSource.chat + : GuidedIntroPayoffShownAnswerSource.fallback, + latencyMs: _payoffStartedAt == null ? 0 : DateTime.now().difference(_payoffStartedAt!).inMilliseconds, + )); } void continuePayoff() { diff --git a/app/lib/pages/onboarding/guided_voice_io.dart b/app/lib/pages/onboarding/guided_voice_io.dart index 8e1e1136150..2f5bf03a2c7 100644 --- a/app/lib/pages/onboarding/guided_voice_io.dart +++ b/app/lib/pages/onboarding/guided_voice_io.dart @@ -20,9 +20,11 @@ import 'guided_voice_controller.dart'; import 'guided_voice_requests.dart'; class DeviceGuidedVoiceIO implements GuidedVoiceIO, GuidedVoiceExperienceIO { - DeviceGuidedVoiceIO({Future Function(String, String, String)? createMemoryRequest, IMicRecorderService? mic}) - : _createMemoryRequest = createMemoryRequest ?? createMemoryServer, - _mic = mic; + DeviceGuidedVoiceIO({ + Future Function(String, String, String)? createMemoryRequest, + IMicRecorderService? mic, + }) : _createMemoryRequest = createMemoryRequest ?? createMemoryServer, + _mic = mic; final Future Function(String, String, String) _createMemoryRequest; final IMicRecorderService? _mic; @@ -59,20 +61,16 @@ class DeviceGuidedVoiceIO implements GuidedVoiceIO, GuidedVoiceExperienceIO { @override Future ask(String question, List savedAnswers) async { - return askGuidedPayoff( - question, - savedAnswers, - onAccepted: () { - PlatformManager.instance.analytics.chatMessageSent( - message: question, - includesFiles: false, - numberOfFiles: 0, - chatTargetId: 'omi', - isPersonaChat: false, - isVoiceInput: false, - ); - }, - ); + return askGuidedPayoff(question, savedAnswers, onAccepted: () { + PlatformManager.instance.analytics.chatMessageSent( + message: question, + includesFiles: false, + numberOfFiles: 0, + chatTargetId: 'omi', + isPersonaChat: false, + isVoiceInput: false, + ); + }); } @override @@ -136,8 +134,7 @@ class DeviceGuidedVoiceIO implements GuidedVoiceIO, GuidedVoiceExperienceIO { Future transcribe(Uint8List pcm) async { String text; if (_local) { - text = - (await _recognizer!.transcribe(_wav(pcm), language: _language).timeout(const Duration(seconds: 25))) + text = (await _recognizer!.transcribe(_wav(pcm), language: _language).timeout(const Duration(seconds: 25))) ?.rawText ?? ''; } else { diff --git a/app/lib/pages/onboarding/guided_voice_payoff.dart b/app/lib/pages/onboarding/guided_voice_payoff.dart index 0782479c807..d0ec285f082 100644 --- a/app/lib/pages/onboarding/guided_voice_payoff.dart +++ b/app/lib/pages/onboarding/guided_voice_payoff.dart @@ -10,35 +10,33 @@ class GuidedVoicePayoff extends StatelessWidget { @override Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(context.l10n.guidedVoiceExperience('title'), style: OmiType.title1, key: const Key('payoff_title')), - const SizedBox(height: OmiSpacing.lg), - for (final answer in flow.savedAnswers) ...[ - DecoratedBox( - decoration: BoxDecoration(color: OmiColors.surface1, borderRadius: OmiRadius.lgAll), - child: Padding( - padding: const EdgeInsets.all(OmiSpacing.md), - child: Text(answer.text, style: OmiType.body), - ), - ), - const SizedBox(height: OmiSpacing.sm), - ], - const SizedBox(height: OmiSpacing.lg), - if (flow.payoffLoading) Text(context.l10n.guidedVoiceExperience('thinking'), style: OmiType.footnote), - if (flow.payoffSource == 'chat') ...[ - Text(context.l10n.guidedVoiceExperience('question'), style: OmiType.title3), - const SizedBox(height: OmiSpacing.sm), - Semantics( - key: const Key('payoff_answer'), - liveRegion: true, - child: MarkdownBody( - data: flow.payoffAnswer, - styleSheet: MarkdownStyleSheet(p: OmiType.body), - ), - ), - ] else if (!flow.payoffLoading) - Text(context.l10n.guidedVoiceExperience('fallback'), key: const Key('payoff_fallback'), style: OmiType.body), - ], - ); + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(context.l10n.guidedVoiceExperience('title'), style: OmiType.title1, key: const Key('payoff_title')), + const SizedBox(height: OmiSpacing.lg), + for (final answer in flow.savedAnswers) ...[ + DecoratedBox( + decoration: BoxDecoration(color: OmiColors.surface1, borderRadius: OmiRadius.lgAll), + child: Padding( + padding: const EdgeInsets.all(OmiSpacing.md), + child: Text(answer.text, style: OmiType.body), + ), + ), + const SizedBox(height: OmiSpacing.sm), + ], + const SizedBox(height: OmiSpacing.lg), + if (flow.payoffLoading) Text(context.l10n.guidedVoiceExperience('thinking'), style: OmiType.footnote), + if (flow.payoffSource == 'chat') ...[ + Text(context.l10n.guidedVoiceExperience('question'), style: OmiType.title3), + const SizedBox(height: OmiSpacing.sm), + Semantics( + key: const Key('payoff_answer'), + liveRegion: true, + child: MarkdownBody(data: flow.payoffAnswer, styleSheet: MarkdownStyleSheet(p: OmiType.body)), + ), + ] else if (!flow.payoffLoading) + Text(context.l10n.guidedVoiceExperience('fallback'), + key: const Key('payoff_fallback'), style: OmiType.body), + ], + ); } diff --git a/app/lib/pages/onboarding/guided_voice_requests.dart b/app/lib/pages/onboarding/guided_voice_requests.dart index 0599f597705..976f33d01a2 100644 --- a/app/lib/pages/onboarding/guided_voice_requests.dart +++ b/app/lib/pages/onboarding/guided_voice_requests.dart @@ -24,11 +24,7 @@ Future guidedVoicePreflight({ApiSend? probe, Duration timeout = const Dura try { return await (probe?.call(request) ?? sendUncaughtApiCall( - url: request.url, - method: request.method, - headers: request.headers, - body: request.body, - )); + url: request.url, method: request.method, headers: request.headers, body: request.body)); } on TimeoutException { slow = true; rethrow; @@ -49,14 +45,13 @@ Future guidedVoicePreflight({ApiSend? probe, Duration timeout = const Dura } Future transcribeGuidedAnswer(File file, String language, {Future Function()? upload}) async { - final response = - await (upload?.call() ?? - makeMultipartApiCallUnpooled( - url: '${Env.apiBaseUrl}v2/voice-message/transcribe', - files: [file], - fields: {'language': language}, - )) - .timeout(const Duration(seconds: 30)); + final response = await (upload?.call() ?? + makeMultipartApiCallUnpooled( + url: '${Env.apiBaseUrl}v2/voice-message/transcribe', + files: [file], + fields: {'language': language}, + )) + .timeout(const Duration(seconds: 30)); if (response.statusCode != 200) { throw const GuidedVoiceFailure('transcribe_http_error'); } @@ -70,14 +65,11 @@ Future transcribeGuidedAnswer(File file, String language, {Future askGuidedPayoff( - String question, - List savedAnswers, { - Stream Function(String)? stream, - void Function()? onAccepted, - ApiStreamingSeams? seams, - Duration timeout = const Duration(seconds: 8), -}) async { +Future askGuidedPayoff(String question, List savedAnswers, + {Stream Function(String)? stream, + void Function()? onAccepted, + ApiStreamingSeams? seams, + Duration timeout = const Duration(seconds: 8)}) async { final body = jsonEncode({ 'text': '$question\n\n${savedAnswers.map((s) => '> ${s.replaceAll('\n', '\n> ')}').join('\n\n')}', 'device_tools': [], @@ -88,13 +80,12 @@ Future askGuidedPayoff( if (!abort.isCompleted) abort.complete(); client.close(); }); - final lines = StreamIterator( - stream?.call(body) ?? - makeStreamingApiCall( - url: '${Env.apiBaseUrl}v2/messages', - headers: const {'X-Omi-Chat-Failure-Protocol': '1'}, - body: body, - seams: ApiStreamingSeams( + final lines = StreamIterator(stream?.call(body) ?? + makeStreamingApiCall( + url: '${Env.apiBaseUrl}v2/messages', + headers: const {'X-Omi-Chat-Failure-Protocol': '1'}, + body: body, + seams: ApiStreamingSeams( headers: seams?.headers, auth: seams?.auth, transport: (request) async { @@ -105,10 +96,8 @@ Future askGuidedPayoff( final response = await (seams?.transport(bounded) ?? client.send(bounded)); if (response.statusCode == 200) onAccepted?.call(); return response; - }, - ), - ), - ); + }), + )); final deadline = DateTime.now().add(timeout); try { while (await lines.moveNext().timeout(deadline.difference(DateTime.now()))) { diff --git a/app/lib/pages/onboarding/speech_profile_widget.dart b/app/lib/pages/onboarding/speech_profile_widget.dart index c81f7692b7a..8407b589ce6 100644 --- a/app/lib/pages/onboarding/speech_profile_widget.dart +++ b/app/lib/pages/onboarding/speech_profile_widget.dart @@ -60,8 +60,7 @@ class _SpeechProfileWidgetState extends State with WidgetsB @override void initState() { super.initState(); - flow = - widget.controller ?? + flow = widget.controller ?? GuidedVoiceController(DeviceGuidedVoiceIO(), flowSource: widget.flowSource, flowVariant: widget.flowVariant); flow.markStarted(); WidgetsBinding.instance.addObserver(this); @@ -228,10 +227,8 @@ class _SpeechProfileWidgetState extends State with WidgetsB } Widget _button(String text, VoidCallback? onPressed, String key) { - final labelStyle = OmiType.callout.copyWith( - fontWeight: FontWeight.w600, - fontFamily: Theme.of(context).textTheme.bodyMedium?.fontFamily, - ); + final labelStyle = OmiType.callout + .copyWith(fontWeight: FontWeight.w600, fontFamily: Theme.of(context).textTheme.bodyMedium?.fontFamily); return OmiButton(key: Key(key), label: text, onPressed: onPressed, expand: true, labelStyle: labelStyle); } @@ -246,25 +243,25 @@ class _SpeechProfileWidgetState extends State with WidgetsB } Widget _status(String text, {bool loading = false, bool success = false}) => Semantics( - liveRegion: true, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (loading) - const OmiSpinner(size: OmiSpinnerSize.small) - else - Icon(success ? Icons.check_circle_outline : Icons.mic_none, size: 20, color: OmiColors.textPrimary), - const SizedBox(width: 10), - Flexible( - child: Text( - text, - textAlign: TextAlign.center, - style: TextStyle(color: OmiColors.textPrimary.withValues(alpha: 0.7)), - ), + liveRegion: true, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (loading) + const OmiSpinner(size: OmiSpinnerSize.small) + else + Icon(success ? Icons.check_circle_outline : Icons.mic_none, size: 20, color: OmiColors.textPrimary), + const SizedBox(width: 10), + Flexible( + child: Text( + text, + textAlign: TextAlign.center, + style: TextStyle(color: OmiColors.textPrimary.withValues(alpha: 0.7)), + ), + ), + ], ), - ], - ), - ); + ); String get _prompt { if (flow.isGoalPrompt) return copy('goalPrompt'); @@ -308,10 +305,9 @@ class _SpeechProfileWidgetState extends State with WidgetsB maxLength: flow.isGoalPrompt ? 500 : 2000, onChanged: (_) => setState(() {}), decoration: InputDecoration( - labelText: context.l10n.guidedVoiceExperience('typing'), - floatingLabelBehavior: FloatingLabelBehavior.always, - alignLabelWithHint: true, - ), + labelText: context.l10n.guidedVoiceExperience('typing'), + floatingLabelBehavior: FloatingLabelBehavior.always, + alignLabelWithHint: true), ), ], if (flow.active) ...[ @@ -325,11 +321,8 @@ class _SpeechProfileWidgetState extends State with WidgetsB child: CustomPaint(painter: AudioWavePainter(levels: _waveform)), ), ), - Text( - context.l10n.secondsCount(flow.seconds.floor()), - key: const Key('introduction_elapsed'), - style: OmiType.footnote, - ), + Text(context.l10n.secondsCount(flow.seconds.floor()), + key: const Key('introduction_elapsed'), style: OmiType.footnote), // Only while the microphone hears nothing; speech coming through needs no instructions. if (flow.quiet) ...[ const SizedBox(height: OmiSpacing.xs), @@ -341,10 +334,8 @@ class _SpeechProfileWidgetState extends State with WidgetsB ), ], ] else if (flow.busy || _stoppingCapture) - _status( - transcribing ? context.l10n.guidedVoiceExperience('transcribing') : context.l10n.loading, - loading: true, - ) + _status(transcribing ? context.l10n.guidedVoiceExperience('transcribing') : context.l10n.loading, + loading: true) else if (paused) _status(context.l10n.recordingPaused), if (flow.stage == IntroductionStage.confirmation) ...[ @@ -373,13 +364,11 @@ class _SpeechProfileWidgetState extends State with WidgetsB ] else if (flow.error != null) ...[ const SizedBox(height: OmiSpacing.sm), Text( - context.l10n.guidedVoiceExperience( - flow.error == 'transcription_unavailable' - ? 'unavailable' - : flow.error == 'engine_start_failed' - ? 'engine' - : 'failed', - ), + context.l10n.guidedVoiceExperience(flow.error == 'transcription_unavailable' + ? 'unavailable' + : flow.error == 'engine_start_failed' + ? 'engine' + : 'failed'), textAlign: TextAlign.center, style: TextStyle(color: OmiColors.textPrimary, height: 1.5), ), @@ -397,100 +386,103 @@ class _SpeechProfileWidgetState extends State with WidgetsB } Widget _review() => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Semantics( - header: true, - child: Text(copy('review'), style: OmiType.title1.copyWith(fontWeight: FontWeight.w600)), - ), - const SizedBox(height: 12), - Text(copy('reviewHint'), style: OmiType.callout.copyWith(color: OmiColors.textSecondary, height: 1.5)), - const SizedBox(height: 20), - for (var i = 0; i < flow.answers.length; i++) ...[ - DecoratedBox( - decoration: BoxDecoration(color: OmiColors.surface1, borderRadius: OmiRadius.lgAll), - child: Padding( - padding: const EdgeInsets.fromLTRB(4, 8, 16, 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Checkbox( - value: flow.answers[i].keep, - activeColor: OmiColors.accent, - checkColor: OmiColors.onAccent, - onChanged: flow.busy || flow.answers[i].locked ? null : (value) => flow.setKeep(i, value ?? false), - ), - Expanded( - child: TextFormField( - key: Key('introduction_memory_${flow.answers[i].id}_${flow.answers[i].editRevision}'), - initialValue: flow.answers[i].text, - minLines: 1, - maxLines: 5, - maxLength: flow.answers[i].isGoal ? 500 : null, - enabled: !flow.busy && !flow.answers[i].locked, - onChanged: (value) => flow.edit(i, value), - style: TextStyle(color: OmiColors.textPrimary, height: 1.5), - decoration: InputDecoration( - border: InputBorder.none, - counterText: '', - labelText: flow.answers[i].isGoal ? context.l10n.myGoal : context.l10n.memories, - labelStyle: TextStyle(color: OmiColors.textPrimary.withValues(alpha: 0.7)), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + header: true, + child: Text(copy('review'), style: OmiType.title1.copyWith(fontWeight: FontWeight.w600)), + ), + const SizedBox(height: 12), + Text(copy('reviewHint'), style: OmiType.callout.copyWith(color: OmiColors.textSecondary, height: 1.5)), + const SizedBox(height: 20), + for (var i = 0; i < flow.answers.length; i++) ...[ + DecoratedBox( + decoration: BoxDecoration(color: OmiColors.surface1, borderRadius: OmiRadius.lgAll), + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 8, 16, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Checkbox( + value: flow.answers[i].keep, + activeColor: OmiColors.accent, + checkColor: OmiColors.onAccent, + onChanged: + flow.busy || flow.answers[i].locked ? null : (value) => flow.setKeep(i, value ?? false), ), - ), + Expanded( + child: TextFormField( + key: Key('introduction_memory_${flow.answers[i].id}_${flow.answers[i].editRevision}'), + initialValue: flow.answers[i].text, + minLines: 1, + maxLines: 5, + maxLength: flow.answers[i].isGoal ? 500 : null, + enabled: !flow.busy && !flow.answers[i].locked, + onChanged: (value) => flow.edit(i, value), + style: TextStyle(color: OmiColors.textPrimary, height: 1.5), + decoration: InputDecoration( + border: InputBorder.none, + counterText: '', + labelText: flow.answers[i].isGoal ? context.l10n.myGoal : context.l10n.memories, + labelStyle: TextStyle(color: OmiColors.textPrimary.withValues(alpha: 0.7)), + ), + ), + ), + if (flow.answers[i].saved) + Padding( + padding: const EdgeInsets.only(top: 14), + child: Icon(Icons.check, color: OmiColors.textPrimary, size: 18), + ), + ], ), - if (flow.answers[i].saved) - Padding( - padding: const EdgeInsets.only(top: 14), - child: Icon(Icons.check, color: OmiColors.textPrimary, size: 18), - ), - ], + ), ), - ), - ), - if (flow.answers[i].isGoal && - flow.answers[i].originalText != null && - flow.answers[i].originalText != flow.answers[i].text && - !flow.answers[i].locked) - Align( - alignment: Alignment.centerLeft, - child: TextButton( - key: Key('introduction_original_${flow.answers[i].id}'), - onPressed: flow.busy ? null : () => flow.useOriginalGoal(i), - child: Text(copy('originalGoal'), style: TextStyle(color: OmiColors.textPrimary.withValues(alpha: 0.7))), + if (flow.answers[i].isGoal && + flow.answers[i].originalText != null && + flow.answers[i].originalText != flow.answers[i].text && + !flow.answers[i].locked) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + key: Key('introduction_original_${flow.answers[i].id}'), + onPressed: flow.busy ? null : () => flow.useOriginalGoal(i), + child: + Text(copy('originalGoal'), style: TextStyle(color: OmiColors.textPrimary.withValues(alpha: 0.7))), + ), + ), + const SizedBox(height: 12), + ], + if (flow.answers.isEmpty) + Text(copy('noMemories'), + style: TextStyle(color: OmiColors.textPrimary.withValues(alpha: 0.7), height: 1.5)), + const SizedBox(height: 20), + // saveAll() enrolls the voice first, so `voiceSaved` is already true while the + // answers are still uploading: both receipts render together for the whole + // memory-save round trip. Space them like the done view below, or the spinner + // and the check sit on the same leading edge and read as one broken glyph. + if (flow.stage == IntroductionStage.savingMemories) ...[ + _status(copy('savingAnswers'), loading: true), + const SizedBox(height: 16), + ], + if (flow.voiceSaved) + _status(copy('savedVoice'), success: true) + else if (flow.stage == IntroductionStage.savingVoice) + _status(copy('savingVoice'), loading: true) + else if (flow.voiceError == 'short') + Text(copy('short'), style: TextStyle(color: OmiColors.textPrimary, height: 1.5)) + else if (flow.voiceError == 'upload') + Text(copy('uploadError'), style: TextStyle(color: OmiColors.textPrimary, height: 1.5)), + if (flow.voiceError == 'voiceUnavailable') + Text(copy('voiceUnavailable'), style: TextStyle(color: OmiColors.textPrimary, height: 1.5)), + if (flow.error == 'goal' || flow.error == 'goalLong') + Text( + copy(flow.error == 'goal' ? 'goalError' : 'goalLong'), + style: TextStyle(color: OmiColors.textPrimary, height: 1.5), ), - ), - const SizedBox(height: 12), - ], - if (flow.answers.isEmpty) - Text(copy('noMemories'), style: TextStyle(color: OmiColors.textPrimary.withValues(alpha: 0.7), height: 1.5)), - const SizedBox(height: 20), - // saveAll() enrolls the voice first, so `voiceSaved` is already true while the - // answers are still uploading: both receipts render together for the whole - // memory-save round trip. Space them like the done view below, or the spinner - // and the check sit on the same leading edge and read as one broken glyph. - if (flow.stage == IntroductionStage.savingMemories) ...[ - _status(copy('savingAnswers'), loading: true), - const SizedBox(height: 16), - ], - if (flow.voiceSaved) - _status(copy('savedVoice'), success: true) - else if (flow.stage == IntroductionStage.savingVoice) - _status(copy('savingVoice'), loading: true) - else if (flow.voiceError == 'short') - Text(copy('short'), style: TextStyle(color: OmiColors.textPrimary, height: 1.5)) - else if (flow.voiceError == 'upload') - Text(copy('uploadError'), style: TextStyle(color: OmiColors.textPrimary, height: 1.5)), - if (flow.voiceError == 'voiceUnavailable') - Text(copy('voiceUnavailable'), style: TextStyle(color: OmiColors.textPrimary, height: 1.5)), - if (flow.error == 'goal' || flow.error == 'goalLong') - Text( - copy(flow.error == 'goal' ? 'goalError' : 'goalLong'), - style: TextStyle(color: OmiColors.textPrimary, height: 1.5), - ), - if (flow.error == 'memories') - Text(copy('memoryError'), style: TextStyle(color: OmiColors.textPrimary, height: 1.5)), - ], - ); + if (flow.error == 'memories') + Text(copy('memoryError'), style: TextStyle(color: OmiColors.textPrimary, height: 1.5)), + ], + ); List _actions() { // Scaffold removes consumed insets from its body MediaQuery. Read the @@ -505,17 +497,16 @@ class _SpeechProfileWidgetState extends State with WidgetsB if (flow.stage == IntroductionStage.typing) { return [ _button( - context.l10n.guidedVoiceExperience('answer'), - _typedAnswer.text.trim().isEmpty - ? null - : () { - final text = _typedAnswer.text; - _typedAnswer.clear(); - FocusScope.of(context).unfocus(); - unawaited(flow.submitTyped(text)); - }, - 'introduction_save_typed', - ), + context.l10n.guidedVoiceExperience('answer'), + _typedAnswer.text.trim().isEmpty + ? null + : () { + final text = _typedAnswer.text; + _typedAnswer.clear(); + FocusScope.of(context).unfocus(); + unawaited(flow.submitTyped(text)); + }, + 'introduction_save_typed'), _textAction(copy('skipPrompt'), () { _typedAnswer.clear(); unawaited(flow.skipPrompt()); @@ -524,16 +515,10 @@ class _SpeechProfileWidgetState extends State with WidgetsB } if (flow.stage == IntroductionStage.confirmation) { return [ - _button( - context.l10n.guidedVoiceExperience('confirm'), - () => unawaited(flow.confirmAnswer()), - 'introduction_confirm', - ), + _button(context.l10n.guidedVoiceExperience('confirm'), () => unawaited(flow.confirmAnswer()), + 'introduction_confirm'), _textAction( - context.l10n.guidedVoiceExperience('type'), - () => unawaited(flow.typeInstead()), - 'introduction_type', - ), + context.l10n.guidedVoiceExperience('type'), () => unawaited(flow.typeInstead()), 'introduction_type'), _textAction(copy('skipPrompt'), () => unawaited(flow.skipPrompt()), 'introduction_skip_prompt'), ]; } @@ -541,11 +526,8 @@ class _SpeechProfileWidgetState extends State with WidgetsB return [ _button(context.l10n.guidedVoiceExperience('type'), () => unawaited(flow.typeInstead()), 'introduction_type'), if (flow.error!.startsWith('transcribe_') && flow.canFinish) - _textAction( - context.l10n.guidedVoiceExperience('retryTranscription'), - () => unawaited(flow.next()), - 'introduction_retry_transcription', - ), + _textAction(context.l10n.guidedVoiceExperience('retryTranscription'), () => unawaited(flow.next()), + 'introduction_retry_transcription'), _textAction(context.l10n.guidedVoiceExperience('retry'), () => unawaited(_start()), 'speech_profile_start'), _textAction(copy('skipPrompt'), () => unawaited(flow.skipPrompt()), 'introduction_skip_prompt'), ]; @@ -630,101 +612,97 @@ class _SpeechProfileWidgetState extends State with WidgetsB } Widget _title() => Semantics( - header: true, - child: Text(copy('title'), textAlign: TextAlign.center, style: OmiType.title2), - ); + header: true, + child: Text(copy('title'), textAlign: TextAlign.center, style: OmiType.title2), + ); /// Before and while recording: the title at the top and the prompt centred in the space left, /// scrolling instead when large text does not fit. Widget _prompting() => CustomScrollView( - key: const Key('introduction_scroll'), - controller: _scrollController, - slivers: [ - SliverPadding( - padding: const EdgeInsets.fromLTRB(24, 20, 24, 20), - sliver: SliverFillRemaining( - hasScrollBody: false, - child: Column( - children: [ - _title(), - const SizedBox(height: OmiSpacing.xl), - Expanded(child: Center(child: _recording())), - ], + key: const Key('introduction_scroll'), + controller: _scrollController, + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 20), + sliver: SliverFillRemaining( + hasScrollBody: false, + child: Column( + children: [ + _title(), + const SizedBox(height: OmiSpacing.xl), + Expanded(child: Center(child: _recording())), + ], + ), + ), ), - ), - ), - ], - ); + ], + ); /// Review and the saved receipt. Review carries its own heading ("Here is what I heard"), so the /// step title is not repeated above it. Widget _scrolling(bool done) => SingleChildScrollView( - key: const Key('introduction_scroll'), - controller: _scrollController, - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - padding: const EdgeInsets.fromLTRB(24, 20, 24, 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (done) ...[ - _title(), - const SizedBox(height: 28), - Icon(Icons.check_circle_outline, size: 64, color: OmiColors.textPrimary), - const SizedBox(height: 24), - _status(flow.savedMemoryCount > 0 ? copy('savedMemories') : copy('noMemories'), success: true), - if (flow.goalSaved) ...[const SizedBox(height: 16), _status(copy('savedGoal'), success: true)], - if (flow.voiceSaved) ...[const SizedBox(height: 16), _status(copy('savedVoice'), success: true)], - ] else - _review(), - ], - ), - ); + key: const Key('introduction_scroll'), + controller: _scrollController, + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.fromLTRB(24, 20, 24, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (done) ...[ + _title(), + const SizedBox(height: 28), + Icon(Icons.check_circle_outline, size: 64, color: OmiColors.textPrimary), + const SizedBox(height: 24), + _status(flow.savedMemoryCount > 0 ? copy('savedMemories') : copy('noMemories'), success: true), + if (flow.goalSaved) ...[const SizedBox(height: 16), _status(copy('savedGoal'), success: true)], + if (flow.voiceSaved) ...[const SizedBox(height: 16), _status(copy('savedVoice'), success: true)], + ] else + _review(), + ], + ), + ); @override Widget build(BuildContext context) => AnimatedBuilder( - animation: flow, - builder: (context, _) { - final done = flow.stage == IntroductionStage.done; - final reviewing = flow.promptIndex >= GuidedVoiceController.promptCount; - return PopScope( - canPop: !flow.saving, - child: ColoredBox( - color: OmiColors.surface0, - child: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) => Column( - children: [ - Expanded( - child: flow.stage == IntroductionStage.payoff - ? SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: GuidedVoicePayoff(flow: flow), - ) - : done || reviewing - ? _scrolling(done) - : _prompting(), - ), - ConstrainedBox( - constraints: BoxConstraints(maxHeight: constraints.maxHeight * 0.6), - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(24, 8, 24, 12), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ..._actions(), - if (flow.stage == IntroductionStage.ready && !flow.isGoalPrompt) - _textAction(copy('another'), flow.changePrompt, 'introduction_another'), - ], - ), - ), - ), - ], + animation: flow, + builder: (context, _) { + final done = flow.stage == IntroductionStage.done; + final reviewing = flow.promptIndex >= GuidedVoiceController.promptCount; + return PopScope( + canPop: !flow.saving, + child: ColoredBox( + color: OmiColors.surface0, + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) => Column( + children: [ + Expanded( + child: flow.stage == IntroductionStage.payoff + ? SingleChildScrollView( + padding: const EdgeInsets.all(24), child: GuidedVoicePayoff(flow: flow)) + : done || reviewing + ? _scrolling(done) + : _prompting()), + ConstrainedBox( + constraints: BoxConstraints(maxHeight: constraints.maxHeight * 0.6), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ..._actions(), + if (flow.stage == IntroductionStage.ready && !flow.isGoalPrompt) + _textAction(copy('another'), flow.changePrompt, 'introduction_another'), + ], + ), + ), + ), + ], + )), ), ), - ), - ), + ); + }, ); - }, - ); } diff --git a/app/lib/pages/referral/referral_page.dart b/app/lib/pages/referral/referral_page.dart index aa4e314a7e5..4c7bb35c8c9 100644 --- a/app/lib/pages/referral/referral_page.dart +++ b/app/lib/pages/referral/referral_page.dart @@ -51,28 +51,26 @@ class _ReferralPageState extends State { appBar: AppBar(leading: const OmiBackButton(), title: Text(context.l10n.referralProgram)), body: SafeArea( child: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: _failed - ? OmiErrorState( - message: context.l10n.failedToStartAuth(context.l10n.referralProgram), - onRetry: _openBrowser, - ) - : OmiEmptyState( - icon: Icons.open_in_browser, - title: context.l10n.referralProgram, - message: context.l10n.completeAuthInBrowser, - action: OmiButton( - key: const Key('referral-open-browser'), - label: context.l10n.open, - isLoading: _opening, - onPressed: _openBrowser, - ), - ), - ), - ), - ), + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: _failed + ? OmiErrorState( + message: context.l10n.failedToStartAuth(context.l10n.referralProgram), + onRetry: _openBrowser) + : OmiEmptyState( + icon: Icons.open_in_browser, + title: context.l10n.referralProgram, + message: context.l10n.completeAuthInBrowser, + action: OmiButton( + key: const Key('referral-open-browser'), + label: context.l10n.open, + isLoading: _opening, + onPressed: _openBrowser, + ), + ), + ), + )), ), ); } diff --git a/app/lib/pages/settings/ai_app_generator_page.dart b/app/lib/pages/settings/ai_app_generator_page.dart index 2bf3a2d4869..b4c114e35ed 100644 --- a/app/lib/pages/settings/ai_app_generator_page.dart +++ b/app/lib/pages/settings/ai_app_generator_page.dart @@ -186,10 +186,8 @@ class _AiAppGeneratorPageState extends State<_AiAppGeneratorPageView> { ), child: Text( '${(progress * 100).round()}%', - style: OmiType.caption.copyWith( - color: OmiColors.textSecondary, - fontWeight: FontWeight.w600, - ), + style: + OmiType.caption.copyWith(color: OmiColors.textSecondary, fontWeight: FontWeight.w600), ), ), ), @@ -199,7 +197,11 @@ class _AiAppGeneratorPageState extends State<_AiAppGeneratorPageView> { // App name (shimmer until it is ready) provider.generatedName != null - ? Text(provider.generatedName!, style: OmiType.title3, textAlign: TextAlign.center) + ? Text( + provider.generatedName!, + style: OmiType.title3, + textAlign: TextAlign.center, + ) : const _ShimmerBlock(width: 160, height: 24), const SizedBox(height: OmiSpacing.sm), @@ -287,12 +289,12 @@ class _AiAppGeneratorPageState extends State<_AiAppGeneratorPageView> { child: isCompleted ? FaIcon(FontAwesomeIcons.check, color: OmiColors.onAccent, size: 12) : isActive - ? const OmiSpinner(size: OmiSpinnerSize.small) - : Container( - width: 8, - height: 8, - decoration: BoxDecoration(shape: BoxShape.circle, color: OmiColors.textTertiary), - ), + ? const OmiSpinner(size: OmiSpinnerSize.small) + : Container( + width: 8, + height: 8, + decoration: BoxDecoration(shape: BoxShape.circle, color: OmiColors.textTertiary), + ), ), ), const SizedBox(width: 14), @@ -325,7 +327,11 @@ class _AiAppGeneratorPageState extends State<_AiAppGeneratorPageView> { padding: const EdgeInsets.only(left: 15), child: Row( children: [ - Container(width: 2, height: OmiSpacing.xl, color: isCompleted ? OmiColors.accent : OmiColors.surface2), + Container( + width: 2, + height: OmiSpacing.xl, + color: isCompleted ? OmiColors.accent : OmiColors.surface2, + ), ], ), ), @@ -414,10 +420,7 @@ class _AiAppGeneratorPageState extends State<_AiAppGeneratorPageView> { if (hasText || isGenerating) ...[ const SizedBox(width: OmiSpacing.xs), isGenerating - ? const SizedBox.square( - dimension: kOmiMinTapTarget, - child: Center(child: OmiSpinner()), - ) + ? const SizedBox.square(dimension: kOmiMinTapTarget, child: Center(child: OmiSpinner())) : OmiIconButton.filled( icon: const FaIcon(FontAwesomeIcons.arrowUp, size: 18), label: context.l10n.send, @@ -585,9 +588,7 @@ class _AiAppGeneratorPageState extends State<_AiAppGeneratorPageView> { child: Center(child: FaIcon(icon, color: OmiColors.textPrimary, size: 16)), ), const SizedBox(width: 14), - Expanded( - child: Text(description, style: OmiType.subhead.copyWith(fontWeight: FontWeight.w600)), - ), + Expanded(child: Text(description, style: OmiType.subhead.copyWith(fontWeight: FontWeight.w600))), ], ), ); @@ -799,10 +800,7 @@ class _Badge extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ if (icon != null) ...[FaIcon(icon, color: color, size: 12), const SizedBox(width: 6)], - Text( - label, - style: OmiType.footnote.copyWith(color: color, fontWeight: FontWeight.w600), - ), + Text(label, style: OmiType.footnote.copyWith(color: color, fontWeight: FontWeight.w600)), ], ), ); @@ -838,10 +836,11 @@ class _SuggestionCardShimmer extends StatelessWidget { @override Widget build(BuildContext context) { Widget line(double width) => Container( - height: 16, - width: width, - decoration: BoxDecoration(color: OmiColors.textPrimary, borderRadius: BorderRadius.circular(OmiRadius.sm / 2)), - ); + height: 16, + width: width, + decoration: + BoxDecoration(color: OmiColors.textPrimary, borderRadius: BorderRadius.circular(OmiRadius.sm / 2)), + ); return ShimmerWithTimeout( baseColor: OmiColors.surface1, highlightColor: OmiColors.surface2, diff --git a/app/lib/pages/settings/integrations_page.dart b/app/lib/pages/settings/integrations_page.dart index 77ff9eff589..24fade59524 100644 --- a/app/lib/pages/settings/integrations_page.dart +++ b/app/lib/pages/settings/integrations_page.dart @@ -473,12 +473,9 @@ class _IntegrationsPageState extends State with WidgetsBinding children: [ if (provider.hasLoadError) Padding( - padding: const EdgeInsets.only(bottom: OmiSpacing.md), - child: Semantics( - liveRegion: true, - child: Text(context.l10n.connectionErrorDesc, style: OmiType.subhead), - ), - ), + padding: const EdgeInsets.only(bottom: OmiSpacing.md), + child: Semantics( + liveRegion: true, child: Text(context.l10n.connectionErrorDesc, style: OmiType.subhead))), const ChatAppsIntegrationsEntry(), ...IntegrationApp.values.map((app) => _buildAppTile(app, isLoading)), Padding( diff --git a/app/lib/pages/settings/settings_drawer.dart b/app/lib/pages/settings/settings_drawer.dart index 90e281d867f..ae011b5ab09 100644 --- a/app/lib/pages/settings/settings_drawer.dart +++ b/app/lib/pages/settings/settings_drawer.dart @@ -75,10 +75,10 @@ class _SettingsDrawerState extends State { } SettingsSearchScope _searchScope(BuildContext context) => SettingsSearchScope( - deviceConnected: context.read().isConnected, - supportLinks: PlatformService.isIntercomSupported, - android: PlatformService.isAndroid, - ); + deviceConnected: context.read().isConnected, + supportLinks: PlatformService.isIntercomSupported, + android: PlatformService.isAndroid, + ); // --------------------------------------------------------------------------------------------- // Rows @@ -326,9 +326,8 @@ class _SettingsDrawerState extends State { child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: OmiSpacing.lg), keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - child: _isSearching && _searchQuery.trim().isNotEmpty - ? _buildSearchResults(context) - : _buildSettings(context), + child: + _isSearching && _searchQuery.trim().isNotEmpty ? _buildSearchResults(context) : _buildSettings(context), ), ), ], diff --git a/app/lib/pages/settings/task_integrations_page.dart b/app/lib/pages/settings/task_integrations_page.dart index 7fbc21ad85d..6925b4225e4 100644 --- a/app/lib/pages/settings/task_integrations_page.dart +++ b/app/lib/pages/settings/task_integrations_page.dart @@ -193,8 +193,7 @@ class _TaskIntegrationsPageState extends State with Widget bool _shouldShowSettingsIcon() { final selected = context.read().selectedApp; - final hasSettings = - (selected == TaskIntegrationApp.asana && AsanaService().isAuthenticated) || + final hasSettings = (selected == TaskIntegrationApp.asana && AsanaService().isAuthenticated) || (selected == TaskIntegrationApp.clickup && ClickUpService().isAuthenticated) || (selected == TaskIntegrationApp.todoist && TodoistService().isAuthenticated) || (selected == TaskIntegrationApp.googleTasks && GoogleTasksService().isAuthenticated); @@ -389,10 +388,7 @@ class _TaskIntegrationsPageState extends State with Widget trailing = Container( width: 24, height: 24, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: OmiColors.border, width: 2), - ), + decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: OmiColors.border, width: 2)), ); } @@ -479,9 +475,8 @@ class _TaskIntegrationsPageState extends State with Widget if (_pendingAuthApp != null) ...[ Text(context.l10n.completeAuthBrowser, style: OmiType.subhead), TextButton( - onPressed: isLoading ? null : () => _loadFromBackend(forceRefresh: true), - child: Text(context.l10n.tryAgain), - ), + onPressed: isLoading ? null : () => _loadFromBackend(forceRefresh: true), + child: Text(context.l10n.tryAgain)), ], // App List Expanded( diff --git a/app/lib/providers/capture_provider.dart b/app/lib/providers/capture_provider.dart index 2c24861013a..0e03e5017c0 100644 --- a/app/lib/providers/capture_provider.dart +++ b/app/lib/providers/capture_provider.dart @@ -57,16 +57,14 @@ class CaptureProvider extends CaptureController { final pending = List.of(segments); final owner = sessionOwner; final token = owner?.token; - _liveSegmentWrite = _liveSegmentWrite - .then((_) async { - if (owner != null && token != null && !owner.isCurrent(token)) return; - await localSegmentStore.replaceSession(sessionId, pending); - if (owner != null && token != null && !owner.isCurrent(token)) return; - _lastPersistedFingerprint = fingerprint; - }) - .catchError((Object e) { - Logger.debug('Error persisting live segments: $e'); - }); + _liveSegmentWrite = _liveSegmentWrite.then((_) async { + if (owner != null && token != null && !owner.isCurrent(token)) return; + await localSegmentStore.replaceSession(sessionId, pending); + if (owner != null && token != null && !owner.isCurrent(token)) return; + _lastPersistedFingerprint = fingerprint; + }).catchError((Object e) { + Logger.debug('Error persisting live segments: $e'); + }); unawaited(_liveSegmentWrite); } } diff --git a/app/lib/providers/conversation_provider.dart b/app/lib/providers/conversation_provider.dart index 0e8317f583b..6655be8ad44 100644 --- a/app/lib/providers/conversation_provider.dart +++ b/app/lib/providers/conversation_provider.dart @@ -27,26 +27,24 @@ typedef ConversationLifecycleFetcher = Future<({ServerConversation? item, bool o /// Returns null when the check could not be made, so the caller keeps the /// last known answer instead of reading a failure as "no recaps". typedef DailySummariesChecker = Future Function(); -typedef ConversationSearchFetcher = - Future<(List, int, int)> Function( - String query, { - int? page, - int? limit, - required bool includeDiscarded, - DateTime? startDate, - DateTime? endDate, - String? speakerId, - }); -typedef ConversationSearchResultFetcher = - Future Function( - String query, { - int? page, - int? limit, - required bool includeDiscarded, - DateTime? startDate, - DateTime? endDate, - String? speakerId, - }); +typedef ConversationSearchFetcher = Future<(List, int, int)> Function( + String query, { + int? page, + int? limit, + required bool includeDiscarded, + DateTime? startDate, + DateTime? endDate, + String? speakerId, +}); +typedef ConversationSearchResultFetcher = Future Function( + String query, { + int? page, + int? limit, + required bool includeDiscarded, + DateTime? startDate, + DateTime? endDate, + String? speakerId, +}); typedef ConversationDetailsFetcher = Future Function(String conversationId); /// Day-bucket key for a conversation timestamp, in the viewer's **local** timezone. @@ -193,36 +191,34 @@ class ConversationProvider extends ChangeNotifier { ConversationSearchResultFetcher? conversationSearchResultFetcher, bool Function()? isSignedIn, ConversationApi? conversationApi, - }) : _conversationListFetcher = conversationListFetcher, - _conversationLifecycleFetcher = - conversationLifecycleFetcher ?? - (conversationApi == null - ? getConversationByIdResult - : (id) => _legacyLifecycleFromTyped(conversationApi, id)), - _dailySummariesChecker = dailySummariesChecker, - _conversationSearchResultFetcher = - conversationSearchResultFetcher ?? - (conversationSearchFetcher == null - ? searchConversationsServerResult - : (query, {page, limit, required includeDiscarded, startDate, endDate, speakerId}) async { - final (items, currentPage, totalPages) = await conversationSearchFetcher( - query, - page: page, - limit: limit, - includeDiscarded: includeDiscarded, - startDate: startDate, - endDate: endDate, - speakerId: speakerId, - ); - return ConversationSearchResult( - items: items, - currentPage: currentPage, - totalPages: totalPages, - outcome: ConversationSearchResultOutcome.success, - ); - }), - _isSignedIn = isSignedIn ?? AuthService.instance.isSignedIn, - _conversationApi = conversationApi { + }) : _conversationListFetcher = conversationListFetcher, + _conversationLifecycleFetcher = conversationLifecycleFetcher ?? + (conversationApi == null + ? getConversationByIdResult + : (id) => _legacyLifecycleFromTyped(conversationApi, id)), + _dailySummariesChecker = dailySummariesChecker, + _conversationSearchResultFetcher = conversationSearchResultFetcher ?? + (conversationSearchFetcher == null + ? searchConversationsServerResult + : (query, {page, limit, required includeDiscarded, startDate, endDate, speakerId}) async { + final (items, currentPage, totalPages) = await conversationSearchFetcher( + query, + page: page, + limit: limit, + includeDiscarded: includeDiscarded, + startDate: startDate, + endDate: endDate, + speakerId: speakerId, + ); + return ConversationSearchResult( + items: items, + currentPage: currentPage, + totalPages: totalPages, + outcome: ConversationSearchResultOutcome.success, + ); + }), + _isSignedIn = isSignedIn ?? AuthService.instance.isSignedIn, + _conversationApi = conversationApi { _setupMergeListener(); _loadSettings(); } @@ -253,9 +249,10 @@ class ConversationProvider extends ChangeNotifier { if (api == null) return; final result = await api.byId(id); _typedDetailStates[id] = presentApiResult(result, isEmpty: (_) => false); - if (result case ApiFailure( - :final problem, - ) when problem.kind == ApiProblemKind.paymentRequired || problem.kind == ApiProblemKind.unprocessable) { + if (result + case ApiFailure( + :final problem, + ) when problem.kind == ApiProblemKind.paymentRequired || problem.kind == ApiProblemKind.unprocessable) { processingConversations = processingConversations.where((conversation) => conversation.id != id).toList(); } notifyListeners(); @@ -617,9 +614,8 @@ class ConversationProvider extends ChangeNotifier { Future checkHasDailySummaries() async { if (!_isSignedIn()) return false; final generation = _sessionGeneration; - final hasSummaries = - await (_dailySummariesChecker?.call() ?? - getDailySummaries(limit: 1, offset: 0).then((result) => result.ok ? result.items.isNotEmpty : null)); + final hasSummaries = await (_dailySummariesChecker?.call() ?? + getDailySummaries(limit: 1, offset: 0).then((result) => result.ok ? result.items.isNotEmpty : null)); if (generation != _sessionGeneration || !_isSignedIn()) return false; if (hasSummaries == null) return true; hasDailySummaries = hasSummaries; @@ -927,8 +923,7 @@ class ConversationProvider extends ChangeNotifier { // Only the unfiltered successful server page can prove absence. The UI may // show cached rows after an empty server response; never use those as the // authoritative keep-set for Spotlight. - final siriFetchIsUnfiltered = - selectedFolderId == null && + final siriFetchIsUnfiltered = selectedFolderId == null && selectedStartDate == null && selectedEndDate == null && selectedSpeakerId == null && @@ -1160,7 +1155,7 @@ class ConversationProvider extends ChangeNotifier { } ({List items, bool ok, bool truncated, ApiResult>? typed}) - _packTypedConversationList(ApiResult> typed) { + _packTypedConversationList(ApiResult> typed) { return switch (typed) { ApiSuccess(:final data) => (items: data, ok: true, truncated: false, typed: typed), ApiFailure() => (items: [], ok: false, truncated: false, typed: typed), @@ -1168,7 +1163,7 @@ class ConversationProvider extends ChangeNotifier { } Future<({List items, bool ok, bool truncated, ApiResult>? typed})> - _getConversationsFromServer() async { + _getConversationsFromServer() async { final typedApi = _conversationApi; if (typedApi != null) { final (startDate, endDate) = _getDateFilterRange(); @@ -1204,9 +1199,9 @@ class ConversationProvider extends ChangeNotifier { } Map _realProcessingConversationsById() => { - for (final conversation in processingConversations) - if (conversation.id != '0') conversation.id: conversation, - }; + for (final conversation in processingConversations) + if (conversation.id != '0') conversation.id: conversation, + }; Future> _loadProcessingLifecycleResults( List pageItems, @@ -1248,9 +1243,8 @@ class ConversationProvider extends ChangeNotifier { } } - final workerCount = ids.length < _processingLifecycleMaxConcurrency - ? ids.length - : _processingLifecycleMaxConcurrency; + final workerCount = + ids.length < _processingLifecycleMaxConcurrency ? ids.length : _processingLifecycleMaxConcurrency; final workers = List>.generate(workerCount, (_) => worker()); try { await Future.wait(workers).timeout(_processingLifecycleDeadline); @@ -1350,8 +1344,12 @@ class ConversationProvider extends ChangeNotifier { final pageOffset = _conversationServerOffset; final typedApi = _conversationApi; - late final ({List items, bool ok, bool truncated, ApiResult>? typed}) - pageResult; + late final ({ + List items, + bool ok, + bool truncated, + ApiResult>? typed + }) pageResult; if (conversationPageFetcherOverride != null) { final fetched = await conversationPageFetcherOverride!.call(); pageResult = (items: fetched.items, ok: fetched.ok, truncated: fetched.truncated, typed: null); @@ -1397,8 +1395,7 @@ class ConversationProvider extends ChangeNotifier { ); conversations.sort((a, b) => (b.startedAt ?? b.createdAt).compareTo(a.startedAt ?? a.createdAt)); _groupConversationsByDateWithoutNotify(); - final siriFetchIsUnfiltered = - selectedFolderId == null && + final siriFetchIsUnfiltered = selectedFolderId == null && selectedStartDate == null && selectedEndDate == null && selectedSpeakerId == null && @@ -1430,7 +1427,7 @@ class ConversationProvider extends ChangeNotifier { void applySpeakerPromptIdentities(GeneratedSpeakerTagPromptAnswerResponse result) { final identities = { for (final identity in result.segmentIdentities ?? []) - identity.id: identity, + identity.id: identity }; final rows = [...conversations, ...searchedConversations, ...groupedConversations.values.expand((group) => group)]; for (final row in rows) { @@ -1758,8 +1755,8 @@ class ConversationProvider extends ChangeNotifier { final originalConvoIndex = conversations.indexWhere((c) => c.id == convoId); if (originalConvoIndex != -1) { final itemIndex = conversations[originalConvoIndex].structured.actionItems.indexWhere( - (item) => item.description == actionItemDescription, - ); + (item) => item.description == actionItemDescription, + ); if (itemIndex != -1) { conversations[originalConvoIndex].structured.actionItems[itemIndex].completed = newState; conversationFoundAndUpdated = true; @@ -1772,8 +1769,8 @@ class ConversationProvider extends ChangeNotifier { final groupIndex = groupedConversations[dateKey]!.indexWhere((c) => c.id == convoId); if (groupIndex != -1) { final itemIndex = groupedConversations[dateKey]![groupIndex].structured.actionItems.indexWhere( - (item) => item.description == actionItemDescription, - ); + (item) => item.description == actionItemDescription, + ); if (itemIndex != -1) { groupedConversations[dateKey]![groupIndex].structured.actionItems[itemIndex].completed = newState; } diff --git a/app/lib/providers/integration_provider.dart b/app/lib/providers/integration_provider.dart index 4114cb20e7c..3703225d1a1 100644 --- a/app/lib/providers/integration_provider.dart +++ b/app/lib/providers/integration_provider.dart @@ -20,10 +20,10 @@ class IntegrationProvider extends ChangeNotifier { IntegrationDeleter? deleteStatus, IntegrationPrefWriter? persistPref, this.statusTimeout = const Duration(seconds: 45), - }) : _fetchStatus = fetchStatus ?? getIntegration, - _saveStatus = saveStatus ?? saveIntegration, - _deleteStatus = deleteStatus ?? deleteIntegration, - _persistPref = persistPref ?? _defaultPersistPref; + }) : _fetchStatus = fetchStatus ?? getIntegration, + _saveStatus = saveStatus ?? saveIntegration, + _deleteStatus = deleteStatus ?? deleteIntegration, + _persistPref = persistPref ?? _defaultPersistPref; final IntegrationStatusFetcher _fetchStatus; final IntegrationSaver _saveStatus; @@ -87,16 +87,14 @@ class IntegrationProvider extends ChangeNotifier { try { final keys = trackedAppKeys; // A malformed or unresponsive service must not strand all other rows. - final responses = await Future.wait( - keys.map((key) async { - try { - final response = await Future.sync(() => _fetchStatus(key)).timeout(statusTimeout); - return response?.appKey == key ? response : null; - } catch (_) { - return null; - } - }), - ); + final responses = await Future.wait(keys.map((key) async { + try { + final response = await Future.sync(() => _fetchStatus(key)).timeout(statusTimeout); + return response?.appKey == key ? response : null; + } catch (_) { + return null; + } + })); if (!isCurrent()) { completeSync(ProductOutcome.superseded); return; diff --git a/app/lib/providers/onboarding_provider.dart b/app/lib/providers/onboarding_provider.dart index 11465f98b03..6bfa5c4473e 100644 --- a/app/lib/providers/onboarding_provider.dart +++ b/app/lib/providers/onboarding_provider.dart @@ -142,7 +142,10 @@ class OnboardingProvider extends BaseProvider with MessageNotifierMixin implemen } Future askForBluetoothPermissions() async { - final attempt = ProductTelemetry.instance.start(ProductJourney.permission, surface: ProductSurface.onboarding); + final attempt = ProductTelemetry.instance.start( + ProductJourney.permission, + surface: ProductSurface.onboarding, + ); try { if (Platform.isIOS) { PermissionStatus bleStatus = await Permission.bluetooth.request(); @@ -176,7 +179,10 @@ class OnboardingProvider extends BaseProvider with MessageNotifierMixin implemen } Future askForNotificationPermissions() async { - final attempt = ProductTelemetry.instance.start(ProductJourney.permission, surface: ProductSurface.onboarding); + final attempt = ProductTelemetry.instance.start( + ProductJourney.permission, + surface: ProductSurface.onboarding, + ); try { var isAllowed = await NotificationService.instance.requestNotificationPermissions(); updateNotificationPermission(isAllowed); @@ -199,7 +205,10 @@ class OnboardingProvider extends BaseProvider with MessageNotifierMixin implemen } Future askForBackgroundPermissions() async { - final attempt = ProductTelemetry.instance.start(ProductJourney.permission, surface: ProductSurface.onboarding); + final attempt = ProductTelemetry.instance.start( + ProductJourney.permission, + surface: ProductSurface.onboarding, + ); try { await FlutterForegroundTask.requestIgnoreBatteryOptimization(); var isAllowed = await ForegroundUtil().isIgnoringBatteryOptimizations; @@ -221,7 +230,10 @@ class OnboardingProvider extends BaseProvider with MessageNotifierMixin implemen } Future<(bool, PermissionStatus)> askForLocationPermissions() async { - final attempt = ProductTelemetry.instance.start(ProductJourney.permission, surface: ProductSurface.onboarding); + final attempt = ProductTelemetry.instance.start( + ProductJourney.permission, + surface: ProductSurface.onboarding, + ); try { if (await Permission.location.serviceStatus.isDisabled) { Logger.debug('Location service is disabled'); @@ -264,7 +276,10 @@ class OnboardingProvider extends BaseProvider with MessageNotifierMixin implemen } Future askForMicrophonePermissions() async { - final attempt = ProductTelemetry.instance.start(ProductJourney.permission, surface: ProductSurface.onboarding); + final attempt = ProductTelemetry.instance.start( + ProductJourney.permission, + surface: ProductSurface.onboarding, + ); try { PermissionStatus micStatus = await Permission.microphone.request(); Logger.debug('micStatus: $micStatus'); @@ -494,12 +509,12 @@ class OnboardingProvider extends BaseProvider with MessageNotifierMixin implemen DevicePairingBlockedReason? _pairingBlockForAdapter(BluetoothAdapterState state) { return switch (state) { BluetoothAdapterState.unauthorized => pairingBlockBeforeScan( - isAndroid: Platform.isAndroid, - androidSdk: _osAndroidSdk, - bluetoothGranted: hasBluetoothPermission, - locationGranted: hasLocationPermission, - adapterOff: false, - ), + isAndroid: Platform.isAndroid, + androidSdk: _osAndroidSdk, + bluetoothGranted: hasBluetoothPermission, + locationGranted: hasLocationPermission, + adapterOff: false, + ), BluetoothAdapterState.on => null, _ => DevicePairingBlockedReason.bluetoothOff, }; diff --git a/app/lib/providers/phone_call_provider.dart b/app/lib/providers/phone_call_provider.dart index 5d859a39960..b2a9ba35138 100644 --- a/app/lib/providers/phone_call_provider.dart +++ b/app/lib/providers/phone_call_provider.dart @@ -619,9 +619,8 @@ class PhoneCallProvider extends ChangeNotifier { _transcriptionStatus = TranscriptionStatus.connecting; notifyListeners(); - var language = SharedPreferencesUtil().hasSetPrimaryLanguage - ? SharedPreferencesUtil().userPrimaryLanguage - : 'multi'; + var language = + SharedPreferencesUtil().hasSetPrimaryLanguage ? SharedPreferencesUtil().userPrimaryLanguage : 'multi'; var wsUrl = api.buildPhoneCallWebSocketUrl( callId: _currentCallId!, @@ -685,8 +684,11 @@ class PhoneCallProvider extends ChangeNotifier { Future> _productionHeaderBuilder(String url) => buildHeaders(requireAuthCheck: true, url: url, forWebSocket: true); - WebSocketChannel _productionSocketFactory(String url, Map headers) => - IOWebSocketChannel.connect(url, headers: headers, pingInterval: const Duration(seconds: 20)); + WebSocketChannel _productionSocketFactory(String url, Map headers) => IOWebSocketChannel.connect( + url, + headers: headers, + pingInterval: const Duration(seconds: 20), + ); void _resetTranscriptSessionStats() { _wsAccepted = false; diff --git a/app/lib/providers/task_integration_provider.dart b/app/lib/providers/task_integration_provider.dart index a2b30f4cfe0..6bd218814e3 100644 --- a/app/lib/providers/task_integration_provider.dart +++ b/app/lib/providers/task_integration_provider.dart @@ -31,9 +31,9 @@ class TaskIntegrationProvider extends ChangeNotifier { Future Function(String appKey)? setDefaultTaskIntegrationFn, Future Function()? fetchStatus, this.statusTimeout = const Duration(seconds: 45), - }) : _setDefaultTaskIntegration = setDefaultTaskIntegrationFn ?? setDefaultTaskIntegration, - _fetchStatus = fetchStatus ?? getTaskIntegrations, - _selectedApp = PlatformService.isApple ? TaskIntegrationApp.appleReminders : TaskIntegrationApp.googleTasks; + }) : _setDefaultTaskIntegration = setDefaultTaskIntegrationFn ?? setDefaultTaskIntegration, + _fetchStatus = fetchStatus ?? getTaskIntegrations, + _selectedApp = PlatformService.isApple ? TaskIntegrationApp.appleReminders : TaskIntegrationApp.googleTasks; TaskIntegrationApp get selectedApp => _selectedApp; Map get connectionDetails => _connectionDetails; @@ -79,12 +79,16 @@ class TaskIntegrationProvider extends ChangeNotifier { // The server status DTO deliberately omits provider secrets. Its connected // verdict is authoritative; do not require a token on the client. - TodoistService().setAuthenticated(_connectionDetails['todoist']?['connected'] == true); + TodoistService().setAuthenticated( + _connectionDetails['todoist']?['connected'] == true, + ); AsanaService().setAuthenticated( _connectionDetails['asana']?['connected'] == true, userGid: _connectionDetails['asana']?['user_gid'], ); - GoogleTasksService().setAuthenticated(_connectionDetails['google_tasks']?['connected'] == true); + GoogleTasksService().setAuthenticated( + _connectionDetails['google_tasks']?['connected'] == true, + ); ClickUpService().setAuthenticated( _connectionDetails['clickup']?['connected'] == true, userId: _connectionDetails['clickup']?['user_id'], diff --git a/app/lib/services/bridges/ble_bridge.dart b/app/lib/services/bridges/ble_bridge.dart index 7fe79e80bf1..1a2449e84b3 100644 --- a/app/lib/services/bridges/ble_bridge.dart +++ b/app/lib/services/bridges/ble_bridge.dart @@ -196,10 +196,8 @@ class BleBridge implements BleFlutterApi { if (_connectionRevisions[key] != revision) return; final start = (jsonDecode(raw) as Map)['audio_outage_started_at'] as num?; if (start != null && start > 0) { - await DeviceHealthTelemetry.recordOutage( - peripheralUuid, - at: DateTime.fromMillisecondsSinceEpoch(start.toInt()), - ); + await DeviceHealthTelemetry.recordOutage(peripheralUuid, + at: DateTime.fromMillisecondsSinceEpoch(start.toInt())); } } catch (_) { // Native has already durably recorded the start; import it on emission. diff --git a/app/lib/services/capture/capture_controller.dart b/app/lib/services/capture/capture_controller.dart index 9eed3969899..038dc0efb91 100644 --- a/app/lib/services/capture/capture_controller.dart +++ b/app/lib/services/capture/capture_controller.dart @@ -316,32 +316,32 @@ class CaptureController extends ChangeNotifier CaptureNativeWriterGate? nativeWriterGate, CaptureWedgeMonitor? captureWedgeMonitor, CalendarCaptureGapMonitor? calendarGapMonitor, - }) : externalActions = externalActions ?? const NoopCaptureExternalActions(), - _conversationLocationCapture = conversationLocationCapture ?? ConversationLocationCapture(), - _inProgressConversationLoader = inProgressConversationLoader, - _audioCodecLoader = audioCodecLoader, - _microphonePermissionRequester = microphonePermissionRequester, - _phoneMicBatchRecorder = phoneMicBatchRecorder, - _recordingTelemetry = recordingTelemetry ?? RecordingLifecycleTelemetry(), - _speakerHaptic = speakerHaptic, - _walServiceOverride = walService, - _phoneMicRecorderOverride = phoneMicRecorder, - _phoneMicBatchSupportedOverride = phoneMicBatchSupported, - _connectivity = connectivity ?? CaptureConnectivityBoundary.production(), - _authOverride = authBoundary, - _nowOverride = now, - _schedulingOverride = scheduling, - _bleListeners = bleListeners, - _openSocketOverride = openSocket, - _sessionOwner = sessionOwner, - _processInProgressConversationOverride = processInProgressConversation, - _processInProgressOutcomeOverride = processInProgressOutcome, - _deviceConnectionLoader = deviceConnectionLoader, - _omiCallState = omiCallState, - _nativeWriterGate = nativeWriterGate ?? const NoopCaptureNativeWriterGate(), - _wedgeMonitorOverride = captureWedgeMonitor, - _calendarGapMonitor = calendarGapMonitor, - _preferences = preferences ?? SharedPreferencesUtil() { + }) : externalActions = externalActions ?? const NoopCaptureExternalActions(), + _conversationLocationCapture = conversationLocationCapture ?? ConversationLocationCapture(), + _inProgressConversationLoader = inProgressConversationLoader, + _audioCodecLoader = audioCodecLoader, + _microphonePermissionRequester = microphonePermissionRequester, + _phoneMicBatchRecorder = phoneMicBatchRecorder, + _recordingTelemetry = recordingTelemetry ?? RecordingLifecycleTelemetry(), + _speakerHaptic = speakerHaptic, + _walServiceOverride = walService, + _phoneMicRecorderOverride = phoneMicRecorder, + _phoneMicBatchSupportedOverride = phoneMicBatchSupported, + _connectivity = connectivity ?? CaptureConnectivityBoundary.production(), + _authOverride = authBoundary, + _nowOverride = now, + _schedulingOverride = scheduling, + _bleListeners = bleListeners, + _openSocketOverride = openSocket, + _sessionOwner = sessionOwner, + _processInProgressConversationOverride = processInProgressConversation, + _processInProgressOutcomeOverride = processInProgressOutcome, + _deviceConnectionLoader = deviceConnectionLoader, + _omiCallState = omiCallState, + _nativeWriterGate = nativeWriterGate ?? const NoopCaptureNativeWriterGate(), + _wedgeMonitorOverride = captureWedgeMonitor, + _calendarGapMonitor = calendarGapMonitor, + _preferences = preferences ?? SharedPreferencesUtil() { _isConnected = _connectivity.initiallyConnected; lifetime.listen(_connectivity.changes, onConnectionStateChanged); final ble = _bleListeners ?? const BleBridgeCaptureListeners(); @@ -968,10 +968,10 @@ class CaptureController extends ChangeNotifier /// The active source: 'phone' for the microphone, the pendant's source (e.g. 'omi'), or null. /// Derived solely from committed coordinator ownership, never recordingState. String? get liveCaptureSource => switch (_capture.readModel.liveOwnerName) { - 'phone' => ConversationSource.phone.name, - 'pendant' => _getConversationSourceFromDevice() ?? ConversationSource.omi.name, - _ => null, - }; + 'phone' => ConversationSource.phone.name, + 'pendant' => _getConversationSourceFromDevice() ?? ConversationSource.omi.name, + _ => null, + }; /// When the live recording started (it keeps counting through a pause), or null. DateTime? get liveCaptureStartedAt => @@ -1364,9 +1364,8 @@ class CaptureController extends ChangeNotifier final effectiveSampleRate = sampleRate ?? mapCodecToSampleRate(audioCodec); final effectiveChannels = channels ?? ((audioCodec == BleAudioCodec.pcm16 || audioCodec == BleAudioCodec.pcm8) ? 1 : 2); - final deviceRevision = !_captureControllerDisposed && _capture.stagedReadModel.pendantOwns - ? _deviceIdentityRevision - : null; + final deviceRevision = + !_captureControllerDisposed && _capture.stagedReadModel.pendantOwns ? _deviceIdentityRevision : null; final owner = _sessionOwner; if (owner != null) { // Configuration is the connect-attempt key within the capture generation. @@ -1466,15 +1465,15 @@ class CaptureController extends ChangeNotifier geolocation: geolocation ?? _sessionGeolocation, ) ?? ServiceManager.instance().socket.conversation( - codec: codec, - sampleRate: sampleRate, - language: language, - force: force, - source: source, - clientConversationId: clientConversationId, - customSttConfig: customSttConfig, - geolocation: geolocation ?? _sessionGeolocation, - ); + codec: codec, + sampleRate: sampleRate, + language: language, + force: force, + source: source, + clientConversationId: clientConversationId, + customSttConfig: customSttConfig, + geolocation: geolocation ?? _sessionGeolocation, + ); } Future _connectTranscriptionSocket({ @@ -1919,24 +1918,20 @@ class CaptureController extends ChangeNotifier _isProcessingButtonEvent = true; if (isPaused) { PlatformManager.instance.analytics.omiDoubleTap(feature: 'unmute'); - resumeDeviceRecording() - .then((_) { - _isProcessingButtonEvent = false; - }) - .catchError((e) { - Logger.debug("Error resuming device recording: $e"); - _isProcessingButtonEvent = false; - }); + resumeDeviceRecording().then((_) { + _isProcessingButtonEvent = false; + }).catchError((e) { + Logger.debug("Error resuming device recording: $e"); + _isProcessingButtonEvent = false; + }); } else { PlatformManager.instance.analytics.omiDoubleTap(feature: 'mute'); - pauseDeviceRecording() - .then((_) { - _isProcessingButtonEvent = false; - }) - .catchError((e) { - Logger.debug("Error pausing device recording: $e"); - _isProcessingButtonEvent = false; - }); + pauseDeviceRecording().then((_) { + _isProcessingButtonEvent = false; + }).catchError((e) { + Logger.debug("Error pausing device recording: $e"); + _isProcessingButtonEvent = false; + }); } } else if (doubleTapAction == 2) { // Star ongoing conversation (doesn't end it) @@ -2055,8 +2050,7 @@ class CaptureController extends ChangeNotifier // bytes locally; it does not prove the server produced a transcript. // Keep pendant frames durable until a ConversationEvent confirms a // transcript. In batch mode the native layer owns the file instead. - var checkWalSupported = - !_preferences.batchModeEnabled && + var checkWalSupported = !_preferences.batchModeEnabled && (_recordingDevice?.type == DeviceType.omi || _recordingDevice?.type == DeviceType.openglass) && codec.isOpusSupported(); if (checkWalSupported != _isWalSupported) { @@ -3234,8 +3228,7 @@ class CaptureController extends ChangeNotifier final device = _recordingDevice; if (device == null || recordingState != RecordingState.deviceRecord || isPaused) return; final stagedPhase = _capture.stagedReadModel.phase; - final attestedIdle = - testingProbe && + final attestedIdle = testingProbe && stagedPhase == CapturePhase.idle && !_capture.stagedReadModel.callActive && _pendantSuspension == null; @@ -3638,34 +3631,34 @@ class CaptureController extends ChangeNotifier request = _processInProgressOutcomeOverride!(); } else if (_processInProgressConversationOverride != null) { request = _processInProgressConversationOverride!().then( - (result) => - InProgressProcessOutcome(response: result, absence: result == null ? ProcessAbsence.transport : null), + (result) => InProgressProcessOutcome( + response: result, + absence: result == null ? ProcessAbsence.transport : null, + ), ); } else { request = processInProgressConversationOutcome(); } _processInFlight = request.then((_) {}, onError: (_) {}); - request - .then((outcome) async { - final conversationId = await OptimisticProcessingPlaceholder.applyProcessResult( - result: outcome.response, - absence: outcome.absence, - actions: externalActions, - onCreated: _processConversationCreated, - onNoConversation: _onProcessProducedNoConversation, - ); - if (sessionStart > 0 && conversationId != null) { - if (phoneSync is LocalWalSyncImpl) { - phoneSync.prepareConversationStamp(recordingSessionId); - } - await phoneSync.stampConversationId(sessionStart, conversationId); - _autoSyncSessionWals(); - } - }) - .catchError((Object error, StackTrace stack) { - Logger.debug('Process Now result handling failed: ${error.runtimeType}'); - return _reportDetachedCaptureError('capture_process_now', error, stack); - }); + request.then((outcome) async { + final conversationId = await OptimisticProcessingPlaceholder.applyProcessResult( + result: outcome.response, + absence: outcome.absence, + actions: externalActions, + onCreated: _processConversationCreated, + onNoConversation: _onProcessProducedNoConversation, + ); + if (sessionStart > 0 && conversationId != null) { + if (phoneSync is LocalWalSyncImpl) { + phoneSync.prepareConversationStamp(recordingSessionId); + } + await phoneSync.stampConversationId(sessionStart, conversationId); + _autoSyncSessionWals(); + } + }).catchError((Object error, StackTrace stack) { + Logger.debug('Process Now result handling failed: ${error.runtimeType}'); + return _reportDetachedCaptureError('capture_process_now', error, stack); + }); } Future _reportDetachedCaptureError(String operation, Object error, StackTrace stack) async { @@ -3746,9 +3739,9 @@ class CaptureController extends ChangeNotifier final startedAt = conversation.startedAt; final anchor = transcriptStartOnDevice([for (final segment in valid) (segment.id, segment.end)], segmentArrivals) ?? - (startedAt != null - ? startedAt.millisecondsSinceEpoch ~/ 1000 - : (sessionStartSeconds > 0 ? sessionStartSeconds : null)); + (startedAt != null + ? startedAt.millisecondsSinceEpoch ~/ 1000 + : (sessionStartSeconds > 0 ? sessionStartSeconds : null)); if (anchor == null || anchor <= 0) { failClosedReason = 'anchor_unavailable'; } else { @@ -3995,15 +3988,12 @@ class CaptureController extends ChangeNotifier _preferences.addCachedPerson( Person(id: event.personId, name: event.personName, createdAt: DateTime.now(), updatedAt: DateTime.now()), ); - _peopleRefreshFuture ??= externalActions - .refreshPeople() - .whenComplete(() { - _peopleRefreshFuture = null; - }) - .catchError((Object error, StackTrace stack) { - Logger.debug('People cache refresh failed: ${error.runtimeType}'); - return _reportDetachedCaptureError('capture_people_refresh_suggestion', error, stack); - }); + _peopleRefreshFuture ??= externalActions.refreshPeople().whenComplete(() { + _peopleRefreshFuture = null; + }).catchError((Object error, StackTrace stack) { + Logger.debug('People cache refresh failed: ${error.runtimeType}'); + return _reportDetachedCaptureError('capture_people_refresh_suggestion', error, stack); + }); } // Auto-apply assignment if backend provided personId (speaker_auto_assign=enabled) @@ -4125,15 +4115,12 @@ class CaptureController extends ChangeNotifier // Refresh people cache if we see unknown personIds (backend-created persons) // Check all newSegments, not just remainSegments, to catch updates to existing segments if (_peopleRefreshFuture == null && _hasMissingPerson(newSegments)) { - _peopleRefreshFuture = externalActions - .refreshPeople() - .whenComplete(() { - _peopleRefreshFuture = null; - }) - .catchError((Object error, StackTrace stack) { - Logger.debug('People cache refresh failed: ${error.runtimeType}'); - return _reportDetachedCaptureError('capture_people_refresh_segments', error, stack); - }); + _peopleRefreshFuture = externalActions.refreshPeople().whenComplete(() { + _peopleRefreshFuture = null; + }).catchError((Object error, StackTrace stack) { + Logger.debug('People cache refresh failed: ${error.runtimeType}'); + return _reportDetachedCaptureError('capture_people_refresh_segments', error, stack); + }); } _segmentsPhotosVersion++; // Bump version so Selector rebuilds @@ -4233,89 +4220,88 @@ class CaptureController extends ChangeNotifier } CaptureEnvironment _readCaptureEnvironment() => CaptureEnvironment( - livenessEpoch: _livenessEpoch, - appSuspended: _appSuspended, - appSuspendedAt: _appSuspendedAt, - appResumedAt: _appResumedAt, - policyMuted: _preferences.capturePolicy.muted, - silencePaused: silencePaused, - uplinkSilenceExpired: _uplinkSilence.expired, - paused: isPaused, - batchModeEnabled: _preferences.batchModeEnabled, - batchModeSuspendedForOnboarding: _preferences.batchModeSuspendedForOnboarding, - deviceSupportsTranscribeLater: deviceSupportsTranscribeLater, - networkConnected: _connectivity.isConnected, - signedIn: _auth.isSignedIn, - phoneMicSupportsBatch: _phoneMicSupportsBatch, - transcriptReady: _transcriptServiceReady, - socketConnected: _socket?.state == SocketServiceState.connected, - deviceServiceReady: recordingDeviceServiceReady, - callActive: _omiCallActive, - deviceRecording: recordingState == RecordingState.deviceRecord, - systemAudioRecording: recordingState == RecordingState.systemAudioRecord, - micCapturing: - recordingState == RecordingState.record || - recordingState == RecordingState.interrupted || - recordingState == RecordingState.systemAudioRecord, - systemSurfaceRecordingId: activeRecordingId, - systemSurfaceConversationRevision: _systemSurfaceConversationRevision, - ); + livenessEpoch: _livenessEpoch, + appSuspended: _appSuspended, + appSuspendedAt: _appSuspendedAt, + appResumedAt: _appResumedAt, + policyMuted: _preferences.capturePolicy.muted, + silencePaused: silencePaused, + uplinkSilenceExpired: _uplinkSilence.expired, + paused: isPaused, + batchModeEnabled: _preferences.batchModeEnabled, + batchModeSuspendedForOnboarding: _preferences.batchModeSuspendedForOnboarding, + deviceSupportsTranscribeLater: deviceSupportsTranscribeLater, + networkConnected: _connectivity.isConnected, + signedIn: _auth.isSignedIn, + phoneMicSupportsBatch: _phoneMicSupportsBatch, + transcriptReady: _transcriptServiceReady, + socketConnected: _socket?.state == SocketServiceState.connected, + deviceServiceReady: recordingDeviceServiceReady, + callActive: _omiCallActive, + deviceRecording: recordingState == RecordingState.deviceRecord, + systemAudioRecording: recordingState == RecordingState.systemAudioRecord, + micCapturing: recordingState == RecordingState.record || + recordingState == RecordingState.interrupted || + recordingState == RecordingState.systemAudioRecord, + systemSurfaceRecordingId: activeRecordingId, + systemSurfaceConversationRevision: _systemSurfaceConversationRevision, + ); CaptureEffectPorts _buildCapturePorts() => CaptureEffectPorts( - writePolicy: (muted) async { - final revision = await _setCaptureMuted(muted); - return PolicyWriteOutcome(revision: revision, superseded: _preferences.capturePolicy.revision != revision); - }, - stopBleStream: ({bool disableNativeBackground = false}) => - _closeBleStream(disableNativeBackground: disableNativeBackground), - startBleStream: () => _initiateDeviceAudioStreaming(), - openSocket: (spec) async { - if (spec.ensureOnly && _socket?.state == SocketServiceState.connected) return; - await _initiateWebsocket( - audioCodec: spec.codec, - sampleRate: spec.sampleRate, - force: spec.force, - source: spec.source, + writePolicy: (muted) async { + final revision = await _setCaptureMuted(muted); + return PolicyWriteOutcome(revision: revision, superseded: _preferences.capturePolicy.revision != revision); + }, + stopBleStream: ({bool disableNativeBackground = false}) => + _closeBleStream(disableNativeBackground: disableNativeBackground), + startBleStream: () => _initiateDeviceAudioStreaming(), + openSocket: (spec) async { + if (spec.ensureOnly && _socket?.state == SocketServiceState.connected) return; + await _initiateWebsocket( + audioCodec: spec.codec, + sampleRate: spec.sampleRate, + force: spec.force, + source: spec.source, + ); + }, + closeSocket: (reason) async => _socket?.stop(reason: reason), + startNativeMic: (mode) async { + if (mode == PhoneMicSessionMode.live) { + await _resumeMicRecording(); + } else { + await _startPhoneMicBatchBody(auto: mode == PhoneMicSessionMode.batchAuto); + } + }, + stopNativeMic: () async { + _prefetchedMicPermission = false; + _phoneMic.stop(); + }, + setNativeWriterGate: _nativeWriterGate.setGate, + finalizeWal: () => _wal.getSyncs().phone.finalizeCurrentSession(), + rollSession: (identity) async => _rollCaptureSession(identity), + mintRecordingId: (sessionKey, telemetrySource) { + _recordingTelemetry.complete(reason: 'session_roll'); + return _recordingTelemetry.prepare(source: telemetrySource); + }, + checkPhonePermission: () async { + final granted = await _requestMicrophonePermission(); + _prefetchedMicPermission = granted; + return granted; + }, + clearPhonePermissionGrant: () => _prefetchedMicPermission = false, + readSnapshot: () => _preferences.getString(_captureSnapshotKey), + persistSnapshot: (encoded) => _preferences.saveString(_captureSnapshotKey, encoded), + runStage: _runCaptureStage, ); - }, - closeSocket: (reason) async => _socket?.stop(reason: reason), - startNativeMic: (mode) async { - if (mode == PhoneMicSessionMode.live) { - await _resumeMicRecording(); - } else { - await _startPhoneMicBatchBody(auto: mode == PhoneMicSessionMode.batchAuto); - } - }, - stopNativeMic: () async { - _prefetchedMicPermission = false; - _phoneMic.stop(); - }, - setNativeWriterGate: _nativeWriterGate.setGate, - finalizeWal: () => _wal.getSyncs().phone.finalizeCurrentSession(), - rollSession: (identity) async => _rollCaptureSession(identity), - mintRecordingId: (sessionKey, telemetrySource) { - _recordingTelemetry.complete(reason: 'session_roll'); - return _recordingTelemetry.prepare(source: telemetrySource); - }, - checkPhonePermission: () async { - final granted = await _requestMicrophonePermission(); - _prefetchedMicPermission = granted; - return granted; - }, - clearPhonePermissionGrant: () => _prefetchedMicPermission = false, - readSnapshot: () => _preferences.getString(_captureSnapshotKey), - persistSnapshot: (encoded) => _preferences.saveString(_captureSnapshotKey, encoded), - runStage: _runCaptureStage, - ); Future _runCaptureStage(CaptureStage stage) => _captureControllerDisposed ? Future.value(null) : switch (stage) { UpdateRecordingDeviceStage() => Future.sync(() => _updateRecordingDeviceBody(stage.device)), StartDeviceSessionStage() => _startDeviceSessionBody( - deviceRequested: stage.deviceRequested, - promptLocation: stage.promptLocation, - ), + deviceRequested: stage.deviceRequested, + promptLocation: stage.promptLocation, + ), StopDeviceSessionStage() => _stopDeviceSessionBody(cleanDevice: stage.cleanDevice), SilencePauseMarkerStage() => _preferences.saveBool(_silencePausedKey, stage.paused), PauseDeviceTailStage() => _pauseDeviceTailBody(), @@ -4329,10 +4315,10 @@ class CaptureController extends ChangeNotifier StopPhoneLiveStage() => _stopPhoneLiveBody(reason: stage.reason), StopPhoneBatchStage() => _stopPhoneBatchBody(reason: stage.reason), StopPhonePolicyRestoreStage() => _stopPhonePolicyRestoreBody( - mutedBefore: stage.mutedBefore, - pendantResumeFollows: stage.pendantResumeFollows, - userStop: stage.userStop, - ), + mutedBefore: stage.mutedBefore, + pendantResumeFollows: stage.pendantResumeFollows, + userStop: stage.userStop, + ), FlushPhoneFramesStage() => Future.sync(_flushPhoneFrames), RecordingMarkStage() => Future.sync(() => updateRecordingState(stage.state)), DeviceStopTelemetryStage() => _deviceStopTelemetryBody(cleanDevice: stage.cleanDevice), diff --git a/app/lib/services/capture/recording_lifecycle_telemetry.dart b/app/lib/services/capture/recording_lifecycle_telemetry.dart index 59b2043522a..0284de50de6 100644 --- a/app/lib/services/capture/recording_lifecycle_telemetry.dart +++ b/app/lib/services/capture/recording_lifecycle_telemetry.dart @@ -17,10 +17,10 @@ class RecordingLifecycleTelemetry { RecordingIdFactory? idFactory, RecordingClock? clock, int Function()? identityEpoch, - }) : _emitter = emitter ?? _emitProductionEvent, - _idFactory = idFactory ?? _defaultId, - _clock = clock ?? DateTime.now, - _identityEpoch = identityEpoch ?? (() => AnalyticsManager.identityEpoch); + }) : _emitter = emitter ?? _emitProductionEvent, + _idFactory = idFactory ?? _defaultId, + _clock = clock ?? DateTime.now, + _identityEpoch = identityEpoch ?? (() => AnalyticsManager.identityEpoch); static const String requestedEvent = 'Recording Requested'; static const String startedEvent = 'Recording Started'; @@ -69,13 +69,12 @@ class RecordingLifecycleTelemetry { } void _milestone(String stage) => _emit('Recording Observation', { - 'recording_id': _recordingId, - 'recording_source': _source, - 'stage': stage, - 'since_prepare_ms': _preparedAt == null - ? 0 - : (_clock().difference(_preparedAt!).inMilliseconds).clamp(0, 2147483647), - }); + 'recording_id': _recordingId, + 'recording_source': _source, + 'stage': stage, + 'since_prepare_ms': + _preparedAt == null ? 0 : (_clock().difference(_preparedAt!).inMilliseconds).clamp(0, 2147483647), + }); String? _recordingId; String? _source; @@ -117,12 +116,12 @@ class RecordingLifecycleTelemetry { } void captureGap({required String reason, required String phase}) => _emit('Capture Gap Detected', { - 'recording_id': _recordingId, - 'recording_source': _source, - 'reason': reason, - 'phase': phase, - 'recovery': 'wal_boundary', - }); + 'recording_id': _recordingId, + 'recording_source': _source, + 'reason': reason, + 'phase': phase, + 'recovery': 'wal_boundary', + }); void subscriptionFailed() => _emit('Recording Subscription Failed', {'recording_id': _recordingId, 'recording_source': _source}); @@ -157,9 +156,8 @@ class RecordingLifecycleTelemetry { 'duration_seconds': _durationSeconds(_startedAt!), 'reason': _normalizeReason(reason), 'audio_observed': _audioObserved, - 'audio_observation_coverage': _source?.startsWith('phone_mic_batch') == true - ? 'native_batch_unavailable' - : 'dart_ingress', + 'audio_observation_coverage': + _source?.startsWith('phone_mic_batch') == true ? 'native_batch_unavailable' : 'dart_ingress', 'transcript_observed': _transcriptObserved, 'audio_bytes_observed': _audioBytes, 'socket_bytes_submitted': _socketBytes, @@ -221,16 +219,17 @@ class RecordingLifecycleTelemetry { } static String _normalizeReason(String reason) => switch (reason) { - 'user_stopped' || 'device_disconnected' || 'mode_changed' || 'pipeline_closed' => reason, - _ => 'unknown', - }; + 'user_stopped' || 'device_disconnected' || 'mode_changed' || 'pipeline_closed' => reason, + _ => 'unknown', + }; static String _normalizeFailureClass(String failureClass) => switch (failureClass) { - 'permission_denied' || - 'capture_unavailable' || - 'pipeline_unavailable' || - 'pipeline_closed' || - 'unknown' => failureClass, - _ => 'unknown', - }; + 'permission_denied' || + 'capture_unavailable' || + 'pipeline_unavailable' || + 'pipeline_closed' || + 'unknown' => + failureClass, + _ => 'unknown', + }; } diff --git a/app/lib/services/devices.dart b/app/lib/services/devices.dart index 2ff12070269..8e2f310430c 100644 --- a/app/lib/services/devices.dart +++ b/app/lib/services/devices.dart @@ -35,14 +35,17 @@ class OmiFeatures { abstract class IDeviceServiceSubsciption { void onDevices(List devices); void onStatusChanged(DeviceServiceStatus status); - void onDeviceConnectionStateChanged(String deviceId, DeviceConnectionState state); + void onDeviceConnectionStateChanged( + String deviceId, + DeviceConnectionState state, + ); } typedef DeviceConnectionBuilder = DeviceConnection? Function(BtDevice device); class DeviceService { DeviceService({DeviceConnectionBuilder? connectionBuilder}) - : _connectionBuilder = connectionBuilder ?? DeviceConnectionFactory.create; + : _connectionBuilder = connectionBuilder ?? DeviceConnectionFactory.create; final DeviceConnectionBuilder _connectionBuilder; @@ -154,12 +157,16 @@ class DeviceService { await _teardownConnection(id); var device = _devices.firstWhereOrNull((f) => f.id == id); - Logger.debug('[DeviceService] device lookup result: ${device?.name ?? "NULL"} (locator: ${device?.locator?.kind})'); + Logger.debug( + '[DeviceService] device lookup result: ${device?.name ?? "NULL"} (locator: ${device?.locator?.kind})', + ); // If device not in discovered list, try to get it from SharedPreferences // This allows background reconnection without scanning if (device == null) { - Logger.debug('[DeviceService] Device not in discovered list, checking stored device'); + Logger.debug( + '[DeviceService] Device not in discovered list, checking stored device', + ); device = _getStoredDevice(id); if (device != null) { Logger.debug('[DeviceService] Using stored device: ${device.name}'); @@ -167,7 +174,9 @@ class DeviceService { _devices.add(device); } } else { - Logger.debug('[DeviceService] No stored device available for $id, returning'); + Logger.debug( + '[DeviceService] No stored device available for $id, returning', + ); return; } } @@ -176,7 +185,9 @@ class DeviceService { if (connection != null) { _connections[id] = connection; try { - await connection.connect(onConnectionStateChanged: onDeviceConnectionStateChanged); + await connection.connect( + onConnectionStateChanged: onDeviceConnectionStateChanged, + ); } catch (e) { final isTimeout = e is TimeoutException || (e is DeviceConnectionException && e.isTimeout); if (isTimeout) { @@ -208,7 +219,9 @@ class DeviceService { rethrow; } } else { - Logger.debug('[DeviceService] Failed to create device connection for ${device.id}'); + Logger.debug( + '[DeviceService] Failed to create device connection for ${device.id}', + ); } } @@ -262,9 +275,15 @@ class DeviceService { } } - void onDeviceConnectionStateChanged(String deviceId, DeviceConnectionState state) { + void onDeviceConnectionStateChanged( + String deviceId, + DeviceConnectionState state, + ) { Logger.debug("device connection state changed...$deviceId...$state"); - DebugLogManager.logEvent('device_connection_state', {'device_id': deviceId, 'state': state.name}); + DebugLogManager.logEvent('device_connection_state', { + 'device_id': deviceId, + 'state': state.name, + }); for (var s in _subscriptions.values) { s.onDeviceConnectionStateChanged(deviceId, state); } @@ -278,11 +297,16 @@ class DeviceService { final Mutex _mutex = Mutex(); - Future ensureConnection(String deviceId, {bool force = false}) async { + Future ensureConnection( + String deviceId, { + bool force = false, + }) async { await _mutex.acquire(); try { final existing = _connections[deviceId]; - Logger.debug("ensureConnection $deviceId ${existing?.status} $force"); + Logger.debug( + "ensureConnection $deviceId ${existing?.status} $force", + ); if (_staleBondRecoveryRequired) { Logger.debug('ensureConnection blocked: stale iOS BLE bond recovery required'); @@ -336,7 +360,9 @@ class DeviceService { // Helper method to get stored device from SharedPreferences BtDevice? _getStoredDevice(String id) { try { - return SharedPreferencesUtil().btDevices.firstWhereOrNull((d) => d.id == id && d.id.isNotEmpty); + return SharedPreferencesUtil().btDevices.firstWhereOrNull( + (d) => d.id == id && d.id.isNotEmpty, + ); } catch (e) { Logger.debug('Error getting stored device: $e'); } diff --git a/app/lib/services/devices/connectors/device_connection.dart b/app/lib/services/devices/connectors/device_connection.dart index e0504bd7f16..1b28534c037 100644 --- a/app/lib/services/devices/connectors/device_connection.dart +++ b/app/lib/services/devices/connectors/device_connection.dart @@ -139,8 +139,7 @@ class DeviceConnectionFactory { // Use name-based detection as fallback for OmiGlass devices (some advertise as DeviceType.omi). final deviceName = device.name.toLowerCase(); - final isOmiGlass = - device.type == DeviceType.openglass || + final isOmiGlass = device.type == DeviceType.openglass || deviceName.contains('openglass') || deviceName.contains('omiglass') || deviceName.contains('glass'); diff --git a/app/lib/services/integrations/base_integration_service.dart b/app/lib/services/integrations/base_integration_service.dart index 511b948e8a4..9a3c6eed57b 100644 --- a/app/lib/services/integrations/base_integration_service.dart +++ b/app/lib/services/integrations/base_integration_service.dart @@ -13,7 +13,7 @@ abstract class BaseIntegrationService { final String oauthAppKey; BaseIntegrationService({required this.appKey, required this.prefKey, String? oauthAppKey}) - : oauthAppKey = oauthAppKey ?? appKey; + : oauthAppKey = oauthAppKey ?? appKey; bool get isAuthenticated { return SharedPreferencesUtil().getBool(prefKey); diff --git a/app/lib/services/wals/local_wal_sync.dart b/app/lib/services/wals/local_wal_sync.dart index 3ed9da15d10..24617c30759 100644 --- a/app/lib/services/wals/local_wal_sync.dart +++ b/app/lib/services/wals/local_wal_sync.dart @@ -336,18 +336,18 @@ class LocalWalSyncImpl with WidgetsBindingObserver implements LocalWalSync { Future Function()? freeDiskBytes, PendantRingCustody? custody, Future Function(String deviceId)? connectionResolver, - }) : _jobPollerOverride = jobPoller, - _uploadGateOverride = uploadGate, - _nowOverride = now, - _periodicOverride = periodic, - _jobStatusFetcherOverride = jobStatusFetcher, - _persistWalsOverride = persistWals, - _loadWalsOverride = loadWals, - _coverageTelemetryOverride = coverageTelemetry, - _staleWalTelemetry = staleWalTelemetry, - _freeDiskBytesOverride = freeDiskBytes, - _custody = custody ?? PendantRingCustody.shared, - _connectionResolver = connectionResolver; + }) : _jobPollerOverride = jobPoller, + _uploadGateOverride = uploadGate, + _nowOverride = now, + _periodicOverride = periodic, + _jobStatusFetcherOverride = jobStatusFetcher, + _persistWalsOverride = persistWals, + _loadWalsOverride = loadWals, + _coverageTelemetryOverride = coverageTelemetry, + _staleWalTelemetry = staleWalTelemetry, + _freeDiskBytesOverride = freeDiskBytes, + _custody = custody ?? PendantRingCustody.shared, + _connectionResolver = connectionResolver; @override int get sessionGeneration => _sessionGeneration; @@ -397,8 +397,7 @@ class LocalWalSyncImpl with WidgetsBindingObserver implements LocalWalSync { final existingIndex = _wals.indexWhere((w) => w.id == wal.id); if (existingIndex >= 0) { final existing = _wals[existingIndex]; - final sameRecord = - existing.codec == wal.codec && + final sameRecord = existing.codec == wal.codec && existing.timerStart == wal.timerStart && existing.seconds == wal.seconds && existing.totalFrames == wal.totalFrames; @@ -706,8 +705,7 @@ class LocalWalSyncImpl with WidgetsBindingObserver implements LocalWalSync { _wals.add(wal); } else { wal = _wals[walIdx]; - final contiguousEvidence = - evidence.root != null && + final contiguousEvidence = evidence.root != null && wal.captureRoot == evidence.root && wal.sourceClockEpoch == evidence.epoch && wal.sourceFrameStart != null && @@ -863,68 +861,68 @@ class LocalWalSyncImpl with WidgetsBindingObserver implements LocalWalSync { } Future _drainPendantTail(int generation) => _enqueueBuffer(() async { - if (!_isCurrent(generation)) return; - final indices = []; - for (var i = 0; i < _frames.length; i++) { - if (_isPendantFrame(_frames[i])) indices.add(i); - } - if (indices.isEmpty) return; - final frames = indices.map((i) => _frames[i]).toList(); - final synced = indices.map((i) => _frameSynced[i]).toList(); - - var shouldStore = SharedPreferencesUtil().unlimitedLocalStorageEnabled; - if (!shouldStore) shouldStore = synced.any((s) => !s); - if (!shouldStore) { - _removeFrameIndices(indices); - return; - } - final syncedOffset = syncedPrefixCount(synced); - - final frameCount = frames.length; - final now = _now(); - var timerEnd = now.millisecondsSinceEpoch ~/ 1000; - var timerStart = timerEnd - frameCount ~/ _framesPerSecond; - final evidence = stableCaptureEvidence(frames); - final live = _liveEvidenceFor(frames); - - if (partitionsEvidenceRuns(_captureEvidenceV1DarkWrite, _codec, frames)) { - _storeSelectedRuns( - frames, - synced, - captureSelectionStartSeconds(now, frameCount, _framesPerSecond), - generation, - extendMemWal: false, - legacySelectionTimerStart: timerStart, - ); - } else { - final wal = Wal( - codec: _codec, - timerStart: timerStart, - data: frames.map((f) => f.payload).toList(), - storage: WalStorage.mem, - status: syncedOffset == frameCount ? WalStatus.synced : WalStatus.miss, - device: _deviceId ?? "omi", - deviceModel: _deviceModel ?? "Omi", - seconds: frameCount ~/ _framesPerSecond, - totalFrames: frameCount, - syncedFrameOffset: syncedOffset, - ownerUid: _currentWalOwnerUid(), - captureRoot: evidence.root, - sourceFrameStart: evidence.start, - sourceClockEpoch: evidence.epoch, - geolocation: _copyGeolocation(_sessionGeolocation), - recordingSessionId: _activeRecordingSessionId, - liveRingId: live.ringId, - liveOrdinalStart: live.start, - liveOrdinalEnd: live.end, - ); - wal.liveConnectionEpoch = live.epoch; - _wals.add(wal); - } - _removeFrameIndices(indices); - _notifyUpdated(generation); - await _flush(generation); - }); + if (!_isCurrent(generation)) return; + final indices = []; + for (var i = 0; i < _frames.length; i++) { + if (_isPendantFrame(_frames[i])) indices.add(i); + } + if (indices.isEmpty) return; + final frames = indices.map((i) => _frames[i]).toList(); + final synced = indices.map((i) => _frameSynced[i]).toList(); + + var shouldStore = SharedPreferencesUtil().unlimitedLocalStorageEnabled; + if (!shouldStore) shouldStore = synced.any((s) => !s); + if (!shouldStore) { + _removeFrameIndices(indices); + return; + } + final syncedOffset = syncedPrefixCount(synced); + + final frameCount = frames.length; + final now = _now(); + var timerEnd = now.millisecondsSinceEpoch ~/ 1000; + var timerStart = timerEnd - frameCount ~/ _framesPerSecond; + final evidence = stableCaptureEvidence(frames); + final live = _liveEvidenceFor(frames); + + if (partitionsEvidenceRuns(_captureEvidenceV1DarkWrite, _codec, frames)) { + _storeSelectedRuns( + frames, + synced, + captureSelectionStartSeconds(now, frameCount, _framesPerSecond), + generation, + extendMemWal: false, + legacySelectionTimerStart: timerStart, + ); + } else { + final wal = Wal( + codec: _codec, + timerStart: timerStart, + data: frames.map((f) => f.payload).toList(), + storage: WalStorage.mem, + status: syncedOffset == frameCount ? WalStatus.synced : WalStatus.miss, + device: _deviceId ?? "omi", + deviceModel: _deviceModel ?? "Omi", + seconds: frameCount ~/ _framesPerSecond, + totalFrames: frameCount, + syncedFrameOffset: syncedOffset, + ownerUid: _currentWalOwnerUid(), + captureRoot: evidence.root, + sourceFrameStart: evidence.start, + sourceClockEpoch: evidence.epoch, + geolocation: _copyGeolocation(_sessionGeolocation), + recordingSessionId: _activeRecordingSessionId, + liveRingId: live.ringId, + liveOrdinalStart: live.start, + liveOrdinalEnd: live.end, + ); + wal.liveConnectionEpoch = live.epoch; + _wals.add(wal); + } + _removeFrameIndices(indices); + _notifyUpdated(generation); + await _flush(generation); + }); int _storageFileSeq = 0; @@ -1101,10 +1099,10 @@ class LocalWalSyncImpl with WidgetsBindingObserver implements LocalWalSync { } int get _pendingDiskWalCount => [ - ..._retiredWals, - ..._foreignWals, - ..._wals, - ].where((wal) => wal.storage == WalStorage.disk && (wal.status != WalStatus.synced || wal.syncedAt == 0)).length; + ..._retiredWals, + ..._foreignWals, + ..._wals, + ].where((wal) => wal.storage == WalStorage.disk && (wal.status != WalStatus.synced || wal.syncedAt == 0)).length; /// Surfaces the bounded-retention pressure once per cap-engagement event. /// Pending (unsynced) copies are never evicted and admission is not refused @@ -1663,17 +1661,15 @@ class LocalWalSyncImpl with WidgetsBindingObserver implements LocalWalSync { final stamped = _wals.where((wal) => wal.conversationId == conversationId).toList(); if (stamped.isEmpty) return (released: 0, kept: 0); - final spans = failClosedReason == null && transcriptSpans != null && transcriptSpans.isNotEmpty - ? transcriptSpans - : null; + final spans = + failClosedReason == null && transcriptSpans != null && transcriptSpans.isNotEmpty ? transcriptSpans : null; final reason = failClosedReason ?? (spans != null ? null : 'no_spans'); final nowSeconds = _now().millisecondsSinceEpoch ~/ 1000; final covered = [], kept = []; for (final wal in stamped) { // Only phone-side pending copies and transport-only synced ones (socket sends, never // server-confirmed) are judged; in-flight, durable and terminal copies never regress. - final eligible = - !wal.isSyncing && + final eligible = !wal.isSyncing && (wal.storage == WalStorage.disk || wal.storage == WalStorage.mem) && (wal.status == WalStatus.miss || wal.status == WalStatus.synced && wal.syncedAt == 0); if (!eligible) continue; @@ -1749,9 +1745,8 @@ class LocalWalSyncImpl with WidgetsBindingObserver implements LocalWalSync { @override Future deleteAllPendingWals() async { final generation = _sessionGeneration; - final pendingWals = _wals - .where((w) => w.status == WalStatus.miss || (w.status == WalStatus.uploaded && w.syncedAt == 0)) - .toList(); + final pendingWals = + _wals.where((w) => w.status == WalStatus.miss || (w.status == WalStatus.uploaded && w.syncedAt == 0)).toList(); for (final wal in pendingWals) { if (!_isCurrent(generation)) break; await _deleteWal(wal, generation: generation); @@ -1957,17 +1952,15 @@ class LocalWalSyncImpl with WidgetsBindingObserver implements LocalWalSync { // Re-snapshot between batches so a newly captured WAL can preempt an // hours-long historical drain without waiting for the original list. final batchNowSeconds = _now().millisecondsSinceEpoch ~/ 1000; - final candidates = _wals - .where((wal) => isAutoUploadEligible(wal) && !attemptedWalIds.contains(walAttemptKey(wal))) - .toList(); + final candidates = + _wals.where((wal) => isAutoUploadEligible(wal) && !attemptedWalIds.contains(walAttemptKey(wal))).toList(); final pending = candidates.where((wal) => !liveCaptureOnly || isLiveCaptureWal(wal, batchNowSeconds)).toList(); if (pending.isEmpty) break; final batch = nextSyncUploadBatch(pending, batchNowSeconds); if (batch.isEmpty) break; attemptedWalIds.addAll(batch.map(walAttemptKey)); final batchConversationId = batch.first.conversationId; - final claimLiveCapture = - !unclaimableConversationIds.contains(batchConversationId) && + final claimLiveCapture = !unclaimableConversationIds.contains(batchConversationId) && canClaimLiveCapture( batch, candidates.where((wal) => wal.conversationId == batchConversationId).toList(), diff --git a/app/lib/services/wals/sync_upload_batch.dart b/app/lib/services/wals/sync_upload_batch.dart index ee99634d541..f665d30d565 100644 --- a/app/lib/services/wals/sync_upload_batch.dart +++ b/app/lib/services/wals/sync_upload_batch.dart @@ -39,9 +39,8 @@ const _captureEvidenceV1DarkWrite = bool.fromEnvironment('CAPTURE_EVIDENCE_V1_DA const _captureEvidenceHeaderMaxBytes = 4096; -final _captureClaimRootPattern = RegExp( - r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', -); +final _captureClaimRootPattern = + RegExp(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'); final _captureClaimNamePattern = RegExp(r'^[A-Za-z0-9_.\-]{1,255}$'); String _walUploadFileName(Wal wal) => @@ -148,8 +147,7 @@ List nextSyncUploadBatch(List pending, int nowSeconds) { ), ); } - final indexed = [for (var i = 0; i < pending.length; i++) (pending[i], i)] - ..sort((a, b) { + final indexed = [for (var i = 0; i < pending.length; i++) (pending[i], i)]..sort((a, b) { final byStart = b.$1.timerStart.compareTo(a.$1.timerStart); return byStart != 0 ? byStart : a.$2.compareTo(b.$2); }); diff --git a/app/lib/utils/analytics/analytics_manager.dart b/app/lib/utils/analytics/analytics_manager.dart index b0052191583..4c37b3342c7 100644 --- a/app/lib/utils/analytics/analytics_manager.dart +++ b/app/lib/utils/analytics/analytics_manager.dart @@ -89,12 +89,12 @@ class AnalyticsManager { static bool get deviceHealthRecordingEnabled => _trackingEnabled && (!_identityKnown || _boundIdentity != null); static String get mobilePlatform => _mobilePlatformName; static Map get healthSnapshot => { - 'ready': _analyticsReady, - 'queue_depth': _queuedEvents.length, - 'dropped_events': _droppedEvents, - 'initialization_failures': _initFailures, - 'handoff_failures': _handoffFailures, - }; + 'ready': _analyticsReady, + 'queue_depth': _queuedEvents.length, + 'dropped_events': _droppedEvents, + 'initialization_failures': _initFailures, + 'handoff_failures': _handoffFailures, + }; void setSessionContext(String sessionId, {bool foreground = true}) { _eventContext['app_session_id'] = sessionId; @@ -181,16 +181,16 @@ class AnalyticsManager { } void recordProductError(ProductErrorKind kind) => track( - 'Product Error', - properties: { - 'error_kind': switch (kind) { - ProductErrorKind.flutterFramework => 'flutter_framework', - ProductErrorKind.uncaughtDart => 'uncaught_dart', - ProductErrorKind.startup => 'startup', - }, - 'diagnostic_source': 'crashlytics', - }, - ); + 'Product Error', + properties: { + 'error_kind': switch (kind) { + ProductErrorKind.flutterFramework => 'flutter_framework', + ProductErrorKind.uncaughtDart => 'uncaught_dart', + ProductErrorKind.startup => 'startup', + }, + 'diagnostic_source': 'crashlytics', + }, + ); /// Periodic operational signal; does not recursively emit on queue failures. void recordTelemetryHealth() { @@ -698,20 +698,18 @@ class AnalyticsManager { properties.putIfAbsent('build', () => build); } if (adapter is AnalyticsDeliveryAdapter) { - await (adapter as AnalyticsDeliveryAdapter) - .deliver( - eventName: event.eventName, - properties: { - ...properties, - 'event_id': event.eventId, - r'$insert_id': event.eventId, - 'occurred_at': event.occurredAt.toIso8601String(), - 'schema_version': properties['schema_version'] ?? 1, - 'client_app_namespace': _clientAppNamespace, - 'client_app_profile': Env.profile.name, - }, - ) - .timeout(_initTimeout); + await (adapter as AnalyticsDeliveryAdapter).deliver( + eventName: event.eventName, + properties: { + ...properties, + 'event_id': event.eventId, + r'$insert_id': event.eventId, + 'occurred_at': event.occurredAt.toIso8601String(), + 'schema_version': properties['schema_version'] ?? 1, + 'client_app_namespace': _clientAppNamespace, + 'client_app_profile': Env.profile.name, + }, + ).timeout(_initTimeout); } else { adapter.track(eventName: event.eventName, properties: properties); } @@ -788,12 +786,10 @@ class AnalyticsManager { void signInFailed(String provider, {Object? error, bool cancelled = false}) { final value = signInFailedProvider(provider); if (value == null) return; - const TypedEvents().emit( - SignInFailed( - provider: value, - errorClass: cancelled || error == null ? SignInFailedErrorClass.cancelled : classifySignInFailure(error), - ), - ); + const TypedEvents().emit(SignInFailed( + provider: value, + errorClass: cancelled || error == null ? SignInFailedErrorClass.cancelled : classifySignInFailure(error), + )); } void onboardingPermissionResult({ @@ -801,26 +797,24 @@ class AnalyticsManager { required bool granted, required bool permanentlyDenied, required bool servicesDisabled, - }) => const TypedEvents().emit( - OnboardingPermissionResult( - permission: permission, - granted: granted, - permanentlyDenied: permanentlyDenied, - servicesDisabled: servicesDisabled, - ), - ); + }) => + const TypedEvents().emit(OnboardingPermissionResult( + permission: permission, + granted: granted, + permanentlyDenied: permanentlyDenied, + servicesDisabled: servicesDisabled, + )); void devicePairingBlocked({ required DevicePairingBlockedReason reason, required int androidSdk, required int iosVersion, - }) => const TypedEvents().emit( - DevicePairingBlocked( - reason: reason, - androidSdk: androidSdk < 0 ? 0 : androidSdk, - iosVersion: iosVersion < 0 ? 0 : iosVersion, - ), - ); + }) => + const TypedEvents().emit(DevicePairingBlocked( + reason: reason, + androidSdk: androidSdk < 0 ? 0 : androidSdk, + iosVersion: iosVersion < 0 ? 0 : iosVersion, + )); void deviceScanEmpty({required int afterSeconds}) => const TypedEvents().emit(DeviceScanEmpty(afterSeconds: afterSeconds < 0 ? 0 : afterSeconds)); @@ -833,22 +827,21 @@ class AnalyticsManager { required int durationSeconds, required bool transcriptObserved, required EmptyRecordingReason reason, - }) => const TypedEvents().emit( - RecordingProducedNoConversation( - source: switch (source) { - RecordingSurface.phone => RecordingProducedNoConversationSource.phone, - RecordingSurface.omi => RecordingProducedNoConversationSource.omi, - RecordingSurface.watch => RecordingProducedNoConversationSource.watch, - }, - durationSeconds: durationSeconds < 0 ? 0 : durationSeconds, - transcriptObserved: transcriptObserved, - reason: switch (reason) { - EmptyRecordingReason.emptyDeleted => RecordingProducedNoConversationReason.emptyDeleted, - EmptyRecordingReason.discarded => RecordingProducedNoConversationReason.discarded, - EmptyRecordingReason.nullResult => RecordingProducedNoConversationReason.nullResult, - }, - ), - ); + }) => + const TypedEvents().emit(RecordingProducedNoConversation( + source: switch (source) { + RecordingSurface.phone => RecordingProducedNoConversationSource.phone, + RecordingSurface.omi => RecordingProducedNoConversationSource.omi, + RecordingSurface.watch => RecordingProducedNoConversationSource.watch, + }, + durationSeconds: durationSeconds < 0 ? 0 : durationSeconds, + transcriptObserved: transcriptObserved, + reason: switch (reason) { + EmptyRecordingReason.emptyDeleted => RecordingProducedNoConversationReason.emptyDeleted, + EmptyRecordingReason.discarded => RecordingProducedNoConversationReason.discarded, + EmptyRecordingReason.nullResult => RecordingProducedNoConversationReason.nullResult, + }, + )); void onboardingStepCompleted(String step) => track('Onboarding Step $step Completed'); @@ -914,17 +907,18 @@ class AnalyticsManager { required int totalBytes, required bool claimsLiveCapture, String? recordingId, - }) => track( - 'Recording Upload Started', - properties: { - 'upload_attempt_id': attemptId, - if (recordingId != null) 'recording_id': recordingId, - 'file_count': fileCount, - 'total_bytes': totalBytes, - 'claims_live_capture': claimsLiveCapture, - 'upload_source': 'offline_audio_queue', - }, - ); + }) => + track( + 'Recording Upload Started', + properties: { + 'upload_attempt_id': attemptId, + if (recordingId != null) 'recording_id': recordingId, + 'file_count': fileCount, + 'total_bytes': totalBytes, + 'claims_live_capture': claimsLiveCapture, + 'upload_source': 'offline_audio_queue', + }, + ); void recordingUploadCompleted({ required String attemptId, @@ -934,19 +928,20 @@ class AnalyticsManager { required double durationSeconds, required String result, String? recordingId, - }) => track( - 'Recording Upload Completed', - properties: { - 'upload_attempt_id': attemptId, - if (recordingId != null) 'recording_id': recordingId, - 'file_count': fileCount, - 'total_bytes': totalBytes, - 'claims_live_capture': claimsLiveCapture, - 'upload_source': 'offline_audio_queue', - 'duration_seconds': durationSeconds, - 'result': result, - }, - ); + }) => + track( + 'Recording Upload Completed', + properties: { + 'upload_attempt_id': attemptId, + if (recordingId != null) 'recording_id': recordingId, + 'file_count': fileCount, + 'total_bytes': totalBytes, + 'claims_live_capture': claimsLiveCapture, + 'upload_source': 'offline_audio_queue', + 'duration_seconds': durationSeconds, + 'result': result, + }, + ); void recordingUploadFailed({ required String attemptId, @@ -956,19 +951,20 @@ class AnalyticsManager { required double durationSeconds, required String failureClass, String? recordingId, - }) => track( - 'Recording Upload Failed', - properties: { - 'upload_attempt_id': attemptId, - if (recordingId != null) 'recording_id': recordingId, - 'file_count': fileCount, - 'total_bytes': totalBytes, - 'claims_live_capture': claimsLiveCapture, - 'upload_source': 'offline_audio_queue', - 'duration_seconds': durationSeconds, - 'failure_class': failureClass, - }, - ); + }) => + track( + 'Recording Upload Failed', + properties: { + 'upload_attempt_id': attemptId, + if (recordingId != null) 'recording_id': recordingId, + 'file_count': fileCount, + 'total_bytes': totalBytes, + 'claims_live_capture': claimsLiveCapture, + 'upload_source': 'offline_audio_queue', + 'duration_seconds': durationSeconds, + 'failure_class': failureClass, + }, + ); // Transcribe Later (batch / offline capture) void transcribeLaterToggled({required bool enabled}) => @@ -1008,21 +1004,22 @@ class AnalyticsManager { required String transcriptionStatusFinal, required int durationSeconds, String? reason, - }) => track( - 'Phone Call Transcript Session', - properties: { - 'ws_accepted': wsAccepted, - 'audio_frames_sent': audioFramesSent, - 'audio_bytes_sent': audioBytesSent, - 'audio_channel_1_frames': audioChannel1Frames, - 'audio_channel_2_frames': audioChannel2Frames, - 'event_channel_errors': eventChannelErrors, - 'event_channel_coerced': eventChannelCoerced, - 'transcription_status_final': transcriptionStatusFinal, - 'duration_seconds': durationSeconds, - if (reason != null) 'reason': reason, - }, - ); + }) => + track( + 'Phone Call Transcript Session', + properties: { + 'ws_accepted': wsAccepted, + 'audio_frames_sent': audioFramesSent, + 'audio_bytes_sent': audioBytesSent, + 'audio_channel_1_frames': audioChannel1Frames, + 'audio_channel_2_frames': audioChannel2Frames, + 'event_channel_errors': eventChannelErrors, + 'event_channel_coerced': eventChannelCoerced, + 'transcription_status_final': transcriptionStatusFinal, + 'duration_seconds': durationSeconds, + if (reason != null) 'reason': reason, + }, + ); void phoneCallFailed({String? error}) => track('Phone Call Failed', properties: {'failure_class': 'call_start_failed'}); @@ -1205,11 +1202,11 @@ class AnalyticsManager { /// from [BtDevice.toJson] (raw id, name, serial, locator, RSSI) stay off /// the analytics channel; hashed identity is the join key. static Map _deviceConnectionEventProperties(BtDevice device) => { - 'type': device.type.name, - 'device_vendor': device.type.analyticsVendor, - 'hardware_family': DeviceUtils.analyticsHardwareFamily(device), - ..._deviceIdentityProperties(device), - }; + 'type': device.type.name, + 'device_vendor': device.type.analyticsVendor, + 'hardware_family': DeviceUtils.analyticsHardwareFamily(device), + ..._deviceIdentityProperties(device), + }; static Map _deviceIdentityProperties(BtDevice device) { final serial = device.serialNumber?.trim(); @@ -1380,9 +1377,9 @@ class AnalyticsManager { @visibleForTesting static Map recordingDeviceProperties(BtDevice? device) => { - 'recording_hardware_type': device?.type.name ?? 'phone', - 'recording_firmware_revision': device == null ? 'not_applicable' : _knownDeviceValue(device.firmwareRevision), - }; + 'recording_hardware_type': device?.type.name ?? 'phone', + 'recording_firmware_revision': device == null ? 'not_applicable' : _knownDeviceValue(device.firmwareRevision), + }; void conversationListItemClicked(ServerConversation conversation, int idx) => track('Memory List Item Clicked', properties: getConversationEventProperties(conversation)); @@ -1400,18 +1397,19 @@ class AnalyticsManager { required String chatTargetId, required bool isPersonaChat, required bool isVoiceInput, - }) => track( - 'Chat Message Sent', - properties: { - 'message_length': message.length, - 'message_word_count': message.split(' ').length, - 'includes_files': includesFiles, - 'number_of_files': numberOfFiles, - 'chat_target_id': chatTargetId, - 'is_persona_chat': isPersonaChat, - 'is_voice_input': isVoiceInput, - }, - ); + }) => + track( + 'Chat Message Sent', + properties: { + 'message_length': message.length, + 'message_word_count': message.split(' ').length, + 'includes_files': includesFiles, + 'number_of_files': numberOfFiles, + 'chat_target_id': chatTargetId, + 'is_persona_chat': isPersonaChat, + 'is_voice_input': isVoiceInput, + }, + ); void chatVoiceInputUsed({required String chatTargetId, required bool isPersonaChat}) { track('Chat Voice Input Used', properties: {'chat_target_id': chatTargetId, 'is_persona_chat': isPersonaChat}); @@ -1424,9 +1422,9 @@ class AnalyticsManager { void speechProfileUploadSucceeded() => const TypedEvents().emit(const SpeechProfileUploadSucceeded()); void speechProfileUploadFailed({String? reason, int? statusCode}) => track( - speechProfileEnrollEventName(SpeechProfileEnrollEvent.uploadFailed), - properties: {if (reason != null) 'reason': reason, if (statusCode != null) 'status_code': statusCode}, - ); + speechProfileEnrollEventName(SpeechProfileEnrollEvent.uploadFailed), + properties: {if (reason != null) 'reason': reason, if (statusCode != null) 'status_code': statusCode}, + ); void speechProfileEmbeddingStored() => const TypedEvents().emit(const SpeechProfileEmbeddingStored()); @@ -1437,20 +1435,22 @@ class AnalyticsManager { required String source, required String variant, required int promptCount, - }) => track( - 'Guided Intro Started', - properties: {'session_id': sessionId, 'source': source, 'variant': variant, 'prompt_count': promptCount}, - ); + }) => + track( + 'Guided Intro Started', + properties: {'session_id': sessionId, 'source': source, 'variant': variant, 'prompt_count': promptCount}, + ); void guidedIntroPromptViewed({ required String sessionId, required String source, required String variant, required int promptIndex, - }) => track( - 'Guided Intro Prompt Viewed', - properties: {'session_id': sessionId, 'source': source, 'variant': variant, 'prompt_index': promptIndex}, - ); + }) => + track( + 'Guided Intro Prompt Viewed', + properties: {'session_id': sessionId, 'source': source, 'variant': variant, 'prompt_index': promptIndex}, + ); void guidedIntroRecordingStarted({ required String sessionId, @@ -1458,16 +1458,17 @@ class AnalyticsManager { required String variant, required int promptIndex, required int attempt, - }) => track( - 'Guided Intro Recording Started', - properties: { - 'session_id': sessionId, - 'source': source, - 'variant': variant, - 'prompt_index': promptIndex, - 'attempt': attempt, - }, - ); + }) => + track( + 'Guided Intro Recording Started', + properties: { + 'session_id': sessionId, + 'source': source, + 'variant': variant, + 'prompt_index': promptIndex, + 'attempt': attempt, + }, + ); void guidedIntroRecordingFailed({ required String sessionId, @@ -1475,16 +1476,17 @@ class AnalyticsManager { required String variant, required int promptIndex, required String failureClass, - }) => track( - 'Guided Intro Recording Failed', - properties: { - 'session_id': sessionId, - 'source': source, - 'variant': variant, - 'prompt_index': promptIndex, - 'failure_class': failureClass, - }, - ); + }) => + track( + 'Guided Intro Recording Failed', + properties: { + 'session_id': sessionId, + 'source': source, + 'variant': variant, + 'prompt_index': promptIndex, + 'failure_class': failureClass, + }, + ); void guidedIntroPromptCompleted({ required String sessionId, @@ -1494,18 +1496,19 @@ class AnalyticsManager { required String result, required int durationMs, required bool transcriptPresent, - }) => track( - 'Guided Intro Prompt Completed', - properties: { - 'session_id': sessionId, - 'source': source, - 'variant': variant, - 'prompt_index': promptIndex, - 'result': result, - 'duration_ms': durationMs, - 'transcript_present': transcriptPresent, - }, - ); + }) => + track( + 'Guided Intro Prompt Completed', + properties: { + 'session_id': sessionId, + 'source': source, + 'variant': variant, + 'prompt_index': promptIndex, + 'result': result, + 'duration_ms': durationMs, + 'transcript_present': transcriptPresent, + }, + ); void guidedIntroReviewShown({ required String sessionId, @@ -1514,17 +1517,18 @@ class AnalyticsManager { required int answerCount, required bool goalPresent, required int reviewAttempt, - }) => track( - 'Guided Intro Review Shown', - properties: { - 'session_id': sessionId, - 'source': source, - 'variant': variant, - 'answer_count': answerCount, - 'goal_present': goalPresent, - 'review_attempt': reviewAttempt, - }, - ); + }) => + track( + 'Guided Intro Review Shown', + properties: { + 'session_id': sessionId, + 'source': source, + 'variant': variant, + 'answer_count': answerCount, + 'goal_present': goalPresent, + 'review_attempt': reviewAttempt, + }, + ); void guidedIntroSaveSubmitted({ required String sessionId, @@ -1535,19 +1539,20 @@ class AnalyticsManager { required bool goalSelected, required bool voiceAttempted, required int attempt, - }) => track( - 'Guided Intro Save Submitted', - properties: { - 'session_id': sessionId, - 'source': source, - 'variant': variant, - 'selected_answer_count': selectedAnswerCount, - 'selected_memory_count': selectedMemoryCount, - 'goal_selected': goalSelected, - 'voice_attempted': voiceAttempted, - 'attempt': attempt, - }, - ); + }) => + track( + 'Guided Intro Save Submitted', + properties: { + 'session_id': sessionId, + 'source': source, + 'variant': variant, + 'selected_answer_count': selectedAnswerCount, + 'selected_memory_count': selectedMemoryCount, + 'goal_selected': goalSelected, + 'voice_attempted': voiceAttempted, + 'attempt': attempt, + }, + ); void guidedIntroVoiceEnrollment({ required String sessionId, @@ -1556,17 +1561,18 @@ class AnalyticsManager { required String result, required int durationMs, required int attempt, - }) => track( - 'Guided Intro Voice Enrollment', - properties: { - 'session_id': sessionId, - 'source': source, - 'variant': variant, - 'result': result, - 'duration_ms': durationMs, - 'attempt': attempt, - }, - ); + }) => + track( + 'Guided Intro Voice Enrollment', + properties: { + 'session_id': sessionId, + 'source': source, + 'variant': variant, + 'result': result, + 'duration_ms': durationMs, + 'attempt': attempt, + }, + ); void guidedIntroContentSave({ required String sessionId, @@ -1577,19 +1583,20 @@ class AnalyticsManager { required int memoryFailed, required String goalResult, required int attempt, - }) => track( - 'Guided Intro Content Save', - properties: { - 'session_id': sessionId, - 'source': source, - 'variant': variant, - 'memory_attempted': memoryAttempted, - 'memory_saved': memorySaved, - 'memory_failed': memoryFailed, - 'goal_result': goalResult, - 'attempt': attempt, - }, - ); + }) => + track( + 'Guided Intro Content Save', + properties: { + 'session_id': sessionId, + 'source': source, + 'variant': variant, + 'memory_attempted': memoryAttempted, + 'memory_saved': memorySaved, + 'memory_failed': memoryFailed, + 'goal_result': goalResult, + 'attempt': attempt, + }, + ); void guidedIntroCompleted({ required String sessionId, @@ -1600,19 +1607,20 @@ class AnalyticsManager { required int memorySaved, required String goalResult, required int elapsedMs, - }) => track( - 'Guided Intro Completed', - properties: { - 'session_id': sessionId, - 'source': source, - 'variant': variant, - 'completion_mode': completionMode, - 'voice_result': voiceResult, - 'memory_saved': memorySaved, - 'goal_result': goalResult, - 'elapsed_ms': elapsedMs, - }, - ); + }) => + track( + 'Guided Intro Completed', + properties: { + 'session_id': sessionId, + 'source': source, + 'variant': variant, + 'completion_mode': completionMode, + 'voice_result': voiceResult, + 'memory_saved': memorySaved, + 'goal_result': goalResult, + 'elapsed_ms': elapsedMs, + }, + ); void showDiscardedMemoriesToggled(bool showDiscarded) => const TypedEvents().emit(ShowDiscardedMemoriesToggled(showDiscarded: showDiscarded)); @@ -1627,8 +1635,8 @@ class AnalyticsManager { const TypedEvents().emit(ShowDiscardedConversationsToggled(showDiscarded: showDiscarded)); void shortConversationThresholdChanged(int thresholdSeconds) => const TypedEvents().emit( - ShortConversationThresholdChanged(thresholdSeconds: thresholdSeconds, thresholdMinutes: thresholdSeconds ~/ 60), - ); + ShortConversationThresholdChanged(thresholdSeconds: thresholdSeconds, thresholdMinutes: thresholdSeconds ~/ 60), + ); void voiceResponseToggled(bool enabled) => const TypedEvents().emit(VoiceResponseToggled(enabled: enabled)); @@ -1662,20 +1670,21 @@ class AnalyticsManager { required VoiceReplyPlaybackFallbackReason fallbackReason, required int firstAudioLatencyMs, required VoiceReplyPlaybackInterruptSource interruptSource, - }) => const TypedEvents().emit( - VoiceReplyPlayback( - outcome: outcome, - skipReason: skipReason, - mode: mode, - outputRoute: outputRoute, - chunksRequested: chunksRequested, - chunksPlayed: chunksPlayed, - chunksDropped: chunksDropped, - fallbackReason: fallbackReason, - firstAudioLatencyMs: firstAudioLatencyMs, - interruptSource: interruptSource, - ), - ); + }) => + const TypedEvents().emit( + VoiceReplyPlayback( + outcome: outcome, + skipReason: skipReason, + mode: mode, + outputRoute: outputRoute, + chunksRequested: chunksRequested, + chunksPlayed: chunksPlayed, + chunksDropped: chunksDropped, + fallbackReason: fallbackReason, + firstAudioLatencyMs: firstAudioLatencyMs, + interruptSource: interruptSource, + ), + ); // Conversation Merge Events void conversationMergeSelectionModeEntered() => @@ -1684,28 +1693,28 @@ class AnalyticsManager { void conversationMergeSelectionModeExited() => const TypedEvents().emit(const ConversationMergeSelectionModeExited()); void conversationSelectedForMerge(String conversationId, int totalSelected) => track( - 'Conversation Selected For Merge', - properties: {'conversation_id': conversationId, 'total_selected': totalSelected}, - ); + 'Conversation Selected For Merge', + properties: {'conversation_id': conversationId, 'total_selected': totalSelected}, + ); void conversationMergeInitiated(List conversationIds) => track( - 'Conversation Merge Initiated', - properties: {'conversation_count': conversationIds.length, 'conversation_ids': conversationIds}, - ); + 'Conversation Merge Initiated', + properties: {'conversation_count': conversationIds.length, 'conversation_ids': conversationIds}, + ); void conversationMergeCompleted(String mergedConversationId, List removedConversationIds) => track( - 'Conversation Merge Completed', - properties: { - 'merged_conversation_id': mergedConversationId, - 'removed_count': removedConversationIds.length, - 'removed_conversation_ids': removedConversationIds, - }, - ); + 'Conversation Merge Completed', + properties: { + 'merged_conversation_id': mergedConversationId, + 'removed_count': removedConversationIds.length, + 'removed_conversation_ids': removedConversationIds, + }, + ); void conversationMergeFailed(List conversationIds) => track( - 'Conversation Merge Failed', - properties: {'conversation_count': conversationIds.length, 'conversation_ids': conversationIds}, - ); + 'Conversation Merge Failed', + properties: {'conversation_count': conversationIds.length, 'conversation_ids': conversationIds}, + ); // Important Conversation Share Events void importantConversationNotificationReceived(String conversationId) => @@ -1715,14 +1724,14 @@ class AnalyticsManager { track('Share To Contacts Sheet Opened', properties: {'conversation_id': conversationId}); void shareToContactsSelected(String conversationId, int contactCount) => track( - 'Share To Contacts Selected', - properties: {'conversation_id': conversationId, 'contact_count': contactCount}, - ); + 'Share To Contacts Selected', + properties: {'conversation_id': conversationId, 'contact_count': contactCount}, + ); void shareToContactsSmsOpened(String conversationId, int contactCount) => track( - 'Share To Contacts SMS Opened', - properties: {'conversation_id': conversationId, 'contact_count': contactCount}, - ); + 'Share To Contacts SMS Opened', + properties: {'conversation_id': conversationId, 'contact_count': contactCount}, + ); void chatMessageConversationClicked(ServerConversation conversation) => track('Chat Message Memory Clicked', properties: getConversationEventProperties(conversation)); @@ -1778,16 +1787,17 @@ class AnalyticsManager { /// [source] is a closed surface id (`chat_quota_exceeded`, `locked_memory`, …). /// [currentPlan] is a catalog tier id. Unknown wires collapse to `unknown`. void paywallOpened(String source, {String currentPlan = 'unknown'}) => const TypedEvents().emit( - PaywallOpened( - source: _closedEnum(PaywallOpenedSource.values, source, PaywallOpenedSource.unknown, (value) => value.wireName), - currentPlan: _closedEnum( - PaywallOpenedCurrentPlan.values, - _planWire(currentPlan), - PaywallOpenedCurrentPlan.unknown, - (value) => value.wireName, - ), - ), - ); + PaywallOpened( + source: + _closedEnum(PaywallOpenedSource.values, source, PaywallOpenedSource.unknown, (value) => value.wireName), + currentPlan: _closedEnum( + PaywallOpenedCurrentPlan.values, + _planWire(currentPlan), + PaywallOpenedCurrentPlan.unknown, + (value) => value.wireName, + ), + ), + ); /// [plan] is the tier id. [billingInterval] is `month` or `year`, not the /// sheet's yearly/monthly toggle. [source] is the surface that opened the sheet. @@ -1871,51 +1881,51 @@ class AnalyticsManager { void subscriptionCancelFlowStarted() => const TypedEvents().emit(const SubscriptionCancelFlowStarted()); void subscriptionCancelReasonSelected({required String reason}) => const TypedEvents().emit( - SubscriptionCancelReasonSelected( - reason: _closedEnum( - SubscriptionCancelReasonSelectedReason.values, - _cancelReasonWire(reason), - SubscriptionCancelReasonSelectedReason.unknown, - (value) => value.wireName, - ), - ), - ); + SubscriptionCancelReasonSelected( + reason: _closedEnum( + SubscriptionCancelReasonSelectedReason.values, + _cancelReasonWire(reason), + SubscriptionCancelReasonSelectedReason.unknown, + (value) => value.wireName, + ), + ), + ); void subscriptionCancelConfirmed({required String reason, String? details}) => const TypedEvents().emit( - SubscriptionCancelConfirmed( - reason: _closedEnum( - SubscriptionCancelConfirmedReason.values, - _cancelReasonWire(reason), - SubscriptionCancelConfirmedReason.unknown, - (value) => value.wireName, - ), - hasDetails: details?.isNotEmpty == true, - ), - ); + SubscriptionCancelConfirmed( + reason: _closedEnum( + SubscriptionCancelConfirmedReason.values, + _cancelReasonWire(reason), + SubscriptionCancelConfirmedReason.unknown, + (value) => value.wireName, + ), + hasDetails: details?.isNotEmpty == true, + ), + ); void subscriptionCancelKeptPlan({required int step, String? reason}) => const TypedEvents().emit( - SubscriptionCancelKeptPlan( - step: step, - reason: _closedEnum( - SubscriptionCancelKeptPlanReason.values, - _cancelReasonWire(reason), - SubscriptionCancelKeptPlanReason.unknown, - (value) => value.wireName, - ), - ), - ); + SubscriptionCancelKeptPlan( + step: step, + reason: _closedEnum( + SubscriptionCancelKeptPlanReason.values, + _cancelReasonWire(reason), + SubscriptionCancelKeptPlanReason.unknown, + (value) => value.wireName, + ), + ), + ); void subscriptionCancelAbandoned({required int step, String? reason}) => const TypedEvents().emit( - SubscriptionCancelAbandoned( - step: step, - reason: _closedEnum( - SubscriptionCancelAbandonedReason.values, - _cancelReasonWire(reason), - SubscriptionCancelAbandonedReason.unknown, - (value) => value.wireName, - ), - ), - ); + SubscriptionCancelAbandoned( + step: step, + reason: _closedEnum( + SubscriptionCancelAbandonedReason.values, + _cancelReasonWire(reason), + SubscriptionCancelAbandonedReason.unknown, + (value) => value.wireName, + ), + ), + ); void connectFriendClicked() => const TypedEvents().emit(const ConnectFriendClicked()); @@ -1964,9 +1974,9 @@ class AnalyticsManager { track('Delete Account Reason Selected', properties: {'reason': reason}); void deleteAccountFeedbackSubmitted({required String reason, String? details}) => track( - 'Delete Account Feedback Submitted', - properties: {'reason': reason, 'has_details': details?.isNotEmpty == true}, - ); + 'Delete Account Feedback Submitted', + properties: {'reason': reason, 'has_details': details?.isNotEmpty == true}, + ); void deleteAccountAbandoned({required int step, String? reason}) => track('Delete Account Abandoned', properties: {'step': step, 'reason': reason}); @@ -1975,11 +1985,11 @@ class AnalyticsManager { track('Delete Account Kept Account', properties: {'step': step, 'reason': reason}); void deleteUser() => PlatformService.executeIfSupported(PlatformService.isAnalyticsSupported, () { - final adapter = _adapter; - if (adapter == null) return; - adapter.track(eventName: 'User Deleted'); - adapter.reset(); - }); + final adapter = _adapter; + if (adapter == null) return; + adapter.track(eventName: 'User Deleted'); + adapter.reset(); + }); // Apps Filter void appsFilterOpened() => const TypedEvents().emit(const AppsFilterOpened()); @@ -2160,14 +2170,15 @@ class AnalyticsManager { required bool hasPhotos, required int segmentCount, required int photoCount, - }) => const TypedEvents().emit( - LiveTranscriptCardClicked( - hasSegments: hasSegments, - hasPhotos: hasPhotos, - segmentCount: segmentCount, - photoCount: photoCount, - ), - ); + }) => + const TypedEvents().emit( + LiveTranscriptCardClicked( + hasSegments: hasSegments, + hasPhotos: hasPhotos, + segmentCount: segmentCount, + photoCount: photoCount, + ), + ); void conversationListItemClickedWithTimeDifference({ required ServerConversation conversation, @@ -2408,17 +2419,18 @@ class AnalyticsManager { required int titleDuePulled, required int titleDuePushed, required int remindersUnlinked, - }) => const TypedEvents().emit( - AppleRemindersSyncCompleted( - pendingExported: pendingExported, - syncedChecked: syncedChecked, - completionsPulled: completionsPulled, - completionsPushed: completionsPushed, - titleDuePulled: titleDuePulled, - titleDuePushed: titleDuePushed, - remindersUnlinked: remindersUnlinked, - ), - ); + }) => + const TypedEvents().emit( + AppleRemindersSyncCompleted( + pendingExported: pendingExported, + syncedChecked: syncedChecked, + completionsPulled: completionsPulled, + completionsPushed: completionsPushed, + titleDuePulled: titleDuePulled, + titleDuePushed: titleDuePushed, + remindersUnlinked: remindersUnlinked, + ), + ); void appleReminderDirectSync({required String actionItemId}) { track('Apple Reminder Direct Sync', properties: {'action_item_id': actionItemId}); @@ -2691,13 +2703,14 @@ class AnalyticsManager { required int totalConversations, required int totalMinutes, required int daysActive, - }) => const TypedEvents().emit( - WrappedGenerationCompleted( - totalConversations: totalConversations, - totalMinutes: totalMinutes, - daysActive: daysActive, - ), - ); + }) => + const TypedEvents().emit( + WrappedGenerationCompleted( + totalConversations: totalConversations, + totalMinutes: totalMinutes, + daysActive: daysActive, + ), + ); void wrappedGenerationFailed({String? error}) { track('Wrapped Generation Failed', properties: {'failure_class': 'unknown'}); @@ -3107,12 +3120,12 @@ class _QueuedAnalyticsEvent { final int identityEpoch; final int attempts; _QueuedAnalyticsEvent nextAttempt() => _QueuedAnalyticsEvent( - eventName: eventName, - properties: properties, - ownedKeys: ownedKeys, - eventId: eventId, - occurredAt: occurredAt, - identityEpoch: identityEpoch, - attempts: attempts + 1, - ); + eventName: eventName, + properties: properties, + ownedKeys: ownedKeys, + eventId: eventId, + occurredAt: occurredAt, + identityEpoch: identityEpoch, + attempts: attempts + 1, + ); } diff --git a/app/lib/utils/auth/sign_in_failure.dart b/app/lib/utils/auth/sign_in_failure.dart index 1f7c12f6ebd..79b2efe4340 100644 --- a/app/lib/utils/auth/sign_in_failure.dart +++ b/app/lib/utils/auth/sign_in_failure.dart @@ -36,11 +36,13 @@ SignInFailedErrorClass classifySignInFailure(Object error) { 'user-disabled' || 'user-not-found' || 'wrong-password' || - 'invalid-verification-code' => SignInFailedErrorClass.credential, + 'invalid-verification-code' => + SignInFailedErrorClass.credential, 'operation-not-allowed' || 'app-not-authorized' || 'invalid-api-key' || - 'configuration-not-found' => SignInFailedErrorClass.configuration, + 'configuration-not-found' => + SignInFailedErrorClass.configuration, _ => SignInFailedErrorClass.unknown, }; } diff --git a/app/test/pages/onboarding/onboarding_permission_decision_test.dart b/app/test/pages/onboarding/onboarding_permission_decision_test.dart index 90eaa5717d8..ee83005f7f9 100644 --- a/app/test/pages/onboarding/onboarding_permission_decision_test.dart +++ b/app/test/pages/onboarding/onboarding_permission_decision_test.dart @@ -31,7 +31,11 @@ void main() { isTrue, ); expect( - log.claim(permission: OnboardingPermissionResultPermission.location, granted: false, permanentlyDenied: true), + log.claim( + permission: OnboardingPermissionResultPermission.location, + granted: false, + permanentlyDenied: true, + ), isTrue, ); expect( @@ -44,7 +48,11 @@ void main() { isTrue, ); expect( - log.claim(permission: OnboardingPermissionResultPermission.location, granted: false, permanentlyDenied: false), + log.claim( + permission: OnboardingPermissionResultPermission.location, + granted: false, + permanentlyDenied: false, + ), isTrue, ); }); @@ -60,9 +68,7 @@ void main() { expect(onboardingPermissionWireName('notifications'), OnboardingPermissionResultPermission.notifications); expect(onboardingPermissionWireName('location'), OnboardingPermissionResultPermission.location); expect( - onboardingPermissionWireName('background_activity'), - OnboardingPermissionResultPermission.backgroundActivity, - ); + onboardingPermissionWireName('background_activity'), OnboardingPermissionResultPermission.backgroundActivity); expect(onboardingPermissionWireName('bluetooth'), isNull); }); } diff --git a/app/test/providers/guided_voice_requests_test.dart b/app/test/providers/guided_voice_requests_test.dart index 180ae84738b..b32a2064e24 100644 --- a/app/test/providers/guided_voice_requests_test.dart +++ b/app/test/providers/guided_voice_requests_test.dart @@ -9,8 +9,14 @@ import 'package:omi/backend/http/streaming_error.dart'; import 'package:omi/pages/onboarding/guided_voice_controller.dart'; import 'package:omi/pages/onboarding/guided_voice_requests.dart'; -String done(String text) => - 'done: ${base64Encode(utf8.encode(jsonEncode({'id': 'answer', 'text': text, 'created_at': '2026-10-13T00:00:00Z', 'sender': 'ai', 'type': 'text', 'memories': []})))}'; +String done(String text) => 'done: ${base64Encode(utf8.encode(jsonEncode({ + 'id': 'answer', + 'text': text, + 'created_at': '2026-10-13T00:00:00Z', + 'sender': 'ai', + 'type': 'text', + 'memories': [], + })))}'; void main() { setUp(() => Env.overrideApiBaseUrl('http://127.0.0.1/')); @@ -20,7 +26,7 @@ void main() { http.Response('{}', 200), http.Response('invalid', 200), http.Response('{"available":false}', 200), - http.Response('{"available":true}', 503), + http.Response('{"available":true}', 503) ]) { expect(await guidedVoicePreflight(probe: (_) async => response), isFalse); } @@ -36,19 +42,16 @@ void main() { test('chat acceptance is emitted after HTTP 200, never offline or rejected', () async { for (final status in [200, 503, 402, -1]) { var accepted = 0; - final asking = askGuidedPayoff( - 'Question', - ['Saved'], - onAccepted: () => accepted++, - seams: ApiStreamingSeams( - headers: (_) async => {}, - transport: (_) async { - expect(accepted, 0); - if (status == -1) throw const SocketException('offline'); - return http.StreamedResponse(Stream.value(utf8.encode('${done('Answer')}\n\n')), status); - }, - ), - ); + final asking = askGuidedPayoff('Question', ['Saved'], + onAccepted: () => accepted++, + seams: ApiStreamingSeams( + headers: (_) async => {}, + transport: (_) async { + expect(accepted, 0); + if (status == -1) throw const SocketException('offline'); + return http.StreamedResponse(Stream.value(utf8.encode('${done('Answer')}\n\n')), status); + }, + )); if (status == 200) { expect(await asking, 'Answer'); } else { @@ -60,54 +63,37 @@ void main() { test('transcription keeps empty and HTTP errors separate without swallowing timeout', () async { final file = File('/synthetic/no-read.wav'); for (final status in [401, 402, 500, 503]) { - await expectLater( - transcribeGuidedAnswer(file, 'en', upload: () async => http.Response('{}', status)), - throwsA(isA().having((e) => e.failureClass, 'kind', 'transcribe_http_error')), - ); + await expectLater(transcribeGuidedAnswer(file, 'en', upload: () async => http.Response('{}', status)), + throwsA(isA().having((e) => e.failureClass, 'kind', 'transcribe_http_error'))); } - await expectLater( - transcribeGuidedAnswer(file, 'en', upload: () async => http.Response('{"transcript":" "}', 200)), - throwsA(isA().having((e) => e.failureClass, 'kind', 'transcribe_empty')), - ); - await expectLater( - transcribeGuidedAnswer(file, 'en', upload: () async => throw TimeoutException('synthetic')), - throwsA(isA()), - ); + await expectLater(transcribeGuidedAnswer(file, 'en', upload: () async => http.Response('{"transcript":" "}', 200)), + throwsA(isA().having((e) => e.failureClass, 'kind', 'transcribe_empty'))); + await expectLater(transcribeGuidedAnswer(file, 'en', upload: () async => throw TimeoutException('synthetic')), + throwsA(isA())); expect( - await transcribeGuidedAnswer( - file, - 'en', - upload: () async => http.Response('{"transcript":" I build bicycles. "}', 200), - ), - 'I build bicycles.', - ); + await transcribeGuidedAnswer(file, 'en', + upload: () async => http.Response('{"transcript":" I build bicycles. "}', 200)), + 'I build bicycles.'); }); test('payoff uses persisted done frame, explicit saved facts and no device tools', () async { String? body; - final text = await askGuidedPayoff( - 'What should I focus on this week?', - ['I build bicycles.'], - stream: (sent) { - body = sent; - return Stream.fromIterable([': heartbeat', 'data: Partial', done('Finish your bicycle project.')]); - }, - ); + final text = await askGuidedPayoff('What should I focus on this week?', ['I build bicycles.'], stream: (sent) { + body = sent; + return Stream.fromIterable([': heartbeat', 'data: Partial', done('Finish your bicycle project.')]); + }); expect(text, 'Finish your bicycle project.'); final decoded = jsonDecode(body!) as Map; expect(decoded['device_tools'], isEmpty); expect(decoded['text'], contains('> I build bicycles.')); - expect( - decoded['text'], - isNot(contains('Base a brief answer')), - reason: 'chat history contains user-facing question and quotes, not helper instructions', - ); + expect(decoded['text'], isNot(contains('Base a brief answer')), + reason: 'chat history contains user-facing question and quotes, not helper instructions'); expect(decoded['text'], startsWith('What should I focus on this week?')); }); for (final frame in [ 'error:402:{}', 'error: {"error":"quota_exceeded","message":"quota_exceeded"}', 'unrecognized frame', - 'data: Partial', + 'data: Partial' ]) { test('payoff does not accept quota/errors/malformed/partial-only frame $frame', () async { await expectLater(askGuidedPayoff('Question', ['Saved'], stream: (_) => Stream.value(frame)), throwsStateError); @@ -115,19 +101,16 @@ void main() { } test('server timeout frame preserves timeout fallback classification', () async { await expectLater( - askGuidedPayoff('Question', [ - 'Saved', - ], stream: (_) => Stream.value('error: {"error":"timeout","message":"timeout"}')), - throwsA(isA().having((e) => e.kind, 'kind', ChatStreamFailureClass.timeout)), - ); + askGuidedPayoff('Question', ['Saved'], + stream: (_) => Stream.value('error: {"error":"timeout","message":"timeout"}')), + throwsA(isA().having((e) => e.kind, 'kind', ChatStreamFailureClass.timeout))); }); test('payoff cancels a silent stream at its total deadline', () async { var cancelled = false; final source = StreamController(onCancel: () => cancelled = true); await expectLater( - askGuidedPayoff('Question', ['Saved'], stream: (_) => source.stream, timeout: const Duration(milliseconds: 2)), - throwsA(isA()), - ); + askGuidedPayoff('Question', ['Saved'], stream: (_) => source.stream, timeout: const Duration(milliseconds: 2)), + throwsA(isA())); await Future.delayed(Duration.zero); expect(cancelled, isTrue); await source.close(); diff --git a/app/test/providers/integration_status_failure_test.dart b/app/test/providers/integration_status_failure_test.dart index 41d66813c0b..728ae1df59c 100644 --- a/app/test/providers/integration_status_failure_test.dart +++ b/app/test/providers/integration_status_failure_test.dart @@ -21,12 +21,11 @@ void main() { test('one malformed status leaves a terminal retry row without hiding successful services', () async { var fail = true; final provider = IntegrationProvider( - fetchStatus: (key) async { - if (key == 'gmail' && fail) throw const FormatException('malformed response'); - return IntegrationResponse(appKey: key, connected: true); - }, - persistPref: (_, __) async {}, - ); + fetchStatus: (key) async { + if (key == 'gmail' && fail) throw const FormatException('malformed response'); + return IntegrationResponse(appKey: key, connected: true); + }, + persistPref: (_, __) async {}); addTearDown(provider.dispose); await provider.loadFromBackend(); expect(provider.hasLoaded, isTrue); @@ -42,11 +41,10 @@ void main() { testWidgets('a nonresponding integration has a bounded status read and ignores its late reply', (tester) async { final pending = Completer(); final provider = IntegrationProvider( - statusTimeout: const Duration(seconds: 1), - fetchStatus: (key) => - key == 'gmail' ? pending.future : Future.value(IntegrationResponse(appKey: key, connected: true)), - persistPref: (_, __) async {}, - ); + statusTimeout: const Duration(seconds: 1), + fetchStatus: (key) => + key == 'gmail' ? pending.future : Future.value(IntegrationResponse(appKey: key, connected: true)), + persistPref: (_, __) async {}); addTearDown(provider.dispose); final load = provider.loadFromBackend(); await tester.pump(const Duration(seconds: 2)); @@ -63,9 +61,8 @@ void main() { final old = Completer(); var fresh = false; final provider = IntegrationProvider( - fetchStatus: (key) => fresh ? Future.value(IntegrationResponse(appKey: key, connected: true)) : old.future, - persistPref: (_, __) async {}, - ); + fetchStatus: (key) => fresh ? Future.value(IntegrationResponse(appKey: key, connected: true)) : old.future, + persistPref: (_, __) async {}); addTearDown(provider.dispose); final first = provider.loadFromBackend(); fresh = true; @@ -77,12 +74,9 @@ void main() { }); test('task read failures preserve verified grants and recover on retry', () async { - TaskIntegrationsResponse? response = TaskIntegrationsResponse( - integrations: { - 'todoist': {'app_key': 'todoist', 'connected': true}, - }, - defaultApp: 'todoist', - ); + TaskIntegrationsResponse? response = TaskIntegrationsResponse(integrations: { + 'todoist': {'app_key': 'todoist', 'connected': true} + }, defaultApp: 'todoist'); final provider = TaskIntegrationProvider(fetchStatus: () async => response); addTearDown(provider.dispose); provider.clearUserData(); @@ -104,10 +98,8 @@ void main() { testWidgets('task timeout ends loading and late results cannot resurrect a cleared account', (tester) async { final pending = Completer(); - final provider = TaskIntegrationProvider( - fetchStatus: () => pending.future, - statusTimeout: const Duration(seconds: 1), - ); + final provider = + TaskIntegrationProvider(fetchStatus: () => pending.future, statusTimeout: const Duration(seconds: 1)); addTearDown(provider.dispose); provider.clearUserData(); final load = provider.loadFromBackend(); @@ -117,13 +109,9 @@ void main() { expect(provider.isLoading, isFalse); expect(provider.hasLoadError, isTrue); provider.clearUserData(); - pending.complete( - TaskIntegrationsResponse( - integrations: { - 'todoist': {'app_key': 'todoist', 'connected': true}, - }, - ), - ); + pending.complete(TaskIntegrationsResponse(integrations: { + 'todoist': {'app_key': 'todoist', 'connected': true} + })); await tester.pump(); expect(provider.hasLoaded, isFalse); expect(provider.isAppConnected(TaskIntegrationApp.todoist), isFalse); @@ -133,9 +121,8 @@ void main() { final old = Completer(); var fresh = false; final provider = TaskIntegrationProvider( - fetchStatus: () => - fresh ? Future.value(TaskIntegrationsResponse(integrations: {}, defaultApp: 'asana')) : old.future, - ); + fetchStatus: () => + fresh ? Future.value(TaskIntegrationsResponse(integrations: {}, defaultApp: 'asana')) : old.future); addTearDown(provider.dispose); final first = provider.loadFromBackend(); fresh = true; @@ -147,9 +134,7 @@ void main() { testWidgets('default selection deadline preserves the previous choice and ignores late acceptance', (tester) async { final pending = Completer(); final provider = TaskIntegrationProvider( - setDefaultTaskIntegrationFn: (_) => pending.future, - statusTimeout: const Duration(seconds: 1), - ); + setDefaultTaskIntegrationFn: (_) => pending.future, statusTimeout: const Duration(seconds: 1)); addTearDown(provider.dispose); final previous = provider.selectedApp; bool? saved; @@ -164,10 +149,8 @@ void main() { test('a status read crossing a successful default write cannot restore the old default', () async { final pending = Completer(); - final provider = TaskIntegrationProvider( - fetchStatus: () => pending.future, - setDefaultTaskIntegrationFn: (_) async => true, - ); + final provider = + TaskIntegrationProvider(fetchStatus: () => pending.future, setDefaultTaskIntegrationFn: (_) async => true); addTearDown(provider.dispose); final read = provider.loadFromBackend(); await provider.setSelectedApp(TaskIntegrationApp.todoist); @@ -182,7 +165,7 @@ void main() { 'integrations': { for (final key in keys) key: {'app_key': key, 'connected': true}, }, - 'default_app': 'google_tasks', + 'default_app': 'google_tasks' }); final provider = TaskIntegrationProvider(fetchStatus: () async => reply); addTearDown(provider.dispose); @@ -193,7 +176,7 @@ void main() { TaskIntegrationApp.todoist, TaskIntegrationApp.asana, TaskIntegrationApp.googleTasks, - TaskIntegrationApp.clickup, + TaskIntegrationApp.clickup ]) { expect(provider.isAppConnected(app), isTrue, reason: app.key); } diff --git a/app/test/providers/phone_call_provider_test.dart b/app/test/providers/phone_call_provider_test.dart index 74afdcd0702..f34c705d198 100644 --- a/app/test/providers/phone_call_provider_test.dart +++ b/app/test/providers/phone_call_provider_test.dart @@ -151,7 +151,11 @@ void main() { late _RecordingAnalyticsAdapter analytics; Future emitEvent(Object? event) { - return messenger.handlePlatformMessage(eventChannelName, codec.encodeSuccessEnvelope(event), (ByteData? data) {}); + return messenger.handlePlatformMessage( + eventChannelName, + codec.encodeSuccessEnvelope(event), + (ByteData? data) {}, + ); } setUp(() async { @@ -204,7 +208,10 @@ void main() { await Future.delayed(const Duration(milliseconds: 300)); expect(provider.transcriptionStatus, TranscriptionStatus.noAudio); - expect(analytics.events.map((event) => event.$1), contains('Phone Call Transcript Session')); + expect( + analytics.events.map((event) => event.$1), + contains('Phone Call Transcript Session'), + ); final stall = analytics.events.firstWhere((event) => event.$1 == 'Phone Call Transcript Session'); expect(stall.$2?['ws_accepted'], true); expect(stall.$2?['audio_frames_sent'], 0); @@ -230,7 +237,10 @@ void main() { await emitEvent({'type': 'callStateChanged', 'state': 'ended'}); await Future.delayed(const Duration(milliseconds: 100)); - expect(analytics.events.map((event) => event.$1), contains('Phone Call Transcript Session')); + expect( + analytics.events.map((event) => event.$1), + contains('Phone Call Transcript Session'), + ); final session = analytics.events.firstWhere((event) => event.$1 == 'Phone Call Transcript Session'); expect(session.$2?['audio_frames_sent'], 0); expect(session.$2?['audio_bytes_sent'], 0); @@ -259,15 +269,12 @@ void main() { await emitEvent({ 'type': 'audioData', 'data': Uint8List.fromList([1, 2, 3, 4]), - 'channel': 1, + 'channel': 1 }); await Future.delayed(const Duration(milliseconds: 100)); - expect( - provider.transcriptionStatus, - TranscriptionStatus.active, - reason: 'frames flowing again must clear the stall chip', - ); + expect(provider.transcriptionStatus, TranscriptionStatus.active, + reason: 'frames flowing again must clear the stall chip'); expect(socket.sent.length, 1); }); @@ -329,12 +336,12 @@ void main() { await emitEvent({ 'type': 'audioData', 'data': Uint8List.fromList([1, 2, 3, 4]), - 'channel': 1, + 'channel': 1 }); await emitEvent({ 'type': 'audioData', 'data': Uint8List.fromList([5, 6, 7, 8]), - 'channel': 1, + 'channel': 1 }); await Future.delayed(const Duration(milliseconds: 100)); @@ -346,7 +353,7 @@ void main() { await emitEvent({ 'type': 'audioData', 'data': Uint8List.fromList([9, 10, 11, 12]), - 'channel': 1, + 'channel': 1 }); await Future.delayed(const Duration(milliseconds: 100)); await emitEvent({'type': 'callStateChanged', 'state': 'ended'}); diff --git a/app/test/services/capture/empty_recording_controller_test.dart b/app/test/services/capture/empty_recording_controller_test.dart index c29fc6e4382..5a75a13912a 100644 --- a/app/test/services/capture/empty_recording_controller_test.dart +++ b/app/test/services/capture/empty_recording_controller_test.dart @@ -61,10 +61,7 @@ void main() { expect(_produced(adapter), isEmpty); world.controller.onMessageEventReceived( - ConversationEvent( - memory: _conversation(id: 'late'), - messages: const [], - ), + ConversationEvent(memory: _conversation(id: 'late'), messages: const []), ); await world.settle(); await _flush(); @@ -91,10 +88,7 @@ void main() { world.emitNativeState(PhoneMicCaptureState.running); await world.settle(); world.controller.onMessageEventReceived( - ConversationEvent( - memory: _conversation(id: 'finalized'), - messages: const [], - ), + ConversationEvent(memory: _conversation(id: 'finalized'), messages: const []), ); await world.settle(); @@ -143,10 +137,7 @@ void main() { addTearDown(provider.dispose); provider.onMessageEventReceived( - ConversationEvent( - memory: _conversation(id: 'gone', discarded: true), - messages: const [], - ), + ConversationEvent(memory: _conversation(id: 'gone', discarded: true), messages: const []), ); await _flush(); diff --git a/app/test/services/integrations/integration_oauth_test.dart b/app/test/services/integrations/integration_oauth_test.dart index 97363a96ce2..cb954daef28 100644 --- a/app/test/services/integrations/integration_oauth_test.dart +++ b/app/test/services/integrations/integration_oauth_test.dart @@ -34,8 +34,7 @@ void main() { final url = Completer(); bool? result; unawaited( - launchIntegrationOAuth(() => url.future, deadline: const Duration(seconds: 1)).then((value) => result = value), - ); + launchIntegrationOAuth(() => url.future, deadline: const Duration(seconds: 1)).then((value) => result = value)); await tester.pump(const Duration(seconds: 2)); expect(result, isFalse); url.complete('https://example.test/oauth'); @@ -44,13 +43,9 @@ void main() { final launched = Completer(); result = null; - unawaited( - launchIntegrationOAuth( - () async => 'https://example.test/oauth', - openBrowser: (_) => launched.future, - deadline: const Duration(seconds: 1), - ).then((value) => result = value), - ); + unawaited(launchIntegrationOAuth(() async => 'https://example.test/oauth', + openBrowser: (_) => launched.future, deadline: const Duration(seconds: 1)) + .then((value) => result = value)); await tester.pump(); await tester.pump(const Duration(seconds: 2)); expect(result, isFalse); diff --git a/app/test/support/capture/capture_replay_world.dart b/app/test/support/capture/capture_replay_world.dart index 639b99feea2..79a86aea000 100644 --- a/app/test/support/capture/capture_replay_world.dart +++ b/app/test/support/capture/capture_replay_world.dart @@ -216,36 +216,35 @@ class ScriptedUploads { SyncUploadGate buildGate() { return SyncUploadGate( limiter: SyncRateLimiter.instance, - uploader: - ( - files, { - onUploadProgress, - conversationId, - captureEvidence, - recordingSessionId, - audioStartSeconds, - audioEndSeconds, - claimLiveCapture = false, - geolocation, - }) async { - attempts.add( - UploadAttempt( - at: clock.now(), - fileNames: files.map((f) => f.path.split(Platform.pathSeparator).last).toList(), - totalBytes: files.fold(0, (sum, f) => sum + f.lengthSync()), - conversationId: conversationId, - claimLiveCapture: claimLiveCapture, - captureEvidence: captureEvidence, - ), - ); - if (failAll) { - throw StateError('synthetic refused upload'); - } - if (script.isNotEmpty) { - return script.removeAt(0)() as UploadFilesResult; - } - return success(); - }, + uploader: ( + files, { + onUploadProgress, + conversationId, + captureEvidence, + recordingSessionId, + audioStartSeconds, + audioEndSeconds, + claimLiveCapture = false, + geolocation, + }) async { + attempts.add( + UploadAttempt( + at: clock.now(), + fileNames: files.map((f) => f.path.split(Platform.pathSeparator).last).toList(), + totalBytes: files.fold(0, (sum, f) => sum + f.lengthSync()), + conversationId: conversationId, + claimLiveCapture: claimLiveCapture, + captureEvidence: captureEvidence, + ), + ); + if (failAll) { + throw StateError('synthetic refused upload'); + } + if (script.isNotEmpty) { + return script.removeAt(0)() as UploadFilesResult; + } + return success(); + }, fairUseStatusLoader: () async => null, clock: clock.now, ); diff --git a/app/test/unit/local_wal_sync_test.dart b/app/test/unit/local_wal_sync_test.dart index 8681c0d8b62..46f100389e1 100644 --- a/app/test/unit/local_wal_sync_test.dart +++ b/app/test/unit/local_wal_sync_test.dart @@ -522,12 +522,12 @@ void main() { group('bounded retained capture WALs', () { Wal retained(int timerStart) => Wal( - timerStart: timerStart, - codec: BleAudioCodec.opus, - seconds: 60, - storage: WalStorage.disk, - status: WalStatus.miss, - ); + timerStart: timerStart, + codec: BleAudioCodec.opus, + seconds: 60, + storage: WalStorage.disk, + status: WalStatus.miss, + ); test('the count cap warns at the boundary but never deletes pending WALs', () async { sync.testWals = List.generate(maxRetainedCaptureWalCount, retained); @@ -576,14 +576,15 @@ void main() { required int syncedAt, WalStatus status = WalStatus.synced, WalStorage storage = WalStorage.disk, - }) => Wal( - timerStart: syncedAt - 1000, - codec: BleAudioCodec.opus, - seconds: 60, - storage: storage, - status: status, - syncedAt: syncedAt, - ); + }) => + Wal( + timerStart: syncedAt - 1000, + codec: BleAudioCodec.opus, + seconds: 60, + storage: storage, + status: status, + syncedAt: syncedAt, + ); test('removes synced disk copies past the retention window and keeps the rest', () async { SharedPreferencesUtil().autoRemoveSyncedCopies = true; @@ -1002,11 +1003,11 @@ void main() { } LocalWalSyncImpl build(SyncJobStatusResponse status) => LocalWalSyncImpl( - listener, - now: () => now, - jobStatusFetcher: (jobId) async => SyncJobFetch(SyncJobFetchOutcome.ok, status), - staleWalTelemetry: staleReports.add, - ); + listener, + now: () => now, + jobStatusFetcher: (jobId) async => SyncJobFetch(SyncJobFetchOutcome.ok, status), + staleWalTelemetry: staleReports.add, + ); SyncJobStatusResponse completed({ List newIds = const [], @@ -1014,15 +1015,16 @@ void main() { bool missingResult = false, int processedSegments = 0, String? reasonCode, - }) => SyncJobStatusResponse( - jobId: 'b4b06584', - status: 'completed', - processedSegments: processedSegments, - reasonCode: reasonCode, - result: missingResult - ? null - : SyncLocalFilesResponse(newConversationIds: newIds, updatedConversationIds: updatedIds), - ); + }) => + SyncJobStatusResponse( + jobId: 'b4b06584', + status: 'completed', + processedSegments: processedSegments, + reasonCode: reasonCode, + result: missingResult + ? null + : SyncLocalFilesResponse(newConversationIds: newIds, updatedConversationIds: updatedIds), + ); Future>> readEvents() async { final events = >[]; @@ -1255,44 +1257,43 @@ void main() { Future Function(List)? persist, SyncUploadGate? uploadGate, DateTime? now, - }) => LocalWalSyncImpl( - listener, - now: now == null ? null : () => now, - persistWals: - persist ?? - (wals) async { - if (persistedSink != null) { - persistedSink - ..clear() - ..addAll(wals); - } - }, - loadWals: () async => [], - uploadGate: uploadGate, - coverageTelemetry: telemetry?.add, - ); + }) => + LocalWalSyncImpl( + listener, + now: now == null ? null : () => now, + persistWals: persist ?? + (wals) async { + if (persistedSink != null) { + persistedSink + ..clear() + ..addAll(wals); + } + }, + loadWals: () async => [], + uploadGate: uploadGate, + coverageTelemetry: telemetry?.add, + ); SyncUploadGate succeedingGate({List>? attempted, UploadFilesResult Function()? outcome}) => SyncUploadGate( limiter: SyncRateLimiter.instance, - uploader: - ( - files, { - onUploadProgress, - conversationId, - captureEvidence, - recordingSessionId, - audioStartSeconds, - audioEndSeconds, - claimLiveCapture = false, - geolocation, - }) async { - attempted?.add(files.map((f) => f.path.split(Platform.pathSeparator).last).toList()); - return outcome?.call() ?? - UploadFilesResult.done( - SyncLocalFilesResponse(newConversationIds: ['c1'], updatedConversationIds: []), - ); - }, + uploader: ( + files, { + onUploadProgress, + conversationId, + captureEvidence, + recordingSessionId, + audioStartSeconds, + audioEndSeconds, + claimLiveCapture = false, + geolocation, + }) async { + attempted?.add(files.map((f) => f.path.split(Platform.pathSeparator).last).toList()); + return outcome?.call() ?? + UploadFilesResult.done( + SyncLocalFilesResponse(newConversationIds: ['c1'], updatedConversationIds: []), + ); + }, fairUseStatusLoader: () async => null, ); @@ -1541,9 +1542,12 @@ void main() { expect(File('${directory.path}/$name').existsSync(), isTrue); await local.syncAll(); - expect(attempted, [ - [name], - ], reason: 'the failed-closed copy uploads on the next pass'); + expect( + attempted, + [ + [name], + ], + reason: 'the failed-closed copy uploads on the next pass'); expect(covered.status, WalStatus.synced); expect(covered.keptForTranscriptRecovery, isFalse); }); @@ -2061,12 +2065,12 @@ void main() { group('walCoveredByTranscript', () { Wal wal(int timerStart, int seconds, {int totalFrames = 0}) => Wal( - timerStart: timerStart, - codec: BleAudioCodec.opus, - seconds: seconds, - totalFrames: totalFrames, - storage: WalStorage.disk, - ); + timerStart: timerStart, + codec: BleAudioCodec.opus, + seconds: seconds, + totalFrames: totalFrames, + storage: WalStorage.disk, + ); test('a span inside the WAL does not cover it', () { expect(walCoveredByTranscript(wal(100, 60), [(120, 129)], 100), isFalse); @@ -2152,16 +2156,19 @@ void main() { test('two minutes of conversation with natural pauses and long silences are covered', () { expect( - walCoveredByTranscript(wal(100, 120), [ - (102, 110), - (112, 116), - (121, 133), - (135, 141), - (149, 154), - (172, 184), - (187, 189), - (214, 220), - ], 100), + walCoveredByTranscript( + wal(100, 120), + [ + (102, 110), + (112, 116), + (121, 133), + (135, 141), + (149, 154), + (172, 184), + (187, 189), + (214, 220), + ], + 100), isTrue, ); }); @@ -2288,39 +2295,41 @@ void main() { int sampleRate = 16000, int channel = 1, BleAudioCodec codec = BleAudioCodec.opus, - }) => Wal( - timerStart: timerStart, - codec: codec, - seconds: 60, - totalFrames: totalFrames, - sampleRate: sampleRate, - channel: channel, - storage: WalStorage.disk, - status: WalStatus.miss, - conversationId: conversationId, - recordingSessionId: recordingSessionId, - filePath: filePath, - captureRoot: captureRoot, - sourceFrameStart: sourceFrameStart, - sourceClockEpoch: sourceClockEpoch, - ); + }) => + Wal( + timerStart: timerStart, + codec: codec, + seconds: 60, + totalFrames: totalFrames, + sampleRate: sampleRate, + channel: channel, + storage: WalStorage.disk, + status: WalStatus.miss, + conversationId: conversationId, + recordingSessionId: recordingSessionId, + filePath: filePath, + captureRoot: captureRoot, + sourceFrameStart: sourceFrameStart, + sourceClockEpoch: sourceClockEpoch, + ); Wal legacyWal( int timerStart, { String? filePath, String? conversationId = 'c1', String? recordingSessionId = 's1', - }) => Wal( - timerStart: timerStart, - codec: BleAudioCodec.opus, - seconds: 60, - totalFrames: 6000, - storage: WalStorage.disk, - status: WalStatus.miss, - conversationId: conversationId, - recordingSessionId: recordingSessionId, - filePath: filePath, - ); + }) => + Wal( + timerStart: timerStart, + codec: BleAudioCodec.opus, + seconds: 60, + totalFrames: 6000, + storage: WalStorage.disk, + status: WalStatus.miss, + conversationId: conversationId, + recordingSessionId: recordingSessionId, + filePath: filePath, + ); test('a claimable-newest mixed group uploads only the claimable files', () { final claimableNew = claimWal(100, filePath: 'claim_new.bin'); @@ -2332,10 +2341,16 @@ void main() { if (darkWrite) { expect(batch, [claimableNew, claimableOld]); - expect(captureEvidenceUploadHeader(batch, [File('claim_new.bin'), File('claim_old.bin')]), isNotNull); + expect( + captureEvidenceUploadHeader(batch, [File('claim_new.bin'), File('claim_old.bin')]), + isNotNull, + ); } else { expect(batch, [claimableNew, legacyMid, claimableOld, legacyOld]); - expect(captureEvidenceUploadHeader(batch, [for (final _ in batch) File('x.bin')]), isNull); + expect( + captureEvidenceUploadHeader(batch, [for (final _ in batch) File('x.bin')]), + isNull, + ); } }); @@ -2353,7 +2368,10 @@ void main() { final remainder = pending.where((wal) => !first.contains(wal)).toList(); final second = nextSyncUploadBatch(remainder, now); expect(second, [claimableMid, claimableOld]); - expect(captureEvidenceUploadHeader(second, [File('claim_mid.bin'), File('claim_old.bin')]), isNotNull); + expect( + captureEvidenceUploadHeader(second, [File('claim_mid.bin'), File('claim_old.bin')]), + isNotNull, + ); } else { expect(first, pending); } @@ -2383,7 +2401,10 @@ void main() { final file = File('ok.bin'); expect(captureEvidenceUploadHeader([], []), isNull); expect(captureEvidenceUploadHeader([claimWal(100, filePath: 'ok.bin')], []), isNull); - expect(captureEvidenceUploadHeader([claimWal(100, filePath: 'ok.bin')], [file, File('extra.bin')]), isNull); + expect( + captureEvidenceUploadHeader([claimWal(100, filePath: 'ok.bin')], [file, File('extra.bin')]), + isNull, + ); final invalid = [ claimWal(100, filePath: 'ok.bin', sampleRate: 0), claimWal(100, filePath: 'ok.bin', sampleRate: -16000), @@ -2449,7 +2470,10 @@ void main() { final batch = nextSyncUploadBatch([invalid, claimable], now); if (darkWrite) { expect(batch, [invalid], reason: 'invalid claim stays a single-file legacy batch'); - expect(captureEvidenceUploadHeader(batch, [File(invalid.filePath!)]), isNull); + expect( + captureEvidenceUploadHeader(batch, [File(invalid.filePath!)]), + isNull, + ); } else { expect(batch, [invalid, claimable]); } @@ -2507,7 +2531,10 @@ void main() { test('an oversized claim list still falls back to an unclaimed upload', () { if (!darkWrite) return; final wals = [for (var i = 0; i < 20; i++) claimWal(200 - i, filePath: '${'a' * 243}_cap_$i.bin')]; - expect(captureEvidenceUploadHeader(wals, [for (final wal in wals) File(wal.filePath!)]), isNull); + expect( + captureEvidenceUploadHeader(wals, [for (final wal in wals) File(wal.filePath!)]), + isNull, + ); }); }); @@ -2565,30 +2592,30 @@ void main() { } LocalWalSyncImpl buildSync() => LocalWalSyncImpl( - listener, - now: () => DateTime.fromMillisecondsSinceEpoch(2000000000 * 1000), - persistWals: (wals) async {}, - loadWals: () async => [], - uploadGate: SyncUploadGate( - limiter: SyncRateLimiter.instance, - uploader: - ( - files, { - onUploadProgress, - conversationId, - captureEvidence, - recordingSessionId, - audioStartSeconds, - audioEndSeconds, - claimLiveCapture = false, - geolocation, - }) async { - uploads.add((files.map((file) => file.uri.pathSegments.last).toList(), captureEvidence)); + listener, + now: () => DateTime.fromMillisecondsSinceEpoch(2000000000 * 1000), + persistWals: (wals) async {}, + loadWals: () async => [], + uploadGate: SyncUploadGate( + limiter: SyncRateLimiter.instance, + uploader: (files, + {onUploadProgress, + conversationId, + captureEvidence, + recordingSessionId, + audioStartSeconds, + audioEndSeconds, + claimLiveCapture = false, + geolocation}) async { + uploads.add(( + files.map((file) => file.uri.pathSegments.last).toList(), + captureEvidence, + )); return UploadFilesResult.queued('job-${uploads.length}'); }, - fairUseStatusLoader: () async => null, - ), - ); + fairUseStatusLoader: () async => null, + ), + ); test('a claimable-newest mixed drain sends claims, then the legacy batch goes unclaimed', () async { final local = buildSync(); @@ -2653,7 +2680,10 @@ void main() { if (darkWrite) { expect(uploads.length, 2, reason: 'attempted membership uses durable keys, not the colliding wal.id'); - expect(uploads.map((upload) => upload.$1.single), unorderedEquals(['split_a.bin', 'split_b.bin'])); + expect( + uploads.map((upload) => upload.$1.single), + unorderedEquals(['split_a.bin', 'split_b.bin']), + ); for (final upload in uploads) { final parsed = jsonDecode(upload.$2!) as Map; expect((parsed['files'] as List).single['name'], upload.$1.single); @@ -2744,11 +2774,10 @@ void main() { if (darkWrite) { expect(sync.testWals, hasLength(3)); - expect(sync.testWals.map((wal) => (wal.captureRoot, wal.sourceFrameStart, wal.totalFrames)).toList(), [ - (rootA, 0, 10), - (null, null, 10), - (rootB, 0, 10), - ]); + expect( + sync.testWals.map((wal) => (wal.captureRoot, wal.sourceFrameStart, wal.totalFrames)).toList(), + [(rootA, 0, 10), (null, null, 10), (rootB, 0, 10)], + ); for (final wal in sync.testWals) { final path = await Wal.getFilePath(wal.filePath!); expect( @@ -2776,11 +2805,10 @@ void main() { if (darkWrite) { expect(sync.testWals, hasLength(3)); - expect(sync.testWals.map((wal) => (wal.captureRoot, wal.sourceFrameStart, wal.totalFrames)).toList(), [ - (rootA, 0, 10), - (null, null, 10), - (rootB, 0, 10), - ]); + expect( + sync.testWals.map((wal) => (wal.captureRoot, wal.sourceFrameStart, wal.totalFrames)).toList(), + [(rootA, 0, 10), (null, null, 10), (rootB, 0, 10)], + ); expect(sync.testWals.every((wal) => wal.device == 'dev'), isTrue); } else { expect(sync.testWals, hasLength(1)); @@ -2792,12 +2820,12 @@ void main() { test('an ordinal gap inside one root splits the run', () async { SharedPreferencesUtil().unlimitedLocalStorageEnabled = true; WalFrame positioned(int position) => WalFrame( - payload: [position & 0xFF], - syncKey: FrameSyncKey.fromIndex(position), - captureRoot: rootA, - sourceFramePosition: position, - sourceClockEpoch: 0, - ); + payload: [position & 0xFF], + syncKey: FrameSyncKey.fromIndex(position), + captureRoot: rootA, + sourceFramePosition: position, + sourceClockEpoch: 0, + ); sync.testFrames.addAll([positioned(0), positioned(1), positioned(5), positioned(6)]); sync.testFrameSynced.addAll([false, false, false, false]); @@ -2805,10 +2833,10 @@ void main() { if (darkWrite) { expect(sync.testWals, hasLength(2)); - expect(sync.testWals.map((wal) => (wal.captureRoot, wal.sourceFrameStart, wal.totalFrames)).toList(), [ - (rootA, 0, 2), - (rootA, 5, 2), - ]); + expect( + sync.testWals.map((wal) => (wal.captureRoot, wal.sourceFrameStart, wal.totalFrames)).toList(), + [(rootA, 0, 2), (rootA, 5, 2)], + ); } else { expect(sync.testWals, hasLength(1)); expect(sync.testWals.single.captureRoot, isNull); @@ -2840,21 +2868,18 @@ void main() { loadWals: () async => [oldWal], uploadGate: SyncUploadGate( limiter: SyncRateLimiter.instance, - uploader: - ( - files, { - onUploadProgress, - conversationId, - captureEvidence, - recordingSessionId, - audioStartSeconds, - audioEndSeconds, - claimLiveCapture = false, - geolocation, - }) async { - uploads.add((files.map((file) => file.uri.pathSegments.last).toList(), captureEvidence)); - return UploadFilesResult.queued('job-${uploads.length}'); - }, + uploader: (files, + {onUploadProgress, + conversationId, + captureEvidence, + recordingSessionId, + audioStartSeconds, + audioEndSeconds, + claimLiveCapture = false, + geolocation}) async { + uploads.add((files.map((file) => file.uri.pathSegments.last).toList(), captureEvidence)); + return UploadFilesResult.queued('job-${uploads.length}'); + }, fairUseStatusLoader: () async => null, ), ); @@ -2871,11 +2896,8 @@ void main() { if (darkWrite) { expect(local.testWals, hasLength(2), reason: 'the non-contiguous run must not extend the rooted WAL'); - expect( - (oldWal.captureRoot, oldWal.sourceFrameStart, oldWal.totalFrames), - (rootA, 0, 60), - reason: 'the prior claim is never cleared', - ); + expect((oldWal.captureRoot, oldWal.sourceFrameStart, oldWal.totalFrames), (rootA, 0, 60), + reason: 'the prior claim is never cleared'); final fresh = local.testWals.firstWhere((wal) => wal != oldWal); expect((fresh.captureRoot, fresh.sourceFrameStart, fresh.totalFrames), (rootB, 0, 100)); expect({oldWal.filePath, fresh.filePath}, hasLength(2)); @@ -2912,20 +2934,17 @@ void main() { loadWals: () async => [oldWal], uploadGate: SyncUploadGate( limiter: SyncRateLimiter.instance, - uploader: - ( - files, { - onUploadProgress, - conversationId, - captureEvidence, - recordingSessionId, - audioStartSeconds, - audioEndSeconds, - claimLiveCapture = false, - geolocation, - }) async { - return UploadFilesResult.queued('job-1'); - }, + uploader: (files, + {onUploadProgress, + conversationId, + captureEvidence, + recordingSessionId, + audioStartSeconds, + audioEndSeconds, + claimLiveCapture = false, + geolocation}) async { + return UploadFilesResult.queued('job-1'); + }, fairUseStatusLoader: () async => null, ), ); @@ -2968,20 +2987,17 @@ void main() { loadWals: () async => [], uploadGate: SyncUploadGate( limiter: SyncRateLimiter.instance, - uploader: - ( - files, { - onUploadProgress, - conversationId, - captureEvidence, - recordingSessionId, - audioStartSeconds, - audioEndSeconds, - claimLiveCapture = false, - geolocation, - }) async { - return UploadFilesResult.queued('job-1'); - }, + uploader: (files, + {onUploadProgress, + conversationId, + captureEvidence, + recordingSessionId, + audioStartSeconds, + audioEndSeconds, + claimLiveCapture = false, + geolocation}) async { + return UploadFilesResult.queued('job-1'); + }, fairUseStatusLoader: () async => null, ), ); @@ -3007,44 +3023,41 @@ void main() { }); LocalWalSyncImpl pinnedSync(Wal prior) => LocalWalSyncImpl( - _MockListener(), - now: () => DateTime.fromMillisecondsSinceEpoch((1700000000 + 16) * 1000), - persistWals: (wals) async {}, - loadWals: () async => [prior], - uploadGate: SyncUploadGate( - limiter: SyncRateLimiter.instance, - uploader: - ( - files, { - onUploadProgress, - conversationId, - captureEvidence, - recordingSessionId, - audioStartSeconds, - audioEndSeconds, - claimLiveCapture = false, - geolocation, - }) async { + _MockListener(), + now: () => DateTime.fromMillisecondsSinceEpoch((1700000000 + 16) * 1000), + persistWals: (wals) async {}, + loadWals: () async => [prior], + uploadGate: SyncUploadGate( + limiter: SyncRateLimiter.instance, + uploader: (files, + {onUploadProgress, + conversationId, + captureEvidence, + recordingSessionId, + audioStartSeconds, + audioEndSeconds, + claimLiveCapture = false, + geolocation}) async { return UploadFilesResult.queued('job-1'); }, - fairUseStatusLoader: () async => null, - ), - ); + fairUseStatusLoader: () async => null, + ), + ); Wal rootedMemWal(int syncedOffset, {WalStatus status = WalStatus.miss}) => Wal( - timerStart: 1700000000, - codec: BleAudioCodec.opus, - seconds: 1, - totalFrames: 60, - storage: WalStorage.mem, - status: status, - device: 'omi', - data: List.generate(60, (_) => [1]), - syncedFrameOffset: syncedOffset, - captureRoot: rootA, - sourceFrameStart: 0, - sourceClockEpoch: 0, - ); + timerStart: 1700000000, + codec: BleAudioCodec.opus, + seconds: 1, + totalFrames: 60, + storage: WalStorage.mem, + status: status, + device: 'omi', + data: List.generate(60, (_) => [1]), + syncedFrameOffset: syncedOffset, + captureRoot: rootA, + sourceFrameStart: 0, + sourceClockEpoch: 0, + ); Future extendOnce(Wal prior, {required int syncedCount}) async { final local = pinnedSync(prior); @@ -3142,20 +3155,17 @@ void main() { loadWals: () async => [walA, walB], uploadGate: SyncUploadGate( limiter: SyncRateLimiter.instance, - uploader: - ( - files, { - onUploadProgress, - conversationId, - captureEvidence, - recordingSessionId, - audioStartSeconds, - audioEndSeconds, - claimLiveCapture = false, - geolocation, - }) async { - return UploadFilesResult.queued('job-1'); - }, + uploader: (files, + {onUploadProgress, + conversationId, + captureEvidence, + recordingSessionId, + audioStartSeconds, + audioEndSeconds, + claimLiveCapture = false, + geolocation}) async { + return UploadFilesResult.queued('job-1'); + }, fairUseStatusLoader: () async => null, ), ); @@ -3171,11 +3181,8 @@ void main() { await local.stop(); if (darkWrite) { - expect( - local.testWals, - hasLength(2), - reason: 'the contiguous sibling extends; the mismatching one is untouched', - ); + expect(local.testWals, hasLength(2), + reason: 'the contiguous sibling extends; the mismatching one is untouched'); expect((walA.captureRoot, walA.sourceFrameStart, walA.totalFrames), (rootA, 0, 60)); expect((walB.captureRoot, walB.sourceFrameStart, walB.totalFrames), (rootB, 200, 160)); } else { @@ -3196,25 +3203,18 @@ void main() { loadWals: () async => [], uploadGate: SyncUploadGate( limiter: SyncRateLimiter.instance, - uploader: - ( - files, { - onUploadProgress, - conversationId, - captureEvidence, - recordingSessionId, - audioStartSeconds, - audioEndSeconds, - claimLiveCapture = false, - geolocation, - }) async { - bounds.add(( - files.map((file) => file.uri.pathSegments.last).toList(), - audioStartSeconds, - audioEndSeconds, - )); - return UploadFilesResult.queued('job-${bounds.length}'); - }, + uploader: (files, + {onUploadProgress, + conversationId, + captureEvidence, + recordingSessionId, + audioStartSeconds, + audioEndSeconds, + claimLiveCapture = false, + geolocation}) async { + bounds.add((files.map((file) => file.uri.pathSegments.last).toList(), audioStartSeconds, audioEndSeconds)); + return UploadFilesResult.queued('job-${bounds.length}'); + }, fairUseStatusLoader: () async => null, ), ); @@ -3222,12 +3222,12 @@ void main() { await local.walReady; WalFrame positioned(int position, String root) => WalFrame( - payload: [position & 0xFF], - syncKey: FrameSyncKey.fromIndex(position), - captureRoot: root, - sourceFramePosition: position, - sourceClockEpoch: 0, - ); + payload: [position & 0xFF], + syncKey: FrameSyncKey.fromIndex(position), + captureRoot: root, + sourceFramePosition: position, + sourceClockEpoch: 0, + ); local.testFrames.addAll([for (var i = 0; i < 100; i++) positioned(i, i < 60 ? rootA : rootB)]); local.testFrameSynced.addAll(List.filled(100, false)); @@ -3245,11 +3245,8 @@ void main() { expect(first.timerStart, timerStart); expect(second.timerStart, timerStart); expect(first.filePath, endsWith('_$timerStart.bin')); - expect( - second.filePath, - endsWith('_$timerStart.6.bin'), - reason: 'the second run keeps its 0.6s offset in the persisted name', - ); + expect(second.filePath, endsWith('_$timerStart.6.bin'), + reason: 'the second run keeps its 0.6s offset in the persisted name'); final reloaded = Wal.fromJson(second.toJson()); expect(reloaded.filePath, second.filePath, reason: 'the fractional name survives serialization'); @@ -3258,11 +3255,8 @@ void main() { expect(bounds, hasLength(1)); expect(bounds.single.$1, unorderedEquals([first.filePath, second.filePath])); expect(bounds.single.$2, timerStart.toDouble()); - expect( - bounds.single.$3, - (timerStart + 1).toDouble(), - reason: 'run durations sum through the fractional start, not integer seconds', - ); + expect(bounds.single.$3, (timerStart + 1).toDouble(), + reason: 'run durations sum through the fractional start, not integer seconds'); final solo = LocalWalSyncImpl( _MockListener(), @@ -3271,25 +3265,18 @@ void main() { loadWals: () async => [], uploadGate: SyncUploadGate( limiter: SyncRateLimiter.instance, - uploader: - ( - files, { - onUploadProgress, - conversationId, - captureEvidence, - recordingSessionId, - audioStartSeconds, - audioEndSeconds, - claimLiveCapture = false, - geolocation, - }) async { - bounds.add(( - files.map((file) => file.uri.pathSegments.last).toList(), - audioStartSeconds, - audioEndSeconds, - )); - return UploadFilesResult.queued('job-${bounds.length}'); - }, + uploader: (files, + {onUploadProgress, + conversationId, + captureEvidence, + recordingSessionId, + audioStartSeconds, + audioEndSeconds, + claimLiveCapture = false, + geolocation}) async { + bounds.add((files.map((file) => file.uri.pathSegments.last).toList(), audioStartSeconds, audioEndSeconds)); + return UploadFilesResult.queued('job-${bounds.length}'); + }, fairUseStatusLoader: () async => null, ), ); @@ -3313,21 +3300,18 @@ void main() { loadWals: () async => [], uploadGate: SyncUploadGate( limiter: SyncRateLimiter.instance, - uploader: - ( - files, { - onUploadProgress, - conversationId, - captureEvidence, - recordingSessionId, - audioStartSeconds, - audioEndSeconds, - claimLiveCapture = false, - geolocation, - }) async { - uploaded.add((files.map((file) => file.uri.pathSegments.last).toList(), captureEvidence)); - return UploadFilesResult.queued('job-1'); - }, + uploader: (files, + {onUploadProgress, + conversationId, + captureEvidence, + recordingSessionId, + audioStartSeconds, + audioEndSeconds, + claimLiveCapture = false, + geolocation}) async { + uploaded.add((files.map((file) => file.uri.pathSegments.last).toList(), captureEvidence)); + return UploadFilesResult.queued('job-1'); + }, fairUseStatusLoader: () async => null, ), ); @@ -3381,11 +3365,8 @@ void main() { expect(second.timerStart, t + 1, reason: 'floor(t + 1.1)'); expect(first.filePath, endsWith('_$t.4.bin')); expect(second.filePath, endsWith('_${t + 1}.1.bin')); - expect( - Wal.fromJson(second.toJson()).filePath, - second.filePath, - reason: 'the fractional name survives serialization', - ); + expect(Wal.fromJson(second.toJson()).filePath, second.filePath, + reason: 'the fractional name survives serialization'); } void expectSubsecondBounds(List wals, List<(List, double?, double?)> bounds) { @@ -3405,39 +3386,33 @@ void main() { loadWals: () async => [], uploadGate: SyncUploadGate( limiter: SyncRateLimiter.instance, - uploader: - ( - files, { - onUploadProgress, - conversationId, - captureEvidence, - recordingSessionId, - audioStartSeconds, - audioEndSeconds, - claimLiveCapture = false, - geolocation, - }) async { - bounds.add(( - files.map((file) => file.uri.pathSegments.last).toList(), - audioStartSeconds, - audioEndSeconds, - )); - return UploadFilesResult.queued('job-${bounds.length}'); - }, + uploader: (files, + {onUploadProgress, + conversationId, + captureEvidence, + recordingSessionId, + audioStartSeconds, + audioEndSeconds, + claimLiveCapture = false, + geolocation}) async { + bounds + .add((files.map((file) => file.uri.pathSegments.last).toList(), audioStartSeconds, audioEndSeconds)); + return UploadFilesResult.queued('job-${bounds.length}'); + }, fairUseStatusLoader: () async => null, ), ); List splitFrames({FrameSyncKey Function(int index)? keyOf}) => [ - for (var i = 0; i < 80; i++) - WalFrame( - payload: [i & 0xFF], - syncKey: keyOf?.call(i) ?? FrameSyncKey.fromIndex(i), - captureRoot: i < 70 ? rootA : rootB, - sourceFramePosition: i, - sourceClockEpoch: 0, - ), - ]; + for (var i = 0; i < 80; i++) + WalFrame( + payload: [i & 0xFF], + syncKey: keyOf?.call(i) ?? FrameSyncKey.fromIndex(i), + captureRoot: i < 70 ? rootA : rootB, + sourceFramePosition: i, + sourceClockEpoch: 0, + ), + ]; test('a precise session end keeps subsecond run starts in names and bounds', () async { const t = 1735689600; diff --git a/app/test/unit/sync_manifest_claim_drain_test.dart b/app/test/unit/sync_manifest_claim_drain_test.dart index a192614dc61..ced2ca57a43 100644 --- a/app/test/unit/sync_manifest_claim_drain_test.dart +++ b/app/test/unit/sync_manifest_claim_drain_test.dart @@ -52,22 +52,19 @@ void main() { _Listener(), uploadGate: SyncUploadGate( limiter: SyncRateLimiter.instance, - uploader: - ( - files, { - onUploadProgress, - conversationId, - captureEvidence, - recordingSessionId, - audioStartSeconds, - audioEndSeconds, - claimLiveCapture = false, - geolocation, - }) async { - claims.add(claimLiveCapture); - batchFileCounts.add(files.length); - return UploadFilesResult.queued('job-${claims.length}'); - }, + uploader: (files, + {onUploadProgress, + conversationId, + captureEvidence, + recordingSessionId, + audioStartSeconds, + audioEndSeconds, + claimLiveCapture = false, + geolocation}) async { + claims.add(claimLiveCapture); + batchFileCounts.add(files.length); + return UploadFilesResult.queued('job-${claims.length}'); + }, fairUseStatusLoader: () async => {'stage': 'none'}, ), ); @@ -163,12 +160,10 @@ void main() { } root = parent; } - final spec = - jsonDecode(File('${root.path}/docs/api-reference/app-client-openapi.json').readAsStringSync()) - as Map; - final files = - (((spec['components'] as Map)['schemas'] as Map)['SyncCaptureManifestRequest'] as Map)['properties']['files'] - as Map; + final spec = jsonDecode(File('${root.path}/docs/api-reference/app-client-openapi.json').readAsStringSync()) + as Map; + final files = (((spec['components'] as Map)['schemas'] as Map)['SyncCaptureManifestRequest'] as Map)['properties'] + ['files'] as Map; expect(syncUploadBatchMaxFiles, lessThanOrEqualTo(files['maxItems'] as int)); }); diff --git a/app/test/utils/analytics/guided_intro_analytics_test.dart b/app/test/utils/analytics/guided_intro_analytics_test.dart index 4a1106f6654..886e82af37a 100644 --- a/app/test/utils/analytics/guided_intro_analytics_test.dart +++ b/app/test/utils/analytics/guided_intro_analytics_test.dart @@ -38,16 +38,10 @@ void main() { await AnalyticsManager.flushPending(force: true); final recording = adapter.events.where((e) => e.eventName == 'Guided Intro Recording Failed').toList(); final transcription = adapter.events.where((e) => e.eventName == 'Guided Intro Prompt Completed').toList(); - expect(recording.map((e) => e.properties['failure_class']), [ - 'permission_denied', - 'permission_permanently_denied', - 'engine_start_failed', - ]); - expect(transcription.map((e) => e.properties['failure_class']), [ - 'transcribe_timeout', - 'transcribe_http_error', - 'transcribe_empty', - ]); + expect(recording.map((e) => e.properties['failure_class']), + ['permission_denied', 'permission_permanently_denied', 'engine_start_failed']); + expect(transcription.map((e) => e.properties['failure_class']), + ['transcribe_timeout', 'transcribe_http_error', 'transcribe_empty']); for (final event in [...recording, ...transcription]) { expect(event.properties['transcription_path'], 'cloud'); expect(event.properties.keys, isNot(contains('transcript'))); @@ -145,16 +139,12 @@ void main() { } await AnalyticsManager.flushPending(force: true); expect( - adapter.events - .where((e) => e.eventName == 'Guided Intro Payoff Shown') - .map((e) => e.properties['memories_count']), - [2, 2, 2], - ); - expect(adapter.events.where((e) => e.eventName == 'fallback_triggered').map((e) => e.properties['reason']), [ - 'timeout', - 'offline', - 'error', - ]); + adapter.events + .where((e) => e.eventName == 'Guided Intro Payoff Shown') + .map((e) => e.properties['memories_count']), + [2, 2, 2]); + expect(adapter.events.where((e) => e.eventName == 'fallback_triggered').map((e) => e.properties['reason']), + ['timeout', 'offline', 'error']); }); test('guided intro events carry bounded funnel properties without content', () async { final adapter = _FakeAnalyticsAdapter(); diff --git a/app/test/utils/analytics/onboarding_funnel_analytics_test.dart b/app/test/utils/analytics/onboarding_funnel_analytics_test.dart index 29aac896553..42411546071 100644 --- a/app/test/utils/analytics/onboarding_funnel_analytics_test.dart +++ b/app/test/utils/analytics/onboarding_funnel_analytics_test.dart @@ -48,7 +48,11 @@ void main() { androidSdk: 30, iosVersion: 0, ); - analytics.devicePairingBlocked(reason: DevicePairingBlockedReason.bluetoothOff, androidSdk: -1, iosVersion: -2); + analytics.devicePairingBlocked( + reason: DevicePairingBlockedReason.bluetoothOff, + androidSdk: -1, + iosVersion: -2, + ); analytics.deviceScanEmpty(afterSeconds: 10); analytics.deviceConnectFailed(DeviceConnectFailedErrorClass.initializationFailed); analytics.recordingProducedNoConversation( @@ -75,7 +79,11 @@ void main() { permanentlyDenied: false, servicesDisabled: true, ); - analytics.devicePairingBlocked(reason: DevicePairingBlockedReason.noAdapter, androidSdk: 34, iosVersion: 0); + analytics.devicePairingBlocked( + reason: DevicePairingBlockedReason.noAdapter, + androidSdk: 34, + iosVersion: 0, + ); analytics.devicePairingBlocked( reason: DevicePairingBlockedReason.scannerUnavailable, androidSdk: 34, diff --git a/app/test/utils/auth/sign_in_failure_test.dart b/app/test/utils/auth/sign_in_failure_test.dart index cce579fc44c..06fcb091c37 100644 --- a/app/test/utils/auth/sign_in_failure_test.dart +++ b/app/test/utils/auth/sign_in_failure_test.dart @@ -32,7 +32,10 @@ void main() { classifySignInFailure(FirebaseAuthException(code: 'network-request-failed', message: 'secret')), SignInFailedErrorClass.network, ); - expect(classifySignInFailure(FirebaseAuthException(code: 'invalid-credential')), SignInFailedErrorClass.credential); + expect( + classifySignInFailure(FirebaseAuthException(code: 'invalid-credential')), + SignInFailedErrorClass.credential, + ); expect( classifySignInFailure(FirebaseAuthException(code: 'operation-not-allowed')), SignInFailedErrorClass.configuration, diff --git a/app/test/widgets/apps_category_section_large_text_test.dart b/app/test/widgets/apps_category_section_large_text_test.dart index bc72ecb067e..dc037f1a240 100644 --- a/app/test/widgets/apps_category_section_large_text_test.dart +++ b/app/test/widgets/apps_category_section_large_text_test.dart @@ -37,7 +37,11 @@ App _testApp({required String id, required String name, required String category ); } -Widget _buildHarness({required Widget child, required double textScale, required double width}) { +Widget _buildHarness({ + required Widget child, + required double textScale, + required double width, +}) { return MultiProvider( providers: [ ChangeNotifierProvider.value(value: AppProvider()), @@ -52,8 +56,15 @@ Widget _buildHarness({required Widget child, required double textScale, required ], supportedLocales: AppLocalizations.supportedLocales, home: MediaQuery( - data: MediaQueryData(size: Size(width, 844), textScaler: TextScaler.linear(textScale)), - child: Scaffold(body: SingleChildScrollView(child: child)), + data: MediaQueryData( + size: Size(width, 844), + textScaler: TextScaler.linear(textScale), + ), + child: Scaffold( + body: SingleChildScrollView( + child: child, + ), + ), ), ), ); @@ -85,12 +96,20 @@ void main() { _buildHarness( textScale: scale, width: testWidth, - child: CategorySection(categoryName: 'Productivity', apps: testApps, onViewAll: () {}), + child: CategorySection( + categoryName: 'Productivity', + apps: testApps, + onViewAll: () {}, + ), ), ); await tester.pump(); - expect(tester.takeException(), isNull, reason: 'CategorySection should not overflow at scale on width '); + expect( + tester.takeException(), + isNull, + reason: 'CategorySection should not overflow at scale on width ', + ); expect(find.text('Productivity'), findsOneWidget); expect(find.text('Productivity Booster Pro'), findsOneWidget); } @@ -101,7 +120,11 @@ void main() { _buildHarness( textScale: 1.3, width: 390.0, - child: CategorySection(categoryName: 'Productivity', apps: testApps, onViewAll: () {}), + child: CategorySection( + categoryName: 'Productivity', + apps: testApps, + onViewAll: () {}, + ), ), ); await tester.pump(); diff --git a/app/test/widgets/capturing_finish_swipe_back_test.dart b/app/test/widgets/capturing_finish_swipe_back_test.dart index 4ef609b67b1..3781dd7695a 100644 --- a/app/test/widgets/capturing_finish_swipe_back_test.dart +++ b/app/test/widgets/capturing_finish_swipe_back_test.dart @@ -75,17 +75,17 @@ class _Syncs { /// A hermetic capture whose finish resolves when the test says so. class _ControlledCapture extends CaptureProvider { _ControlledCapture(this._finish) - : super( - walService: _WalService(), - connectivity: CaptureConnectivityBoundary( - initiallyConnected: true, - changes: const Stream.empty(), - isConnected: () => true, - ), - bleListeners: _NoopBle(), - inProgressConversationLoader: () async {}, - localSegmentStore: LocalSegmentStore.disabled(), - ); + : super( + walService: _WalService(), + connectivity: CaptureConnectivityBoundary( + initiallyConnected: true, + changes: const Stream.empty(), + isConnected: () => true, + ), + bleListeners: _NoopBle(), + inProgressConversationLoader: () async {}, + localSegmentStore: LocalSegmentStore.disabled(), + ); final Future _finish; diff --git a/app/test/widgets/guided_voice_render_test.dart b/app/test/widgets/guided_voice_render_test.dart index e74e0725698..2cff0208b56 100644 --- a/app/test/widgets/guided_voice_render_test.dart +++ b/app/test/widgets/guided_voice_render_test.dart @@ -64,7 +64,7 @@ void main() { 'review', 'payoff-chat', 'payoff-fallback', - 'paused', + 'paused' ]) { testWidgets('${platform.name} $state at 393x852', (tester) async { debugDefaultTargetPlatformOverride = platform; @@ -124,33 +124,26 @@ void main() { final family = platform == TargetPlatform.iOS ? 'SF Pro Display' : 'Roboto'; final boundary = GlobalKey(); var continued = false; - await tester.pumpWidget( - MaterialApp( - theme: theme.copyWith( + await tester.pumpWidget(MaterialApp( + theme: theme.copyWith( textTheme: theme.textTheme.apply(fontFamily: family), - primaryTextTheme: theme.primaryTextTheme.apply(fontFamily: family), - ), - localizationsDelegates: const [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: AppLocalizations.supportedLocales, - home: RepaintBoundary( + primaryTextTheme: theme.primaryTextTheme.apply(fontFamily: family)), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate + ], + supportedLocales: AppLocalizations.supportedLocales, + home: RepaintBoundary( key: boundary, - child: Scaffold( - body: SpeechProfileWidget(controller: flow, goNext: () => continued = true, onSkip: () {}), - ), - ), - ), - ); + child: + Scaffold(body: SpeechProfileWidget(controller: flow, goNext: () => continued = true, onSkip: () {}))), + )); await tester.pump(const Duration(milliseconds: 300)); if (state == 'typing') { await tester.enterText( - find.byKey(const Key('introduction_typed_0')), - 'I build bicycles and want to finish my next design.', - ); + find.byKey(const Key('introduction_typed_0')), 'I build bicycles and want to finish my next design.'); await tester.pump(const Duration(milliseconds: 300)); } expect(tester.takeException(), isNull); @@ -174,14 +167,13 @@ void main() { 'typing', 'review', 'payoff-chat', - 'payoff-fallback', + 'payoff-fallback' ]; - final ordinal = - (state == 'paused' - ? (platform == TargetPlatform.iOS ? 19 : 20) - : states.indexOf(state) + 1 + (platform == TargetPlatform.iOS ? 0 : 9)) - .toString() - .padLeft(3, '0'); + final ordinal = (state == 'paused' + ? (platform == TargetPlatform.iOS ? 19 : 20) + : states.indexOf(state) + 1 + (platform == TargetPlatform.iOS ? 0 : 9)) + .toString() + .padLeft(3, '0'); final platformSlug = platform == TargetPlatform.iOS ? 'mobile-ios' : 'mobile-android'; final file = File('$output/$ordinal-$platformSlug-$state.png'); await file.parent.create(recursive: true); diff --git a/app/test/widgets/memory_graph_fit_to_bounds_test.dart b/app/test/widgets/memory_graph_fit_to_bounds_test.dart index 4ad0c6e5b42..85d4d8691a9 100644 --- a/app/test/widgets/memory_graph_fit_to_bounds_test.dart +++ b/app/test/widgets/memory_graph_fit_to_bounds_test.dart @@ -23,7 +23,7 @@ Future> _sampleFiveNodeGraph() async => { {'source_id': '1', 'target_id': '3', 'label': 'works_at'}, {'source_id': '1', 'target_id': '4', 'label': 'knows'}, {'source_id': '1', 'target_id': '5', 'label': 'goes_to'}, - ], + ] }; Widget _app(Widget home) { @@ -79,7 +79,15 @@ void main() { }); testWidgets('OnboardingKnowledgeGraphStep configures MemoryGraphPage with fitToBounds', (tester) async { - await tester.pumpWidget(_app(Scaffold(body: OnboardingKnowledgeGraphStep(onContinue: () {})))); + await tester.pumpWidget( + _app( + Scaffold( + body: OnboardingKnowledgeGraphStep( + onContinue: () {}, + ), + ), + ), + ); final graphPage = tester.widget(find.byType(MemoryGraphPage)); expect(graphPage.fitToBounds, isTrue); diff --git a/app/test/widgets/onboarding_resume_and_voice_retention_test.dart b/app/test/widgets/onboarding_resume_and_voice_retention_test.dart index 705880b7a80..17d3081fbd7 100644 --- a/app/test/widgets/onboarding_resume_and_voice_retention_test.dart +++ b/app/test/widgets/onboarding_resume_and_voice_retention_test.dart @@ -3,12 +3,8 @@ import 'package:omi/pages/onboarding/guided_voice_controller.dart'; import 'package:omi/pages/onboarding/wrapper.dart'; import '../providers/guided_voice_controller_test.dart' show FakeVoiceIO; -Future _answerAll( - FakeVoiceIO io, - GuidedVoiceController flow, { - required String goal, - required String memory, -}) async { +Future _answerAll(FakeVoiceIO io, GuidedVoiceController flow, + {required String goal, required String memory}) async { await flow.start(); for (var i = 0; i < 4; i++) { io.text = i == 3 ? goal : '$memory $i'; diff --git a/app/test/widgets/process_now_conversations_tab_test.dart b/app/test/widgets/process_now_conversations_tab_test.dart index 315b4a6946c..fd22688c12f 100644 --- a/app/test/widgets/process_now_conversations_tab_test.dart +++ b/app/test/widgets/process_now_conversations_tab_test.dart @@ -68,18 +68,18 @@ class _Syncs { class _TrackingCaptureProvider extends CaptureProvider { _TrackingCaptureProvider() - : super( - walService: _Wal(), - processInProgressConversation: () => Completer().future, - connectivity: CaptureConnectivityBoundary( - initiallyConnected: true, - changes: const Stream.empty(), - isConnected: () => true, - ), - bleListeners: _NoopBle(), - inProgressConversationLoader: () async {}, - localSegmentStore: LocalSegmentStore.disabled(), - ); + : super( + walService: _Wal(), + processInProgressConversation: () => Completer().future, + connectivity: CaptureConnectivityBoundary( + initiallyConnected: true, + changes: const Stream.empty(), + isConnected: () => true, + ), + bleListeners: _NoopBle(), + inProgressConversationLoader: () async {}, + localSegmentStore: LocalSegmentStore.disabled(), + ); var forceProcessingCalls = 0; @@ -167,8 +167,10 @@ void main() { value: home, child: MaterialApp( home: Builder( - builder: (context) => - TextButton(onPressed: () => switchHomeToConversationsTab(context), child: const Text('go')), + builder: (context) => TextButton( + onPressed: () => switchHomeToConversationsTab(context), + child: const Text('go'), + ), ), ), ), @@ -255,9 +257,9 @@ Future<_Harness> _pumpCapturingPage(WidgetTester tester, {bool heard = true}) as body: Center( child: TextButton( onPressed: () { - Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const ConversationCapturingPage())); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ConversationCapturingPage()), + ); }, child: const Text('open-capture'), ), diff --git a/app/test/widgets/settings/ai_app_generator_large_text_test.dart b/app/test/widgets/settings/ai_app_generator_large_text_test.dart index 150c7d76e53..56127326b75 100644 --- a/app/test/widgets/settings/ai_app_generator_large_text_test.dart +++ b/app/test/widgets/settings/ai_app_generator_large_text_test.dart @@ -46,7 +46,11 @@ final class _TestEnvFields implements EnvFields { bool? get useWebAuth => false; } -Widget _buildHarness({required Widget child, required double textScale, required double width}) { +Widget _buildHarness({ + required Widget child, + required double textScale, + required double width, +}) { return ChangeNotifierProvider.value( value: AppProvider(), child: MaterialApp( @@ -58,7 +62,10 @@ Widget _buildHarness({required Widget child, required double textScale, required ], supportedLocales: AppLocalizations.supportedLocales, home: MediaQuery( - data: MediaQueryData(size: Size(width, 844), textScaler: TextScaler.linear(textScale)), + data: MediaQueryData( + size: Size(width, 844), + textScaler: TextScaler.linear(textScale), + ), child: child, ), ), @@ -82,7 +89,13 @@ void main() { const testWidth = 390.0; for (final scale in textScales) { - await tester.pumpWidget(_buildHarness(textScale: scale, width: testWidth, child: const AiAppGeneratorPage())); + await tester.pumpWidget( + _buildHarness( + textScale: scale, + width: testWidth, + child: const AiAppGeneratorPage(), + ), + ); await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); diff --git a/app/test/widgets/settings/android_hanging_flows_test.dart b/app/test/widgets/settings/android_hanging_flows_test.dart index daf23a409e6..bc856ce7e96 100644 --- a/app/test/widgets/settings/android_hanging_flows_test.dart +++ b/app/test/widgets/settings/android_hanging_flows_test.dart @@ -40,8 +40,7 @@ void main() { final loader = FontLoader(family); for (final weight in ['Regular', 'Medium', 'Bold']) { loader.addFont( - Future.value(ByteData.sublistView(File('${fonts.path}/Roboto-$weight.ttf').readAsBytesSync())), - ); + Future.value(ByteData.sublistView(File('${fonts.path}/Roboto-$weight.ttf').readAsBytesSync()))); } await loader.load(); } @@ -67,18 +66,16 @@ void main() { final previousPalette = OmiColors.active; OmiColors.active = OmiPalette.dark; addTearDown(() => OmiColors.active = previousPalette); - await tester.pumpWidget( - RepaintBoundary( - key: const Key('hanging-evidence'), - child: MaterialApp( - debugShowCheckedModeBanner: false, - theme: buildOmiTheme().copyWith(platform: TargetPlatform.android), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: const [Locale('en')], - home: page, - ), + await tester.pumpWidget(RepaintBoundary( + key: const Key('hanging-evidence'), + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: buildOmiTheme().copyWith(platform: TargetPlatform.android), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: const [Locale('en')], + home: page, ), - ); + )); await tester.pumpAndSettle(); } @@ -123,12 +120,9 @@ void main() { expect(provider.hasLoadError, isTrue); expect(find.text('Try Again'), findsNWidgets(4)); await evidence(tester, '003-mobile-android-integration-retry'); - reply = TaskIntegrationsResponse( - integrations: { - 'todoist': {'app_key': 'todoist', 'connected': true}, - }, - defaultApp: 'todoist', - ); + reply = TaskIntegrationsResponse(integrations: { + 'todoist': {'app_key': 'todoist', 'connected': true} + }, defaultApp: 'todoist'); await tester.tap(find.text('Todoist')); await tester.pumpAndSettle(); expect(provider.hasLoadError, isFalse); @@ -141,12 +135,11 @@ void main() { final defaults = []; final provider = TaskIntegrationProvider( fetchStatus: () async => TaskIntegrationsResponse( - integrations: connected - ? { - 'todoist': {'app_key': 'todoist', 'connected': true}, - } - : {}, - ), + integrations: connected + ? { + 'todoist': {'app_key': 'todoist', 'connected': true} + } + : {}), setDefaultTaskIntegrationFn: (key) async { defaults.add(key); return true; @@ -154,13 +147,8 @@ void main() { ); addTearDown(provider.dispose); provider.clearUserData(); - await pumpPage( - tester, - ChangeNotifierProvider.value( - value: provider, - child: TaskIntegrationsPage(authenticate: (app) async => true), - ), - ); + await pumpPage(tester, + ChangeNotifierProvider.value(value: provider, child: TaskIntegrationsPage(authenticate: (app) async => true))); await tester.tap(find.text('Todoist')); await tester.pumpAndSettle(); await tester.tap(find.text('Continue')); diff --git a/app/test/widgets/settings/settings_search_header_stability_test.dart b/app/test/widgets/settings/settings_search_header_stability_test.dart index 910993c77de..e6977f2666f 100644 --- a/app/test/widgets/settings/settings_search_header_stability_test.dart +++ b/app/test/widgets/settings/settings_search_header_stability_test.dart @@ -32,7 +32,9 @@ Widget _buildHarness() { GlobalCupertinoLocalizations.delegate, ], supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold(body: SettingsDrawer()), + home: Scaffold( + body: SettingsDrawer(), + ), ), ); } diff --git a/app/test/widgets/speech_profile_onboarding_test.dart b/app/test/widgets/speech_profile_onboarding_test.dart index 956a9950da3..499dde33216 100644 --- a/app/test/widgets/speech_profile_onboarding_test.dart +++ b/app/test/widgets/speech_profile_onboarding_test.dart @@ -142,9 +142,8 @@ void main() { expect(flow.payoffSource, 'fallback'); }); - testWidgets('failure offers same-clip retry without claiming Recording Paused; confirmation can skip', ( - tester, - ) async { + testWidgets('failure offers same-clip retry without claiming Recording Paused; confirmation can skip', + (tester) async { final io = ExperienceIO()..transcriptionFails = true; final flow = GuidedVoiceController(io); addTearDown(flow.dispose); From 445c5f7f494f4cd41ae9c860bcc2e37449ed61b6 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 13 Oct 2026 22:35:40 +0700 Subject: [PATCH 14/14] fix(billing): offer only the price a cancel or yearly switch can buy A scheduled cancellation was advertising the other interval, which checkout rejects. Monthly cards now check out the yearly price alone, and current cards send no bullets. Co-authored-by: Cursor --- backend/config/storefront_copy_v1.json | 1 + backend/docs/storefront-contract.md | 44 ++-- backend/models/storefront.py | 5 +- .../storefront_v2/architect_settings.json | 16 +- .../storefront_v2/cancel_scheduled_plus.json | 18 +- .../fixtures/storefront_v2/free_budget80.json | 6 +- .../fixtures/storefront_v2/free_mcp_cap.json | 6 +- .../free_pro_gate_proactive.json | 6 +- .../storefront_v2/free_pro_gate_screen.json | 6 +- .../fixtures/storefront_v2/free_settings.json | 6 +- .../fixtures/storefront_v2/legacy_neo.json | 196 ++++++++++++++++++ .../storefront_v2/legacy_unlimited.json | 16 +- .../storefront_v2/plus_annual_current.json | 6 +- .../fixtures/storefront_v2/plus_budget80.json | 17 +- .../plus_monthly_yearly_toggle.json | 17 +- .../fixtures/storefront_v2/pro_settings.json | 17 +- backend/tests/unit/test_storefront.py | 6 +- .../unit/test_storefront_presentation.py | 138 ++++++++++-- backend/utils/storefront.py | 75 +++++-- backend/utils/storefront_copy.py | 14 +- 20 files changed, 439 insertions(+), 177 deletions(-) create mode 100644 backend/tests/fixtures/storefront_v2/legacy_neo.json diff --git a/backend/config/storefront_copy_v1.json b/backend/config/storefront_copy_v1.json index ab28b3dd027..fe5f94d08d0 100644 --- a/backend/config/storefront_copy_v1.json +++ b/backend/config/storefront_copy_v1.json @@ -45,6 +45,7 @@ "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" diff --git a/backend/docs/storefront-contract.md b/backend/docs/storefront-contract.md index 19094866766..afcf7504f59 100644 --- a/backend/docs/storefront-contract.md +++ b/backend/docs/storefront-contract.md @@ -54,26 +54,33 @@ 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. -Legacy cards do not repeat ladder hours or bullets; the legacy sentence is -`current.legacy_note`. - -Same tier: an annual subscriber is not offered the monthly price. A monthly -subscriber may still see the annual price. That yearly price carries -`cta_label` `Switch to yearly · save {saving}` (`saving` is 12× the monthly -amount minus the annual amount, formatted in the price currency). The tier -button stays empty, so the current plan never says `Get {Plan}`. The current -card sets `compact` when it has no tier button and no price CTA; the client -drops bullets. A price CTA or Resubscribe leaves `compact` false and still -sends no extra tagline. +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` on its current price. Every -other tier is `action: none`, `cta_label: null`, and -`blocked_line: "Resubscribe first to change plans"`. There is no switch-to-yearly -label, no recommended badge, and the current tier is first. The header is +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. +`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`. @@ -91,7 +98,8 @@ 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. +`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. diff --git a/backend/models/storefront.py b/backend/models/storefront.py index cb9ec35a149..14041aa5b85 100644 --- a/backend/models/storefront.py +++ b/backend/models/storefront.py @@ -14,7 +14,7 @@ class StorefrontPrice(BaseModel): subline: str | None = None renewal_line: str | None = None months_free: int | None = None - # Present only when this price overrides the tier CTA (switch to yearly). + # Present only when this price overrides the tier cta_label (switch to yearly). cta_label: str | None = None @model_serializer(mode='wrap') @@ -38,7 +38,8 @@ class StorefrontTier(BaseModel): 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 button, so the client drops bullets. + # 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] diff --git a/backend/tests/fixtures/storefront_v2/architect_settings.json b/backend/tests/fixtures/storefront_v2/architect_settings.json index a62417f76be..26795dfdf3e 100644 --- a/backend/tests/fixtures/storefront_v2/architect_settings.json +++ b/backend/tests/fixtures/storefront_v2/architect_settings.json @@ -137,24 +137,12 @@ "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": [ - "Everything in Pro, with over 4 times the usage", - "At least 5,000 MCP requests a day" - ], - "action": "current", + "bullets": [], + "action": "checkout", "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", diff --git a/backend/tests/fixtures/storefront_v2/cancel_scheduled_plus.json b/backend/tests/fixtures/storefront_v2/cancel_scheduled_plus.json index 5a55fdb0439..2fc2fd8dfc7 100644 --- a/backend/tests/fixtures/storefront_v2/cancel_scheduled_plus.json +++ b/backend/tests/fixtures/storefront_v2/cancel_scheduled_plus.json @@ -33,11 +33,7 @@ "recommended": false, "is_current": true, "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" - ], + "bullets": [], "action": "checkout", "cta_label": "Resubscribe", "blocked_line": null, @@ -51,16 +47,6 @@ "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 } ] }, @@ -168,5 +154,5 @@ ] } ], - "yearly_toggle_label": "Yearly · 2 months free" + "yearly_toggle_label": null } diff --git a/backend/tests/fixtures/storefront_v2/free_budget80.json b/backend/tests/fixtures/storefront_v2/free_budget80.json index 38a04d39b01..5fade9a5410 100644 --- a/backend/tests/fixtures/storefront_v2/free_budget80.json +++ b/backend/tests/fixtures/storefront_v2/free_budget80.json @@ -71,11 +71,7 @@ "recommended": false, "is_current": true, "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" - ], + "bullets": [], "action": "current", "cta_label": null, "blocked_line": null, diff --git a/backend/tests/fixtures/storefront_v2/free_mcp_cap.json b/backend/tests/fixtures/storefront_v2/free_mcp_cap.json index e195af02093..4adbb6bc590 100644 --- a/backend/tests/fixtures/storefront_v2/free_mcp_cap.json +++ b/backend/tests/fixtures/storefront_v2/free_mcp_cap.json @@ -71,11 +71,7 @@ "recommended": false, "is_current": true, "hours_line": "About 15 hours of recording a month", - "bullets": [ - "100 MCP requests a day", - "Transcripts, summaries and memories of your conversations", - "Ask Omi anything about your day" - ], + "bullets": [], "action": "current", "cta_label": null, "blocked_line": null, diff --git a/backend/tests/fixtures/storefront_v2/free_pro_gate_proactive.json b/backend/tests/fixtures/storefront_v2/free_pro_gate_proactive.json index cf9a36b374a..f2745229a62 100644 --- a/backend/tests/fixtures/storefront_v2/free_pro_gate_proactive.json +++ b/backend/tests/fixtures/storefront_v2/free_pro_gate_proactive.json @@ -71,11 +71,7 @@ "recommended": false, "is_current": true, "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" - ], + "bullets": [], "action": "current", "cta_label": null, "blocked_line": null, diff --git a/backend/tests/fixtures/storefront_v2/free_pro_gate_screen.json b/backend/tests/fixtures/storefront_v2/free_pro_gate_screen.json index 18a5d4ad6a5..2874562dec3 100644 --- a/backend/tests/fixtures/storefront_v2/free_pro_gate_screen.json +++ b/backend/tests/fixtures/storefront_v2/free_pro_gate_screen.json @@ -71,11 +71,7 @@ "recommended": false, "is_current": true, "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" - ], + "bullets": [], "action": "current", "cta_label": null, "blocked_line": null, diff --git a/backend/tests/fixtures/storefront_v2/free_settings.json b/backend/tests/fixtures/storefront_v2/free_settings.json index a37c45ac25b..af9e0eafa65 100644 --- a/backend/tests/fixtures/storefront_v2/free_settings.json +++ b/backend/tests/fixtures/storefront_v2/free_settings.json @@ -71,11 +71,7 @@ "recommended": false, "is_current": true, "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" - ], + "bullets": [], "action": "current", "cta_label": null, "blocked_line": null, 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 index 1ad96e74e7f..883376b5b42 100644 --- a/backend/tests/fixtures/storefront_v2/legacy_unlimited.json +++ b/backend/tests/fixtures/storefront_v2/legacy_unlimited.json @@ -4,13 +4,13 @@ "show_subscription_ui": true, "header": "Your plan", "current": { - "plan_id": "unlimited", - "name": "Neo", - "price_id": "price_legacy_unlimited", + "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 Neo · $29.99/mo · renews Nov 1", + "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": { @@ -168,9 +168,9 @@ ] }, { - "tier_id": "unlimited:legacy", - "plan_id": "unlimited", - "name": "Neo", + "tier_id": "unlimited_v2:legacy", + "plan_id": "unlimited_v2", + "name": "Unlimited", "recommended": false, "is_current": true, "hours_line": "", @@ -181,7 +181,7 @@ "compact": true, "prices": [ { - "price_id": "price_legacy_unlimited", + "price_id": "price_legacy_unlimited_v2", "interval": "month", "amount_minor": 2999, "currency": "usd", diff --git a/backend/tests/fixtures/storefront_v2/plus_annual_current.json b/backend/tests/fixtures/storefront_v2/plus_annual_current.json index cc40b0fd7ad..894235c566a 100644 --- a/backend/tests/fixtures/storefront_v2/plus_annual_current.json +++ b/backend/tests/fixtures/storefront_v2/plus_annual_current.json @@ -99,11 +99,7 @@ "recommended": false, "is_current": true, "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" - ], + "bullets": [], "action": "current", "cta_label": null, "blocked_line": null, diff --git a/backend/tests/fixtures/storefront_v2/plus_budget80.json b/backend/tests/fixtures/storefront_v2/plus_budget80.json index e7547b3ae64..e5a0f982524 100644 --- a/backend/tests/fixtures/storefront_v2/plus_budget80.json +++ b/backend/tests/fixtures/storefront_v2/plus_budget80.json @@ -99,25 +99,12 @@ "recommended": false, "is_current": true, "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": "current", + "bullets": [], + "action": "checkout", "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", diff --git a/backend/tests/fixtures/storefront_v2/plus_monthly_yearly_toggle.json b/backend/tests/fixtures/storefront_v2/plus_monthly_yearly_toggle.json index 600668eca17..af61ee068e2 100644 --- a/backend/tests/fixtures/storefront_v2/plus_monthly_yearly_toggle.json +++ b/backend/tests/fixtures/storefront_v2/plus_monthly_yearly_toggle.json @@ -99,25 +99,12 @@ "recommended": false, "is_current": true, "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": "current", + "bullets": [], + "action": "checkout", "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", diff --git a/backend/tests/fixtures/storefront_v2/pro_settings.json b/backend/tests/fixtures/storefront_v2/pro_settings.json index 6fb421e9bb8..d3ca9499fd3 100644 --- a/backend/tests/fixtures/storefront_v2/pro_settings.json +++ b/backend/tests/fixtures/storefront_v2/pro_settings.json @@ -99,25 +99,12 @@ "recommended": false, "is_current": true, "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": "current", + "bullets": [], + "action": "checkout", "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", diff --git a/backend/tests/unit/test_storefront.py b/backend/tests/unit/test_storefront.py index 2c475c60088..249590e1dfe 100644 --- a/backend/tests/unit/test_storefront.py +++ b/backend/tests/unit/test_storefront.py @@ -81,7 +81,11 @@ def test_registered_purchasable_ladder_is_ordered_and_computes_live_prices(): 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 [tier.plan_id for tier in paid.tiers] == ['basic', 'plus', 'operator', 'architect'] - assert [tier.action for tier in paid.tiers] == ['none', 'none', 'current', 'checkout'] + 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' diff --git a/backend/tests/unit/test_storefront_presentation.py b/backend/tests/unit/test_storefront_presentation.py index aa6ba2663e6..c8457b4f97b 100644 --- a/backend/tests/unit/test_storefront_presentation.py +++ b/backend/tests/unit/test_storefront_presentation.py @@ -136,7 +136,8 @@ def test_floor_to_nice_and_per_day_phrasing(): 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 hours a day' + 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' @@ -145,7 +146,10 @@ def test_floor_to_nice_and_per_day_phrasing(): 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 @@ -185,10 +189,18 @@ def expected_action(current, tier, legacy, cancel): return 'checkout' -def expected_intervals(current, tier, interval, available): +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 and interval == 'year': + 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 @@ -220,9 +232,15 @@ def test_policy_matrix(current, interval, cancel, legacy, tier): 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 - ) == expected_intervals(current, tier, interval, available) + 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' @@ -256,8 +274,15 @@ def test_built_sheets_match_the_policy_matrix(): 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: - assert tier.action == expected_action(plan, tier.plan_id, legacy, cancel) + 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': @@ -266,9 +291,11 @@ def test_built_sheets_match_the_policy_matrix(): available = [interval] else: available = ['month', 'year'] - assert [item.interval for item in tier.prices] == expected_intervals(plan, plan, interval, available) - resubscribe_only = cancel and not legacy and plan in LADDER and plan != 'basic' + 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' @@ -278,6 +305,8 @@ def test_built_sheets_match_the_policy_matrix(): 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: @@ -287,16 +316,52 @@ def test_built_sheets_match_the_policy_matrix(): 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': - yearly = next(item for item in current_tier.prices if item.interval == 'year') + 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} @@ -345,10 +410,22 @@ def _scenario(name): return sheet_for('architect', price_id='price_architect_month', source='settings', **common) if name == 'legacy_unlimited': rows = dict(rows) - rows['price_legacy_unlimited'] = price('price_legacy_unlimited', 2999) + 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_unlimited', + price_id='price_legacy_neo', rows=rows, bindings=list(recognized and common['bindings']), recognized=recognized, @@ -372,6 +449,7 @@ def _scenario(name): 'pro_settings', 'architect_settings', 'legacy_unlimited', + 'legacy_neo', 'cancel_scheduled_plus', ) @@ -386,7 +464,8 @@ def test_golden_storefront_scenarios(): 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'] - assert next(tier for tier in free.tiers if tier.is_current).compact is True + 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") @@ -397,7 +476,7 @@ def test_golden_storefront_scenarios(): 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) + 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' @@ -405,21 +484,22 @@ def test_golden_storefront_scenarios(): 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 + 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 == 'current' and plus_month.cta_label is None and plus_month.compact is False - assert next(item for item in plus_month.prices if item.interval == 'month').cta_label is None - assert next(item for item in plus_month.prices if item.interval == 'year').cta_label == ( - 'Switch to yearly · save $39.98' - ) + 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.line == "You're on Neo · $29.99/mo · renews Nov 1" + 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'} >= { @@ -428,6 +508,13 @@ def test_golden_storefront_scenarios(): '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" @@ -435,14 +522,21 @@ def test_golden_storefront_scenarios(): 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 + 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(rendered[name].yearly_toggle_label == 'Yearly · 2 months free' for name in SCENARIOS) + 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' diff --git a/backend/utils/storefront.py b/backend/utils/storefront.py index 2f7f15be05b..3a0e969b086 100644 --- a/backend/utils/storefront.py +++ b/backend/utils/storefront.py @@ -89,12 +89,32 @@ def recommended_plan_id(current_plan: str, trigger: str | None) -> str | None: def offered_intervals( - *, current_plan: str, tier_plan: str, current_interval: str | None, available: list[str] + *, + 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 not offered; monthly → annual stays available.""" + """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 and current_interval == 'year': + 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 @@ -150,7 +170,7 @@ def _prices_for( def _compact(*, is_current: bool, action: str, prices: list[StorefrontPrice]) -> bool: - """Current card with no button. The client drops bullets; a price CTA still counts as an action.""" + """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) @@ -250,19 +270,28 @@ def build_storefront( available = [row['recurring']['interval'] for row in _valid_price_rows(rows)] if plan == 'basic': available = ['month'] - offered = offered_intervals( - current_plan=current_plan, tier_plan=plan, current_interval=current_interval, available=available - ) is_current = plan == current_plan and not legacy + switch = False if plan == 'basic': prices = [_free_price(currency, locale)] else: - prices = _prices_for( - rows, - offered=offered, - locale=locale, - switch_yearly=is_current and current_interval == 'month' and not resubscribe_only, + 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 action = tier_action( @@ -273,6 +302,8 @@ def build_storefront( ) 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', @@ -281,9 +312,13 @@ def build_storefront( recommended=bool(badge) and plan == badge and is_higher_offer(current_plan, plan), is_current=is_current, hours_line=hours_line(plan, locale), - bullets=bullets(plan, trigger, locale), + bullets=[] if is_current else bullets(plan, trigger, locale), action=action, - cta_label=cta_label(action=action, tier_plan=plan, current_plan=current_plan, locale=locale), + 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, @@ -297,15 +332,19 @@ def build_storefront( cancel=subscription.cancel_at_period_end, ) current_prices: list[StorefrontPrice] = [] - if current_row: - available = [current_interval] if current_interval else [] + 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"}', @@ -314,7 +353,7 @@ def build_storefront( recommended=False, is_current=True, hours_line='' if legacy or current_plan not in RANK else hours_line(current_plan, locale), - bullets=[] if legacy or current_plan not in RANK else bullets(current_plan, trigger, locale), + bullets=[], action=action, cta_label=cta_label(action=action, tier_plan=current_plan, current_plan=current_plan, locale=locale), blocked_line=None, @@ -360,5 +399,5 @@ def build_storefront( legacy_note=note, ), usage=usage, - yearly_toggle_label=yearly_toggle_label(_sheet_months_free(tiers), locale), + 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 index 0885906b518..82ab78aa634 100644 --- a/backend/utils/storefront_copy.py +++ b/backend/utils/storefront_copy.py @@ -85,11 +85,16 @@ def per_day_count(monthly_hours: int) -> int: def per_day_phrase(monthly_hours: int, locale: str | None = None) -> str: - """monthly ÷ 30. <6 → about N hours a day; 6–16 → all day; >16 → more than one device.""" + """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: - return phrases['under_6'].format(n=per_day_count(monthly_hours)) + 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'] @@ -105,13 +110,16 @@ def hours_line(plan_id: str, locale: str | None = None) -> str: def recording_hours_left(plan_id: str, percent: float) -> int: - """Remaining wall-clock hours. + """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))