feat: billable expenses, flexible invoice periods, and TUI log/render revamp - #14
Merged
Merged
Conversation
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>
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Expense/ExpenseReceipt/InvoiceExpenseLinemodels (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 expenseCLI:add/list/edit/rm+ areceiptgroup; history-based recall.total = subtotal + tax + expenses_subtotal. Invoices with no expenses render byte-identically to before.pypdf; invoice format choice (default PDF) with markdown blocked when receipts are included.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,tabswitches section,e/xedit/delete the focused row,ladds (time or expense via a chooser). Expenses also show in the new-invoice draft preview.3. Flexible invoice periods
Richer
--periodparsing (invoice CLI + TUI):this week/last week, rollinglast 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
eon 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 sharedload_invoice_receiptshelper backs both the CLI and TUI.Testing
Full suite green throughout: 397 passing, 84.59% coverage,
ty+ruffclean. Built brainstorm → spec → plan → subagent implementation with per-task review and an opus whole-branch review per feature. Specs/plans underdocs/superpowers/.Known follow-ups (not in this PR)
invoice_has_receipts/ some services use.all()+filter scans (codebase convention; tied to the relationship migration in Migrate models to Ferro relationships + cascade definitions (retire manual service-layer cascades) #13).Related issues
bytescan't be saved via the ORM — drove the base64 receipt design).🤖 Generated with Claude Code