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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SplttyCLI
4
+ VERSION = "0.1.0"
5
+ 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.
@@ -0,0 +1,4 @@
1
+ .DS_Store
2
+
3
+ # Temporary work-in-progress checkpoint written during ingestion
4
+ INGEST-WIP.md
@@ -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.