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,407 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "erb"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module SplttyCLI
|
|
8
|
+
module Commands
|
|
9
|
+
# `spltty install` — scaffold a whole workspace in a folder: the CLI config,
|
|
10
|
+
# accounts/ with one entries + notes file per ledger, sources/INDEX.md, and a
|
|
11
|
+
# CLAUDE.md generated from the shipped template so an AI agent can drive the
|
|
12
|
+
# tracker from the first session.
|
|
13
|
+
#
|
|
14
|
+
# Interactive by default; every step can be skipped except the participants
|
|
15
|
+
# (at least one) and the default ledger (pre-filled with "expenses").
|
|
16
|
+
class Install < Dry::CLI::Command
|
|
17
|
+
desc "Set up a new expense-tracking workspace in a folder"
|
|
18
|
+
|
|
19
|
+
argument :path, required: false, desc: "Workspace directory (default: current directory)"
|
|
20
|
+
|
|
21
|
+
option :participants, aliases: ["-P"], desc: "Comma-separated participant names (e.g. Ana,Bruno)"
|
|
22
|
+
option :group, aliases: ["-g"], desc: "Name of the shared split group (default: Both)"
|
|
23
|
+
option :split, aliases: ["-s"], desc: "Group split as NAME:PCT,NAME:PCT (default: even)"
|
|
24
|
+
option :ledgers, aliases: ["-l"], desc: "Comma-separated ledger names (default: expenses)"
|
|
25
|
+
option :single, type: :boolean, default: false,
|
|
26
|
+
desc: "Create ledgers as single files instead of monthly"
|
|
27
|
+
option :git, type: :boolean, default: nil,
|
|
28
|
+
desc: "Initialize a git repo (--no-git to skip)"
|
|
29
|
+
option :force, aliases: ["-f"], type: :boolean, default: false,
|
|
30
|
+
desc: "Overwrite an existing workspace config"
|
|
31
|
+
option :yes, aliases: ["-y"], type: :boolean, default: false,
|
|
32
|
+
desc: "Skip prompts (non-interactive)"
|
|
33
|
+
|
|
34
|
+
example [
|
|
35
|
+
" # interactive, in the current directory",
|
|
36
|
+
"~/finances # interactive, in a new folder",
|
|
37
|
+
"~/finances -y -P Ana,Bruno # non-interactive, one 'expenses' ledger",
|
|
38
|
+
"~/finances -y -P Ana,Bruno -s Ana:60,Bruno:40 -l HOME,TRIP",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
def call(path: nil, **opts)
|
|
42
|
+
InstallRunner.new(path, opts).run
|
|
43
|
+
rescue Prompt::Abort, Config::Error, ArgumentError => e
|
|
44
|
+
warn "spltty install: #{e.message}"
|
|
45
|
+
exit 1
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Orchestrates the install flow. Extracted from the command so it is
|
|
50
|
+
# unit-testable without going through dry-cli.
|
|
51
|
+
class InstallRunner
|
|
52
|
+
DEFAULT_LEDGER = "expenses"
|
|
53
|
+
DEFAULT_GROUP = "Both"
|
|
54
|
+
|
|
55
|
+
def initialize(path, opts)
|
|
56
|
+
@root = File.expand_path(path.to_s.empty? ? Dir.pwd : path.to_s)
|
|
57
|
+
@opts = opts
|
|
58
|
+
@interactive = !opts[:yes]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run
|
|
62
|
+
prepare_root
|
|
63
|
+
participants = ask_participants
|
|
64
|
+
group_name, group = ask_group(participants)
|
|
65
|
+
ledgers = ask_ledgers(participants, group_name)
|
|
66
|
+
methods = ask_methods(participants, group_name || participants.first)
|
|
67
|
+
|
|
68
|
+
config = write_config(participants, group_name, group, ledgers, methods)
|
|
69
|
+
ledgers.each { |l| write_ledger(config, l) }
|
|
70
|
+
write_sources
|
|
71
|
+
claude_path = write_claude_md(participants, group_name, group, ledgers, methods)
|
|
72
|
+
maybe_git_init
|
|
73
|
+
|
|
74
|
+
reconcile(config)
|
|
75
|
+
report(ledgers, claude_path)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# --- prompts ------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def prepare_root
|
|
83
|
+
existing = Config.workspace_path(@root)
|
|
84
|
+
if File.exist?(existing) && !@opts[:force]
|
|
85
|
+
raise Config::Error,
|
|
86
|
+
"#{@root} is already a spltty workspace (#{existing}) — pass --force to overwrite its config"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
FileUtils.mkdir_p(@root)
|
|
90
|
+
warn "Setting up a spltty workspace in #{@root}" if @interactive
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# At least one participant is required — everything else can be skipped.
|
|
94
|
+
def ask_participants
|
|
95
|
+
list = split_list(@opts[:participants])
|
|
96
|
+
while list.empty?
|
|
97
|
+
raise Prompt::Abort, "--participants is required (e.g. -P Ana,Bruno)" unless @interactive
|
|
98
|
+
|
|
99
|
+
list = split_list(Prompt.ask("Participants (comma-separated)", required: true))
|
|
100
|
+
warn " (at least one participant is required)" if list.empty?
|
|
101
|
+
end
|
|
102
|
+
list
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The shared split group. Pointless with a single participant — skipped.
|
|
106
|
+
# Returns [name, {person => pct}] or [nil, nil] when there is none.
|
|
107
|
+
def ask_group(participants)
|
|
108
|
+
return [participants.first, nil] if participants.length < 2
|
|
109
|
+
|
|
110
|
+
name = @opts[:group]
|
|
111
|
+
name = Prompt.ask("Name for the shared split group", default: DEFAULT_GROUP) if name.nil? && @interactive
|
|
112
|
+
name = DEFAULT_GROUP if blank?(name)
|
|
113
|
+
|
|
114
|
+
split = @opts[:split]
|
|
115
|
+
default = SplttyCLI::Groups.format_split(even_split(participants))
|
|
116
|
+
split = Prompt.ask("Split for #{name}", default: default) if blank?(split) && @interactive
|
|
117
|
+
hash = blank?(split) ? even_split(participants) : SplttyCLI::Groups.parse_split(split)
|
|
118
|
+
SplttyCLI::Groups.validate(name, hash)
|
|
119
|
+
[name, hash]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Even split, with the rounding remainder given to the first participant so
|
|
123
|
+
# the percentages always sum to 100.
|
|
124
|
+
def even_split(participants)
|
|
125
|
+
share = 100 / participants.length
|
|
126
|
+
hash = participants.to_h { |p| [p, share] }
|
|
127
|
+
hash[participants.first] += 100 - (share * participants.length)
|
|
128
|
+
hash
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# One entry per ledger to create:
|
|
132
|
+
# { name:, type:, schema:, default_responsible:, default_currency: }
|
|
133
|
+
def ask_ledgers(participants, group_name)
|
|
134
|
+
default_responsible = group_name || participants.first
|
|
135
|
+
names = split_list(@opts[:ledgers])
|
|
136
|
+
return names.map { |n| ledger_entry(n, default_responsible) } unless names.empty?
|
|
137
|
+
|
|
138
|
+
unless @interactive
|
|
139
|
+
return [ledger_entry(DEFAULT_LEDGER, default_responsible, monthly: !@opts[:single])]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
ledgers = []
|
|
143
|
+
loop do
|
|
144
|
+
name = Prompt.ask("Ledger name", default: ledgers.empty? ? DEFAULT_LEDGER : nil)
|
|
145
|
+
if blank?(name)
|
|
146
|
+
break unless ledgers.empty?
|
|
147
|
+
|
|
148
|
+
name = DEFAULT_LEDGER
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
monthly = Prompt.confirm(" Monthly (one file per month)?", default: true)
|
|
152
|
+
multi = Prompt.confirm(" Multi-currency (extra Orig. Value + Cur. columns)?", default: false)
|
|
153
|
+
currency = multi ? Prompt.ask(" Default original currency", default: "USD").upcase : nil
|
|
154
|
+
responsible = Prompt.ask(" Default responsible", default: default_responsible)
|
|
155
|
+
|
|
156
|
+
ledgers << ledger_entry(name, responsible, monthly: monthly, currency: currency)
|
|
157
|
+
break unless Prompt.confirm("Add another ledger?", default: false)
|
|
158
|
+
end
|
|
159
|
+
ledgers
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def ledger_entry(name, responsible, monthly: nil, currency: nil)
|
|
163
|
+
monthly = !@opts[:single] if monthly.nil?
|
|
164
|
+
{
|
|
165
|
+
name: name,
|
|
166
|
+
type: monthly ? "monthly" : "single",
|
|
167
|
+
schema: currency ? "montreal" : "standard",
|
|
168
|
+
default_responsible: responsible,
|
|
169
|
+
default_currency: currency,
|
|
170
|
+
}
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Optional payment-method defaults (cards). Interactive only — skipped by
|
|
174
|
+
# default and always skippable.
|
|
175
|
+
def ask_methods(participants, default_responsible)
|
|
176
|
+
return {} unless @interactive
|
|
177
|
+
return {} unless Prompt.confirm("Add a payment method (card) with defaults?", default: false)
|
|
178
|
+
|
|
179
|
+
methods = {}
|
|
180
|
+
loop do
|
|
181
|
+
name = Prompt.ask(" Payment method name (e.g. \"Visa 1234\")", required: true)
|
|
182
|
+
cfg = {}
|
|
183
|
+
slug = Prompt.ask(" Short alias for -m", default: slugify(name))
|
|
184
|
+
cfg["slug"] = slug unless blank?(slug)
|
|
185
|
+
cfg["paid_by"] = Prompt.ask(" Default paid by", default: participants.first)
|
|
186
|
+
cfg["responsible"] = Prompt.ask(" Default responsible", default: default_responsible)
|
|
187
|
+
day = Prompt.ask(" Bill payment day (1-31 or 'last')", default: "last")
|
|
188
|
+
cfg["bill_day"] = day.to_s.strip.casecmp?("last") ? "last" : day.to_i unless blank?(day)
|
|
189
|
+
methods[name] = cfg
|
|
190
|
+
|
|
191
|
+
break unless Prompt.confirm("Add another payment method?", default: false)
|
|
192
|
+
end
|
|
193
|
+
methods
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def slugify(name)
|
|
197
|
+
name.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# --- writing ------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def write_config(participants, group_name, group, ledgers, methods)
|
|
203
|
+
path = Config.workspace_path(@root)
|
|
204
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
205
|
+
|
|
206
|
+
config = Config.load(path)
|
|
207
|
+
config.data["accounts_dir"] = "../accounts"
|
|
208
|
+
config.data["default_ledger"] = ledgers.first[:name]
|
|
209
|
+
config.data["date_format"] = "%Y-%m-%d"
|
|
210
|
+
config.data["participants"] = participants
|
|
211
|
+
# Merge, never replace: on a --force re-install the cards the user
|
|
212
|
+
# configured with `methods add` must survive. Same additive treatment
|
|
213
|
+
# groups and ledger entries already get below.
|
|
214
|
+
config.payment_methods.merge!(methods)
|
|
215
|
+
config.groups[group_name] = group if group
|
|
216
|
+
ledgers.each do |l|
|
|
217
|
+
entry = { "type" => l[:type], "schema" => l[:schema], "default_responsible" => l[:default_responsible] }
|
|
218
|
+
l[:type] == "monthly" ? entry["dir"] = l[:name] : entry["file"] = "#{l[:name]}.ledger.md"
|
|
219
|
+
entry["notes"] = notes_name(l)
|
|
220
|
+
entry["default_currency"] = l[:default_currency] if l[:default_currency]
|
|
221
|
+
entry["groups"] = { group_name => group } if group
|
|
222
|
+
config.ledgers[l[:name]] = entry
|
|
223
|
+
end
|
|
224
|
+
config.save
|
|
225
|
+
config
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Entries file (via Ledger.scaffold) + notes file carrying the ledger's
|
|
229
|
+
# config in its YAML header — the header is the source of truth NotesSync
|
|
230
|
+
# reconciles against config.json.
|
|
231
|
+
def write_ledger(config, ledger)
|
|
232
|
+
entry = config.ledgers[ledger[:name]]
|
|
233
|
+
today = Date.today
|
|
234
|
+
entries_path = Ledger.target_path(config, ledger[:name], entry, today)
|
|
235
|
+
unless File.exist?(entries_path)
|
|
236
|
+
Ledger.scaffold(entries_path,
|
|
237
|
+
Ledger.title(ledger[:name], entry, today),
|
|
238
|
+
Ledger.notes_pointer(ledger[:name], entry),
|
|
239
|
+
Ledger.schema(entry))
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
notes_path = Notes.path_for(config.accounts_dir, ledger[:name], entry)
|
|
243
|
+
return if File.exist?(notes_path)
|
|
244
|
+
|
|
245
|
+
frontmatter = { "spltty" => header_for(entry, ledger) }
|
|
246
|
+
body = render("notes.md.erb",
|
|
247
|
+
name: ledger[:name],
|
|
248
|
+
entries: entries_label(ledger),
|
|
249
|
+
default_responsible: ledger[:default_responsible],
|
|
250
|
+
group_line: group_line(entry),
|
|
251
|
+
multi_currency: ledger[:schema] == "montreal",
|
|
252
|
+
default_currency: ledger[:default_currency])
|
|
253
|
+
FileUtils.mkdir_p(File.dirname(notes_path))
|
|
254
|
+
Notes.write(notes_path, frontmatter, body)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The `spltty:` sub-map written into a ledger's notes header. Only the
|
|
258
|
+
# fields NotesSync manages, and only when set.
|
|
259
|
+
#
|
|
260
|
+
# `title` is deliberately omitted: Ledger.title treats it as a *format
|
|
261
|
+
# template* (`%{name} — %{month_name} %{year}`), so writing a rendered
|
|
262
|
+
# title here would freeze every future month file to this month's name.
|
|
263
|
+
def header_for(entry, ledger)
|
|
264
|
+
header = { "default_responsible" => ledger[:default_responsible] }
|
|
265
|
+
header["default_currency"] = ledger[:default_currency] if ledger[:default_currency]
|
|
266
|
+
header["groups"] = entry["groups"] if entry["groups"]
|
|
267
|
+
header
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# How the notes file refers to its entries: a monthly ledger has many, so
|
|
271
|
+
# it gets a description rather than a link to one particular month.
|
|
272
|
+
def entries_label(ledger)
|
|
273
|
+
return "the `YYYY-MM.md` files in this folder" if ledger[:type] == "monthly"
|
|
274
|
+
|
|
275
|
+
file = "#{ledger[:name]}.ledger.md"
|
|
276
|
+
"[`#{file}`](#{file})"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def group_line(entry)
|
|
280
|
+
groups = entry["groups"]
|
|
281
|
+
return nil unless groups.is_a?(Hash) && !groups.empty?
|
|
282
|
+
|
|
283
|
+
groups.map { |name, hash| "`#{name}` = #{SplttyCLI::Groups.format(hash)}" }.join(", ")
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def notes_name(ledger)
|
|
287
|
+
ledger[:type] == "monthly" ? "notes.md" : "#{ledger[:name]}.notes.md"
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def write_sources
|
|
291
|
+
path = File.join(@root, "sources", "INDEX.md")
|
|
292
|
+
return if File.exist?(path)
|
|
293
|
+
|
|
294
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
295
|
+
File.write(path, File.read(File.join(SplttyCLI::TEMPLATES_DIR, "sources-INDEX.md")))
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Render the workspace guide. An existing CLAUDE.md is never clobbered —
|
|
299
|
+
# the generated one lands next to it as CLAUDE.spltty.md instead.
|
|
300
|
+
def write_claude_md(participants, group_name, group, ledgers, methods)
|
|
301
|
+
groups = group ? { group_name => group } : {}
|
|
302
|
+
content = render("CLAUDE.md.erb",
|
|
303
|
+
participants: participants,
|
|
304
|
+
participants_sentence: sentence(participants),
|
|
305
|
+
groups: groups,
|
|
306
|
+
default_responsible: group_name || participants.first,
|
|
307
|
+
example_split: SplttyCLI::Groups.format_split(even_split(participants)),
|
|
308
|
+
default_ledger: ledgers.first[:name],
|
|
309
|
+
methods: methods,
|
|
310
|
+
ledgers: ledgers.map { |l| claude_ledger_row(l) })
|
|
311
|
+
|
|
312
|
+
path = File.join(@root, "CLAUDE.md")
|
|
313
|
+
path = File.join(@root, "CLAUDE.spltty.md") if File.exist?(path)
|
|
314
|
+
File.write(path, content)
|
|
315
|
+
|
|
316
|
+
skill = File.join(@root, ".claude", "skills", "ingest", "SKILL.md")
|
|
317
|
+
unless File.exist?(skill)
|
|
318
|
+
FileUtils.mkdir_p(File.dirname(skill))
|
|
319
|
+
File.write(skill, File.read(File.join(SplttyCLI::TEMPLATES_DIR, "skills", "ingest", "SKILL.md")))
|
|
320
|
+
end
|
|
321
|
+
path
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def claude_ledger_row(ledger)
|
|
325
|
+
monthly = ledger[:type] == "monthly"
|
|
326
|
+
ledger.merge(
|
|
327
|
+
entries: monthly ? "accounts/#{ledger[:name]}/YYYY-MM.md" : "accounts/#{ledger[:name]}.ledger.md",
|
|
328
|
+
notes: monthly ? "accounts/#{ledger[:name]}/notes.md" : "accounts/#{ledger[:name]}.notes.md"
|
|
329
|
+
)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def maybe_git_init
|
|
333
|
+
return if File.exist?(File.join(@root, ".git"))
|
|
334
|
+
|
|
335
|
+
wanted = @opts[:git]
|
|
336
|
+
if wanted.nil?
|
|
337
|
+
wanted = @interactive && Prompt.confirm("Initialize a git repository here?", default: true)
|
|
338
|
+
end
|
|
339
|
+
return unless wanted
|
|
340
|
+
|
|
341
|
+
gitignore = File.join(@root, ".gitignore")
|
|
342
|
+
unless File.exist?(gitignore)
|
|
343
|
+
File.write(gitignore, File.read(File.join(SplttyCLI::TEMPLATES_DIR, "gitignore")))
|
|
344
|
+
end
|
|
345
|
+
system("git", "init", "--quiet", @root) || warn("spltty install: git init failed — skipping")
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# --- finishing ----------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
# Reconcile what was just written on disk with the config, so the very
|
|
351
|
+
# first `spltty list` / `totals` run is a no-op rather than a resync.
|
|
352
|
+
def reconcile(config)
|
|
353
|
+
Discovery.sync(config)
|
|
354
|
+
NotesSync.run(config)
|
|
355
|
+
config.save
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def report(ledgers, claude_path)
|
|
359
|
+
puts "Created a spltty workspace in #{@root}"
|
|
360
|
+
puts " #{Config::WORKSPACE_DIR}/#{Config::WORKSPACE_FILE} config (participants, groups, payment methods)"
|
|
361
|
+
puts " accounts/ #{ledgers.map { |l| l[:name] }.join(', ')}"
|
|
362
|
+
puts " sources/INDEX.md source tracker"
|
|
363
|
+
puts " #{File.basename(claude_path)} AI workflow guide"
|
|
364
|
+
puts
|
|
365
|
+
puts "Next:"
|
|
366
|
+
puts " cd #{@root}"
|
|
367
|
+
puts " spltty list"
|
|
368
|
+
puts " spltty add \"First expense\" -l #{ledgers.first[:name]} -v 10.00"
|
|
369
|
+
puts " spltty totals"
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# --- helpers ------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
def render(template, **vars)
|
|
375
|
+
src = File.read(File.join(SplttyCLI::TEMPLATES_DIR, template))
|
|
376
|
+
scope = TemplateScope.new(vars)
|
|
377
|
+
ERB.new(src, trim_mode: "-").result(scope.binding_for)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def split_list(str)
|
|
381
|
+
str.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def sentence(list)
|
|
385
|
+
return list.first.to_s if list.length < 2
|
|
386
|
+
|
|
387
|
+
"#{list[0..-2].join(', ')} and #{list[-1]}"
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def blank?(value)
|
|
391
|
+
value.nil? || value.to_s.strip.empty?
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Plain binding holder for ERB — each key becomes a local-ish reader method,
|
|
396
|
+
# so templates read `<%= participants %>` rather than `<%= vars[:participants] %>`.
|
|
397
|
+
class TemplateScope
|
|
398
|
+
def initialize(vars)
|
|
399
|
+
vars.each { |k, v| define_singleton_method(k) { v } }
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def binding_for
|
|
403
|
+
binding
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SplttyCLI
|
|
4
|
+
module Commands
|
|
5
|
+
# `spltty list` — run discovery and list the known ledgers.
|
|
6
|
+
class List < Dry::CLI::Command
|
|
7
|
+
desc "List ledgers discovered under the accounts directory"
|
|
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
|
+
result = Discovery.sync(config)
|
|
15
|
+
notes = NotesSync.run(config)
|
|
16
|
+
config.save if result[:changed] || notes.values.any? { |v| !v.empty? }
|
|
17
|
+
|
|
18
|
+
if config.ledgers.empty?
|
|
19
|
+
puts "No ledgers found under #{config.accounts_dir}"
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
width = config.ledgers.keys.map(&:length).max
|
|
24
|
+
puts "Ledgers (accounts dir: #{config.accounts_dir}):"
|
|
25
|
+
config.ledgers.each do |name, cfg|
|
|
26
|
+
location = cfg["dir"] ? "#{cfg['dir']}/" : cfg["file"]
|
|
27
|
+
flags = cfg["missing"] ? " [MISSING ON DISK]" : ""
|
|
28
|
+
puts format(" %-#{width}s %-8s %-9s %s%s",
|
|
29
|
+
name, cfg["type"], cfg["schema"], location, flags)
|
|
30
|
+
end
|
|
31
|
+
rescue Config::Error => e
|
|
32
|
+
warn "spltty list: #{e.message}"
|
|
33
|
+
exit 1
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SplttyCLI
|
|
4
|
+
module Commands
|
|
5
|
+
# `spltty methods` — list configured payment methods and their defaults.
|
|
6
|
+
class Methods < Dry::CLI::Command
|
|
7
|
+
desc "List configured payment methods and their defaults"
|
|
8
|
+
|
|
9
|
+
option :config, aliases: ["-C"], desc: "Path to config.json (default: cli/config.json)"
|
|
10
|
+
|
|
11
|
+
def call(**opts)
|
|
12
|
+
config = SplttyCLI.load_config(opts)
|
|
13
|
+
methods = config.payment_methods
|
|
14
|
+
|
|
15
|
+
if methods.empty?
|
|
16
|
+
puts "No payment methods configured in #{config.path}"
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
width = methods.keys.map(&:length).max
|
|
21
|
+
slug_width = methods.values.map { |c| (c["slug"] || "").length }.max
|
|
22
|
+
puts "Payment methods (from #{config.path}):"
|
|
23
|
+
methods.each do |name, cfg|
|
|
24
|
+
puts format(" %-#{width}s [%-#{slug_width}s] paid_by=%-7s responsible=%-16s bill_day=%s",
|
|
25
|
+
name, cfg["slug"], cfg["paid_by"], cfg["responsible"], cfg["bill_day"])
|
|
26
|
+
end
|
|
27
|
+
rescue Config::Error => e
|
|
28
|
+
warn "spltty methods: #{e.message}"
|
|
29
|
+
exit 1
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SplttyCLI
|
|
6
|
+
module Commands
|
|
7
|
+
# `spltty methods add` — add or update a payment method in the config. Only
|
|
8
|
+
# the properties you pass are set; existing ones are preserved.
|
|
9
|
+
class MethodsAdd < Dry::CLI::Command
|
|
10
|
+
desc "Add or update a payment method in the config"
|
|
11
|
+
|
|
12
|
+
argument :name, required: true, desc: "Payment method name / key (e.g. \"Mastercard 3278\")"
|
|
13
|
+
|
|
14
|
+
option :slug, aliases: ["-s"], desc: "Short alias usable with -m (e.g. mc-3278)"
|
|
15
|
+
option :paid_by, aliases: ["-p"], desc: "Default Paid By (Thiago/Camila/...)"
|
|
16
|
+
option :responsible, aliases: ["-r"], desc: "Default Responsible (Thiago/Camila/Both/...)"
|
|
17
|
+
option :bill_day, aliases: ["-b"], desc: "Bill payment day: a day number (1-31) or 'last'"
|
|
18
|
+
option :config, aliases: ["-C"], desc: "Path to config.json (default: cli/config.json)"
|
|
19
|
+
|
|
20
|
+
example [
|
|
21
|
+
%("Mastercard 3278" -s mc-3278 -p Camila -r Camila -b last),
|
|
22
|
+
%("Nubank" -s nu -p Thiago -r Both -b 10),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
def call(name:, **opts)
|
|
26
|
+
config = SplttyCLI.load_config(opts)
|
|
27
|
+
|
|
28
|
+
existed = config.payment_methods.key?(name)
|
|
29
|
+
entry = (config.payment_methods[name] ||= {})
|
|
30
|
+
entry["slug"] = opts[:slug] unless blank?(opts[:slug])
|
|
31
|
+
entry["paid_by"] = opts[:paid_by] unless blank?(opts[:paid_by])
|
|
32
|
+
entry["responsible"] = opts[:responsible] unless blank?(opts[:responsible])
|
|
33
|
+
entry["bill_day"] = parse_bill_day(opts[:bill_day]) unless blank?(opts[:bill_day])
|
|
34
|
+
|
|
35
|
+
config.save
|
|
36
|
+
puts "#{existed ? 'Updated' : 'Added'} payment method #{name.inspect}: #{entry.to_json}"
|
|
37
|
+
rescue Config::Error => e
|
|
38
|
+
warn "spltty methods add: #{e.message}"
|
|
39
|
+
exit 1
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def blank?(value)
|
|
45
|
+
value.nil? || value.to_s.strip.empty?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse_bill_day(value)
|
|
49
|
+
s = value.to_s.strip
|
|
50
|
+
return "last" if s.casecmp?("last")
|
|
51
|
+
|
|
52
|
+
Integer(s)
|
|
53
|
+
rescue ArgumentError
|
|
54
|
+
raise Config::Error, "invalid --bill-day #{value.inspect} (use a day number 1-31 or 'last')"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|