From 66eda2ae60689f56f7b12bd4a9f986c535d8371c Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Wed, 18 Mar 2026 02:19:02 +0000 Subject: [PATCH 1/7] Rebuild usage_credits on wallets ledger core (v1.0.0) This release rebuilds usage_credits on top of the new `wallets` gem, which provides the ledger core: balances, transactions, allocations, transfers, and expiration handling. Architecture: - UsageCredits::Wallet, Transaction, Allocation, Transfer now extend their Wallets::* counterparts using the embeddability hooks - Each subclass sets embedded_table_name, config_provider, callbacks_module, and related model class names to maintain full isolation - Both gems can coexist in the same Rails app without table/config collision Key changes: - Add `wallets` gem dependency - Wallet/Transaction/Allocation models now extend Wallets::* base classes - Add Transfer model for credit transfers between users - Add upgrade generator for pre-1.0 installs (asset_code, bigint columns, transfers table, transfer_id on transactions) - Migration templates updated with new schema (expiration_policy on transfers, no singular outbound/inbound transaction FKs) - Coexistence test verifies both gems work independently in same app Backwards compatibility: - All existing API preserved: give_credits, deduct_credits, spend_credits_on - credits, credit_history, has_enough_credits_to? unchanged - Existing installs run upgrade migration to add new columns/tables New capabilities from wallets core: - Transfer expiration policies (preserve/none/fixed) - Multi-bucket transfer splitting for expiration preservation - Row-level locking for concurrent operations - Balance snapshots on transactions Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 6 +- CHANGELOG.md | 6 + README.md | 79 +++- .../create_usage_credits_tables.rb.erb | 47 ++- ...grade_usage_credits_to_wallets_core.rb.erb | 56 +++ .../usage_credits/upgrade_generator.rb | 42 ++ lib/usage_credits.rb | 4 + lib/usage_credits/callbacks.rb | 6 +- lib/usage_credits/configuration.rb | 6 + lib/usage_credits/models/allocation.rb | 32 +- .../models/concerns/has_wallet.rb | 18 +- lib/usage_credits/models/transaction.rb | 202 +++------- lib/usage_credits/models/transfer.rb | 30 ++ lib/usage_credits/models/wallet.rb | 358 +++++------------- lib/usage_credits/version.rb | 2 +- test/dummy/app/models/team.rb | 10 + ...50212181807_create_usage_credits_tables.rb | 45 ++- ...00000_create_wallets_coexistence_tables.rb | 88 +++++ test/dummy/db/schema.rb | 219 +++++++---- test/fixtures/teams.yml | 12 + test/fixtures/usage_credits/wallets.yml | 17 +- test/fixtures/users.yml | 8 + test/fixtures/wallets/wallets.yml | 22 ++ test/integration/coexistence_test.rb | 198 ++++++++++ test/models/concerns/has_wallet_test.rb | 72 ++++ test/models/usage_credits/transaction_test.rb | 6 +- test/models/usage_credits/wallet_test.rb | 89 ++++- test/services/fulfillment_service_test.rb | 2 +- test/usage_credits/callbacks_test.rb | 8 +- .../usage_credits/migration_templates_test.rb | 35 ++ test/usage_credits/upgrade_migration_test.rb | 238 ++++++++++++ usage_credits.gemspec | 1 + 32 files changed, 1379 insertions(+), 585 deletions(-) create mode 100644 lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb create mode 100644 lib/generators/usage_credits/upgrade_generator.rb create mode 100644 lib/usage_credits/models/transfer.rb create mode 100644 test/dummy/app/models/team.rb create mode 100644 test/dummy/db/migrate/20250417000000_create_wallets_coexistence_tables.rb create mode 100644 test/fixtures/teams.yml create mode 100644 test/fixtures/wallets/wallets.yml create mode 100644 test/integration/coexistence_test.rb create mode 100644 test/usage_credits/migration_templates_test.rb create mode 100644 test/usage_credits/upgrade_migration_test.rb diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8e801dd..f9084ed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,8 +44,10 @@ jobs: ruby-version: ${{ matrix.ruby_version }} bundler-cache: true - - name: Run tests - run: bundle exec rake test + - name: Prepare database and run tests + # Exercise the real migration path in SQLite too so dummy/test schema + # drift is caught before release, not only in adapter-specific jobs. + run: bundle exec rake db:migrate:reset test - name: Upload test results if: failure() diff --git a/CHANGELOG.md b/CHANGELOG.md index 10009d0..11d3695 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [1.0.0] - 2026-03-18 + +- Rebuild `usage_credits` on top of the new `wallets` ledger core while preserving the existing credits-focused DX +- Add the pre-1.0 upgrade generator for existing installs, including `asset_code`, `bigint` value columns, and transfer support in the underlying wallet layer +- Keep `usage_credits` single-asset and backwards-compatible while allowing advanced wallet-level operations through `credit_wallet` + ## [0.5.0] - 2026-03-15 - 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 diff --git a/README.md b/README.md index d878f5c..5397474 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ `usage_credits` allows your users to have in-app credits / tokens they can use to perform operations. -✨ Perfect for SaaS, AI apps, games, API products, and **marketplace wallets** that want to implement usage-based pricing or track money-like balances. +✨ 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. -> **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. +> **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. [ 🟢 [Live interactive demo website](https://usagecredits.com/) ] [ 🎥 [Quick video overview](https://x.com/rameerez/status/1890419563189195260) ] @@ -97,6 +97,12 @@ rails generate usage_credits:install rails db:migrate ``` +If you're upgrading an existing app from pre-1.0 `usage_credits`, run: +```bash +rails generate usage_credits:upgrade +rails db:migrate +``` + Add `has_credits` your user model (or any model that needs to have credits): ```ruby class User < ApplicationRecord @@ -629,9 +635,17 @@ Which will get you: 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. -## Beyond credits: using this gem for money-like wallets and payouts +## Beyond credits: wallet-like balances on top of a credits product layer + +While this gem is called `usage_credits`, the underlying architecture is still a **production-grade append-only ledger** with row-level locking, FIFO allocation, and full audit trails. That means you can use it for more than just API credits when the product still fits a **single-asset credits model**. + +Good fits here: +- marketplace seller balances in cents +- internal store credit +- cashback / reward points +- telecom-style balances where acquisition/refill matters more than multi-asset modeling -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. +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. ### Custom transaction categories @@ -729,9 +743,43 @@ Now you have: end ``` -### Why this works for money +### Wallet-level transfers -The gem's architecture gives you everything you'd need for a money-handling system: +Because `usage_credits` uses `wallets` underneath, the underlying wallet object also supports low-level wallet operations like transfers: + +```ruby +seller.credit_wallet.transfer_to( + buyer.credit_wallet, + 500, + category: :refund, + metadata: { order_id: 42 } +) +``` + +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: + +```ruby +seller.credit_wallet.transfer_to( + buyer.credit_wallet, + 500, + category: :refund, + expiration_policy: :none, + metadata: { order_id: 42 } +) +``` + +This is intentionally a **wallet-level API**, not the main `usage_credits` DSL. The main product surface of `usage_credits` is still: +- `give_credits` +- `spend_credits_on` +- credit packs +- subscription fulfillment +- Pay integration + +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. + +### Why this still works for money-like balances + +The ledger architecture gives you everything you'd want from a serious internal balance system: | Feature | How it helps | |---------|--------------| @@ -744,14 +792,17 @@ The gem's architecture gives you everything you'd need for a money-handling syst ### A note on multi-currency -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. +`usage_credits` is intentionally **single-asset**. All amounts are stored as integers (for money, usually cents) to avoid floating-point issues. -If you need multi-currency support, you could: -1. Store amounts in the smallest unit of each currency (cents, pence, etc.) -2. Use metadata to track the currency per transaction -3. Handle conversion at the application layer +If you need one wallet per currency or asset per user, use [`wallets`](https://github.com/rameerez/wallets): -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. +```ruby +user.wallet(:eur) +user.wallet(:usd) +user.wallet(:wood) +``` + +That is now the dedicated gem for multi-asset support. ### Naming your "credits" @@ -862,7 +913,9 @@ Real billing systems usually find edge cases when handling things like: Please help us by contributing to add tests to cover all critical paths! ## TODO -No open TODOs here right now. If you find an edge case, please open an issue or PR. + +- Add a first-class reversal/refund helper on top of wallet-level transfers if transfers become a documented primary use case +- Clarify paused subscription behavior across processors and plan states ## Testing diff --git a/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb b/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb index 5a6cf3d..2608843 100644 --- a/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +++ b/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb @@ -6,17 +6,33 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %> create_table :usage_credits_wallets, id: primary_key_type do |t| t.references :owner, polymorphic: true, null: false, type: foreign_key_type - t.integer :balance, null: false, default: 0 + t.string :asset_code, null: false, default: "credits" + t.bigint :balance, null: false, default: 0 + t.send(json_column_type, :metadata, null: false, default: json_column_default) + + t.timestamps + end + + add_index :usage_credits_wallets, [:owner_type, :owner_id, :asset_code], unique: true, name: "index_usage_credits_wallets_on_owner_and_asset" + + create_table :usage_credits_transfers, id: primary_key_type do |t| + t.references :from_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets } + t.references :to_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets } + t.string :asset_code, null: false, default: "credits" + t.bigint :amount, null: false + t.string :category, null: false, default: "transfer" + t.string :expiration_policy, null: false, default: "preserve" t.send(json_column_type, :metadata, null: false, default: json_column_default) t.timestamps end create_table :usage_credits_transactions, id: primary_key_type do |t| - t.references :wallet, null: false, type: foreign_key_type - t.integer :amount, null: false + t.references :wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets } + t.bigint :amount, null: false t.string :category, null: false t.datetime :expires_at + t.references :transfer, type: foreign_key_type, foreign_key: { to_table: :usage_credits_transfers } t.references :fulfillment, type: foreign_key_type t.send(json_column_type, :metadata, null: false, default: json_column_default) @@ -24,9 +40,9 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %> end create_table :usage_credits_fulfillments, id: primary_key_type do |t| - t.references :wallet, null: false, type: foreign_key_type + t.references :wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets } t.references :source, polymorphic: true, type: foreign_key_type - t.integer :credits_last_fulfillment, null: false # Credits given in last fulfillment + t.bigint :credits_last_fulfillment, null: false # Credits given in last fulfillment t.string :fulfillment_type, null: false # What kind of fulfillment is this? (credit_pack / subscription) t.datetime :last_fulfilled_at # When last fulfilled t.datetime :next_fulfillment_at # When to fulfill next (nil if stopped/completed) @@ -42,31 +58,32 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %> # The "spend" transaction (negative) that is *using* credits t.references :transaction, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_transactions }, - index: { name: "index_allocations_on_transaction_id" } + index: { name: "index_usage_credits_allocations_on_transaction_id" } # The "source" transaction (positive) from which the credits are drawn t.references :source_transaction, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_transactions }, - index: { name: "index_allocations_on_source_transaction_id" } + index: { name: "index_usage_credits_allocations_on_source_tx_id" } # How many credits were allocated from that particular source - t.integer :amount, null: false + t.bigint :amount, null: false t.timestamps end - # Add indexes + # Transaction indexes add_index :usage_credits_transactions, :category add_index :usage_credits_transactions, :expires_at + add_index :usage_credits_transactions, [:expires_at, :id], name: "index_usage_credits_transactions_on_expires_at_and_id" + add_index :usage_credits_transactions, [:wallet_id, :amount], name: "index_usage_credits_transactions_on_wallet_id_and_amount" - # Composite index on (expires_at, id) for efficient ordering when calculating balances - add_index :usage_credits_transactions, [:expires_at, :id], name: 'index_transactions_on_expires_at_and_id' - - # Index on wallet_id and amount to speed up queries filtering by wallet and positive amounts - add_index :usage_credits_transactions, [:wallet_id, :amount], name: 'index_transactions_on_wallet_id_and_amount' + # Allocation indexes + add_index :usage_credits_allocations, [:transaction_id, :source_transaction_id], name: "index_usage_credits_allocations_on_tx_and_source_tx" - add_index :usage_credits_allocations, [:transaction_id, :source_transaction_id], name: "index_allocations_on_tx_and_source_tx" + # Transfer indexes + add_index :usage_credits_transfers, [:from_wallet_id, :to_wallet_id, :asset_code], name: "index_usage_credits_transfers_on_wallets_and_asset" + # Fulfillment indexes add_index :usage_credits_fulfillments, :next_fulfillment_at add_index :usage_credits_fulfillments, :fulfillment_type end diff --git a/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb b/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb new file mode 100644 index 0000000..8761c00 --- /dev/null +++ b/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class UpgradeUsageCreditsToWalletsCore < ActiveRecord::Migration<%= migration_version %> + def up + primary_key_type, foreign_key_type = primary_and_foreign_key_types + + # Add asset_code to wallets (default to "credits" for backwards compatibility) + add_column :usage_credits_wallets, :asset_code, :string, null: false, default: "credits" + add_index :usage_credits_wallets, [:owner_type, :owner_id, :asset_code], unique: true, name: "index_usage_credits_wallets_on_owner_and_asset" + + # Change integer columns to bigint for larger balance support + # Note: This is a potentially slow operation on large tables + change_column :usage_credits_wallets, :balance, :bigint, null: false, default: 0 + change_column :usage_credits_transactions, :amount, :bigint, null: false + change_column :usage_credits_allocations, :amount, :bigint, null: false + change_column :usage_credits_fulfillments, :credits_last_fulfillment, :bigint, null: false + + # Create transfers table for wallet-to-wallet transfers + create_table :usage_credits_transfers, id: primary_key_type do |t| + t.references :from_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets } + t.references :to_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets } + t.string :asset_code, null: false, default: "credits" + t.bigint :amount, null: false + t.string :category, null: false, default: "transfer" + t.string :expiration_policy, null: false, default: "preserve" + t.send(json_column_type, :metadata, null: false, default: json_column_default) + + t.timestamps + end + + add_index :usage_credits_transfers, [:from_wallet_id, :to_wallet_id, :asset_code], name: "index_usage_credits_transfers_on_wallets_and_asset" + + # Add transfer reference to transactions + add_reference :usage_credits_transactions, :transfer, type: foreign_key_type, foreign_key: { to_table: :usage_credits_transfers } + end + + private + + def primary_and_foreign_key_types + config = Rails.configuration.generators + setting = config.options[config.orm][:primary_key_type] + primary_key_type = setting || :primary_key + foreign_key_type = setting || :bigint + [primary_key_type, foreign_key_type] + end + + def json_column_type + return :jsonb if connection.adapter_name.downcase.include?("postgresql") + :json + end + + def json_column_default + return nil if connection.adapter_name.downcase.include?("mysql") + {} + end +end diff --git a/lib/generators/usage_credits/upgrade_generator.rb b/lib/generators/usage_credits/upgrade_generator.rb new file mode 100644 index 0000000..abc62a6 --- /dev/null +++ b/lib/generators/usage_credits/upgrade_generator.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails/generators/base" +require "rails/generators/active_record" + +module UsageCredits + module Generators + class UpgradeGenerator < Rails::Generators::Base + include ActiveRecord::Generators::Migration + + source_root File.expand_path("templates", __dir__) + + def self.next_migration_number(dir) + ActiveRecord::Generators::Base.next_migration_number(dir) + end + + def create_migration_file + migration_template "upgrade_usage_credits_to_wallets_core.rb.erb", File.join(db_migrate_path, "upgrade_usage_credits_to_wallets_core.rb") + end + + def display_post_upgrade_message + say "\nUsageCredits 1.0 upgrade migration has been generated!", :green + say "\nThis migration will:" + say " - Add 'asset_code' column to wallets (default: 'credits')" + say " - Change integer columns to bigint for larger balance support" + say " - Create 'usage_credits_transfers' table for wallet transfers" + say " - Add 'transfer_id' column to transactions" + say " - Upgrade pre-1.0 installs to the wallets-backed ledger core" + say "\nTo complete the upgrade:" + say " 1. Review the migration file in db/migrate/" + say " 2. Run 'rails db:migrate'" + say "\n" + end + + private + + def migration_version + "[#{ActiveRecord::VERSION::STRING.to_f}]" + end + end + end +end diff --git a/lib/usage_credits.rb b/lib/usage_credits.rb index a7186cc..19bd90c 100644 --- a/lib/usage_credits.rb +++ b/lib/usage_credits.rb @@ -6,6 +6,7 @@ require "rails" require "active_record" require "pay" +require "wallets" require "active_support/all" # Load order matters! Dependencies are loaded in this specific order: @@ -43,9 +44,11 @@ class ApplicationJob < ActiveJob::Base end # 6. Models (order matters for dependencies) +# These extend Wallets::* classes, so wallets gem must be loaded first require "usage_credits/models/wallet" require "usage_credits/models/transaction" require "usage_credits/models/allocation" +require "usage_credits/models/transfer" require "usage_credits/models/operation" require "usage_credits/models/fulfillment" require "usage_credits/models/credit_pack" @@ -62,6 +65,7 @@ module UsageCredits class Error < StandardError; end class InsufficientCredits < Error; end class InvalidOperation < Error; end + class InvalidTransfer < Error; end class << self attr_writer :configuration diff --git a/lib/usage_credits/callbacks.rb b/lib/usage_credits/callbacks.rb index 38fc695..8ff557b 100644 --- a/lib/usage_credits/callbacks.rb +++ b/lib/usage_credits/callbacks.rb @@ -13,7 +13,11 @@ module Callbacks # @param context_data [Hash] Data to pass to the callback via CallbackContext def dispatch(event, **context_data) config = UsageCredits.configuration - callback = config.public_send(:"on_#{event}_callback") + callback_method = :"on_#{event}_callback" + + return unless config.respond_to?(callback_method) + + callback = config.public_send(callback_method) return unless callback.is_a?(Proc) diff --git a/lib/usage_credits/configuration.rb b/lib/usage_credits/configuration.rb index da048c6..54fcbb4 100644 --- a/lib/usage_credits/configuration.rb +++ b/lib/usage_credits/configuration.rb @@ -33,6 +33,12 @@ class Configuration # Custom transaction categories that extend the default set attr_reader :additional_categories + # Table prefix for usage_credits tables (for wallets gem compatibility) + # Note: usage_credits uses fixed table names, so this is always "usage_credits_" + def table_prefix + "usage_credits_" + end + # Minimum allowed fulfillment period for subscription plans. # Defaults to 1.day to prevent accidental 1-second refill loops in production. # Can be set to shorter periods (e.g., 2.seconds) in development/test for faster iteration. diff --git a/lib/usage_credits/models/allocation.rb b/lib/usage_credits/models/allocation.rb index 96ca4ed..e1025db 100644 --- a/lib/usage_credits/models/allocation.rb +++ b/lib/usage_credits/models/allocation.rb @@ -5,29 +5,21 @@ module UsageCredits # to a *positive* (credit) transaction, indicating how many # credits were taken from that specific credit source. # - # Allocations are the basis for the bucket-based, FIFO-with-expiration inventory-like system - # This is critical for calculating balances when there are mixed expiring and non-expiring credits - # Otherwise, balance calculations will always be wrong because negative transactions get dragged forever - # More info: https://x.com/rameerez/status/1884246492837302759 - class Allocation < ApplicationRecord - self.table_name = "usage_credits_allocations" + # This class extends Wallets::Allocation with usage_credits table configuration. - belongs_to :spend_transaction, class_name: "UsageCredits::Transaction", foreign_key: "transaction_id" - belongs_to :source_transaction, class_name: "UsageCredits::Transaction" - - validates :amount, presence: true, numericality: { only_integer: true, greater_than: 0 } - - validate :allocation_does_not_exceed_remaining_amount + class Allocation < Wallets::Allocation + # ========================================= + # Embeddability Configuration + # ========================================= - private + self.embedded_table_name = "usage_credits_allocations" + self.config_provider = -> { UsageCredits.configuration } - def allocation_does_not_exceed_remaining_amount - return if amount.blank? || source_transaction.blank? - - if source_transaction.remaining_amount < amount - errors.add(:amount, "exceeds the remaining amount of the source transaction") - end - end + # ========================================= + # Re-declare Associations with Correct Classes + # ========================================= + belongs_to :spend_transaction, class_name: "UsageCredits::Transaction", foreign_key: "transaction_id" + belongs_to :source_transaction, class_name: "UsageCredits::Transaction" end end diff --git a/lib/usage_credits/models/concerns/has_wallet.rb b/lib/usage_credits/models/concerns/has_wallet.rb index ea636c9..d35b080 100644 --- a/lib/usage_credits/models/concerns/has_wallet.rb +++ b/lib/usage_credits/models/concerns/has_wallet.rb @@ -6,7 +6,9 @@ module HasWallet extend ActiveSupport::Concern included do + # Filter to the default "credits" asset_code for backwards compatibility has_one :credit_wallet, + -> { where(asset_code: "credits") }, class_name: "UsageCredits::Wallet", as: :owner, dependent: :destroy @@ -75,16 +77,16 @@ def should_create_wallet? end def ensure_credit_wallet - return original_credit_wallet if original_credit_wallet.present? + wallet = original_credit_wallet || UsageCredits::Wallet.find_by(owner: self, asset_code: "credits") + return wallet if wallet.present? return unless should_create_wallet? + raise "Cannot create wallet for unsaved owner" unless persisted? - if persisted? - build_credit_wallet( - balance: credit_options[:initial_balance] || 0 - ).tap(&:save!) - else - raise "Cannot create wallet for unsaved owner" - end + UsageCredits::Wallet.create_for_owner!( + owner: self, + asset_code: "credits", + initial_balance: credit_options[:initial_balance].to_i + ) end def create_credit_wallet diff --git a/lib/usage_credits/models/transaction.rb b/lib/usage_credits/models/transaction.rb index 4c752e9..0949542 100644 --- a/lib/usage_credits/models/transaction.rb +++ b/lib/usage_credits/models/transaction.rb @@ -3,146 +3,85 @@ module UsageCredits # Records all credit changes in a wallet (additions, deductions, expirations). # - # Each transaction represents a single credit operation and includes: - # - amount: How many credits (positive for additions, negative for deductions) - # - category: What kind of operation (subscription fulfillment, pack purchase, etc) - # - metadata: Additional details about the operation - # - expires_at: When these credits expire (optional) - class Transaction < ApplicationRecord - self.table_name = "usage_credits_transactions" + # This class extends Wallets::Transaction with usage_credits-specific features: + # - Fulfillment tracking for subscription/pack credits + # - Usage-credits specific transaction categories + # - Operation charge descriptions and formatting + + class Transaction < Wallets::Transaction + # ========================================= + # Embeddability Configuration + # ========================================= + + self.embedded_table_name = "usage_credits_transactions" + self.config_provider = -> { UsageCredits.configuration } # ========================================= # Transaction Categories # ========================================= - # Default transaction types, grouped by purpose: + # Override base categories with usage_credits-specific ones DEFAULT_CATEGORIES = [ # Bonus credits - "signup_bonus", # Initial signup bonus - "referral_bonus", # Referral reward bonus - "bonus", # Generic bonus + "signup_bonus", + "referral_bonus", + "bonus", # Subscription-related - "subscription_credits", # Generic subscription credits - "subscription_trial", # Trial period credits - "subscription_signup_bonus", # Bonus for subscribing - "subscription_upgrade", # Plan upgrade credits + "subscription_credits", + "subscription_trial", + "subscription_signup_bonus", + "subscription_upgrade", # One-time purchases - "credit_pack", # Generic credit pack - "credit_pack_purchase", # Credit pack bought - "credit_pack_refund", # Credit pack refunded + "credit_pack", + "credit_pack_purchase", + "credit_pack_refund", # Credit usage & management - "operation_charge", # Credits spent on operation - "manual_adjustment", # Manual admin adjustment - "credit_added", # Generic addition - "credit_deducted" # Generic deduction + "operation_charge", + "manual_adjustment", + "credit_added", + "credit_deducted", + + # Transfer categories (from wallets) + "transfer_in", + "transfer_out" ].freeze - # All valid categories: defaults + any custom categories added via config - # @return [Array] Combined list of valid category names + CATEGORIES = DEFAULT_CATEGORIES + def self.categories - (DEFAULT_CATEGORIES + UsageCredits.configuration.additional_categories).uniq + (DEFAULT_CATEGORIES + resolved_config.additional_categories).uniq end - # Backwards compatibility: CATEGORIES constant still works - # but prefer using Transaction.categories for dynamic lookup - CATEGORIES = DEFAULT_CATEGORIES - # ========================================= - # Associations & Validations + # Additional Associations # ========================================= - belongs_to :wallet - - belongs_to :fulfillment, optional: true + belongs_to :wallet, class_name: "UsageCredits::Wallet" + belongs_to :transfer, class_name: "UsageCredits::Transfer", optional: true + belongs_to :fulfillment, class_name: "UsageCredits::Fulfillment", optional: true + # Re-declare allocation associations with correct classes has_many :outgoing_allocations, - class_name: "UsageCredits::Allocation", - foreign_key: :transaction_id, - dependent: :destroy + class_name: "UsageCredits::Allocation", + foreign_key: :transaction_id, + dependent: :destroy has_many :incoming_allocations, - class_name: "UsageCredits::Allocation", - foreign_key: :source_transaction_id, - dependent: :destroy - - validates :amount, presence: true, numericality: { only_integer: true } - validates :category, presence: true, inclusion: { in: ->(record) { Transaction.categories } } - - validate :remaining_amount_cannot_be_negative + class_name: "UsageCredits::Allocation", + foreign_key: :source_transaction_id, + dependent: :destroy # ========================================= - # Scopes + # Backwards Compatibility Scopes # ========================================= scope :credits_added, -> { where("amount > 0") } scope :credits_deducted, -> { where("amount < 0") } - scope :by_category, ->(category) { where(category: category) } - scope :recent, -> { order(created_at: :desc) } scope :operation_charges, -> { where(category: :operation_charge) } - # A transaction is not expired if: - # 1. It has no expiration date, OR - # 2. Its expiration date is in the future - scope :not_expired, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) } - scope :expired, -> { where("expires_at < ?", Time.current) } - - - # ========================================= - # Helpers - # ========================================= - - # Get the owner of the wallet these credits belong to - def owner - wallet.owner - end - - # Have these credits expired? - def expired? - expires_at.present? && expires_at < Time.current - end - - # Is this transaction a positive credit or a negative (spend)? - def credit? - amount > 0 - end - - def debit? - amount < 0 - end - - # How many credits from this transaction have already been allocated (spent)? - # Only applies if this transaction is positive. - def allocated_amount - incoming_allocations.sum(:amount) - end - - # How many credits remain unused in this positive transaction? - # If negative, this will effectively be 0. - def remaining_amount - return 0 unless credit? - amount - allocated_amount - end - - # ========================================= - # Balance After Transaction - # ========================================= - - # Get the balance after this transaction was applied - # Returns nil for transactions created before this feature was added - def balance_after - metadata[:balance_after] - end - - # Get the balance before this transaction was applied - # Returns the stored value if available, otherwise nil - # Note: For transactions created before this feature, returns nil - def balance_before - metadata[:balance_before] - end - # ========================================= # Display Formatting # ========================================= @@ -154,7 +93,6 @@ def formatted_amount end # Format the balance after for display (e.g., "500 credits") - # Returns nil if balance_after is not stored def formatted_balance_after return nil unless balance_after UsageCredits.configuration.credit_formatter.call(balance_after) @@ -162,54 +100,13 @@ def formatted_balance_after # Get a human-readable description of what this transaction represents def description - # Custom description takes precedence return self[:description] if self[:description].present? - - # Operation charges have dynamic descriptions return operation_description if category == "operation_charge" - - # Use predefined description or fallback to titleized category category.titleize end - # ========================================= - # Metadata Handling - # ========================================= - - # Sync in-place modifications to metadata before saving - before_save :sync_metadata_cache - - # Get metadata with indifferent access (string/symbol keys) - # Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults) - def metadata - @indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {}) - end - - # Set metadata, ensuring consistent storage format - def metadata=(hash) - @indifferent_metadata = nil # Clear cache - super(hash.is_a?(Hash) ? hash.to_h : {}) - end - - # Clear metadata cache on reload to ensure fresh data from database - def reload(*) - @indifferent_metadata = nil - super - end - private - # Sync in-place modifications to the cached metadata back to the attribute - # This ensures changes like `metadata["key"] = "value"` are persisted on save - # Also ensures metadata is never null for MySQL compatibility (JSON columns can't have defaults) - def sync_metadata_cache - if @indifferent_metadata - write_attribute(:metadata, @indifferent_metadata.to_h) - elsif read_attribute(:metadata).nil? - write_attribute(:metadata, {}) - end - end - # Format operation charge descriptions (e.g., "Process Video (-10 credits)") def operation_description operation = metadata["operation"]&.to_s&.titleize @@ -220,12 +117,5 @@ def operation_description "#{operation} (-#{cost} credits)" end - - def remaining_amount_cannot_be_negative - if credit? && remaining_amount < 0 - errors.add(:base, "Allocated amount exceeds transaction amount") - end - end - end end diff --git a/lib/usage_credits/models/transfer.rb b/lib/usage_credits/models/transfer.rb new file mode 100644 index 0000000..df920ed --- /dev/null +++ b/lib/usage_credits/models/transfer.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module UsageCredits + # A transfer records an internal movement of credits between two wallets. + # The actual balance impact lives in the linked transactions on each side + # so the ledger remains append-only. + # + # This class extends Wallets::Transfer with usage_credits table configuration. + + class Transfer < Wallets::Transfer + # ========================================= + # Embeddability Configuration + # ========================================= + + self.embedded_table_name = "usage_credits_transfers" + self.config_provider = -> { UsageCredits.configuration } + self.transaction_class_name = "UsageCredits::Transaction" + + # ========================================= + # Re-declare Associations with Correct Classes + # ========================================= + + belongs_to :from_wallet, class_name: "UsageCredits::Wallet", inverse_of: :outgoing_transfers + belongs_to :to_wallet, class_name: "UsageCredits::Wallet", inverse_of: :incoming_transfers + has_many :transactions, + class_name: "UsageCredits::Transaction", + foreign_key: :transfer_id, + inverse_of: :transfer + end +end diff --git a/lib/usage_credits/models/wallet.rb b/lib/usage_credits/models/wallet.rb index aa79b47..99441e0 100644 --- a/lib/usage_credits/models/wallet.rb +++ b/lib/usage_credits/models/wallet.rb @@ -3,90 +3,95 @@ module UsageCredits # A Wallet manages credit balance and transactions for a user/owner. # - # It's responsible for: - # 1. Tracking credit balance - # 2. Performing credit operations (add/deduct) - # 3. Managing credit expiration - # 4. Handling low balance alerts - - class Wallet < ApplicationRecord - self.table_name = "usage_credits_wallets" + # This class extends Wallets::Wallet with usage_credits-specific features: + # - Operation-based spending (spend_credits_on) + # - Human-friendly API (give_credits, credits, credit_history) + # - Fulfillment tracking for subscriptions and credit packs + # - Usage-credits specific callbacks (credits_added, credits_deducted, etc.) + class Wallet < Wallets::Wallet # ========================================= - # Associations & Validations + # Embeddability Configuration # ========================================= - belongs_to :owner, polymorphic: true - has_many :transactions, class_name: "UsageCredits::Transaction", dependent: :destroy - has_many :fulfillments, class_name: "UsageCredits::Fulfillment", dependent: :destroy - has_many :outbound_allocations, through: :transactions, source: :outgoing_allocations - has_many :inbound_allocations, through: :transactions, source: :incoming_allocations - has_many :allocations, ->(wallet) { unscope(:where).where("usage_credits_allocations.transaction_id IN (?) OR usage_credits_allocations.source_transaction_id IN (?)", wallet.transaction_ids, wallet.transaction_ids) }, class_name: "UsageCredits::Allocation", dependent: :destroy - - validates :balance, numericality: { greater_than_or_equal_to: 0 }, unless: :allow_negative_balance? + self.embedded_table_name = "usage_credits_wallets" + self.config_provider = -> { UsageCredits.configuration } + self.callbacks_module = UsageCredits::Callbacks + self.transaction_class_name = "UsageCredits::Transaction" + self.allocation_class_name = "UsageCredits::Allocation" + self.transfer_class_name = "UsageCredits::Transfer" + + # Map base wallet events to usage_credits-specific event names + self.callback_event_map = { + credited: :credits_added, + debited: :credits_deducted, + insufficient: :insufficient_credits, + low_balance: :low_balance_reached, + depleted: :balance_depleted, + transfer_completed: nil + }.freeze # ========================================= - # Metadata Handling + # Re-declare Associations with Correct Classes # ========================================= - # Sync in-place modifications to metadata before saving - before_save :sync_metadata_cache - - # Get metadata with indifferent access (string/symbol keys) - # Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults) - def metadata - @indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {}) - end + # Override parent associations to use UsageCredits classes + has_many :transactions, class_name: "UsageCredits::Transaction", dependent: :destroy + has_many :outgoing_transfers, + class_name: "UsageCredits::Transfer", + foreign_key: :from_wallet_id, + dependent: :destroy, + inverse_of: :from_wallet + has_many :incoming_transfers, + class_name: "UsageCredits::Transfer", + foreign_key: :to_wallet_id, + dependent: :destroy, + inverse_of: :to_wallet + + # UsageCredits-specific associations + has_many :fulfillments, class_name: "UsageCredits::Fulfillment", dependent: :destroy - # Set metadata, ensuring consistent storage format - def metadata=(hash) - @indifferent_metadata = nil # Clear cache - super(hash.is_a?(Hash) ? hash.to_h : {}) - end + class << self + private - # Clear metadata cache on reload to ensure fresh data from database - def reload(*) - @indifferent_metadata = nil - super + def initial_balance_credit_attributes + { + category: :manual_adjustment, + metadata: { reason: "initial_balance" } + } + end end # ========================================= - # Credit Balance & History + # Backwards Compatibility API # ========================================= - # Get current credit balance + # Get current credit balance (alias for balance) # - # The first naive approach was to compute this as a sum of all non-expired transactions like: - # transactions.not_expired.sum(:amount) - # but that fails when we mix expiring and non-expiring credits: https://x.com/rameerez/status/1884246492837302759 - # - # So we needed to introduce the Allocation model - # - # Now to calculate current balance, instead of summing: - # we sum only unexpired positive transactions’ remaining_amount + # usage_credits historically floors negative balances to zero even when + # allow_negative_balance is enabled. Keep that contract for backwards + # compatibility, even though the shared wallets core can represent unbacked + # negative debits explicitly. def credits - # Sum the leftover in all *positive* transactions that haven't expired - transactions - .where("amount > 0") - .where("expires_at IS NULL OR expires_at > ?", Time.current) - .sum("amount - (SELECT COALESCE(SUM(amount), 0) FROM usage_credits_allocations WHERE source_transaction_id = usage_credits_transactions.id)") - .yield_self { |sum| [sum, 0].max }.to_i + balance end - # Get transaction history (oldest first) + def current_balance + positive_remaining_balance + end + + # Get transaction history (oldest first) - alias for history def credit_history - transactions.order(created_at: :asc) + history end # ========================================= - # Credit Operations + # Credit Operations (High-Level API) # ========================================= # Check if wallet has enough credits for an operation def has_enough_credits_to?(operation_name, **params) operation = find_and_validate_operation(operation_name, params) - - # Then check if we actually have enough credits credits >= operation.calculate_cost(params) rescue InvalidOperation => e raise e @@ -97,8 +102,6 @@ def has_enough_credits_to?(operation_name, **params) # Calculate how many credits an operation would cost def estimate_credits_to(operation_name, **params) operation = find_and_validate_operation(operation_name, params) - - # Then calculate the cost operation.calculate_cost(params) rescue InvalidOperation => e raise e @@ -112,12 +115,10 @@ def estimate_credits_to(operation_name, **params) # @yield Optional block that must succeed before credits are deducted def spend_credits_on(operation_name, **params) operation = find_and_validate_operation(operation_name, params) - cost = operation.calculate_cost(params) # Check if user has enough credits unless has_enough_credits_to?(operation_name, **params) - # Fire insufficient_credits callback before raising UsageCredits::Callbacks.dispatch(:insufficient_credits, wallet: self, amount: cost, @@ -132,7 +133,6 @@ def spend_credits_on(operation_name, **params) end # Create audit trail - # Stringify keys from audit_data to avoid duplicate key warnings in JSON audit_data = operation.to_audit_hash(params).deep_stringify_keys deduct_params = { metadata: audit_data.merge(operation.metadata.deep_stringify_keys).merge( @@ -143,13 +143,10 @@ def spend_credits_on(operation_name, **params) } if block_given? - # If block given, only deduct credits if it succeeds ActiveRecord::Base.transaction do - lock! # Row-level lock for concurrency safety - - yield # Perform the operation first - - deduct_credits(cost, **deduct_params) # Deduct credits only if the block was successful + lock! + yield + deduct_credits(cost, **deduct_params) end else deduct_credits(cost, **deduct_params) @@ -160,7 +157,7 @@ def spend_credits_on(operation_name, **params) # Give credits to the wallet with optional reason and expiration date # @param amount [Integer] Number of credits to give - # @param reason [String, nil] Optional reason for giving credits (for auditing / trail purposes) + # @param reason [String, nil] Optional reason for giving credits # @param expires_at [DateTime, nil] Optional expiration date for the credits def give_credits(amount, reason: nil, expires_at: nil) raise ArgumentError, "Amount is required" if amount.nil? @@ -188,227 +185,48 @@ def give_credits(amount, reason: nil, expires_at: nil) # Credit Management (Internal API) # ========================================= - # Add credits to the wallet (internal method) + # Add credits to the wallet (wraps parent's credit method) + # Maintains backwards compatibility with fulfillment parameter def add_credits(amount, metadata: {}, category: :credit_added, expires_at: nil, fulfillment: nil) - with_lock do - amount = amount.to_i - raise ArgumentError, "Cannot add non-positive credits" if amount <= 0 - - previous_balance = credits # Capture BEFORE creating transaction - - transaction = transactions.create!( - amount: amount, - category: category, - expires_at: expires_at, - metadata: metadata, - fulfillment: fulfillment - ) - - # Sync the wallet's `balance` column - self.balance = credits - save! - - # Store balance information in transaction metadata for audit trail. - # Note: This update! is in the same DB transaction as the create! above (via with_lock), - # so if this fails, the entire transaction rolls back - no orphaned records possible. - # We intentionally overwrite any user-supplied balance_before/balance_after keys - # to ensure system-set values are authoritative. - transaction.update!(metadata: transaction.metadata.merge( - balance_before: previous_balance, - balance_after: balance - )) - - # Dispatch callback with full context - UsageCredits::Callbacks.dispatch(:credits_added, - wallet: self, - amount: amount, - category: category, - transaction: transaction, - previous_balance: previous_balance, - new_balance: balance, - metadata: metadata - ) - - # To finish, let's return the transaction that has been just created so we can reference it in parts of the code - # Useful, for example, to update the transaction's `fulfillment` reference in the subscription extension - # after the credits have been awarded and the Fulfillment object has been created, we need to store it - return transaction - end - end - - # Remove credits from the wallet (Internal method) - # - # After implementing the expiring FIFO inventory-like system through the Allocation model, - # we no longer just create one -X transaction. Now we also allocate that spend across whichever - # positive transactions still have leftover. - # - # TODO: This code enumerates all unexpired positive transactions each time. - # That's fine if usage scale is moderate. We're already indexing this. - # If performance becomes a concern, we need to create a separate model to store the partial allocations efficiently. - def deduct_credits(amount, metadata: {}, category: :credit_deducted) - with_lock do - amount = amount.to_i - raise InsufficientCredits, "Cannot deduct a non-positive amount" if amount <= 0 - - # Capture previous balance for low_balance check - previous_balance = credits - - # Figure out how many credits are available right now - available = previous_balance - if amount > available && !allow_negative_balance? - raise InsufficientCredits, "Insufficient credits (#{available} < #{amount})" - end - - # Create the negative transaction that represents the spend - spend_tx = transactions.create!( - amount: -amount, + credit( + amount, + metadata: metadata, category: category, - metadata: metadata - ) # We'll attach allocations to it next. - - # We now allocate from oldest/soonest-expiring positive transactions - remaining_to_deduct = amount - - # 1) Gather all unexpired positives with leftover, order by expire time (soonest first), - # then fallback to any with no expiry (which should come last). - positive_txs = transactions - .where("amount > 0") - .where("expires_at IS NULL OR expires_at > ?", Time.current) - .order(Arel.sql("COALESCE(expires_at, '9999-12-31 23:59:59'), id ASC")) - .lock("FOR UPDATE") - .select(:id, :amount, :expires_at) - .to_a - - positive_txs.each do |pt| - # Calculate leftover amount for this transaction - allocated = pt.incoming_allocations.sum(:amount) - leftover = pt.amount - allocated - next if leftover <= 0 - - allocate_amount = [leftover, remaining_to_deduct].min - - # Create allocation - Allocation.create!( - spend_transaction: spend_tx, - source_transaction: pt, - amount: allocate_amount - ) - - remaining_to_deduct -= allocate_amount - break if remaining_to_deduct <= 0 - end - - # If anything’s still left to deduct (and we allow negative?), we just leave it unallocated - # TODO: implement this edge case; typically we'd create an unbacked negative record. - if remaining_to_deduct.positive? && allow_negative_balance? - # The spend_tx already has -amount, so effectively user goes negative - # with no “source bucket” to allocate from. That is an edge case the end user's business logic must handle. - elsif remaining_to_deduct.positive? - # We shouldn’t get here if InsufficientCredits is raised earlier, but just in case: - raise InsufficientCredits, "Not enough credit buckets to cover the deduction" - end - - # Keep the `balance` column in sync - self.balance = credits - save! - - # Store balance information in transaction metadata for audit trail. - # Note: This update! is in the same DB transaction as the create! above (via with_lock), - # so if this fails, the entire transaction rolls back - no orphaned records possible. - # We intentionally overwrite any user-supplied balance_before/balance_after keys - # to ensure system-set values are authoritative. - spend_tx.update!(metadata: spend_tx.metadata.merge( - balance_before: previous_balance, - balance_after: balance - )) - - # Dispatch credits_deducted callback - UsageCredits::Callbacks.dispatch(:credits_deducted, - wallet: self, - amount: amount, - category: category, - transaction: spend_tx, - previous_balance: previous_balance, - new_balance: balance, - metadata: metadata + expires_at: expires_at, + fulfillment: fulfillment ) + end - # Check for low balance threshold crossing - if !was_low_balance?(previous_balance) && low_balance? - UsageCredits::Callbacks.dispatch(:low_balance_reached, - wallet: self, - threshold: UsageCredits.configuration.low_balance_threshold, - previous_balance: previous_balance, - new_balance: balance - ) - end - - # Check for balance depletion (balance reaches exactly zero) - if previous_balance > 0 && balance == 0 - UsageCredits::Callbacks.dispatch(:balance_depleted, - wallet: self, - previous_balance: previous_balance, - new_balance: 0 - ) - end - - spend_tx - end + # Remove credits from the wallet (wraps parent's debit method) + # Converts Wallets::InsufficientBalance to InsufficientCredits for backwards compatibility + def deduct_credits(amount, metadata: {}, category: :credit_deducted) + debit(amount, metadata: metadata, category: category) + rescue Wallets::InsufficientBalance => e + raise InsufficientCredits, e.message end + # Transfer credits to another wallet + # Converts Wallets errors to usage_credits errors for backwards compatibility + def transfer_credits_to(other_wallet, amount, category: :transfer, metadata: {}) + transfer_to(other_wallet, amount, category: category, metadata: metadata) + rescue Wallets::InvalidTransfer => e + raise InvalidTransfer, e.message + rescue Wallets::InsufficientBalance => e + raise InsufficientCredits, e.message + end private - # Sync in-place modifications to the cached metadata back to the attribute - # This ensures changes like `metadata["key"] = "value"` are persisted on save - # Also ensures metadata is never null for MySQL compatibility (JSON columns can't have defaults) - def sync_metadata_cache - if @indifferent_metadata - write_attribute(:metadata, @indifferent_metadata.to_h) - elsif read_attribute(:metadata).nil? - write_attribute(:metadata, {}) - end - end - # ========================================= # Helper Methods # ========================================= # Find an operation and validate its parameters - # @param name [Symbol] Operation name - # @param params [Hash] Operation parameters to validate - # @return [Operation] The validated operation - # @raise [InvalidOperation] If operation not found or validation fails def find_and_validate_operation(name, params) operation = UsageCredits.operations[name.to_sym] raise InvalidOperation, "Operation not found: #{name}" unless operation operation.validate!(params) operation end - - def insufficient_credits?(amount) - !allow_negative_balance? && amount > credits - end - - def allow_negative_balance? - UsageCredits.configuration.allow_negative_balance - end - - # ========================================= - # Balance Threshold Helpers - # ========================================= - - def low_balance? - threshold = UsageCredits.configuration.low_balance_threshold - return false if threshold.nil? || threshold.negative? - credits <= threshold - end - - def was_low_balance?(previous_balance) - threshold = UsageCredits.configuration.low_balance_threshold - return false if threshold.nil? || threshold.negative? - previous_balance <= threshold - end end - end diff --git a/lib/usage_credits/version.rb b/lib/usage_credits/version.rb index 49911e1..b82655c 100644 --- a/lib/usage_credits/version.rb +++ b/lib/usage_credits/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module UsageCredits - VERSION = "0.5.0" + VERSION = "1.0.0" end diff --git a/test/dummy/app/models/team.rb b/test/dummy/app/models/team.rb new file mode 100644 index 0000000..47411e1 --- /dev/null +++ b/test/dummy/app/models/team.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Model for testing coexistence of wallets and usage_credits gems +# This model uses has_wallets directly from the wallets gem, +# while User model uses has_credits from usage_credits gem. +class Team < ApplicationRecord + include Wallets::HasWallets + + has_wallets default_asset: :points +end diff --git a/test/dummy/db/migrate/20250212181807_create_usage_credits_tables.rb b/test/dummy/db/migrate/20250212181807_create_usage_credits_tables.rb index 57887b2..f62c74a 100644 --- a/test/dummy/db/migrate/20250212181807_create_usage_credits_tables.rb +++ b/test/dummy/db/migrate/20250212181807_create_usage_credits_tables.rb @@ -6,17 +6,33 @@ def change create_table :usage_credits_wallets, id: primary_key_type do |t| t.references :owner, polymorphic: true, null: false, type: foreign_key_type - t.integer :balance, null: false, default: 0 + t.string :asset_code, null: false, default: "credits" + t.bigint :balance, null: false, default: 0 + t.send(json_column_type, :metadata, null: false, default: json_column_default) + + t.timestamps + end + + add_index :usage_credits_wallets, [:owner_type, :owner_id, :asset_code], unique: true, name: "index_usage_credits_wallets_on_owner_and_asset" + + create_table :usage_credits_transfers, id: primary_key_type do |t| + t.references :from_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets } + t.references :to_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets } + t.string :asset_code, null: false, default: "credits" + t.bigint :amount, null: false + t.string :category, null: false, default: "transfer" + t.string :expiration_policy, null: false, default: "preserve" t.send(json_column_type, :metadata, null: false, default: json_column_default) t.timestamps end create_table :usage_credits_transactions, id: primary_key_type do |t| - t.references :wallet, null: false, type: foreign_key_type - t.integer :amount, null: false + t.references :wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets } + t.bigint :amount, null: false t.string :category, null: false t.datetime :expires_at + t.references :transfer, type: foreign_key_type, foreign_key: { to_table: :usage_credits_transfers } t.references :fulfillment, type: foreign_key_type t.send(json_column_type, :metadata, null: false, default: json_column_default) @@ -24,9 +40,9 @@ def change end create_table :usage_credits_fulfillments, id: primary_key_type do |t| - t.references :wallet, null: false, type: foreign_key_type + t.references :wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets } t.references :source, polymorphic: true, type: foreign_key_type - t.integer :credits_last_fulfillment, null: false # Credits given in last fulfillment + t.bigint :credits_last_fulfillment, null: false # Credits given in last fulfillment t.string :fulfillment_type, null: false # What kind of fulfillment is this? (credit_pack / subscription) t.datetime :last_fulfilled_at # When last fulfilled t.datetime :next_fulfillment_at # When to fulfill next (nil if stopped/completed) @@ -47,26 +63,27 @@ def change # The "source" transaction (positive) from which the credits are drawn t.references :source_transaction, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_transactions }, - index: { name: "index_allocations_on_source_transaction_id" } + index: { name: "index_usage_credits_allocations_on_source_tx_id" } # How many credits were allocated from that particular source - t.integer :amount, null: false + t.bigint :amount, null: false t.timestamps end - # Add indexes + # Transaction indexes add_index :usage_credits_transactions, :category add_index :usage_credits_transactions, :expires_at + add_index :usage_credits_transactions, [:expires_at, :id], name: "index_usage_credits_transactions_on_expires_at_and_id" + add_index :usage_credits_transactions, [:wallet_id, :amount], name: "index_usage_credits_transactions_on_wallet_id_and_amount" - # Composite index on (expires_at, id) for efficient ordering when calculating balances - add_index :usage_credits_transactions, [:expires_at, :id], name: 'index_transactions_on_expires_at_and_id' - - # Index on wallet_id and amount to speed up queries filtering by wallet and positive amounts - add_index :usage_credits_transactions, [:wallet_id, :amount], name: 'index_transactions_on_wallet_id_and_amount' + # Allocation indexes + add_index :usage_credits_allocations, [:transaction_id, :source_transaction_id], name: "index_usage_credits_allocations_on_tx_and_source_tx" - add_index :usage_credits_allocations, [:transaction_id, :source_transaction_id], name: "index_allocations_on_tx_and_source_tx" + # Transfer indexes + add_index :usage_credits_transfers, [:from_wallet_id, :to_wallet_id, :asset_code], name: "index_usage_credits_transfers_on_wallets_and_asset" + # Fulfillment indexes add_index :usage_credits_fulfillments, :next_fulfillment_at add_index :usage_credits_fulfillments, :fulfillment_type end diff --git a/test/dummy/db/migrate/20250417000000_create_wallets_coexistence_tables.rb b/test/dummy/db/migrate/20250417000000_create_wallets_coexistence_tables.rb new file mode 100644 index 0000000..fcaaa4f --- /dev/null +++ b/test/dummy/db/migrate/20250417000000_create_wallets_coexistence_tables.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +class CreateWalletsCoexistenceTables < ActiveRecord::Migration[7.2] + def change + primary_key_type, foreign_key_type = primary_and_foreign_key_types + + create_table :teams, id: primary_key_type do |t| + t.string :name + + t.timestamps + end + + create_table :wallets_wallets, id: primary_key_type do |t| + t.references :owner, polymorphic: true, null: false, type: foreign_key_type + t.string :asset_code, null: false + t.bigint :balance, null: false, default: 0 + t.send(json_column_type, :metadata, null: false, default: json_column_default) + + t.timestamps + end + + add_index :wallets_wallets, [:owner_type, :owner_id, :asset_code], unique: true, name: "index_wallets_on_owner_and_asset_code" + + create_table :wallets_transfers, id: primary_key_type do |t| + t.references :from_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :wallets_wallets } + t.references :to_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :wallets_wallets } + t.string :asset_code, null: false + t.bigint :amount, null: false + t.string :category, null: false, default: "transfer" + t.string :expiration_policy, null: false, default: "preserve" + t.send(json_column_type, :metadata, null: false, default: json_column_default) + + t.timestamps + end + + create_table :wallets_transactions, id: primary_key_type do |t| + t.references :wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :wallets_wallets } + t.bigint :amount, null: false + t.string :category, null: false + t.datetime :expires_at + t.references :transfer, type: foreign_key_type, foreign_key: { to_table: :wallets_transfers } + t.send(json_column_type, :metadata, null: false, default: json_column_default) + + t.timestamps + end + + create_table :wallets_allocations, id: primary_key_type do |t| + t.references :transaction, null: false, type: foreign_key_type, + foreign_key: { to_table: :wallets_transactions }, + index: { name: "index_wallets_allocations_on_transaction_id" } + t.references :source_transaction, null: false, type: foreign_key_type, + foreign_key: { to_table: :wallets_transactions }, + index: { name: "index_wallets_allocations_on_source_transaction_id" } + t.bigint :amount, null: false + + t.timestamps + end + + add_index :wallets_transactions, :category + add_index :wallets_transactions, :expires_at + add_index :wallets_transactions, [:wallet_id, :amount], name: "index_wallets_transactions_on_wallet_id_and_amount" + add_index :wallets_transactions, [:expires_at, :id], name: "index_wallets_transactions_on_expires_at_and_id" + add_index :wallets_allocations, [:transaction_id, :source_transaction_id], name: "index_wallets_allocations_on_tx_and_source_tx" + add_index :wallets_transfers, [:from_wallet_id, :to_wallet_id, :asset_code], name: "index_wallets_transfers_on_wallets_and_asset" + end + + private + + def primary_and_foreign_key_types + config = Rails.configuration.generators + setting = config.options[config.orm][:primary_key_type] + primary_key_type = setting || :primary_key + foreign_key_type = setting || :bigint + [primary_key_type, foreign_key_type] + end + + def json_column_type + return :jsonb if connection.adapter_name.downcase.include?("postgresql") + + :json + end + + def json_column_default + return nil if connection.adapter_name.downcase.include?("mysql") + + {} + end +end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index 55b05a1..0f8d5b1 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -10,130 +10,136 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_04_16_000000) do +ActiveRecord::Schema[8.1].define(version: 2025_04_17_000000) do create_table "pay_charges", force: :cascade do |t| - t.bigint "customer_id", null: false - t.bigint "subscription_id" - t.string "processor_id", null: false t.integer "amount", null: false - t.string "currency" - t.integer "application_fee_amount" t.integer "amount_refunded" - t.json "metadata" + t.integer "application_fee_amount" + t.datetime "created_at", null: false + t.string "currency" + t.bigint "customer_id", null: false t.json "data" + t.json "metadata" + t.json "object" + t.string "processor_id", null: false t.string "stripe_account" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.bigint "subscription_id" t.string "type" - t.json "object" + t.datetime "updated_at", null: false t.index ["customer_id", "processor_id"], name: "index_pay_charges_on_customer_id_and_processor_id", unique: true t.index ["subscription_id"], name: "index_pay_charges_on_subscription_id" end create_table "pay_customers", force: :cascade do |t| - t.string "owner_type" + t.datetime "created_at", null: false + t.json "data" + t.boolean "default" + t.datetime "deleted_at", precision: nil + t.json "object" t.bigint "owner_id" + t.string "owner_type" t.string "processor", null: false t.string "processor_id" - t.boolean "default" - t.json "data" t.string "stripe_account" - t.datetime "deleted_at", precision: nil - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "type" - t.json "object" + t.datetime "updated_at", null: false t.index ["owner_type", "owner_id", "deleted_at"], name: "pay_customer_owner_index", unique: true t.index ["processor", "processor_id"], name: "index_pay_customers_on_processor_and_processor_id", unique: true end create_table "pay_merchants", force: :cascade do |t| - t.string "owner_type" + t.datetime "created_at", null: false + t.json "data" + t.boolean "default" t.bigint "owner_id" + t.string "owner_type" t.string "processor", null: false t.string "processor_id" - t.boolean "default" - t.json "data" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "type" + t.datetime "updated_at", null: false t.index ["owner_type", "owner_id", "processor"], name: "index_pay_merchants_on_owner_type_and_owner_id_and_processor" end create_table "pay_payment_methods", force: :cascade do |t| + t.datetime "created_at", null: false t.bigint "customer_id", null: false - t.string "processor_id", null: false + t.json "data" t.boolean "default" t.string "payment_method_type" - t.json "data" + t.string "processor_id", null: false t.string "stripe_account" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false t.string "type" + t.datetime "updated_at", null: false t.index ["customer_id", "processor_id"], name: "index_pay_payment_methods_on_customer_id_and_processor_id", unique: true end create_table "pay_subscriptions", force: :cascade do |t| + t.decimal "application_fee_percent", precision: 8, scale: 2 + t.datetime "created_at", null: false + t.datetime "current_period_end", precision: nil + t.datetime "current_period_start", precision: nil t.bigint "customer_id", null: false + t.json "data" + t.datetime "ends_at", precision: nil + t.json "metadata" + t.boolean "metered" t.string "name", null: false + t.json "object" + t.string "pause_behavior" + t.datetime "pause_resumes_at", precision: nil + t.datetime "pause_starts_at", precision: nil + t.string "payment_method_id" t.string "processor_id", null: false t.string "processor_plan", null: false t.integer "quantity", default: 1, null: false t.string "status", null: false - t.datetime "current_period_start", precision: nil - t.datetime "current_period_end", precision: nil - t.datetime "trial_ends_at", precision: nil - t.datetime "ends_at", precision: nil - t.boolean "metered" - t.string "pause_behavior" - t.datetime "pause_starts_at", precision: nil - t.datetime "pause_resumes_at", precision: nil - t.decimal "application_fee_percent", precision: 8, scale: 2 - t.json "metadata" - t.json "data" t.string "stripe_account" - t.string "payment_method_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "trial_ends_at", precision: nil t.string "type" - t.json "object" + t.datetime "updated_at", null: false t.index ["customer_id", "processor_id"], name: "index_pay_subscriptions_on_customer_id_and_processor_id", unique: true t.index ["metered"], name: "index_pay_subscriptions_on_metered" t.index ["pause_starts_at"], name: "index_pay_subscriptions_on_pause_starts_at" end create_table "pay_webhooks", force: :cascade do |t| - t.string "processor" - t.string "event_type" + t.datetime "created_at", null: false t.json "event" + t.string "event_type" + t.string "processor" + t.datetime "updated_at", null: false + end + + create_table "teams", force: :cascade do |t| t.datetime "created_at", null: false + t.string "name" t.datetime "updated_at", null: false end create_table "usage_credits_allocations", force: :cascade do |t| - t.bigint "transaction_id", null: false - t.bigint "source_transaction_id", null: false - t.integer "amount", null: false + t.bigint "amount", null: false t.datetime "created_at", null: false + t.bigint "source_transaction_id", null: false + t.bigint "transaction_id", null: false t.datetime "updated_at", null: false - t.index ["source_transaction_id"], name: "index_allocations_on_source_transaction_id" - t.index ["transaction_id", "source_transaction_id"], name: "index_allocations_on_tx_and_source_tx" + t.index ["source_transaction_id"], name: "index_usage_credits_allocations_on_source_tx_id" + t.index ["transaction_id", "source_transaction_id"], name: "index_usage_credits_allocations_on_tx_and_source_tx" t.index ["transaction_id"], name: "index_allocations_on_transaction_id" end create_table "usage_credits_fulfillments", force: :cascade do |t| - t.bigint "wallet_id", null: false - t.string "source_type" - t.bigint "source_id" - t.integer "credits_last_fulfillment", null: false + t.datetime "created_at", null: false + t.bigint "credits_last_fulfillment", null: false + t.string "fulfillment_period" t.string "fulfillment_type", null: false t.datetime "last_fulfilled_at" + t.json "metadata", default: {}, null: false t.datetime "next_fulfillment_at" - t.string "fulfillment_period" + t.bigint "source_id" + t.string "source_type" t.datetime "stops_at" - t.json "metadata", default: {}, null: false - t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "wallet_id", null: false t.index ["fulfillment_type"], name: "index_usage_credits_fulfillments_on_fulfillment_type" t.index ["next_fulfillment_at"], name: "index_usage_credits_fulfillments_on_next_fulfillment_at" t.index ["source_type", "source_id"], name: "index_usage_credits_fulfillments_on_source" @@ -141,37 +147,111 @@ end create_table "usage_credits_transactions", force: :cascade do |t| - t.bigint "wallet_id", null: false - t.integer "amount", null: false + t.bigint "amount", null: false t.string "category", null: false + t.datetime "created_at", null: false t.datetime "expires_at" t.bigint "fulfillment_id" t.json "metadata", default: {}, null: false - t.datetime "created_at", null: false + t.bigint "transfer_id" t.datetime "updated_at", null: false + t.bigint "wallet_id", null: false t.index ["category"], name: "index_usage_credits_transactions_on_category" - t.index ["expires_at", "id"], name: "index_transactions_on_expires_at_and_id" + t.index ["expires_at", "id"], name: "index_usage_credits_transactions_on_expires_at_and_id" t.index ["expires_at"], name: "index_usage_credits_transactions_on_expires_at" t.index ["fulfillment_id"], name: "index_usage_credits_transactions_on_fulfillment_id" - t.index ["wallet_id", "amount"], name: "index_transactions_on_wallet_id_and_amount" + t.index ["transfer_id"], name: "index_usage_credits_transactions_on_transfer_id" + t.index ["wallet_id", "amount"], name: "index_usage_credits_transactions_on_wallet_id_and_amount" t.index ["wallet_id"], name: "index_usage_credits_transactions_on_wallet_id" end - create_table "usage_credits_wallets", force: :cascade do |t| - t.string "owner_type", null: false - t.bigint "owner_id", null: false - t.integer "balance", default: 0, null: false + create_table "usage_credits_transfers", force: :cascade do |t| + t.bigint "amount", null: false + t.string "asset_code", default: "credits", null: false + t.string "category", default: "transfer", null: false + t.datetime "created_at", null: false + t.string "expiration_policy", default: "preserve", null: false + t.bigint "from_wallet_id", null: false t.json "metadata", default: {}, null: false + t.bigint "to_wallet_id", null: false + t.datetime "updated_at", null: false + t.index ["from_wallet_id", "to_wallet_id", "asset_code"], name: "index_usage_credits_transfers_on_wallets_and_asset" + t.index ["from_wallet_id"], name: "index_usage_credits_transfers_on_from_wallet_id" + t.index ["to_wallet_id"], name: "index_usage_credits_transfers_on_to_wallet_id" + end + + create_table "usage_credits_wallets", force: :cascade do |t| + t.string "asset_code", default: "credits", null: false + t.bigint "balance", default: 0, null: false t.datetime "created_at", null: false + t.json "metadata", default: {}, null: false + t.bigint "owner_id", null: false + t.string "owner_type", null: false t.datetime "updated_at", null: false + t.index ["owner_type", "owner_id", "asset_code"], name: "index_usage_credits_wallets_on_owner_and_asset", unique: true t.index ["owner_type", "owner_id"], name: "index_usage_credits_wallets_on_owner" end create_table "users", force: :cascade do |t| + t.datetime "created_at", null: false t.string "email" t.string "name" + t.datetime "updated_at", null: false + end + + create_table "wallets_allocations", force: :cascade do |t| + t.bigint "amount", null: false t.datetime "created_at", null: false + t.bigint "source_transaction_id", null: false + t.bigint "transaction_id", null: false + t.datetime "updated_at", null: false + t.index ["source_transaction_id"], name: "index_wallets_allocations_on_source_transaction_id" + t.index ["transaction_id", "source_transaction_id"], name: "index_wallets_allocations_on_tx_and_source_tx" + t.index ["transaction_id"], name: "index_wallets_allocations_on_transaction_id" + end + + create_table "wallets_transactions", force: :cascade do |t| + t.bigint "amount", null: false + t.string "category", null: false + t.datetime "created_at", null: false + t.datetime "expires_at" + t.json "metadata", default: {}, null: false + t.bigint "transfer_id" + t.datetime "updated_at", null: false + t.bigint "wallet_id", null: false + t.index ["category"], name: "index_wallets_transactions_on_category" + t.index ["expires_at", "id"], name: "index_wallets_transactions_on_expires_at_and_id" + t.index ["expires_at"], name: "index_wallets_transactions_on_expires_at" + t.index ["transfer_id"], name: "index_wallets_transactions_on_transfer_id" + t.index ["wallet_id", "amount"], name: "index_wallets_transactions_on_wallet_id_and_amount" + t.index ["wallet_id"], name: "index_wallets_transactions_on_wallet_id" + end + + create_table "wallets_transfers", force: :cascade do |t| + t.bigint "amount", null: false + t.string "asset_code", null: false + t.string "category", default: "transfer", null: false + t.datetime "created_at", null: false + t.string "expiration_policy", default: "preserve", null: false + t.bigint "from_wallet_id", null: false + t.json "metadata", default: {}, null: false + t.bigint "to_wallet_id", null: false + t.datetime "updated_at", null: false + t.index ["from_wallet_id", "to_wallet_id", "asset_code"], name: "index_wallets_transfers_on_wallets_and_asset" + t.index ["from_wallet_id"], name: "index_wallets_transfers_on_from_wallet_id" + t.index ["to_wallet_id"], name: "index_wallets_transfers_on_to_wallet_id" + end + + create_table "wallets_wallets", force: :cascade do |t| + t.string "asset_code", null: false + t.bigint "balance", default: 0, null: false + t.datetime "created_at", null: false + t.json "metadata", default: {}, null: false + t.bigint "owner_id", null: false + t.string "owner_type", null: false t.datetime "updated_at", null: false + t.index ["owner_type", "owner_id", "asset_code"], name: "index_wallets_on_owner_and_asset_code", unique: true + t.index ["owner_type", "owner_id"], name: "index_wallets_wallets_on_owner" end add_foreign_key "pay_charges", "pay_customers", column: "customer_id" @@ -180,4 +260,15 @@ add_foreign_key "pay_subscriptions", "pay_customers", column: "customer_id" add_foreign_key "usage_credits_allocations", "usage_credits_transactions", column: "source_transaction_id" add_foreign_key "usage_credits_allocations", "usage_credits_transactions", column: "transaction_id" + add_foreign_key "usage_credits_fulfillments", "usage_credits_wallets", column: "wallet_id" + add_foreign_key "usage_credits_transactions", "usage_credits_transfers", column: "transfer_id" + add_foreign_key "usage_credits_transactions", "usage_credits_wallets", column: "wallet_id" + add_foreign_key "usage_credits_transfers", "usage_credits_wallets", column: "from_wallet_id" + add_foreign_key "usage_credits_transfers", "usage_credits_wallets", column: "to_wallet_id" + add_foreign_key "wallets_allocations", "wallets_transactions", column: "source_transaction_id" + add_foreign_key "wallets_allocations", "wallets_transactions", column: "transaction_id" + add_foreign_key "wallets_transactions", "wallets_transfers", column: "transfer_id" + add_foreign_key "wallets_transactions", "wallets_wallets", column: "wallet_id" + add_foreign_key "wallets_transfers", "wallets_wallets", column: "from_wallet_id" + add_foreign_key "wallets_transfers", "wallets_wallets", column: "to_wallet_id" end diff --git a/test/fixtures/teams.yml b/test/fixtures/teams.yml new file mode 100644 index 0000000..bfcc134 --- /dev/null +++ b/test/fixtures/teams.yml @@ -0,0 +1,12 @@ +# Team fixtures for coexistence testing +alpha_team: + id: 1 + name: Alpha Team + created_at: <%= 10.days.ago %> + updated_at: <%= 10.days.ago %> + +beta_team: + id: 2 + name: Beta Team + created_at: <%= 5.days.ago %> + updated_at: <%= 5.days.ago %> diff --git a/test/fixtures/usage_credits/wallets.yml b/test/fixtures/usage_credits/wallets.yml index 97472cd..2311103 100644 --- a/test/fixtures/usage_credits/wallets.yml +++ b/test/fixtures/usage_credits/wallets.yml @@ -3,6 +3,7 @@ rich_wallet: id: 1 owner_type: User owner_id: 1 + asset_code: credits balance: 1000 metadata: {} created_at: <%= 30.days.ago %> @@ -13,6 +14,7 @@ poor_wallet: id: 2 owner_type: User owner_id: 2 + asset_code: credits balance: 5 metadata: {} created_at: <%= 15.days.ago %> @@ -23,6 +25,7 @@ subscribed_wallet: id: 3 owner_type: User owner_id: 4 + asset_code: credits balance: 500 metadata: { subscription_tier: "pro" } created_at: <%= 60.days.ago %> @@ -33,6 +36,7 @@ expiry_wallet: id: 4 owner_type: User owner_id: 5 + asset_code: credits balance: 300 metadata: {} created_at: <%= 90.days.ago %> @@ -43,6 +47,7 @@ trial_wallet: id: 5 owner_type: User owner_id: 6 + asset_code: credits balance: 500 metadata: {} created_at: <%= 7.days.ago %> @@ -53,6 +58,7 @@ cancelled_wallet: id: 6 owner_type: User owner_id: 7 + asset_code: credits balance: 50 metadata: {} created_at: <%= 90.days.ago %> @@ -63,17 +69,8 @@ refund_wallet: id: 7 owner_type: User owner_id: 8 + asset_code: credits balance: 1000 metadata: {} created_at: <%= 20.days.ago %> updated_at: <%= 1.day.ago %> - -# Empty wallet (for testing zero balance) -empty_wallet: - id: 8 - owner_type: User - owner_id: 3 - balance: 0 - metadata: {} - created_at: <%= 1.day.ago %> - updated_at: <%= 1.day.ago %> diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 5bcdb34..d634302 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -61,3 +61,11 @@ refund_user: name: Refund User created_at: <%= 20.days.ago %> updated_at: <%= 1.day.ago %> + +# User truly without any wallet (for wallet creation tests) +walletless_user: + id: 9 + email: walletless@example.com + name: Walletless User + created_at: <%= 1.day.ago %> + updated_at: <%= 1.day.ago %> diff --git a/test/fixtures/wallets/wallets.yml b/test/fixtures/wallets/wallets.yml new file mode 100644 index 0000000..140823b --- /dev/null +++ b/test/fixtures/wallets/wallets.yml @@ -0,0 +1,22 @@ +# Wallets gem wallets (for coexistence testing) +# These use the wallets_wallets table, separate from usage_credits_wallets + +alpha_points_wallet: + id: 1 + owner_type: Team + owner_id: 1 + asset_code: points + balance: 500 + metadata: {} + created_at: <%= 10.days.ago %> + updated_at: <%= 1.day.ago %> + +beta_points_wallet: + id: 2 + owner_type: Team + owner_id: 2 + asset_code: points + balance: 200 + metadata: {} + created_at: <%= 5.days.ago %> + updated_at: <%= 1.day.ago %> diff --git a/test/integration/coexistence_test.rb b/test/integration/coexistence_test.rb new file mode 100644 index 0000000..f5fe11a --- /dev/null +++ b/test/integration/coexistence_test.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +require "test_helper" + +# This test verifies that the wallets gem and usage_credits gem can coexist +# in the same Rails application without conflicts. +# +# Plan requirements tested: +# - One model uses direct has_wallets (Team) +# - One model uses has_credits (User) +# - Direct wallets write to wallets_* tables +# - Usage credits write to usage_credits_* tables +# - Cross-transfer between gems is rejected +class CoexistenceTest < ActiveSupport::TestCase + # Load both wallets gem fixtures and usage_credits fixtures + fixtures :teams, :users + + setup do + # Ensure clean state for wallets gem tables + Wallets::Wallet.where(owner_type: "Team").delete_all + end + + test "Team model uses has_wallets from wallets gem" do + team = teams(:alpha_team) + + # Should be able to create a wallet via the wallets gem + wallet = team.wallet(:points) + + assert_instance_of Wallets::Wallet, wallet + assert_equal "points", wallet.asset_code + assert_equal "Team", wallet.owner_type + assert_equal team.id, wallet.owner_id + end + + test "User model uses has_credits from usage_credits gem" do + user = users(:rich_user) + wallet = user.credit_wallet + + assert_instance_of UsageCredits::Wallet, wallet + assert_equal "credits", wallet.asset_code + assert_equal "User", wallet.owner_type + assert_equal user.id, wallet.owner_id + end + + test "wallets gem writes to wallets_wallets table" do + team = teams(:alpha_team) + team.wallet(:points).credit(100, category: :reward) + + # Verify data is in wallets_wallets table + wallet_record = Wallets::Wallet.find_by(owner_type: "Team", owner_id: team.id, asset_code: "points") + assert_not_nil wallet_record + assert_equal 100, wallet_record.balance + + # Verify NOT in usage_credits_wallets table + uc_record = UsageCredits::Wallet.find_by(owner_type: "Team", owner_id: team.id) + assert_nil uc_record + end + + test "usage_credits gem writes to usage_credits_wallets table" do + user = User.create!(email: "coexist-#{SecureRandom.hex(4)}@example.com", name: "Coexist User") + user.give_credits(100, reason: "test") + + # Verify data is in usage_credits_wallets table + uc_record = UsageCredits::Wallet.find_by(owner_type: "User", owner_id: user.id, asset_code: "credits") + assert_not_nil uc_record + assert_equal 100, uc_record.balance + + # Verify NOT in wallets_wallets table + wallet_record = Wallets::Wallet.find_by(owner_type: "User", owner_id: user.id) + assert_nil wallet_record + end + + test "wallets gem transfers stay within wallets_* tables" do + team1 = teams(:alpha_team) + team2 = teams(:beta_team) + + team1.wallet(:points).credit(100, category: :reward) + team2.wallet(:points) # Ensure wallet exists + + transfer = team1.wallet(:points).transfer_to(team2.wallet(:points), 30, category: :gift) + + # Verify transfer is in wallets_transfers table + assert_instance_of Wallets::Transfer, transfer + assert_equal 30, transfer.amount + assert_equal 70, team1.wallet(:points).reload.balance + assert_equal 30, team2.wallet(:points).reload.balance + + # Verify no records in usage_credits_transfers + assert_equal 0, UsageCredits::Transfer.where(from_wallet_id: team1.wallet(:points).id).count + end + + test "usage_credits gem transfers stay within usage_credits_* tables" do + user1 = User.create!(email: "sender-coex-#{SecureRandom.hex(4)}@example.com", name: "Sender") + user2 = User.create!(email: "recipient-coex-#{SecureRandom.hex(4)}@example.com", name: "Recipient") + + user1.give_credits(100, reason: "test") + + transfer = nil + + assert_difference -> { UsageCredits::Transfer.count }, 1 do + assert_difference -> { UsageCredits::Transaction.count }, 2 do + assert_no_difference -> { Wallets::Transfer.count } do + assert_no_difference -> { Wallets::Transaction.count } do + transfer = user1.credit_wallet.transfer_to(user2.credit_wallet, 30, category: :gift) + end + end + end + end + + # Verify transfer is in usage_credits_transfers table + assert_instance_of UsageCredits::Transfer, transfer + assert_equal 30, transfer.amount + assert_equal 70, user1.credits + assert_equal 30, user2.credits + assert_instance_of UsageCredits::Transaction, transfer.outbound_transaction + assert_equal [UsageCredits::Transaction], transfer.inbound_transactions.map(&:class).uniq + assert_equal 1, transfer.inbound_transactions.count + assert_equal "preserve", transfer.expiration_policy + end + + test "cross-gem transfers are rejected" do + team = teams(:alpha_team) + user = User.create!(email: "cross-#{SecureRandom.hex(4)}@example.com", name: "Cross User") + + # Use same asset code for both to test class mismatch specifically + # (asset mismatch check happens before class check in transfer_to) + team.wallet(:credits).credit(100, category: :reward) + user.give_credits(100, reason: "test") + + # Attempting to transfer from wallets gem wallet to usage_credits gem wallet + # should fail because the wallet classes are different + error = assert_raises(Wallets::InvalidTransfer) do + team.wallet(:credits).transfer_to(user.credit_wallet, 30, category: :gift) + end + assert_equal "Wallet classes must match", error.message + + # Reverse direction should also fail + error = assert_raises(Wallets::InvalidTransfer) do + user.credit_wallet.transfer_to(team.wallet(:credits), 30, category: :gift) + end + assert_equal "Wallet classes must match", error.message + end + + test "transactions use correct classes and tables per gem" do + team = teams(:alpha_team) + user = User.create!(email: "tx-#{SecureRandom.hex(4)}@example.com", name: "TX User") + + team.wallet(:points).credit(100, category: :reward) + user.give_credits(100, reason: "test") + + # Wallets gem transactions + team_transactions = team.wallet(:points).transactions + assert team_transactions.all? { |tx| tx.is_a?(Wallets::Transaction) } + + # Usage credits transactions + user_transactions = user.credit_wallet.transactions + assert user_transactions.all? { |tx| tx.is_a?(UsageCredits::Transaction) } + end + + test "callbacks are isolated between gems" do + wallets_callback_fired = false + usage_credits_callback_fired = false + + # Set up wallets gem callback + original_wallets_callback = Wallets.configuration.instance_variable_get(:@on_balance_credited_callback) + Wallets.configure do |config| + config.on_balance_credited { |_ctx| wallets_callback_fired = true } + end + + # Set up usage_credits gem callback + original_uc_callback = UsageCredits.configuration.instance_variable_get(:@on_credits_added_callback) + UsageCredits.configure do |config| + config.on_credits_added { |_ctx| usage_credits_callback_fired = true } + end + + # Credit via wallets gem + team = teams(:alpha_team) + team.wallet(:points).credit(50, category: :reward) + + assert wallets_callback_fired, "Wallets gem callback should have fired" + assert_not usage_credits_callback_fired, "Usage credits callback should NOT have fired for wallets gem operation" + + # Reset flags + wallets_callback_fired = false + usage_credits_callback_fired = false + + # Credit via usage_credits gem + user = User.create!(email: "callback-#{SecureRandom.hex(4)}@example.com", name: "Callback User") + user.give_credits(50, reason: "test") + + assert usage_credits_callback_fired, "Usage credits callback should have fired" + assert_not wallets_callback_fired, "Wallets gem callback should NOT have fired for usage_credits operation" + + # Restore original callbacks + Wallets.configuration.instance_variable_set(:@on_balance_credited_callback, original_wallets_callback) + UsageCredits.configuration.instance_variable_set(:@on_credits_added_callback, original_uc_callback) + end +end diff --git a/test/models/concerns/has_wallet_test.rb b/test/models/concerns/has_wallet_test.rb index 012108c..a503c07 100644 --- a/test/models/concerns/has_wallet_test.rb +++ b/test/models/concerns/has_wallet_test.rb @@ -82,6 +82,47 @@ def self.name assert_equal 100, test_class.credit_options[:initial_balance] end + test "initial_balance is applied through a manual_adjustment transaction" do + test_class = Class.new(User) do + def self.name + "TestUserWithInitialBalanceLedgerBootstrap" + end + + has_credits initial_balance: 100 + end + + user = test_class.create!(email: "initial-balance-#{SecureRandom.hex(4)}@example.com", name: "Initial Balance User") + wallet = user.credit_wallet + + assert_equal 100, user.credits + assert_equal 1, wallet.transactions.count + assert_equal "manual_adjustment", wallet.transactions.first.category + assert_equal "initial_balance", wallet.transactions.first.metadata["reason"] + end + + test "usage credits wallet create_for_owner applies initial_balance via manual_adjustment once" do + wallet = UsageCredits::Wallet.create_for_owner!( + owner: users(:new_user), + asset_code: :credits, + initial_balance: 60 + ) + + assert_no_difference -> { UsageCredits::Wallet.where(owner: users(:new_user), asset_code: "credits").count } do + same_wallet = UsageCredits::Wallet.create_for_owner!( + owner: users(:new_user), + asset_code: "CREDITS", + initial_balance: 999 + ) + + assert_equal wallet.id, same_wallet.id + end + + assert_equal 60, wallet.reload.balance + assert_equal 1, wallet.transactions.count + assert_equal "manual_adjustment", wallet.transactions.sole.category + assert_equal "initial_balance", wallet.transactions.sole.metadata["reason"] + end + # ======================================== # ASSOCIATIONS # ======================================== @@ -125,6 +166,12 @@ def self.name assert_equal user.credit_wallet, user.wallet end + test "does not expose plural credit_wallets association" do + user = users(:rich_user) + + refute_respond_to user, :credit_wallets + end + # ======================================== # WALLET AUTO-CREATION (ensure_credit_wallet) # ======================================== @@ -163,6 +210,31 @@ def self.name assert_nil user.original_credit_wallet end + test "ensure_credit_wallet reuses an existing wallet when the association reader returns nil" do + test_class = Class.new(User) do + def self.name + "TestUserWithExistingWalletLookup" + end + + has_credits initial_balance: 80 + end + + user = test_class.create!(email: "lookup-#{SecureRandom.hex(4)}@example.com", name: "Lookup User") + existing_wallet = user.credit_wallet + + user.define_singleton_method(:original_credit_wallet) { nil } + + assert_no_difference -> { UsageCredits::Wallet.where(owner: user, asset_code: "credits").count } do + wallet = user.send(:ensure_credit_wallet) + + assert_equal existing_wallet.id, wallet.id + end + + assert_equal 80, existing_wallet.reload.balance + assert_equal 1, existing_wallet.transactions.count + assert_equal "manual_adjustment", existing_wallet.transactions.sole.category + end + # ======================================== # METHOD DELEGATION # ======================================== diff --git a/test/models/usage_credits/transaction_test.rb b/test/models/usage_credits/transaction_test.rb index a8d3541..66532f6 100644 --- a/test/models/usage_credits/transaction_test.rb +++ b/test/models/usage_credits/transaction_test.rb @@ -906,12 +906,10 @@ class UsageCredits::TransactionTest < ActiveSupport::TestCase wallet = UsageCredits::Wallet.create!(owner: users(:new_user)) wallet.give_credits(10, reason: "initial") - # Deduct more than available (goes "negative" but credits floors at 0) + # Deduct more than available - usage_credits preserves the legacy public + # contract of flooring displayed balances to zero. spend_tx = wallet.deduct_credits(25, category: "operation_charge", metadata: {}) - # Note: The credits method floors at 0, so balance_after shows 0 even when - # allow_negative_balance is enabled. This is the existing system behavior. - # The negative deduction is tracked but the balance is capped at 0. assert_equal 0, spend_tx.balance_after assert_equal 10, spend_tx.balance_before diff --git a/test/models/usage_credits/wallet_test.rb b/test/models/usage_credits/wallet_test.rb index 0bcbb19..117b2bb 100644 --- a/test/models/usage_credits/wallet_test.rb +++ b/test/models/usage_credits/wallet_test.rb @@ -206,7 +206,7 @@ class UsageCredits::WalletTest < ActiveSupport::TestCase end test "respects grace period for expiration" do - wallet = usage_credits_wallets(:empty_wallet) + wallet = UsageCredits::Wallet.create!(owner: users(:walletless_user), asset_code: "grace_test") # Add credit that expires very soon (within grace period) expires_at = 1.minute.from_now @@ -281,6 +281,66 @@ class UsageCredits::WalletTest < ActiveSupport::TestCase assert_equal 100, total_allocated end + test "credit wallet supports direct wallet transfers without transfer callback wiring" do + sender = User.create!(email: "sender-#{SecureRandom.hex(4)}@example.com", name: "Sender") + recipient = User.create!(email: "recipient-#{SecureRandom.hex(4)}@example.com", name: "Recipient") + + sender.credit_wallet.give_credits(100, reason: "bonus") + + assert_difference -> { UsageCredits::Transfer.count }, 1 do + transfer = sender.credit_wallet.transfer_to( + recipient.credit_wallet, + 30, + category: :gift, + metadata: { source: "test" } + ) + + assert_equal sender.credit_wallet, transfer.from_wallet + assert_equal recipient.credit_wallet, transfer.to_wallet + assert_equal 30, transfer.amount + assert_instance_of UsageCredits::Transaction, transfer.outbound_transaction + assert_instance_of UsageCredits::Transaction, transfer.inbound_transactions.sole + assert_equal "transfer_out", transfer.outbound_transaction.category + assert_equal "transfer_in", transfer.inbound_transactions.sole.category + assert_equal "preserve", transfer.expiration_policy + end + + assert_equal 70, sender.credit_wallet.reload.credits + assert_equal 30, recipient.credit_wallet.reload.credits + end + + test "credit wallet transfers preserve expiration buckets by default" do + sender = User.create!(email: "sender-exp-#{SecureRandom.hex(4)}@example.com", name: "Sender Exp") + recipient = User.create!(email: "recipient-exp-#{SecureRandom.hex(4)}@example.com", name: "Recipient Exp") + earliest_credit = sender.credit_wallet.give_credits(100, reason: "promo", expires_at: 5.days.from_now) + later_credit = sender.credit_wallet.give_credits(80, reason: "promo", expires_at: 20.days.from_now) + + transfer = sender.credit_wallet.transfer_to(recipient.credit_wallet, 130, category: :gift) + inbound_legs = transfer.inbound_transactions.order(:expires_at, :id).to_a + + assert_equal "preserve", transfer.expiration_policy + assert_equal 2, inbound_legs.size + assert_nil transfer.inbound_transaction + assert_equal [100, 30], inbound_legs.map(&:amount) + assert_equal [earliest_credit.expires_at.to_i, later_credit.expires_at.to_i], inbound_legs.map { |tx| tx.expires_at.to_i } + end + + test "credit wallet transfer can override expiration policy to none" do + sender = User.create!(email: "sender-none-#{SecureRandom.hex(4)}@example.com", name: "Sender None") + recipient = User.create!(email: "recipient-none-#{SecureRandom.hex(4)}@example.com", name: "Recipient None") + sender.credit_wallet.give_credits(100, reason: "promo", expires_at: 10.days.from_now) + + transfer = sender.credit_wallet.transfer_to( + recipient.credit_wallet, + 30, + category: :gift, + expiration_policy: :none + ) + + assert_equal "none", transfer.expiration_policy + assert_nil transfer.inbound_transactions.sole.expires_at + end + test "partial allocation from multiple sources" do wallet = UsageCredits::Wallet.create!(owner: users(:new_user)) @@ -623,10 +683,29 @@ class UsageCredits::WalletTest < ActiveSupport::TestCase wallet.deduct_credits(10, category: "operation_charge", metadata: {}) end - # The credits method calculates from remaining positive transactions - # With negative balance enabled, it should show 0 or the actual negative - # depending on implementation - assert wallet.reload.balance <= 0 + # usage_credits historically floors negative balances to zero for public + # balance access, even when negative balances are allowed. + assert_equal 0, wallet.reload.credits + assert_equal 0, wallet.balance + ensure + UsageCredits.configuration.allow_negative_balance = original_setting + end + end + + test "new credits remain fully usable after an unbacked negative debit" do + original_setting = UsageCredits.configuration.allow_negative_balance + + begin + UsageCredits.configuration.allow_negative_balance = true + + wallet = UsageCredits::Wallet.create!(owner: users(:new_user)) + wallet.give_credits(10, reason: "initial") + wallet.deduct_credits(25, category: "operation_charge", metadata: {}) + + refill = wallet.give_credits(20, reason: "refill") + + assert_equal 20, wallet.reload.credits + assert_equal 20, refill.balance_after ensure UsageCredits.configuration.allow_negative_balance = original_setting end diff --git a/test/services/fulfillment_service_test.rb b/test/services/fulfillment_service_test.rb index 87a2ced..99c6687 100644 --- a/test/services/fulfillment_service_test.rb +++ b/test/services/fulfillment_service_test.rb @@ -642,7 +642,7 @@ class FulfillmentServiceTest < ActiveSupport::TestCase end end - wallet = usage_credits_wallets(:empty_wallet) + wallet = UsageCredits::Wallet.create!(owner: users(:walletless_user), asset_code: "accumulation_test") # Create fulfillment fulfillment = Fulfillment.create!( diff --git a/test/usage_credits/callbacks_test.rb b/test/usage_credits/callbacks_test.rb index b0122ec..61f0c34 100644 --- a/test/usage_credits/callbacks_test.rb +++ b/test/usage_credits/callbacks_test.rb @@ -51,6 +51,12 @@ class UsageCredits::CallbacksTest < ActiveSupport::TestCase end end + test "dispatch ignores unsupported callback events" do + assert_nothing_raised do + UsageCredits::Callbacks.dispatch(:transfer_completed, wallet: @user.credit_wallet, amount: 100) + end + end + test "CallbackContext provides owner convenience method" do wallet = @user.credit_wallet ctx = UsageCredits::CallbackContext.new(event: :test, wallet: wallet) @@ -87,7 +93,7 @@ class UsageCredits::CallbacksTest < ActiveSupport::TestCase assert_nil UsageCredits.configuration.on_credits_added_callback end - test "dispatch handles all 7 callback events" do + test "dispatch handles all 7 supported callback events" do events_received = [] UsageCredits.configure do |config| diff --git a/test/usage_credits/migration_templates_test.rb b/test/usage_credits/migration_templates_test.rb new file mode 100644 index 0000000..c5b2023 --- /dev/null +++ b/test/usage_credits/migration_templates_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "test_helper" + +class UsageCredits::MigrationTemplatesTest < ActiveSupport::TestCase + test "fresh install template matches the wallets-core transfer schema" do + template = File.read(template_path("create_usage_credits_tables.rb.erb")) + + assert_includes template, 't.string :asset_code, null: false, default: "credits"' + assert_includes template, 't.string :expiration_policy, null: false, default: "preserve"' + assert_includes template, "t.references :transfer" + refute_includes template, "outbound_transaction" + refute_includes template, "inbound_transaction" + end + + test "upgrade template uses an explicit up migration without adding new legacy fulfillment foreign keys" do + template = File.read(template_path("upgrade_usage_credits_to_wallets_core.rb.erb")) + + assert_includes template, "def up" + assert_includes template, "class UpgradeUsageCreditsToWalletsCore" + assert_includes template, "add_column :usage_credits_wallets, :asset_code" + assert_includes template, "change_column :usage_credits_wallets, :balance, :bigint" + assert_includes template, "create_table :usage_credits_transfers" + assert_includes template, 't.string :expiration_policy, null: false, default: "preserve"' + refute_includes template, "outbound_transaction" + refute_includes template, "inbound_transaction" + refute_includes template, "add_foreign_key :usage_credits_fulfillments" + end + + private + + def template_path(filename) + File.expand_path("../../lib/generators/usage_credits/templates/#{filename}", __dir__) + end +end diff --git a/test/usage_credits/upgrade_migration_test.rb b/test/usage_credits/upgrade_migration_test.rb new file mode 100644 index 0000000..0ac0c06 --- /dev/null +++ b/test/usage_credits/upgrade_migration_test.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +require "test_helper" +require "erb" +require "fileutils" +require "tmpdir" + +class UsageCredits::UpgradeMigrationTest < ActiveSupport::TestCase + self.use_transactional_tests = false + + class TemporaryRecord < ActiveRecord::Base + self.abstract_class = true + end + + class TemporaryConnectionRecord < TemporaryRecord + self.abstract_class = true + end + + def setup + super + + @tmpdir = Dir.mktmpdir("usage-credits-upgrade") + @database_path = File.join(@tmpdir, "upgrade.sqlite3") + + @migration_base = TemporaryConnectionRecord + @migration_base.establish_connection(adapter: "sqlite3", database: @database_path) + @connection = @migration_base.connection + + create_pre_1_0_schema! + seed_pre_1_0_data! + end + + def teardown + super + end + + def after_teardown + super + @migration_base.connection_pool.disconnect! if defined?(@migration_base) && @migration_base&.connection_pool + FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir) + end + + test "upgrade migration preserves pre-1.0 data while adding the wallets core schema" do + run_upgrade_migration! + + wallet_row = @connection.select_one("SELECT * FROM usage_credits_wallets WHERE id = 1") + assert_equal "User", wallet_row["owner_type"] + assert_equal 42, wallet_row["owner_id"] + assert_equal 150, wallet_row["balance"] + assert_equal "credits", wallet_row["asset_code"] + + transaction_rows = @connection.exec_query("SELECT id, wallet_id, amount, category, transfer_id FROM usage_credits_transactions ORDER BY id").to_a + assert_equal [ + { "id" => 1, "wallet_id" => 1, "amount" => 200, "category" => "signup_bonus", "transfer_id" => nil }, + { "id" => 2, "wallet_id" => 1, "amount" => -50, "category" => "operation_charge", "transfer_id" => nil } + ], transaction_rows + + allocation_row = @connection.select_one("SELECT * FROM usage_credits_allocations WHERE id = 1") + assert_equal 50, allocation_row["amount"] + assert_equal 2, allocation_row["transaction_id"] + assert_equal 1, allocation_row["source_transaction_id"] + + fulfillment_row = @connection.select_one("SELECT * FROM usage_credits_fulfillments WHERE id = 1") + assert_equal 1, fulfillment_row["wallet_id"] + assert_equal 200, fulfillment_row["credits_last_fulfillment"] + assert_equal "signup_fulfillment", fulfillment_row["fulfillable_type"] + assert_equal 7, fulfillment_row["fulfillable_id"] + + assert_includes @connection.tables, "usage_credits_transfers" + assert_equal 0, @connection.select_value("SELECT COUNT(*) FROM usage_credits_transfers") + + wallet_index = @connection.indexes(:usage_credits_wallets).find { |index| index.name == "index_usage_credits_wallets_on_owner_and_asset" } + assert wallet_index, "expected owner/asset index to be created" + assert wallet_index.unique + assert_equal %w[owner_type owner_id asset_code], wallet_index.columns + + transfers_index = @connection.indexes(:usage_credits_transfers).find { |index| index.name == "index_usage_credits_transfers_on_wallets_and_asset" } + assert transfers_index, "expected transfers wallet/asset index to be created" + + wallet_balance_column = @connection.columns(:usage_credits_wallets).find { |column| column.name == "balance" } + transaction_amount_column = @connection.columns(:usage_credits_transactions).find { |column| column.name == "amount" } + allocation_amount_column = @connection.columns(:usage_credits_allocations).find { |column| column.name == "amount" } + fulfillment_amount_column = @connection.columns(:usage_credits_fulfillments).find { |column| column.name == "credits_last_fulfillment" } + transfer_amount_column = @connection.columns(:usage_credits_transfers).find { |column| column.name == "amount" } + transfer_policy_column = @connection.columns(:usage_credits_transfers).find { |column| column.name == "expiration_policy" } + + assert_equal "bigint", wallet_balance_column.sql_type + assert_equal "bigint", transaction_amount_column.sql_type + assert_equal "bigint", allocation_amount_column.sql_type + assert_equal "bigint", fulfillment_amount_column.sql_type + assert_equal "bigint", transfer_amount_column.sql_type + assert_equal "preserve", transfer_policy_column.default + + transfer_reference = @connection.columns(:usage_credits_transactions).find { |column| column.name == "transfer_id" } + + assert transfer_reference + refute @connection.columns(:usage_credits_transfers).any? { |column| column.name == "outbound_transaction_id" } + refute @connection.columns(:usage_credits_transfers).any? { |column| column.name == "inbound_transaction_id" } + end + + private + + def create_pre_1_0_schema! + @connection.create_table :usage_credits_wallets do |t| + t.string :owner_type, null: false + t.integer :owner_id, null: false + t.integer :balance, null: false, default: 0 + t.timestamps + end + + @connection.create_table :usage_credits_transactions do |t| + t.references :wallet, null: false + t.references :fulfillment + t.integer :amount, null: false + t.string :category, null: false + t.send(json_column_type, :metadata, default: json_column_default) + t.datetime :expires_at + t.integer :balance_before + t.integer :balance_after + t.timestamps + end + + @connection.create_table :usage_credits_allocations do |t| + t.references :transaction, null: false + t.references :source_transaction, null: false + t.integer :amount, null: false + t.timestamps + end + + @connection.create_table :usage_credits_fulfillments do |t| + t.references :wallet + t.string :fulfillable_type, null: false + t.integer :fulfillable_id, null: false + t.datetime :fulfilled_at + t.integer :credits_last_fulfillment, null: false, default: 0 + t.send(json_column_type, :metadata, default: json_column_default) + t.timestamps + end + end + + def seed_pre_1_0_data! + now = Time.current + + insert_row :usage_credits_wallets, + id: 1, + owner_type: "User", + owner_id: 42, + balance: 150, + created_at: now, + updated_at: now + + insert_row :usage_credits_transactions, + id: 1, + wallet_id: 1, + fulfillment_id: 1, + amount: 200, + category: "signup_bonus", + metadata: json_payload(reason: "welcome"), + balance_before: 0, + balance_after: 200, + created_at: now, + updated_at: now + + insert_row :usage_credits_transactions, + id: 2, + wallet_id: 1, + fulfillment_id: nil, + amount: -50, + category: "operation_charge", + metadata: json_payload(operation: "generate_report"), + balance_before: 200, + balance_after: 150, + created_at: now, + updated_at: now + + insert_row :usage_credits_allocations, + id: 1, + transaction_id: 2, + source_transaction_id: 1, + amount: 50, + created_at: now, + updated_at: now + + insert_row :usage_credits_fulfillments, + id: 1, + wallet_id: 1, + fulfillable_type: "signup_fulfillment", + fulfillable_id: 7, + fulfilled_at: now, + credits_last_fulfillment: 200, + metadata: json_payload(source: "signup"), + created_at: now, + updated_at: now + end + + def run_upgrade_migration! + migration_class = load_upgrade_migration_class + migration = migration_class.new + migration.verbose = false + migration.exec_migration(@connection, :up) + end + + def load_upgrade_migration_class + source = ERB.new(File.read(template_path("upgrade_usage_credits_to_wallets_core.rb.erb"))).result_with_hash( + migration_version: "[#{ActiveRecord::VERSION::STRING.to_f}]" + ) + + mod = Module.new + mod.module_eval(source, template_path("upgrade_usage_credits_to_wallets_core.rb.erb"), 1) + mod.const_get(:UpgradeUsageCreditsToWalletsCore) + end + + def insert_row(table_name, attributes) + columns = attributes.keys.map(&:to_s) + values = attributes.values.map { |value| @connection.quote(value) } + + @connection.execute(<<~SQL.squish) + INSERT INTO #{table_name} (#{columns.join(', ')}) + VALUES (#{values.join(', ')}) + SQL + end + + def template_path(filename) + File.expand_path("../../lib/generators/usage_credits/templates/#{filename}", __dir__) + end + + def json_column_type + @connection.adapter_name.downcase.include?("postgresql") ? :jsonb : :json + end + + def json_column_default + @connection.adapter_name.downcase.include?("mysql") ? nil : {} + end + + def json_payload(attributes) + ActiveSupport::JSON.encode(attributes) + end +end diff --git a/usage_credits.gemspec b/usage_credits.gemspec index 68fdaad..27c99dc 100644 --- a/usage_credits.gemspec +++ b/usage_credits.gemspec @@ -35,4 +35,5 @@ Gem::Specification.new do |spec| spec.add_dependency "pay", ">= 8.3", "< 12.0" spec.add_dependency "rails", ">= 6.1" + spec.add_dependency "wallets", "~> 0.2" end From 5446ac14758e492262d7dd242c1d2cb25f48a4a6 Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Wed, 18 Mar 2026 02:39:49 +0000 Subject: [PATCH 2/7] Fix association cache and clean up gemspec - Properly set self.credit_wallet after find/create in ensure_credit_wallet - Gemspec: better file excludes, add Rails < 9.0 ceiling Co-Authored-By: Claude Opus 4.6 --- .../models/concerns/has_wallet.rb | 10 ++++++++-- usage_credits.gemspec | 18 +++++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/lib/usage_credits/models/concerns/has_wallet.rb b/lib/usage_credits/models/concerns/has_wallet.rb index d35b080..bd2c3fc 100644 --- a/lib/usage_credits/models/concerns/has_wallet.rb +++ b/lib/usage_credits/models/concerns/has_wallet.rb @@ -78,15 +78,21 @@ def should_create_wallet? def ensure_credit_wallet wallet = original_credit_wallet || UsageCredits::Wallet.find_by(owner: self, asset_code: "credits") - return wallet if wallet.present? + if wallet.present? + self.credit_wallet = wallet unless original_credit_wallet == wallet + return wallet + end return unless should_create_wallet? raise "Cannot create wallet for unsaved owner" unless persisted? - UsageCredits::Wallet.create_for_owner!( + wallet = UsageCredits::Wallet.create_for_owner!( owner: self, asset_code: "credits", initial_balance: credit_options[:initial_balance].to_i ) + + self.credit_wallet = wallet + wallet end def create_credit_wallet diff --git a/usage_credits.gemspec b/usage_credits.gemspec index 27c99dc..9160ebe 100644 --- a/usage_credits.gemspec +++ b/usage_credits.gemspec @@ -16,7 +16,7 @@ Gem::Specification.new do |spec| spec.metadata["allowed_push_host"] = "https://rubygems.org" spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "#{spec.homepage}/tree/main" spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md" spec.metadata["rubygems_mfa_required"] = "true" @@ -26,7 +26,19 @@ Gem::Specification.new do |spec| spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| ls.readlines("\x0", chomp: true).reject do |f| (f == gemspec) || - f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile]) + f.start_with?(*%w[ + .aux/ + .claude/ + .cursor/ + .git + .github/ + appveyor + bin/ + features/ + Gemfile + spec/ + test/ + ]) end end spec.bindir = "exe" @@ -34,6 +46,6 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency "pay", ">= 8.3", "< 12.0" - spec.add_dependency "rails", ">= 6.1" + spec.add_dependency "rails", ">= 6.1", "< 9.0" spec.add_dependency "wallets", "~> 0.2" end From 9ff164ea892d9952bbea200b2635743fe66abec5 Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Wed, 18 Mar 2026 03:20:26 +0000 Subject: [PATCH 3/7] Update wallets dependency to ~> 0.1 Co-Authored-By: Claude Opus 4.6 --- usage_credits.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/usage_credits.gemspec b/usage_credits.gemspec index 9160ebe..a8c7efb 100644 --- a/usage_credits.gemspec +++ b/usage_credits.gemspec @@ -47,5 +47,5 @@ Gem::Specification.new do |spec| spec.add_dependency "pay", ">= 8.3", "< 12.0" spec.add_dependency "rails", ">= 6.1", "< 9.0" - spec.add_dependency "wallets", "~> 0.2" + spec.add_dependency "wallets", "~> 0.1" end From 5fd5cc83b747fabb63e02c83973ef37580e2db01 Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:10:45 +0100 Subject: [PATCH 4/7] Require wallets ~> 0.2 and sync appraisal gemfiles The integration relies on semantics pinned in wallets 0.2.0 (transfer_to honoring allow_negative_balance, :balance_depleted firing on <= 0 crossings). The ~> 0.1 constraint predates the 0.2.0 release. Appraisal gemfiles regenerated to pick up mysql2 from the main Gemfile. Co-Authored-By: Claude Fable 5 --- gemfiles/pay_10.0.gemfile | 1 + gemfiles/pay_11.0.gemfile | 1 + gemfiles/pay_8.3.gemfile | 1 + gemfiles/pay_9.0.gemfile | 1 + gemfiles/rails_7.2.gemfile | 1 + gemfiles/rails_8.1.gemfile | 1 + usage_credits.gemspec | 2 +- 7 files changed, 7 insertions(+), 1 deletion(-) diff --git a/gemfiles/pay_10.0.gemfile b/gemfiles/pay_10.0.gemfile index e5187ed..fb62cda 100644 --- a/gemfiles/pay_10.0.gemfile +++ b/gemfiles/pay_10.0.gemfile @@ -29,6 +29,7 @@ group :test do gem "receipts" gem "sqlite3" gem "pg" + gem "mysql2" gem "bootsnap", require: false gem "puma" gem "importmap-rails" diff --git a/gemfiles/pay_11.0.gemfile b/gemfiles/pay_11.0.gemfile index f1e0e34..0c4ef8f 100644 --- a/gemfiles/pay_11.0.gemfile +++ b/gemfiles/pay_11.0.gemfile @@ -29,6 +29,7 @@ group :test do gem "receipts" gem "sqlite3" gem "pg" + gem "mysql2" gem "bootsnap", require: false gem "puma" gem "importmap-rails" diff --git a/gemfiles/pay_8.3.gemfile b/gemfiles/pay_8.3.gemfile index c1fa9ec..d096210 100644 --- a/gemfiles/pay_8.3.gemfile +++ b/gemfiles/pay_8.3.gemfile @@ -29,6 +29,7 @@ group :test do gem "receipts" gem "sqlite3" gem "pg" + gem "mysql2" gem "bootsnap", require: false gem "puma" gem "importmap-rails" diff --git a/gemfiles/pay_9.0.gemfile b/gemfiles/pay_9.0.gemfile index 51dedf5..a9f74a0 100644 --- a/gemfiles/pay_9.0.gemfile +++ b/gemfiles/pay_9.0.gemfile @@ -29,6 +29,7 @@ group :test do gem "receipts" gem "sqlite3" gem "pg" + gem "mysql2" gem "bootsnap", require: false gem "puma" gem "importmap-rails" diff --git a/gemfiles/rails_7.2.gemfile b/gemfiles/rails_7.2.gemfile index f49aa3b..af757d2 100644 --- a/gemfiles/rails_7.2.gemfile +++ b/gemfiles/rails_7.2.gemfile @@ -29,6 +29,7 @@ group :test do gem "receipts" gem "sqlite3" gem "pg" + gem "mysql2" gem "bootsnap", require: false gem "puma" gem "importmap-rails" diff --git a/gemfiles/rails_8.1.gemfile b/gemfiles/rails_8.1.gemfile index b0f7428..1130f10 100644 --- a/gemfiles/rails_8.1.gemfile +++ b/gemfiles/rails_8.1.gemfile @@ -29,6 +29,7 @@ group :test do gem "receipts" gem "sqlite3" gem "pg" + gem "mysql2" gem "bootsnap", require: false gem "puma" gem "importmap-rails" diff --git a/usage_credits.gemspec b/usage_credits.gemspec index a8c7efb..9160ebe 100644 --- a/usage_credits.gemspec +++ b/usage_credits.gemspec @@ -47,5 +47,5 @@ Gem::Specification.new do |spec| spec.add_dependency "pay", ">= 8.3", "< 12.0" spec.add_dependency "rails", ">= 6.1", "< 9.0" - spec.add_dependency "wallets", "~> 0.1" + spec.add_dependency "wallets", "~> 0.2" end From f2000d8dc44248969c9235c651fa217da454cf64 Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:10:56 +0100 Subject: [PATCH 5/7] Harden the pre-1.0 upgrade migration for production data Pre-1.0 schemas never enforced one-wallet-per-owner, so long-lived installs can hold duplicate wallets created by races. The unique index the upgrade adds would fail halfway through on such data -- and MySQL cannot roll DDL back. The migration now: - Pre-checks for duplicate owner wallets before any DDL and aborts with step-by-step merge instructions while the schema is still untouched - Guards every step (column_exists? / index_exists? / table_exists?) so it is safe to re-run after an interrupted attempt - Tells fresh apps to use the install generator instead - Declares an explicit irreversible down The upgrade test now simulates the real 0.5.0 schema (source polymorphic fulfillments, balance snapshots in metadata, 0.5.0 index names) instead of a fictional pre-release one, and covers the duplicate-abort, re-run, and missing-tables paths. Co-Authored-By: Claude Fable 5 --- ...grade_usage_credits_to_wallets_core.rb.erb | 117 +++++++++++++-- .../usage_credits/upgrade_generator.rb | 6 +- test/usage_credits/upgrade_migration_test.rb | 142 +++++++++++++----- 3 files changed, 210 insertions(+), 55 deletions(-) diff --git a/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb b/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb index 8761c00..6a5d499 100644 --- a/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb +++ b/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb @@ -1,12 +1,36 @@ # frozen_string_literal: true +# Upgrades a pre-1.0 usage_credits install to the wallets-backed ledger core. +# +# Every schema step is guarded (column_exists? / index_exists? / if_not_exists) +# so the migration is safe to re-run if a previous attempt was interrupted -- +# important on MySQL, where DDL statements cannot be rolled back. +# +# Notes on intentional differences vs. a fresh 1.0 install: +# - Index names created by pre-1.0 versions (e.g. "index_allocations_on_tx_and_source_tx") +# are preserved as-is: renaming indexes on large production tables buys nothing +# functionally, and pre-1.0 installs may have slightly different names depending +# on the version they were created with. +# - Pre-1.0 installs have no foreign key on usage_credits_transactions.wallet_id; +# we deliberately don't add one here because validating it would lock large +# tables. Fresh 1.0 installs do get it. Rails-level integrity is unaffected. class UpgradeUsageCreditsToWalletsCore < ActiveRecord::Migration<%= migration_version %> def up + ensure_usage_credits_installed! + ensure_no_duplicate_wallets! + primary_key_type, foreign_key_type = primary_and_foreign_key_types # Add asset_code to wallets (default to "credits" for backwards compatibility) - add_column :usage_credits_wallets, :asset_code, :string, null: false, default: "credits" - add_index :usage_credits_wallets, [:owner_type, :owner_id, :asset_code], unique: true, name: "index_usage_credits_wallets_on_owner_and_asset" + unless column_exists?(:usage_credits_wallets, :asset_code) + add_column :usage_credits_wallets, :asset_code, :string, null: false, default: "credits" + end + + # One wallet per owner per asset. The pre-flight check above guarantees + # existing data satisfies this before we enforce it. + unless index_exists?(:usage_credits_wallets, [:owner_type, :owner_id, :asset_code], name: "index_usage_credits_wallets_on_owner_and_asset") + add_index :usage_credits_wallets, [:owner_type, :owner_id, :asset_code], unique: true, name: "index_usage_credits_wallets_on_owner_and_asset" + end # Change integer columns to bigint for larger balance support # Note: This is a potentially slow operation on large tables @@ -16,26 +40,91 @@ class UpgradeUsageCreditsToWalletsCore < ActiveRecord::Migration<%= migration_ve change_column :usage_credits_fulfillments, :credits_last_fulfillment, :bigint, null: false # Create transfers table for wallet-to-wallet transfers - create_table :usage_credits_transfers, id: primary_key_type do |t| - t.references :from_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets } - t.references :to_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets } - t.string :asset_code, null: false, default: "credits" - t.bigint :amount, null: false - t.string :category, null: false, default: "transfer" - t.string :expiration_policy, null: false, default: "preserve" - t.send(json_column_type, :metadata, null: false, default: json_column_default) - - t.timestamps + unless table_exists?(:usage_credits_transfers) + create_table :usage_credits_transfers, id: primary_key_type do |t| + t.references :from_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets } + t.references :to_wallet, null: false, type: foreign_key_type, foreign_key: { to_table: :usage_credits_wallets } + t.string :asset_code, null: false, default: "credits" + t.bigint :amount, null: false + t.string :category, null: false, default: "transfer" + t.string :expiration_policy, null: false, default: "preserve" + t.send(json_column_type, :metadata, null: false, default: json_column_default) + + t.timestamps + end end - add_index :usage_credits_transfers, [:from_wallet_id, :to_wallet_id, :asset_code], name: "index_usage_credits_transfers_on_wallets_and_asset" + unless index_exists?(:usage_credits_transfers, [:from_wallet_id, :to_wallet_id, :asset_code], name: "index_usage_credits_transfers_on_wallets_and_asset") + add_index :usage_credits_transfers, [:from_wallet_id, :to_wallet_id, :asset_code], name: "index_usage_credits_transfers_on_wallets_and_asset" + end # Add transfer reference to transactions - add_reference :usage_credits_transactions, :transfer, type: foreign_key_type, foreign_key: { to_table: :usage_credits_transfers } + unless column_exists?(:usage_credits_transactions, :transfer_id) + add_reference :usage_credits_transactions, :transfer, type: foreign_key_type, foreign_key: { to_table: :usage_credits_transfers } + end + end + + def down + raise ActiveRecord::IrreversibleMigration, "usage_credits 1.0 cannot be downgraded in place. Restore from the backup you took before upgrading." end private + # Guard against running the upgrade on an app that never installed usage_credits. + def ensure_usage_credits_installed! + return if table_exists?(:usage_credits_wallets) + + raise <<~MESSAGE + No usage_credits tables found. This migration upgrades an existing pre-1.0 + install. For new apps, run `rails generate usage_credits:install` instead. + MESSAGE + end + + # Pre-1.0 schemas never enforced one-wallet-per-owner, so long-lived installs + # can hold duplicate wallets for the same owner (e.g. created by a race between + # two concurrent requests). The unique index this migration adds would fail + # halfway through on such data -- so we check up front, before touching the + # schema, and fail with actionable instructions while everything is untouched. + def ensure_no_duplicate_wallets! + duplicates = connection.select_rows(<<~SQL.squish) + SELECT owner_type, owner_id, COUNT(*) + FROM usage_credits_wallets + GROUP BY owner_type, owner_id + HAVING COUNT(*) > 1 + ORDER BY owner_type, owner_id + SQL + + return if duplicates.empty? + + listed = duplicates.first(10).map { |type, id, count| " - #{type}##{id} (#{count} wallets)" } + listed << " ...and #{duplicates.size - 10} more" if duplicates.size > 10 + + raise <<~MESSAGE + Cannot upgrade: #{duplicates.size} owner(s) have more than one usage_credits wallet: + + #{listed.join("\n")} + + usage_credits 1.0 enforces one wallet per owner, so these duplicates must be + merged first. For each owner, keep the oldest wallet, move the other wallets' + records over, and delete the empty duplicates. For example, in `rails console`: + + UsageCredits::Wallet.group(:owner_type, :owner_id).having("COUNT(*) > 1").count.each_key do |owner_type, owner_id| + wallets = UsageCredits::Wallet.where(owner_type: owner_type, owner_id: owner_id).order(:id).to_a + keep = wallets.shift + wallets.each do |duplicate| + UsageCredits::Transaction.where(wallet_id: duplicate.id).update_all(wallet_id: keep.id) + UsageCredits::Fulfillment.where(wallet_id: duplicate.id).update_all(wallet_id: keep.id) + duplicate.delete + end + end + + Review the duplicates manually if the merge strategy matters for your app + (the snippet above keeps the oldest wallet and moves everything into it). + No schema changes have been applied yet -- once the data is clean, run + `rails db:migrate` again. + MESSAGE + end + def primary_and_foreign_key_types config = Rails.configuration.generators setting = config.options[config.orm][:primary_key_type] diff --git a/lib/generators/usage_credits/upgrade_generator.rb b/lib/generators/usage_credits/upgrade_generator.rb index abc62a6..e9c0526 100644 --- a/lib/generators/usage_credits/upgrade_generator.rb +++ b/lib/generators/usage_credits/upgrade_generator.rb @@ -21,14 +21,18 @@ def create_migration_file def display_post_upgrade_message say "\nUsageCredits 1.0 upgrade migration has been generated!", :green say "\nThis migration will:" + say " - Check for duplicate owner wallets first, and abort with instructions if any exist" say " - Add 'asset_code' column to wallets (default: 'credits')" + say " - Enforce one wallet per owner per asset with a unique index" say " - Change integer columns to bigint for larger balance support" say " - Create 'usage_credits_transfers' table for wallet transfers" say " - Add 'transfer_id' column to transactions" say " - Upgrade pre-1.0 installs to the wallets-backed ledger core" + say "\nEvery step is guarded, so the migration is safe to re-run if interrupted." say "\nTo complete the upgrade:" say " 1. Review the migration file in db/migrate/" - say " 2. Run 'rails db:migrate'" + say " 2. Back up your database (this migration is not reversible)" + say " 3. Run 'rails db:migrate'" say "\n" end diff --git a/test/usage_credits/upgrade_migration_test.rb b/test/usage_credits/upgrade_migration_test.rb index 0ac0c06..1a447e7 100644 --- a/test/usage_credits/upgrade_migration_test.rb +++ b/test/usage_credits/upgrade_migration_test.rb @@ -25,9 +25,6 @@ def setup @migration_base = TemporaryConnectionRecord @migration_base.establish_connection(adapter: "sqlite3", database: @database_path) @connection = @migration_base.connection - - create_pre_1_0_schema! - seed_pre_1_0_data! end def teardown @@ -41,6 +38,9 @@ def after_teardown end test "upgrade migration preserves pre-1.0 data while adding the wallets core schema" do + create_pre_1_0_schema! + seed_pre_1_0_data! + run_upgrade_migration! wallet_row = @connection.select_one("SELECT * FROM usage_credits_wallets WHERE id = 1") @@ -55,6 +55,11 @@ def after_teardown { "id" => 2, "wallet_id" => 1, "amount" => -50, "category" => "operation_charge", "transfer_id" => nil } ], transaction_rows + # Pre-1.0 stored balance snapshots inside metadata; they must survive untouched. + credit_metadata = ActiveSupport::JSON.decode(@connection.select_value("SELECT metadata FROM usage_credits_transactions WHERE id = 1")) + assert_equal "welcome", credit_metadata["reason"] + assert_equal 200, credit_metadata["balance_after"] + allocation_row = @connection.select_one("SELECT * FROM usage_credits_allocations WHERE id = 1") assert_equal 50, allocation_row["amount"] assert_equal 2, allocation_row["transaction_id"] @@ -63,8 +68,9 @@ def after_teardown fulfillment_row = @connection.select_one("SELECT * FROM usage_credits_fulfillments WHERE id = 1") assert_equal 1, fulfillment_row["wallet_id"] assert_equal 200, fulfillment_row["credits_last_fulfillment"] - assert_equal "signup_fulfillment", fulfillment_row["fulfillable_type"] - assert_equal 7, fulfillment_row["fulfillable_id"] + assert_equal "Pay::Charge", fulfillment_row["source_type"] + assert_equal 7, fulfillment_row["source_id"] + assert_equal "credit_pack", fulfillment_row["fulfillment_type"] assert_includes @connection.tables, "usage_credits_transfers" assert_equal 0, @connection.select_value("SELECT COUNT(*) FROM usage_credits_transfers") @@ -77,6 +83,10 @@ def after_teardown transfers_index = @connection.indexes(:usage_credits_transfers).find { |index| index.name == "index_usage_credits_transfers_on_wallets_and_asset" } assert transfers_index, "expected transfers wallet/asset index to be created" + # Pre-1.0 index names are intentionally preserved (no renames on production tables). + legacy_allocation_index = @connection.indexes(:usage_credits_allocations).find { |index| index.name == "index_allocations_on_tx_and_source_tx" } + assert legacy_allocation_index, "expected pre-1.0 allocation index name to be preserved" + wallet_balance_column = @connection.columns(:usage_credits_wallets).find { |column| column.name == "balance" } transaction_amount_column = @connection.columns(:usage_credits_transactions).find { |column| column.name == "amount" } allocation_amount_column = @connection.columns(:usage_credits_allocations).find { |column| column.name == "amount" } @@ -98,44 +108,106 @@ def after_teardown refute @connection.columns(:usage_credits_transfers).any? { |column| column.name == "inbound_transaction_id" } end + test "upgrade migration aborts before touching the schema when duplicate owner wallets exist" do + create_pre_1_0_schema! + seed_pre_1_0_data! + + # The pre-1.0 schema never enforced one-wallet-per-owner, so a race could + # have created duplicates. Simulate that exact production scenario. + insert_row :usage_credits_wallets, + id: 2, + owner_type: "User", + owner_id: 42, + balance: 25, + created_at: Time.current, + updated_at: Time.current + + error = assert_raises(StandardError) { run_upgrade_migration! } + assert_match(/more than one usage_credits wallet/, error.message) + assert_match(/User#42 \(2 wallets\)/, error.message) + assert_match(/No schema changes have been applied yet/, error.message) + + # The database must be completely untouched so the user can fix data and re-run. + refute @connection.columns(:usage_credits_wallets).any? { |column| column.name == "asset_code" } + refute_includes @connection.tables, "usage_credits_transfers" + refute @connection.columns(:usage_credits_transactions).any? { |column| column.name == "transfer_id" } + end + + test "upgrade migration is safe to re-run after a completed or interrupted attempt" do + create_pre_1_0_schema! + seed_pre_1_0_data! + + run_upgrade_migration! + run_upgrade_migration! + + assert_equal 1, @connection.indexes(:usage_credits_wallets).count { |index| index.name == "index_usage_credits_wallets_on_owner_and_asset" } + assert_equal 150, @connection.select_value("SELECT balance FROM usage_credits_wallets WHERE id = 1") + end + + test "upgrade migration tells fresh apps to use the install generator instead" do + error = assert_raises(StandardError) { run_upgrade_migration! } + assert_match(/No usage_credits tables found/, error.message) + assert_match(/usage_credits:install/, error.message) + end + private + # Mirrors the actual 0.5.0 install template (lib/generators/usage_credits/templates/ + # create_usage_credits_tables.rb.erb on the 0.5.0 tag) so we test the upgrade + # against the schema real production apps are coming from. def create_pre_1_0_schema! @connection.create_table :usage_credits_wallets do |t| - t.string :owner_type, null: false - t.integer :owner_id, null: false + t.references :owner, polymorphic: true, null: false t.integer :balance, null: false, default: 0 + t.json :metadata, null: false, default: {} + t.timestamps end @connection.create_table :usage_credits_transactions do |t| t.references :wallet, null: false - t.references :fulfillment t.integer :amount, null: false t.string :category, null: false - t.send(json_column_type, :metadata, default: json_column_default) t.datetime :expires_at - t.integer :balance_before - t.integer :balance_after + t.references :fulfillment + t.json :metadata, null: false, default: {} + t.timestamps end - @connection.create_table :usage_credits_allocations do |t| - t.references :transaction, null: false - t.references :source_transaction, null: false - t.integer :amount, null: false + @connection.create_table :usage_credits_fulfillments do |t| + t.references :wallet, null: false + t.references :source, polymorphic: true + t.integer :credits_last_fulfillment, null: false + t.string :fulfillment_type, null: false + t.datetime :last_fulfilled_at + t.datetime :next_fulfillment_at + t.string :fulfillment_period + t.datetime :stops_at + t.json :metadata, null: false, default: {} + t.timestamps end - @connection.create_table :usage_credits_fulfillments do |t| - t.references :wallet - t.string :fulfillable_type, null: false - t.integer :fulfillable_id, null: false - t.datetime :fulfilled_at - t.integer :credits_last_fulfillment, null: false, default: 0 - t.send(json_column_type, :metadata, default: json_column_default) + @connection.create_table :usage_credits_allocations do |t| + t.references :transaction, null: false, + foreign_key: { to_table: :usage_credits_transactions }, + index: { name: "index_allocations_on_transaction_id" } + t.references :source_transaction, null: false, + foreign_key: { to_table: :usage_credits_transactions }, + index: { name: "index_allocations_on_source_transaction_id" } + t.integer :amount, null: false + t.timestamps end + + @connection.add_index :usage_credits_transactions, :category + @connection.add_index :usage_credits_transactions, :expires_at + @connection.add_index :usage_credits_transactions, [:expires_at, :id], name: "index_transactions_on_expires_at_and_id" + @connection.add_index :usage_credits_transactions, [:wallet_id, :amount], name: "index_transactions_on_wallet_id_and_amount" + @connection.add_index :usage_credits_allocations, [:transaction_id, :source_transaction_id], name: "index_allocations_on_tx_and_source_tx" + @connection.add_index :usage_credits_fulfillments, :next_fulfillment_at + @connection.add_index :usage_credits_fulfillments, :fulfillment_type end def seed_pre_1_0_data! @@ -149,15 +221,14 @@ def seed_pre_1_0_data! created_at: now, updated_at: now + # 0.5.0 stored balance snapshots in metadata, not in dedicated columns. insert_row :usage_credits_transactions, id: 1, wallet_id: 1, fulfillment_id: 1, amount: 200, category: "signup_bonus", - metadata: json_payload(reason: "welcome"), - balance_before: 0, - balance_after: 200, + metadata: json_payload(reason: "welcome", balance_before: 0, balance_after: 200), created_at: now, updated_at: now @@ -167,9 +238,7 @@ def seed_pre_1_0_data! fulfillment_id: nil, amount: -50, category: "operation_charge", - metadata: json_payload(operation: "generate_report"), - balance_before: 200, - balance_after: 150, + metadata: json_payload(operation: "generate_report", balance_before: 200, balance_after: 150), created_at: now, updated_at: now @@ -184,11 +253,12 @@ def seed_pre_1_0_data! insert_row :usage_credits_fulfillments, id: 1, wallet_id: 1, - fulfillable_type: "signup_fulfillment", - fulfillable_id: 7, - fulfilled_at: now, + source_type: "Pay::Charge", + source_id: 7, credits_last_fulfillment: 200, - metadata: json_payload(source: "signup"), + fulfillment_type: "credit_pack", + last_fulfilled_at: now, + metadata: json_payload(purchase: "starter_pack"), created_at: now, updated_at: now end @@ -224,14 +294,6 @@ def template_path(filename) File.expand_path("../../lib/generators/usage_credits/templates/#{filename}", __dir__) end - def json_column_type - @connection.adapter_name.downcase.include?("postgresql") ? :jsonb : :json - end - - def json_column_default - @connection.adapter_name.downcase.include?("mysql") ? nil : {} - end - def json_payload(attributes) ActiveSupport::JSON.encode(attributes) end From 25f70510220dfc51ae018b41a78bef2aede44930 Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:11:06 +0100 Subject: [PATCH 6/7] Polish 1.0.0 release docs and clean up no-op rescue - CHANGELOG: full 1.0.0 entry covering the wallets dependency, schema changes, what stays backwards-compatible, and upgrade instructions - README: document the upgrade path (backup, in-place migration, duplicate pre-check, single-deploy guidance), list the wallets dependency in requirements, and clarify has_credits / has_wallets coexistence (including the `wallet` method collision caveat) - Wallet: drop a rescue-and-reraise that did nothing; keep the InvalidOperation guards in their idiomatic bare-raise form - HasWallet: explain the alias-before-redefine dance that preserves the pre-1.0 `user.wallet` vs `user.credit_wallet` asymmetry Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 31 ++++++++++++++++--- README.md | 30 +++++++++++++----- .../models/concerns/has_wallet.rb | 6 ++++ lib/usage_credits/models/wallet.rb | 10 +++--- 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11d3695..047a39c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,31 @@ -## [1.0.0] - 2026-03-18 +## [1.0.0] - Unreleased -- Rebuild `usage_credits` on top of the new `wallets` ledger core while preserving the existing credits-focused DX -- Add the pre-1.0 upgrade generator for existing installs, including `asset_code`, `bigint` value columns, and transfer support in the underlying wallet layer -- Keep `usage_credits` single-asset and backwards-compatible while allowing advanced wallet-level operations through `credit_wallet` +`usage_credits` is now built on top of [`wallets`](https://github.com/rameerez/wallets), our append-only, 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 FIFO ledger, balance math, row-level locking, and transfer machinery now live in a shared, independently tested core. + +### Added + +- New runtime dependency: `wallets` (`~> 0.2`), installed automatically with the gem +- Upgrade generator for existing installs: `rails generate usage_credits:upgrade` creates an in-place, re-runnable migration that preserves all existing ledger data. It pre-checks for duplicate owner wallets (possible under pre-1.0's lack of a uniqueness constraint) and aborts with actionable instructions *before* touching the schema if any exist +- Wallet-to-wallet credit transfers via the underlying wallets layer (`usage_credits_transfers` table), with expiration-preserving semantics by default +- `UsageCredits::Transfer` model, plus `transfer_in` / `transfer_out` transaction categories + +### Changed + +- **Schema** (handled by the upgrade migration for existing apps): wallets gain an `asset_code` column (default `"credits"`); 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 +- `UsageCredits::Wallet`, `Transaction`, and `Allocation` now subclass the `wallets` core models via its embeddability hooks (same tables as before, prefixed `usage_credits_`) +- 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 + +### Unchanged (backwards compatibility) + +- The entire public API: `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 all behave exactly as in 0.5.0 +- Negative balances still floor to zero in `credits` (the wallets core can represent overdrafts, but `usage_credits` keeps its historical contract) +- `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 + +### Upgrade instructions + +1. Update the gem, then run `rails generate usage_credits:upgrade` +2. Review the generated migration and **back up your database** (the migration is not reversible) +3. Deploy the gem update and `rails db:migrate` together — the 1.0 models expect the upgraded schema ## [0.5.0] - 2026-03-15 diff --git a/README.md b/README.md index 5397474..5c7a7d7 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ All with a simple DSL that reads just like English. - An ActiveJob backend (Sidekiq, `solid_queue`, etc.) for subscription credit fulfillment - [`pay`](https://github.com/pay-rails/pay) gem for Stripe/PayPal/Lemon Squeezy integration (sell credits, refill subscriptions) +- [`wallets`](https://github.com/rameerez/wallets) gem (installed automatically as a dependency — it's the ledger core `usage_credits` runs on) ## 👨‍💻 Example @@ -97,12 +98,23 @@ rails generate usage_credits:install rails db:migrate ``` -If you're upgrading an existing app from pre-1.0 `usage_credits`, run: +### Upgrading from pre-1.0 + +If you're upgrading an existing app from pre-1.0 `usage_credits`, run this instead of the install generator: ```bash rails generate usage_credits:upgrade rails db:migrate ``` +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: + +- Adds an `asset_code` column to wallets (defaults to `"credits"`, so nothing changes for you) +- 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) +- Widens integer amount columns to `bigint` +- Creates the `usage_credits_transfers` table that powers wallet-to-wallet transfers + +A few production notes: back up your database first (the migration is not reversible), expect the `bigint` column changes to take a while on very large tables, and deploy the gem update and the migration together (the 1.0 models expect the upgraded schema). Every step of the migration is guarded, so it's safe to re-run if it ever gets interrupted halfway. + Add `has_credits` your user model (or any model that needs to have credits): ```ruby class User < ApplicationRecord @@ -792,17 +804,21 @@ The ledger architecture gives you everything you'd want from a serious internal ### A note on multi-currency -`usage_credits` is intentionally **single-asset**. All amounts are stored as integers (for money, usually cents) to avoid floating-point issues. +`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. -If you need one wallet per currency or asset per user, use [`wallets`](https://github.com/rameerez/wallets): +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: ```ruby -user.wallet(:eur) -user.wallet(:usd) -user.wallet(:wood) +class User < ApplicationRecord + has_credits # user.credits, user.spend_credits_on(...) +end + +class Team < ApplicationRecord + has_wallets # team.wallet(:eur), team.wallet(:usd), team.wallet(:wood) +end ``` -That is now the dedicated gem for multi-asset support. +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. ### Naming your "credits" diff --git a/lib/usage_credits/models/concerns/has_wallet.rb b/lib/usage_credits/models/concerns/has_wallet.rb index bd2c3fc..cfb5afe 100644 --- a/lib/usage_credits/models/concerns/has_wallet.rb +++ b/lib/usage_credits/models/concerns/has_wallet.rb @@ -13,6 +13,12 @@ module HasWallet as: :owner, dependent: :destroy + # NOTE on the alias dance below: `credits_wallet` and `wallet` are aliased + # to the raw has_one reader *before* `credit_wallet` is redefined to + # auto-create missing wallets (see `define_method(:credit_wallet)` further + # down). So `user.credit_wallet` auto-creates, while `user.wallet` / + # `user.credits_wallet` just read. This asymmetry is the pre-1.0 contract, + # kept as-is for backwards compatibility. alias_method :credits_wallet, :credit_wallet alias_method :wallet, :credit_wallet diff --git a/lib/usage_credits/models/wallet.rb b/lib/usage_credits/models/wallet.rb index 99441e0..d596313 100644 --- a/lib/usage_credits/models/wallet.rb +++ b/lib/usage_credits/models/wallet.rb @@ -93,8 +93,8 @@ def credit_history def has_enough_credits_to?(operation_name, **params) operation = find_and_validate_operation(operation_name, params) credits >= operation.calculate_cost(params) - rescue InvalidOperation => e - raise e + rescue InvalidOperation + raise rescue StandardError => e raise InvalidOperation, "Error checking credits: #{e.message}" end @@ -103,8 +103,8 @@ def has_enough_credits_to?(operation_name, **params) def estimate_credits_to(operation_name, **params) operation = find_and_validate_operation(operation_name, params) operation.calculate_cost(params) - rescue InvalidOperation => e - raise e + rescue InvalidOperation + raise rescue StandardError => e raise InvalidOperation, "Error estimating cost: #{e.message}" end @@ -151,8 +151,6 @@ def spend_credits_on(operation_name, **params) else deduct_credits(cost, **deduct_params) end - rescue StandardError => e - raise e end # Give credits to the wallet with optional reason and expiration date From 10c386053c6e1fdf579df6c645f48e55fff8d16d Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:23:27 +0100 Subject: [PATCH 7/7] Bump stripe to ~> 19 in the pay 11 appraisals pay 11.6+ requires stripe ~> 19 at boot, but pay declares no gemspec dependency on stripe (processors are optional), so bundler happily resolves pay 11.6.1 with stripe 18 and the dummy app fails to boot. This is what broke the rails-7.2 / rails-8.1 / pay-11.0 CI cells once the bundler cache was invalidated: the pins predate pay 11.6. Whole matrix verified green locally against fresh lockfiles (pay 11.6.1 + stripe 19.2.0 in the pay 11 lanes). Co-Authored-By: Claude Fable 5 --- Appraisals | 6 +++--- gemfiles/pay_11.0.gemfile | 2 +- gemfiles/rails_7.2.gemfile | 2 +- gemfiles/rails_8.1.gemfile | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Appraisals b/Appraisals index 57cc332..f6f46c6 100644 --- a/Appraisals +++ b/Appraisals @@ -4,14 +4,14 @@ appraise "rails-7.2" do gem "rails", "~> 7.2.0" gem "pay", "~> 11.0" - gem "stripe", "~> 18.0" + gem "stripe", "~> 19.0" # pay >= 11.6 requires stripe ~> 19 at boot end # Test latest Rails version (with latest Pay) - this is the default/main Gemfile anyway appraise "rails-8.1" do gem "rails", "~> 8.1.0" gem "pay", "~> 11.0" - gem "stripe", "~> 18.0" + gem "stripe", "~> 19.0" # pay >= 11.6 requires stripe ~> 19 at boot end # Test minimum supported Pay version (with latest Rails) @@ -38,6 +38,6 @@ end # Test latest Pay version (with latest Rails) appraise "pay-11.0" do gem "pay", "~> 11.0" - gem "stripe", "~> 18.0" + gem "stripe", "~> 19.0" # pay >= 11.6 requires stripe ~> 19 at boot gem "rails", "~> 8.1.0" end diff --git a/gemfiles/pay_11.0.gemfile b/gemfiles/pay_11.0.gemfile index 0c4ef8f..4d7e8d8 100644 --- a/gemfiles/pay_11.0.gemfile +++ b/gemfiles/pay_11.0.gemfile @@ -4,7 +4,7 @@ source "https://rubygems.org" gem "rake", "~> 13.0" gem "pay", "~> 11.0" -gem "stripe", "~> 18.0" +gem "stripe", "~> 19.0" gem "rails", "~> 8.1.0" group :development do diff --git a/gemfiles/rails_7.2.gemfile b/gemfiles/rails_7.2.gemfile index af757d2..c71bb06 100644 --- a/gemfiles/rails_7.2.gemfile +++ b/gemfiles/rails_7.2.gemfile @@ -5,7 +5,7 @@ source "https://rubygems.org" gem "rake", "~> 13.0" gem "rails", "~> 7.2.0" gem "pay", "~> 11.0" -gem "stripe", "~> 18.0" +gem "stripe", "~> 19.0" group :development do gem "appraisal" diff --git a/gemfiles/rails_8.1.gemfile b/gemfiles/rails_8.1.gemfile index 1130f10..2824167 100644 --- a/gemfiles/rails_8.1.gemfile +++ b/gemfiles/rails_8.1.gemfile @@ -5,7 +5,7 @@ source "https://rubygems.org" gem "rake", "~> 13.0" gem "rails", "~> 8.1.0" gem "pay", "~> 11.0" -gem "stripe", "~> 18.0" +gem "stripe", "~> 19.0" group :development do gem "appraisal"