Cancel Stripe subscription when an organization is deleted#946
Conversation
🤖 Claude Code ReviewPR: #946 Code Review: Cancel Stripe subscription when an organization is deletedOverall this is a clean, well-reasoned change. The refactor of Correctness & robustness (positives worth confirming)
Observations / minor concerns
Standards compliance (CLAUDE.md)
TestsUnit coverage ( Verdict: Approve. The two items to consider before merge are the doc note on intentional plan-row hard-delete during soft-delete (#1) and confirming the standalone-endpoint transient-error behavior (#2) is acceptable; neither blocks. Model: claude-opus-4-8 |
800e1e4 to
87d81e6
Compare
|
Thanks for the review. Addressed in Issue #1 (licensed asymmetry) - fixed. The licensed branch in Issue #2 (503 on Stripe outage) - intentional. Aborting the delete when the subscription status can't be resolved is the deliberate fail-safe: better to block deletion transiently than to delete the org and orphan a possibly-live subscription. It matches the Issue #3 (non-atomic partial-failure window) - acknowledged. A Stripe call can't sit inside the DB transaction, so cancel plus local-row-delete, then org-delete, is inherently two steps. As noted it is recoverable: a retry short-circuits on the now-missing plan row (and a gone/terminal subscription is skipped), then proceeds. Testing. The |
|
| Branch | u/ep/cancel-subscription-on-org-delete |
| Testbed | intel-v1 |
Click to view all benchmark results
| Benchmark | Latency | Benchmark Result microseconds (µs) (Result Δ%) | Upper Boundary microseconds (µs) (Limit %) |
|---|---|---|---|
| Adapter::Json | 📈 view plot 🚷 view threshold | 4.70 µs(+1.24%)Baseline: 4.64 µs | 4.90 µs (95.86%) |
| Adapter::Magic (JSON) | 📈 view plot 🚷 view threshold | 4.57 µs(+1.27%)Baseline: 4.51 µs | 4.72 µs (96.78%) |
| Adapter::Magic (Rust) | 📈 view plot 🚷 view threshold | 25.47 µs(-0.55%)Baseline: 25.61 µs | 26.77 µs (95.16%) |
| Adapter::Rust | 📈 view plot 🚷 view threshold | 3.50 µs(+0.29%)Baseline: 3.49 µs | 3.61 µs (97.08%) |
| Adapter::RustBench | 📈 view plot 🚷 view threshold | 3.50 µs(+0.41%)Baseline: 3.49 µs | 3.60 µs (97.29%) |
87d81e6 to
6981328
Compare
|
Thanks, another good catch. Addressed in Finding #1 (swallowing all DB errors as "no plan") - fixed. Finding #2 (tests). Added a deterministic unit test ( The remaining paths you list ( Observations.
|
6981328 to
519a9ca
Compare
519a9ca to
c521265
Compare
c521265 to
5eaca06
Compare
Deleting an organization left its Stripe subscription live, so the customer kept being billed for an organization that no longer exists. Cancel any active subscription and remove the local plan row before deleting the organization, in both the soft-delete and admin hard-delete paths. Cancel first so a Stripe failure aborts the deletion and never orphans a live subscription. Make delete_plan status-aware for both metered and licensed plans so an already-canceled or gone subscription is skipped instead of erroring, which keeps the user-facing delete from failing and also makes the admin plan-delete endpoint idempotent.
5eaca06 to
9c9ae4c
Compare
Problem
When an organization has a paid Stripe subscription, deleting the organization left the subscription live in Stripe, so the customer kept being billed for an organization that no longer exists. Organization deletion (
delete_inner) never touched theplantable or Stripe in either the soft-delete or the admin hard-delete path.Fix
Cascade an immediate subscription cancellation on organization deletion, reusing the existing cancellation logic:
cancel_and_remove_planhelper (lib/api_organizations/src/plan.rs) cancels any live Stripe subscription and removes the localplanrow. It is a no-op when there is no biller (Self-Hosted) or no plan row.delete_inner(lib/api_organizations/src/organizations.rs) calls it under theplusfeature in both the soft-delete and admin hard-delete branches, before the organization is deleted. Cancel-first means a Stripe failure aborts the deletion, so a live subscription is never orphaned (the delete stays retryable).delete_planis now status-aware for metered plans: it checksmetered_plan_statusand only callscancel_metered_subscriptionwhen the subscription is still live (reusingexisting_plan_action). An already-canceled subscription is skipped instead of erroring, which also makes the adminDELETE .../planendpoint idempotent.Edge cases
delete_planbranch.Testing
cargo nextest run -p api_organizations --features plus: 40/40, including every deletion test (the new#[cfg(feature = "plus")]path cleanly no-ops with no biller configured).cargo clippy --all-targets --all-features -Dwarnings,cargo check --no-default-features,cargo test --doc,cargo fmt --check: all clean.cargo gen-types); only the organization-delete endpoint description changed.The full end-to-end "delete org cancels Stripe" path is not exercised in the standard suite because the API test harness uses
biller: Nonewith no mock Stripe. The decision logic (existing_plan_action) and the Stripe building blocks (theTEST_BILLING_KEY-gatedbiller()test) are covered. A biller mock for deterministic end-to-end coverage is a suggested follow-up.