feat(admin): edit Stripe products and replace prices from /admin/stripe (BUNYIP-511) #521
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/BUNYIP-511-edit-products-replace-prices"
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
Implements BUNYIP-511, which was marked Done by a prior (failed) agent but was not actually implemented: nothing in
bunyip-webcalled 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
<details>, no JS, matching the app's other disclosures) prefilled with the current name and description.admin_api::update_stripe_product(PUT) and routePOST /admin/stripe/products/:idwith astripe_product_edithandler. The API'sPUT /v1/admin/stripe/products/{id}already existed; this wires the web tier to it.""so Stripe clears it.active(lifecycle stays with archive/unarchive, BUNYIP-512/513) and nevermetadata(Stripe replaces the whole map on update, so sending it would drop theapp_tagand 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.
POST /v1/admin/stripe/prices/{id}/replacecreates a new price on the same product, repoints bunyip's references, and archives the old price. The old price is archived withStripeService::archive_pricedirectly, bypassing the BUNYIP-512 member guard on purpose (a replace does not withdraw the plan).tier_configprice column (free_price_id,early_adopter_price_id,standard_price_id;lifetime_price_idwas dropped in BUNYIP-517, so the ticket's mention of it no longer applies) and everystripe_price_entitlementsrow for the old price.users.locked_price_id(grandfathered pricing) andsubscriptions.stripe_price_id(a live subscription keeps billing on its price until migrated in Stripe). The Replace confirm and the block caption say so.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-containergreen (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).🤖 Generated with Claude Code
https://claude.ai/code/session_01GkNbvEq6awuMRULFCiYKe3
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