wallets 0.1.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.
data/README.md ADDED
@@ -0,0 +1,740 @@
1
+ # 💼 `wallets` - Add user wallets with money-like balances to your Rails app
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/wallets.svg)](https://badge.fury.io/rb/wallets) [![Build Status](https://github.com/rameerez/wallets/workflows/Tests/badge.svg)](https://github.com/rameerez/wallets/actions)
4
+
5
+ > [!TIP]
6
+ > **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=wallets)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=wallets)!
7
+
8
+ Allow your users to have wallets with money-like balances for value holding and transfering. `wallets` gives any Rails model money-like wallets backed by an append-only transaction ledger. You can use these wallets to store and transfer value in any "currency" (points inside your app, call minutes, in-game resources, in-app assets, etc.)
9
+
10
+ ![wallets](wallets.webp)
11
+
12
+ Use it for:
13
+
14
+ - **Rewards & loyalty points**: Cashback, points, store credit, referral bonuses
15
+ - **Marketplace balances**: Seller earnings, buyer credits, platform payouts
16
+ - **Gig economy**: Driver earnings, rider credits, tip wallets
17
+ - **Multi-currency balances**: EUR, USD, GBP wallets per user
18
+ - **Game resources**: Wood, stone, gems, gold, energy; any virtual economy
19
+ - **Telecom / SIM data plans**: "This plan gives you 10 GB per month, transfer unused data to friends"
20
+
21
+ At its core, `wallets` provides your users with: a wallet with balance, a log of transactions, expirable balances, and transfers between users.
22
+
23
+ For example, imagine you're building a SIM card app with data plans. At the beginning of each month, you give your users expirable data and call minutes:
24
+
25
+ ```ruby
26
+ user.wallet(:mb).credit(10_240, expires_at: month_end) # 10 GB in MB
27
+ user.wallet(:minutes).credit(500, expires_at: month_end) # 500 call minutes
28
+ ```
29
+
30
+ Users can transfer their unused balance to friends:
31
+
32
+ ```ruby
33
+ user.wallet(:mb).transfer_to(friend.wallet(:mb), 3_072) # Send 3 GB
34
+ ```
35
+
36
+ And balances decrease as they're consumed:
37
+
38
+ ```ruby
39
+ user.wallet(:mb).debit(512, category: :network_usage)
40
+ user.wallet(:mb).balance # => 6656 MB remaining
41
+ ```
42
+
43
+ > [!TIP]
44
+ > If you want to implement usage credits in your app, use the [`usage_credits`](https://github.com/rameerez/usage_credits) gem! It uses `wallets` under the hood, and on top provides very handy DX ergonomics for recurring credits fulfillment, credit pack purchases, `pay` integration for charging users for credits, etc. `wallets` sits at the core of the `usage_credits` gem. It's meant to handle a generalized version of any digital in-app currency, not just credits. If you don't know whether you should use the `wallets` gem or the `usage_credits` gem, check out the [`wallets` vs `usage_credits`](#wallets-vs-usage_credits--which-gem-do-i-need) section below.
45
+
46
+ ## Why this gem
47
+
48
+ `wallets` gives you more than `users.balance += 1`, but less than a full banking system:
49
+
50
+ | Feature | What it does |
51
+ |---------|--------------|
52
+ | **Multi-asset** | One wallet per asset: `user.wallet(:usd)`, `user.wallet(:gems)` |
53
+ | **Append-only ledger** | Every balance change is a transaction: no edits, only new entries |
54
+ | **FIFO allocation** | Debits consume oldest credits first (important for expiring balances) |
55
+ | **Linked transfers** | Both sides of a transfer are recorded and queryable |
56
+ | **Row-level locking** | Prevents race conditions and double-spending |
57
+ | **Balance snapshots** | Each transaction records before/after balance for reconciliation |
58
+ | **Rich metadata** | Attach any JSON to transactions for audit and filtering |
59
+
60
+ ## Quick start
61
+
62
+ Add the gem to your Gemfile:
63
+
64
+ ```ruby
65
+ gem "wallets"
66
+ ```
67
+
68
+ Then run:
69
+
70
+ ```bash
71
+ bundle install
72
+ rails generate wallets:install
73
+ rails db:migrate
74
+ ```
75
+
76
+ Add `has_wallets` to any model that should own wallets:
77
+
78
+ ```ruby
79
+ class User < ApplicationRecord
80
+ has_wallets default_asset: :coins
81
+ end
82
+ ```
83
+
84
+ That gives you:
85
+
86
+ ```ruby
87
+ user.wallet # => same as user.main_wallet
88
+ user.main_wallet # => wallet(:coins)
89
+
90
+ user.wallet(:coins).credit(100, category: :reward)
91
+ user.wallet(:coins).debit(25, category: :purchase)
92
+
93
+ user.wallet(:wood).credit(20, category: :quest_reward)
94
+ user.wallet(:gems).credit(5, category: :top_up)
95
+ ```
96
+
97
+ ## Example
98
+
99
+ ```ruby
100
+ class User < ApplicationRecord
101
+ has_wallets default_asset: :eur
102
+ end
103
+
104
+ buyer = User.find(1)
105
+ seller = User.find(2)
106
+
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 })
109
+
110
+ buyer.wallet(:eur).transfer_to(
111
+ seller.wallet(:eur),
112
+ 1_800,
113
+ category: :marketplace_sale,
114
+ metadata: { order_id: 42 }
115
+ )
116
+
117
+ buyer.wallet(:wood).credit(50, category: :quest_reward)
118
+ buyer.wallet(:wood).debit(10, category: :crafting)
119
+ ```
120
+
121
+ Amounts are always integers. For money, store the smallest unit like cents. For games, store whole resource units.
122
+
123
+ ## API
124
+
125
+ ### Owners
126
+
127
+ ```ruby
128
+ class User < ApplicationRecord
129
+ has_wallets default_asset: :credits
130
+ end
131
+ ```
132
+
133
+ Options:
134
+
135
+ - `default_asset:` asset returned by `user.wallet` and `user.main_wallet`
136
+ - `auto_create:` whether the main wallet should be created automatically
137
+ - `initial_balance:` optional starting balance for the auto-created main wallet
138
+
139
+ ### Lookup wallets
140
+
141
+ ```ruby
142
+ user.wallet # => default asset wallet
143
+ user.main_wallet # => same as user.wallet
144
+ user.wallet(:eur) # => auto-creates the EUR wallet if needed
145
+ user.wallet?(:gems) # => whether a wallet already exists
146
+ user.find_wallet(:usd) # => returns nil instead of auto-creating
147
+ ```
148
+
149
+ ### Credit and debit
150
+
151
+ ```ruby
152
+ wallet = user.wallet(:gems)
153
+
154
+ wallet.credit(100, category: :reward)
155
+ wallet.debit(20, category: :purchase)
156
+
157
+ wallet.balance
158
+ wallet.history
159
+ wallet.has_enough_balance?(50)
160
+ ```
161
+
162
+ Every transaction can carry metadata:
163
+
164
+ ```ruby
165
+ wallet.credit(
166
+ 500,
167
+ category: :top_up,
168
+ metadata: { source: "promo_campaign", campaign_id: 12 }
169
+ )
170
+ ```
171
+
172
+ ### Transfers
173
+
174
+ For internal app payments, transfers are the main primitive:
175
+
176
+ ```ruby
177
+ sender = user.wallet(:eur)
178
+ receiver = other_user.wallet(:eur)
179
+
180
+ transfer = sender.transfer_to(
181
+ receiver,
182
+ 2_000,
183
+ category: :peer_payment,
184
+ metadata: { message: "Dinner split" }
185
+ )
186
+
187
+ transfer.outbound_transaction
188
+ transfer.inbound_transactions
189
+ ```
190
+
191
+ 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
+
193
+ > [!NOTE]
194
+ > **Transfer expiration behavior:** Transfers preserve expiration buckets by default. If a single transfer consumes multiple source buckets with different expirations, the receiver gets multiple inbound credit transactions so those expirations remain intact.
195
+ >
196
+ > You can override that per transfer:
197
+ >
198
+ > ```ruby
199
+ > sender.transfer_to(receiver, 100, expiration_policy: :none) # evergreen on receive
200
+ > sender.transfer_to(receiver, 100, expires_at: 30.days.from_now) # fixed expiration on receive
201
+ > sender.transfer_to(receiver, 100, expiration_policy: :fixed, expires_at: 30.days.from_now)
202
+ > ```
203
+
204
+ ### Expiring balances
205
+
206
+ Credits can expire:
207
+
208
+ ```ruby
209
+ user.wallet(:coins).credit(
210
+ 1_000,
211
+ category: :season_reward,
212
+ expires_at: 30.days.from_now
213
+ )
214
+ ```
215
+
216
+ Debits allocate against the oldest available, non-expired credits first.
217
+
218
+ ## Configuration
219
+
220
+ Create or edit `config/initializers/wallets.rb`:
221
+
222
+ ```ruby
223
+ Wallets.configure do |config|
224
+ config.default_asset = :coins
225
+
226
+ # Useful for app-specific business events like games, marketplaces, or rewards.
227
+ config.additional_categories = %w[
228
+ quest_reward
229
+ marketplace_sale
230
+ ride_fare
231
+ peer_payment
232
+ ]
233
+
234
+ config.allow_negative_balance = false
235
+ config.low_balance_threshold = 50
236
+ config.transfer_expiration_policy = :preserve
237
+ end
238
+ ```
239
+
240
+ ## Callbacks
241
+
242
+ `wallets` ships with lifecycle callbacks you can use for notifications, analytics, or product logic.
243
+
244
+ ```ruby
245
+ Wallets.configure do |config|
246
+ config.on_balance_credited do |ctx|
247
+ Rails.logger.info("Wallet #{ctx.wallet.id} credited by #{ctx.amount}")
248
+ end
249
+
250
+ config.on_balance_debited do |ctx|
251
+ Rails.logger.info("Wallet #{ctx.wallet.id} debited by #{ctx.amount}")
252
+ end
253
+
254
+ config.on_transfer_completed do |ctx|
255
+ Rails.logger.info("Transfer #{ctx.transfer.id} completed")
256
+ end
257
+
258
+ config.on_low_balance_reached do |ctx|
259
+ UserMailer.low_balance(ctx.wallet.owner).deliver_later
260
+ end
261
+
262
+ config.on_insufficient_balance do |ctx|
263
+ Rails.logger.warn("Insufficient balance: #{ctx.metadata[:required]}")
264
+ end
265
+ end
266
+ ```
267
+
268
+ Useful fields on `ctx` include:
269
+
270
+ - `ctx.wallet`
271
+ - `ctx.transfer`
272
+ - `ctx.amount`
273
+ - `ctx.previous_balance`
274
+ - `ctx.new_balance`
275
+ - `ctx.transaction`
276
+ - `ctx.category`
277
+ - `ctx.metadata`
278
+
279
+ ## wallets vs usage_credits — which gem do I need?
280
+
281
+ Both gems handle balances, but they solve different problems:
282
+
283
+ ```
284
+ ┌─────────────────────────────────────────────────────────────────┐
285
+ │ usage_credits │
286
+ │ ┌───────────────────────────────────────────────────────────┐ │
287
+ │ │ Subscriptions, Credit Packs, Pay Intgration, Fulfillment │ │
288
+ │ │ Operations DSL, Pricing, Refunds, Webhook Handling │ │
289
+ │ └───────────────────────────────────────────────────────────┘ │
290
+ │ │ │
291
+ │ ▼ │
292
+ │ ┌───────────────────────────────────────────────────────────┐ │
293
+ │ │ wallets │ │
294
+ │ │ Balance, Credit, Debit, Transfer, Expiration, FIFO, │ │
295
+ │ │ Audit Trail, Row-Level Locking, Multi-Asset │ │
296
+ │ └───────────────────────────────────────────────────────────┘ │
297
+ └─────────────────────────────────────────────────────────────────┘
298
+ ```
299
+
300
+ | Aspect | `wallets` | `usage_credits` |
301
+ |--------|-----------|-----------------|
302
+ | **Core job** | Store and move value | Sell and consume value |
303
+ | **Balance model** | Multi-asset (`:gb`, `:eur`, `:gems`) | Single asset (credits) |
304
+ | **Consumption** | Passive — balance depletes over time | Active — `spend_credits_on(:operation)` |
305
+ | **Transfers** | Built-in between users | Not designed for this |
306
+ | **Subscriptions** | You handle externally | Built-in with Stripe via `pay` |
307
+ | **Operations DSL** | None | `operation :send_email { costs 1.credit }` |
308
+ | **Best for** | B2C: games, telecom, rewards, marketplaces | B2B: SaaS, APIs, AI apps |
309
+
310
+ ### When to use `wallets` alone
311
+
312
+ Use `wallets` directly when your product:
313
+ - Needs **multiple asset types** — `user.wallet(:wood)`, `user.wallet(:gold)`, `user.wallet(:eur)`
314
+ - Has **passive consumption** — balance depletes from usage over time (data, minutes, energy)
315
+ - Needs **user-to-user transfers** — gifting, P2P payments, marketplace settlements
316
+ - Manages its own subscription logic — or doesn't need subscriptions at all
317
+
318
+ ### When to use `usage_credits`
319
+
320
+ Use `usage_credits` when your product:
321
+ - Sells **credits for specific operations** — "Process image costs 10 credits"
322
+ - Needs **Stripe subscriptions** with automatic credit fulfillment
323
+ - Wants the **operations DSL** — `spend_credits_on(:generate_report)`
324
+ - Is a **B2B/SaaS/API product** with usage-based pricing
325
+
326
+ ### When to use both together
327
+
328
+ For products like a **SIM/telecom app**, you might use both:
329
+
330
+ ```ruby
331
+ # usage_credits handles ACQUISITION (how users get balance)
332
+ subscription_plan :basic_data do
333
+ stripe_price "price_xyz"
334
+ gives 10_000.credits.every(:month) # 10 GB in MB
335
+ end
336
+
337
+ # wallet-level movement is still available underneath usage_credits
338
+ user.credit_wallet.transfer_to(friend.credit_wallet, 3_000) # Gift 3 GB
339
+ user.credit_wallet.balance # => 7000 MB remaining
340
+ ```
341
+
342
+ > [!TIP]
343
+ > `usage_credits` uses `wallets` as its ledger core. If you only need `usage_credits`, you get `wallets` for free underneath. Wallet-level methods like `user.credit_wallet.transfer_to(...)` are still available there, but the transfer DX intentionally lives at the wallet layer rather than the credits DSL.
344
+
345
+ ## Real-world examples
346
+
347
+ ### Telecom / Mobile data app
348
+
349
+ A SIM card app where users get monthly data and can transfer unused GBs to friends:
350
+
351
+ ```ruby
352
+ class User < ApplicationRecord
353
+ has_wallets default_asset: :data_mb # Store in MB for precision
354
+ end
355
+
356
+ # Monthly plan grants 10 GB (stored as 10,240 MB)
357
+ user.wallet(:data_mb).credit(
358
+ 10_240,
359
+ category: :monthly_plan,
360
+ expires_at: 1.month.from_now,
361
+ metadata: { plan: "basic", period: "2024-03" }
362
+ )
363
+
364
+ # Network usage consumes data passively
365
+ user.wallet(:data_mb).debit(512, category: :network_usage)
366
+
367
+ # User transfers 3 GB to a friend
368
+ user.wallet(:data_mb).transfer_to(
369
+ friend.wallet(:data_mb),
370
+ 3_072,
371
+ category: :gift,
372
+ metadata: { message: "Here's some extra data!" }
373
+ )
374
+
375
+ user.wallet(:data_mb).balance # => 6656 MB (6.5 GB remaining)
376
+ ```
377
+
378
+ > [!NOTE]
379
+ > Store data in the smallest practical unit (MB or KB, not GB as a float). `wallets` uses integers to avoid floating-point issues.
380
+
381
+ ### Game economy
382
+
383
+ A farming/strategy game with multiple resources:
384
+
385
+ ```ruby
386
+ class Player < ApplicationRecord
387
+ has_wallets default_asset: :gold
388
+ end
389
+
390
+ # Quest rewards multiple resources
391
+ player.wallet(:wood).credit(100, category: :quest_reward, metadata: { quest: "forest_patrol" })
392
+ player.wallet(:stone).credit(50, category: :quest_reward)
393
+ player.wallet(:gold).credit(25, category: :quest_reward)
394
+
395
+ # Crafting consumes resources
396
+ player.wallet(:wood).debit(30, category: :crafting, metadata: { item: "wooden_sword" })
397
+
398
+ # Premium currency from in-app purchase
399
+ player.wallet(:gems).credit(500, category: :purchase, metadata: { sku: "gem_pack_500" })
400
+
401
+ # Seasonal event with expiring currency
402
+ player.wallet(:snowflakes).credit(
403
+ 1_000,
404
+ category: :event_reward,
405
+ expires_at: Date.new(2024, 1, 7) # Winter event ends
406
+ )
407
+
408
+ # Trading between players
409
+ player.wallet(:gold).transfer_to(
410
+ other_player.wallet(:gold),
411
+ 100,
412
+ category: :trade,
413
+ metadata: { item_received: "rare_armor" }
414
+ )
415
+ ```
416
+
417
+ ### Marketplace with seller balances
418
+
419
+ An Etsy/Fiverr-style marketplace where sellers earn and can withdraw:
420
+
421
+ ```ruby
422
+ class User < ApplicationRecord
423
+ has_wallets default_asset: :usd_cents
424
+ end
425
+
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
430
+
431
+ seller.wallet(:usd_cents).credit(
432
+ seller_earnings,
433
+ category: :sale,
434
+ metadata: {
435
+ order_id: order.id,
436
+ gross_amount: order_total,
437
+ platform_fee: platform_fee,
438
+ buyer_id: buyer.id
439
+ }
440
+ )
441
+
442
+ # Buyer uses store credit
443
+ buyer.wallet(:usd_cents).debit(
444
+ 2000,
445
+ category: :purchase,
446
+ metadata: { order_id: order.id }
447
+ )
448
+
449
+ # Seller requests payout
450
+ seller.wallet(:usd_cents).debit(
451
+ seller.wallet(:usd_cents).balance,
452
+ category: :payout,
453
+ metadata: { stripe_transfer_id: "tr_xxx" }
454
+ )
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
+ ```
462
+
463
+ ### Loyalty programs & Reward points
464
+
465
+ Whether you're building a Starbucks-style loyalty program, credit card rewards, airline miles, or a Sweatcoin-style earn-from-actions app — it's the same pattern:
466
+
467
+ ```
468
+ ┌─────────────────────────────────────────────────────────────┐
469
+ │ Loyalty program flow │
470
+ ├─────────────────────────────────────────────────────────────┤
471
+ │ EARN │ Purchase, action, referral, promo │
472
+ │ HOLD │ Points accumulate, some may expire │
473
+ │ TRANSFER │ Gift to family, pool with friends │
474
+ │ REDEEM │ Rewards, discounts, gift cards │
475
+ └─────────────────────────────────────────────────────────────┘
476
+ ```
477
+
478
+ ```ruby
479
+ class User < ApplicationRecord
480
+ has_wallets default_asset: :points
481
+ end
482
+
483
+ # ═══════════════════════════════════════════════════════════
484
+ # EARN — from purchases, actions, referrals
485
+ # ═══════════════════════════════════════════════════════════
486
+
487
+ # Points from purchase (1 point per dollar)
488
+ user.wallet(:points).credit(
489
+ order.total_cents / 100,
490
+ category: :purchase,
491
+ metadata: { order_id: order.id }
492
+ )
493
+
494
+ # Bonus points for specific products
495
+ user.wallet(:points).credit(150, category: :bonus_item, metadata: { sku: "featured_product" })
496
+
497
+ # Referral bonus
498
+ user.wallet(:points).credit(500, category: :referral, metadata: { referred_user_id: friend.id })
499
+
500
+ # Daily check-in streaks
501
+ user.wallet(:points).credit(50 * streak_multiplier, category: :daily_checkin)
502
+
503
+ # Receipt scanning (Ibotta-style)
504
+ user.wallet(:points).credit(100, category: :receipt_scan, metadata: { receipt_id: 123 })
505
+
506
+ # ═══════════════════════════════════════════════════════════
507
+ # EXPIRING PROMOS — use-it-or-lose-it campaigns
508
+ # ═══════════════════════════════════════════════════════════
509
+
510
+ # Welcome bonus that expires in 30 days
511
+ user.wallet(:points).credit(
512
+ 500,
513
+ category: :welcome_bonus,
514
+ expires_at: 30.days.from_now
515
+ )
516
+
517
+ # Double points weekend (expires Monday)
518
+ user.wallet(:points).credit(
519
+ 200,
520
+ category: :promo,
521
+ expires_at: Date.current.next_occurring(:monday),
522
+ metadata: { campaign: "double_points_weekend" }
523
+ )
524
+
525
+ # Birthday reward
526
+ user.wallet(:points).credit(
527
+ 1000,
528
+ category: :birthday,
529
+ expires_at: 1.month.from_now,
530
+ metadata: { birthday_year: Date.current.year }
531
+ )
532
+
533
+ # ═══════════════════════════════════════════════════════════
534
+ # TRANSFER — gift to friends, pool with family
535
+ # ═══════════════════════════════════════════════════════════
536
+
537
+ # Gift points to another member
538
+ user.wallet(:points).transfer_to(
539
+ friend.wallet(:points),
540
+ 500,
541
+ category: :gift,
542
+ metadata: { message: "Happy birthday!" }
543
+ )
544
+
545
+ # Family pooling (multiple transfers to a shared account)
546
+ family_members.each do |member|
547
+ member.wallet(:points).transfer_to(
548
+ family_pool.wallet(:points),
549
+ member.wallet(:points).balance,
550
+ category: :family_pool
551
+ )
552
+ end
553
+
554
+ # ═══════════════════════════════════════════════════════════
555
+ # REDEEM — rewards, discounts, cash out
556
+ # ═══════════════════════════════════════════════════════════
557
+
558
+ # Redeem for a reward
559
+ user.wallet(:points).debit(
560
+ 2500,
561
+ category: :redemption,
562
+ metadata: { reward: "free_coffee", reward_id: 42 }
563
+ )
564
+
565
+ # Redeem for statement credit / gift card
566
+ user.wallet(:points).debit(
567
+ 10_000,
568
+ category: :cash_out,
569
+ metadata: { gift_card_code: "XXXX-YYYY", value_cents: 1000 }
570
+ )
571
+
572
+ # Partial redemption with points + cash
573
+ points_portion = 500
574
+ user.wallet(:points).debit(
575
+ points_portion,
576
+ category: :partial_redemption,
577
+ metadata: { order_id: order.id, points_value_cents: points_portion }
578
+ )
579
+ ```
580
+
581
+ **Loyalty-specific patterns:**
582
+
583
+ | Pattern | Implementation |
584
+ |---------|----------------|
585
+ | **Tiered earning** | `credit(amount * tier_multiplier, ...)` |
586
+ | **Points expiration** | `expires_at: 1.year.from_now` |
587
+ | **Family pooling** | `transfer_to` family wallet |
588
+ | **Gifting** | `transfer_to` friend's wallet |
589
+ | **Earn + burn in one transaction** | `debit` points, `credit` new promo points |
590
+ | **Points + cash** | `debit` points portion, charge card for remainder |
591
+
592
+ **Real-world examples this pattern fits:**
593
+
594
+ - Starbucks Stars
595
+ - Airline miles (Delta SkyMiles, United MileagePlus)
596
+ - Credit card points (Chase Ultimate Rewards, Amex MR)
597
+ - Hotel points (Marriott Bonvoy, Hilton Honors)
598
+ - Retail loyalty (Sephora Beauty Insider, REI Co-op)
599
+ - Cashback apps (Rakuten, Ibotta, Fetch)
600
+ - Fitness rewards (Sweatcoin, Stepn)
601
+
602
+ ### Gig economy / Driver earnings
603
+
604
+ An Uber/DoorDash-style app with earnings and tips:
605
+
606
+ ```ruby
607
+ class Driver < ApplicationRecord
608
+ has_wallets default_asset: :usd_cents
609
+ end
610
+
611
+ # Ride completed
612
+ driver.wallet(:usd_cents).credit(
613
+ 1250, # $12.50 base fare
614
+ category: :ride_fare,
615
+ metadata: { ride_id: ride.id, distance_miles: 5.2 }
616
+ )
617
+
618
+ # Tip added later
619
+ driver.wallet(:usd_cents).credit(
620
+ 300, # $3.00 tip
621
+ category: :tip,
622
+ metadata: { ride_id: ride.id, rider_id: rider.id }
623
+ )
624
+
625
+ # Weekly payout
626
+ driver.wallet(:usd_cents).debit(
627
+ driver.wallet(:usd_cents).balance,
628
+ category: :weekly_payout,
629
+ metadata: { payout_date: Date.current, bank_account: "****1234" }
630
+ )
631
+ ```
632
+
633
+ ## Perfect use cases
634
+
635
+ `wallets` is best for **closed-loop value** inside your app — where the app itself is the source of truth.
636
+
637
+ | Use case | Example | Why `wallets` fits |
638
+ |----------|---------|-------------------|
639
+ | **Telecom / data plans** | Mobile data that users can share | Multi-asset (`:data_mb`, `:sms`, `:minutes`), transfers, expiration |
640
+ | **Game economies** | FarmVille, Fortnite, OGame | Multiple resources, trading between players |
641
+ | **Marketplaces** | Etsy, Fiverr, Airbnb | Seller earnings, buyer credits, platform settlements |
642
+ | **Rewards / loyalty** | Sweatcoin, credit card points | Points from actions, expiring promos, redemptions |
643
+ | **Gig economy** | Uber, DoorDash | Driver earnings, tips, scheduled payouts |
644
+ | **Multi-currency** | Travel apps, international platforms | Per-currency wallets (`:eur`, `:usd`, `:gbp`) |
645
+ | **Store credit** | Gift cards, refund credits | Simple balance with full audit trail |
646
+
647
+ **Key signals that `wallets` is the right fit:**
648
+ - Users hold **multiple types of value** (not just one "credits" balance)
649
+ - Users **transfer value to each other** (gifts, trades, P2P payments)
650
+ - Value **expires** (promotional credits, seasonal currencies, data rollovers)
651
+ - You need a **full audit trail** (not just a cached integer)
652
+ - The app is the **source of truth** (not syncing with external ledgers)
653
+
654
+ ## When NOT to use `wallets`
655
+
656
+ ### Use `usage_credits` instead if:
657
+
658
+ - You're building a **SaaS/API product** with usage-based pricing
659
+ - You need **Stripe subscriptions** with automatic credit fulfillment
660
+ - You want an **operations DSL** like `spend_credits_on(:generate_report)`
661
+ - Your users **buy credits to perform specific actions** (not hold transferable balances)
662
+
663
+ See [usage_credits](https://github.com/rameerez/usage_credits) — it uses `wallets` underneath.
664
+
665
+ ### Use something else entirely if:
666
+
667
+ `wallets` is the wrong abstraction when the hard part is external money movement, regulation, or accounting-grade settlement:
668
+
669
+ - **Banking infrastructure** — transfers to/from bank rails, cards, ACH, SEPA
670
+ - **Regulated stored-value** — KYC, AML, licensing, custody requirements
671
+ - **Escrow systems** — pending, available, reserved, delayed-release states
672
+ - **FX conversion** — multi-currency conversion with exchange rates
673
+ - **Full accounting** — charts of accounts, journal entries, financial reporting
674
+ - **Blockchain/crypto** — consensus, custody, cryptographic guarantees
675
+
676
+ ### Skip both gems if:
677
+
678
+ - You just need **one cached integer** (`users.balance += 1`) and don't care about history, audits, or transfers
679
+ - Your "balance" is just a counter for display purposes
680
+
681
+ **Rule of thumb:**
682
+ - "How do I track balances and transfers inside my app?" → `wallets`
683
+ - "How do I sell credits for API/SaaS operations?" → `usage_credits`
684
+ - "How do I build payments infrastructure?" → Neither (you need a banking partner)
685
+
686
+ ## Is this production-ready?
687
+
688
+ Yes, this is production-ready for internal app balances and user-to-user value transfer inside your product. It is substantially more trustworthy than a single integer column because it gives you an append-only ledger, FIFO allocation, linked transfer records, balance snapshots, and row-level locking.
689
+
690
+ In practice, that means you get:
691
+
692
+ - a full transaction history instead of just a cached balance
693
+ - FIFO consumption of the oldest available balance buckets
694
+ - linked debit/credit records for transfers between users
695
+ - concurrency protection when multiple writes hit the same wallet
696
+ - enough structure to support marketplace balances, peer payments, rewards, and in-game assets inside a real production app
697
+
698
+ If your product needs users to hold value, earn value, spend value, or transmit value to other users inside your own app, this is the sort of foundation you want instead of `users.balance += 1`.
699
+
700
+ ## Can it support payments between users?
701
+
702
+ Yes. `transfer_to` lets you move value between users while keeping both sides of the movement linked in the ledger. That makes it suitable for peer payments, marketplace payouts, seller balances, rewards, and in-game trades inside your own app.
703
+
704
+ But it is not a blockchain and not a full payments stack.
705
+
706
+ What it does not do for you:
707
+
708
+ - external settlement to banks or cards
709
+ - KYC/AML/compliance
710
+ - escrow, reserves, or held balances
711
+ - FX conversion between assets
712
+ - disputes, chargebacks, or processor reconciliation
713
+ - cryptographic consensus or custody guarantees
714
+
715
+ So the right framing is: strong internal wallet/accounting primitive, not money infrastructure by itself.
716
+
717
+ ## TODO
718
+
719
+ - First-class transfer reversal/refund API built on compensating ledger entries
720
+ - Optional pending/held balance primitives for escrow-like flows
721
+ - Multi-step transfer policies beyond `:preserve`, `:none`, and fixed `expires_at`
722
+
723
+ ## Development
724
+
725
+ Run the test suite:
726
+
727
+ ```bash
728
+ bundle exec rake test
729
+ ```
730
+
731
+ Run a specific appraisal:
732
+
733
+ ```bash
734
+ bundle exec appraisal rails-7.2 rake test
735
+ bundle exec appraisal rails-8.1 rake test
736
+ ```
737
+
738
+ ## License
739
+
740
+ This project is available as open source under the terms of the [MIT License](LICENSE.txt).