Skip to content

fix(billing): remind trialing readers before the first charge and state it at checkout - #25428

Merged
kodjima33 merged 4 commits into
mainfrom
fix/trial-ending-reminder
Nov 15, 2026
Merged

kodjima33 merged 4 commits into
mainfrom
fix/trial-ending-reminder

Conversation

@kodjima33

@kodjima33 kodjima33 commented Nov 15, 2026 •

Copy link
Copy Markdown
Collaborator

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_end to api.omi.me/v1/stripe/webhook and nothing handled it.

  • Reminder push: 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.
  • Checkout terms: a trial Checkout session now shows the first charge above the button: "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." The USD line is unchanged.

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

  • Unit: tests/unit/test_trial_ending_reminder.py (new, 16 cases) plus the existing payment, billing, promo, webhook and checkout suites.
  • Stripe sandbox (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. Signed customer.subscription.trial_will_end events 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

kodjima33 and others added 3 commits November 15, 2026 15:49
…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>
@kodjima33

Copy link
Copy Markdown
Collaborator Author

Sandbox verification, Stripe test mode acct_1UDsEF01XKH14D0C (livemode=false). This branch's real code ran with its Stripe SDK calls routed through the stripe CLI.

Checkout: create_subscription_checkout_session(..., trial_period_days=7) on the sandbox yearly price (US$269.91) created hosted session cs_test_b0…. The hosted page renders, directly above Start trial:

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. All amounts are in USD (US dollars).

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 evt_1UcATN01XKH14D0CjWOtidvE, which was fed twice into _handle_stripe_event. Only Firestore, Redis and FCM were faked. Result: both deliveries returned success, and exactly one notification went out:
Your Omi Pro free trial ends soon / It ends in 2 days, then your card is charged US$269.91 a year. To pay nothing, cancel before then in Settings. (navigate_to /settings). The test clock was 2 days from trial end. In production Stripe sends this event 3 days before the end, so the push reads "in 3 days".

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>
@kodjima33
kodjima33 merged commit 71ad44c into main Nov 15, 2026
62 checks passed
@kodjima33
kodjima33 deleted the fix/trial-ending-reminder branch November 15, 2026 21:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant