Skip to content

Fix billing downgrade recharge and suspension race - #2397

Merged
niemyjski merged 3 commits into
mainfrom
issue/billing-downgrade-race
Jul 30, 2026
Merged

Fix billing downgrade recharge and suspension race#2397
niemyjski merged 3 commits into
mainfrom
issue/billing-downgrade-race

Conversation

@niemyjski

@niemyjski niemyjski commented Jul 24, 2026

Copy link
Copy Markdown
Member

Summary

  • Prorate and immediately invoice paid-to-free subscription cancellations so unused time becomes customer credit before a later upgrade.
  • Ignore subscription deletion webhooks created before the organization latest billing change, preventing a delayed old deletion from suspending the newer free or paid state.
  • Add focused endpoint coverage for cancellation options and locally signed stale webhook delivery.

Root cause

Paid-to-free changes canceled the active subscription with default options, so no prorated credit was finalized. A delayed deletion webhook could then overwrite billing state saved by the downgrade or a later upgrade.

Verification

  • dotnet build Exceptionless.slnx --no-restore -m:1 — 0 warnings, 0 errors
  • OrganizationEndpointTests — 84/84 passed
  • StripeEndpointTests — 6/6 passed, including locally HMAC-signed stale deletion events against both Trialing and Active state
  • Local-only Stripe boundary — no Stripe API, dashboard, Stripe.js, keys, or production URLs used

Breaking changes

None.

@niemyjski

niemyjski commented Jul 26, 2026

Copy link
Copy Markdown
Member Author

Follow-up audit completed against live head 101b6d236 and the complete branch-vs-origin/main diff.

Feedback inventory:

  • GraphQL review threads: 0
  • REST inline review comments: 0
  • Submitted reviews: 0
  • Human, Codex, and Copilot findings: 0
  • Top-level comments: 1 GitHub Actions coverage badge, classified as informational/non-actionable
  • Old/outdated/already-fixed/superseded/incorrect/still-actionable reviewer findings: 0 in every category because no reviewer finding is currently recorded

The thermo-nuclear review did find one real retry-state defect in the live implementation. RCA: the definitive Stripe-rejection catch cleared a matching pending operation only when that operation was created by the current request. An identical resumed request had already proven ownership by matching plan + fingerprint, but a 400/other definitive provider rejection still left its pending marker behind, permanently blocking corrected billing details.

Fix in 2751d3e13:

  • clear a matching resumed pending operation after a definitive provider rejection
  • preserve fail-closed behavior for mismatched or uncertain operations
  • add a focused regression proving the rejected resumed payment method is cleared and a corrected payment method can start a new operation
  • delete the duplicate free-plan constant and use canonical BillingPlans.FreePlanId

Verification:

  • dotnet build Exceptionless.slnx -m:1 --no-restore — passed, 0 warnings / 0 errors
  • new resumed-rejection regression — 1/1 passed
  • complete billing lifecycle suite — 144/144 passed
  • billing unit suite — 39/39 passed
  • usage service — 21/21 passed
  • organization repository — 12/12 passed
  • project repository — 7/7 passed
  • public API compatibility — 15/15 passed
  • git diff --check — clean
  • no PR-changed file crossed from below 1,000 lines to above 1,000 lines

Broader local suite note: the full backend run reached an unrelated shared Elasticsearch limit (1000/1000 open shards) and could not create a migration-test index. I did not delete shared indices owned by other worktrees.

Final GitHub CI for 2751d3e13 is green: API, client, E2E, Docker, version, and CLA passed; deploy/publish/preview jobs skipped as expected.

@niemyjski
niemyjski force-pushed the issue/billing-downgrade-race branch 2 times, most recently from 10a0595 to 494b772 Compare July 30, 2026 17:07
@niemyjski
niemyjski force-pushed the issue/billing-downgrade-race branch from 494b772 to fdbe4fc Compare July 30, 2026 17:08
@niemyjski niemyjski self-assigned this Jul 30, 2026
@niemyjski
niemyjski marked this pull request as ready for review July 30, 2026 17:13

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fdbe4fc37d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


_logger.LogInformation("Stripe subscription deleted. Customer: {CustomerId} Org: {Organization} Org Name: {OrganizationName}", sub.CustomerId, org.Id, org.Name);

if (org.BillingChangeDate > DateTime.MinValue && eventCreatedUtc < org.BillingChangeDate)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Compare deletions against a Stripe event watermark

When Stripe generates an earlier customer.subscription.updated event and a later deletion before either webhook is delivered, processing the update first sets BillingChangeDate to the current handler time; the deletion's creation time is then earlier than that value and this branch acknowledges but discards the genuinely newer deletion. Because Stripe does not guarantee immediate delivery, the organization can remain active or past due after its subscription was canceled. Track the latest Stripe source-event timestamp separately, or otherwise distinguish local plan transitions from webhook receipt times, and cover queued/out-of-order events.

AGENTS.md reference: AGENTS.md:L74-L74

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown

Code Coverage

Package Line Rate Branch Rate Complexity Health
Exceptionless.Core 74% 66% 9626
Exceptionless.AppHost 38% 40% 139
Exceptionless.Insulation 37% 35% 286
Exceptionless.Web 84% 67% 6935
Summary 77% (22964 / 29881) 66% (10537 / 16064) 16986

@niemyjski
niemyjski merged commit 53e7b22 into main Jul 30, 2026
12 checks passed
@niemyjski
niemyjski deleted the issue/billing-downgrade-race branch July 30, 2026 17:35
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