diff --git a/.simplecov b/.simplecov index 4572b50..9ef95c3 100644 --- a/.simplecov +++ b/.simplecov @@ -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" @@ -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'] diff --git a/Appraisals b/Appraisals index 5bd1904..7250474 100644 --- a/Appraisals +++ b/Appraisals @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b26999..564e4a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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__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 diff --git a/README.md b/README.md index add5db9..8491ccc 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/lib/generators/wallets/templates/initializer.rb b/lib/generators/wallets/templates/initializer.rb index 7701660..dba12a9 100644 --- a/lib/generators/wallets/templates/initializer.rb +++ b/lib/generators/wallets/templates/initializer.rb @@ -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 diff --git a/lib/wallets.rb b/lib/wallets.rb index f05e62c..a5bc015 100644 --- a/lib/wallets.rb +++ b/lib/wallets.rb @@ -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" diff --git a/lib/wallets/callbacks.rb b/lib/wallets/callbacks.rb index 19ab6c0..94bfe91 100644 --- a/lib/wallets/callbacks.rb +++ b/lib/wallets/callbacks.rb @@ -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) @@ -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 diff --git a/lib/wallets/configuration.rb b/lib/wallets/configuration.rb index 4e34b26..6d625c7 100644 --- a/lib/wallets/configuration.rb +++ b/lib/wallets/configuration.rb @@ -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 @@ -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 diff --git a/lib/wallets/engine.rb b/lib/wallets/engine.rb index 8b299c4..2395cbb 100644 --- a/lib/wallets/engine.rb +++ b/lib/wallets/engine.rb @@ -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 diff --git a/lib/wallets/models/allocation.rb b/lib/wallets/models/allocation.rb index 442bc98..2f6befc 100644 --- a/lib/wallets/models/allocation.rb +++ b/lib/wallets/models/allocation.rb @@ -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 diff --git a/lib/wallets/models/concerns/embeddable.rb b/lib/wallets/models/concerns/embeddable.rb new file mode 100644 index 0000000..e8cccce --- /dev/null +++ b/lib/wallets/models/concerns/embeddable.rb @@ -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 diff --git a/lib/wallets/models/concerns/has_metadata.rb b/lib/wallets/models/concerns/has_metadata.rb new file mode 100644 index 0000000..f28ae43 --- /dev/null +++ b/lib/wallets/models/concerns/has_metadata.rb @@ -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 diff --git a/lib/wallets/models/concerns/has_wallets.rb b/lib/wallets/models/concerns/has_wallets.rb index c6c5ef4..297b512 100644 --- a/lib/wallets/models/concerns/has_wallets.rb +++ b/lib/wallets/models/concerns/has_wallets.rb @@ -11,19 +11,30 @@ module HasWallets def has_wallets(**options) include Wallets::HasWallets unless included_modules.include?(Wallets::HasWallets) - @wallet_options = { - default_asset: Wallets.configuration.default_asset, - auto_create: true, - initial_balance: 0 - }.merge(options) + @wallet_options = options end + # Resolved lazily on every call so subclasses inherit their parent's + # `has_wallets` declaration and so `Wallets.configuration.default_asset` + # is honored no matter when the host app's initializer ran. def wallet_options - @wallet_options ||= { + { default_asset: Wallets.configuration.default_asset, auto_create: true, initial_balance: 0 - } + }.merge(declared_wallet_options) + end + + private + + def declared_wallet_options + if defined?(@wallet_options) && @wallet_options + @wallet_options + elsif superclass.respond_to?(:declared_wallet_options, true) + superclass.send(:declared_wallet_options) + else + {} + end end end @@ -45,7 +56,7 @@ def wallet(asset_code = nil) end def wallet?(asset_code = nil) - find_wallet(asset_code || wallet_options[:default_asset]).present? + find_wallet(asset_code).present? end def main_wallet @@ -53,7 +64,7 @@ def main_wallet end def find_wallet(asset_code = nil) - normalized_asset_code = normalize_asset_code(asset_code || wallet_options[:default_asset]) + normalized_asset_code = Wallets.normalize_asset_code(asset_code || wallet_options[:default_asset]) wallets.find_by(asset_code: normalized_asset_code) end @@ -67,7 +78,7 @@ def ensure_wallet(asset_code) existing_wallet = find_wallet(asset_code) return existing_wallet if existing_wallet.present? return unless should_auto_create_wallet? - raise "Cannot create wallet for unsaved owner" unless persisted? + raise Wallets::Error, "Cannot create wallet for unsaved owner" unless persisted? Wallet.create_for_owner!( owner: self, @@ -80,12 +91,8 @@ def create_main_wallet main_wallet end - def normalize_asset_code(value) - value.to_s.strip.downcase - end - def initial_balance_for(asset_code) - return 0 unless normalize_asset_code(asset_code) == normalize_asset_code(wallet_options[:default_asset]) + return 0 unless Wallets.normalize_asset_code(asset_code) == Wallets.normalize_asset_code(wallet_options[:default_asset]) wallet_options[:initial_balance] || 0 end diff --git a/lib/wallets/models/transaction.rb b/lib/wallets/models/transaction.rb index 4f16dfd..712d3e4 100644 --- a/lib/wallets/models/transaction.rb +++ b/lib/wallets/models/transaction.rb @@ -8,17 +8,10 @@ module Wallets # 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 } + include Wallets::Embeddable + include Wallets::HasMetadata - def self.table_name - embedded_table_name || "#{resolved_config.table_prefix}transactions" - end - - def self.resolved_config - value = config_provider - value.respond_to?(:call) ? value.call : value - end + self.table_suffix = "transactions" DEFAULT_CATEGORIES = [ "credit", @@ -44,7 +37,9 @@ def self.categories (DEFAULT_CATEGORIES + extra_categories).uniq end - belongs_to :wallet, class_name: "Wallets::Wallet" + # Explicit `optional:` flags because the gem's models load before Rails + # applies `belongs_to_required_by_default`. + belongs_to :wallet, class_name: "Wallets::Wallet", optional: false belongs_to :transfer, class_name: "Wallets::Transfer", optional: true has_many :outgoing_allocations, @@ -61,35 +56,22 @@ def self.categories validates :category, presence: true, inclusion: { in: ->(record) { record.class.categories } } validate :remaining_amount_cannot_be_negative - before_save :sync_metadata_cache - scope :credits, -> { where("amount > 0") } scope :debits, -> { where("amount < 0") } scope :recent, -> { order(created_at: :desc) } scope :by_category, ->(category) { where(category: category) } + # `not_expired` and `expired` partition all transactions at any instant: + # a transaction expiring exactly "now" is already expired, matching the + # balance math, which only counts buckets that are strictly still alive. scope :not_expired, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) } - scope :expired, -> { where("expires_at < ?", Time.current) } - - 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 + scope :expired, -> { where("expires_at <= ?", Time.current) } def owner wallet.owner end def expired? - expires_at.present? && expires_at < Time.current + expires_at.present? && expires_at <= Time.current end def credit? @@ -137,14 +119,6 @@ def sync_balance_snapshot!(before:, after:) 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 - def remaining_amount_cannot_be_negative if credit? && remaining_amount.negative? errors.add(:base, "Allocated amount exceeds transaction amount") diff --git a/lib/wallets/models/transfer.rb b/lib/wallets/models/transfer.rb index 1f56917..b9d0a81 100644 --- a/lib/wallets/models/transfer.rb +++ b/lib/wallets/models/transfer.rb @@ -9,59 +9,46 @@ module Wallets # 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" + include Wallets::Embeddable + include Wallets::HasMetadata - SUPPORTED_EXPIRATION_POLICIES = %w[preserve none fixed].freeze + self.table_suffix = "transfers" - def self.table_name - embedded_table_name || "#{resolved_config.table_prefix}transfers" - end + class_attribute :transaction_class_name, default: "Wallets::Transaction" - def self.resolved_config - value = config_provider - value.respond_to?(:call) ? value.call : value - end + SUPPORTED_EXPIRATION_POLICIES = %w[preserve none fixed].freeze 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 + # Explicit `optional: false` because the gem's models load before Rails + # applies `belongs_to_required_by_default`. + belongs_to :from_wallet, class_name: "Wallets::Wallet", inverse_of: :outgoing_transfers, optional: false + belongs_to :to_wallet, class_name: "Wallets::Wallet", inverse_of: :incoming_transfers, optional: false + # When a transfer record goes away (e.g. one side's wallet or owner is + # destroyed), the counterparty's ledger rows must survive: only the link + # is cleared. The transaction metadata still carries `transfer_id` and the + # counterparty details for audit purposes. has_many :transactions, class_name: "Wallets::Transaction", foreign_key: :transfer_id, - inverse_of: :transfer + inverse_of: :transfer, + dependent: :nullify validates :asset_code, presence: true validates :amount, presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :category, presence: true validates :expiration_policy, presence: true, inclusion: { in: SUPPORTED_EXPIRATION_POLICIES } validate :wallets_must_differ validate :wallet_assets_match_transfer_asset before_validation :normalize_asset_code! before_validation :normalize_expiration_policy! - before_save :sync_metadata_cache - - 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 def outbound_transactions - transfer_transactions_for(wallet_id: from_wallet_id).where("amount < 0") + transfer_transactions_for(wallet_id: from_wallet_id).debits end def outbound_transaction @@ -69,7 +56,7 @@ def outbound_transaction end def inbound_transactions - transfer_transactions_for(wallet_id: to_wallet_id).where("amount > 0") + transfer_transactions_for(wallet_id: to_wallet_id).credits end def inbound_transaction @@ -84,7 +71,7 @@ def transaction_class end def normalize_asset_code! - self.asset_code = asset_code.to_s.strip.downcase.presence + self.asset_code = Wallets.normalize_asset_code(asset_code).presence end def normalize_expiration_policy! @@ -105,14 +92,6 @@ def wallet_assets_match_transfer_asset errors.add(:asset_code, "must match both wallets") 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? diff --git a/lib/wallets/models/wallet.rb b/lib/wallets/models/wallet.rb index 5c4fba6..1d4f763 100644 --- a/lib/wallets/models/wallet.rb +++ b/lib/wallets/models/wallet.rb @@ -12,12 +12,15 @@ module Wallets # table names, and related model classes without affecting the base Wallets::* # behavior in the same application. class Wallet < ApplicationRecord + include Wallets::Embeddable + include Wallets::HasMetadata + + self.table_suffix = "wallets" + # ========================================= # Embeddability Hooks # ========================================= - class_attribute :embedded_table_name, default: nil - class_attribute :config_provider, default: -> { Wallets.configuration } class_attribute :callbacks_module, default: Wallets::Callbacks class_attribute :transaction_class_name, default: "Wallets::Transaction" class_attribute :allocation_class_name, default: "Wallets::Allocation" @@ -31,19 +34,6 @@ class Wallet < ApplicationRecord transfer_completed: :transfer_completed }.freeze - # ========================================= - # Table Name Resolution - # ========================================= - - def self.table_name - embedded_table_name || "#{resolved_config.table_prefix}wallets" - 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 @@ -60,7 +50,10 @@ def self.transfer_class # Associations & Validations # ========================================= - belongs_to :owner, polymorphic: true + # `optional: false` is explicit everywhere because the gem's models load + # before Rails applies `belongs_to_required_by_default`, so the host app's + # default never reaches these associations. + belongs_to :owner, polymorphic: true, optional: false has_many :transactions, class_name: "Wallets::Transaction", dependent: :destroy has_many :outgoing_transfers, @@ -79,7 +72,6 @@ def self.transfer_class validates :balance, numericality: { greater_than_or_equal_to: 0 }, unless: :allow_negative_balance? before_validation :normalize_asset_code! - before_save :sync_metadata_cache # ========================================= # Class Methods @@ -89,33 +81,35 @@ class << self def create_for_owner!(owner:, asset_code:, initial_balance: 0, metadata: {}) initial_balance = normalize_initial_balance(initial_balance) asset_code = normalize_asset_code(asset_code) - metadata = metadata.respond_to?(:to_h) ? metadata.to_h : {} existing_wallet = find_by(owner: owner, asset_code: asset_code) return existing_wallet if existing_wallet.present? - transaction do - wallet = create!( - owner: owner, - asset_code: asset_code, - balance: 0, - metadata: metadata - ) + # `requires_new` gives us a savepoint when we are already inside a + # caller's transaction (e.g. `after_create` wallet auto-creation), and + # the rescue lives OUTSIDE the transaction block. Both matter on + # PostgreSQL: a concurrent duplicate INSERT aborts the transaction it + # ran in, so recovering with a SELECT only works after that + # transaction (or savepoint) has rolled back. + begin + transaction(requires_new: true) do + wallet = create!( + owner: owner, + asset_code: asset_code, + balance: 0, + metadata: metadata + ) + + wallet.credit(initial_balance, **initial_balance_credit_attributes) if initial_balance.positive? - if initial_balance.positive? - wallet.credit(initial_balance, **initial_balance_credit_attributes) + wallet end - - wallet rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => error - wallet = find_by(owner: owner, asset_code: asset_code) - raise error if wallet.nil? + raise error unless record_conflict_due_to_existing_wallet?(error) - if record_conflict_due_to_existing_wallet?(error) - wallet - else - raise error - end + # Two callers raced to create the same wallet; the loser gracefully + # returns the winner's row. + find_by(owner: owner, asset_code: asset_code) || raise(error) end end @@ -130,7 +124,9 @@ def initial_balance_credit_attributes def normalize_initial_balance(value) return 0 if value.nil? - raise ArgumentError, "Initial balance must be a whole number" unless value == value.to_i + + whole = (value == value.to_i rescue false) + raise ArgumentError, "Initial balance must be a whole number" unless whole value = value.to_i raise ArgumentError, "Initial balance cannot be negative" if value.negative? @@ -139,35 +135,16 @@ def normalize_initial_balance(value) end def normalize_asset_code(value) - value.to_s.strip.downcase.presence || raise(ArgumentError, "Asset code is required") + Wallets.normalize_asset_code(value).presence || raise(ArgumentError, "Asset code is required") end def record_conflict_due_to_existing_wallet?(error) return true if error.is_a?(ActiveRecord::RecordNotUnique) - return false unless error.is_a?(ActiveRecord::RecordInvalid) error.record.errors.of_kind?(:asset_code, :taken) end end - # ========================================= - # Metadata Handling - # ========================================= - - 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 - # ========================================= # Balance & History # ========================================= @@ -181,7 +158,7 @@ def current_balance end def history - transactions.order(created_at: :asc) + transactions.order(created_at: :asc, id: :asc) end def has_enough_balance?(amount) @@ -230,6 +207,7 @@ def debit(amount, metadata: {}, category: :debit, transfer: nil, **extra_transac def transfer_to(other_wallet, amount, category: :transfer, metadata: {}, expiration_policy: nil, expires_at: nil) raise InvalidTransfer, "Target wallet is required" if other_wallet.nil? + raise InvalidTransfer, "Source wallet must be persisted" unless persisted? raise InvalidTransfer, "Target wallet must be persisted" unless other_wallet.persisted? raise InvalidTransfer, "Cannot transfer to the same wallet" if other_wallet.id == id raise InvalidTransfer, "Wallet assets must match" unless asset_code == other_wallet.asset_code @@ -239,7 +217,7 @@ def transfer_to(other_wallet, amount, category: :transfer, metadata: {}, expirat metadata = normalize_metadata(metadata) resolved_policy, inbound_expires_at = resolve_transfer_expiration!(expiration_policy, expires_at) - ActiveRecord::Base.transaction do + self.class.transaction do lock_wallet_pair!(other_wallet) previous_balance = balance @@ -466,9 +444,12 @@ def apply_debit(amount, metadata:, category:, transfer:, extra_attributes: {}) def allocate_debit!(spend_transaction, amount) remaining_to_allocate = amount + # Soonest-expiring buckets are consumed first so expirable value never + # sits unused behind evergreen value; ties (including all-evergreen + # wallets) fall back to oldest-first. positive_transactions = transactions - .where("amount > 0") - .where("expires_at IS NULL OR expires_at > ?", Time.current) + .credits + .not_expired .order(Arel.sql("COALESCE(expires_at, '9999-12-31 23:59:59'), id ASC")) .lock("FOR UPDATE") .to_a @@ -573,8 +554,8 @@ def positive_remaining_balance alloc_table = allocation_class.table_name transactions - .where("amount > 0") - .where("expires_at IS NULL OR expires_at > ?", Time.current) + .credits + .not_expired .sum("amount - (SELECT COALESCE(SUM(amount), 0) FROM #{alloc_table} WHERE source_transaction_id = #{txn_table}.id)") .to_i end @@ -584,7 +565,7 @@ def unbacked_negative_balance alloc_table = allocation_class.table_name transactions - .where("amount < 0") + .debits .sum("ABS(amount) - (SELECT COALESCE(SUM(amount), 0) FROM #{alloc_table} WHERE transaction_id = #{txn_table}.id)") .to_i end @@ -665,12 +646,21 @@ def allow_negative_balance? def validate_expiration!(expires_at) return if expires_at.nil? raise ArgumentError, "Expiration date must respond to to_datetime" unless expires_at.respond_to?(:to_datetime) - raise ArgumentError, "Expiration date must be in the future" if expires_at <= Time.current + + expiration = begin + expires_at.to_datetime + rescue StandardError + raise ArgumentError, "Expiration date must be a valid date or time" + end + + raise ArgumentError, "Expiration date must be in the future" if expiration <= Time.current end def normalize_positive_amount!(amount) raise ArgumentError, "Amount is required" if amount.nil? - raise ArgumentError, "Amount must be a whole number" unless amount == amount.to_i + + whole = (amount == amount.to_i rescue false) + raise ArgumentError, "Amount must be a whole number" unless whole amount = amount.to_i raise ArgumentError, "Amount must be positive" unless amount.positive? @@ -689,15 +679,7 @@ def lock_wallet_pair!(other_wallet) end def normalize_asset_code! - self.asset_code = asset_code.to_s.strip.downcase.presence - end - - def sync_metadata_cache - if @indifferent_metadata - write_attribute(:metadata, @indifferent_metadata.to_h) - elsif read_attribute(:metadata).nil? - write_attribute(:metadata, {}) - end + self.asset_code = Wallets.normalize_asset_code(asset_code).presence end end end diff --git a/lib/wallets/railtie.rb b/lib/wallets/railtie.rb deleted file mode 100644 index 2038749..0000000 --- a/lib/wallets/railtie.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -module Wallets - class Railtie < Rails::Railtie - railtie_name :wallets - end -end diff --git a/test/generators/install_generator_test.rb b/test/generators/install_generator_test.rb new file mode 100644 index 0000000..bf9a8ef --- /dev/null +++ b/test/generators/install_generator_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "test_helper" +require "rails/generators" +require "rails/generators/test_case" +require "generators/wallets/install_generator" + +class Wallets::InstallGeneratorTest < Rails::Generators::TestCase + tests Wallets::Generators::InstallGenerator + destination File.expand_path("../../tmp/generator_sandbox", __dir__) + setup :prepare_destination + + # The migration-execution test issues real DDL. On MySQL, DDL implicitly + # commits the surrounding transaction, which would corrupt transactional + # fixtures — so this class runs without them. + self.use_transactional_tests = false + + test "creates the migration and the initializer" do + run_generator + + assert_migration "db/migrate/create_wallets_tables.rb" do |migration| + assert_match(/class CreateWalletsTables < ActiveRecord::Migration\[\d+\.\d+\]/, migration) + assert_match(/create_table wallets_table/, migration) + assert_match(/Wallets\.configuration\.table_prefix/, migration) + end + + assert_file "config/initializers/wallets.rb" do |initializer| + assert_match(/Wallets\.configure do \|config\|/, initializer) + assert_match(/config\.default_asset = :credits/, initializer) + end + end + + test "generated migration creates and drops all four tables against a real database" do + run_generator + migration_path = Dir.glob(File.join(destination_root, "db/migrate/*_create_wallets_tables.rb")).sole + + Wallets.configuration.table_prefix = "gencheck_" + tables = %w[gencheck_wallets gencheck_transfers gencheck_transactions gencheck_allocations] + connection = ActiveRecord::Base.connection + + begin + # Load the migration under a sandbox module so the test never touches + # the global CreateWalletsTables constant. + sandbox = Module.new + load migration_path, sandbox + migration = sandbox.const_get(:CreateWalletsTables).new + + ActiveRecord::Migration.suppress_messages do + migration.migrate(:up) + tables.each { |table| assert connection.data_source_exists?(table), "expected #{table} to be created" } + assert connection.indexes("gencheck_transactions").any?, "expected indexes on gencheck_transactions" + assert connection.indexes("gencheck_transfers").any?, "expected indexes on gencheck_transfers" + + migration.migrate(:down) + tables.each { |table| refute connection.data_source_exists?(table), "expected #{table} to be dropped" } + end + ensure + ActiveRecord::Migration.suppress_messages do + tables.reverse_each { |table| connection.drop_table(table, if_exists: true) } + end + Wallets.configuration.table_prefix = "wallets_" + end + end +end diff --git a/test/integration/embeddability_test.rb b/test/integration/embeddability_test.rb index eca9a8d..57582f2 100644 --- a/test/integration/embeddability_test.rb +++ b/test/integration/embeddability_test.rb @@ -211,7 +211,8 @@ class EmbeddedTransfer < Wallets::Transfer has_many :transactions, class_name: "EmbeddabilityTest::EmbeddedTransaction", foreign_key: :transfer_id, - inverse_of: :transfer + inverse_of: :transfer, + dependent: :nullify end ensure_embedded_tables! @@ -236,6 +237,46 @@ class EmbeddedTransfer < Wallets::Transfer assert_equal Wallets::Callbacks, Wallets::Wallet.callbacks_module end + test "embedded table names derive from config prefix and model suffix when not set explicitly" do + derived_wallet_class = Class.new(Wallets::Wallet) do + self.config_provider = -> { EmbeddabilityTest.embedded_config } + end + derived_transaction_class = Class.new(Wallets::Transaction) do + self.config_provider = -> { EmbeddabilityTest.embedded_config } + end + + assert_equal "embedded_wallets", derived_wallet_class.table_name + assert_equal "embedded_transactions", derived_transaction_class.table_name + end + + test "config_provider accepts a plain config object as well as a callable" do + config_instance = EmbeddedConfig.new + direct_config_class = Class.new(Wallets::Wallet) + direct_config_class.config_provider = config_instance + + assert_same config_instance, direct_config_class.resolved_config + assert_equal "embedded_wallets", direct_config_class.table_name + end + + test "events missing from the callback event map are silently skipped" do + silent_wallet_class = Class.new(EmbeddedWallet) do + def self.name + "EmbeddabilityTest::SilentWallet" + end + + self.callback_event_map = { debited: :embedded_debited }.freeze + end + + wallet = silent_wallet_class.create_for_owner!(owner: users(:new_user), asset_code: :silent_asset) + wallet.credit(50, category: :embedded_reward) + + assert_empty EmbeddedCallbacks.events, "unmapped credited event must not dispatch" + + wallet.debit(10, category: :embedded_charge) + + assert_equal [:embedded_debited], EmbeddedCallbacks.events.map { |event| event[:event] } + end + test "embedded table names are used at runtime" do wallet = nil diff --git a/test/integration/wallet_callbacks_test.rb b/test/integration/wallet_callbacks_test.rb index 7de27b5..ff1bcd4 100644 --- a/test/integration/wallet_callbacks_test.rb +++ b/test/integration/wallet_callbacks_test.rb @@ -198,4 +198,49 @@ class WalletCallbacksTest < ActiveSupport::TestCase assert_equal 5, events.first.metadata[:available] assert_equal 10, events.first.metadata[:required] end + + test "a transfer dispatches debited, credited, and transfer_completed callbacks in order" do + events = [] + Wallets.configure do |config| + config.on_balance_debited { |ctx| events << [:debited, ctx] } + config.on_balance_credited { |ctx| events << [:credited, ctx] } + config.on_transfer_completed { |ctx| events << [:completed, ctx] } + end + + transfer = wallets_wallets(:rich_coins_wallet).transfer_to( + wallets_wallets(:peer_coins_wallet), + 50, + category: :peer_payment + ) + + assert_equal [:debited, :credited, :completed], events.map(&:first) + + debited_context = events[0].last + assert_equal :transfer_out, debited_context.category + assert_equal "peer_payment", debited_context.metadata["transfer_category"] + assert_equal wallets_wallets(:rich_coins_wallet).id, debited_context.wallet.id + + credited_context = events[1].last + assert_equal :transfer_in, credited_context.category + assert_equal wallets_wallets(:peer_coins_wallet).id, credited_context.wallet.id + + completed_context = events[2].last + assert_equal transfer.id, completed_context.transfer.id + assert_equal 50, completed_context.amount + end + + test "transfer legs record balance snapshots on both wallets" do + sender = create_wallet(users(:new_user), asset_code: :snap, initial_balance: 100) + recipient = create_wallet(users(:peer_user), asset_code: :snap, initial_balance: 7) + + transfer = sender.transfer_to(recipient, 40, category: :gift) + + outbound = transfer.outbound_transaction + inbound = transfer.inbound_transactions.sole + + assert_equal 100, outbound.balance_before + assert_equal 60, outbound.balance_after + assert_equal 7, inbound.balance_before + assert_equal 47, inbound.balance_after + end end diff --git a/test/models/concerns/has_wallets_test.rb b/test/models/concerns/has_wallets_test.rb index 119605c..4492e7d 100644 --- a/test/models/concerns/has_wallets_test.rb +++ b/test/models/concerns/has_wallets_test.rb @@ -55,5 +55,120 @@ def self.name user = test_class.create!(email: "walletless@example.com", name: "Walletless User") assert_nil user.find_wallet(:coins) + assert_nil user.wallet(:coins), "wallet lookup must not auto-create when disabled" + assert_nil user.main_wallet + refute user.wallet? + end + + test "wallet? reports existence without creating wallets" do + user = users(:new_user) + + assert_no_difference -> { Wallets::Wallet.count } do + refute user.wallet?(:never_created) + end + + user.wallet(:now_created) + + assert user.wallet?(:now_created) + assert user.wallet?(" NOW_CREATED "), "asset codes are normalized on lookup" + assert user.wallet?, "defaults to the default asset wallet" + end + + test "find_wallet returns nil instead of creating and normalizes the asset code" do + user = users(:new_user) + + assert_nil user.find_wallet(:missing) + + wallet = user.wallet(:gems) + + assert_equal wallet, user.find_wallet(" GEMS ") + assert_equal user.main_wallet, user.find_wallet + end + + test "wallet raises a Wallets::Error for unsaved owners" do + user = User.new(email: "unsaved@example.com", name: "Unsaved") + + error = assert_raises(Wallets::Error) { user.wallet(:coins) } + assert_includes error.message, "unsaved" + end + + test "owner creation auto-creates the main wallet inside the same transaction" do + user = nil + ActiveRecord::Base.transaction do + user = User.create!(email: "txn-#{SecureRandom.hex(4)}@example.com", name: "Txn User") + + assert user.wallet?(:coins), "wallet exists before the outer transaction commits" + end + + assert user.main_wallet.persisted? + end + + test "subclasses inherit their parent's wallet options" do + parent_class = Class.new(User) do + def self.name + "ParentWithWallets" + end + + has_wallets default_asset: :doubloons, initial_balance: 5 + end + + child_class = Class.new(parent_class) do + def self.name + "ChildOfParentWithWallets" + end + end + + assert_equal :doubloons, child_class.wallet_options[:default_asset] + + child = child_class.create!(email: "child@example.com", name: "Child") + + assert_equal "doubloons", child.main_wallet.asset_code + assert_equal 5, child.main_wallet.balance + end + + test "subclasses can override their parent's wallet options" do + parent_class = Class.new(User) do + def self.name + "OverridableParent" + end + + has_wallets default_asset: :gold + end + + child_class = Class.new(parent_class) do + def self.name + "OverridingChild" + end + + has_wallets default_asset: :silver + end + + assert_equal :silver, child_class.wallet_options[:default_asset] + assert_equal :gold, parent_class.wallet_options[:default_asset], "the parent keeps its own options" + end + + test "wallet_options returns pure defaults for models that never declared has_wallets" do + options = ActiveRecord::Base.wallet_options + + assert_equal :coins, options[:default_asset] + assert_equal true, options[:auto_create] + assert_equal 0, options[:initial_balance] + end + + test "models without explicit options follow the configured default asset lazily" do + test_class = Class.new(User) do + def self.name + "LazyDefaultUser" + end + + has_wallets + end + + assert_equal :coins, test_class.wallet_options[:default_asset] + + Wallets.configuration.default_asset = :stars + + assert_equal :stars, test_class.wallet_options[:default_asset], + "config changes apply without re-declaring has_wallets" end end diff --git a/test/models/wallets/allocation_test.rb b/test/models/wallets/allocation_test.rb index db0284d..9fadcc0 100644 --- a/test/models/wallets/allocation_test.rb +++ b/test/models/wallets/allocation_test.rb @@ -31,4 +31,28 @@ class Wallets::AllocationTest < ActiveSupport::TestCase assert_not allocation.valid? assert_includes allocation.errors.full_messages.join, "same wallet" end + + test "validation guards tolerate missing associations" do + allocation = Wallets::Allocation.new(amount: 5) + + refute allocation.valid? + assert allocation.errors[:spend_transaction].any? + assert allocation.errors[:source_transaction].any? + end + + test "requires a positive whole amount" do + allocation = Wallets::Allocation.new( + spend_transaction: wallets_transactions(:rich_purchase), + source_transaction: wallets_transactions(:rich_top_up) + ) + + [nil, 0, -5, 2.5].each do |bad_amount| + allocation.amount = bad_amount + + assert_not allocation.valid?, "expected amount=#{bad_amount.inspect} to be invalid" + end + + allocation.amount = 10 + assert allocation.valid? + end end diff --git a/test/models/wallets/transaction_test.rb b/test/models/wallets/transaction_test.rb index 8708cf4..838c953 100644 --- a/test/models/wallets/transaction_test.rb +++ b/test/models/wallets/transaction_test.rb @@ -73,4 +73,143 @@ class Wallets::TransactionTest < ActiveSupport::TestCase ensure Wallets.configuration.allow_negative_balance = original_setting end + + # ─────────────────────────────────────────────────────────────────────────── + # Scopes + # ─────────────────────────────────────────────────────────────────────────── + + test "expired and not_expired scopes partition transactions at the boundary instant" do + wallet = create_wallet(users(:new_user), asset_code: :boundary) + boundary_time = 2.days.from_now.change(usec: 0) + + expiring = wallet.credit(10, category: :reward, expires_at: boundary_time) + evergreen = wallet.credit(10, category: :top_up) + + travel_to boundary_time do + assert_includes wallet.transactions.expired.pluck(:id), expiring.id + refute_includes wallet.transactions.not_expired.pluck(:id), expiring.id + assert_includes wallet.transactions.not_expired.pluck(:id), evergreen.id + refute_includes wallet.transactions.expired.pluck(:id), evergreen.id + + assert expiring.reload.expired?, "a transaction expiring exactly now is already expired" + assert_equal 10, wallet.balance, "balance counts only the evergreen bucket at the boundary" + end + end + + test "scopes slice the ledger by sign, category, and recency" do + wallet = nil + travel_to 2.minutes.ago do + wallet = create_wallet(users(:new_user), asset_code: :scoped, initial_balance: 100) + end + seed = wallet.transactions.sole + spend = wallet.debit(25, category: :purchase) + + assert_equal [seed.id], wallet.transactions.credits.pluck(:id) + assert_equal [spend.id], wallet.transactions.debits.pluck(:id) + assert_equal [spend.id], wallet.transactions.by_category(:purchase).pluck(:id) + assert_equal [spend.id, seed.id], wallet.transactions.recent.pluck(:id) + end + + # ─────────────────────────────────────────────────────────────────────────── + # Categories + # ─────────────────────────────────────────────────────────────────────────── + + test "categories fall back to the defaults when the config does not support extras" do + minimal_config = Struct.new(:table_prefix).new("wallets_") + Wallets::Transaction.stubs(:resolved_config).returns(minimal_config) + + assert_equal Wallets::Transaction::DEFAULT_CATEGORIES, Wallets::Transaction.categories + end + + test "categories deduplicate overlap between defaults and extras" do + Wallets.configuration.additional_categories = %w[credit special_bonus] + + categories = Wallets::Transaction.categories + + assert_equal 1, categories.count("credit") + assert_includes categories, "special_bonus" + end + + test "rejects categories outside the configured list" do + transaction = Wallets::Transaction.new( + wallet: wallets_wallets(:rich_coins_wallet), + amount: 5, + category: "made_up" + ) + + refute transaction.valid? + assert_includes transaction.errors[:category], "is not included in the list" + end + + # ─────────────────────────────────────────────────────────────────────────── + # Allocation accounting and validations + # ─────────────────────────────────────────────────────────────────────────── + + test "allocated and spent amounts read from the allocation links" do + top_up = wallets_transactions(:rich_top_up) # +900 with 100 allocated away + purchase = wallets_transactions(:rich_purchase) # -100 fully backed + + assert_equal 100, top_up.allocated_amount + assert_equal 0, top_up.spent_amount + assert_equal 100, purchase.spent_amount + assert_equal 0, purchase.allocated_amount + end + + test "a credit cannot be allocated beyond its amount" do + top_up = wallets_transactions(:rich_top_up) # 900 with 100 already allocated + top_up.amount = 99 + + refute top_up.valid? + assert_includes top_up.errors[:base].join, "Allocated amount exceeds" + end + + # ─────────────────────────────────────────────────────────────────────────── + # Metadata and delegation + # ─────────────────────────────────────────────────────────────────────────── + + test "owner is delegated through the wallet" do + assert_equal users(:rich_user), wallets_transactions(:rich_top_up).owner + end + + test "balance snapshots are nil when metadata lacks them" do + assert_nil wallets_transactions(:rich_top_up).balance_before + assert_nil wallets_transactions(:rich_top_up).balance_after + end + + test "metadata always reads as an indifferent hash" do + transaction = Wallets::Transaction.new + + assert_equal({}, transaction.metadata) + + transaction.metadata = nil + assert_equal({}, transaction.metadata) + + transaction.metadata = { "a" => 1 } + assert_equal 1, transaction.metadata[:a] + end + + test "metadata mutations survive save" do + transaction = wallets_transactions(:rich_top_up) + transaction.metadata[:audited] = true + transaction.save! + + assert Wallets::Transaction.find(transaction.id).metadata[:audited] + end + + test "non-hash metadata assignments are coerced to an empty hash" do + transaction = Wallets::Transaction.new + transaction.metadata = "garbage" + + assert_equal({}, transaction.metadata) + end + + test "a nil metadata column is normalized to an empty hash on save" do + # MySQL cannot give JSON columns a default, so records can arrive with a + # NULL metadata column; saving must heal it without touching the getter. + transaction = Wallets::Transaction.new(wallet: wallets_wallets(:rich_coins_wallet), amount: 5, category: "credit") + transaction[:metadata] = nil + transaction.save! + + assert_equal({}, Wallets::Transaction.find(transaction.id).metadata) + end end diff --git a/test/models/wallets/transfer_test.rb b/test/models/wallets/transfer_test.rb index f8ce9f3..513a805 100644 --- a/test/models/wallets/transfer_test.rb +++ b/test/models/wallets/transfer_test.rb @@ -450,4 +450,197 @@ class Wallets::TransferTest < ActiveSupport::TestCase refute transfer.valid? assert_includes transfer.errors[:expiration_policy], "is not included in the list" end + + # ─────────────────────────────────────────────────────────────────────────── + # transfer_to guards + # ─────────────────────────────────────────────────────────────────────────── + + test "transfer_to rejects a nil target" do + error = assert_raises(Wallets::InvalidTransfer) do + wallets_wallets(:rich_coins_wallet).transfer_to(nil, 10) + end + + assert_equal "Target wallet is required", error.message + end + + test "transfer_to rejects an unpersisted source wallet" do + unsaved = Wallets::Wallet.new(owner: users(:new_user), asset_code: :coins) + + error = assert_raises(Wallets::InvalidTransfer) do + unsaved.transfer_to(wallets_wallets(:peer_coins_wallet), 10) + end + + assert_equal "Source wallet must be persisted", error.message + end + + test "transfer_to rejects an unpersisted target wallet" do + unsaved = Wallets::Wallet.new(owner: users(:new_user), asset_code: :coins) + + error = assert_raises(Wallets::InvalidTransfer) do + wallets_wallets(:rich_coins_wallet).transfer_to(unsaved, 10) + end + + assert_equal "Target wallet must be persisted", error.message + end + + test "transfer_to rejects transferring to the same wallet" do + wallet = wallets_wallets(:rich_coins_wallet) + + error = assert_raises(Wallets::InvalidTransfer) { wallet.transfer_to(wallet, 10) } + assert_equal "Cannot transfer to the same wallet", error.message + + same_row = Wallets::Wallet.find(wallet.id) + assert_raises(Wallets::InvalidTransfer) { wallet.transfer_to(same_row, 10) } + end + + test "transfer_to validates the amount before touching the ledger" do + source_wallet = wallets_wallets(:rich_coins_wallet) + target_wallet = wallets_wallets(:peer_coins_wallet) + + [nil, 0, -10, 2.5].each do |bad_amount| + assert_no_difference -> { Wallets::Transfer.count } do + assert_raises(ArgumentError) { source_wallet.transfer_to(target_wallet, bad_amount) } + end + end + end + + test "expires_at cannot be combined with preserve or none policies" do + source_wallet = wallets_wallets(:rich_coins_wallet) + target_wallet = wallets_wallets(:peer_coins_wallet) + + %i[preserve none].each do |policy| + error = assert_raises(ArgumentError) do + source_wallet.transfer_to(target_wallet, 10, expiration_policy: policy, expires_at: 1.day.from_now) + end + + assert_includes error.message, "cannot be combined" + end + end + + test "transfer expiration policy falls back to preserve when config does not define one" do + sender = create_wallet(users(:new_user), asset_code: :minimal, initial_balance: 50) + recipient = create_wallet(users(:peer_user), asset_code: :minimal) + + minimal_config = Struct.new(:table_prefix, :allow_negative_balance, :low_balance_threshold) + .new("wallets_", false, nil) + Wallets::Wallet.stubs(:resolved_config).returns(minimal_config) + + transfer = sender.transfer_to(recipient, 10, category: :gift) + + assert_equal "preserve", transfer.expiration_policy + end + + # ─────────────────────────────────────────────────────────────────────────── + # Transfer model validations and leg queries + # ─────────────────────────────────────────────────────────────────────────── + + test "transfer model rejects an asset code that does not match the wallets" do + transfer = Wallets::Transfer.new( + from_wallet: wallets_wallets(:rich_coins_wallet), + to_wallet: wallets_wallets(:peer_coins_wallet), + asset_code: :gems, + amount: 10, + expiration_policy: :preserve + ) + + refute transfer.valid? + assert_includes transfer.errors[:asset_code], "must match both wallets" + end + + test "transfer model requires a category" do + transfer = Wallets::Transfer.new( + from_wallet: wallets_wallets(:rich_coins_wallet), + to_wallet: wallets_wallets(:peer_coins_wallet), + asset_code: :coins, + amount: 10, + category: nil, + expiration_policy: :preserve + ) + + refute transfer.valid? + assert_includes transfer.errors[:category], "can't be blank" + end + + test "transfer model normalizes asset code and expiration policy" do + transfer = Wallets::Transfer.new( + from_wallet: wallets_wallets(:rich_coins_wallet), + to_wallet: wallets_wallets(:peer_coins_wallet), + asset_code: " COINS ", + amount: 10, + expiration_policy: " PRESERVE " + ) + + assert transfer.valid? + assert_equal "coins", transfer.asset_code + assert_equal "preserve", transfer.expiration_policy + end + + test "inbound_transaction returns the single inbound leg when there is exactly one" do + sender = create_wallet(users(:new_user), asset_code: :single_leg, initial_balance: 100) + recipient = create_wallet(users(:peer_user), asset_code: :single_leg) + + transfer = sender.transfer_to(recipient, 25, category: :gift) + + assert_equal transfer.inbound_transactions.sole.id, transfer.inbound_transaction.id + assert_equal transfer.outbound_transactions.sole.id, transfer.outbound_transaction.id + end + + test "leg queries are empty on an unpersisted transfer" do + transfer = Wallets::Transfer.new + + assert_empty transfer.outbound_transactions + assert_empty transfer.inbound_transactions + assert_nil transfer.outbound_transaction + assert_nil transfer.inbound_transaction + end + + test "a bare transfer is invalid without crashing the validation guards" do + transfer = Wallets::Transfer.new + + refute transfer.valid? + assert transfer.errors[:from_wallet].any? + assert transfer.errors[:to_wallet].any? + assert transfer.errors[:amount].any? + end + + test "transfer metadata reads with indifferent access and mutations survive save" do + sender = create_wallet(users(:new_user), asset_code: :meta_check, initial_balance: 50) + recipient = create_wallet(users(:peer_user), asset_code: :meta_check) + transfer = sender.transfer_to(recipient, 10, category: :gift, metadata: { "note" => "hi" }) + + assert_equal "hi", transfer.metadata[:note] + + transfer.metadata[:flagged] = true + transfer.save! + + assert Wallets::Transfer.find(transfer.id).metadata[:flagged] + end + + # ─────────────────────────────────────────────────────────────────────────── + # Concurrency + # ─────────────────────────────────────────────────────────────────────────── + + test "simultaneous opposing transfers serialize without deadlocking" do + # `lock_wallet_pair!` always locks the wallet with the smaller id first, + # so two opposing transfers (A→B and B→A) can never each hold one lock + # while waiting on the other. + alice = create_wallet(users(:new_user), asset_code: :duel, initial_balance: 100) + bob = create_wallet(users(:peer_user), asset_code: :duel, initial_balance: 100) + + start_line = Queue.new + threads = [[alice, bob], [bob, alice]].map do |from, to| + Thread.new do + ActiveRecord::Base.connection_pool.with_connection do + start_line.pop + from.class.find(from.id).transfer_to(to.class.find(to.id), 10, category: :peer_payment) + end + end + end + 2.times { start_line << true } + threads.each(&:join) + + assert_equal 100, alice.reload.balance + assert_equal 100, bob.reload.balance + assert_equal 2, Wallets::Transfer.where(from_wallet_id: [alice.id, bob.id]).count + end end diff --git a/test/models/wallets/wallet_test.rb b/test/models/wallets/wallet_test.rb index b4343d5..38feda5 100644 --- a/test/models/wallets/wallet_test.rb +++ b/test/models/wallets/wallet_test.rb @@ -239,4 +239,373 @@ class Wallets::WalletTest < ActiveSupport::TestCase assert_equal(-40, wallet.reload.balance) assert_equal 4, wallet.transactions.where("amount < 0").count end + + # ─────────────────────────────────────────────────────────────────────────── + # Balance, expiration, and FIFO order + # ─────────────────────────────────────────────────────────────────────────── + + test "balance excludes credits once they expire" do + wallet = create_wallet(users(:new_user), asset_code: :promo) + wallet.credit(100, category: :reward, expires_at: 2.days.from_now) + wallet.credit(40, category: :top_up) + + assert_equal 140, wallet.balance + + travel 3.days do + assert_equal 40, wallet.balance + end + end + + test "debit consumes the soonest-expiring bucket before older evergreen credits" do + wallet = create_wallet(users(:new_user), asset_code: :data) + evergreen = wallet.credit(100, category: :top_up) + expiring = wallet.credit(50, category: :reward, expires_at: 2.days.from_now) + + spend = wallet.debit(60, category: :purchase) + allocations = spend.outgoing_allocations.order(:id) + + assert_equal expiring.id, allocations.first.source_transaction_id, "expiring value is spent first" + assert_equal 50, allocations.first.amount + assert_equal evergreen.id, allocations.second.source_transaction_id + assert_equal 10, allocations.second.amount + end + + test "debit never allocates from expired buckets" do + wallet = create_wallet(users(:new_user), asset_code: :stale) + expiring = wallet.credit(100, category: :reward, expires_at: 1.day.from_now) + evergreen = wallet.credit(50, category: :top_up) + + travel 2.days do + spend = wallet.debit(30, category: :purchase) + + assert_equal [evergreen.id], spend.outgoing_allocations.pluck(:source_transaction_id) + assert_equal 0, expiring.reload.allocated_amount + assert_equal 20, wallet.balance + end + end + + test "debit can empty the wallet exactly to zero" do + wallet = create_wallet(users(:new_user), asset_code: :exact, initial_balance: 75) + + spend = wallet.debit(75, category: :purchase) + + assert_equal 0, wallet.reload.balance + assert_equal(-75, spend.amount) + assert_equal 0, spend.unbacked_amount + end + + test "history orders transactions chronologically with id as tiebreaker" do + wallet = create_wallet(users(:new_user), asset_code: :hist) + ids = freeze_time do + 3.times.map { |i| wallet.credit(10 + i, category: :top_up).id } + end + + assert_equal ids, wallet.history.where(id: ids).pluck(:id) + end + + # ─────────────────────────────────────────────────────────────────────────── + # Amount validation edges + # ─────────────────────────────────────────────────────────────────────────── + + test "credit and debit reject invalid amounts with ArgumentError" do + wallet = wallets_wallets(:rich_coins_wallet) + + [nil, 0, -5, 10.5, "10", :ten, Float::INFINITY, Float::NAN].each do |bad_amount| + assert_raises(ArgumentError, "credit(#{bad_amount.inspect}) should raise") { wallet.credit(bad_amount) } + assert_raises(ArgumentError, "debit(#{bad_amount.inspect}) should raise") { wallet.debit(bad_amount) } + end + end + + test "whole-number floats are accepted and stored as integers" do + wallet = create_wallet(users(:new_user), asset_code: :floaty) + + transaction = wallet.credit(10.0, category: :top_up) + + assert_equal 10, transaction.amount + assert_equal 10, wallet.reload.balance + end + + test "has_enough_balance? handles edge inputs gracefully" do + wallet = wallets_wallets(:rich_coins_wallet) # balance 1000 + + assert wallet.has_enough_balance?(1000) + assert wallet.has_enough_balance?(999.0) + refute wallet.has_enough_balance?(1001) + refute wallet.has_enough_balance?(nil) + refute wallet.has_enough_balance?(0) + refute wallet.has_enough_balance?(-5) + refute wallet.has_enough_balance?(10.5) + refute wallet.has_enough_balance?(:lots) + end + + # ─────────────────────────────────────────────────────────────────────────── + # Expiration validation + # ─────────────────────────────────────────────────────────────────────────── + + test "credit rejects past expirations" do + wallet = wallets_wallets(:rich_coins_wallet) + + error = assert_raises(ArgumentError) { wallet.credit(10, expires_at: 1.hour.ago) } + assert_includes error.message, "future" + end + + test "credit rejects non-temporal expirations" do + wallet = wallets_wallets(:rich_coins_wallet) + + assert_raises(ArgumentError) { wallet.credit(10, expires_at: 123) } + assert_raises(ArgumentError) { wallet.credit(10, expires_at: "not a date") } + end + + test "credit accepts a Date and a parseable String as expiration" do + wallet = create_wallet(users(:new_user), asset_code: :seasonal) + + from_date = wallet.credit(10, category: :reward, expires_at: Date.tomorrow) + from_string = wallet.credit(10, category: :reward, expires_at: 2.days.from_now.iso8601) + + assert_equal Date.tomorrow, from_date.expires_at.to_date + assert from_string.expires_at.future? + end + + # ─────────────────────────────────────────────────────────────────────────── + # create_for_owner! race and conflict paths + # ─────────────────────────────────────────────────────────────────────────── + + test "create_for_owner returns the winner's wallet when losing a duplicate-insert race" do + owner = users(:new_user) + existing = create_wallet(owner, asset_code: :raced) + + Wallets::Wallet.stubs(:find_by).returns(nil).then.returns(existing) + Wallets::Wallet.stubs(:create!).raises(ActiveRecord::RecordNotUnique.new("duplicate key")) + + assert_equal existing.id, Wallets::Wallet.create_for_owner!(owner: owner, asset_code: :raced).id + end + + test "create_for_owner re-raises duplicate-insert errors when no wallet actually exists" do + Wallets::Wallet.stubs(:create!).raises(ActiveRecord::RecordNotUnique.new("duplicate key")) + + assert_raises(ActiveRecord::RecordNotUnique) do + Wallets::Wallet.create_for_owner!(owner: users(:new_user), asset_code: :ghost_asset) + end + end + + test "create_for_owner re-raises validation failures unrelated to the uniqueness conflict" do + invalid_record = Wallets::Wallet.new + invalid_record.errors.add(:balance, :invalid) + Wallets::Wallet.stubs(:create!).raises(ActiveRecord::RecordInvalid.new(invalid_record)) + + assert_raises(ActiveRecord::RecordInvalid) do + Wallets::Wallet.create_for_owner!(owner: users(:new_user), asset_code: :broken_asset) + end + end + + test "create_for_owner survives a real duplicate insert inside a caller's transaction" do + # This is the exact shape of the production race: the uniqueness + # validation misses a concurrent row, the INSERT hits the unique index + # for real, and all of it happens inside the caller's transaction (like + # `after_create` wallet auto-creation). On PostgreSQL a unique-index + # violation aborts the transaction it ran in, so recovering requires the + # gem to write through a savepoint and rescue outside of it. + owner = users(:new_user) + existing = create_wallet(owner, asset_code: :hard_race) + + Wallets::Wallet.define_singleton_method(:create!) do |**attributes| + record = new(**attributes) + record.save!(validate: false) # skip the uniqueness SELECT, hit the index + record + end + + begin + result = nil + ActiveRecord::Base.transaction do + result = Wallets::Wallet.create_for_owner!(owner: owner, asset_code: :hard_race) + assert User.count.positive?, "outer transaction must remain usable after the rescued conflict" + end + + assert_equal existing.id, result.id + ensure + Wallets::Wallet.singleton_class.send(:remove_method, :create!) + end + end + + test "create_for_owner normalizes asset codes and persists metadata" do + wallet = Wallets::Wallet.create_for_owner!( + owner: users(:new_user), + asset_code: " EUR ", + metadata: { "tier" => "vip" } + ) + + assert_equal "eur", wallet.asset_code + assert_equal "vip", wallet.reload.metadata[:tier] + end + + test "create_for_owner rejects blank and fractional inputs" do + owner = users(:new_user) + + assert_raises(ArgumentError) { Wallets::Wallet.create_for_owner!(owner: owner, asset_code: " ") } + assert_raises(ArgumentError) { Wallets::Wallet.create_for_owner!(owner: owner, asset_code: :ok, initial_balance: 10.5) } + assert_raises(ArgumentError) { Wallets::Wallet.create_for_owner!(owner: owner, asset_code: :ok, initial_balance: Float::INFINITY) } + end + + test "create_for_owner treats a nil initial balance as zero and coerces non-hash metadata" do + wallet = Wallets::Wallet.create_for_owner!( + owner: users(:new_user), + asset_code: :lenient, + initial_balance: nil, + metadata: "not a hash" + ) + + assert_equal 0, wallet.balance + assert_equal({}, wallet.metadata) + assert_empty wallet.transactions, "no seed credit is written for a zero initial balance" + end + + test "credit and debit coerce non-hash metadata to an empty hash" do + wallet = create_wallet(users(:new_user), asset_code: :tolerant, initial_balance: 50) + + credit = wallet.credit(10, category: :reward, metadata: "not a hash") + debit = wallet.debit(5, category: :purchase, metadata: 42) + + assert_equal %w[balance_after balance_before], credit.metadata.keys.sort + assert_equal %w[balance_after balance_before], debit.metadata.keys.sort + end + + # ─────────────────────────────────────────────────────────────────────────── + # Wallet validations and metadata + # ─────────────────────────────────────────────────────────────────────────── + + test "duplicate wallets for the same owner and asset are invalid" do + existing = wallets_wallets(:rich_coins_wallet) + duplicate = Wallets::Wallet.new(owner: existing.owner, asset_code: "coins", balance: 0) + + refute duplicate.valid? + assert duplicate.errors.of_kind?(:asset_code, :taken) + end + + test "wallet requires a present asset code and an integer balance" do + wallet = Wallets::Wallet.new(owner: users(:new_user), asset_code: " ", balance: 1.5) + + refute wallet.valid? + assert wallet.errors[:asset_code].any? + assert wallet.errors[:balance].any? + end + + test "wallet asset codes are normalized before validation" do + wallet = Wallets::Wallet.create!(owner: users(:new_user), asset_code: " GOLD ", balance: 0) + + assert_equal "gold", wallet.asset_code + end + + test "wallet metadata reads with indifferent access and mutations survive save" do + wallet = Wallets::Wallet.create_for_owner!(owner: users(:new_user), asset_code: :meta, metadata: { "tier" => "vip" }) + + assert_equal "vip", wallet.metadata[:tier] + + wallet.metadata[:flag] = true + wallet.save! + assert Wallets::Wallet.find(wallet.id).metadata[:flag] + + wallet.metadata = nil + assert_equal({}, wallet.metadata) + end + + # ─────────────────────────────────────────────────────────────────────────── + # Allocation safety net + # ─────────────────────────────────────────────────────────────────────────── + + test "debit raises when allocations cannot cover the amount even after the balance check" do + # Safety net for the rare race where a bucket expires between the + # balance pre-check and the FIFO allocation query. + wallet = create_wallet(users(:new_user), asset_code: :race_guard, initial_balance: 100) + wallet.stubs(:allocate_debit!).returns(25) + + error = assert_raises(Wallets::InsufficientBalance) { wallet.debit(50, category: :purchase) } + assert_includes error.message, "balance buckets" + end + + test "preserve refuses to fabricate inbound credits when allocations do not cover the amount" do + # Deep safety net: if the FIFO allocation invariant ever broke mid-transfer, + # the receiver must not be credited buckets that do not add up. + wallet = wallets_wallets(:rich_coins_wallet) + transfer = stub(id: 42) + allocation = stub(amount: 30, source_transaction: stub(expires_at: nil)) + outbound = stub(outgoing_allocations: stub(includes: stub(order: stub(to_a: [allocation])))) + + error = assert_raises(Wallets::InvalidTransfer) do + wallet.send(:build_preserved_transfer_inbound_credit_specs, transfer, outbound, 100) + end + + assert_includes error.message, "could not preserve expiration buckets" + end + + test "inbound credit specs reject policies that slipped past resolution" do + wallet = wallets_wallets(:rich_coins_wallet) + + assert_raises(ArgumentError) do + wallet.send( + :build_transfer_inbound_credit_specs, + transfer: nil, outbound_transaction: nil, amount: 10, + expiration_policy: "bogus", expires_at: nil + ) + end + end + + test "lock_wallet_pair! locks the same row only once when handed twin instances" do + wallet = wallets_wallets(:rich_coins_wallet) + twin = Wallets::Wallet.find(wallet.id) + + ActiveRecord::Base.transaction do + assert_nothing_raised { wallet.send(:lock_wallet_pair!, twin) } + end + end + + # ─────────────────────────────────────────────────────────────────────────── + # Destroy semantics + # ─────────────────────────────────────────────────────────────────────────── + + test "destroying a wallet with outgoing transfer history preserves the counterparty's ledger" do + sender_owner = User.create!(email: "dst-a-#{SecureRandom.hex(4)}@example.com", name: "A") + receiver_owner = User.create!(email: "dst-b-#{SecureRandom.hex(4)}@example.com", name: "B") + sender = sender_owner.wallet(:coins) + receiver = receiver_owner.wallet(:coins) + sender.credit(100, category: :top_up) + transfer = sender.transfer_to(receiver, 40, category: :peer_payment) + transfer_id = transfer.id + + assert_difference -> { Wallets::Transfer.count }, -1 do + sender.destroy! + end + + inbound = receiver.transactions.credits.sole + assert_equal 40, receiver.reload.balance, "the receiver keeps the transferred value" + assert_nil inbound.reload.transfer_id, "the link object is gone" + assert_equal transfer_id, inbound.metadata[:transfer_id], "metadata still records the transfer for audit" + end + + test "destroying the receiving wallet keeps the sender's ledger intact" do + sender_owner = User.create!(email: "dst-c-#{SecureRandom.hex(4)}@example.com", name: "C") + receiver_owner = User.create!(email: "dst-d-#{SecureRandom.hex(4)}@example.com", name: "D") + sender = sender_owner.wallet(:coins) + receiver = receiver_owner.wallet(:coins) + sender.credit(100, category: :top_up) + sender.transfer_to(receiver, 40, category: :peer_payment) + + receiver.destroy! + + outbound = sender.transactions.debits.sole + assert_nil outbound.reload.transfer_id + assert_equal 60, sender.reload.balance + end + + test "destroying an owner removes their wallets, transactions, and allocations" do + owner = User.create!(email: "cascade-#{SecureRandom.hex(4)}@example.com", name: "Cascade") + wallet = owner.wallet(:coins) + wallet.credit(30, category: :top_up) + wallet.debit(10, category: :purchase) + wallet_id = wallet.id + + owner.destroy! + + assert_empty Wallets::Wallet.where(id: wallet_id) + assert_empty Wallets::Transaction.where(wallet_id: wallet_id) + end end diff --git a/test/wallets/callback_context_test.rb b/test/wallets/callback_context_test.rb index c568e6a..b609432 100644 --- a/test/wallets/callback_context_test.rb +++ b/test/wallets/callback_context_test.rb @@ -23,4 +23,11 @@ class Wallets::CallbackContextTest < ActiveSupport::TestCase context.to_h ) end + + test "owner is nil without a wallet" do + context = Wallets::CallbackContext.new(event: :balance_credited) + + assert_nil context.owner + assert_equal({ event: :balance_credited }, context.to_h) + end end diff --git a/test/wallets/callbacks_test.rb b/test/wallets/callbacks_test.rb index cbb535b..8db2ea8 100644 --- a/test/wallets/callbacks_test.rb +++ b/test/wallets/callbacks_test.rb @@ -41,4 +41,81 @@ class Wallets::CallbacksTest < ActiveSupport::TestCase Wallets::Callbacks.dispatch(:balance_debited, wallet: wallets_wallets(:rich_coins_wallet)) end + + test "dispatch ignores unknown events instead of raising" do + assert_nothing_raised do + Wallets::Callbacks.dispatch(:made_up_event, wallet: wallets_wallets(:rich_coins_wallet)) + end + end + + test "dispatch supports callbacks with splat arguments" do + captured = [] + Wallets.configure do |config| + config.on_balance_depleted { |*args| captured.concat(args) } + end + + Wallets::Callbacks.dispatch(:balance_depleted, wallet: wallets_wallets(:rich_coins_wallet)) + + assert_equal 1, captured.size + assert_instance_of Wallets::CallbackContext, captured.first + end + + test "a raising callback never breaks the ledger write" do + Wallets.configure do |config| + config.on_balance_credited { |_ctx| raise "callback exploded" } + end + + wallet = wallets_wallets(:rich_coins_wallet) + transaction = nil + + assert_difference -> { wallet.transactions.count }, 1 do + transaction = wallet.credit(25, category: :reward) + end + + assert transaction.persisted? + assert_equal 1025, wallet.reload.balance + end + + # ─────────────────────────────────────────────────────────────────────────── + # Logging plumbing + # ─────────────────────────────────────────────────────────────────────────── + + test "log_error and log_warn prefer the Rails logger" do + fake_logger = mock + fake_logger.expects(:error).with("boom") + fake_logger.expects(:warn).with("careful") + Rails.stubs(:logger).returns(fake_logger) + + Wallets::Callbacks.log_error("boom") + Wallets::Callbacks.log_warn("careful") + end + + test "log_error and log_warn fall back to Kernel#warn without a Rails logger" do + Rails.stubs(:logger).returns(nil) + + assert_output(nil, /boom\ncareful/) do + Wallets::Callbacks.log_error("boom") + Wallets::Callbacks.log_warn("careful") + end + end + + test "log_debug logs only when the logger is at debug level" do + chatty = mock + chatty.stubs(:debug?).returns(true) + chatty.expects(:debug).with("details") + Rails.stubs(:logger).returns(chatty) + + Wallets::Callbacks.log_debug("details") + + quiet = mock + quiet.stubs(:debug?).returns(false) + quiet.expects(:debug).never + Rails.stubs(:logger).returns(quiet) + + Wallets::Callbacks.log_debug("details") + + Rails.stubs(:logger).returns(nil) + + assert_nothing_raised { Wallets::Callbacks.log_debug("details") } + end end diff --git a/test/wallets/configuration_test.rb b/test/wallets/configuration_test.rb index 5682f15..900ba65 100644 --- a/test/wallets/configuration_test.rb +++ b/test/wallets/configuration_test.rb @@ -81,4 +81,34 @@ class Wallets::ConfigurationTest < ActiveSupport::TestCase assert_equal depleted, configuration.on_balance_depleted_callback assert_equal insufficient, configuration.on_insufficient_balance_callback end + + test "low_balance_threshold accepts zero, nil, and numeric strings" do + configuration = Wallets::Configuration.new + + configuration.low_balance_threshold = 0 + assert_equal 0, configuration.low_balance_threshold + + configuration.low_balance_threshold = nil + assert_nil configuration.low_balance_threshold + + assert_raises(ArgumentError) { configuration.low_balance_threshold = "abc" } + end + + test "default_asset accepts symbols and normalizes case" do + configuration = Wallets::Configuration.new + + configuration.default_asset = :GEMS + + assert_equal :gems, configuration.default_asset + assert_raises(ArgumentError) { configuration.default_asset = nil } + end + + test "transfer_expiration_policy normalizes strings and rejects unknown values" do + configuration = Wallets::Configuration.new + + configuration.transfer_expiration_policy = " PRESERVE " + + assert_equal :preserve, configuration.transfer_expiration_policy + assert_raises(ArgumentError) { configuration.transfer_expiration_policy = :forever } + end end diff --git a/test/wallets/engine_test.rb b/test/wallets/engine_test.rb new file mode 100644 index 0000000..82b7260 --- /dev/null +++ b/test/wallets/engine_test.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "test_helper" + +class Wallets::EngineTest < ActiveSupport::TestCase + test "gem code is not added to the host app autoloaders" do + # Registering lib/wallets/models with Zeitwerk would claim top-level + # constants like ::Wallet and ::Transaction, shadowing (and breaking) + # host apps that define models with those very common names. + gem_lib = Wallets::Engine.root.join("lib").to_s + + Rails.autoloaders.each do |autoloader| + autoloader.dirs.each do |dir| + refute dir.to_s.start_with?(gem_lib), "#{dir} must not be autoloaded from the wallets gem" + end + end + end + + test "all gem constants are eagerly available without autoloading" do + assert defined?(Wallets::Wallet) + assert defined?(Wallets::Transaction) + assert defined?(Wallets::Allocation) + assert defined?(Wallets::Transfer) + assert defined?(Wallets::HasWallets) + assert defined?(Wallets::Embeddable) + assert defined?(Wallets::HasMetadata) + end + + test "active record models gain the has_wallets macro" do + assert_respond_to ActiveRecord::Base, :has_wallets + assert_respond_to User, :has_wallets + end +end diff --git a/test/wallets/wallets_test.rb b/test/wallets/wallets_test.rb new file mode 100644 index 0000000..40c0aaa --- /dev/null +++ b/test/wallets/wallets_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "test_helper" + +class WalletsTest < ActiveSupport::TestCase + test "VERSION is a semantic version string" do + assert_match(/\A\d+\.\d+\.\d+\z/, Wallets::VERSION) + end + + test "configuration is memoized until reset" do + config = Wallets.configuration + + assert_same config, Wallets.configuration + + Wallets.reset! + + refute_same config, Wallets.configuration + end + + test "configure yields the active configuration" do + yielded = nil + Wallets.configure { |config| yielded = config } + + assert_same Wallets.configuration, yielded + end + + test "a replacement configuration can be assigned directly" do + custom = Wallets::Configuration.new + Wallets.configuration = custom + + assert_same custom, Wallets.configuration + ensure + Wallets.reset! + end + + test "normalize_asset_code is the single source of truth for asset naming" do + assert_equal "eur", Wallets.normalize_asset_code(" EUR ") + assert_equal "wood", Wallets.normalize_asset_code(:WOOD) + assert_equal "", Wallets.normalize_asset_code(nil) + end + + test "error hierarchy lets apps rescue all gem errors at once" do + assert_operator Wallets::InsufficientBalance, :<, Wallets::Error + assert_operator Wallets::InvalidTransfer, :<, Wallets::Error + assert_operator Wallets::Error, :<, StandardError + end +end