Module: SplttyCLI::Totals

Defined in:
lib/spltty_cli/totals.rb

Overview

Per-person totals + settlement for ledger tables. The Responsible cell is resolved against split groups — the row's ledger header groups first, then the global config groups — rather than by parsing a Both (NN/NN T/C) string. A match splits the value by the group's participant percentages; anything that matches no group is a person (100% to that name).

Two dimensions are reported: RESPONSIBLE (what each person owes) and PAID BY (what each fronted). Net = Paid - Owed drives the greedy settlement.

Constant Summary collapse

EPS =
0.005

Class Method Summary collapse

Class Method Details

.fmt(number) ⇒ Object

"%,.2f": comma thousands separators, 2 decimals.



211
212
213
214
215
# File 'lib/spltty_cli/totals.rb', line 211

def fmt(number)
  whole, frac = format("%.2f", number.abs).split(".")
  whole = whole.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
  "#{'-' if number.negative?}#{whole}.#{frac}"
end

.fmt_signed(number) ⇒ Object

"%+,.2f": like fmt but always signed.



218
219
220
# File 'lib/spltty_cli/totals.rb', line 218

def fmt_signed(number)
  "#{number.negative? ? '-' : '+'}#{fmt(number.abs)}"
end

.ledger_rows(config, name) ⇒ Object

All entry rows for a ledger: a monthly ledger merges every YYYY-MM.md; a single-file ledger is its one .ledger.md.



91
92
93
94
95
96
97
98
99
100
101
# File 'lib/spltty_cli/totals.rb', line 91

def ledger_rows(config, name)
  entry = config.ledgers[name]
  if entry["type"] == "monthly"
    dir = File.join(config.accounts_dir, entry["dir"] || name)
    months = Dir.children(dir).select { |f| f =~ Discovery::MONTH_FILE }.sort
    months.flat_map { |m| read_rows(File.join(dir, m)) }
  else
    file = File.join(config.accounts_dir, entry["file"] || "#{name}.ledger.md")
    File.exist?(file) ? read_rows(file) : []
  end
end

.merge(tallies) ⇒ Object

Sum several tallies into one (for --combined).



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/spltty_cli/totals.rb', line 134

def merge(tallies)
  out = {
    count: 0, grand: 0.0,
    paid: Hash.new(0.0), owed: Hash.new(0.0),
    spent: Hash.new(0.0), individual: Hash.new(0.0),
    shared: Hash.new(0.0), other: Hash.new(0.0)
  }
  tallies.each do |t|
    out[:count] += t[:count]
    out[:grand] += t[:grand]
    %i[paid owed spent individual shared other].each do |k|
      t[k].each { |person, amt| out[k][person] += amt }
    end
  end
  out
end

.net(tally) ⇒ Object

Net balances from one tally: { person => paid - owed }.



152
153
154
# File 'lib/spltty_cli/totals.rb', line 152

def net(tally)
  (tally[:paid].keys | tally[:owed].keys).to_h { |p| [p, tally[:paid][p] - tally[:owed][p]] }
end

.owed_split(value, resp, resolver) ⇒ Object

{ person => amount_owed } for one row. Group -> proportional split by pct/sum(pct); otherwise the whole value goes to the (person) name.



42
43
44
45
46
47
48
49
50
51
52
# File 'lib/spltty_cli/totals.rb', line 42

def owed_split(value, resp, resolver)
  split = resolver.call(resp)
  if split.is_a?(Hash) && !split.empty?
    total = split.values.sum.to_f
    return { resp => value } if total.zero?

    split.transform_values { |pct| value * pct / total }
  else
    { resp => value }
  end
end

.read_rows(path) ⇒ Object

Parse a ledger table file -> [{ value:, paid:, responsible: }]: only "|" lines, skip the separator, first row is the header, columns addressed by name, rows with a non-numeric value are skipped.



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/spltty_cli/totals.rb', line 57

def read_rows(path)
  rows = []
  header = nil
  vi = pi = ri = nil
  File.foreach(path) do |line|
    next unless line.strip.start_with?("|")

    cells = Table.row_cells(line)
    next if Table.separator?(cells)

    if header.nil?
      header = cells
      idx = header.each_index.to_h { |i| [header[i], i] }
      vi = idx["Value (R$)"]
      pi = idx["Paid By"]
      ri = idx["Responsible"]
      raise ArgumentError, "#{path}: missing Value (R$)/Responsible header" if vi.nil? || ri.nil?

      next
    end

    next if cells.length <= [vi, ri].max

    value = (Float(cells[vi]) rescue nil)
    next if value.nil?

    paid = pi && pi < cells.length ? cells[pi] : ""
    rows << { value: value, paid: paid, responsible: cells[ri] }
  end
  rows
end

.reference?(config, name) ⇒ Boolean

A reference ledger intentionally duplicates rows that already exist in a primary ledger (a project tracker mirroring shared spend, say). Its rows are copies, not new spend, so it must never be summed with the primaries. Marked per ledger with reference: true — in the ledger's notes header (the source of truth) or directly in config.json.

Returns:

  • (Boolean)


22
23
24
25
# File 'lib/spltty_cli/totals.rb', line 22

def reference?(config, name)
  entry = config.ledgers[name]
  entry.is_a?(Hash) && entry["reference"] == true
end

.reference_ledgers(config) ⇒ Object

Names of every ledger flagged as a reference ledger.



28
29
30
# File 'lib/spltty_cli/totals.rb', line 28

def reference_ledgers(config)
  config.ledgers.keys.select { |name| reference?(config, name) }
end

.report(title, t, reference: false) ⇒ Object

Full report block for one tally.



176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
# File 'lib/spltty_cli/totals.rb', line 176

def report(title, t, reference: false)
  people = (t[:owed].keys | t[:paid].keys).sort
  out = +"== #{title} ==\n"
  if reference
    out << "** REFERENCE/PROJECT LEDGER — duplicates entries from a primary " \
            "ledger; do NOT combine with others. **\n"
  end
  out << "Rows: #{t[:count]}   Grand total: R$ #{fmt(t[:grand])}\n\n"
  out << ("%-10s%14s%14s%14s\n" % ["Person", "Paid", "Owes", "Net"])
  people.each do |p|
    net = t[:paid][p] - t[:owed][p]
    out << ("%-10s%14s%14s%14s\n" % [p, fmt(t[:paid][p]), fmt(t[:owed][p]), fmt_signed(net)])
  end
  out << "  (Net = Paid - Owes; positive means others owe this person)\n\n"

  out << ("%-10s%14s%14s%14s%14s\n" % ["", "Total Spent", "Individual", "Shared", "For Other"])
  people.each do |p|
    out << ("%-10s%14s%14s%14s%14s\n" %
            [p, fmt(t[:spent][p]), fmt(t[:individual][p]), fmt(t[:shared][p]), fmt(t[:other][p])])
  end
  out << "  (Total Spent = what each fronted; For Other = paid but the other " \
         "person bears it)\n\n"

  transfers = settle(net(t))
  if transfers.empty?
    out << "Settlement: already even.\n"
  else
    out << "Settlement:\n"
    transfers.each { |d, c, amt| out << "  #{c} is owed — #{d} owes #{c}: R$ #{fmt(amt)}\n" }
  end
  out << "\n"
  out
end

.resolver(config, ledger_key) ⇒ Object

Build a resolver lambda for one ledger: name -> split Hash (or nil). The ledger's own groups shadow the global groups.



34
35
36
37
38
# File 'lib/spltty_cli/totals.rb', line 34

def resolver(config, ledger_key)
  ledger_groups = (ledger_key && config.ledgers[ledger_key] && config.ledgers[ledger_key]["groups"]) || {}
  global_groups = config.groups
  ->(name) { ledger_groups[name] || global_groups[name] }
end

.settle(net) ⇒ Object

Greedy settlement from net balances (paid - owed). -> [[debtor, creditor, amt]].



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/spltty_cli/totals.rb', line 157

def settle(net)
  debtors = net.select { |_, b| b < -EPS }.map { |p, b| [p, -b] }.sort_by { |x| x[1] }
  creditors = net.select { |_, b| b > EPS }.map { |p, b| [p, b] }.sort_by { |x| -x[1] }
  i = j = 0
  transfers = []
  while i < debtors.length && j < creditors.length
    d = debtors[i]
    c = creditors[j]
    amt = [d[1], c[1]].min
    transfers << [d[0], c[0], amt]
    d[1] -= amt
    c[1] -= amt
    i += 1 if d[1] <= EPS
    j += 1 if c[1] <= EPS
  end
  transfers
end

.tally(rows, resolver) ⇒ Object

Accumulate paid/owed/spending-breakdown for a set of rows under one resolver.



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
# File 'lib/spltty_cli/totals.rb', line 104

def tally(rows, resolver)
  t = {
    count: rows.length, grand: 0.0,
    paid: Hash.new(0.0), owed: Hash.new(0.0),
    spent: Hash.new(0.0), individual: Hash.new(0.0),
    shared: Hash.new(0.0), other: Hash.new(0.0)
  }
  rows.each do |r|
    v = r[:value]
    t[:grand] += v
    split = owed_split(v, r[:responsible], resolver)
    split.each { |person, amt| t[:owed][person] += amt }

    pby = r[:paid]
    next if pby.nil? || pby.empty?

    t[:paid][pby] += v
    t[:spent][pby] += v
    if split.size > 1
      t[:shared][pby] += v
    elsif split.keys.first == pby
      t[:individual][pby] += v
    else
      t[:other][pby] += v
    end
  end
  t
end