feat(admin): edit Stripe products and replace prices from /admin/stripe (BUNYIP-511) #521

Merged
longjacksonle merged 1 commit from feat/BUNYIP-511-edit-products-replace-prices into main 2026-08-12 16:43:15 +02:00

What

Implements BUNYIP-511, which was marked Done by a prior (failed) agent but was not actually implemented: nothing in bunyip-web called the product-edit endpoint, and there was no price-replace path at all. An admin can now correct a Stripe product (name, description) and change what a plan costs entirely from /admin/stripe, without opening the Stripe dashboard and without stranding bunyip's own references to the replaced price.

Products: edit in place

  • Each product row gets an Edit disclosure (native <details>, no JS, matching the app's other disclosures) prefilled with the current name and description.
  • New admin_api::update_stripe_product (PUT) and route POST /admin/stripe/products/:id with a stripe_product_edit handler. The API's PUT /v1/admin/stripe/products/{id} already existed; this wires the web tier to it.
  • Name validated at the edge (trimmed, required, 1 to 250 chars), description bounded to 500; blank name rejected with a toast and no upstream call; blank description sent as "" so Stripe clears it.
  • The request carries name + description ONLY: never active (lifecycle stays with archive/unarchive, BUNYIP-512/513) and never metadata (Stripe replaces the whole map on update, so sending it would drop the app_tag and vanish the product). Unit tests assert the body has neither key.

Prices: replace

Stripe prices are immutable in amount, currency and interval, so "edit price" is a replace.

  • Each active price row gets a Replace disclosure prefilled with the current amount, currency and interval.
  • New POST /v1/admin/stripe/prices/{id}/replace creates a new price on the same product, repoints bunyip's references, and archives the old price. The old price is archived with StripeService::archive_price directly, bypassing the BUNYIP-512 member guard on purpose (a replace does not withdraw the plan).
  • Repointed: every matching tier_config price column (free_price_id, early_adopter_price_id, standard_price_id; lifetime_price_id was dropped in BUNYIP-517, so the ticket's mention of it no longer applies) and every stripe_price_entitlements row for the old price.
  • Left untouched: users.locked_price_id (grandfathered pricing) and subscriptions.stripe_price_id (a live subscription keeps billing on its price until migrated in Stripe). The Replace confirm and the block caption say so.
  • Audit AdminStripePriceReplaced (old id, new id, product id, amount, currency, interval, repointed refs); the pricing cache is invalidated (the existing source-scan test now also enforces this for the replace handler).

Deliberate deviation from the ticket's proposed order

The ticket proposes create -> archive -> repoint. I repoint BEFORE archiving so that no failure can strand a reference on an archived price: if the final archive fails, checkout already runs on the new (active) price and the old price is only a harmless still-active duplicate. This directly serves the ticket's Goal ("without stranding references"). Every partial-failure path still returns an error naming the new price id and the failed step, logged at error, shown verbatim in the toast; no path reports success for a partially applied replace.

Verification

  • just check-container green (fmt, clippy -D warnings, 253 web + 123 api tests, including the new Edit/Replace rendering, no-active/no-metadata body, blank-name rejection, and parse_price_cents-reuse tests).
  • Rendered the Products and Prices blocks with the disclosures expanded (screenshot in the ticket / chat): each product row prefills name + description; each active price row prefills amount/currency/interval with the "Stripe cannot change an existing price" note; the archived price shows only Unarchive (no Replace); the Prices caption describes replacement.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GkNbvEq6awuMRULFCiYKe3

## What Implements BUNYIP-511, which was marked Done by a prior (failed) agent but was not actually implemented: nothing in `bunyip-web` called the product-edit endpoint, and there was no price-replace path at all. An admin can now correct a Stripe product (name, description) and change what a plan costs entirely from `/admin/stripe`, without opening the Stripe dashboard and without stranding bunyip's own references to the replaced price. ## Products: edit in place - Each product row gets an **Edit** disclosure (native `<details>`, no JS, matching the app's other disclosures) prefilled with the current name and description. - New `admin_api::update_stripe_product` (PUT) and route `POST /admin/stripe/products/:id` with a `stripe_product_edit` handler. The API's `PUT /v1/admin/stripe/products/{id}` already existed; this wires the web tier to it. - Name validated at the edge (trimmed, required, 1 to 250 chars), description bounded to 500; blank name rejected with a toast and no upstream call; blank description sent as `""` so Stripe clears it. - The request carries name + description ONLY: never `active` (lifecycle stays with archive/unarchive, BUNYIP-512/513) and never `metadata` (Stripe replaces the whole map on update, so sending it would drop the `app_tag` and vanish the product). Unit tests assert the body has neither key. ## Prices: replace Stripe prices are immutable in amount, currency and interval, so "edit price" is a **replace**. - Each active price row gets a **Replace** disclosure prefilled with the current amount, currency and interval. - New `POST /v1/admin/stripe/prices/{id}/replace` creates a new price on the same product, repoints bunyip's references, and archives the old price. The old price is archived with `StripeService::archive_price` directly, bypassing the BUNYIP-512 member guard on purpose (a replace does not withdraw the plan). - Repointed: every matching `tier_config` price column (`free_price_id`, `early_adopter_price_id`, `standard_price_id`; `lifetime_price_id` was dropped in BUNYIP-517, so the ticket's mention of it no longer applies) and every `stripe_price_entitlements` row for the old price. - Left untouched: `users.locked_price_id` (grandfathered pricing) and `subscriptions.stripe_price_id` (a live subscription keeps billing on its price until migrated in Stripe). The Replace confirm and the block caption say so. - Audit `AdminStripePriceReplaced` (old id, new id, product id, amount, currency, interval, repointed refs); the pricing cache is invalidated (the existing source-scan test now also enforces this for the replace handler). ## Deliberate deviation from the ticket's proposed order The ticket proposes create -> archive -> repoint. I repoint BEFORE archiving so that no failure can strand a reference on an archived price: if the final archive fails, checkout already runs on the new (active) price and the old price is only a harmless still-active duplicate. This directly serves the ticket's Goal ("without stranding references"). Every partial-failure path still returns an error naming the new price id and the failed step, logged at `error`, shown verbatim in the toast; no path reports success for a partially applied replace. ## Verification - `just check-container` green (fmt, clippy -D warnings, 253 web + 123 api tests, including the new Edit/Replace rendering, no-active/no-metadata body, blank-name rejection, and parse_price_cents-reuse tests). - Rendered the Products and Prices blocks with the disclosures expanded (screenshot in the ticket / chat): each product row prefills name + description; each active price row prefills amount/currency/interval with the "Stripe cannot change an existing price" note; the archived price shows only Unarchive (no Replace); the Prices caption describes replacement. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01GkNbvEq6awuMRULFCiYKe3
feat(admin): edit Stripe products and replace prices from /admin/stripe (BUNYIP-511)
All checks were successful
E2E / PR gate (pull_request) Successful in 27s
Check / fmt + clippy + build + tests (pull_request) Successful in 25m59s
Create release / Create release from merged PR (pull_request) Has been skipped
ede6502858
An admin can now correct a Stripe product (name, description) and change what a plan costs entirely from /admin/stripe, without opening the Stripe dashboard and without stranding bunyip's own references to the replaced price. The API had PUT /v1/admin/stripe/products/{id} but nothing in bunyip-web called it, and there was no price-replace path at all; this wires both ends up.

Products edit in place. Each product row gets an Edit disclosure (a native <details>, no JS, matching the app's other disclosures) prefilled with the current name and description. The web handler validates the name at the edge (trimmed, required, 1 to 250 chars) and bounds the description to 500, rejecting a blank name with a toast and never calling upstream; a blank description is sent as "" so Stripe clears the field. The request carries name and description ONLY: never `active` (lifecycle stays owned by archive/unarchive, BUNYIP-512/513) and never `metadata` (Stripe replaces the whole metadata map on update, so sending it would drop the app_tag and vanish the product from the list). Unit tests assert the request body has neither key.

Prices replace, because Stripe prices are immutable in amount, currency and interval. Each active price row gets a Replace disclosure prefilled with the current amount, currency and interval. The new POST /v1/admin/stripe/prices/{id}/replace creates a new price on the same product, repoints bunyip's own references, and archives the old price. The repoint runs BEFORE the archive so that no failure can strand a reference on an archived price: if the final archive fails, checkout already runs on the new (active) price and the old price is only a harmless still-active duplicate. This is a deliberate strengthening of the ticket's create-then-archive-then-repoint order, in service of its stated goal ("without stranding references"). The old price is archived via StripeService::archive_price directly, bypassing the BUNYIP-512 member guard on purpose, since a replace does not withdraw the plan.

Repointed: every tier_config price column equal to the old id (free_price_id, early_adopter_price_id, standard_price_id; lifetime_price_id was dropped in BUNYIP-517, so the ticket's mention of it no longer applies), and every stripe_price_entitlements row for the old price id (add the new mapping, drop the old). Deliberately NOT repointed: users.locked_price_id (a grandfathered user stays on the price they locked) and subscriptions.stripe_price_id (a live Stripe subscription keeps billing on its price until migrated in Stripe); the Replace confirm and the block caption say so.

A partial failure never reports success: any step after the new price is created returns an error naming the new price id and the step that failed, logged at error, and the web toast shows it verbatim. The replace writes an AdminStripePriceReplaced audit entry (old id, new id, product id, amount, currency, interval, repointed references) and invalidates the pricing cache (enforced by the existing source-scan test, which now also covers the replace handler).

just check-container is green (fmt, clippy, 253 web + 123 api tests). styles.css rebuilt for the new form utility classes.

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 16:19:49 +02:00
longjacksonle deleted branch feat/BUNYIP-511-edit-products-replace-prices 2026-08-12 16:43:15 +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!521
No description provided.