Skip to content

fix(billing): keep paid access until period end after immediate cancellation - #21339

Merged
Git-on-my-level merged 1 commit into
BasedHardware:mainfrom
axAilotl:fix/cancel-immediate-paid-through-grace
Oct 13, 2026
Merged

Git-on-my-level merged 1 commit into
BasedHardware:mainfrom
axAilotl:fix/cancel-immediate-paid-through-grace

Conversation

@axAilotl

@axAilotl axAilotl commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

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 / updated with status='canceled' while current_period_end is still in the future. _build_subscription_from_stripe_object (backend/routers/payment.py) downgrades any non-active/trialing status to Basic on the spot, discarding the remaining paid period. The stored row becomes basic, so get_user_valid_subscription's paid-period grace in database/users.py never applies — the plan is already Basic.

The in-app cancel path is unaffected: it sets cancel_at_period_end=true (status stays active).

Fix

In _build_subscription_from_stripe_object: when a subscription event carries status canceled but current_period_end is still in the future and the price resolves to a known paid plan, keep that plan (status active, 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.json is added in this commit range alongside the fix; evidence_prs is 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 pins stripe==11.3.0 (backend/requirements.txt), whose requests default to Stripe API version 2024-11-20.acacia (verified from the installed library: stripe.api_version). In the acacia line, current_period_end and current_period_start remain top-level subscription fields; the move to per-item/current-item period fields only affects API versions after 2025-03-31.basil. So the customer.subscription.deleted payload for an immediately-cancelled, paid-through subscription carries a future top-level current_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_end even 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 through transition() with a grace_paid marker so the stale-downgrade shield and replacement adoption still apply. The behavioral test harness namespace carries both the time and project_subscription entries. 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):

  • immediately-canceled + future period end → keeps Unlimited, active, cancel_at_period_end=True
  • immediately-canceled + unresolvable price → Basic (grace cannot invent a plan)
  • unpaid + future period end → Basic (grace limited to canceled)
  • canceled paid-through without the grace grant (schedule/checkout paths) → Basic
  • existing canceled-after-period-end cases remain Basic
337 passed  # test_stripe_webhook_behavioral, test_stripe_webhook_none_guard, test_payment_cancel_subscription_http_exception, test_subscription_ingestion_characterization, test_subscription_state_machine, test_stripe_subscription_terminal_status

One known trade-off: the customer.subscription.deleted webhook for an immediately-canceled-but-paid-through sub now counts resulting_paid_subscription_id as 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.

View guided diff

@Git-on-my-level Git-on-my-level left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 — only status == 'canceled', requires a future current_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), so get_user_valid_subscription's existing paid-period validity check in backend/database/users.py picks 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_event now 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. Injecting time into the exec namespace is fine here since payment.py already imports time at module scope.

Blocking (PR metadata, not code)

  1. Hygiene / PR Metadata Preflight fail the product-file line-count ratchet: backend/routers/payment.py grew 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).
  2. failure-class-protocol: with a fix: commit subject, the PR body needs a Failure-Class: FC-<lower-kebab-slug> declaration.

Questions before this moves forward

  1. Can you attach a sanitized customer.subscription.deleted payload from a test-mode immediate cancellation? The fix hinges on Stripe delivering a future top-level current_period_end on that event; newer Stripe API versions moved current_period_end to subscription items, so concrete payload evidence would confirm the trigger condition fires in practice for this account.
  2. 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.

@Git-on-my-level Git-on-my-level added security-review Touches auth, provider routing, secrets, or security-sensitive surfaces needs-maintainer-review Needs a human maintainer to sign off before merge backend Backend Task (python) labels Oct 12, 2026
@Git-on-my-level
Git-on-my-level force-pushed the fix/cancel-immediate-paid-through-grace branch from 9992ec4 to 13f47ba Compare October 13, 2026 14:13
@Git-on-my-level

Copy link
Copy Markdown
Collaborator

Rebased onto main and pushed — new head 13f47bac0991 (was 9992ec4b6d09, force-pushed via --force-with-lease against the reviewed head; reviewed commit content is preserved).

Why a force-push: main landed a Stripe state-machine refactor (#21373) that moved the Basic downgrade into project_subscription() and added a stale-downgrade guard plus replacement adoption. Rebasing the PR replaced the branch head, which a same-repo push cannot do non-fast-forward.

What the rebase keeps from main:

  • The inactive path falls back through main's project_subscription(stripe_sub, None, get_basic_plan_limits()) — no second downgrade implementation.
  • The grace candidate flows through transition() with a grace_paid marker, so it is downgrade-shaped for the stale-downgrade shield: a newer stored paid subscription still short-circuits the event, and an adoptable active replacement still wins. Analysis of the characterization suite showed the un-guarded grace would have let a redelivered deleted event for an old subscription id resurrect a paid plan over a newer live one (test_adoption_preserves_replacement_bytes_and_does_not_copy_stale_customer pins exactly this) — the fix now routes grace through the same protection.
  • The grace only fires when the canceled event's subscription id is the user's current subscription; a superseded id keeps main's plain downgrade.
  • Schedule-completed/canceled and checkout paths build without the grace grant — main's behavior unchanged there.

PR body updated with: Failure-Class: new + definition .github/failure-classes/FC-early-terminal-strips-paid-period.json (added in-range per the protocol), the exact Line-Count-Exception the ratchet demands (backend/routers/payment.py | 1639 -> 1715), and answers to Q3 (Stripe pin stripe==11.3.0 → API 2024-11-20.acacia, top-level current_period_end still live, so the trigger fires) and Q4 (grace mirrors the existing scheduled-cancel semantics; refund-aware entitlement would be a follow-up for both cancel paths).

Local receipts: backend/test.sh with the six affected suites → exit 0, 232 passed with the repo's CPU guard; full pre-push gate passed locally before the transfer.

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.
@Git-on-my-level
Git-on-my-level force-pushed the fix/cancel-immediate-paid-through-grace branch from 13f47ba to 23dec8e Compare October 13, 2026 14:40
@Git-on-my-level
Git-on-my-level merged commit 508226f into BasedHardware:main Oct 13, 2026
48 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Backend Task (python) needs-maintainer-review Needs a human maintainer to sign off before merge security-review Touches auth, provider routing, secrets, or security-sensitive surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants