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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +29 -0
- data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +1 -1
- data/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb +1 -1
- data/lib/usage_credits/configuration.rb +18 -0
- data/lib/usage_credits/engine.rb +5 -0
- data/lib/usage_credits/models/credit_subscription_plan.rb +66 -2
- data/lib/usage_credits/version.rb +1 -1
- data/lib/usage_credits.rb +1 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 10c8b86a5ff03a6d32bc494739245bbf3a829966620931efb6fcea8c72d327bf
|
|
4
|
+
data.tar.gz: deec62e5e448b924dec3e2896491c3723dbb07f7b90f2945b13d117b64a1c444
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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!
|
data/lib/usage_credits/engine.rb
CHANGED
|
@@ -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
|
-
:
|
|
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)
|
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
|