feat(admin): tier/pricing config cohesion - mapping home, checkout-trial, (none) clearing, per-tier visibility (BUNYIP-527) #525

Merged
longjacksonle merged 1 commit from feat/BUNYIP-527-tier-config-cohesion into main 2026-08-12 19:14:33 +02:00

What

The BUNYIP-526 follow-ups (mapping location, checkout-trial reconciliation, "(none)" semantics), plus a per-tier visibility toggle requested during the work. Directions were confirmed with the ticket owner before building.

Changes

1. Catalog mapping moves back to the Pricing tiers page. Reverses the BUNYIP-417/524 consolidation: /admin/tier-settings now owns slots, per-tier trials, the tier -> Stripe price mapping, per-tier visibility, and the publish switch + live status. /admin/stripe keeps only raw Stripe (connection, products, prices, webhooks). The catalog form posts to the new /admin/tier-settings/catalog route.

2. Checkout trial relocated + relabelled (interim). The trial actually granted at checkout is a single global stripe_config.trial_period_days; the per-tier *_trial_days are only advertised on /pricing, not granted (create_checkout_session takes no per-tier length - honouring the advertised per-tier trial needs a dunite-stripe change, tracked separately). Until then the field moves off the Stripe Checkout block onto the Pricing tiers page as Trial applied at checkout, labelled to say it is the one trial applied for every tier. The Pricing tiers save writes both backends (tier config + stripe config), validating up front so a bad field persists nothing.

3. "(none)" actually clears a mapping. The price selects submit "" for "(none)" and the save always sends the price fields, so an empty one reaches the API as an explicit empty string. TierConfigRepository::update reads the six Stripe id columns three-state (omitted keeps, "" clears to NULL, an id sets) via CASE instead of COALESCE, and update_tier_config clears a tier's derived product alongside a cleared price. Previously "(none)" was a silent no-op.

4. Per-tier visibility (new, requested mid-work). Each tier gets a Show this tier on the pricing page toggle, backed by new tier_config.{lifetime,early_adopter,standard}_visible columns (migration 20260812000010, NOT NULL default true). The /pricing resolve drops a hidden tier even when mapped, underneath the global publish switch; hiding is deliberate, so it produces no unpublished reason.

Verification

  • just check-container green (fmt, clippy -D warnings, workspace tests, incl. the clear-body/visibility-toggle tests and the reworked publish-switch test).
  • migration-immutability and serde-compat guards pass; web PricingTier/TierConfigResponse gain their fields with serde defaults (visibility defaults true), so a one-release skew never renders a tier as wrongly hidden.
  • Screenshot (in the ticket / chat): the rebuilt Pricing tiers page - Tiers & Slots with per-tier advertised trials, the relocated checkout trial with explanatory copy, and the catalog mapping with publish switch, live status, price selects, and per-tier visibility toggles (Early Adopter hidden -> excluded from the status line).

Out of scope (still tracked)

Making Stripe checkout honour the per-tier advertised trial (needs a dunite-stripe change to pass a trial-days override into create_checkout_session).

🤖 Generated with Claude Code

https://claude.ai/code/session_01GkNbvEq6awuMRULFCiYKe3

## What The BUNYIP-526 follow-ups (mapping location, checkout-trial reconciliation, "(none)" semantics), plus a per-tier visibility toggle requested during the work. Directions were confirmed with the ticket owner before building. ## Changes **1. Catalog mapping moves back to the Pricing tiers page.** Reverses the BUNYIP-417/524 consolidation: `/admin/tier-settings` now owns slots, per-tier trials, the tier -> Stripe price mapping, per-tier visibility, and the publish switch + live status. `/admin/stripe` keeps only raw Stripe (connection, products, prices, webhooks). The catalog form posts to the new `/admin/tier-settings/catalog` route. **2. Checkout trial relocated + relabelled (interim).** The trial actually granted at checkout is a single global `stripe_config.trial_period_days`; the per-tier `*_trial_days` are only advertised on `/pricing`, not granted (`create_checkout_session` takes no per-tier length - honouring the advertised per-tier trial needs a dunite-stripe change, tracked separately). Until then the field moves off the Stripe Checkout block onto the Pricing tiers page as **Trial applied at checkout**, labelled to say it is the one trial applied for every tier. The Pricing tiers save writes both backends (tier config + stripe config), validating up front so a bad field persists nothing. **3. "(none)" actually clears a mapping.** The price selects submit `""` for "(none)" and the save always sends the price fields, so an empty one reaches the API as an explicit empty string. `TierConfigRepository::update` reads the six Stripe id columns three-state (`omitted` keeps, `""` clears to NULL, an id sets) via `CASE` instead of `COALESCE`, and `update_tier_config` clears a tier's derived product alongside a cleared price. Previously "(none)" was a silent no-op. **4. Per-tier visibility (new, requested mid-work).** Each tier gets a **Show this tier on the pricing page** toggle, backed by new `tier_config.{lifetime,early_adopter,standard}_visible` columns (migration `20260812000010`, NOT NULL default true). The `/pricing` resolve drops a hidden tier even when mapped, underneath the global publish switch; hiding is deliberate, so it produces no unpublished reason. ## Verification - `just check-container` green (fmt, clippy -D warnings, workspace tests, incl. the clear-body/visibility-toggle tests and the reworked publish-switch test). - migration-immutability and serde-compat guards pass; web `PricingTier`/`TierConfigResponse` gain their fields with serde defaults (visibility defaults true), so a one-release skew never renders a tier as wrongly hidden. - Screenshot (in the ticket / chat): the rebuilt Pricing tiers page - Tiers & Slots with per-tier advertised trials, the relocated checkout trial with explanatory copy, and the catalog mapping with publish switch, live status, price selects, and per-tier visibility toggles (Early Adopter hidden -> excluded from the status line). ## Out of scope (still tracked) Making Stripe checkout honour the per-tier advertised trial (needs a dunite-stripe change to pass a trial-days override into `create_checkout_session`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01GkNbvEq6awuMRULFCiYKe3
feat(admin): tier/pricing config cohesion - mapping home, checkout-trial relocation, (none) clearing, per-tier visibility (BUNYIP-527)
All checks were successful
E2E / PR gate (pull_request) Successful in 22s
Check / fmt + clippy + build + tests (pull_request) Successful in 4m59s
Create release / Create release from merged PR (pull_request) Has been skipped
11ea8fe4fb
Follow-ups flagged out of BUNYIP-526, plus a per-tier visibility toggle requested during the work. Directions were confirmed with the ticket owner before implementing.

Catalog mapping moves back to the Pricing tiers page. BUNYIP-417/524 had consolidated the tier -> Stripe price mapping (and the publish switch + live status) onto the Stripe page; this reverses that so the Pricing tiers page owns everything about bunyip's own tiers - slot limits, per-tier trial lengths, the price mapping, per-tier visibility, and the publish switch - while the Stripe page keeps only raw Stripe (connection, products, prices, webhooks). The catalog section is rendered from the Pricing tiers page and its form posts to the new /admin/tier-settings/catalog route (redirects back there).

Checkout trial relocates and is relabelled (interim). The trial actually granted at checkout is a single global stripe_config.trial_period_days; the per-tier *_trial_days are only advertised on /pricing, not granted (create_checkout_session takes no per-tier length - honouring the advertised per-tier trial needs a dunite-stripe change, tracked separately). Until then the field moves off the Stripe Checkout block onto the Pricing tiers page as "Trial applied at checkout", labelled to say it is the one trial applied for every tier versus the per-tier advertised lengths above it. The Pricing tiers save now writes both backends (tier config for slots/trials, stripe config for the checkout trial), validating everything up front so a bad field persists nothing.

"(none)" now actually clears a mapping. The catalog price selects submit "" for "(none)", and the save always sends the price fields, so an empty one reaches the API as an explicit empty string. TierConfigRepository::update reads the six Stripe id columns as three-state - omitted keeps, "" clears to NULL, an id sets - via CASE instead of COALESCE, and update_tier_config clears a tier's derived product alongside a cleared price. Previously "(none)" was a silent no-op that kept the old mapping.

Per-tier visibility (new). Each tier gets a "Show this tier on the pricing page" toggle in its catalog card, backed by new tier_config.{lifetime,early_adopter,standard}_visible columns (migration 20260812000010, NOT NULL default true so existing mapped tiers keep showing). The public /pricing resolve drops a hidden tier even when it maps to a usable price, underneath the global publish switch; hiding a tier is a deliberate state, so it produces no unpublished reason.

just check-container is green (fmt, clippy, workspace tests); the migration-immutability and serde-compat guards pass. The web PricingTier/TierConfigResponse gain their new fields with serde defaults (visibility defaults true) so a one-release skew never renders a tier as wrongly hidden or sold out.

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 19:10:52 +02:00
longjacksonle deleted branch feat/BUNYIP-527-tier-config-cohesion 2026-08-12 19:14:33 +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!525
No description provided.