Skip to content
Open
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
7 changes: 6 additions & 1 deletion .simplecov
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ SimpleCov.start do
# Track coverage for the lib directory (gem source code)
add_filter "/test/"
add_filter "/lib/generators/"
# Scratch files generated by the install-generator tests.
add_filter "/tmp/"
# version.rb is loaded by the gemspec during Bundler setup, before SimpleCov
# starts, so it can never register hits.
add_filter "/lib/wallets/version.rb"

# Track Ruby files in lib directory
track_files "lib/**/*.rb"
Expand All @@ -18,7 +23,7 @@ SimpleCov.start do
enable_coverage :branch

# Keep the gate focused on the runtime wallet core, not install scaffolding.
minimum_coverage line: 80, branch: 50
minimum_coverage line: 98, branch: 85

# Disambiguate parallel test runs
command_name "Job #{ENV['TEST_ENV_NUMBER']}" if ENV['TEST_ENV_NUMBER']
Expand Down
16 changes: 2 additions & 14 deletions Appraisals
Original file line number Diff line number Diff line change
@@ -1,21 +1,9 @@
# 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.0" do
gem "rails", "~> 8.0.0"
appraise "rails-8.1" do
gem "rails", "~> 8.1.0"
end
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,40 @@
## [Unreleased]

### Fixed

- **Host app model shadowing.** The engine no longer adds the gem's `lib` directories to the host app's autoload paths. Those paths made Zeitwerk claim top-level constants like `::Wallet`, `::Transaction`, `::Transfer`, and `::Allocation` — so any host app with its own model by one of those (very common) names found it shadowed and unresolvable (`NameError: uninitialized constant Transaction`). All gem code is required eagerly by `lib/wallets.rb`, so the autoload paths were never needed.
- **Owners with transfer history could not be destroyed.** `Wallets::Transfer#transactions` now uses `dependent: :nullify`. Previously, destroying a wallet (or its owner) that had ever sent or received a transfer raised `ActiveRecord::InvalidForeignKey`: the transfer row was destroyed while the counterparty's ledger row still pointed at it. Now the link object is cleared while both sides' ledger rows — and the counterparty's balance — survive intact; transaction metadata still carries `transfer_id` and counterparty details for audit.
- **`create_for_owner!` race recovery now works on PostgreSQL.** The duplicate-insert rescue ran inside the same transaction as the failed `INSERT`. On PostgreSQL a unique-index violation aborts that transaction, so the recovery `SELECT` raised `PG::InFailedSqlTransaction` instead of returning the winner's wallet — precisely in the concurrent-creation scenario the rescue exists for. The create now writes through a savepoint (`requires_new: true`) and rescues outside it, which also keeps a caller's surrounding transaction (e.g. `after_create` wallet auto-creation) usable after a lost race.
- **`belongs_to` requiredness is now explicit.** The gem's models load before Rails applies `belongs_to_required_by_default`, so all ledger associations were silently optional: an orphan `Transaction`/`Allocation`/`Transfer` passed validation and crashed later with a database-level `NotNullViolation`. All required associations now declare `optional: false` and fail with friendly validation errors regardless of host app configuration or load order.
- **`has_wallets` options now reach subclasses.** Wallet options were stored in a class-level ivar that STI/inheritance never saw, so a subclass of a model with `has_wallets default_asset: :coins` silently fell back to the global default asset. Options are now resolved lazily through the ancestor chain; subclasses inherit their parent's declaration and can override it with their own `has_wallets`. Lazy resolution also means `Wallets.configuration.default_asset` is honored no matter when the host app's initializer ran relative to model loading.
- **Unknown callback events no longer break ledger writes.** `Wallets::Callbacks.dispatch` promised error isolation but raised `NoMethodError` (mid-transaction!) when handed an event with no matching `on_<event>_callback` reader — e.g. from an embedded subclass with a custom `callback_event_map` but the default callbacks module. Unknown events are now ignored.
- `transfer_to` raises a friendly `Wallets::InvalidTransfer` ("Source wallet must be persisted") instead of a confusing `ArgumentError` from internal lock ordering when called on an unpersisted wallet.
- Amount validation no longer leaks internal errors: `credit`/`debit`/`transfer_to` with `Float::INFINITY`, `Float::NAN`, or a non-numeric like a `Symbol` now raise `ArgumentError` (previously `FloatDomainError`/`NoMethodError`), and `has_enough_balance?` returns `false` for them instead of crashing.
- `expires_at` given as a `String` is now validated by parsing it (`"2030-01-01"` works; garbage raises a clear `ArgumentError`). Previously any string — valid or not — crashed with "comparison of String with Time failed".
- `wallet()` on an unsaved owner raises `Wallets::Error` instead of a bare `RuntimeError`, so `rescue Wallets::Error` catches everything the gem raises.
- `Transaction.expired` scope and `Transaction#expired?` now treat a transaction expiring exactly "now" as expired (`<=` instead of `<`), matching `not_expired` and the balance math, so the two scopes partition the ledger cleanly at any instant.
- The install initializer template now lists the gem's real default categories (it previously mentioned `:transfer` and `:expiration`, which don't exist).
- `Appraisals` was out of sync with `gemfiles/` and CI (it listed Rails 6.1–8.0; the tested matrix is Rails 7.2 and 8.1).

### Changed

- `Wallet#history` orders by `created_at` with `id` as tiebreaker, so same-instant transactions (e.g. both legs of a transfer) have a deterministic order.
- `transfer_to` opens its transaction via the wallet class (`self.class.transaction`) instead of `ActiveRecord::Base.transaction`, so embedded wallet subclasses connected to a different database transact on the right connection.
- Removed the redundant `Wallets::Railtie` (the engine already is a railtie; the extra one did nothing).
- `Wallets::Transfer` now validates `category` presence at the model layer instead of failing with a database `NOT NULL` violation.

### Added

- `Wallets.normalize_asset_code(value)` — the single source of truth for asset code normalization (`" EUR "`, `:EUR`, and `"eur"` all name the same wallet), used consistently across configuration, wallets, transfers, and owner lookups.
- `Wallets::Embeddable` concern — the embeddability plumbing (`embedded_table_name`, `config_provider`, `resolved_config`, prefix-derived table names) extracted from the four models into one place. Embedded subclasses without an explicit `embedded_table_name` derive `"#{config.table_prefix}#{table_suffix}"` automatically.
- `Wallets::HasMetadata` concern — the indifferent-access metadata behavior (hash coercion, mutation-safe saves, NULL-column healing for MySQL) extracted from the three metadata-carrying models into one place.

### Tests

- Test suite grew from 92 runs / 338 assertions to 186 runs / 667 assertions. Line coverage 93.45% → 100%, branch coverage 65.93% → 99.39%; the SimpleCov gate is raised to 98% line / 85% branch.
- New regression tests for every fix above, including a real duplicate-insert race executed inside a caller's transaction (exercises PostgreSQL savepoint semantics in CI), destroy cascades with transfer history, FIFO expiring-first allocation order, expiration boundary partitioning, amount/expiration edge cases, callback logging fallbacks, and STI wallet-option inheritance.
- The install generator now runs in tests, and its generated migration is executed (up and down) against the real database adapter in CI.

## [0.2.0] - 2026-05-03

### Fixed
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -799,8 +799,49 @@ What it does not do for you:

So the right framing is: strong internal wallet/accounting primitive, not money infrastructure by itself.

## Embedding `wallets` in your own gem

`wallets` is built to be embeddable: other gems can reuse the ledger core (FIFO allocation, locking, transfers, callbacks) on top of their **own** tables, config, and event names. This is exactly how [`usage_credits`](https://github.com/rameerez/usage_credits) works — its `UsageCredits::Wallet` is a `Wallets::Wallet` subclass living in `usage_credits_*` tables.

These class-level hooks are a supported, stable contract (covered by tests — breaking them is a breaking change for downstream gems):

```ruby
class MyGem::Wallet < Wallets::Wallet
self.embedded_table_name = "my_gem_wallets" # your table, not wallets_wallets
self.config_provider = -> { MyGem.configuration } # your config object
self.callbacks_module = MyGem::Callbacks # your callback dispatcher
self.transaction_class_name = "MyGem::Transaction" # your subclasses
self.allocation_class_name = "MyGem::Allocation"
self.transfer_class_name = "MyGem::Transfer"

# Rename (or silence, with nil) core events for your domain:
self.callback_event_map = {
credited: :coins_added,
debited: :coins_spent,
insufficient: :not_enough_coins,
low_balance: :low_balance,
depleted: :out_of_coins,
transfer_completed: nil # nil = don't dispatch this event
}.freeze

class << self
private

# Customize the ledger entry recorded for `create_for_owner!(initial_balance:)`
def initial_balance_credit_attributes
{ category: :starting_coins, metadata: { reason: "initial_balance" } }
end
end
end
```

Your `Transaction`, `Allocation`, and `Transfer` subclasses set `embedded_table_name` (and `config_provider` / `transaction_class_name` where relevant) the same way.

One current limitation to be aware of: the core models declare their associations against the `Wallets::*` class names, so your subclasses should **re-declare associations** with your own classes (`belongs_to :wallet, class_name: "MyGem::Wallet"`, etc.) so that records load as your subclasses rather than the core ones. See `usage_credits`' models for the canonical embedding pattern.

## TODO

- Dynamic association class resolution for embedded subclasses (so embedders don't need to re-declare associations)
- First-class transfer reversal/refund API built on compensating ledger entries
- Optional pending/held balance primitives for escrow-like flows
- Multi-step transfer policies beyond `:preserve`, `:none`, and fixed `expires_at`
Expand Down
3 changes: 2 additions & 1 deletion lib/generators/wallets/templates/initializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@

# Extra business event labels so the ledger stays readable.
# These extend the built-in defaults:
# :credit, :debit, :transfer, :expiration, :adjustment
# :credit, :debit, :transfer_in, :transfer_out, :refund,
# :reward, :purchase, :top_up, :adjustment
#
# config.additional_categories = %w[
# ride_fare
Expand Down
11 changes: 9 additions & 2 deletions lib/wallets.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,21 @@ def configure
def reset!
@configuration = nil
end

# Single source of truth for asset code normalization:
# " EUR ", :EUR, and "eur" all name the same wallet.
def normalize_asset_code(value)
value.to_s.strip.downcase
end
end
end

require "wallets/models/concerns/embeddable"
require "wallets/models/concerns/has_metadata"
require "wallets/models/concerns/has_wallets"
require "wallets/models/wallet"
require "wallets/models/transaction"
require "wallets/models/allocation"
require "wallets/models/transfer"

require "wallets/engine" if defined?(Rails)
require "wallets/railtie" if defined?(Rails)
require "wallets/engine"
21 changes: 13 additions & 8 deletions lib/wallets/callbacks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ module Callbacks
module_function

def dispatch(event, **context_data)
callback = Wallets.configuration.public_send(:"on_#{event}_callback")
reader = :"on_#{event}_callback"
return unless Wallets.configuration.respond_to?(reader)

callback = Wallets.configuration.public_send(reader)
return unless callback.is_a?(Proc)

context = CallbackContext.new(event: event, **context_data)
Expand All @@ -29,25 +32,27 @@ def execute_safely(callback, context)
end

def log_error(message)
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
Rails.logger.error(message)
if rails_logger
rails_logger.error(message)
else
warn message
end
end

def log_warn(message)
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
Rails.logger.warn(message)
if rails_logger
rails_logger.warn(message)
else
warn message
end
end

def log_debug(message)
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger&.debug?
Rails.logger.debug(message)
end
rails_logger.debug(message) if rails_logger&.debug?
end

def rails_logger
Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)
end
end
end
6 changes: 1 addition & 5 deletions lib/wallets/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def initialize
end

def default_asset=(value)
value = normalize_asset_code(value)
value = Wallets.normalize_asset_code(value)
raise ArgumentError, "Default asset can't be blank" if value.blank?

@default_asset = value.to_sym
Expand Down Expand Up @@ -108,10 +108,6 @@ def on_insufficient_balance(&block)

private

def normalize_asset_code(value)
value.to_s.strip.downcase
end

def normalize_category(value)
value.to_s.strip
end
Expand Down
14 changes: 5 additions & 9 deletions lib/wallets/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,11 @@ module Wallets
class Engine < ::Rails::Engine
isolate_namespace Wallets

# Load wallet models early so host apps can reference them during boot.
config.autoload_paths << File.expand_path("models", __dir__)
config.autoload_paths << File.expand_path("models/concerns", __dir__)

initializer "wallets.autoload", before: :set_autoload_paths do |app|
app.config.autoload_paths << root.join("lib")
app.config.autoload_paths << root.join("lib/wallets/models")
app.config.autoload_paths << root.join("lib/wallets/models/concerns")
end
# All gem code is required eagerly by lib/wallets.rb, so nothing here is
# added to the host app's autoload paths. Registering lib/wallets/models
# with Zeitwerk would claim top-level constants like ::Wallet and
# ::Transaction and shadow (break) host apps that define models with
# those very common names.

initializer "wallets.active_record" do
ActiveSupport.on_load(:active_record) do
Expand Down
18 changes: 6 additions & 12 deletions lib/wallets/models/allocation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,14 @@ module Wallets
# 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 }
include Wallets::Embeddable

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

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

belongs_to :spend_transaction, class_name: "Wallets::Transaction", foreign_key: "transaction_id"
belongs_to :source_transaction, class_name: "Wallets::Transaction"
# Explicit `optional: false` because the gem's models load before Rails
# applies `belongs_to_required_by_default`.
belongs_to :spend_transaction, class_name: "Wallets::Transaction", foreign_key: "transaction_id", optional: false
belongs_to :source_transaction, class_name: "Wallets::Transaction", optional: false

validates :amount, presence: true, numericality: { only_integer: true, greater_than: 0 }
validate :source_transaction_has_matching_asset
Expand Down
36 changes: 36 additions & 0 deletions lib/wallets/models/concerns/embeddable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# frozen_string_literal: true

module Wallets
# Lets other gems (like usage_credits) subclass the ledger models with their
# own tables, configuration, and callbacks without touching global Wallets
# state in the same application.
#
# The embedding contract on a subclass:
#
# self.embedded_table_name = "usage_credits_wallets" # exact table name
# self.config_provider = -> { UsageCredits.configuration } # custom config
#
# When `embedded_table_name` is not set, the table name is derived from the
# provided config's `table_prefix` plus the model's `table_suffix`
# ("wallets", "transactions", "allocations", or "transfers").
module Embeddable
extend ActiveSupport::Concern

included do
class_attribute :embedded_table_name, default: nil
class_attribute :config_provider, default: -> { Wallets.configuration }
class_attribute :table_suffix, default: nil, instance_accessor: false
end

class_methods do
def table_name
embedded_table_name || "#{resolved_config.table_prefix}#{table_suffix}"
end

def resolved_config
value = config_provider
value.respond_to?(:call) ? value.call : value
end
end
end
end
41 changes: 41 additions & 0 deletions lib/wallets/models/concerns/has_metadata.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

module Wallets
# Shared metadata behavior for ledger records: metadata always reads as a
# hash with indifferent access, accepts anything hash-like on assignment,
# and in-place mutations survive `save`.
#
# MySQL cannot give JSON columns a default, so a NULL column is treated the
# same as an empty hash everywhere.
module HasMetadata
extend ActiveSupport::Concern

included do
before_save :sync_metadata_cache
end

def metadata
@indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
end

def metadata=(hash)
@indifferent_metadata = nil
super(hash.respond_to?(:to_h) ? hash.to_h : {})
end

def reload(*)
@indifferent_metadata = nil
super
end

private

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