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.
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module TokenLedger
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ class_option :owner_model, type: :string, default: "User",
12
+ desc: "The model that owns tokens (e.g., User, Team)"
13
+
14
+ source_root File.expand_path("templates", __dir__)
15
+
16
+ def copy_ledger_migration
17
+ migration_template "create_ledger_tables.rb",
18
+ "db/migrate/create_ledger_tables.rb"
19
+ end
20
+
21
+ def copy_owner_migration
22
+ @owner_class_name = options[:owner_model]
23
+ @owner_table_name = @owner_class_name.tableize
24
+
25
+ migration_template "add_cached_balance_to_owner.rb",
26
+ "db/migrate/add_cached_balance_to_#{@owner_table_name}.rb"
27
+ end
28
+
29
+ def show_readme
30
+ readme "README" if behavior == :invoke
31
+ end
32
+
33
+ private
34
+
35
+ def owner_class_name
36
+ @owner_class_name
37
+ end
38
+
39
+ def owner_table_name
40
+ @owner_table_name
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,41 @@
1
+ ===============================================================================
2
+
3
+ TokenLedger has been installed!
4
+
5
+ Created migrations:
6
+ ✓ db/migrate/XXXXXX_create_ledger_tables.rb
7
+ ✓ db/migrate/XXXXXX_add_cached_balance_to_users.rb
8
+
9
+ ===============================================================================
10
+
11
+ Next steps:
12
+
13
+ 1. Run migrations:
14
+ rails db:migrate
15
+
16
+ 2. Add to app/models/user.rb:
17
+
18
+ has_many :ledger_transactions,
19
+ as: :owner,
20
+ class_name: "TokenLedger::LedgerTransaction"
21
+
22
+ def balance
23
+ cached_balance
24
+ end
25
+
26
+ 3. Optional: Create seed accounts (see README for examples)
27
+
28
+ 4. Start using the ledger:
29
+
30
+ TokenLedger::Manager.deposit(
31
+ owner: user,
32
+ amount: 100,
33
+ description: "Welcome bonus",
34
+ external_source: "promo",
35
+ external_id: "signup_#{user.id}"
36
+ )
37
+
38
+ 5. Read the full documentation:
39
+ https://github.com/wuliwong/token_ledger#readme
40
+
41
+ ===============================================================================
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddCachedBalanceTo<%= owner_class_name.pluralize %> < ActiveRecord::Migration[7.0]
4
+ def change
5
+ add_column :<%= owner_table_name %>, :cached_balance, :bigint, default: 0, null: false
6
+ add_index :<%= owner_table_name %>, :cached_balance
7
+ end
8
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateLedgerTables < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :ledger_accounts do |t|
6
+ t.string :code, null: false
7
+ t.string :name, null: false
8
+ t.bigint :current_balance, default: 0, null: false
9
+ t.jsonb :metadata, default: {}, null: false
10
+ t.timestamps
11
+
12
+ t.index :code, unique: true
13
+ t.index :current_balance
14
+ end
15
+
16
+ create_table :ledger_transactions do |t|
17
+ t.string :transaction_type, null: false
18
+ t.string :description
19
+ t.references :owner, polymorphic: true
20
+ t.bigint :parent_transaction_id
21
+ t.string :external_source
22
+ t.string :external_id
23
+ t.jsonb :metadata, default: {}, null: false
24
+ t.timestamps
25
+
26
+ t.index [:external_source, :external_id], unique: true, where: "external_source IS NOT NULL"
27
+ t.index [:owner_type, :owner_id, :created_at]
28
+ t.index [:transaction_type, :created_at]
29
+ t.index :parent_transaction_id
30
+ end
31
+
32
+ # Add foreign key constraint for parent-child transaction relationships
33
+ add_foreign_key :ledger_transactions, :ledger_transactions,
34
+ column: :parent_transaction_id,
35
+ on_delete: :restrict
36
+
37
+ create_table :ledger_entries do |t|
38
+ t.references :account, null: false, foreign_key: { to_table: :ledger_accounts, on_delete: :restrict }
39
+ t.references :transaction, null: false, foreign_key: { to_table: :ledger_transactions, on_delete: :restrict }
40
+ t.string :entry_type, null: false
41
+ t.bigint :amount, null: false
42
+ t.jsonb :metadata, default: {}, null: false
43
+ t.timestamps
44
+
45
+ t.index [:account_id, :created_at]
46
+ t.index [:transaction_id, :entry_type]
47
+ end
48
+
49
+ # Add CHECK constraints for data integrity
50
+ add_check_constraint :ledger_entries, "amount > 0", name: "positive_amount"
51
+ add_check_constraint :ledger_entries, "entry_type IN ('debit', 'credit')", name: "valid_entry_type"
52
+ add_check_constraint :ledger_transactions,
53
+ "transaction_type IN ('deposit', 'spend', 'reserve', 'capture', 'release', 'adjustment')",
54
+ name: "valid_transaction_type"
55
+ add_check_constraint :ledger_transactions,
56
+ "(external_source IS NULL AND external_id IS NULL) OR (external_source IS NOT NULL AND external_id IS NOT NULL)",
57
+ name: "external_source_id_consistency"
58
+ end
59
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenLedger
4
+ class Error < StandardError; end
5
+ class InsufficientFundsError < Error; end
6
+ class ImbalancedTransactionError < Error; end
7
+ class DuplicateTransactionError < Error; end
8
+ class AccountNotFoundError < Error; end
9
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenLedger
4
+ class LedgerAccount < ActiveRecord::Base
5
+ self.table_name = "ledger_accounts"
6
+
7
+ has_many :ledger_entries, class_name: "TokenLedger::LedgerEntry", foreign_key: "account_id", dependent: :restrict_with_error
8
+
9
+ validates :code, presence: true, uniqueness: true
10
+ validates :name, presence: true
11
+ validates :current_balance, presence: true, numericality: { only_integer: true }
12
+
13
+ def self.find_or_create_account(code:, name:)
14
+ find_or_create_by!(code: code) do |account|
15
+ account.name = name
16
+ account.current_balance = 0
17
+ account.metadata = {}
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenLedger
4
+ class LedgerEntry < ActiveRecord::Base
5
+ self.table_name = "ledger_entries"
6
+
7
+ belongs_to :account, class_name: "TokenLedger::LedgerAccount", required: true
8
+ belongs_to :ledger_transaction, class_name: "TokenLedger::LedgerTransaction", foreign_key: "transaction_id", required: true
9
+
10
+ validates :entry_type, presence: true, inclusion: { in: %w[debit credit] }
11
+ validates :amount, presence: true, numericality: { greater_than: 0, only_integer: true }
12
+ end
13
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenLedger
4
+ class LedgerTransaction < ActiveRecord::Base
5
+ self.table_name = "ledger_transactions"
6
+
7
+ belongs_to :owner, polymorphic: true, optional: true
8
+ belongs_to :parent_transaction, class_name: "TokenLedger::LedgerTransaction", optional: true
9
+ has_many :child_transactions, class_name: "TokenLedger::LedgerTransaction", foreign_key: "parent_transaction_id", dependent: :nullify
10
+ has_many :ledger_entries, class_name: "TokenLedger::LedgerEntry", foreign_key: "transaction_id", dependent: :destroy
11
+
12
+ validates :transaction_type, presence: true
13
+ validates :description, presence: true
14
+ validates :external_id, uniqueness: { scope: :external_source }, if: -> { external_source.present? }
15
+
16
+ def amount
17
+ ledger_entries.where(entry_type: "debit").sum(:amount)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenLedger
4
+ class Account
5
+ def self.find_or_create(code:, name:)
6
+ LedgerAccount.find_or_create_account(code: code, name: name)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenLedger
4
+ class Balance
5
+ # Calculate balance from entries (source of truth)
6
+ def self.calculate(account_or_code)
7
+ account = account_or_code.is_a?(String) ?
8
+ LedgerAccount.find_by(code: account_or_code) :
9
+ account_or_code
10
+ return 0 unless account
11
+
12
+ entries = LedgerEntry.where(account: account)
13
+
14
+ entries.sum do |entry|
15
+ case entry.entry_type
16
+ when "debit" then entry.amount
17
+ when "credit" then -entry.amount
18
+ else 0
19
+ end
20
+ end
21
+ end
22
+
23
+ # Reconcile cached balance with calculated balance
24
+ def self.reconcile!(account_or_code)
25
+ account = account_or_code.is_a?(String) ?
26
+ LedgerAccount.find_by(code: account_or_code) :
27
+ account_or_code
28
+ return unless account
29
+
30
+ calculated = calculate(account)
31
+ cached = account.current_balance
32
+
33
+ if calculated != cached
34
+ account.update_column(:current_balance, calculated)
35
+ end
36
+
37
+ calculated
38
+ end
39
+
40
+ # Reconcile user's cached balance
41
+ def self.reconcile_user!(user)
42
+ wallet_account = LedgerAccount.find_by(code: "wallet:#{user.id}")
43
+ raise AccountNotFoundError, "Account not found for User ##{user.id}" unless wallet_account
44
+
45
+ reconcile!(wallet_account)
46
+ user.update_column(:cached_balance, wallet_account.current_balance)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module TokenLedger
6
+ class Manager
7
+ # Deposit tokens (purchase, bonus)
8
+ def self.deposit(owner:, amount:, description:, external_source: nil, external_id: nil, metadata: {})
9
+ logger.info "TokenLedger::Manager.deposit called - Owner: #{owner&.class&.name}##{owner&.id}, Amount: #{amount}, Source: #{external_source}, ID: #{external_id}"
10
+
11
+ return if amount.zero?
12
+
13
+ # Determine token source account
14
+ source_code = external_source ? "source:#{external_source}" : "source:other"
15
+ source_name = external_source ? "#{external_source.capitalize} Token Source" : "Other Token Source"
16
+
17
+ logger.info "Calling record_transaction for deposit"
18
+ record_transaction(
19
+ type: "deposit",
20
+ description: description,
21
+ owner: owner,
22
+ external_source: external_source,
23
+ external_id: external_id,
24
+ metadata: metadata,
25
+ entries: [
26
+ {
27
+ account_code: "wallet:#{owner.id}",
28
+ account_name: "User #{owner.id} Wallet",
29
+ type: :debit,
30
+ amount: amount
31
+ },
32
+ {
33
+ account_code: source_code,
34
+ account_name: source_name,
35
+ type: :credit,
36
+ amount: amount
37
+ }
38
+ ]
39
+ )
40
+ end
41
+
42
+ # Simple spend method - deducts tokens immediately
43
+ # DO NOT use this with external API calls - use reserve/capture/release instead
44
+ def self.spend(owner:, amount:, description:, metadata: {})
45
+ return if amount.zero?
46
+
47
+ record_transaction(
48
+ type: "spend",
49
+ description: description,
50
+ owner: owner,
51
+ metadata: metadata,
52
+ entries: [
53
+ {
54
+ account_code: "wallet:#{owner.id}",
55
+ account_name: "User #{owner.id} Wallet",
56
+ type: :credit,
57
+ amount: amount,
58
+ enforce_positive: true # No overdraft
59
+ },
60
+ {
61
+ account_code: "sink:consumed",
62
+ account_name: "Tokens Consumed",
63
+ type: :debit,
64
+ amount: amount
65
+ }
66
+ ]
67
+ )
68
+ end
69
+
70
+ # Reserve/Capture/Release pattern for spending with external APIs
71
+ def self.spend_with_api(owner:, amount:, description:, metadata: {}, &block)
72
+ return yield if amount.zero? # Skip ledger for free operations
73
+
74
+ # Check available balance (cached)
75
+ raise InsufficientFundsError, "Insufficient tokens" if owner.cached_balance < amount
76
+
77
+ reservation_id = nil
78
+ result = nil
79
+
80
+ begin
81
+ # Step 1: Reserve tokens (atomic, inside DB transaction)
82
+ reservation_id = reserve(
83
+ owner: owner,
84
+ amount: amount,
85
+ description: "Reserve: #{description}",
86
+ metadata: metadata
87
+ )
88
+
89
+ # Step 2: Execute external API (OUTSIDE transaction - cannot rollback)
90
+ result = block.call
91
+
92
+ # Step 3: Capture reserved tokens (mark as consumed)
93
+ capture(
94
+ reservation_id: reservation_id,
95
+ description: "Capture: #{description}",
96
+ metadata: metadata
97
+ )
98
+
99
+ result
100
+ rescue => e
101
+ # Step 3b: Release reserved tokens on failure
102
+ release(
103
+ reservation_id: reservation_id,
104
+ description: "Release: #{description}",
105
+ metadata: metadata.merge(error: e.message)
106
+ ) if reservation_id
107
+
108
+ raise e # Re-raise to maintain error flow
109
+ end
110
+ end
111
+
112
+ # Reserve tokens (move from wallet to reserved)
113
+ def self.reserve(owner:, amount:, description:, metadata: {})
114
+ record_transaction(
115
+ type: "reserve",
116
+ description: description,
117
+ owner: owner,
118
+ metadata: metadata,
119
+ entries: [
120
+ {
121
+ account_code: "wallet:#{owner.id}",
122
+ account_name: "User #{owner.id} Wallet",
123
+ type: :credit,
124
+ amount: amount,
125
+ enforce_positive: true # No overdraft
126
+ },
127
+ {
128
+ account_code: "wallet:#{owner.id}:reserved",
129
+ account_name: "User #{owner.id} Reserved",
130
+ type: :debit,
131
+ amount: amount
132
+ }
133
+ ]
134
+ )
135
+ end
136
+
137
+ # Capture reserved tokens (mark as consumed)
138
+ def self.capture(reservation_id:, amount: nil, description:, external_source: nil, external_id: nil, metadata: {})
139
+ # Find and verify the reservation transaction
140
+ reservation = LedgerTransaction.find_by(id: reservation_id, transaction_type: "reserve")
141
+ raise ArgumentError, "Reservation transaction #{reservation_id} not found" unless reservation
142
+
143
+ owner = reservation.owner
144
+ raise ArgumentError, "Reservation has no owner" unless owner
145
+
146
+ # Get the reserved amount from the original transaction
147
+ reserved_entry = reservation.ledger_entries.find_by(entry_type: "debit")
148
+ reserved_amount = reserved_entry.amount
149
+
150
+ # Use specified amount or full reserved amount
151
+ capture_amount = amount || reserved_amount
152
+
153
+ # Verify we're not capturing more than was reserved
154
+ raise ArgumentError, "Cannot capture #{capture_amount} tokens (only #{reserved_amount} reserved)" if capture_amount > reserved_amount
155
+
156
+ record_transaction(
157
+ type: "capture",
158
+ description: description,
159
+ owner: owner,
160
+ parent_transaction_id: reservation_id,
161
+ external_source: external_source,
162
+ external_id: external_id,
163
+ metadata: metadata,
164
+ entries: [
165
+ {
166
+ account_code: "wallet:#{owner.id}:reserved",
167
+ account_name: "User #{owner.id} Reserved",
168
+ type: :credit,
169
+ amount: capture_amount,
170
+ enforce_positive: true
171
+ },
172
+ {
173
+ account_code: "sink:consumed",
174
+ account_name: "Tokens Consumed",
175
+ type: :debit,
176
+ amount: capture_amount
177
+ }
178
+ ]
179
+ )
180
+ end
181
+
182
+ # Release reserved tokens (refund to wallet)
183
+ def self.release(reservation_id:, amount: nil, description:, external_source: nil, external_id: nil, metadata: {})
184
+ # Find and verify the reservation transaction
185
+ reservation = LedgerTransaction.find_by(id: reservation_id, transaction_type: "reserve")
186
+ raise ArgumentError, "Reservation transaction #{reservation_id} not found" unless reservation
187
+
188
+ owner = reservation.owner
189
+ raise ArgumentError, "Reservation has no owner" unless owner
190
+
191
+ # Get the reserved amount from the original transaction
192
+ reserved_entry = reservation.ledger_entries.find_by(entry_type: "debit")
193
+ reserved_amount = reserved_entry.amount
194
+
195
+ # Use specified amount or full reserved amount
196
+ release_amount = amount || reserved_amount
197
+
198
+ # Verify we're not releasing more than was reserved
199
+ raise ArgumentError, "Cannot release #{release_amount} tokens (only #{reserved_amount} reserved)" if release_amount > reserved_amount
200
+
201
+ record_transaction(
202
+ type: "release",
203
+ description: description,
204
+ owner: owner,
205
+ parent_transaction_id: reservation_id,
206
+ external_source: external_source,
207
+ external_id: external_id,
208
+ metadata: metadata,
209
+ entries: [
210
+ {
211
+ account_code: "wallet:#{owner.id}:reserved",
212
+ account_name: "User #{owner.id} Reserved",
213
+ type: :credit,
214
+ amount: release_amount,
215
+ enforce_positive: true
216
+ },
217
+ {
218
+ account_code: "wallet:#{owner.id}",
219
+ account_name: "User #{owner.id} Wallet",
220
+ type: :debit,
221
+ amount: release_amount
222
+ }
223
+ ]
224
+ )
225
+ end
226
+
227
+ # Adjust/Reverse a transaction by posting opposite entries
228
+ # Used for corrections, reversals, and manual adjustments
229
+ def self.adjust(owner:, entries:, description:, external_source: nil, external_id: nil, metadata: {})
230
+ record_transaction(
231
+ type: "adjustment",
232
+ description: description,
233
+ owner: owner,
234
+ external_source: external_source,
235
+ external_id: external_id,
236
+ metadata: metadata,
237
+ entries: entries
238
+ )
239
+ end
240
+
241
+ # Low-level transaction creation with row-level locking
242
+ def self.record_transaction(type:, description:, entries:, owner: nil, parent_transaction_id: nil, external_source: nil, external_id: nil, metadata: {})
243
+ logger.info "TokenLedger::Manager.record_transaction called - Type: #{type}, Owner: #{owner&.class&.name}##{owner&.id}, External: #{external_source}/#{external_id}"
244
+
245
+ ActiveRecord::Base.transaction do
246
+ # Verify entries balance
247
+ debits = entries.select { |e| e[:type] == :debit }.sum { |e| e[:amount] }
248
+ credits = entries.select { |e| e[:type] == :credit }.sum { |e| e[:amount] }
249
+
250
+ logger.info "Debits: #{debits}, Credits: #{credits}"
251
+
252
+ raise ImbalancedTransactionError, "Debits (#{debits}) != Credits (#{credits})" if debits != credits
253
+ raise ImbalancedTransactionError, "Transaction must have entries" if debits.zero? && credits.zero?
254
+
255
+ # Check for duplicate external transaction
256
+ if external_source && external_id
257
+ existing = LedgerTransaction.find_by(external_source: external_source, external_id: external_id)
258
+ if existing
259
+ logger.warn "Duplicate transaction found: #{external_source}/#{external_id}"
260
+ raise DuplicateTransactionError, "Duplicate transaction detected: #{external_source}/#{external_id}"
261
+ end
262
+ logger.info "No duplicate found for #{external_source}/#{external_id}"
263
+ end
264
+
265
+ # Create transaction
266
+ logger.info "Creating LedgerTransaction..."
267
+ txn = LedgerTransaction.create!(
268
+ transaction_type: type,
269
+ description: description,
270
+ owner: owner,
271
+ parent_transaction_id: parent_transaction_id,
272
+ external_source: external_source,
273
+ external_id: external_id,
274
+ metadata: metadata
275
+ )
276
+ logger.info "LedgerTransaction created: #{txn.id}"
277
+
278
+ # Create entries with row-level locking and balance updates
279
+ entries.each do |entry_data|
280
+ account = Account.find_or_create(
281
+ code: entry_data[:account_code],
282
+ name: entry_data[:account_name]
283
+ )
284
+
285
+ # Lock account row for update (prevents race conditions)
286
+ account.lock!
287
+
288
+ # Calculate new balance
289
+ balance_delta = case entry_data[:type]
290
+ when :debit then entry_data[:amount]
291
+ when :credit then -entry_data[:amount]
292
+ end
293
+
294
+ new_balance = account.current_balance + balance_delta
295
+
296
+ # Enforce no overdraft (unless explicitly allowed)
297
+ if entry_data[:enforce_positive] && new_balance < 0
298
+ raise InsufficientFundsError, "Account #{account.code} would go negative (balance: #{account.current_balance}, delta: #{balance_delta})"
299
+ end
300
+
301
+ # Update account balance atomically
302
+ account.update_column(:current_balance, new_balance)
303
+
304
+ # Create ledger entry
305
+ LedgerEntry.create!(
306
+ account: account,
307
+ ledger_transaction: txn,
308
+ entry_type: entry_data[:type].to_s,
309
+ amount: entry_data[:amount],
310
+ metadata: entry_data[:metadata] || {}
311
+ )
312
+ end
313
+
314
+ # Update user cached balance if wallet affected
315
+ if owner && owner.respond_to?(:cached_balance)
316
+ wallet_account = LedgerAccount.find_by(code: "wallet:#{owner.id}")
317
+ if wallet_account
318
+ logger.info "Updating cached_balance for #{owner.class.name}##{owner.id}: #{wallet_account.current_balance}"
319
+ owner.update_column(:cached_balance, wallet_account.current_balance)
320
+ owner.broadcast_balance if owner.respond_to?(:broadcast_balance)
321
+ end
322
+ end
323
+
324
+ logger.info "Transaction #{txn.id} completed successfully"
325
+ txn.id
326
+ end
327
+ end
328
+
329
+ def self.logger
330
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
331
+ Rails.logger
332
+ else
333
+ @logger ||= begin
334
+ fallback_logger = Logger.new($stdout)
335
+ fallback_logger.level = Logger::WARN
336
+ fallback_logger
337
+ end
338
+ end
339
+ end
340
+ private_class_method :logger
341
+ end
342
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenLedger
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "active_support"
5
+
6
+ require_relative "token_ledger/version"
7
+ require_relative "token_ledger/errors"
8
+ require_relative "token_ledger/models/ledger_account"
9
+ require_relative "token_ledger/models/ledger_transaction"
10
+ require_relative "token_ledger/models/ledger_entry"
11
+ require_relative "token_ledger/services/account"
12
+ require_relative "token_ledger/services/balance"
13
+ require_relative "token_ledger/services/manager"
14
+
15
+ module TokenLedger
16
+ end
@@ -0,0 +1,4 @@
1
+ module TokenLedger
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end