spltty 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/LICENSE +21 -0
- data/README.md +351 -0
- data/config.example.json +33 -0
- data/exe/spltty +11 -0
- data/lib/spltty_cli/commands/add.rb +274 -0
- data/lib/spltty_cli/commands/groups.rb +47 -0
- data/lib/spltty_cli/commands/groups_add.rb +73 -0
- data/lib/spltty_cli/commands/groups_rm.rb +73 -0
- data/lib/spltty_cli/commands/help.rb +44 -0
- data/lib/spltty_cli/commands/install.rb +407 -0
- data/lib/spltty_cli/commands/list.rb +37 -0
- data/lib/spltty_cli/commands/methods.rb +33 -0
- data/lib/spltty_cli/commands/methods_add.rb +58 -0
- data/lib/spltty_cli/commands/settle.rb +302 -0
- data/lib/spltty_cli/commands/sync.rb +41 -0
- data/lib/spltty_cli/commands/totals.rb +85 -0
- data/lib/spltty_cli/config.rb +140 -0
- data/lib/spltty_cli/discovery.rb +95 -0
- data/lib/spltty_cli/groups.rb +60 -0
- data/lib/spltty_cli/ledger.rb +68 -0
- data/lib/spltty_cli/notes.rb +67 -0
- data/lib/spltty_cli/notes_sync.rb +137 -0
- data/lib/spltty_cli/prompt.rb +75 -0
- data/lib/spltty_cli/table.rb +95 -0
- data/lib/spltty_cli/totals.rb +222 -0
- data/lib/spltty_cli/version.rb +5 -0
- data/lib/spltty_cli.rb +98 -0
- data/templates/CLAUDE.md.erb +229 -0
- data/templates/gitignore +4 -0
- data/templates/notes.md.erb +39 -0
- data/templates/skills/ingest/SKILL.md +66 -0
- data/templates/sources-INDEX.md +13 -0
- metadata +94 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module SplttyCLI
|
|
6
|
+
module Commands
|
|
7
|
+
# `spltty settle [LEDGER...]` — record settlement rows.
|
|
8
|
+
# One ledger: a ledger-local cash settlement (full, or partial via -v),
|
|
9
|
+
# never touching other ledgers. No/many ledgers: offset mode — ledgers
|
|
10
|
+
# whose debt points opposite to the combined direction are zeroed with
|
|
11
|
+
# mirrored `offset` rows (no real money); cash is written only when -v is
|
|
12
|
+
# given, allocated smallest-ledger-first against what remains.
|
|
13
|
+
class Settle < Dry::CLI::Command
|
|
14
|
+
desc "Record a settlement (one ledger = cash; several/all = cross-ledger offsets + optional cash)"
|
|
15
|
+
|
|
16
|
+
argument :ledgers, type: :array, required: false,
|
|
17
|
+
desc: "Ledger names (none = all non-reference; one = cash mode; several = offset mode)"
|
|
18
|
+
|
|
19
|
+
option :value, aliases: ["-v"], desc: "Cash amount (single ledger: partial settle; offset mode: cash paid after offsets)"
|
|
20
|
+
option :method, aliases: ["-m"], desc: "Payment method for cash rows", default: "Pix"
|
|
21
|
+
option :date, aliases: ["-d"], desc: "Date for the settlement rows (default: today)"
|
|
22
|
+
option :source, aliases: ["-s"], desc: "Provenance", default: "settle"
|
|
23
|
+
option :yes, aliases: ["-y"], type: :boolean, default: false,
|
|
24
|
+
desc: "Skip confirmation (non-interactive)"
|
|
25
|
+
option :config, aliases: ["-C"], desc: "Path to config.json (default: cli/config.json)"
|
|
26
|
+
option :accounts_dir, aliases: ["-A"], desc: "Override the accounts directory"
|
|
27
|
+
|
|
28
|
+
example [
|
|
29
|
+
"CASA # cash-settle CASA in full",
|
|
30
|
+
"MONTREAL -v 500 -y # partial cash settle, non-interactive",
|
|
31
|
+
" # global offset mode: zero opposite-direction ledgers",
|
|
32
|
+
"-v 2000 # global: offsets + R$ 2000 cash, smallest ledger first",
|
|
33
|
+
"CASA CASAMENTO # offset mode restricted to these two ledgers",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
def call(ledgers: [], **opts)
|
|
37
|
+
SettleRunner.new(ledgers, opts).run
|
|
38
|
+
rescue Prompt::Abort, Config::Error, ArgumentError => e
|
|
39
|
+
warn "spltty settle: #{e.message}"
|
|
40
|
+
exit 1
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Orchestrates the settle flow. Extracted from the command so it is
|
|
45
|
+
# unit-testable (same pattern as AddRunner).
|
|
46
|
+
class SettleRunner
|
|
47
|
+
OFFSET_METHOD = "offset"
|
|
48
|
+
|
|
49
|
+
def initialize(requested, opts)
|
|
50
|
+
@requested = Array(requested)
|
|
51
|
+
@opts = opts
|
|
52
|
+
@interactive = !opts[:yes]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def run
|
|
56
|
+
@config = SplttyCLI.load_config(@opts)
|
|
57
|
+
Discovery.sync(@config)
|
|
58
|
+
NotesSync.run(@config)
|
|
59
|
+
@config.save
|
|
60
|
+
|
|
61
|
+
names = resolve_scope
|
|
62
|
+
transfers = names.to_h { |n| [n, ledger_transfer(n)] }
|
|
63
|
+
|
|
64
|
+
plan = @requested.length == 1 ? cash_plan(names.first, transfers) : offset_plan(transfers)
|
|
65
|
+
return if plan.nil? || plan.empty?
|
|
66
|
+
|
|
67
|
+
plan.each { |row| assert_person!(row[:debtor], row[:ledger]) && assert_person!(row[:creditor], row[:ledger]) }
|
|
68
|
+
|
|
69
|
+
preview(plan)
|
|
70
|
+
if @interactive && !Prompt.confirm("Record #{plan.length == 1 ? 'this settlement row' : "these #{plan.length} settlement rows"}?", default: true)
|
|
71
|
+
puts "Aborted — nothing written."
|
|
72
|
+
return
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
plan.each { |row| write_row(row) }
|
|
76
|
+
puts "Recorded #{plan.length} settlement row#{plan.length == 1 ? '' : 's'}."
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# -> canonical ledger names in scope. No names = all non-missing,
|
|
82
|
+
# non-reference ledgers; explicit names are validated (reference ledgers
|
|
83
|
+
# may only be settled on their own).
|
|
84
|
+
def resolve_scope
|
|
85
|
+
if @requested.empty?
|
|
86
|
+
names = @config.ledgers.reject { |_, e| e["missing"] }.keys - SplttyCLI::Totals.reference_ledgers(@config)
|
|
87
|
+
raise Config::Error, "no ledgers found under #{@config.accounts_dir}" if names.empty?
|
|
88
|
+
|
|
89
|
+
return names
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
names = @requested.map do |n|
|
|
93
|
+
@config.ledger_key(n) or raise Config::Error, "unknown ledger #{n.inspect}"
|
|
94
|
+
end.uniq
|
|
95
|
+
names.each do |n|
|
|
96
|
+
raise Config::Error, "ledger #{n} is missing on disk" if @config.ledgers[n]["missing"]
|
|
97
|
+
end
|
|
98
|
+
if names.length > 1 && (refs = names & SplttyCLI::Totals.reference_ledgers(@config)).any?
|
|
99
|
+
raise Config::Error,
|
|
100
|
+
"reference ledger#{refs.length == 1 ? '' : 's'} #{refs.join(', ')} cannot join a cross-ledger settle — settle them on their own"
|
|
101
|
+
end
|
|
102
|
+
names
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The ledger's single outstanding transfer [debtor, creditor, amount], or
|
|
106
|
+
# nil when even. More than one transfer (3+ people) is out of scope.
|
|
107
|
+
def ledger_transfer(name)
|
|
108
|
+
tally = SplttyCLI::Totals.tally(SplttyCLI::Totals.ledger_rows(@config, name), SplttyCLI::Totals.resolver(@config, name))
|
|
109
|
+
transfers = SplttyCLI::Totals.settle(SplttyCLI::Totals.net(tally))
|
|
110
|
+
if transfers.length > 1
|
|
111
|
+
raise Config::Error, "#{name}: #{transfers.length} outstanding transfers — settle involves more than two people; record rows with `spltty add` instead"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
transfers.first
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Single-ledger mode: a real cash payment of the ledger's outstanding
|
|
118
|
+
# (or -v part of it). -> plan rows, or nil after printing a no-op message.
|
|
119
|
+
def cash_plan(name, transfers)
|
|
120
|
+
debtor, creditor, outstanding = transfers[name]
|
|
121
|
+
if outstanding.nil?
|
|
122
|
+
puts "#{name}: already even — nothing to settle."
|
|
123
|
+
return nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
amount = outstanding
|
|
127
|
+
if @opts[:value]
|
|
128
|
+
amount = parse_amount(@opts[:value])
|
|
129
|
+
validate_amount!(amount, outstanding)
|
|
130
|
+
amount = outstanding if (outstanding - amount).abs <= SplttyCLI::Totals::EPS
|
|
131
|
+
end
|
|
132
|
+
[{ ledger: name, debtor: debtor, creditor: creditor, amount: amount,
|
|
133
|
+
kind: :cash, partial: amount < outstanding - SplttyCLI::Totals::EPS }]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Offset mode: zero opposite-direction ledgers with mirrored offset rows,
|
|
137
|
+
# then allocate -v (if given) as cash — both smallest-ledger-first.
|
|
138
|
+
def offset_plan(transfers)
|
|
139
|
+
open = transfers.compact
|
|
140
|
+
if open.empty?
|
|
141
|
+
puts "Already even — nothing to settle."
|
|
142
|
+
return nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
debtor, creditor, combined = combined_transfer(transfers)
|
|
146
|
+
same, opposite = partition_directions(open, debtor, creditor)
|
|
147
|
+
|
|
148
|
+
# Same-direction remainders, consumed smallest-outstanding-first.
|
|
149
|
+
remainders = same.map { |name, (_d, _c, amt)| [name, amt] }.sort_by { |_, amt| amt }
|
|
150
|
+
|
|
151
|
+
plan = []
|
|
152
|
+
opposite.each do |name, (odebtor, ocreditor, oamt)|
|
|
153
|
+
allocate(remainders, oamt) do |target, take|
|
|
154
|
+
plan << { ledger: name, debtor: odebtor, creditor: ocreditor, amount: take,
|
|
155
|
+
kind: :offset, partial: false, other: target }
|
|
156
|
+
plan << { ledger: target, debtor: debtor, creditor: creditor, amount: take,
|
|
157
|
+
kind: :offset, partial: false, other: name }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if @opts[:value]
|
|
162
|
+
cash = parse_amount(@opts[:value])
|
|
163
|
+
raise Config::Error, "nothing left to pay — the offsets already even everything out" if combined <= SplttyCLI::Totals::EPS
|
|
164
|
+
|
|
165
|
+
validate_amount!(cash, combined)
|
|
166
|
+
cash = combined if (combined - cash).abs <= SplttyCLI::Totals::EPS
|
|
167
|
+
allocate(remainders.sort_by { |_, amt| amt }, cash) do |target, take, remaining|
|
|
168
|
+
plan << { ledger: target, debtor: debtor, creditor: creditor, amount: take,
|
|
169
|
+
kind: :cash, partial: take < remaining - SplttyCLI::Totals::EPS }
|
|
170
|
+
end
|
|
171
|
+
elsif plan.empty?
|
|
172
|
+
puts "Outstanding: #{debtor} owes #{creditor} R$ #{SplttyCLI::Totals.fmt(combined)} — nothing to offset, nothing written. Pass -v to record a payment."
|
|
173
|
+
return nil
|
|
174
|
+
end
|
|
175
|
+
plan
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Combined transfer across the scope; direction falls back to the first
|
|
179
|
+
# open ledger when the set offsets to zero exactly.
|
|
180
|
+
def combined_transfer(transfers)
|
|
181
|
+
merged = Hash.new(0.0)
|
|
182
|
+
transfers.each do |_, t|
|
|
183
|
+
next if t.nil?
|
|
184
|
+
|
|
185
|
+
d, c, amt = t
|
|
186
|
+
merged[d] -= amt
|
|
187
|
+
merged[c] += amt
|
|
188
|
+
end
|
|
189
|
+
combined = SplttyCLI::Totals.settle(merged)
|
|
190
|
+
if combined.length > 1
|
|
191
|
+
raise Config::Error, "combined settlement needs #{combined.length} transfers (more than two people) — settle per ledger instead"
|
|
192
|
+
end
|
|
193
|
+
return combined.first if combined.first
|
|
194
|
+
|
|
195
|
+
d, c, = transfers.compact.first[1]
|
|
196
|
+
[d, c, 0.0]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def partition_directions(open, debtor, creditor)
|
|
200
|
+
same = {}
|
|
201
|
+
opposite = {}
|
|
202
|
+
open.each do |name, (d, c, _amt)|
|
|
203
|
+
if d == debtor && c == creditor
|
|
204
|
+
same[name] = open[name]
|
|
205
|
+
elsif d == creditor && c == debtor
|
|
206
|
+
opposite[name] = open[name]
|
|
207
|
+
else
|
|
208
|
+
raise Config::Error, "#{name}: transfer #{d} → #{c} does not line up with the combined #{debtor} → #{creditor} settlement"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
[same, opposite]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Consume `amount` from the remainders list in order, yielding
|
|
215
|
+
# (ledger, take, remaining-before-take) per allocation.
|
|
216
|
+
def allocate(remainders, amount)
|
|
217
|
+
need = amount
|
|
218
|
+
remainders.each do |entry|
|
|
219
|
+
break if need <= SplttyCLI::Totals::EPS
|
|
220
|
+
next if entry[1] <= SplttyCLI::Totals::EPS
|
|
221
|
+
|
|
222
|
+
take = [need, entry[1]].min
|
|
223
|
+
yield entry[0], take, entry[1]
|
|
224
|
+
entry[1] -= take
|
|
225
|
+
need -= take
|
|
226
|
+
end
|
|
227
|
+
raise Config::Error, "internal: could not allocate R$ #{SplttyCLI::Totals.fmt(need)} across the ledgers in scope" if need > SplttyCLI::Totals::EPS
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def validate_amount!(amount, outstanding)
|
|
231
|
+
raise Config::Error, "--value must be positive (got #{@opts[:value].inspect})" if amount <= 0
|
|
232
|
+
if amount > outstanding + SplttyCLI::Totals::EPS
|
|
233
|
+
raise Config::Error, "--value R$ #{SplttyCLI::Totals.fmt(amount)} exceeds the outstanding R$ #{SplttyCLI::Totals.fmt(outstanding)}"
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Accepts "12.5", "12,50", and "1.234,56" like AddRunner#money.
|
|
238
|
+
def parse_amount(str)
|
|
239
|
+
s = str.to_s.strip
|
|
240
|
+
s = s.include?(".") && s.include?(",") ? s.delete(".").tr(",", ".") : s.tr(",", ".")
|
|
241
|
+
Float(s)
|
|
242
|
+
rescue ArgumentError
|
|
243
|
+
raise Config::Error, "invalid --value: #{str.inspect}"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# A settlement row must land on a person; a name that resolves to a split
|
|
247
|
+
# group would silently divide the offset and corrupt future nets.
|
|
248
|
+
def assert_person!(name, ledger)
|
|
249
|
+
if SplttyCLI::Totals.resolver(@config, ledger).call(name).is_a?(Hash)
|
|
250
|
+
raise Config::Error, "#{name.inspect} is a split group in #{ledger} — settlement rows must name a person"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
true
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def title(row)
|
|
257
|
+
if row[:kind] == :offset
|
|
258
|
+
"Settlement — offset vs #{row[:other]}"
|
|
259
|
+
else
|
|
260
|
+
"Settlement — #{row[:debtor]} → #{row[:creditor]}#{' (partial)' if row[:partial]}"
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def row_method(row)
|
|
265
|
+
row[:kind] == :offset ? OFFSET_METHOD : (@opts[:method] || "Pix")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def preview(plan)
|
|
269
|
+
warn ""
|
|
270
|
+
warn "Settlement plan:"
|
|
271
|
+
plan.each do |row|
|
|
272
|
+
tag = row[:kind] == :offset ? "offset vs #{row[:other]}" : row_method(row)
|
|
273
|
+
tag += ", partial" if row[:partial]
|
|
274
|
+
warn format(" %-10s %s → %s R$ %s [%s]",
|
|
275
|
+
row[:ledger], row[:debtor], row[:creditor], SplttyCLI::Totals.fmt(row[:amount]), tag)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Persist one row through AddRunner (non-interactive): reuses ledger/month
|
|
280
|
+
# routing, file scaffolding, and aligned table writing.
|
|
281
|
+
def write_row(row)
|
|
282
|
+
amount = format("%.2f", row[:amount])
|
|
283
|
+
opts = {
|
|
284
|
+
title: title(row), ledger: row[:ledger], value: amount,
|
|
285
|
+
paid_by: row[:debtor], responsible: row[:creditor],
|
|
286
|
+
method: row_method(row), date: date_string,
|
|
287
|
+
source: @opts[:source] || "settle", yes: true,
|
|
288
|
+
config: @opts[:config], accounts_dir: @opts[:accounts_dir]
|
|
289
|
+
}
|
|
290
|
+
if Ledger.montreal?(@config.ledgers[row[:ledger]])
|
|
291
|
+
opts[:orig_value] = amount
|
|
292
|
+
opts[:currency] = "BRL"
|
|
293
|
+
end
|
|
294
|
+
AddRunner.new(opts).run
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def date_string
|
|
298
|
+
@opts[:date] || Date.today.strftime("%Y-%m-%d")
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SplttyCLI
|
|
4
|
+
module Commands
|
|
5
|
+
# `spltty sync` — reconcile ledger config between the `*.notes.md` frontmatter
|
|
6
|
+
# headers and config.json. Runs structural discovery, then the two-way
|
|
7
|
+
# notes<->config sync (see NotesSync), and reports what moved in each direction.
|
|
8
|
+
class Sync < Dry::CLI::Command
|
|
9
|
+
desc "Sync ledger config between notes-file headers and config.json"
|
|
10
|
+
|
|
11
|
+
option :config, aliases: ["-C"], desc: "Path to config.json (default: cli/config.json)"
|
|
12
|
+
option :accounts_dir, aliases: ["-A"], desc: "Override the accounts directory"
|
|
13
|
+
|
|
14
|
+
def call(**opts)
|
|
15
|
+
config = SplttyCLI.load_config(opts)
|
|
16
|
+
Discovery.sync(config)
|
|
17
|
+
result = NotesSync.run(config)
|
|
18
|
+
config.save
|
|
19
|
+
|
|
20
|
+
if result[:to_config].empty? && result[:to_notes].empty? && result[:globalized].empty?
|
|
21
|
+
puts "Already in sync (accounts dir: #{config.accounts_dir})"
|
|
22
|
+
return
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
puts "Synced (accounts dir: #{config.accounts_dir}):"
|
|
26
|
+
unless result[:to_config].empty?
|
|
27
|
+
puts " notes -> config: #{result[:to_config].join(', ')}"
|
|
28
|
+
end
|
|
29
|
+
unless result[:to_notes].empty?
|
|
30
|
+
puts " config -> notes: #{result[:to_notes].join(', ')}"
|
|
31
|
+
end
|
|
32
|
+
unless result[:globalized].empty?
|
|
33
|
+
puts " promoted to global groups: #{result[:globalized].join(', ')}"
|
|
34
|
+
end
|
|
35
|
+
rescue Config::Error => e
|
|
36
|
+
warn "spltty sync: #{e.message}"
|
|
37
|
+
exit 1
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SplttyCLI
|
|
4
|
+
module Commands
|
|
5
|
+
# `spltty totals [LEDGER...]` — per-person totals + settlement per ledger.
|
|
6
|
+
# Splits are resolved against split groups (ledger header groups, then global
|
|
7
|
+
# config groups); an unmatched Responsible value is a person (100%).
|
|
8
|
+
# `--combined` merges the non-reference ledgers into one settlement.
|
|
9
|
+
class Totals < Dry::CLI::Command
|
|
10
|
+
desc "Per-person totals & settlement (per ledger, or --combined)"
|
|
11
|
+
|
|
12
|
+
argument :ledgers, type: :array, required: false, desc: "Ledger names (default: all)"
|
|
13
|
+
|
|
14
|
+
option :combined, type: :boolean, default: false, desc: "Merge ledgers into one settlement"
|
|
15
|
+
option :config, aliases: ["-C"], desc: "Path to config.json (default: cli/config.json)"
|
|
16
|
+
option :accounts_dir, aliases: ["-A"], desc: "Override the accounts directory"
|
|
17
|
+
|
|
18
|
+
def call(ledgers: [], **opts)
|
|
19
|
+
config = SplttyCLI.load_config(opts)
|
|
20
|
+
Discovery.sync(config)
|
|
21
|
+
NotesSync.run(config)
|
|
22
|
+
config.save
|
|
23
|
+
|
|
24
|
+
names = resolve_names(config, ledgers)
|
|
25
|
+
return if names.empty?
|
|
26
|
+
|
|
27
|
+
if opts[:combined]
|
|
28
|
+
combined(config, names)
|
|
29
|
+
else
|
|
30
|
+
names.each do |name|
|
|
31
|
+
rows = read_ledger(config, name)
|
|
32
|
+
reference = SplttyCLI::Totals.reference?(config, name)
|
|
33
|
+
print SplttyCLI::Totals.report(name, SplttyCLI::Totals.tally(rows, SplttyCLI::Totals.resolver(config, name)), reference: reference)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
rescue ArgumentError => e
|
|
37
|
+
warn "spltty totals: #{e.message}"
|
|
38
|
+
exit 1
|
|
39
|
+
rescue Config::Error => e
|
|
40
|
+
warn "spltty totals: #{e.message}"
|
|
41
|
+
exit 1
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def resolve_names(config, requested)
|
|
47
|
+
if requested.nil? || requested.empty?
|
|
48
|
+
available = config.ledgers.reject { |_, e| e["missing"] }.keys
|
|
49
|
+
if available.empty?
|
|
50
|
+
puts "No ledgers found under #{config.accounts_dir}"
|
|
51
|
+
end
|
|
52
|
+
return available
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
requested.map do |n|
|
|
56
|
+
config.ledger_key(n) or raise Config::Error, "unknown ledger #{n.inspect}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def combined(config, names)
|
|
61
|
+
used = []
|
|
62
|
+
skipped = []
|
|
63
|
+
tallies = names.map do |name|
|
|
64
|
+
if SplttyCLI::Totals.reference?(config, name)
|
|
65
|
+
skipped << name
|
|
66
|
+
next nil
|
|
67
|
+
end
|
|
68
|
+
used << name
|
|
69
|
+
rows = read_ledger(config, name)
|
|
70
|
+
SplttyCLI::Totals.tally(rows, SplttyCLI::Totals.resolver(config, name))
|
|
71
|
+
end.compact
|
|
72
|
+
|
|
73
|
+
title = "COMBINED (#{used.length} ledger#{used.length == 1 ? '' : 's'}: #{used.join(', ')})"
|
|
74
|
+
print SplttyCLI::Totals.report(title, SplttyCLI::Totals.merge(tallies))
|
|
75
|
+
unless skipped.empty?
|
|
76
|
+
puts "Skipped reference ledgers (report these on their own): #{skipped.join(', ')}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def read_ledger(config, name)
|
|
81
|
+
SplttyCLI::Totals.ledger_rows(config, name)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SplttyCLI
|
|
6
|
+
# Reads / writes the CLI-owned config.json. Holds user-authored payment
|
|
7
|
+
# methods and an auto-maintained `ledgers` cache (see Discovery). The
|
|
8
|
+
# `ledgers` key may be absent on load — it is created on demand.
|
|
9
|
+
class Config
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
# Where a workspace keeps its config, relative to the workspace root.
|
|
13
|
+
# `spltty install` writes it; every other command finds it by walking up
|
|
14
|
+
# from the cwd (see .discover).
|
|
15
|
+
WORKSPACE_DIR = ".spltty"
|
|
16
|
+
WORKSPACE_FILE = "config.json"
|
|
17
|
+
|
|
18
|
+
DEFAULT = {
|
|
19
|
+
"accounts_dir" => "../accounts",
|
|
20
|
+
"default_ledger" => "expenses",
|
|
21
|
+
"date_format" => "%Y-%m-%d",
|
|
22
|
+
"payment_methods" => {},
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :path, :data
|
|
26
|
+
|
|
27
|
+
def initialize(path, data)
|
|
28
|
+
@path = path
|
|
29
|
+
@data = data
|
|
30
|
+
@accounts_dir_override = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Load config.json, or start from DEFAULT when the file does not exist yet
|
|
34
|
+
# (the ledgers block gets written on the first discovery run).
|
|
35
|
+
def self.load(path)
|
|
36
|
+
data =
|
|
37
|
+
if File.exist?(path)
|
|
38
|
+
begin
|
|
39
|
+
JSON.parse(File.read(path))
|
|
40
|
+
rescue JSON::ParserError => e
|
|
41
|
+
raise Error, "malformed config at #{path}: #{e.message}"
|
|
42
|
+
end
|
|
43
|
+
else
|
|
44
|
+
Marshal.load(Marshal.dump(DEFAULT))
|
|
45
|
+
end
|
|
46
|
+
raise Error, "config root must be a JSON object" unless data.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
new(path, data)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Walk up from `start_dir` looking for `.spltty/config.json` — the same
|
|
52
|
+
# nearest-ancestor rule git uses for .git. Returns the absolute path of the
|
|
53
|
+
# first hit, or nil when there is no workspace above `start_dir`.
|
|
54
|
+
def self.discover(start_dir = Dir.pwd)
|
|
55
|
+
dir = File.expand_path(start_dir)
|
|
56
|
+
loop do
|
|
57
|
+
candidate = File.join(dir, WORKSPACE_DIR, WORKSPACE_FILE)
|
|
58
|
+
return candidate if File.file?(candidate)
|
|
59
|
+
|
|
60
|
+
parent = File.dirname(dir)
|
|
61
|
+
return nil if parent == dir # reached the filesystem root
|
|
62
|
+
|
|
63
|
+
dir = parent
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Absolute path of the config file for a workspace rooted at `dir`.
|
|
68
|
+
def self.workspace_path(dir)
|
|
69
|
+
File.join(File.expand_path(dir), WORKSPACE_DIR, WORKSPACE_FILE)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Absolute accounts directory. Resolved relative to the config file unless an
|
|
73
|
+
# override (from --accounts-dir / env) was supplied.
|
|
74
|
+
def accounts_dir
|
|
75
|
+
base = @accounts_dir_override || @data["accounts_dir"] || "../accounts"
|
|
76
|
+
File.expand_path(base, File.dirname(@path))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Override with an already-absolute path (App expands --accounts-dir vs cwd).
|
|
80
|
+
def accounts_dir=(absolute_path)
|
|
81
|
+
@accounts_dir_override = absolute_path
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def default_ledger
|
|
85
|
+
@data["default_ledger"]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def date_format
|
|
89
|
+
@data["date_format"] || "%Y-%m-%d"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The ledgers cache — created lazily so a minimal config (methods only) works.
|
|
93
|
+
def ledgers
|
|
94
|
+
@data["ledgers"] ||= {}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Global split groups (shared across ledgers). Created lazily. Ledger-specific
|
|
98
|
+
# groups live under ledgers[name]["groups"]; these are the promoted globals.
|
|
99
|
+
def groups
|
|
100
|
+
@data["groups"] ||= {}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def payment_methods
|
|
104
|
+
@data["payment_methods"] ||= {}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Everyone who can appear in a Paid By / Responsible cell. Recorded by
|
|
108
|
+
# `spltty install` and used to render the workspace CLAUDE.md; the split
|
|
109
|
+
# groups remain the authority on how a cost is divided.
|
|
110
|
+
def participants
|
|
111
|
+
@data["participants"] ||= []
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Resolve a payment method by its full name or its configured slug (both
|
|
115
|
+
# case-insensitive) → returns [canonical_name, cfg], or [name, nil] when
|
|
116
|
+
# unknown (e.g. one-off methods like "cash").
|
|
117
|
+
def payment_method(name)
|
|
118
|
+
return [nil, nil] if name.nil? || name.empty?
|
|
119
|
+
|
|
120
|
+
n = name.strip
|
|
121
|
+
key = payment_methods.keys.find { |k| k.casecmp?(n) }
|
|
122
|
+
key ||= payment_methods.keys.find do |k|
|
|
123
|
+
slug = payment_methods[k]["slug"]
|
|
124
|
+
slug && slug.casecmp?(n)
|
|
125
|
+
end
|
|
126
|
+
key ? [key, payment_methods[key]] : [name, nil]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Case-insensitive resolution of a ledger name to its canonical config key.
|
|
130
|
+
def ledger_key(name)
|
|
131
|
+
return nil if name.nil? || name.empty?
|
|
132
|
+
|
|
133
|
+
ledgers.keys.find { |k| k.casecmp?(name) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def save
|
|
137
|
+
File.write(@path, JSON.pretty_generate(@data) + "\n")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SplttyCLI
|
|
6
|
+
# Scan accounts/ for ledgers and reconcile them into the config cache.
|
|
7
|
+
#
|
|
8
|
+
# A ledger is either:
|
|
9
|
+
# * a root file named "<NAME>.ledger.md" -> single-file ledger, or
|
|
10
|
+
# * a subfolder containing "YYYY-MM.md" -> monthly ledger (e.g. CASA).
|
|
11
|
+
# Everything else (*.notes.md, README, plain *.md, other files) is ignored.
|
|
12
|
+
module Discovery
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
LEDGER_SUFFIX = ".ledger.md"
|
|
16
|
+
MONTH_FILE = /\A\d{4}-\d{2}\.md\z/
|
|
17
|
+
|
|
18
|
+
# Discover ledgers on disk. Returns an array of hashes:
|
|
19
|
+
# { name:, type: "single"|"monthly", file: / dir:, schema:, notes: }
|
|
20
|
+
def scan(accounts_dir)
|
|
21
|
+
return [] unless Dir.exist?(accounts_dir)
|
|
22
|
+
|
|
23
|
+
found = []
|
|
24
|
+
Dir.children(accounts_dir).sort.each do |entry|
|
|
25
|
+
full = File.join(accounts_dir, entry)
|
|
26
|
+
|
|
27
|
+
if File.file?(full) && entry.end_with?(LEDGER_SUFFIX)
|
|
28
|
+
name = entry[0...-LEDGER_SUFFIX.length]
|
|
29
|
+
notes = "#{name}.notes.md"
|
|
30
|
+
found << {
|
|
31
|
+
name: name, type: "single", file: entry,
|
|
32
|
+
schema: detect_schema(full),
|
|
33
|
+
notes: (notes if File.exist?(File.join(accounts_dir, notes))),
|
|
34
|
+
}
|
|
35
|
+
elsif File.directory?(full)
|
|
36
|
+
months = Dir.children(full).select { |f| f =~ MONTH_FILE }.sort
|
|
37
|
+
next if months.empty?
|
|
38
|
+
|
|
39
|
+
notes = "notes.md"
|
|
40
|
+
found << {
|
|
41
|
+
name: entry, type: "monthly", dir: entry,
|
|
42
|
+
schema: detect_schema(File.join(full, months.last)),
|
|
43
|
+
notes: (notes if File.exist?(File.join(full, notes))),
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
found
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Schema from the table header: "montreal" if it carries the Orig. Value
|
|
51
|
+
# column, otherwise "standard". Defaults to standard when no header exists.
|
|
52
|
+
def detect_schema(file)
|
|
53
|
+
return "standard" unless file && File.exist?(file)
|
|
54
|
+
|
|
55
|
+
File.foreach(file) do |line|
|
|
56
|
+
next unless line.strip.start_with?("|")
|
|
57
|
+
|
|
58
|
+
return line.include?("Orig. Value") ? "montreal" : "standard"
|
|
59
|
+
end
|
|
60
|
+
"standard"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Merge discovered ledgers into config.ledgers. Adds new ones, refreshes
|
|
64
|
+
# structural fields (type/file/dir/schema) on existing ones, and preserves
|
|
65
|
+
# user overrides (title, default_responsible, default_currency, notes).
|
|
66
|
+
# Flags config entries whose files vanished as "missing".
|
|
67
|
+
# Returns { added: [names], missing: [names], changed: bool }.
|
|
68
|
+
def sync(config)
|
|
69
|
+
before = JSON.generate(config.ledgers)
|
|
70
|
+
discovered = scan(config.accounts_dir)
|
|
71
|
+
names = discovered.map { |d| d[:name] }
|
|
72
|
+
|
|
73
|
+
discovered.each do |d|
|
|
74
|
+
entry = (config.ledgers[d[:name]] ||= {})
|
|
75
|
+
entry["type"] = d[:type]
|
|
76
|
+
entry["schema"] = d[:schema]
|
|
77
|
+
if d[:type] == "monthly"
|
|
78
|
+
entry["dir"] = d[:dir]
|
|
79
|
+
entry.delete("file")
|
|
80
|
+
else
|
|
81
|
+
entry["file"] = d[:file]
|
|
82
|
+
entry.delete("dir")
|
|
83
|
+
end
|
|
84
|
+
entry["notes"] ||= d[:notes] if d[:notes]
|
|
85
|
+
entry.delete("missing")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
added = names - JSON.parse(before).keys
|
|
89
|
+
missing = config.ledgers.keys - names
|
|
90
|
+
missing.each { |m| config.ledgers[m]["missing"] = true }
|
|
91
|
+
|
|
92
|
+
{ added: added, missing: missing, changed: JSON.generate(config.ledgers) != before }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|