usage_credits 0.5.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -0
  3. data/README.md +120 -29
  4. data/lib/generators/usage_credits/install_generator.rb +9 -1
  5. data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +64 -20
  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 +25 -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 +51 -158
  30. data/lib/usage_credits/models/transfer.rb +37 -0
  31. data/lib/usage_credits/models/wallet.rb +224 -312
  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 -37
  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: 707a34fdb4e9cfa1ca2cb0ccd128baf514c3cc68512afdd93706c288172f296f
4
- data.tar.gz: 847ecdc0ffef49fe9b6a948dc6d199fa88ab1ea0d4c5486b26ff5b55deb4838c
3
+ metadata.gz: 710aa384672853be7b8711cffb537ad348761d0f8bf8c9df899565c7917522a8
4
+ data.tar.gz: de219e55c5442ce44bbdf9bc1843c5bdeaa1e32508e3ee3c193f5baef0b7c259
5
5
  SHA512:
6
- metadata.gz: 2d778d2cd412b1754eb79a34d9f405241bd38461dbaf5021e1c948c570b09ffc36676ab742b23868754d7c9ddc94259afb383ff4e987abf26f243e3ed20ea767
7
- data.tar.gz: b918f017dddbbd325db6e71c4d4163a01164c0d5ebf3007f07a0e0853bc0c898a486eb6905a6327ae7ffe02d7dda5692a12111694c592318222740e6b1ae57ed
6
+ metadata.gz: ddd7d4f68a78f5608ded3984b277b6956ed1f849a72061a1816e1456252a164d87b771dd94337d653f28414932e349d31c88168085c1ead78e4083103920f356
7
+ data.tar.gz: cfaa71674966f0197675a0467cce70d5476b4dd161d838907567e286920590a3051463cb1583baab92e23e852942b6414812df23ed93dc2a421f613f9c853b8f
data/CHANGELOG.md CHANGED
@@ -1,3 +1,78 @@
1
+ ## [1.0.1] - 2026-10-18
2
+
3
+ ### Fixed
4
+
5
+ - **A refused `spend_credits_on` no longer poisons the wallet instance.** When the block had spent on the same wallet instance (or a caller's transaction rolled a command back), Rails left the cached balance and `ledger_revision` the command had written in memory as unsaved changes, and the next command on that instance raised `RuntimeError: Locking a record with unpersisted changes is not supported` instead of running. The fix is in `wallets` 0.4.2, which this release requires (`~> 0.4.2`); a regression test here pins the `spend_credits_on` shape.
6
+ - `InsufficientCredits` speaks of credits whichever command raised it: `deduct_credits` and transfers used to pass the core's "Insufficient balance (5 < 10)" through, while `spend_credits_on` said "Insufficient credits (5 < 10)".
7
+
8
+ ## [1.0.0] - 2026-10-18
9
+
10
+ `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.
11
+
12
+ ### Added
13
+
14
+ - New runtime dependency: `wallets` (`~> 0.4.0`), installed automatically with the gem
15
+ - 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.
16
+ - Wallet-to-wallet credit transfers via the underlying wallets layer (`usage_credits_transfers` table), with expiration-preserving semantics by default
17
+ - `UsageCredits::Transfer` model, plus `transfer_in` / `transfer_out` transaction categories
18
+
19
+ ### Changed
20
+
21
+ - **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
22
+ - `UsageCredits::Wallet`, `Transaction`, and `Allocation` now subclass the `wallets` core models via its embeddability hooks (same tables as before, prefixed `usage_credits_`)
23
+ - 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.
24
+ - 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.
25
+ - `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.
26
+ - 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.
27
+ - `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.
28
+ - 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.
29
+ - 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.
30
+ - 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.
31
+ - `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.
32
+ - 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
33
+ - Payment and subscription terms are snapshotted at checkout/fulfillment time, so delayed webhooks, refunds, renewals, and plan changes never depend on mutable initializer configuration.
34
+ - 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.
35
+ - 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.
36
+ - 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.
37
+ - 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.
38
+ - `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.
39
+ - 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.
40
+ - 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.
41
+ - 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.
42
+ - 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.
43
+ - 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.
44
+ - `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.
45
+ - 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.
46
+ - 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.
47
+ - Rails 7.2's transaction callback API is now required to prevent rolled-back ledger events.
48
+ - 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.
49
+ - 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.
50
+
51
+ ### Fixed
52
+
53
+ - 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.
54
+
55
+ ### Preserved public surface
56
+
57
+ - 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.
58
+ - Negative balances still floor to zero in `credits` (the wallets core can represent overdrafts, but `usage_credits` keeps its historical contract)
59
+ - `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
60
+
61
+ ### Tests
62
+
63
+ - 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.
64
+ - 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.
65
+ - CI audits every supported dependency bundle against the latest `ruby-advisory-db` before release.
66
+ - 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.
67
+
68
+ ### Upgrade instructions
69
+
70
+ 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.
71
+ 2. Update `usage_credits`, then run `rails generate usage_credits:upgrade`.
72
+ 3. Review the generated migration and **back up your database** (the migration is not reversible).
73
+ 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.
74
+ 5. Deploy the gem update and `rails db:migrate` together — the 1.0 models expect the upgraded schema.
75
+
1
76
  ## [0.5.0] - 2026-03-15
2
77
 
3
78
  - 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
data/README.md CHANGED
@@ -7,9 +7,9 @@
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, API products, and **marketplace wallets** that want to implement usage-based pricing or track money-like balances.
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
11
 
12
- > **Not just for credits!** While the gem is called "usage_credits", it's built on a production-grade double-entry ledger with row-level locking, FIFO allocation, and full audit trails. You can use it for marketplace seller balances, in-app wallets, reward points, or any system that needs to track money-like assets with proper accounting. [See the "Beyond credits" section](#beyond-credits-using-this-gem-for-money-like-wallets-and-payouts) for examples.
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.
13
13
 
14
14
  [ 🟢 [Live interactive demo website](https://usagecredits.com/) ] [ 🎥 [Quick video overview](https://x.com/rameerez/status/1890419563189195260) ]
15
15
 
@@ -19,8 +19,10 @@ All with a simple DSL that reads just like English.
19
19
 
20
20
  **Requirements**
21
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.
22
23
  - An ActiveJob backend (Sidekiq, `solid_queue`, etc.) for subscription credit fulfillment
23
- - [`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.2 or any later 0.4.x (installed automatically as the ledger core)
24
26
 
25
27
  ## 👨‍💻 Example
26
28
 
@@ -97,6 +99,32 @@ rails generate usage_credits:install
97
99
  rails db:migrate
98
100
  ```
99
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
+
100
128
  Add `has_credits` your user model (or any model that needs to have credits):
101
129
  ```ruby
102
130
  class User < ApplicationRecord
@@ -390,7 +418,7 @@ redirect_to session.url
390
418
 
391
419
  The gem automatically handles:
392
420
  - Credit pack fulfillment after successful payment
393
- - 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
394
422
  - Prevention of double-processing of purchase
395
423
  - Support for multiple currencies (USD, EUR, etc.)
396
424
  - Detailed transaction tracking with metadata like:
@@ -422,6 +450,13 @@ subscription_plan :pro do
422
450
  end
423
451
  ```
424
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
+
425
460
  The first thing to understand is that **credit fulfillment** is decoupled from **billing periods**:
426
461
 
427
462
  ### Credit fulfillment cycles
@@ -538,11 +573,11 @@ This happens automatically thanks to our Pay Subscription extension (changes to
538
573
  Handled:
539
574
  - Subscription create, renew, cancel, upgrade, downgrade, non-credit transitions
540
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
541
577
  - Credit expiration and rollover
542
578
 
543
579
  Not handled (yet):
544
580
  - Plan changes while **trialing** (we only handle `status == "active"`)
545
- - Paused subscriptions (see TODO in code)
546
581
 
547
582
  ## Transaction history & audit trail
548
583
 
@@ -629,9 +664,17 @@ Which will get you:
629
664
 
630
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.
631
666
 
632
- ## Beyond credits: using this gem for money-like wallets and payouts
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**.
633
670
 
634
- While this gem is called `usage_credits`, the underlying architecture is a **production-grade double-entry ledger** with row-level locking, FIFO allocation, and full audit trails. This makes it suitable for more than just API credits — you can use it as a wallet system for **money-like assets**, **marketplace payouts**, **in-app balances**, and more.
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.
635
678
 
636
679
  ### Custom transaction categories
637
680
 
@@ -665,9 +708,7 @@ class User < ApplicationRecord
665
708
  has_credits # Each user gets a wallet
666
709
 
667
710
  def request_payout(amount_cents)
668
- # In production, wrap in wallet.with_lock { } to prevent race conditions
669
- raise "Insufficient balance" if credits < amount_cents
670
-
711
+ # deduct_credits locks and checks the wallet atomically.
671
712
  wallet.deduct_credits(
672
713
  amount_cents,
673
714
  category: :payout_requested,
@@ -729,29 +770,78 @@ Now you have:
729
770
  end
730
771
  ```
731
772
 
732
- ### Why this works for money
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.
733
803
 
734
- The gem's architecture gives you everything you'd need for a money-handling system:
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:
735
816
 
736
817
  | Feature | How it helps |
737
818
  |---------|--------------|
738
- | Double-entry ledger | Every credit has a corresponding debit source tracked via allocations |
739
- | Immutable transactions | Append-only — no edits, only new entries (required for financial audit) |
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 |
740
821
  | Row-level locking | Prevents race conditions and double-spending |
741
- | FIFO allocation | When spending, oldest credits are used first (important for expiring balances) |
822
+ | Expiration-aware allocation | Soonest-expiring credits are spent first, with oldest-first ties |
742
823
  | Balance snapshots | Each transaction records balance before/after for reconciliation |
743
824
  | Rich metadata | Store order IDs, user IDs, payment references — whatever you need for audit |
744
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
+
745
828
  ### A note on multi-currency
746
829
 
747
- Currently, the gem uses a single currency per installation (configured via `config.default_currency`). All amounts are stored as integers (cents) to avoid floating-point issues.
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.
748
831
 
749
- If you need multi-currency support, you could:
750
- 1. Store amounts in the smallest unit of each currency (cents, pence, etc.)
751
- 2. Use metadata to track the currency per transaction
752
- 3. Handle conversion at the application layer
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:
753
833
 
754
- Multi-currency wallets (one wallet per currency per user) is on the roadmap for a future version. For now, if you need this, you'd run separate wallet instances or handle it at the application level.
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.
755
845
 
756
846
  ### Naming your "credits"
757
847
 
@@ -783,13 +873,13 @@ That results in a plethora of bugs as soon as time starts rolling and customers
783
873
 
784
874
  That only gets you so far.
785
875
 
786
- 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.
787
877
 
788
878
  That's exactly what I ended up building:
789
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.
790
880
  - `Transaction` - operations get logged as transactions. The Transaction model is the basis for the ledger system.
791
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.
792
- - `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.
793
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.
794
884
  - `Operation` represents a credit-spending operation.
795
885
 
@@ -800,7 +890,7 @@ Heads up: we acquire a row-level lock when spending credits, to avoid concurrenc
800
890
  ### Summary of features
801
891
 
802
892
  **Core ledger:**
803
- - Immutable ledger design (transactions are append-only)
893
+ - Append-oriented wallet operations with a complete transaction trail
804
894
  - Row-level locks to prevent double-spending even with concurrent usage
805
895
  - Secure credit spending (credits will not be deducted if the operation fails)
806
896
  - Audit trail / transaction logs (each transaction has metadata on how the credits were spent, and what "credit bucket" they drew from)
@@ -817,7 +907,7 @@ Heads up: we acquire a row-level lock when spending credits, to avoid concurrenc
817
907
  - Credits can be expired
818
908
  - Credits can be rolled over to the next period
819
909
  - Prevents double-fulfillment of credits
820
- - FIFO bucketed ledger approach for credit spending
910
+ - First-expiring-first-out bucket allocation, with oldest-first ties
821
911
 
822
912
  ### Numeric extensions
823
913
 
@@ -848,12 +938,12 @@ This gem _pollutes_ a bit the `Kernel` namespace by defining 3 top-level methods
848
938
 
849
939
  Billing systems are extremely complex and full of edge cases. This is a new gem, and it may be missing some edge cases.
850
940
 
851
- 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:
852
942
  - Prorated changes
853
943
  - Different pricing tiers
854
944
  - Usage rollups and aggregation
855
945
  - Upgrading and downgrading subscriptions
856
- - Pausing and resuming subscriptions (especially at edge times)
946
+ - Processor-specific billing/proration modes around subscription transitions
857
947
  - Re-activating subscriptions
858
948
  - Refunds and credits
859
949
  - Failed payments
@@ -862,7 +952,8 @@ Real billing systems usually find edge cases when handling things like:
862
952
  Please help us by contributing to add tests to cover all critical paths!
863
953
 
864
954
  ## TODO
865
- 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
866
957
 
867
958
  ## Testing
868
959
 
@@ -870,7 +961,7 @@ Run the test suite with `bundle exec rake test`
870
961
 
871
962
  ## Development
872
963
 
873
- 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.
874
965
 
875
966
  To install this gem onto your local machine, run `bundle exec rake install`.
876
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
140
  end