Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,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 template/schema
# drift is caught in the default matrix, not only adapter-specific jobs.
run: bundle exec rake db:migrate:reset test

- name: Upload test results
if: failure()
Expand Down
16 changes: 14 additions & 2 deletions Appraisals
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
# frozen_string_literal: true

appraise "rails-6.1" do
gem "rails", "~> 6.1.0"
end

appraise "rails-7.0" do
gem "rails", "~> 7.0.0"
end

appraise "rails-7.1" do
gem "rails", "~> 7.1.0"
end

appraise "rails-7.2" do
gem "rails", "~> 7.2.0"
end

appraise "rails-8.1" do
gem "rails", "~> 8.1.0"
appraise "rails-8.0" do
gem "rails", "~> 8.0.0"
end
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## [0.2.0] - 2026-03-18

- Add embeddability hooks so other gems, including `usage_credits`, can reuse the wallet ledger core with their own tables, callbacks, and configuration
- Widen stored ledger values to `bigint` and harden transfer/class isolation for coexistence in the same Rails app
- Expand the runtime test suite and documentation for production-ready internal balances, transfers, and multi-asset wallet use cases

## [0.1.0] - 2026-03-15

- Extract the neutral wallet ledger core from `usage_credits`
Expand Down
494 changes: 428 additions & 66 deletions README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class CreateWalletsTables < ActiveRecord::Migration<%= migration_version %>
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
Expand Down Expand Up @@ -60,10 +61,6 @@ class CreateWalletsTables < ActiveRecord::Migration<%= migration_version %>
add_index transactions_table, [:expires_at, :id], name: index_name("transactions_on_expires_at_and_id")
add_index allocations_table, [:transaction_id, :source_transaction_id], name: index_name("allocations_on_tx_and_source_tx")
add_index transfers_table, [:from_wallet_id, :to_wallet_id, :asset_code], name: index_name("transfers_on_wallets_and_asset")

# Add these after both tables exist to avoid circular foreign key creation.
add_reference transfers_table, :outbound_transaction, type: foreign_key_type, foreign_key: { to_table: transactions_table }
add_reference transfers_table, :inbound_transaction, type: foreign_key_type, foreign_key: { to_table: transactions_table }
end

private
Expand Down
11 changes: 11 additions & 0 deletions lib/wallets/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class Configuration
attr_accessor :allow_negative_balance
attr_reader :default_asset, :additional_categories, :table_prefix
attr_reader :low_balance_threshold
attr_reader :transfer_expiration_policy

# =========================================
# Lifecycle Callbacks
Expand All @@ -30,6 +31,7 @@ def initialize
@additional_categories = []
@allow_negative_balance = false
@low_balance_threshold = nil
@transfer_expiration_policy = :preserve
# This prefix is used by the models at runtime and by the install
# migration when it is executed for the first time.
@table_prefix = "wallets_"
Expand Down Expand Up @@ -71,6 +73,15 @@ def table_prefix=(value)
@table_prefix = value
end

def transfer_expiration_policy=(value)
normalized_value = value.to_s.strip.downcase.to_sym
allowed_values = %i[preserve none]

raise ArgumentError, "Transfer expiration policy must be one of: #{allowed_values.join(', ')}" unless allowed_values.include?(normalized_value)

@transfer_expiration_policy = normalized_value
end

def on_balance_credited(&block)
@on_balance_credited_callback = block
end
Expand Down
13 changes: 12 additions & 1 deletion lib/wallets/models/allocation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,20 @@ module Wallets
# Allocations link a negative spend transaction to the positive transactions it
# consumed from. This is what makes FIFO spending and expiration-aware balances
# possible without mutating historical transactions.
#
# This class supports embedding: subclasses can override config and table
# names without affecting the base Wallets::* behavior.
class Allocation < ApplicationRecord
class_attribute :embedded_table_name, default: nil
class_attribute :config_provider, default: -> { Wallets.configuration }

def self.table_name
"#{Wallets.configuration.table_prefix}allocations"
embedded_table_name || "#{resolved_config.table_prefix}allocations"
end

def self.resolved_config
value = config_provider
value.respond_to?(:call) ? value.call : value
end

belongs_to :spend_transaction, class_name: "Wallets::Transaction", foreign_key: "transaction_id"
Expand Down
32 changes: 24 additions & 8 deletions lib/wallets/models/transaction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,20 @@ module Wallets
# Transactions are the append-only source of truth for wallet balance changes.
# Positive rows add value, negative rows consume value, and transfers link both
# sides of an internal movement through `transfer_id`.
#
# This class supports embedding: subclasses can override config and table
# names without affecting the base Wallets::* behavior.
class Transaction < ApplicationRecord
class_attribute :embedded_table_name, default: nil
class_attribute :config_provider, default: -> { Wallets.configuration }

def self.table_name
"#{Wallets.configuration.table_prefix}transactions"
embedded_table_name || "#{resolved_config.table_prefix}transactions"
end

def self.resolved_config
value = config_provider
value.respond_to?(:call) ? value.call : value
end

DEFAULT_CATEGORIES = [
Expand All @@ -22,6 +33,17 @@ def self.table_name
].freeze
CATEGORIES = DEFAULT_CATEGORIES

def self.categories
extra_categories =
if resolved_config.respond_to?(:additional_categories)
resolved_config.additional_categories
else
[]
end

(DEFAULT_CATEGORIES + extra_categories).uniq
end

belongs_to :wallet, class_name: "Wallets::Wallet"
belongs_to :transfer, class_name: "Wallets::Transfer", optional: true

Expand All @@ -36,7 +58,7 @@ def self.table_name
dependent: :destroy

validates :amount, presence: true, numericality: { only_integer: true }
validates :category, presence: true, inclusion: { in: ->(_) { categories } }
validates :category, presence: true, inclusion: { in: ->(record) { record.class.categories } }
validate :remaining_amount_cannot_be_negative

before_save :sync_metadata_cache
Expand All @@ -48,10 +70,6 @@ def self.table_name
scope :not_expired, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
scope :expired, -> { where("expires_at < ?", Time.current) }

def self.categories
(DEFAULT_CATEGORIES + Wallets.configuration.additional_categories).uniq
end

def metadata
@indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
end
Expand Down Expand Up @@ -96,8 +114,6 @@ def remaining_amount
amount - allocated_amount
end

# When negative balances are allowed, a debit can exceed the currently
# available positive buckets. The unmatched portion remains "unbacked".
def unbacked_amount
return 0 unless debit?

Expand Down
72 changes: 58 additions & 14 deletions lib/wallets/models/transfer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,46 @@ module Wallets
# A transfer records an internal movement of value between two wallets of the
# same asset. The actual balance impact lives in the linked transactions on
# each side so the ledger remains append-only.
#
# Transfers keep the outbound leg singular and the inbound legs plural so the
# receiver can preserve the sender's expiration buckets when one transfer
# consumes multiple source transactions with different expirations.
class Transfer < ApplicationRecord
class_attribute :embedded_table_name, default: nil
class_attribute :config_provider, default: -> { Wallets.configuration }
class_attribute :transaction_class_name, default: "Wallets::Transaction"

SUPPORTED_EXPIRATION_POLICIES = %w[preserve none fixed].freeze

def self.table_name
"#{Wallets.configuration.table_prefix}transfers"
embedded_table_name || "#{resolved_config.table_prefix}transfers"
end

def self.resolved_config
value = config_provider
value.respond_to?(:call) ? value.call : value
end

def self.transaction_class
transaction_class_name.constantize
end

belongs_to :from_wallet, class_name: "Wallets::Wallet", inverse_of: :outgoing_transfers
belongs_to :to_wallet, class_name: "Wallets::Wallet", inverse_of: :incoming_transfers
belongs_to :outbound_transaction, class_name: "Wallets::Transaction", optional: true
belongs_to :inbound_transaction, class_name: "Wallets::Transaction", optional: true

has_many :transactions,
class_name: "Wallets::Transaction",
foreign_key: :transfer_id,
inverse_of: :transfer

validates :asset_code, presence: true
validates :amount, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :expiration_policy, presence: true, inclusion: { in: SUPPORTED_EXPIRATION_POLICIES }
validate :wallets_must_differ
validate :wallet_assets_match_transfer_asset
validate :linked_transactions_match_wallets

before_validation :normalize_asset_code!
before_validation :normalize_expiration_policy!
before_save :sync_metadata_cache

def metadata
Expand All @@ -37,12 +60,37 @@ def reload(*)
super
end

def outbound_transactions
transfer_transactions_for(wallet_id: from_wallet_id).where("amount < 0")
end

def outbound_transaction
outbound_transactions.order(:id).first
end

def inbound_transactions
transfer_transactions_for(wallet_id: to_wallet_id).where("amount > 0")
end

def inbound_transaction
records = inbound_transactions.order(:id).limit(2).to_a
records.one? ? records.first : nil
end

private

def transaction_class
self.class.transaction_class
end

def normalize_asset_code!
self.asset_code = asset_code.to_s.strip.downcase.presence
end

def normalize_expiration_policy!
self.expiration_policy = expiration_policy.to_s.strip.downcase.presence
end

def wallets_must_differ
return if from_wallet.blank? || to_wallet.blank?
return if from_wallet_id != to_wallet_id
Expand All @@ -57,22 +105,18 @@ def wallet_assets_match_transfer_asset
errors.add(:asset_code, "must match both wallets")
end

def linked_transactions_match_wallets
if outbound_transaction.present? && from_wallet.present? && outbound_transaction.wallet_id != from_wallet_id
errors.add(:outbound_transaction, "must belong to the source wallet")
end

if inbound_transaction.present? && to_wallet.present? && inbound_transaction.wallet_id != to_wallet_id
errors.add(:inbound_transaction, "must belong to the target wallet")
end
end

def sync_metadata_cache
if @indifferent_metadata
write_attribute(:metadata, @indifferent_metadata.to_h)
elsif read_attribute(:metadata).nil?
write_attribute(:metadata, {})
end
end

def transfer_transactions_for(wallet_id:)
return transaction_class.none unless persisted? && wallet_id.present?

transaction_class.where(transfer_id: id, wallet_id: wallet_id)
end
end
end
Loading
Loading