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
|
@@ -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,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
|
data/lib/token_ledger.rb
ADDED
|
@@ -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
|