wallets 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f59353d157eeea702a8a157b16914cba3f9fbb6dcaa8845a3ffdbd911fc51777
4
- data.tar.gz: e1c1d836b7a8013ad6a84da9aa0ba4924361b0d023797a265d15b8bde262d998
3
+ metadata.gz: dcdad28fc010051f982f84493c687649cd9f6af970774dedd13ec9ec76a24a1c
4
+ data.tar.gz: ec2ca091ea219646e77503ef8da39a0ce468b7ce63a7b7e46807174b83b3799a
5
5
  SHA512:
6
- metadata.gz: a1a2714ddd409c47b62efad44eafadce0381ce8843ab71bdbbab4c57f2858d03fdb62980e10be00854009f47405805c54cfada0e3f2837b82334128b19b4bd9a
7
- data.tar.gz: 21f39ed24795f1b78090c5f1b10e1009e0f42edaa48384c86f3812bf6ea9591ded673a0408df070d0909284bdaefb176ce42376677c70f5abc873e8614877d99
6
+ metadata.gz: 75ae215cb1c057a6aef302504c12113aa8271dc083c84b3e6635aeee9b2e2f64fef3cad3019ce3513008babd85e0b7dde031abe5f6cdd2ba9986e43e565d7016
7
+ data.tar.gz: e9219a6fe158109c11a54dc30572ca71bb866e190cb8c071cc97f59bfb40e8fb3de3d6448b7a78fa3b1978f6f406a82961526256aed7b5bd5642a11aed579ac3
data/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## [0.2.0] - 2026-05-03
2
+
3
+ ### Fixed
4
+
5
+ - `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))
6
+ - `: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.
7
+
8
+ ### Changed
9
+
10
+ - 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.
11
+ - `: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.
12
+
13
+ ### Documented
14
+
15
+ - `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.
16
+ - `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!`).
17
+ - `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.
18
+
1
19
  ## [0.1.0] - 2026-03-18
2
20
 
3
21
  Initial release.
data/README.md CHANGED
@@ -96,26 +96,53 @@ user.wallet(:gems).credit(5, category: :top_up)
96
96
 
97
97
  ## Example
98
98
 
99
+ Here is a more relatable money flow for a home-cleaning app:
100
+
99
101
  ```ruby
100
102
  class User < ApplicationRecord
101
- has_wallets default_asset: :eur
103
+ has_wallets default_asset: :eur_cents
104
+ end
105
+
106
+ class Platform < ApplicationRecord
107
+ has_wallets default_asset: :eur_cents
102
108
  end
103
109
 
104
- buyer = User.find(1)
105
- seller = User.find(2)
110
+ sara = User.find(1) # customer
111
+ lucia = User.find(2) # cleaner
112
+ platform = Platform.first
113
+
114
+ # Sara books a cleaning and tops up her wallet
115
+ sara.wallet.credit(10_000, category: :top_up, metadata: { source: "card" })
116
+
117
+ # Sara books a 2-hour cleaning for €60.00
118
+ booking_total = 6_000
119
+ platform_fee = 900
120
+ cleaner_payout = booking_total - platform_fee
106
121
 
107
- buyer.wallet(:eur).credit(10_000, category: :top_up, metadata: { source: "card" })
108
- buyer.wallet(:eur).debit(2_500, category: :purchase, metadata: { order_id: 42 })
122
+ # Sara pays the platform the gross booking price
123
+ charge = sara.wallet.transfer_to(
124
+ platform.wallet,
125
+ booking_total,
126
+ category: :cleaning_booking_charge,
127
+ metadata: { booking_id: 42, cleaner_id: lucia.id }
128
+ )
109
129
 
110
- buyer.wallet(:eur).transfer_to(
111
- seller.wallet(:eur),
112
- 1_800,
113
- category: :marketplace_sale,
114
- metadata: { order_id: 42 }
130
+ # The app later pays Lucia the net amount and keeps its fee
131
+ payout = platform.wallet.transfer_to(
132
+ lucia.wallet,
133
+ cleaner_payout,
134
+ category: :cleaner_payout,
135
+ metadata: {
136
+ booking_id: 42,
137
+ customer_id: sara.id,
138
+ gross_transfer_id: charge.id,
139
+ platform_fee: platform_fee
140
+ }
115
141
  )
116
142
 
117
- buyer.wallet(:wood).credit(50, category: :quest_reward)
118
- buyer.wallet(:wood).debit(10, category: :crafting)
143
+ # Query the linked transfer records later
144
+ charge.outbound_transaction
145
+ payout.inbound_transactions
119
146
  ```
120
147
 
121
148
  Amounts are always integers. For money, store the smallest unit like cents. For games, store whole resource units.
@@ -188,6 +215,23 @@ transfer.outbound_transaction
188
215
  transfer.inbound_transactions
189
216
  ```
190
217
 
218
+ `category:` on `transfer_to` describes the **business meaning of the transfer itself**. The ledger entries created underneath are still recorded as `transfer_out` and `transfer_in`, and the original transfer category is mirrored into transaction metadata as `transfer_category`.
219
+
220
+ That means you can query transfers directly for business flows:
221
+
222
+ ```ruby
223
+ user.wallet.outgoing_transfers.where(category: :peer_payment)
224
+ user.wallet.incoming_transfers.where(category: :peer_payment)
225
+ ```
226
+
227
+ Or query the ledger legs when you need transaction-level audit data:
228
+
229
+ ```ruby
230
+ user.wallet.history
231
+ .by_category(:transfer_in)
232
+ .where("metadata->>'transfer_category' = ?", "peer_payment")
233
+ ```
234
+
191
235
  Transfers require both wallets to use the same asset and the same wallet class. `:eur` can move to `:eur`; `:wood` can move to `:wood`; `Wallets::Wallet` cannot transfer directly to `UsageCredits::Wallet`.
192
236
 
193
237
  > [!NOTE]
@@ -201,6 +245,36 @@ Transfers require both wallets to use the same asset and the same wallet class.
201
245
  > sender.transfer_to(receiver, 100, expiration_policy: :fixed, expires_at: 30.days.from_now)
202
246
  > ```
203
247
 
248
+ ### Negative balances and overdraft
249
+
250
+ 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.
251
+
252
+ Flip one flag and it applies consistently to direct `wallet.debit` and to `wallet.transfer_to`:
253
+
254
+ ```ruby
255
+ Wallets.configure do |config|
256
+ config.allow_negative_balance = true
257
+ end
258
+
259
+ passenger.wallet(:eur).balance # => 100 (1€)
260
+ passenger.wallet(:eur).transfer_to(driver.wallet(:eur), 300, category: :ride_fare)
261
+ passenger.wallet(:eur).reload.balance # => -200 (-2€)
262
+ driver.wallet(:eur).reload.balance # => 300 (3€)
263
+ ```
264
+
265
+ A few things to know:
266
+
267
+ - **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`).
268
+ - **`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.
269
+ - **`: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.
270
+ - **`: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.
271
+ - **`: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.
272
+ - **`: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.
273
+ - **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).
274
+ - **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.
275
+ - **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.
276
+ - **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`.
277
+
204
278
  ### Expiring balances
205
279
 
206
280
  Credits can expire:
@@ -223,12 +297,13 @@ Create or edit `config/initializers/wallets.rb`:
223
297
  Wallets.configure do |config|
224
298
  config.default_asset = :coins
225
299
 
226
- # Useful for app-specific business events like games, marketplaces, or rewards.
300
+ # Useful for direct credit/debit events in your app.
301
+ # Transfer business categories live on Wallets::Transfer.category.
227
302
  config.additional_categories = %w[
228
303
  quest_reward
229
- marketplace_sale
230
- ride_fare
231
- peer_payment
304
+ provider_bonus
305
+ payout
306
+ promo_credit
232
307
  ]
233
308
 
234
309
  config.allow_negative_balance = false
@@ -414,50 +489,60 @@ player.wallet(:gold).transfer_to(
414
489
  )
415
490
  ```
416
491
 
417
- ### Marketplace with seller balances
492
+ ### Home services booking app
418
493
 
419
- An Etsy/Fiverr-style marketplace where sellers earn and can withdraw:
494
+ A home-services app where the platform collects the customer payment, keeps its fee, and later pays the provider:
420
495
 
421
496
  ```ruby
422
497
  class User < ApplicationRecord
423
498
  has_wallets default_asset: :usd_cents
424
499
  end
425
500
 
426
- # Order completed — credit seller (minus platform fee)
427
- order_total = 5000 # $50.00
428
- platform_fee = (order_total * 0.10).to_i # 10%
429
- seller_earnings = order_total - platform_fee
501
+ class Marketplace < ApplicationRecord
502
+ has_wallets default_asset: :usd_cents
503
+ end
430
504
 
431
- seller.wallet(:usd_cents).credit(
432
- seller_earnings,
433
- category: :sale,
505
+ sara = User.find(1) # customer
506
+ lucia = User.find(2) # cleaner
507
+ booking = Booking.find(42)
508
+ marketplace = Marketplace.first
509
+
510
+ # Booking completed
511
+ booking_total = 5000 # $50.00
512
+ platform_fee = (booking_total * 0.10).to_i # 10%
513
+ provider_earnings = booking_total - platform_fee
514
+
515
+ # Customer pays the marketplace
516
+ charge = sara.wallet(:usd_cents).transfer_to(
517
+ marketplace.wallet(:usd_cents),
518
+ booking_total,
519
+ category: :booking_charge,
434
520
  metadata: {
435
- order_id: order.id,
436
- gross_amount: order_total,
437
- platform_fee: platform_fee,
438
- buyer_id: buyer.id
521
+ booking_id: booking.id,
522
+ provider_id: lucia.id
439
523
  }
440
524
  )
441
525
 
442
- # Buyer uses store credit
443
- buyer.wallet(:usd_cents).debit(
444
- 2000,
445
- category: :purchase,
446
- metadata: { order_id: order.id }
526
+ # Marketplace pays the cleaner the net amount
527
+ payout = marketplace.wallet(:usd_cents).transfer_to(
528
+ lucia.wallet(:usd_cents),
529
+ provider_earnings,
530
+ category: :provider_payout,
531
+ metadata: {
532
+ booking_id: booking.id,
533
+ gross_amount: booking_total,
534
+ platform_fee: platform_fee,
535
+ customer_id: sara.id,
536
+ booking_charge_transfer_id: charge.id
537
+ }
447
538
  )
448
539
 
449
- # Seller requests payout
450
- seller.wallet(:usd_cents).debit(
451
- seller.wallet(:usd_cents).balance,
540
+ # Cleaner later cashes out to Stripe
541
+ lucia.wallet(:usd_cents).debit(
542
+ lucia.wallet(:usd_cents).balance,
452
543
  category: :payout,
453
544
  metadata: { stripe_transfer_id: "tr_xxx" }
454
545
  )
455
-
456
- # Transaction history for accounting
457
- seller.wallet(:usd_cents).history.each do |tx|
458
- puts "#{tx.created_at}: #{tx.category} #{tx.amount} cents"
459
- puts " Balance: #{tx.balance_before} → #{tx.balance_after}"
460
- end
461
546
  ```
462
547
 
463
548
  ### Loyalty programs & Reward points
data/context7.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "url": "https://context7.com/rameerez/wallets",
3
+ "public_key": "pk_HibNJE5rTFvy1txHHXUot"
4
+ }
@@ -17,6 +17,14 @@ Wallets.configure do |config|
17
17
  # config.table_prefix = "wallets_"
18
18
 
19
19
  # Set to true only if your domain explicitly supports debt or overdrafts.
20
+ # Applies consistently across `wallet.debit` and `wallet.transfer_to` — a
21
+ # debit or a transfer can drive the source wallet below zero. Cap how
22
+ # negative a wallet can go in your own service layer (the flag is binary).
23
+ #
24
+ # Heads up: this is meant to be a stable config decision, not a runtime
25
+ # toggle. Flipping it OFF while wallets are negative leaves those wallets
26
+ # un-saveable until you flip it back on. Drain or settle negative wallets
27
+ # before disabling.
20
28
  #
21
29
  # config.allow_negative_balance = false
22
30
 
@@ -49,7 +57,8 @@ Wallets.configure do |config|
49
57
  # on_balance_debited - After value is deducted from a wallet
50
58
  # on_transfer_completed - After a transfer between wallets succeeds
51
59
  # on_low_balance_reached - When balance drops below the threshold
52
- # on_balance_depleted - When balance reaches exactly zero
60
+ # on_balance_depleted - When a debit drives balance from positive
61
+ # to zero or below (one-shot per crossing)
53
62
  # on_insufficient_balance - When a debit or transfer is rejected
54
63
  #
55
64
  # Context object properties (available depending on event):
@@ -243,11 +243,50 @@ module Wallets
243
243
  lock_wallet_pair!(other_wallet)
244
244
 
245
245
  previous_balance = balance
246
- if amount > previous_balance
246
+
247
+ # Insufficient-balance gate.
248
+ #
249
+ # `allow_negative_balance` is the single source of truth for whether
250
+ # the gem permits a wallet to drop below zero. Direct `wallet.debit`
251
+ # has always honored it via `apply_debit`; transfers must too, or
252
+ # the flag is half-applied and surprising (debits go through, the
253
+ # canonical transfer primitive doesn't).
254
+ #
255
+ # When the flag is OFF, this is the only chance to refuse cleanly:
256
+ # we want to dispatch `:insufficient_balance` for the observability
257
+ # callback, then raise BEFORE creating the Transfer row. (The later
258
+ # `apply_debit` call would also catch this, but only after a
259
+ # pointless Transfer row was created.)
260
+ #
261
+ # When the flag is ON, we let the transfer through. `apply_debit`
262
+ # below will allocate as many positive buckets as exist; the
263
+ # remaining shortfall lands as an unbacked negative transaction the
264
+ # wallet's `balance` accounting accommodates via
265
+ # `unbacked_negative_balance`.
266
+ if amount > previous_balance && !allow_negative_balance?
247
267
  dispatch_insufficient_balance!(amount, previous_balance, metadata)
248
268
  raise InsufficientBalance, "Insufficient balance (#{previous_balance} < #{amount})"
249
269
  end
250
270
 
271
+ # When a transfer dips below zero we can't faithfully apply the
272
+ # `:preserve` expiration policy: there are no positive source
273
+ # buckets to inherit expirations from for the deficit portion.
274
+ # `build_preserved_transfer_inbound_credit_specs` would refuse with
275
+ # `InvalidTransfer` on the count mismatch, defeating the very
276
+ # callers who opted into negative balances. The only honest
277
+ # fallback is to collapse the inbound side to a single evergreen
278
+ # credit (`:none`) — value created without a source bucket has no
279
+ # source expiration to preserve.
280
+ #
281
+ # Callers who care about a specific expiration on the inbound side
282
+ # should pass `expiration_policy: :fixed, expires_at: …`. Those are
283
+ # honored unchanged because they don't depend on source-bucket
284
+ # walking. Same for an explicit `:none`.
285
+ if resolved_policy == "preserve" && amount > previous_balance
286
+ resolved_policy = "none"
287
+ inbound_expires_at = nil
288
+ end
289
+
251
290
  transfer = transfer_class.create!(
252
291
  from_wallet: self,
253
292
  to_wallet: other_wallet,
@@ -582,11 +621,21 @@ module Wallets
582
621
  )
583
622
  end
584
623
 
585
- if previous_balance.positive? && balance.zero?
624
+ # `:balance_depleted` fires when the wallet crosses from a positive
625
+ # balance to ZERO OR LOWER on this debit. We use `<= 0` rather than
626
+ # `== 0` so the callback still fires when `allow_negative_balance` is
627
+ # true and a single debit takes the wallet from e.g. +100 to -50
628
+ # (skipping exact zero). With negatives disabled, `balance` cannot
629
+ # go below zero, so `<= 0` collapses to `== 0` and the original
630
+ # "exactly zero" semantic is preserved for default-config users.
631
+ # Either way the callback is one-shot per crossing — going from -50
632
+ # to -80 doesn't re-fire because `previous_balance` was already
633
+ # non-positive.
634
+ if previous_balance.positive? && !balance.positive?
586
635
  dispatch_callback(:depleted,
587
636
  wallet: self,
588
637
  previous_balance: previous_balance,
589
- new_balance: 0
638
+ new_balance: balance
590
639
  )
591
640
  end
592
641
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wallets
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wallets
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-03-18 00:00:00.000000000 Z
10
+ date: 2026-05-03 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -50,6 +50,7 @@ files:
50
50
  - LICENSE.txt
51
51
  - README.md
52
52
  - Rakefile
53
+ - context7.json
53
54
  - lib/generators/wallets/install_generator.rb
54
55
  - lib/generators/wallets/templates/create_wallets_tables.rb.erb
55
56
  - lib/generators/wallets/templates/initializer.rb