From 795219f048ba56b4c349b6ea231d518cd3658dc5 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 14 Oct 2026 15:56:34 +0700 Subject: [PATCH 01/16] harden(subscription): cover future fields in client decoders --- .../models/subscription_plan_type_test.dart | 3 ++- .../Tests/SubscriptionInfoDecoderTests.swift | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/app/test/models/subscription_plan_type_test.dart b/app/test/models/subscription_plan_type_test.dart index 35195341b5e..74aa4fa4470 100644 --- a/app/test/models/subscription_plan_type_test.dart +++ b/app/test/models/subscription_plan_type_test.dart @@ -9,7 +9,8 @@ Subscription _subFromWirePlan(String plan) { 'features': [], 'cancel_at_period_end': false, 'deprecated': false, - 'limits': {}, + 'future_subscription_field': {'enabled': true}, + 'limits': {'future_allowance': 42}, }); } diff --git a/desktop/macos/Desktop/Tests/SubscriptionInfoDecoderTests.swift b/desktop/macos/Desktop/Tests/SubscriptionInfoDecoderTests.swift index e83972a9119..e7683ce77e9 100644 --- a/desktop/macos/Desktop/Tests/SubscriptionInfoDecoderTests.swift +++ b/desktop/macos/Desktop/Tests/SubscriptionInfoDecoderTests.swift @@ -183,4 +183,22 @@ final class SubscriptionInfoDecoderTests: XCTestCase { let encoded = try JSONEncoder().encode(plan) XCTAssertEqual(String(data: encoded, encoding: .utf8), #""future_plan_123""#) } + func testUnknownPlanInSubscriptionResponseIgnoresAdditiveFields() throws { + let json = Data( + #""" + { + "subscription": { + "plan": "future_plan_123", "status": "active", "features": [], + "cancel_at_period_end": false, "limits": {"future_allowance": 42}, + "future_subscription_field": {"enabled": true} + }, + "future_response_field": ["safe"] + } + """#.utf8) + let response = try JSONDecoder().decode(UserSubscriptionResponse.self, from: json) + XCTAssertEqual(response.subscription.plan, .unknown("future_plan_123")) + XCTAssertEqual(response.subscription.plan.displayName, "Plan unavailable") + XCTAssertFalse(response.subscription.plan.hasPaidCapability) + } + } From eccc986d9cfc85aa4107b2866abb0bcd5e12d06f Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 14 Oct 2026 15:57:38 +0700 Subject: [PATCH 02/16] feat(clients): show saved content waiting on its allowance --- .../20261014-subscription-waiting-state.json | 1 + .../widgets/conversation_list_item.dart | 30 +++++++++---------- .../pages/memories/widgets/memory_item.dart | 16 +++++----- app/lib/ui/components/omi_locked_preview.dart | 20 +++++++++---- .../conversation_locked_preview_test.dart | 13 +++++--- .../Components/ConversationStatusBadge.swift | 6 ++-- .../20261014-subscription-waiting-state.json | 1 + 7 files changed, 50 insertions(+), 37 deletions(-) create mode 100644 app/changelog/unreleased/20261014-subscription-waiting-state.json create mode 100644 desktop/macos/changelog/unreleased/20261014-subscription-waiting-state.json diff --git a/app/changelog/unreleased/20261014-subscription-waiting-state.json b/app/changelog/unreleased/20261014-subscription-waiting-state.json new file mode 100644 index 00000000000..967d120ea58 --- /dev/null +++ b/app/changelog/unreleased/20261014-subscription-waiting-state.json @@ -0,0 +1 @@ +{"change": "Saved conversations and memories waiting on your allowance link to usage without an upgrade prompt"} diff --git a/app/lib/pages/conversations/widgets/conversation_list_item.dart b/app/lib/pages/conversations/widgets/conversation_list_item.dart index 96213ff55ee..ca1cf281732 100644 --- a/app/lib/pages/conversations/widgets/conversation_list_item.dart +++ b/app/lib/pages/conversations/widgets/conversation_list_item.dart @@ -71,7 +71,7 @@ class ConversationListItem extends StatefulWidget { /// (the Home preview), so selection mode can never start without a way to act on it or leave. final bool allowSelection; - /// Drawn inside a [LockedConversationRun]: the run blurs the card under its one upgrade action, + /// Drawn inside a [LockedConversationRun]: the run blurs the card under its one usage action, /// so the row draws no padding or lock of its own but keeps its gestures (open, long-press menu, /// swipe-to-delete, selection). final bool inLockedRun; @@ -176,11 +176,7 @@ class _ConversationListItemState extends State { Future _open(BuildContext context, ConversationProvider provider) async { if (widget.conversation.isLocked) { if (!context.read().showSubscriptionUI) return; - PlatformManager.instance.analytics.paywallOpened( - 'locked_conversation', - currentPlan: context.read().subscription?.subscription.plan.wireName ?? 'unknown', - ); - routeToPage(context, const UsagePage(showUpgradeDialog: true, paywallSource: 'locked_conversation')); + routeToPage(context, const UsagePage()); return; } HapticFeedback.selectionClick(); @@ -429,7 +425,12 @@ class _ConversationListItemState extends State { ); // A run frosts its rows together under one action. if (!widget.conversation.isLocked || widget.inLockedRun) return content; - return OmiLockedPreview(label: context.l10n.upgradeToUnlimited, onPressed: onTap, child: content); + return OmiLockedPreview( + status: context.l10n.statusPending, + label: context.l10n.paywallComparePlans, + onPressed: onTap, + child: content, + ); } /// Time and length, with the New badge beside them (hub audit #16) and the star. @@ -838,7 +839,7 @@ class _MergingIndicatorState extends State with SingleTickerPr } /// Two or more locked rows in a row on the free plan: one frosted card holding all of them, with a -/// single "Upgrade to Unlimited" instead of one per row. A lone locked row stays a +/// single usage action instead of one per row. A lone locked row stays a /// [ConversationListItem] with its own [OmiLockedPreview]. class LockedConversationRun extends StatelessWidget { const LockedConversationRun({super.key, required this.conversations, required this.date}); @@ -846,7 +847,7 @@ class LockedConversationRun extends StatelessWidget { final List conversations; final DateTime date; - Future _upgrade(BuildContext context) async { + Future _openUsage(BuildContext context) async { // Same as a lone locked row's action: while merging, a locked row can't be picked. if (context.read().isSelectionModeActive) { HapticFeedback.lightImpact(); @@ -854,11 +855,7 @@ class LockedConversationRun extends StatelessWidget { return; } if (!context.read().showSubscriptionUI) return; - PlatformManager.instance.analytics.paywallOpened( - 'locked_conversation', - currentPlan: context.read().subscription?.subscription.plan.wireName ?? 'unknown', - ); - routeToPage(context, const UsagePage(showUpgradeDialog: true, paywallSource: 'locked_conversation')); + routeToPage(context, const UsagePage()); } @override @@ -869,8 +866,9 @@ class LockedConversationRun extends StatelessWidget { key: const Key('locked_conversation_run'), // Each row keeps its own long-press menu, swipe-to-delete and selection handling. interactive: true, - label: context.l10n.upgradeToUnlimited, - onPressed: () => _upgrade(context), + status: context.l10n.statusPending, + label: context.l10n.paywallComparePlans, + onPressed: () => _openUsage(context), child: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/app/lib/pages/memories/widgets/memory_item.dart b/app/lib/pages/memories/widgets/memory_item.dart index 89650734198..7b559346dd3 100644 --- a/app/lib/pages/memories/widgets/memory_item.dart +++ b/app/lib/pages/memories/widgets/memory_item.dart @@ -1,6 +1,5 @@ import 'dart:ui'; -import 'package:omi/utils/platform/platform_manager.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; @@ -186,12 +185,7 @@ class MemoryItem extends StatelessWidget { showMemoryQuickEditSheet(context, memory, provider, readOnly: true); return; } - PlatformManager.instance.analytics.paywallOpened( - 'locked_memory', - currentPlan: - context.read().subscription?.subscription.plan.wireName ?? 'unknown', - ); - routeToPage(context, const UsagePage(showUpgradeDialog: true, paywallSource: 'locked_memory')); + routeToPage(context, const UsagePage()); return; }, child: Container( @@ -201,7 +195,13 @@ class MemoryItem extends StatelessWidget { borderRadius: OmiRadius.smAll, ), child: context.watch().showSubscriptionUI - ? Text(context.l10n.upgradeToUnlimited, style: OmiType.headline) + ? Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(context.l10n.statusPending, style: OmiType.subhead), + Text(context.l10n.paywallComparePlans, style: OmiType.headline), + ], + ) : const SizedBox.shrink(), ), ), diff --git a/app/lib/ui/components/omi_locked_preview.dart b/app/lib/ui/components/omi_locked_preview.dart index ed6d05584ef..113d8ceb7ec 100644 --- a/app/lib/ui/components/omi_locked_preview.dart +++ b/app/lib/ui/components/omi_locked_preview.dart @@ -13,11 +13,13 @@ class OmiLockedPreview extends StatelessWidget { required this.child, required this.label, required this.onPressed, + this.status, this.interactive = false, }); final Widget child; final String label; + final String? status; final FutureOr Function() onPressed; /// Lets gestures reach the obscured [child] (for example a row's long-press menu or swipe). The @@ -55,12 +57,18 @@ class OmiLockedPreview extends StatelessWidget { // Non-positioned so the action can also determine the card's height. Padding( padding: const EdgeInsets.symmetric(horizontal: OmiSpacing.md, vertical: OmiSpacing.sm), - child: OmiButton.tertiary( - key: const Key('locked_preview_action'), - label: label, - icon: Icons.lock_outline, - size: OmiButtonSize.compact, - onPressed: onPressed, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (status != null) Text(status!, style: OmiType.subhead), + OmiButton.tertiary( + key: const Key('locked_preview_action'), + label: label, + icon: Icons.hourglass_empty, + size: OmiButtonSize.compact, + onPressed: onPressed, + ), + ], ), ), ], diff --git a/app/test/widgets/conversation_locked_preview_test.dart b/app/test/widgets/conversation_locked_preview_test.dart index c1d1a462abb..a9e64ffc978 100644 --- a/app/test/widgets/conversation_locked_preview_test.dart +++ b/app/test/widgets/conversation_locked_preview_test.dart @@ -14,7 +14,7 @@ import 'package:omi/providers/usage_provider.dart'; import 'package:omi/ui/ui.dart'; const _title = 'Planning the next team meeting'; -const _upgrade = 'Upgrade to Unlimited'; +const _upgrade = 'See plans'; class _Routes extends NavigatorObserver { final pushed = >[]; @@ -103,6 +103,9 @@ void main() { expect(find.descendant(of: find.byType(ImageFiltered), matching: find.text(_title)), findsOneWidget); expect(find.descendant(of: find.byType(ImageFiltered), matching: find.text(_upgrade)), findsNothing); expect(find.byType(BackdropFilter), findsNothing); + expect(find.text('Pending'), findsOneWidget); + expect(find.byIcon(Icons.lock_outline), findsNothing); + expect(find.byIcon(Icons.hourglass_empty), findsOneWidget); expect(find.bySemanticsLabel(RegExp(_title)), findsNothing); expect(find.bySemanticsLabel(_upgrade), findsOneWidget); @@ -139,7 +142,7 @@ void main() { expect(find.text(_upgrade), findsNothing); }); - testWidgets('the upgrade action keeps the existing paywall destination', (tester) async { + testWidgets('the waiting preview opens usage without forcing a paywall', (tester) async { final routes = _Routes(); await pumpRow(tester, observer: routes); await tester.tap(find.byKey(const Key('locked_preview_action'))); @@ -147,7 +150,7 @@ void main() { final route = routes.pushed.last as MaterialPageRoute; final destination = route.builder(tester.element(find.byType(ConversationListItem))); expect(destination, isA()); - expect((destination as UsagePage).showUpgradeDialog, isTrue); + expect((destination as UsagePage).showUpgradeDialog, isFalse); // Check navigation without building the network-backed usage screen. await tester.pumpWidget(const SizedBox.shrink()); }); @@ -214,7 +217,9 @@ void main() { tester, body: LockedConversationRun(conversations: run, date: run.first.createdAt), ); - await tester.longPress(find.text('$_title 1')); + final row = tester.getRect(find.byKey(const ValueKey('locked_locked-1'))); + // The central usage action is interactive; the leading edge still opens this row's menu. + await tester.longPressAt(Offset(row.left + 16, row.center.dy)); await tester.pumpAndSettle(); expect(find.byKey(const Key('conversation_action_delete')), findsOneWidget); }); diff --git a/desktop/macos/Desktop/Sources/MainWindow/Components/ConversationStatusBadge.swift b/desktop/macos/Desktop/Sources/MainWindow/Components/ConversationStatusBadge.swift index f6744d87e97..f829c381a8b 100644 --- a/desktop/macos/Desktop/Sources/MainWindow/Components/ConversationStatusBadge.swift +++ b/desktop/macos/Desktop/Sources/MainWindow/Components/ConversationStatusBadge.swift @@ -23,10 +23,10 @@ struct ConversationStatusBadge: View { ) case .locked: Pill( - icon: "lock.fill", - text: "Locked", + icon: "hourglass", + text: "Saved and waiting", color: PageGlass.warning, - help: "This conversation is locked until your subscription is active." + help: "This conversation is saved. Check your plan and usage to see when processing is available." ) case .failed: Pill( diff --git a/desktop/macos/changelog/unreleased/20261014-subscription-waiting-state.json b/desktop/macos/changelog/unreleased/20261014-subscription-waiting-state.json new file mode 100644 index 00000000000..bb1570a5210 --- /dev/null +++ b/desktop/macos/changelog/unreleased/20261014-subscription-waiting-state.json @@ -0,0 +1 @@ +{"change": "Saved conversations waiting on your allowance show a waiting state instead of a padlock"} From 19fd7aac10f307429bc7ba3bfad4c4bd2f1caa52 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 14 Oct 2026 15:57:50 +0700 Subject: [PATCH 03/16] harden(billing): pin unresolved checkout effects in both ledger modes --- ...subscription_ingestion_characterization.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/backend/tests/unit/test_subscription_ingestion_characterization.py b/backend/tests/unit/test_subscription_ingestion_characterization.py index 2df85765a86..0c6f7374da1 100644 --- a/backend/tests/unit/test_subscription_ingestion_characterization.py +++ b/backend/tests/unit/test_subscription_ingestion_characterization.py @@ -672,3 +672,26 @@ def test_lifecycle_observation_precedes_entitlement_effects(lane, monkeypatch): monkeypatch.setattr(payment, 'record_subscription_event', lambda **kw: lane.calls.append(('observation', ()))) run(lane) assert [name for name, _ in lane.calls][:2] == ['observation', 'customer'] + + +@pytest.mark.parametrize('ledger_enabled', [False, True]) +@pytest.mark.parametrize('stored_plan', [PlanType.basic, PlanType.plus]) +def test_unresolved_checkout_preserves_cache_and_valid_stored_plan_effects( + lane, monkeypatch, ledger_enabled, stored_plan +): + """#21372: retry admission must follow cache invalidation and valid-paid unlocks.""" + lane.current = Subscription(plan=stored_plan, current_period_end=NOW + 100) + lane.stripe_obj = event(EVENTS[1], 'active')['data']['object'] + lane.event = _checkout_event() + monkeypatch.setattr(payment, '_update_subscription_from_session', lambda uid, session: False) + monkeypatch.setattr(payment, 'webhook_ledger_enabled', lambda: ledger_enabled) + result = asyncio.run(payment._handle_stripe_event(lane.event)) + expected = ['credits', 'trial_cache'] + if stored_plan == PlanType.plus: + expected += ['conversations', 'memories', 'actions', 'fair_use'] + assert _call_names(lane)[: len(expected)] == expected + assert 'write' not in _call_names(lane) + assert lane.current.plan == stored_plan + assert isinstance(result, payment.RetryableBillingSkip) == ledger_enabled + if ledger_enabled: + assert _call_names(lane) == expected From 9cd01131b8ac2b0368c76292a13eca191d696396 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 14 Oct 2026 15:58:49 +0700 Subject: [PATCH 04/16] feat(web): clarify legacy usage and measure Account billing --- .../src/components/settings/PlansSheet.tsx | 26 +-- .../src/components/settings/SettingsPage.tsx | 148 +++++------------- .../settings/TranscriptionUsageMeter.tsx | 65 ++++++++ .../SettingsSubscriptionClarity.test.tsx | 88 +++++++++++ .../TranscriptionUsageMeter.test.tsx | 47 ++++++ .../lib/__tests__/subscriptionPlanApi.test.ts | 6 + web/app/src/lib/api.ts | 2 + web/app/src/lib/planFeatures.ts | 7 +- web/app/src/types/user.ts | 3 + 9 files changed, 272 insertions(+), 120 deletions(-) create mode 100644 web/app/src/components/settings/TranscriptionUsageMeter.tsx create mode 100644 web/app/src/components/settings/__tests__/SettingsSubscriptionClarity.test.tsx create mode 100644 web/app/src/components/settings/__tests__/TranscriptionUsageMeter.test.tsx diff --git a/web/app/src/components/settings/PlansSheet.tsx b/web/app/src/components/settings/PlansSheet.tsx index 273c26c00aa..e945c58f2b7 100644 --- a/web/app/src/components/settings/PlansSheet.tsx +++ b/web/app/src/components/settings/PlansSheet.tsx @@ -18,8 +18,9 @@ import type { PricingOption, AvailablePlansResponse, } from '@/types/user'; -import { decodePlan, planGrantsPaidCapability } from '@/types/user'; +import { decodePlan, planDisplayName, planGrantsPaidCapability } from '@/types/user'; import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; +import { TranscriptionUsageMeter } from './TranscriptionUsageMeter'; import { DEFAULT_PLAN_FEATURES } from '@/lib/planFeatures'; import { trackPaywallOpened, @@ -54,7 +55,10 @@ export function PlansSheet({ const planIdentity = subscription ? subscription.plan_identity ?? decodePlan(subscription.plan) : null; - const isUnlimited = planIdentity ? planGrantsPaidCapability(planIdentity) : false; + const isPaid = planIdentity ? planGrantsPaidCapability(planIdentity) : false; + const currentPlanName = planIdentity + ? planDisplayName(planIdentity) + : 'Plan unavailable'; const isUnknownPlan = planIdentity?.kind === 'unknown'; const isCanceling_ = subscription?.cancel_at_period_end; @@ -153,7 +157,7 @@ export function PlansSheet({ trackUpgradePlanSelected(tier, interval, 'web_plans'); // If already subscribed and selecting a different plan, use upgrade endpoint - if (isUnlimited && !isCanceling_ && !isCurrentPlan) { + if (isPaid && !isCanceling_ && !isCurrentPlan) { const result = await upgradeSubscription(selectedPriceId); if (result?.status === 'success' || result?.scheduled_start) { trackUpgradeSucceeded(tier, interval); @@ -294,11 +298,11 @@ export function PlansSheet({ {isUnknownPlan ? 'Plan unavailable' - : isUnlimited && !isCanceling_ - ? 'Manage Your Plan' + : isPaid && !isCanceling_ + ? currentPlanName : 'Choose Your Plan'} - {isUnlimited && subscription?.current_period_end && ( + {isPaid && subscription?.current_period_end && (

{isCanceling_ ? `Cancels on ${formatDate( @@ -389,6 +393,8 @@ export function PlansSheet({

)} + + {/* Features List */}

@@ -420,7 +426,7 @@ export function PlansSheet({ disabled={ isLoading || !selectedPriceId || - (isUnlimited && !isCanceling_ && selectedOption?.is_active) + (isPaid && !isCanceling_ && selectedOption?.is_active) } className={cn( 'w-full rounded-xl py-3 font-medium transition-colors', @@ -436,7 +442,7 @@ export function PlansSheet({ ) : isCanceling_ ? ( 'Reactivate Subscription' - ) : isUnlimited ? ( + ) : isPaid ? ( selectedOption?.is_active ? ( 'Current Plan' ) : ( @@ -447,7 +453,7 @@ export function PlansSheet({ )} - {!isUnlimited && subscription?.stripe_subscription_id && ( + {!isPaid && subscription?.stripe_subscription_id && ( {subscription?.stripe_subscription_id && (

- {/* Monthly Listening Usage */} -
-
- - - Monthly Listening Limit - -
-
- - {monthlyUsage - ? Math.round(monthlyUsage.transcription_seconds / 60) - : 0} - - / 1,200 min - - - - {monthlyUsage - ? 1200 - Math.round(monthlyUsage.transcription_seconds / 60) - : 1200}{' '} - min left - -
-
-
-
-
+ {/* What's Included - Checklist */}

What's included

-
-
-
- -
- - - 1,200 minutes - {' '} - of listening per month - (limited) - -
-
-
- -
- - Unlimited{' '} - words transcribed - -
-
-
- -
- - Unlimited{' '} - insights - -
-
-
- -
- - Unlimited{' '} - memories - -
-
+
    + {DEFAULT_PLAN_FEATURES.map((feature) => ( +
  • + + {feature} +
  • + ))} +
@@ -1192,9 +1128,7 @@ function UsageSectionContent({

Choose a Plan

-

- Unlock unlimited listening time -

+

Choose your plan