From ec1f30d90886c223686987ee52828187cf29a4b8 Mon Sep 17 00:00:00 2001 From: Nik Shevchenko Date: Sun, 15 Nov 2026 15:49:47 -0500 Subject: [PATCH 1/4] fix(billing): remind trialing readers before the first charge and state it at checkout Co-Authored-By: Claude Opus 5.5 --- backend/database/redis_db.py | 20 ++ backend/routers/payment.py | 46 +++- .../unit/test_payment_declined_proration.py | 1 + .../test_payment_promo_adaptive_pricing.py | 5 +- .../unit/test_payment_promotion_codes.py | 1 + .../tests/unit/test_trial_ending_reminder.py | 197 ++++++++++++++++++ backend/utils/notification_dispatch.py | 1 + backend/utils/stripe.py | 21 +- backend/utils/trial_reminder.py | 93 +++++++++ 9 files changed, 379 insertions(+), 6 deletions(-) create mode 100644 backend/tests/unit/test_trial_ending_reminder.py create mode 100644 backend/utils/trial_reminder.py diff --git a/backend/database/redis_db.py b/backend/database/redis_db.py index 9209cdcdc7da..958d3d8148dc 100644 --- a/backend/database/redis_db.py +++ b/backend/database/redis_db.py @@ -1012,6 +1012,26 @@ def claim_payment_recovery_notification(uid: str, invoice_id: str) -> bool: ) +TRIAL_ENDING_NOTIFICATION_TTL_SECONDS = 60 * 24 * 60 * 60 + + +def claim_trial_ending_notification(uid: str, subscription_id: str, trial_end: int) -> bool: + """Atomically claim one trial-ending reminder per subscription and trial end. + + Stripe retries and replays of the same `trial_will_end` event send nothing twice; an + extended trial (new `trial_end`) earns its own reminder. Redis errors propagate so the + webhook stays retryable and never sends without a confirmed claim. + """ + return bool( + r.set( + f'users:{uid}:trial_ending_notification:{subscription_id}:{trial_end}', + '1', + nx=True, + ex=TRIAL_ENDING_NOTIFICATION_TTL_SECONDS, + ) + ) + + # ****************************************************** # ******* IMPORTANT CONVERSATION NOTIFICATIONS ********* # ****************************************************** diff --git a/backend/routers/payment.py b/backend/routers/payment.py index a3154cf31786..409c6fd6d7a9 100644 --- a/backend/routers/payment.py +++ b/backend/routers/payment.py @@ -29,7 +29,11 @@ action_items as action_items_db, ) from database.locked_content import has_recoverable_locked_content -from database.redis_db import claim_payment_recovery_notification, set_credits_invalidation_signal +from database.redis_db import ( + claim_payment_recovery_notification, + claim_trial_ending_notification, + set_credits_invalidation_signal, +) from config.plan_catalog import _paid_period_still_open, plan_uses_overage from utils.subscription_state_machine import ( project_subscription, @@ -57,6 +61,7 @@ dispatch_notification_async, ) from utils import billing_recovery +from utils import trial_reminder from models.users import PlanType, Subscription, SubscriptionStatus, PlanLimits from utils.subscription import ( get_basic_plan_limits, @@ -1251,6 +1256,41 @@ async def _schedule_payment_recovery_notification( await dispatch_notification_async(intent) +async def _schedule_trial_ending_notification(background_tasks: BackgroundTasks | None, subscription_obj: Mapping) -> None: + """Remind a trialing reader before the first charge (`customer.subscription.trial_will_end`). + + Only the account whose stored subscription is this one is reminded; a stale or foreign + subscription sends nothing. Pre-claim failures propagate so Stripe retries the event. + """ + subscription_id = subscription_obj.get('id') + uid = (subscription_obj.get('metadata') or {}).get('uid') + if not uid or not isinstance(subscription_id, str): + return + stored = await run_blocking(db_executor, users_db.get_user_subscription, uid) + if stored is None or stored.stripe_subscription_id != subscription_id: + return + body = trial_reminder.trial_ending_reminder_body(uid, subscription_obj, int(time.time())) + if body is None: + return + claimed = await run_blocking( + db_executor, claim_trial_ending_notification, uid, subscription_id, int(subscription_obj['trial_end']) + ) + if not claimed: + return + intent = NotificationIntent( + user_id=uid, + title=trial_reminder.TRIAL_ENDING_NOTIFICATION_TITLE, + body=body, + source='stripe_trial_will_end', + kind=NotificationKind.TRIAL_ENDING, + data={'type': 'trial_ending', 'navigate_to': '/settings'}, + ) + if background_tasks is not None: + background_tasks.add_task(dispatch_notification, intent) + else: + await dispatch_notification_async(intent) + + def _update_checkout_subscription_metadata(uid: str, session: dict) -> None: """Converge account and optional order attribution from verified Stripe objects.""" subscription_id = session.get('subscription') @@ -1319,6 +1359,10 @@ async def run_db(function, *args, **kwargs): @fresh_reader async def _handle_stripe_event(event: dict, background_tasks: BackgroundTasks | None = None): + if event['type'] == 'customer.subscription.trial_will_end': + await _schedule_trial_ending_notification(background_tasks, event['data']['object']) + return {"status": "success", "message": "Trial ending reminder handled."} + if event['type'] == 'checkout.session.completed': session = event['data']['object'] client_reference_id = session.get('client_reference_id') diff --git a/backend/tests/unit/test_payment_declined_proration.py b/backend/tests/unit/test_payment_declined_proration.py index 9517586d5cdd..89c781ce61c4 100644 --- a/backend/tests/unit/test_payment_declined_proration.py +++ b/backend/tests/unit/test_payment_declined_proration.py @@ -96,6 +96,7 @@ def _noop_fresh_reads(): redis_mod = sys.modules["database.redis_db"] redis_mod.claim_payment_recovery_notification = _shared_mock() + redis_mod.claim_trial_ending_notification = _shared_mock() redis_mod.set_credits_invalidation_signal = _shared_mock() locked_content_mod = sys.modules["database.locked_content"] diff --git a/backend/tests/unit/test_payment_promo_adaptive_pricing.py b/backend/tests/unit/test_payment_promo_adaptive_pricing.py index 4faa5f807e66..303cd1bd3ee1 100644 --- a/backend/tests/unit/test_payment_promo_adaptive_pricing.py +++ b/backend/tests/unit/test_payment_promo_adaptive_pricing.py @@ -84,7 +84,10 @@ def test_promo_free_checkout_disables_adaptive_pricing(trial_period_days, custom assert params['adaptive_pricing'] == {'enabled': False} assert params['currency'] == 'usd' assert params['locale'] == 'auto' - assert params['custom_text']['submit']['message'] == 'All amounts are in USD (US dollars).' + message = params['custom_text']['submit']['message'] + assert message.endswith('All amounts are in USD (US dollars).') + # A trial session also states the first charge above the button. + assert message.startswith('No charge today.') == bool(trial_period_days) def test_adaptive_pricing_pin_is_unconditional(): diff --git a/backend/tests/unit/test_payment_promotion_codes.py b/backend/tests/unit/test_payment_promotion_codes.py index 6078988bd662..d467ffe0aa32 100644 --- a/backend/tests/unit/test_payment_promotion_codes.py +++ b/backend/tests/unit/test_payment_promotion_codes.py @@ -498,6 +498,7 @@ def _setup_payment_module(include_client: bool = True) -> Any: redis_mod = sys.modules["database.redis_db"] redis_mod.claim_payment_recovery_notification = MagicMock() + redis_mod.claim_trial_ending_notification = MagicMock() redis_mod.set_credits_invalidation_signal = MagicMock() locked_content_mod = sys.modules["database.locked_content"] diff --git a/backend/tests/unit/test_trial_ending_reminder.py b/backend/tests/unit/test_trial_ending_reminder.py new file mode 100644 index 000000000000..556cc18301b3 --- /dev/null +++ b/backend/tests/unit/test_trial_ending_reminder.py @@ -0,0 +1,197 @@ +"""A free trial's first charge is announced before it happens. + +Support saw readers surprised by the full-price charge when a 7-day trial ended, and the in-app +30-day trial offer promises "We'll remind you before anything is charged". Stripe's +`customer.subscription.trial_will_end` (three days before the end) now sends that reminder once, +only to the account that owns the trial, and Checkout states the charge above its button. +""" + +import asyncio +from types import SimpleNamespace + +import pytest +from fastapi import BackgroundTasks + +from config.plan_catalog import RECOGNIZED_STRIPE_PRICE_PLAN_TYPES +from models.users import PlanType, Subscription +from routers import payment +from utils import trial_reminder +import utils.stripe as stripe_utils + +NOW = 1_794_000_000 +DAY = 86_400 +PAID_PRICE = next(k for k, v in RECOGNIZED_STRIPE_PRICE_PLAN_TYPES.items() if v == PlanType.unlimited) + + +def _trial(**changes): + sub = { + 'id': 'sub-trial', + 'status': 'trialing', + 'trial_end': NOW + 3 * DAY, + 'cancel_at_period_end': False, + 'metadata': {'uid': 'user-trial'}, + 'items': { + 'data': [ + { + 'quantity': 1, + 'price': { + 'id': PAID_PRICE, + 'currency': 'usd', + 'unit_amount': 26991, + 'recurring': {'interval': 'year'}, + }, + } + ] + }, + } + sub.update(changes) + return sub + + +def test_trialing_paid_plan_names_the_charge_and_when(): + body = trial_reminder.trial_ending_reminder_body('user-trial', _trial(), NOW) + assert body == ( + 'It ends in 3 days, then your card is charged US$269.91 a year. ' + 'To pay nothing, cancel before then in Settings.' + ) + + +def test_monthly_and_last_day_wording(): + monthly = _trial(trial_end=NOW + DAY // 2) + monthly['items']['data'][0]['price'].update({'unit_amount': 2999, 'recurring': {'interval': 'month'}}) + body = trial_reminder.trial_ending_reminder_body('user-trial', monthly, NOW) + assert body.startswith('It ends tomorrow, then your card is charged US$29.99 a month.') + + +@pytest.mark.parametrize( + 'changes', + [ + {'status': 'active'}, + {'cancel_at_period_end': True}, + {'cancel_at': NOW + 3 * DAY}, + {'trial_end': NOW - 1}, + {'metadata': {'uid': 'someone-else'}}, + # An included-year code makes the next charge something other than the list price. + {'discounts': ['di_123']}, + {'discount': {'id': 'di_123'}}, + {'collection_method': 'send_invoice'}, + ], + ids=['not-trialing', 'cancelling', 'cancel-at', 'ended', 'foreign', 'discounts', 'discount', 'invoiced'], +) +def test_no_reminder_when_nothing_unexpected_will_be_charged(changes): + assert trial_reminder.trial_ending_reminder_body('user-trial', _trial(**changes), NOW) is None + + +def test_unknown_price_sends_nothing(): + sub = _trial() + sub['items']['data'][0]['price']['id'] = 'price_not_omi' + assert trial_reminder.trial_ending_reminder_body('user-trial', sub, NOW) is None + + +def test_checkout_terms_state_amount_and_interval(): + assert trial_reminder.checkout_trial_terms(7, 26991, 'usd', 'year') == ( + 'No charge today. When your 7-day free trial ends, your card is charged US$269.91 a year, ' + 'less any code you apply, until you cancel. Cancel in the Omi app before the trial ends and you pay nothing.' + ) + assert trial_reminder.checkout_trial_terms(7, None, 'usd', None).startswith('No charge today.') + + +def test_trial_checkout_session_carries_the_terms(monkeypatch): + captured = {} + price = SimpleNamespace(currency='usd', unit_amount=2999, recurring={'interval': 'month'}) + monkeypatch.setattr(stripe_utils.stripe.Price, 'retrieve', lambda price_id: price) + monkeypatch.setattr( + stripe_utils.stripe.checkout.Session, + 'create', + lambda **params: captured.update(params) or SimpleNamespace(url='u', id='cs'), + ) + stripe_utils.create_subscription_checkout_session('uid', PAID_PRICE, 'idem', trial_period_days=7) + assert captured['subscription_data']['trial_period_days'] == 7 + assert captured['custom_text']['submit']['message'] == ( + 'No charge today. When your 7-day free trial ends, your card is charged US$29.99 a month, ' + 'less any code you apply, until you cancel. Cancel in the Omi app before the trial ends and you pay nothing. ' + 'All amounts are in USD (US dollars).' + ) + + captured.clear() + stripe_utils.create_subscription_checkout_session('uid', PAID_PRICE, 'idem') + assert captured['custom_text']['submit']['message'] == 'All amounts are in USD (US dollars).' + + +@pytest.fixture +def lane(monkeypatch): + state = SimpleNamespace( + stored=Subscription(plan=PlanType.unlimited, stripe_subscription_id='sub-trial'), + claims=set(), + notifications=[], + claim_error=None, + ) + + async def run_blocking(executor, fn, *args, **kwargs): + return fn(*args, **kwargs) + + def claim(uid, subscription_id, trial_end): + if state.claim_error: + raise state.claim_error + marker = (uid, subscription_id, trial_end) + if marker in state.claims: + return False + state.claims.add(marker) + return True + + monkeypatch.setattr(payment, 'run_blocking', run_blocking) + monkeypatch.setattr(payment.users_db, 'get_user_subscription', lambda uid: state.stored) + monkeypatch.setattr(payment, 'claim_trial_ending_notification', claim) + monkeypatch.setattr(payment.time, 'time', lambda: NOW) + monkeypatch.setattr( + payment, + 'dispatch_notification', + lambda intent: state.notifications.append((intent.user_id, intent.title, intent.body, intent.data)), + ) + return state + + +def _event(sub=None): + return { + 'id': 'evt-trial', + 'type': 'customer.subscription.trial_will_end', + 'data': {'object': sub or _trial()}, + } + + +def _deliver(event): + async def run(): + tasks = BackgroundTasks() + result = await payment._handle_stripe_event(event, tasks) + await tasks() + return result + + return asyncio.run(run()) + + +def test_trial_will_end_sends_one_reminder_across_redeliveries(lane): + assert _deliver(_event())['status'] == 'success' + _deliver(_event()) + assert lane.notifications == [ + ( + 'user-trial', + 'Your Omi Pro free trial ends soon', + 'It ends in 3 days, then your card is charged US$269.91 a year. ' + 'To pay nothing, cancel before then in Settings.', + {'type': 'trial_ending', 'navigate_to': '/settings'}, + ) + ] + + +def test_stale_subscription_or_missing_owner_sends_nothing(lane): + lane.stored = Subscription(plan=PlanType.unlimited, stripe_subscription_id='sub-newer') + _deliver(_event()) + _deliver(_event(_trial(metadata={}))) + assert lane.notifications == [] + + +def test_claim_failure_stays_retryable(lane): + lane.claim_error = ConnectionError('redis down') + with pytest.raises(ConnectionError): + _deliver(_event()) + assert lane.notifications == [] diff --git a/backend/utils/notification_dispatch.py b/backend/utils/notification_dispatch.py index 050e24e45e8c..7958afa6666a 100644 --- a/backend/utils/notification_dispatch.py +++ b/backend/utils/notification_dispatch.py @@ -30,6 +30,7 @@ class NotificationKind(str, Enum): ACTION_ITEM_REMINDER = 'action_item_reminder' CONVERSATION_MERGE = 'conversation_merge' PAYMENT_RECOVERY = 'payment_recovery' + TRIAL_ENDING = 'trial_ending' class NotificationPolicy(str, Enum): diff --git a/backend/utils/stripe.py b/backend/utils/stripe.py index b5158afbed72..c530a80344f8 100644 --- a/backend/utils/stripe.py +++ b/backend/utils/stripe.py @@ -8,6 +8,7 @@ import stripe from database import redis_db +from utils.trial_reminder import checkout_trial_terms import logging logger = logging.getLogger(__name__) @@ -256,13 +257,25 @@ def create_subscription_checkout_session( 'adaptive_pricing': {'enabled': False}, } + submit_lines = [] + if trial_period_days: + session_params['subscription_data']['trial_period_days'] = trial_period_days + # The first charge is the most-disputed moment: say when and how much, above the button. + recurring = getattr(price, 'recurring', None) + submit_lines.append( + checkout_trial_terms( + trial_period_days, + getattr(price, 'unit_amount', None), + currency, + recurring.get('interval') if recurring else None, + ) + ) if currency == 'usd': # Stripe owns its currency glyphs. Keep USD explicit even where its # hosted template uses a dollar glyph rather than the app's US$. - session_params['custom_text'] = {'submit': {'message': 'All amounts are in USD (US dollars).'}} - - if trial_period_days: - session_params['subscription_data']['trial_period_days'] = trial_period_days + submit_lines.append('All amounts are in USD (US dollars).') + if submit_lines: + session_params['custom_text'] = {'submit': {'message': ' '.join(submit_lines)}} if promotion_code_id: session_params['discounts'] = [{'promotion_code': promotion_code_id}] diff --git a/backend/utils/trial_reminder.py b/backend/utils/trial_reminder.py new file mode 100644 index 000000000000..a539f6396c0c --- /dev/null +++ b/backend/utils/trial_reminder.py @@ -0,0 +1,93 @@ +"""Trial-ending reminder and checkout trial terms. + +Readers who start a free trial are charged the full plan price when it ends. Support saw +customers surprised by that first charge, and the in-app trial offer promises a reminder +before anything is charged. Stripe sends `customer.subscription.trial_will_end` three days +before a trial ends; this module decides whether that event earns a reminder push and +what it says. Pure functions over Stripe dicts, so the webhook stays thin. +""" + +import math +from typing import Any, Mapping, Optional + +from utils.storefront_copy import format_money +from utils.subscription import get_plan_type_from_price_id, is_paid_plan + +TRIAL_ENDING_NOTIFICATION_TITLE = 'Your Omi Pro free trial ends soon' + + +def _when(trial_end: int, now: int) -> str: + days = math.ceil((trial_end - now) / 86400) + if days <= 1: + return 'in less than a day' if days <= 0 else 'tomorrow' + return f'in {days} days' + + +def recurring_terms(amount_minor: int, currency: str, interval: str) -> str: + """`US$269.91 a year` / `US$29.99 a month`.""" + return f"{format_money(amount_minor, currency)} a {'year' if interval == 'year' else 'month'}" + + +def trial_ending_reminder_body(uid: str, subscription: Mapping[str, Any], now: int) -> Optional[str]: + """Push body for a trial that will convert to a paid charge, or None when no reminder applies. + + No reminder when the trial is already set to cancel, belongs to another account, has a + discount (an included-year code makes the next charge something other than the list price), + or is not a paid Omi plan. + """ + if subscription.get('status') != 'trialing': + return None + if subscription.get('cancel_at_period_end') or subscription.get('cancel_at'): + return None + if subscription.get('collection_method', 'charge_automatically') != 'charge_automatically': + return None + owner = (subscription.get('metadata') or {}).get('uid') + if owner and owner != uid: + return None + trial_end = subscription.get('trial_end') + if not isinstance(trial_end, int) or trial_end <= now: + return None + if subscription.get('discount') or subscription.get('discounts'): + return None + items = (subscription.get('items') or {}).get('data') or [] + if len(items) != 1: + return None + price = items[0].get('price') or {} + try: + if not is_paid_plan(get_plan_type_from_price_id(price.get('id'))): + return None + except ValueError: + return None + amount = price.get('unit_amount') + currency = price.get('currency') + interval = (price.get('recurring') or {}).get('interval') + quantity = items[0].get('quantity') or 1 + if not isinstance(amount, int) or amount <= 0 or not currency or interval not in ('month', 'year'): + return None + terms = recurring_terms(amount * quantity, currency, interval) + return ( + f'It ends {_when(trial_end, now)}, then your card is charged {terms}. ' + 'To pay nothing, cancel before then in Settings.' + ) + + +def checkout_trial_terms(trial_days: int, amount_minor: Optional[int], currency: str, interval: Optional[str]) -> str: + """The line shown above Stripe Checkout's button for a free-trial session.""" + if not isinstance(amount_minor, int) or amount_minor <= 0 or interval not in ('month', 'year'): + return ( + f'No charge today. When your {trial_days}-day free trial ends, your card is charged ' + 'unless you cancel before then in the Omi app.' + ) + return ( + f'No charge today. When your {trial_days}-day free trial ends, your card is charged ' + f'{recurring_terms(amount_minor, currency, interval)}, less any code you apply, until you cancel. ' + 'Cancel in the Omi app before the trial ends and you pay nothing.' + ) + + +__all__ = [ + 'TRIAL_ENDING_NOTIFICATION_TITLE', + 'checkout_trial_terms', + 'recurring_terms', + 'trial_ending_reminder_body', +] From c820195d41ecb5a721ee0b771716dd6a4a670084 Mon Sep 17 00:00:00 2001 From: Nik Shevchenko Date: Sun, 15 Nov 2026 15:51:38 -0500 Subject: [PATCH 2/4] fix(billing): pyright-safe price id in trial reminder Co-Authored-By: Claude Opus 5.5 --- backend/utils/trial_reminder.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/utils/trial_reminder.py b/backend/utils/trial_reminder.py index a539f6396c0c..135d41527793 100644 --- a/backend/utils/trial_reminder.py +++ b/backend/utils/trial_reminder.py @@ -53,8 +53,11 @@ def trial_ending_reminder_body(uid: str, subscription: Mapping[str, Any], now: i if len(items) != 1: return None price = items[0].get('price') or {} + price_id = price.get('id') + if not isinstance(price_id, str): + return None try: - if not is_paid_plan(get_plan_type_from_price_id(price.get('id'))): + if not is_paid_plan(get_plan_type_from_price_id(price_id)): return None except ValueError: return None From 91f7548eb2953862209c2479a8c4f889fce720ab Mon Sep 17 00:00:00 2001 From: Nik Shevchenko Date: Sun, 15 Nov 2026 15:52:32 -0500 Subject: [PATCH 3/4] style: black Co-Authored-By: Claude Opus 5.5 --- backend/routers/payment.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/routers/payment.py b/backend/routers/payment.py index 409c6fd6d7a9..41546e20df34 100644 --- a/backend/routers/payment.py +++ b/backend/routers/payment.py @@ -1256,7 +1256,9 @@ async def _schedule_payment_recovery_notification( await dispatch_notification_async(intent) -async def _schedule_trial_ending_notification(background_tasks: BackgroundTasks | None, subscription_obj: Mapping) -> None: +async def _schedule_trial_ending_notification( + background_tasks: BackgroundTasks | None, subscription_obj: Mapping +) -> None: """Remind a trialing reader before the first charge (`customer.subscription.trial_will_end`). Only the account whose stored subscription is this one is reminded; a stale or foreign From d6dd58ddb80822873b912c7b2fa23c24390abf0c Mon Sep 17 00:00:00 2001 From: Nik Shevchenko Date: Sun, 15 Nov 2026 16:06:35 -0500 Subject: [PATCH 4/4] fix(billing): keep the single-line redis_db import the read-cache contract test pins Co-Authored-By: Claude Opus 5.5 --- backend/routers/payment.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/backend/routers/payment.py b/backend/routers/payment.py index 41546e20df34..2c9685204cd1 100644 --- a/backend/routers/payment.py +++ b/backend/routers/payment.py @@ -29,11 +29,8 @@ action_items as action_items_db, ) from database.locked_content import has_recoverable_locked_content -from database.redis_db import ( - claim_payment_recovery_notification, - claim_trial_ending_notification, - set_credits_invalidation_signal, -) +from database.redis_db import claim_payment_recovery_notification, set_credits_invalidation_signal +from database.redis_db import claim_trial_ending_notification from config.plan_catalog import _paid_period_still_open, plan_uses_overage from utils.subscription_state_machine import ( project_subscription,