usage_credits 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +8 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +559 -0
  6. data/Rakefile +32 -0
  7. data/lib/generators/usage_credits/install_generator.rb +49 -0
  8. data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +88 -0
  9. data/lib/generators/usage_credits/templates/initializer.rb +105 -0
  10. data/lib/usage_credits/configuration.rb +204 -0
  11. data/lib/usage_credits/core_ext/numeric.rb +59 -0
  12. data/lib/usage_credits/cost/base.rb +43 -0
  13. data/lib/usage_credits/cost/compound.rb +37 -0
  14. data/lib/usage_credits/cost/fixed.rb +34 -0
  15. data/lib/usage_credits/cost/variable.rb +42 -0
  16. data/lib/usage_credits/engine.rb +37 -0
  17. data/lib/usage_credits/helpers/credit_calculator.rb +34 -0
  18. data/lib/usage_credits/helpers/credits_helper.rb +45 -0
  19. data/lib/usage_credits/helpers/period_parser.rb +77 -0
  20. data/lib/usage_credits/jobs/fulfillment_job.rb +25 -0
  21. data/lib/usage_credits/models/allocation.rb +31 -0
  22. data/lib/usage_credits/models/concerns/has_wallet.rb +94 -0
  23. data/lib/usage_credits/models/concerns/pay_charge_extension.rb +198 -0
  24. data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +251 -0
  25. data/lib/usage_credits/models/credit_pack.rb +159 -0
  26. data/lib/usage_credits/models/credit_subscription_plan.rb +204 -0
  27. data/lib/usage_credits/models/fulfillment.rb +91 -0
  28. data/lib/usage_credits/models/operation.rb +153 -0
  29. data/lib/usage_credits/models/transaction.rb +174 -0
  30. data/lib/usage_credits/models/wallet.rb +310 -0
  31. data/lib/usage_credits/railtie.rb +17 -0
  32. data/lib/usage_credits/services/fulfillment_service.rb +129 -0
  33. data/lib/usage_credits/version.rb +5 -0
  34. data/lib/usage_credits.rb +170 -0
  35. data/sig/usagecredits.rbs +4 -0
  36. 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
@@ -0,0 +1,8 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.1
3
+
4
+ Style/StringLiterals:
5
+ EnforcedStyle: double_quotes
6
+
7
+ Style/StringLiteralsInInterpolation:
8
+ EnforcedStyle: double_quotes
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-01-18
4
+
5
+ - Initial release
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
+ [![Gem Version](https://badge.fury.io/rb/usage_credits.svg)](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