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.
@@ -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