fix(pricing): show each tier's own trial and reflect slot availability on /pricing (BUNYIP-526) #522

Merged
longjacksonle merged 1 commit from feat/BUNYIP-526-per-tier-trial-and-availability into main 2026-08-12 17:19:33 +02:00

What

Fixes the public /pricing page, which misrepresented the tiers using data the app already stored.

The bug

  • One global trial. The subhead "Start free for 75 days" (the Standard tier's trial) was printed above all three cards as if it applied to every tier: wrong for Early Adopter (its own trial length) and meaningless for Lifetime ($0, price-locked, no trial).
  • Slot availability ignored. Sold-out limited tiers (Lifetime, Early Adopter) still showed a normal sign-up CTA, inviting a purchase that cannot be honoured.

Changes

  • Per-tier trial on each card, from the tier's own trial_days that GET /v1/pricing already returned. Paid tiers state their own trial ("Free 90-day trial, then price locked for life"); Lifetime never says "trial". The subhead drops the number ("No credit card required.").
  • Availability from slots. PublicPricingTier gains available + slots_remaining, computed from the slot caps in tier config and the live count_tier_assignments usage. Standard is uncapped (always available, no count); Lifetime / Early Adopter sell out once usage reaches the cap (remaining clamped to zero when oversold). The usage rides inside the cached resolve (same TTL as prices); a count failure degrades to "available" rather than 500ing the public page.
  • Render. A sold-out limited tier shows a disabled Sold out CTA instead of a sign-up link; an available limited tier with slots left shows an honest "Only N spots left"; Standard always shows a live CTA. Rule chosen: show-as-sold-out, not hide, so the tier ladder does not silently change shape.
  • Web PricingTier gains the two fields with serde defaults (available defaults true, so an older API never renders a tier as wrongly sold out); the wire-compat guard passes.

Out of scope (flagged on the ticket for separate decisions)

  • Moving the tier -> Stripe catalog mapping back to the Pricing tiers page (it was deliberately consolidated onto the Stripe page in BUNYIP-417 / BUNYIP-524).
  • Reconciling the Stripe Checkout "Trial period (days)" field with the per-tier trial days (there are two trial concepts; the checkout flow needs verifying before relocating either).
  • The catalog mapping "(none)" price option's semantics.

Verification

  • just check-container green (fmt, clippy -D warnings, 250 web + API pricing tests: per-tier trial rendering, Lifetime's no-trial wording, sold-out CTA, scarcity line, and the availability computation used >= cap => unavailable, Standard unlimited).
  • Screenshot (in the ticket / chat): Lifetime "Price locked for life when you join" + disabled Sold out; Early Adopter "Free 90-day trial ..." + "Only 2 spots left"; Standard "Free 75-day trial ..." + live CTA; generic subhead.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GkNbvEq6awuMRULFCiYKe3

## What Fixes the public `/pricing` page, which misrepresented the tiers using data the app already stored. ## The bug - **One global trial.** The subhead "Start free for 75 days" (the Standard tier's trial) was printed above all three cards as if it applied to every tier: wrong for Early Adopter (its own trial length) and meaningless for Lifetime ($0, price-locked, no trial). - **Slot availability ignored.** Sold-out limited tiers (Lifetime, Early Adopter) still showed a normal sign-up CTA, inviting a purchase that cannot be honoured. ## Changes - **Per-tier trial on each card**, from the tier's own `trial_days` that `GET /v1/pricing` already returned. Paid tiers state their own trial ("Free 90-day trial, then price locked for life"); Lifetime never says "trial". The subhead drops the number ("No credit card required."). - **Availability from slots.** `PublicPricingTier` gains `available` + `slots_remaining`, computed from the slot caps in tier config and the live `count_tier_assignments` usage. Standard is uncapped (always available, no count); Lifetime / Early Adopter sell out once usage reaches the cap (remaining clamped to zero when oversold). The usage rides inside the cached resolve (same TTL as prices); a count failure degrades to "available" rather than 500ing the public page. - **Render.** A sold-out limited tier shows a disabled **Sold out** CTA instead of a sign-up link; an available limited tier with slots left shows an honest **"Only N spots left"**; Standard always shows a live CTA. Rule chosen: show-as-sold-out, not hide, so the tier ladder does not silently change shape. - Web `PricingTier` gains the two fields with serde defaults (`available` defaults `true`, so an older API never renders a tier as wrongly sold out); the wire-compat guard passes. ## Out of scope (flagged on the ticket for separate decisions) - Moving the tier -> Stripe catalog mapping back to the Pricing tiers page (it was deliberately consolidated onto the Stripe page in BUNYIP-417 / BUNYIP-524). - Reconciling the Stripe Checkout "Trial period (days)" field with the per-tier trial days (there are two trial concepts; the checkout flow needs verifying before relocating either). - The catalog mapping "(none)" price option's semantics. ## Verification - `just check-container` green (fmt, clippy -D warnings, 250 web + API pricing tests: per-tier trial rendering, Lifetime's no-trial wording, sold-out CTA, scarcity line, and the availability computation `used >= cap => unavailable`, Standard unlimited). - Screenshot (in the ticket / chat): Lifetime "Price locked for life when you join" + disabled Sold out; Early Adopter "Free 90-day trial ..." + "Only 2 spots left"; Standard "Free 75-day trial ..." + live CTA; generic subhead. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01GkNbvEq6awuMRULFCiYKe3
fix(pricing): show each tier's own trial and reflect slot availability on /pricing (BUNYIP-526)
All checks were successful
E2E / PR gate (pull_request) Successful in 25s
Check / fmt + clippy + build + tests (pull_request) Successful in 9m53s
Create release / Create release from merged PR (pull_request) Has been skipped
8a5d4f9176
The public /pricing page misrepresented the tiers with data the app already had. The subhead read "Start free for 75 days" (the Standard tier's trial) above all three cards as if it were global: wrong for Early Adopter, which has its own trial length, and meaningless for Lifetime, a $0 price-locked membership with no trial. And slot-limited tiers (Lifetime, Early Adopter) that were sold out still showed a normal sign-up CTA, inviting a purchase that could not be honoured.

Per-tier trial. GET /v1/pricing already returns each tier's own trial_days; the card now renders it instead of one global number. Each paid tier states its own trial ("Free 90-day trial, then price locked for life"). Lifetime is a price-locked membership, not a trial, so its line never says "trial". The subhead drops the single number and reads "No credit card required."

Availability from slots. PublicPricingTier gains available + slots_remaining, computed from the slot caps in tier config and the live count_tier_assignments usage (Standard is uncapped, so always available with no count; Lifetime and Early Adopter sell out once usage reaches the cap, clamping remaining to zero when oversold). public_pricing and admin_pricing_status now read that usage; the count rides inside the cached resolve, so it refreshes on the same TTL as the prices and a count failure degrades to "available" rather than 500ing the public page. A sold-out limited tier renders a disabled "Sold out" CTA instead of a sign-up link; an available limited tier with slots left shows an honest "Only N spots left" line; Standard always shows a live CTA. Rule chosen: show-as-sold-out, not hide, so the tier ladder does not silently change shape.

The web PricingTier gains the two fields with serde defaults (available defaults true so an older API never renders a tier as wrongly sold out); the wire-compat guard passes.

Out of scope, flagged on the ticket for separate decisions: moving the tier -> Stripe catalog mapping back to the Pricing tiers page (it was deliberately consolidated onto the Stripe page in BUNYIP-417/524), reconciling the Stripe Checkout "Trial period (days)" field with the per-tier trial days (needs the checkout flow verified first), and the catalog "(none)" price option's semantics.

just check-container is green (fmt, clippy, 250 web + api pricing tests, including per-tier trial rendering, Lifetime's no-trial wording, the sold-out CTA, the scarcity line, and the availability computation).

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:36:02 +02:00
longjacksonle deleted branch feat/BUNYIP-526-per-tier-trial-and-availability 2026-08-12 17:19: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!522
No description provided.