usage_credits 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +559 -0
- data/Rakefile +32 -0
- data/lib/generators/usage_credits/install_generator.rb +49 -0
- data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +88 -0
- data/lib/generators/usage_credits/templates/initializer.rb +105 -0
- data/lib/usage_credits/configuration.rb +204 -0
- data/lib/usage_credits/core_ext/numeric.rb +59 -0
- data/lib/usage_credits/cost/base.rb +43 -0
- data/lib/usage_credits/cost/compound.rb +37 -0
- data/lib/usage_credits/cost/fixed.rb +34 -0
- data/lib/usage_credits/cost/variable.rb +42 -0
- data/lib/usage_credits/engine.rb +37 -0
- data/lib/usage_credits/helpers/credit_calculator.rb +34 -0
- data/lib/usage_credits/helpers/credits_helper.rb +45 -0
- data/lib/usage_credits/helpers/period_parser.rb +77 -0
- data/lib/usage_credits/jobs/fulfillment_job.rb +25 -0
- data/lib/usage_credits/models/allocation.rb +31 -0
- data/lib/usage_credits/models/concerns/has_wallet.rb +94 -0
- data/lib/usage_credits/models/concerns/pay_charge_extension.rb +198 -0
- data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +251 -0
- data/lib/usage_credits/models/credit_pack.rb +159 -0
- data/lib/usage_credits/models/credit_subscription_plan.rb +204 -0
- data/lib/usage_credits/models/fulfillment.rb +91 -0
- data/lib/usage_credits/models/operation.rb +153 -0
- data/lib/usage_credits/models/transaction.rb +174 -0
- data/lib/usage_credits/models/wallet.rb +310 -0
- data/lib/usage_credits/railtie.rb +17 -0
- data/lib/usage_credits/services/fulfillment_service.rb +129 -0
- data/lib/usage_credits/version.rb +5 -0
- data/lib/usage_credits.rb +170 -0
- data/sig/usagecredits.rbs +4 -0
- metadata +115 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 746f3023cb2533204e1eb089718a09019786f868a76cfa58c61fce459d608985
|
4
|
+
data.tar.gz: cca4bb0ac339d37151f84a2270e7c34f436f6f66cdfbb593e8fbdb57aa7cefcd
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 539023a63628718b2a44628abb725ab6a1d23f115ca509fbb4cff5284f296cc77e71ae28d0fe0dd84725f415b984757973828214b190da5ba48be0f633778092
|
7
|
+
data.tar.gz: 036544fb6fe39eb762216fef0f9a56cc41c31f0ff5eeb53156a47252a5948b473146b8ead147d5a27e98be652d5cac238f27b503747811064e9f83a2815522e2
|
data/.rubocop.yml
ADDED
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2024 Javi R
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,559 @@
|
|
1
|
+
# 💳✨ `usage_credits` - Add usage-based credits to your Rails app
|
2
|
+
|
3
|
+
[](https://badge.fury.io/rb/usage_credits)
|
4
|
+
|
5
|
+
Allow your users to have in-app credits / tokens they can use to perform operations.
|
6
|
+
|
7
|
+
✨ Perfect for SaaS, AI apps, games, and API products that want to implement usage-based pricing.
|
8
|
+
|
9
|
+
Refill user credits with Stripe subscriptions, allow your users to top up by purchasing booster credit packs at any time, rollover unused credits to the next billing period, expire credits, implement PAYG (pay-as-you-go) billing, award free credits as bonuses (for referrals, giving feedback, etc.), get a detailed history and audit trail of every transaction for billing / reporting, and more!
|
10
|
+
|
11
|
+
All with a simple DSL that reads just like English.
|
12
|
+
|
13
|
+
**Requirements**
|
14
|
+
|
15
|
+
- An ActiveJob backend (Sidekiq, `solid_queue`, etc.) for subscription credit fulfillment
|
16
|
+
- [`pay`](https://github.com/pay-rails/pay) gem for Stripe/PayPal/Lemon Squeezy integration (sell credits, refill subscriptions)
|
17
|
+
|
18
|
+
## 👨💻 Example
|
19
|
+
|
20
|
+
`usage_credits` allows you to add credits to your Rails app in just one line of code. If you have a `User` model, just add `has_credits` to it and you're ready to go:
|
21
|
+
|
22
|
+
```ruby
|
23
|
+
class User
|
24
|
+
has_credits
|
25
|
+
end
|
26
|
+
```
|
27
|
+
|
28
|
+
With that, your users automatically get all credits functionality, and you can start performing operations:
|
29
|
+
|
30
|
+
```ruby
|
31
|
+
@user.give_credits(100)
|
32
|
+
```
|
33
|
+
|
34
|
+
You can check any user's balance:
|
35
|
+
```ruby
|
36
|
+
@user.credits
|
37
|
+
=> 100
|
38
|
+
```
|
39
|
+
|
40
|
+
And spend their credits securely:
|
41
|
+
```ruby
|
42
|
+
@user.spend_credits_on(:send_email) do
|
43
|
+
# Perform the actual operation here.
|
44
|
+
# No credits will be spent if this block fails.
|
45
|
+
end
|
46
|
+
```
|
47
|
+
|
48
|
+
Defining credit-spending operations is as simple as:
|
49
|
+
```ruby
|
50
|
+
operation :send_email do
|
51
|
+
costs 1.credit
|
52
|
+
end
|
53
|
+
```
|
54
|
+
|
55
|
+
And defining credit-fulfilling subscriptions is really simple too:
|
56
|
+
```ruby
|
57
|
+
subscription_plan :pro do
|
58
|
+
gives 1_000.credits.every :month
|
59
|
+
unused_credits :rollover # or :expire
|
60
|
+
end
|
61
|
+
```
|
62
|
+
|
63
|
+
This gem keeps track of every transaction and its cost + origin, so you can keep a clean audit trail for clear invoicing and reference / auditing purposes:
|
64
|
+
```ruby
|
65
|
+
@user.credit_history.pluck(:category, :amount)
|
66
|
+
=> [["signup_bonus", 100], ["operation_charge", -1]]
|
67
|
+
```
|
68
|
+
|
69
|
+
Each transaction stores comprehensive metadata about the action that was performed:
|
70
|
+
```ruby
|
71
|
+
@user.credit_history.last.metadata
|
72
|
+
=> {"operation"=>"send_email", "cost"=>1, "params"=>{}, "metadata"=>{}, "executed_at"=>"..."}
|
73
|
+
```
|
74
|
+
|
75
|
+
You can also expire credits, fulfill credits based on Stripe subscriptions, sell one-time booster credit packs, rollover/expire unused credits to the next fulfillment period, and more!
|
76
|
+
|
77
|
+
Sounds good? Let's get started!
|
78
|
+
|
79
|
+
## Quick start
|
80
|
+
|
81
|
+
Add the gem to your Gemfile:
|
82
|
+
```ruby
|
83
|
+
gem 'usage_credits'
|
84
|
+
```
|
85
|
+
|
86
|
+
Then run:
|
87
|
+
```bash
|
88
|
+
bundle install
|
89
|
+
rails generate usage_credits:install
|
90
|
+
rails db:migrate
|
91
|
+
```
|
92
|
+
|
93
|
+
Add `has_credits` your user model (or any model that needs to have credits):
|
94
|
+
```ruby
|
95
|
+
class User < ApplicationRecord
|
96
|
+
has_credits
|
97
|
+
end
|
98
|
+
```
|
99
|
+
|
100
|
+
Lastly, schedule the `UsageCredits::FulfillmentJob` to run periodically (we rely on this ActiveJob job to refill credits for subscriptions). For example, with Solid Queue:
|
101
|
+
|
102
|
+
```yaml
|
103
|
+
# config/recurring.yml
|
104
|
+
|
105
|
+
production:
|
106
|
+
refill_credits:
|
107
|
+
class: UsageCredits::FulfillmentJob
|
108
|
+
queue: default
|
109
|
+
schedule: every 5 minutes
|
110
|
+
```
|
111
|
+
|
112
|
+
(Your actual setup for the recurring job may change if you're using Sidekiq or other ActiveJob backend – make sure you set it up right for your specific backend)
|
113
|
+
|
114
|
+
> [!IMPORTANT]
|
115
|
+
> This gem requires an ActiveJob backend to handle recurring credit fulfillment. Make sure you have one configured (Sidekiq, `solid_queue`, etc.) or subscription credits won't be fulfilled.
|
116
|
+
|
117
|
+
That's it! Your app now has a credits system. Let's see how to use it.
|
118
|
+
|
119
|
+
## How it works
|
120
|
+
|
121
|
+
`usage_credits` makes it dead simple to add a usage-based credits system to your Rails app:
|
122
|
+
|
123
|
+
1. Users can get credits by:
|
124
|
+
- Purchasing credit packs (e.g., "1000 credits for $49")
|
125
|
+
- Having a subscription (e.g., "Pro plan includes 10,000 credits/month")
|
126
|
+
|
127
|
+
2. Users spend credits on operations you define:
|
128
|
+
- "Sending an email costs 1 credit"
|
129
|
+
- "Processing an image costs 10 credits + 1 credit per MB"
|
130
|
+
|
131
|
+
First, let's see how to define these credit-consuming operations.
|
132
|
+
|
133
|
+
## Define credit-consuming operations and set credit costs
|
134
|
+
|
135
|
+
Define all your operations and their cost in your `config/initializers/usage_credits.rb` file.
|
136
|
+
|
137
|
+
For example, create a simple operation named `send_email` that costs 1 credit to perform:
|
138
|
+
|
139
|
+
```ruby
|
140
|
+
operation :send_email do
|
141
|
+
costs 1.credit
|
142
|
+
end
|
143
|
+
```
|
144
|
+
|
145
|
+
You can get quite sophisticated in pricing, and define the cost of your operations based on parameters:
|
146
|
+
```ruby
|
147
|
+
operation :process_image do
|
148
|
+
costs 10.credits + 1.credit_per(:mb)
|
149
|
+
end
|
150
|
+
```
|
151
|
+
|
152
|
+
> [!NOTE]
|
153
|
+
> Credit costs must be whole numbers. Decimals are not allowed to avoid floating-point issues and ensure predictable billing.
|
154
|
+
> ```ruby
|
155
|
+
> 1.credit # ✅ Valid: whole number
|
156
|
+
> 10.credits # ✅ Valid: whole number
|
157
|
+
> 1.credits_per(:mb) # ✅ Valid: whole number rate
|
158
|
+
>
|
159
|
+
> 0.5.credits # ❌ Invalid: decimal credits
|
160
|
+
> 1.5.credits_per(:mb) # ❌ Invalid: decimal rate
|
161
|
+
> ```
|
162
|
+
> For variable costs (like per MB), the final cost is rounded according to your configured rounding strategy (defaults to rounding up).
|
163
|
+
> For example, with `1.credits_per(:mb)`, using 2.3 MB will cost 3 credits by default, to avoid undercharging users.
|
164
|
+
|
165
|
+
### Units and Rounding
|
166
|
+
|
167
|
+
For variable costs, you can specify units in different ways:
|
168
|
+
|
169
|
+
```ruby
|
170
|
+
# Using megabytes
|
171
|
+
operation :process_image do
|
172
|
+
costs 1.credits_per(:mb) # or :megabytes, :megabyte
|
173
|
+
end
|
174
|
+
|
175
|
+
# Using units
|
176
|
+
operation :process_items do
|
177
|
+
costs 1.credits_per(:units) # or :unit
|
178
|
+
end
|
179
|
+
```
|
180
|
+
|
181
|
+
When using the operation, you can specify the size directly in the unit:
|
182
|
+
```ruby
|
183
|
+
# Direct MB specification
|
184
|
+
@user.estimate_credits_to(:process_image, mb: 5) # => 5 credits
|
185
|
+
|
186
|
+
# Or using byte size (automatically converted)
|
187
|
+
@user.estimate_credits_to(:process_image, size: 5.megabytes) # => 5 credits
|
188
|
+
```
|
189
|
+
|
190
|
+
You can configure how fractional costs are rounded:
|
191
|
+
|
192
|
+
```ruby
|
193
|
+
UsageCredits.configure do |config|
|
194
|
+
# :ceil (default) - Always round up (2.1 => 3)
|
195
|
+
# :floor - Always round down (2.9 => 2)
|
196
|
+
# :round - Standard rounding (2.4 => 2, 2.6 => 3)
|
197
|
+
config.rounding_strategy = :ceil
|
198
|
+
end
|
199
|
+
```
|
200
|
+
|
201
|
+
By default, we round up (`:ceil`) all credit costs to avoid undercharging. So if an operation costs 1 credit per megabyte, and the user submits a file that's 5.2 megabytes, we'll deduct 6 credits from the user's wallet.
|
202
|
+
|
203
|
+
It's also possible to add validations and metadata to your operations:
|
204
|
+
|
205
|
+
```ruby
|
206
|
+
# With custom validation
|
207
|
+
operation :generate_ai_response do
|
208
|
+
costs 5.credits
|
209
|
+
validate ->(params) { params[:prompt].length <= 1000 }, "Prompt too long"
|
210
|
+
end
|
211
|
+
|
212
|
+
# With metadata for better tracking
|
213
|
+
operation :analyze_data do
|
214
|
+
costs 20.credits
|
215
|
+
meta category: :analytics, description: "Deep data analysis"
|
216
|
+
end
|
217
|
+
```
|
218
|
+
|
219
|
+
## Spend credits
|
220
|
+
|
221
|
+
There's a handy `estimate_credits_to` method to can estimate the total cost of an operation before spending any credits:
|
222
|
+
|
223
|
+
```ruby
|
224
|
+
@user.estimate_credits_to(:process_image, size: 5.megabytes)
|
225
|
+
=> 15 # (10 base + 5 MB * 1 credit/MB)
|
226
|
+
```
|
227
|
+
|
228
|
+
There's also a `has_enough_credits_to?` method to nicely check the user has enough credits before performing a certain operation:
|
229
|
+
```ruby
|
230
|
+
if @user.has_enough_credits_to?(:process_image, size: 5.megabytes)
|
231
|
+
# actually spend the credits
|
232
|
+
else
|
233
|
+
redirect_to credits_path, alert: "Not enough credits!"
|
234
|
+
end
|
235
|
+
```
|
236
|
+
|
237
|
+
Finally, you can actually spend credits with `spend_credits_on`:
|
238
|
+
```ruby
|
239
|
+
@user.spend_credits_on(:process_image, size: 5.megabytes)
|
240
|
+
```
|
241
|
+
|
242
|
+
To ensure credits are not subtracted from users during failed operations, you can pass a block to `spend_credits_on`. No credits are spent if the block doesn't succeed (it shouldn't raise any exceptions or throw any errors) This way, you ensure credits are only spent if the operation succeeds:
|
243
|
+
|
244
|
+
```ruby
|
245
|
+
@user.spend_credits_on(:process_image, size: 5.megabytes) do
|
246
|
+
process_image(params) # If this raises an error, no credits are spent
|
247
|
+
end
|
248
|
+
```
|
249
|
+
|
250
|
+
If you want to spend the credits immediately, you can use the non-block form:
|
251
|
+
|
252
|
+
```ruby
|
253
|
+
@user.spend_credits_on(:process_image, size: 5.megabytes)
|
254
|
+
process_image(params) # If this fails, credits are already spent!
|
255
|
+
```
|
256
|
+
|
257
|
+
> [!TIP]
|
258
|
+
> Always estimate and check credits before performing expensive operations.
|
259
|
+
> If validation fails (e.g., file too large), both methods will raise `InvalidOperation`.
|
260
|
+
> Perform your operation inside the `spend_credits_on` block OR make the credit spend conditional to the actual operation, so users are not charged if the operation fails.
|
261
|
+
|
262
|
+
## Low balance alerts
|
263
|
+
|
264
|
+
You can hook on to our low balance event to notify users when they are running low on credits (useful to upsell them a credit pack):
|
265
|
+
|
266
|
+
```ruby
|
267
|
+
UsageCredits.configure do |config|
|
268
|
+
# Alert when balance drops below 100 credits
|
269
|
+
# Set to nil to disable low balance alerts
|
270
|
+
config.low_balance_threshold = 100.credits
|
271
|
+
|
272
|
+
# Handle low credit balance alerts
|
273
|
+
config.on_low_balance do |user|
|
274
|
+
# Send notification to user
|
275
|
+
UserMailer.low_credits_alert(user).deliver_later
|
276
|
+
|
277
|
+
# Or trigger any other business logic
|
278
|
+
SlackNotifier.notify("User #{user.id} is running low on credits!")
|
279
|
+
end
|
280
|
+
end
|
281
|
+
```
|
282
|
+
|
283
|
+
## Sell credit packs
|
284
|
+
|
285
|
+
> [!IMPORTANT]
|
286
|
+
> For all payment-related operations (sell credit packs, handle subscription-based fulfillment, etc.) this gem relies on the [`pay`](https://github.com/pay-rails/pay) gem – make sure you have it installed and correctly configured with your payment processor (Stripe, Lemon Squeezy, PayPal, etc.) before continuing. Follow the `pay` README for more information and installation instructions.
|
287
|
+
|
288
|
+
In the `config/initializers/usage_credits.rb` file, define credit packs users can buy:
|
289
|
+
|
290
|
+
```ruby
|
291
|
+
credit_pack :starter do
|
292
|
+
gives 1000.credits
|
293
|
+
costs 49.dollars
|
294
|
+
end
|
295
|
+
```
|
296
|
+
|
297
|
+
Then, you can prompt them to purchase it with our `pay`-based helpers:
|
298
|
+
```ruby
|
299
|
+
# Create a Stripe Checkout session for purchase
|
300
|
+
credit_pack = UsageCredits.find_credit_pack(:starter)
|
301
|
+
session = credit_pack.create_checkout_session(current_user)
|
302
|
+
redirect_to session.url
|
303
|
+
```
|
304
|
+
|
305
|
+
The gem automatically handles:
|
306
|
+
- Credit pack fulfillment after successful payment
|
307
|
+
- Proportional credit removal on refunds (e.g., if 50% is refunded, 50% of credits are removed)
|
308
|
+
- Prevention of double-processing of purchase
|
309
|
+
- Support for multiple currencies (USD, EUR, etc.)
|
310
|
+
- Detailed transaction tracking with metadata like:
|
311
|
+
```ruby
|
312
|
+
{
|
313
|
+
credit_pack: "starter", # Credit pack identifier
|
314
|
+
charge_id: "ch_xxx", # Payment processor charge ID
|
315
|
+
processor: "stripe", # Payment processor used
|
316
|
+
price_cents: 4900, # Amount paid in cents
|
317
|
+
currency: "usd", # Currency used for payment
|
318
|
+
credits: 1000, # Base credits given
|
319
|
+
purchased_at: "2024-01-20" # Purchase timestamp
|
320
|
+
}
|
321
|
+
```
|
322
|
+
|
323
|
+
## Subscription plans that grant credits
|
324
|
+
|
325
|
+
Users can subscribe to a plan (monthly, yearly, etc.) that gives them credits.
|
326
|
+
|
327
|
+
Defining a subscription plan is as simple as this:
|
328
|
+
```ruby
|
329
|
+
subscription_plan :pro do
|
330
|
+
stripe_price "price_XYZ" # Link it to your Stripe price ID
|
331
|
+
gives 10_000.credits.every(:month) # Monthly credits
|
332
|
+
signup_bonus 1_000.credits # One-time bonus
|
333
|
+
trial_includes 500.credits # Trial period credits
|
334
|
+
unused_credits :rollover # Credits roll over to the next fulfillment period (:rollover or :expire)
|
335
|
+
expire_after 30.days # Optional: credits expire after cancellation
|
336
|
+
end
|
337
|
+
```
|
338
|
+
|
339
|
+
The first thing to understand is that **credit fulfillment** is decoupled from **billing periods**:
|
340
|
+
|
341
|
+
### Credit fulfillment cycles
|
342
|
+
|
343
|
+
Credit fulfillment is completely decoupled from billing periods.
|
344
|
+
|
345
|
+
This means you can drip credits at any pace you want (e.g., 100/day instead of 3000/month) – and that's completely independent of when your users get actually charged (typically on a monthly or yearly basis, as you defined on Stripe)
|
346
|
+
|
347
|
+
`pay` handles the user's subscription payments (billing periods), we handle how we fulfill that subscription (fulfilling cycles)
|
348
|
+
|
349
|
+
We rely on ActiveJob to fulfill credits. So you should have an ActiveJob backend installed and configured (Sidekiq, `solid_queue`, etc.) for credits to be refilled. To make fulfillment actually work, you'll need to schedule the fulfillment job to run periodically, as explained in the setup section.
|
350
|
+
|
351
|
+
### First, create a Stripe subscription
|
352
|
+
|
353
|
+
`usage_credits` relies on you first creating a subscription on your Stripe dashboard and then linking it to the gem by setting the specific Stripe plan ID in the subscription config using the `stripe_price` option, like this:
|
354
|
+
```ruby
|
355
|
+
subscription_plan :pro do
|
356
|
+
stripe_price "price_XYZ"
|
357
|
+
# ...
|
358
|
+
end
|
359
|
+
```
|
360
|
+
|
361
|
+
For now, only Stripe subscriptions are supported (contribute to the codebase to help us add more payment processors!)
|
362
|
+
|
363
|
+
### Specify a fulfillment period
|
364
|
+
|
365
|
+
Next, specify how many credits a user subscribed to this plan gets, and when they get them.
|
366
|
+
|
367
|
+
Since fulfillment cycles are decoupled from billing cycles, you can either match fulfillment cycles to billing cycles (that is, charge your users every month AND fulfill them every month too, to keep things simple) OR you can specify something else like refill credits every `:day`, every `:quarter`, every `15.days`, every `:year` etc.
|
368
|
+
|
369
|
+
```ruby
|
370
|
+
subscription_plan :pro do
|
371
|
+
gives 10_000.credits.every(15.days)
|
372
|
+
# or, another example:
|
373
|
+
# gives 10_000.credits.every(:quarter)
|
374
|
+
# ...
|
375
|
+
end
|
376
|
+
```
|
377
|
+
|
378
|
+
### Expire or rollover unused credits
|
379
|
+
|
380
|
+
At the end of the fulfillment cycle, you can either:
|
381
|
+
- Expire all unused credits (so the user starts with X fixed amount of credits every period, and all of them expire at the end of the period, whether they've used them or not)
|
382
|
+
- Carry unused credits over to the next period
|
383
|
+
|
384
|
+
Just set `unused_credits` to either `:expire` or `:rollover`
|
385
|
+
|
386
|
+
```ruby
|
387
|
+
subscription_plan :pro do
|
388
|
+
unused_credits :expire # or :rollover
|
389
|
+
# ...
|
390
|
+
end
|
391
|
+
```
|
392
|
+
|
393
|
+
## Transaction history & audit trail
|
394
|
+
|
395
|
+
Every transaction (whether adding or deducting credits) is logged in the ledger, and automatically tracked with metadata:
|
396
|
+
|
397
|
+
```ruby
|
398
|
+
# Get recent activity
|
399
|
+
user.credit_history.recent
|
400
|
+
|
401
|
+
# Filter by type
|
402
|
+
user.credit_history.by_category(:operation_charge)
|
403
|
+
user.credit_history.by_category(:subscription_credits)
|
404
|
+
|
405
|
+
# Audit operation usage
|
406
|
+
user.credit_history
|
407
|
+
.where(category: :operation_charge)
|
408
|
+
.where("metadata->>'operation' = ?", 'process_image')
|
409
|
+
.where(created_at: 1.month.ago..)
|
410
|
+
```
|
411
|
+
|
412
|
+
Each operation charge includes detailed audit metadata:
|
413
|
+
```ruby
|
414
|
+
{
|
415
|
+
operation: "process_image", # Operation name
|
416
|
+
cost: 15, # Actual cost charged
|
417
|
+
params: { size: 1024 }, # Parameters used
|
418
|
+
metadata: { category: "image" }, # Custom metadata
|
419
|
+
executed_at: "2024-01-19T16:57:16Z", # When executed
|
420
|
+
gem_version: "1.0.0" # Gem version
|
421
|
+
}
|
422
|
+
```
|
423
|
+
|
424
|
+
This makes it easy to:
|
425
|
+
- Audit operation usage
|
426
|
+
- Generate detailed invoices
|
427
|
+
- Monitor usage patterns
|
428
|
+
|
429
|
+
### Custom credit formatting
|
430
|
+
|
431
|
+
A minor thing, but if you want to use the `@transaction.formatted_amount` helper, you can specify the format:
|
432
|
+
|
433
|
+
```ruby
|
434
|
+
UsageCredits.configure do |config|
|
435
|
+
config.format_credits do |amount|
|
436
|
+
"#{amount} tokens"
|
437
|
+
end
|
438
|
+
end
|
439
|
+
```
|
440
|
+
|
441
|
+
Which will get you:
|
442
|
+
```ruby
|
443
|
+
@transaction.formatted_amount
|
444
|
+
# => "42 tokens"
|
445
|
+
```
|
446
|
+
|
447
|
+
It's useful if you want to name your credits something else (tokens, virtual currency, tasks, in-app gems, whatever) and you want the name to be consistent.
|
448
|
+
|
449
|
+
## Technical notes on architecture and how this gem is built
|
450
|
+
|
451
|
+
Building a usage credits system is deceptively complex.
|
452
|
+
|
453
|
+
The first naive approach is to think this whole thing can be implemented as just a `balance` attribute in the database, a number that you update whenever the user buys or spends credits.
|
454
|
+
|
455
|
+
That results in a plethora of bugs as soon as time starts rolling and customers start upgrading, downgrading, and cancelling subscriptions. Customers won't get what they paid for, and you'll always have problems. You always feel like repairing a leaking budget. So you may be tempted to offload all the credit-fulfilling logic to Stripe webhooks and such.
|
456
|
+
|
457
|
+
That only gets you so far.
|
458
|
+
|
459
|
+
One problem is the discrepancy between billing periods and fulfillment cycles (you may want to charge your users up front for a whole year if they have a yearly subscription, but you may not want to refill all their credits up front, but month by month) Then if you want expiring credits (so that unused credits don't roll over to the next period), credit packs, etc. you essentially end up needing to build a double-entry ledger system. You need to keep track of every credit-giving and credit-spending operation. The ledger should be immutable by design (append-only), transactions should happen on row-level locks to prevent double-spending, operations should be atomic, etc.
|
460
|
+
|
461
|
+
That's exactly what I ended up building:
|
462
|
+
- `Wallet` is the root of all functionality. All users have a wallet that centralizes everything and keeps track of the available balance – and all credit operations (add/deduct credits) are performed on the wallet.
|
463
|
+
- `Transaction` - operations get logged as transactions. The Transaction model is the basis for the ledger system.
|
464
|
+
- `Fulfillment` represents a credit-giving action (wether recurring or not). Subscriptions are tied to a Fulfillment record that orchestrates when the actual credit fulfillment should happen, and how often. A Fulfillment object will create one or many positive Transactions.
|
465
|
+
- `Allocation` is the basis for our bucket-based FIFO credit spending system. It's what solves the [dragging cost problem](https://x.com/rameerez/status/1884246492837302759) and allows for expiring credits.
|
466
|
+
- `CreditPack` and `CreditSubscriptionPlan` are POROs that model credit-giving objects (one-time purchases for credit packs; recurring subscriptions for subscription plans). They allow for easy configuration through the DSL and store all information on memory.
|
467
|
+
- `Operation` represents a credit-spending operation.
|
468
|
+
|
469
|
+
### Row-level locks
|
470
|
+
|
471
|
+
Heads up: we acquire a row-level lock when spending credits, to avoid concurrency inconsistencies. This means the row will be locked for as long as the credit-spending operation lasts. If the block is short (which 99% of the time it is – like updating a record, sending an email, etc.), you’re golden. If someone tries to do 2 minutes of CPU-bound AI generation under that lock, concurrency for that user’s wallet is blocked. Possibly that’s what we want in any case, but it’s something you should know for large tasks.
|
472
|
+
|
473
|
+
### Summary of features
|
474
|
+
|
475
|
+
**Core ledger:**
|
476
|
+
- Immutable ledger design (transactions are append-only)
|
477
|
+
- Row-level locks to prevent double-spending even with concurrent usage
|
478
|
+
- Secure credit spending (credits will not be deducted if the operation fails)
|
479
|
+
- Audit trail / transaction logs (each transaction has metadata on how the credits were spent, and what "credit bucket" they drew from)
|
480
|
+
- Avoids floating-point issues by enforcing integer-only operations
|
481
|
+
|
482
|
+
**Billing system:**
|
483
|
+
- Integrates with `pay` loosely enough not to rely on a single payment processor (we use Pay::Charge and Pay::Subscription model callbacks, not payment-processor-specific webhooks)
|
484
|
+
- Handles total and partial refunds
|
485
|
+
- Deals with new subscriptions and cancellations
|
486
|
+
- One-time credit packs can be bought at any time, independent of subscriptions
|
487
|
+
|
488
|
+
**Credit fulfillment system:**
|
489
|
+
- Credits can be fulfilled at arbitrary periods, decoupled from billing cycles
|
490
|
+
- Credits can be expired
|
491
|
+
- Credits can be rolled over to the next period
|
492
|
+
- Prevents double-fulfillment of credits
|
493
|
+
- FIFO bucketed ledger approach for credit spending
|
494
|
+
|
495
|
+
### Numeric extensions
|
496
|
+
|
497
|
+
The gem adds several convenient methods to Ruby's `Numeric` class to make the DSL read naturally:
|
498
|
+
|
499
|
+
```ruby
|
500
|
+
# Credit amounts
|
501
|
+
1.credit # => 1 credit
|
502
|
+
10.credits # => 10 credits
|
503
|
+
|
504
|
+
# Pricing
|
505
|
+
49.dollars # => 4900 cents (for Stripe)
|
506
|
+
29.euros # => 2900 cents (for Stripe)
|
507
|
+
99.cents # => 99 cents (for Stripe)
|
508
|
+
|
509
|
+
# Sizes and rates
|
510
|
+
1.credit_per(:mb) # => 1 credit per megabyte
|
511
|
+
2.credits_per(:unit) # => 2 credits per unit
|
512
|
+
100.megabytes # => 100 MB (uses Rails' numeric extensions)
|
513
|
+
```
|
514
|
+
|
515
|
+
### Kernel extensions
|
516
|
+
|
517
|
+
This gem _pollutes_ a bit the `Kernel` namespace by defining 3 top-level methods: `operation`, `credit_pack`, and `credit_subscription`. We do this to have a DSL that reads like plain English. I think the benefits of having these methods outweight the downsides, and there's a low chance of name collision, but in any case it's important you know they're there.
|
518
|
+
|
519
|
+
|
520
|
+
## Edge cases
|
521
|
+
|
522
|
+
Billing systems are extremely complex and full of edge cases. This is a new gem, and it may be missing some edge cases.
|
523
|
+
|
524
|
+
Real billing systems usually find edge cases when handling things like:
|
525
|
+
- Prorated changes
|
526
|
+
- Different pricing tiers
|
527
|
+
- Usage rollups and aggregation
|
528
|
+
- Upgrading and downgrading subscriptions
|
529
|
+
- Pausing and resuming subscriptions (especially at edge times)
|
530
|
+
- Re-activating subscriptions
|
531
|
+
- Refunds and credits
|
532
|
+
- Failed payments
|
533
|
+
- Usage caps
|
534
|
+
|
535
|
+
Please help us by contributing to add tests to cover all critical paths!
|
536
|
+
|
537
|
+
## TODO
|
538
|
+
|
539
|
+
- [ ] Write a comprehensive `minitest` test suite that covers all critical paths (both happy paths and weird edge cases)
|
540
|
+
- [ ] Handle subscription upgrades and downgrades (upgrade immediately; downgrade at end of billing period? Cover all scenarios allowed by the Stripe Customer Portal?)
|
541
|
+
|
542
|
+
## Testing
|
543
|
+
|
544
|
+
Run the test suite with `bundle exec rake test`
|
545
|
+
|
546
|
+
## Development
|
547
|
+
|
548
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
549
|
+
|
550
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
551
|
+
|
552
|
+
## Contributing
|
553
|
+
|
554
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/rameerez/usage_credits. Our code of conduct is: just be nice and make your mom proud of what
|
555
|
+
you do and post online.
|
556
|
+
|
557
|
+
## License
|
558
|
+
|
559
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
begin
|
2
|
+
require "bundler/setup"
|
3
|
+
rescue LoadError
|
4
|
+
puts "You must `gem install bundler` and `bundle install` to run rake tasks"
|
5
|
+
end
|
6
|
+
|
7
|
+
require "bundler/gem_tasks"
|
8
|
+
|
9
|
+
require "rdoc/task"
|
10
|
+
|
11
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
12
|
+
rdoc.rdoc_dir = "rdoc"
|
13
|
+
rdoc.title = "Pay"
|
14
|
+
rdoc.options << "--line-numbers"
|
15
|
+
rdoc.rdoc_files.include("README.md")
|
16
|
+
rdoc.rdoc_files.include("lib/**/*.rb")
|
17
|
+
end
|
18
|
+
|
19
|
+
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
20
|
+
load "rails/tasks/engine.rake"
|
21
|
+
|
22
|
+
load "rails/tasks/statistics.rake"
|
23
|
+
|
24
|
+
require "rake/testtask"
|
25
|
+
|
26
|
+
Rake::TestTask.new(:test) do |t|
|
27
|
+
t.libs << "test"
|
28
|
+
t.pattern = "test/**/*_test.rb"
|
29
|
+
t.verbose = false
|
30
|
+
end
|
31
|
+
|
32
|
+
task default: :test
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators/base"
|
4
|
+
require "rails/generators/active_record"
|
5
|
+
|
6
|
+
module UsageCredits
|
7
|
+
module Generators
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
9
|
+
include ActiveRecord::Generators::Migration
|
10
|
+
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
12
|
+
|
13
|
+
def self.next_migration_number(dir)
|
14
|
+
ActiveRecord::Generators::Base.next_migration_number(dir)
|
15
|
+
end
|
16
|
+
|
17
|
+
def create_migration_file
|
18
|
+
migration_template "create_usage_credits_tables.rb.erb", File.join(db_migrate_path, "create_usage_credits_tables.rb")
|
19
|
+
end
|
20
|
+
|
21
|
+
def create_initializer
|
22
|
+
template "initializer.rb", "config/initializers/usage_credits.rb"
|
23
|
+
end
|
24
|
+
|
25
|
+
def display_post_install_message
|
26
|
+
say "\n🎉 The `usage_credits` gem has been successfully installed!", :green
|
27
|
+
say "\nTo complete the setup:"
|
28
|
+
|
29
|
+
say " 1. Run 'rails db:migrate' to create the necessary tables."
|
30
|
+
say " ⚠️ You must run migrations before starting your app!", :yellow
|
31
|
+
|
32
|
+
say " 2. Add 'has_credits' to your User model (or any model that should have credits)."
|
33
|
+
|
34
|
+
say " 3. Define the actions that consume credits in config/initializers/usage_credits.rb"
|
35
|
+
say " ➡️ See README.md for usage examples and detailed configuration options."
|
36
|
+
|
37
|
+
say " 4. 💸 Make sure you have the `pay` gem installed and configured for your chosen payment processor(s) if you want to handle payments and subscriptions (f.ex. for credit refills)"
|
38
|
+
|
39
|
+
say "\nEnjoy your new usage-based credits system! 💳✨\n", :green
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def migration_version
|
45
|
+
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|