pg_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 +3 -0
- data/LICENSE.txt +22 -0
- data/README.md +242 -0
- data/lib/generators/pg_ledger/install/install_generator.rb +20 -0
- data/lib/generators/pg_ledger/install/templates/create_pg_ledger_tables.rb +511 -0
- data/lib/pg_ledger/batch.rb +97 -0
- data/lib/pg_ledger/entry_builder.rb +33 -0
- data/lib/pg_ledger/ledger_scope.rb +43 -0
- data/lib/pg_ledger/models.rb +269 -0
- data/lib/pg_ledger/posting.rb +167 -0
- data/lib/pg_ledger/railtie.rb +12 -0
- data/lib/pg_ledger/version.rb +5 -0
- data/lib/pg_ledger.rb +408 -0
- metadata +76 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgLedger
|
|
4
|
+
def self.table_name_prefix
|
|
5
|
+
"pg_ledger_"
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
class Record < ActiveRecord::Base
|
|
9
|
+
self.abstract_class = true
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class Ledger < Record
|
|
13
|
+
has_many :accounts
|
|
14
|
+
belongs_to :owner, polymorphic: true, optional: true
|
|
15
|
+
validates :name, presence: true
|
|
16
|
+
|
|
17
|
+
DEFAULT_ID = 1
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class Account < Record
|
|
21
|
+
def self.resolve!(ref, currency: nil, ledger_id: Ledger::DEFAULT_ID)
|
|
22
|
+
case ref
|
|
23
|
+
when Account then ref
|
|
24
|
+
when String
|
|
25
|
+
scope = where(code: ref, ledger_id: ledger_id)
|
|
26
|
+
scope = scope.where(currency: currency.to_s.upcase) if currency
|
|
27
|
+
found = scope.limit(2).to_a
|
|
28
|
+
raise UnknownAccount, "no account with code #{ref.inspect}#{" (#{currency})" if currency} in ledger #{ledger_id}" if found.empty?
|
|
29
|
+
raise Error, "code #{ref.inspect} is ambiguous, pass the record or a currency:" if found.size > 1
|
|
30
|
+
found.first
|
|
31
|
+
else
|
|
32
|
+
raise ArgumentError, "account must be a PgLedger::Account or code String, got #{ref.class}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
belongs_to :ledger
|
|
37
|
+
|
|
38
|
+
belongs_to :owner, polymorphic: true, optional: true
|
|
39
|
+
has_many :debit_lines, class_name: "PgLedger::Line", foreign_key: :debit_account_id
|
|
40
|
+
has_many :credit_lines, class_name: "PgLedger::Line", foreign_key: :credit_account_id
|
|
41
|
+
has_many :balances, class_name: "PgLedger::Balance"
|
|
42
|
+
|
|
43
|
+
validates :code, :currency, presence: true
|
|
44
|
+
validates :normal_balance, inclusion: { in: %w[debit credit] }
|
|
45
|
+
|
|
46
|
+
# Balance-aware relation: each row responds to #current_balance, and the
|
|
47
|
+
# aggregate is filterable/sortable in SQL. Sharded accounts sum exactly.
|
|
48
|
+
#
|
|
49
|
+
# PgLedger::Account.with_balance.where("current_balance > ?", 100_00)
|
|
50
|
+
# PgLedger::Account.with_balance.order(current_balance: :desc).limit(10)
|
|
51
|
+
#
|
|
52
|
+
# A LATERAL subquery join (not GROUP BY) so it composes with any other
|
|
53
|
+
# where/order without dragging them into aggregate rules.
|
|
54
|
+
scope :with_balance, -> {
|
|
55
|
+
joins(<<~SQL)
|
|
56
|
+
JOIN LATERAL (
|
|
57
|
+
SELECT COALESCE(SUM(b.amount), 0) AS current_balance
|
|
58
|
+
FROM pg_ledger_balances b WHERE b.account_id = pg_ledger_accounts.id
|
|
59
|
+
) balance_sum ON true
|
|
60
|
+
SQL
|
|
61
|
+
.select("pg_ledger_accounts.*, balance_sum.current_balance")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
INTERVALS = %w[hour day week month].freeze
|
|
65
|
+
# Fixed-step buckets ("30 minutes", "6 hours") chart via date_bin;
|
|
66
|
+
# calendar units above stay on date_trunc (a month has no fixed step).
|
|
67
|
+
FIXED_STEP = /\A\d+\s+(minute|minutes|hour|hours)\z/
|
|
68
|
+
|
|
69
|
+
def balance(at: nil)
|
|
70
|
+
return balances.sum(:amount) unless at
|
|
71
|
+
|
|
72
|
+
# Watermark resolved separately and bound as a literal — a subquery here
|
|
73
|
+
# blinds the planner and turns the tail read into a seq scan. All day
|
|
74
|
+
# boundaries are computed by Postgres in its own timezone (rollup! cuts
|
|
75
|
+
# days the same way); Ruby's zone never enters the comparison.
|
|
76
|
+
sql = <<~SQL
|
|
77
|
+
SELECT
|
|
78
|
+
COALESCE((SELECT SUM(debits - credits) FROM pg_ledger_account_days
|
|
79
|
+
WHERE account_id = :id
|
|
80
|
+
AND day < LEAST(:wm_next::date, :at::timestamptz::date)), 0)
|
|
81
|
+
+
|
|
82
|
+
COALESCE((SELECT SUM(amount) FROM pg_ledger_lines
|
|
83
|
+
WHERE debit_account_id = :id
|
|
84
|
+
AND posted_at >= LEAST(:wm_next::date, :at::timestamptz::date)::timestamptz
|
|
85
|
+
AND posted_at <= :at), 0)
|
|
86
|
+
-
|
|
87
|
+
COALESCE((SELECT SUM(amount) FROM pg_ledger_lines
|
|
88
|
+
WHERE credit_account_id = :id
|
|
89
|
+
AND posted_at >= LEAST(:wm_next::date, :at::timestamptz::date)::timestamptz
|
|
90
|
+
AND posted_at <= :at), 0)
|
|
91
|
+
SQL
|
|
92
|
+
row = self.class.connection.select_rows(
|
|
93
|
+
self.class.sanitize_sql_array([sql, { id: id, at: at, wm_next: rollup_next_day }])
|
|
94
|
+
).first
|
|
95
|
+
signed = Integer(row.first)
|
|
96
|
+
normal_balance == "debit" ? signed : -signed
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# First un-rolled day, as a bare Date — bound into SQL and interpreted in
|
|
100
|
+
# Postgres' timezone, matching how rollup! truncates days.
|
|
101
|
+
def rollup_next_day
|
|
102
|
+
day = AccountDay.where(account_id: id).maximum(:day)
|
|
103
|
+
day ? day + 1 : Date.new(1970, 1, 1)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Balance at the end of each bucket in [from, to] — chart-ready [[Time, Integer]].
|
|
107
|
+
# day/week/month read the daily rollups plus an un-rolled tail from lines;
|
|
108
|
+
# hour reads lines directly (rollups are daily). Exact for sharded accounts.
|
|
109
|
+
def balance_series(from:, to: Time.current, interval: "day")
|
|
110
|
+
interval = interval.to_s
|
|
111
|
+
fixed = interval.match?(FIXED_STEP)
|
|
112
|
+
unless fixed || INTERVALS.include?(interval)
|
|
113
|
+
raise ArgumentError,
|
|
114
|
+
"interval must be one of #{INTERVALS.join(", ")} or a fixed step like \"30 minutes\", got #{interval.inspect}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Bucket function: date_bin for fixed steps, date_trunc for calendar
|
|
118
|
+
# units. Both strings are chosen here, never from user input.
|
|
119
|
+
bucket = fixed ? "date_bin(:unit::interval, %s, 'epoch'::timestamptz)" : "date_trunc(:unit, %s)"
|
|
120
|
+
step = fixed ? ":unit::interval" : "('1 ' || :unit)::interval"
|
|
121
|
+
|
|
122
|
+
sign = normal_balance == "debit" ? "1" : "-1"
|
|
123
|
+
source =
|
|
124
|
+
if fixed || interval == "hour"
|
|
125
|
+
<<~SQL
|
|
126
|
+
day_rows AS (
|
|
127
|
+
SELECT l.posted_at AS ts, #{sign} * l.amount AS delta
|
|
128
|
+
FROM pg_ledger_lines l WHERE l.debit_account_id = :id
|
|
129
|
+
UNION ALL
|
|
130
|
+
SELECT l.posted_at, #{sign} * -l.amount
|
|
131
|
+
FROM pg_ledger_lines l WHERE l.credit_account_id = :id
|
|
132
|
+
)
|
|
133
|
+
SQL
|
|
134
|
+
else
|
|
135
|
+
<<~SQL
|
|
136
|
+
day_rows AS (
|
|
137
|
+
SELECT day::timestamptz AS ts, #{sign} * (debits - credits) AS delta
|
|
138
|
+
FROM pg_ledger_account_days WHERE account_id = :id
|
|
139
|
+
UNION ALL
|
|
140
|
+
SELECT date_trunc('day', l.posted_at), SUM(#{sign} * l.amount)
|
|
141
|
+
FROM pg_ledger_lines l
|
|
142
|
+
WHERE l.debit_account_id = :id AND l.posted_at >= :wm_next::date
|
|
143
|
+
GROUP BY 1
|
|
144
|
+
UNION ALL
|
|
145
|
+
SELECT date_trunc('day', l.posted_at), SUM(#{sign} * -l.amount)
|
|
146
|
+
FROM pg_ledger_lines l
|
|
147
|
+
WHERE l.credit_account_id = :id AND l.posted_at >= :wm_next::date
|
|
148
|
+
GROUP BY 1
|
|
149
|
+
)
|
|
150
|
+
SQL
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
sql = <<~SQL
|
|
154
|
+
WITH #{source},
|
|
155
|
+
buckets AS (
|
|
156
|
+
SELECT generate_series(
|
|
157
|
+
#{format(bucket, ":from::timestamptz")},
|
|
158
|
+
#{format(bucket, ":to::timestamptz")},
|
|
159
|
+
#{step}
|
|
160
|
+
) AS bucket
|
|
161
|
+
),
|
|
162
|
+
opening AS (
|
|
163
|
+
SELECT COALESCE(SUM(delta), 0) AS amount FROM day_rows
|
|
164
|
+
WHERE ts < #{format(bucket, ":from::timestamptz")}
|
|
165
|
+
),
|
|
166
|
+
deltas AS (
|
|
167
|
+
SELECT #{format(bucket, "ts")} AS bucket, SUM(delta) AS delta
|
|
168
|
+
FROM day_rows
|
|
169
|
+
WHERE ts >= #{format(bucket, ":from::timestamptz")} AND ts <= :to
|
|
170
|
+
GROUP BY 1
|
|
171
|
+
)
|
|
172
|
+
SELECT b.bucket,
|
|
173
|
+
(SELECT amount FROM opening) +
|
|
174
|
+
COALESCE(SUM(d.delta) OVER (ORDER BY b.bucket ROWS UNBOUNDED PRECEDING), 0)
|
|
175
|
+
FROM buckets b
|
|
176
|
+
LEFT JOIN deltas d USING (bucket)
|
|
177
|
+
ORDER BY b.bucket
|
|
178
|
+
SQL
|
|
179
|
+
rows = self.class.connection.select_rows(self.class.sanitize_sql_array(
|
|
180
|
+
[sql, { unit: interval, from: from, to: to, id: id, wm_next: rollup_next_day }]
|
|
181
|
+
))
|
|
182
|
+
rows.map { |bucket, amount| [bucket.to_time, Integer(amount)] }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Debit/credit volume, total or per bucket. day/week/month buckets read the
|
|
186
|
+
# daily rollups + un-rolled tail; hour and totals read lines directly.
|
|
187
|
+
def turnover(from:, to: Time.current, interval: nil)
|
|
188
|
+
if interval.nil?
|
|
189
|
+
return {
|
|
190
|
+
debits: debit_lines.where(posted_at: from..to).sum(:amount),
|
|
191
|
+
credits: credit_lines.where(posted_at: from..to).sum(:amount)
|
|
192
|
+
}
|
|
193
|
+
end
|
|
194
|
+
if interval.to_s == "hour"
|
|
195
|
+
sql = <<~SQL
|
|
196
|
+
SELECT bucket, SUM(d), SUM(c) FROM (
|
|
197
|
+
SELECT date_trunc('hour', posted_at) AS bucket, SUM(amount) AS d, 0 AS c
|
|
198
|
+
FROM pg_ledger_lines WHERE debit_account_id = :id AND posted_at BETWEEN :from AND :to GROUP BY 1
|
|
199
|
+
UNION ALL
|
|
200
|
+
SELECT date_trunc('hour', posted_at), 0, SUM(amount)
|
|
201
|
+
FROM pg_ledger_lines WHERE credit_account_id = :id AND posted_at BETWEEN :from AND :to GROUP BY 1
|
|
202
|
+
) t GROUP BY 1 ORDER BY 1
|
|
203
|
+
SQL
|
|
204
|
+
return self.class.connection.select_rows(self.class.sanitize_sql_array(
|
|
205
|
+
[sql, { id: id, from: from, to: to }]
|
|
206
|
+
)).map { |b, d, c| { period: b.to_time, debits: d.to_i, credits: c.to_i } }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
interval = interval.to_s
|
|
210
|
+
unless INTERVALS.include?(interval)
|
|
211
|
+
raise ArgumentError, "interval must be one of #{INTERVALS.join(", ")}, got #{interval.inspect}"
|
|
212
|
+
end
|
|
213
|
+
sql = <<~SQL
|
|
214
|
+
WITH day_rows AS (
|
|
215
|
+
SELECT day::timestamptz AS ts, debits, credits
|
|
216
|
+
FROM pg_ledger_account_days WHERE account_id = :id
|
|
217
|
+
UNION ALL
|
|
218
|
+
SELECT date_trunc('day', l.posted_at), SUM(l.amount), 0
|
|
219
|
+
FROM pg_ledger_lines l
|
|
220
|
+
WHERE l.debit_account_id = :id AND l.posted_at >= :wm_next::date
|
|
221
|
+
GROUP BY 1
|
|
222
|
+
UNION ALL
|
|
223
|
+
SELECT date_trunc('day', l.posted_at), 0, SUM(l.amount)
|
|
224
|
+
FROM pg_ledger_lines l
|
|
225
|
+
WHERE l.credit_account_id = :id AND l.posted_at >= :wm_next::date
|
|
226
|
+
GROUP BY 1
|
|
227
|
+
)
|
|
228
|
+
SELECT date_trunc(:unit, ts) AS bucket, SUM(debits), SUM(credits)
|
|
229
|
+
FROM day_rows
|
|
230
|
+
WHERE ts >= date_trunc(:unit, :from::timestamptz) AND ts <= :to
|
|
231
|
+
GROUP BY 1 ORDER BY 1
|
|
232
|
+
SQL
|
|
233
|
+
rows = self.class.connection.select_rows(self.class.sanitize_sql_array(
|
|
234
|
+
[sql, { unit: interval, from: from, to: to, id: id, wm_next: rollup_next_day }]
|
|
235
|
+
))
|
|
236
|
+
rows.map { |b, d, c| { period: b.to_time, debits: Integer(d), credits: Integer(c) } }
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
class Entry < Record
|
|
241
|
+
has_many :lines
|
|
242
|
+
|
|
243
|
+
validates :posted_at, presence: true
|
|
244
|
+
|
|
245
|
+
def readonly?
|
|
246
|
+
persisted?
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
class Line < Record
|
|
251
|
+
belongs_to :entry
|
|
252
|
+
belongs_to :debit_account, class_name: "PgLedger::Account"
|
|
253
|
+
belongs_to :credit_account, class_name: "PgLedger::Account"
|
|
254
|
+
|
|
255
|
+
validates :amount, numericality: { only_integer: true, greater_than: 0 }
|
|
256
|
+
|
|
257
|
+
def readonly?
|
|
258
|
+
persisted?
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
class Balance < Record
|
|
263
|
+
belongs_to :account
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
class AccountDay < Record
|
|
267
|
+
belongs_to :account
|
|
268
|
+
end
|
|
269
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgLedger
|
|
4
|
+
class Posting
|
|
5
|
+
CALL_SQL = <<~SQL
|
|
6
|
+
SELECT * FROM pg_ledger_post(?, ?, ?::jsonb, ?, ?::jsonb, ?::jsonb, ?, ?)
|
|
7
|
+
SQL
|
|
8
|
+
|
|
9
|
+
def initialize(legs:, idempotency_key:, metadata:, posted_at:, reverses: nil,
|
|
10
|
+
shard_hint: nil, ledger_id: Ledger::DEFAULT_ID)
|
|
11
|
+
@legs = legs
|
|
12
|
+
@idempotency_key = idempotency_key
|
|
13
|
+
@metadata = metadata || {}
|
|
14
|
+
@posted_at = posted_at
|
|
15
|
+
@reverses = reverses
|
|
16
|
+
@shard_hint = shard_hint
|
|
17
|
+
@ledger_id = ledger_id
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
prepare
|
|
22
|
+
rows = Entry.find_by_sql(
|
|
23
|
+
[CALL_SQL, @idempotency_key, digest, JSON.generate(@metadata), @posted_at,
|
|
24
|
+
JSON.generate(legs_payload), JSON.generate(deltas_payload), @reverses, @ledger_id]
|
|
25
|
+
)
|
|
26
|
+
rows.first
|
|
27
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
28
|
+
raise Posting.map_pg_error(e)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# For Batch: everything the SQL function needs, no execution.
|
|
32
|
+
def payload
|
|
33
|
+
prepare
|
|
34
|
+
{
|
|
35
|
+
idempotency_key: @idempotency_key,
|
|
36
|
+
request_digest: digest,
|
|
37
|
+
metadata: @metadata,
|
|
38
|
+
posted_at: @posted_at,
|
|
39
|
+
reverses_entry_id: @reverses,
|
|
40
|
+
ledger_id: @ledger_id,
|
|
41
|
+
legs: legs_payload,
|
|
42
|
+
deltas: deltas_payload
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
ERROR_MARKERS = {
|
|
47
|
+
"pg_ledger:insufficient_balance" => :InsufficientBalance,
|
|
48
|
+
"pg_ledger:idempotency_conflict" => :IdempotencyConflict,
|
|
49
|
+
"pg_ledger:frozen_account" => :FrozenAccount,
|
|
50
|
+
"pg_ledger:period_closed" => :PeriodClosed,
|
|
51
|
+
"pg_ledger:over_reversal" => :OverReversal,
|
|
52
|
+
"pg_ledger:invalid_reversal" => :InvalidReversal,
|
|
53
|
+
"pg_ledger:cross_ledger" => :CrossLedger,
|
|
54
|
+
"is not balanced" => :Unbalanced,
|
|
55
|
+
"rows are immutable" => :ImmutableRecord
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
58
|
+
def self.map_pg_error(error)
|
|
59
|
+
ERROR_MARKERS.each do |marker, name|
|
|
60
|
+
return PgLedger.const_get(name).new(error.message) if error.message.include?(marker)
|
|
61
|
+
end
|
|
62
|
+
error
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def prepare
|
|
68
|
+
return if @accounts
|
|
69
|
+
|
|
70
|
+
raise ArgumentError, "entry has no lines" if @legs.empty?
|
|
71
|
+
@accounts = @legs.to_h { |leg| [leg, Account.resolve!(leg.account_ref, ledger_id: @ledger_id)] }
|
|
72
|
+
check_balanced!
|
|
73
|
+
build_pairs
|
|
74
|
+
build_deltas
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def check_balanced!
|
|
78
|
+
sums = Hash.new(0)
|
|
79
|
+
@legs.each do |leg|
|
|
80
|
+
sign = leg.direction == "debit" ? 1 : -1
|
|
81
|
+
sums[@accounts[leg].currency] += sign * leg.amount
|
|
82
|
+
end
|
|
83
|
+
unbalanced = sums.reject { |_, sum| sum.zero? }
|
|
84
|
+
return if unbalanced.empty?
|
|
85
|
+
|
|
86
|
+
raise Unbalanced, "entry does not balance: #{unbalanced.map { |cur, sum| "#{cur} off by #{sum}" }.join(", ")}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A journal row IS a balanced pair: debit_account -> credit_account in
|
|
90
|
+
# one currency. Builder legs are netted per (account, currency) first —
|
|
91
|
+
# a debit and a credit of the same account cancel — then greedily zipped
|
|
92
|
+
# into pairs. Netting is what makes decomposition total: net debits and
|
|
93
|
+
# net credits sum equal per currency (check_balanced! holds), so the zip
|
|
94
|
+
# always terminates with both sides empty.
|
|
95
|
+
def build_pairs
|
|
96
|
+
@pairs = []
|
|
97
|
+
by_currency = Hash.new { |h, k| h[k] = Hash.new(0) }
|
|
98
|
+
@legs.each do |leg|
|
|
99
|
+
account = @accounts[leg]
|
|
100
|
+
by_currency[account.currency][account] += (leg.direction == "debit" ? leg.amount : -leg.amount)
|
|
101
|
+
end
|
|
102
|
+
by_currency.each do |currency, nets|
|
|
103
|
+
debits = nets.select { |_, v| v.positive? }.map { |a, v| [a, v] }.sort_by { |a, _| a.id }
|
|
104
|
+
credits = nets.select { |_, v| v.negative? }.map { |a, v| [a, -v] }.sort_by { |a, _| a.id }
|
|
105
|
+
until debits.empty? || credits.empty?
|
|
106
|
+
d, c = debits.first, credits.first
|
|
107
|
+
amount = [d[1], c[1]].min
|
|
108
|
+
@pairs << { debit: d[0], credit: c[0], currency: currency, amount: amount }
|
|
109
|
+
d[1] -= amount
|
|
110
|
+
c[1] -= amount
|
|
111
|
+
debits.shift if d[1].zero?
|
|
112
|
+
credits.shift if c[1].zero?
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
raise ArgumentError, "entry nets to nothing" if @pairs.empty?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# One delta row per account; sharded accounts get one random shard per
|
|
119
|
+
# posting, chosen here so batches can pre-lock every touched row. A batch
|
|
120
|
+
# passes a shared shard_hint so all its postings hit the SAME shard per
|
|
121
|
+
# account — otherwise a large batch touches every shard and concurrent
|
|
122
|
+
# batches serialize on the pre-lock union.
|
|
123
|
+
def build_deltas
|
|
124
|
+
@deltas = {}
|
|
125
|
+
@pairs.each do |pair|
|
|
126
|
+
[[pair[:debit], "debit"], [pair[:credit], "credit"]].each do |account, side|
|
|
127
|
+
sign = (side == account.normal_balance) ? 1 : -1
|
|
128
|
+
delta = (@deltas[account.id] ||= {
|
|
129
|
+
account_id: account.id,
|
|
130
|
+
shard: pick_shard(account),
|
|
131
|
+
delta: 0,
|
|
132
|
+
min_balance: account.min_balance
|
|
133
|
+
})
|
|
134
|
+
delta[:delta] += sign * pair[:amount]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def pick_shard(account)
|
|
140
|
+
shards = account.balance_shards
|
|
141
|
+
return 0 if shards <= 1
|
|
142
|
+
|
|
143
|
+
@shard_hint ? [(@shard_hint * shards).floor, shards - 1].min : rand(shards)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def legs_payload
|
|
147
|
+
@pairs.map do |pair|
|
|
148
|
+
{ debit_account_id: pair[:debit].id, credit_account_id: pair[:credit].id,
|
|
149
|
+
currency: pair[:currency], amount: pair[:amount] }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def deltas_payload
|
|
154
|
+
@deltas.values
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def digest
|
|
158
|
+
@digest ||= Digest::SHA256.hexdigest(
|
|
159
|
+
JSON.generate(
|
|
160
|
+
pairs: @pairs.map { |p| [p[:debit].id, p[:credit].id, p[:currency], p[:amount]] },
|
|
161
|
+
metadata: @metadata,
|
|
162
|
+
reverses: @reverses
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgLedger
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
# The models load via ActiveSupport.on_load(:active_record); in an app
|
|
6
|
+
# that reaches PgLedger.* before touching ActiveRecord (a rake task, a
|
|
7
|
+
# console one-liner) that hook may not have fired yet — trigger it.
|
|
8
|
+
config.after_initialize do
|
|
9
|
+
ActiveRecord::Base if defined?(ActiveRecord)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|