Repository navigation
fix(billing): remind trialing readers before the first charge and state it at checkout - #25428
Conversation
…te it at checkout Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Sandbox verification, Stripe test mode Checkout:
A non-trial session still shows only the USD line. (The page also shows Stripe's own "Add promotion code" button and "Total due today $0.00".) trial_will_end: set up a sandbox customer on a test clock with a trialing yearly subscription, then advanced the clock 5 days. Stripe emitted the real event Not exercised: real FCM delivery to a phone and the prod Redis claim. |
…tract test pins Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What changed and why
Trial readers get charged the full plan price when the trial ends. In the last 30 days, 14 Intercom customers said a trial turned into a charge they didn't expect, and 11 of those wrote after Nov 2. Several of them filed refunds or disputes. The in-app 30-day trial sheet also promises "We'll remind you before anything is charged", but nothing sent that reminder: Stripe already delivers
customer.subscription.trial_will_endtoapi.omi.me/v1/stripe/webhookand nothing handled it.customer.subscription.trial_will_end(3 days before the trial ends) now sends one push: "It ends in 3 days, then your card is charged US$269.91 a year. To pay nothing, cancel before then in Settings." Tapping it opens Settings. It goes only to the account whose stored subscription is this one. It never goes out for a trial that is already cancelling, invoiced, or discounted, since an included-year code means the charge isn't the list price. A Redis claim per (subscription, trial_end) dedupes Stripe retries. A claim failure stays retryable.The decision logic is pure (
utils/trial_reminder.py), and the webhook handler is a thin wrapper around it.Product invariants affected
none
How it was verified
tests/unit/test_trial_ending_reminder.py(new, 16 cases) plus the existing payment, billing, promo, webhook and checkout suites.acct_1UDsEF01XKH14D0C, test mode), real path: the local backend on this branch created a trial Checkout session, and the hosted page shows the new terms above the button. Signedcustomer.subscription.trial_will_endevents for a real sandbox trialing subscription went to the local webhook and produced exactly one dispatched notification across two deliveries. Results are in the PR comments.Tests
backend/tests/unit/test_trial_ending_reminder.py: reminder copy, every no-reminder case (cancelling, discounted, foreign owner, ended, invoiced, unknown price), one push across redeliveries, stale subscription sends nothing, claim failure stays retryable, checkout session text.UI evidence
UI-Evidence: none -- backend-only; Stripe-hosted page proof attached as a PR comment
Line-Count-Exception: backend/routers/payment.py | 2146 -> 2189 | trial_will_end handler sits beside the existing payment recovery push scheduler it mirrors
Line-Count-Exception: backend/database/redis_db.py | 1568 -> 1588 | dedupe claim sits beside claim_payment_recovery_notification
Failure class (fixes)
Failure-Class: FC-subscription-price-presentment-drift
🤖 Generated with Claude Code