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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
## [0.2.0] - 2026-05-03

### Fixed

- `Wallet#transfer_to` now honors `Wallets.configuration.allow_negative_balance`, matching `Wallet#debit`. Previously the flag was half-applied: direct debits could go below zero, but the canonical transfer primitive (used to move value between users) silently rejected any transfer that would push the source below zero. Apps using the flag for a "convenience overdraft" (e.g. ride-fare apps where passengers may briefly go negative until rewards land) had to monkey-patch the gem to get consistent behavior. ([#3](https://github.com/rameerez/wallets/pull/3))
- `:balance_depleted` callback now fires when a debit takes a wallet from a positive balance to **zero or lower** (was: exactly zero). Previously, with `allow_negative_balance = true`, a single debit that drove a wallet from e.g. +100 to -50 would skip the callback because the wallet never landed on exact zero. The callback semantic is "ran out of available value", which is at least as true at -50 as at 0. With `allow_negative_balance = false` the behavior is unchanged because balances cannot go below zero.

### Changed

- When a transfer drives the source wallet below zero AND the caller is using the default `:preserve` expiration policy, the policy automatically falls back to `:none` for that transfer. Rationale: there are no positive source buckets to "preserve" expirations from for the deficit portion, and `build_preserved_transfer_inbound_credit_specs` would otherwise raise `InvalidTransfer`. The inbound credit becomes a single evergreen entry — the only honest representation of "value created without a source bucket". Explicit `:fixed` and `:none` policies are honored unchanged.
- `:insufficient_balance` callback no longer fires for transfers that succeed under `allow_negative_balance = true`. It still fires (and the transfer still raises `Wallets::InsufficientBalance`) when the flag is off and the transfer is rejected.

### Documented

- `apply_credit` does NOT auto-allocate a new credit against existing unbacked debits. The FIFO ledger is intentionally append-only — both the unbacked debit and the new credit persist as independent rows; `balance` reconciles them on the fly. Apps that want automatic debt settlement should layer that on in their own service code.
- `allow_negative_balance` is meant to be a stable config decision, not a runtime toggle. Flipping it OFF while wallets are below zero leaves them un-saveable, since the model's `balance >= 0` validation is gated on the flag and any subsequent `credit` / `debit` calls `refresh_cached_balance!` (which calls `save!`).
- `has_enough_balance?` keeps strict semantics under `allow_negative_balance = true`: it still answers "does this wallet have enough on hand right now?", returning `false` when the gem would happily complete an overdraft. Overdraft is a deliberate caller choice via `debit` / `transfer_to`, not a query semantic.

## [0.1.0] - 2026-03-18

Initial release.
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,36 @@ Transfers require both wallets to use the same asset and the same wallet class.
> sender.transfer_to(receiver, 100, expiration_policy: :fixed, expires_at: 30.days.from_now)
> ```

### Negative balances and overdraft

By default `wallets` rejects any debit or transfer that would push a wallet below zero. Some apps (ride-fare apps with rewards, family wallets with shared spend, telecom plans with bridging credit, etc.) want to allow a small overdraft so the user experience doesn't hard-stop on a low balance.

Flip one flag and it applies consistently to direct `wallet.debit` and to `wallet.transfer_to`:

```ruby
Wallets.configure do |config|
config.allow_negative_balance = true
end

passenger.wallet(:eur).balance # => 100 (1€)
passenger.wallet(:eur).transfer_to(driver.wallet(:eur), 300, category: :ride_fare)
passenger.wallet(:eur).reload.balance # => -200 (-2€)
driver.wallet(:eur).reload.balance # => 300 (3€)
```

A few things to know:

- **Apps own the floor.** The flag is binary — it doesn't cap how negative a wallet can go. If you want a "5€ convenience overdraft", enforce that in your domain code before calling `transfer_to` (e.g. a `WalletPolicy.can_afford?(wallet:, amount:)` service that checks `wallet.balance + MAX_OVERDRAFT >= amount`).
- **`has_enough_balance?` stays strict.** It answers "does this wallet have enough on hand right now?" — overdraft is a deliberate choice the caller makes by attempting the debit/transfer, not by querying. So `wallet.has_enough_balance?(amount)` returns `false` even when the gem would happily complete an overdraft.
- **`:preserve` expiration falls back to `:none` when a transfer goes negative.** With `allow_negative_balance = true` and the default `:preserve` policy, transfers that exceed the source's positive buckets can't honestly "preserve" an expiration on the deficit portion (there is no source bucket). The transfer's `expiration_policy` is automatically downgraded to `:none` for that transfer; the receiver gets a single evergreen credit. Explicit `:fixed` (with `expires_at:`) and `:none` are honored as-is.
- **`:balance_depleted` fires on positive→non-positive crossings**, not on exact zero. A debit that takes a wallet from +100 to -50 in one shot still fires the callback. With `allow_negative_balance = false` the behavior collapses back to "exactly zero" (since balances can't go below zero), so existing callers don't see a change.
- **`:low_balance_reached` is one-shot per crossing**, regardless of how deep the dip goes. Going from +200 to -100 fires once; the next debit from -100 to -200 does not re-fire because the wallet was already below threshold.
- **`:insufficient_balance` callback.** Fires only when a debit or transfer is actually rejected (i.e. flag off or your service-layer floor refused). Successful overdraft transfers do not fire it.
- **Credits don't auto-settle prior debt.** If a wallet is at -50 and you `credit(80)`, the wallet's balance becomes 30 — but the unbacked -50 debit and the +80 credit persist as independent ledger rows. The next debit consumes from the +80 bucket (FIFO) without back-filling the older unbacked debit. The math is consistent; the audit story is "we never settled the original debt with this credit". If you want auto-settlement, do it in your service layer (e.g. when crediting, `wallet.transactions.where("amount < 0").where("ABS(amount) > spent_amount") …` and create allocations explicitly).
- **System-initiated reversals.** Refunds and payout reversals via `transfer_to` will also go through, even if the recipient's wallet ends up below zero. That's correct: the ledger has to settle, and a negative wallet records a real debt instead of a silent failure. Apps that want to *block* user-initiated overdrafts but *allow* system reversals should keep the floor check in their service layer, not toggle the flag mid-request.
- **Don't toggle the flag at runtime while wallets are negative.** `allow_negative_balance` is meant to be a stable config decision. The model carries a `balance >= 0` validation gated on the flag; flipping it OFF while wallets sit below zero leaves them un-saveable until you flip it back on (any subsequent `credit` / `debit` calls `refresh_cached_balance!`, which raises a `RecordInvalid`). If you need to turn the flag off, drain or settle any negative wallets first.
- **Concurrency holds.** `wallet.debit` / `wallet.credit` use `with_lock` (row-level `SELECT … FOR UPDATE`); `wallet.transfer_to` locks both wallets in id order via `lock_wallet_pair!`. Two concurrent overdraft debits on the same wallet serialize cleanly — they don't double-count `previous_balance`.

### Expiring balances

Credits can expire:
Expand Down
11 changes: 10 additions & 1 deletion lib/generators/wallets/templates/initializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@
# config.table_prefix = "wallets_"

# Set to true only if your domain explicitly supports debt or overdrafts.
# Applies consistently across `wallet.debit` and `wallet.transfer_to` — a
# debit or a transfer can drive the source wallet below zero. Cap how
# negative a wallet can go in your own service layer (the flag is binary).
#
# Heads up: this is meant to be a stable config decision, not a runtime
# toggle. Flipping it OFF while wallets are negative leaves those wallets
# un-saveable until you flip it back on. Drain or settle negative wallets
# before disabling.
#
# config.allow_negative_balance = false

Expand Down Expand Up @@ -49,7 +57,8 @@
# on_balance_debited - After value is deducted from a wallet
# on_transfer_completed - After a transfer between wallets succeeds
# on_low_balance_reached - When balance drops below the threshold
# on_balance_depleted - When balance reaches exactly zero
# on_balance_depleted - When a debit drives balance from positive
# to zero or below (one-shot per crossing)
# on_insufficient_balance - When a debit or transfer is rejected
#
# Context object properties (available depending on event):
Expand Down
55 changes: 52 additions & 3 deletions lib/wallets/models/wallet.rb
Original file line number Diff line number Diff line change
Expand Up @@ -243,11 +243,50 @@ def transfer_to(other_wallet, amount, category: :transfer, metadata: {}, expirat
lock_wallet_pair!(other_wallet)

previous_balance = balance
if amount > previous_balance

# Insufficient-balance gate.
#
# `allow_negative_balance` is the single source of truth for whether
# the gem permits a wallet to drop below zero. Direct `wallet.debit`
# has always honored it via `apply_debit`; transfers must too, or
# the flag is half-applied and surprising (debits go through, the
# canonical transfer primitive doesn't).
#
# When the flag is OFF, this is the only chance to refuse cleanly:
# we want to dispatch `:insufficient_balance` for the observability
# callback, then raise BEFORE creating the Transfer row. (The later
# `apply_debit` call would also catch this, but only after a
# pointless Transfer row was created.)
#
# When the flag is ON, we let the transfer through. `apply_debit`
# below will allocate as many positive buckets as exist; the
# remaining shortfall lands as an unbacked negative transaction the
# wallet's `balance` accounting accommodates via
# `unbacked_negative_balance`.
if amount > previous_balance && !allow_negative_balance?
dispatch_insufficient_balance!(amount, previous_balance, metadata)
raise InsufficientBalance, "Insufficient balance (#{previous_balance} < #{amount})"
end

# When a transfer dips below zero we can't faithfully apply the
# `:preserve` expiration policy: there are no positive source
# buckets to inherit expirations from for the deficit portion.
# `build_preserved_transfer_inbound_credit_specs` would refuse with
# `InvalidTransfer` on the count mismatch, defeating the very
# callers who opted into negative balances. The only honest
# fallback is to collapse the inbound side to a single evergreen
# credit (`:none`) — value created without a source bucket has no
# source expiration to preserve.
#
# Callers who care about a specific expiration on the inbound side
# should pass `expiration_policy: :fixed, expires_at: …`. Those are
# honored unchanged because they don't depend on source-bucket
# walking. Same for an explicit `:none`.
if resolved_policy == "preserve" && amount > previous_balance
resolved_policy = "none"
inbound_expires_at = nil
end

transfer = transfer_class.create!(
from_wallet: self,
to_wallet: other_wallet,
Expand Down Expand Up @@ -582,11 +621,21 @@ def dispatch_balance_threshold_callbacks!(previous_balance)
)
end

if previous_balance.positive? && balance.zero?
# `:balance_depleted` fires when the wallet crosses from a positive
# balance to ZERO OR LOWER on this debit. We use `<= 0` rather than
# `== 0` so the callback still fires when `allow_negative_balance` is
# true and a single debit takes the wallet from e.g. +100 to -50
# (skipping exact zero). With negatives disabled, `balance` cannot
# go below zero, so `<= 0` collapses to `== 0` and the original
# "exactly zero" semantic is preserved for default-config users.
# Either way the callback is one-shot per crossing — going from -50
# to -80 doesn't re-fire because `previous_balance` was already
# non-positive.
if previous_balance.positive? && !balance.positive?
dispatch_callback(:depleted,
wallet: self,
previous_balance: previous_balance,
new_balance: 0
new_balance: balance
)
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/wallets/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Wallets
VERSION = "0.1.0"
VERSION = "0.2.0"
end
97 changes: 97 additions & 0 deletions test/integration/wallet_callbacks_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,103 @@ class WalletCallbacksTest < ActiveSupport::TestCase
assert_equal 0, events.first.new_balance
end

# ───────────────────────────────────────────────────────────────────────────
# depleted under `allow_negative_balance`
# ───────────────────────────────────────────────────────────────────────────
#
# The original semantic was "fires when balance reaches exactly zero".
# That breaks for apps with negative balances enabled — a single debit
# can take a wallet from +100 to -50, skipping zero entirely. The
# callback was conceptually about "ran out of available value", and a
# negative balance is even more "out" than zero. The condition is
# widened to `previous > 0 && new <= 0` so positive→negative crossings
# also fire it. With negatives disabled, balance can't go below zero,
# so `new <= 0` collapses back to `new == 0` and existing callers see
# no behavior change.

test "balance_depleted fires when a debit crosses zero straight into negative" do
Wallets.configuration.allow_negative_balance = true
wallet = create_wallet(users(:new_user), asset_code: :wood, initial_balance: 50)
events = []

Wallets.configuration.on_balance_depleted { |ctx| events << ctx }

wallet.debit(120, category: :purchase)

assert_equal 1, events.size
assert_equal 50, events.first.previous_balance
assert_equal(-70, events.first.new_balance)
assert_equal(-70, wallet.reload.balance)
end

test "balance_depleted does not re-fire on already-negative wallets that go more negative" do
Wallets.configuration.allow_negative_balance = true
wallet = create_wallet(users(:new_user), asset_code: :wood, initial_balance: 0)
wallet.debit(20, category: :purchase) # balance goes 0 → -20 (depleted fires once)
events = []

Wallets.configuration.on_balance_depleted { |ctx| events << ctx }
wallet.debit(30, category: :purchase) # -20 → -50 (already depleted)

assert_empty events, "depleted is one-shot per crossing — going deeper into negative does not re-fire"
end

test "balance_depleted re-fires after a positive bounce-back" do
Wallets.configuration.allow_negative_balance = true
wallet = create_wallet(users(:new_user), asset_code: :wood, initial_balance: 100)
events = []

Wallets.configuration.on_balance_depleted { |ctx| events << ctx }

wallet.debit(150, category: :purchase) # 100 → -50, fires (1)
wallet.credit(80, category: :reward) # -50 → 30, no fire (credit)
wallet.debit(60, category: :purchase) # 30 → -30, fires (2)

assert_equal 2, events.size, "depleted fires once per fresh crossing of the positive→non-positive boundary"
assert_equal [ 100, 30 ], events.map(&:previous_balance)
assert_equal [ -50, -30 ], events.map(&:new_balance)
end

test "balance_depleted does not fire when a debit lands a wallet that was already non-positive" do
Wallets.configuration.allow_negative_balance = true
wallet = create_wallet(users(:new_user), asset_code: :wood, initial_balance: 0)
events = []

Wallets.configuration.on_balance_depleted { |ctx| events << ctx }
wallet.debit(40, category: :purchase) # 0 → -40, previous was already non-positive

assert_empty events, "previous balance must be strictly positive for depleted to fire"
end

test "low_balance_reached fires once when a debit drops a wallet below threshold and into negative" do
Wallets.configuration.allow_negative_balance = true
wallet = create_wallet(users(:new_user), asset_code: :gems, initial_balance: 200)
events = []

Wallets.configuration.low_balance_threshold = 50
Wallets.configuration.on_low_balance_reached { |ctx| events << ctx }

wallet.debit(300, category: :purchase) # 200 → -100, well below threshold

assert_equal 1, events.size
assert_equal 50, events.first.threshold
assert_equal 200, events.first.previous_balance
assert_equal(-100, events.first.new_balance)
end

test "low_balance_reached does not re-fire when an already-low wallet dips deeper" do
Wallets.configuration.allow_negative_balance = true
wallet = create_wallet(users(:new_user), asset_code: :gems, initial_balance: 30)
Wallets.configuration.low_balance_threshold = 50
events = []

Wallets.configuration.on_low_balance_reached { |ctx| events << ctx }

wallet.debit(80, category: :purchase) # 30 → -50, was already below threshold

assert_empty events
end

test "insufficient_balance fires before raising" do
wallet = wallets_wallets(:poor_coins_wallet)
events = []
Expand Down
Loading
Loading