Skip to content

Billing intervals beyond month/year, public price → plan lookup, stable plan order - #31

Open
rameerez wants to merge 5 commits into
mainfrom
billing-intervals-and-price-lookup
Open

rameerez wants to merge 5 commits into
mainfrom
billing-intervals-and-price-lookup

Conversation

@rameerez

Copy link
Copy Markdown
Owner

Four gaps a production app (Pro at $24/mo, $54/quarter, $108/yr) had to work around by hand.

1. Price every billing interval for real

plan :pro do
  price        month: 24, quarter: 54, year: 108
  stripe_price month: "price_pro_m", quarter: "price_pro_q", year: "price_pro_y"
end

pro.prices                      # => { month: 24, quarter: 54, year: 108 }
pro.price                       # => 24 (still the monthly amount)
pro.billing_intervals           # => [:month, :quarter, :year]
pc = pro.price_components(interval: :quarter)
pc.label                        # => "$54/qtr"   (declared, not derived)
pc.monthly_equivalent_cents     # => 1800
pc.monthly_equivalent_label     # => "$18/mo"
pro.price_id_for(:quarter)      # => "price_pro_q"
pro.cta_url(interval: :quarter) # => subscribe_path(plan: :pro, interval: :quarter)
  • Intervals: :day, :week, :month, :quarter, :year, in both price and stripe_price (plus :id, which counts as monthly). String intervals (params[:interval]) are accepted everywhere; unknown ones raise (ConfigurationError in config, ArgumentError at call sites).
  • price 24 stays shorthand for price month: 24, so existing configs don't change. An undeclared interval is derived from the monthly price only as a fallback; with no monthly price it is reported as not present.
  • Stripe's interval: "month", interval_count: 3 reads as :quarter; week/day monthly equivalents are computed correctly (52/12, 365/12).
  • to_view_model adds billing_intervals and interval_prices; pricing_plan_cta(plan, interval:) passes the interval through.
  • If price is a Hash and stripe_price is declared too, both must cover the same intervals (raises at boot). Otherwise the page would show an amount Stripe doesn't charge, or offer an interval checkout can't sell.

2. Public price → plan lookup

PricingPlans.plan_for_price("price_pro_q")       # => #<PricingPlans::Plan :pro>, nil when unknown (hidden plans included)
PricingPlans.billing_interval_for("price_pro_q") # => :quarter, nil when unknown
plan.billing_interval_for("price_pro_q")         # => :quarter for this plan's own ids only

PlanResolver.plan_for_processor_plan now delegates to Registry.plan_for_price, so there is one implementation.

3. Stable plan order

PricingPlans.plans used sort_by, which is not stable, so Stripe-only plans (all ranked 10_000) and equally priced plans came back in arbitrary order. Ties now break by declaration index. test_plans_with_the_same_rank_keep_declaration_order fails on main and passes here.

4. Warn when a paid plan silently compares as $0

A Stripe-only paid plan whose price can't be resolved still compares as $0 in upgrade_from?/downgrade_from?, but it now logs one warning per plan naming it and suggesting a local price (to Rails.logger, or stderr outside Rails). Nothing else changes.

Things to note

  • stripe_price Hash keys are now validated. Any key other than :id or an interval raises at boot. Before, such keys were silently ignored by monthly_price_id/yearly_price_id. This is flagged as a heads-up in the CHANGELOG.
  • Labels keep cents ($29.99/mo, including Stripe-derived labels that used to round to $30/mo), and whole amounts drop .0.
  • No version bump; the CHANGELOG entry is under ## [Unreleased].

Tests

  • bundle exec rake test: 739 runs, 2379 assertions, 0 failures (main: 663 runs). Line coverage is 88.51% and branch coverage 70.01%, up from 87.42% and 68.74%.
  • Appraisals: rails-7.1, rails-7.2 and rails-8.1 all green.
  • RuboCop offenses in the touched lib files go down, from 198 to 194.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GHFoaVbFsz35hTz1TQLfbe

rameerez and others added 5 commits September 29, 2026 14:28
…plan order stable

- `price month: 24, quarter: 54, year: 108` declares real per-interval
  amounts (day/week/month/quarter/year); `price 24` stays monthly shorthand.
  price_components returns the declared amount plus a per-month equivalent
  label, derives from monthly only for undeclared intervals, and reads
  Stripe's month x3 as a quarter. Adds billing_intervals, prices,
  price_id_for, price_ids, billing_interval_for, monthly_equivalent_cents,
  and an interval: argument on cta_url / pricing_plan_cta.
- PricingPlans.plan_for_price / billing_interval_for expose the price id
  lookup PlanResolver uses, as one implementation.
- PricingPlans.plans breaks rank ties by declaration order (sort_by is not
  stable).
- A Stripe-only paid plan that compares as $0 logs a one-time warning.
- stripe_price keys are validated; a price Hash and stripe_price must cover
  the same intervals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GHFoaVbFsz35hTz1TQLfbe
usage_credits 1.0.0 adds `pricing_plan` to its subscription_plan DSL
(rameerez/usage_credits#35), which reads the ids from
PricingPlans::Registry.plan(key).stripe_price. The doc now shows that as
the way to keep one list of Stripe ids, with the two-initializer form
kept for earlier usage_credits versions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GHFoaVbFsz35hTz1TQLfbe
…cing_plan

From usage_credits 1.0.0 a subscription plan links to its pricing plan and
reads its stripe_price lazily, so the ids live in one initializer. The
two-initializer setup stays documented for earlier usage_credits versions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GHFoaVbFsz35hTz1TQLfbe
Two concurrent edits added the same usage_credits `pricing_plan` section;
keep the first one. The CHANGELOG line stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GHFoaVbFsz35hTz1TQLfbe
Runtime intervals (price_components, price_id_for, cta_url) usually come
straight from params[:interval], so raising turned a hand-edited
?interval=foo into a 500. An unknown or blank interval now means :month,
logged at debug level; padded strings like " quarter " are understood.
Unknown intervals in the plan configuration still raise
ConfigurationError at boot, since those are typos, not user input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GHFoaVbFsz35hTz1TQLfbe
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant