Releases: rameerez/wallets
v0.2.0
Highlights
This release fixes two bugs in Wallets.configuration.allow_negative_balance handling and pins the documented edge-case behavior with explicit tests + inline comments. Default-config users (the flag stays false) see no behavior change.
🐞 Wallet#transfer_to now honors allow_negative_balance
Previously the flag was half-applied: direct wallet.debit honored it via apply_debit, but wallet.transfer_to (the canonical user-to-user primitive) had its own balance pre-check that always rejected transfers below zero — regardless of the flag. Apps using the flag for a "convenience overdraft" (ride-fare apps where passengers briefly go negative until rewards land, family wallets with shared spend, telecom plans with bridging credit) had to monkey-patch the gem.
What's new:
transfer_tonow goes through when the flag is on, driving the source wallet negative the same waydebitalready could.- When the transfer drives the source below zero AND the caller is using the default
:preserveexpiration policy, the policy automatically falls back to:nonefor that transfer. There are no positive source buckets to inherit expirations from for the deficit portion. The receiver gets a single evergreen credit. Explicit:fixed(withexpires_at:) and explicit:noneare honored unchanged. - The
:insufficient_balancecallback no longer fires for successful overdraft transfers. It still fires (and the transfer still raisesWallets::InsufficientBalance) when the flag is off and the transfer is rejected.
🐞 :balance_depleted callback fires on positive→non-positive crossings
Previously fired only on exact zero. With allow_negative_balance = true, a single debit could take a wallet from +100 to -50 — skipping zero entirely — and the callback was silently missing. Widened the condition from balance.zero? to !balance.positive?. With the flag off, balances can't go below zero, so the new condition collapses to "exactly zero" and existing callers see no behavior change.
📌 Documented behavior, now pinned by tests
Audit pass added explicit coverage and inline comments for the subtleties that were correct already but easy to break:
- Compounding debits accumulate
unbacked_amountper row (gem doesn't spread debt). - A credit on a negative-balance wallet does not auto-allocate against existing unbacked debits. Both ledger entries persist;
balancereconciles them. FIFO consumption on the next debit pulls from the new credit, not the old debt. has_enough_balance?keeps strict semantics under the flag — it answers "do you have it on hand?", not "would the gem accept this debit?". Overdraft is a deliberate caller choice.- Flipping
allow_negative_balanceOFF while wallets are below zero leaves them un-saveable until you flip it back on (the model'sbalance >= 0validation is gated on the flag, andrefresh_cached_balance!callssave!on every credit/debit). The flag is meant to be a stable config decision, not a runtime toggle. :low_balance_reachedis one-shot per crossing — going from -50 to -80 doesn't re-fire because the wallet was already low; coming back up to +200 then dipping low again does fire.- Concurrent overdraft debits on the same wallet serialize through
with_lock(FOR UPDATE); concurrent transfers serialize throughlock_wallet_pair!.
The README gains a Negative balances and overdraft section covering all of the above.
Compatibility
- The gem default for
allow_negative_balanceis stillfalse. Apps that haven't opted in see no behavior change. - Apps that previously caught
Wallets::InsufficientBalancefromtransfer_toafter flipping the flag on (i.e. were aware of the half-applied behavior and relied on it as a guardrail) need to know transfers will now go through. Layer an app-level floor policy in front oftransfer_toif you want a cap — the gem flag is binary by design. - Successful overdraft transfers no longer fire
:insufficient_balance. Apps that hooked the callback to nudge users to top up after a failed attempt will still see those events when the flag is off (or when their own policy refuses); they will correctly stop seeing them for transfers that actually went through.
Test coverage
22 new tests across transfer_test.rb, wallet_test.rb, wallet_callbacks_test.rb. Existing tests covering the old "transfers always reject below zero" and "depleted = exactly zero" semantics are amended to reflect the new contracts.
| Run | Result |
|---|---|
bundle exec rake test |
92 / 338 assertions, all green |
gemfiles/rails_7.2.gemfile |
92 / 338, all green |
gemfiles/rails_8.1.gemfile |
92 / 338, all green |
| Postgres + MySQL + SQLite (3.3, 3.4, 4.0) on CI | all green |
What's Changed
What's Changed
- Negative-balance correctness: fix transfer_to + :balance_depleted, audit + pin documented behavior by @rameerez in #3
Full Changelog: v0.1.0...v0.2.0
v0.1.0
Initial Release
- Multi-asset wallets per owner via
has_wallets—user.wallet(:usd),user.wallet(:gems), etc. - Append-only transaction ledger with
credit,debit, andtransfer_toAPIs - FIFO allocation for expiring balances — oldest credits consumed first
- Transfer expiration policies:
:preserve(default),:none,:fixed - Transfers split into multiple inbound legs when consuming buckets with different expirations
- Embeddability hooks for other gems to reuse the ledger core with custom tables/config/callbacks
- Idempotent
create_for_owner!with race condition handling - Row-level locking to prevent double-spending
- Balance snapshots on every transaction for reconciliation
- Rich metadata support on wallets, transactions, and transfers
- Lifecycle callbacks:
on_balance_credited,on_balance_debited,on_transfer_completed, etc. - Install generator with migrations and initializer
- Rails 6.1+ support (tested through Rails 8.x)
What's Changed
- Add embeddability hooks and transfer expiration policies (v0.2.0) by @rameerez in #1
- Make create_for_owner! idempotent and race-safe by @rameerez in #2
New Contributors
Full Changelog: https://github.com/rameerez/wallets/commits/v0.1.0