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,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SplttyCLI
|
|
4
|
+
# Per-person totals + settlement for ledger tables. The Responsible cell is
|
|
5
|
+
# resolved against split groups — the row's ledger header groups first, then
|
|
6
|
+
# the global config groups — rather than by parsing a `Both (NN/NN T/C)` string.
|
|
7
|
+
# A match splits the value by the group's participant percentages; anything
|
|
8
|
+
# that matches no group is a person (100% to that name).
|
|
9
|
+
#
|
|
10
|
+
# Two dimensions are reported: RESPONSIBLE (what each person owes) and PAID BY
|
|
11
|
+
# (what each fronted). Net = Paid - Owed drives the greedy settlement.
|
|
12
|
+
module Totals
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
EPS = 0.005
|
|
16
|
+
|
|
17
|
+
# A **reference ledger** intentionally duplicates rows that already exist in
|
|
18
|
+
# a primary ledger (a project tracker mirroring shared spend, say). Its rows
|
|
19
|
+
# are copies, not new spend, so it must never be summed with the primaries.
|
|
20
|
+
# Marked per ledger with `reference: true` — in the ledger's notes header
|
|
21
|
+
# (the source of truth) or directly in config.json.
|
|
22
|
+
def reference?(config, name)
|
|
23
|
+
entry = config.ledgers[name]
|
|
24
|
+
entry.is_a?(Hash) && entry["reference"] == true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Names of every ledger flagged as a reference ledger.
|
|
28
|
+
def reference_ledgers(config)
|
|
29
|
+
config.ledgers.keys.select { |name| reference?(config, name) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Build a resolver lambda for one ledger: name -> split Hash (or nil).
|
|
33
|
+
# The ledger's own groups shadow the global groups.
|
|
34
|
+
def resolver(config, ledger_key)
|
|
35
|
+
ledger_groups = (ledger_key && config.ledgers[ledger_key] && config.ledgers[ledger_key]["groups"]) || {}
|
|
36
|
+
global_groups = config.groups
|
|
37
|
+
->(name) { ledger_groups[name] || global_groups[name] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# { person => amount_owed } for one row. Group -> proportional split by
|
|
41
|
+
# pct/sum(pct); otherwise the whole value goes to the (person) name.
|
|
42
|
+
def owed_split(value, resp, resolver)
|
|
43
|
+
split = resolver.call(resp)
|
|
44
|
+
if split.is_a?(Hash) && !split.empty?
|
|
45
|
+
total = split.values.sum.to_f
|
|
46
|
+
return { resp => value } if total.zero?
|
|
47
|
+
|
|
48
|
+
split.transform_values { |pct| value * pct / total }
|
|
49
|
+
else
|
|
50
|
+
{ resp => value }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Parse a ledger table file -> [{ value:, paid:, responsible: }]: only "|"
|
|
55
|
+
# lines, skip the separator, first row is the header, columns addressed by
|
|
56
|
+
# name, rows with a non-numeric value are skipped.
|
|
57
|
+
def read_rows(path)
|
|
58
|
+
rows = []
|
|
59
|
+
header = nil
|
|
60
|
+
vi = pi = ri = nil
|
|
61
|
+
File.foreach(path) do |line|
|
|
62
|
+
next unless line.strip.start_with?("|")
|
|
63
|
+
|
|
64
|
+
cells = Table.row_cells(line)
|
|
65
|
+
next if Table.separator?(cells)
|
|
66
|
+
|
|
67
|
+
if header.nil?
|
|
68
|
+
header = cells
|
|
69
|
+
idx = header.each_index.to_h { |i| [header[i], i] }
|
|
70
|
+
vi = idx["Value (R$)"]
|
|
71
|
+
pi = idx["Paid By"]
|
|
72
|
+
ri = idx["Responsible"]
|
|
73
|
+
raise ArgumentError, "#{path}: missing Value (R$)/Responsible header" if vi.nil? || ri.nil?
|
|
74
|
+
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
next if cells.length <= [vi, ri].max
|
|
79
|
+
|
|
80
|
+
value = (Float(cells[vi]) rescue nil)
|
|
81
|
+
next if value.nil?
|
|
82
|
+
|
|
83
|
+
paid = pi && pi < cells.length ? cells[pi] : ""
|
|
84
|
+
rows << { value: value, paid: paid, responsible: cells[ri] }
|
|
85
|
+
end
|
|
86
|
+
rows
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# All entry rows for a ledger: a monthly ledger merges every YYYY-MM.md;
|
|
90
|
+
# a single-file ledger is its one .ledger.md.
|
|
91
|
+
def ledger_rows(config, name)
|
|
92
|
+
entry = config.ledgers[name]
|
|
93
|
+
if entry["type"] == "monthly"
|
|
94
|
+
dir = File.join(config.accounts_dir, entry["dir"] || name)
|
|
95
|
+
months = Dir.children(dir).select { |f| f =~ Discovery::MONTH_FILE }.sort
|
|
96
|
+
months.flat_map { |m| read_rows(File.join(dir, m)) }
|
|
97
|
+
else
|
|
98
|
+
file = File.join(config.accounts_dir, entry["file"] || "#{name}.ledger.md")
|
|
99
|
+
File.exist?(file) ? read_rows(file) : []
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Accumulate paid/owed/spending-breakdown for a set of rows under one resolver.
|
|
104
|
+
def tally(rows, resolver)
|
|
105
|
+
t = {
|
|
106
|
+
count: rows.length, grand: 0.0,
|
|
107
|
+
paid: Hash.new(0.0), owed: Hash.new(0.0),
|
|
108
|
+
spent: Hash.new(0.0), individual: Hash.new(0.0),
|
|
109
|
+
shared: Hash.new(0.0), other: Hash.new(0.0)
|
|
110
|
+
}
|
|
111
|
+
rows.each do |r|
|
|
112
|
+
v = r[:value]
|
|
113
|
+
t[:grand] += v
|
|
114
|
+
split = owed_split(v, r[:responsible], resolver)
|
|
115
|
+
split.each { |person, amt| t[:owed][person] += amt }
|
|
116
|
+
|
|
117
|
+
pby = r[:paid]
|
|
118
|
+
next if pby.nil? || pby.empty?
|
|
119
|
+
|
|
120
|
+
t[:paid][pby] += v
|
|
121
|
+
t[:spent][pby] += v
|
|
122
|
+
if split.size > 1
|
|
123
|
+
t[:shared][pby] += v
|
|
124
|
+
elsif split.keys.first == pby
|
|
125
|
+
t[:individual][pby] += v
|
|
126
|
+
else
|
|
127
|
+
t[:other][pby] += v
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
t
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Sum several tallies into one (for --combined).
|
|
134
|
+
def merge(tallies)
|
|
135
|
+
out = {
|
|
136
|
+
count: 0, grand: 0.0,
|
|
137
|
+
paid: Hash.new(0.0), owed: Hash.new(0.0),
|
|
138
|
+
spent: Hash.new(0.0), individual: Hash.new(0.0),
|
|
139
|
+
shared: Hash.new(0.0), other: Hash.new(0.0)
|
|
140
|
+
}
|
|
141
|
+
tallies.each do |t|
|
|
142
|
+
out[:count] += t[:count]
|
|
143
|
+
out[:grand] += t[:grand]
|
|
144
|
+
%i[paid owed spent individual shared other].each do |k|
|
|
145
|
+
t[k].each { |person, amt| out[k][person] += amt }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
out
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Net balances from one tally: { person => paid - owed }.
|
|
152
|
+
def net(tally)
|
|
153
|
+
(tally[:paid].keys | tally[:owed].keys).to_h { |p| [p, tally[:paid][p] - tally[:owed][p]] }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Greedy settlement from net balances (paid - owed). -> [[debtor, creditor, amt]].
|
|
157
|
+
def settle(net)
|
|
158
|
+
debtors = net.select { |_, b| b < -EPS }.map { |p, b| [p, -b] }.sort_by { |x| x[1] }
|
|
159
|
+
creditors = net.select { |_, b| b > EPS }.map { |p, b| [p, b] }.sort_by { |x| -x[1] }
|
|
160
|
+
i = j = 0
|
|
161
|
+
transfers = []
|
|
162
|
+
while i < debtors.length && j < creditors.length
|
|
163
|
+
d = debtors[i]
|
|
164
|
+
c = creditors[j]
|
|
165
|
+
amt = [d[1], c[1]].min
|
|
166
|
+
transfers << [d[0], c[0], amt]
|
|
167
|
+
d[1] -= amt
|
|
168
|
+
c[1] -= amt
|
|
169
|
+
i += 1 if d[1] <= EPS
|
|
170
|
+
j += 1 if c[1] <= EPS
|
|
171
|
+
end
|
|
172
|
+
transfers
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Full report block for one tally.
|
|
176
|
+
def report(title, t, reference: false)
|
|
177
|
+
people = (t[:owed].keys | t[:paid].keys).sort
|
|
178
|
+
out = +"== #{title} ==\n"
|
|
179
|
+
if reference
|
|
180
|
+
out << "** REFERENCE/PROJECT LEDGER — duplicates entries from a primary " \
|
|
181
|
+
"ledger; do NOT combine with others. **\n"
|
|
182
|
+
end
|
|
183
|
+
out << "Rows: #{t[:count]} Grand total: R$ #{fmt(t[:grand])}\n\n"
|
|
184
|
+
out << ("%-10s%14s%14s%14s\n" % ["Person", "Paid", "Owes", "Net"])
|
|
185
|
+
people.each do |p|
|
|
186
|
+
net = t[:paid][p] - t[:owed][p]
|
|
187
|
+
out << ("%-10s%14s%14s%14s\n" % [p, fmt(t[:paid][p]), fmt(t[:owed][p]), fmt_signed(net)])
|
|
188
|
+
end
|
|
189
|
+
out << " (Net = Paid - Owes; positive means others owe this person)\n\n"
|
|
190
|
+
|
|
191
|
+
out << ("%-10s%14s%14s%14s%14s\n" % ["", "Total Spent", "Individual", "Shared", "For Other"])
|
|
192
|
+
people.each do |p|
|
|
193
|
+
out << ("%-10s%14s%14s%14s%14s\n" %
|
|
194
|
+
[p, fmt(t[:spent][p]), fmt(t[:individual][p]), fmt(t[:shared][p]), fmt(t[:other][p])])
|
|
195
|
+
end
|
|
196
|
+
out << " (Total Spent = what each fronted; For Other = paid but the other " \
|
|
197
|
+
"person bears it)\n\n"
|
|
198
|
+
|
|
199
|
+
transfers = settle(net(t))
|
|
200
|
+
if transfers.empty?
|
|
201
|
+
out << "Settlement: already even.\n"
|
|
202
|
+
else
|
|
203
|
+
out << "Settlement:\n"
|
|
204
|
+
transfers.each { |d, c, amt| out << " #{c} is owed — #{d} owes #{c}: R$ #{fmt(amt)}\n" }
|
|
205
|
+
end
|
|
206
|
+
out << "\n"
|
|
207
|
+
out
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# "%,.2f": comma thousands separators, 2 decimals.
|
|
211
|
+
def fmt(number)
|
|
212
|
+
whole, frac = format("%.2f", number.abs).split(".")
|
|
213
|
+
whole = whole.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
214
|
+
"#{'-' if number.negative?}#{whole}.#{frac}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# "%+,.2f": like fmt but always signed.
|
|
218
|
+
def fmt_signed(number)
|
|
219
|
+
"#{number.negative? ? '-' : '+'}#{fmt(number.abs)}"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
data/lib/spltty_cli.rb
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/cli"
|
|
4
|
+
|
|
5
|
+
require_relative "spltty_cli/version"
|
|
6
|
+
require_relative "spltty_cli/config"
|
|
7
|
+
require_relative "spltty_cli/table"
|
|
8
|
+
require_relative "spltty_cli/discovery"
|
|
9
|
+
require_relative "spltty_cli/ledger"
|
|
10
|
+
require_relative "spltty_cli/notes"
|
|
11
|
+
require_relative "spltty_cli/notes_sync"
|
|
12
|
+
require_relative "spltty_cli/groups"
|
|
13
|
+
require_relative "spltty_cli/totals"
|
|
14
|
+
require_relative "spltty_cli/prompt"
|
|
15
|
+
require_relative "spltty_cli/commands/install"
|
|
16
|
+
require_relative "spltty_cli/commands/add"
|
|
17
|
+
require_relative "spltty_cli/commands/list"
|
|
18
|
+
require_relative "spltty_cli/commands/sync"
|
|
19
|
+
require_relative "spltty_cli/commands/totals"
|
|
20
|
+
require_relative "spltty_cli/commands/settle"
|
|
21
|
+
require_relative "spltty_cli/commands/groups"
|
|
22
|
+
require_relative "spltty_cli/commands/groups_add"
|
|
23
|
+
require_relative "spltty_cli/commands/groups_rm"
|
|
24
|
+
require_relative "spltty_cli/commands/methods"
|
|
25
|
+
require_relative "spltty_cli/commands/methods_add"
|
|
26
|
+
require_relative "spltty_cli/commands/help"
|
|
27
|
+
|
|
28
|
+
module SplttyCLI
|
|
29
|
+
# Files bundled with the gem: the workspace CLAUDE.md template, the per-ledger
|
|
30
|
+
# notes header, the source index, and the /ingest skill.
|
|
31
|
+
TEMPLATES_DIR = File.expand_path("../templates", __dir__)
|
|
32
|
+
|
|
33
|
+
# Entrypoint shared by exe/spltty (gem binary) and bin/spltty (mise launcher).
|
|
34
|
+
#
|
|
35
|
+
# `-pr` is normalized before dry-cli parses it: dry-cli would otherwise read
|
|
36
|
+
# `-pr X` as `-p r` (bundled short flags). Maps to the real
|
|
37
|
+
# --paid-responsible option, supporting both `-pr X` and `-pr=X`.
|
|
38
|
+
def self.run(argv = ARGV)
|
|
39
|
+
$PROGRAM_NAME = "spltty"
|
|
40
|
+
normalized = argv.map do |arg|
|
|
41
|
+
if arg == "-pr"
|
|
42
|
+
"--paid-responsible"
|
|
43
|
+
elsif arg.start_with?("-pr=")
|
|
44
|
+
"--paid-responsible=#{arg[4..]}"
|
|
45
|
+
else
|
|
46
|
+
arg
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
Dry::CLI.new(CLI).call(arguments: normalized)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Load the config for the workspace this command is running against, honoring
|
|
53
|
+
# --accounts-dir / SPLTTY_ACCOUNTS_DIR. Resolution order for the config path:
|
|
54
|
+
#
|
|
55
|
+
# 1. --config / -C
|
|
56
|
+
# 2. SPLTTY_CONFIG
|
|
57
|
+
# 3. the nearest .spltty/config.json walking up from the cwd
|
|
58
|
+
#
|
|
59
|
+
# With none of those, there is no workspace here — every command except
|
|
60
|
+
# `install` errors out rather than silently inventing an empty config.
|
|
61
|
+
def self.load_config(opts)
|
|
62
|
+
path = opts[:config] || ENV["SPLTTY_CONFIG"]
|
|
63
|
+
path ||= Config.discover(Dir.pwd)
|
|
64
|
+
unless path
|
|
65
|
+
raise Config::Error,
|
|
66
|
+
"no spltty workspace found in #{Dir.pwd} or any parent directory — " \
|
|
67
|
+
"run `spltty install` to create one (or pass --config PATH)"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
config = Config.load(path)
|
|
71
|
+
override = opts[:accounts_dir] || ENV["SPLTTY_ACCOUNTS_DIR"]
|
|
72
|
+
config.accounts_dir = File.expand_path(override) if override && !override.empty?
|
|
73
|
+
config
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Single source of truth for commands; used both to register with dry-cli and
|
|
77
|
+
# to render `spltty help`.
|
|
78
|
+
COMMAND_SPECS = [
|
|
79
|
+
{ name: "install", klass: Commands::Install },
|
|
80
|
+
{ name: "add", klass: Commands::Add },
|
|
81
|
+
{ name: "list", klass: Commands::List },
|
|
82
|
+
{ name: "sync", klass: Commands::Sync },
|
|
83
|
+
{ name: "totals", klass: Commands::Totals },
|
|
84
|
+
{ name: "settle", klass: Commands::Settle },
|
|
85
|
+
{ name: "groups", klass: Commands::Groups },
|
|
86
|
+
{ name: "groups add", klass: Commands::GroupsAdd },
|
|
87
|
+
{ name: "groups rm", klass: Commands::GroupsRm },
|
|
88
|
+
{ name: "methods", klass: Commands::Methods },
|
|
89
|
+
{ name: "methods add", klass: Commands::MethodsAdd },
|
|
90
|
+
{ name: "help", klass: Commands::Help },
|
|
91
|
+
].freeze
|
|
92
|
+
|
|
93
|
+
module CLI
|
|
94
|
+
extend Dry::CLI::Registry
|
|
95
|
+
|
|
96
|
+
COMMAND_SPECS.each { |spec| register spec[:name], spec[:klass] }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# Shared Expense Tracker
|
|
2
|
+
|
|
3
|
+
Expense tracking for <%= participants_sentence %>. Inputs arrive as text, audio transcripts, images
|
|
4
|
+
or PDFs; outputs are markdown tables plus on-demand compilations. **Add entries and compute totals
|
|
5
|
+
through the `spltty` CLI (see [Tooling](#tooling--the-spltty-cli)) — do not hand-edit the ledger
|
|
6
|
+
tables to record spend, and do not tally by hand.**
|
|
7
|
+
|
|
8
|
+
## Participants
|
|
9
|
+
|
|
10
|
+
<%- participants.each do |p| -%>
|
|
11
|
+
- **<%= p %>**
|
|
12
|
+
<%- end -%>
|
|
13
|
+
|
|
14
|
+
Spell these names exactly as written above — they are matched literally in the `Paid By` and
|
|
15
|
+
`Responsible` columns.
|
|
16
|
+
|
|
17
|
+
## Ledgers
|
|
18
|
+
|
|
19
|
+
A **ledger** is one account being tracked. Two shapes exist:
|
|
20
|
+
|
|
21
|
+
- **Monthly** — a folder under `accounts/` holding one file per month (`YYYY-MM.md`) plus a single
|
|
22
|
+
`notes.md`. Use for ongoing, open-ended spend.
|
|
23
|
+
- **Single-file** — `accounts/<NAME>.ledger.md` plus `accounts/<NAME>.notes.md`. Use for a project
|
|
24
|
+
with a beginning and an end (a trip, a renovation, an event).
|
|
25
|
+
|
|
26
|
+
This workspace has:
|
|
27
|
+
|
|
28
|
+
| Ledger | Type | Default responsible | Entries | Notes |
|
|
29
|
+
|--------|------|---------------------|---------|-------|
|
|
30
|
+
<%- ledgers.each do |l| -%>
|
|
31
|
+
| `<%= l[:name] %>` | <%= l[:type] == "monthly" ? "monthly" : "single file" %><%= l[:schema] == "montreal" ? " · multi-currency" : "" %> | <%= l[:default_responsible] %> | `<%= l[:entries] %>` | `<%= l[:notes] %>` |
|
|
32
|
+
<%- end -%>
|
|
33
|
+
|
|
34
|
+
The default ledger (used when `-l` is omitted) is **`<%= default_ledger %>`**.
|
|
35
|
+
|
|
36
|
+
Each ledger's own rules and split groups live in its `*.notes.md` YAML header — that header is the
|
|
37
|
+
source of truth, reconciled into `.spltty/config.json` by `spltty sync`. Add ledgers by creating the
|
|
38
|
+
files (`spltty add -l NEWNAME` offers to scaffold them) — `spltty list` picks them up automatically.
|
|
39
|
+
|
|
40
|
+
**Reference ledgers:** if a ledger intentionally *duplicates* rows that already exist in another one
|
|
41
|
+
(e.g. a project tracker that mirrors shared spend so the project cost is visible in one place), its
|
|
42
|
+
rows are copies, not new spend. Never sum it together with the primary ledgers — that double-counts.
|
|
43
|
+
Mark it as such in its notes file and settle it on its own.
|
|
44
|
+
|
|
45
|
+
## Currency
|
|
46
|
+
|
|
47
|
+
- Values are stored as plain numbers with two decimals (e.g. `129.90`), no currency symbol inside
|
|
48
|
+
the cells. The value column is labelled `Value (R$)`.
|
|
49
|
+
- A **multi-currency** ledger adds `Orig. Value` + `Cur.` columns before `Value (R$)` to carry the
|
|
50
|
+
original foreign amount alongside the converted one. Record the FX rate you used in
|
|
51
|
+
`sources/INDEX.md`, not in the table.
|
|
52
|
+
|
|
53
|
+
## Directory layout
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
.
|
|
57
|
+
├── CLAUDE.md # this file
|
|
58
|
+
├── .spltty/config.json # CLI config: participants, groups, payment methods, ledger cache
|
|
59
|
+
├── accounts/
|
|
60
|
+
<%- ledgers.each_with_index do |l, i| -%>
|
|
61
|
+
<%- branch = i == ledgers.length - 1 ? "└──" : "├──" -%>
|
|
62
|
+
<%- if l[:type] == "monthly" -%>
|
|
63
|
+
│ <%= branch %> <%= l[:name] %>/<%= " " * [1, 14 - l[:name].length].max %># monthly ledger — YYYY-MM.md files + notes.md
|
|
64
|
+
<%- else -%>
|
|
65
|
+
│ <%= branch %> <%= l[:name] %>.ledger.md # single-file ledger (+ <%= l[:name] %>.notes.md)
|
|
66
|
+
<%- end -%>
|
|
67
|
+
<%- end -%>
|
|
68
|
+
└── sources/
|
|
69
|
+
├── INDEX.md # tracker of which source files have been processed
|
|
70
|
+
└── <pdf|img|txt|audio> # raw inputs dropped in
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Entries vs. notes.** An **entries** file holds *only* a title, a one-line pointer to its notes, and
|
|
74
|
+
the table — nothing else; rows are added with `spltty add`, never by hand. A **notes** file holds
|
|
75
|
+
*only forward-applicable rules* (split conventions, cross-posting rules, multi-currency handling).
|
|
76
|
+
Per-batch history (methodology, FX rates, exclusions, dedup) lives in `sources/INDEX.md`. Never
|
|
77
|
+
wedge prose between table rows.
|
|
78
|
+
|
|
79
|
+
## Expense table format
|
|
80
|
+
|
|
81
|
+
| Date | Title | Value (R$) | Paid By | Payment Method | Responsible | Source |
|
|
82
|
+
|------------|-------|-----------:|---------|----------------|-------------|--------|
|
|
83
|
+
|
|
84
|
+
- **Date**: `YYYY-MM-DD`. **For credit-card statement imports, use the date the card bill is paid**
|
|
85
|
+
(the bill day from *Payment method defaults*), not the per-transaction date in the statement —
|
|
86
|
+
every row from one bill shares that single date; keep the transaction date in the title if it
|
|
87
|
+
matters. For everything else, use the day it happened; if unknown, ask.
|
|
88
|
+
- **Title**: short description.
|
|
89
|
+
- **Value**: number with two decimals, right-aligned.
|
|
90
|
+
- **Paid By**: who actually fronted the money. This is *who paid*, independent of who bears the cost.
|
|
91
|
+
- **Payment Method**: where the money came from — a configured card, or a one-off like `cash`/`Pix`.
|
|
92
|
+
- **Responsible**: who bears the cost — either a **split-group name** or a **person's name**. At
|
|
93
|
+
totals time it resolves against split groups: the ledger's own header groups first, then the
|
|
94
|
+
global ones. A group divides the cost by its percentages; anything else falls 100% on that name.
|
|
95
|
+
- **Source**: filename in `sources/` when it came from a file; otherwise `text` or `audio`. Rows
|
|
96
|
+
written by `spltty settle` use `settle`.
|
|
97
|
+
|
|
98
|
+
### Split groups
|
|
99
|
+
|
|
100
|
+
<%- if groups.empty? -%>
|
|
101
|
+
No groups are defined yet. Create one with `spltty groups add NAME -s <%= example_split %> --global`
|
|
102
|
+
(or `-l LEDGER` to scope it to one ledger).
|
|
103
|
+
<%- else -%>
|
|
104
|
+
| Group | Split |
|
|
105
|
+
|-------|-------|
|
|
106
|
+
<%- groups.each do |name, hash| -%>
|
|
107
|
+
| `<%= name %>` | <%= hash.map { |p, pct| "#{p} #{pct}%" }.join(" / ") %> |
|
|
108
|
+
<%- end -%>
|
|
109
|
+
|
|
110
|
+
A ledger may define its own group with the same name in its notes header, which **shadows** the
|
|
111
|
+
global one — that is how one ledger can split `<%= groups.keys.first %>` differently from the rest.
|
|
112
|
+
<%- end -%>
|
|
113
|
+
|
|
114
|
+
### Payment method defaults
|
|
115
|
+
|
|
116
|
+
<%- if methods.empty? -%>
|
|
117
|
+
None configured. Add one with:
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
spltty methods add "Visa 1234" -s visa-1234 -p <%= participants.first %> -r <%= default_responsible %> -b 21
|
|
121
|
+
# -s slug -p default paid-by -r default responsible -b bill day (1-31 or "last")
|
|
122
|
+
```
|
|
123
|
+
<%- else -%>
|
|
124
|
+
| Payment Method | Default Paid By | Default Responsible | Bill payment day |
|
|
125
|
+
|----------------|-----------------|---------------------|------------------|
|
|
126
|
+
<%- methods.each do |name, cfg| -%>
|
|
127
|
+
| `<%= name %>` | <%= cfg["paid_by"] || "—" %> | <%= cfg["responsible"] || "—" %> | <%= cfg["bill_day"] == "last" ? "last day of the month" : (cfg["bill_day"] || "—") %> |
|
|
128
|
+
<%- end -%>
|
|
129
|
+
|
|
130
|
+
When one of these is named with `-m`, Paid By / Responsible / Date are pre-filled from this table.
|
|
131
|
+
The defaults are a starting point — override any of them per expense.
|
|
132
|
+
<%- end -%>
|
|
133
|
+
|
|
134
|
+
## Tooling — the `spltty` CLI
|
|
135
|
+
|
|
136
|
+
All entry input and all totals go through `spltty`. **Never hand-edit a ledger table to add spend,
|
|
137
|
+
and never compute a settlement by hand.** Run `spltty help` for the full reference.
|
|
138
|
+
|
|
139
|
+
**Add an expense** — title is positional, everything else is a flag. `-y` skips the confirm once you
|
|
140
|
+
have every field:
|
|
141
|
+
|
|
142
|
+
```sh
|
|
143
|
+
spltty add "Groceries" -l <%= default_ledger %> -v 14.20 -p <%= participants.first %> -m cash -r <%= default_responsible %> -y
|
|
144
|
+
spltty add "Coffee" -l <%= default_ledger %> -v 12.90 -m visa-1234 -y # Paid By/Responsible/Date from the card
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Flags: `-l` ledger · `-v` value · `-p` paid-by · `-m` payment method (name or slug) · `-r`
|
|
148
|
+
responsible (a group name or a person) · `--pr` set both paid-by & responsible · `-d` date
|
|
149
|
+
(defaults to today, or the card's bill day) · `-o`/`-c` original value/currency (multi-currency
|
|
150
|
+
ledgers) · `-s` source.
|
|
151
|
+
|
|
152
|
+
An unknown `-l` name is treated as a **typo, not a new account**: with `-y` the command fails and
|
|
153
|
+
lists the known ledgers. Never work around that by inventing a ledger — re-read the list and use
|
|
154
|
+
the right name, or confirm first and pass `--create-ledger` when a new one is genuinely wanted.
|
|
155
|
+
|
|
156
|
+
**Compute totals** — per-person paid/owed/net + settlement:
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
spltty totals # one report per ledger
|
|
160
|
+
spltty totals <%= default_ledger %> # only this one
|
|
161
|
+
spltty totals --combined # merge all non-reference ledgers into ONE settlement
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Record a settlement** — writes settlement rows (Paid By = debtor, Responsible = creditor) so the
|
|
165
|
+
payment offsets the debt in future `totals` runs:
|
|
166
|
+
|
|
167
|
+
```sh
|
|
168
|
+
spltty settle <%= default_ledger %> # cash mode: settle this ledger in full (Pix row)
|
|
169
|
+
spltty settle <%= default_ledger %> -v 500 # partial
|
|
170
|
+
spltty settle # offset mode across all non-reference ledgers
|
|
171
|
+
spltty settle -v 2000 # offsets + 2000 cash against the remainder
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**Other commands:** `spltty list` (ledgers) · `spltty sync` (notes headers ↔ config) ·
|
|
175
|
+
`spltty groups` / `groups add` / `groups rm` · `spltty methods` / `methods add`.
|
|
176
|
+
|
|
177
|
+
## Workflow
|
|
178
|
+
|
|
179
|
+
### Ingesting raw inputs
|
|
180
|
+
|
|
181
|
+
When a **source file** (PDF, image/screenshot, txt, audio transcript) or **raw expense text** is sent
|
|
182
|
+
straight to the prompt, take it as an instruction to ingest it — don't ask "what should I do with
|
|
183
|
+
this?". Read / OCR / transcribe it, interpret it, and organize it into structured rows before doing
|
|
184
|
+
anything else.
|
|
185
|
+
|
|
186
|
+
**Checkpoint the inferred work.** As soon as you've interpreted the input, write the structured
|
|
187
|
+
expenses to a temporary **`INGEST-WIP.md`** at the workspace root (one row per expense: extracted
|
|
188
|
+
fields + resolved-or-open ledger / paid-by / responsible + any open questions). It survives
|
|
189
|
+
interruptions and lets the rows be reviewed before they land. It's gitignored — delete it once every
|
|
190
|
+
row has been added.
|
|
191
|
+
|
|
192
|
+
Two rules govern how much to ask and how much to read:
|
|
193
|
+
|
|
194
|
+
- **Ask only for what's missing and not inferable.** If the **ledger**, **responsible** or
|
|
195
|
+
**paid-by** wasn't given and can't be inferred from the payment method, ask — batching the
|
|
196
|
+
questions for all pending expenses into one confirm. Never guess these.
|
|
197
|
+
- **Read frugally.** Reading a ledger for context (matching a prior categorization, dedup,
|
|
198
|
+
cross-posting) is fine when the task needs it. Don't read ledger files "just in case".
|
|
199
|
+
|
|
200
|
+
### Steps
|
|
201
|
+
|
|
202
|
+
1. **Receive** an expense (text, audio, image, PDF).
|
|
203
|
+
2. **Parse** title + value (+ date). For files, save the raw input under `sources/` and add a row to
|
|
204
|
+
`sources/INDEX.md`.
|
|
205
|
+
3. **Route** to a ledger — confirm which one when it isn't obvious. Default: `<%= default_ledger %>`.
|
|
206
|
+
4. **Confirm** before writing: (a) who paid + payment method, (b) responsibility — a group name or a
|
|
207
|
+
person. A configured card pre-fills both.
|
|
208
|
+
5. **Add** each confirmed expense with `spltty add … -y`. Do not hand-edit the table. Per-batch
|
|
209
|
+
methodology (FX, exclusions, dedup) goes in `sources/INDEX.md`.
|
|
210
|
+
6. **Update** `sources/INDEX.md` to mark the source processed, listing the titles extracted and the
|
|
211
|
+
ledger they landed in.
|
|
212
|
+
7. **Compile** on demand with `spltty totals` (`--combined` to merge ledgers).
|
|
213
|
+
|
|
214
|
+
## Source tracker (`sources/INDEX.md`)
|
|
215
|
+
|
|
216
|
+
Every file dropped into `sources/` must have a row with status `pending` or `processed`. When
|
|
217
|
+
processed, list the expense titles extracted so the trail is reversible. Never delete entries — mark
|
|
218
|
+
superseded files as `superseded` and link the replacement.
|
|
219
|
+
|
|
220
|
+
## Things to never do
|
|
221
|
+
|
|
222
|
+
- Never hand-edit a ledger table to record spend — add every expense with `spltty add`. Never
|
|
223
|
+
compute totals or a settlement by hand — use `spltty totals`. Never record a settlement payment
|
|
224
|
+
with `spltty add` or a hand edit — use `spltty settle`.
|
|
225
|
+
- Never guess which ledger an expense belongs to — confirm when it isn't obvious.
|
|
226
|
+
- Never guess who is responsible — always confirm.
|
|
227
|
+
- Never conflate **Paid By** (who fronted the money) with **Responsible** (who bears the cost).
|
|
228
|
+
- Never invent expense values from blurry or uncertain source content — ask for clarification.
|
|
229
|
+
- Never misspell a participant's name — they are matched literally.
|
data/templates/gitignore
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
|
|
2
|
+
# <%= name %> — notes
|
|
3
|
+
|
|
4
|
+
> Rules and conventions for the `<%= name %>` ledger. Entries live in <%= entries %> — this file
|
|
5
|
+
> holds **only forward-applicable rules**, never per-batch import history (that goes in
|
|
6
|
+
> `sources/INDEX.md`), and never table rows.
|
|
7
|
+
|
|
8
|
+
The YAML header above is read by the `spltty` CLI and is the **source of truth** for this ledger's
|
|
9
|
+
configuration — its default responsible party and its split groups. Edit it here and run
|
|
10
|
+
`spltty sync` (or any command that reads ledgers) to reconcile it into `.spltty/config.json`.
|
|
11
|
+
|
|
12
|
+
## Split
|
|
13
|
+
|
|
14
|
+
- Default responsible: **<%= default_responsible %>**<% if group_line %> — <%= group_line %><% end %>.
|
|
15
|
+
- A group defined in the header above **shadows** the global group of the same name, so this ledger
|
|
16
|
+
can split differently from the rest of the workspace.
|
|
17
|
+
- Override per row at entry time with `spltty add -r <group-or-person>`.
|
|
18
|
+
<% if multi_currency -%>
|
|
19
|
+
|
|
20
|
+
## Currency
|
|
21
|
+
|
|
22
|
+
- Multi-currency ledger: rows carry `Orig. Value` + `Cur.` (default `<%= default_currency %>`)
|
|
23
|
+
alongside the converted `Value (R$)`.
|
|
24
|
+
- Record the FX rate and how it was derived in `sources/INDEX.md` for each batch — not here.
|
|
25
|
+
<% end -%>
|
|
26
|
+
|
|
27
|
+
## Conventions
|
|
28
|
+
|
|
29
|
+
<!-- Add rules that apply to every future entry in this ledger, e.g.:
|
|
30
|
+
- which merchants always route here
|
|
31
|
+
- recurring installments and how they are titled
|
|
32
|
+
- anything a future import must not get wrong
|
|
33
|
+
-->
|
|
34
|
+
|
|
35
|
+
<!-- Reference ledger? If this ledger intentionally duplicates rows that already exist in
|
|
36
|
+
another one (a project tracker mirroring shared spend, say), add `reference: true` to the
|
|
37
|
+
`spltty:` header above. Reference ledgers are flagged in `spltty totals`, excluded from
|
|
38
|
+
`--combined`, and never join a cross-ledger `spltty settle` — summing them with the
|
|
39
|
+
primary ledgers would double-count. -->
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ingest
|
|
3
|
+
description: Ingest a raw source file (PDF, image/screenshot, txt, audio transcript) or pasted expense text into the ledger — read/OCR/interpret it, organize the expenses, and add them with the spltty CLI. Use whenever raw expense inputs are dropped into the prompt for this expense tracker.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Ingest raw inputs into the ledger
|
|
7
|
+
|
|
8
|
+
Turn a raw input — a source file (PDF, image/screenshot, `.txt`, audio transcript) or expense text
|
|
9
|
+
pasted straight into the prompt — into ledger rows. This is the ingestion path of the workflow in
|
|
10
|
+
`CLAUDE.md`; that file is the source of truth for the table schema, payment-method defaults, split
|
|
11
|
+
groups and routing. Read it if you need those details.
|
|
12
|
+
|
|
13
|
+
## When to use
|
|
14
|
+
|
|
15
|
+
A source file or raw expense text was sent to the prompt. Treat its arrival as "ingest this" — do not
|
|
16
|
+
ask what to do with it. If instead the ask was for totals or a settlement, that's `spltty totals` /
|
|
17
|
+
`spltty settle`, not this skill.
|
|
18
|
+
|
|
19
|
+
## Do
|
|
20
|
+
|
|
21
|
+
1. **Read & interpret the input yourself.** OCR images/screenshots, read PDFs (some are
|
|
22
|
+
password-protected — the password is usually given), transcribe audio. Extract each expense:
|
|
23
|
+
title, value (two decimals), and date if present. Never invent a value from blurry or uncertain
|
|
24
|
+
content — ask about that line.
|
|
25
|
+
2. **Checkpoint the inferred work.** As soon as you've interpreted the input, write the structured
|
|
26
|
+
expenses to **`INGEST-WIP.md`** at the workspace root — one row per expense with the extracted
|
|
27
|
+
fields plus the resolved (or still-open) ledger / paid-by / responsible and any open questions.
|
|
28
|
+
This preserves work across interruptions and allows review before it lands. It's gitignored.
|
|
29
|
+
Delete it once every row has been added with `spltty add`.
|
|
30
|
+
3. **Save file inputs.** If the input is a file (not chat text), make sure it lives under `sources/`
|
|
31
|
+
and gets a `sources/INDEX.md` row (`pending` now, `processed` at the end).
|
|
32
|
+
4. **Resolve the three routing fields** for each expense — **ledger**, **paid-by**, **responsible**:
|
|
33
|
+
- If the prompt states them, use them.
|
|
34
|
+
- Else if the **payment method** matches a configured card, pre-fill paid-by / responsible / date
|
|
35
|
+
from it (`spltty methods` lists them).
|
|
36
|
+
- Else **ask** — one batched confirm for all pending expenses. Never guess these three.
|
|
37
|
+
5. **Read for context only when needed.** Reading a ledger's entries/notes is fine to match a prior
|
|
38
|
+
categorization, dedup against an existing bill, or copy a split convention — but only when the
|
|
39
|
+
task genuinely requires it. Don't read files speculatively.
|
|
40
|
+
6. **Add each expense with `spltty add`** — never hand-edit a ledger table:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
spltty add "<title>" -l <LEDGER> -v <value> -p <paid-by> -m "<method>" -r <responsible> -y
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- Card entries can omit `-p`/`-r`/`-d` (defaulted from the card).
|
|
47
|
+
- Multi-currency ledgers take `-o <orig-value> -c <CUR>`.
|
|
48
|
+
- Set `-s <source-filename>` when the row came from a file in `sources/`.
|
|
49
|
+
- If it fails with *unknown ledger*, that is a typo guard, not a missing feature: use one of
|
|
50
|
+
the ledgers it lists. Only pass `--create-ledger` after confirming a new account is wanted.
|
|
51
|
+
7. **Record per-batch methodology** (FX rates, exclusions, dedup, subtotals) in `sources/INDEX.md` —
|
|
52
|
+
not in the `*.notes.md` files, which hold only forward-applicable rules. Update the source's row
|
|
53
|
+
to `processed` with the extracted titles and the ledger(s) they landed in.
|
|
54
|
+
|
|
55
|
+
## Prefer the CLI
|
|
56
|
+
|
|
57
|
+
If the operation has a `spltty` command (`add`, `totals`, `settle`, `list`, `sync`, `groups`,
|
|
58
|
+
`methods`), use it rather than doing the work by hand — unless explicitly told otherwise.
|
|
59
|
+
|
|
60
|
+
## Never
|
|
61
|
+
|
|
62
|
+
- Never guess ledger, paid-by, or responsible — confirm when not inferable.
|
|
63
|
+
- Never conflate **Paid By** (who fronted the money) with **Responsible** (who bears the cost).
|
|
64
|
+
- Never hand-edit a ledger table to record spend, or tally totals by hand.
|
|
65
|
+
- Never invent values from uncertain source content.
|
|
66
|
+
- Never misspell a participant's name — they are matched literally.
|