fix(admin): archive Stripe plans all-or-nothing with a member guard (BUNYIP-512) #513
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/BUNYIP-512-archive-cascade-and-member-guard"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What and why
Two defects on the admin Stripe page (
/admin/stripe), both on the archive path (BUNYIP-512).Archiving a product set the product
active = falsebut left its pricesactive(prices are a separate Stripe object), andStripeService::list_pricesnever filtered onproduct.active, so the orphaned prices kept rendering as Active. An active price on an archived product is still usable in a checkout session, so the "archived" plan was not actually withdrawn. Separately, nothing stopped an admin archiving a plan that still had members: what happens to an active member whose plan is archived under them (entitlements, renewal, tier fallback) is undefined, so the decision here is to refuse the archive while members exist rather than define that behaviour.What changed
Archiving a plan is now all-or-nothing and safe.
UserRepository::count_members_for_plan(pool, tiers, price_ids)counts users withmembership_statusin (active, grace_period, past_due) on the plan's tiers, plus anyone whoselocked_price_idis one of the plan's prices, excluding soft-deleted rows. The tier and locked-price arms are OR'd so a user matching both is counted once. A non-zero count refuses the archive with HTTP 409 and a message naming the count (which surfaces as a toast in the web UI); no Stripe call is made.archive_stripe_productarchives every active price of the product first, then the product, so no active price is ever left under an archived product. A partial price failure is returned naming the archived and failed price ids, logged at error, and never returns a success status (the product is left active and visible).archive_stripe_pricehas nothing to cascade to, so it is guard-only.tier_configprice and product column, including the one-timelifetime_price_id. That column has existed since20260429000045_add_price_ids_to_tier_config.sqlbutTierConfigRownever mapped it; this PR adds it (read-only path; the catalog form still does not set it).GET /v1/admin/stripe/productsand/pricesnow wrap each row with amember_count, computed by one groupedplan_member_indexquery for the whole page (grouped by(active_tier, locked_price_id)), not one query per row.bunyip-web'sStripeProduct/StripePricegainmember_countwith#[serde(default)]; it is not identity-bearing, so it is not inESSENTIAL_FIELDSandscripts/check-serde-compat.nustays green.AdminStripePlanArchived(admin_stripe_plan_archived) entry with product id, archived price ids and member count.audit_logs.actionis free-textVARCHAR(100), so no migration.The guard lives in the archive handlers, not in
dunite-stripe, which stays domain-free.Deviations from the ticket (worth a look)
lifetime_price_idamong the tier columns as if already mapped; it existed in the DB but was missing fromTierConfigRow. Added.POST /v1/admin/stripe/prices/{id}/replace) does not exist yet onmain(PR #511 was a different change). The guard is structured so a future replace endpoint bypasses it by callingStripeService::archive_pricedirectly; a module comment records this. There is no replace endpoint to write the exemption test against yet, so that one acceptance-criterion test is deferred to when the endpoint lands.Known limit
The guard reads bunyip's own DB. A live Stripe subscription on a price no longer referenced by
tier_configand not anyone'slocked_price_idis invisible to it, becausedunite-stripehas no "list subscriptions for a price" call. Adding that is tracked in DUNITE-9 and is not required here.Tests
count_members_for_plan_sql_guards_status_locked_price_and_soft_deleteasserts the predicate set on the const SQL (no live DB), mirroring theCOUNT_TIER_ASSIGNMENTS_SQLregression.plan_member_index_counts_or_semantics_without_double_countingproves the page-wide index matches the single-plan OR count with no double count.lifetime_price_idcolumn, and the 409 pluralization.just check-containergreen (fmt + clippy + full workspace tests);scripts/check-serde-compat.nugreen.🤖 Generated with Claude Code
https://claude.ai/code/session_01GkNbvEq6awuMRULFCiYKe3