usage_credits 0.4.0 → 1.0.0

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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +246 -13
  4. data/lib/generators/usage_credits/install_generator.rb +9 -1
  5. data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +65 -21
  6. data/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb +602 -0
  7. data/lib/generators/usage_credits/upgrade_generator.rb +47 -0
  8. data/lib/usage_credits/callbacks.rb +9 -54
  9. data/lib/usage_credits/configuration.rb +43 -13
  10. data/lib/usage_credits/core_ext/numeric.rb +12 -16
  11. data/lib/usage_credits/cost/base.rb +1 -6
  12. data/lib/usage_credits/cost/compound.rb +3 -2
  13. data/lib/usage_credits/cost/fixed.rb +1 -2
  14. data/lib/usage_credits/cost/variable.rb +23 -8
  15. data/lib/usage_credits/engine.rb +8 -12
  16. data/lib/usage_credits/helpers/credit_calculator.rb +18 -0
  17. data/lib/usage_credits/helpers/credits_helper.rb +9 -11
  18. data/lib/usage_credits/helpers/period_parser.rb +31 -9
  19. data/lib/usage_credits/helpers/processor_metadata.rb +46 -0
  20. data/lib/usage_credits/jobs/fulfillment_job.rb +1 -1
  21. data/lib/usage_credits/models/allocation.rb +15 -20
  22. data/lib/usage_credits/models/concerns/has_wallet.rb +50 -27
  23. data/lib/usage_credits/models/concerns/pay_charge_extension.rb +184 -139
  24. data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +490 -174
  25. data/lib/usage_credits/models/credit_pack.rb +34 -13
  26. data/lib/usage_credits/models/credit_subscription_plan.rb +36 -15
  27. data/lib/usage_credits/models/fulfillment.rb +23 -48
  28. data/lib/usage_credits/models/operation.rb +36 -28
  29. data/lib/usage_credits/models/transaction.rb +55 -152
  30. data/lib/usage_credits/models/transfer.rb +37 -0
  31. data/lib/usage_credits/models/wallet.rb +219 -314
  32. data/lib/usage_credits/railtie.rb +4 -13
  33. data/lib/usage_credits/services/fulfillment_service.rb +146 -22
  34. data/lib/usage_credits/subscription_terms.rb +131 -0
  35. data/lib/usage_credits/version.rb +1 -1
  36. data/lib/usage_credits.rb +16 -3
  37. metadata +33 -20
  38. data/.rubocop.yml +0 -8
  39. data/.simplecov +0 -48
  40. data/AGENTS.md +0 -5
  41. data/Appraisals +0 -43
  42. data/CLAUDE.md +0 -5
  43. data/Rakefile +0 -32
  44. data/gemfiles/pay_10.0.gemfile +0 -41
  45. data/gemfiles/pay_11.0.gemfile +0 -41
  46. data/gemfiles/pay_8.3.gemfile +0 -41
  47. data/gemfiles/pay_9.0.gemfile +0 -41
  48. data/gemfiles/rails_7.2.gemfile +0 -41
  49. data/gemfiles/rails_8.1.gemfile +0 -41
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6947ef76d1f3674d94e3fbff6f4149daff82f0e90e55365c485f2e6eef88b319
4
- data.tar.gz: 784a9c5fb5ca6b139bf04bab6f85ddff1d96a15e30cd57f7d8b2f9f167f0aac6
3
+ metadata.gz: 36ed774a5aebbe0638ebdfc33bf28aa93e8c59bc9da80767e7f4f1598ee1dd96
4
+ data.tar.gz: 5e44018f29eba1d7a99c4f46f969c09fff2d86441ce6024630f3f0756357d677
5
5
  SHA512:
6
- metadata.gz: 54d40b947cda3d9da7932bf1da5c8f8bb97c68c359aa087c8913c2e18ca8bab9500d257c275d02070bcbf5122a4550c83b37205a76080a2db7c598f87366a9d9
7
- data.tar.gz: 8d6e8689d9271526b768472fb8af5ee74caab0dfdb729750d38d0168eb6cf4e6f3f9a0daf034bfbae048a619c38cd8f19c5c2d3b821a797bcbb1db99d85267d2
6
+ metadata.gz: 6075093fad2de5a3ece80406537e76b09f094bd80f9805df0c43b5ef402764d0ef2d92ffddb342ae2ccfa87bf6e4f79f80ae1a5ad8009c9d66cc62e2061a055c
7
+ data.tar.gz: 4fe597e7027e3495cc3ba3bce7c15bd77ec95403d6735ebba9fc03ca4ba4ee280247c6866e8a005508a6e8231b29af77d2309dd3e2cd1ca1bc94f419065dacd4
data/CHANGELOG.md CHANGED
@@ -1,3 +1,75 @@
1
+ ## [1.0.0] - 2026-10-18
2
+
3
+ `usage_credits` is now built on top of [`wallets`](https://github.com/rameerez/wallets), our append-oriented, multi-asset ledger core. The credits-focused DX you know is unchanged — same `has_credits`, `spend_credits_on`, `give_credits`, packs, subscriptions, and Pay integration — but the expiration-aware allocation ledger, balance math, row-level locking, and transfer machinery now live in a shared, independently tested core.
4
+
5
+ ### Added
6
+
7
+ - New runtime dependency: `wallets` (`~> 0.4.0`), installed automatically with the gem
8
+ - Upgrade generator for existing installs: `rails generate usage_credits:upgrade` creates an in-place, re-runnable migration that preserves all existing ledger data. Before touching the schema it rejects duplicate owner wallets or fulfillment sources, partial polymorphic references, orphaned ledger or Pay sources, invalid ledger amounts/allocations, incompatible key types, reserved-index collisions, and interrupted transfer schemas with actionable diagnostics.
9
+ - Wallet-to-wallet credit transfers via the underlying wallets layer (`usage_credits_transfers` table), with expiration-preserving semantics by default
10
+ - `UsageCredits::Transfer` model, plus `transfer_in` / `transfer_out` transaction categories
11
+
12
+ ### Changed
13
+
14
+ - **Schema** (handled by the upgrade migration for existing apps): wallets gain an `asset_code` column (default `"credits"`) and a `ledger_revision` counter (`bigint`, default `0`); one wallet per owner per asset is now enforced with a unique index; `balance` / `amount` / `credits_last_fulfillment` columns widen from `integer` to `bigint`; transactions gain a nullable `transfer_id` reference
15
+ - `UsageCredits::Wallet`, `Transaction`, and `Allocation` now subclass the `wallets` core models via its embeddability hooks (same tables as before, prefixed `usage_credits_`)
16
+ - Holds and reservations (new in `wallets` 0.4) are a wallets-level feature that `usage_credits` 1.0 does not expose. Credit wallets keep no holds and run no hold SQL, `credits` keeps its floored contract, and `credit(hold:)`, `credit(available_at:)`, `debit(from:)`, `hold` and `reserve` raise `Wallets::HoldsNotEnabled` on a credits wallet. Apps that need pending credits use `wallets` directly, side by side.
17
+ - Every wallet command (`give_credits`, `add_credits`, `deduct_credits`, `spend_credits_on`, transfers, and the cancellation expiry and refund debits that Pay events trigger) runs in its own savepoint: a command that fails leaves no rows behind and reports no success callbacks, even when the caller rescues the error and commits. Inside `transaction(joinable: false)`, `credits_added`, `credits_deducted`, `low_balance_reached` and `balance_depleted` fire when that inner transaction completes, not at the outer commit.
18
+ - `insufficient_credits` callbacks fired by `deduct_credits` and by transfers now carry `held:` and `reserved:` in their metadata, always `0` because credit wallets keep no holds; the one fired by `spend_credits_on` keeps its existing payload.
19
+ - A refund webhook's clawback is one wallet command: what earlier refunds of the purchase already took and the debit for the rest are read and written under the wallet lock, at one instant, as the ledger is now, so two refund webhooks for one charge, or one handled inside a transaction that read the ledger first, never claw back the same credits twice.
20
+ - `spend_credits_on` runs its block inside that savepoint, under the wallet lock, between the affordability check and the debit, which both read the ledger at the same instant. If the block raises, or the debit is refused (because the block itself spent the credits, say), the block's own database writes roll back with the command, even when the caller rescues the error and commits its transaction. Work outside the database, such as an API call or an email, cannot be rolled back.
21
+ - With `allow_negative_balance` on and credit-pack refund debt outstanding, a `:preserve` transfer above `credits` that unexpired credits fully back now keeps their expirations instead of arriving evergreen: the fallback to a single evergreen credit (`:none`) follows the part of a transfer that is really unbacked, not the available balance.
22
+ - Every ledger change increments the wallet row's new `ledger_revision`. On MySQL the wallets core compares it to tell a stale snapshot from a current one, so a command called after its transaction already read something no longer decides on stale balances, `spend_credits_on`'s affordability check and cancellation expiry included. It moves even when the balance does not, so read neither it nor `updated_at` as a balance change.
23
+ - The wallets core's save-time `balance >= 0` validation is gone; commands enforce funding under the wallet lock instead. `credits` and the cached `balance` column both come from the floored `usage_credits` balance, so they stay at zero or above.
24
+ - `credit` reserves the keywords `hold:` and `available_at:`, and `debit` reserves `from:`. They raise `Wallets::HoldsNotEnabled` instead of `ArgumentError`, and an app that added transaction columns by those names to `additional_transaction_attribute_names` can no longer pass them through.
25
+ - Wallet creation now goes through the race-safe, idempotent `create_for_owner!` from the wallets core; an `initial_balance` is recorded as a proper ledger transaction (category `manual_adjustment`, reason `initial_balance`) instead of a bare column write, so initial balances are auditable
26
+ - Payment and subscription terms are snapshotted at checkout/fulfillment time, so delayed webhooks, refunds, renewals, and plan changes never depend on mutable initializer configuration.
27
+ - Credit-pack fulfillment/refunds and subscription fulfillment are serialized with row locks and database uniqueness constraints; successful callbacks are deferred until the outermost transaction commits.
28
+ - Pay fulfillment hooks run only after create/update commits, never after destroy commits; deleting an eligible but unfulfilled processor record cannot mint credits against a dangling source.
29
+ - Credit-pack refunds are cumulative, proportional, and idempotent under concurrent webhook delivery. If purchased credits were already spent, the refund records explicit credit debt instead of silently under-refunding; a never-fulfilled purchase cannot create debt.
30
+ - Operations evaluate dynamic costs exactly once under the wallet lock, round once at the final operation boundary, and normalize fixed/dynamic credit amounts through one strict validation path. Free operations remain ledger-free while preserving the established callback contract.
31
+ - `expire_after` now applies the snapshotted cancellation policy to credits created by that subscription, including after the configured plan is removed; unrelated credits are never shortened. The wallet owns this mutation and emits low-balance/depleted crossings when cancellation makes the expiration effective immediately.
32
+ - Persisted fulfillment cadence is parsed through the same strict duration parser as configuration and is never permitted below one second, preventing malformed metadata or a zero-period retry loop.
33
+ - Recurring Pay-backed fulfillment locks and re-checks the subscription before minting, fails closed for dangling Pay sources (including processor-specific STI type names) and unresolved plan transitions, and never awards while the processor subscription is trialing, paused, incomplete, or canceled.
34
+ - The locked subscription freshness check canonicalizes processor timestamps to the database column precision. Sub-microsecond values from processor SDKs can no longer make a just-committed callback look stale and silently suppress the initial credit award.
35
+ - Subscription lifecycle transactions now exit their blocks locally instead of using method-level `return`, preserving explicit commit semantics across the Rails 7.2-to-8.x behavior change; unrelated subscription updates also skip deferred-resume reconciliation queries.
36
+ - Credit-wallet lookup now delegates cold-cache lookup and race recovery to the wallets core's single `create_for_owner!` path instead of querying the same owner/asset association first.
37
+ - `transfer_to` and its backwards-compatible `transfer_credits_to` alias now share the complete expiration-policy signature and consistently translate core transfer failures into the `usage_credits` error hierarchy.
38
+ - Effective processor pauses use Pay's processor-specific lifecycle predicate rather than raw status (Stripe can remain `"active"` while paused). Plan changes made during a pause are snapshotted without minting, reconciled before the first resumed fulfillment even if an after-commit callback was interrupted, and rejected fail-closed if the persisted terms are inconsistent.
39
+ - Fresh and upgrade migrations add row-local ledger constraints and abort before schema changes when legacy rows violate amount, allocation, transfer, wallet, or fulfillment invariants.
40
+ - Rails 7.2's transaction callback API is now required to prevent rolled-back ledger events.
41
+ - Ruby 3.2 and Rails 7.2.3.1 are the minimum supported runtime versions. Current security-patched Rails dependency releases cannot be installed on Ruby 3.1, and earlier Rails 7.2 patch releases contain known vulnerabilities.
42
+ - Pay 11.6.2 is now the minimum supported version. Earlier Pay releases are excluded because [GHSA-mjgf-xj26-9qf9](https://github.com/pay-rails/pay/security/advisories/GHSA-mjgf-xj26-9qf9) permits forged Paddle Billing webhooks through non-constant-time signature comparison.
43
+
44
+ ### Fixed
45
+
46
+ - MySQL through Rails' Trilogy adapter is MySQL. The install and upgrade migrations now leave JSON columns without a default under it, as MySQL 8 requires, and the Pay charge fulfilment and refund lookups ask MySQL's JSON functions under it. Before, Trilogy took the SQLite paths: the migrations failed on MySQL 8, and the refund lookup compared MySQL's JSON `true` with the number `1`, which never matches, so every refund webhook saw nothing refunded before it and deducted the refunded total again, a repeated or partial refund included.
47
+
48
+ ### Preserved public surface
49
+
50
+ - The established entry points remain: `credits`, `credit_history`, `give_credits`, `spend_credits_on`, `has_enough_credits_to?`, `estimate_credits_to`, `add_credits`, `deduct_credits`, callbacks, categories, scopes, and the Pay integration.
51
+ - Negative balances still floor to zero in `credits` (the wallets core can represent overdrafts, but `usage_credits` keeps its historical contract)
52
+ - `usage_credits` stays single-asset (`"credits"`) by design — multi-asset apps can use the `wallets` gem directly, side by side, including in the same app
53
+
54
+ ### Tests
55
+
56
+ - The suite now contains 856 runs / 2,564 assertions, including adversarial coverage for concurrent fulfillment/refund delivery, stale processor records, processor timestamp precision, processor pauses/resumes, transfer API/error compatibility, cross-gem isolation, the `wallets` 0.4 boundary (no hold command or hold query reaches a credits wallet, and every mutation path increments `ledger_revision`), stale MySQL snapshots raced across two real connections for `spend_credits_on`, cancellation expiry and refund webhooks (on PostgreSQL and MySQL; SQLite serializes writers), the committed dummy schema against its migrations, destroy callbacks, immutable commercial terms, cancellation expiration and threshold callbacks, malformed persisted cadence, centralized cost normalization, one-time compound rounding, interrupted upgrades, and database constraints.
57
+ - Compatibility coverage includes Ruby 3.2 across both the Rails 7.2 and Rails 8.1 boundaries, Ruby 3.3/3.4/4.0 across Rails 7.2/8.1 and both the Pay 11.6.2 security floor and latest compatible Pay release, plus clean migrations and the full suite on SQLite, PostgreSQL, and MySQL. PostgreSQL and MySQL each run both the default dependency graph and the literal Ruby 3.2 / Rails 7.2 / Pay 11.6.2 minimum boundary.
58
+ - CI audits every supported dependency bundle against the latest `ruby-advisory-db` before release.
59
+ - The development Gemfile and the appraisal gemfiles pin `receipts` to `~> 2`. Every Pay 11 release raises at boot with receipts 3.0.0, so an unpinned fresh bundle could not load the test app.
60
+
61
+ ### Upgrade instructions
62
+
63
+ 1. Release or install `wallets` 0.4.x first; `usage_credits` 1.0 will not resolve against 0.3.x or earlier. If your app also uses `wallets` directly, run `rails generate wallets:upgrade` and migrate its own tables before deploying: `wallets` 0.4 raises on every `Wallets::Wallet` balance read until its holds table exists, and on every command until both the holds table and the `ledger_revision` column exist.
64
+ 2. Update `usage_credits`, then run `rails generate usage_credits:upgrade`.
65
+ 3. Review the generated migration and **back up your database** (the migration is not reversible).
66
+ 4. Run the migration against a production snapshot, resolve every preflight failure, and measure its locking window before deployment. On PostgreSQL, locks acquired across all ledger DDL are held until the whole migration commits.
67
+ 5. Deploy the gem update and `rails db:migrate` together — the 1.0 models expect the upgraded schema.
68
+
69
+ ## [0.5.0] - 2026-03-15
70
+
71
+ - Add configurable transaction categories via `config.additional_categories` for money-like wallet use cases (marketplaces, fintech) by @rameerez in https://github.com/rameerez/usage_credits/pull/29
72
+
1
73
  ## [0.4.0] - 2026-01-16
2
74
 
3
75
  - Add `balance_before` and `balance_after` to transactions by @rameerez (h/t @yshmarov) in https://github.com/rameerez/usage_credits/pull/27
data/README.md CHANGED
@@ -3,11 +3,13 @@
3
3
  [![Gem Version](https://badge.fury.io/rb/usage_credits.svg)](https://badge.fury.io/rb/usage_credits) [![Build Status](https://github.com/rameerez/usage_credits/workflows/Tests/badge.svg)](https://github.com/rameerez/usage_credits/actions)
4
4
 
5
5
  > [!TIP]
6
- > **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks.
6
+ > **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=usage_credits)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=usage_credits)!
7
7
 
8
8
  `usage_credits` allows your users to have in-app credits / tokens they can use to perform operations.
9
9
 
10
- ✨ Perfect for SaaS, AI apps, games, and API products that want to implement usage-based pricing.
10
+ ✨ Perfect for SaaS, AI apps, games, API products, and **single-asset credit systems** that want to implement usage-based pricing or track money-like balances.
11
+
12
+ > **Built on top of [`wallets`](https://github.com/rameerez/wallets).** As of `usage_credits` 1.0, `usage_credits` uses `wallets` as its ledger core underneath. If your main problem is multi-asset wallets, transfers, in-game resources, or general balances, use `wallets` directly. Use `usage_credits` when you want the opinionated DX for credits, operations, subscriptions, packs, and payments.
11
13
 
12
14
  [ 🟢 [Live interactive demo website](https://usagecredits.com/) ] [ 🎥 [Quick video overview](https://x.com/rameerez/status/1890419563189195260) ]
13
15
 
@@ -17,8 +19,10 @@ All with a simple DSL that reads just like English.
17
19
 
18
20
  **Requirements**
19
21
 
22
+ - Ruby 3.2+ and Rails 7.2.3.1–8.x. Ruby 3.2 is the security floor because current patched versions of transitive Rails dependencies no longer support Ruby 3.1.
20
23
  - An ActiveJob backend (Sidekiq, `solid_queue`, etc.) for subscription credit fulfillment
21
- - [`pay`](https://github.com/pay-rails/pay) gem for Stripe/PayPal/Lemon Squeezy integration (sell credits, refill subscriptions)
24
+ - [`pay`](https://github.com/pay-rails/pay) 11.6.2–11.x for payment integration. Pay 11.6.2 is the security floor because it fixes [GHSA-mjgf-xj26-9qf9](https://github.com/pay-rails/pay/security/advisories/GHSA-mjgf-xj26-9qf9).
25
+ - [`wallets`](https://github.com/rameerez/wallets) 0.4.x (installed automatically as the ledger core)
22
26
 
23
27
  ## 👨‍💻 Example
24
28
 
@@ -95,6 +99,32 @@ rails generate usage_credits:install
95
99
  rails db:migrate
96
100
  ```
97
101
 
102
+ ### Upgrading from pre-1.0
103
+
104
+ If you're upgrading an existing app from pre-1.0 `usage_credits`, run this instead of the install generator:
105
+ ```bash
106
+ rails generate usage_credits:upgrade
107
+ rails db:migrate
108
+ ```
109
+
110
+ The upgrade migration moves your existing ledger onto the [`wallets`](https://github.com/rameerez/wallets) core schema **in place** — all balances, transactions, allocations, and fulfillments are preserved exactly as they are. It:
111
+
112
+ - Adds an `asset_code` column to wallets (defaults to `"credits"`, so nothing changes for you)
113
+ - Adds a `ledger_revision` counter to wallets, which the `wallets` core increments on every ledger change (it is how a stale MySQL snapshot is told from a current one)
114
+ - Enforces one wallet per owner with a unique index (it first checks your data and aborts with step-by-step instructions if any owner somehow has duplicate wallets, before touching anything)
115
+ - Widens integer amount columns to `bigint`
116
+ - Creates the `usage_credits_transfers` table that powers wallet-to-wallet transfers
117
+
118
+ A few production notes:
119
+
120
+ - Back up your database first. The migration is intentionally irreversible; rollback means restoring that backup.
121
+ - Rehearse the migration against a recent production snapshot and measure it before choosing a deployment window.
122
+ - On PostgreSQL, Rails runs the migration in one DDL transaction. The unique-index build, four `bigint` conversions, constraints, foreign keys, and transfer-table changes therefore hold their acquired table locks until the whole migration commits. Writes to **all** `usage_credits_*` ledger tables may be blocked for the combined duration, not only while each individual `bigint` conversion is executing. Use a maintenance window for a large or write-heavy ledger. A genuinely lower-lock rollout requires a DBA-reviewed, application-specific staged migration (including concurrent indexes and separately validated constraints); do not add `disable_ddl_transaction!` to the generated migration casually, because that trades atomicity for partial-schema exposure.
123
+ - MySQL DDL commits step by step. Every mutation is independently guarded, so an interrupted run can be fixed and safely re-run.
124
+ - Deploy the gem update and migration together: the 1.0 models expect the upgraded schema.
125
+ - If your app also uses `wallets` directly, `usage_credits` 1.0 brings it to 0.4: run `rails generate wallets:upgrade` and migrate its tables too. `wallets` 0.4 raises on every `Wallets::Wallet` balance read until its holds table exists, and on every command until both the holds table and the `ledger_revision` column exist.
126
+ - Fresh installs use descriptive `usage_credits_*` index names. Upgrades deliberately preserve safe legacy index names rather than dropping and rebuilding equivalent indexes, so schema dumps from fresh and upgraded databases can differ by index name while remaining structurally equivalent.
127
+
98
128
  Add `has_credits` your user model (or any model that needs to have credits):
99
129
  ```ruby
100
130
  class User < ApplicationRecord
@@ -388,7 +418,7 @@ redirect_to session.url
388
418
 
389
419
  The gem automatically handles:
390
420
  - Credit pack fulfillment after successful payment
391
- - Proportional credit removal on refunds (e.g., if 50% is refunded, 50% of credits are removed)
421
+ - Proportional credit removal on refunds: if 50% is refunded, 50% of the credits are removed. A fraction of a credit rounds up, and a repeated or partial refund of one purchase only ever takes the difference still outstanding
392
422
  - Prevention of double-processing of purchase
393
423
  - Support for multiple currencies (USD, EUR, etc.)
394
424
  - Detailed transaction tracking with metadata like:
@@ -420,6 +450,13 @@ subscription_plan :pro do
420
450
  end
421
451
  ```
422
452
 
453
+ Cancellation terms are snapshotted when the subscription is fulfilled, so a
454
+ later initializer change or plan removal cannot rewrite the customer's policy.
455
+ `expire_after` only shortens credits minted by that subscription; manual grants,
456
+ credit packs, and other subscriptions are untouched. When cancellation makes
457
+ those credits expire immediately, any resulting low-balance or depleted
458
+ crossing is dispatched after the cancellation transaction commits.
459
+
423
460
  The first thing to understand is that **credit fulfillment** is decoupled from **billing periods**:
424
461
 
425
462
  ### Credit fulfillment cycles
@@ -536,11 +573,11 @@ This happens automatically thanks to our Pay Subscription extension (changes to
536
573
  Handled:
537
574
  - Subscription create, renew, cancel, upgrade, downgrade, non-credit transitions
538
575
  - Pending downgrade application on renewal
576
+ - Processor pauses and resumes: no credits are minted during an effective pause; scheduled pauses follow Pay's processor lifecycle semantics; plan changes made while paused are atomically reconciled without a bonus before service resumes minting
539
577
  - Credit expiration and rollover
540
578
 
541
579
  Not handled (yet):
542
580
  - Plan changes while **trialing** (we only handle `status == "active"`)
543
- - Paused subscriptions (see TODO in code)
544
581
 
545
582
  ## Transaction history & audit trail
546
583
 
@@ -627,6 +664,201 @@ Which will get you:
627
664
 
628
665
  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.
629
666
 
667
+ ## Beyond credits: wallet-like balances on top of a credits product layer
668
+
669
+ While this gem is called `usage_credits`, the underlying architecture is still a **production-grade append-oriented ledger API** with row-level locking, expiration-aware allocation, and full transaction trails. That means you can use it for more than just API credits when the product still fits a **single-asset credits model**.
670
+
671
+ Good fits here:
672
+ - marketplace seller balances in cents
673
+ - internal store credit
674
+ - cashback / reward points
675
+ - telecom-style balances where acquisition/refill matters more than multi-asset modeling
676
+
677
+ If the real problem is **multi-asset wallets**, **player inventories**, or **wallet-to-wallet transfers as a primary feature**, use [`wallets`](https://github.com/rameerez/wallets) directly instead.
678
+
679
+ ### Custom transaction categories
680
+
681
+ By default, the gem includes categories like `signup_bonus`, `operation_charge`, `subscription_credits`, etc. But you can extend these with your own categories for your specific use case:
682
+
683
+ ```ruby
684
+ # config/initializers/usage_credits.rb
685
+ UsageCredits.configure do |config|
686
+ # Add custom categories for your business logic
687
+ config.additional_categories = %w[
688
+ payment_received
689
+ payment_sent
690
+ payout_requested
691
+ platform_fee
692
+ refund
693
+ tip
694
+ cashback
695
+ ]
696
+ end
697
+ ```
698
+
699
+ These custom categories work exactly like the built-in ones — they're validated, tracked in transaction history, and available for filtering/querying.
700
+
701
+ ### Example: Marketplace with seller payouts
702
+
703
+ Imagine you're building a marketplace where sellers earn money and can withdraw their balance. Here's how you'd implement it with `usage_credits`:
704
+
705
+ ```ruby
706
+ # app/models/user.rb
707
+ class User < ApplicationRecord
708
+ has_credits # Each user gets a wallet
709
+
710
+ def request_payout(amount_cents)
711
+ # deduct_credits locks and checks the wallet atomically.
712
+ wallet.deduct_credits(
713
+ amount_cents,
714
+ category: :payout_requested,
715
+ metadata: {
716
+ requested_at: Time.current,
717
+ payout_method: stripe_account_id
718
+ }
719
+ )
720
+
721
+ PayoutJob.perform_later(self, amount_cents)
722
+ end
723
+ end
724
+
725
+ # app/services/order_payment_service.rb
726
+ class OrderPaymentService
727
+ def initialize(order)
728
+ @order = order
729
+ @buyer = order.buyer
730
+ @seller = order.seller
731
+ end
732
+
733
+ def process!
734
+ platform_fee = (@order.total_cents * 0.10).to_i # 10% fee
735
+ seller_amount = @order.total_cents - platform_fee
736
+
737
+ ActiveRecord::Base.transaction do
738
+ # Credit the seller (net of platform fee)
739
+ @seller.wallet.add_credits(
740
+ seller_amount,
741
+ category: :payment_received,
742
+ metadata: {
743
+ order_id: @order.id,
744
+ gross_amount: @order.total_cents,
745
+ platform_fee: platform_fee,
746
+ buyer_id: @buyer.id
747
+ }
748
+ )
749
+
750
+ @order.update!(paid: true)
751
+ end
752
+ end
753
+ end
754
+ ```
755
+
756
+ Now you have:
757
+ - **Full audit trail**: Every transaction is logged with metadata
758
+ - **Balance tracking**: `@seller.credits` returns current balance in cents
759
+ - **Transaction history**: `@seller.credit_history.by_category(:payment_received)`
760
+ - **Concurrency safety**: Row-level locks prevent double-spending
761
+ - **Running balance**: Each transaction stores `balance_before` and `balance_after`
762
+
763
+ ```ruby
764
+ # Show transaction history with running balance
765
+ @seller.credit_history.recent.each do |tx|
766
+ puts "#{tx.created_at.strftime('%Y-%m-%d')}: #{tx.category}"
767
+ puts " Amount: #{tx.amount} cents"
768
+ puts " Balance: #{tx.balance_before} → #{tx.balance_after}"
769
+ puts " Order: ##{tx.metadata['order_id']}" if tx.metadata['order_id']
770
+ end
771
+ ```
772
+
773
+ ### Wallet-level transfers
774
+
775
+ Because `usage_credits` uses `wallets` underneath, the underlying wallet object also supports low-level wallet operations like transfers:
776
+
777
+ ```ruby
778
+ seller.credit_wallet.transfer_to(
779
+ buyer.credit_wallet,
780
+ 500,
781
+ category: :refund,
782
+ metadata: { order_id: 42 }
783
+ )
784
+ ```
785
+
786
+ Transfers preserve expiration buckets by default because they run through the underlying `wallets` ledger. If you need cash-like behavior instead, you can still opt into evergreen receive-side credits at the wallet layer:
787
+
788
+ ```ruby
789
+ seller.credit_wallet.transfer_to(
790
+ buyer.credit_wallet,
791
+ 500,
792
+ category: :refund,
793
+ expiration_policy: :none,
794
+ metadata: { order_id: 42 }
795
+ )
796
+ ```
797
+
798
+ `transfer_credits_to` is a backwards-compatible alias for `transfer_to`; both
799
+ accept the same expiration options and return a `UsageCredits::Transfer`.
800
+ Transfer domain failures stay inside the `usage_credits` error hierarchy:
801
+ `UsageCredits::InvalidTransfer` for invalid endpoints and
802
+ `UsageCredits::InsufficientCredits` for insufficient balance.
803
+
804
+ This is intentionally a **wallet-level API**, not the main `usage_credits` DSL. The main product surface of `usage_credits` is still:
805
+ - `give_credits`
806
+ - `spend_credits_on`
807
+ - credit packs
808
+ - subscription fulfillment
809
+ - Pay integration
810
+
811
+ If transfers, multi-asset balances, and wallet movement are central to your app, that is usually a sign you should use [`wallets`](https://github.com/rameerez/wallets) directly.
812
+
813
+ ### Why this still works for money-like balances
814
+
815
+ The ledger architecture gives you everything you'd want from a serious internal balance system:
816
+
817
+ | Feature | How it helps |
818
+ |---------|--------------|
819
+ | Allocation-backed ledger | Every spend records exactly which credit buckets it consumed |
820
+ | Append-oriented operations | Public wallet operations record new transaction rows instead of editing balances in place |
821
+ | Row-level locking | Prevents race conditions and double-spending |
822
+ | Expiration-aware allocation | Soonest-expiring credits are spent first, with oldest-first ties |
823
+ | Balance snapshots | Each transaction records balance before/after for reconciliation |
824
+ | Rich metadata | Store order IDs, user IDs, payment references — whatever you need for audit |
825
+
826
+ The public API is append-oriented, not tamper-proof: code with model or SQL access can still modify ledger rows, and destroying an owner intentionally cascades through that owner's credit history. Use soft deletion or an application-level destroy restriction when records must be retained, keep database backups, reconcile payment-processor events, and apply the audit controls appropriate to your risk model.
827
+
828
+ ### A note on multi-currency
829
+
830
+ `usage_credits` is intentionally **single-asset**: every owner gets exactly one credits wallet (asset code `"credits"`). All amounts are stored as integers (for money, usually cents) to avoid floating-point issues.
831
+
832
+ If you need one wallet per currency or asset, use [`wallets`](https://github.com/rameerez/wallets) — the dedicated gem for multi-asset support, and the same ledger core `usage_credits` runs on. Both gems coexist cleanly in the same app, each with its own tables. Put `has_wallets` (from `wallets`) on the models that need multi-asset balances, and `has_credits` (from `usage_credits`) on the models that need credits:
833
+
834
+ ```ruby
835
+ class User < ApplicationRecord
836
+ has_credits # user.credits, user.spend_credits_on(...)
837
+ end
838
+
839
+ class Team < ApplicationRecord
840
+ has_wallets # team.wallet(:eur), team.wallet(:usd), team.wallet(:wood)
841
+ end
842
+ ```
843
+
844
+ One caveat: avoid putting both `has_credits` and `has_wallets` on the *same* model — both define a `wallet` method (the credits wallet vs. the multi-asset lookup), so whichever you include last wins. If you ever do need both on one model, use the unambiguous `credit_wallet` for credits and `find_wallet(:asset)` for the rest.
845
+
846
+ ### Naming your "credits"
847
+
848
+ Remember you can customize how credits are displayed:
849
+
850
+ ```ruby
851
+ UsageCredits.configure do |config|
852
+ config.format_credits do |amount|
853
+ # Display as money
854
+ "$#{(amount / 100.0).round(2)}"
855
+ end
856
+ end
857
+
858
+ @user.credit_history.last.formatted_amount
859
+ # => "+$25.00"
860
+ ```
861
+
630
862
  ## Demo Rails app
631
863
 
632
864
  There's a demo Rails app showcasing the features in the `usage_credits` gem under `test/dummy`. It's currently deployed to `usagecredits.com`. If you want to run it yourself locally, you can just clone this repo, `cd` into the `test/dummy` folder, and then `bundle` and `rails s` to launch it. You can examine the code of the demo app to better understand the gem.
@@ -641,13 +873,13 @@ That results in a plethora of bugs as soon as time starts rolling and customers
641
873
 
642
874
  That only gets you so far.
643
875
 
644
- 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.
876
+ 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). Once you add expiring credits, credit packs, and refunds, you need an allocation-backed transaction ledger that tracks every grant and spend. Writes must be atomic and serialized with row-level locks to prevent double-spending.
645
877
 
646
878
  That's exactly what I ended up building:
647
879
  - `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.
648
880
  - `Transaction` - operations get logged as transactions. The Transaction model is the basis for the ledger system.
649
881
  - `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.
650
- - `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.
882
+ - `Allocation` is the basis for our bucket-based, first-expiring-first-out credit spending system. It's what solves the [dragging cost problem](https://x.com/rameerez/status/1884246492837302759) and allows for expiring credits.
651
883
  - `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.
652
884
  - `Operation` represents a credit-spending operation.
653
885
 
@@ -658,7 +890,7 @@ Heads up: we acquire a row-level lock when spending credits, to avoid concurrenc
658
890
  ### Summary of features
659
891
 
660
892
  **Core ledger:**
661
- - Immutable ledger design (transactions are append-only)
893
+ - Append-oriented wallet operations with a complete transaction trail
662
894
  - Row-level locks to prevent double-spending even with concurrent usage
663
895
  - Secure credit spending (credits will not be deducted if the operation fails)
664
896
  - Audit trail / transaction logs (each transaction has metadata on how the credits were spent, and what "credit bucket" they drew from)
@@ -675,7 +907,7 @@ Heads up: we acquire a row-level lock when spending credits, to avoid concurrenc
675
907
  - Credits can be expired
676
908
  - Credits can be rolled over to the next period
677
909
  - Prevents double-fulfillment of credits
678
- - FIFO bucketed ledger approach for credit spending
910
+ - First-expiring-first-out bucket allocation, with oldest-first ties
679
911
 
680
912
  ### Numeric extensions
681
913
 
@@ -706,12 +938,12 @@ This gem _pollutes_ a bit the `Kernel` namespace by defining 3 top-level methods
706
938
 
707
939
  Billing systems are extremely complex and full of edge cases. This is a new gem, and it may be missing some edge cases.
708
940
 
709
- Real billing systems usually find edge cases when handling things like:
941
+ Production integrations should still define and test their product policy for things like:
710
942
  - Prorated changes
711
943
  - Different pricing tiers
712
944
  - Usage rollups and aggregation
713
945
  - Upgrading and downgrading subscriptions
714
- - Pausing and resuming subscriptions (especially at edge times)
946
+ - Processor-specific billing/proration modes around subscription transitions
715
947
  - Re-activating subscriptions
716
948
  - Refunds and credits
717
949
  - Failed payments
@@ -720,7 +952,8 @@ Real billing systems usually find edge cases when handling things like:
720
952
  Please help us by contributing to add tests to cover all critical paths!
721
953
 
722
954
  ## TODO
723
- No open TODOs here right now. If you find an edge case, please open an issue or PR.
955
+
956
+ - Add a first-class reversal/refund helper on top of wallet-level transfers if transfers become a documented primary use case
724
957
 
725
958
  ## Testing
726
959
 
@@ -728,7 +961,7 @@ Run the test suite with `bundle exec rake test`
728
961
 
729
962
  ## Development
730
963
 
731
- 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.
964
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
732
965
 
733
966
  To install this gem onto your local machine, run `bundle exec rake install`.
734
967
 
@@ -19,7 +19,15 @@ module UsageCredits
19
19
  end
20
20
 
21
21
  def create_initializer
22
- template "initializer.rb", "config/initializers/usage_credits.rb"
22
+ destination = "config/initializers/usage_credits.rb"
23
+ if File.exist?(File.expand_path(destination, destination_root))
24
+ # Never clobber a configured app. Thor's interactive conflict prompt
25
+ # is not a safety net in non-TTY runs (scripts, CI, AI agents), and
26
+ # this file is where apps define their whole credits catalog.
27
+ say_status :skip, "#{destination} already exists — keeping your configuration (delete it and re-run to regenerate the template)", :yellow
28
+ return
29
+ end
30
+ template "initializer.rb", destination
23
31
  end
24
32
 
25
33
  def display_post_install_message
@@ -6,17 +6,36 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
6
6
 
7
7
  create_table :usage_credits_wallets, id: primary_key_type do |t|
8
8
  t.references :owner, polymorphic: true, null: false, type: foreign_key_type
9
- t.integer :balance, null: false, default: 0
9
+ t.string :asset_code, null: false, default: "credits"
10
+ t.bigint :balance, null: false, default: 0
11
+ # Incremented by every wallet command, so a stale database snapshot can
12
+ # always be told from a current one, whatever the clock or a `touch` says.
13
+ t.bigint :ledger_revision, null: false, default: 0
14
+ t.send(json_column_type, :metadata, null: false, default: json_column_default)
15
+
16
+ t.timestamps
17
+ end
18
+
19
+ add_index :usage_credits_wallets, [ :owner_type, :owner_id, :asset_code ], unique: true, name: "index_usage_credits_wallets_on_owner_and_asset"
20
+
21
+ create_table :usage_credits_transfers, id: primary_key_type do |t|
22
+ t.references :from_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets }
23
+ t.references :to_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets }
24
+ t.string :asset_code, null: false, default: "credits"
25
+ t.bigint :amount, null: false
26
+ t.string :category, null: false, default: "transfer"
27
+ t.string :expiration_policy, null: false, default: "preserve"
10
28
  t.send(json_column_type, :metadata, null: false, default: json_column_default)
11
29
 
12
30
  t.timestamps
13
31
  end
14
32
 
15
33
  create_table :usage_credits_transactions, id: primary_key_type do |t|
16
- t.references :wallet, null: false, type: foreign_key_type
17
- t.integer :amount, null: false
34
+ t.references :wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets }
35
+ t.bigint :amount, null: false
18
36
  t.string :category, null: false
19
37
  t.datetime :expires_at
38
+ t.references :transfer, type: foreign_key_type, foreign_key: { to_table: :usage_credits_transfers }
20
39
  t.references :fulfillment, type: foreign_key_type
21
40
  t.send(json_column_type, :metadata, null: false, default: json_column_default)
22
41
 
@@ -24,9 +43,12 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
24
43
  end
25
44
 
26
45
  create_table :usage_credits_fulfillments, id: primary_key_type do |t|
27
- t.references :wallet, null: false, type: foreign_key_type
28
- t.references :source, polymorphic: true, type: foreign_key_type
29
- t.integer :credits_last_fulfillment, null: false # Credits given in last fulfillment
46
+ t.references :wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets }
47
+ t.references :source,
48
+ polymorphic: true,
49
+ type: foreign_key_type,
50
+ index: { unique: true, name: "index_usage_credits_fulfillments_on_source" }
51
+ t.bigint :credits_last_fulfillment, null: false # Credits given in last fulfillment
30
52
  t.string :fulfillment_type, null: false # What kind of fulfillment is this? (credit_pack / subscription)
31
53
  t.datetime :last_fulfilled_at # When last fulfilled
32
54
  t.datetime :next_fulfillment_at # When to fulfill next (nil if stopped/completed)
@@ -37,36 +59,57 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
37
59
  t.timestamps
38
60
  end
39
61
 
40
- # Allocations are the basis for the bucket-based, FIFO with expiration inventory-like system
62
+ add_foreign_key :usage_credits_transactions,
63
+ :usage_credits_fulfillments,
64
+ column: :fulfillment_id
65
+
66
+ # Allocations are the basis for the bucket-based, first-expiring-first-out inventory system
41
67
  create_table :usage_credits_allocations, id: primary_key_type do |t|
42
68
  # The "spend" transaction (negative) that is *using* credits
43
69
  t.references :transaction, null: false, type: foreign_key_type,
44
70
  foreign_key: { to_table: :usage_credits_transactions },
45
- index: { name: "index_allocations_on_transaction_id" }
71
+ index: { name: "index_usage_credits_allocations_on_transaction_id" }
46
72
 
47
73
  # The "source" transaction (positive) from which the credits are drawn
48
74
  t.references :source_transaction, null: false, type: foreign_key_type,
49
75
  foreign_key: { to_table: :usage_credits_transactions },
50
- index: { name: "index_allocations_on_source_transaction_id" }
76
+ index: { name: "index_usage_credits_allocations_on_source_tx_id" }
51
77
 
52
78
  # How many credits were allocated from that particular source
53
- t.integer :amount, null: false
79
+ t.bigint :amount, null: false
54
80
 
55
81
  t.timestamps
56
82
  end
57
83
 
58
- # Add indexes
84
+ add_check_constraint :usage_credits_transfers,
85
+ "amount > 0",
86
+ name: "check_usage_credits_transfers_amount_positive"
87
+ add_check_constraint :usage_credits_transfers,
88
+ "from_wallet_id <> to_wallet_id",
89
+ name: "check_usage_credits_transfers_distinct_wallets"
90
+ add_check_constraint :usage_credits_transactions,
91
+ "amount <> 0",
92
+ name: "check_usage_credits_transactions_amount_nonzero"
93
+ add_check_constraint :usage_credits_allocations,
94
+ "amount > 0",
95
+ name: "check_usage_credits_allocations_amount_positive"
96
+ add_check_constraint :usage_credits_fulfillments,
97
+ "credits_last_fulfillment >= 0",
98
+ name: "check_usage_credits_fulfillments_credits_nonnegative"
99
+
100
+ # Transaction indexes
59
101
  add_index :usage_credits_transactions, :category
60
102
  add_index :usage_credits_transactions, :expires_at
103
+ add_index :usage_credits_transactions, [ :expires_at, :id ], name: "index_usage_credits_transactions_on_expires_at_and_id"
104
+ add_index :usage_credits_transactions, [ :wallet_id, :amount ], name: "index_usage_credits_transactions_on_wallet_id_and_amount"
61
105
 
62
- # Composite index on (expires_at, id) for efficient ordering when calculating balances
63
- add_index :usage_credits_transactions, [:expires_at, :id], name: 'index_transactions_on_expires_at_and_id'
64
-
65
- # Index on wallet_id and amount to speed up queries filtering by wallet and positive amounts
66
- add_index :usage_credits_transactions, [:wallet_id, :amount], name: 'index_transactions_on_wallet_id_and_amount'
106
+ # Allocation indexes
107
+ add_index :usage_credits_allocations, [ :transaction_id, :source_transaction_id ], name: "index_usage_credits_allocations_on_tx_and_source_tx"
67
108
 
68
- add_index :usage_credits_allocations, [:transaction_id, :source_transaction_id], name: "index_allocations_on_tx_and_source_tx"
109
+ # Transfer indexes
110
+ add_index :usage_credits_transfers, [ :from_wallet_id, :to_wallet_id, :asset_code ], name: "index_usage_credits_transfers_on_wallets_and_asset"
69
111
 
112
+ # Fulfillment indexes
70
113
  add_index :usage_credits_fulfillments, :next_fulfillment_at
71
114
  add_index :usage_credits_fulfillments, :fulfillment_type
72
115
  end
@@ -75,10 +118,10 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
75
118
 
76
119
  def primary_and_foreign_key_types
77
120
  config = Rails.configuration.generators
78
- setting = config.options[config.orm][:primary_key_type]
121
+ setting = config.options[config.orm][ :primary_key_type ]
79
122
  primary_key_type = setting || :primary_key
80
123
  foreign_key_type = setting || :bigint
81
- [primary_key_type, foreign_key_type]
124
+ [ primary_key_type, foreign_key_type ]
82
125
  end
83
126
 
84
127
  def json_column_type
@@ -89,8 +132,9 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
89
132
  # MySQL 8+ doesn't allow default values on JSON columns.
90
133
  # Returns an empty hash default for SQLite/PostgreSQL, nil for MySQL.
91
134
  # Models handle nil metadata gracefully by defaulting to {} in their accessors.
135
+ # Rails' Trilogy adapter talks to MySQL too, under its own name.
92
136
  def json_column_default
93
- return nil if connection.adapter_name.downcase.include?('mysql')
137
+ return nil if connection.adapter_name.match?(/mysql|trilogy/i)
94
138
  {}
95
139
  end
96
- end
140
+ end