Repository navigation
fix(billing): keep paid access until period end after immediate cancellation - #21339
Conversation
Git-on-my-level
left a comment
There was a problem hiding this comment.
Thanks for this — the diagnosis is well-grounded and the change is tight. I verified the logic against the head:
Code review
backend/routers/payment.py(_build_subscription_from_stripe_object, lines 232–250): the grace branch is narrowly guarded — onlystatus == 'canceled', requires a futurecurrent_period_end, and an unresolvable price ID falls back to Basic rather than inventing a plan. The returned row mirrors the cancel-at-period-end representation (status=active,cancel_at_period_end=True), soget_user_valid_subscription's existing paid-period validity check inbackend/database/users.pypicks it up with no changes there. Clean.- Downstream in the webhook handler (~lines 1099–1198): because the built row is an active paid plan, the stale-downgrade guard is bypassed and
emit_billing_product_eventnow records retention rather than churn for immediate cancels until period end. That reads as intended, but it does change billing-telemetry semantics for immediate cancellations — worth a maintainer confirming that's desired. backend/tests/unit/test_stripe_webhook_behavioral.py: the three new tests (keep-paid-plan, unresolvable-price → Basic, unpaid → Basic) cover the important boundaries. Injectingtimeinto the exec namespace is fine here sincepayment.pyalready importstimeat module scope.
Blocking (PR metadata, not code)
- Hygiene / PR Metadata Preflight fail the product-file line-count ratchet:
backend/routers/payment.pygrew 1646 → 1669 against main. Please add to the PR body:
Line-Count-Exception: backend/routers/payment.py | 1646 -> 1669 | <reason>
(or reduce growth if you'd rather not take the exception). - failure-class-protocol: with a
fix:commit subject, the PR body needs aFailure-Class: FC-<lower-kebab-slug>declaration.
Questions before this moves forward
- Can you attach a sanitized
customer.subscription.deletedpayload from a test-mode immediate cancellation? The fix hinges on Stripe delivering a future top-levelcurrent_period_endon that event; newer Stripe API versions movedcurrent_period_endto subscription items, so concrete payload evidence would confirm the trigger condition fires in practice for this account. - Billing policy: portal immediate cancellation can be configured to issue a prorated refund/credit. In that case the customer has been refunded the unused time, and this grace would keep paid access until period end anyway. Should access preservation depend on whether a refund/credit was issued? That's a product/billing call for a maintainer.
Once the two metadata declarations are added and the payload/policy questions are answered, this should be in good shape. Since it changes entitlement behavior on the billing path, it needs maintainer sign-off on the policy questions before merge.
— automated review on behalf of the Omi maintainers
by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.
9992ec4 to
13f47ba
Compare
|
Rebased onto main and pushed — new head Why a force-push: main landed a Stripe state-machine refactor (#21373) that moved the Basic downgrade into What the rebase keeps from main:
PR body updated with: Local receipts: CI is starting fresh on the new head; watching for green. |
…llation A subscription canceled immediately (Stripe portal 'cancel immediately' mode) arrives as status='canceled' while current_period_end is still in the future. _build_subscription_from_stripe_object downgraded those users to Basic on the spot, stripping weeks of already-paid access — the app then showed 'Upgrade to Unlimited' to a paying customer. Preserve the paid plan (active, cancel_at_period_end=True) until current_period_end, mirroring cancel-at-period-end handling and get_user_valid_subscription's paid-period grace. Unpaid/incomplete and post-period cancellations still downgrade immediately.
13f47ba to
23dec8e
Compare
Problem
A user on Unlimited cancels their plan, but the cancellation takes effect immediately instead of at period end — the customer paid through
current_period_end(e.g. Nov 3), yet the app immediately shows "Upgrade to Unlimited" and paid features lock.Root cause
When a subscription is cancelled via the Stripe customer portal in "cancel immediately" mode, Stripe fires
customer.subscription.deleted/updatedwithstatus='canceled'whilecurrent_period_endis still in the future._build_subscription_from_stripe_object(backend/routers/payment.py) downgrades any non-active/trialingstatus to Basic on the spot, discarding the remaining paid period. The stored row becomesbasic, soget_user_valid_subscription's paid-period grace indatabase/users.pynever applies — the plan is already Basic.The in-app cancel path is unaffected: it sets
cancel_at_period_end=true(status staysactive).Fix
In
_build_subscription_from_stripe_object: when a subscription event carries statuscanceledbutcurrent_period_endis still in the future and the price resolves to a known paid plan, keep that plan (statusactive,cancel_at_period_end=True, unchanged period end) — the exact same representation the normal cancel-at-period-end path produces. Everything downstream (get_user_valid_subscription, app plan gating, reactivation) then behaves identically to a scheduled cancellation.The grace candidate is downgrade-shaped for main's stale-downgrade protection: if the stored row is a newer valid paid subscription, or Stripe exposes a different active paid subscription, the event keeps main's semantics (shield short-circuit or replacement adoption, Basic fallback otherwise). The grace also only fires when the canceled event's subscription id is the user's current subscription — a superseded id keeps the plain downgrade. Schedule-completed/canceled and checkout paths are untouched (they build without the grace grant).
Unchanged behavior:
unpaid/incomplete_expired, cancellations after period end, and unresolvable price IDs still downgrade to Basic immediately.New failure class — an early terminal event (portal cancel-immediately) must not strip a period the customer already paid for. The definition file
.github/failure-classes/FC-early-terminal-strips-paid-period.jsonis added in this commit range alongside the fix;evidence_prsis empty because this PR is the first instance.Failure-Class: new
Line-Count-Exception: backend/routers/payment.py | 1639 -> 1715 | payment.py concentrates the Stripe webhook projection; the grace branch plus its stale-shield routing need the price-resolution and projection helpers already in this file, and splitting one entitlement decision across modules would scatter the cancel flow
Review answers
Q3 — does the trigger actually fire (top-level
current_period_end)? Yes. The backend pinsstripe==11.3.0(backend/requirements.txt), whose requests default to Stripe API version2024-11-20.acacia(verified from the installed library:stripe.api_version). In the acacia line,current_period_endandcurrent_period_startremain top-level subscription fields; the move to per-item/current-item period fields only affects API versions after 2025-03-31.basil. So thecustomer.subscription.deletedpayload for an immediately-cancelled, paid-through subscription carries a future top-levelcurrent_period_end, and the branch fires.Q4 — refunds (billing policy). Preserving access depends only on Stripe's own entitlement signal, exactly as it already does for scheduled cancellations: a scheduled cancel keeps access until
current_period_endeven when the portal issues a prorated credit, so an immediate cancel now behaves identically to the existing scheduled-cancel path rather than being treated more harshly. If policy later wants refund-aware entitlement, that is a follow-up change to both cancel paths together, not a reason this parity fix should keep a worse inconsistency in place.Merge conflict with main. Resolved by rebasing the grace branch onto main's state-machine refactor: the inactive path keeps the new grace branch, the Basic fallback goes through main's
project_subscription(stripe_sub, None, get_basic_plan_limits()), and grace candidates flow throughtransition()with agrace_paidmarker so the stale-downgrade shield and replacement adoption still apply. The behavioral test harness namespace carries both thetimeandproject_subscriptionentries. The lifecycle characterization matrix pins the new canceled-with-future-period-end expectation; every other characterization row (stale shield, adoption, schedule matrices) is unchanged and green.Testing
backend/tests/unit/test_stripe_webhook_behavioral.py(builder is extracted and executed directly):cancel_at_period_end=Trueunpaid+ future period end → Basic (grace limited tocanceled)One known trade-off: the
customer.subscription.deletedwebhook for an immediately-canceled-but-paid-through sub now countsresulting_paid_subscription_idas non-null in billing-product telemetry, so the Stripe-side churn event is still recorded raw but the projection may under-count immediate churn. Entitlement correctness for the paying customer took priority.