Skip to content

feat: billable expenses, flexible invoice periods, and TUI log/render revamp - #14

Merged
0x054 merged 42 commits into
chore/ferro-0-12-migrationfrom
feat/billable-expenses
Jul 1, 2026
Merged

feat: billable expenses, flexible invoice periods, and TUI log/render revamp#14
0x054 merged 42 commits into
chore/ferro-0-12-migrationfrom
feat/billable-expenses

Conversation

@0x054

@0x054 0x054 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

This branch grew well beyond its original "billable expenses" scope. It now bundles four related pieces of invoicing/expense work. Base is chore/ferro-0-12-migration (the feature depends on the ferro-orm 0.12 migration).

1. Billable expenses (client chargebacks)

Record purchased items against a project and bill them back as untaxed, pass-through line items on invoices, with optional receipts.

  • New Expense / ExpenseReceipt / InvoiceExpenseLine models (plain-id FK convention; receipts stored base64 in a side table because raw bytes can't round-trip through the ORM — ferro-orm#160).
  • ttd expense CLI: add / list / edit / rm + a receipt group; history-based recall.
  • Invoicing lifecycle: expenses lock on invoice, release on void, and participate in refresh. Tax applies only to the time subtotal; total = subtotal + tax + expenses_subtotal. Invoices with no expenses render byte-identically to before.
  • Rendering: PDF/markdown expense section; opt-in receipt pages merged into the PDF via pypdf; invoice format choice (default PDF) with markdown blocked when receipts are included.
  • JSON backup (envelope v2) round-trips expenses + receipts (incl. non-UTF-8 binary); v1 backups still import.

2. TUI "log" page (re-scoped timesheet)

The underused timesheet screen became a month-scoped log page (nav 2) with stacked time and expense sections: [/] cycle months, tab switches section, e/x edit/delete the focused row, l adds (time or expense via a chooser). Expenses also show in the new-invoice draft preview.

3. Flexible invoice periods

Richer --period parsing (invoice CLI + TUI): this week/last week, rolling last N days/weeks/months, and month-name ranges (june 16 to june 30, jun, june, june 16 - 30) with a closest-year-never-future rule and cross-year wrap. Separately, an invoice's recorded period now derives from the billed items (min–max of line dates), not the requested window — so a mid-month re-invoice reads the true span. Refresh re-derives.

4. TUI invoice render format modal

e on the invoices screen now opens a render chooser (checkboxes: Include receipts / PDF / Markdown): receipts embed into the PDF, the receipts option is disabled unless the invoice has receipts, and enabling it locks out markdown. A shared load_invoice_receipts helper backs both the CLI and TUI.

Testing

Full suite green throughout: 397 passing, 84.59% coverage, ty + ruff clean. Built brainstorm → spec → plan → subagent implementation with per-task review and an opus whole-branch review per feature. Specs/plans under docs/superpowers/.

Known follow-ups (not in this PR)

Related issues

🤖 Generated with Claude Code

0x054 and others added 30 commits June 30, 2026 13:28
Approved design for tracking purchased items per project and billing them
back to clients on invoices.

Scope A (now): Expense/ExpenseReceipt/InvoiceExpenseLine models, expense
CRUD + recall service, `ttd expense` CLI, invoicing lifecycle with untaxed
expense totals, PDF receipt pages (opt-in via --receipts), explicit invoice
format choice (default PDF, markdown disabled when receipts present), and
JSON-backup round-trip. Reports and full CSV/XLSX interchange deferred.

Receipts stored base64 in a side table due to ferro-orm#160 (Model.save
can't persist binary bytes). Plain-id FK convention retained; relationship
migration tracked in ttd#13.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task-by-task TDD plan (11 tasks) for the approved billable-expenses design:
data model, expense service + receipts, CLI, invoicing lifecycle with
untaxed totals, PDF/markdown rendering, opt-in receipt pages, invoice
format choice with markdown gating, JSON backup v2, and TUI.

Grounded in the real codebase: export_records/meta export path, the
EntryRecord-only importer (expenses get a dedicated restore function),
and the pypdf-based receipt merge with receipts loaded by the CLI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Harden list_expenses() against KeyError when a project references a
client not in the client map. Changed client_slug filter to use .get()
with walrus operator, and added guard in row-building loop to skip
expenses whose project has a missing client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…refresh diff, currency)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pressing l now opens a two-item picker ("time" / "expense"). Choosing
"time" runs the existing QuickLogModal flow unchanged; choosing "expense"
pushes a FormModal with project, description, amount, and date fields.

- expenses.add_expense gains client_slug keyword param for disambiguation
- tui/_data.py: add add_expense_entry(payload) helper mirroring split_and_log
- tui/screens/_base.py: action_quick_log → PickerModal; _open_time_log and
  _open_expense_form workers; pure validators _validate_amount/_validate_date
- tests: add_expense_entry with/without date, validator unit tests,
  client_slug disambiguation test, update test_quick_log_modal_live_preview

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Broaden exception handling in _open_expense_form to catch all exceptions
  (KeyError, decimal.InvalidOperation, ValueError, etc.), not just TtdError,
  so any form error surfaces to the user instead of failing silently.
- Swap test names in test_expense_data.py to match their payloads:
  test_add_expense_entry_missing_date_key_defaults_to_today now tests
  the case with no "date" key; test_add_expense_entry_blank_date_defaults_to_today
  now tests "date": "" (empty string).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add expense rows to the NewInvoiceModal draft DataTable so the visible
line items match what the total includes. A divider row marks the
expense section, and the status line now reports expense count alongside
the bold total. Mirrors what the CLI's _print_draft already shows.

Add pilot test asserting the expense description, amount, and section
marker appear in the table and that the status mentions "expense".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Re-scope the underused timesheet screen into a unified "log" page that
views/adds/edits/deletes both time entries and expenses: two stacked
sections (time, then expenses), month-only window cycled with [/],
focus-based e/x editing, adding via the existing l chooser. Preserves
time-entry editing while giving expenses a browse/manage home, no 7th
nav item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3-task TDD plan to re-scope the timesheet screen into a month-only "log"
page with stacked time + expense sections: Task 1 rename/month-only,
Task 2 expenses display, Task 3 focus switching + expense edit/delete.
Reuses entry_svc/expense_svc, FormModal, ConfirmModal, and the existing
l chooser for adding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add early invoiced check in _delete_expense() to match _edit_expense() pattern
- Show warning and return before pushing ConfirmModal if expense is invoiced
- Add precise type annotation to kwargs in expense edit: dict[str, object]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Fix 1: replace vacuous month-nav disjunction with deterministic anchor_date assertion
- Fix 2: add TUI regression test for invoiced-expense edit guard
- Fix 3: comment single-currency assumption at expenses footer total line
- Fix 4: kwargs: dict[str,object] in _edit_entry_row; simplify expense-section cell check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two related invoice-period improvements: (1) richer period parsing in
reporting/periods.py — relative durations (this/last week, rolling
"last N days/weeks/months") and month-name ranges ("june 16 to june 30")
with a closest-year (never-future) inference rule and cross-year wrap;
(2) record the invoice's actual period derived from the billed line
dates instead of the requested window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0x054 and others added 12 commits July 1, 2026 08:17
3-task TDD plan: Task 1 relative durations (this/last week + rolling
last N days/weeks/months), Task 2 month-name ranges with closest-year
inference + cross-year wrap + help text, Task 3 derive the invoice
period from billed line dates (build_draft + apply_refresh). All parsing
stays in reporting/periods.py; no new dependency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- distinguish unrecognized count words from invalid digits in _parse_relative
- add clarifying comments on period derivation in build_draft and apply_refresh
- strengthen period tests with end-date assertions

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring the TUI invoice render step to parity with the CLI: a bespoke
RenderFormatModal (PDF/Markdown/Receipts switches) with the receipts
switch disabled unless the invoice has receipts, and receipts-on locking
out markdown. Rewire `e` (action_render_files) to use it and pass
receipts to render_pdf; extract a shared receipt-loader used by both CLI
and TUI. Fixes: receipts not embedded, no format prompt, no md gating.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2-task plan: Task 1 extracts a shared load_invoice_receipts helper (CLI +
TUI, DRY); Task 2 adds RenderFormatModal (PDF/Markdown/Receipts switches
with receipts-disabled-unless-present and receipts-locks-markdown) and
rewires the TUI `e` render action to prompt for format and embed receipts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add pilot test for RenderFormatModal validation: both format switches off -> render button -> error displayed, modal stays open
- Restore receipt count to file names in notify message (e.g., '+1 receipt')
- Type load_invoice_receipts parameter as list[InvoiceExpenseLine]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@0x054 0x054 changed the title feat: billable expenses (client chargebacks) feat: billable expenses, flexible invoice periods, and TUI log/render revamp Jul 1, 2026
@0x054
0x054 merged commit 9ee9202 into chore/ferro-0-12-migration Jul 1, 2026
6 checks passed
@0x054
0x054 deleted the feat/billable-expenses branch July 1, 2026 16:10
0x054 added a commit that referenced this pull request Jul 7, 2026
… revamp (#14)

This branch grew well beyond its original "billable expenses" scope. It
now bundles four related pieces of invoicing/expense work. Base is
`chore/ferro-0-12-migration` (the feature depends on the ferro-orm 0.12
migration).

## 1. Billable expenses (client chargebacks)
Record purchased items against a project and bill them back as
**untaxed, pass-through** line items on invoices, with optional
receipts.
- New `Expense` / `ExpenseReceipt` / `InvoiceExpenseLine` models
(plain-id FK convention; receipts stored base64 in a side table because
raw bytes can't round-trip through the ORM — ferro-orm#160).
- `ttd expense` CLI: `add` / `list` / `edit` / `rm` + a `receipt` group;
history-based recall.
- Invoicing lifecycle: expenses lock on invoice, release on void, and
participate in refresh. Tax applies only to the time subtotal; `total =
subtotal + tax + expenses_subtotal`. Invoices with no expenses render
byte-identically to before.
- Rendering: PDF/markdown expense section; **opt-in receipt pages**
merged into the PDF via `pypdf`; invoice format choice (default PDF)
with markdown blocked when receipts are included.
- JSON backup (envelope v2) round-trips expenses + receipts (incl.
non-UTF-8 binary); v1 backups still import.

## 2. TUI "log" page (re-scoped timesheet)
The underused timesheet screen became a month-scoped **log** page (nav
2) with stacked **time** and **expense** sections: `[`/`]` cycle months,
`tab` switches section, `e`/`x` edit/delete the focused row, `l` adds
(time or expense via a chooser). Expenses also show in the new-invoice
draft preview.

## 3. Flexible invoice periods
Richer `--period` parsing (invoice CLI + TUI): `this week`/`last week`,
rolling `last N days/weeks/months`, and month-name ranges (`june 16 to
june 30`, `jun`, `june`, `june 16 - 30`) with a
closest-year-never-future rule and cross-year wrap. Separately, an
invoice's recorded period now **derives from the billed items** (min–max
of line dates), not the requested window — so a mid-month re-invoice
reads the true span. Refresh re-derives.

## 4. TUI invoice render format modal
`e` on the invoices screen now opens a **render chooser** (checkboxes:
Include receipts / PDF / Markdown): receipts embed into the PDF, the
receipts option is disabled unless the invoice has receipts, and
enabling it locks out markdown. A shared `load_invoice_receipts` helper
backs both the CLI and TUI.

## Testing
Full suite green throughout: **397 passing, 84.59% coverage, `ty` +
`ruff` clean.** Built brainstorm → spec → plan → subagent implementation
with per-task review and an opus whole-branch review per feature.
Specs/plans under `docs/superpowers/`.

## Known follow-ups (not in this PR)
- TUI: attach a receipt when adding an expense (drag-and-drop path into
the form) — designed-but-deferred to a future session.
- `invoice_has_receipts` / some services use `.all()`+filter scans
(codebase convention; tied to the relationship migration in #13).
- Multi-currency expense-total footer on the log page (edge case).

## Related issues
- ferro-orm#160 (raw `bytes` can't be saved via the ORM — drove the
base64 receipt design).
- #13 (migrate models to Ferro relationships + cascades).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant