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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -0
- data/README.md +246 -13
- data/lib/generators/usage_credits/install_generator.rb +9 -1
- data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +65 -21
- data/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb +602 -0
- data/lib/generators/usage_credits/upgrade_generator.rb +47 -0
- data/lib/usage_credits/callbacks.rb +9 -54
- data/lib/usage_credits/configuration.rb +43 -13
- data/lib/usage_credits/core_ext/numeric.rb +12 -16
- data/lib/usage_credits/cost/base.rb +1 -6
- data/lib/usage_credits/cost/compound.rb +3 -2
- data/lib/usage_credits/cost/fixed.rb +1 -2
- data/lib/usage_credits/cost/variable.rb +23 -8
- data/lib/usage_credits/engine.rb +8 -12
- data/lib/usage_credits/helpers/credit_calculator.rb +18 -0
- data/lib/usage_credits/helpers/credits_helper.rb +9 -11
- data/lib/usage_credits/helpers/period_parser.rb +31 -9
- data/lib/usage_credits/helpers/processor_metadata.rb +46 -0
- data/lib/usage_credits/jobs/fulfillment_job.rb +1 -1
- data/lib/usage_credits/models/allocation.rb +15 -20
- data/lib/usage_credits/models/concerns/has_wallet.rb +50 -27
- data/lib/usage_credits/models/concerns/pay_charge_extension.rb +184 -139
- data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +490 -174
- data/lib/usage_credits/models/credit_pack.rb +34 -13
- data/lib/usage_credits/models/credit_subscription_plan.rb +36 -15
- data/lib/usage_credits/models/fulfillment.rb +23 -48
- data/lib/usage_credits/models/operation.rb +36 -28
- data/lib/usage_credits/models/transaction.rb +55 -152
- data/lib/usage_credits/models/transfer.rb +37 -0
- data/lib/usage_credits/models/wallet.rb +219 -314
- data/lib/usage_credits/railtie.rb +4 -13
- data/lib/usage_credits/services/fulfillment_service.rb +146 -22
- data/lib/usage_credits/subscription_terms.rb +131 -0
- data/lib/usage_credits/version.rb +1 -1
- data/lib/usage_credits.rb +16 -3
- metadata +33 -20
- data/.rubocop.yml +0 -8
- data/.simplecov +0 -48
- data/AGENTS.md +0 -5
- data/Appraisals +0 -43
- data/CLAUDE.md +0 -5
- data/Rakefile +0 -32
- data/gemfiles/pay_10.0.gemfile +0 -41
- data/gemfiles/pay_11.0.gemfile +0 -41
- data/gemfiles/pay_8.3.gemfile +0 -41
- data/gemfiles/pay_9.0.gemfile +0 -41
- data/gemfiles/rails_7.2.gemfile +0 -41
- data/gemfiles/rails_8.1.gemfile +0 -41
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 36ed774a5aebbe0638ebdfc33bf28aa93e8c59bc9da80767e7f4f1598ee1dd96
|
|
4
|
+
data.tar.gz: 5e44018f29eba1d7a99c4f46f969c09fff2d86441ce6024630f3f0756357d677
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://badge.fury.io/rb/usage_credits) [](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,
|
|
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)
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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,
|
|
29
|
-
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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.
|
|
79
|
+
t.bigint :amount, null: false
|
|
54
80
|
|
|
55
81
|
t.timestamps
|
|
56
82
|
end
|
|
57
83
|
|
|
58
|
-
|
|
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
|
-
#
|
|
63
|
-
add_index :
|
|
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
|
-
|
|
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.
|
|
137
|
+
return nil if connection.adapter_name.match?(/mysql|trilogy/i)
|
|
94
138
|
{}
|
|
95
139
|
end
|
|
96
|
-
end
|
|
140
|
+
end
|