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,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module SplttyCLI
6
+ module Commands
7
+ # `spltty add` — append one expense row to a ledger, applying payment-method
8
+ # defaults from config and offering to create the ledger/file if missing.
9
+ class Add < Dry::CLI::Command
10
+ desc "Append an expense entry to a ledger"
11
+
12
+ argument :title, required: false, desc: "Expense description (positional, e.g. \"Leite 1L\")"
13
+
14
+ option :ledger, aliases: ["-l"], desc: "Ledger name (default: config default_ledger)"
15
+ option :date, aliases: ["-d"], desc: "Date: DD-MM or DD/MM (this year), DD/MM/YYYY, MM-DD-YYYY, or YYYY-MM-DD (default: today or card bill day)"
16
+ option :value, aliases: ["-v"], desc: "Amount in R$ (e.g. 129.90)"
17
+ option :paid_by, aliases: ["-p"], desc: "Who fronted the money (Thiago/Camila/...)"
18
+ option :method, aliases: ["-m"], desc: "Payment method: full name, its slug (e.g. mc-3278), or a one-off like cash/Pix"
19
+ option :responsible, aliases: ["-r"], desc: "Who bears the cost: a split-group name (Both/...) or a person (Thiago/Camila/...)"
20
+ option :paid_responsible, aliases: ["-pr"], desc: "Shortcut: set both Paid By and Responsible (overridden by -p/-r)"
21
+ option :source, aliases: ["-s"], desc: "Provenance (text/audio/filename)", default: "text"
22
+ option :orig_value, aliases: ["-o"], desc: "Original amount (montreal schema only)"
23
+ option :currency, aliases: ["-c"], desc: "Original currency, e.g. CAD (montreal schema only)"
24
+ option :create_ledger, type: :boolean, default: false,
25
+ desc: "Create --ledger when it does not exist yet (required with --yes)"
26
+ option :yes, aliases: ["-y"], type: :boolean, default: false,
27
+ desc: "Skip prompts/confirmation (non-interactive)"
28
+ option :config, aliases: ["-C"], desc: "Path to config.json (default: the workspace's .spltty/config.json)"
29
+ option :accounts_dir, aliases: ["-A"], desc: "Override the accounts directory"
30
+
31
+ example [
32
+ %("Leite 1L" -l Casa -v 14.20 -p Thiago -m cash),
33
+ %("Coffee" -l CASA -v 12.90 -m "Mastercard 9759" -y),
34
+ %("Metro" -l MONTREAL -o 3.75 -c CAD -v 14.20 -p Camila -m Wise -y),
35
+ " # fully interactive (prompts for what's missing)",
36
+ ]
37
+
38
+ def call(**opts)
39
+ AddRunner.new(opts).run
40
+ rescue Prompt::Abort, Config::Error, ArgumentError => e
41
+ warn "spltty add: #{e.message}"
42
+ exit 1
43
+ end
44
+ end
45
+
46
+ # Orchestrates the add flow. Extracted from the command so it is unit-testable.
47
+ class AddRunner
48
+ def initialize(opts)
49
+ @opts = opts
50
+ @interactive = !opts[:yes]
51
+ @dirty = false
52
+ end
53
+
54
+ def run
55
+ @config = SplttyCLI.load_config(@opts)
56
+ print_discovery(Discovery.sync(@config))
57
+
58
+ name, entry = resolve_ledger
59
+ montreal = Ledger.montreal?(entry)
60
+
61
+ method_name, method_cfg = resolve_method
62
+ date = resolve_date(method_cfg)
63
+
64
+ values = gather(entry, montreal, method_name, method_cfg, date)
65
+
66
+ path = Ledger.target_path(@config, name, entry, date)
67
+ ensure_file(name, entry, path, date)
68
+
69
+ text = File.read(path)
70
+ parsed = Table.parse(text)
71
+ row = Table.format_row(parsed, values)
72
+
73
+ preview(name, path, row)
74
+ if @interactive && !Prompt.confirm("Append this row?", default: true)
75
+ puts "Aborted — nothing written."
76
+ return
77
+ end
78
+
79
+ File.write(path, Table.insert_row(text, parsed, row))
80
+ @config.save if @dirty
81
+ puts "Appended to #{path}"
82
+ puts row
83
+ end
84
+
85
+ private
86
+
87
+ def resolve_ledger
88
+ name = (@opts[:ledger] || @config.default_ledger).to_s
89
+ name = Prompt.ask("Ledger", required: true) if name.empty?
90
+ key, entry = Ledger.config_entry(@config, name)
91
+ return [key, entry] if entry
92
+
93
+ resolve_unknown_ledger(key)
94
+ end
95
+
96
+ # An unknown ledger name is far more often a typo (`-l CAS`) than a new
97
+ # account, and a typo silently scaffolds a junk ledger that then shows up
98
+ # in every totals run. So: offer to create it, but list the existing
99
+ # ledgers alongside so the slip can be corrected in place.
100
+ # Returns [name, entry].
101
+ def resolve_unknown_ledger(name)
102
+ return [name, create_ledger(name)] if @opts[:create_ledger]
103
+
104
+ known = @config.ledgers.keys
105
+ unless @interactive
106
+ hint = known.empty? ? "no ledgers exist yet" : "known ledgers: #{known.join(', ')}"
107
+ raise Prompt::Abort, "unknown ledger #{name.inspect} (#{hint}) — " \
108
+ "pass --create-ledger to create it"
109
+ end
110
+
111
+ warn "Ledger #{name.inspect} is not known yet."
112
+ if known.empty?
113
+ raise Prompt::Abort, "ledger #{name.inspect} does not exist" unless Prompt.confirm("Create it?", default: true)
114
+
115
+ return [name, create_ledger(name)]
116
+ end
117
+
118
+ choice = Prompt.choose("Which ledger?", ["Create a new ledger #{name.inspect}"] + known, default: 1)
119
+ return [name, create_ledger(name)] if choice.zero?
120
+
121
+ key = known[choice - 1]
122
+ [key, @config.ledgers[key]]
123
+ end
124
+
125
+ # Register a new ledger in config (the entries file itself is scaffolded
126
+ # later, by ensure_file). Monthly is the house default, matching
127
+ # `spltty install`.
128
+ def create_ledger(name)
129
+ type = "monthly"
130
+ schema = "standard"
131
+ if @interactive
132
+ type = Prompt.confirm("Monthly (one file per month)? (n = single file)", default: true) ? "monthly" : "single"
133
+ schema = Prompt.confirm("Multi-currency (extra Orig. Value + Cur. columns)?", default: false) ? "montreal" : "standard"
134
+ end
135
+ entry = { "type" => type, "schema" => schema }
136
+ type == "monthly" ? entry["dir"] = name : entry["file"] = "#{name}.ledger.md"
137
+ @config.ledgers[name] = entry
138
+ @dirty = true
139
+ warn "Creating ledger #{name.inspect} (#{type})."
140
+ entry
141
+ end
142
+
143
+ def resolve_method
144
+ name = @opts[:method]
145
+ name = Prompt.ask("Payment Method") if missing?(name) && @interactive
146
+ method_name, method_cfg = @config.payment_method(name.to_s)
147
+ [method_name.to_s, method_cfg]
148
+ end
149
+
150
+ def resolve_date(method_cfg)
151
+ return parse_date(@opts[:date].to_s) unless missing?(@opts[:date])
152
+
153
+ today = Date.today
154
+ return bill_date(today, method_cfg["bill_day"]) if method_cfg && method_cfg["bill_day"]
155
+
156
+ today
157
+ rescue Date::Error, ArgumentError
158
+ raise Prompt::Abort, "invalid --date: #{@opts[:date].inspect}"
159
+ end
160
+
161
+ # Flexible date input:
162
+ # YYYY-MM-DD -> ISO (as stored)
163
+ # MM-DD-YYYY -> American (dash + year)
164
+ # DD-MM / DD/MM -> day/month in the current year
165
+ # DD/MM/YYYY -> day/month/year
166
+ # Anything else falls back to Ruby's Date.parse.
167
+ def parse_date(str)
168
+ s = str.strip
169
+ year = Date.today.year
170
+ case s
171
+ when /\A(\d{4})-(\d{1,2})-(\d{1,2})\z/ then Date.new(::Regexp.last_match(1).to_i, ::Regexp.last_match(2).to_i, ::Regexp.last_match(3).to_i)
172
+ when /\A(\d{1,2})-(\d{1,2})-(\d{4})\z/ then Date.new(::Regexp.last_match(3).to_i, ::Regexp.last_match(1).to_i, ::Regexp.last_match(2).to_i)
173
+ when %r{\A(\d{1,2})/(\d{1,2})/(\d{4})\z} then Date.new(::Regexp.last_match(3).to_i, ::Regexp.last_match(2).to_i, ::Regexp.last_match(1).to_i)
174
+ when /\A(\d{1,2})-(\d{1,2})\z/ then Date.new(year, ::Regexp.last_match(2).to_i, ::Regexp.last_match(1).to_i)
175
+ when %r{\A(\d{1,2})/(\d{1,2})\z} then Date.new(year, ::Regexp.last_match(2).to_i, ::Regexp.last_match(1).to_i)
176
+ else Date.parse(s)
177
+ end
178
+ end
179
+
180
+ def bill_date(ref, day)
181
+ return Date.new(ref.year, ref.month, -1) if day.to_s == "last"
182
+
183
+ Date.new(ref.year, ref.month, day.to_i)
184
+ end
185
+
186
+ # Build the row. Fields supplied by flag (or a payment-method / ledger
187
+ # default) are used as-is; only genuinely missing required fields prompt in
188
+ # interactive mode. A confirmation step follows in `run`.
189
+ def gather(entry, montreal, method_name, method_cfg, date)
190
+ values = { "Date" => date.strftime(@config.date_format) }
191
+
192
+ values["Title"] = need(:title, "Title")
193
+
194
+ if montreal
195
+ values["Orig. Value"] = money(need(:orig_value, "Orig. Value"))
196
+ cur = @opts[:currency] || (entry && entry["default_currency"]) || "CAD"
197
+ values["Cur."] = cur.to_s.upcase
198
+ end
199
+
200
+ values["Value (R$)"] = money(need(:value, "Value (R$)"))
201
+
202
+ both = @opts[:paid_responsible]
203
+
204
+ paid_by = @opts[:paid_by] || both || method_cfg&.dig("paid_by")
205
+ paid_by = Prompt.ask("Paid By", required: true) if missing?(paid_by) && @interactive
206
+ values["Paid By"] = require_value!(paid_by, "Paid By (--paid-by / --paid-responsible)")
207
+
208
+ values["Payment Method"] = method_name
209
+
210
+ values["Responsible"] = @opts[:responsible] || both || method_cfg&.dig("responsible") ||
211
+ (entry && entry["default_responsible"]) || "Both"
212
+
213
+ values["Source"] = @opts[:source] || "text"
214
+
215
+ values
216
+ end
217
+
218
+ def missing?(val)
219
+ val.nil? || val.to_s.strip.empty?
220
+ end
221
+
222
+ # Return a required field's value: use the flag if present, otherwise prompt
223
+ # (interactive) or error (non-interactive).
224
+ def need(key, label)
225
+ val = @opts[key]
226
+ return val unless missing?(val)
227
+ return Prompt.ask(label, required: true) if @interactive
228
+
229
+ raise Prompt::Abort, "missing required option --#{key.to_s.tr('_', '-')}"
230
+ end
231
+
232
+ # Normalize an amount string to two decimals. Accepts "12.5", "12,50", and
233
+ # "1.234,56" (dot thousands + comma decimal).
234
+ def money(str)
235
+ s = str.to_s.strip
236
+ raise Prompt::Abort, "missing amount" if s.empty?
237
+
238
+ s = s.include?(".") && s.include?(",") ? s.delete(".").tr(",", ".") : s.tr(",", ".")
239
+ format("%.2f", Float(s))
240
+ rescue ArgumentError
241
+ raise Prompt::Abort, "invalid amount: #{str.inspect}"
242
+ end
243
+
244
+ def require_value!(val, label)
245
+ raise Prompt::Abort, "missing #{label}" if val.nil? || val.to_s.strip.empty?
246
+
247
+ val
248
+ end
249
+
250
+ def ensure_file(name, entry, path, date)
251
+ return if File.exist?(path)
252
+
253
+ if @interactive && !Prompt.confirm("File #{path} doesn't exist. Create it?", default: true)
254
+ raise Prompt::Abort, "target file does not exist: #{path}"
255
+ end
256
+
257
+ Ledger.scaffold(path, Ledger.title(name, entry, date),
258
+ Ledger.notes_pointer(name, entry), Ledger.schema(entry))
259
+ warn "Created #{path}"
260
+ end
261
+
262
+ def preview(name, path, row)
263
+ warn ""
264
+ warn "Ledger : #{name} (#{path})"
265
+ warn "Row : #{row}"
266
+ end
267
+
268
+ def print_discovery(result)
269
+ warn "Discovered ledgers added to config: #{result[:added].join(', ')}" if result[:added].any?
270
+ warn "Ledgers in config but missing on disk: #{result[:missing].join(', ')}" if result[:missing].any?
271
+ end
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SplttyCLI
4
+ module Commands
5
+ # `spltty groups` — list global split groups and each ledger's own groups.
6
+ class Groups < Dry::CLI::Command
7
+ desc "List split groups (global + per-ledger)"
8
+
9
+ option :config, aliases: ["-C"], desc: "Path to config.json (default: cli/config.json)"
10
+ option :accounts_dir, aliases: ["-A"], desc: "Override the accounts directory"
11
+
12
+ def call(**opts)
13
+ config = SplttyCLI.load_config(opts)
14
+
15
+ printed = false
16
+ unless config.groups.empty?
17
+ puts "Global groups:"
18
+ print_groups(config.groups)
19
+ printed = true
20
+ end
21
+
22
+ config.ledgers.each do |name, entry|
23
+ groups = entry["groups"]
24
+ next unless groups.is_a?(Hash) && !groups.empty?
25
+
26
+ puts "#{name}:"
27
+ print_groups(groups)
28
+ printed = true
29
+ end
30
+
31
+ puts "No split groups defined." unless printed
32
+ rescue Config::Error => e
33
+ warn "spltty groups: #{e.message}"
34
+ exit 1
35
+ end
36
+
37
+ private
38
+
39
+ def print_groups(groups)
40
+ width = groups.keys.map(&:length).max
41
+ groups.each do |name, hash|
42
+ puts format(" %-#{width}s %s", name, SplttyCLI::Groups.format(hash))
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SplttyCLI
6
+ module Commands
7
+ # `spltty groups add` — define or update a split group. For a ledger the
8
+ # group is written into that ledger's notes header (the source of truth) and
9
+ # then reconciled into config via NotesSync. With --global it is written
10
+ # straight into the config's global groups (globals never live in a header).
11
+ class GroupsAdd < Dry::CLI::Command
12
+ desc "Add or update a split group (in a ledger header, or --global)"
13
+
14
+ argument :name, required: true, desc: "Group name (e.g. \"Both\")"
15
+
16
+ option :split, aliases: ["-s"], desc: "Participants as NAME:PCT,NAME:PCT (e.g. Thiago:70,Camila:30)"
17
+ option :ledger, aliases: ["-l"], desc: "Ledger whose header owns this group"
18
+ option :global, aliases: ["-g"], type: :boolean, default: false, desc: "Store as a global group instead"
19
+ option :config, aliases: ["-C"], desc: "Path to config.json (default: cli/config.json)"
20
+ option :accounts_dir, aliases: ["-A"], desc: "Override the accounts directory"
21
+
22
+ example [
23
+ %(Both -s Thiago:70,Camila:30 -l CASA),
24
+ %(Both -s Thiago:50,Camila:50 --global),
25
+ ]
26
+
27
+ def call(name:, **opts)
28
+ config = SplttyCLI.load_config(opts)
29
+ hash = SplttyCLI::Groups.parse_split(opts[:split])
30
+ SplttyCLI::Groups.validate(name, hash)
31
+
32
+ if opts[:global]
33
+ config.groups[name] = hash
34
+ config.save
35
+ puts "Added global group #{name.inspect}: #{SplttyCLI::Groups.format(hash)}"
36
+ return
37
+ end
38
+
39
+ raise Config::Error, "specify --ledger NAME or --global" if blank?(opts[:ledger])
40
+
41
+ Discovery.sync(config)
42
+ key = config.ledger_key(opts[:ledger]) or
43
+ raise Config::Error, "unknown ledger #{opts[:ledger].inspect}"
44
+ entry = config.ledgers[key]
45
+ path = Notes.path_for(config.accounts_dir, key, entry)
46
+ raise Config::Error, "no notes file for #{key} at #{path}" unless File.exist?(path)
47
+
48
+ parsed = Notes.parse(path)
49
+ fm = parsed[:frontmatter]
50
+ spltty = (fm["spltty"] ||= {})
51
+ groups = (spltty["groups"] ||= {})
52
+ groups[name] = hash
53
+ Notes.write(path, fm, parsed[:body])
54
+
55
+ NotesSync.run(config)
56
+ config.save
57
+ puts "Added group #{name.inspect} to #{key}: #{SplttyCLI::Groups.format(hash)}"
58
+ rescue ArgumentError => e
59
+ warn "spltty groups add: #{e.message}"
60
+ exit 1
61
+ rescue Config::Error => e
62
+ warn "spltty groups add: #{e.message}"
63
+ exit 1
64
+ end
65
+
66
+ private
67
+
68
+ def blank?(value)
69
+ value.nil? || value.to_s.strip.empty?
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SplttyCLI
4
+ module Commands
5
+ # `spltty groups rm` — remove a split group from a ledger header (source of
6
+ # truth, then reconciled via NotesSync) or from the global groups (--global).
7
+ class GroupsRm < Dry::CLI::Command
8
+ desc "Remove a split group (from a ledger header, or --global)"
9
+
10
+ argument :name, required: true, desc: "Group name to remove"
11
+
12
+ option :ledger, aliases: ["-l"], desc: "Ledger whose header owns this group"
13
+ option :global, aliases: ["-g"], type: :boolean, default: false, desc: "Remove from the global groups instead"
14
+ option :config, aliases: ["-C"], desc: "Path to config.json (default: cli/config.json)"
15
+ option :accounts_dir, aliases: ["-A"], desc: "Override the accounts directory"
16
+
17
+ def call(name:, **opts)
18
+ config = SplttyCLI.load_config(opts)
19
+
20
+ if opts[:global]
21
+ removed = config.groups.delete(name)
22
+ config.save
23
+ puts removed ? "Removed global group #{name.inspect}" : "No global group #{name.inspect}"
24
+ return
25
+ end
26
+
27
+ raise Config::Error, "specify --ledger NAME or --global" if blank?(opts[:ledger])
28
+
29
+ Discovery.sync(config)
30
+ key = config.ledger_key(opts[:ledger]) or
31
+ raise Config::Error, "unknown ledger #{opts[:ledger].inspect}"
32
+ entry = config.ledgers[key]
33
+ path = Notes.path_for(config.accounts_dir, key, entry)
34
+ raise Config::Error, "no notes file for #{key} at #{path}" unless File.exist?(path)
35
+
36
+ parsed = Notes.parse(path)
37
+ groups = parsed[:frontmatter].dig("spltty", "groups")
38
+ unless groups.is_a?(Hash) && groups.key?(name)
39
+ puts "No group #{name.inspect} in #{key}"
40
+ return
41
+ end
42
+
43
+ groups.delete(name)
44
+ if groups.empty?
45
+ parsed[:frontmatter]["spltty"].delete("groups")
46
+ parsed[:frontmatter].delete("spltty") if parsed[:frontmatter]["spltty"].empty?
47
+ end
48
+ Notes.write(path, parsed[:frontmatter], parsed[:body])
49
+
50
+ # Apply the deletion to config directly: when the header's last group is
51
+ # removed the "groups" key vanishes, and NotesSync's absent-key guard
52
+ # would otherwise leave the stale copy in config.
53
+ if entry["groups"].is_a?(Hash)
54
+ entry["groups"].delete(name)
55
+ entry.delete("groups") if entry["groups"].empty?
56
+ end
57
+
58
+ NotesSync.run(config)
59
+ config.save
60
+ puts "Removed group #{name.inspect} from #{key}"
61
+ rescue Config::Error => e
62
+ warn "spltty groups rm: #{e.message}"
63
+ exit 1
64
+ end
65
+
66
+ private
67
+
68
+ def blank?(value)
69
+ value.nil? || value.to_s.strip.empty?
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SplttyCLI
4
+ module Commands
5
+ # `spltty help` — print every command with its aliases and options, read
6
+ # straight from each command's dry-cli metadata.
7
+ class Help < Dry::CLI::Command
8
+ desc "Show all commands and their options"
9
+
10
+ def call(**)
11
+ puts "spltty — append entries to the expense ledgers under accounts/"
12
+ puts
13
+ puts "Usage:"
14
+ puts " spltty COMMAND [options]"
15
+ puts
16
+ puts "Commands:"
17
+ SplttyCLI::COMMAND_SPECS.each { |spec| print_command(spec) }
18
+ puts "Run `spltty COMMAND --help` for details on a single command."
19
+ end
20
+
21
+ private
22
+
23
+ def print_command(spec)
24
+ klass = spec[:klass]
25
+ puts
26
+ puts " #{spec[:name]}"
27
+ puts " #{klass.description}" if klass.description
28
+ klass.arguments.each do |arg|
29
+ label = "<#{arg.name}>#{arg.required? ? '' : ' (optional)'}"
30
+ puts " #{label.ljust(32)} #{arg.desc}"
31
+ end
32
+ klass.options.each do |opt|
33
+ puts " #{flag(opt).ljust(32)} #{opt.desc}"
34
+ end
35
+ end
36
+
37
+ def flag(opt)
38
+ name = "--#{opt.name.to_s.tr('_', '-')}"
39
+ name += ", #{opt.aliases.join(', ')}" unless opt.aliases.empty?
40
+ name
41
+ end
42
+ end
43
+ end
44
+ end