feat(pricing): drive /pricing from the admin Pricing tiers page #487

Merged
Claude-Run merged 1 commit from feat/BUNYIP-487-admin-driven-pricing into main 2026-08-06 05:09:55 +02:00
Member

The public /pricing page and the admin tier configuration were two unrelated sources of truth, and the public one was hardcoded: the amount was the literal "$3", the hero said "Start free for 14 days" while tier_config.standard_trial_days defaults to 30, and a second "Business" card behind BUNYIP_SHOW_BUSINESS_PRICING advertised a tier that exists nowhere in SubscriptionTier or tier_config. The page also advertised organizations, which the product does not have: there is no orgs table and no migration creates one.

The admin page becomes the single source and the public page a read-only projection of it. A new migration adds tier_config.pricing_enabled boolean not null default false, carried through TierConfig, TierConfigRepository::update, UpdateTierConfigRequest, and the tier-config response (as TierConfigWithPricing, a flattened superset of the dunite-shared TierConfigResponse, so the shared shape keeps one definition). has_db_overrides counts the new column, otherwise enabling pricing and changing nothing else would fall back to env on the next restart and silently unpublish the page. The admin page is relabelled "Pricing tiers" in its heading, browser title, and nav entry; the route stays /admin/tier-settings so existing bookmarks keep working.

A new public GET /v1/pricing returns the switch, the standard trial length, and the tiers that resolve to a usable Stripe price, with each amount read from the price tier_config.standard_price_id maps to. The advertised price is therefore incapable of disagreeing with the charged price. The payload is cached behind a TTL slot so a public page cannot generate a Stripe call per visit, and update_tier_config invalidates it, so changing the mapped price id in the admin panel (from either the Pricing tiers page or the Stripe catalog form, which posts to the same endpoint) changes the public page with no redeploy and no TTL wait. The route is deliberately absent from rate_limit_floor::EXEMPT_PATHS, so the default per-IP cap applies.

/pricing returns 404 when the switch is off or when no tier resolves, and the nav link, footer link, and homepage "See pricing" button are gated on exactly the same condition, so no rendered page links to a 404. The trial length in the pricing hero, the homepage hero chip, and the homepage CTA all come from standard_trial_days.

Removals: the BUSINESS const, the second pricing card and its show_business branch, the show_business_pricing config field, its env read, the compose and .env.example entries, and the three test fixtures that set it. The org copy is removed or rewritten in the Personal bullet list, the Standard tagline, both Our Story sentences, and the homepage feature card (now "Membership and entitlements", which the product actually does). The roadmap entries keep their wording, and the IP-enrichment organization field (the ASN owner) is untouched. A source-scanning guard in security.rs fails the build if any removed string reappears, and render-level tests assert the pricing page and homepage feature cards carry no org claim.

#BUNYIP-487

The public /pricing page and the admin tier configuration were two unrelated sources of truth, and the public one was hardcoded: the amount was the literal "$3", the hero said "Start free for 14 days" while `tier_config.standard_trial_days` defaults to 30, and a second "Business" card behind `BUNYIP_SHOW_BUSINESS_PRICING` advertised a tier that exists nowhere in `SubscriptionTier` or `tier_config`. The page also advertised organizations, which the product does not have: there is no orgs table and no migration creates one. The admin page becomes the single source and the public page a read-only projection of it. A new migration adds `tier_config.pricing_enabled boolean not null default false`, carried through `TierConfig`, `TierConfigRepository::update`, `UpdateTierConfigRequest`, and the tier-config response (as `TierConfigWithPricing`, a flattened superset of the dunite-shared `TierConfigResponse`, so the shared shape keeps one definition). `has_db_overrides` counts the new column, otherwise enabling pricing and changing nothing else would fall back to env on the next restart and silently unpublish the page. The admin page is relabelled "Pricing tiers" in its heading, browser title, and nav entry; the route stays /admin/tier-settings so existing bookmarks keep working. A new public `GET /v1/pricing` returns the switch, the standard trial length, and the tiers that resolve to a usable Stripe price, with each amount read from the price `tier_config.standard_price_id` maps to. The advertised price is therefore incapable of disagreeing with the charged price. The payload is cached behind a TTL slot so a public page cannot generate a Stripe call per visit, and `update_tier_config` invalidates it, so changing the mapped price id in the admin panel (from either the Pricing tiers page or the Stripe catalog form, which posts to the same endpoint) changes the public page with no redeploy and no TTL wait. The route is deliberately absent from `rate_limit_floor::EXEMPT_PATHS`, so the default per-IP cap applies. /pricing returns 404 when the switch is off or when no tier resolves, and the nav link, footer link, and homepage "See pricing" button are gated on exactly the same condition, so no rendered page links to a 404. The trial length in the pricing hero, the homepage hero chip, and the homepage CTA all come from `standard_trial_days`. Removals: the `BUSINESS` const, the second pricing card and its `show_business` branch, the `show_business_pricing` config field, its env read, the compose and .env.example entries, and the three test fixtures that set it. The org copy is removed or rewritten in the Personal bullet list, the Standard tagline, both Our Story sentences, and the homepage feature card (now "Membership and entitlements", which the product actually does). The roadmap entries keep their wording, and the IP-enrichment `organization` field (the ASN owner) is untouched. A source-scanning guard in `security.rs` fails the build if any removed string reappears, and render-level tests assert the pricing page and homepage feature cards carry no org claim. #BUNYIP-487
feat(pricing): drive /pricing from the admin Pricing tiers page
All checks were successful
E2E PR gate / E2E / PR gate (pull_request) Successful in 30s
Check / fmt + clippy + build + tests (pull_request) Successful in 8m41s
Create release / Create release from merged PR (pull_request) Has been skipped
44150e0567
The public /pricing page and the admin tier configuration were two unrelated sources of truth, and the public one was hardcoded: the amount was the literal "$3", the hero said "Start free for 14 days" while `tier_config.standard_trial_days` defaults to 30, and a second "Business" card behind `BUNYIP_SHOW_BUSINESS_PRICING` advertised a tier that exists nowhere in `SubscriptionTier` or `tier_config`. The page also advertised organizations, which the product does not have: there is no orgs table and no migration creates one.

The admin page becomes the single source and the public page a read-only projection of it. A new migration adds `tier_config.pricing_enabled boolean not null default false`, carried through `TierConfig`, `TierConfigRepository::update`, `UpdateTierConfigRequest`, and the tier-config response (as `TierConfigWithPricing`, a flattened superset of the dunite-shared `TierConfigResponse`, so the shared shape keeps one definition). `has_db_overrides` counts the new column, otherwise enabling pricing and changing nothing else would fall back to env on the next restart and silently unpublish the page. The admin page is relabelled "Pricing tiers" in its heading, browser title, and nav entry; the route stays /admin/tier-settings so existing bookmarks keep working.

A new public `GET /v1/pricing` returns the switch, the standard trial length, and the tiers that resolve to a usable Stripe price, with each amount read from the price `tier_config.standard_price_id` maps to. The advertised price is therefore incapable of disagreeing with the charged price. The payload is cached behind a TTL slot so a public page cannot generate a Stripe call per visit, and `update_tier_config` invalidates it, so changing the mapped price id in the admin panel (from either the Pricing tiers page or the Stripe catalog form, which posts to the same endpoint) changes the public page with no redeploy and no TTL wait. The route is deliberately absent from `rate_limit_floor::EXEMPT_PATHS`, so the default per-IP cap applies.

/pricing returns 404 when the switch is off or when no tier resolves, and the nav link, footer link, and homepage "See pricing" button are gated on exactly the same condition, so no rendered page links to a 404. The trial length in the pricing hero, the homepage hero chip, and the homepage CTA all come from `standard_trial_days`.

Removals: the `BUSINESS` const, the second pricing card and its `show_business` branch, the `show_business_pricing` config field, its env read, the compose and .env.example entries, and the three test fixtures that set it. The org copy is removed or rewritten in the Personal bullet list, the Standard tagline, both Our Story sentences, and the homepage feature card (now "Membership and entitlements", which the product actually does). The roadmap entries keep their wording, and the IP-enrichment `organization` field (the ASN owner) is untouched. A source-scanning guard in `security.rs` fails the build if any removed string reappears, and render-level tests assert the pricing page and homepage feature cards carry no org claim.

#BUNYIP-487
Claude-Run deleted branch feat/BUNYIP-487-admin-driven-pricing 2026-08-06 05:09:55 +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!487
No description provided.