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
-
.fmt(number) ⇒ Object
"%,.2f": comma thousands separators, 2 decimals.
-
.fmt_signed(number) ⇒ Object
"%+,.2f": like fmt but always signed.
-
.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.
-
.merge(tallies) ⇒ Object
Sum several tallies into one (for --combined).
-
.net(tally) ⇒ Object
Net balances from one tally: { person => paid - owed }.
-
.owed_split(value, resp, resolver) ⇒ Object
{ person => amount_owed } for one row.
-
.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.
-
.reference?(config, name) ⇒ Boolean
A reference ledger intentionally duplicates rows that already exist in a primary ledger (a project tracker mirroring shared spend, say).
-
.reference_ledgers(config) ⇒ Object
Names of every ledger flagged as a reference ledger.
-
.report(title, t, reference: false) ⇒ Object
Full report block for one tally.
-
.resolver(config, ledger_key) ⇒ Object
Build a resolver lambda for one ledger: name -> split Hash (or nil).
-
.settle(net) ⇒ Object
Greedy settlement from net balances (paid - owed).
-
.tally(rows, resolver) ⇒ Object
Accumulate paid/owed/spending-breakdown for a set of rows under one resolver.
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.
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 |