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