Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions docs/pages/guides/invoicing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
10 changes: 10 additions & 0 deletions docs/pages/guides/taxes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 4 additions & 2 deletions docs/pages/guides/the-tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
42 changes: 33 additions & 9 deletions src/ttd/cli/invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")

Expand All @@ -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)
Expand Down Expand Up @@ -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]"
)


Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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]"
)


Expand Down
36 changes: 36 additions & 0 deletions src/ttd/services/taxes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 8 additions & 1 deletion src/ttd/tui/screens/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
50 changes: 41 additions & 9 deletions src/ttd/tui/screens/invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")]

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]"
Expand Down
30 changes: 30 additions & 0 deletions tests/test_cli/test_invoice_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
Loading
Loading