fix(admin): archive Stripe plans all-or-nothing with a member guard (BUNYIP-512) #513

Merged
longjacksonle merged 1 commit from fix/BUNYIP-512-archive-cascade-and-member-guard into main 2026-08-12 04:17:46 +02:00

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 = false but left its prices active (prices are a separate Stripe object), and StripeService::list_prices never filtered on product.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.

  • Member guard (runs before any Stripe call). UserRepository::count_members_for_plan(pool, tiers, price_ids) counts users with membership_status in (active, grace_period, past_due) on the plan's tiers, plus anyone whose locked_price_id is 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.
  • Cascade. archive_stripe_product archives 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_price has nothing to cascade to, so it is guard-only.
  • Tier resolution reads every tier_config price and product column, including the one-time lifetime_price_id. That column has existed since 20260429000045_add_price_ids_to_tier_config.sql but TierConfigRow never mapped it; this PR adds it (read-only path; the catalog form still does not set it).
  • List endpoints. GET /v1/admin/stripe/products and /prices now wrap each row with a member_count, computed by one grouped plan_member_index query for the whole page (grouped by (active_tier, locked_price_id)), not one query per row. bunyip-web's StripeProduct / StripePrice gain member_count with #[serde(default)]; it is not identity-bearing, so it is not in ESSENTIAL_FIELDS and scripts/check-serde-compat.nu stays green.
  • Admin UI. Rows with members render a disabled Archive control plus the count instead of a live button (the server guard is authoritative; this is a courtesy). The product archive confirmation now says its prices are archived too. An active product with no active price is flagged with a warning badge, so the unsellable state a future unarchive can leave behind is visible; when the price list failed to load, no such claim is made.
  • Audit. Each archive writes an AdminStripePlanArchived (admin_stripe_plan_archived) entry with product id, archived price ids and member count. audit_logs.action is free-text VARCHAR(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)

  • The ticket lists lifetime_price_id among the tier columns as if already mapped; it existed in the DB but was missing from TierConfigRow. Added.
  • The BUNYIP-511 price-replace endpoint (POST /v1/admin/stripe/prices/{id}/replace) does not exist yet on main (PR #511 was a different change). The guard is structured so a future replace endpoint bypasses it by calling StripeService::archive_price directly; 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.
  • No user-facing doc describes product/price archiving today (only a design gap-matrix that references stale line numbers), so there was nothing to update. The behaviour is documented in code comments.

Known limit

The guard reads bunyip's own DB. A live Stripe subscription on a price no longer referenced by tier_config and not anyone's locked_price_id is invisible to it, because dunite-stripe has 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_delete asserts the predicate set on the const SQL (no live DB), mirroring the COUNT_TIER_ASSIGNMENTS_SQL regression.
  • plan_member_index_counts_or_semantics_without_double_counting proves the page-wide index matches the single-plan OR count with no double count.
  • Handler tests cover tier resolution by product id, by price id, the lifetime_price_id column, and the 409 pluralization.
  • Web block tests cover the disabled-archive rendering + member-count text (products and prices), the singular label, the no-active-price warning, and no-warning-when-prices-unknown.
  • just check-container green (fmt + clippy + full workspace tests); scripts/check-serde-compat.nu green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GkNbvEq6awuMRULFCiYKe3

## 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 = false` but left its prices `active` (prices are a separate Stripe object), and `StripeService::list_prices` never filtered on `product.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. - Member guard (runs before any Stripe call). `UserRepository::count_members_for_plan(pool, tiers, price_ids)` counts users with `membership_status` in (active, grace_period, past_due) on the plan's tiers, plus anyone whose `locked_price_id` is 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. - Cascade. `archive_stripe_product` archives 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_price` has nothing to cascade to, so it is guard-only. - Tier resolution reads every `tier_config` price and product column, including the one-time `lifetime_price_id`. That column has existed since `20260429000045_add_price_ids_to_tier_config.sql` but `TierConfigRow` never mapped it; this PR adds it (read-only path; the catalog form still does not set it). - List endpoints. `GET /v1/admin/stripe/products` and `/prices` now wrap each row with a `member_count`, computed by one grouped `plan_member_index` query for the whole page (grouped by `(active_tier, locked_price_id)`), not one query per row. `bunyip-web`'s `StripeProduct` / `StripePrice` gain `member_count` with `#[serde(default)]`; it is not identity-bearing, so it is not in `ESSENTIAL_FIELDS` and `scripts/check-serde-compat.nu` stays green. - Admin UI. Rows with members render a disabled Archive control plus the count instead of a live button (the server guard is authoritative; this is a courtesy). The product archive confirmation now says its prices are archived too. An active product with no active price is flagged with a warning badge, so the unsellable state a future unarchive can leave behind is visible; when the price list failed to load, no such claim is made. - Audit. Each archive writes an `AdminStripePlanArchived` (`admin_stripe_plan_archived`) entry with product id, archived price ids and member count. `audit_logs.action` is free-text `VARCHAR(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) - The ticket lists `lifetime_price_id` among the tier columns as if already mapped; it existed in the DB but was missing from `TierConfigRow`. Added. - The BUNYIP-511 price-replace endpoint (`POST /v1/admin/stripe/prices/{id}/replace`) does not exist yet on `main` (PR #511 was a different change). The guard is structured so a future replace endpoint bypasses it by calling `StripeService::archive_price` directly; 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. - No user-facing doc describes product/price archiving today (only a design gap-matrix that references stale line numbers), so there was nothing to update. The behaviour is documented in code comments. ## Known limit The guard reads bunyip's own DB. A live Stripe subscription on a price no longer referenced by `tier_config` and not anyone's `locked_price_id` is invisible to it, because `dunite-stripe` has 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_delete` asserts the predicate set on the const SQL (no live DB), mirroring the `COUNT_TIER_ASSIGNMENTS_SQL` regression. - `plan_member_index_counts_or_semantics_without_double_counting` proves the page-wide index matches the single-plan OR count with no double count. - Handler tests cover tier resolution by product id, by price id, the `lifetime_price_id` column, and the 409 pluralization. - Web block tests cover the disabled-archive rendering + member-count text (products and prices), the singular label, the no-active-price warning, and no-warning-when-prices-unknown. - `just check-container` green (fmt + clippy + full workspace tests); `scripts/check-serde-compat.nu` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01GkNbvEq6awuMRULFCiYKe3
fix(admin): archive Stripe plans all-or-nothing with a member guard (BUNYIP-512)
All checks were successful
E2E / PR gate (pull_request) Successful in 26s
Check / fmt + clippy + build + tests (pull_request) Successful in 4m47s
Create release / Create release from merged PR (pull_request) Has been skipped
fec98ca260
Archiving a plan from the admin Stripe page was two bugs. Archiving a product set the product `active = false` but left its prices `active`, and `list_prices` never filtered on `product.active`, so the orphaned prices still rendered as Active and stayed usable in a checkout session: the plan was not actually withdrawn. Separately, nothing stopped an admin archiving a plan that still had members, leaving active members with undefined entitlements and renewal.

The archive is now all-or-nothing and safe. A product archive cascades to its prices (prices first, so a failure leaves the product active and visible rather than an archived product with live prices), and any archive is refused with HTTP 409 while the plan still has members, before any Stripe call is made. A partial price failure is returned naming which price ids were archived and which failed, logged at error, never a success status.

Membership is resolved from bunyip's own state. `UserRepository::count_members_for_plan` counts users with `membership_status` in (active, grace_period, past_due) on the plan's tiers, plus anyone whose `locked_price_id` is one of the plan's prices, excluding soft-deleted rows, OR-deduplicated so a user is counted once. The list endpoints label every product/price row with a `member_count` computed by one grouped `plan_member_index` query for the whole page, not one query per row. Tier resolution reads every `tier_config` price and product column, including the one-time `lifetime_price_id`, which the DB has had since 20260429000045 but `TierConfigRow` never mapped.

The Products and Prices blocks render a disabled Archive control plus the member count for plans that have members (the server guard stays authoritative), the product archive confirmation now states its prices are archived too, and an active product with no active price is flagged so the unsellable state a future unarchive can leave behind is visible. Each archive writes an `AdminStripePlanArchived` audit entry with the product id, archived price ids and member count.

The guard lives in the archive handlers, not in `dunite-stripe` (which stays domain-free); a future BUNYIP-511 price-replace flow that archives the old price on purpose must call `StripeService::archive_price` directly, bypassing this guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GkNbvEq6awuMRULFCiYKe3
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-08-12 04:13:52 +02:00
longjacksonle deleted branch fix/BUNYIP-512-archive-cascade-and-member-guard 2026-08-12 04:17:46 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!513
No description provided.