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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgLedger
4
+ VERSION = "0.1.0"
5
+ end