token_ledger 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/CHANGELOG.md +11 -0
- data/LICENSE +21 -0
- data/README.md +1208 -0
- data/lib/generators/token_ledger/install/install_generator.rb +44 -0
- data/lib/generators/token_ledger/install/templates/README +41 -0
- data/lib/generators/token_ledger/install/templates/add_cached_balance_to_owner.rb +8 -0
- data/lib/generators/token_ledger/install/templates/create_ledger_tables.rb +59 -0
- data/lib/token_ledger/errors.rb +9 -0
- data/lib/token_ledger/models/ledger_account.rb +21 -0
- data/lib/token_ledger/models/ledger_entry.rb +13 -0
- data/lib/token_ledger/models/ledger_transaction.rb +20 -0
- data/lib/token_ledger/services/account.rb +9 -0
- data/lib/token_ledger/services/balance.rb +49 -0
- data/lib/token_ledger/services/manager.rb +342 -0
- data/lib/token_ledger/version.rb +5 -0
- data/lib/token_ledger.rb +16 -0
- data/sig/token_ledger.rbs +4 -0
- metadata +134 -0
data/README.md
ADDED
|
@@ -0,0 +1,1208 @@
|
|
|
1
|
+
# TokenLedger
|
|
2
|
+
|
|
3
|
+
A double-entry accounting ledger for managing token balances in Ruby on Rails applications. Provides atomic transactions, idempotency, audit trails, and thread-safe operations.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Double-entry accounting** - Every transaction is balanced (debits = credits)
|
|
8
|
+
- **Atomic operations** - All-or-nothing transactions with automatic rollback
|
|
9
|
+
- **Thread-safe** - Pessimistic locking (`lock!`) on account rows prevents race conditions and overdrafts
|
|
10
|
+
- **Idempotency** - Duplicate transaction prevention using external IDs
|
|
11
|
+
- **Audit trail** - Complete transaction history with metadata
|
|
12
|
+
- **Reserve/Capture/Release** - Handle external API calls safely
|
|
13
|
+
- **Polymorphic owners** - Support multiple owner types (User, Team, etc.)
|
|
14
|
+
- **Balance caching** - Fast balance lookups with reconciliation tools
|
|
15
|
+
|
|
16
|
+
## Double-Entry Accounting Fundamentals
|
|
17
|
+
|
|
18
|
+
TokenLedger implements traditional double-entry accounting with explicit semantics.
|
|
19
|
+
|
|
20
|
+
### Core Invariants
|
|
21
|
+
|
|
22
|
+
1. **`ledger_entries.amount`** - Always a positive integer (never zero, never negative). Enforced by database CHECK constraint.
|
|
23
|
+
2. **`entry_type`** - Either `"debit"` or `"credit"` (no other values allowed). Enforced by database CHECK constraint.
|
|
24
|
+
3. **Balance Formula** - `balance = sum(debits) - sum(credits)` (asset-style accounting)
|
|
25
|
+
4. **Account Balance** - `LedgerAccount.current_balance` uses the same formula as `Balance.calculate`
|
|
26
|
+
5. **Integer-Only Amounts** - TokenLedger operates strictly on **positive integers**. If your tokens have decimal values (e.g., $10.50), you must store them in base units/cents (e.g., 1050) and format them in the view layer. Never use floats for financial amounts.
|
|
27
|
+
|
|
28
|
+
### Account Types and Normal Balances
|
|
29
|
+
|
|
30
|
+
**Accounting Perspective:** These accounts are modeled from the **token holder's perspective**. A User Wallet is treated as an **Asset** (the user owns the tokens). From the platform's perspective, user balances are technically liabilities, but for clarity and intuition, we model them as assets from the user's viewpoint.
|
|
31
|
+
|
|
32
|
+
**Asset accounts** (wallets, reserved): Normal balance is DEBIT (positive)
|
|
33
|
+
- Increase with debits
|
|
34
|
+
- Decrease with credits
|
|
35
|
+
- Examples: `wallet:user_123`, `wallet:user_123:reserved`
|
|
36
|
+
|
|
37
|
+
**Liability accounts** (sources): Normal balance is CREDIT (typically negative under debits-minus-credits)
|
|
38
|
+
- Increase with credits
|
|
39
|
+
- Decrease with debits
|
|
40
|
+
- Examples: `source:stripe`, `source:promo`
|
|
41
|
+
- Represents the system's liability to the token issuer
|
|
42
|
+
|
|
43
|
+
**Expense/Consumption accounts** (sinks): Normal balance is DEBIT (positive)
|
|
44
|
+
- Increase with debits
|
|
45
|
+
- Decrease with credits
|
|
46
|
+
- Examples: `sink:consumed`, `sink:refunded`
|
|
47
|
+
- Tracks where tokens have been spent/consumed
|
|
48
|
+
|
|
49
|
+
### Worked Examples
|
|
50
|
+
|
|
51
|
+
Each operation creates two balanced entries (debits = credits).
|
|
52
|
+
|
|
53
|
+
#### Deposit (100 tokens)
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
TokenLedger::Manager.deposit(owner: user, amount: 100, description: "Token purchase")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Entries created:**
|
|
60
|
+
```
|
|
61
|
+
Entry 1: Debit wallet:user_123 100 (balance delta: +100)
|
|
62
|
+
Entry 2: Credit source:stripe 100 (balance delta: -100)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Result:** User balance = 100, Source balance = -100 (liability to token issuer)
|
|
66
|
+
|
|
67
|
+
#### Spend (50 tokens)
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
TokenLedger::Manager.spend(owner: user, amount: 50, description: "Service consumed")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Entries created:**
|
|
74
|
+
```
|
|
75
|
+
Entry 1: Credit wallet:user_123 50 (balance delta: -50)
|
|
76
|
+
Entry 2: Debit sink:consumed 50 (balance delta: +50)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Result:** User balance = 50, Consumed = 50
|
|
80
|
+
|
|
81
|
+
#### Reserve (30 tokens)
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
TokenLedger::Manager.reserve(owner: user, amount: 30, description: "Hold for API call")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Entries created:**
|
|
88
|
+
```
|
|
89
|
+
Entry 1: Credit wallet:user_123 30 (balance delta: -30)
|
|
90
|
+
Entry 2: Debit wallet:user_123:reserved 30 (balance delta: +30)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Result:** Available = 20, Reserved = 30, Total still 50
|
|
94
|
+
|
|
95
|
+
#### Capture (30 tokens from reservation)
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
TokenLedger::Manager.capture(reservation_id: reservation_id, description: "API call succeeded")
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Entries created:**
|
|
102
|
+
```
|
|
103
|
+
Entry 1: Credit wallet:user_123:reserved 30 (balance delta: -30)
|
|
104
|
+
Entry 2: Debit sink:consumed 30 (balance delta: +30)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Result:** Available = 20, Reserved = 0, Consumed = 80
|
|
108
|
+
|
|
109
|
+
#### Release (30 tokens back to wallet)
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
TokenLedger::Manager.release(reservation_id: reservation_id, description: "API call failed")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Entries created:**
|
|
116
|
+
```
|
|
117
|
+
Entry 1: Credit wallet:user_123:reserved 30 (balance delta: -30)
|
|
118
|
+
Entry 2: Debit wallet:user_123 30 (balance delta: +30)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Result:** Available = 50, Reserved = 0
|
|
122
|
+
|
|
123
|
+
## Requirements
|
|
124
|
+
|
|
125
|
+
- Ruby 3.0+
|
|
126
|
+
- Rails 7.0+
|
|
127
|
+
- PostgreSQL (recommended for production) or SQLite (development/testing)
|
|
128
|
+
|
|
129
|
+
## Installation
|
|
130
|
+
|
|
131
|
+
Add to your Gemfile:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
gem "token_ledger"
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
If you want the latest unreleased code from GitHub:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
gem "token_ledger", git: "https://github.com/wuliwong/token_ledger", branch: "main"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Install and generate migrations:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
bundle install
|
|
147
|
+
rails generate token_ledger:install
|
|
148
|
+
rails db:migrate
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The generator creates two migrations automatically:
|
|
152
|
+
- `db/migrate/XXXXXX_create_ledger_tables.rb` - Core ledger tables with all constraints
|
|
153
|
+
- `db/migrate/XXXXXX_add_cached_balance_to_users.rb` - Cached balance column for your owner model
|
|
154
|
+
|
|
155
|
+
**Custom owner model:** If you're using a different owner model (not `User`), specify it:
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
rails generate token_ledger:install --owner-model=Team
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
This will create `add_cached_balance_to_teams.rb` instead.
|
|
162
|
+
|
|
163
|
+
## Migrating from Simple Integer Columns
|
|
164
|
+
|
|
165
|
+
If you already have a `users.credits` or similar integer column tracking balances, you can migrate to TokenLedger:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
# db/migrate/XXXXXX_migrate_to_token_ledger.rb
|
|
169
|
+
class MigrateToTokenLedger < ActiveRecord::Migration[7.0]
|
|
170
|
+
def up
|
|
171
|
+
# Ensure TokenLedger tables exist
|
|
172
|
+
# (Run `rails generate token_ledger:install` first)
|
|
173
|
+
|
|
174
|
+
# Migrate existing balances
|
|
175
|
+
User.find_each do |user|
|
|
176
|
+
next if user.credits.zero? # Skip users with no balance
|
|
177
|
+
|
|
178
|
+
TokenLedger::Manager.deposit(
|
|
179
|
+
owner: user,
|
|
180
|
+
amount: user.credits,
|
|
181
|
+
description: "Balance migration from legacy credits column",
|
|
182
|
+
external_source: "migration",
|
|
183
|
+
external_id: "user_#{user.id}_migration",
|
|
184
|
+
metadata: {
|
|
185
|
+
legacy_credits: user.credits,
|
|
186
|
+
migrated_at: Time.current.iso8601
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Optional: Remove old column after verifying migration
|
|
192
|
+
# remove_column :users, :credits
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def down
|
|
196
|
+
# Restore credits from ledger if needed
|
|
197
|
+
User.find_each do |user|
|
|
198
|
+
wallet = TokenLedger::LedgerAccount.find_by(code: "wallet:#{user.id}")
|
|
199
|
+
user.update_column(:credits, wallet&.current_balance || 0) if wallet
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**Verification:**
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
# Verify migration accuracy
|
|
209
|
+
User.find_each do |user|
|
|
210
|
+
legacy = user.credits
|
|
211
|
+
ledger = TokenLedger::LedgerAccount.find_by(code: "wallet:#{user.id}")&.current_balance || 0
|
|
212
|
+
|
|
213
|
+
if legacy != ledger
|
|
214
|
+
puts "MISMATCH: User #{user.id} - Legacy: #{legacy}, Ledger: #{ledger}"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Configuration
|
|
220
|
+
|
|
221
|
+
### 1. Add to your owner model (User, Team, etc.):
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
class User < ApplicationRecord
|
|
225
|
+
has_many :ledger_transactions,
|
|
226
|
+
as: :owner,
|
|
227
|
+
class_name: "TokenLedger::LedgerTransaction"
|
|
228
|
+
|
|
229
|
+
# Optional: Add helper method for balance
|
|
230
|
+
def balance
|
|
231
|
+
cached_balance
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### 2. Create seed accounts (recommended):
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
# db/seeds.rb or db/seeds/token_ledger.rb
|
|
240
|
+
|
|
241
|
+
# TOKEN SOURCES (where tokens enter the system)
|
|
242
|
+
TokenLedger::LedgerAccount.find_or_create_by!(code: "source:stripe") do |account|
|
|
243
|
+
account.name = "Tokens Purchased via Stripe"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
TokenLedger::LedgerAccount.find_or_create_by!(code: "source:paypal") do |account|
|
|
247
|
+
account.name = "Tokens Purchased via PayPal"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
TokenLedger::LedgerAccount.find_or_create_by!(code: "source:promo") do |account|
|
|
251
|
+
account.name = "Promotional Token Grants"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
TokenLedger::LedgerAccount.find_or_create_by!(code: "source:referral") do |account|
|
|
255
|
+
account.name = "Referral Bonuses"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
TokenLedger::LedgerAccount.find_or_create_by!(code: "source:admin") do |account|
|
|
259
|
+
account.name = "Admin Manual Credits"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# TOKEN SINKS (where tokens leave the system)
|
|
263
|
+
TokenLedger::LedgerAccount.find_or_create_by!(code: "sink:consumed") do |account|
|
|
264
|
+
account.name = "Tokens Consumed (Service Delivered)"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
TokenLedger::LedgerAccount.find_or_create_by!(code: "sink:refunded") do |account|
|
|
268
|
+
account.name = "Tokens Refunded"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
TokenLedger::LedgerAccount.find_or_create_by!(code: "sink:expired") do |account|
|
|
272
|
+
account.name = "Tokens Expired"
|
|
273
|
+
end
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Run seeds:
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
rails db:seed
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## Data Integrity Guarantees
|
|
283
|
+
|
|
284
|
+
TokenLedger enforces correctness at the database level, not just in application code.
|
|
285
|
+
|
|
286
|
+
### Database-Level Constraints
|
|
287
|
+
|
|
288
|
+
All constraints are enforced by the database itself (PostgreSQL or SQLite):
|
|
289
|
+
|
|
290
|
+
#### CHECK Constraints
|
|
291
|
+
|
|
292
|
+
1. **Positive amounts**: `ledger_entries.amount > 0`
|
|
293
|
+
- Prevents zero or negative amounts
|
|
294
|
+
- Financial entries must always be positive (sign is determined by entry_type)
|
|
295
|
+
|
|
296
|
+
2. **Valid entry types**: `ledger_entries.entry_type IN ('debit', 'credit')`
|
|
297
|
+
- Only allows "debit" or "credit"
|
|
298
|
+
- Prevents typos or invalid values
|
|
299
|
+
|
|
300
|
+
3. **Valid transaction types**: `ledger_transactions.transaction_type IN ('deposit', 'spend', 'reserve', 'capture', 'release', 'adjustment')`
|
|
301
|
+
- Only allows the 6 supported operation types
|
|
302
|
+
- Ensures consistency across the application
|
|
303
|
+
|
|
304
|
+
4. **External ID consistency**: `(external_source IS NULL AND external_id IS NULL) OR (external_source IS NOT NULL AND external_id IS NOT NULL)`
|
|
305
|
+
- Prevents `external_source` without `external_id` (which would break idempotency)
|
|
306
|
+
- Prevents `external_id` without `external_source` (which would be ambiguous)
|
|
307
|
+
|
|
308
|
+
#### Foreign Key Constraints
|
|
309
|
+
|
|
310
|
+
1. **Immutable transactions**: `on_delete: :restrict`
|
|
311
|
+
- `ledger_entries.account_id` → `ledger_accounts.id`
|
|
312
|
+
- `ledger_entries.transaction_id` → `ledger_transactions.id`
|
|
313
|
+
- Prevents deletion of accounts or transactions that have entries
|
|
314
|
+
- Enforces the audit trail: transactions are immutable financial records
|
|
315
|
+
|
|
316
|
+
2. **Parent-child relationships**: `on_delete: :restrict` (enforces strict immutability)
|
|
317
|
+
- `ledger_transactions.parent_transaction_id` → `ledger_transactions.id`
|
|
318
|
+
- Prevents deletion of parent reservations that have child transactions
|
|
319
|
+
- For development/test flexibility, you can change to `:nullify` in the generated migration before running it
|
|
320
|
+
|
|
321
|
+
### Uniqueness Constraints
|
|
322
|
+
|
|
323
|
+
1. **Account codes**: `ledger_accounts.code` (unique index)
|
|
324
|
+
- Prevents duplicate account codes
|
|
325
|
+
- Ensures each account has a unique identifier
|
|
326
|
+
|
|
327
|
+
2. **External tracking**: `[external_source, external_id]` (unique partial index where `external_source IS NOT NULL`)
|
|
328
|
+
- Prevents duplicate transactions from the same external source
|
|
329
|
+
- Enables idempotency for Stripe invoices, PayPal transactions, etc.
|
|
330
|
+
|
|
331
|
+
### Immutability
|
|
332
|
+
|
|
333
|
+
Transactions are immutable:
|
|
334
|
+
- No `update` operations on ledger_transactions or ledger_entries
|
|
335
|
+
- Foreign key constraints with `on_delete: :restrict` prevent accidental deletion
|
|
336
|
+
- Creates a permanent, tamper-proof audit trail
|
|
337
|
+
|
|
338
|
+
If you need to correct a mistake, create a **reversing transaction** by posting the opposite entries:
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
# Wrong: Don't do this
|
|
342
|
+
transaction.destroy # Will fail due to FK constraint
|
|
343
|
+
|
|
344
|
+
# Right: Create a reversing transaction by swapping debit/credit on same accounts
|
|
345
|
+
original_transaction = TokenLedger::LedgerTransaction.find(transaction_id)
|
|
346
|
+
|
|
347
|
+
TokenLedger::Manager.adjust(
|
|
348
|
+
owner: original_transaction.owner,
|
|
349
|
+
description: "Reversal of transaction ##{original_transaction.id}",
|
|
350
|
+
entries: original_transaction.ledger_entries.map { |entry|
|
|
351
|
+
{
|
|
352
|
+
account_code: entry.account.code,
|
|
353
|
+
account_name: entry.account.name,
|
|
354
|
+
type: entry.entry_type == 'debit' ? :credit : :debit, # Swap entry type
|
|
355
|
+
amount: entry.amount
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
)
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
## Usage
|
|
362
|
+
|
|
363
|
+
### Basic Operations
|
|
364
|
+
|
|
365
|
+
#### Deposit (Add Tokens)
|
|
366
|
+
|
|
367
|
+
```ruby
|
|
368
|
+
# Simple deposit
|
|
369
|
+
TokenLedger::Manager.deposit(
|
|
370
|
+
owner: user,
|
|
371
|
+
amount: 100,
|
|
372
|
+
description: "Token purchase"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Deposit with external tracking (for idempotency)
|
|
376
|
+
TokenLedger::Manager.deposit(
|
|
377
|
+
owner: user,
|
|
378
|
+
amount: 100,
|
|
379
|
+
description: "Subscription renewal",
|
|
380
|
+
external_source: "stripe",
|
|
381
|
+
external_id: "inv_123456", # Prevents duplicate processing
|
|
382
|
+
metadata: { plan: "pro", period: "monthly" }
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# Will raise DuplicateTransactionError if called again with same external_source + external_id
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
#### Spend (Deduct Tokens)
|
|
389
|
+
|
|
390
|
+
**Safe by design** - simply deducts tokens immediately. For external API calls that need rollback protection, use the Reserve/Capture/Release pattern below.
|
|
391
|
+
|
|
392
|
+
```ruby
|
|
393
|
+
# Simple spend - deducts tokens immediately
|
|
394
|
+
TokenLedger::Manager.spend(
|
|
395
|
+
owner: user,
|
|
396
|
+
amount: 5,
|
|
397
|
+
description: "Image generation"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
# With metadata for tracking
|
|
401
|
+
TokenLedger::Manager.spend(
|
|
402
|
+
owner: user,
|
|
403
|
+
amount: 10,
|
|
404
|
+
description: "Video processing",
|
|
405
|
+
metadata: { resolution: "1080p", duration: 30 }
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Raises InsufficientFundsError if balance is too low
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
**⚠️ IMPORTANT:** `.spend` deducts tokens immediately and cannot be rolled back. For operations involving external APIs (payment processors, AI services, etc.), use the Reserve/Capture/Release pattern below to handle failures safely.
|
|
412
|
+
|
|
413
|
+
### Advanced: Reserve/Capture/Release Pattern
|
|
414
|
+
|
|
415
|
+
For external API calls that can't be rolled back (like third-party services), use the reserve/capture/release pattern:
|
|
416
|
+
|
|
417
|
+
**Invariants:**
|
|
418
|
+
- A reservation can be captured or released (partially or fully), but the total captured + released cannot exceed the reserved amount
|
|
419
|
+
- Once a reservation is fully captured or fully released, it is closed
|
|
420
|
+
- Each reserve, capture, and release operation creates its own immutable ledger transaction - the original reservation is never modified
|
|
421
|
+
- Capture and release transactions link back to their parent reservation via `parent_transaction_id` for complete audit trails
|
|
422
|
+
- Use `external_source` + `external_id` in capture/release for idempotency when handling external API callbacks
|
|
423
|
+
|
|
424
|
+
```ruby
|
|
425
|
+
# Step 1: Reserve tokens (makes them unavailable but not consumed)
|
|
426
|
+
reservation_id = TokenLedger::Manager.reserve(
|
|
427
|
+
owner: user,
|
|
428
|
+
amount: 50,
|
|
429
|
+
description: "Reserve for API call",
|
|
430
|
+
metadata: { job_id: "job_123" }
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
begin
|
|
434
|
+
# Step 2: Call external API (this can't be rolled back)
|
|
435
|
+
result = ExternalAPI.expensive_operation(job_id: "job_123")
|
|
436
|
+
|
|
437
|
+
# Step 3: Capture the reserved tokens (mark as consumed)
|
|
438
|
+
# For idempotency with external job systems, use external_source/external_id
|
|
439
|
+
TokenLedger::Manager.capture(
|
|
440
|
+
reservation_id: reservation_id,
|
|
441
|
+
description: "API call completed",
|
|
442
|
+
external_source: "job_runner",
|
|
443
|
+
external_id: "job_123:capture" # Prevents duplicate capture on retry
|
|
444
|
+
)
|
|
445
|
+
rescue => e
|
|
446
|
+
# Step 3b: Release reserved tokens back to wallet on failure
|
|
447
|
+
TokenLedger::Manager.release(
|
|
448
|
+
reservation_id: reservation_id,
|
|
449
|
+
description: "API call failed - refund",
|
|
450
|
+
external_source: "job_runner",
|
|
451
|
+
external_id: "job_123:release",
|
|
452
|
+
metadata: { error: e.message }
|
|
453
|
+
)
|
|
454
|
+
raise e
|
|
455
|
+
end
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Or use the convenience method that handles this automatically:
|
|
459
|
+
|
|
460
|
+
```ruby
|
|
461
|
+
result = TokenLedger::Manager.spend_with_api(
|
|
462
|
+
owner: user,
|
|
463
|
+
amount: 50,
|
|
464
|
+
description: "External API call"
|
|
465
|
+
) do
|
|
466
|
+
# This block is NOT in a database transaction
|
|
467
|
+
# If it fails, tokens are automatically released
|
|
468
|
+
ExternalAPI.expensive_operation
|
|
469
|
+
end
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
**Transaction Linkage:** Each reserve, capture, and release creates its own `LedgerTransaction` row with its own `external_source` + `external_id` for idempotency. Capture and release transactions link back to the original reservation via `parent_transaction_id` for complete audit trails:
|
|
473
|
+
|
|
474
|
+
```ruby
|
|
475
|
+
# Find a reservation and its child transactions
|
|
476
|
+
reservation = TokenLedger::LedgerTransaction.find(reservation_id)
|
|
477
|
+
captures = TokenLedger::LedgerTransaction.where(
|
|
478
|
+
parent_transaction_id: reservation_id,
|
|
479
|
+
transaction_type: "capture"
|
|
480
|
+
)
|
|
481
|
+
releases = TokenLedger::LedgerTransaction.where(
|
|
482
|
+
parent_transaction_id: reservation_id,
|
|
483
|
+
transaction_type: "release"
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
# Find the parent of a capture
|
|
487
|
+
capture_txn = TokenLedger::LedgerTransaction.find_by(transaction_type: "capture")
|
|
488
|
+
parent = TokenLedger::LedgerTransaction.find(capture_txn.parent_transaction_id) if capture_txn.parent_transaction_id
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
**Optional:** If you prefer convenient association methods like `child_transactions` and `parent_transaction`, add these to the `LedgerTransaction` model in your application:
|
|
492
|
+
|
|
493
|
+
```ruby
|
|
494
|
+
# Add to gems/token_ledger/app/models/token_ledger/ledger_transaction.rb
|
|
495
|
+
class TokenLedger::LedgerTransaction < ApplicationRecord
|
|
496
|
+
belongs_to :parent_transaction,
|
|
497
|
+
class_name: "TokenLedger::LedgerTransaction",
|
|
498
|
+
optional: true
|
|
499
|
+
|
|
500
|
+
has_many :child_transactions,
|
|
501
|
+
class_name: "TokenLedger::LedgerTransaction",
|
|
502
|
+
foreign_key: :parent_transaction_id
|
|
503
|
+
end
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
Then you can use:
|
|
507
|
+
```ruby
|
|
508
|
+
reservation.child_transactions.where(transaction_type: "capture")
|
|
509
|
+
capture_txn.parent_transaction
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
### Balance Operations
|
|
513
|
+
|
|
514
|
+
#### Balance Hierarchy
|
|
515
|
+
|
|
516
|
+
TokenLedger maintains two balance caches with a clear hierarchy:
|
|
517
|
+
|
|
518
|
+
```
|
|
519
|
+
Source of Truth: LedgerAccount.current_balance (for any account)
|
|
520
|
+
↓
|
|
521
|
+
Optional Mirror: owner.cached_balance (denormalized for convenience)
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
**Important Invariant:**
|
|
525
|
+
|
|
526
|
+
After any successful ledger write:
|
|
527
|
+
```ruby
|
|
528
|
+
user.cached_balance == LedgerAccount.find_by(code: "wallet:#{user.id}").current_balance
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
**Atomicity Guarantee:** Both `LedgerAccount.current_balance` and `owner.cached_balance` are updated atomically in the same database transaction. The `Manager` methods use `ActiveRecord::Base.transaction` to ensure that either both caches are updated or neither is (all-or-nothing).
|
|
532
|
+
|
|
533
|
+
**When to use which:**
|
|
534
|
+
|
|
535
|
+
- ✅ **Use `user.cached_balance`** for fast reads (no JOIN required)
|
|
536
|
+
- ✅ **Use `LedgerAccount.current_balance`** if you need account-level granularity (e.g., reserved balance)
|
|
537
|
+
- ⚠️ **Use `Balance.calculate`** only for reconciliation or verification
|
|
538
|
+
|
|
539
|
+
#### Usage Examples
|
|
540
|
+
|
|
541
|
+
```ruby
|
|
542
|
+
# Get current balance (from cache - fast)
|
|
543
|
+
user.cached_balance # or user.balance if you added the helper method
|
|
544
|
+
|
|
545
|
+
# Calculate balance from ledger entries (slow but accurate)
|
|
546
|
+
actual_balance = TokenLedger::Balance.calculate("wallet:#{user.id}")
|
|
547
|
+
|
|
548
|
+
# Reconcile cached balance with calculated balance
|
|
549
|
+
TokenLedger::Balance.reconcile_user!(user)
|
|
550
|
+
user.reload
|
|
551
|
+
user.cached_balance # Now matches calculated balance
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
**Reconciliation:**
|
|
555
|
+
|
|
556
|
+
If you suspect drift between the caches:
|
|
557
|
+
```ruby
|
|
558
|
+
TokenLedger::Balance.reconcile_user!(user)
|
|
559
|
+
# This updates BOTH caches from the ledger entries
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
### Query Transactions
|
|
563
|
+
|
|
564
|
+
```ruby
|
|
565
|
+
# Get user's transaction history
|
|
566
|
+
user.ledger_transactions.order(created_at: :desc).limit(20)
|
|
567
|
+
|
|
568
|
+
# Filter by type
|
|
569
|
+
user.ledger_transactions.where(transaction_type: "deposit")
|
|
570
|
+
user.ledger_transactions.where(transaction_type: "spend")
|
|
571
|
+
|
|
572
|
+
# Find specific transaction
|
|
573
|
+
txn = TokenLedger::LedgerTransaction.find_by(
|
|
574
|
+
external_source: "stripe",
|
|
575
|
+
external_id: "inv_123"
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
# Get entries for a transaction
|
|
579
|
+
txn.ledger_entries.each do |entry|
|
|
580
|
+
puts "#{entry.account.name}: #{entry.entry_type} #{entry.amount}"
|
|
581
|
+
end
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
## Integration with Stripe and Pay Gem
|
|
585
|
+
|
|
586
|
+
### Option 1: With Pay Gem (Recommended)
|
|
587
|
+
|
|
588
|
+
Install Pay gem:
|
|
589
|
+
|
|
590
|
+
```ruby
|
|
591
|
+
# Gemfile
|
|
592
|
+
gem 'pay'
|
|
593
|
+
|
|
594
|
+
bundle install
|
|
595
|
+
rails pay:install
|
|
596
|
+
rails db:migrate
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
Add to User model:
|
|
600
|
+
|
|
601
|
+
```ruby
|
|
602
|
+
class User < ApplicationRecord
|
|
603
|
+
pay_customer
|
|
604
|
+
|
|
605
|
+
has_many :ledger_transactions,
|
|
606
|
+
as: :owner,
|
|
607
|
+
class_name: "TokenLedger::LedgerTransaction"
|
|
608
|
+
end
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
Set up webhook handler:
|
|
612
|
+
|
|
613
|
+
```ruby
|
|
614
|
+
# config/routes.rb
|
|
615
|
+
post "/webhooks/stripe", to: "webhooks/stripe#create"
|
|
616
|
+
|
|
617
|
+
# app/controllers/webhooks/stripe_controller.rb
|
|
618
|
+
class Webhooks::StripeController < ApplicationController
|
|
619
|
+
skip_before_action :verify_authenticity_token
|
|
620
|
+
|
|
621
|
+
def create
|
|
622
|
+
event = Stripe::Webhook.construct_event(
|
|
623
|
+
request.body.read,
|
|
624
|
+
request.env['HTTP_STRIPE_SIGNATURE'],
|
|
625
|
+
ENV['STRIPE_WEBHOOK_SECRET']
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
case event.type
|
|
629
|
+
when 'invoice.payment_succeeded'
|
|
630
|
+
handle_subscription_payment(event.data.object)
|
|
631
|
+
when 'checkout.session.completed'
|
|
632
|
+
handle_onetime_purchase(event.data.object)
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
head :ok
|
|
636
|
+
rescue Stripe::SignatureVerificationError
|
|
637
|
+
head :bad_request
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
private
|
|
641
|
+
|
|
642
|
+
def handle_subscription_payment(invoice)
|
|
643
|
+
user = User.find_by(pay_customer_id: invoice.customer)
|
|
644
|
+
return unless user
|
|
645
|
+
|
|
646
|
+
# Get token amount from Price metadata
|
|
647
|
+
credits = invoice.lines.data.first.price.metadata['monthly_credits'].to_i
|
|
648
|
+
|
|
649
|
+
TokenLedger::Manager.deposit(
|
|
650
|
+
owner: user,
|
|
651
|
+
amount: credits,
|
|
652
|
+
description: "Subscription: #{invoice.lines.data.first.price.nickname}",
|
|
653
|
+
external_source: "stripe",
|
|
654
|
+
external_id: invoice.id, # Prevents duplicate credits
|
|
655
|
+
metadata: {
|
|
656
|
+
invoice_id: invoice.id,
|
|
657
|
+
subscription_id: invoice.subscription,
|
|
658
|
+
plan: invoice.lines.data.first.price.nickname
|
|
659
|
+
}
|
|
660
|
+
)
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def handle_onetime_purchase(session)
|
|
664
|
+
user = User.find_by(pay_customer_id: session.customer)
|
|
665
|
+
return unless user
|
|
666
|
+
|
|
667
|
+
# Get token amount from session metadata
|
|
668
|
+
credits = session.metadata['token_amount'].to_i
|
|
669
|
+
|
|
670
|
+
TokenLedger::Manager.deposit(
|
|
671
|
+
owner: user,
|
|
672
|
+
amount: credits,
|
|
673
|
+
description: "Token purchase",
|
|
674
|
+
external_source: "stripe",
|
|
675
|
+
external_id: session.id,
|
|
676
|
+
metadata: {
|
|
677
|
+
session_id: session.id,
|
|
678
|
+
amount_paid: session.amount_total / 100.0
|
|
679
|
+
}
|
|
680
|
+
)
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
Set up Stripe Products with metadata:
|
|
686
|
+
|
|
687
|
+
```ruby
|
|
688
|
+
# In Stripe Dashboard or via API, add metadata to Price objects:
|
|
689
|
+
# metadata: { monthly_credits: "1000" }
|
|
690
|
+
# metadata: { monthly_credits: "3500" }
|
|
691
|
+
# metadata: { monthly_credits: "12500" }
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
### Option 2: Direct Stripe Integration (Without Pay Gem)
|
|
695
|
+
|
|
696
|
+
Add Stripe gem:
|
|
697
|
+
|
|
698
|
+
```ruby
|
|
699
|
+
# Gemfile
|
|
700
|
+
gem 'stripe'
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
Add stripe_customer_id to User:
|
|
704
|
+
|
|
705
|
+
```bash
|
|
706
|
+
rails generate migration AddStripeCustomerIdToUsers stripe_customer_id:string
|
|
707
|
+
rails db:migrate
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
Set up webhook handler (similar to above but without Pay gem dependency):
|
|
711
|
+
|
|
712
|
+
```ruby
|
|
713
|
+
class Webhooks::StripeController < ApplicationController
|
|
714
|
+
skip_before_action :verify_authenticity_token
|
|
715
|
+
|
|
716
|
+
def create
|
|
717
|
+
event = Stripe::Webhook.construct_event(
|
|
718
|
+
request.body.read,
|
|
719
|
+
request.env['HTTP_STRIPE_SIGNATURE'],
|
|
720
|
+
ENV['STRIPE_WEBHOOK_SECRET']
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
case event.type
|
|
724
|
+
when 'invoice.payment_succeeded'
|
|
725
|
+
handle_payment(event.data.object)
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
head :ok
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
private
|
|
732
|
+
|
|
733
|
+
def handle_payment(invoice)
|
|
734
|
+
user = User.find_by(stripe_customer_id: invoice.customer)
|
|
735
|
+
return unless user
|
|
736
|
+
|
|
737
|
+
credits = invoice.lines.data.first.price.metadata['monthly_credits'].to_i
|
|
738
|
+
|
|
739
|
+
TokenLedger::Manager.deposit(
|
|
740
|
+
owner: user,
|
|
741
|
+
amount: credits,
|
|
742
|
+
description: "Payment received",
|
|
743
|
+
external_source: "stripe",
|
|
744
|
+
external_id: invoice.id,
|
|
745
|
+
metadata: { invoice_id: invoice.id }
|
|
746
|
+
)
|
|
747
|
+
end
|
|
748
|
+
end
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
### Option 3: Without Stripe (Manual Credits, Other Payment Processors)
|
|
752
|
+
|
|
753
|
+
TokenLedger is completely payment-processor agnostic. You can credit tokens from any source:
|
|
754
|
+
|
|
755
|
+
```ruby
|
|
756
|
+
# Admin manually credits user
|
|
757
|
+
TokenLedger::Manager.deposit(
|
|
758
|
+
owner: user,
|
|
759
|
+
amount: 500,
|
|
760
|
+
description: "Admin credit - customer support",
|
|
761
|
+
external_source: "admin",
|
|
762
|
+
external_id: "admin_#{current_admin.id}_#{Time.now.to_i}",
|
|
763
|
+
metadata: { admin_id: current_admin.id, reason: "Apology for service issue" }
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
# PayPal webhook
|
|
767
|
+
TokenLedger::Manager.deposit(
|
|
768
|
+
owner: user,
|
|
769
|
+
amount: 1000,
|
|
770
|
+
description: "PayPal purchase",
|
|
771
|
+
external_source: "paypal",
|
|
772
|
+
external_id: paypal_transaction_id
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
# Promotional bonus
|
|
776
|
+
TokenLedger::Manager.deposit(
|
|
777
|
+
owner: user,
|
|
778
|
+
amount: 100,
|
|
779
|
+
description: "Welcome bonus",
|
|
780
|
+
external_source: "promo",
|
|
781
|
+
external_id: "signup_bonus_#{user.id}"
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
# Referral credit
|
|
785
|
+
TokenLedger::Manager.deposit(
|
|
786
|
+
owner: referrer,
|
|
787
|
+
amount: 50,
|
|
788
|
+
description: "Referral bonus",
|
|
789
|
+
external_source: "referral",
|
|
790
|
+
external_id: "referral_#{referred_user.id}",
|
|
791
|
+
metadata: { referred_user_id: referred_user.id }
|
|
792
|
+
)
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
## API Reference
|
|
796
|
+
|
|
797
|
+
### TokenLedger::Manager
|
|
798
|
+
|
|
799
|
+
#### `.deposit(owner:, amount:, description:, external_source: nil, external_id: nil, metadata: {})`
|
|
800
|
+
|
|
801
|
+
Adds tokens to owner's wallet.
|
|
802
|
+
|
|
803
|
+
**Parameters:**
|
|
804
|
+
- `owner` (required) - The owner object (User, Team, etc.)
|
|
805
|
+
- `amount` (required) - Integer amount of tokens to add
|
|
806
|
+
- `description` (required) - String description of transaction
|
|
807
|
+
- `external_source` (optional) - String identifier for source system (e.g., "stripe", "paypal")
|
|
808
|
+
- `external_id` (optional) - String unique ID from external system (enables idempotency)
|
|
809
|
+
- `metadata` (optional) - Hash of additional data to store with transaction
|
|
810
|
+
|
|
811
|
+
**Returns:** Transaction ID (Integer)
|
|
812
|
+
|
|
813
|
+
**Raises:**
|
|
814
|
+
- `DuplicateTransactionError` if external_source + external_id combination already exists
|
|
815
|
+
|
|
816
|
+
---
|
|
817
|
+
|
|
818
|
+
#### `.spend(owner:, amount:, description:, metadata: {})`
|
|
819
|
+
|
|
820
|
+
Deducts tokens immediately. Safe by design - no block means no risk of unsafe rollback.
|
|
821
|
+
|
|
822
|
+
**Parameters:**
|
|
823
|
+
- `owner` (required) - The owner object
|
|
824
|
+
- `amount` (required) - Integer amount of tokens to deduct
|
|
825
|
+
- `description` (required) - String description
|
|
826
|
+
- `metadata` (optional) - Hash of additional data
|
|
827
|
+
|
|
828
|
+
**Returns:** Transaction ID
|
|
829
|
+
|
|
830
|
+
**Raises:**
|
|
831
|
+
- `InsufficientFundsError` if balance is too low
|
|
832
|
+
|
|
833
|
+
**Example:**
|
|
834
|
+
```ruby
|
|
835
|
+
TokenLedger::Manager.spend(owner: user, amount: 10, description: "Image generation")
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
**Note:** For external API calls that need rollback protection, use `.spend_with_api` or the manual reserve/capture/release pattern instead.
|
|
839
|
+
|
|
840
|
+
---
|
|
841
|
+
|
|
842
|
+
#### `.spend_with_api(owner:, amount:, description:, metadata: {}, &block)`
|
|
843
|
+
|
|
844
|
+
Reserve/capture/release pattern for external API calls. Automatically handles failures.
|
|
845
|
+
|
|
846
|
+
**Parameters:** Same as `.spend`
|
|
847
|
+
|
|
848
|
+
**Returns:** Return value of the block
|
|
849
|
+
|
|
850
|
+
**Behavior:**
|
|
851
|
+
1. Reserves tokens (moves to reserved account)
|
|
852
|
+
2. Executes block (NOT in database transaction)
|
|
853
|
+
3. On success: Captures reserved tokens
|
|
854
|
+
4. On failure: Releases tokens back to wallet
|
|
855
|
+
|
|
856
|
+
---
|
|
857
|
+
|
|
858
|
+
#### `.reserve(owner:, amount:, description:, metadata: {})`
|
|
859
|
+
|
|
860
|
+
Reserves tokens (moves from wallet to reserved account).
|
|
861
|
+
|
|
862
|
+
**Returns:** Transaction ID
|
|
863
|
+
|
|
864
|
+
**Raises:** `InsufficientFundsError` if balance is too low
|
|
865
|
+
|
|
866
|
+
---
|
|
867
|
+
|
|
868
|
+
#### `.capture(reservation_id:, amount: nil, description:, external_source: nil, external_id: nil, metadata: {})`
|
|
869
|
+
|
|
870
|
+
Captures reserved tokens (marks as consumed). Targets a specific reservation by ID.
|
|
871
|
+
|
|
872
|
+
**Parameters:**
|
|
873
|
+
- `reservation_id` (required) - ID of the reservation transaction to capture
|
|
874
|
+
- `amount` (optional) - Amount to capture (defaults to full reserved amount)
|
|
875
|
+
- `description` (required) - Description of the capture
|
|
876
|
+
- `external_source` (optional) - String identifier for external system (e.g., "job_runner")
|
|
877
|
+
- `external_id` (optional) - String unique ID from external system (enables idempotency)
|
|
878
|
+
- `metadata` (optional) - Additional metadata
|
|
879
|
+
|
|
880
|
+
**Returns:** Transaction ID
|
|
881
|
+
|
|
882
|
+
**Raises:**
|
|
883
|
+
- `DuplicateTransactionError` if external_source + external_id combination already exists
|
|
884
|
+
- `ArgumentError` if reservation not found or amount exceeds reserved amount
|
|
885
|
+
|
|
886
|
+
---
|
|
887
|
+
|
|
888
|
+
#### `.release(reservation_id:, amount: nil, description:, external_source: nil, external_id: nil, metadata: {})`
|
|
889
|
+
|
|
890
|
+
Releases reserved tokens back to wallet. Targets a specific reservation by ID.
|
|
891
|
+
|
|
892
|
+
**Parameters:**
|
|
893
|
+
- `reservation_id` (required) - ID of the reservation transaction to release
|
|
894
|
+
- `amount` (optional) - Amount to release (defaults to full reserved amount)
|
|
895
|
+
- `description` (required) - Description of the release
|
|
896
|
+
- `external_source` (optional) - String identifier for external system (e.g., "job_runner")
|
|
897
|
+
- `external_id` (optional) - String unique ID from external system (enables idempotency)
|
|
898
|
+
- `metadata` (optional) - Additional metadata
|
|
899
|
+
|
|
900
|
+
**Returns:** Transaction ID
|
|
901
|
+
|
|
902
|
+
**Raises:**
|
|
903
|
+
- `DuplicateTransactionError` if external_source + external_id combination already exists
|
|
904
|
+
- `ArgumentError` if reservation not found or amount exceeds reserved amount
|
|
905
|
+
|
|
906
|
+
---
|
|
907
|
+
|
|
908
|
+
#### `.adjust(owner:, entries:, description:, external_source: nil, external_id: nil, metadata: {})`
|
|
909
|
+
|
|
910
|
+
Creates an adjustment transaction with custom entries. Used for reversals, corrections, and manual adjustments.
|
|
911
|
+
|
|
912
|
+
**Parameters:**
|
|
913
|
+
- `owner` (required) - The owner object
|
|
914
|
+
- `entries` (required) - Array of entry specifications, each with:
|
|
915
|
+
- `account_code` - Account code string
|
|
916
|
+
- `account_name` - Account name string
|
|
917
|
+
- `type` - `:debit` or `:credit`
|
|
918
|
+
- `amount` - Positive integer amount
|
|
919
|
+
- `description` (required) - Description of the adjustment
|
|
920
|
+
- `external_source` (optional) - String identifier for source system
|
|
921
|
+
- `external_id` (optional) - String unique ID from external system (enables idempotency)
|
|
922
|
+
- `metadata` (optional) - Additional metadata
|
|
923
|
+
|
|
924
|
+
**Returns:** Transaction ID
|
|
925
|
+
|
|
926
|
+
**Raises:**
|
|
927
|
+
- `DuplicateTransactionError` if external_source + external_id combination already exists
|
|
928
|
+
- `ImbalancedTransactionError` if debits don't equal credits
|
|
929
|
+
|
|
930
|
+
**Note:** Adjustment transactions can post to any accounts. Unlike `spend` and `reserve` which enforce non-negative wallet balances, `adjust` allows negative balances - use with caution for manual corrections.
|
|
931
|
+
|
|
932
|
+
**Example:**
|
|
933
|
+
```ruby
|
|
934
|
+
# Reverse a transaction by swapping debit/credit on same accounts
|
|
935
|
+
original = TokenLedger::LedgerTransaction.find(txn_id)
|
|
936
|
+
TokenLedger::Manager.adjust(
|
|
937
|
+
owner: original.owner,
|
|
938
|
+
description: "Reversal of transaction ##{original.id}",
|
|
939
|
+
entries: original.ledger_entries.map { |e|
|
|
940
|
+
{
|
|
941
|
+
account_code: e.account.code,
|
|
942
|
+
account_name: e.account.name,
|
|
943
|
+
type: e.entry_type == 'debit' ? :credit : :debit,
|
|
944
|
+
amount: e.amount
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
)
|
|
948
|
+
```
|
|
949
|
+
|
|
950
|
+
---
|
|
951
|
+
|
|
952
|
+
### TokenLedger::Balance
|
|
953
|
+
|
|
954
|
+
#### `.calculate(account_or_code)`
|
|
955
|
+
|
|
956
|
+
Calculates actual balance from ledger entries.
|
|
957
|
+
|
|
958
|
+
**Parameters:**
|
|
959
|
+
- `account_or_code` - LedgerAccount object or account code string
|
|
960
|
+
|
|
961
|
+
**Returns:** Integer balance (debits - credits)
|
|
962
|
+
|
|
963
|
+
---
|
|
964
|
+
|
|
965
|
+
#### `.reconcile!(account_or_code)`
|
|
966
|
+
|
|
967
|
+
Updates cached balance to match calculated balance.
|
|
968
|
+
|
|
969
|
+
**Parameters:**
|
|
970
|
+
- `account_or_code` - LedgerAccount object or account code string
|
|
971
|
+
|
|
972
|
+
**Returns:** Integer calculated balance
|
|
973
|
+
|
|
974
|
+
---
|
|
975
|
+
|
|
976
|
+
#### `.reconcile_user!(user)`
|
|
977
|
+
|
|
978
|
+
Reconciles both the account's cached balance and the user's cached_balance.
|
|
979
|
+
|
|
980
|
+
**Parameters:**
|
|
981
|
+
- `user` - User object
|
|
982
|
+
|
|
983
|
+
**Raises:** `AccountNotFoundError` if wallet account doesn't exist
|
|
984
|
+
|
|
985
|
+
---
|
|
986
|
+
|
|
987
|
+
### TokenLedger::Account
|
|
988
|
+
|
|
989
|
+
#### `.find_or_create(code:, name:)`
|
|
990
|
+
|
|
991
|
+
Finds existing account or creates new one. Thread-safe.
|
|
992
|
+
|
|
993
|
+
**Parameters:**
|
|
994
|
+
- `code` (required) - Unique account code (e.g., "wallet:123")
|
|
995
|
+
- `name` (required) - Account name
|
|
996
|
+
|
|
997
|
+
**Returns:** LedgerAccount object
|
|
998
|
+
|
|
999
|
+
---
|
|
1000
|
+
|
|
1001
|
+
## Error Handling
|
|
1002
|
+
|
|
1003
|
+
```ruby
|
|
1004
|
+
begin
|
|
1005
|
+
TokenLedger::Manager.spend(owner: user, amount: 100, description: "Image generation")
|
|
1006
|
+
rescue TokenLedger::InsufficientFundsError => e
|
|
1007
|
+
# Handle insufficient balance
|
|
1008
|
+
flash[:error] = "Not enough tokens. Please purchase more."
|
|
1009
|
+
rescue TokenLedger::DuplicateTransactionError => e
|
|
1010
|
+
# Already processed this transaction
|
|
1011
|
+
Rails.logger.warn "Duplicate transaction: #{e.message}"
|
|
1012
|
+
rescue TokenLedger::ImbalancedTransactionError => e
|
|
1013
|
+
# Internal error - debits don't equal credits
|
|
1014
|
+
Rails.logger.error "Ledger imbalance: #{e.message}"
|
|
1015
|
+
Bugsnag.notify(e)
|
|
1016
|
+
end
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
## Account Codes Convention
|
|
1020
|
+
|
|
1021
|
+
Use hierarchical account codes for organization:
|
|
1022
|
+
|
|
1023
|
+
```ruby
|
|
1024
|
+
# Wallets (user-specific)
|
|
1025
|
+
"wallet:#{user.id}" # Main balance
|
|
1026
|
+
"wallet:#{user.id}:reserved" # Reserved tokens
|
|
1027
|
+
|
|
1028
|
+
# Token Sources (system-wide - where tokens enter)
|
|
1029
|
+
"source:stripe" # Purchased via Stripe
|
|
1030
|
+
"source:paypal" # Purchased via PayPal
|
|
1031
|
+
"source:promo" # Promotional grants
|
|
1032
|
+
"source:referral" # Referral bonuses
|
|
1033
|
+
"source:admin" # Manual admin credits
|
|
1034
|
+
|
|
1035
|
+
# Token Sinks (system-wide - where tokens leave)
|
|
1036
|
+
"sink:consumed" # Tokens consumed for service delivery
|
|
1037
|
+
"sink:refunded" # Refunded to customer
|
|
1038
|
+
"sink:expired" # Tokens expired
|
|
1039
|
+
```
|
|
1040
|
+
|
|
1041
|
+
**Important:** These are NOT accounting revenue/expense accounts. They track token flow:
|
|
1042
|
+
- **Sources** = tokens added to the system (liability increases)
|
|
1043
|
+
- **Sinks** = tokens removed from the system (liability decreases)
|
|
1044
|
+
- `sink:consumed` represents tokens consumed for service delivery, which corresponds to when your money accounting system would recognize revenue
|
|
1045
|
+
|
|
1046
|
+
**Note on adjustments:** Adjustment transactions (created via `Manager.adjust`) can post to any accounts - they don't require a dedicated `sink:adjustment` account. Most reversals will post to the same accounts as the original transaction with swapped debit/credit entries.
|
|
1047
|
+
|
|
1048
|
+
## Testing
|
|
1049
|
+
|
|
1050
|
+
The gem includes comprehensive tests for all functionality including thread safety and concurrency.
|
|
1051
|
+
|
|
1052
|
+
Run tests:
|
|
1053
|
+
|
|
1054
|
+
```bash
|
|
1055
|
+
cd gems/token_ledger
|
|
1056
|
+
bundle exec rake test
|
|
1057
|
+
```
|
|
1058
|
+
|
|
1059
|
+
### Writing Tests
|
|
1060
|
+
|
|
1061
|
+
```ruby
|
|
1062
|
+
# test/services/my_service_test.rb
|
|
1063
|
+
require 'test_helper'
|
|
1064
|
+
|
|
1065
|
+
class MyServiceTest < ActiveSupport::TestCase
|
|
1066
|
+
setup do
|
|
1067
|
+
@user = users(:one)
|
|
1068
|
+
|
|
1069
|
+
# Ensure system accounts exist
|
|
1070
|
+
TokenLedger::LedgerAccount.find_or_create_by!(code: "source:test") do |account|
|
|
1071
|
+
account.name = "Test Token Source"
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
TokenLedger::LedgerAccount.find_or_create_by!(code: "sink:consumed") do |account|
|
|
1075
|
+
account.name = "Tokens Consumed"
|
|
1076
|
+
end
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
test "credits user on purchase" do
|
|
1080
|
+
initial_balance = @user.cached_balance
|
|
1081
|
+
|
|
1082
|
+
TokenLedger::Manager.deposit(
|
|
1083
|
+
owner: @user,
|
|
1084
|
+
amount: 100,
|
|
1085
|
+
description: "Test purchase",
|
|
1086
|
+
external_source: "test"
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
@user.reload
|
|
1090
|
+
assert_equal initial_balance + 100, @user.cached_balance
|
|
1091
|
+
end
|
|
1092
|
+
end
|
|
1093
|
+
```
|
|
1094
|
+
|
|
1095
|
+
## Performance Considerations
|
|
1096
|
+
|
|
1097
|
+
### Concurrency and Locking
|
|
1098
|
+
|
|
1099
|
+
TokenLedger uses **pessimistic locking** to ensure thread safety:
|
|
1100
|
+
|
|
1101
|
+
- Each transaction acquires a row-level lock on affected account records using `account.lock!`
|
|
1102
|
+
- This prevents race conditions when multiple processes try to modify the same balance
|
|
1103
|
+
- Locks are held for the duration of the database transaction, then released automatically
|
|
1104
|
+
- PostgreSQL handles concurrent transactions more efficiently than SQLite
|
|
1105
|
+
|
|
1106
|
+
**Production tip:** Under high concurrency, ensure your connection pool size is appropriate to avoid lock contention.
|
|
1107
|
+
|
|
1108
|
+
### Balance Caching
|
|
1109
|
+
|
|
1110
|
+
Always use `user.cached_balance` for reads. Only use `TokenLedger::Balance.calculate` when you need to verify accuracy or during reconciliation.
|
|
1111
|
+
|
|
1112
|
+
```ruby
|
|
1113
|
+
# Fast (uses cached value)
|
|
1114
|
+
if user.cached_balance >= cost
|
|
1115
|
+
# proceed
|
|
1116
|
+
end
|
|
1117
|
+
|
|
1118
|
+
# Slow (calculates from all entries)
|
|
1119
|
+
if TokenLedger::Balance.calculate("wallet:#{user.id}") >= cost
|
|
1120
|
+
# proceed
|
|
1121
|
+
end
|
|
1122
|
+
```
|
|
1123
|
+
|
|
1124
|
+
### Batch Operations
|
|
1125
|
+
|
|
1126
|
+
When crediting multiple users, use transactions:
|
|
1127
|
+
|
|
1128
|
+
```ruby
|
|
1129
|
+
ActiveRecord::Base.transaction do
|
|
1130
|
+
users.each do |user|
|
|
1131
|
+
TokenLedger::Manager.deposit(
|
|
1132
|
+
owner: user,
|
|
1133
|
+
amount: 50,
|
|
1134
|
+
description: "Promotional credit"
|
|
1135
|
+
)
|
|
1136
|
+
end
|
|
1137
|
+
end
|
|
1138
|
+
```
|
|
1139
|
+
|
|
1140
|
+
### Index Optimization
|
|
1141
|
+
|
|
1142
|
+
Ensure you have appropriate indexes for your query patterns:
|
|
1143
|
+
|
|
1144
|
+
```ruby
|
|
1145
|
+
# For transaction history queries
|
|
1146
|
+
add_index :ledger_transactions, [:owner_type, :owner_id, :created_at]
|
|
1147
|
+
|
|
1148
|
+
# For transaction type filtering
|
|
1149
|
+
add_index :ledger_transactions, [:transaction_type, :created_at]
|
|
1150
|
+
|
|
1151
|
+
# For account balance lookups
|
|
1152
|
+
add_index :ledger_accounts, :current_balance
|
|
1153
|
+
```
|
|
1154
|
+
|
|
1155
|
+
## Production Recommendations
|
|
1156
|
+
|
|
1157
|
+
1. **Use PostgreSQL** - Better concurrency handling than SQLite or MySQL
|
|
1158
|
+
2. **Monitor balance drift** - Periodically reconcile cached balances
|
|
1159
|
+
3. **Archive old transactions** - Move old ledger entries to archive tables
|
|
1160
|
+
4. **Set up alerts** - Monitor for `ImbalancedTransactionError` (should never happen)
|
|
1161
|
+
5. **Backup regularly** - Ledger data is financial data
|
|
1162
|
+
6. **Use idempotency keys** - Always provide `external_id` for webhook-triggered deposits
|
|
1163
|
+
7. **Log all transactions** - Send ledger transactions to logging service
|
|
1164
|
+
8. **Rate limit deposits** - Prevent abuse of promotional bonuses
|
|
1165
|
+
|
|
1166
|
+
## Troubleshooting
|
|
1167
|
+
|
|
1168
|
+
### Balance doesn't match expectations
|
|
1169
|
+
|
|
1170
|
+
```ruby
|
|
1171
|
+
# Check actual balance from entries
|
|
1172
|
+
actual = TokenLedger::Balance.calculate("wallet:#{user.id}")
|
|
1173
|
+
cached = user.cached_balance
|
|
1174
|
+
|
|
1175
|
+
if actual != cached
|
|
1176
|
+
puts "Balance drift detected: actual=#{actual}, cached=#{cached}"
|
|
1177
|
+
|
|
1178
|
+
# Fix it
|
|
1179
|
+
TokenLedger::Balance.reconcile_user!(user)
|
|
1180
|
+
end
|
|
1181
|
+
```
|
|
1182
|
+
|
|
1183
|
+
### Find duplicate transactions
|
|
1184
|
+
|
|
1185
|
+
```ruby
|
|
1186
|
+
# Find transactions with same external_id
|
|
1187
|
+
TokenLedger::LedgerTransaction
|
|
1188
|
+
.where(external_source: "stripe", external_id: "inv_123")
|
|
1189
|
+
.count
|
|
1190
|
+
# Should be 1 or 0, never more
|
|
1191
|
+
```
|
|
1192
|
+
|
|
1193
|
+
### Audit specific user's transactions
|
|
1194
|
+
|
|
1195
|
+
```ruby
|
|
1196
|
+
user.ledger_transactions.order(created_at: :desc).each do |txn|
|
|
1197
|
+
puts "#{txn.created_at} | #{txn.transaction_type.ljust(10)} | #{txn.description.ljust(30)} | #{txn.metadata}"
|
|
1198
|
+
|
|
1199
|
+
txn.ledger_entries.each do |entry|
|
|
1200
|
+
sign = entry.entry_type == 'debit' ? '+' : '-'
|
|
1201
|
+
puts " #{sign}#{entry.amount} #{entry.account.name}"
|
|
1202
|
+
end
|
|
1203
|
+
end
|
|
1204
|
+
```
|
|
1205
|
+
|
|
1206
|
+
## License
|
|
1207
|
+
|
|
1208
|
+
MIT. See `LICENSE` for full text.
|