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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## [Unreleased]

### Fixed

- The `credits`, `debits`, `not_expired`, and `expired` scopes now table-qualify their column predicates (via `arel_table`) instead of a bare `where("amount > 0")`. A bare column name is ambiguous the moment a host joins another table that also has that column — and every wallet has an `amount` on **both** `wallets_transactions` and `wallets_transfers`, so a natural query like `wallet.transactions.joins(:transfer).credits` (e.g. "which of my transactions belong to a reversal transfer?") raised `PG::AmbiguousColumn` (SQLite/MySQL raise the equivalent). `arel_table` also respects the embedding `table_name` override, so embedded/subclassed transaction tables get the correct qualification for free.

## [0.2.0] - 2026-05-03

### Fixed
Expand Down
17 changes: 13 additions & 4 deletions lib/wallets/models/transaction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,21 @@ def self.categories

before_save :sync_metadata_cache

scope :credits, -> { where("amount > 0") }
scope :debits, -> { where("amount < 0") }
# Column predicates go through `arel_table` rather than a raw
# `where("amount > 0")` string so the generated SQL is always
# TABLE-QUALIFIED (`"wallets_transactions"."amount" > 0`). A bare
# `amount` breaks the moment a host joins another table that also has an
# `amount` column — every wallet has an `amount` on BOTH transactions and
# transfers, so `wallet.transactions.joins(:transfer).credits` raised
# `PG::AmbiguousColumn` (SQLite/MySQL raise the equivalent). Arel also
# respects the embedding `table_name` override for free, since
# `arel_table` is derived from the resolved table name.
scope :credits, -> { where(arel_table[:amount].gt(0)) }
scope :debits, -> { where(arel_table[:amount].lt(0)) }
scope :recent, -> { order(created_at: :desc) }
scope :by_category, ->(category) { where(category: category) }
scope :not_expired, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
scope :expired, -> { where("expires_at < ?", Time.current) }
scope :not_expired, -> { where(arel_table[:expires_at].eq(nil).or(arel_table[:expires_at].gt(Time.current))) }
scope :expired, -> { where(arel_table[:expires_at].lt(Time.current)) }

def metadata
@indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
Expand Down
27 changes: 27 additions & 0 deletions test/models/wallets/transaction_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,31 @@ class Wallets::TransactionTest < ActiveSupport::TestCase
ensure
Wallets.configuration.allow_negative_balance = original_setting
end

test "amount scopes qualify their column so they compose with a transfer join" do
# Regression: `credits`/`debits` used a bare `where(\"amount > 0\")`.
# Both `wallets_transactions` and `wallets_transfers` have an `amount`
# column, so the instant a host joined the two — e.g.
# `wallet.transactions.joins(:transfer).credits` to find the transactions
# belonging to a category of transfer — the database raised an ambiguous
# column error (PG::AmbiguousColumn; SQLite "ambiguous column name").
sender = User.create!(email: "amount-scope-sender-#{SecureRandom.hex(4)}@example.com", name: "Sender").wallet(:coins)
recipient = User.create!(email: "amount-scope-recipient-#{SecureRandom.hex(4)}@example.com", name: "Recipient").wallet(:coins)
sender.credit(500, category: :top_up)
transfer = sender.transfer_to(recipient, 120, category: :peer_payment)

# The join is what triggered the ambiguity; both scopes must survive it.
sender_debit = sender.transactions.joins(:transfer).debits.sole
recipient_credit = recipient.transactions.joins(:transfer).credits.sole

assert_equal(-120, sender_debit.amount)
assert_equal transfer.id, sender_debit.transfer_id
assert_equal 120, recipient_credit.amount
assert_equal transfer.id, recipient_credit.transfer_id

# DB-agnostic proof the column is table-qualified in the generated SQL.
table = Wallets::Transaction.table_name
assert_includes Wallets::Transaction.credits.to_sql, %("#{table}"."amount")
assert_includes Wallets::Transaction.debits.to_sql, %("#{table}"."amount")
end
end
Loading