feat(admin): unarchive a Stripe product or price from the admin Stripe page (BUNYIP-513) #515

Merged
longjacksonle merged 2 commits from feat/BUNYIP-513-unarchive-stripe-plan into main 2026-08-12 05:02:57 +02:00

Stacked on BUNYIP-512

This branch is stacked on fix/BUNYIP-512-archive-cascade-and-member-guard (bunyip PR for BUNYIP-512), because both touch the same admin Stripe handler, block, audit enum and test regions. Until BUNYIP-512 merges, this PR's diff shows both commits; once it merges to main, only the BUNYIP-513 commit remains. Merge BUNYIP-512 first.

What and why

Archiving was one-way in the admin Stripe page (BUNYIP-513). stripe_products_block / stripe_prices_block rendered an action only for active rows; an archived row had none, so undoing an accidental archive meant logging into the Stripe dashboard, exactly the split the admin page exists to avoid. Stripe itself treats archive as reversible.

What changed

  • POST /v1/admin/stripe/products/{id}/unarchive sets the product active = true via update_product(id, None, None, None, Some(true)). Metadata is not sent, so update_product leaves the map intact and the app tag survives (the same trap the update path documents).
  • POST /v1/admin/stripe/prices/{id}/unarchive restores a single price via StripeService::unarchive_price.
  • No cascade on unarchive, deliberately. The archive direction (BUNYIP-512) cascades a product to its prices; the reverse is not symmetric (the prices a cascade touched are not recorded, and a blanket restore would resurrect prices archived for unrelated reasons, e.g. a superseded price). Restoring a product restores only the product; each price is restored explicitly. An unarchived product with no active price is already flagged by BUNYIP-512's "No active price" warning, and its fix is the per-price Unarchive control here.
  • Not member-guarded: restoring a plan withdraws nothing from anyone.
  • UI: an archived row renders an Unarchive control where Archive sits for active rows, with a confirmation stating the plan becomes purchasable again. Web routes, client functions (bunyip-web/src/api/admin.rs) and handlers mirror the archive ones, keeping the toast-on-error posture (upstream message on toast_err, never a silent success redirect).
  • Audit: both paths write an AdminStripePlanUnarchived (admin_stripe_plan_unarchived) entry naming the restored id. audit_logs.action is free-text, so no migration.

dunite bump

StripeService::unarchive_price shipped in dunite-stripe commit a7872fc (already on dunite main), so #DUNITE-9 is done upstream: no dunite PR, just a rev bump. dunite-stripe and dunite-stripe-core move together ae62c27 -> 5dab94d (they are a path-linked pair, so a single rev keeps one dunite-stripe-core; its source is byte-identical across the range). Member-scoped cargo update --package dunite-stripe --package dunite-stripe-core, a 2-line Cargo.lock diff.

That range also (DUNITE-10) added a classified StripeServiceError::Stripe { message, details } variant and a recurring_interval_count field on StripePriceResponse. The shared stripe_err mapper now handles the new variant (resource_missing -> 404, else 500, per the variant's doc), and the one affected test constructor sets the new field. These are the only ripples from the bump.

Deviation from the ticket

The ticket assumed #DUNITE-9 still needed to land and this PR would introduce it. It was already merged upstream, so this is a plain rev bump, not a cross-repo change.

Tests

  • unarchive_product_sends_active_true_and_no_metadata asserts the handler calls update_product with metadata None and active Some(true) (AC: no metadata key).
  • unarchive_product_issues_no_price_update asserts the product path touches no price call (AC: unarchive does not change price state).
  • unarchive_price_restores_the_one_price asserts the single-price path.
  • stripe_err_maps_the_classified_stripe_variant covers the new error mapping.
  • products_block_offers_unarchive_for_archived_only / prices_block_offers_unarchive_for_archived_only cover the Unarchive control on archived rows and its absence on active rows, plus the "becomes purchasable again" copy.
  • The pricing_cache invalidation scan now includes unarchive, so a future price-unarchive handler that forgets to drop the cache fails the build.
  • just check-container green (fmt + clippy + full workspace tests). No doc described archiving as one-way, so none needed updating.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GkNbvEq6awuMRULFCiYKe3

## Stacked on BUNYIP-512 This branch is stacked on `fix/BUNYIP-512-archive-cascade-and-member-guard` (bunyip PR for BUNYIP-512), because both touch the same admin Stripe handler, block, audit enum and test regions. Until BUNYIP-512 merges, this PR's diff shows both commits; once it merges to main, only the BUNYIP-513 commit remains. Merge BUNYIP-512 first. ## What and why Archiving was one-way in the admin Stripe page (BUNYIP-513). `stripe_products_block` / `stripe_prices_block` rendered an action only for active rows; an archived row had none, so undoing an accidental archive meant logging into the Stripe dashboard, exactly the split the admin page exists to avoid. Stripe itself treats archive as reversible. ## What changed - `POST /v1/admin/stripe/products/{id}/unarchive` sets the product `active = true` via `update_product(id, None, None, None, Some(true))`. Metadata is not sent, so `update_product` leaves the map intact and the app tag survives (the same trap the update path documents). - `POST /v1/admin/stripe/prices/{id}/unarchive` restores a single price via `StripeService::unarchive_price`. - No cascade on unarchive, deliberately. The archive direction (BUNYIP-512) cascades a product to its prices; the reverse is not symmetric (the prices a cascade touched are not recorded, and a blanket restore would resurrect prices archived for unrelated reasons, e.g. a superseded price). Restoring a product restores only the product; each price is restored explicitly. An unarchived product with no active price is already flagged by BUNYIP-512's "No active price" warning, and its fix is the per-price Unarchive control here. - Not member-guarded: restoring a plan withdraws nothing from anyone. - UI: an archived row renders an Unarchive control where Archive sits for active rows, with a confirmation stating the plan becomes purchasable again. Web routes, client functions (`bunyip-web/src/api/admin.rs`) and handlers mirror the archive ones, keeping the toast-on-error posture (upstream message on `toast_err`, never a silent success redirect). - Audit: both paths write an `AdminStripePlanUnarchived` (`admin_stripe_plan_unarchived`) entry naming the restored id. `audit_logs.action` is free-text, so no migration. ## dunite bump `StripeService::unarchive_price` shipped in dunite-stripe commit `a7872fc` (already on dunite main), so #DUNITE-9 is done upstream: no dunite PR, just a rev bump. `dunite-stripe` and `dunite-stripe-core` move together `ae62c27` -> `5dab94d` (they are a path-linked pair, so a single rev keeps one `dunite-stripe-core`; its source is byte-identical across the range). Member-scoped `cargo update --package dunite-stripe --package dunite-stripe-core`, a 2-line `Cargo.lock` diff. That range also (DUNITE-10) added a classified `StripeServiceError::Stripe { message, details }` variant and a `recurring_interval_count` field on `StripePriceResponse`. The shared `stripe_err` mapper now handles the new variant (resource_missing -> 404, else 500, per the variant's doc), and the one affected test constructor sets the new field. These are the only ripples from the bump. ## Deviation from the ticket The ticket assumed #DUNITE-9 still needed to land and this PR would introduce it. It was already merged upstream, so this is a plain rev bump, not a cross-repo change. ## Tests - `unarchive_product_sends_active_true_and_no_metadata` asserts the handler calls `update_product` with metadata `None` and active `Some(true)` (AC: no metadata key). - `unarchive_product_issues_no_price_update` asserts the product path touches no price call (AC: unarchive does not change price state). - `unarchive_price_restores_the_one_price` asserts the single-price path. - `stripe_err_maps_the_classified_stripe_variant` covers the new error mapping. - `products_block_offers_unarchive_for_archived_only` / `prices_block_offers_unarchive_for_archived_only` cover the Unarchive control on archived rows and its absence on active rows, plus the "becomes purchasable again" copy. - The `pricing_cache` invalidation scan now includes `unarchive`, so a future price-unarchive handler that forgets to drop the cache fails the build. - `just check-container` green (fmt + clippy + full workspace tests). No doc described archiving as one-way, so none needed updating. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01GkNbvEq6awuMRULFCiYKe3
feat(admin): unarchive a Stripe product or price from the admin Stripe page (BUNYIP-513)
Some checks failed
E2E / PR gate (pull_request) Successful in 22s
Check / fmt + clippy + build + tests (pull_request) Has been cancelled
04b652b397
Archiving was one-way in the admin Stripe page: an archived product or price row had no action, so undoing an accidental archive meant logging into the Stripe dashboard, exactly the split the admin page exists to avoid. This adds the reverse transition.

`POST /v1/admin/stripe/products/{id}/unarchive` sets the product `active = true` via `update_product(id, None, None, None, Some(true))`. Metadata is deliberately not sent, so the whole metadata map is left intact and the app tag survives. `POST /v1/admin/stripe/prices/{id}/unarchive` restores a single price via `StripeService::unarchive_price`.

There is no cascade on unarchive, on purpose. The archive direction (BUNYIP-512) cascades a product to its prices, but the reverse is not symmetric: the set of prices a given archive cascade touched is not recorded, and a blanket restore would resurrect prices archived for unrelated reasons (a superseded price, for one). Restoring a product restores only the product; each archived price is restored explicitly on its own row. An unarchived product with no active price is already flagged by BUNYIP-512's "No active price" warning, whose fix is the per-price Unarchive control this adds. Neither path is member-guarded: restoring a plan withdraws nothing.

In the Products and Prices blocks an archived row now renders an Unarchive control where the Archive control sits for active rows, with a confirmation stating the plan becomes purchasable again. Web routes, client functions and handlers mirror the archive ones, keeping the toast-on-error posture (the upstream message rides `toast_err`, never a silent success redirect). Both paths write an `AdminStripePlanUnarchived` audit entry naming the restored id.

`unarchive_price` ships in dunite-stripe as of a7872fc (already on dunite main), so this bumps the `dunite-stripe` / `dunite-stripe-core` rev ae62c27 -> 5dab94d (the two move together as a single dunite-stripe-core; stripe-core source is byte-identical across the range) with the member-scoped `cargo update` (a 2-line Cargo.lock diff). That range also added the classified `StripeServiceError::Stripe { .. }` variant and a `recurring_interval_count` field on `StripePriceResponse`; the shared `stripe_err` mapper now handles the new variant (resource_missing -> 404, else 500) and the one affected test constructor is updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GkNbvEq6awuMRULFCiYKe3
Merge branch 'main' into feat/BUNYIP-513-unarchive-stripe-plan
All checks were successful
E2E / PR gate (pull_request) Successful in 12s
Check / fmt + clippy + build + tests (pull_request) Successful in 34m19s
Create release / Create release from merged PR (pull_request) Has been skipped
013a459296
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-08-12 04:42:38 +02:00
longjacksonle deleted branch feat/BUNYIP-513-unarchive-stripe-plan 2026-08-12 05:02:57 +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!515
No description provided.