usage_credits 1.0.1 → 1.1.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: 710aa384672853be7b8711cffb537ad348761d0f8bf8c9df899565c7917522a8
4
- data.tar.gz: de219e55c5442ce44bbdf9bc1843c5bdeaa1e32508e3ee3c193f5baef0b7c259
3
+ metadata.gz: 10c8b86a5ff03a6d32bc494739245bbf3a829966620931efb6fcea8c72d327bf
4
+ data.tar.gz: deec62e5e448b924dec3e2896491c3723dbb07f7b90f2945b13d117b64a1c444
5
5
  SHA512:
6
- metadata.gz: ddd7d4f68a78f5608ded3984b277b6956ed1f849a72061a1816e1456252a164d87b771dd94337d653f28414932e349d31c88168085c1ead78e4083103920f356
7
- data.tar.gz: cfaa71674966f0197675a0467cce70d5476b4dd161d838907567e286920590a3051463cb1583baab92e23e852942b6414812df23ed93dc2a421f613f9c853b8f
6
+ metadata.gz: acef2a23c57df23db5738fbee7d718f15a0883404cd059f6e04e049e2fa00777ab37894b9ccc4abae11bdbe1cf0c5a4374dd682d80ef33ccac44f560399d6e8d
7
+ data.tar.gz: aee4071cbae93898c31014bac55fc08f2b196f9d4831ab64f749b3a4802d97499dd9a9f73e5cd8cc027bf154a2c6283c62b9d96ce60ebd0543d7ba18435c3ef4
data/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## [1.1.1] - 2026-10-19
2
+
3
+ ### Fixed
4
+
5
+ - The install and upgrade migrations (and the demo app's) pick `jsonb` for JSON columns on PostgreSQL under either of its adapter names: `PostGIS` (activerecord-postgis-adapter) was getting plain `json`. Same fix as wallets 0.4.3.
6
+
7
+ ## [1.1.0] - 2026-10-18
8
+
9
+ ### Added
10
+
11
+ - `pricing_plan` in `subscription_plan` blocks: take a plan's Stripe price IDs from the [`pricing_plans`](https://github.com/rameerez/pricing_plans) plan of the same (or a given) key, so prices are declared once and can't drift. Every price of the pricing plan (String or any interval Hash, e.g. `month:`, `year:`, `quarter:`) matches subscriptions. Prices are read lazily, so initializer order doesn't matter; `pricing_plans` remains an optional dependency. Combining `pricing_plan` with `stripe_price`, a missing `pricing_plans` gem, or a missing pricing plan raises the new `UsageCredits::ConfigurationError` (at boot, via `validate_pricing_plan_links!`, and on lookup).
12
+
13
+ ### Fixed
14
+
15
+ - **No credit grant can vanish through a `return` out of a lock or transaction block, and the suite now proves it.** In 0.5, `Wallet#add_credits` returned from inside `with_lock`, and the plan-change handler and `FulfillmentService#process` returned from inside `transaction`; on Rails 7.0, and on 7.1 without `load_defaults 7.1`, that rolled back what the block had written without an error, while `add_credits` still returned its `Transaction`. An audit of 1.0.1 found none of those exits left (the 1.0 rewrite removed the first and turned the other two into `next`) and no new one in any `transaction`, `with_lock` or wallet command block, and 1.0 requires Rails 7.2.3.1 or newer, which commits a block left by `return`, `break` or `throw`. New tests read back the ledger rows and the cached balance that `add_credits` and `give_credits` write, and document on every supported Rails that such a block commits. Reported by Tal Gruper (@TalGruperFolloze) in #31, with a fix for the 0.5 code in #32.
16
+
1
17
  ## [1.0.1] - 2026-10-18
2
18
 
3
19
  ### Fixed
data/README.md CHANGED
@@ -283,6 +283,8 @@ To ensure credits are not subtracted from users during failed operations, you ca
283
283
  end
284
284
  ```
285
285
 
286
+ The credits are deducted right after the block, inside the same database transaction, so leave the block by finishing it or by raising. A `return` or `break` out of the block skips the deduction: the block's own database writes are committed and the operation ends up free.
287
+
286
288
  If you want to spend the credits immediately, you can use the non-block form:
287
289
 
288
290
  ```ruby
@@ -506,6 +508,33 @@ subscription_plan :basic do
506
508
  end
507
509
  ```
508
510
 
511
+ #### Using `pricing_plans`? Declare your prices once
512
+
513
+ If you also use [`pricing_plans`](https://github.com/rameerez/pricing_plans), your Stripe prices already live in `config/initializers/pricing_plans.rb`. Don't copy them here: point the subscription plan at its pricing plan instead.
514
+
515
+ ```ruby
516
+ # config/initializers/pricing_plans.rb
517
+ plan :pro do
518
+ stripe_price month: "price_pro_monthly", year: "price_pro_yearly", quarter: "price_pro_quarterly"
519
+ end
520
+
521
+ # config/initializers/usage_credits.rb
522
+ subscription_plan :pro do
523
+ pricing_plan # Take the Stripe prices of pricing_plans' :pro plan
524
+ gives 10_000.credits.every(:month)
525
+ unused_credits :expire
526
+ end
527
+ ```
528
+
529
+ Every Stripe price of the pricing plan counts, whatever you named its keys (a single `stripe_price "price_x"` works too), so a subscriber on any of them gets their credits. Pass a key when the names differ: `pricing_plan :pro_2026`.
530
+
531
+ A few things to know:
532
+
533
+ - **Initializer order doesn't matter.** Prices are read from `pricing_plans` every time a subscription is matched, never copied at boot, so `pricing_plans.rb` can load before or after `usage_credits.rb`.
534
+ - **One source of truth.** A plan that uses `pricing_plan` can't also declare `stripe_price`; that raises `UsageCredits::ConfigurationError`. Keep every price, including legacy ones for grandfathered subscribers, in the pricing plan. `pricing_plans` up to 0.7.x accepts any key for them, but its next version only accepts `id`, `day`, `week`, `month`, `quarter` and `year`, so there a legacy price needs a spare interval key. Plan IDs for other processors (`processor_plan :paddle, ...`) still work alongside it.
535
+ - **Broken links fail loudly.** If `pricing_plans` isn't installed, or has no plan with that key, your app raises `UsageCredits::ConfigurationError` at boot (and on any lookup) instead of silently refilling nothing. A pricing plan with no Stripe price in the current environment only logs a warning at boot, since build steps like `assets:precompile` often run without credentials.
536
+ - `pricing_plans` stays optional: `usage_credits` never requires it. The only thing it reads is `PricingPlans::Registry.plan(key).stripe_price`.
537
+
509
538
  ### Specify a fulfillment period
510
539
 
511
540
  Next, specify how many credits a user subscribed to this plan gets, and when they get them.
@@ -125,7 +125,7 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
125
125
  end
126
126
 
127
127
  def json_column_type
128
- return :jsonb if connection.adapter_name.downcase.include?('postgresql')
128
+ return :jsonb if connection.adapter_name.match?(/postg/i) # PostgreSQL, and PostGIS, which is PostgreSQL under another name
129
129
  :json
130
130
  end
131
131
 
@@ -589,7 +589,7 @@ class UpgradeUsageCreditsToWalletsCore < ActiveRecord::Migration<%= migration_ve
589
589
  end
590
590
 
591
591
  def json_column_type
592
- return :jsonb if connection.adapter_name.downcase.include?("postgresql")
592
+ return :jsonb if connection.adapter_name.match?(/postg/i) # PostgreSQL, and PostGIS, which is PostgreSQL under another name
593
593
  :json
594
594
  end
595
595
 
@@ -303,6 +303,24 @@ module UsageCredits
303
303
  true
304
304
  end
305
305
 
306
+ # Resolve every `pricing_plan` link once all initializers have run, so a
307
+ # missing gem or plan fails at boot instead of on the first paid webhook.
308
+ # A linked plan without Stripe prices only warns: build steps like
309
+ # `assets:precompile` often boot without the credentials that hold them.
310
+ def validate_pricing_plan_links!
311
+ @credit_subscription_plans.each_value do |plan|
312
+ next unless plan.pricing_plan_key
313
+ next if plan.stripe_price_from_pricing_plan
314
+
315
+ Rails.logger.warn(
316
+ "[UsageCredits] Subscription plan '#{plan.name}' takes its Stripe prices from pricing plan " \
317
+ "'#{plan.pricing_plan_key}', which has no Stripe price. No Stripe subscription will match it, " \
318
+ "so Stripe subscribers won't get its credits."
319
+ )
320
+ end
321
+ true
322
+ end
323
+
306
324
  private
307
325
 
308
326
  def validate_currency!
@@ -23,6 +23,11 @@ module UsageCredits
23
23
  end
24
24
  end
25
25
 
26
+ # Runs after every initializer, so pricing_plans.rb and usage_credits.rb can load in any order
27
+ config.after_initialize do
28
+ UsageCredits.configuration.validate_pricing_plan_links!
29
+ end
30
+
26
31
  initializer "usage_credits.action_view" do
27
32
  ActiveSupport.on_load :action_view do
28
33
  require "usage_credits/helpers/credits_helper"
@@ -10,7 +10,7 @@ module UsageCredits
10
10
  # @see PaySubscriptionExtension for the actual credit fulfillment logic
11
11
  class CreditSubscriptionPlan
12
12
  attr_reader :name,
13
- :processor_plan_ids,
13
+ :pricing_plan_key,
14
14
  :fulfillment_period, :credits_per_period,
15
15
  :signup_bonus_credits, :trial_credits,
16
16
  :rollover_enabled,
@@ -94,6 +94,49 @@ module UsageCredits
94
94
  # Payment Processor Integration
95
95
  # =========================================
96
96
 
97
+ # Take this plan's Stripe price IDs from the `pricing_plans` plan with the
98
+ # given key (this plan's own name by default), so every price is declared
99
+ # exactly once: in pricing_plans, the single source of truth for pricing.
100
+ #
101
+ # Prices are read lazily, every time the plan is matched or asked for its
102
+ # prices, so it doesn't matter which initializer Rails loads first. The only
103
+ # contract with pricing_plans is `PricingPlans::Registry.plan(key).stripe_price`.
104
+ #
105
+ # @param key [Symbol, String] The pricing_plans plan key
106
+ # @example Same key
107
+ # pricing_plan
108
+ # @example Different key
109
+ # pricing_plan :pro_2026
110
+ def pricing_plan(key = name)
111
+ raise ArgumentError, "Pricing plan key can't be blank" if key.blank?
112
+ @pricing_plan_key = key.to_sym
113
+ self
114
+ end
115
+
116
+ # Processor-specific plan ID(s), including Stripe prices read from the linked pricing plan
117
+ # @return [Hash] processor => String or Hash of period => ID
118
+ def processor_plan_ids
119
+ return @processor_plan_ids unless pricing_plan_key
120
+ @processor_plan_ids.merge(stripe: stripe_price_from_pricing_plan).compact
121
+ end
122
+
123
+ # Stripe price ID(s) of the linked pricing plan, in the same shape `stripe_price` returns:
124
+ # a String for a single price, or a Hash of period => price ID. Blank and non-String
125
+ # values (e.g. a credential that isn't set in this environment) are skipped.
126
+ #
127
+ # @return [String, Hash, nil]
128
+ # @raise [ConfigurationError] if pricing_plans isn't loaded or has no such plan
129
+ def stripe_price_from_pricing_plan
130
+ case (prices = linked_pricing_plan.stripe_price)
131
+ when String
132
+ prices.presence
133
+ when Hash
134
+ ids = prices.to_h { |period, id| [period.to_sym, id] }.select { |_, id| id.is_a?(String) && id.present? }
135
+ # pricing_plans stores a single `stripe_price "price_x"` as { id: "price_x" }
136
+ (ids.keys == [:id]) ? ids[:id] : ids.presence
137
+ end
138
+ end
139
+
97
140
  # Set the processor-specific plan ID(s)
98
141
  # Accepts either a single ID (String) or multiple period-specific IDs (Hash)
99
142
  #
@@ -109,7 +152,7 @@ module UsageCredits
109
152
  # Normalize all keys to symbols for consistent lookup
110
153
  id = id.transform_keys(&:to_sym)
111
154
  end
112
- processor_plan_ids[processor.to_sym] = id
155
+ @processor_plan_ids[processor.to_sym] = id
113
156
  end
114
157
 
115
158
  # Get the plan ID(s) for a specific processor
@@ -244,6 +287,11 @@ module UsageCredits
244
287
  raise ArgumentError, "Fulfillment period must be set" if fulfillment_period.nil?
245
288
  raise ArgumentError, "Signup bonus credits must be greater than or equal to 0" if signup_bonus_credits.to_i.negative?
246
289
  raise ArgumentError, "Trial credits must be greater than or equal to 0" if trial_credits.to_i.negative?
290
+ if pricing_plan_key && @processor_plan_ids.key?(:stripe)
291
+ raise ConfigurationError, "Subscription plan :#{name} takes its Stripe prices from pricing plan " \
292
+ ":#{pricing_plan_key}, so it can't also declare `stripe_price`. Declare every price once, " \
293
+ "in the pricing plan (legacy prices included), and remove `stripe_price` here."
294
+ end
247
295
  true
248
296
  end
249
297
 
@@ -293,6 +341,22 @@ module UsageCredits
293
341
 
294
342
  private
295
343
 
344
+ def linked_pricing_plan
345
+ unless defined?(::PricingPlans::Registry)
346
+ raise ConfigurationError, "Subscription plan :#{name} uses `pricing_plan :#{pricing_plan_key}`, " \
347
+ "but the pricing_plans gem isn't loaded. Add `gem \"pricing_plans\"` to your Gemfile, " \
348
+ "or declare `stripe_price` in this plan instead."
349
+ end
350
+
351
+ begin
352
+ ::PricingPlans::Registry.plan(pricing_plan_key) or raise KeyError
353
+ rescue
354
+ raise ConfigurationError, "Subscription plan :#{name} uses `pricing_plan :#{pricing_plan_key}`, " \
355
+ "but pricing_plans has no plan :#{pricing_plan_key}. Define `plan :#{pricing_plan_key}` in " \
356
+ "config/initializers/pricing_plans.rb, or pass the right key: `pricing_plan :your_plan`."
357
+ end
358
+ end
359
+
296
360
  def normalize_whole_number!(amount, name, minimum:)
297
361
  amount = amount.to_i if amount.is_a?(UsageCredits::Cost::Fixed)
298
362
  value = Wallets::WholeNumber.parse(amount, name: name)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module UsageCredits
4
- VERSION = "1.0.1"
4
+ VERSION = "1.1.1"
5
5
  end
data/lib/usage_credits.rb CHANGED
@@ -73,6 +73,7 @@ require "usage_credits/jobs/fulfillment_job"
73
73
  module UsageCredits
74
74
  # Custom error classes
75
75
  class Error < StandardError; end
76
+ class ConfigurationError < Error; end
76
77
  class InsufficientCredits < Error; end
77
78
  class InvalidOperation < Error; end
78
79
  class InvalidTransfer < Error; end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: usage_credits
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez