pricing_plans 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fee743f485f98161652b725ca25882ccdd0066500b4612d8df6cf023d22552d8
4
- data.tar.gz: 61654ab323c68cde37eb9a258dc8ac3636d43e7abcae6407484a38b986342333
3
+ metadata.gz: 1a22c4cf12c06ba9c2e7d649811c286044ae8ff5f9586bea223584fa10b054e4
4
+ data.tar.gz: 55ee30afed8af0fd32018ca6d706ab0efe4cd745346fc24787d90ddbcf815071
5
5
  SHA512:
6
- metadata.gz: 88408758acf45aabfb9281281b31d069aeb1db85cc3bccb96f31768f2f035fc6a5e27fab9405f15525e7d521fe32381a53adbf9a8802c8cad8dc002c8f21dc3b
7
- data.tar.gz: ae1ddb7ace1f67c1d5ac9213c69fe3b14a52944f8b4beb90b616463e90b8377de1d2a135920841f96a81cc1bbeb097144ca535afea6f5aaff33db9b185fdc890
6
+ metadata.gz: bf40f8507ad67ca84c0e46e390db6fdb65e13ea247f5f1a12446c88ec6eaff59d0b519f2951538a59858f2b4222c4257112c5f13d4697a78fc84124e6c713782
7
+ data.tar.gz: 2a3906414a684300257e1b20b1f4b3cbd278ef7631c7a4f7c675f006483cb107145c323e61072e0fe2fe91976ca140b86dc617d3a14cc1495a010cf8c897c19a
data/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [0.4.1] - 2026-08-24
2
+
3
+ - **Never 500 a pricing page when Stripe is unreachable**: `Plan#currency_symbol` was the one Stripe lookup without a rescue — a Stripe outage, rate limit, or missing API key (any test/CI environment) raised straight through the pricing page. It now degrades to `default_currency_symbol` like every other presentation method (#24)
4
+ - **`price` and `stripe_price` can be declared together**: the numeric price is the local source of truth for display and plan comparison (no network in the request path); the Stripe id stays the billing identity for checkout and subscription matching. Previously `validate_pricing!` forced a choice, which meant Stripe-priced plans had no local number — and when the live lookup failed, `comparable_price_cents` silently became 0 for every paid plan, so `upgrade_from?` and `next_upgrade_plan` stopped offering upgrades with no error and no log line (#25)
5
+ - Only `price_string` remains exclusive, since a label cannot be compared numerically
6
+ - Known edge: for a both-declared plan the yearly figure derives as 12x monthly; a discounted yearly Stripe price needs `price_components_resolver`
7
+
1
8
  ## [0.4.0] - 2026-03-19
2
9
 
3
10
  - **Add plan provenance helpers**: `current_pricing_plan_resolution`, `current_pricing_plan_source`, and `PlanResolver.resolution_for(plan_owner)` now expose whether the effective plan comes from a manual assignment, a Pay subscription, or the default plan
@@ -493,6 +493,26 @@ end
493
493
 
494
494
  `stripe_price` accepts String or Hash (e.g., `{ month:, year:, id: }`) and the `pricing_plans` PlanResolver maps against Pay's `subscription.processor_plan`.
495
495
 
496
+ ### Declaring both a `price` and a `stripe_price`
497
+
498
+ You can declare both, and it's recommended for paid plans:
499
+
500
+ ```ruby
501
+ plan :pro do
502
+ price 29
503
+ stripe_price month: "price_123abc", year: "price_456def"
504
+ end
505
+ ```
506
+
507
+ They answer different questions, so they're not alternatives:
508
+
509
+ - `stripe_price` is the **billing identity**: what checkout charges, and what a Pay subscription is matched against.
510
+ - `price` is the **local source of truth for display and plan comparison**.
511
+
512
+ When both are set, the number wins for `price_label`, `price_components`, `currency_symbol` and upgrade/downgrade comparisons, so pricing pages and upgrade CTAs render without a single Stripe API call. Without a numeric price, all of that depends on a live `Stripe::Price.retrieve`; if that fails (Stripe unreachable, missing API key, cold cache), every paid plan compares as $0 and upgrade prompts silently vanish.
513
+
514
+ `price_string` remains exclusive with both: it's a label ("Contact us"), not a number, so it can't be compared.
515
+
496
516
 
497
517
  ## Example: define an enterprise plan
498
518
 
data/docs/04-views.md CHANGED
@@ -15,7 +15,7 @@ Each `PricingPlans::Plan` responds to:
15
15
  - `plan.name`
16
16
  - `plan.description`
17
17
  - `plan.bullets` → Array of strings
18
- - `plan.price_label` → The `price` or `price_string` you've defined for the plan. If `stripe_price` is set and the Stripe gem is available, it auto-fetches the live price from Stripe. You can override or disable this.
18
+ - `plan.price_label` → The `price` or `price_string` you've defined for the plan. If `stripe_price` is set (and no numeric `price` is declared) and the Stripe gem is available, it auto-fetches the live price from Stripe. You can override or disable this.
19
19
  - `plan.cta_text`
20
20
  - `plan.cta_url`
21
21
  - `plan.metadata` → Optional hash for UI/presentation attributes (icons, colors, badges)
@@ -42,6 +42,7 @@ plan.currency_symbol # "$" or derived from Stripe
42
42
  Notes:
43
43
 
44
44
  - If `stripe_price` is configured, we derive cents, currency, and interval from the Stripe Price (and cache it).
45
+ - If a numeric `price` is declared alongside `stripe_price`, the local number wins and no Stripe call is made (the yearly interval is then derived as 12× the monthly number; use `price_components_resolver` if your yearly price is discounted).
45
46
  - If `price 0` (free), we return components with `present? == true`, amount 0 and the configured default currency symbol.
46
47
  - If only `price_string` is set (e.g., "Contact us"), components return `present? == false`, `label == price_string`.
47
48
 
@@ -139,6 +140,8 @@ end
139
140
 
140
141
  By default, if a plan has `stripe_price` configured and the `stripe` gem is present, we auto-fetch the Stripe Price and render a friendly label (e.g., `$29/mo`). This mirrors Pay’s use of Stripe Prices.
141
142
 
143
+ Declaring a numeric `price` next to `stripe_price` opts that plan out: the local number is rendered and no Stripe call is made.
144
+
142
145
 
143
146
  To disable auto-fetching globally:
144
147
 
@@ -301,9 +301,11 @@ module PricingPlans
301
301
 
302
302
  # Human label to display price in UIs. Prefers explicit string, then numeric, else contact.
303
303
  def price_label
304
- # Auto-fetch from processor (Stripe) if enabled and plan has stripe_price
304
+ # Auto-fetch from processor (Stripe) if enabled and plan has stripe_price.
305
+ # A locally declared numeric price wins: it is the source of truth for
306
+ # display, and honoring it keeps rendering off the network entirely.
305
307
  cfg = PricingPlans.configuration
306
- if cfg&.auto_price_labels_from_processor && stripe_price
308
+ if cfg&.auto_price_labels_from_processor && stripe_price && price.nil?
307
309
  begin
308
310
  if defined?(::Stripe)
309
311
  price_id = stripe_price.is_a?(Hash) ? (stripe_price[:id] || stripe_price[:month] || stripe_price[:year]) : stripe_price
@@ -443,11 +445,17 @@ module PricingPlans
443
445
  end
444
446
 
445
447
  def currency_symbol
446
- if stripe_price
448
+ # A locally declared numeric price is rendered in the configured currency
449
+ # (see #price_components), so don't ask Stripe when we have one.
450
+ if stripe_price && price.nil?
447
451
  # Try to derive from Stripe API/cache; fall back to default
448
- pr = fetch_stripe_price_record(preferred_price_id(:month) || preferred_price_id(:year))
449
- if pr
450
- return currency_symbol_from(pr)
452
+ begin
453
+ pr = fetch_stripe_price_record(preferred_price_id(:month) || preferred_price_id(:year))
454
+ if pr
455
+ return currency_symbol_from(pr)
456
+ end
457
+ rescue StandardError
458
+ # Stripe unreachable, rate-limited or unconfigured: never take down a pricing page
451
459
  end
452
460
  end
453
461
  PricingPlans.configuration.default_currency_symbol
@@ -544,8 +552,12 @@ module PricingPlans
544
552
  end
545
553
 
546
554
  def validate_pricing!
547
- pricing_fields = [@price, @price_string, @stripe_price].compact
548
- if pricing_fields.size > 1
555
+ # `price` and `stripe_price` are not alternatives: the number is the local
556
+ # source of truth for display and plan comparison (resolved with no network
557
+ # call), while the Stripe id stays the billing identity used by checkout and
558
+ # by subscription -> plan matching. Only `price_string` remains exclusive,
559
+ # since it is a label that cannot be compared numerically.
560
+ if @price_string && (@price || @stripe_price)
549
561
  raise ConfigurationError, "Plan #{@key} can only have one of: price, price_string, or stripe_price"
550
562
  end
551
563
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PricingPlans
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.1"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pricing_plans
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-03-19 00:00:00.000000000 Z
10
+ date: 2026-08-24 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord