feat(admin): derive a tier's Stripe product from its mapped price (BUNYIP-517) #516

Merged
longjacksonle merged 1 commit from feat/BUNYIP-517-derive-tier-product-from-price into main 2026-08-12 05:05:12 +02:00

What and why

Raised as a question about the Tier catalog mapping on /admin/stripe: creating a price already associates it with a product, so why ask for the association again? The block is not a product-to-price association (Stripe knows price.product). It binds a bunyip membership tier to the Stripe object that represents it, and the binding is read both ways: forwards (advertise pricing on /pricing, open the $0 subscription behind a free/lifetime grant) and backwards (resolve_tier_for_product classifies incoming customer.subscription.* events onto a tier). One binding per tier is genuinely needed. The redundancy was narrower: the form asked for both a price id and a product id, when the price already determines the product, and the two could disagree with nothing checking.

What changed

  • The catalog form asks for a price per tier and no product id.
  • On save the api resolves each submitted price against Stripe (list_prices) and writes the price's product id to the existing *_product_id column. resolve_tier_for_product keeps reading the stored product, so no Stripe call is added to the webhook path.
  • A price that does not resolve to an active, visible price is refused with the tier and reason named (archived, or not visible under the configured app tag), instead of storing a mapping that resolves to no tier later.
  • The derived product is shown read-only next to each price. A stored product that no longer matches its price's current product is flagged (not silently rewritten).
  • The price fields are selects populated from the loaded active prices; the stored value is preserved as a selected option even when it is no longer in the list, so a save cannot silently drop it.
  • The block carries copy explaining why the mapping exists and which direction each half is read.

Confirmed assumption + the lifetime_price_id column

Free and lifetime share the one $0 free_price_id (both grant paths open a $0 subscription on it via live_free_price_id). Per that decision: the free / lifetime naming is unified between the catalog form and the Pricing tiers resolved-pricing block, and the unused lifetime_price_id column (added in 20260429000045, never read by any resolver or written by any form) is dropped by a new migration, never by editing the committed one. TierConfigRow drops the field, and the BUNYIP-512 archive tier resolution now matches lifetime by its derived product instead of a dedicated price column.

Screenshot

The rendered catalog: price selects only, derived product read-only per tier, and the disagreement flag (on Standard) naming the stale stored product. Top block = mapped tiers, bottom = unmapped ((none), "derived from the price on save"). Attached in the conversation.

Tests

  • derive_product_for_price_returns_the_active_prices_product, ..._refuses_an_archived_price_by_name, ..._refuses_an_invisible_price_naming_the_app_tag.
  • catalog_section_asks_for_price_only_and_shows_derived_product (price selects present, product-id inputs gone, derived shown) and catalog_section_flags_a_stored_product_that_disagrees_with_the_price.
  • resolve_tier_for_product_matches_the_stored_product_ids locks the webhook classifier as unchanged.
  • BUNYIP-512's lifetime tier-resolution test updated to match by product.
  • just check-container green (fmt + clippy + full workspace tests). No user-facing doc described the six-field mapping (only a design gap-matrix that references file line ranges), so none needed updating.

Note on the open Stripe-page PRs

This is off main. It touches the catalog section, which the in-flight #514 (BUNYIP-516) and #515 (BUNYIP-513) do not, but it shares the stripe() page loader with them (one catalog-call line). A small conflict on that line is likely once those merge; happy to rebase when they land.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GkNbvEq6awuMRULFCiYKe3

## What and why Raised as a question about the Tier catalog mapping on `/admin/stripe`: creating a price already associates it with a product, so why ask for the association again? The block is not a product-to-price association (Stripe knows `price.product`). It binds a bunyip membership tier to the Stripe object that represents it, and the binding is read both ways: forwards (advertise pricing on `/pricing`, open the $0 subscription behind a free/lifetime grant) and backwards (`resolve_tier_for_product` classifies incoming `customer.subscription.*` events onto a tier). One binding per tier is genuinely needed. The redundancy was narrower: the form asked for **both** a price id and a product id, when the price already determines the product, and the two could disagree with nothing checking. ## What changed - The catalog form asks for a **price per tier** and no product id. - On save the api resolves each submitted price against Stripe (`list_prices`) and writes the price's product id to the existing `*_product_id` column. `resolve_tier_for_product` keeps reading the stored product, so no Stripe call is added to the webhook path. - A price that does not resolve to an active, visible price is refused with the tier and reason named (archived, or not visible under the configured app tag), instead of storing a mapping that resolves to no tier later. - The derived product is shown read-only next to each price. A stored product that no longer matches its price's current product is flagged (not silently rewritten). - The price fields are selects populated from the loaded active prices; the stored value is preserved as a selected option even when it is no longer in the list, so a save cannot silently drop it. - The block carries copy explaining why the mapping exists and which direction each half is read. ## Confirmed assumption + the lifetime_price_id column Free and lifetime share the one $0 `free_price_id` (both grant paths open a $0 subscription on it via `live_free_price_id`). Per that decision: the free / lifetime naming is unified between the catalog form and the Pricing tiers resolved-pricing block, and the unused `lifetime_price_id` column (added in `20260429000045`, never read by any resolver or written by any form) is **dropped by a new migration**, never by editing the committed one. `TierConfigRow` drops the field, and the BUNYIP-512 archive tier resolution now matches lifetime by its derived product instead of a dedicated price column. ## Screenshot The rendered catalog: price selects only, derived product read-only per tier, and the disagreement flag (on Standard) naming the stale stored product. Top block = mapped tiers, bottom = unmapped (`(none)`, "derived from the price on save"). Attached in the conversation. ## Tests - `derive_product_for_price_returns_the_active_prices_product`, `..._refuses_an_archived_price_by_name`, `..._refuses_an_invisible_price_naming_the_app_tag`. - `catalog_section_asks_for_price_only_and_shows_derived_product` (price selects present, product-id inputs gone, derived shown) and `catalog_section_flags_a_stored_product_that_disagrees_with_the_price`. - `resolve_tier_for_product_matches_the_stored_product_ids` locks the webhook classifier as unchanged. - BUNYIP-512's lifetime tier-resolution test updated to match by product. - `just check-container` green (fmt + clippy + full workspace tests). No user-facing doc described the six-field mapping (only a design gap-matrix that references file line ranges), so none needed updating. ## Note on the open Stripe-page PRs This is off `main`. It touches the catalog section, which the in-flight #514 (BUNYIP-516) and #515 (BUNYIP-513) do not, but it shares the `stripe()` page loader with them (one catalog-call line). A small conflict on that line is likely once those merge; happy to rebase when they land. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01GkNbvEq6awuMRULFCiYKe3
feat(admin): derive a tier's Stripe product from its mapped price (BUNYIP-517)
All checks were successful
E2E / PR gate (pull_request) Successful in 16s
Check / fmt + clippy + build + tests (pull_request) Successful in 5m17s
Create release / Create release from merged PR (pull_request) Has been skipped
f9914dbd2e
The Tier catalog mapping on /admin/stripe asked for both a price id and a product id per tier, when a Stripe price already determines its product. The two halves could disagree with nothing checking: map standard_price_id to a price on product A and standard_product_id to product B, and checkout charges A while every subscription webhook classifies B's subscribers as standard. Both fields look filled in and the divergence only surfaces as members landing on the wrong tier. Six free-text id fields with no validation was also a lot of surface for a silent typo.

The mapping now asks for one value per tier, the price, entered once. On save the api resolves each submitted price against Stripe and writes the price's product id to the existing *_product_id column, so resolve_tier_for_product keeps reading a stored product with no Stripe call added to the webhook path. A price that does not resolve to an active, visible price is refused, naming the tier and the reason (archived, or not visible under the configured app tag) rather than storing a mapping that resolves to no tier when an event arrives. The derived product is shown read-only next to each price, and a stored product that no longer matches its price's product is flagged on the page instead of being silently rewritten.

The price fields are selects populated from the loaded active prices, with the stored value preserved as a selected option even when it is no longer in the list, so a save cannot silently drop it. The block carries copy answering the question that prompted this: Stripe has no concept of a bunyip tier, so the binding exists and is read forwards for pricing and grants, backwards to classify subscription events.

Free and lifetime share the one $0 free_price_id (confirmed): the free / lifetime naming is unified between the catalog form and the Pricing tiers resolved-pricing block, and the unused lifetime_price_id column (added in 20260429000045, never read by any resolver or written by any form) is dropped by a new migration. TierConfigRow drops the field with it, and the BUNYIP-512 archive tier resolution now matches lifetime by its derived product rather than a dedicated price column.

Tests cover the product-id derivation, refusal on an archived and on an invisible price, the disagreement flag, that the catalog asks for price only, and that resolve_tier_for_product is unchanged. just check-container is green.

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 05:01:58 +02:00
longjacksonle deleted branch feat/BUNYIP-517-derive-tier-product-from-price 2026-08-12 05:05:12 +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!516
No description provided.