Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions backend/database/redis_db.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import ast

Check warning on line 1 in backend/database/redis_db.py

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

backend/database/redis_db.py is 1588 lines; consider splitting files over 800 lines.
import base64
import json
from typing import Any, Callable, Dict, List, Optional, TypeVar, Union, cast
Expand Down Expand Up @@ -1012,6 +1012,26 @@
)


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 *********
# ******************************************************
Expand Down
43 changes: 43 additions & 0 deletions backend/routers/payment.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Payments surface: plan catalog serving, checkout/upgrade/cancel, Stripe

Check warning on line 1 in backend/routers/payment.py

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

backend/routers/payment.py is 2189 lines; consider splitting files over 800 lines.
webhooks and Connect onboarding, PayPal details, and the customer portal.

Prices come only from the generated plan catalog -- current Stripe amounts are
Expand Down Expand Up @@ -30,6 +30,7 @@
)
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_trial_ending_notification
from config.plan_catalog import _paid_period_still_open, plan_uses_overage
from utils.subscription_state_machine import (
project_subscription,
Expand Down Expand Up @@ -57,6 +58,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,
Expand Down Expand Up @@ -488,7 +490,7 @@


@router.get('/v1/payments/available-plans', response_model=AvailablePlansResponse)
def get_available_plans_endpoint(

Check warning on line 493 in backend/routers/payment.py

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

get_available_plans_endpoint is 166 lines; consider extracting focused helpers over 150 lines.
# Payment / plan surfaces must stay reachable even if BYOK fingerprints
# drift (e.g. user rotated a key locally without re-activating). Otherwise
# a broken-BYOK user can't see or change their plan to recover.
Expand Down Expand Up @@ -887,7 +889,7 @@

@router.post('/v1/payments/upgrade-subscription', response_model=PaymentUpgradeSubscriptionResponse)
@fresh_reader
def upgrade_subscription_endpoint(request: UpgradeSubscriptionRequest, uid: str = Depends(auth.get_current_user_uid)):

Check warning on line 892 in backend/routers/payment.py

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

upgrade_subscription_endpoint is 237 lines; consider extracting focused helpers over 150 lines.
"""Upgrade or change a user's subscription plan.

- Cross-plan changes (e.g. Unlimited→Pro): immediate swap via Subscription.modify(),
Expand Down Expand Up @@ -1251,6 +1253,43 @@
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')
Expand Down Expand Up @@ -1318,7 +1357,11 @@


@fresh_reader
async def _handle_stripe_event(event: dict, background_tasks: BackgroundTasks | None = None):

Check warning on line 1360 in backend/routers/payment.py

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

_handle_stripe_event is 468 lines; consider extracting focused helpers over 150 lines.
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')
Expand Down
1 change: 1 addition & 0 deletions backend/tests/unit/test_payment_declined_proration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
5 changes: 4 additions & 1 deletion backend/tests/unit/test_payment_promo_adaptive_pricing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
1 change: 1 addition & 0 deletions backend/tests/unit/test_payment_promotion_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@
# ---------------------------------------------------------------------------


def _setup_payment_module(include_client: bool = True) -> Any:

Check warning on line 464 in backend/tests/unit/test_payment_promotion_codes.py

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

_setup_payment_module is 162 lines; consider extracting focused helpers over 150 lines.
"""Import payment router with all heavy deps mocked."""
# Mock heavy modules before importing
for mod_name in [
Expand Down Expand Up @@ -498,6 +498,7 @@

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"]
Expand Down
197 changes: 197 additions & 0 deletions backend/tests/unit/test_trial_ending_reminder.py
Original file line number Diff line number Diff line change
@@ -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 == []
1 change: 1 addition & 0 deletions backend/utils/notification_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
21 changes: 17 additions & 4 deletions backend/utils/stripe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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}]
Expand Down
Loading
Loading