From 6d0b9d7a766e10831c9387abff2ab6d356554524 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 17 Jun 2026 10:39:07 -0400 Subject: [PATCH 1/3] feat(invoices): show per-invoice tax set-aside and take-home estimates Add estimate_invoice() for frozen paid snapshots vs current-rate previews, and surface est. tax / take-home in CLI list/show and TUI list/detail when a tax rate is configured. Co-authored-by: Cursor --- src/ttd/cli/invoices.py | 42 ++++++++++++++++++----- src/ttd/services/taxes.py | 36 ++++++++++++++++++++ src/ttd/tui/screens/invoices.py | 50 +++++++++++++++++++++++----- tests/test_cli/test_invoice_cli.py | 30 +++++++++++++++++ tests/test_taxes/test_tax_service.py | 31 +++++++++++++++++ tests/test_tui/test_app.py | 47 ++++++++++++++++++++++++++ 6 files changed, 218 insertions(+), 18 deletions(-) diff --git a/src/ttd/cli/invoices.py b/src/ttd/cli/invoices.py index e959593..04f9e47 100644 --- a/src/ttd/cli/invoices.py +++ b/src/ttd/cli/invoices.py @@ -20,7 +20,7 @@ from ttd.reporting import periods from ttd.services import invoicing as svc from ttd.services import taxes as taxes_svc -from ttd.storage.models import enum_value +from ttd.storage.models import Invoice, enum_value app = TtdApp(name="invoice", help="Create and manage invoices.") @@ -31,6 +31,19 @@ def _status_pill(status: str) -> str: return f"[{STATUS_STYLE.get(status, 'muted')}]{status}[/]" +def _estimate_cells(invoice: Invoice, estimate: taxes_svc.InvoiceEstimate | None) -> list[str]: + """``Est. Tax`` and ``Take-Home`` cells; unpaid previews render muted.""" + if estimate is None: + return ["[muted]—[/muted]", "[muted]—[/muted]"] + cells = [ + format_money(estimate.set_aside, invoice.currency), + format_money(estimate.take_home, invoice.currency), + ] + if enum_value(invoice.status) != "paid": # not frozen yet — current-rate preview + cells = [f"[muted]{cell}[/muted]" for cell in cells] + return cells + + def _parse_date(raw: str, what: str) -> date: try: return date.fromisoformat(raw) @@ -89,7 +102,8 @@ def _print_draft(draft: svc.Draft) -> None: preview = compute_set_aside(draft.subtotal, rate) console.print( f"[muted]Set aside at {format_rate(rate)} when paid: " - f"{format_money(preview, currency)}[/muted]" + f"{format_money(preview, currency)} · take-home " + f"{format_money(draft.subtotal - preview, currency)}[/muted]" ) @@ -171,15 +185,23 @@ async def list_() -> None: if not rows: console.print("[muted]No invoices yet — `ttd invoice create --client SLUG`[/muted]") return - t = table("Number", "Client", "Period", "Total", "Status") - for invoice, client in rows: - t.add_row( + rate = get_settings().tax.set_aside_rate + estimates = [taxes_svc.estimate_invoice(invoice, rate) for invoice, _ in rows] + show_tax = any(e is not None for e in estimates) + headers = ["Number", "Client", "Period", "Total"] + if show_tax: + headers += ["Est. Tax", "Take-Home"] + t = table(*headers, "Status") + for (invoice, client), estimate in zip(rows, estimates, strict=True): + row = [ invoice.number, client.slug, f"{invoice.period_start:%b %-d} – {invoice.period_end:%b %-d %Y}", format_money(invoice.total, invoice.currency), - _status_pill(enum_value(invoice.status)), - ) + ] + if show_tax: + row += _estimate_cells(invoice, estimate) + t.add_row(*row, _status_pill(enum_value(invoice.status))) console.print(t) @@ -212,13 +234,15 @@ async def show(number: str) -> None: if set_aside: console.print( f"Set aside ({format_rate(rate)}): " - f"{format_money(set_aside, invoice.currency)} · paid {paid_on}" + f"{format_money(set_aside, invoice.currency)} · take-home " + f"{format_money(invoice.subtotal - set_aside, invoice.currency)} · paid {paid_on}" ) elif settings.tax.set_aside_rate > 0 and enum_value(invoice.status) != "void": preview = compute_set_aside(invoice.subtotal, settings.tax.set_aside_rate) console.print( f"[muted]Set aside at {format_rate(settings.tax.set_aside_rate)} when paid: " - f"{format_money(preview, invoice.currency)}[/muted]" + f"{format_money(preview, invoice.currency)} · take-home " + f"{format_money(invoice.subtotal - preview, invoice.currency)}[/muted]" ) diff --git a/src/ttd/services/taxes.py b/src/ttd/services/taxes.py index 47ec87c..dd11de3 100644 --- a/src/ttd/services/taxes.py +++ b/src/ttd/services/taxes.py @@ -27,6 +27,42 @@ class QuarterSummary: payment_count: int +@dataclass +class InvoiceEstimate: + """Estimated tax and take-home for one invoice. + + Both are based on the subtotal — invoice sales tax is pass-through money, + not income. + """ + + set_aside: Decimal + take_home: Decimal # subtotal - set_aside + + +def estimate_invoice(invoice: Invoice, fallback_rate: Decimal) -> InvoiceEstimate | None: + """Per-invoice set-aside estimate, or ``None`` when there is nothing to show. + + Paid invoices use their frozen snapshot; everything else previews at the + current configured rate. Void invoices and 0% rates yield ``None`` — for + those rows the feature is off, not zero. + """ + status = enum_value(invoice.status) + if status == InvoiceStatus.VOID.value: + return None + if ( + status == InvoiceStatus.PAID.value + and invoice.set_aside is not None + and invoice.set_aside_rate is not None + ): + if invoice.set_aside_rate == 0: + return None + return InvoiceEstimate(invoice.set_aside, invoice.subtotal - invoice.set_aside) + if fallback_rate <= 0: + return None + set_aside = compute_set_aside(invoice.subtotal, fallback_rate) + return InvoiceEstimate(set_aside, invoice.subtotal - set_aside) + + def paid_facts(invoice: Invoice, fallback_rate: Decimal) -> tuple[date, Decimal, Decimal]: """``(paid_date, rate, set_aside)`` for a paid invoice. diff --git a/src/ttd/tui/screens/invoices.py b/src/ttd/tui/screens/invoices.py index ee64294..58a0a70 100644 --- a/src/ttd/tui/screens/invoices.py +++ b/src/ttd/tui/screens/invoices.py @@ -17,13 +17,27 @@ from ttd.invoicing.pdf import render_pdf from ttd.reporting import periods from ttd.services import invoicing as svc -from ttd.storage.models import Client, enum_value +from ttd.services import taxes as taxes_svc +from ttd.storage.models import Client, Invoice, enum_value from ttd.tui.screens._base import TtdScreen from ttd.tui.widgets.modals import ConfirmModal, PickerModal PILL = {"draft": "dim", "sent": "#ffcf5c", "paid": "#3fcf8e", "void": "#ff5c5c"} +def _estimate_cells(invoice: Invoice, estimate: taxes_svc.InvoiceEstimate | None) -> list[str]: + """``est. tax`` and ``take-home`` cells; unpaid previews render dim.""" + if estimate is None: + return ["[dim]—[/dim]", "[dim]—[/dim]"] + cells = [ + format_money(estimate.set_aside, invoice.currency), + format_money(estimate.take_home, invoice.currency), + ] + if enum_value(invoice.status) != "paid": # not frozen yet — current-rate preview + cells = [f"[dim]{cell}[/dim]" for cell in cells] + return cells + + class InvoiceDetailModal(ModalScreen[None]): BINDINGS: ClassVar = [("escape", "dismiss", "close")] @@ -51,10 +65,17 @@ def compose(self) -> ComposeResult: format_money(line.amount, invoice.currency), ) yield table - yield Label( + summary = ( f"issued {invoice.issued_date} · due {invoice.due_date or 'on receipt'} · " f"[bold]{format_money(invoice.total, invoice.currency)}[/bold]" ) + estimate = taxes_svc.estimate_invoice(invoice, get_settings().tax.set_aside_rate) + if estimate is not None: + summary += ( + f" · est. tax {format_money(estimate.set_aside, invoice.currency)}" + f" · take-home {format_money(estimate.take_home, invoice.currency)}" + ) + yield Label(summary) yield Button("close (esc)", id="close") def on_button_pressed(self) -> None: @@ -197,23 +218,34 @@ def compose_content(self) -> ComposeResult: yield Label("", id="invoice-help", classes="muted") def setup(self) -> None: - table = self.query_one("#invoice-table", DataTable) - table.add_columns("number", "client", "period", "total", "status") + self._table_columns: tuple[str, ...] = () async def render_data(self) -> None: rows = await svc.list_invoices() + rate = get_settings().tax.set_aside_rate + estimates = [taxes_svc.estimate_invoice(invoice, rate) for invoice, _ in rows] + + columns = ("number", "client", "period", "total", "status") + if any(e is not None for e in estimates): + columns = ("number", "client", "period", "total", "est. tax", "take-home", "status") table = self.query_one("#invoice-table", DataTable) + if columns != self._table_columns: + table.clear(columns=True) + table.add_columns(*columns) + self._table_columns = columns table.clear() - for invoice, client in rows: + for (invoice, client), estimate in zip(rows, estimates, strict=True): status = enum_value(invoice.status) - table.add_row( + row = [ invoice.number, client.slug, f"{invoice.period_start:%b %-d} – {invoice.period_end:%b %-d %Y}", format_money(invoice.total, invoice.currency), - f"[{PILL.get(status, 'dim')}]{status}[/]", - key=invoice.number, - ) + ] + if "est. tax" in columns: + row += _estimate_cells(invoice, estimate) + row.append(f"[{PILL.get(status, 'dim')}]{status}[/]") + table.add_row(*row, key=invoice.number) self.query_one("#invoice-help", Label).update( f"{len(rows)} invoice{'s' if len(rows) != 1 else ''} " "[dim]n new · o detail · m preview md · e render · t sent · p paid · v void[/dim]" diff --git a/tests/test_cli/test_invoice_cli.py b/tests/test_cli/test_invoice_cli.py index 91efd0d..d7bcd9b 100644 --- a/tests/test_cli/test_invoice_cli.py +++ b/tests/test_cli/test_invoice_cli.py @@ -58,6 +58,36 @@ def test_invoice_lifecycle_via_cli(isolated_config): assert bad.exit_code == 1 +def test_invoice_tax_columns_with_rate(isolated_config): + _seed(isolated_config) + runner.invoke(app, ["config", "set", "tax.set_aside_rate", "0.32"]) + runner.invoke(app, ["invoice", "create", "--client", "acme", "--month", "2026-06"]) + + listing = runner.invoke(app, ["invoice", "list"]) + assert listing.exit_code == 0, listing.output + assert "Est. Tax" in listing.output + assert "192.00" in listing.output # 32% of $600 + assert "408.00" in listing.output # take-home + + show = runner.invoke(app, ["invoice", "show", "2026-001"]) + assert "take-home" in show.output + assert "408.00" in show.output + + runner.invoke(app, ["invoice", "mark", "2026-001", "paid"]) + show = runner.invoke(app, ["invoice", "show", "2026-001"]) + assert "take-home" in show.output + assert "paid" in show.output + + +def test_invoice_list_hides_tax_columns_without_rate(isolated_config): + _seed(isolated_config) + runner.invoke(app, ["invoice", "create", "--client", "acme", "--month", "2026-06"]) + listing = runner.invoke(app, ["invoice", "list"]) + assert listing.exit_code == 0, listing.output + assert "Est. Tax" not in listing.output + assert "Take-Home" not in listing.output + + def test_invoiced_entries_locked_via_cli(isolated_config): _seed(isolated_config) runner.invoke(app, ["invoice", "create", "--client", "acme", "--month", "2026-06"]) diff --git a/tests/test_taxes/test_tax_service.py b/tests/test_taxes/test_tax_service.py index 0d4cf48..b40ae02 100644 --- a/tests/test_taxes/test_tax_service.py +++ b/tests/test_taxes/test_tax_service.py @@ -100,6 +100,37 @@ async def test_void_clears_snapshot_and_drops_from_summary(invoice): assert all(s.invoice_count == 0 for s in summaries) +# --- per-invoice estimates ------------------------------------------------------ + + +async def test_estimate_unpaid_previews_at_current_rate(invoice): + estimate = svc.estimate_invoice(invoice, Decimal("0.32")) + assert estimate.set_aside == Decimal("288.00") + assert estimate.take_home == Decimal("612.00") + + +async def test_estimate_paid_uses_frozen_snapshot(invoice): + marked = await invoice_svc.mark_invoice( + invoice.number, "paid", paid_date=date(2026, 5, 10), set_aside_rate=Decimal("0.32") + ) + # the configured rate changed later — the snapshot must not move + estimate = svc.estimate_invoice(marked, Decimal("0.25")) + assert estimate.set_aside == Decimal("288.00") + assert estimate.take_home == Decimal("612.00") + + +async def test_estimate_none_when_feature_off(invoice): + assert svc.estimate_invoice(invoice, Decimal("0")) is None + # paid while the rate was 0: the 0% snapshot also means "nothing to show" + marked = await invoice_svc.mark_invoice(invoice.number, "paid", set_aside_rate=Decimal("0")) + assert svc.estimate_invoice(marked, Decimal("0")) is None + + +async def test_estimate_none_for_void(invoice): + voided = await invoice_svc.mark_invoice(invoice.number, "void") + assert svc.estimate_invoice(voided, Decimal("0.32")) is None + + # --- pre-feature fallback ----------------------------------------------------- diff --git a/tests/test_tui/test_app.py b/tests/test_tui/test_app.py index beeea81..2ac3f63 100644 --- a/tests/test_tui/test_app.py +++ b/tests/test_tui/test_app.py @@ -242,6 +242,53 @@ async def test_invoice_detail_modal(seeded_app, monkeypatch): await pilot.pause() +async def test_invoices_screen_tax_columns(seeded_app, monkeypatch): + # persist an invoice directly, then view the list with a rate configured + from datetime import date + from datetime import timedelta as td + + from ttd.config.schema import Settings as S + from ttd.reporting.periods import range_period + + await init_db() + period = range_period(date.today() - td(days=30), date.today()) + settings = S(business={"default_hourly_rate": 100}) + draft = await invoice_svc.build_draft("acme-corp", period, settings) + await invoice_svc.persist_draft(draft, settings) + await close_db() + + monkeypatch.setenv("TTD_TAX__SET_ASIDE_RATE", "0.32") + async with seeded_app.run_test(size=(140, 40)) as pilot: + await pilot.press("5") + await pilot.pause() + screen = seeded_app.screen + table = screen.query_one("#invoice-table") + labels = [str(col.label) for col in table.columns.values()] + assert labels == [ + "number", + "client", + "period", + "total", + "est. tax", + "take-home", + "status", + ] + # detail modal carries the same estimate + await pilot.press("o") + await pilot.pause() + from ttd.tui.screens.invoices import InvoiceDetailModal + + assert isinstance(seeded_app.screen, InvoiceDetailModal) + await pilot.press("escape") + await pilot.pause() + # rate cleared mid-session → columns drop on the next render + monkeypatch.delenv("TTD_TAX__SET_ASIDE_RATE") + await pilot.press("r") + await pilot.pause() + labels = [str(col.label) for col in table.columns.values()] + assert labels == ["number", "client", "period", "total", "status"] + + # --- TUI enhancements: spans, entry edit, clients CRUD, invoice period ------- From 0f37b468c766cf9e6748b790cc9d8a015583e4f1 Mon Sep 17 00:00:00 2001 From: Taylor Date: Wed, 17 Jun 2026 10:57:45 -0400 Subject: [PATCH 2/3] docs: invoice tax estimate guides and AGENTS doc-sync rule Document per-invoice est. tax / take-home in invoicing, taxes, and TUI guides; add AGENTS.md guidance to ship guide updates with behavior changes. Co-authored-by: Cursor --- AGENTS.md | 27 +++++++++++++++++++++++++++ docs/pages/guides/invoicing.md | 19 ++++++++++++++++--- docs/pages/guides/taxes.md | 10 ++++++++++ docs/pages/guides/the-tui.md | 6 ++++-- 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a66a865..44eab3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,33 @@ What this means in practice: This rule binds human contributors and AI agents equally, and overrides any agent default that biases toward minimal or expedient changes. +## Documentation + +User-facing docs live in `docs/pages/` (Zensical site). **Ship doc updates in +the same change** when behavior users read about changes. + +**Already automated — do not hand-edit:** + +- `docs/pages/reference/cli/` — generated from cyclopts (`just docs-cli`; prek + `cli-docs` on commit) +- Configuration reference — rendered from Pydantic schema docstrings +- Site build — prek `zensical-build` and CI catch broken links and build errors + +**Update guides in the same PR when you change:** + +- CLI output (table columns, labels, previews) → `docs/pages/guides/` and + cyclopts command docstrings when the reference should describe behavior +- TUI screens, columns, or modals → matching guide(s); run `just docs-shots` when + committed screenshots should reflect the new UI +- Config keys or semantics → schema docstrings first; guides when users need + workflow context + +Prefer tests that assert key output strings (labels, columns) so review can +spot guide drift. Do not add planning or design markdown under `docs/pages/` — +CE artifacts belong in repo-root `brainstorms/` and `plans/`; internal design +notes stay in `docs/design/`. See `docs/pages/contributing.md` for the full +docs workflow. + ## Conventions - Python 3.13+, uv, ruff, ty, pytest + Hypothesis for billing-sensitive invariants; TUI snapshot tests via pytest-textual-snapshot diff --git a/docs/pages/guides/invoicing.md b/docs/pages/guides/invoicing.md index 13554ec..cf2957a 100644 --- a/docs/pages/guides/invoicing.md +++ b/docs/pages/guides/invoicing.md @@ -29,7 +29,9 @@ $ ttd invoice create --client acme-corp --number 2026-CUSTOM-1 $ ttd invoice create --client acme-corp --dry-run ``` -Prints the line items, subtotal, tax, and total — and changes nothing. +Prints the line items, subtotal, tax, and total — and changes nothing. When +`tax.set_aside_rate` is set, the preview also shows how much to set aside and +the estimated take-home (subtotal minus set-aside). ## Rendering PDF and Markdown @@ -74,6 +76,14 @@ $ ttd invoice list # newest first: number, client, period, total, sta $ ttd invoice show 2026-001 # line items, dates, subtotal/tax/total, set-aside ``` +When `tax.set_aside_rate` is greater than zero, `invoice list` also shows +**Est. Tax** and **Take-Home** columns. Unpaid invoices preview at the current +rate (shown muted); paid invoices use the frozen snapshot from when they were +marked paid. With the rate at `0` (the default), those columns are omitted. + +`invoice show` includes the same set-aside and take-home figures — a preview +for open invoices, frozen amounts for paid ones. + ## Invoice numbering Numbers come from the `invoice.number_format` template — default @@ -109,14 +119,17 @@ only what's billed. ## Invoices in the TUI -Screen `5` lists invoices with status pills: +Screen `5` lists invoices with status pills. When a tax set-aside rate is +configured, the table also shows **est. tax** and **take-home** columns (dim +for unpaid previews, normal for paid). The detail modal (`o`) includes the same +estimates in its summary line. ![Invoices list](../assets/screenshots/invoices-list.svg) | Key | Action | | --- | --- | | `n` | new invoice (pick client, live-preview period) | -| `o` | open line-item detail | +| `o` | open line-item detail (with est. tax / take-home when configured) | | `m` | preview the Markdown render | | `e` | render PDF + Markdown files | | `t` / `p` / `v` | mark sent / paid / void | diff --git a/docs/pages/guides/taxes.md b/docs/pages/guides/taxes.md index 0cb42e5..02ffb21 100644 --- a/docs/pages/guides/taxes.md +++ b/docs/pages/guides/taxes.md @@ -24,6 +24,16 @@ The amount and the rate used are frozen onto that invoice, so changing what actually moved to your savings account. Re-marking with a corrected `--paid-date` re-files the set-aside into the right quarter. +## Per-invoice estimates + +With a rate configured, [`invoice list`](invoicing.md#reviewing-invoices) and +[`invoice show`](invoicing.md#reviewing-invoices) surface **est. tax** and +**take-home** for each invoice — subtotal minus set-aside. Draft and sent +invoices preview at the current `tax.set_aside_rate` (muted in the CLI, dim in +the TUI); paid invoices always show the frozen snapshot from mark-paid, even if +you change the rate later. Void invoices and a `0` rate omit the columns +entirely. + ## Estimated-tax quarters ttd uses the IRS estimated-tax calendar (note the uneven windows): diff --git a/docs/pages/guides/the-tui.md b/docs/pages/guides/the-tui.md index 7078387..b01000f 100644 --- a/docs/pages/guides/the-tui.md +++ b/docs/pages/guides/the-tui.md @@ -53,8 +53,10 @@ heat strips. Keys: `w`/`m` mode, `[` `]` paging. ## Invoices Screen `5`. Every invoice with its status pill; create, inspect, render, and -advance them without leaving the screen. Keys: `n` new, `o` detail, `m` -markdown preview, `e` render files, `t` sent, `p` paid, `v` void. +advance them without leaving the screen. When `tax.set_aside_rate` is set, the +list adds **est. tax** and **take-home** columns (dim until the invoice is +paid), and the detail modal shows the same figures. Keys: `n` new, `o` detail, +`m` markdown preview, `e` render files, `t` sent, `p` paid, `v` void. ![Invoices](../assets/screenshots/invoices-list.svg) From a7a62f48b4783638556d09a69c654e4dfa9d23a4 Mon Sep 17 00:00:00 2001 From: Taylor Date: Thu, 18 Jun 2026 09:45:26 -0400 Subject: [PATCH 3/3] fix(tui): pause dashboard timer tick when screen is suspended Prevents background _tick callbacks from querying #big-timer after switching away, which flaked on macOS Python 3.14 CI. Co-authored-by: Cursor --- src/ttd/tui/screens/dashboard.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ttd/tui/screens/dashboard.py b/src/ttd/tui/screens/dashboard.py index 69ce9a8..a7c75f6 100644 --- a/src/ttd/tui/screens/dashboard.py +++ b/src/ttd/tui/screens/dashboard.py @@ -31,7 +31,14 @@ def compose_content(self) -> ComposeResult: def setup(self) -> None: table = self.query_one("#today-table", DataTable) table.add_columns("project", "time", "hours", "note") - self.set_interval(1.0, self._tick) + self._tick_timer = self.set_interval(1.0, self._tick) + + async def on_screen_suspend(self) -> None: + self._tick_timer.pause() + + async def on_screen_resume(self) -> None: + self._tick_timer.resume() + await super().on_screen_resume() async def _tick(self) -> None: status = await timer_svc.timer_status(now=datetime.now())