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.
- checksums.yaml +7 -0
- data/.gitignore +36 -0
- data/.rubocop.yml +8 -0
- data/.simplecov +37 -0
- data/AGENTS.md +5 -0
- data/CHANGELOG.md +17 -0
- data/CLAUDE.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +740 -0
- data/Rakefile +30 -0
- data/lib/generators/wallets/install_generator.rb +43 -0
- data/lib/generators/wallets/templates/create_wallets_tables.rb.erb +113 -0
- data/lib/generators/wallets/templates/initializer.rb +87 -0
- data/lib/wallets/callback_context.rb +28 -0
- data/lib/wallets/callbacks.rb +53 -0
- data/lib/wallets/configuration.rb +119 -0
- data/lib/wallets/engine.rb +23 -0
- data/lib/wallets/models/allocation.rb +47 -0
- data/lib/wallets/models/concerns/has_wallets.rb +93 -0
- data/lib/wallets/models/transaction.rb +154 -0
- data/lib/wallets/models/transfer.rb +122 -0
- data/lib/wallets/models/wallet.rb +654 -0
- data/lib/wallets/railtie.rb +7 -0
- data/lib/wallets/version.rb +5 -0
- data/lib/wallets.rb +45 -0
- data/sig/wallets.rbs +3 -0
- data/wallets.webp +0 -0
- metadata +96 -0
data/README.md
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
# 💼 `wallets` - Add user wallets with money-like balances to your Rails app
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/wallets) [](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
|
+

|
|
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).
|