diff --git a/.superpowers/sdd/final-review-fixes-report.md b/.superpowers/sdd/final-review-fixes-report.md new file mode 100644 index 0000000..fe2e9d0 --- /dev/null +++ b/.superpowers/sdd/final-review-fixes-report.md @@ -0,0 +1,64 @@ +# Final Review Fixes Report + +## Finding 1 — `--invoiced` filter now applied to expenses on export + +**File:** `src/ttd/services/interchange_svc.py` + +Moved `expense_views = await list_expenses(...)` before the `used_clients`/`used_projects` sets are built. Added a filter immediately after fetching: `if invoiced is not None: expense_views = [v for v in expense_views if (v.expense.invoice_id is not None) == invoiced]`. The existing `receipts_meta` is derived from `expense_ids` which is now computed from the filtered `expense_views`, so receipts for filtered-out expenses are correctly excluded. + +**Test added:** `test_export_invoiced_filter_applies_to_expenses` in `tests/test_interchange/test_expense_backup.py` — creates one uninvoiced expense and one with a fake `invoice_id`, then asserts `invoiced=True` returns only the invoiced one, `invoiced=False` only the free one, and `invoiced=None` returns both. + +--- + +## Finding 2 — Expense-only clients now included in JSON backup metadata + +**File:** `src/ttd/services/interchange_svc.py` + +After the expense filter, added: +```python +used_clients |= {v.client.slug for v in expense_views} +used_projects |= {(v.client.slug, v.project.slug) for v in expense_views} +``` +before building `clients_meta`/`projects_meta`. Also deduplicated the `Client.all()` call (was called twice; now uses `all_clients` local variable). + +**Test added:** `test_export_includes_expense_only_client_in_meta` in `tests/test_interchange/test_expense_backup.py` — creates a client with `currency="EUR"` that has an expense but no entries, then asserts the client and project appear in `meta["clients"]` and `meta["projects"]` with correct name and currency. + +--- + +## Finding 3 — Refresh diff now prints Expenses line when expense subtotal changes + +**File:** `src/ttd/cli/invoices.py`, function `_print_refresh_diff` + +Added a block inside the `if preview.totals_changed:` branch: +```python +if preview.before_expenses_subtotal != preview.after_expenses_subtotal: + console.print( + f"Expenses: {format_money(preview.before_expenses_subtotal, currency)} → " + f"[bold]{format_money(preview.after_expenses_subtotal, currency)}[/bold]" + ) +``` +Placed between the Subtotal and Tax lines, matching the existing arrow style. + +**Test added:** `test_print_refresh_diff_shows_expenses_line_when_expense_subtotal_changes` in `tests/test_cli/test_invoice_cli.py` — builds a `RefreshPreview` with `before_expenses_subtotal=100` and `after_expenses_subtotal=50`, calls `_print_refresh_diff`, and asserts "Expenses" appears in the captured stdout. + +--- + +## Finding 4 — Hardcoded 'USD' replaced in expense CLI messages + +**File:** `src/ttd/cli/expenses.py` + +- Moved `get_settings` import to module level (was only inside `add`). +- `add` success message: `format_money(expense.amount, get_settings().business.currency)` (extracted to `currency` local for line-length). +- `list` footer total: uses `rows[0].client.currency` when rows exist; falls back to `"USD"` only in the impossible case that rows is empty (the function returns early if no rows). +- `rm` success message: `format_money(expense.amount, get_settings().business.currency)` (extracted to `currency` local). + +No test added (behavior is cosmetic and covered by existing CLI integration tests). + +--- + +## Final Results + +- **pytest:** 361 passed, 0 failed +- **Coverage:** 84.60% (threshold: 84%) +- **ty check:** All checks passed +- **ruff check:** All checks passed diff --git a/docs/pages/reference/cli/invoice.md b/docs/pages/reference/cli/invoice.md index 89d8efe..171979d 100644 --- a/docs/pages/reference/cli/invoice.md +++ b/docs/pages/reference/cli/invoice.md @@ -31,12 +31,13 @@ Invoice a client's uninvoiced billable work (defaults to last month). * `--client`: Client slug * `--month`: YYYY-MM -* `--period`: Period spec: 'last month', 'this month', YYYY-MM, or YYYY-MM-DD to YYYY-MM-DD +* `--period`: Period spec: 'last month', 'this week', 'last two weeks', 'june 16 to june 30', YYYY-MM, or YYYY-MM-DD to YYYY-MM-DD * `--from`: * `--to`: * `--number`: Override the number * `--pdf, --no-pdf`: Render a PDF *\[default: False\]* * `--md, --no-md`: Render Markdown *\[default: False\]* +* `--receipts, --no-receipts`: Append expense receipts to the PDF *\[default: False\]* * `--out`: Output directory * `--dry-run, --no-dry-run`: Preview, change nothing *\[default: False\]* * `--interactive, --no-interactive, -i`: Fill remaining fields via a form *\[default: False\]* @@ -75,6 +76,7 @@ ttd invoice render [OPTIONS] NUMBER * `NUMBER, --number`: **\[required\]** * `--pdf, --no-pdf`: *\[default: False\]* * `--md, --no-md`: *\[default: False\]* +* `--receipts, --no-receipts`: Append expense receipts to the PDF *\[default: False\]* * `--out`: ## invoice refresh diff --git a/docs/superpowers/plans/2026-06-30-billable-expenses.md b/docs/superpowers/plans/2026-06-30-billable-expenses.md new file mode 100644 index 0000000..df5c074 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-billable-expenses.md @@ -0,0 +1,2135 @@ +# Billable Expenses (Client Chargebacks) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a solo developer record purchased items against a project and bill them back to the client as untaxed, pass-through line items on invoices (with optional receipts). + +**Architecture:** A new `Expense` model parallels `Entry` (attached to a project, locked to an invoice via `invoice_id`). Expenses ride a *separate* line table (`InvoiceExpenseLine`) onto invoices alongside time lines — time stays the taxed `subtotal`, expenses become a new untaxed `expenses_subtotal`. Receipts are stored base64 in a side table (`ExpenseReceipt`) because ferro-orm#160 blocks raw binary through `Model.save()`. CRUD, services, CLI, invoicing lifecycle, rendering, and JSON backup are all touched; reports and CSV/XLSX interchange are out of scope. + +**Tech Stack:** Python 3.13, Ferro-ORM 0.12.x over SQLite, Cyclopts (CLI), Rich (output), Textual (TUI), fpdf2 (PDF), Jinja2 (markdown), `pypdf` (new — PDF receipt merging), pytest + pytest-asyncio. + +## Global Constraints + +- **Pass-through only:** an expense has one money figure, `amount`. No markup, no cost-vs-billed split. +- **Expenses are untaxed:** `tax = to_cents(subtotal * tax_rate)` stays time-only. `total = subtotal + tax + expenses_subtotal`. +- **Plain-id FK convention:** new models use `*_id: UUID` columns + manual service-layer cascade. No Ferro relationships (tracked separately in ttd#13). +- **Receipts are base64 text** in `ExpenseReceipt.data_b64` (ferro-orm#160 — raw `bytes` can't be saved via the ORM). Never add a raw `bytes` field to a model. +- **Locking parity with entries:** editing/deleting an expense whose `invoice_id` is set raises; voiding an invoice releases its expenses. Same rule and wording style as `InvoicedEntryError`. +- **No new system dependencies:** `pypdf` is pure-python and acceptable; nothing requiring a system library. +- **Existing invoices must render byte-identically** when an invoice has no expenses. +- **Migrations are automatic:** Ferro `migrate_updates=True` (in `init_db`) creates new tables and adds the defaulted `expenses_subtotal` column on connect. No manual migration script. +- **Tests:** `asyncio_mode = "auto"` — write `async def test_...(db)`; the `db` fixture (from `tests/conftest.py`) yields `Settings` with a temp SQLite DB. Money is `Decimal`. Set up clients/projects via `client_svc.create_client(name, hourly_rate=...)` and `project_svc.create_project(name, client_slug)`. +- **Commit style:** Conventional Commits (`feat:`, `test:`, `docs:`). Pre-commit runs ruff + ty + docs build; keep imports sorted and types clean. + +--- + +## File Structure + +**Create:** +- `src/ttd/storage/models/expense.py` — `Expense`, `ExpenseReceipt`. +- `src/ttd/services/expenses.py` — expense CRUD, recall, receipts. +- `src/ttd/cli/expenses.py` — `ttd expense` sub-app + `receipt` group. +- `tests/test_storage/test_expenses.py` — model + service CRUD/locking/receipts. +- `tests/test_services/test_invoicing_expenses.py` — draft/persist/void/refresh with expenses. +- `tests/test_invoicing/test_expense_render.py` — PDF/markdown expense sections + receipt gating. +- `tests/test_interchange/test_expense_backup.py` — JSON v2 round-trip. + +**Modify:** +- `src/ttd/storage/models/invoice.py` — add `InvoiceExpenseLine`; add `Invoice.expenses_subtotal`. +- `src/ttd/storage/models/__init__.py` — export new models. +- `src/ttd/core/errors.py` — add `InvoicedExpenseError`. +- `src/ttd/services/invoicing.py` — expense draft lines, untaxed totals, persist/void/refresh, `InvoiceView.expense_lines`, `invoice_has_receipts`. +- `src/ttd/config/schema.py` — `InvoiceConfig.attach_receipts`. +- `src/ttd/cli/app.py` — register expense sub-app. +- `src/ttd/cli/invoices.py` — format choice (default PDF), `--receipts`, markdown gating. +- `src/ttd/invoicing/pdf.py` — expense section + receipt pages. +- `src/ttd/invoicing/markdown.py` + `templates/invoice.md.j2` — expense section. +- `src/ttd/interchange/json_io.py` — expenses + receipts in envelope (v2). +- `src/ttd/tui/screens/invoices.py`, `src/ttd/tui/_data.py` — invoice detail expenses + quick-add. +- `pyproject.toml` — add `pypdf` dependency. + +--- + +## Task 1: Data model — Expense, ExpenseReceipt, InvoiceExpenseLine + +**Files:** +- Create: `src/ttd/storage/models/expense.py` +- Modify: `src/ttd/storage/models/invoice.py` +- Modify: `src/ttd/storage/models/__init__.py` +- Test: `tests/test_storage/test_expenses.py` + +**Interfaces:** +- Produces: `Expense(id, project_id, incurred_date, description, amount, note, invoice_id, created_at, updated_at)`; `ExpenseReceipt(id, expense_id, filename, content_type, data_b64)`; `InvoiceExpenseLine(id, invoice_id, expense_id, incurred_date, description, amount)`; `Invoice.expenses_subtotal: Decimal`. All exported from `ttd.storage.models`. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_storage/test_expenses.py +from datetime import date, datetime +from decimal import Decimal +from uuid import uuid4 + +from ttd.services import clients as client_svc +from ttd.services import projects as project_svc +from ttd.storage.models import Expense, ExpenseReceipt, pk + + +async def _project(db): + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + return await project_svc.create_project("API Rewrite", "acme-corp") + + +async def test_expense_roundtrips(db): + project = await _project(db) + now = datetime.now() + exp = Expense( + id=uuid4(), + project_id=pk(project), + incurred_date=date(2026, 6, 15), + description="Claude Code", + amount=Decimal("100.00"), + created_at=now, + updated_at=now, + ) + await exp.save() + + fetched = (await Expense.all())[0] + assert fetched.description == "Claude Code" + assert fetched.amount == Decimal("100.00") + assert fetched.incurred_date == date(2026, 6, 15) + assert fetched.invoice_id is None + + +async def test_receipt_roundtrips_as_base64(db): + project = await _project(db) + now = datetime.now() + exp = Expense( + id=uuid4(), project_id=pk(project), incurred_date=date(2026, 6, 15), + description="x", amount=Decimal("1"), created_at=now, updated_at=now, + ) + await exp.save() + receipt = ExpenseReceipt( + id=uuid4(), expense_id=pk(exp), filename="r.pdf", + content_type="application/pdf", data_b64="JVBERi0xLjQ=", + ) + await receipt.save() + assert (await ExpenseReceipt.all())[0].data_b64 == "JVBERi0xLjQ=" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_storage/test_expenses.py -v` +Expected: FAIL with `ImportError: cannot import name 'Expense'`. + +- [ ] **Step 3: Create the expense models** + +```python +# src/ttd/storage/models/expense.py +from datetime import date, datetime +from decimal import Decimal +from typing import Annotated +from uuid import UUID + +from ferro import FerroField +from ferro.models import Model + + +class Expense(Model): + """A purchased item billed back to a client, attached to a project. + + ``invoice_id`` set means billed & locked — mirrors ``Entry``. ``amount`` is + pure pass-through: what you paid is what the client is billed. + """ + + id: Annotated[UUID | None, FerroField(primary_key=True)] = None + project_id: Annotated[UUID, FerroField(index=True)] + incurred_date: Annotated[date, FerroField(db_type="date", index=True)] + description: str + amount: Decimal + note: str = "" + invoice_id: Annotated[UUID | None, FerroField(index=True)] = None + created_at: datetime + updated_at: datetime + + +class ExpenseReceipt(Model): + """Optional receipt for an expense, stored base64 in its own table. + + Separate table so ``expense list`` never loads receipt bytes. Base64 text + rather than raw ``bytes`` because ferro-orm#160 blocks binary via the ORM. + """ + + id: Annotated[UUID | None, FerroField(primary_key=True)] = None + expense_id: Annotated[UUID, FerroField(unique=True, index=True)] + filename: str + content_type: str + data_b64: Annotated[str, FerroField(db_type="text")] +``` + +- [ ] **Step 4: Add `InvoiceExpenseLine` and the `Invoice` field** + +In `src/ttd/storage/models/invoice.py`, add the `expenses_subtotal` field to `Invoice` (place it next to `subtotal`/`total`): + +```python + subtotal: Decimal + tax_rate: Decimal = Decimal("0") + tax: Decimal = Decimal("0") + expenses_subtotal: Decimal = Decimal("0") # untaxed pass-through expenses + total: Decimal +``` + +And append a new model at the end of the file: + +```python +class InvoiceExpenseLine(Model): + """One expense frozen onto an invoice; ``amount`` is frozen at invoice time.""" + + id: Annotated[UUID | None, FerroField(primary_key=True)] = None + invoice_id: Annotated[UUID, FerroField(index=True)] + expense_id: Annotated[UUID, FerroField(index=True)] + incurred_date: Annotated[date, FerroField(db_type="date")] + description: str + amount: Decimal +``` + +- [ ] **Step 5: Export from the models package** + +In `src/ttd/storage/models/__init__.py` add imports and `__all__` entries: + +```python +from ttd.storage.models.expense import Expense, ExpenseReceipt +from ttd.storage.models.invoice import Invoice, InvoiceExpenseLine, InvoiceLine +``` + +Add `"Expense"`, `"ExpenseReceipt"`, `"InvoiceExpenseLine"` to `__all__` (keep it alphabetized). + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `uv run pytest tests/test_storage/test_expenses.py -v` +Expected: PASS (2 passed). + +- [ ] **Step 7: Commit** + +```bash +git add src/ttd/storage/models/ tests/test_storage/test_expenses.py +git commit -m "feat: add Expense, ExpenseReceipt, InvoiceExpenseLine models" +``` + +--- + +## Task 2: Expense service — CRUD + recall + +**Files:** +- Create: `src/ttd/services/expenses.py` +- Modify: `src/ttd/core/errors.py` +- Test: `tests/test_storage/test_expenses.py` (append) + +**Interfaces:** +- Consumes: `Expense`, `pk` (Task 1); `project_svc.get_project`, `client_svc`. +- Produces: + - `InvoicedExpenseError(TtdError)` + - `@dataclass ExpenseView(expense: Expense, project: Project, client: Client, has_receipt: bool)` + - `@dataclass ExpenseSuggestion(description: str, amount: Decimal)` + - `async add_expense(project_slug, description, amount, *, incurred_date=None, note="") -> Expense` + - `async find_expense(uid_prefix) -> Expense` + - `async list_expenses(*, project_slug=None, client_slug=None, date_from=None, date_to=None, unbilled_only=False) -> list[ExpenseView]` + - `async edit_expense(uid_prefix, *, amount=None, description=None, note=None, incurred_date=None, project_slug=None, client_slug=None) -> Expense` + - `async delete_expense(uid_prefix) -> Expense` + - `async recent_expenses(*, project_slug=None, client_slug=None, limit=8) -> list[ExpenseSuggestion]` + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_storage/test_expenses.py (append) +import pytest +from ttd.core.errors import InvoicedExpenseError, NotFoundError +from ttd.services import expenses as expense_svc + + +async def test_add_and_list_expense(db): + await _project(db) + exp = await expense_svc.add_expense("api-rewrite", "Claude Code", Decimal("100")) + assert exp.amount == Decimal("100") + views = await expense_svc.list_expenses() + assert len(views) == 1 + assert views[0].client.slug == "acme-corp" + assert views[0].has_receipt is False + + +async def test_edit_and_delete_expense(db): + await _project(db) + exp = await expense_svc.add_expense("api-rewrite", "Claude", Decimal("100")) + await expense_svc.edit_expense(str(exp.id)[:8], amount=Decimal("120")) + assert (await expense_svc.list_expenses())[0].expense.amount == Decimal("120") + await expense_svc.delete_expense(str(exp.id)[:8]) + assert await expense_svc.list_expenses() == [] + + +async def test_locked_expense_refuses_edit_and_delete(db): + await _project(db) + exp = await expense_svc.add_expense("api-rewrite", "Claude", Decimal("100")) + exp.invoice_id = uuid4() + await exp.save() + with pytest.raises(InvoicedExpenseError): + await expense_svc.edit_expense(str(exp.id)[:8], amount=Decimal("1")) + with pytest.raises(InvoicedExpenseError): + await expense_svc.delete_expense(str(exp.id)[:8]) + + +async def test_recent_expenses_returns_distinct_pairs(db): + await _project(db) + await expense_svc.add_expense("api-rewrite", "Claude Code", Decimal("100")) + await expense_svc.add_expense("api-rewrite", "Claude Code", Decimal("100")) + await expense_svc.add_expense("api-rewrite", "Figma", Decimal("15")) + suggestions = await expense_svc.recent_expenses(project_slug="api-rewrite") + pairs = [(s.description, s.amount) for s in suggestions] + assert pairs == [("Figma", Decimal("15")), ("Claude Code", Decimal("100"))] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_storage/test_expenses.py -v` +Expected: FAIL with `ImportError: cannot import name 'InvoicedExpenseError'`. + +- [ ] **Step 3: Add the error type** + +In `src/ttd/core/errors.py`, after `InvoicedEntryError`: + +```python +class InvoicedExpenseError(TtdError): + """Attempted to modify an expense that is locked to an invoice.""" +``` + +- [ ] **Step 4: Write the service** + +```python +# src/ttd/services/expenses.py +"""Logging and managing billable expenses (client chargebacks).""" + +from dataclasses import dataclass +from datetime import date, datetime +from decimal import Decimal +from uuid import uuid4 + +from ttd.core.errors import ConflictError, InvoicedExpenseError, NotFoundError +from ttd.services.projects import get_project +from ttd.storage.db import in_db_session +from ttd.storage.models import Client, Expense, ExpenseReceipt, Project, pk + + +@dataclass +class ExpenseView: + expense: Expense + project: Project + client: Client + has_receipt: bool + + +@dataclass +class ExpenseSuggestion: + description: str + amount: Decimal + + +@in_db_session +async def add_expense( + project_slug: str, + description: str, + amount: Decimal, + *, + incurred_date: date | None = None, + note: str = "", +) -> Expense: + project = await get_project(project_slug) + stamp = datetime.now() + expense = Expense( + id=uuid4(), + project_id=pk(project), + incurred_date=incurred_date or date.today(), + description=description.strip(), + amount=amount, + note=note, + created_at=stamp, + updated_at=stamp, + ) + await expense.save() + return expense + + +@in_db_session +async def find_expense(uid_prefix: str) -> Expense: + needle = uid_prefix.lower().replace("-", "") + if not needle: + raise NotFoundError("Empty expense id") + matches = [e for e in await Expense.all() if str(e.id).replace("-", "").startswith(needle)] + if not matches: + raise NotFoundError(f"No expense matching '{uid_prefix}'") + if len(matches) > 1: + raise ConflictError(f"'{uid_prefix}' matches {len(matches)} expenses — use more characters") + return matches[0] + + +@in_db_session +async def list_expenses( + *, + project_slug: str | None = None, + client_slug: str | None = None, + date_from: date | None = None, + date_to: date | None = None, + unbilled_only: bool = False, +) -> list[ExpenseView]: + expenses = await Expense.all() + projects = {p.id: p for p in await Project.all()} + clients = {c.id: c for c in await Client.all()} + receipted = {r.expense_id for r in await ExpenseReceipt.all()} + + if project_slug is not None: + project = await get_project(project_slug, client_slug) + expenses = [e for e in expenses if e.project_id == project.id] + elif client_slug is not None: + wanted = {p.id for p in projects.values() if clients[p.client_id].slug == client_slug} + expenses = [e for e in expenses if e.project_id in wanted] + if date_from is not None: + expenses = [e for e in expenses if e.incurred_date >= date_from] + if date_to is not None: + expenses = [e for e in expenses if e.incurred_date <= date_to] + if unbilled_only: + expenses = [e for e in expenses if e.invoice_id is None] + + rows: list[ExpenseView] = [] + for e in sorted(expenses, key=lambda e: (e.incurred_date, e.created_at)): + project = projects.get(e.project_id) + if project is None: + continue + rows.append(ExpenseView(e, project, clients[project.client_id], e.id in receipted)) + return rows + + +def _ensure_unlocked(expense: Expense) -> None: + if expense.invoice_id is not None: + raise InvoicedExpenseError( + f"Expense {str(expense.id)[:8]} is on an invoice — void the invoice first" + ) + + +@in_db_session +async def edit_expense( + uid_prefix: str, + *, + amount: Decimal | None = None, + description: str | None = None, + note: str | None = None, + incurred_date: date | None = None, + project_slug: str | None = None, + client_slug: str | None = None, +) -> Expense: + expense = await find_expense(uid_prefix) + _ensure_unlocked(expense) + if amount is not None: + expense.amount = amount + if description is not None: + expense.description = description.strip() + if note is not None: + expense.note = note + if incurred_date is not None: + expense.incurred_date = incurred_date + if project_slug is not None: + project = await get_project(project_slug, client_slug) + expense.project_id = pk(project) + expense.updated_at = datetime.now() + await expense.save() + return expense + + +@in_db_session +async def delete_expense(uid_prefix: str) -> Expense: + expense = await find_expense(uid_prefix) + _ensure_unlocked(expense) + for receipt in await ExpenseReceipt.where(lambda r: r.expense_id == expense.id).all(): + await receipt.delete() # manual cascade (ttd#13 would make this a DB action) + await expense.delete() + return expense + + +@in_db_session +async def recent_expenses( + *, + project_slug: str | None = None, + client_slug: str | None = None, + limit: int = 8, +) -> list[ExpenseSuggestion]: + """Distinct (description, amount) pairs from prior expenses, newest first. + + Scoped to the project; if no project given, scoped to the client. + """ + views = await list_expenses(project_slug=project_slug, client_slug=client_slug) + seen: set[tuple[str, Decimal]] = set() + out: list[ExpenseSuggestion] = [] + for view in reversed(views): # list_expenses is oldest-first; we want newest-first + key = (view.expense.description, view.expense.amount) + if key in seen: + continue + seen.add(key) + out.append(ExpenseSuggestion(view.expense.description, view.expense.amount)) + if len(out) >= limit: + break + return out +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/test_storage/test_expenses.py -v` +Expected: PASS (all expense tests green). + +- [ ] **Step 6: Commit** + +```bash +git add src/ttd/services/expenses.py src/ttd/core/errors.py tests/test_storage/test_expenses.py +git commit -m "feat: expense CRUD service with history recall" +``` + +--- + +## Task 3: Expense service — receipts + +**Files:** +- Modify: `src/ttd/services/expenses.py` +- Test: `tests/test_storage/test_expenses.py` (append) + +**Interfaces:** +- Consumes: `ExpenseReceipt`, `add_expense`, `find_expense` (Tasks 1–2). +- Produces: + - `MAX_RECEIPT_BYTES = 5 * 1024 * 1024` + - `async add_receipt(uid_prefix: str, path: Path) -> ExpenseReceipt` + - `async get_receipt(uid_prefix: str) -> tuple[str, str, bytes] | None` (filename, content_type, raw bytes) + - `async remove_receipt(uid_prefix: str) -> None` + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_storage/test_expenses.py (append) +from pathlib import Path + + +async def test_receipt_add_get_roundtrip(db, tmp_path): + await _project(db) + exp = await expense_svc.add_expense("api-rewrite", "Claude", Decimal("100")) + src = tmp_path / "receipt.pdf" + payload = b"%PDF-1.4\n\xff\xd8 binary" + src.write_bytes(payload) + + await expense_svc.add_receipt(str(exp.id)[:8], src) + filename, content_type, data = await expense_svc.get_receipt(str(exp.id)[:8]) + assert filename == "receipt.pdf" + assert content_type == "application/pdf" + assert data == payload + assert (await expense_svc.list_expenses())[0].has_receipt is True + + +async def test_receipt_remove(db, tmp_path): + await _project(db) + exp = await expense_svc.add_expense("api-rewrite", "Claude", Decimal("100")) + src = tmp_path / "r.png" + src.write_bytes(b"\x89PNG\r\n") + await expense_svc.add_receipt(str(exp.id)[:8], src) + await expense_svc.remove_receipt(str(exp.id)[:8]) + assert await expense_svc.get_receipt(str(exp.id)[:8]) is None + + +async def test_oversized_receipt_rejected(db, tmp_path): + await _project(db) + exp = await expense_svc.add_expense("api-rewrite", "Claude", Decimal("100")) + big = tmp_path / "big.pdf" + big.write_bytes(b"0" * (expense_svc.MAX_RECEIPT_BYTES + 1)) + with pytest.raises(Exception): # TtdError subclass + await expense_svc.add_receipt(str(exp.id)[:8], big) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_storage/test_expenses.py -k receipt -v` +Expected: FAIL with `AttributeError: module ... has no attribute 'add_receipt'`. + +- [ ] **Step 3: Implement receipt functions** + +Add imports at the top of `src/ttd/services/expenses.py`: + +```python +import base64 +import mimetypes +from pathlib import Path + +from ttd.core.errors import TtdError +``` + +Append: + +```python +MAX_RECEIPT_BYTES = 5 * 1024 * 1024 # 5 MiB — receipts are meant to be small + + +@in_db_session +async def add_receipt(uid_prefix: str, path: Path) -> ExpenseReceipt: + expense = await find_expense(uid_prefix) + raw = path.read_bytes() + if len(raw) > MAX_RECEIPT_BYTES: + raise TtdError( + f"Receipt is {len(raw) // 1024} KB; the limit is " + f"{MAX_RECEIPT_BYTES // (1024 * 1024)} MB" + ) + content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" + for existing in await ExpenseReceipt.where(lambda r: r.expense_id == expense.id).all(): + await existing.delete() # one receipt per expense; replace + receipt = ExpenseReceipt( + id=uuid4(), + expense_id=pk(expense), + filename=path.name, + content_type=content_type, + data_b64=base64.b64encode(raw).decode("ascii"), + ) + await receipt.save() + return receipt + + +@in_db_session +async def get_receipt(uid_prefix: str) -> tuple[str, str, bytes] | None: + expense = await find_expense(uid_prefix) + receipt = await ExpenseReceipt.where(lambda r: r.expense_id == expense.id).first() + if receipt is None: + return None + return receipt.filename, receipt.content_type, base64.b64decode(receipt.data_b64) + + +@in_db_session +async def remove_receipt(uid_prefix: str) -> None: + expense = await find_expense(uid_prefix) + for receipt in await ExpenseReceipt.where(lambda r: r.expense_id == expense.id).all(): + await receipt.delete() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_storage/test_expenses.py -k receipt -v` +Expected: PASS (3 passed). + +- [ ] **Step 5: Commit** + +```bash +git add src/ttd/services/expenses.py tests/test_storage/test_expenses.py +git commit -m "feat: expense receipt storage (base64, size-guarded)" +``` + +--- + +## Task 4: CLI — `ttd expense` sub-app + +**Files:** +- Create: `src/ttd/cli/expenses.py` +- Modify: `src/ttd/cli/app.py` +- Test: `tests/test_storage/test_expenses.py` (append a CLI smoke test) — or `tests/test_cli/` if present; use the `isolated_config` fixture. + +**Interfaces:** +- Consumes: `expense_svc` (Tasks 2–3), `TtdApp`, `with_db`, `console`, `success`, `table` (existing CLI helpers). +- Produces: a Cyclopts `app` named `expense` with commands `add`, `list`, `edit`, `rm`, and a nested `receipt` group (`add`, `get`, `rm`); registered in the root app. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_storage/test_expenses.py (append) +async def test_cli_app_registers_expense_commands(): + from ttd.cli.expenses import app as expense_app + names = set(expense_app._commands) if hasattr(expense_app, "_commands") else None + # Fallback: the sub-app must at least be importable and named "expense" + assert expense_app.name == "expense" or expense_app.name == ["expense"] +``` + +> Note: if `TtdApp` doesn't expose `_commands`, keep the import + name assertion only. The real behavioral coverage for CLI lives in the service tests; this test guards that the module imports cleanly (catches typos/bad imports in the command module). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_storage/test_expenses.py -k cli_app -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'ttd.cli.expenses'`. + +- [ ] **Step 3: Write the CLI module** + +```python +# src/ttd/cli/expenses.py +"""`ttd expense …` commands.""" + +import json +from datetime import date +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Annotated + +from cyclopts import Parameter + +from ttd.cli._output import console, success, table +from ttd.cli._run import TtdApp, with_db +from ttd.core.errors import TtdError +from ttd.core.money import format_money +from ttd.services import expenses as svc + +app = TtdApp(name="expense", help="Track and bill back client expenses.") +receipt_app = TtdApp(name="receipt", help="Attach receipts to an expense.") +app.command(receipt_app) + + +def _parse_date(raw: str | None) -> date | None: + if raw is None: + return None + try: + return date.fromisoformat(raw) + except ValueError as exc: + raise TtdError(f"Dates must be YYYY-MM-DD (got '{raw}')") from exc + + +def _amount(raw: str) -> Decimal: + try: + return Decimal(raw) + except InvalidOperation as exc: + raise TtdError(f"Amount must be a number (got '{raw}')") from exc + + +@app.command(name="add") +@with_db +async def add( + description: str, + amount: str, + *, + project: Annotated[str | None, Parameter(name=["--project", "-p"])] = None, + on: Annotated[str | None, Parameter(name="--on", help="Incurred date YYYY-MM-DD")] = None, + note: Annotated[str, Parameter(name=["--note", "-n"])] = "", + receipt: Annotated[Path | None, Parameter(help="Receipt file to attach")] = None, +) -> None: + """Record a purchased item to bill back to the client.""" + from ttd.config.loader import get_settings + + project = project or get_settings().defaults.project + if project is None: + raise TtdError("No project given and no [defaults].project — pass --project") + expense = await svc.add_expense( + project, description, _amount(amount), incurred_date=_parse_date(on), note=note + ) + if receipt is not None: + await svc.add_receipt(str(expense.id)[:8], receipt) + success(f"Logged {format_money(expense.amount, 'USD')} — {expense.description}") + + +@app.command(name="list") +@with_db +async def list_( + *, + project: Annotated[str | None, Parameter(name=["--project", "-p"])] = None, + client: str | None = None, + date_from: Annotated[str | None, Parameter(name="--from")] = None, + date_to: Annotated[str | None, Parameter(name="--to")] = None, + unbilled: Annotated[bool, Parameter(help="Only not-yet-invoiced expenses")] = False, + as_json: Annotated[bool, Parameter(name="--json")] = False, +) -> None: + """List expenses, oldest first.""" + rows = await svc.list_expenses( + project_slug=project, client_slug=client, + date_from=_parse_date(date_from), date_to=_parse_date(date_to), + unbilled_only=unbilled, + ) + if as_json: + payload = [ + { + "id": str(r.expense.id), + "client": r.client.slug, + "project": r.project.slug, + "date": r.expense.incurred_date.isoformat(), + "description": r.expense.description, + "amount": str(r.expense.amount), + "note": r.expense.note, + "invoiced": r.expense.invoice_id is not None, + "receipt": r.has_receipt, + } + for r in rows + ] + console.print_json(json.dumps(payload)) + return + if not rows: + console.print('[muted]No expenses — `ttd expense add "Claude Code" 100 -p PROJECT`[/muted]') + return + t = table("ID", "Date", "Project", "Description", "Amount", "") + total = Decimal("0") + for r in rows: + e = r.expense + total += e.amount + flags = (" [accent]·inv[/accent]" if e.invoice_id else "") + ( + " [muted]📎[/muted]" if r.has_receipt else "" + ) + t.add_row( + str(e.id)[:8], + e.incurred_date.strftime("%a %b %-d"), + f"{r.client.slug}/{r.project.slug}", + e.description, + format_money(e.amount, r.client.currency) + flags, + "", + ) + console.print(t) + console.print(f"Total: [bold]{format_money(total, 'USD')}[/bold]") + + +@app.command(name="edit") +@with_db +async def edit( + uid: str, + *, + amount: str | None = None, + description: Annotated[str | None, Parameter(name=["--description", "-d"])] = None, + note: Annotated[str | None, Parameter(name=["--note", "-n"])] = None, + on: Annotated[str | None, Parameter(name="--on")] = None, + project: Annotated[str | None, Parameter(name=["--project", "-p"])] = None, +) -> None: + """Edit an expense (refuses if it's on an invoice).""" + expense = await svc.edit_expense( + uid, + amount=_amount(amount) if amount is not None else None, + description=description, + note=note, + incurred_date=_parse_date(on), + project_slug=project, + ) + success(f"Updated expense {str(expense.id)[:8]}") + + +@app.command(name="rm") +@with_db +async def rm(uid: str) -> None: + """Delete an expense (refuses if it's on an invoice).""" + expense = await svc.delete_expense(uid) + success(f"Deleted expense {str(expense.id)[:8]} ({format_money(expense.amount, 'USD')})") + + +@receipt_app.command(name="add") +@with_db +async def receipt_add(uid: str, path: Path) -> None: + """Attach (or replace) a receipt on an expense.""" + receipt = await svc.add_receipt(uid, path) + success(f"Attached {receipt.filename} to expense {uid}") + + +@receipt_app.command(name="get") +@with_db +async def receipt_get(uid: str, *, out: Annotated[Path | None, Parameter(help="Output file")] = None) -> None: + """Write an expense's receipt to a file.""" + result = await svc.get_receipt(uid) + if result is None: + raise TtdError(f"Expense {uid} has no receipt") + filename, _content_type, data = result + dest = out or Path(filename) + dest.write_bytes(data) + success(f"Wrote {dest}") + + +@receipt_app.command(name="rm") +@with_db +async def receipt_rm(uid: str) -> None: + """Remove an expense's receipt.""" + await svc.remove_receipt(uid) + success(f"Removed receipt from expense {uid}") +``` + +> **Interactive form + recall:** add an `-i` form to `add` mirroring `InvoiceCreateInput` in `cli/invoices.py` (a pydantic model fed to `interactive_fill`), with a select widget whose choices come from `svc.recent_expenses(...)`. Fold this in here only if `interactive_fill` supports dynamic per-field choices the way `client_choices` is used; otherwise leave a follow-up note and ship the explicit CLI. Do not block this task on the form. + +- [ ] **Step 4: Register the sub-app** + +In `src/ttd/cli/app.py`, add `expenses` to the import tuple in `_register_subcommands` and register it after `entries`: + +```python + from ttd.cli import ( + clients, config_cmds, db_cmds, entries, expenses, export, import_, + invoices, log, projects, reports, taxes, timer, + ) + ... + app.command(entries.app) + app.command(expenses.app) +``` + +- [ ] **Step 5: Run test + lint** + +Run: `uv run pytest tests/test_storage/test_expenses.py -k cli_app -v && uv run ruff check src/ttd/cli/expenses.py && uv run ty check` +Expected: test PASS, lint clean. + +- [ ] **Step 6: Manual smoke (optional but recommended)** + +Run: +```bash +uv run ttd client add "Acme Corp" --rate 150 && uv run ttd project add "API Rewrite" --client acme-corp +uv run ttd expense add "Claude Code" 100 -p api-rewrite && uv run ttd expense list +``` +Expected: a one-row table totaling $100.00. + +- [ ] **Step 7: Commit** + +```bash +git add src/ttd/cli/expenses.py src/ttd/cli/app.py tests/test_storage/test_expenses.py +git commit -m "feat: ttd expense CLI (add/list/edit/rm + receipt group)" +``` + +--- + +## Task 5: Invoicing — expense draft lines, untaxed totals, persist + +**Files:** +- Modify: `src/ttd/services/invoicing.py` +- Test: `tests/test_services/test_invoicing_expenses.py` + +**Interfaces:** +- Consumes: `Expense`, `InvoiceExpenseLine`, `pk` (Task 1); `build_draft`, `persist_draft`, `get_invoice`, `Draft`, `InvoiceView` (existing). +- Produces: + - `@dataclass DraftExpenseLine(expense: Expense, incurred_date: date, description: str, amount: Decimal)` + - `Draft.expense_lines: list[DraftExpenseLine]` and `Draft.expenses_subtotal: Decimal` + - `InvoiceView.expense_lines: list[InvoiceExpenseLine]` + - Updated totals helper so `total = subtotal + tax + expenses_subtotal`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_services/test_invoicing_expenses.py +from datetime import date +from decimal import Decimal + +from ttd.config.schema import Settings +from ttd.reporting import periods +from ttd.services import expenses as expense_svc +from ttd.services import invoicing as svc +from ttd.services import clients as client_svc +from ttd.services import projects as project_svc +from ttd.storage.models import Expense + + +async def _client_project(db): + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + + +def _june() -> periods.Period: + return periods.range_period(date(2026, 6, 1), date(2026, 6, 30)) + + +async def test_draft_includes_unbilled_expenses_untaxed(db): + await _client_project(db) + await expense_svc.add_expense( + "api-rewrite", "Claude Code", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + settings = Settings() # tax_rate defaults to 0 + draft = await svc.build_draft("acme-corp", _june(), settings) + assert draft.expenses_subtotal == Decimal("100") + assert draft.subtotal == Decimal("0") # no time entries + assert draft.total == Decimal("100") + + +async def test_persist_locks_expenses_and_stores_subtotal(db): + await _client_project(db) + exp = await expense_svc.add_expense( + "api-rewrite", "Claude Code", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + settings = Settings() + draft = await svc.build_draft("acme-corp", _june(), settings) + invoice = await svc.persist_draft(draft, settings) + + refetched = await Expense.get_or_none(exp.id) + assert refetched.invoice_id == invoice.id # locked + assert invoice.expenses_subtotal == Decimal("100") + view = await svc.get_invoice(invoice.number) + assert len(view.expense_lines) == 1 + assert view.expense_lines[0].amount == Decimal("100") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_services/test_invoicing_expenses.py -v` +Expected: FAIL — `AttributeError: 'Draft' object has no attribute 'expenses_subtotal'`. + +- [ ] **Step 3: Extend the dataclasses** + +In `src/ttd/services/invoicing.py`, import the new model and add the dataclass + fields: + +```python +from ttd.storage.models import ( # add to existing import + ..., + Expense, + InvoiceExpenseLine, +) +``` + +```python +@dataclass +class DraftExpenseLine: + expense: Expense + incurred_date: date + description: str + amount: Decimal + + +@dataclass +class Draft: + client: Client + period: Period + lines: list[DraftLine] + expense_lines: list[DraftExpenseLine] # NEW + subtotal: Decimal + expenses_subtotal: Decimal # NEW + tax: Decimal + total: Decimal + number: str | None = None +``` + +Add `expense_lines` to `InvoiceView`: + +```python +@dataclass +class InvoiceView: + invoice: Invoice + client: Client + lines: list[InvoiceLine] + expense_lines: list[InvoiceExpenseLine] # NEW + project_names: dict +``` + +- [ ] **Step 4: Build expense lines in `build_draft`; update totals** + +Replace `_draft_totals` and the tail of `build_draft`: + +```python +def _draft_totals( + lines: list[DraftLine], expense_lines: list["DraftExpenseLine"], tax_rate: Decimal +) -> tuple[Decimal, Decimal, Decimal, Decimal]: + subtotal = sum((line.amount for line in lines), Decimal("0")) + expenses_subtotal = sum((e.amount for e in expense_lines), Decimal("0")) + tax = to_cents(subtotal * tax_rate) # time only — expenses are untaxed + total = subtotal + tax + expenses_subtotal + return subtotal, expenses_subtotal, tax, total +``` + +In `build_draft`, after building `lines`, gather expenses and relax the empty-guard: + +```python + expenses = [ + e + for e in await Expense.all() + if e.project_id in projects + and e.invoice_id is None + and period.start <= e.incurred_date <= period.end + ] + if not entries and not expenses: + raise TtdError( + f"No uninvoiced billable entries or expenses for '{client_slug}' in {period.label}" + ) + + lines = await _build_lines_from_entries(entries, client, projects, settings) + expense_lines = [ + DraftExpenseLine(e, e.incurred_date, e.description, e.amount) + for e in sorted(expenses, key=lambda e: (e.incurred_date, e.created_at)) + ] + subtotal, expenses_subtotal, tax, total = _draft_totals( + lines, expense_lines, settings.invoice.tax_rate + ) + return Draft( + client=client, + period=period, + lines=lines, + expense_lines=expense_lines, + subtotal=subtotal, + expenses_subtotal=expenses_subtotal, + tax=tax, + total=total, + ) +``` + +> The current `build_draft` raises when `not entries`; replace that guard with the combined one above. Remove the old `if not entries:` block. + +- [ ] **Step 5: Persist expense lines and lock expenses** + +In `persist_draft`, set `expenses_subtotal` on the `Invoice(...)` constructor: + +```python + expenses_subtotal=draft.expenses_subtotal, +``` + +Inside the `async with transaction():` block, after the `InvoiceLine` loop, add: + +```python + for eline in draft.expense_lines: + await InvoiceExpenseLine( + id=uuid4(), + invoice_id=pk(invoice), + expense_id=pk(eline.expense), + incurred_date=eline.incurred_date, + description=eline.description, + amount=eline.amount, + ).save() + expense = await Expense.get_or_none(pk(eline.expense)) + if expense is not None: + expense.invoice_id = invoice.id + await expense.save() +``` + +- [ ] **Step 6: Load expense lines in `get_invoice`** + +In `get_invoice`, after loading `lines`, add and pass through: + +```python + expense_lines = await InvoiceExpenseLine.where(lambda li: li.invoice_id == invoice.id).all() + expense_lines.sort(key=lambda li: (li.incurred_date, li.description)) + ... + return InvoiceView(invoice, client, lines, expense_lines, names) +``` + +- [ ] **Step 7: Fix other `_draft_totals` / `InvoiceView` / `Draft` callers** + +`preview_refresh` calls `_draft_totals(after_lines, settings.invoice.tax_rate)` — update it to pass an expense-lines argument. For now (refresh expense support lands in Task 6) pass the invoice's existing expense lines so totals stay correct: + +```python + after_subtotal, after_expenses, after_tax, after_total = _draft_totals( + after_lines, [], settings.invoice.tax_rate + ) +``` + +(Task 6 replaces the `[]` with real refreshed expense lines.) Update any other construction of `Draft(...)` or `InvoiceView(...)` in the file (search for them) to include the new fields. The `_print_draft` CLI helper is updated in Task 7. + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `uv run pytest tests/test_services/test_invoicing_expenses.py tests/test_storage -v && uv run ty check` +Expected: PASS. Also run the full existing invoicing suite to catch signature breaks: `uv run pytest tests/test_services -v`. + +- [ ] **Step 9: Commit** + +```bash +git add src/ttd/services/invoicing.py tests/test_services/test_invoicing_expenses.py +git commit -m "feat: bill expenses on invoices (untaxed totals, lock on persist)" +``` + +--- + +## Task 6: Invoicing — void release + refresh + +**Files:** +- Modify: `src/ttd/services/invoicing.py` +- Test: `tests/test_services/test_invoicing_expenses.py` (append) + +**Interfaces:** +- Consumes: everything from Task 5. +- Produces: void nulls `expense.invoice_id`; refresh rebuilds expense lines, updates `expenses_subtotal`/`total`, and blocks amount changes on paid invoices. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_services/test_invoicing_expenses.py (append) +async def test_void_releases_expenses(db): + await _client_project(db) + exp = await expense_svc.add_expense( + "api-rewrite", "Claude", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + settings = Settings() + invoice = await svc.persist_draft(await svc.build_draft("acme-corp", _june(), settings), settings) + await svc.mark_invoice(invoice.number, "void") + assert (await Expense.get_or_none(exp.id)).invoice_id is None + + +async def test_refresh_drops_deleted_expense(db): + await _client_project(db) + exp = await expense_svc.add_expense( + "api-rewrite", "Claude", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + settings = Settings() + invoice = await svc.persist_draft(await svc.build_draft("acme-corp", _june(), settings), settings) + # Release + delete the expense, then refresh. + await svc.mark_invoice(invoice.number, "void") + # Re-invoice fresh so the expense is linked again, then delete underlying expense via direct unlink + # (simulating an expense removed from the period): + invoice2 = await svc.persist_draft(await svc.build_draft("acme-corp", _june(), settings), settings) + locked = await Expense.get_or_none(exp.id) + locked.invoice_id = None + await locked.save() + await locked.delete() + preview = await svc.preview_refresh(invoice2.number, settings) + fresh = await svc.apply_refresh(invoice2.number, preview, settings) + assert fresh.expenses_subtotal == Decimal("0") + assert fresh.total == Decimal("0") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_services/test_invoicing_expenses.py -k "void or refresh" -v` +Expected: FAIL — void leaves `invoice_id` set / refresh totals wrong. + +- [ ] **Step 3: Release expenses on void** + +In `mark_invoice`, inside the `if status == "void":` transaction block, after the entry-release loop add: + +```python + for expense in await Expense.where(lambda e: e.invoice_id == invoice.id).all(): + expense.invoice_id = None + await expense.save() +``` + +- [ ] **Step 4: Rebuild expense lines on refresh** + +In `preview_refresh`, after computing `after_lines`, build the current expense lines from the invoice's linked expenses: + +```python + linked_expenses = await Expense.where(lambda e: e.invoice_id == invoice.id).all() + after_expense_lines = [ + DraftExpenseLine(e, e.incurred_date, e.description, e.amount) + for e in sorted(linked_expenses, key=lambda e: (e.incurred_date, e.created_at)) + ] + after_subtotal, after_expenses, after_tax, after_total = _draft_totals( + after_lines, after_expense_lines, settings.invoice.tax_rate + ) +``` + +Add `after_expenses` to `RefreshPreview` (a new field `after_expenses_subtotal: Decimal` and a `before_expenses_subtotal: Decimal = invoice.expenses_subtotal`), and fold expenses into `totals_changed`: + +```python + totals_changed = ( + before_subtotal != after_subtotal + or before_tax != after_tax + or before_total != after_total + or invoice.expenses_subtotal != after_expenses + ) +``` + +Stash `after_expense_lines` on the preview (add a field `after_expense_lines: list[DraftExpenseLine]`) so `apply_refresh` can persist them without recomputing. + +- [ ] **Step 5: Persist expense changes in `apply_refresh`** + +In the non-paid branch of `apply_refresh`, after reconciling `InvoiceLine`s and before saving the invoice, reconcile expense lines (delete all + rewrite is simplest and safe — expense lines have no per-line history): + +```python + for stale in await InvoiceExpenseLine.where( + lambda li: li.invoice_id == invoice.id + ).all(): + await stale.delete() + for eline in fresh.after_expense_lines: + await InvoiceExpenseLine( + id=uuid4(), + invoice_id=pk(invoice), + expense_id=pk(eline.expense), + incurred_date=eline.incurred_date, + description=eline.description, + amount=eline.amount, + ).save() + invoice.expenses_subtotal = fresh.after_expenses_subtotal +``` + +And update the invoice-total assignments already present to use the refreshed values: + +```python + invoice.subtotal = fresh.after_subtotal + invoice.tax = fresh.after_tax + invoice.total = fresh.after_total +``` + +> Paid invoices: expense **amounts** are billing fields, so they fall under the existing paid-invoice block (`PAID_REFRESH_BLOCKED`) — no expense rewrite happens in the paid branch, matching time-line behavior. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `uv run pytest tests/test_services/test_invoicing_expenses.py -v && uv run pytest tests/test_services -v` +Expected: PASS (all green, including pre-existing invoicing tests). + +- [ ] **Step 7: Commit** + +```bash +git add src/ttd/services/invoicing.py tests/test_services/test_invoicing_expenses.py +git commit -m "feat: release and refresh expenses through invoice lifecycle" +``` + +--- + +## Task 7: Rendering — PDF + markdown expense section (no receipts yet) + +**Files:** +- Modify: `src/ttd/invoicing/pdf.py` +- Modify: `src/ttd/invoicing/markdown.py`, `src/ttd/invoicing/templates/invoice.md.j2` +- Modify: `src/ttd/cli/invoices.py` (`_print_draft` to show expenses) +- Test: `tests/test_invoicing/test_expense_render.py` + +**Interfaces:** +- Consumes: `InvoiceView.expense_lines`, `Invoice.expenses_subtotal` (Tasks 5–6). +- Produces: PDF + markdown both render an "Reimbursable expenses" section and the expenses line in totals, omitted entirely when there are no expenses. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_invoicing/test_expense_render.py +from datetime import date +from decimal import Decimal + +from ttd.config.schema import Settings +from ttd.invoicing.markdown import render_markdown +from ttd.invoicing.pdf import render_pdf +from ttd.reporting import periods +from ttd.services import clients as client_svc +from ttd.services import expenses as expense_svc +from ttd.services import invoicing as svc +from ttd.services import projects as project_svc + + +async def _invoice_with_expense(db): + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + await expense_svc.add_expense( + "api-rewrite", "Claude Code", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + period = periods.range_period(date(2026, 6, 1), date(2026, 6, 30)) + settings = Settings() + invoice = await svc.persist_draft(await svc.build_draft("acme-corp", period, settings), settings) + return await svc.get_invoice(invoice.number), settings + + +async def test_markdown_shows_expense_section(db): + view, settings = await _invoice_with_expense(db) + md = render_markdown(view, settings) + assert "Reimbursable expenses" in md + assert "Claude Code" in md + assert "Expenses" in md # totals line + + +async def test_pdf_renders_with_expenses(db, tmp_path): + view, settings = await _invoice_with_expense(db) + out = render_pdf(view, settings, tmp_path / "inv.pdf") + assert out.exists() and out.stat().st_size > 0 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_invoicing/test_expense_render.py -v` +Expected: FAIL — markdown lacks "Reimbursable expenses". + +- [ ] **Step 3: Update the markdown template** + +In `src/ttd/invoicing/templates/invoice.md.j2`, after the `## Work` loop and before the totals table, add a guarded expenses section: + +```jinja +{% if expense_lines %} +## Reimbursable expenses + +{% for e in expense_lines -%} +**{{ e.incurred_date.strftime("%b %-d") }}** · {{ e.description }} · **{{ money(e.amount) }}** + +{% endfor %} +{% endif -%} +``` + +In the totals table, add the expenses row before `Total due`: + +```jinja +| Subtotal | {{ money(invoice.subtotal) }} | +{% if invoice.tax -%} +| Tax ({{ "%.2f" | format(invoice.tax_rate * 100) }}%) | {{ money(invoice.tax) }} | +{% endif -%} +{% if invoice.expenses_subtotal -%} +| Expenses (reimbursable) | {{ money(invoice.expenses_subtotal) }} | +{% endif -%} +| **Total due** | **{{ money(invoice.total) }}** | +``` + +- [ ] **Step 4: Pass `expense_lines` to the template** + +In `src/ttd/invoicing/markdown.py`, add `expense_lines=view.expense_lines` to `template.render(...)`. + +- [ ] **Step 5: Update the PDF renderer** + +In `src/ttd/invoicing/pdf.py`, after the time `lines` table and before the totals box, render an expenses table when present: + +```python + if view.expense_lines: + pdf.ln(3) + pdf.set_font("helvetica", style="B", size=9) + pdf.cell(0, 6, "REIMBURSABLE EXPENSES", new_x=XPos.LMARGIN, new_y=YPos.NEXT) + pdf.set_font("helvetica", size=9) + with pdf.table( + col_widths=(20, 138, 22), + text_align=("LEFT", "LEFT", "RIGHT"), + borders_layout="HORIZONTAL_LINES", + line_height=6.5, + padding=1.2, + ) as etable: + header = etable.row() + pdf.set_font("helvetica", style="B", size=8) + for col in ("DATE", "DESCRIPTION", "AMOUNT"): + header.cell(col) + pdf.set_font("helvetica", size=9) + for eline in view.expense_lines: + row = etable.row() + row.cell(eline.incurred_date.strftime("%b %-d")) + row.cell(_latin(eline.description)) + row.cell(_money(eline.amount, currency)) +``` + +In the totals box, insert an expenses line between tax and total: + +```python + rows = [("Subtotal", _money(invoice.subtotal, currency))] + if invoice.tax: + rows.append((f"Tax ({invoice.tax_rate * 100:.2f}%)", _money(invoice.tax, currency))) + if invoice.expenses_subtotal: + rows.append(("Expenses", _money(invoice.expenses_subtotal, currency))) + rows.append(("Total due", _money(invoice.total, currency))) +``` + +- [ ] **Step 6: Update `_print_draft` in the CLI** + +In `src/ttd/cli/invoices.py`, in `_print_draft`, after the time-lines table, print expenses when present: + +```python + if draft.expense_lines: + et = table("Date", "Description", "Amount") + for e in draft.expense_lines: + et.add_row( + e.incurred_date.strftime("%a %b %-d"), e.description, format_money(e.amount, currency) + ) + console.print(et) + console.print(f"Expenses: {format_money(draft.expenses_subtotal, currency)}") +``` + +- [ ] **Step 7: Add a regression test — no expenses renders unchanged** + +```python +# tests/test_invoicing/test_expense_render.py (append) +async def test_no_expense_invoice_omits_section(db, tmp_path): + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + from ttd.services import entries as entry_svc + from datetime import datetime + await entry_svc.log_entry( + "2026-06-10 9am-11am", "api-rewrite", now=datetime(2026, 6, 10, 12, 0) + ) + period = periods.range_period(date(2026, 6, 1), date(2026, 6, 30)) + settings = Settings() + invoice = await svc.persist_draft(await svc.build_draft("acme-corp", period, settings), settings) + view = await svc.get_invoice(invoice.number) + md = render_markdown(view, settings) + assert "Reimbursable expenses" not in md + assert "Expenses (reimbursable)" not in md +``` + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `uv run pytest tests/test_invoicing -v` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add src/ttd/invoicing/ src/ttd/cli/invoices.py tests/test_invoicing/test_expense_render.py +git commit -m "feat: render expense section on PDF and markdown invoices" +``` + +--- + +## Task 8: Receipt pages in PDF + config + pypdf + +**Files:** +- Modify: `pyproject.toml` (add `pypdf`) +- Modify: `src/ttd/config/schema.py` (`InvoiceConfig.attach_receipts`) +- Modify: `src/ttd/invoicing/pdf.py` (receipt pages) +- Modify: `src/ttd/services/invoicing.py` (`invoice_has_receipts`) +- Test: `tests/test_invoicing/test_expense_render.py` (append) + +**Interfaces:** +- Consumes: `get_receipt` (Task 3), `InvoiceView` (Task 5). +- Produces: + - `settings.invoice.attach_receipts: bool` (default `False`) + - `async svc.invoice_has_receipts(view: InvoiceView) -> bool` + - `render_pdf(view, settings, path, *, receipts: bool = False) -> Path` (new keyword) + +- [ ] **Step 1: Add the dependency** + +Run: `uv add pypdf` +Expected: `pyproject.toml` gains `pypdf` under dependencies; lockfile updates. + +- [ ] **Step 2: Write the failing tests** + +```python +# tests/test_invoicing/test_expense_render.py (append) +from pypdf import PdfReader +from ttd.services import invoicing as svc2 # alias to avoid clashing if needed + + +async def test_pdf_appends_pdf_receipt_pages(db, tmp_path): + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + exp = await expense_svc.add_expense( + "api-rewrite", "Claude", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + # a real 1-page PDF as the receipt + from fpdf import FPDF + receipt_pdf = tmp_path / "receipt.pdf" + r = FPDF(); r.add_page(); r.set_font("helvetica", size=12); r.cell(0, 10, "RECEIPT"); r.output(str(receipt_pdf)) + await expense_svc.add_receipt(str(exp.id)[:8], receipt_pdf) + + period = periods.range_period(date(2026, 6, 1), date(2026, 6, 30)) + settings = Settings() + invoice = await svc.persist_draft(await svc.build_draft("acme-corp", period, settings), settings) + view = await svc.get_invoice(invoice.number) + + without = render_pdf(view, settings, tmp_path / "no.pdf", receipts=False) + with_r = render_pdf(view, settings, tmp_path / "yes.pdf", receipts=True) + assert len(PdfReader(str(with_r)).pages) > len(PdfReader(str(without)).pages) + + +async def test_invoice_has_receipts(db, tmp_path): + view, settings = await _invoice_with_expense(db) # expense, no receipt + assert await svc.invoice_has_receipts(view) is False +``` + +- [ ] **Step 3: Add the config field** + +In `src/ttd/config/schema.py`, `InvoiceConfig`: + +```python + attach_receipts: bool = False + """Append expense receipts as pages when rendering invoice PDFs.""" +``` + +- [ ] **Step 4: Add `invoice_has_receipts`** + +In `src/ttd/services/invoicing.py`: + +```python +from ttd.storage.models import ExpenseReceipt # add to imports + + +@in_db_session +async def invoice_has_receipts(view: InvoiceView) -> bool: + """True if any of the invoice's linked expenses has a stored receipt.""" + if not view.expense_lines: + return False + expense_ids = {li.expense_id for li in view.expense_lines} + receipts = await ExpenseReceipt.all() + return any(r.expense_id in expense_ids for r in receipts) +``` + +- [ ] **Step 5: Append receipt pages in `render_pdf`** + +The renderer must NOT touch the DB. Receipts arrive already decoded from the caller +(the CLI loads them inside its async session — Task 9). Change the signature to accept a +list of `(filename, content_type, bytes)` and split image vs PDF receipts: + +```python +import io + +from pypdf import PdfReader, PdfWriter + +Receipt = tuple[str, str, bytes] # (filename, content_type, raw bytes) + + +def render_pdf( + view: InvoiceView, + settings: Settings, + path: Path, + *, + receipts: list[Receipt] | None = None, +) -> Path: + ... # all existing rendering up to the footer note is unchanged + path.parent.mkdir(parents=True, exist_ok=True) + if not receipts: + pdf.output(str(path)) + return path + _write_with_receipts(pdf, receipts, path) + return path + + +def _write_with_receipts(pdf: "FPDF", receipts: list[Receipt], path: Path) -> None: + """Append image receipts as fpdf2 pages, then merge PDF receipts via pypdf.""" + images = [r for r in receipts if r[1].startswith("image/")] + pdfs = [r for r in receipts if r[1] == "application/pdf"] + + for _filename, _ct, data in images: + pdf.add_page() + pdf.image(io.BytesIO(data), x=18, y=24, w=pdf.w - 36) + + invoice_bytes = bytes(pdf.output()) # fpdf2 returns the PDF as bytes when no dest given + + writer = PdfWriter() + for page in PdfReader(io.BytesIO(invoice_bytes)).pages: + writer.add_page(page) + for _filename, _ct, data in pdfs: + for page in PdfReader(io.BytesIO(data)).pages: + writer.add_page(page) + with open(path, "wb") as fh: + writer.write(fh) +``` + +> Receipts whose `content_type` is neither image nor PDF are silently skipped — only +> image and PDF receipts can be embedded. (Most receipts are PDFs or images.) + +- [ ] **Step 6: Align the Step 2 test with the parameter form** + +The test in Step 2 must build the decoded receipt list and pass it in: + +```python + decoded = [await expense_svc.get_receipt(str(exp.id)[:8])] + with_r = render_pdf(view, settings, tmp_path / "yes.pdf", receipts=decoded) + without = render_pdf(view, settings, tmp_path / "no.pdf", receipts=None) + assert len(PdfReader(str(with_r)).pages) > len(PdfReader(str(without)).pages) +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `uv run pytest tests/test_invoicing/test_expense_render.py -v` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add pyproject.toml uv.lock src/ttd/config/schema.py src/ttd/invoicing/pdf.py src/ttd/services/invoicing.py tests/test_invoicing/test_expense_render.py +git commit -m "feat: append expense receipts to invoice PDFs (opt-in)" +``` + +--- + +## Task 9: CLI invoices — format choice + receipts + markdown gating + +**Files:** +- Modify: `src/ttd/cli/invoices.py` +- Test: `tests/test_invoicing/test_expense_render.py` (append a gating unit test on a helper) + +**Interfaces:** +- Consumes: `invoice_has_receipts`, `get_receipt`, `render_pdf(..., receipts=...)`, `settings.invoice.attach_receipts`. +- Produces: updated `create`/`render` commands — default PDF, `--receipts` flag, markdown disabled (hard error) when receipts present. + +- [ ] **Step 1: Write the failing test (gating helper)** + +Factor the gating decision into a pure helper so it's unit-testable without invoking Cyclopts: + +```python +# tests/test_invoicing/test_expense_render.py (append) +import pytest +from ttd.cli.invoices import _resolve_formats +from ttd.core.errors import TtdError + + +def test_resolve_formats_defaults_to_pdf(): + assert _resolve_formats(pdf=False, md=False, receipts=False, has_receipts=False) == (True, False) + + +def test_resolve_formats_md_blocked_when_receipts_present(): + with pytest.raises(TtdError): + _resolve_formats(pdf=False, md=True, receipts=True, has_receipts=True) + + +def test_resolve_formats_md_ok_when_no_receipts_on_invoice(): + assert _resolve_formats(pdf=True, md=True, receipts=True, has_receipts=False) == (True, True) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_invoicing/test_expense_render.py -k resolve_formats -v` +Expected: FAIL — `ImportError: cannot import name '_resolve_formats'`. + +- [ ] **Step 3: Add the gating helper** + +In `src/ttd/cli/invoices.py`: + +```python +def _resolve_formats(*, pdf: bool, md: bool, receipts: bool, has_receipts: bool) -> tuple[bool, bool]: + """Decide which formats to render. Default to PDF; block markdown when an + invoice carries receipts (markdown can't render them).""" + if not pdf and not md: + pdf = True # default to the canonical, sendable artifact + if md and receipts and has_receipts: + raise TtdError( + "This invoice has receipts; Markdown can't render them. " + "Drop --md, or omit --receipts to generate Markdown without them." + ) + return pdf, md +``` + +- [ ] **Step 4: Wire receipts + formats into `_render_files`** + +Rewrite `_render_files` to take the resolved flags and load receipts when asked: + +```python +async def _render_files( + view: svc.InvoiceView, *, pdf: bool, md: bool, receipts: bool, out: Path | None +) -> None: + settings = get_settings() + stem = _output_paths(view, out) + if pdf: + decoded = None + if receipts: + from ttd.services import expenses as expense_svc + + decoded = [] + for line in view.expense_lines: + got = await expense_svc.get_receipt(str(line.expense_id)[:8]) + if got is not None: + decoded.append(got) + path = render_pdf(view, settings, stem.with_suffix(".pdf"), receipts=decoded) + success(f"Wrote {path}") + if md: + path = write_markdown(view, settings, stem.with_suffix(".md")) + success(f"Wrote {path}") +``` + +- [ ] **Step 5: Update `create` and `render` commands** + +In both `create` and `render`, add a `receipts` parameter and call the gating helper. For `create` (after `view` is obtained): + +```python + receipts_on = receipts or settings.invoice.attach_receipts + has_r = await svc.invoice_has_receipts(view) + pdf, md = _resolve_formats(pdf=pdf, md=md, receipts=receipts_on, has_receipts=has_r) + await _render_files(view, pdf=pdf, md=md, receipts=receipts_on, out=out) +``` + +Add the flag to the signature of both commands: + +```python + receipts: Annotated[bool, Parameter(help="Append expense receipts to the PDF")] = False, +``` + +For `render`, remove the old `if not pdf and not md: pdf = md = True` line — `_resolve_formats` now owns the default. Load the view first, compute `has_r`, then resolve and render. + +> `_render_files` is now async and awaited; ensure both call sites `await` it (they're already inside `@with_db` async commands). + +- [ ] **Step 6: Run tests + full suite** + +Run: `uv run pytest tests/test_invoicing -v && uv run ty check && uv run ruff check src/ttd/cli/invoices.py` +Expected: PASS, clean. + +- [ ] **Step 7: Commit** + +```bash +git add src/ttd/cli/invoices.py tests/test_invoicing/test_expense_render.py +git commit -m "feat: invoice format choice (default PDF) with receipt-aware markdown gating" +``` + +--- + +## Task 10: JSON backup — expenses + receipts (envelope v2) + +**Architecture note (verified):** Export flows `export_records() -> (records, meta)`, then +the CLI calls `fmt_obj.writer(records, path, meta)`. `meta` already carries +`clients`/`projects`; we add `expenses`/`receipts` to it, and only `write_json` reads them +(CSV/XLSX/Numbers writers ignore the extra keys). Import flows through +`importer.build_plan`/`apply_plan`, which are **`EntryRecord`-only**. Expenses therefore get +a **separate restore function**, not a shoehorn into `ImportPlan`. The JSON `meta` is read +back via `json_io.read_metadata`. + +**Files:** +- Modify: `src/ttd/services/interchange_svc.py` (`export_records` adds expenses/receipts to meta) +- Modify: `src/ttd/interchange/json_io.py` (envelope v2 + `read_metadata`) +- Modify: `src/ttd/interchange/importer.py` (add `restore_expenses`) +- Modify: `src/ttd/cli/import_.py` (call `restore_expenses` for JSON files) +- Test: `tests/test_interchange/test_expense_backup.py` + +**Interfaces:** +- Consumes: `Expense`, `ExpenseReceipt` (Task 1); `list_expenses` (Task 2); `read_metadata` (existing). +- Produces: + - `export_records(...)` meta gains `"expenses": list[dict]` and `"receipts": list[dict]`. + - `ENVELOPE_VERSION = 2`; `read_metadata` returns `expenses`/`receipts` (empty for v1). + - `async importer.restore_expenses(metadata, *, on_conflict="skip", create_missing=False) -> int` + +- [ ] **Step 1: Write the failing test (through the real seams)** + +```python +# tests/test_interchange/test_expense_backup.py +import json +from datetime import date +from decimal import Decimal + +from ttd.interchange import json_io +from ttd.interchange.importer import restore_expenses +from ttd.services import clients as client_svc +from ttd.services import expenses as expense_svc +from ttd.services import projects as project_svc +from ttd.services.interchange_svc import export_records +from ttd.storage.models import Expense, ExpenseReceipt + + +async def test_json_backup_roundtrips_expenses_and_receipts(db, tmp_path): + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + exp = await expense_svc.add_expense( + "api-rewrite", "Claude Code", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + src = tmp_path / "r.pdf"; src.write_bytes(b"%PDF-1.4\n\xff") + await expense_svc.add_receipt(str(exp.id)[:8], src) + + # Export -> json + records, meta = await export_records() + assert len(meta["expenses"]) == 1 + assert len(meta["receipts"]) == 1 + backup = tmp_path / "backup.json" + json_io.write_json(records, backup, meta) + + # Wipe, then restore from the file's metadata. + for e in await Expense.all(): + await e.delete() + for r in await ExpenseReceipt.all(): + await r.delete() + restored_meta = json_io.read_metadata(backup) + written = await restore_expenses(restored_meta, on_conflict="update", create_missing=True) + + assert written == 1 + restored = await Expense.all() + assert len(restored) == 1 and restored[0].amount == Decimal("100") + assert restored[0].invoice_id is None # imports never re-link invoices + assert len(await ExpenseReceipt.all()) == 1 + + +async def test_v1_metadata_without_expenses_restores_nothing(db, tmp_path): + payload = {"ttd_export": 1, "clients": [], "projects": [], "entries": []} + p = tmp_path / "v1.json"; p.write_text(json.dumps(payload)) + written = await restore_expenses(json_io.read_metadata(p), create_missing=True) + assert written == 0 + assert await Expense.all() == [] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_interchange/test_expense_backup.py -v` +Expected: FAIL — `KeyError: 'expenses'` (meta has no expenses) / `ImportError` for `restore_expenses`. + +- [ ] **Step 3: Add expenses + receipts to the export meta** + +In `src/ttd/services/interchange_svc.py`, import the new models and expense service, then +extend the returned meta. After building `projects_meta`, add: + +```python +from ttd.services.expenses import list_expenses # add import +from ttd.storage.models import Client, Expense, ExpenseReceipt, Invoice, Project # extend + + # ... after projects_meta, before the return ... + expense_views = await list_expenses( + project_slug=project_slug, client_slug=client_slug, + date_from=date_from, date_to=date_to, + ) + invoice_numbers = {i.id: i.number for i in await Invoice.all()} + expenses_meta = [ + { + "id": str(v.expense.id), + "client": v.client.slug, + "project": v.project.slug, + "incurred_date": v.expense.incurred_date.isoformat(), + "description": v.expense.description, + "amount": str(v.expense.amount), + "note": v.expense.note, + "invoice_number": invoice_numbers.get(v.expense.invoice_id, "") + if v.expense.invoice_id else "", + } + for v in expense_views + ] + expense_ids = {str(v.expense.id) for v in expense_views} + receipts_meta = [ + { + "expense_id": str(r.expense_id), + "filename": r.filename, + "content_type": r.content_type, + "data_b64": r.data_b64, + } + for r in await ExpenseReceipt.all() + if str(r.expense_id) in expense_ids + ] + return records, { + "clients": clients_meta, + "projects": projects_meta, + "expenses": expenses_meta, + "receipts": receipts_meta, + } +``` + +(Replace the existing `return records, {"clients": ..., "projects": ...}` with the above.) + +- [ ] **Step 4: Extend the JSON envelope** + +In `src/ttd/interchange/json_io.py`, bump version and write/read the new keys: + +```python +ENVELOPE_VERSION = 2 + + +def write_json(records, path, meta): + payload = { + "ttd_export": ENVELOPE_VERSION, + "clients": meta.get("clients", []), + "projects": meta.get("projects", []), + "entries": [ + {**r.to_cells(), "seconds": r.seconds, "billable": r.billable} for r in records + ], + "expenses": meta.get("expenses", []), + "receipts": meta.get("receipts", []), + } + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") +``` + +In `read_metadata`, return the new keys (empty lists for v1 files): + +```python + if isinstance(payload, dict): + return { + "clients": payload.get("clients", []), + "projects": payload.get("projects", []), + "expenses": payload.get("expenses", []), + "receipts": payload.get("receipts", []), + } +``` + +- [ ] **Step 5: Add `restore_expenses` to the importer** + +In `src/ttd/interchange/importer.py`, following the `_create_missing` pattern (resolve +project by `(client, project)` slug, create missing clients/projects from metadata): + +```python +from datetime import date as date_t # add to imports +from ttd.storage.models import Expense, ExpenseReceipt # add + + +async def restore_expenses( + metadata: dict[str, Any], + *, + on_conflict: OnConflict = "skip", + create_missing: bool = False, +) -> int: + """Restore expenses + receipts from a JSON backup's metadata. Returns count written. + + Never sets ``invoice_id`` — imports keep ``invoice_number`` informational only, + mirroring entry import. + """ + expenses = metadata.get("expenses", []) + if not expenses: + return 0 + + if create_missing: + # reuse the client/project bootstrap by faking a plan of the referenced pairs + plan = ImportPlan() + existing_clients = {c.slug for c in await Client.all()} + projects_present = set() + for p in await Project.all(): + cslug = next((c.slug for c in await Client.all() if c.id == p.client_id), None) + if cslug: + projects_present.add((cslug, p.slug)) + for row in expenses: + if row["client"] not in existing_clients: + plan.missing_clients.add(row["client"]) + if (row["client"], row["project"]) not in projects_present: + plan.missing_projects.add((row["client"], row["project"])) + if plan.missing_clients or plan.missing_projects: + await _create_missing(plan, metadata) + + clients = {c.slug: c for c in await Client.all()} + project_map = {} + for p in await Project.all(): + cslug = next((s for s, c in clients.items() if c.id == p.client_id), None) + project_map[(cslug, p.slug)] = p + + existing = {str(e.id): e for e in await Expense.all()} + stamp = datetime.now() + written = 0 + for row in expenses: + key = (row["client"], row["project"]) + if key not in project_map: + continue # unresolved project; skip silently (create_missing handles real ones) + project = project_map[key] + match = existing.get(row["id"]) + if match is not None and match.invoice_id is not None: + continue # never touch invoiced expenses + if match is not None and on_conflict == "skip": + continue + if match is not None and on_conflict == "update": + match.project_id = pk(project) + match.incurred_date = date_t.fromisoformat(row["incurred_date"]) + match.description = row["description"] + match.amount = Decimal(row["amount"]) + match.note = row.get("note", "") + match.updated_at = stamp + await match.save() + else: # new (or duplicate) + from uuid import UUID + await Expense( + id=UUID(row["id"]), + project_id=pk(project), + incurred_date=date_t.fromisoformat(row["incurred_date"]), + description=row["description"], + amount=Decimal(row["amount"]), + note=row.get("note", ""), + created_at=stamp, + updated_at=stamp, + ).save() + written += 1 + + # receipts (replace any existing for that expense) + valid_ids = {row["id"] for row in expenses} + for r in metadata.get("receipts", []): + if r["expense_id"] not in valid_ids: + continue + from uuid import UUID, uuid4 + for old in await ExpenseReceipt.where( + lambda rec, eid=UUID(r["expense_id"]): rec.expense_id == eid + ).all(): + await old.delete() + await ExpenseReceipt( + id=uuid4(), + expense_id=UUID(r["expense_id"]), + filename=r["filename"], + content_type=r["content_type"], + data_b64=r["data_b64"], + ).save() + return written +``` + +> Move the `from uuid import UUID, uuid4` imports to the top of the file instead of inline; +> they're inline here only to keep the diff localized in the plan. + +- [ ] **Step 6: Wire into the import CLI** + +In `src/ttd/cli/import_.py`, after `apply_plan(...)` runs, restore expenses when the file +carried them. Read the existing import command to match its variable names; the addition is: + +```python + from ttd.interchange.importer import restore_expenses + from ttd.interchange.json_io import read_metadata + + metadata = read_metadata(path) # empty dict for non-JSON formats + if metadata.get("expenses"): + n = await restore_expenses(metadata, on_conflict=on_conflict, create_missing=create_missing) + if n: + success(f"Restored {n} expense{'s' if n != 1 else ''}") +``` + +(Place it inside the existing `@with_db` import command, using its `on_conflict`/`create_missing`/`path` variables. Skip during `--dry-run`.) + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `uv run pytest tests/test_interchange -v` +Expected: PASS (new + existing interchange tests green). + +- [ ] **Step 8: Commit** + +```bash +git add src/ttd/interchange/ src/ttd/services/interchange_svc.py src/ttd/cli/import_.py tests/test_interchange/test_expense_backup.py +git commit -m "feat: include expenses and receipts in JSON backup (envelope v2)" +``` + +--- + +## Task 11: TUI — invoice detail expenses + quick-add + +**Files:** +- Modify: `src/ttd/tui/_data.py` +- Modify: `src/ttd/tui/screens/invoices.py` +- Modify: `src/ttd/tui/screens/timesheet.py` (or dashboard) for quick-add binding +- Test: `tests/test_tui/test_expense_data.py` + +**Interfaces:** +- Consumes: `expense_svc`, `invoicing.get_invoice` (`InvoiceView.expense_lines`). +- Produces: `_data` read helpers for expenses; invoice detail shows an expenses table; a quick-add keybinding (`e`) opens an expense form. + +- [ ] **Step 1: Inspect TUI data + invoice screen patterns** + +Run: `uv run grep -rn "def \|BINDINGS\|DataTable" src/ttd/tui/_data.py src/ttd/tui/screens/invoices.py | head -60` +Read both files to learn the exact data-access and screen-composition patterns (the screens call `_data` helpers, which wrap services in a DB session). + +- [ ] **Step 2: Write the failing test (data helper)** + +TUI widgets are hard to unit test; cover the data helper that the screen consumes. + +```python +# tests/test_tui/test_expense_data.py +from datetime import date +from decimal import Decimal + +from ttd.services import clients as client_svc +from ttd.services import expenses as expense_svc +from ttd.services import projects as project_svc +from ttd.tui import _data + + +async def test_recent_expense_choices(db): + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + await expense_svc.add_expense( + "api-rewrite", "Claude Code", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + suggestions = await _data.recent_expense_suggestions(project_slug="api-rewrite") + assert [(s.description, s.amount) for s in suggestions] == [("Claude Code", Decimal("100"))] +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `uv run pytest tests/test_tui/test_expense_data.py -v` +Expected: FAIL — `AttributeError: module 'ttd.tui._data' has no attribute 'recent_expense_suggestions'`. + +- [ ] **Step 4: Add the data helpers** + +In `src/ttd/tui/_data.py`, following the existing helper pattern (wrap in the DB session the file already uses): + +```python +async def recent_expense_suggestions(*, project_slug=None, client_slug=None, limit=8): + from ttd.services import expenses as expense_svc + + return await expense_svc.recent_expenses( + project_slug=project_slug, client_slug=client_slug, limit=limit + ) + + +async def expenses_for_invoice(view): + # view.expense_lines is already loaded by get_invoice; this is a thin accessor + return view.expense_lines +``` + +- [ ] **Step 5: Show expenses in the invoice detail screen** + +In `src/ttd/tui/screens/invoices.py`, where the invoice detail renders the line-items `DataTable`, add a second table (or section) populated from `view.expense_lines` with columns Date / Description / Amount, shown only when non-empty. Follow the existing table-building code in that screen verbatim for styling. + +- [ ] **Step 6: Add quick-add binding** + +In the timesheet (or dashboard) screen, add a binding `("e", "quick_expense", "Expense")` and an `action_quick_expense` that opens a modal form: project picker → optional recall select (from `recent_expense_suggestions`) → description/amount/date, then calls `expense_svc.add_expense` and refreshes. Mirror the existing quick-log modal in the same screen. + +- [ ] **Step 7: Run tests + TUI smoke** + +Run: `uv run pytest tests/test_tui -v` +Expected: PASS. Optional manual: `just tui` (or `uv run ttd`), seed demo, open Invoices, confirm an expense-bearing invoice shows the expenses table; press `e` to add one. + +- [ ] **Step 8: Commit** + +```bash +git add src/ttd/tui/ tests/test_tui/test_expense_data.py +git commit -m "feat: TUI invoice expenses view and quick-add" +``` + +--- + +## Final verification + +- [ ] **Run the full suite + lint:** + +Run: `just test && just lint` +Expected: all tests pass; ruff + ty clean. + +- [ ] **Regenerate CLI docs** (pre-commit hook `cli reference docs` runs this; do it explicitly if needed): + +Run: `uv run python scripts/gen_cli_docs.py` (confirm exact script entrypoint), then commit any doc changes. + +- [ ] **Update CHANGELOG.md** with a `feat: billable expenses (client chargebacks)` entry under the next version. + +- [ ] **Commit docs:** + +```bash +git add CHANGELOG.md docs/ +git commit -m "docs: document billable expenses" +``` + +--- + +## Self-Review Notes (coverage against the spec) + +- **Data model** → Task 1 (Expense, ExpenseReceipt, InvoiceExpenseLine, `expenses_subtotal`). +- **Services CRUD + recall** → Task 2; **receipts** → Task 3. +- **CLI** (`expense` sub-app, receipt group) → Task 4; interactive recall flagged as fold-in. +- **Invoicing: draft/persist/untaxed totals** → Task 5; **void/refresh** → Task 6. +- **Rendering: PDF/markdown expense section** → Task 7; **receipt pages + config + pypdf** → Task 8. +- **Invoice generation: format choice + `--receipts` + markdown gating** → Task 9. +- **JSON backup v2** → Task 10 (verified against the real `export_records`/`meta` export path and the `EntryRecord`-only importer; expenses get a dedicated `restore_expenses` rather than reusing `ImportPlan`). +- **TUI** → Task 11. The data helper is TDD'd concretely; the screen/keybinding wiring is descriptive by necessity (Textual widgets aren't unit-testable here) and instructs the implementer to mirror the existing invoice-detail table and quick-log modal verbatim. +- **Deferred (not in this plan, per spec):** reports awareness, CSV/XLSX/Numbers interchange, dedicated TUI expenses screen, recurrence, markup. +- **Receipt rendering wiring (resolved):** the CLI loads receipts via `get_receipt` inside its async session and passes them to `render_pdf(..., receipts=[(filename, content_type, bytes), ...])`. The renderer never touches the DB — image receipts become fpdf2 pages, PDF receipts are merged with `pypdf`. +- **Cross-task type consistency checked:** `_draft_totals` returns `(subtotal, expenses_subtotal, tax, total)` everywhere; `Draft`/`InvoiceView`/`RefreshPreview` gain expense fields used consistently by later tasks; `render_pdf`'s `receipts` keyword is introduced in Task 8 and consumed in Task 9; `_render_files` becomes async with both call sites updated. diff --git a/docs/superpowers/plans/2026-06-30-tui-log-page.md b/docs/superpowers/plans/2026-06-30-tui-log-page.md new file mode 100644 index 0000000..9c5ef25 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-tui-log-page.md @@ -0,0 +1,702 @@ +# TUI Log Page (re-scope timesheet) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Re-scope the underused TUI `timesheet` screen into a `log` page that views, adds, edits, and deletes both time entries and expenses, with a month-only window. + +**Architecture:** Rework `TimesheetScreen` into `LogScreen` (rename file/class/nav_id, update the screen registry and nav). Two stacked `DataTable` sections — time, then expenses — both scoped to one month cycled with `[`/`]`. Adding reuses the existing global `l` chooser; `e`/`x` edit/delete the highlighted row of the focused section, dispatched to `entry_svc` or `expense_svc`. + +**Tech Stack:** Python 3.13, Textual (TUI), Ferro-ORM/SQLite, pytest + pytest-asyncio (Textual pilot tests). + +## Global Constraints + +- Re-scope, do not add a 7th nav item. `timesheet` → `log` everywhere (nav label "2 log", `nav_id = "log"`, registry key `"log"`). +- **Month-only** window cycled with `[` / `]`; `g` resets to the current month. Remove the `d`/`w`/`m` span toggle and the `Span` machinery. +- Adding is the existing global `l` chooser (time/expense). Remove the screen-local `a` (add_entry) binding. +- Two stacked sections: **time** (date · project · time · hours · note · flags — the current table, unchanged) then **expenses** (date · project · description · amount). Empty expenses → header + muted "no expenses this month". +- `e` edits / `x` deletes the highlighted row of the **focused** section; dispatch to `entry_svc` (entries) or `expense_svc` (expenses). Both services already refuse invoiced rows — surface that via `notify`. +- Reuse existing pieces: `entry_svc` list/edit/delete (unchanged), `expense_svc.list_expenses`/`edit_expense`/`delete_expense`, the generic `FormModal`, `ConfirmModal`, and the `_validate_amount`/`_validate_date` helpers already in `screens/_base.py`. +- Tests: `asyncio_mode = "auto"`; Textual pilot tests use the `seeded_app` fixture + `app.run_test(size=(120, 40)) as pilot`, `pilot.press(...)`, `pilot.pause()`, assert on `seeded_app.screen.nav_id` and `screen.query_one("#id", DataTable).row_count`. Keep the coverage gate (`fail_under = 84`) green; `ty` + `ruff` clean. + +--- + +## File Structure + +- **Rename/rework:** `src/ttd/tui/screens/timesheet.py` → `src/ttd/tui/screens/log.py` (`TimesheetScreen` → `LogScreen`). +- **Modify:** `src/ttd/tui/app.py` (import + `SCREENS` key), `src/ttd/tui/screens/_base.py` (`NAV` entry). +- **Modify tests:** `tests/test_tui/test_app.py` (nav refs `timesheet`→`log`; day-navigation test → month navigation; add-via-`a` test → add-via-`l`; seed an expense in `seeded_app`). + +--- + +## Task 1: Rename timesheet → log, month-only window + +**Files:** +- Rename + rewrite: `src/ttd/tui/screens/timesheet.py` → `src/ttd/tui/screens/log.py` +- Modify: `src/ttd/tui/app.py` +- Modify: `src/ttd/tui/screens/_base.py` +- Modify: `tests/test_tui/test_app.py` + +**Interfaces:** +- Produces: `LogScreen` (in `ttd.tui.screens.log`) with `nav_id = "log"`; registry key `"log"`; nav entry `("log", "2 log")`. Keeps `#day-title`, `#day-table`, `#day-total` ids and the entry edit/delete actions (`action_edit_entry`, `action_delete_entry`, `_selected_entry_id`). Month-only: `action_shift(delta)` moves whole months; `action_today` resets to the current month. No `action_span`, no `a`/`d`/`w`/`m` bindings. + +- [ ] **Step 1: Rename the file with git** + +```bash +git mv src/ttd/tui/screens/timesheet.py src/ttd/tui/screens/log.py +``` + +- [ ] **Step 2: Rewrite `src/ttd/tui/screens/log.py`** + +Replace the whole file with the month-only LogScreen (entry section only — expenses arrive in Task 2): + +```python +"""Log: month-scoped time entries (expenses added in Task 2); add/edit/delete.""" + +from datetime import date, datetime, timedelta +from typing import ClassVar + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.coordinate import Coordinate +from textual.widgets import DataTable, Label + +from ttd.cli._pickers import describe_timespec, split_project_choice, validate_timespec +from ttd.config.loader import get_settings +from ttd.core.errors import TtdError +from ttd.core.money import format_hours +from ttd.reporting import periods +from ttd.services import entries as entry_svc +from ttd.tui._data import hours_for_row, project_options +from ttd.tui.screens._base import PREV_NEXT_GROUP, TtdScreen +from ttd.tui.widgets.forms import FormField, FormModal +from ttd.tui.widgets.modals import ConfirmModal + + +def _entry_spec(entry) -> str: + """Reconstruct an unambiguous, round-trippable time spec for an entry.""" + if entry.started_at and entry.ended_at: + return f"{entry.work_date} {entry.started_at:%H:%M} to {entry.ended_at:%H:%M}" + h, rem = divmod(entry.seconds, 3600) + duration = f"{h}h{rem // 60}m" if h else f"{rem // 60}m" + return f"{entry.work_date} {duration}" + + +class LogScreen(TtdScreen): + nav_id = "log" + + BINDINGS: ClassVar = [ + *TtdScreen.BINDINGS, + Binding("left_square_bracket", "shift(-1)", "prev", group=PREV_NEXT_GROUP), + Binding("right_square_bracket", "shift(1)", "next", group=PREV_NEXT_GROUP), + ("g", "today", "this month"), + ("e", "edit_entry", "edit"), + ("x", "delete_entry", "delete"), + ] + + def __init__(self) -> None: + super().__init__() + self.anchor_date: date = date.today() + + def compose_content(self) -> ComposeResult: + with Vertical(id="log"): + yield Label("", id="day-title", classes="section-title") + yield DataTable(id="day-table", cursor_type="row") + yield Label("", id="day-total", classes="muted") + + def setup(self) -> None: + table = self.query_one("#day-table", DataTable) + table.add_columns("date", "project", "time", "hours", "note", "flags") + + def _period(self) -> periods.Period: + return periods.month_period(self.anchor_date) + + async def render_data(self) -> None: + period = self._period() + rows = await entry_svc.list_entries(date_from=period.start, date_to=period.end) + table = self.query_one("#day-table", DataTable) + table.clear() + total = 0 + last_day = None + for r in rows: + total += r.entry.seconds + flags = [] + if not r.entry.billable: + flags.append("nb") + if r.entry.invoice_id is not None: + flags.append("inv") + day_label = r.entry.work_date.strftime("%a %b %-d") + table.add_row( + day_label if day_label != last_day else "", + f"{r.client.slug}/{r.project.slug}", + hours_for_row(r.entry), + format_hours(r.entry.seconds), + r.entry.note, + ",".join(flags), + key=str(r.entry.id), + ) + last_day = day_label + self.query_one("#day-title", Label).update(period.label) + self.query_one("#day-total", Label).update( + f"{len(rows)} entr{'y' if len(rows) == 1 else 'ies'} · {format_hours(total)}" + " [dim]\\[ ] prev/next month · g this month · l add · e edit · x delete[/dim]" + ) + + async def action_shift(self, delta: int) -> None: + first = self.anchor_date.replace(day=1) + if delta > 0: + self.anchor_date = (first + timedelta(days=32)).replace(day=1) + else: + self.anchor_date = (first - timedelta(days=1)).replace(day=1) + await self.refresh_data() + + async def action_today(self) -> None: + self.anchor_date = date.today() + await self.refresh_data() + + def _selected_entry_id(self) -> str | None: + table = self.query_one("#day-table", DataTable) + if table.row_count == 0 or table.cursor_row is None: + return None + key = table.coordinate_to_cell_key(Coordinate(table.cursor_row, 0)).row_key.value + return str(key) if key is not None else None + + async def action_edit_entry(self) -> None: + uid = self._selected_entry_id() + if uid is None: + return + entry = await entry_svc.find_entry(uid) + if entry.invoice_id is not None: + self.notify("entry is on an invoice — void it first", severity="warning") + return + options = await project_options() + rows = await entry_svc.list_entries(date_from=entry.work_date, date_to=entry.work_date) + current = next((r for r in rows if r.entry.id == entry.id), None) + current_project = f"{current.client.slug}/{current.project.slug}" if current else None + + initial = { + "time": _entry_spec(entry), + "note": entry.note, + "tags": entry.tags, + "billable": entry.billable, + "project": current_project, + } + form = FormModal( + f"edit entry {uid[:8]}", + [ + FormField( + "time", + "Time", + kind="spec", + value=initial["time"], + validate=validate_timespec, + preview=describe_timespec, + required=True, + ), + FormField("note", "Note", value=entry.note), + FormField("tags", "Tags (comma-separated)", value=entry.tags), + FormField("billable", "Billable", kind="toggle", value=entry.billable), + FormField( + "project", "Project", kind="select", value=current_project, choices=options + ), + ], + ) + + async def _save(values: dict | None) -> None: + if values is None: + return + kwargs: dict = {} + if values["time"] != initial["time"]: + kwargs["spec"] = values["time"] + if values["note"] != initial["note"]: + kwargs["note"] = values["note"] + if values["tags"] != initial["tags"]: + kwargs["tags"] = values["tags"] + if values["billable"] != initial["billable"]: + kwargs["billable"] = values["billable"] + if values["project"] and values["project"] != initial["project"]: + project_slug, client_slug = split_project_choice(values["project"]) + kwargs["project_slug"] = project_slug + kwargs["client_slug"] = client_slug + if not kwargs: + return + try: + await entry_svc.edit_entry(uid, now=datetime.now(), settings=get_settings(), **kwargs) + self.notify("entry updated") + except TtdError as exc: + self.notify(str(exc), severity="error") + await self.refresh_data() + + self.app.push_screen(form, _save) + + async def action_delete_entry(self) -> None: + uid = self._selected_entry_id() + if uid is None: + return + + async def _confirmed(yes: bool | None) -> None: + if not yes: + return + try: + await entry_svc.delete_entry(uid) + self.notify("entry deleted") + except TtdError as exc: + self.notify(str(exc), severity="error") + await self.refresh_data() + + self.app.push_screen(ConfirmModal(f"Delete entry {uid[:8]}?"), _confirmed) +``` + +(This is the current timesheet minus: `Span`/`SPAN_GROUP`, `action_span`, the `d`/`w`/`m`/`a` bindings, `action_add_entry`, and the day/yesterday title suffix. `QuickLogModal`/`split_and_log` imports are dropped because adding now goes through the global `l` chooser in `_base.py`.) + +- [ ] **Step 3: Update the screen registry and nav** + +In `src/ttd/tui/app.py`: change the import and the `SCREENS` key: + +```python +from ttd.tui.screens.log import LogScreen +``` +```python + SCREENS: ClassVar = { + "dashboard": DashboardScreen, + "log": LogScreen, + "clients": ClientsScreen, + "reports": ReportsScreen, + "invoices": InvoicesScreen, + "taxes": TaxesScreen, + } +``` + +In `src/ttd/tui/screens/_base.py`, change the `NAV` entry: + +```python +NAV = [ + ("dashboard", "1 dashboard"), + ("log", "2 log"), + ("clients", "3 clients"), + ("reports", "4 reports"), + ("invoices", "5 invoices"), + ("taxes", "6 taxes"), +] +``` + +(The `goto('timesheet')` binding in `_base.py` is keyed off `nav_id`; the nav key `2` maps to whatever the registry/nav call it. Search `_base.py` for `goto('timesheet')` / `"timesheet"` and change to `'log'` if present — the nav `2` binding uses the registry key, so update it to `"log"`.) + +- [ ] **Step 4: Update existing tests for the rename + month-only + add-via-l** + +In `tests/test_tui/test_app.py`: +- In `test_navigation_between_screens`, change `("2", "timesheet")` to `("2", "log")`. +- In `test_quick_log_creates_entry`: it presses `"2"` then `"a"`. Remove the `"a"` path (the `a` binding is gone) and drive adding through `l` → time. Rewrite its body to: + +```python +async def test_quick_log_creates_entry(seeded_app): + async with seeded_app.run_test(size=(120, 40)) as pilot: + await pilot.press("2") # log + await pilot.pause() + assert seeded_app.screen.nav_id == "log" + before = seeded_app.screen.query_one("#day-table").row_count + await pilot.press("l") # log chooser + await pilot.pause() + await pilot.press("enter") # first option = time + await pilot.pause() + await pilot.press(*"today 3pm to 4pm") + await pilot.pause() + await pilot.press("enter") # submit spec → picks first project + await pilot.pause() + await pilot.pause() + assert seeded_app.screen.query_one("#day-table").row_count == before + 1 +``` + +- Replace `test_timesheet_day_navigation` with a month-navigation test (rename to `test_log_month_navigation`). The seed logs entries every 2 days for the last 14 days; all fall in the current or previous month. Assert the current month has rows and the previous month differs: + +```python +async def test_log_month_navigation(seeded_app): + async with seeded_app.run_test(size=(120, 40)) as pilot: + await pilot.press("2") # log + await pilot.pause() + screen = seeded_app.screen + this_month = screen.query_one("#day-table").row_count + assert this_month >= 1 + await pilot.press("left_square_bracket") # previous month + await pilot.pause() + prev_month = screen.query_one("#day-table").row_count + await pilot.press("g") # back to this month + await pilot.pause() + assert screen.query_one("#day-table").row_count == this_month + assert prev_month != this_month or prev_month == 0 +``` + +- Grep the test file for any other `"timesheet"` / `TimesheetScreen` references and update them. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/test_tui -v && uv run ty check && uv run ruff check` +Expected: PASS, clean. Also run the full suite to catch any other `timesheet` reference: `uv run pytest -q`. + +- [ ] **Step 6: Commit** + +```bash +git add src/ttd/tui/ tests/test_tui/test_app.py +git commit -m "feat: re-scope timesheet into month-only log screen" +``` + +--- + +## Task 2: Add the expenses section (display) + +**Files:** +- Modify: `src/ttd/tui/screens/log.py` +- Modify: `tests/test_tui/test_app.py` (seed an expense; assert the expenses table renders it) + +**Interfaces:** +- Consumes: `LogScreen` (Task 1); `expense_svc.list_expenses(*, date_from, date_to)` returning `ExpenseView`s (`.expense`, `.project`, `.client`, `.has_receipt`). +- Produces: a second `DataTable#expense-table` (columns date · project · description · amount), a `#expense-title` label, a `#expense-total` label, all populated in `render_data`. + +- [ ] **Step 1: Write the failing test** + +Seed an expense in the `seeded_app` fixture (add after the entry seeding loop, inside `open_test_db`): + +```python + from decimal import Decimal as _D + from ttd.services import expenses as expense_svc + await expense_svc.add_expense("api-rewrite", "Cloud hosting", _D("49.99")) +``` + +Then add a test: + +```python +async def test_log_shows_expense_section(seeded_app): + async with seeded_app.run_test(size=(120, 40)) as pilot: + await pilot.press("2") # log + await pilot.pause() + screen = seeded_app.screen + expense_table = screen.query_one("#expense-table") + assert expense_table.row_count == 1 + # the description appears in the rendered table + cells = [expense_table.get_row_at(0)] + assert any("Cloud hosting" in str(c) for row in cells for c in row) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_tui/test_app.py -k log_shows_expense -v` +Expected: FAIL — `#expense-table` does not exist (`NoMatches`). + +- [ ] **Step 3: Add the expenses widgets to `compose_content` and `setup`** + +In `log.py`, extend `compose_content`: + +```python + def compose_content(self) -> ComposeResult: + with Vertical(id="log"): + yield Label("", id="day-title", classes="section-title") + yield DataTable(id="day-table", cursor_type="row") + yield Label("", id="day-total", classes="muted") + yield Label("expenses", id="expense-title", classes="section-title") + yield DataTable(id="expense-table", cursor_type="row") + yield Label("", id="expense-total", classes="muted") +``` + +Extend `setup` to add the expense columns: + +```python + def setup(self) -> None: + self.query_one("#day-table", DataTable).add_columns( + "date", "project", "time", "hours", "note", "flags" + ) + self.query_one("#expense-table", DataTable).add_columns( + "date", "project", "description", "amount" + ) +``` + +- [ ] **Step 4: Render expenses in `render_data`** + +Add these imports at the top of `log.py`: + +```python +from decimal import Decimal + +from ttd.core.money import format_hours, format_money +from ttd.services import expenses as expense_svc +``` + +(Replace the existing `from ttd.core.money import format_hours` line with the combined import.) + +At the end of `render_data`, after updating `#day-total`, render the expenses section: + +```python + expenses = await expense_svc.list_expenses(date_from=period.start, date_to=period.end) + etable = self.query_one("#expense-table", DataTable) + etable.clear() + etotal = Decimal("0") + for v in expenses: + etotal += v.expense.amount + flags = " inv" if v.expense.invoice_id is not None else "" + etable.add_row( + v.expense.incurred_date.strftime("%a %b %-d"), + f"{v.client.slug}/{v.project.slug}", + v.expense.description + flags, + format_money(v.expense.amount, v.client.currency), + key=str(v.expense.id), + ) + if expenses: + self.query_one("#expense-total", Label).update( + f"{len(expenses)} expense{'s' if len(expenses) != 1 else ''} · " + f"{format_money(etotal, expenses[0].client.currency)}" + ) + else: + self.query_one("#expense-total", Label).update("[dim]no expenses this month[/dim]") +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/test_tui -v && uv run ty check && uv run ruff check` +Expected: PASS, clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/ttd/tui/screens/log.py tests/test_tui/test_app.py +git commit -m "feat: show expenses section on the log screen" +``` + +--- + +## Task 3: Focus switching + expense edit/delete + +**Files:** +- Modify: `src/ttd/tui/screens/log.py` +- Modify: `tests/test_tui/test_app.py` + +**Interfaces:** +- Consumes: Task 2 widgets; `expense_svc.find_expense`, `expense_svc.edit_expense(uid_prefix, *, amount=None, description=None, note=None, incurred_date=None, project_slug=None, client_slug=None)`, `expense_svc.delete_expense(uid_prefix)`; `_validate_amount`/`_validate_date` from `ttd.tui.screens._base`; `split_project_choice`; `FormModal`/`FormField`/`ConfirmModal`. +- Produces: a `tab` binding that toggles the active section and focuses its table; `e`/`x` dispatch to entry vs expense based on the active section; expense edit (FormModal) and delete (ConfirmModal) flows. + +- [ ] **Step 1: Write the failing test (delete an expense from the log screen)** + +```python +async def test_log_delete_expense(seeded_app): + async with seeded_app.run_test(size=(120, 40)) as pilot: + await pilot.press("2") # log + await pilot.pause() + screen = seeded_app.screen + assert screen.query_one("#expense-table").row_count == 1 + await pilot.press("tab") # focus the expenses section + await pilot.pause() + await pilot.press("x") # delete highlighted expense + await pilot.pause() + await pilot.press("enter") # confirm + await pilot.pause() + await pilot.pause() + assert screen.query_one("#expense-table").row_count == 0 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_tui/test_app.py -k log_delete_expense -v` +Expected: FAIL — `tab` doesn't switch focus and `x` deletes an entry (or does nothing), so the expense row remains. + +- [ ] **Step 3: Add active-section state, the tab binding, and a selected-expense helper** + +In `log.py`, add to `__init__`: + +```python + def __init__(self) -> None: + super().__init__() + self.anchor_date: date = date.today() + self.active_section: str = "time" # "time" | "expenses" +``` + +Add a `tab` binding to `BINDINGS` (after the `x` binding): + +```python + ("tab", "switch_section", "switch section"), +``` + +Add the action and an expense-id helper (mirrors `_selected_entry_id`): + +```python + async def action_switch_section(self) -> None: + self.active_section = "expenses" if self.active_section == "time" else "time" + table_id = "#expense-table" if self.active_section == "expenses" else "#day-table" + self.query_one(table_id, DataTable).focus() + # mark the active section title + self.query_one("#day-title", Label).remove_class("active-section") + self.query_one("#expense-title", Label).remove_class("active-section") + active_title = "#expense-title" if self.active_section == "expenses" else "#day-title" + self.query_one(active_title, Label).add_class("active-section") + + def _selected_expense_id(self) -> str | None: + table = self.query_one("#expense-table", DataTable) + if table.row_count == 0 or table.cursor_row is None: + return None + key = table.coordinate_to_cell_key(Coordinate(table.cursor_row, 0)).row_key.value + return str(key) if key is not None else None +``` + +- [ ] **Step 4: Dispatch `e`/`x` by active section** + +Rename the entry actions to private helpers and make `action_edit_entry`/`action_delete_entry` (still bound to `e`/`x`) route. Replace the binding-targeted actions: + +```python + async def action_edit_entry(self) -> None: + if self.active_section == "expenses": + await self._edit_expense() + else: + await self._edit_entry_row() + + async def action_delete_entry(self) -> None: + if self.active_section == "expenses": + await self._delete_expense() + else: + await self._delete_entry_row() +``` + +Rename the existing `action_edit_entry` body to `_edit_entry_row` and the existing `action_delete_entry` body to `_delete_entry_row` (same code, just the method names change). + +- [ ] **Step 5: Add the expense edit/delete flows** + +Add these imports to `log.py`: + +```python +from ttd.tui.screens._base import PREV_NEXT_GROUP, TtdScreen, _validate_amount, _validate_date +``` + +(extend the existing `_base` import). Then add: + +```python + async def _edit_expense(self) -> None: + uid = self._selected_expense_id() + if uid is None: + return + expense = await expense_svc.find_expense(uid) + if expense.invoice_id is not None: + self.notify("expense is on an invoice — void it first", severity="warning") + return + options = await project_options() + views = await expense_svc.list_expenses( + date_from=expense.incurred_date, date_to=expense.incurred_date + ) + current = next((v for v in views if v.expense.id == expense.id), None) + current_project = f"{current.client.slug}/{current.project.slug}" if current else None + initial = { + "description": expense.description, + "amount": str(expense.amount), + "date": expense.incurred_date.isoformat(), + "note": expense.note, + "project": current_project, + } + form = FormModal( + f"edit expense {uid[:8]}", + [ + FormField("description", "Description", value=expense.description, required=True), + FormField( + "amount", "Amount", value=str(expense.amount), + validate=_validate_amount, required=True, + ), + FormField("date", "Date (YYYY-MM-DD)", value=initial["date"], validate=_validate_date), + FormField("note", "Note", value=expense.note), + FormField( + "project", "Project", kind="select", value=current_project, choices=options + ), + ], + ) + + async def _save(values: dict | None) -> None: + if values is None: + return + kwargs: dict = {} + if values["description"] != initial["description"]: + kwargs["description"] = values["description"] + if values["amount"] != initial["amount"]: + kwargs["amount"] = Decimal(values["amount"]) + if values["date"] != initial["date"] and values["date"]: + kwargs["incurred_date"] = date.fromisoformat(values["date"]) + if values["note"] != initial["note"]: + kwargs["note"] = values["note"] + if values["project"] and values["project"] != initial["project"]: + project_slug, client_slug = split_project_choice(values["project"]) + kwargs["project_slug"] = project_slug + kwargs["client_slug"] = client_slug + if not kwargs: + return + try: + await expense_svc.edit_expense(uid, **kwargs) + self.notify("expense updated") + except TtdError as exc: + self.notify(str(exc), severity="error") + await self.refresh_data() + + self.app.push_screen(form, _save) + + async def _delete_expense(self) -> None: + uid = self._selected_expense_id() + if uid is None: + return + + async def _confirmed(yes: bool | None) -> None: + if not yes: + return + try: + await expense_svc.delete_expense(uid) + self.notify("expense deleted") + except TtdError as exc: + self.notify(str(exc), severity="error") + await self.refresh_data() + + self.app.push_screen(ConfirmModal(f"Delete expense {uid[:8]}?"), _confirmed) +``` + +- [ ] **Step 6: Add an edit-expense test** + +```python +async def test_log_edit_expense(seeded_app): + async with seeded_app.run_test(size=(120, 40)) as pilot: + await pilot.press("2") + await pilot.pause() + screen = seeded_app.screen + await pilot.press("tab") # focus expenses + await pilot.pause() + await pilot.press("e") # edit + await pilot.pause() + # amount field is the second field; clear and retype via the form is heavy — + # assert the edit modal opened with the expense's values instead. + from ttd.tui.widgets.forms import FormModal + assert isinstance(seeded_app.screen, FormModal) + await pilot.press("escape") + await pilot.pause() +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `uv run pytest tests/test_tui -v && uv run ty check && uv run ruff check` +Expected: PASS, clean. + +- [ ] **Step 8: Optional polish — active-section CSS** + +If the app has a TUI stylesheet (search for `.section-title` in `src/ttd/tui/*.tcss` or theme files), add an `.active-section` rule so the focused section header stands out (e.g. accent color/bold). If no stylesheet rule is found, the `add_class("active-section")` is harmless and this step is a no-op; note it in the report. + +- [ ] **Step 9: Run the full suite + commit** + +Run: `uv run pytest -q && uv run ty check && uv run ruff check` +Expected: full suite passes, coverage ≥84%, clean. + +```bash +git add src/ttd/tui/screens/log.py tests/test_tui/test_app.py +git commit -m "feat: focus switching and expense edit/delete on the log screen" +``` + +--- + +## Self-Review Notes (coverage against the spec) + +- Re-scope timesheet → log (nav slot 2, rename, registry) → **Task 1**. +- Month-only window, `[`/`]`, `g`; drop `d`/`w`/`m` and `a` (add via `l`) → **Task 1**. +- Two stacked sections (time, then expenses) with empty-state → **Task 2**. +- Focus-based `e`/`x` dispatch; expense edit (FormModal) / delete (ConfirmModal) honoring invoiced lock → **Task 3**. +- Reuse `entry_svc`, `expense_svc`, `FormModal`, `ConfirmModal`, `_validate_amount`/`_validate_date` → Tasks 1–3. +- Adding via global `l` chooser (already built) — no new add code; existing flow refreshes the log screen via `refresh_data`. +- Coverage gate kept green via pilot tests each task. +- **Deviation from spec (justified):** the spec suggested a new `_data` helper for "expenses in a month window"; the log screen instead calls `expense_svc.list_expenses(date_from=, date_to=)` directly, exactly as it calls `entry_svc.list_entries` directly — mirroring the existing pattern rather than adding an indirection. Noted for the reviewer. +- **Trim (YAGNI):** up/down edge-rollover between the two tables (mentioned in the spec) is not implemented; `tab` switches sections and focuses the table so arrows navigate within it. Flagged as an optional follow-up rather than building fiddly cross-table cursor handoff. +- **Verify during impl:** confirm `_validate_amount`/`_validate_date` are module-level importable from `ttd.tui.screens._base` (they were added there in the `l`-chooser work). If they are nested/non-importable, lift them to module scope in `_base.py` as part of Task 3. diff --git a/docs/superpowers/plans/2026-07-01-flexible-invoice-periods.md b/docs/superpowers/plans/2026-07-01-flexible-invoice-periods.md new file mode 100644 index 0000000..591decc --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-flexible-invoice-periods.md @@ -0,0 +1,586 @@ +# Flexible Invoice Periods Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Accept richer invoice period specs (relative durations, month-name ranges) and record each invoice's period from the items actually billed rather than the requested window. + +**Architecture:** Extend `reporting/periods.py:parse_period` with two self-contained matchers (relative durations; month-name ranges with a closest-year rule). Separately, make `services/invoicing.py` derive the stored invoice period from the billed line dates. No new dependencies; no schema change. + +**Tech Stack:** Python 3.13, stdlib `re`/`calendar`/`datetime`, Ferro-ORM/SQLite, pytest + pytest-asyncio. + +## Global Constraints + +- All new parsing lives in `reporting/periods.py`. Do NOT reuse/extend the `ttd log` grammar and do NOT add an NL-date dependency. +- **Relative forms:** `this week`/`last week` (calendar, respect `display.week_start`); rolling `last days|weeks|months` ending **today**; `` is a digit or a word `one`…`twelve`. +- **Month-name forms:** full names + 3-letter abbreviations; separators `to`/`through`/`thru`/`until`/`till`/`-`/`–`/`—`/`..`; shorthands `` (whole month) and ` ` (second inherits month); optional single trailing 4-digit year applies to both endpoints. +- **Year inference (closest-year, never future):** candidates = this year and last year; pick the one whose range is temporally closest to today (0 if today inside); ties → this year; never infer next year. Cross-year wrap: when the end month < start month, the end year = start year + 1. +- **Derived invoice period:** the parsed `Period` is only a sieve; the invoice's `period_start`/`period_end` = min–max of billed line dates (`work_date` for time, `incurred_date` for expenses). Refresh re-derives. +- Coverage gate `fail_under = 84` stays green; `ty` + `ruff` clean; avoid non-ASCII in code literals that trips `RUF001`. +- Tests: `asyncio_mode = "auto"`. Parser tests are pure (no db). Behavioral invoicing tests use the `db` fixture; set up via `client_svc.create_client`, `project_svc.create_project`, `entry_svc.log_entry`, `expense_svc.add_expense`. + +## File Structure + +- **Modify:** `src/ttd/reporting/periods.py` — the two new matchers + `parse_period` wiring + error text (Tasks 1–2). +- **Modify:** `src/ttd/cli/invoices.py` — pass `week_start` to `parse_period`; update `--period` help (Tasks 1–2). +- **Modify:** `src/ttd/tui/screens/invoices.py` — pass `week_start`; update the period placeholder/label (Tasks 1–2). +- **Modify:** `src/ttd/services/invoicing.py` — derive period in `build_draft` + `apply_refresh` (Task 3). +- **Create:** `tests/test_reporting/test_periods.py` — parser unit tests (Tasks 1–2). +- **Create:** `tests/test_invoicing/test_derived_period.py` — behavioral period-derivation tests (Task 3). + +--- + +## Task 1: Relative-duration parsing + +**Files:** +- Modify: `src/ttd/reporting/periods.py` +- Modify: `src/ttd/cli/invoices.py`, `src/ttd/tui/screens/invoices.py` +- Test: `tests/test_reporting/test_periods.py` + +**Interfaces:** +- Produces: `parse_period(text: str, today: date, *, week_start: str = "monday") -> Period` — new keyword `week_start`; new accepted forms `this week`, `last week`, and `last days|weeks|months`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_reporting/test_periods.py +from datetime import date + +from ttd.reporting import periods + + +def test_this_week_and_last_week(): + today = date(2026, 6, 18) # a Thursday + tw = periods.parse_period("this week", today) + assert tw.start == date(2026, 6, 15) and tw.end == date(2026, 6, 21) # Mon–Sun + lw = periods.parse_period("last week", today) + assert lw.start == date(2026, 6, 8) and lw.end == date(2026, 6, 14) + + +def test_rolling_last_n_ending_today(): + today = date(2026, 6, 18) + assert periods.parse_period("last two weeks", today).start == date(2026, 6, 5) + assert periods.parse_period("last two weeks", today).end == today + assert periods.parse_period("last 10 days", today).start == date(2026, 6, 9) + assert periods.parse_period("last 1 week", today).start == date(2026, 6, 12) + assert periods.parse_period("last 3 months", today).start == date(2026, 3, 18) + assert periods.parse_period("last 3 months", today).end == today + + +def test_rolling_month_clamps_day(): + # today Mar 31 minus 1 month clamps to Feb 28 (2026 not a leap year) + assert periods.parse_period("last 1 month", date(2026, 3, 31)).start == date(2026, 2, 28) + + +def test_week_start_sunday(): + today = date(2026, 6, 18) + tw = periods.parse_period("this week", today, week_start="sunday") + assert tw.start == date(2026, 6, 14) # Sunday +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_reporting/test_periods.py -v` +Expected: FAIL — `parse_period` raises `TtdError("Can't read period 'this week' …")` / rejects the rolling forms. + +- [ ] **Step 3: Add the relative matcher + wire `parse_period`** + +In `src/ttd/reporting/periods.py`, add near the other module constants: + +```python +_NUMBER_WORDS = { + "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, + "seven": 7, "eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12, +} +_RELATIVE_RE = re.compile(r"^last\s+(\w+)\s+(day|days|week|weeks|month|months)$") + + +def _subtract_months(d: date, n: int) -> date: + """d shifted back n calendar months, clamping the day to the target month.""" + month_index = (d.year * 12 + (d.month - 1)) - n + year, month = divmod(month_index, 12) + month += 1 + last_day = calendar.monthrange(year, month)[1] + return date(year, month, min(d.day, last_day)) + + +def _parse_relative(text: str, today: date) -> Period | None: + """Rolling 'last days|weeks|months' ending today; None if no match.""" + m = _RELATIVE_RE.match(text) + if m is None: + return None + raw, unit = m[1], m[2].rstrip("s") + n = _NUMBER_WORDS.get(raw) or (int(raw) if raw.isdigit() else 0) + if n < 1: + raise TtdError(f"'{text}' — the count must be a positive number") + if unit == "day": + start = today - timedelta(days=n - 1) + elif unit == "week": + start = today - timedelta(days=n * 7 - 1) + else: # month + start = _subtract_months(today, n) + return range_period(start, today) +``` + +Rewrite `parse_period` to take `week_start` and handle the new week/relative forms: + +```python +def parse_period(text: str, today: date, *, week_start: str = "monday") -> Period: + """Parse a human period spec. Supports: '' / 'last month' / 'this month' / + 'this week' / 'last week' / 'last days|weeks|months' / 'YYYY-MM' / + 'YYYY-MM-DD to YYYY-MM-DD' / month-name ranges like 'june 16 to june 30'.""" + text = text.strip().lower() + if text in ("", "last month"): + return month_period(today, last=True) + if text == "this month": + return month_period(today) + if text == "this week": + return week_period(today, week_start) + if text == "last week": + return week_period(today, week_start, last=True) + if _MONTH_RE.match(text): + return month_period(today, ym=text) + if m := _RANGE_RE.match(text): + try: + return range_period(date.fromisoformat(m[1]), date.fromisoformat(m[2])) + except ValueError as exc: + raise TtdError(f"Not a real date in '{text}' ({exc})") from exc + if relative := _parse_relative(text, today): + return relative + raise TtdError( + f"Can't read period '{text}' — try '2026-05', 'last month', 'this week', " + "'last two weeks', or '2026-05-01 to 2026-05-15'" + ) +``` + +(The month-name branch is added in Task 2, immediately before the final `raise`.) + +- [ ] **Step 4: Pass `week_start` from the invoice call sites** + +In `src/ttd/cli/invoices.py`, `_resolve_period` calls `periods.parse_period(period, datetime.now().date())` — change to: + +```python + return periods.parse_period( + period, datetime.now().date(), week_start=get_settings().display.week_start + ) +``` + +In `src/ttd/tui/screens/invoices.py`, `_rebuild` calls `periods.parse_period(raw, datetime.now().date())` — change to: + +```python + period = periods.parse_period( + raw, datetime.now().date(), week_start=get_settings().display.week_start + ) +``` + +(Both modules already import `get_settings`.) + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/test_reporting/test_periods.py -v && uv run pytest -q && uv run ty check && uv run ruff check` +Expected: PASS, full suite green (coverage ≥84%), clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/ttd/reporting/periods.py src/ttd/cli/invoices.py src/ttd/tui/screens/invoices.py tests/test_reporting/test_periods.py +git commit -m "feat: relative period specs (this/last week, last N days/weeks/months)" +``` + +--- + +## Task 2: Month-name ranges + closest-year + help text + +**Files:** +- Modify: `src/ttd/reporting/periods.py` +- Modify: `src/ttd/cli/invoices.py`, `src/ttd/tui/screens/invoices.py` +- Test: `tests/test_reporting/test_periods.py` (append) + +**Interfaces:** +- Consumes: `parse_period` (Task 1), `range_period`, `month_period`. +- Produces: `parse_period` additionally accepts ``, ` `, ` `, with optional trailing 4-digit year and the closest-year rule. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_reporting/test_periods.py (append) +import pytest +from ttd.core.errors import TtdError + + +def test_month_name_range_closest_year(): + # today mid-2026 + today = date(2026, 7, 1) + p = periods.parse_period("june 16 to june 30", today) + assert p.start == date(2026, 6, 16) and p.end == date(2026, 6, 30) + + +def test_closest_year_examples(): + # Jan 1 2026, "dec 15 - dec 31" -> Dec 2025 (last year is closest) + p = periods.parse_period("dec 15 - dec 31", date(2026, 1, 1)) + assert p.start == date(2025, 12, 15) and p.end == date(2025, 12, 31) + # June 30 2026, "june 16 - june 30" -> this year (today inside) + p = periods.parse_period("june 16 - june 30", date(2026, 6, 30)) + assert p.start == date(2026, 6, 16) + # June 1 2026, "june 16 - june 30" -> this year (near future beats a year ago) + p = periods.parse_period("june 16 - june 30", date(2026, 6, 1)) + assert p.start == date(2026, 6, 16) + + +def test_month_shorthands(): + today = date(2026, 7, 1) + whole = periods.parse_period("june", today) + assert whole.start == date(2026, 6, 1) and whole.end == date(2026, 6, 30) + inherit = periods.parse_period("june 16 - 30", today) + assert inherit.start == date(2026, 6, 16) and inherit.end == date(2026, 6, 30) + abbrev = periods.parse_period("jun 16 to jun 30", today) + assert abbrev.start == date(2026, 6, 16) + + +def test_cross_year_wrap(): + # "dec 28 to jan 3" — end month wraps into the next year + p = periods.parse_period("dec 28 to jan 3", date(2026, 1, 15)) + # closest-year for start Dec: Dec 2025 (ended ~2 weeks ago) beats Dec 2026 + assert p.start == date(2025, 12, 28) and p.end == date(2026, 1, 3) + + +def test_explicit_year_honored(): + p = periods.parse_period("june 16 to june 30 2024", date(2026, 7, 1)) + assert p.start == date(2024, 6, 16) and p.end == date(2024, 6, 30) + + +def test_bad_month_name_errors(): + with pytest.raises(TtdError): + periods.parse_period("smarch 3 to smarch 9", date(2026, 7, 1)) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_reporting/test_periods.py -k "month or closest or cross or explicit or shorthand or bad_month" -v` +Expected: FAIL — these forms hit the final `raise TtdError("Can't read period …")`. + +- [ ] **Step 3: Add the month-name matcher + closest-year** + +In `src/ttd/reporting/periods.py`, add constants and helpers: + +```python +_MONTHS = { + "jan": 1, "january": 1, "feb": 2, "february": 2, "mar": 3, "march": 3, + "apr": 4, "april": 4, "may": 5, "jun": 6, "june": 6, "jul": 7, "july": 7, + "aug": 8, "august": 8, "sep": 9, "sept": 9, "september": 9, "oct": 10, + "october": 10, "nov": 11, "november": 11, "dec": 12, "december": 12, +} +_MON = r"[a-z]{3,9}" +_SEP = r"(?:to|through|thru|until|till|\.\.|-|–|—)" +_MM_RANGE_RE = re.compile( + rf"^(?P{_MON})\s+(?P\d{{1,2}})\s*{_SEP}\s*(?P{_MON})\s+(?P\d{{1,2}})" + rf"(?:\s+(?P\d{{4}}))?$" +) +_MD_RANGE_RE = re.compile( + rf"^(?P{_MON})\s+(?P\d{{1,2}})\s*{_SEP}\s*(?P\d{{1,2}})" + rf"(?:\s+(?P\d{{4}}))?$" +) +_MONTH_ONLY_RE = re.compile(rf"^(?P{_MON})(?:\s+(?P\d{{4}}))?$") + + +def _month_num(name: str) -> int | None: + return _MONTHS.get(name) + + +def _range_distance(start: date, end: date, today: date) -> int: + if start <= today <= end: + return 0 + if today < start: + return (start - today).days + return (today - end).days + + +def _closest_year_range(m1: int, d1: int, m2: int, d2: int, today: date) -> Period: + """Build (start, end) for the closest non-future year; end wraps to +1 year + when the end month is earlier than the start month.""" + best: tuple[int, date, date] | None = None + for y in (today.year, today.year - 1): # this year first → ties favor it + end_year = y + 1 if m2 < m1 else y + try: + start = date(y, m1, d1) + end = date(end_year, m2, d2) + except ValueError: + continue + dist = _range_distance(start, end, today) + if best is None or dist < best[0]: + best = (dist, start, end) + if best is None: + raise TtdError("Not a real date in that month-name range") + return range_period(best[1], best[2]) + + +def _fixed_year_range(m1: int, d1: int, m2: int, d2: int, year: int) -> Period: + end_year = year + 1 if m2 < m1 else year + try: + return range_period(date(year, m1, d1), date(end_year, m2, d2)) + except ValueError as exc: + raise TtdError(f"Not a real date ({exc})") from exc + + +def _parse_month_name(text: str, today: date) -> Period | None: + # whole month: "june" / "june 2025" + if m := _MONTH_ONLY_RE.match(text): + num = _month_num(m["m1"]) + if num is None: + return None + year = int(m["year"]) if m["year"] else _closest_month_year(num, today) + return month_period(date(year, num, 1), ym=f"{year}-{num:02d}") + # month day month day + if m := _MM_RANGE_RE.match(text): + n1, n2 = _month_num(m["m1"]), _month_num(m["m2"]) + if n1 is None or n2 is None: + return None + d1, d2 = int(m["d1"]), int(m["d2"]) + if m["year"]: + return _fixed_year_range(n1, d1, n2, d2, int(m["year"])) + return _closest_year_range(n1, d1, n2, d2, today) + # month day day (inherit month) + if m := _MD_RANGE_RE.match(text): + n1 = _month_num(m["m1"]) + if n1 is None: + return None + d1, d2 = int(m["d1"]), int(m["d2"]) + if m["year"]: + return _fixed_year_range(n1, d1, n1, d2, int(m["year"])) + return _closest_year_range(n1, d1, n1, d2, today) + return None + + +def _closest_month_year(month: int, today: date) -> int: + """Closest non-future year for a whole-month reference.""" + best: tuple[int, int] | None = None + for y in (today.year, today.year - 1): + first = date(y, month, 1) + last = date(y, month, calendar.monthrange(y, month)[1]) + dist = _range_distance(first, last, today) + if best is None or dist < best[0]: + best = (dist, y) + assert best is not None + return best[1] +``` + +> Note: `_MON` matches any 3–9 letter word, so `_parse_month_name` returns `None` (not a match) when the "month" isn't real (`_month_num` → None), letting `parse_period` fall through to its error. But a *range* with a bad month (e.g. `smarch 3 to smarch 9`) matches `_MM_RANGE_RE` yet `_month_num` is None → returns None → falls through to the final `TtdError`. Good. + +Wire it into `parse_period` immediately before the final `raise`: + +```python + if relative := _parse_relative(text, today): + return relative + if month_name := _parse_month_name(text, today): + return month_name + raise TtdError( + f"Can't read period '{text}' — try '2026-05', 'last month', 'this week', " + "'last two weeks', 'june 16 to june 30', or '2026-05-01 to 2026-05-15'" + ) +``` + +- [ ] **Step 4: Update the CLI + TUI help text** + +In `src/ttd/cli/invoices.py`, the `--period` `help=`: + +```python + help=( + "Period spec: 'last month', 'this week', 'last two weeks', " + "'june 16 to june 30', YYYY-MM, or YYYY-MM-DD to YYYY-MM-DD" + ) +``` + +In `src/ttd/tui/screens/invoices.py`, the period `Input` placeholder: + +```python + placeholder="last month · this week · last two weeks · june 16 to june 30 · 2026-05", +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/test_reporting/test_periods.py -v && uv run pytest -q && uv run ty check && uv run ruff check` +Expected: PASS, full suite green, clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/ttd/reporting/periods.py src/ttd/cli/invoices.py src/ttd/tui/screens/invoices.py tests/test_reporting/test_periods.py +git commit -m "feat: month-name period ranges with closest-year inference" +``` + +--- + +## Task 3: Derive the invoice period from billed items + +**Files:** +- Modify: `src/ttd/services/invoicing.py` +- Test: `tests/test_invoicing/test_derived_period.py` + +**Interfaces:** +- Consumes: `build_draft`, `persist_draft`, `apply_refresh`, `Draft`, `DraftLine.work_date`, `DraftExpenseLine.incurred_date`, `range_period`, `InvoiceLine`, `InvoiceExpenseLine`. +- Produces: `Draft.period` = the derived (min–max billed-date) period; `apply_refresh` updates `invoice.period_start`/`period_end` from persisted rows. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_invoicing/test_derived_period.py +from datetime import date, datetime +from decimal import Decimal + +from ttd.config.schema import Settings +from ttd.reporting import periods +from ttd.services import clients as client_svc +from ttd.services import expenses as expense_svc +from ttd.services import invoicing as svc +from ttd.services import projects as project_svc + + +async def _setup(db): + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + + +def _june() -> periods.Period: + return periods.range_period(date(2026, 6, 1), date(2026, 6, 30)) + + +async def test_invoice_period_tightens_to_billed_entries(db): + await _setup(db) + from ttd.services import entries as entry_svc + await entry_svc.log_entry("2026-06-16 9am-11am", "api-rewrite", now=datetime(2026, 6, 16, 12)) + await entry_svc.log_entry("2026-06-20 9am-10am", "api-rewrite", now=datetime(2026, 6, 20, 12)) + settings = Settings() + invoice = await svc.persist_draft(await svc.build_draft("acme-corp", _june(), settings), settings) + assert invoice.period_start == date(2026, 6, 16) # not June 1 + assert invoice.period_end == date(2026, 6, 20) # not June 30 + + +async def test_invoice_period_from_expenses_only(db): + await _setup(db) + await expense_svc.add_expense("api-rewrite", "Claude", Decimal("100"), incurred_date=date(2026, 6, 18)) + settings = Settings() + invoice = await svc.persist_draft(await svc.build_draft("acme-corp", _june(), settings), settings) + assert invoice.period_start == date(2026, 6, 18) + assert invoice.period_end == date(2026, 6, 18) + + +async def test_invoice_period_spans_time_and_expenses(db): + await _setup(db) + from ttd.services import entries as entry_svc + await entry_svc.log_entry("2026-06-16 9am-11am", "api-rewrite", now=datetime(2026, 6, 16, 12)) + await expense_svc.add_expense("api-rewrite", "Claude", Decimal("100"), incurred_date=date(2026, 6, 25)) + settings = Settings() + invoice = await svc.persist_draft(await svc.build_draft("acme-corp", _june(), settings), settings) + assert invoice.period_start == date(2026, 6, 16) + assert invoice.period_end == date(2026, 6, 25) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_invoicing/test_derived_period.py -v` +Expected: FAIL — `period_start`/`period_end` are June 1 / June 30 (the requested window). + +- [ ] **Step 3: Derive the period in `build_draft`** + +In `src/ttd/services/invoicing.py`, add a helper near `_draft_totals`: + +```python +def _derive_period(lines: list[DraftLine], expense_lines: list[DraftExpenseLine], fallback: Period) -> Period: + dates = [li.work_date for li in lines] + [el.incurred_date for el in expense_lines] + if not dates: + return fallback + return range_period(min(dates), max(dates)) +``` + +(`range_period` is already imported from `ttd.reporting.periods`; if only `Period` is imported, add `range_period` to that import.) + +In `build_draft`, change the final `return Draft(...)` to use the derived period: + +```python + subtotal, expenses_subtotal, tax, total = _draft_totals( + lines, expense_lines, settings.invoice.tax_rate + ) + actual_period = _derive_period(lines, expense_lines, fallback=period) + return Draft( + client=client, + period=actual_period, + lines=lines, + expense_lines=expense_lines, + subtotal=subtotal, + expenses_subtotal=expenses_subtotal, + tax=tax, + total=total, + ) +``` + +The empty-check above (`if not entries and not expenses:`) is unchanged and still reports the requested `period.label`, so a no-match window still errors clearly. + +- [ ] **Step 4: Re-derive the period on refresh** + +In `apply_refresh`, in the non-paid branch where `invoice.subtotal`/`tax`/`total`/`expenses_subtotal` are assigned (around the `invoice.save()` call), re-derive from the persisted rows before saving: + +```python + time_rows = await InvoiceLine.where(lambda li: li.invoice_id == invoice.id).all() + exp_rows = await InvoiceExpenseLine.where(lambda li: li.invoice_id == invoice.id).all() + billed_dates = [li.work_date for li in time_rows] + [li.incurred_date for li in exp_rows] + if billed_dates: + invoice.period_start = min(billed_dates) + invoice.period_end = max(billed_dates) + invoice.subtotal = fresh.after_subtotal + invoice.tax = fresh.after_tax + invoice.expenses_subtotal = fresh.after_expenses_subtotal + invoice.total = fresh.after_total + await invoice.save() +``` + +(Place the query after the line/expense reconciliation writes so it reflects the final rows. Match the exact existing assignment block; only add the period-derivation lines.) + +- [ ] **Step 5: Add a refresh test** + +```python +# tests/test_invoicing/test_derived_period.py (append) +from ttd.storage.models import Expense + + +async def test_refresh_reduces_period_when_item_removed(db): + await _setup(db) + from ttd.services import entries as entry_svc + await entry_svc.log_entry("2026-06-16 9am-11am", "api-rewrite", now=datetime(2026, 6, 16, 12)) + exp = await expense_svc.add_expense("api-rewrite", "Claude", Decimal("100"), incurred_date=date(2026, 6, 25)) + settings = Settings() + invoice = await svc.persist_draft(await svc.build_draft("acme-corp", _june(), settings), settings) + assert invoice.period_end == date(2026, 6, 25) + # release + delete the later expense, then refresh + locked = await Expense.get_or_none(exp.id) + locked.invoice_id = None + await locked.save() + await locked.delete() + preview = await svc.preview_refresh(invoice.number, settings) + refreshed = await svc.apply_refresh(invoice.number, preview, settings) + assert refreshed.period_end == date(2026, 6, 16) # period tightened back to the entry +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `uv run pytest tests/test_invoicing/test_derived_period.py -v && uv run pytest -q && uv run ty check && uv run ruff check` +Expected: PASS, full suite green (coverage ≥84%), clean. (Existing invoicing tests that assert on `period_start`/`period_end` may need updating if any asserted the full-window values — check `tests/test_services`/`tests/test_invoicing` for such assertions and update them to the derived values.) + +- [ ] **Step 7: Commit** + +```bash +git add src/ttd/services/invoicing.py tests/test_invoicing/test_derived_period.py +git commit -m "feat: record invoice period from billed items, not the requested window" +``` + +--- + +## Self-Review Notes (coverage against the spec) + +- Part 1 relative durations (this/last week, rolling last N days/weeks/months, digit + word counts, week_start) → **Task 1**. +- Part 1 month-name ranges + shorthands (`june`, `june 16 - 30`, abbreviations, separators, optional trailing year) → **Task 2**. +- Part 2 closest-year (never future) + cross-year wrap → **Task 2** (`_closest_year_range`/`_closest_month_year`). +- Part 3 derived invoice period (build_draft + apply_refresh) → **Task 3**. +- Part 4 error/help text → error message in Tasks 1 & 2; CLI `--period` help + TUI placeholder in Task 2. +- Part 4 tests → parser tests (Tasks 1–2, `test_reporting/test_periods.py`), behavioral derivation tests (Task 3, `test_invoicing/test_derived_period.py`). +- **Deferred (per spec):** quarter/year-to-date/year forms; log-grammar reuse; report-specific changes (reports inherit the new forms for free via `parse_period`). +- **Type consistency:** `parse_period(text, today, *, week_start="monday")` is the one signature used by both new families and both call-site updates; `_derive_period(lines, expense_lines, fallback)` and the apply_refresh re-derivation both key on `work_date`/`incurred_date`. +- **Verify during impl:** confirm `range_period` is imported in `invoicing.py` (add to the `ttd.reporting.periods` import if only `Period` is there). Confirm no existing test asserts an invoice's period equals the full requested window (Task 3 Step 6 checks and updates any). diff --git a/docs/superpowers/plans/2026-07-01-tui-invoice-render-format.md b/docs/superpowers/plans/2026-07-01-tui-invoice-render-format.md new file mode 100644 index 0000000..4397b95 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-tui-invoice-render-format.md @@ -0,0 +1,392 @@ +# TUI Invoice Render Format Modal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the TUI invoice render step (`e`) prompt for format, embed receipts in the PDF, and lock out Markdown when receipts are included — matching the CLI. + +**Architecture:** Extract the CLI's inline receipt-loading into one shared service helper. Add a bespoke `RenderFormatModal` (PDF/Markdown/Receipts switches with live reactivity) and rewire `action_render_files` to use it, passing decoded receipts to `render_pdf`. + +**Tech Stack:** Python 3.13, Textual (TUI: `ModalScreen`, `Switch`), Ferro-ORM/SQLite, pytest + pytest-asyncio (Textual pilot). + +## Global Constraints + +- Fix lives entirely in the render step (`e`); TUI invoice **creation stays persist-only** (unchanged). +- Bespoke modal (not the generic `FormModal`) because of live inter-switch reactivity. +- **Receipts** switch is disabled unless the invoice has ≥1 receipt; default on when available. +- Turning **Receipts on** forces **PDF on** and disables + clears **Markdown**; turning it off re-enables Markdown. +- Submit requires ≥1 format selected. +- PDF embeds receipts (decoded) only when the Receipts switch is on. +- One shared `load_invoice_receipts` used by both CLI and TUI (DRY); CLI user-facing behavior unchanged. +- Coverage gate `fail_under = 84` stays green; `ty` + `ruff` clean; avoid non-ASCII code literals (RUF001). + +## File Structure + +- **Modify:** `src/ttd/services/expenses.py` — add `load_invoice_receipts` (Task 1). +- **Modify:** `src/ttd/cli/invoices.py` — call the shared helper in `_render_files` (Task 1). +- **Modify:** `src/ttd/tui/screens/invoices.py` — `RenderFormatModal`, `_write_selected_formats`, rewired `action_render_files`, `Switch` import, binding label (Task 2). +- **Create:** `tests/test_storage/test_expenses.py` additions or `tests/test_invoicing/test_render_helper.py` — `load_invoice_receipts` unit test (Task 1). +- **Create:** `tests/test_tui/test_render_modal.py` — modal reactivity pilot tests + `_write_selected_formats` unit test (Task 2). + +--- + +## Task 1: Shared `load_invoice_receipts` helper (DRY) + +**Files:** +- Modify: `src/ttd/services/expenses.py` +- Modify: `src/ttd/cli/invoices.py` +- Test: `tests/test_invoicing/test_render_helper.py` (create) + +**Interfaces:** +- Consumes: `get_receipt` (existing, in `services/expenses.py`); `InvoiceExpenseLine.expense_id`. +- Produces: `async load_invoice_receipts(expense_lines) -> list[tuple[str, str, bytes]]` — decoded `(filename, content_type, bytes)` for each expense line that has a receipt, in line order. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_invoicing/test_render_helper.py +from datetime import date +from decimal import Decimal + +from ttd.services import clients as client_svc +from ttd.services import expenses as expense_svc +from ttd.services import invoicing as svc +from ttd.services import projects as project_svc +from ttd.reporting import periods + + +async def _invoice_with_receipt(db, tmp_path): + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + exp = await expense_svc.add_expense( + "api-rewrite", "Claude", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + rp = tmp_path / "r.pdf" + rp.write_bytes(b"%PDF-1.4\n\xff\xd8 binary") + await expense_svc.add_receipt(str(exp.id)[:8], rp) + period = periods.range_period(date(2026, 6, 1), date(2026, 6, 30)) + from ttd.config.schema import Settings + invoice = await svc.persist_draft(await svc.build_draft("acme-corp", period, Settings()), Settings()) + return await svc.get_invoice(invoice.number) + + +async def test_load_invoice_receipts_returns_decoded(db, tmp_path): + view = await _invoice_with_receipt(db, tmp_path) + receipts = await expense_svc.load_invoice_receipts(view.expense_lines) + assert len(receipts) == 1 + filename, content_type, data = receipts[0] + assert filename == "r.pdf" + assert content_type == "application/pdf" + assert data == b"%PDF-1.4\n\xff\xd8 binary" + + +async def test_load_invoice_receipts_empty_when_none(db): + assert await expense_svc.load_invoice_receipts([]) == [] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_invoicing/test_render_helper.py -v` +Expected: FAIL — `AttributeError: module 'ttd.services.expenses' has no attribute 'load_invoice_receipts'`. + +- [ ] **Step 3: Add the helper** + +In `src/ttd/services/expenses.py`, add (near `get_receipt`): + +```python +@in_db_session +async def load_invoice_receipts(expense_lines) -> list[tuple[str, str, bytes]]: + """Decoded (filename, content_type, bytes) receipts for an invoice's expense + lines, in line order; expense lines without a receipt are skipped.""" + out: list[tuple[str, str, bytes]] = [] + for line in expense_lines: + got = await get_receipt(str(line.expense_id)[:8]) + if got is not None: + out.append(got) + return out +``` + +- [ ] **Step 4: Use it in the CLI (behavior-preserving)** + +In `src/ttd/cli/invoices.py`, `_render_files`, replace the inline receipt-loading: + +```python + if pdf: + decoded = None + if receipts: + from ttd.services.expenses import load_invoice_receipts + + decoded = await load_invoice_receipts(view.expense_lines) + path = render_pdf(view, settings, stem.with_suffix(".pdf"), receipts=decoded) + success(f"Wrote {path}") +``` + +(Removes the inline `for line in view.expense_lines: get_receipt(...)` loop.) + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/test_invoicing/test_render_helper.py tests/test_cli -v && uv run pytest -q && uv run ty check && uv run ruff check` +Expected: PASS, full suite green (coverage ≥84%), clean. (The CLI receipt tests still pass — behavior is unchanged.) + +- [ ] **Step 6: Commit** + +```bash +git add src/ttd/services/expenses.py src/ttd/cli/invoices.py tests/test_invoicing/test_render_helper.py +git commit -m "refactor: shared load_invoice_receipts helper for CLI and TUI" +``` + +--- + +## Task 2: RenderFormatModal + rewire the TUI render action + +**Files:** +- Modify: `src/ttd/tui/screens/invoices.py` +- Test: `tests/test_tui/test_render_modal.py` (create) + +**Interfaces:** +- Consumes: `load_invoice_receipts` (Task 1); `svc.invoice_has_receipts`, `svc.get_invoice`, `render_pdf`, `write_markdown` (existing). +- Produces: `RenderFormatModal(has_receipts: bool)` returning `{"pdf": bool, "md": bool, "receipts": bool} | None`; module-level `async _write_selected_formats(view, settings, choice) -> list[str]` (returns the names written); rewired `action_render_files`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_tui/test_render_modal.py +from datetime import date +from decimal import Decimal + +import pytest +from textual.widgets import Switch + +from ttd.config.schema import InvoiceConfig, Settings, StorageConfig +from tests.test_tui._db import open_test_db + + +@pytest.fixture +async def app_and_settings(tmp_path, monkeypatch): + monkeypatch.setenv("TTD_DB_PATH", str(tmp_path / "tui.db")) + monkeypatch.setenv("TTD_CONFIG_DIR", str(tmp_path / "config")) + from ttd.tui.app import TtdApp + return TtdApp() + + +async def test_modal_receipts_on_disables_markdown(app_and_settings): + from ttd.tui.screens.invoices import RenderFormatModal + app = app_and_settings + async with app.run_test(size=(120, 40)) as pilot: + app.push_screen(RenderFormatModal(has_receipts=True)) + await pilot.pause() + modal = app.screen + assert isinstance(modal, RenderFormatModal) + # receipts enabled + on; markdown disabled at start (receipts default on) + assert modal.query_one("#receipts", Switch).disabled is False + assert modal.query_one("#receipts", Switch).value is True + assert modal.query_one("#md", Switch).disabled is True + # turn receipts off -> markdown re-enabled + modal.query_one("#receipts", Switch).value = False + await pilot.pause() + assert modal.query_one("#md", Switch).disabled is False + + +async def test_modal_no_receipts_disables_receipts_switch(app_and_settings): + from ttd.tui.screens.invoices import RenderFormatModal + app = app_and_settings + async with app.run_test(size=(120, 40)) as pilot: + app.push_screen(RenderFormatModal(has_receipts=False)) + await pilot.pause() + modal = app.screen + assert modal.query_one("#receipts", Switch).disabled is True + assert modal.query_one("#md", Switch).disabled is False + + +async def test_write_selected_formats_pdf_with_receipts(db, tmp_path): + # build an invoice whose expense has a receipt, then render via the helper + from ttd.services import clients as client_svc + from ttd.services import expenses as expense_svc + from ttd.services import invoicing as svc + from ttd.services import projects as project_svc + from ttd.reporting import periods + from ttd.tui.screens.invoices import _write_selected_formats + from pypdf import PdfReader + + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + exp = await expense_svc.add_expense("api-rewrite", "Claude", Decimal("100"), incurred_date=date(2026, 6, 15)) + from fpdf import FPDF + rp = tmp_path / "r.pdf" + r = FPDF(); r.add_page(); r.set_font("helvetica", size=12); r.cell(0, 10, "RECEIPT"); r.output(str(rp)) + await expense_svc.add_receipt(str(exp.id)[:8], rp) + period = periods.range_period(date(2026, 6, 1), date(2026, 6, 30)) + settings = Settings(invoice=InvoiceConfig(output_dir=tmp_path / "out")) + invoice = await svc.persist_draft(await svc.build_draft("acme-corp", period, settings), settings) + view = await svc.get_invoice(invoice.number) + + without = await _write_selected_formats(view, settings, {"pdf": True, "md": False, "receipts": False}) + base_pdf = tmp_path / "out" / f"{invoice.number}-acme-corp.pdf" + base_pages = len(PdfReader(str(base_pdf)).pages) + with_r = await _write_selected_formats(view, settings, {"pdf": True, "md": False, "receipts": True}) + assert len(PdfReader(str(base_pdf)).pages) > base_pages # receipts appended + assert any(name.endswith(".pdf") for name in with_r) + + +async def test_write_selected_formats_markdown(db, tmp_path): + from ttd.services import clients as client_svc + from ttd.services import expenses as expense_svc + from ttd.services import invoicing as svc + from ttd.services import projects as project_svc + from ttd.reporting import periods + from ttd.tui.screens.invoices import _write_selected_formats + + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + await expense_svc.add_expense("api-rewrite", "Claude", Decimal("100"), incurred_date=date(2026, 6, 15)) + period = periods.range_period(date(2026, 6, 1), date(2026, 6, 30)) + settings = Settings(invoice=InvoiceConfig(output_dir=tmp_path / "out")) + invoice = await svc.persist_draft(await svc.build_draft("acme-corp", period, settings), settings) + view = await svc.get_invoice(invoice.number) + wrote = await _write_selected_formats(view, settings, {"pdf": False, "md": True, "receipts": False}) + assert (tmp_path / "out" / f"{invoice.number}-acme-corp.md").exists() + assert any(name.endswith(".md") for name in wrote) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_tui/test_render_modal.py -v` +Expected: FAIL — `ImportError: cannot import name 'RenderFormatModal'` / `_write_selected_formats`. + +- [ ] **Step 3: Add the `Switch` import** + +In `src/ttd/tui/screens/invoices.py`, add `Switch` to the `textual.widgets` import: + +```python +from textual.widgets import Button, DataTable, Input, Label, Markdown, Static, Switch +``` + +- [ ] **Step 4: Add `RenderFormatModal`** + +Add near the other modal classes in `src/ttd/tui/screens/invoices.py`: + +```python +class RenderFormatModal(ModalScreen[dict | None]): + """Choose which files to render. Receipts embed into the PDF and are only + available when the invoice has receipts; enabling them locks out Markdown.""" + + BINDINGS: ClassVar = [("escape", "dismiss(None)", "cancel")] + + def __init__(self, has_receipts: bool) -> None: + super().__init__() + self.has_receipts = has_receipts + + def compose(self) -> ComposeResult: + with Vertical(classes="modal-box"): + yield Label("render invoice", classes="modal-title") + with Horizontal(classes="form-toggle-row"): + yield Switch(value=True, id="pdf") + yield Label("PDF", classes="field-label") + with Horizontal(classes="form-toggle-row"): + yield Switch(value=False, id="md", disabled=self.has_receipts) + yield Label("Markdown", classes="field-label") + with Horizontal(classes="form-toggle-row"): + yield Switch( + value=self.has_receipts, id="receipts", disabled=not self.has_receipts + ) + yield Label("Include receipts", classes="field-label") + yield Static("", id="render-error", classes="form-error") + with Horizontal(classes="modal-buttons"): + yield Button("Render", variant="primary", id="render") + yield Button("Cancel", id="cancel") + + @on(Switch.Changed, "#receipts") + def _receipts_changed(self, event: Switch.Changed) -> None: + md = self.query_one("#md", Switch) + if event.value: + self.query_one("#pdf", Switch).value = True + md.value = False + md.disabled = True + else: + md.disabled = False + + @on(Button.Pressed, "#render") + def _render(self) -> None: + pdf = self.query_one("#pdf", Switch).value + md = self.query_one("#md", Switch).value + receipts = self.query_one("#receipts", Switch).value + if not pdf and not md: + self.query_one("#render-error", Static).update("[red]Choose at least one format[/red]") + return + self.dismiss({"pdf": pdf, "md": md, "receipts": receipts}) + + @on(Button.Pressed, "#cancel") + def _cancel(self) -> None: + self.dismiss(None) +``` + +- [ ] **Step 5: Add `_write_selected_formats` and rewire `action_render_files`** + +Add a module-level helper: + +```python +async def _write_selected_formats(view: svc.InvoiceView, settings, choice: dict) -> list[str]: + """Render the chosen formats; return the file names written.""" + from ttd.services.expenses import load_invoice_receipts + + stem = settings.invoice.output_dir / f"{view.invoice.number}-{view.client.slug}" + wrote: list[str] = [] + if choice["pdf"]: + decoded = await load_invoice_receipts(view.expense_lines) if choice["receipts"] else None + render_pdf(view, settings, stem.with_suffix(".pdf"), receipts=decoded) + n = len(decoded) if decoded else 0 + wrote.append(f"{stem.name}.pdf" + (f" (+{n} receipt{'s' if n != 1 else ''})" if n else "")) + if choice["md"]: + write_markdown(view, settings, stem.with_suffix(".md")) + wrote.append(f"{stem.name}.md") + return wrote +``` + +Replace `action_render_files`: + +```python + async def action_render_files(self) -> None: + number = self._selected_number() + if number is None: + return + settings = get_settings() + view = await svc.get_invoice(number) + has_receipts = await svc.invoice_has_receipts(view) + + async def _render(choice: dict | None) -> None: + if choice is None: + return + wrote = await _write_selected_formats(view, settings, choice) + self.notify("wrote " + ", ".join(wrote), title="rendered") + + self.app.push_screen(RenderFormatModal(has_receipts), _render) +``` + +Change the `e` binding label: + +```python + ("e", "render_files", "render"), +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `uv run pytest tests/test_tui/test_render_modal.py -v && uv run pytest -q && uv run ty check && uv run ruff check` +Expected: PASS, full suite green (coverage ≥84%), clean. + +- [ ] **Step 7: Commit** + +```bash +git add src/ttd/tui/screens/invoices.py tests/test_tui/test_render_modal.py +git commit -m "feat: TUI invoice render format modal with receipts and md gating" +``` + +--- + +## Self-Review Notes (coverage against the spec) + +- Shared receipt loader (DRY, CLI + TUI) → **Task 1** (`load_invoice_receipts`). +- `RenderFormatModal` with the switch reactivity + receipts-disabled-unless-present + receipts-on-locks-md + submit validation → **Task 2**. +- Rewired `action_render_files` passing decoded receipts to `render_pdf`; binding label "render" → **Task 2**. +- Tests: helper unit (Task 1); modal reactivity pilot + format-writing unit (Task 2). The receipt-embedding-in-PDF behavior is verified via `_write_selected_formats` (page-count grows) rather than a fragile full keystroke pilot — the risky logic (reactivity, receipt loading, format dispatch) is all covered; only the trivial `push_screen` glue in `action_render_files` is exercised indirectly. +- Creation stays persist-only (untouched) — per spec, out of scope. +- **Type consistency:** `load_invoice_receipts(expense_lines) -> list[tuple[str,str,bytes]]` is used by both the CLI `_render_files` and the TUI `_write_selected_formats`; the modal returns `{"pdf","md","receipts"}` consumed by `_write_selected_formats`. +- **Verify during impl:** confirm `Settings(invoice=InvoiceConfig(output_dir=...))` constructs cleanly (InvoiceConfig with an explicit `output_dir`); if the field validator requires a `Path`, pass a `Path`. Confirm `_write_selected_formats`'s `settings` param type — it's the app `Settings`; annotate as `Settings` if imported, else leave untyped to avoid an import cycle. diff --git a/docs/superpowers/specs/2026-06-30-billable-expenses-design.md b/docs/superpowers/specs/2026-06-30-billable-expenses-design.md new file mode 100644 index 0000000..fd06ff1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-billable-expenses-design.md @@ -0,0 +1,259 @@ +# Billable Expenses (Client Chargebacks) — Design + +**Status:** Approved design, pre-implementation +**Date:** 2026-06-30 +**Scope:** Track purchased items for a project and bill them back to the client on invoices. + +## Problem + +A solo developer pays for things on a client's behalf (e.g. a $100/month Claude Code +subscription on their own card) and needs to charge those costs back to the client. +Today `ttd` only bills *time*. We need a second kind of billable thing — an **expense** — +that flows onto invoices alongside time entries. + +## Decisions (settled during brainstorming) + +| Decision | Choice | Rationale | +|---|---|---| +| Markup | **Pure pass-through** — one `amount` | What you paid is what you bill. No cost/markup split. | +| Attachment | **To a project** (client derived) | Mirrors `Entry`; reuses all project→client invoicing plumbing. | +| Tax | **Expenses untaxed** | Reimbursements; tax applies only to the time subtotal. Expenses add on after tax. | +| Recurrence | **One-off logging + history recall** | No scheduler. When adding, offer recent `(description, amount)` for the project/client to reuse. | +| Receipts | **Stored in DB, opt-in attachment** | Base64 text in a side table (see "Ferro constraint"). Travels in the single SQLite backup. | +| Invoice integration | **Approach 1 — separate line table** | `InvoiceExpenseLine` parallels `InvoiceLine`; keeps each shape honest, existing time logic untouched. | +| FK style | **Plain `*_id` columns** (codebase convention) | Matches every existing model; relationship migration tracked separately (ttd#13). | +| Scope | **A now, designed for C** | Invoicing + JSON backup now. Reports + full interchange deferred as later increments. | + +### Ferro constraint (ferro-orm#160) + +`Model.save()` serializes the whole instance via `model_dump_json()`, which cannot +represent non-UTF-8 `bytes`. Real binary (PDF/image receipts) therefore cannot be stored +in a raw `bytes` field through the ORM. **Workaround:** store receipts **base64-encoded in +a `text` column**, in a dedicated side table so list queries never load receipt bytes. +Tracked upstream as ferro-orm#160; the related ttd convention/refactor is ttd#13. + +## Scope + +**In scope (A):** +- `Expense`, `ExpenseReceipt`, `InvoiceExpenseLine` models + one `Invoice` field. +- `services/expenses.py` (CRUD, receipts, recall). +- `ttd expense` CLI sub-app (+ interactive form with recall, + `receipt` subcommands). +- Invoicing lifecycle: draft → persist → refresh → void, including untaxed totals. +- Invoice rendering: PDF expense section + opt-in receipt pages; markdown expense section. +- Invoice generation: explicit format choice (default PDF); markdown disabled when receipts present. +- JSON backup: expenses + receipts in the envelope (v2). +- TUI: invoice detail shows expenses; quick-add expense entry. + +**Deferred (later increments):** +- **Reports awareness** (B): expense totals in `ttd report …` and `summary.py`. +- **Full interchange** (C): expenses in CSV/XLSX/Numbers export *and* import. +- TUI: dedicated full expenses management screen (v1 ships quick-add only). +- Recurring-expense scheduling/generation (explicitly out — recall covers the need). +- Expense markup, per-expense taxable flag, cost-vs-billed split. + +## Data model + +New module `storage/models/expense.py`: + +```python +class Expense(Model): + """A purchased item billed back to a client, attached to a project. + `invoice_id` set means billed & locked — mirrors Entry.""" + id: Annotated[UUID | None, FerroField(primary_key=True)] = None + project_id: Annotated[UUID, FerroField(index=True)] + incurred_date: Annotated[date, FerroField(db_type="date", index=True)] + description: str + amount: Decimal + note: str = "" + invoice_id: Annotated[UUID | None, FerroField(index=True)] = None + created_at: datetime + updated_at: datetime + +class ExpenseReceipt(Model): + """Optional receipt blob; own table so `expense list` never loads bytes. + Base64 text per ferro-orm#160.""" + id: Annotated[UUID | None, FerroField(primary_key=True)] = None + expense_id: Annotated[UUID, FerroField(unique=True, index=True)] + filename: str + content_type: str + data_b64: Annotated[str, FerroField(db_type="text")] +``` + +Add to `storage/models/invoice.py`: + +```python +class InvoiceExpenseLine(Model): + """One expense frozen onto an invoice; amount frozen at invoice time.""" + id: Annotated[UUID | None, FerroField(primary_key=True)] = None + invoice_id: Annotated[UUID, FerroField(index=True)] + expense_id: Annotated[UUID, FerroField(index=True)] + incurred_date: Annotated[date, FerroField(db_type="date")] + description: str + amount: Decimal +``` + +Add one field to `Invoice` (default keeps existing rows valid): + +```python + expenses_subtotal: Decimal = Decimal("0") +``` + +**Invoice totals contract (after this change):** +- `subtotal` — time lines only; remains the **taxed** base. *(unchanged meaning)* +- `tax` — `to_cents(subtotal * tax_rate)`. *(unchanged math; time only)* +- `expenses_subtotal` — sum of expense lines; **untaxed**. +- `total` — `subtotal + tax + expenses_subtotal`. + +**Migration:** Ferro `migrate_updates=True` adds the new tables and the defaulted +`expenses_subtotal` column on connect. No manual migration script (consistent with the project). + +## Services (`services/expenses.py`) + +All under `@in_db_session`, mirroring `services/entries.py`: + +```python +async def add_expense(project_slug, description, amount, *, incurred_date=None, + note="", receipt_path=None) -> Expense +async def list_expenses(*, client=None, project=None, date_from=None, + date_to=None, unbilled_only=False) -> list[ExpenseView] +async def edit_expense(expense_id, **fields) -> Expense # blocked if invoice_id set +async def delete_expense(expense_id) -> None # also deletes its receipt (manual cascade) +async def recent_expenses(project_slug=None, client_slug=None, limit=8) -> list[ExpenseSuggestion] + +# Receipts +async def add_receipt(expense_id, path) -> ExpenseReceipt +async def get_receipt(expense_id) -> tuple[str, str, bytes] | None # (filename, content_type, bytes) +async def remove_receipt(expense_id) -> None +``` + +- `ExpenseView` = `expense + project + client + has_receipt`, resolved via the manual + dict-join pattern used elsewhere — list/table rendering needs no extra queries. +- **Locking parity:** `edit_expense`/`delete_expense` raise if `invoice_id` is set + (same "void and re-invoice" rule as invoiced entries). +- **Receipts:** read file → base64 → sniff `content_type` from extension → one + `ExpenseReceipt` row. Size guard (~5 MB) rejects oversized files. +- **Recall:** `recent_expenses` returns distinct `(description, amount)` pairs from prior + expenses, newest-first, scoped to project then falling back to client. Pure read; no + dedup/template table. + +## CLI (`cli/expenses.py`) + +New `ttd expense` sub-app, registered in `cli/app.py`: + +```sh +ttd expense add "Claude Code" 100 -p api-rewrite +ttd expense add "Claude Code" 100 -p api-rewrite --on 2026-06-15 \ + --note "June sub" --receipt ~/Downloads/claude-receipt.pdf +ttd expense add -i # interactive form; recall picker after project + +ttd expense list -p api-rewrite +ttd expense list --client acme-corp --from 2026-06-01 --to 2026-06-30 +ttd expense list --unbilled +ttd expense list --json + +ttd expense edit --amount 120 --note "..." # refuses if invoiced +ttd expense rm # refuses if invoiced + +ttd expense receipt add ~/Downloads/receipt.pdf +ttd expense receipt get --out ./receipt.pdf # decode base64 → file +ttd expense receipt rm +``` + +- `add` takes `description` + `amount` positionally; `-p/--project`, `--on` (incurred + date, default today), `--note`, `--receipt` as options. +- `list` renders `table("ID", "Date", "Project", "Description", "Amount", "")` with an + `·inv` flag on invoiced rows and a footer total; `--json` emits structured form. +- `-i` triggers the interactive form; passed flags pre-fill it; recall picker sourced from + `recent_expenses`. +- `receipt` is a nested command group (like `invoice mark`). + +## Invoicing integration (`services/invoicing.py`) + +- **Draft (`build_draft`):** after time lines, pull uninvoiced billable expenses for the + client's projects in the period (`invoice_id is None`, within period). Build trivial + expense draft lines (no rollup/rounding/rate). `Draft` gains `expense_lines` and + `expenses_subtotal`. **Drafts may be expenses-only** — the "no billable entries" guard + relaxes to "no entries *and* no expenses". +- **Persist (`persist_draft`):** in the existing transaction, write one + `InvoiceExpenseLine` per expense line and stamp `expense.invoice_id = invoice.id` + (mirrors the entry-locking loop). Store `invoice.expenses_subtotal`. +- **Void (`mark_invoice`):** add a loop nulling `expense.invoice_id` for linked expenses, + releasing them exactly as entries are released (manual cascade; ttd#13 would make it a + DB action later). +- **Refresh (`preview_refresh`/`apply_refresh`):** expense lines join the diff model; the + only mutable billing field is `amount`. Paid invoices block amount changes (description + edits allowed), reusing `PAID_REFRESH_BLOCKED`. Added/removed expenses show as + add/remove diffs. + +## Invoice rendering & generation + +**PDF (`invoicing/pdf.py`):** a "Reimbursable expenses" table after time lines (date / +description / amount); totals block becomes Subtotal (time) / Tax / Expenses +(reimbursable) / Total. No expenses → section and line omitted (existing invoices render +byte-identically). + +**Markdown (`invoicing/markdown.py`):** same expense section in text. **Markdown never +renders receipts.** + +**Receipt inclusion (opt-in):** +- `--receipts` flag on `invoice create` / `invoice render`; `[invoice].attach_receipts` + config default. +- **PDF only.** Image receipts via fpdf2 `image()`; PDF receipts merged via **`pypdf`** + (new pure-python dependency — honors the "no system dependencies" rule), behind a + "Receipts" divider page. +- `--receipts` with no PDF target → error. + +**Format is an explicit choice (no auto-both):** +- Remove `render`'s `if not pdf and not md: pdf = md = True`. When no format flag is + given, **default to PDF only** (canonical, sendable). Apply the same default to `create` + so a bare `invoice create --client x` produces a PDF. +- `--md` opts into markdown; `--pdf --md` for both. + +**Markdown disabled when receipts present:** +- Condition: `--receipts` (or config) active **and** the invoice has ≥1 linked expense + with an `ExpenseReceipt`. +- Explicit `--md` then → **hard error** (fail fast): *"Invoice N has K receipts; Markdown + can't render them. Drop --md, or omit --receipts to generate Markdown without them."* +- Interactive `create` form disables/hides the "Render Markdown?" option in that state. +- Receipts active but invoice has no receipts → markdown stays available. +- `svc.invoice_has_receipts(view) -> bool` centralizes the check for CLI + form. + +## TUI + +- **Invoices screen:** invoice detail gains a read-only "Reimbursable expenses" table and + the expanded totals block (parity with PDF). +- **Quick-add expense** affixed to timesheet/dashboard (key `e` → expense form with project + picker + recall list) so the recall UX exists in the TUI. +- `tui/_data.py` gains read helpers (expenses for an invoice / period). +- Full dedicated expenses screen deferred. + +## JSON backup (`interchange/json_io.py`) + +- Envelope gains an `expenses` array (project slug, incurred_date, description, amount, + note, invoice_number) **and** receipts (base64, keyed to expense), so backups round-trip + everything. +- `read_json` restores expenses + receipts; `ENVELOPE_VERSION` → 2; v1 envelopes still + read (no `expenses` key → none imported). +- CSV/XLSX/Numbers untouched (deferred to increment C). + +## Testing + +- `test_storage/` — Expense/ExpenseReceipt/InvoiceExpenseLine CRUD; base64 receipt + round-trip; locking (edit/delete refused when invoiced). +- `test_services` — draft with expenses (incl. expenses-only); untaxed-total math; void + releases expenses; refresh diffs (add/remove/amount); paid-invoice block; `recent_expenses`. +- invoicing render — PDF with image + PDF receipts (page count grows / merge ran); markdown + omits receipts; md-disabled-on-receipted-invoice guard; no-expense invoice renders + byte-identically (regression guard). +- `test_interchange` — JSON v2 round-trips expenses + receipts; v1 envelope still imports. + +## New dependency + +- `pypdf` — pure-python, for merging PDF receipts into the invoice PDF. Pure-python keeps + the project's "no system dependencies" constraint intact. + +## Related issues + +- **ferro-orm#160** — `Model.save()` can't persist binary `bytes` (drives the base64 receipt workaround). +- **ttd#13** — migrate models to Ferro relationships + cascades (would replace the manual + expense/receipt/invoice cascade logic). diff --git a/docs/superpowers/specs/2026-06-30-tui-log-page-design.md b/docs/superpowers/specs/2026-06-30-tui-log-page-design.md new file mode 100644 index 0000000..f624e34 --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-tui-log-page-design.md @@ -0,0 +1,87 @@ +# TUI Log Page (re-scope timesheet) — Design + +**Status:** Approved design, pre-implementation +**Date:** 2026-06-30 +**Branch:** feat/billable-expenses (TUI side of the billable-expenses feature) +**Scope:** Re-scope the TUI `timesheet` screen into a unified `log` page that views, adds, edits, and deletes BOTH time entries and expenses. + +## Problem + +Expenses can be created in the TUI (via the `l` chooser) and seen on invoices, but there is +no TUI surface to **browse/manage expenses** as a list. Meanwhile the `timesheet` screen — the +only TUI place to view a span of time entries and edit/delete them — is underused (the user +goes straight to reports). Rather than add a 7th nav item, re-scope `timesheet` into a `log` +page that manages both record types, mirroring the `l` chooser (log time / log expense). + +## Decisions (settled during brainstorming) + +| Decision | Choice | +|---|---| +| Replace vs add | Re-scope `timesheet` → `log` (nav slot 2). No 7th nav item. Time-entry edit/delete is preserved (the concern that drove this). | +| Layout | **Two stacked sections** — a time table, then an expenses table — each with its own columns (different record shapes; matches the invoice detail/preview layout). | +| Add/edit/delete | **Focus-based.** `e` edits / `x` deletes the highlighted row in the *focused* section. Adding reuses the global `l` chooser (time/expense) — no separate add key. | +| Period window | **Month only**, cycled with `[` / `]`. Drop the day/week/month (`d`/`w`/`m`) toggle. | +| Empty expenses | Show the section header + a muted "no expenses this month" line (page shape stays stable). | + +## Page identity & nav + +- `NAV` entry `("timesheet", "2 timesheet")` → `("log", "2 log")` in `src/ttd/tui/screens/_base.py`. +- Rework `TimesheetScreen` into `LogScreen`: rename the class, rename `screens/timesheet.py` → + `screens/log.py`, set `nav_id = "log"`, and update the `SCREENS` registry in `src/ttd/tui/app.py` + (the screen is keyed by `nav_id`, so the `goto('timesheet')` binding/registry key becomes + `'log'`). Update any imports/references. +- Keep it one screen with two sections. + +## Layout & data + +Under a shared month header (e.g. "June 2026"), two `DataTable`s: + +- **Time** — columns: `date · project · hours · note`. This is the current timesheet day table, + unchanged in content; rows are entries in the active month by `work_date`. +- **Expenses** — columns: `date · project · description · amount`. Rows are expenses in the + active month by `incurred_date`. Empty → header + muted "no expenses this month". + +A footer shows the month's billable time total and the month's expense total. + +Both sections are scoped to the same active month; `[` / `]` shift the month and refresh both. + +## Interaction + +- `[` / `]` cycle months; `d`/`w`/`m` span bindings are removed. +- Focus moves between the two sections (Tab; and up/down rolls past a table edge into the other). + The focused section's header is visually highlighted. +- **`e`** edits the highlighted row in the focused section: + - time → existing entry edit flow. + - expense → a `FormModal` (project/description/amount/date) prefilled with the row's values, + submitting through `expense_svc.edit_expense`. +- **`x`** deletes the highlighted row in the focused section (entry → `entry_svc.delete_entry`, + expense → `expense_svc.delete_expense`). Both services already refuse invoiced rows; surface + that error via `notify`. +- **`l`** (global chooser) adds time or expense; on return the page refreshes. + +## Implementation & testing + +- **Services/data:** reuse `entry_svc` (list/edit/delete) and `expense_svc.list_expenses`/ + `edit_expense`/`delete_expense`. Add a `_data` helper for "expenses in a month window" + mirroring the entries-by-window helper, returning the rows the expenses table renders. +- **Edit modal:** reuse the generic `FormModal` with the same fields as the `l` expense form; + prefill from the selected expense; validate amount/date with the existing `_validate_amount`/ + `_validate_date` helpers. +- **Focus model:** track the active section; route `e`/`x` by it; highlight the active header. +- **Tests (pilot + data):** + - `_data` month-window expense helper returns the right rows (unit). + - Pilot: log screen renders both sections for a month containing an entry + an expense; + `[`/`]` changes the month and re-renders; `x` on a focused expense row deletes it; + deleting/editing an invoiced expense surfaces an error. + - Keep the coverage gate (`fail_under = 84`) green. + +## Out of scope / deferred + +- Receipt attachment from the TUI (still CLI-only — separate follow-up). +- Any change to dashboard/reports. +- Recurring expenses, markup (already out of scope for the feature). + +## Related + +- Builds on the billable-expenses feature (PR #14): the `l` time/expense chooser, the + `FormModal` expense form, and `expense_svc` CRUD all already exist. diff --git a/docs/superpowers/specs/2026-07-01-flexible-invoice-periods-design.md b/docs/superpowers/specs/2026-07-01-flexible-invoice-periods-design.md new file mode 100644 index 0000000..e885a08 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-flexible-invoice-periods-design.md @@ -0,0 +1,89 @@ +# Flexible Invoice Periods — Design + +**Status:** Approved design, pre-implementation +**Date:** 2026-07-01 +**Branch:** feat/billable-expenses (Part 2 depends on the expense draft-line code there) +**Scope:** Two related improvements to invoice periods — (1) richer period parsing, and (2) recording the invoice's *actual* period derived from the billed items rather than the requested window. + +## Problem + +1. **Period parsing is rigid.** `reporting/periods.py` `parse_period` accepts only `''`/`last month`/`this month`/`YYYY-MM`/`YYYY-MM-DD to YYYY-MM-DD`. Users want relative durations ("last two weeks") and natural month-name ranges ("june 16 to june 30"). +2. **The recorded invoice period overstates coverage.** `persist_draft` stamps the invoice's `period_start`/`period_end` as the *requested window*. If June 1–15 was already invoiced and you invoice "this month" again, it correctly sweeps only the uninvoiced June 16–30 work but records the period as "June 1–30". + +The existing `ttd log` natural-language parser is NOT reusable: it has no month-name support and requires a clock time (it's built for time-of-day intervals). + +## Decisions (settled during brainstorming) + +| Decision | Choice | +|---|---| +| New relative forms | `this week` / `last week` (calendar), plus rolling `last days\|weeks\|months` ending **today**; `` is a digit or a word (`one`…`twelve`). | +| Month-name forms | `june 16 to june 30`, abbreviations (`jun`), separators `to`/`-`/`–`/`..`; shorthands `june` (whole month) and `june 16 - 30` (second endpoint inherits the month). | +| Year inference | **Closest-year, never future.** | +| Cross-year ranges | Month wrap (`dec 28 to jan 3`) rolls the end into the following month/year. | +| Invoice period | **Derived from the billed line items** (min–max of dates), not the requested window. | +| Implementation home | All parsing stays in `reporting/periods.py` (no log-grammar reuse, no new NL-date dependency). | + +## Part 1 — Period grammar (`reporting/periods.py`) + +`parse_period(text, today)` gains two new families on top of the existing branches: + +**Relative durations** +- `this week` / `last week` → calendar weeks (respect `display.week_start`, like `week_period`). `this week` = current calendar week; `last week` = previous full calendar week. +- Rolling `last days|weeks|months` ending today: + - days: `today − (N−1) … today` (N calendar days including today). + - weeks: `today − (N*7 − 1) … today`. + - months: `(today − N calendar months) … today`. + - `` accepts digits (`2`, `10`) or number words `one…twelve`. + - Note: bare `last week` (calendar) and `last 1 week` (rolling 7 days) may differ by a day or two; Part 2 makes this invisible on the invoice. + +**Month-name ranges** +- ` ` — full names + 3-letter abbreviations; `sep` ∈ {`to`, `-`, `–`, `..`}. +- Shorthands: + - `` alone → that whole month. + - ` ` → second endpoint inherits the first month. +- Both endpoints resolve under one inferred year unless a year is explicit (a year token, if present, is honored). + +## Part 2 — Year inference (closest-year, never future) + +When a month-name form omits the year, resolve it as follows: +- Build the range twice: once with **this year**, once with **last year**. +- Choose the candidate whose range is **temporally closest to today** — distance from `today` to the range interval, `0` if today falls inside it. Ties → **this year**. +- **Never infer a future (next) year** — only this year and last year are candidates. + +Worked examples (all confirmed): +- Jan 1 2026, `dec 15 – 31` → **2025** (Dec 2025 ended ~1 day ago; Dec 2026 ~11 months away). +- June 30 2026, `june 16 – 30` → **2026** (today inside the range). +- June 18 2026, `june 1 – 15` → **2026** (ended 3 days ago vs a year ago). +- June 1 2026, `june 16 – 30` → **2026** (15 days out beats ~11 months). + +**Cross-year ranges:** when the second month is earlier in the year than the first (`dec 28 to jan 3`), the end rolls into the following month/year: start Dec (inferred year Y), end Jan (Y+1). Apply the closest-year rule to the *start* month; the end takes the wrapped year. + +## Part 3 — Derived invoice period (`services/invoicing.py`) + +- `build_draft` continues to use the parsed `Period` **only as a sieve** to select uninvoiced items within `[period.start, period.end]`. +- The **empty-check** ("no uninvoiced entries or expenses for … in {window}") still uses the requested window's label. +- After building the time lines and expense lines, derive the invoice's actual period: + - `dates = [line.work_date for time lines] + [eline.incurred_date for expense lines]` + - `actual = range_period(min(dates), max(dates))` + - Set `Draft.period = actual` so `persist_draft` records the tight span. (The window is no longer stored anywhere on the invoice.) +- `apply_refresh` re-derives the period from the remaining linked items (time + expenses) so the stored period stays accurate after line edits/removals; update `invoice.period_start`/`period_end` accordingly in the non-paid branch. +- Single-day results (start == end) are valid. + +## Part 4 — Errors, help text, testing + +- Update the `parse_period` error message to list the new forms. +- Update the CLI `invoice create` `--period` help and the TUI new-invoice period-field placeholder/label to advertise: `last two weeks`, `this week`, `june 16 to june 30`, alongside the existing examples. +- Tests: + - `reporting/periods` (unit, table-driven): each relative form (`this week`, `last week`, `last two weeks`, `last 10 days`, `last 3 months`, digit + word counts); each month-name form (`june`, `june 16 to june 30`, `jun 16 - 30`, cross-year `dec 28 to jan 3`); the four closest-year examples; and error cases (unknown month, malformed). + - `services/invoicing` (behavioral): sweeping a window where only part is uninvoiced records the *derived* period (e.g. only June 16–30 uninvoiced → invoice period June 16–30); an expenses-only invoice derives its period from `incurred_date`s; refresh re-derives after removing an item. +- Keep the coverage gate (`fail_under = 84`) green; `ty` + `ruff` clean. + +## Out of scope / deferred + +- `this quarter`/`last quarter`, `year to date`, `this year`/`last year` (expressible as explicit ranges; add later if wanted). +- Reusing/extending the `ttd log` grammar for month names (separate concern). +- Reports currently call `parse_period` too — they automatically inherit the new forms (no extra work), but no report-specific behavior changes are in scope. + +## Related + +- Builds on the billable-expenses feature (PR #14): Part 3 derives the period from both time and expense line dates, so it needs the expense draft-line code. diff --git a/docs/superpowers/specs/2026-07-01-tui-invoice-render-format-design.md b/docs/superpowers/specs/2026-07-01-tui-invoice-render-format-design.md new file mode 100644 index 0000000..ca080c7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-tui-invoice-render-format-design.md @@ -0,0 +1,88 @@ +# TUI Invoice Render Format Modal — Design + +**Status:** Approved design, pre-implementation +**Date:** 2026-07-01 +**Branch:** feat/billable-expenses (depends on the receipt + CLI render logic there) +**Scope:** Bring the TUI invoice render step to parity with the CLI — explicit format choice, receipt inclusion, and markdown gating. + +## Problem + +The TUI invoice render action (`e` on the invoices screen, `action_render_files`) is broken relative to the CLI: + +1. It always renders **both** PDF and Markdown — never asks which format. +2. It calls `render_pdf(view, settings, path)` with **no `receipts=` argument**, so an invoice's receipts are never embedded in the TUI-rendered PDF. +3. There is no way to gate Markdown when the invoice has receipts (Markdown can't carry them). + +The CLI already solved this (`_resolve_formats`, `--receipts`, `render_pdf(..., receipts=…)`, `invoice_has_receipts`, per-expense `get_receipt`), but none of it is wired into the TUI. TUI invoice **creation** only persists (no rendering), so the fix belongs entirely in the render step. + +## Decisions (settled during brainstorming) + +| Decision | Choice | +|---|---| +| Where the choice lives | The render step (`e`) only. Creation stays persist-only. | +| UI | An explicit **toggle form** (bespoke modal), not an adaptive picker. | +| Receipts control | A **Receipts** switch, **disabled unless the invoice has ≥1 receipt**. | +| Receipts ↔ Markdown | When Receipts is on, **Markdown is disabled** (can't render receipts); turning Receipts on also forces PDF on. | + +## The `RenderFormatModal` + +A new `ModalScreen[dict | None]` (in `src/ttd/tui/screens/invoices.py`) with three switches and Render/Cancel buttons: + +- **PDF** switch — default **on**. +- **Markdown** switch — default **off**. +- **Receipts** switch — **disabled unless `has_receipts`**; default **on** when `has_receipts`, else off. + +Constructor: `RenderFormatModal(has_receipts: bool)`. + +Live reactivity (via `@on(Switch.Changed)`): +- **Receipts → on:** set PDF on; set Markdown off and `disabled=True`. +- **Receipts → off:** set Markdown `disabled=False` (re-enable). + +Submit (Render button / enter): +- Validate at least one of PDF/Markdown is on; if neither, show an inline error and stay open. +- Dismiss with `{"pdf": bool, "md": bool, "receipts": bool}`. + +Escape / Cancel dismisses with `None`. + +Because the Receipts switch starts disabled when there are no receipts, and is auto-cleared/locked against Markdown when on, the invalid combination (markdown + receipts) is unreachable through the UI. Submit still validates format presence defensively. + +## Rewiring `action_render_files` (`e`) + +``` +view = await svc.get_invoice(number) +has_receipts = await svc.invoice_has_receipts(view) +push RenderFormatModal(has_receipts) with callback: + if result is None: return + stem = settings.invoice.output_dir / f"{number}-{client.slug}" + if result["pdf"]: + receipts = await load_invoice_receipts(view) if result["receipts"] else None + render_pdf(view, settings, stem.with_suffix(".pdf"), receipts=receipts) + if result["md"]: + write_markdown(view, settings, stem.with_suffix(".md")) + notify what was written (formats + receipt count) +``` + +The `e` binding label changes from `"render pdf+md"` to `"render"`. + +## Shared receipt loading (DRY) + +The CLI `_render_files` currently inlines "for each expense line, `get_receipt(...)`, collect the decoded `(filename, content_type, bytes)` list". Extract this into a single helper — e.g. `async def load_invoice_receipts(view) -> list[tuple[str, str, bytes]]` (in `services/expenses.py` or `services/invoicing.py`) — and call it from both the CLI and the new TUI path, so PDF-with-receipts is identical in both. + +## Testing + +- **Pilot (with receipts):** an invoice whose expense has a receipt → open the render modal via `e`; assert the Receipts switch is enabled and on and the Markdown switch is disabled; render PDF and assert the output PDF's page count exceeds the same invoice rendered without receipts (receipts embedded). +- **Pilot (no receipts):** the Receipts switch is disabled; Markdown is selectable; rendering Markdown writes the `.md`. +- **Live rule:** toggling Receipts on disables and clears Markdown. +- **Submit validation:** with neither format selected, submit does not dismiss (stays open / shows error). +- **Unit:** `load_invoice_receipts(view)` returns the decoded receipts for the invoice's expense lines and `[]`/None-equivalent when there are none; used by both CLI and TUI. +- Keep the coverage gate (`fail_under = 84`) green; `ty` + `ruff` clean. + +## Out of scope + +- Rendering at creation time (creation stays persist-only; decided in brainstorming). +- Any change to the CLI's user-facing behavior (only the internal receipt-loader is extracted for reuse). +- Attaching receipts from the TUI expense form (separate, still-deferred follow-up). + +## Related + +- Builds on the billable-expenses feature (PR #14): `render_pdf(..., receipts=…)`, `invoice_has_receipts`, `get_receipt`, and the CLI `_resolve_formats`/`_render_files` already exist. diff --git a/pyproject.toml b/pyproject.toml index 80666bb..7d12150 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "numbers-parser>=4.18", "fpdf2>=2.8", "textual-plotext>=1.0.1", + "pypdf>=6.14.2", ] [project.scripts] diff --git a/src/ttd/cli/app.py b/src/ttd/cli/app.py index c02adb8..8983360 100644 --- a/src/ttd/cli/app.py +++ b/src/ttd/cli/app.py @@ -20,6 +20,7 @@ def _register_subcommands() -> None: config_cmds, db_cmds, entries, + expenses, export, import_, invoices, @@ -33,6 +34,7 @@ def _register_subcommands() -> None: app.command(clients.app) app.command(projects.app) app.command(entries.app) + app.command(expenses.app) app.command(reports.app) app.command(invoices.app) app.command(taxes.app) diff --git a/src/ttd/cli/expenses.py b/src/ttd/cli/expenses.py new file mode 100644 index 0000000..9bd6ed1 --- /dev/null +++ b/src/ttd/cli/expenses.py @@ -0,0 +1,185 @@ +"""`ttd expense …` commands.""" + +import json +from datetime import date +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Annotated + +from cyclopts import Parameter + +from ttd.cli._output import console, success, table +from ttd.cli._run import TtdApp, with_db +from ttd.config.loader import get_settings +from ttd.core.errors import TtdError +from ttd.core.money import format_money +from ttd.services import expenses as svc + +app = TtdApp(name="expense", help="Track and bill back client expenses.") +receipt_app = TtdApp(name="receipt", help="Attach receipts to an expense.") +app.command(receipt_app) + + +def _parse_date(raw: str | None) -> date | None: + if raw is None: + return None + try: + return date.fromisoformat(raw) + except ValueError as exc: + raise TtdError(f"Dates must be YYYY-MM-DD (got '{raw}')") from exc + + +def _amount(raw: str) -> Decimal: + try: + return Decimal(raw) + except InvalidOperation as exc: + raise TtdError(f"Amount must be a number (got '{raw}')") from exc + + +@app.command(name="add") +@with_db +async def add( + description: str, + amount: str, + *, + project: Annotated[str | None, Parameter(name=["--project", "-p"])] = None, + on: Annotated[str | None, Parameter(name="--on", help="Incurred date YYYY-MM-DD")] = None, + note: Annotated[str, Parameter(name=["--note", "-n"])] = "", + receipt: Annotated[Path | None, Parameter(help="Receipt file to attach")] = None, +) -> None: + """Record a purchased item to bill back to the client.""" + project = project or get_settings().defaults.project + if project is None: + raise TtdError("No project given and no [defaults].project — pass --project") + expense = await svc.add_expense( + project, description, _amount(amount), incurred_date=_parse_date(on), note=note + ) + if receipt is not None: + await svc.add_receipt(str(expense.id)[:8], receipt) + currency = get_settings().business.currency + success(f"Logged {format_money(expense.amount, currency)} — {expense.description}") + + +@app.command(name="list") +@with_db +async def list_( + *, + project: Annotated[str | None, Parameter(name=["--project", "-p"])] = None, + client: str | None = None, + date_from: Annotated[str | None, Parameter(name="--from")] = None, + date_to: Annotated[str | None, Parameter(name="--to")] = None, + unbilled: Annotated[bool, Parameter(help="Only not-yet-invoiced expenses")] = False, + as_json: Annotated[bool, Parameter(name="--json")] = False, +) -> None: + """List expenses, oldest first.""" + rows = await svc.list_expenses( + project_slug=project, + client_slug=client, + date_from=_parse_date(date_from), + date_to=_parse_date(date_to), + unbilled_only=unbilled, + ) + if as_json: + payload = [ + { + "id": str(r.expense.id), + "client": r.client.slug, + "project": r.project.slug, + "date": r.expense.incurred_date.isoformat(), + "description": r.expense.description, + "amount": str(r.expense.amount), + "note": r.expense.note, + "invoiced": r.expense.invoice_id is not None, + "receipt": r.has_receipt, + } + for r in rows + ] + console.print_json(json.dumps(payload)) + return + if not rows: + console.print('[muted]No expenses — `ttd expense add "Claude Code" 100 -p PROJECT`[/muted]') + return + t = table("ID", "Date", "Project", "Description", "Amount", "") + total = Decimal("0") + for r in rows: + e = r.expense + total += e.amount + flags = (" [accent]·inv[/accent]" if e.invoice_id else "") + ( + " [muted]📎[/muted]" if r.has_receipt else "" + ) + t.add_row( + str(e.id)[:8], + e.incurred_date.strftime("%a %b %-d"), + f"{r.client.slug}/{r.project.slug}", + e.description, + format_money(e.amount, r.client.currency) + flags, + "", + ) + console.print(t) + footer_currency = rows[0].client.currency if rows else "USD" + console.print(f"Total: [bold]{format_money(total, footer_currency)}[/bold]") + + +@app.command(name="edit") +@with_db +async def edit( + uid: str, + *, + amount: str | None = None, + description: Annotated[str | None, Parameter(name=["--description", "-d"])] = None, + note: Annotated[str | None, Parameter(name=["--note", "-n"])] = None, + on: Annotated[str | None, Parameter(name="--on")] = None, + project: Annotated[str | None, Parameter(name=["--project", "-p"])] = None, +) -> None: + """Edit an expense (refuses if it's on an invoice).""" + expense = await svc.edit_expense( + uid, + amount=_amount(amount) if amount is not None else None, + description=description, + note=note, + incurred_date=_parse_date(on), + project_slug=project, + ) + success(f"Updated expense {str(expense.id)[:8]}") + + +@app.command(name="rm") +@with_db +async def rm(uid: str) -> None: + """Delete an expense (refuses if it's on an invoice).""" + expense = await svc.delete_expense(uid) + currency = get_settings().business.currency + success(f"Deleted expense {str(expense.id)[:8]} ({format_money(expense.amount, currency)})") + + +@receipt_app.command(name="add") +@with_db +async def receipt_add(uid: str, path: Path) -> None: + """Attach (or replace) a receipt on an expense.""" + receipt = await svc.add_receipt(uid, path) + success(f"Attached {receipt.filename} to expense {uid}") + + +@receipt_app.command(name="get") +@with_db +async def receipt_get( + uid: str, + *, + out: Annotated[Path | None, Parameter(help="Output file")] = None, +) -> None: + """Write an expense's receipt to a file.""" + result = await svc.get_receipt(uid) + if result is None: + raise TtdError(f"Expense {uid} has no receipt") + filename, _content_type, data = result + dest = out or Path(filename) + dest.write_bytes(data) + success(f"Wrote {dest}") + + +@receipt_app.command(name="rm") +@with_db +async def receipt_rm(uid: str) -> None: + """Remove an expense's receipt.""" + await svc.remove_receipt(uid) + success(f"Removed receipt from expense {uid}") diff --git a/src/ttd/cli/import_.py b/src/ttd/cli/import_.py index ad8cc39..8edba68 100644 --- a/src/ttd/cli/import_.py +++ b/src/ttd/cli/import_.py @@ -53,6 +53,16 @@ async def import_( plan, create_missing=create_missing, metadata=metadata ) + # Restore expenses from JSON metadata (skip on dry_run or non-JSON files). + if not dry_run and metadata.get("expenses"): + from ttd.interchange.importer import restore_expenses + + n = await restore_expenses( + metadata, on_conflict=conflict_mode, create_missing=create_missing + ) + if n: + success(f"Restored {n} expense{'s' if n != 1 else ''}") + t = table("Action", "Rows") t.add_row("new", str(len(plan.new))) t.add_row("update", str(len(plan.update))) diff --git a/src/ttd/cli/invoices.py b/src/ttd/cli/invoices.py index ffaa6a5..485572c 100644 --- a/src/ttd/cli/invoices.py +++ b/src/ttd/cli/invoices.py @@ -60,7 +60,9 @@ def _resolve_period( if period is not None: if month is not None or date_from is not None or date_to is not None: raise TtdError("Pass --period alone, not with --month or --from/--to") - return periods.parse_period(period, datetime.now().date()) + return periods.parse_period( + period, datetime.now().date(), week_start=get_settings().display.week_start + ) if month is not None: return periods.month_period(datetime.now().date(), ym=month) if date_from is not None and date_to is not None: @@ -76,11 +78,33 @@ def _output_paths(view: svc.InvoiceView, out: Path | None) -> Path: return base / f"{view.invoice.number}-{view.client.slug}" -def _render_files(view: svc.InvoiceView, pdf: bool, md: bool, out: Path | None) -> None: +def _resolve_formats( + *, pdf: bool, md: bool, receipts: bool, has_receipts: bool +) -> tuple[bool, bool]: + """Decide which formats to render. Default to PDF; block markdown when an + invoice carries receipts (markdown can't render them).""" + if not pdf and not md: + pdf = True # default to the canonical, sendable artifact + if md and receipts and has_receipts: + raise TtdError( + "This invoice has receipts; Markdown can't render them. " + "Drop --md, or omit --receipts to generate Markdown without them." + ) + return pdf, md + + +async def _render_files( + view: svc.InvoiceView, *, pdf: bool, md: bool, receipts: bool, out: Path | None +) -> None: settings = get_settings() stem = _output_paths(view, out) if pdf: - path = render_pdf(view, settings, stem.with_suffix(".pdf")) + decoded = None + if receipts: + from ttd.services.expenses import load_invoice_receipts + + decoded = await load_invoice_receipts(view.expense_lines) + path = render_pdf(view, settings, stem.with_suffix(".pdf"), receipts=decoded) success(f"Wrote {path}") if md: path = write_markdown(view, settings, stem.with_suffix(".md")) @@ -99,6 +123,16 @@ def _print_draft(draft: svc.Draft) -> None: format_money(line.amount, currency), ) console.print(t) + if draft.expense_lines: + et = table("Date", "Description", "Amount") + for e in draft.expense_lines: + et.add_row( + e.incurred_date.strftime("%a %b %-d"), + e.description, + format_money(e.amount, currency), + ) + console.print(et) + console.print(f"Expenses: {format_money(draft.expenses_subtotal, currency)}") console.print(f"Subtotal: {format_money(draft.subtotal, currency)}") if draft.tax: console.print(f"Tax: {format_money(draft.tax, currency)}") @@ -147,7 +181,10 @@ async def create( period: Annotated[ str | None, Parameter( - help="Period spec: 'last month', 'this month', YYYY-MM, or YYYY-MM-DD to YYYY-MM-DD" + help=( + "Period spec: 'last month', 'this week', 'last two weeks', " + "'june 16 to june 30', YYYY-MM, or YYYY-MM-DD to YYYY-MM-DD" + ) ), ] = None, date_from: Annotated[str | None, Parameter(name="--from")] = None, @@ -155,6 +192,7 @@ async def create( number: Annotated[str | None, Parameter(help="Override the number")] = None, pdf: Annotated[bool, Parameter(help="Render a PDF")] = False, md: Annotated[bool, Parameter(help="Render Markdown")] = False, + receipts: Annotated[bool, Parameter(help="Append expense receipts to the PDF")] = False, out: Annotated[Path | None, Parameter(help="Output directory")] = None, dry_run: Annotated[bool, Parameter(help="Preview, change nothing")] = False, interactive: Annotated[ @@ -186,7 +224,10 @@ async def create( return assert view is not None success(f"Created invoice [accent]{view.invoice.number}[/accent]") - _render_files(view, pdf, md, out) + receipts_on = receipts or settings.invoice.attach_receipts + has_r = await svc.invoice_has_receipts(view) + pdf, md = _resolve_formats(pdf=pdf, md=md, receipts=receipts_on, has_receipts=has_r) + await _render_files(view, pdf=pdf, md=md, receipts=receipts_on, out=out) @app.command(name="list") @@ -276,13 +317,16 @@ async def render( *, pdf: bool = False, md: bool = False, + receipts: Annotated[bool, Parameter(help="Append expense receipts to the PDF")] = False, out: Path | None = None, ) -> None: """(Re)render an invoice's PDF/Markdown files.""" - if not pdf and not md: - pdf = md = True view = await svc.get_invoice(number) - _render_files(view, pdf, md, out) + settings = get_settings() + receipts_on = receipts or settings.invoice.attach_receipts + has_r = await svc.invoice_has_receipts(view) + pdf, md = _resolve_formats(pdf=pdf, md=md, receipts=receipts_on, has_receipts=has_r) + await _render_files(view, pdf=pdf, md=md, receipts=receipts_on, out=out) def _print_refresh_diff(preview: svc.RefreshPreview) -> None: @@ -317,6 +361,11 @@ def _print_refresh_diff(preview: svc.RefreshPreview) -> None: total_after = format_money(preview.after_total, currency) if preview.totals_changed: console.print(f"Subtotal: {sub} → [bold]{sub_after}[/bold]") + if preview.before_expenses_subtotal != preview.after_expenses_subtotal: + console.print( + f"Expenses: {format_money(preview.before_expenses_subtotal, currency)} → " + f"[bold]{format_money(preview.after_expenses_subtotal, currency)}[/bold]" + ) if preview.before_tax or preview.after_tax: console.print( f"Tax: {format_money(preview.before_tax, currency)} → " diff --git a/src/ttd/config/schema.py b/src/ttd/config/schema.py index 46919a1..ea6f9c2 100644 --- a/src/ttd/config/schema.py +++ b/src/ttd/config/schema.py @@ -48,6 +48,8 @@ class InvoiceConfig(_Section): # skips validators on defaults otherwise, which left a literal "~" path output_dir: Path = Field(default=Path("~/Documents/invoices"), validate_default=True) """Directory where rendered invoices are written.""" + attach_receipts: bool = False + """Append expense receipts as pages when rendering invoice PDFs.""" _tax = field_validator("tax_rate", mode="before")(_to_decimal) diff --git a/src/ttd/core/errors.py b/src/ttd/core/errors.py index a86b4c2..fac7060 100644 --- a/src/ttd/core/errors.py +++ b/src/ttd/core/errors.py @@ -17,6 +17,10 @@ class InvoicedEntryError(TtdError): """Attempted to modify an entry that is locked to an invoice.""" +class InvoicedExpenseError(TtdError): + """Attempted to modify an expense that is locked to an invoice.""" + + class ConfigError(TtdError): """Invalid or unwritable configuration.""" diff --git a/src/ttd/interchange/importer.py b/src/ttd/interchange/importer.py index 4851d05..e04c525 100644 --- a/src/ttd/interchange/importer.py +++ b/src/ttd/interchange/importer.py @@ -1,16 +1,17 @@ """Import engine: validate rows, resolve slugs, dedupe, apply.""" from dataclasses import dataclass, field +from datetime import date as date_t from datetime import datetime from decimal import Decimal from typing import Any, Literal -from uuid import uuid4 +from uuid import UUID, uuid4 from ttd.core.errors import TtdError from ttd.interchange.model import EntryRecord, from_raw from ttd.services import clients as client_svc from ttd.services import projects as project_svc -from ttd.storage.models import Client, Entry, EntrySource, Project, pk +from ttd.storage.models import Client, Entry, EntrySource, Expense, ExpenseReceipt, Project, pk OnConflict = Literal["skip", "update", "duplicate"] @@ -203,3 +204,100 @@ async def _create_missing(plan: ImportPlan, metadata: dict[str, Any]) -> None: slug=project_slug, hourly_rate=Decimal(str(rate)) if rate is not None else None, ) + + +async def restore_expenses( + metadata: dict[str, Any], + *, + on_conflict: OnConflict = "skip", + create_missing: bool = False, +) -> int: + """Restore expenses + receipts from a JSON backup's metadata. Returns count written. + + Never sets ``invoice_id`` — imports keep ``invoice_number`` informational only, + mirroring entry import. + """ + expenses = metadata.get("expenses", []) + if not expenses: + return 0 + + if create_missing: + # Reuse the client/project bootstrap by faking a plan of the referenced pairs. + plan = ImportPlan() + existing_clients = {c.slug for c in await Client.all()} + projects_present: set[tuple[str, str]] = set() + all_clients = {c.id: c for c in await Client.all()} + for p in await Project.all(): + client = all_clients.get(p.client_id) + if client: + projects_present.add((client.slug, p.slug)) + for row in expenses: + if row["client"] not in existing_clients: + plan.missing_clients.add(row["client"]) + if (row["client"], row["project"]) not in projects_present: + plan.missing_projects.add((row["client"], row["project"])) + if plan.missing_clients or plan.missing_projects: + await _create_missing(plan, metadata) + + clients = {c.slug: c for c in await Client.all()} + project_map: dict[tuple[str, str], Project] = {} + for p in await Project.all(): + for cslug, c in clients.items(): + if c.id == p.client_id: + project_map[(cslug, p.slug)] = p + break + + existing = {str(e.id): e for e in await Expense.all()} + stamp = datetime.now() + written = 0 + written_ids: set[str] = set() + for row in expenses: + key = (row["client"], row["project"]) + if key not in project_map: + continue # unresolved project; skip silently + project = project_map[key] + match = existing.get(row["id"]) + if match is not None and match.invoice_id is not None: + continue # never touch invoiced expenses + if match is not None and on_conflict == "skip": + continue + if match is not None and on_conflict == "update": + match.project_id = pk(project) + match.incurred_date = date_t.fromisoformat(row["incurred_date"]) + match.description = row["description"] + match.amount = Decimal(row["amount"]) + match.note = row.get("note", "") + match.updated_at = stamp + await match.save() + written_ids.add(row["id"]) + else: # new (or duplicate) + await Expense( + id=UUID(row["id"]), + project_id=pk(project), + incurred_date=date_t.fromisoformat(row["incurred_date"]), + description=row["description"], + amount=Decimal(row["amount"]), + note=row.get("note", ""), + created_at=stamp, + updated_at=stamp, + ).save() + written_ids.add(row["id"]) + written += 1 + + # Receipts: replace any existing receipt for expenses that were actually written. + for r in metadata.get("receipts", []): + if r["expense_id"] not in written_ids: + continue + expense_uuid = UUID(r["expense_id"]) + for old in await ExpenseReceipt.where( + lambda rec, eid=expense_uuid: rec.expense_id == eid + ).all(): + await old.delete() + await ExpenseReceipt( + id=uuid4(), + expense_id=expense_uuid, + filename=r["filename"], + content_type=r["content_type"], + data_b64=r["data_b64"], + ).save() + return written diff --git a/src/ttd/interchange/json_io.py b/src/ttd/interchange/json_io.py index 54d43a0..0249f77 100644 --- a/src/ttd/interchange/json_io.py +++ b/src/ttd/interchange/json_io.py @@ -6,7 +6,7 @@ from ttd.interchange.base import Format, register from ttd.interchange.model import EntryRecord -ENVELOPE_VERSION = 1 +ENVELOPE_VERSION = 2 def write_json(records: list[EntryRecord], path: Path, meta: dict[str, Any]) -> None: @@ -18,6 +18,8 @@ def write_json(records: list[EntryRecord], path: Path, meta: dict[str, Any]) -> "entries": [ {**r.to_cells(), "seconds": r.seconds, "billable": r.billable} for r in records ], + "expenses": meta.get("expenses", []), + "receipts": meta.get("receipts", []), } path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") @@ -43,7 +45,12 @@ def read_metadata(path: Path) -> dict[str, Any]: except (json.JSONDecodeError, OSError): return {} if isinstance(payload, dict): - return {"clients": payload.get("clients", []), "projects": payload.get("projects", [])} + return { + "clients": payload.get("clients", []), + "projects": payload.get("projects", []), + "expenses": payload.get("expenses", []), + "receipts": payload.get("receipts", []), + } return {} diff --git a/src/ttd/invoicing/markdown.py b/src/ttd/invoicing/markdown.py index bda4133..cf264a6 100644 --- a/src/ttd/invoicing/markdown.py +++ b/src/ttd/invoicing/markdown.py @@ -28,6 +28,7 @@ def money(value: Decimal) -> str: invoice=view.invoice, client=view.client, lines=view.lines, + expense_lines=view.expense_lines, user=settings.user, money=money, terms_days=settings.invoice.payment_terms_days, diff --git a/src/ttd/invoicing/pdf.py b/src/ttd/invoicing/pdf.py index f4ac17e..9a780c2 100644 --- a/src/ttd/invoicing/pdf.py +++ b/src/ttd/invoicing/pdf.py @@ -1,15 +1,19 @@ """PDF invoice rendering (fpdf2). Pure python — no system dependencies.""" +import io from decimal import Decimal from pathlib import Path from fpdf import FPDF from fpdf.enums import XPos, YPos +from pypdf import PdfReader, PdfWriter from ttd.config.schema import Settings from ttd.core.money import format_money from ttd.services.invoicing import InvoiceView +Receipt = tuple[str, str, bytes] # (filename, content_type, raw bytes) + ACCENT = (255, 176, 0) # the ttd amber INK = (13, 15, 18) MUTED = (110, 116, 125) @@ -38,7 +42,13 @@ def _money(value: Decimal, currency: str) -> str: return f"{value:,.2f} {currency}" -def render_pdf(view: InvoiceView, settings: Settings, path: Path) -> Path: +def render_pdf( + view: InvoiceView, + settings: Settings, + path: Path, + *, + receipts: list[Receipt] | None = None, +) -> Path: invoice, client, lines = view.invoice, view.client, view.lines currency = invoice.currency @@ -134,12 +144,37 @@ def render_pdf(view: InvoiceView, settings: Settings, path: Path) -> Path: row.cell(_money(line.rate, currency)) row.cell(_money(line.amount, currency)) + if view.expense_lines: + pdf.ln(3) + pdf.set_font("helvetica", style="B", size=9) + pdf.cell(0, 6, "REIMBURSABLE EXPENSES", new_x=XPos.LMARGIN, new_y=YPos.NEXT) + pdf.set_font("helvetica", size=9) + with pdf.table( + col_widths=(20, 138, 22), + text_align=("LEFT", "LEFT", "RIGHT"), + borders_layout="HORIZONTAL_LINES", + line_height=6.5, + padding=1.2, + ) as etable: + header = etable.row() + pdf.set_font("helvetica", style="B", size=8) + for col in ("DATE", "DESCRIPTION", "AMOUNT"): + header.cell(col) + pdf.set_font("helvetica", size=9) + for eline in view.expense_lines: + row = etable.row() + row.cell(eline.incurred_date.strftime("%b %-d")) + row.cell(_latin(eline.description)) + row.cell(_money(eline.amount, currency)) + # totals box pdf.ln(4) label_x = pdf.w - 18 - 70 rows = [("Subtotal", _money(invoice.subtotal, currency))] if invoice.tax: rows.append((f"Tax ({invoice.tax_rate * 100:.2f}%)", _money(invoice.tax, currency))) + if invoice.expenses_subtotal: + rows.append(("Expenses", _money(invoice.expenses_subtotal, currency))) rows.append(("Total due", _money(invoice.total, currency))) for i, (label, value) in enumerate(rows): is_total = i == len(rows) - 1 @@ -159,5 +194,31 @@ def render_pdf(view: InvoiceView, settings: Settings, path: Path) -> Path: pdf.multi_cell(0, 4, _latin(note)) path.parent.mkdir(parents=True, exist_ok=True) - pdf.output(str(path)) + if not receipts: + pdf.output(str(path)) + return path + _write_with_receipts(pdf, receipts, path) return path + + +def _write_with_receipts(pdf: FPDF, receipts: list[Receipt], path: Path) -> None: + """Append image receipts as fpdf2 pages, then merge PDF receipts via pypdf.""" + images = [r for r in receipts if r[1].startswith("image/")] + pdfs = [r for r in receipts if r[1] == "application/pdf"] + + for _filename, _ct, data in images: + pdf.add_page() + pdf.image(io.BytesIO(data), x=18, y=24, w=pdf.w - 36) + + raw = pdf.output() # fpdf2 returns bytearray when no dest given + assert raw is not None + invoice_bytes = bytes(raw) + + writer = PdfWriter() + for page in PdfReader(io.BytesIO(invoice_bytes)).pages: + writer.add_page(page) + for _filename, _ct, data in pdfs: + for page in PdfReader(io.BytesIO(data)).pages: + writer.add_page(page) + with path.open("wb") as fh: + writer.write(fh) diff --git a/src/ttd/invoicing/templates/invoice.md.j2 b/src/ttd/invoicing/templates/invoice.md.j2 index 957330f..e967235 100644 --- a/src/ttd/invoicing/templates/invoice.md.j2 +++ b/src/ttd/invoicing/templates/invoice.md.j2 @@ -23,6 +23,14 @@ {{ line.description }} {% endfor %} +{% if expense_lines %} +## Reimbursable expenses + +{% for e in expense_lines -%} +**{{ e.incurred_date.strftime("%b %-d") }}** · {{ e.description }} · **{{ money(e.amount) }}** + +{% endfor %} +{% endif -%} | | | |---|--:| @@ -30,6 +38,9 @@ {% if invoice.tax -%} | Tax ({{ "%.2f" | format(invoice.tax_rate * 100) }}%) | {{ money(invoice.tax) }} | {% endif -%} +{% if invoice.expenses_subtotal -%} +| Expenses (reimbursable) | {{ money(invoice.expenses_subtotal) }} | +{% endif -%} | **Total due** | **{{ money(invoice.total) }}** | {% if invoice.notes %}{{ invoice.notes }} diff --git a/src/ttd/reporting/periods.py b/src/ttd/reporting/periods.py index a34430c..b12dd09 100644 --- a/src/ttd/reporting/periods.py +++ b/src/ttd/reporting/periods.py @@ -55,15 +55,188 @@ def range_period(start: date, end: date) -> Period: _MONTH_RE = re.compile(r"^(\d{4})-(\d{1,2})$") _RANGE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})\s*(?:to|\.\.|–|-)\s*(\d{4}-\d{2}-\d{2})$") - -def parse_period(text: str, today: date) -> Period: - """Parse a human period spec: '' / 'last month' / 'this month' / - 'YYYY-MM' / 'YYYY-MM-DD to YYYY-MM-DD'.""" +_NUMBER_WORDS = { + "one": 1, + "two": 2, + "three": 3, + "four": 4, + "five": 5, + "six": 6, + "seven": 7, + "eight": 8, + "nine": 9, + "ten": 10, + "eleven": 11, + "twelve": 12, +} +_RELATIVE_RE = re.compile(r"^last\s+(\w+)\s+(day|days|week|weeks|month|months)$") + + +def _subtract_months(d: date, n: int) -> date: + """d shifted back n calendar months, clamping the day to the target month.""" + month_index = (d.year * 12 + (d.month - 1)) - n + year, month = divmod(month_index, 12) + month += 1 + last_day = calendar.monthrange(year, month)[1] + return date(year, month, min(d.day, last_day)) + + +def _parse_relative(text: str, today: date) -> "Period | None": + """Rolling 'last days|weeks|months' ending today; None if no match.""" + m = _RELATIVE_RE.match(text) + if m is None: + return None + raw, unit = m[1], m[2].rstrip("s") + if raw.isdigit(): + n = int(raw) + elif raw in _NUMBER_WORDS: + n = _NUMBER_WORDS[raw] + else: + raise TtdError(f"'{raw}' is not a recognised count — use a digit or one-twelve") + if n < 1: + raise TtdError(f"'{text}' — the count must be a positive number") + if unit == "day": + start = today - timedelta(days=n - 1) + elif unit == "week": + start = today - timedelta(days=n * 7 - 1) + else: # month + start = _subtract_months(today, n) + return range_period(start, today) + + +_MONTHS = { + "jan": 1, + "january": 1, + "feb": 2, + "february": 2, + "mar": 3, + "march": 3, + "apr": 4, + "april": 4, + "may": 5, + "jun": 6, + "june": 6, + "jul": 7, + "july": 7, + "aug": 8, + "august": 8, + "sep": 9, + "sept": 9, + "september": 9, + "oct": 10, + "october": 10, + "nov": 11, + "november": 11, + "dec": 12, + "december": 12, +} +_MON = r"[a-z]{3,9}" +_SEP = r"(?:to|through|thru|until|till|\.\.|--|-|–|—)" +_MM_RANGE_RE = re.compile( + rf"^(?P{_MON})\s+(?P\d{{1,2}})\s*{_SEP}\s*(?P{_MON})\s+(?P\d{{1,2}})" + rf"(?:\s+(?P\d{{4}}))?$" +) +_MD_RANGE_RE = re.compile( + rf"^(?P{_MON})\s+(?P\d{{1,2}})\s*{_SEP}\s*(?P\d{{1,2}})" + rf"(?:\s+(?P\d{{4}}))?$" +) +_MONTH_ONLY_RE = re.compile(rf"^(?P{_MON})(?:\s+(?P\d{{4}}))?$") + + +def _month_num(name: str) -> int | None: + return _MONTHS.get(name) + + +def _range_distance(start: date, end: date, today: date) -> int: + if start <= today <= end: + return 0 + if today < start: + return (start - today).days + return (today - end).days + + +def _closest_year_range(m1: int, d1: int, m2: int, d2: int, today: date) -> Period: + """Build (start, end) for the closest non-future year; end wraps to +1 year + when the end month is earlier than the start month.""" + best: tuple[int, date, date] | None = None + for y in (today.year, today.year - 1): # this year first -> ties favor it + end_year = y + 1 if m2 < m1 else y + try: + start = date(y, m1, d1) + end = date(end_year, m2, d2) + except ValueError: + continue + dist = _range_distance(start, end, today) + if best is None or dist < best[0]: + best = (dist, start, end) + if best is None: + raise TtdError("Not a real date in that month-name range") + return range_period(best[1], best[2]) + + +def _fixed_year_range(m1: int, d1: int, m2: int, d2: int, year: int) -> Period: + end_year = year + 1 if m2 < m1 else year + try: + return range_period(date(year, m1, d1), date(end_year, m2, d2)) + except ValueError as exc: + raise TtdError(f"Not a real date ({exc})") from exc + + +def _closest_month_year(month: int, today: date) -> int: + """Closest non-future year for a whole-month reference.""" + best: tuple[int, int] | None = None + for y in (today.year, today.year - 1): + first = date(y, month, 1) + last = date(y, month, calendar.monthrange(y, month)[1]) + dist = _range_distance(first, last, today) + if best is None or dist < best[0]: + best = (dist, y) + assert best is not None + return best[1] + + +def _parse_month_name(text: str, today: date) -> "Period | None": + # whole month: "june" / "june 2025" + if m := _MONTH_ONLY_RE.match(text): + num = _month_num(m["m1"]) + if num is None: + return None + year = int(m["year"]) if m["year"] else _closest_month_year(num, today) + return month_period(date(year, num, 1), ym=f"{year}-{num:02d}") + # month day month day + if m := _MM_RANGE_RE.match(text): + n1, n2 = _month_num(m["m1"]), _month_num(m["m2"]) + if n1 is None or n2 is None: + return None + d1, d2 = int(m["d1"]), int(m["d2"]) + if m["year"]: + return _fixed_year_range(n1, d1, n2, d2, int(m["year"])) + return _closest_year_range(n1, d1, n2, d2, today) + # month day day (inherit month) + if m := _MD_RANGE_RE.match(text): + n1 = _month_num(m["m1"]) + if n1 is None: + return None + d1, d2 = int(m["d1"]), int(m["d2"]) + if m["year"]: + return _fixed_year_range(n1, d1, n1, d2, int(m["year"])) + return _closest_year_range(n1, d1, n1, d2, today) + return None + + +def parse_period(text: str, today: date, *, week_start: str = "monday") -> Period: + """Parse a human period spec. Supports: '' / 'last month' / 'this month' / + 'this week' / 'last week' / 'last days|weeks|months' / 'YYYY-MM' / + 'YYYY-MM-DD to YYYY-MM-DD' / month-name ranges like 'june 16 to june 30'.""" text = text.strip().lower() if text in ("", "last month"): return month_period(today, last=True) if text == "this month": return month_period(today) + if text == "this week": + return week_period(today, week_start) + if text == "last week": + return week_period(today, week_start, last=True) if _MONTH_RE.match(text): return month_period(today, ym=text) if m := _RANGE_RE.match(text): @@ -71,7 +244,11 @@ def parse_period(text: str, today: date) -> Period: return range_period(date.fromisoformat(m[1]), date.fromisoformat(m[2])) except ValueError as exc: raise TtdError(f"Not a real date in '{text}' ({exc})") from exc + if relative := _parse_relative(text, today): + return relative + if month_name := _parse_month_name(text, today): + return month_name raise TtdError( - f"Can't read period '{text}' — try '2026-05', 'last month', 'this month', " - "or '2026-05-01 to 2026-05-15'" + f"Can't read period '{text}' — try '2026-05', 'last month', 'this week', " + "'last two weeks', 'june 16 to june 30', or '2026-05-01 to 2026-05-15'" ) diff --git a/src/ttd/services/expenses.py b/src/ttd/services/expenses.py new file mode 100644 index 0000000..f0d2b2d --- /dev/null +++ b/src/ttd/services/expenses.py @@ -0,0 +1,237 @@ +"""Logging and managing billable expenses (client chargebacks).""" + +import base64 +import mimetypes +from dataclasses import dataclass +from datetime import date, datetime +from decimal import Decimal +from pathlib import Path +from uuid import uuid4 + +from ttd.core.errors import ConflictError, InvoicedExpenseError, NotFoundError, TtdError +from ttd.services.projects import get_project +from ttd.storage.db import in_db_session +from ttd.storage.models import Client, Expense, ExpenseReceipt, InvoiceExpenseLine, Project, pk + + +@dataclass +class ExpenseView: + expense: Expense + project: Project + client: Client + has_receipt: bool + + +@dataclass +class ExpenseSuggestion: + description: str + amount: Decimal + + +@in_db_session +async def add_expense( + project_slug: str, + description: str, + amount: Decimal, + *, + client_slug: str | None = None, + incurred_date: date | None = None, + note: str = "", +) -> Expense: + project = await get_project(project_slug, client_slug) + stamp = datetime.now() + expense = Expense( + id=uuid4(), + project_id=pk(project), + incurred_date=incurred_date or date.today(), + description=description.strip(), + amount=amount, + note=note, + created_at=stamp, + updated_at=stamp, + ) + await expense.save() + return expense + + +@in_db_session +async def find_expense(uid_prefix: str) -> Expense: + needle = uid_prefix.lower().replace("-", "") + if not needle: + raise NotFoundError("Empty expense id") + matches = [e for e in await Expense.all() if str(e.id).replace("-", "").startswith(needle)] + if not matches: + raise NotFoundError(f"No expense matching '{uid_prefix}'") + if len(matches) > 1: + raise ConflictError(f"'{uid_prefix}' matches {len(matches)} expenses — use more characters") + return matches[0] + + +@in_db_session +async def list_expenses( + *, + project_slug: str | None = None, + client_slug: str | None = None, + date_from: date | None = None, + date_to: date | None = None, + unbilled_only: bool = False, +) -> list[ExpenseView]: + expenses = await Expense.all() + projects = {p.id: p for p in await Project.all()} + clients = {c.id: c for c in await Client.all()} + receipted = {r.expense_id for r in await ExpenseReceipt.all()} + + if project_slug is not None: + project = await get_project(project_slug, client_slug) + expenses = [e for e in expenses if e.project_id == project.id] + elif client_slug is not None: + wanted = { + p.id + for p in projects.values() + if (c := clients.get(p.client_id)) is not None and c.slug == client_slug + } + expenses = [e for e in expenses if e.project_id in wanted] + if date_from is not None: + expenses = [e for e in expenses if e.incurred_date >= date_from] + if date_to is not None: + expenses = [e for e in expenses if e.incurred_date <= date_to] + if unbilled_only: + expenses = [e for e in expenses if e.invoice_id is None] + + rows: list[ExpenseView] = [] + for e in sorted(expenses, key=lambda e: (e.incurred_date, e.created_at)): + project = projects.get(e.project_id) + if project is None: + continue + client = clients.get(project.client_id) + if client is None: + continue + rows.append(ExpenseView(e, project, client, e.id in receipted)) + return rows + + +def _ensure_unlocked(expense: Expense) -> None: + if expense.invoice_id is not None: + raise InvoicedExpenseError( + f"Expense {str(expense.id)[:8]} is on an invoice — void the invoice first" + ) + + +@in_db_session +async def edit_expense( + uid_prefix: str, + *, + amount: Decimal | None = None, + description: str | None = None, + note: str | None = None, + incurred_date: date | None = None, + project_slug: str | None = None, + client_slug: str | None = None, +) -> Expense: + expense = await find_expense(uid_prefix) + _ensure_unlocked(expense) + if amount is not None: + expense.amount = amount + if description is not None: + expense.description = description.strip() + if note is not None: + expense.note = note + if incurred_date is not None: + expense.incurred_date = incurred_date + if project_slug is not None: + project = await get_project(project_slug, client_slug) + expense.project_id = pk(project) + expense.updated_at = datetime.now() + await expense.save() + return expense + + +@in_db_session +async def delete_expense(uid_prefix: str) -> Expense: + expense = await find_expense(uid_prefix) + _ensure_unlocked(expense) + for receipt in await ExpenseReceipt.where(lambda r: r.expense_id == expense.id).all(): + await receipt.delete() # manual cascade (ttd#13 would make this a DB action) + await expense.delete() + return expense + + +@in_db_session +async def recent_expenses( + *, + project_slug: str | None = None, + client_slug: str | None = None, + limit: int = 8, +) -> list[ExpenseSuggestion]: + """Distinct (description, amount) pairs from prior expenses, newest first. + + Scoped to the project; if no project given, scoped to the client. + """ + views = await list_expenses(project_slug=project_slug, client_slug=client_slug) + seen: set[tuple[str, Decimal]] = set() + out: list[ExpenseSuggestion] = [] + for view in reversed(views): # list_expenses is oldest-first; we want newest-first + key = (view.expense.description, view.expense.amount) + if key in seen: + continue + seen.add(key) + out.append(ExpenseSuggestion(view.expense.description, view.expense.amount)) + if len(out) >= limit: + break + return out + + +MAX_RECEIPT_BYTES = 5 * 1024 * 1024 # 5 MiB — receipts are meant to be small + + +@in_db_session +async def add_receipt(uid_prefix: str, path: Path) -> ExpenseReceipt: + expense = await find_expense(uid_prefix) + raw = path.read_bytes() + if len(raw) > MAX_RECEIPT_BYTES: + raise TtdError( + f"Receipt is {len(raw) // 1024} KB; the limit is " + f"{MAX_RECEIPT_BYTES // (1024 * 1024)} MB" + ) + content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" + for existing in await ExpenseReceipt.where(lambda r: r.expense_id == expense.id).all(): + await existing.delete() # one receipt per expense; replace + receipt = ExpenseReceipt( + id=uuid4(), + expense_id=pk(expense), + filename=path.name, + content_type=content_type, + data_b64=base64.b64encode(raw).decode("ascii"), + ) + await receipt.save() + return receipt + + +@in_db_session +async def get_receipt(uid_prefix: str) -> tuple[str, str, bytes] | None: + expense = await find_expense(uid_prefix) + receipt = await ExpenseReceipt.where(lambda r: r.expense_id == expense.id).first() + if receipt is None: + return None + return receipt.filename, receipt.content_type, base64.b64decode(receipt.data_b64) + + +@in_db_session +async def remove_receipt(uid_prefix: str) -> None: + expense = await find_expense(uid_prefix) + for receipt in await ExpenseReceipt.where(lambda r: r.expense_id == expense.id).all(): + await receipt.delete() + + +@in_db_session +async def load_invoice_receipts( + expense_lines: list[InvoiceExpenseLine], +) -> list[tuple[str, str, bytes]]: + """Decoded (filename, content_type, bytes) receipts for an invoice's expense + lines, in line order; expense lines without a receipt are skipped.""" + out: list[tuple[str, str, bytes]] = [] + for line in expense_lines: + got = await get_receipt(str(line.expense_id)[:8]) + if got is not None: + out.append(got) + return out diff --git a/src/ttd/services/interchange_svc.py b/src/ttd/services/interchange_svc.py index 83d697d..ba9c7fa 100644 --- a/src/ttd/services/interchange_svc.py +++ b/src/ttd/services/interchange_svc.py @@ -4,8 +4,9 @@ from ttd.interchange.model import EntryRecord from ttd.services.entries import list_entries +from ttd.services.expenses import list_expenses from ttd.storage.db import in_db_session -from ttd.storage.models import Client, Invoice, Project +from ttd.storage.models import Client, ExpenseReceipt, Invoice, Project @in_db_session @@ -45,8 +46,21 @@ async def export_records( ] records.sort(key=lambda r: (r.date, r.start or time.min, r.uid)) + expense_views = await list_expenses( + project_slug=project_slug, + client_slug=client_slug, + date_from=date_from, + date_to=date_to, + ) + if invoiced is not None: + expense_views = [v for v in expense_views if (v.expense.invoice_id is not None) == invoiced] + used_clients = {r.client for r in records} used_projects = {(r.client, r.project) for r in records} + used_clients |= {v.client.slug for v in expense_views} + used_projects |= {(v.client.slug, v.project.slug) for v in expense_views} + + all_clients = await Client.all() clients_meta = [ { "slug": c.slug, @@ -55,10 +69,10 @@ async def export_records( "hourly_rate": str(c.hourly_rate) if c.hourly_rate is not None else None, "email": c.email, } - for c in await Client.all() + for c in all_clients if c.slug in used_clients ] - client_slugs = {c.id: c.slug for c in await Client.all()} + client_slugs = {c.id: c.slug for c in all_clients} projects_meta = [ { "client": client_slugs.get(p.client_id), @@ -69,4 +83,37 @@ async def export_records( for p in await Project.all() if (client_slugs.get(p.client_id), p.slug) in used_projects ] - return records, {"clients": clients_meta, "projects": projects_meta} + + invoice_numbers = {i.id: i.number for i in await Invoice.all()} + expenses_meta = [ + { + "id": str(v.expense.id), + "client": v.client.slug, + "project": v.project.slug, + "incurred_date": v.expense.incurred_date.isoformat(), + "description": v.expense.description, + "amount": str(v.expense.amount), + "note": v.expense.note, + "invoice_number": invoice_numbers.get(v.expense.invoice_id, "") + if v.expense.invoice_id + else "", + } + for v in expense_views + ] + expense_ids = {str(v.expense.id) for v in expense_views} + receipts_meta = [ + { + "expense_id": str(r.expense_id), + "filename": r.filename, + "content_type": r.content_type, + "data_b64": r.data_b64, + } + for r in await ExpenseReceipt.all() + if str(r.expense_id) in expense_ids + ] + return records, { + "clients": clients_meta, + "projects": projects_meta, + "expenses": expenses_meta, + "receipts": receipts_meta, + } diff --git a/src/ttd/services/invoicing.py b/src/ttd/services/invoicing.py index 72301d7..7a15175 100644 --- a/src/ttd/services/invoicing.py +++ b/src/ttd/services/invoicing.py @@ -13,14 +13,17 @@ from ttd.core.rollup import EntryFacts, rollup_days from ttd.core.taxes import compute_set_aside from ttd.invoicing.numbering import next_number -from ttd.reporting.periods import Period +from ttd.reporting.periods import Period, range_period from ttd.services.clients import get_client from ttd.services.projects import effective_rate from ttd.storage.db import in_db_session from ttd.storage.models import ( Client, Entry, + Expense, + ExpenseReceipt, Invoice, + InvoiceExpenseLine, InvoiceLine, InvoiceStatus, Project, @@ -71,12 +74,22 @@ class DraftLine: entry_ids: list +@dataclass +class DraftExpenseLine: + expense: Expense + incurred_date: date + description: str + amount: Decimal + + @dataclass class Draft: client: Client period: Period lines: list[DraftLine] + expense_lines: list[DraftExpenseLine] subtotal: Decimal + expenses_subtotal: Decimal tax: Decimal total: Decimal number: str | None = None # set when persisted @@ -87,6 +100,7 @@ class InvoiceView: invoice: Invoice client: Client lines: list[InvoiceLine] + expense_lines: list[InvoiceExpenseLine] project_names: dict @@ -111,6 +125,9 @@ class RefreshPreview: after_tax: Decimal before_total: Decimal after_total: Decimal + before_expenses_subtotal: Decimal + after_expenses_subtotal: Decimal + after_expense_lines: list[DraftExpenseLine] totals_changed: bool billing_fields_changed: bool has_changes: bool @@ -122,10 +139,23 @@ def _line_key(project_id: UUID, work_date: date) -> tuple[UUID, date]: return (project_id, work_date) -def _draft_totals(lines: list[DraftLine], tax_rate: Decimal) -> tuple[Decimal, Decimal, Decimal]: +def _draft_totals( + lines: list[DraftLine], expense_lines: list["DraftExpenseLine"], tax_rate: Decimal +) -> tuple[Decimal, Decimal, Decimal, Decimal]: subtotal = sum((line.amount for line in lines), Decimal("0")) - tax = to_cents(subtotal * tax_rate) - return subtotal, tax, subtotal + tax + expenses_subtotal = sum((e.amount for e in expense_lines), Decimal("0")) + tax = to_cents(subtotal * tax_rate) # time only — expenses are untaxed + total = subtotal + tax + expenses_subtotal + return subtotal, expenses_subtotal, tax, total + + +def _derive_period( + lines: list[DraftLine], expense_lines: list[DraftExpenseLine], fallback: Period +) -> Period: + dates = [li.work_date for li in lines] + [el.incurred_date for el in expense_lines] + if not dates: + return fallback + return range_period(min(dates), max(dates)) def _line_changed(before: InvoiceLine | None, after: DraftLine) -> frozenset[str]: @@ -206,16 +236,34 @@ async def build_draft(client_slug: str, period: Period, settings: Settings) -> D and e.billable and period.start <= e.work_date <= period.end ] - if not entries: - raise TtdError(f"No uninvoiced billable entries for '{client_slug}' in {period.label}") + expenses = [ + e + for e in await Expense.all() + if e.project_id in projects + and e.invoice_id is None + and period.start <= e.incurred_date <= period.end + ] + if not entries and not expenses: + raise TtdError( + f"No uninvoiced billable entries or expenses for '{client_slug}' in {period.label}" + ) lines = await _build_lines_from_entries(entries, client, projects, settings) - subtotal, tax, total = _draft_totals(lines, settings.invoice.tax_rate) + expense_lines = [ + DraftExpenseLine(e, e.incurred_date, e.description, e.amount) + for e in sorted(expenses, key=lambda e: (e.incurred_date, e.created_at)) + ] + subtotal, expenses_subtotal, tax, total = _draft_totals( + lines, expense_lines, settings.invoice.tax_rate + ) + actual_period = _derive_period(lines, expense_lines, fallback=period) return Draft( client=client, - period=period, + period=actual_period, # derives from billed items; requested window is only a sieve lines=lines, + expense_lines=expense_lines, subtotal=subtotal, + expenses_subtotal=expenses_subtotal, tax=tax, total=total, ) @@ -244,6 +292,7 @@ async def persist_draft( subtotal=draft.subtotal, tax_rate=settings.invoice.tax_rate, tax=draft.tax, + expenses_subtotal=draft.expenses_subtotal, total=draft.total, status=InvoiceStatus.DRAFT, created_at=now, @@ -266,6 +315,19 @@ async def persist_draft( if entry is not None: entry.invoice_id = invoice.id await entry.save() + for eline in draft.expense_lines: + await InvoiceExpenseLine( + id=uuid4(), + invoice_id=pk(invoice), + expense_id=pk(eline.expense), + incurred_date=eline.incurred_date, + description=eline.description, + amount=eline.amount, + ).save() + expense = await Expense.get_or_none(pk(eline.expense)) + if expense is not None: + expense.invoice_id = invoice.id + await expense.save() draft.number = final_number return invoice @@ -279,8 +341,10 @@ async def get_invoice(number: str) -> InvoiceView: assert client is not None lines = await InvoiceLine.where(lambda li: li.invoice_id == invoice.id).all() lines.sort(key=lambda li: (li.work_date, li.description)) + expense_lines = await InvoiceExpenseLine.where(lambda li: li.invoice_id == invoice.id).all() + expense_lines.sort(key=lambda li: (li.incurred_date, li.description)) names = {pk(p): p.name for p in await Project.all()} - return InvoiceView(invoice, client, lines, names) + return InvoiceView(invoice, client, lines, expense_lines, names) @in_db_session @@ -319,6 +383,9 @@ async def mark_invoice( for entry in await Entry.where(lambda e: e.invoice_id == invoice.id).all(): entry.invoice_id = None await entry.save() + for expense in await Expense.where(lambda e: e.invoice_id == invoice.id).all(): + expense.invoice_id = None + await expense.save() invoice.status = InvoiceStatus.VOID _clear_paid_snapshot(invoice) await invoice.save() @@ -351,8 +418,8 @@ async def preview_refresh(number: str, settings: Settings) -> RefreshPreview: raise ConflictError(f"Invoice {number} is void and can't be refreshed") entries = await Entry.where(lambda e: e.invoice_id == invoice.id).all() - if not entries: - raise TtdError(f"Invoice {number} has no linked entries") + if not entries and not view.lines and not view.expense_lines: + raise TtdError(f"Invoice {number} has no linked entries or expenses") project_ids = {e.project_id for e in entries} projects = {pk(p): p for p in await Project.all() if pk(p) in project_ids} @@ -412,10 +479,22 @@ async def preview_refresh(number: str, settings: Settings) -> RefreshPreview: before_subtotal = invoice.subtotal before_tax = invoice.tax before_total = invoice.total - after_subtotal, after_tax, after_total = _draft_totals(after_lines, settings.invoice.tax_rate) + before_expenses_subtotal = invoice.expenses_subtotal + + linked_expenses = await Expense.where(lambda e: e.invoice_id == invoice.id).all() + after_expense_lines = [ + DraftExpenseLine(e, e.incurred_date, e.description, e.amount) + for e in sorted(linked_expenses, key=lambda e: (e.incurred_date, e.created_at)) + ] + after_subtotal, after_expenses, after_tax, after_total = _draft_totals( + after_lines, after_expense_lines, settings.invoice.tax_rate + ) totals_changed = ( - before_subtotal != after_subtotal or before_tax != after_tax or before_total != after_total + before_subtotal != after_subtotal + or before_tax != after_tax + or before_total != after_total + or invoice.expenses_subtotal != after_expenses ) has_changes = any(d.changed for d in diffs) or totals_changed @@ -437,6 +516,9 @@ async def preview_refresh(number: str, settings: Settings) -> RefreshPreview: after_tax=after_tax, before_total=before_total, after_total=after_total, + before_expenses_subtotal=before_expenses_subtotal, + after_expenses_subtotal=after_expenses, + after_expense_lines=after_expense_lines, totals_changed=totals_changed, billing_fields_changed=billing_fields_changed, has_changes=has_changes, @@ -514,6 +596,30 @@ async def apply_refresh(number: str, preview: RefreshPreview, settings: Settings if key not in seen_keys: await line.delete() + for stale in await InvoiceExpenseLine.where( + lambda li: li.invoice_id == invoice.id + ).all(): + await stale.delete() + for eline in fresh.after_expense_lines: + await InvoiceExpenseLine( + id=uuid4(), + invoice_id=pk(invoice), + expense_id=pk(eline.expense), + incurred_date=eline.incurred_date, + description=eline.description, + amount=eline.amount, + ).save() + time_rows = await InvoiceLine.where(lambda li: li.invoice_id == invoice.id).all() + exp_rows = await InvoiceExpenseLine.where(lambda li: li.invoice_id == invoice.id).all() + billed_dates = [li.work_date for li in time_rows] + [ + li.incurred_date for li in exp_rows + ] + # re-derive the period from the surviving billed rows + if billed_dates: + invoice.period_start = min(billed_dates) + invoice.period_end = max(billed_dates) + invoice.expenses_subtotal = fresh.after_expenses_subtotal + invoice.subtotal = fresh.after_subtotal invoice.tax_rate = settings.invoice.tax_rate invoice.tax = fresh.after_tax @@ -523,3 +629,13 @@ async def apply_refresh(number: str, preview: RefreshPreview, settings: Settings updated = await Invoice.where(lambda i: i.number == number).first() assert updated is not None return updated + + +@in_db_session +async def invoice_has_receipts(view: InvoiceView) -> bool: + """True if any of the invoice's linked expenses has a stored receipt.""" + if not view.expense_lines: + return False + expense_ids = {li.expense_id for li in view.expense_lines} + receipts = await ExpenseReceipt.all() + return any(r.expense_id in expense_ids for r in receipts) diff --git a/src/ttd/storage/models/__init__.py b/src/ttd/storage/models/__init__.py index 8da6a91..9537eb7 100644 --- a/src/ttd/storage/models/__init__.py +++ b/src/ttd/storage/models/__init__.py @@ -6,7 +6,8 @@ from ttd.storage.models.client import Client from ttd.storage.models.entry import Entry from ttd.storage.models.enums import EntrySource, InvoiceStatus, enum_value -from ttd.storage.models.invoice import Invoice, InvoiceLine +from ttd.storage.models.expense import Expense, ExpenseReceipt +from ttd.storage.models.invoice import Invoice, InvoiceExpenseLine, InvoiceLine from ttd.storage.models.project import Project from ttd.storage.models.tax_payment import TaxPayment from ttd.storage.models.timer import TIMER_SINGLETON_ID, TimerState @@ -27,7 +28,10 @@ def pk(model: _HasId) -> UUID: "Client", "Entry", "EntrySource", + "Expense", + "ExpenseReceipt", "Invoice", + "InvoiceExpenseLine", "InvoiceLine", "InvoiceStatus", "Project", diff --git a/src/ttd/storage/models/expense.py b/src/ttd/storage/models/expense.py new file mode 100644 index 0000000..7289a16 --- /dev/null +++ b/src/ttd/storage/models/expense.py @@ -0,0 +1,39 @@ +from datetime import date, datetime +from decimal import Decimal +from typing import Annotated +from uuid import UUID + +from ferro import FerroField +from ferro.models import Model + + +class Expense(Model): + """A purchased item billed back to a client, attached to a project. + + ``invoice_id`` set means billed & locked — mirrors ``Entry``. ``amount`` is + pure pass-through: what you paid is what the client is billed. + """ + + id: Annotated[UUID | None, FerroField(primary_key=True)] = None + project_id: Annotated[UUID, FerroField(index=True)] + incurred_date: Annotated[date, FerroField(db_type="date", index=True)] + description: str + amount: Decimal + note: str = "" + invoice_id: Annotated[UUID | None, FerroField(index=True)] = None + created_at: datetime + updated_at: datetime + + +class ExpenseReceipt(Model): + """Optional receipt for an expense, stored base64 in its own table. + + Separate table so ``expense list`` never loads receipt bytes. Base64 text + rather than raw ``bytes`` because ferro-orm#160 blocks binary via the ORM. + """ + + id: Annotated[UUID | None, FerroField(primary_key=True)] = None + expense_id: Annotated[UUID, FerroField(unique=True, index=True)] + filename: str + content_type: str + data_b64: Annotated[str, FerroField(db_type="text")] diff --git a/src/ttd/storage/models/invoice.py b/src/ttd/storage/models/invoice.py index 132fe6e..53c28b3 100644 --- a/src/ttd/storage/models/invoice.py +++ b/src/ttd/storage/models/invoice.py @@ -23,6 +23,7 @@ class Invoice(Model): subtotal: Decimal tax_rate: Decimal = Decimal("0") tax: Decimal = Decimal("0") + expenses_subtotal: Decimal = Decimal("0") # untaxed pass-through expenses total: Decimal status: Annotated[InvoiceStatus, FerroField(db_type="text")] = InvoiceStatus.DRAFT notes: str = "" @@ -45,3 +46,14 @@ class InvoiceLine(Model): rate: Decimal amount: Decimal description: str = "" + + +class InvoiceExpenseLine(Model): + """One expense frozen onto an invoice; ``amount`` is frozen at invoice time.""" + + id: Annotated[UUID | None, FerroField(primary_key=True)] = None + invoice_id: Annotated[UUID, FerroField(index=True)] + expense_id: Annotated[UUID, FerroField(index=True)] + incurred_date: Annotated[date, FerroField(db_type="date")] + description: str + amount: Decimal diff --git a/src/ttd/tui/_data.py b/src/ttd/tui/_data.py index 7e835a3..3172250 100644 --- a/src/ttd/tui/_data.py +++ b/src/ttd/tui/_data.py @@ -1,6 +1,7 @@ """Data helpers shared by TUI screens (thin wrappers over services).""" from datetime import date, datetime, timedelta +from decimal import Decimal from ttd.config.loader import get_settings from ttd.core.errors import TtdError @@ -8,9 +9,10 @@ from ttd.reporting.render import entry_time_label from ttd.services import clients as client_svc from ttd.services import entries as entry_svc +from ttd.services import expenses as expense_svc from ttd.services import projects as project_svc from ttd.storage.db import in_db_session -from ttd.storage.models import Entry, pk +from ttd.storage.models import Entry, Expense, pk @in_db_session @@ -48,6 +50,27 @@ async def split_and_log(payload: dict, *, now: datetime) -> Entry: ) +@in_db_session +async def add_expense_entry(payload: dict) -> Expense: + """Create an expense from a FormModal payload dict. + + Expected keys: project ('client/project'), description, amount (str), + date (YYYY-MM-DD string or blank/absent → today), note (optional). + """ + client_slug, project_slug = payload["project"].split("/", 1) + amount = Decimal(payload["amount"]) + raw_date = payload.get("date", "") + incurred: date | None = date.fromisoformat(raw_date) if raw_date else None + return await expense_svc.add_expense( + project_slug, + payload["description"], + amount, + client_slug=client_slug, + incurred_date=incurred, + note=payload.get("note", ""), + ) + + @in_db_session async def heatmap_data(days: int = 91, today: date | None = None) -> dict[date, int]: today = today or date.today() @@ -123,14 +146,37 @@ def hours_for_row(entry: Entry) -> str: return entry_time_label(entry) +@in_db_session +async def recent_expense_suggestions( + *, + project_slug: str | None = None, + client_slug: str | None = None, + limit: int = 8, +): + """Distinct (description, amount) pairs from recent expenses, newest first.""" + from ttd.services import expenses as expense_svc + + return await expense_svc.recent_expenses( + project_slug=project_slug, client_slug=client_slug, limit=limit + ) + + +async def expenses_for_invoice(view) -> list: + """Thin accessor: view.expense_lines is already loaded by get_invoice.""" + return view.expense_lines + + __all__ = [ + "add_expense_entry", "client_tree", "day_rows", "delete_entry_by_id", + "expenses_for_invoice", "heatmap_data", "hours_for_row", "pk", "project_options", + "recent_expense_suggestions", "split_and_log", "unbilled_value", "week_seconds", diff --git a/src/ttd/tui/app.py b/src/ttd/tui/app.py index 0848815..10b9359 100644 --- a/src/ttd/tui/app.py +++ b/src/ttd/tui/app.py @@ -14,9 +14,9 @@ from ttd.tui.screens.clients import ClientsScreen from ttd.tui.screens.dashboard import DashboardScreen from ttd.tui.screens.invoices import InvoicesScreen +from ttd.tui.screens.log import LogScreen from ttd.tui.screens.reports import ReportsScreen from ttd.tui.screens.taxes import TaxesScreen -from ttd.tui.screens.timesheet import TimesheetScreen from ttd.tui.theme import THEME_DARK, TTD_DARK, TTD_LIGHT from ttd.tui.widgets.modals import ConfirmModal from ttd.tui.widgets.theme_picker import ThemePickerModal @@ -28,7 +28,7 @@ class TtdApp(App): SCREENS: ClassVar = { "dashboard": DashboardScreen, - "timesheet": TimesheetScreen, + "log": LogScreen, "clients": ClientsScreen, "reports": ReportsScreen, "invoices": InvoicesScreen, diff --git a/src/ttd/tui/screens/_base.py b/src/ttd/tui/screens/_base.py index 191b398..0f73a00 100644 --- a/src/ttd/tui/screens/_base.py +++ b/src/ttd/tui/screens/_base.py @@ -1,7 +1,8 @@ """Base screen with the left nav rail; subclasses fill the content area.""" import asyncio -from datetime import datetime +import decimal +from datetime import date, datetime from typing import ClassVar from textual.app import ComposeResult @@ -12,13 +13,14 @@ from ttd.core.errors import TtdError from ttd.services import timer as timer_svc -from ttd.tui._data import project_options, split_and_log +from ttd.tui._data import add_expense_entry, project_options, split_and_log from ttd.tui.widgets.footer import AdaptiveFooter +from ttd.tui.widgets.forms import FormField, FormModal from ttd.tui.widgets.modals import PickerModal, QuickLogModal NAV = [ ("dashboard", "1 dashboard"), - ("timesheet", "2 timesheet"), + ("log", "2 log"), ("clients", "3 clients"), ("reports", "4 reports"), ("invoices", "5 invoices"), @@ -33,6 +35,30 @@ """Shared by screens that page through periods with [ and ].""" +def _validate_amount(raw: str) -> bool | str: + """Return True if *raw* is a positive number; else an error string.""" + try: + value = decimal.Decimal(raw) + except decimal.InvalidOperation: + return "amount must be a number" + if value <= 0: + return "amount must be positive" + return True + + +def _validate_date(raw: str) -> bool | str: + """Return True if *raw* is a valid ISO date (YYYY-MM-DD); else an error string. + + FormModal only calls validate on non-empty values, so blank → today is + handled downstream without touching this validator. + """ + try: + date.fromisoformat(raw) + except ValueError: + return "date must be YYYY-MM-DD" + return True + + class TtdScreen(Screen): """Nav rail + content + footer; global timer/log actions.""" @@ -40,7 +66,7 @@ class TtdScreen(Screen): BINDINGS: ClassVar = [ Binding("1", "goto('dashboard')", "dashboard", group=SCREEN_GROUP), - Binding("2", "goto('timesheet')", "timesheet", group=SCREEN_GROUP), + Binding("2", "goto('log')", "log", group=SCREEN_GROUP), Binding("3", "goto('clients')", "clients", group=SCREEN_GROUP), Binding("4", "goto('reports')", "reports", group=SCREEN_GROUP), Binding("5", "goto('invoices')", "invoices", group=SCREEN_GROUP), @@ -136,6 +162,21 @@ def action_pick_theme(self) -> None: search() async def action_quick_log(self) -> None: + """Open a chooser: 'time' → existing log-time flow; 'expense' → expense form.""" + + def _route(choice: str | None) -> None: + if choice == "time": + self.run_worker(self._open_time_log()) + elif choice == "expense": + self.run_worker(self._open_expense_form()) + + self.app.push_screen( + PickerModal("log…", [("time", "⏱ time"), ("expense", "$ expense")]), + _route, + ) + + async def _open_time_log(self) -> None: + """Original quick-log body: pick a project and log a time entry.""" options = await project_options() if not options: self.notify("no projects yet — add a client and project first", severity="warning") @@ -156,3 +197,52 @@ async def _log(payload: dict | None) -> None: await self.refresh_data() self.app.push_screen(QuickLogModal(options), _log) + + async def _open_expense_form(self) -> None: + """Show a FormModal to log a billable expense.""" + options = await project_options() + if not options: + self.notify("no projects yet — add a client and project first", severity="warning") + return + + fields = [ + FormField( + "project", + "project", + kind="select", + choices=options, + value=options[0][0], + required=True, + ), + FormField("description", "description", required=True, placeholder="Claude Code"), + FormField( + "amount", + "amount", + required=True, + placeholder="100.00", + validate=_validate_amount, + ), + FormField( + "date", + "date", + placeholder="YYYY-MM-DD (blank = today)", + validate=_validate_date, + ), + ] + + async def _save(payload: dict | None) -> None: + if payload is None: + return + try: + expense = await add_expense_entry(payload) + self.notify( + f"{payload['description']} · {expense.amount}", + title="expense added", + ) + except TtdError as exc: + self.notify(str(exc), severity="error") + except Exception as exc: + self.notify(str(exc), severity="error") + await self.refresh_data() + + self.app.push_screen(FormModal("log expense", fields), _save) diff --git a/src/ttd/tui/screens/invoices.py b/src/ttd/tui/screens/invoices.py index ec411b2..8e7d363 100644 --- a/src/ttd/tui/screens/invoices.py +++ b/src/ttd/tui/screens/invoices.py @@ -1,14 +1,17 @@ """Invoices: list with status pills, detail view, create wizard, render, mark paid.""" from datetime import datetime -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar + +if TYPE_CHECKING: + from ttd.config.schema import Settings from textual import on from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical, VerticalScroll from textual.screen import ModalScreen -from textual.widgets import Button, DataTable, Input, Label, Markdown, Static +from textual.widgets import Button, Checkbox, DataTable, Input, Label, Markdown, Static from ttd.config.loader import get_settings from ttd.core.errors import TtdError @@ -65,6 +68,17 @@ def compose(self) -> ComposeResult: format_money(line.amount, invoice.currency), ) yield table + if self.view.expense_lines: + yield Label("expenses", classes="section-title") + expense_table = DataTable(id="expense-table", cursor_type="none") + expense_table.add_columns("date", "description", "amount") + for eline in self.view.expense_lines: + expense_table.add_row( + eline.incurred_date.strftime("%a %b %-d"), + eline.description, + format_money(eline.amount, invoice.currency), + ) + yield expense_table summary = ( f"issued {invoice.issued_date} · due {invoice.due_date or 'on receipt'} · " f"[bold]{format_money(invoice.total, invoice.currency)}[/bold]" @@ -122,7 +136,7 @@ def compose(self) -> ComposeResult: yield Label(f"new invoice · {self.client_slug}", classes="modal-title") yield Label("Period (blank = last month)", classes="field-label") yield Input( - placeholder="2026-05 · last month · this month · 2026-05-01 to 2026-05-15", + placeholder="last month · this week · last two weeks · june 16–30 · 2026-05", id="period", ) yield Static("", id="draft-status") @@ -153,7 +167,9 @@ async def _rebuild(self, raw: str) -> None: button.disabled = True try: - period = periods.parse_period(raw, datetime.now().date()) + period = periods.parse_period( + raw, datetime.now().date(), week_start=get_settings().display.week_start + ) except TtdError as exc: status.update(f"[red]✗ {exc}[/red]") return @@ -174,11 +190,27 @@ async def _rebuild(self, raw: str) -> None: format_money(line.rate, currency), format_money(line.amount, currency), ) + if draft.expense_lines: + table.add_row("", "-- reimbursable expenses --", "", "", "") + for e in draft.expense_lines: + table.add_row( + e.incurred_date.strftime("%a %b %-d"), + e.description, + "", + "", + format_money(e.amount, currency), + ) hours = format_hours(sum(line.billed_seconds for line in draft.lines)) + expense_note = ( + f" · {len(draft.expense_lines)} expense{'s' if len(draft.expense_lines) != 1 else ''}" + if draft.expense_lines + else "" + ) status.update( f"[#ffb000]✓[/#ffb000] {period.label} · {entry_count} " f"entr{'y' if entry_count == 1 else 'ies'} → {len(draft.lines)} " - f"line{'s' if len(draft.lines) != 1 else ''} · {hours} · " + f"line{'s' if len(draft.lines) != 1 else ''} · {hours}" + f"{expense_note} · " f"[bold]{format_money(draft.total, currency)}[/bold]" ) self.draft = draft @@ -310,6 +342,78 @@ def action_apply(self) -> None: self.dismiss(self.preview) +class RenderFormatModal(ModalScreen[dict | None]): + """Choose which files to render. Receipts embed into the PDF and are only + available when the invoice has receipts; enabling them locks out Markdown.""" + + BINDINGS: ClassVar = [("escape", "dismiss(None)", "cancel")] + + def __init__(self, has_receipts: bool) -> None: + super().__init__() + self.has_receipts = has_receipts + + def compose(self) -> ComposeResult: + with Vertical(classes="modal-box"): + yield Label("Render Invoice", classes="modal-title") + yield Checkbox( + "Include receipts", + value=self.has_receipts, + id="receipts", + disabled=not self.has_receipts, + ) + yield Label("Format", classes="field-label") + with Horizontal(classes="form-toggle-row"): + yield Checkbox("PDF", value=True, id="pdf") + yield Checkbox("Markdown", value=False, id="md", disabled=self.has_receipts) + yield Static("", id="render-error", classes="form-error") + with Horizontal(classes="modal-buttons"): + yield Button("Render", variant="primary", id="render") + yield Button("Cancel", id="cancel") + + @on(Checkbox.Changed, "#receipts") + def _receipts_changed(self, event: Checkbox.Changed) -> None: + md = self.query_one("#md", Checkbox) + if event.value: + self.query_one("#pdf", Checkbox).value = True + md.value = False + md.disabled = True + else: + md.disabled = False + + @on(Button.Pressed, "#render") + def _do_render(self) -> None: + pdf = self.query_one("#pdf", Checkbox).value + md = self.query_one("#md", Checkbox).value + receipts = self.query_one("#receipts", Checkbox).value + if not pdf and not md: + self.query_one("#render-error", Static).update("[red]Choose at least one format[/red]") + return + self.dismiss({"pdf": pdf, "md": md, "receipts": receipts}) + + @on(Button.Pressed, "#cancel") + def _cancel(self) -> None: + self.dismiss(None) + + +async def _write_selected_formats( + view: "svc.InvoiceView", settings: "Settings", choice: dict +) -> list[str]: + """Render the chosen formats; return the file names (with extension) written.""" + from ttd.services.expenses import load_invoice_receipts + + stem = settings.invoice.output_dir / f"{view.invoice.number}-{view.client.slug}" + wrote: list[str] = [] + if choice["pdf"]: + decoded = await load_invoice_receipts(view.expense_lines) if choice["receipts"] else None + render_pdf(view, settings, stem.with_suffix(".pdf"), receipts=decoded) + n = len(decoded) if decoded else 0 + wrote.append(f"{stem.name}.pdf" + (f" (+{n} receipt{'s' if n != 1 else ''})" if n else "")) + if choice["md"]: + write_markdown(view, settings, stem.with_suffix(".md")) + wrote.append(f"{stem.name}.md") + return wrote + + class InvoicesScreen(TtdScreen): nav_id = "invoices" @@ -318,7 +422,7 @@ class InvoicesScreen(TtdScreen): ("n", "new_invoice", "new"), Binding("o", "open_detail", "open"), ("m", "preview_markdown", "preview md"), - ("e", "render_files", "render pdf+md"), + ("e", "render_files", "render"), ("u", "refresh_invoice", "update"), ("p", "mark('paid')", "paid"), ("t", "mark('sent')", "sent"), @@ -393,10 +497,15 @@ async def action_render_files(self) -> None: return settings = get_settings() view = await svc.get_invoice(number) - stem = settings.invoice.output_dir / f"{view.invoice.number}-{view.client.slug}" - render_pdf(view, settings, stem.with_suffix(".pdf")) - write_markdown(view, settings, stem.with_suffix(".md")) - self.notify(f"wrote {stem}.pdf + .md", title="rendered") + has_receipts = await svc.invoice_has_receipts(view) + + async def _render(choice: dict | None) -> None: + if choice is None: + return + wrote = await _write_selected_formats(view, settings, choice) + self.notify("wrote " + ", ".join(wrote), title="rendered") + + self.app.push_screen(RenderFormatModal(has_receipts), _render) async def action_refresh_invoice(self) -> None: number = self._selected_number() diff --git a/src/ttd/tui/screens/log.py b/src/ttd/tui/screens/log.py new file mode 100644 index 0000000..5e28e60 --- /dev/null +++ b/src/ttd/tui/screens/log.py @@ -0,0 +1,350 @@ +"""Log: month-scoped time entries (expenses added in Task 2); add/edit/delete.""" + +from datetime import date, datetime, timedelta +from decimal import Decimal +from typing import Any, ClassVar, cast + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.coordinate import Coordinate +from textual.widgets import DataTable, Label + +from ttd.cli._pickers import describe_timespec, split_project_choice, validate_timespec +from ttd.config.loader import get_settings +from ttd.core.errors import TtdError +from ttd.core.money import format_hours, format_money +from ttd.reporting import periods +from ttd.services import entries as entry_svc +from ttd.services import expenses as expense_svc +from ttd.tui._data import hours_for_row, project_options +from ttd.tui.screens._base import PREV_NEXT_GROUP, TtdScreen, _validate_amount, _validate_date +from ttd.tui.widgets.forms import FormField, FormModal +from ttd.tui.widgets.modals import ConfirmModal + + +def _entry_spec(entry) -> str: + """Reconstruct an unambiguous, round-trippable time spec for an entry.""" + if entry.started_at and entry.ended_at: + return f"{entry.work_date} {entry.started_at:%H:%M} to {entry.ended_at:%H:%M}" + h, rem = divmod(entry.seconds, 3600) + duration = f"{h}h{rem // 60}m" if h else f"{rem // 60}m" + return f"{entry.work_date} {duration}" + + +class LogScreen(TtdScreen): + nav_id = "log" + + BINDINGS: ClassVar = [ + *TtdScreen.BINDINGS, + Binding("left_square_bracket", "shift(-1)", "prev", group=PREV_NEXT_GROUP), + Binding("right_square_bracket", "shift(1)", "next", group=PREV_NEXT_GROUP), + ("g", "today", "this month"), + ("e", "edit_entry", "edit"), + ("x", "delete_entry", "delete"), + ("tab", "switch_section", "switch section"), + ] + + def __init__(self) -> None: + super().__init__() + self.anchor_date: date = date.today() + self.active_section: str = "time" # "time" | "expenses" + + def compose_content(self) -> ComposeResult: + with Vertical(id="log"): + yield Label("", id="day-title", classes="section-title") + yield DataTable(id="day-table", cursor_type="row") + yield Label("", id="day-total", classes="muted") + yield Label("expenses", id="expense-title", classes="section-title") + yield DataTable(id="expense-table", cursor_type="row") + yield Label("", id="expense-total", classes="muted") + + def setup(self) -> None: + self.query_one("#day-table", DataTable).add_columns( + "date", "project", "time", "hours", "note", "flags" + ) + self.query_one("#expense-table", DataTable).add_columns( + "date", "project", "description", "amount" + ) + + def _period(self) -> periods.Period: + return periods.month_period(self.anchor_date) + + async def render_data(self) -> None: + period = self._period() + rows = await entry_svc.list_entries(date_from=period.start, date_to=period.end) + table = self.query_one("#day-table", DataTable) + table.clear() + total = 0 + last_day = None + for r in rows: + total += r.entry.seconds + flags = [] + if not r.entry.billable: + flags.append("nb") + if r.entry.invoice_id is not None: + flags.append("inv") + day_label = r.entry.work_date.strftime("%a %b %-d") + table.add_row( + day_label if day_label != last_day else "", + f"{r.client.slug}/{r.project.slug}", + hours_for_row(r.entry), + format_hours(r.entry.seconds), + r.entry.note, + ",".join(flags), + key=str(r.entry.id), + ) + last_day = day_label + self.query_one("#day-title", Label).update(period.label) + self.query_one("#day-total", Label).update( + f"{len(rows)} entr{'y' if len(rows) == 1 else 'ies'} · {format_hours(total)}" + " [dim]\\[ ] prev/next month · g this month · l add · e edit · x delete[/dim]" + ) + expenses = await expense_svc.list_expenses(date_from=period.start, date_to=period.end) + etable = self.query_one("#expense-table", DataTable) + etable.clear() + etotal = Decimal("0") + for v in expenses: + etotal += v.expense.amount + flags = " inv" if v.expense.invoice_id is not None else "" + etable.add_row( + v.expense.incurred_date.strftime("%a %b %-d"), + f"{v.client.slug}/{v.project.slug}", + v.expense.description + flags, + format_money(v.expense.amount, v.client.currency), + key=str(v.expense.id), + ) + if expenses: + # Single-currency assumption: expenses[0].client.currency used for total; + # multi-currency totals are a tracked follow-up. + self.query_one("#expense-total", Label).update( + f"{len(expenses)} expense{'s' if len(expenses) != 1 else ''} · " + f"{format_money(etotal, expenses[0].client.currency)}" + ) + else: + self.query_one("#expense-total", Label).update("[dim]no expenses this month[/dim]") + + async def action_shift(self, delta: int) -> None: + first = self.anchor_date.replace(day=1) + if delta > 0: + self.anchor_date = (first + timedelta(days=32)).replace(day=1) + else: + self.anchor_date = (first - timedelta(days=1)).replace(day=1) + await self.refresh_data() + + async def action_today(self) -> None: + self.anchor_date = date.today() + await self.refresh_data() + + async def action_switch_section(self) -> None: + self.active_section = "expenses" if self.active_section == "time" else "time" + table_id = "#expense-table" if self.active_section == "expenses" else "#day-table" + self.query_one(table_id, DataTable).focus() + # mark the active section title + self.query_one("#day-title", Label).remove_class("active-section") + self.query_one("#expense-title", Label).remove_class("active-section") + active_title = "#expense-title" if self.active_section == "expenses" else "#day-title" + self.query_one(active_title, Label).add_class("active-section") + + def _selected_entry_id(self) -> str | None: + table = self.query_one("#day-table", DataTable) + if table.row_count == 0 or table.cursor_row is None: + return None + key = table.coordinate_to_cell_key(Coordinate(table.cursor_row, 0)).row_key.value + return str(key) if key is not None else None + + def _selected_expense_id(self) -> str | None: + table = self.query_one("#expense-table", DataTable) + if table.row_count == 0 or table.cursor_row is None: + return None + key = table.coordinate_to_cell_key(Coordinate(table.cursor_row, 0)).row_key.value + return str(key) if key is not None else None + + async def action_edit_entry(self) -> None: + if self.active_section == "expenses": + await self._edit_expense() + else: + await self._edit_entry_row() + + async def action_delete_entry(self) -> None: + if self.active_section == "expenses": + await self._delete_expense() + else: + await self._delete_entry_row() + + async def _edit_entry_row(self) -> None: + uid = self._selected_entry_id() + if uid is None: + return + entry = await entry_svc.find_entry(uid) + if entry.invoice_id is not None: + self.notify("entry is on an invoice — void it first", severity="warning") + return + options = await project_options() + rows = await entry_svc.list_entries(date_from=entry.work_date, date_to=entry.work_date) + current = next((r for r in rows if r.entry.id == entry.id), None) + current_project = f"{current.client.slug}/{current.project.slug}" if current else None + + initial = { + "time": _entry_spec(entry), + "note": entry.note, + "tags": entry.tags, + "billable": entry.billable, + "project": current_project, + } + form = FormModal( + f"edit entry {uid[:8]}", + [ + FormField( + "time", + "Time", + kind="spec", + value=initial["time"], + validate=validate_timespec, + preview=describe_timespec, + required=True, + ), + FormField("note", "Note", value=entry.note), + FormField("tags", "Tags (comma-separated)", value=entry.tags), + FormField("billable", "Billable", kind="toggle", value=entry.billable), + FormField( + "project", "Project", kind="select", value=current_project, choices=options + ), + ], + ) + + async def _save(values: dict | None) -> None: + if values is None: + return + kwargs: dict[str, object] = {} + if values["time"] != initial["time"]: + kwargs["spec"] = values["time"] + if values["note"] != initial["note"]: + kwargs["note"] = values["note"] + if values["tags"] != initial["tags"]: + kwargs["tags"] = values["tags"] + if values["billable"] != initial["billable"]: + kwargs["billable"] = values["billable"] + if values["project"] and values["project"] != initial["project"]: + project_slug, client_slug = split_project_choice(values["project"]) + kwargs["project_slug"] = project_slug + kwargs["client_slug"] = client_slug + if not kwargs: + return + try: + await entry_svc.edit_entry( + uid, now=datetime.now(), settings=get_settings(), **cast("Any", kwargs) + ) + self.notify("entry updated") + except TtdError as exc: + self.notify(str(exc), severity="error") + await self.refresh_data() + + self.app.push_screen(form, _save) + + async def _delete_entry_row(self) -> None: + uid = self._selected_entry_id() + if uid is None: + return + + async def _confirmed(yes: bool | None) -> None: + if not yes: + return + try: + await entry_svc.delete_entry(uid) + self.notify("entry deleted") + except TtdError as exc: + self.notify(str(exc), severity="error") + await self.refresh_data() + + self.app.push_screen(ConfirmModal(f"Delete entry {uid[:8]}?"), _confirmed) + + async def _edit_expense(self) -> None: + uid = self._selected_expense_id() + if uid is None: + return + expense = await expense_svc.find_expense(uid) + if expense.invoice_id is not None: + self.notify("expense is on an invoice -- void it first", severity="warning") + return + options = await project_options() + views = await expense_svc.list_expenses( + date_from=expense.incurred_date, date_to=expense.incurred_date + ) + current = next((v for v in views if v.expense.id == expense.id), None) + current_project = f"{current.client.slug}/{current.project.slug}" if current else None + initial = { + "description": expense.description, + "amount": str(expense.amount), + "date": expense.incurred_date.isoformat(), + "note": expense.note, + "project": current_project, + } + form = FormModal( + f"edit expense {uid[:8]}", + [ + FormField("description", "Description", value=expense.description, required=True), + FormField( + "amount", + "Amount", + value=str(expense.amount), + validate=_validate_amount, + required=True, + ), + FormField( + "date", "Date (YYYY-MM-DD)", value=initial["date"], validate=_validate_date + ), + FormField("note", "Note", value=expense.note), + FormField( + "project", "Project", kind="select", value=current_project, choices=options + ), + ], + ) + + async def _save(values: dict | None) -> None: + if values is None: + return + kwargs: dict[str, object] = {} + if values["description"] != initial["description"]: + kwargs["description"] = values["description"] + if values["amount"] != initial["amount"]: + kwargs["amount"] = Decimal(values["amount"]) + if values["date"] != initial["date"] and values["date"]: + kwargs["incurred_date"] = date.fromisoformat(values["date"]) + if values["note"] != initial["note"]: + kwargs["note"] = values["note"] + if values["project"] and values["project"] != initial["project"]: + project_slug, client_slug = split_project_choice(values["project"]) + kwargs["project_slug"] = project_slug + kwargs["client_slug"] = client_slug + if not kwargs: + return + try: + await expense_svc.edit_expense(uid, **cast("Any", kwargs)) + self.notify("expense updated") + except TtdError as exc: + self.notify(str(exc), severity="error") + await self.refresh_data() + + self.app.push_screen(form, _save) + + async def _delete_expense(self) -> None: + uid = self._selected_expense_id() + if uid is None: + return + expense = await expense_svc.find_expense(uid) + if expense.invoice_id is not None: + self.notify("expense is on an invoice — void it first", severity="warning") + return + + async def _confirmed(yes: bool | None) -> None: + if not yes: + return + try: + await expense_svc.delete_expense(uid) + self.notify("expense deleted") + except TtdError as exc: + self.notify(str(exc), severity="error") + await self.refresh_data() + + self.app.push_screen(ConfirmModal(f"Delete expense {uid[:8]}?"), _confirmed) diff --git a/src/ttd/tui/screens/timesheet.py b/src/ttd/tui/screens/timesheet.py deleted file mode 100644 index c1038a9..0000000 --- a/src/ttd/tui/screens/timesheet.py +++ /dev/null @@ -1,244 +0,0 @@ -"""Timesheet: day/week/month spans, day-grouped entries, add/edit/delete.""" - -from datetime import date, datetime, timedelta -from typing import ClassVar, Literal, cast - -from textual.app import ComposeResult -from textual.binding import Binding -from textual.containers import Vertical -from textual.coordinate import Coordinate -from textual.widgets import DataTable, Label - -from ttd.cli._pickers import describe_timespec, split_project_choice, validate_timespec -from ttd.config.loader import get_settings -from ttd.core.errors import TtdError -from ttd.core.money import format_hours -from ttd.reporting import periods -from ttd.services import entries as entry_svc -from ttd.tui._data import hours_for_row, project_options, split_and_log -from ttd.tui.screens._base import PREV_NEXT_GROUP, TtdScreen -from ttd.tui.widgets.forms import FormField, FormModal -from ttd.tui.widgets.modals import ConfirmModal, QuickLogModal - -Span = Literal["day", "week", "month"] - -SPAN_GROUP = Binding.Group("span", compact=True) - - -def _entry_spec(entry) -> str: - """Reconstruct an unambiguous, round-trippable time spec for an entry.""" - if entry.started_at and entry.ended_at: - return f"{entry.work_date} {entry.started_at:%H:%M} to {entry.ended_at:%H:%M}" - h, rem = divmod(entry.seconds, 3600) - duration = f"{h}h{rem // 60}m" if h else f"{rem // 60}m" - return f"{entry.work_date} {duration}" - - -class TimesheetScreen(TtdScreen): - nav_id = "timesheet" - - BINDINGS: ClassVar = [ - *TtdScreen.BINDINGS, - Binding("d", "span('day')", "day", group=SPAN_GROUP), - Binding("w", "span('week')", "week", group=SPAN_GROUP), - Binding("m", "span('month')", "month", group=SPAN_GROUP), - Binding("left_square_bracket", "shift(-1)", "prev", group=PREV_NEXT_GROUP), - Binding("right_square_bracket", "shift(1)", "next", group=PREV_NEXT_GROUP), - ("g", "today", "today"), - ("a", "add_entry", "add"), - ("e", "edit_entry", "edit"), - ("x", "delete_entry", "delete"), - ] - - def __init__(self) -> None: - super().__init__() - self.span: Span = "day" - self.anchor_date: date = date.today() - - def compose_content(self) -> ComposeResult: - with Vertical(id="timesheet"): - yield Label("", id="day-title", classes="section-title") - yield DataTable(id="day-table", cursor_type="row") - yield Label("", id="day-total", classes="muted") - - def setup(self) -> None: - table = self.query_one("#day-table", DataTable) - table.add_columns("date", "project", "time", "hours", "note", "flags") - - def _period(self) -> periods.Period: - if self.span == "day": - return periods.day_period(self.anchor_date) - if self.span == "week": - return periods.week_period(self.anchor_date, get_settings().display.week_start) - return periods.month_period(self.anchor_date) - - async def render_data(self) -> None: - period = self._period() - rows = await entry_svc.list_entries(date_from=period.start, date_to=period.end) - table = self.query_one("#day-table", DataTable) - table.clear() - total = 0 - last_day = None - for r in rows: - total += r.entry.seconds - flags = [] - if not r.entry.billable: - flags.append("nb") - if r.entry.invoice_id is not None: - flags.append("inv") - day_label = r.entry.work_date.strftime("%a %b %-d") - table.add_row( - day_label if day_label != last_day else "", - f"{r.client.slug}/{r.project.slug}", - hours_for_row(r.entry), - format_hours(r.entry.seconds), - r.entry.note, - ",".join(flags), - key=str(r.entry.id), - ) - last_day = day_label - title = period.label - if self.span == "day": - days_ago = (date.today() - self.anchor_date).days - title += {0: " · today", 1: " · yesterday"}.get(days_ago, "") - self.query_one("#day-title", Label).update(f"{title} [dim]({self.span})[/dim]") - self.query_one("#day-total", Label).update( - f"{len(rows)} entr{'y' if len(rows) == 1 else 'ies'} · {format_hours(total)}" - " [dim]d/w/m span · \\[ ] prev/next · g today · a add · e edit · x delete[/dim]" - ) - - async def action_span(self, span: str) -> None: - if span in ("day", "week", "month"): - self.span = cast("Span", span) - await self.refresh_data() - - async def action_shift(self, delta: int) -> None: - if self.span == "day": - self.anchor_date += timedelta(days=delta) - elif self.span == "week": - self.anchor_date += timedelta(days=7 * delta) - else: - first = self.anchor_date.replace(day=1) - if delta > 0: - self.anchor_date = (first + timedelta(days=32)).replace(day=1) - else: - self.anchor_date = (first - timedelta(days=1)).replace(day=1) - await self.refresh_data() - - async def action_today(self) -> None: - self.anchor_date = date.today() - await self.refresh_data() - - def _selected_entry_id(self) -> str | None: - table = self.query_one("#day-table", DataTable) - if table.row_count == 0 or table.cursor_row is None: - return None - key = table.coordinate_to_cell_key(Coordinate(table.cursor_row, 0)).row_key.value - return str(key) if key is not None else None - - async def action_add_entry(self) -> None: - options = await project_options() - if not options: - self.notify("no projects yet", severity="warning") - return - prefix = "" - if self.span == "day" and self.anchor_date != date.today(): - prefix = f"{self.anchor_date.isoformat()} " - - async def _log(payload: dict | None) -> None: - if payload is None: - return - try: - await split_and_log(payload, now=datetime.now()) - except TtdError as exc: - self.notify(str(exc), severity="error") - await self.refresh_data() - - self.app.push_screen(QuickLogModal(options, initial_spec=prefix), _log) - - async def action_edit_entry(self) -> None: - uid = self._selected_entry_id() - if uid is None: - return - entry = await entry_svc.find_entry(uid) - if entry.invoice_id is not None: - self.notify("entry is on an invoice — void it first", severity="warning") - return - options = await project_options() - rows = await entry_svc.list_entries(date_from=entry.work_date, date_to=entry.work_date) - current = next((r for r in rows if r.entry.id == entry.id), None) - current_project = f"{current.client.slug}/{current.project.slug}" if current else None - - initial = { - "time": _entry_spec(entry), - "note": entry.note, - "tags": entry.tags, - "billable": entry.billable, - "project": current_project, - } - form = FormModal( - f"edit entry {uid[:8]}", - [ - FormField( - "time", - "Time", - kind="spec", - value=initial["time"], - validate=validate_timespec, - preview=describe_timespec, - required=True, - ), - FormField("note", "Note", value=entry.note), - FormField("tags", "Tags (comma-separated)", value=entry.tags), - FormField("billable", "Billable", kind="toggle", value=entry.billable), - FormField( - "project", "Project", kind="select", value=current_project, choices=options - ), - ], - ) - - async def _save(values: dict | None) -> None: - if values is None: - return - kwargs: dict = {} - if values["time"] != initial["time"]: - kwargs["spec"] = values["time"] - if values["note"] != initial["note"]: - kwargs["note"] = values["note"] - if values["tags"] != initial["tags"]: - kwargs["tags"] = values["tags"] - if values["billable"] != initial["billable"]: - kwargs["billable"] = values["billable"] - if values["project"] and values["project"] != initial["project"]: - project_slug, client_slug = split_project_choice(values["project"]) - kwargs["project_slug"] = project_slug - kwargs["client_slug"] = client_slug - if not kwargs: - return - try: - await entry_svc.edit_entry( - uid, now=datetime.now(), settings=get_settings(), **kwargs - ) - self.notify("entry updated") - except TtdError as exc: - self.notify(str(exc), severity="error") - await self.refresh_data() - - self.app.push_screen(form, _save) - - async def action_delete_entry(self) -> None: - uid = self._selected_entry_id() - if uid is None: - return - - async def _confirmed(yes: bool | None) -> None: - if not yes: - return - try: - await entry_svc.delete_entry(uid) - self.notify("entry deleted") - except TtdError as exc: - self.notify(str(exc), severity="error") - await self.refresh_data() - - self.app.push_screen(ConfirmModal(f"Delete entry {uid[:8]}?"), _confirmed) diff --git a/src/ttd/tui/ttd.tcss b/src/ttd/tui/ttd.tcss index e693c5c..83816cd 100644 --- a/src/ttd/tui/ttd.tcss +++ b/src/ttd/tui/ttd.tcss @@ -43,6 +43,11 @@ Screen { margin-bottom: 1; } +.section-title.active-section { + color: $accent; + text-style: bold; +} + .muted { color: $secondary; margin-top: 1; @@ -212,6 +217,11 @@ ModalScreen { background: $surface; } +.modal-box Checkbox { + background: $surface; + margin-top: 1; +} + .markdown-preview { height: 80%; } diff --git a/src/ttd/tui/widgets/theme_picker.py b/src/ttd/tui/widgets/theme_picker.py index 0da74e9..3b36bfc 100644 --- a/src/ttd/tui/widgets/theme_picker.py +++ b/src/ttd/tui/widgets/theme_picker.py @@ -82,7 +82,7 @@ def compose(self) -> ComposeResult: with Vertical(id="preview-rail"): yield Label("ttd", id="preview-brand") yield Label("1 dashboard", classes="preview-nav-active") - yield Label("2 timesheet", classes="preview-nav") + yield Label("2 log", classes="preview-nav") yield Label("3 clients", classes="preview-nav") with Vertical(id="preview-content"): yield Label("today", classes="preview-section") diff --git a/tests/test_cli/test_invoice_cli.py b/tests/test_cli/test_invoice_cli.py index d9fdd1c..b073f55 100644 --- a/tests/test_cli/test_invoice_cli.py +++ b/tests/test_cli/test_invoice_cli.py @@ -104,7 +104,7 @@ def test_invoiced_entries_locked_via_cli(isolated_config): def test_invoice_create_with_period_spec(isolated_config): _seed(isolated_config) result = runner.invoke( - app, ["invoice", "create", "--client", "acme", "--period", "this month", "--dry-run"] + app, ["invoice", "create", "--client", "acme", "--period", "last two weeks", "--dry-run"] ) assert result.exit_code == 0, result.output assert "Dry run" in result.output @@ -138,3 +138,63 @@ def test_invoice_show_markdown_format(isolated_config): assert result.exit_code == 0, result.output assert "# Invoice 2026-001" in result.output assert "API" in result.output + + +# --------------------------------------------------------------------------- +# Finding 3 — _print_refresh_diff shows Expenses line when expense delta changes +# --------------------------------------------------------------------------- + + +def test_print_refresh_diff_shows_expenses_line_when_expense_subtotal_changes(isolated_config): + """Refresh diff must print an Expenses line when before != after expense subtotal.""" + import contextlib + import io + from datetime import date + from decimal import Decimal + from uuid import uuid4 + + from ttd.cli.invoices import _print_refresh_diff + from ttd.services.invoicing import RefreshPreview + from ttd.storage.models.enums import InvoiceStatus + from ttd.storage.models.invoice import Invoice + + fake_invoice = Invoice( + id=uuid4(), + number="2026-001", + client_id=uuid4(), + period_start=date(2026, 6, 1), + period_end=date(2026, 6, 30), + issued_date=date(2026, 6, 30), + currency="USD", + subtotal=Decimal("300"), + tax=Decimal("0"), + expenses_subtotal=Decimal("100"), + total=Decimal("400"), + status=InvoiceStatus.DRAFT, + created_at=__import__("datetime").datetime(2026, 6, 30), + ) + preview = RefreshPreview( + invoice=fake_invoice, + client=None, # type: ignore[arg-type] + lines=[], + before_subtotal=Decimal("300"), + after_subtotal=Decimal("300"), + before_tax=Decimal("0"), + after_tax=Decimal("0"), + before_total=Decimal("400"), + after_total=Decimal("350"), + before_expenses_subtotal=Decimal("100"), + after_expenses_subtotal=Decimal("50"), + after_expense_lines=[], + totals_changed=True, + billing_fields_changed=False, + has_changes=True, + can_apply=True, + blocked_reason=None, + ) + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + _print_refresh_diff(preview) + output = buf.getvalue() + assert "Expenses" in output, f"Expected 'Expenses' in output:\n{output}" diff --git a/tests/test_interchange/test_expense_backup.py b/tests/test_interchange/test_expense_backup.py new file mode 100644 index 0000000..7aa3395 --- /dev/null +++ b/tests/test_interchange/test_expense_backup.py @@ -0,0 +1,443 @@ +import json +import uuid +from datetime import date +from decimal import Decimal + +import pytest + +from ttd.core.errors import TtdError +from ttd.interchange import json_io +from ttd.interchange.importer import restore_expenses +from ttd.services import clients as client_svc +from ttd.services import expenses as expense_svc +from ttd.services import projects as project_svc +from ttd.services.interchange_svc import export_records +from ttd.storage.models import Expense, ExpenseReceipt + + +async def test_json_backup_roundtrips_expenses_and_receipts(db, tmp_path): + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + exp = await expense_svc.add_expense( + "api-rewrite", "Claude Code", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + src = tmp_path / "r.pdf" + src.write_bytes(b"%PDF-1.4\n\xff") + await expense_svc.add_receipt(str(exp.id)[:8], src) + + # Export -> json + records, meta = await export_records() + assert len(meta["expenses"]) == 1 + assert len(meta["receipts"]) == 1 + backup = tmp_path / "backup.json" + json_io.write_json(records, backup, meta) + + # Wipe, then restore from the file's metadata. + for e in await Expense.all(): + await e.delete() + for r in await ExpenseReceipt.all(): + await r.delete() + restored_meta = json_io.read_metadata(backup) + written = await restore_expenses(restored_meta, on_conflict="update", create_missing=True) + + assert written == 1 + restored = await Expense.all() + assert len(restored) == 1 and restored[0].amount == Decimal("100") + assert restored[0].invoice_id is None # imports never re-link invoices + assert len(await ExpenseReceipt.all()) == 1 + + +async def test_v1_metadata_without_expenses_restores_nothing(db, tmp_path): + payload = {"ttd_export": 1, "clients": [], "projects": [], "entries": []} + p = tmp_path / "v1.json" + p.write_text(json.dumps(payload)) + written = await restore_expenses(json_io.read_metadata(p), create_missing=True) + assert written == 0 + assert await Expense.all() == [] + + +# --------------------------------------------------------------------------- +# on_conflict="skip" — existing expense is left unchanged +# --------------------------------------------------------------------------- + + +async def test_restore_expenses_skip_leaves_existing_unchanged(db): + await client_svc.create_client("Beta Corp") + await project_svc.create_project("Beta Project", "beta-corp") + exp = await expense_svc.add_expense( + "beta-project", "Original Desc", Decimal("50"), incurred_date=date(2026, 1, 10) + ) + original_id = str(exp.id) + + metadata = { + "expenses": [ + { + "id": original_id, + "client": "beta-corp", + "project": "beta-project", + "incurred_date": "2026-01-10", + "description": "Updated Desc", + "amount": "99.00", + "note": "", + } + ], + "receipts": [], + } + written = await restore_expenses(metadata, on_conflict="skip") + + assert written == 0 + unchanged = await Expense.all() + assert len(unchanged) == 1 + assert unchanged[0].description == "Original Desc" + assert unchanged[0].amount == Decimal("50") + + +# --------------------------------------------------------------------------- +# on_conflict="update" — existing expense gets new field values +# --------------------------------------------------------------------------- + + +async def test_restore_expenses_update_overwrites_existing(db): + await client_svc.create_client("Gamma LLC") + await project_svc.create_project("Gamma Project", "gamma-llc") + exp = await expense_svc.add_expense( + "gamma-project", "Old Desc", Decimal("25"), incurred_date=date(2026, 2, 1) + ) + eid = str(exp.id) + + metadata = { + "expenses": [ + { + "id": eid, + "client": "gamma-llc", + "project": "gamma-project", + "incurred_date": "2026-02-15", + "description": "New Desc", + "amount": "75.00", + "note": "updated", + } + ], + "receipts": [], + } + written = await restore_expenses(metadata, on_conflict="update") + + assert written == 1 + updated = await Expense.all() + assert len(updated) == 1 + assert updated[0].description == "New Desc" + assert updated[0].amount == Decimal("75") + assert updated[0].note == "updated" + assert updated[0].incurred_date == date(2026, 2, 15) + + +# --------------------------------------------------------------------------- +# invoiced-expense guard — expense with invoice_id is never overwritten +# --------------------------------------------------------------------------- + + +async def test_restore_expenses_never_overwrites_invoiced_expense(db): + await client_svc.create_client("Delta Inc") + await project_svc.create_project("Delta Project", "delta-inc") + exp = await expense_svc.add_expense( + "delta-project", "Invoiced Expense", Decimal("200"), incurred_date=date(2026, 3, 1) + ) + # Simulate the expense being locked to an invoice. + fake_invoice_id = uuid.uuid4() + exp.invoice_id = fake_invoice_id + await exp.save() + + metadata = { + "expenses": [ + { + "id": str(exp.id), + "client": "delta-inc", + "project": "delta-project", + "incurred_date": "2026-03-01", + "description": "Should Not Change", + "amount": "999.00", + "note": "", + } + ], + "receipts": [], + } + written = await restore_expenses(metadata, on_conflict="update") + + assert written == 0 + locked = await Expense.all() + assert len(locked) == 1 + assert locked[0].description == "Invoiced Expense" + assert locked[0].amount == Decimal("200") + assert locked[0].invoice_id == fake_invoice_id + + +# --------------------------------------------------------------------------- +# receipt for unknown expense id is skipped +# --------------------------------------------------------------------------- + + +async def test_restore_expenses_skips_receipt_for_unknown_expense(db): + await client_svc.create_client("Epsilon Co") + await project_svc.create_project("Eps Project", "epsilon-co") + exp = await expense_svc.add_expense( + "eps-project", "Known Expense", Decimal("10"), incurred_date=date(2026, 4, 1) + ) + import base64 + + dummy_b64 = base64.b64encode(b"receipt-data").decode() + unknown_id = str(uuid.uuid4()) + + metadata = { + "expenses": [ + { + "id": str(exp.id), + "client": "epsilon-co", + "project": "eps-project", + "incurred_date": "2026-04-01", + "description": "Known Expense", + "amount": "10.00", + "note": "", + } + ], + "receipts": [ + # This receipt references an expense id NOT in the expenses list -> skipped. + { + "expense_id": unknown_id, + "filename": "ghost.pdf", + "content_type": "application/pdf", + "data_b64": dummy_b64, + } + ], + } + written = await restore_expenses(metadata, on_conflict="skip") + + # The expense was already present → skip; no receipt added for the ghost id. + assert written == 0 + assert await ExpenseReceipt.all() == [] + + +# --------------------------------------------------------------------------- +# receipt replace path — existing receipt is deleted and replaced +# --------------------------------------------------------------------------- + + +async def test_restore_expenses_replaces_existing_receipt(db, tmp_path): + await client_svc.create_client("Zeta Ltd") + await project_svc.create_project("Zeta Project", "zeta-ltd") + exp = await expense_svc.add_expense( + "zeta-project", "Receipt Expense", Decimal("30"), incurred_date=date(2026, 5, 1) + ) + # Attach an initial receipt. + src = tmp_path / "old.pdf" + src.write_bytes(b"%PDF old") + await expense_svc.add_receipt(str(exp.id)[:8], src) + assert len(await ExpenseReceipt.all()) == 1 + + # Delete the expense so restore_expenses re-inserts it (new path), then also + # provides a new receipt for the same expense id. + import base64 + + new_b64 = base64.b64encode(b"%PDF new").decode() + + # Delete expense so it gets re-inserted (exercises the "new" branch + receipt replace). + for e in await Expense.all(): + await e.delete() + for r in await ExpenseReceipt.all(): + await r.delete() + + metadata = { + "expenses": [ + { + "id": str(exp.id), + "client": "zeta-ltd", + "project": "zeta-project", + "incurred_date": "2026-05-01", + "description": "Receipt Expense", + "amount": "30.00", + "note": "", + } + ], + "receipts": [ + { + "expense_id": str(exp.id), + "filename": "new.pdf", + "content_type": "application/pdf", + "data_b64": new_b64, + } + ], + } + written = await restore_expenses(metadata, on_conflict="update") + + assert written == 1 + receipts = await ExpenseReceipt.all() + assert len(receipts) == 1 + assert receipts[0].filename == "new.pdf" + + +# --------------------------------------------------------------------------- +# export_records with invoiced filter (interchange_svc.py line 28) +# --------------------------------------------------------------------------- + + +async def test_export_records_invoiced_filter_excludes_non_invoiced(db): + # With no entries and invoiced=True, the filter branch is exercised. + records, meta = await export_records(invoiced=True) + assert records == [] + # expenses and receipts lists are present but empty. + assert meta["expenses"] == [] + assert meta["receipts"] == [] + + +async def test_export_records_no_expenses_produces_empty_lists(db): + # Confirms the meta structure when there are no expenses at all. + _records, meta = await export_records() + assert "expenses" in meta + assert "receipts" in meta + assert meta["expenses"] == [] + assert meta["receipts"] == [] + + +# --------------------------------------------------------------------------- +# json_io edge cases +# --------------------------------------------------------------------------- + + +def test_read_json_invalid_json_raises(tmp_path): + bad = tmp_path / "bad.json" + bad.write_text("not json {{{") + with pytest.raises(TtdError, match="not valid JSON"): + json_io.read_json(bad) + + +def test_read_json_bare_array(tmp_path): + p = tmp_path / "bare.json" + rows = [{"date": "2026-01-01", "note": "test"}] + p.write_text(json.dumps(rows)) + result = json_io.read_json(p) + assert result == rows + + +def test_read_json_missing_entries_key_raises(tmp_path): + p = tmp_path / "bad_dict.json" + p.write_text(json.dumps({"foo": "bar"})) + with pytest.raises(TtdError, match="no 'entries' key"): + json_io.read_json(p) + + +def test_read_metadata_oserror_returns_empty(tmp_path): + missing = tmp_path / "nonexistent.json" + result = json_io.read_metadata(missing) + assert result == {} + + +def test_read_metadata_bare_list_returns_empty(tmp_path): + p = tmp_path / "list.json" + p.write_text(json.dumps([1, 2, 3])) + result = json_io.read_metadata(p) + assert result == {} + + +# --------------------------------------------------------------------------- +# on_conflict="skip" — receipt for skipped expense is NOT replaced +# --------------------------------------------------------------------------- + + +async def test_restore_expenses_skip_leaves_receipt_intact(db, tmp_path): + import base64 + + await client_svc.create_client("Kappa Corp") + await project_svc.create_project("Kappa Project", "kappa-corp") + exp = await expense_svc.add_expense( + "kappa-project", "Kappa Expense", Decimal("40"), incurred_date=date(2026, 6, 1) + ) + expense_id = str(exp.id) + + # Attach RECEIPT_A via a temp file. + receipt_a = tmp_path / "receipt_a.pdf" + receipt_a.write_bytes(b"AAA") + await expense_svc.add_receipt(expense_id[:8], receipt_a) + + # Build metadata via export_records, then mutate the receipt entry to RECEIPT_B. + _records, meta = await export_records() + assert len(meta["receipts"]) == 1 + meta["receipts"][0]["filename"] = "receipt_b.pdf" + meta["receipts"][0]["data_b64"] = base64.b64encode(b"BBB").decode() + + # Restore with skip — expense already exists so it will be skipped. + written = await restore_expenses(meta, on_conflict="skip", create_missing=False) + + assert written == 0 + # The receipt should still be RECEIPT_A. + result = await expense_svc.get_receipt(expense_id[:8]) + assert result is not None + filename, _content_type, data = result + assert filename == "receipt_a.pdf" + assert data == b"AAA" + + +# --------------------------------------------------------------------------- +# Finding 1 — export_records(invoiced=...) filters expenses too +# --------------------------------------------------------------------------- + + +async def test_export_invoiced_filter_applies_to_expenses(db, settings): + """export_records(invoiced=True/False/None) must filter expenses as well as entries.""" + import uuid as _uuid + + await client_svc.create_client("Filter Corp", hourly_rate=Decimal("100")) + await project_svc.create_project("Filter Project", "filter-corp") + + from ttd.storage.models import Expense + + # Create two expenses: one uninvoiced, one with a fake invoice_id + await expense_svc.add_expense( + "filter-project", "Not invoiced", Decimal("50"), incurred_date=date(2026, 6, 1) + ) + exp_inv = await expense_svc.add_expense( + "filter-project", "Invoiced", Decimal("75"), incurred_date=date(2026, 6, 2) + ) + # Directly mark exp_inv as invoiced (simulates it being on a draft invoice) + fake_invoice_id = _uuid.uuid4() + exp_inv_row = await Expense.get_or_none(exp_inv.id) + assert exp_inv_row is not None + exp_inv_row.invoice_id = fake_invoice_id + await exp_inv_row.save() + + # invoiced=True → only invoiced expense + _records, meta = await export_records(invoiced=True) + assert len(meta["expenses"]) == 1 + assert meta["expenses"][0]["description"] == "Invoiced" + + # invoiced=False → only free expense + _records, meta = await export_records(invoiced=False) + assert len(meta["expenses"]) == 1 + assert meta["expenses"][0]["description"] == "Not invoiced" + + # invoiced=None → both + _records, meta = await export_records(invoiced=None) + assert len(meta["expenses"]) == 2 + + +# --------------------------------------------------------------------------- +# Finding 2 — expenses-only client appears in clients_meta +# --------------------------------------------------------------------------- + + +async def test_export_includes_expense_only_client_in_meta(db, settings): + """A client with expenses but no entries must appear in meta['clients'].""" + await client_svc.create_client("Expense Only", hourly_rate=Decimal("200"), currency="EUR") + await project_svc.create_project("Expense Project", "expense-only") + await expense_svc.add_expense( + "expense-project", "SaaS Tool", Decimal("99"), incurred_date=date(2026, 6, 10) + ) + + _records, meta = await export_records() + + client_slugs = [c["slug"] for c in meta["clients"]] + assert "expense-only" in client_slugs + + client_entry = next(c for c in meta["clients"] if c["slug"] == "expense-only") + assert client_entry["name"] == "Expense Only" + assert client_entry["currency"] == "EUR" + + project_keys = [(p["client"], p["slug"]) for p in meta["projects"]] + assert ("expense-only", "expense-project") in project_keys diff --git a/tests/test_invoicing/test_derived_period.py b/tests/test_invoicing/test_derived_period.py new file mode 100644 index 0000000..78a50c2 --- /dev/null +++ b/tests/test_invoicing/test_derived_period.py @@ -0,0 +1,81 @@ +from datetime import date, datetime +from decimal import Decimal + +from ttd.config.schema import Settings +from ttd.reporting import periods +from ttd.services import clients as client_svc +from ttd.services import expenses as expense_svc +from ttd.services import invoicing as svc +from ttd.services import projects as project_svc +from ttd.storage.models import Expense + + +async def _setup(db): + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + + +def _june() -> periods.Period: + return periods.range_period(date(2026, 6, 1), date(2026, 6, 30)) + + +async def test_invoice_period_tightens_to_billed_entries(db): + await _setup(db) + from ttd.services import entries as entry_svc + + await entry_svc.log_entry("2026-06-16 9am-11am", "api-rewrite", now=datetime(2026, 6, 16, 12)) + await entry_svc.log_entry("2026-06-20 9am-10am", "api-rewrite", now=datetime(2026, 6, 20, 12)) + settings = Settings() + draft = await svc.build_draft("acme-corp", _june(), settings) + invoice = await svc.persist_draft(draft, settings) + assert invoice.period_start == date(2026, 6, 16) # not June 1 + assert invoice.period_end == date(2026, 6, 20) # not June 30 + + +async def test_invoice_period_from_expenses_only(db): + await _setup(db) + await expense_svc.add_expense( + "api-rewrite", "Claude", Decimal("100"), incurred_date=date(2026, 6, 18) + ) + settings = Settings() + draft = await svc.build_draft("acme-corp", _june(), settings) + invoice = await svc.persist_draft(draft, settings) + assert invoice.period_start == date(2026, 6, 18) + assert invoice.period_end == date(2026, 6, 18) + + +async def test_invoice_period_spans_time_and_expenses(db): + await _setup(db) + from ttd.services import entries as entry_svc + + await entry_svc.log_entry("2026-06-16 9am-11am", "api-rewrite", now=datetime(2026, 6, 16, 12)) + await expense_svc.add_expense( + "api-rewrite", "Claude", Decimal("100"), incurred_date=date(2026, 6, 25) + ) + settings = Settings() + draft = await svc.build_draft("acme-corp", _june(), settings) + invoice = await svc.persist_draft(draft, settings) + assert invoice.period_start == date(2026, 6, 16) + assert invoice.period_end == date(2026, 6, 25) + + +async def test_refresh_reduces_period_when_item_removed(db): + await _setup(db) + from ttd.services import entries as entry_svc + + await entry_svc.log_entry("2026-06-16 9am-11am", "api-rewrite", now=datetime(2026, 6, 16, 12)) + exp = await expense_svc.add_expense( + "api-rewrite", "Claude", Decimal("100"), incurred_date=date(2026, 6, 25) + ) + settings = Settings() + draft = await svc.build_draft("acme-corp", _june(), settings) + invoice = await svc.persist_draft(draft, settings) + assert invoice.period_end == date(2026, 6, 25) + # release + delete the later expense, then refresh + locked = await Expense.get_or_none(exp.id) + locked.invoice_id = None + await locked.save() + await locked.delete() + preview = await svc.preview_refresh(invoice.number, settings) + refreshed = await svc.apply_refresh(invoice.number, preview, settings) + assert refreshed.period_end == date(2026, 6, 16) # period tightened back to the entry diff --git a/tests/test_invoicing/test_expense_render.py b/tests/test_invoicing/test_expense_render.py new file mode 100644 index 0000000..92ca72e --- /dev/null +++ b/tests/test_invoicing/test_expense_render.py @@ -0,0 +1,115 @@ +from datetime import date +from decimal import Decimal + +import pytest +from fpdf import FPDF +from pypdf import PdfReader + +from ttd.cli.invoices import _resolve_formats +from ttd.config.schema import Settings +from ttd.core.errors import TtdError +from ttd.invoicing.markdown import render_markdown +from ttd.invoicing.pdf import render_pdf +from ttd.reporting import periods +from ttd.services import clients as client_svc +from ttd.services import expenses as expense_svc +from ttd.services import invoicing as svc +from ttd.services import projects as project_svc + + +async def _invoice_with_expense(db): + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + await expense_svc.add_expense( + "api-rewrite", "Claude Code", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + period = periods.range_period(date(2026, 6, 1), date(2026, 6, 30)) + settings = Settings() + invoice = await svc.persist_draft( + await svc.build_draft("acme-corp", period, settings), settings + ) + return await svc.get_invoice(invoice.number), settings + + +async def test_markdown_shows_expense_section(db): + view, settings = await _invoice_with_expense(db) + md = render_markdown(view, settings) + assert "Reimbursable expenses" in md + assert "Claude Code" in md + assert "Expenses" in md # totals line + + +async def test_pdf_renders_with_expenses(db, tmp_path): + view, settings = await _invoice_with_expense(db) + out = render_pdf(view, settings, tmp_path / "inv.pdf") + assert out.exists() and out.stat().st_size > 0 + + +async def test_no_expense_invoice_omits_section(db, tmp_path): + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + from datetime import datetime + + from ttd.services import entries as entry_svc + + await entry_svc.log_entry( + "2026-06-10 9am-11am", "api-rewrite", now=datetime(2026, 6, 10, 12, 0) + ) + period = periods.range_period(date(2026, 6, 1), date(2026, 6, 30)) + settings = Settings() + invoice = await svc.persist_draft( + await svc.build_draft("acme-corp", period, settings), settings + ) + view = await svc.get_invoice(invoice.number) + md = render_markdown(view, settings) + assert "Reimbursable expenses" not in md + assert "Expenses (reimbursable)" not in md + + +async def test_pdf_appends_pdf_receipt_pages(db, tmp_path): + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + exp = await expense_svc.add_expense( + "api-rewrite", "Claude", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + # a real 1-page PDF as the receipt + receipt_pdf = tmp_path / "receipt.pdf" + r = FPDF() + r.add_page() + r.set_font("helvetica", size=12) + r.cell(0, 10, "RECEIPT") + r.output(str(receipt_pdf)) + await expense_svc.add_receipt(str(exp.id)[:8], receipt_pdf) + + period = periods.range_period(date(2026, 6, 1), date(2026, 6, 30)) + settings = Settings() + invoice = await svc.persist_draft( + await svc.build_draft("acme-corp", period, settings), settings + ) + view = await svc.get_invoice(invoice.number) + + decoded = [await expense_svc.get_receipt(str(exp.id)[:8])] + with_r = render_pdf(view, settings, tmp_path / "yes.pdf", receipts=decoded) + without = render_pdf(view, settings, tmp_path / "no.pdf", receipts=None) + assert len(PdfReader(str(with_r)).pages) > len(PdfReader(str(without)).pages) + + +async def test_invoice_has_receipts(db, tmp_path): + view, _settings = await _invoice_with_expense(db) # expense, no receipt + assert await svc.invoice_has_receipts(view) is False + + +def test_resolve_formats_defaults_to_pdf(): + assert _resolve_formats(pdf=False, md=False, receipts=False, has_receipts=False) == ( + True, + False, + ) + + +def test_resolve_formats_md_blocked_when_receipts_present(): + with pytest.raises(TtdError): + _resolve_formats(pdf=False, md=True, receipts=True, has_receipts=True) + + +def test_resolve_formats_md_ok_when_no_receipts_on_invoice(): + assert _resolve_formats(pdf=True, md=True, receipts=True, has_receipts=False) == (True, True) diff --git a/tests/test_invoicing/test_invoicing_expenses.py b/tests/test_invoicing/test_invoicing_expenses.py new file mode 100644 index 0000000..8b173be --- /dev/null +++ b/tests/test_invoicing/test_invoicing_expenses.py @@ -0,0 +1,89 @@ +from datetime import date +from decimal import Decimal + +from ttd.config.schema import Settings +from ttd.reporting import periods +from ttd.services import clients as client_svc +from ttd.services import expenses as expense_svc +from ttd.services import invoicing as svc +from ttd.services import projects as project_svc +from ttd.storage.models import Expense + + +async def _client_project(db): + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + + +def _june() -> periods.Period: + return periods.range_period(date(2026, 6, 1), date(2026, 6, 30)) + + +async def test_draft_includes_unbilled_expenses_untaxed(db): + await _client_project(db) + await expense_svc.add_expense( + "api-rewrite", "Claude Code", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + settings = Settings() # tax_rate defaults to 0 + draft = await svc.build_draft("acme-corp", _june(), settings) + assert draft.expenses_subtotal == Decimal("100") + assert draft.subtotal == Decimal("0") # no time entries + assert draft.total == Decimal("100") + + +async def test_persist_locks_expenses_and_stores_subtotal(db): + await _client_project(db) + exp = await expense_svc.add_expense( + "api-rewrite", "Claude Code", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + settings = Settings() + draft = await svc.build_draft("acme-corp", _june(), settings) + invoice = await svc.persist_draft(draft, settings) + + refetched = await Expense.get_or_none(exp.id) + assert refetched.invoice_id == invoice.id # locked + assert invoice.expenses_subtotal == Decimal("100") + view = await svc.get_invoice(invoice.number) + assert len(view.expense_lines) == 1 + assert view.expense_lines[0].amount == Decimal("100") + + +async def test_void_releases_expenses(db): + await _client_project(db) + exp = await expense_svc.add_expense( + "api-rewrite", "Claude", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + settings = Settings() + invoice = await svc.persist_draft( + await svc.build_draft("acme-corp", _june(), settings), settings + ) + await svc.mark_invoice(invoice.number, "void") + assert (await Expense.get_or_none(exp.id)).invoice_id is None + + +async def test_refresh_drops_deleted_expense(db): + await _client_project(db) + exp = await expense_svc.add_expense( + "api-rewrite", "Claude", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + settings = Settings() + invoice = await svc.persist_draft( + await svc.build_draft("acme-corp", _june(), settings), settings + ) + # Release + delete the expense, then refresh. + await svc.mark_invoice(invoice.number, "void") + # Re-invoice fresh so the expense is linked again, then delete underlying expense + # (simulating an expense removed from the period): + invoice2 = await svc.persist_draft( + await svc.build_draft("acme-corp", _june(), settings), settings + ) + locked = await Expense.get_or_none(exp.id) + locked.invoice_id = None + await locked.save() + await locked.delete() + preview = await svc.preview_refresh(invoice2.number, settings) + assert preview.totals_changed is True + assert preview.before_expenses_subtotal == Decimal("100") + fresh = await svc.apply_refresh(invoice2.number, preview, settings) + assert fresh.expenses_subtotal == Decimal("0") + assert fresh.total == fresh.subtotal + fresh.tax diff --git a/tests/test_invoicing/test_render_helper.py b/tests/test_invoicing/test_render_helper.py new file mode 100644 index 0000000..88edf20 --- /dev/null +++ b/tests/test_invoicing/test_render_helper.py @@ -0,0 +1,40 @@ +from datetime import date +from decimal import Decimal + +from ttd.config.schema import Settings +from ttd.reporting import periods +from ttd.services import clients as client_svc +from ttd.services import expenses as expense_svc +from ttd.services import invoicing as svc +from ttd.services import projects as project_svc + + +async def _invoice_with_receipt(db, tmp_path): + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + exp = await expense_svc.add_expense( + "api-rewrite", "Claude", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + rp = tmp_path / "r.pdf" + rp.write_bytes(b"%PDF-1.4\n\xff\xd8 binary") + await expense_svc.add_receipt(str(exp.id)[:8], rp) + period = periods.range_period(date(2026, 6, 1), date(2026, 6, 30)) + settings = Settings() + invoice = await svc.persist_draft( + await svc.build_draft("acme-corp", period, settings), settings + ) + return await svc.get_invoice(invoice.number) + + +async def test_load_invoice_receipts_returns_decoded(db, tmp_path): + view = await _invoice_with_receipt(db, tmp_path) + receipts = await expense_svc.load_invoice_receipts(view.expense_lines) + assert len(receipts) == 1 + filename, content_type, data = receipts[0] + assert filename == "r.pdf" + assert content_type == "application/pdf" + assert data == b"%PDF-1.4\n\xff\xd8 binary" + + +async def test_load_invoice_receipts_empty_when_none(db): + assert await expense_svc.load_invoice_receipts([]) == [] diff --git a/tests/test_reporting/__init__.py b/tests/test_reporting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_reporting/test_periods.py b/tests/test_reporting/test_periods.py new file mode 100644 index 0000000..930d8bf --- /dev/null +++ b/tests/test_reporting/test_periods.py @@ -0,0 +1,83 @@ +# tests/test_reporting/test_periods.py +from datetime import date + +import pytest + +from ttd.core.errors import TtdError +from ttd.reporting import periods + + +def test_this_week_and_last_week(): + today = date(2026, 6, 18) # a Thursday + tw = periods.parse_period("this week", today) + assert tw.start == date(2026, 6, 15) and tw.end == date(2026, 6, 21) # Mon–Sun + lw = periods.parse_period("last week", today) + assert lw.start == date(2026, 6, 8) and lw.end == date(2026, 6, 14) + + +def test_rolling_last_n_ending_today(): + today = date(2026, 6, 18) + ltw = periods.parse_period("last two weeks", today) + assert ltw.start == date(2026, 6, 5) + assert ltw.end == today + assert periods.parse_period("last 10 days", today).start == date(2026, 6, 9) + assert periods.parse_period("last 1 week", today).start == date(2026, 6, 12) + assert periods.parse_period("last 3 months", today).start == date(2026, 3, 18) + assert periods.parse_period("last 3 months", today).end == today + + +def test_rolling_month_clamps_day(): + # today Mar 31 minus 1 month clamps to Feb 28 (2026 not a leap year) + assert periods.parse_period("last 1 month", date(2026, 3, 31)).start == date(2026, 2, 28) + + +def test_week_start_sunday(): + today = date(2026, 6, 18) + tw = periods.parse_period("this week", today, week_start="sunday") + assert tw.start == date(2026, 6, 14) # Sunday + + +def test_month_name_range_closest_year(): + # today mid-2026 + today = date(2026, 7, 1) + p = periods.parse_period("june 16 to june 30", today) + assert p.start == date(2026, 6, 16) and p.end == date(2026, 6, 30) + + +def test_closest_year_examples(): + # Jan 1 2026, "dec 15 - dec 31" -> Dec 2025 (last year is closest) + p = periods.parse_period("dec 15 - dec 31", date(2026, 1, 1)) + assert p.start == date(2025, 12, 15) and p.end == date(2025, 12, 31) + # June 30 2026, "june 16 - june 30" -> this year (today inside) + p = periods.parse_period("june 16 - june 30", date(2026, 6, 30)) + assert p.start == date(2026, 6, 16) + # June 1 2026, "june 16 - june 30" -> this year (near future beats a year ago) + p = periods.parse_period("june 16 - june 30", date(2026, 6, 1)) + assert p.start == date(2026, 6, 16) + + +def test_month_shorthands(): + today = date(2026, 7, 1) + whole = periods.parse_period("june", today) + assert whole.start == date(2026, 6, 1) and whole.end == date(2026, 6, 30) + inherit = periods.parse_period("june 16 - 30", today) + assert inherit.start == date(2026, 6, 16) and inherit.end == date(2026, 6, 30) + abbrev = periods.parse_period("jun 16 to jun 30", today) + assert abbrev.start == date(2026, 6, 16) and abbrev.end == date(2026, 6, 30) + + +def test_cross_year_wrap(): + # "dec 28 to jan 3" — end month wraps into the next year + p = periods.parse_period("dec 28 to jan 3", date(2026, 1, 15)) + # closest-year for start Dec: Dec 2025 (ended ~2 weeks ago) beats Dec 2026 + assert p.start == date(2025, 12, 28) and p.end == date(2026, 1, 3) + + +def test_explicit_year_honored(): + p = periods.parse_period("june 16 to june 30 2024", date(2026, 7, 1)) + assert p.start == date(2024, 6, 16) and p.end == date(2024, 6, 30) + + +def test_bad_month_name_errors(): + with pytest.raises(TtdError): + periods.parse_period("smarch 3 to smarch 9", date(2026, 7, 1)) diff --git a/tests/test_storage/test_expenses.py b/tests/test_storage/test_expenses.py new file mode 100644 index 0000000..5e4c257 --- /dev/null +++ b/tests/test_storage/test_expenses.py @@ -0,0 +1,161 @@ +from datetime import date, datetime +from decimal import Decimal +from uuid import uuid4 + +import pytest + +from ttd.core.errors import InvoicedExpenseError, TtdError +from ttd.services import clients as client_svc +from ttd.services import expenses as expense_svc +from ttd.services import projects as project_svc +from ttd.storage.models import Expense, ExpenseReceipt, pk + + +async def _project(db): + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + return await project_svc.create_project("API Rewrite", "acme-corp") + + +async def test_expense_roundtrips(db): + project = await _project(db) + now = datetime.now() + exp = Expense( + id=uuid4(), + project_id=pk(project), + incurred_date=date(2026, 6, 15), + description="Claude Code", + amount=Decimal("100.00"), + created_at=now, + updated_at=now, + ) + await exp.save() + + fetched = (await Expense.all())[0] + assert fetched.description == "Claude Code" + assert fetched.amount == Decimal("100.00") + assert fetched.incurred_date == date(2026, 6, 15) + assert fetched.invoice_id is None + + +async def test_receipt_roundtrips_as_base64(db): + project = await _project(db) + now = datetime.now() + exp = Expense( + id=uuid4(), + project_id=pk(project), + incurred_date=date(2026, 6, 15), + description="x", + amount=Decimal("1"), + created_at=now, + updated_at=now, + ) + await exp.save() + receipt = ExpenseReceipt( + id=uuid4(), + expense_id=pk(exp), + filename="r.pdf", + content_type="application/pdf", + data_b64="JVBERi0xLjQ=", + ) + await receipt.save() + assert (await ExpenseReceipt.all())[0].data_b64 == "JVBERi0xLjQ=" + + +async def test_add_and_list_expense(db): + await _project(db) + exp = await expense_svc.add_expense("api-rewrite", "Claude Code", Decimal("100")) + assert exp.amount == Decimal("100") + views = await expense_svc.list_expenses() + assert len(views) == 1 + assert views[0].client.slug == "acme-corp" + assert views[0].has_receipt is False + + +async def test_edit_and_delete_expense(db): + await _project(db) + exp = await expense_svc.add_expense("api-rewrite", "Claude", Decimal("100")) + await expense_svc.edit_expense(str(exp.id)[:8], amount=Decimal("120")) + assert (await expense_svc.list_expenses())[0].expense.amount == Decimal("120") + await expense_svc.delete_expense(str(exp.id)[:8]) + assert await expense_svc.list_expenses() == [] + + +async def test_locked_expense_refuses_edit_and_delete(db): + await _project(db) + exp = await expense_svc.add_expense("api-rewrite", "Claude", Decimal("100")) + exp.invoice_id = uuid4() + await exp.save() + with pytest.raises(InvoicedExpenseError): + await expense_svc.edit_expense(str(exp.id)[:8], amount=Decimal("1")) + with pytest.raises(InvoicedExpenseError): + await expense_svc.delete_expense(str(exp.id)[:8]) + + +async def test_recent_expenses_returns_distinct_pairs(db): + await _project(db) + await expense_svc.add_expense("api-rewrite", "Claude Code", Decimal("100")) + await expense_svc.add_expense("api-rewrite", "Claude Code", Decimal("100")) + await expense_svc.add_expense("api-rewrite", "Figma", Decimal("15")) + suggestions = await expense_svc.recent_expenses(project_slug="api-rewrite") + pairs = [(s.description, s.amount) for s in suggestions] + assert pairs == [("Figma", Decimal("15")), ("Claude Code", Decimal("100"))] + + +async def test_receipt_add_get_roundtrip(db, tmp_path): + await _project(db) + exp = await expense_svc.add_expense("api-rewrite", "Claude", Decimal("100")) + src = tmp_path / "receipt.pdf" + payload = b"%PDF-1.4\n\xff\xd8 binary" + src.write_bytes(payload) + + await expense_svc.add_receipt(str(exp.id)[:8], src) + filename, content_type, data = await expense_svc.get_receipt(str(exp.id)[:8]) + assert filename == "receipt.pdf" + assert content_type == "application/pdf" + assert data == payload + assert (await expense_svc.list_expenses())[0].has_receipt is True + + +async def test_receipt_remove(db, tmp_path): + await _project(db) + exp = await expense_svc.add_expense("api-rewrite", "Claude", Decimal("100")) + src = tmp_path / "r.png" + src.write_bytes(b"\x89PNG\r\n") + await expense_svc.add_receipt(str(exp.id)[:8], src) + await expense_svc.remove_receipt(str(exp.id)[:8]) + assert await expense_svc.get_receipt(str(exp.id)[:8]) is None + + +async def test_oversized_receipt_rejected(db, tmp_path): + await _project(db) + exp = await expense_svc.add_expense("api-rewrite", "Claude", Decimal("100")) + big = tmp_path / "big.pdf" + big.write_bytes(b"0" * (expense_svc.MAX_RECEIPT_BYTES + 1)) + with pytest.raises(TtdError): + await expense_svc.add_receipt(str(exp.id)[:8], big) + + +async def test_cli_app_registers_expense_commands(): + from ttd.cli.expenses import app as expense_app + + # The sub-app must be importable and named "expense". + # Cyclopts stores name as a tuple, string, or list depending on version. + name = expense_app.name + assert name == "expense" or name == ["expense"] or name == ("expense",) + + +async def test_add_expense_with_client_slug_disambiguates(db): + """add_expense(client_slug=...) picks the correct project when two clients + share a project with the same slug.""" + await client_svc.create_client("Alpha Corp", hourly_rate=Decimal("100")) + await client_svc.create_client("Beta LLC", hourly_rate=Decimal("100")) + # Both clients have a project slug "website" + await project_svc.create_project("Website", "alpha-corp") + await project_svc.create_project("Website", "beta-llc") + + exp = await expense_svc.add_expense("website", "Hosting", Decimal("50"), client_slug="beta-llc") + # The expense must belong to Beta LLC's website project + views = await expense_svc.list_expenses() + assert len(views) == 1 + assert views[0].client.slug == "beta-llc" + assert views[0].expense.id == exp.id diff --git a/tests/test_tui/test_app.py b/tests/test_tui/test_app.py index e39f00b..e2d8bcc 100644 --- a/tests/test_tui/test_app.py +++ b/tests/test_tui/test_app.py @@ -1,6 +1,6 @@ """Pilot tests: drive the TUI headless and assert on real behavior.""" -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from decimal import Decimal import pytest @@ -9,6 +9,7 @@ from ttd.config.schema import Settings, StorageConfig from ttd.services import clients as client_svc from ttd.services import entries as entry_svc +from ttd.services import expenses as expense_svc from ttd.services import invoicing as invoice_svc from ttd.services import projects as project_svc from ttd.services import timer as timer_svc @@ -36,6 +37,7 @@ async def seeded_app(tmp_path, monkeypatch): day = (NOW - timedelta(days=days_back)).date().isoformat() await entry_svc.log_entry(f"{day} 09:00 to 11:30", "api-rewrite", now=NOW) await entry_svc.log_entry("today 1pm to 2pm", "design", now=NOW, note="reviews") + await expense_svc.add_expense("api-rewrite", "Cloud hosting", Decimal("49.99")) yield TtdApp() @@ -54,7 +56,7 @@ async def test_navigation_between_screens(seeded_app): async with seeded_app.run_test(size=(120, 40)) as pilot: await pilot.pause() for key, nav_id in [ - ("2", "timesheet"), + ("2", "log"), ("3", "clients"), ("4", "reports"), ("5", "invoices"), @@ -66,28 +68,31 @@ async def test_navigation_between_screens(seeded_app): assert seeded_app.screen.nav_id == nav_id -async def test_timesheet_day_navigation(seeded_app): +async def test_log_month_navigation(seeded_app): async with seeded_app.run_test(size=(120, 40)) as pilot: - await pilot.press("2") + await pilot.press("2") # log await pilot.pause() screen = seeded_app.screen - assert screen.query_one("#day-table").row_count == 2 - await pilot.press("left_square_bracket") # yesterday: no entries (even days only) - await pilot.pause() - assert screen.query_one("#day-table").row_count == 0 - await pilot.press("left_square_bracket") # 2 days ago: 1 entry + this_month = screen.query_one("#day-table").row_count + assert this_month >= 1 + await pilot.press("left_square_bracket") # previous month await pilot.pause() - assert screen.query_one("#day-table").row_count == 1 - await pilot.press("g") + assert screen.anchor_date < date.today().replace(day=1) + await pilot.press("g") # back to this month await pilot.pause() - assert screen.query_one("#day-table").row_count == 2 + assert screen.query_one("#day-table").row_count == this_month async def test_quick_log_modal_live_preview(seeded_app): async with seeded_app.run_test(size=(120, 40)) as pilot: await pilot.press("l") await pilot.pause() - from ttd.tui.widgets.modals import QuickLogModal + from ttd.tui.widgets.modals import PickerModal, QuickLogModal + + # l now opens a chooser first + assert isinstance(seeded_app.screen, PickerModal) + await pilot.press("enter") # first option = "time" + await pilot.pause() modal = seeded_app.screen assert isinstance(modal, QuickLogModal) @@ -107,17 +112,19 @@ async def test_quick_log_modal_live_preview(seeded_app): async def test_quick_log_creates_entry(seeded_app): async with seeded_app.run_test(size=(120, 40)) as pilot: - await pilot.press("2") # timesheet + await pilot.press("2") # log await pilot.pause() + assert seeded_app.screen.nav_id == "log" before = seeded_app.screen.query_one("#day-table").row_count - await pilot.press("a") + await pilot.press("l") # log chooser await pilot.pause() - modal = seeded_app.screen - modal.query_one("#spec").value = "today 7pm to 8pm" - modal._submit() + await pilot.press("enter") # first option = time + await pilot.pause() + await pilot.press(*"today 3pm to 4pm") + await pilot.pause() + await pilot.press("enter") # submit spec → picks first project await pilot.pause() await pilot.pause() - assert seeded_app.screen.nav_id == "timesheet" assert seeded_app.screen.query_one("#day-table").row_count == before + 1 @@ -310,34 +317,7 @@ async def test_invoices_screen_tax_columns(seeded_app, monkeypatch): # --- TUI enhancements: spans, entry edit, clients CRUD, invoice period ------- -async def test_timesheet_spans(seeded_app): - async with seeded_app.run_test(size=(120, 40)) as pilot: - await pilot.press("2") - await pilot.pause() - screen = seeded_app.screen - day_count = screen.query_one("#day-table").row_count - assert day_count == 2 # today's two entries - - await pilot.press("m") # month spans every seeded entry this month - await pilot.pause() - month_count = screen.query_one("#day-table").row_count - assert month_count >= day_count - - await pilot.press("w") - await pilot.pause() - week_count = screen.query_one("#day-table").row_count - assert day_count <= week_count <= month_count - - await pilot.press("left_square_bracket") # previous week - await pilot.pause() - await pilot.press("g") # back to today - await pilot.press("d") - await pilot.pause() - assert screen.query_one("#day-table").row_count == day_count - assert screen.span == "day" - - -async def test_timesheet_delete_rebound_to_x(seeded_app): +async def test_log_delete_entry(seeded_app): async with seeded_app.run_test(size=(120, 40)) as pilot: await pilot.press("2") await pilot.pause() @@ -382,8 +362,10 @@ async def test_entry_edit_invoiced_blocked(seeded_app): from uuid import uuid4 async with open_test_db(): - rows = await entry_svc.list_entries() - target = next(r for r in rows if r.entry.work_date == NOW.date()) + first_of_month = date.today().replace(day=1) + rows = await entry_svc.list_entries(date_from=first_of_month, date_to=date.today()) + # Mark the first current-month entry (row 0 in month view) as invoiced. + target = rows[0] target.entry.invoice_id = uuid4() await target.entry.save() @@ -392,11 +374,33 @@ async def test_entry_edit_invoiced_blocked(seeded_app): async with seeded_app.run_test(size=(120, 40)) as pilot: await pilot.press("2") await pilot.pause() - await pilot.press("e") # cursor starts on the invoiced (first) entry + await pilot.press("e") # cursor starts on the invoiced (first) row await pilot.pause() assert not isinstance(seeded_app.screen, FormModal) +async def test_log_expense_edit_invoiced_blocked(seeded_app): + from uuid import uuid4 + + async with open_test_db(): + expenses = await expense_svc.list_expenses() + target = expenses[0] + target.expense.invoice_id = uuid4() + await target.expense.save() + + from ttd.tui.widgets.forms import FormModal + + async with seeded_app.run_test(size=(120, 40)) as pilot: + await pilot.press("2") # log + await pilot.pause() + await pilot.press("tab") # focus expenses section + await pilot.pause() + await pilot.press("e") # attempt edit on invoiced expense + await pilot.pause() + assert not isinstance(seeded_app.screen, FormModal) + assert seeded_app.screen.nav_id == "log" + + async def test_clients_crud_flow(seeded_app): from textual.widgets import Input, Tree @@ -511,7 +515,11 @@ async def test_invoice_wizard_custom_period_with_line_preview(seeded_app): from ttd.services import invoicing as invoice_svc ((invoice, _client),) = await invoice_svc.list_invoices() - assert invoice.period_start.isoformat() == start + # Period derives from actual billed dates (not the requested window). + # Entries are seeded every 2 days from 0..12 days back; the earliest is 12 days back. + # The expense defaults to today. Both collapse inward from the 14-day window. + derived_start = (NOW - td(days=12)).date().isoformat() + assert invoice.period_start.isoformat() == derived_start assert invoice.period_end.isoformat() == end @@ -720,3 +728,102 @@ async def test_palette_theme_applies_on_select(seeded_app): await pilot.pause() assert seeded_app.theme == THEME_LIGHT assert seeded_app.screen.nav_id == "dashboard" + + +async def test_invoice_wizard_draft_preview_shows_expense_rows(seeded_app): + """_rebuild must render expense lines in the draft table and mention them in the status.""" + from datetime import timedelta as td + + from textual.widgets import Input, Static + + from ttd.services import expenses as expense_svc + from ttd.tui.screens.invoices import NewInvoiceModal + from ttd.tui.widgets.modals import PickerModal + + start = (NOW - td(days=14)).date().isoformat() + end = NOW.date().isoformat() + + # Add an uninvoiced expense for the acme-corp project within the period. + async with open_test_db(): + await expense_svc.add_expense( + "api-rewrite", + "Cloud hosting", + Decimal("49.99"), + incurred_date=(NOW - td(days=7)).date(), + ) + + async with seeded_app.run_test(size=(120, 40)) as pilot: + await pilot.press("5") + await pilot.pause() + await pilot.press("n") + await pilot.pause() + assert isinstance(seeded_app.screen, PickerModal) + await pilot.press("enter") # first client: acme-corp + await pilot.pause() + modal = seeded_app.screen + assert isinstance(modal, NewInvoiceModal) + + modal.query_one("#period", Input).value = f"{start} to {end}" + await pilot.pause() + await pilot.pause() + + table = modal.query_one("#draft-table") + # There should be more rows than just the time lines (divider + expense row added). + assert table.row_count >= 2 + + # Collect all cell values from the table to search for expense markers. + all_cells = [] + for row_key in table.rows: + row_data = table.get_row(row_key) + all_cells.extend(str(cell) for cell in row_data) + cells_text = " ".join(all_cells) + assert "Cloud hosting" in cells_text + assert "49.99" in cells_text + assert "reimbursable expenses" in cells_text + + status = str(modal.query_one("#draft-status", Static).content) + assert "expense" in status + + +async def test_log_shows_expense_section(seeded_app): + async with seeded_app.run_test(size=(120, 40)) as pilot: + await pilot.press("2") # log + await pilot.pause() + screen = seeded_app.screen + expense_table = screen.query_one("#expense-table") + assert expense_table.row_count == 1 + # the description appears in the rendered table + assert any("Cloud hosting" in str(c) for c in expense_table.get_row_at(0)) + + +async def test_log_delete_expense(seeded_app): + async with seeded_app.run_test(size=(120, 40)) as pilot: + await pilot.press("2") # log + await pilot.pause() + screen = seeded_app.screen + assert screen.query_one("#expense-table").row_count == 1 + await pilot.press("tab") # focus the expenses section + await pilot.pause() + await pilot.press("x") # delete highlighted expense + await pilot.pause() + await pilot.press("enter") # confirm + await pilot.pause() + await pilot.pause() + assert screen.query_one("#expense-table").row_count == 0 + + +async def test_log_edit_expense(seeded_app): + async with seeded_app.run_test(size=(120, 40)) as pilot: + await pilot.press("2") + await pilot.pause() + await pilot.press("tab") # focus expenses + await pilot.pause() + await pilot.press("e") # edit + await pilot.pause() + # amount field is the second field; clear and retype via the form is heavy -- + # assert the edit modal opened with the expense's values instead. + from ttd.tui.widgets.forms import FormModal + + assert isinstance(seeded_app.screen, FormModal) + await pilot.press("escape") + await pilot.pause() diff --git a/tests/test_tui/test_expense_data.py b/tests/test_tui/test_expense_data.py new file mode 100644 index 0000000..81a54bc --- /dev/null +++ b/tests/test_tui/test_expense_data.py @@ -0,0 +1,149 @@ +"""Tests for TUI data helpers that expose expense data.""" + +from datetime import date +from decimal import Decimal +from typing import ClassVar + +from ttd.services import clients as client_svc +from ttd.services import expenses as expense_svc +from ttd.services import projects as project_svc +from ttd.tui import _data +from ttd.tui.screens._base import _validate_amount, _validate_date + + +async def test_recent_expense_choices(db): + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + await expense_svc.add_expense( + "api-rewrite", "Claude Code", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + suggestions = await _data.recent_expense_suggestions(project_slug="api-rewrite") + assert [(s.description, s.amount) for s in suggestions] == [("Claude Code", Decimal("100"))] + + +async def test_recent_expense_suggestions_empty(db): + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + suggestions = await _data.recent_expense_suggestions(project_slug="api-rewrite") + assert suggestions == [] + + +async def test_expenses_for_invoice_returns_expense_lines(db): + """expenses_for_invoice is a thin accessor over view.expense_lines.""" + + # Build a minimal fake view with expense_lines already set. + class _FakeView: + expense_lines: ClassVar = ["line-a", "line-b"] + + view = _FakeView() + result = await _data.expenses_for_invoice(view) + assert result == ["line-a", "line-b"] + + +# --------------------------------------------------------------------------- +# add_expense_entry +# --------------------------------------------------------------------------- + + +async def test_add_expense_entry_with_date(db): + """add_expense_entry creates an expense with the correct fields.""" + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + + expense = await _data.add_expense_entry( + { + "project": "acme-corp/api-rewrite", + "description": "Claude", + "amount": "100", + "date": "2026-06-15", + } + ) + + assert expense.description == "Claude" + assert expense.amount == Decimal("100") + assert expense.incurred_date == date(2026, 6, 15) + + +async def test_add_expense_entry_missing_date_key_defaults_to_today(db): + """Blank / absent date key means incurred today.""" + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + + expense = await _data.add_expense_entry( + { + "project": "acme-corp/api-rewrite", + "description": "Figma", + "amount": "15.50", + # no "date" key → should default to today + } + ) + + assert expense.incurred_date == date.today() + + +async def test_add_expense_entry_blank_date_defaults_to_today(db): + """Explicitly blank date string also defaults to today.""" + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + + expense = await _data.add_expense_entry( + { + "project": "acme-corp/api-rewrite", + "description": "Software", + "amount": "9.99", + "date": "", + } + ) + + assert expense.incurred_date == date.today() + + +# --------------------------------------------------------------------------- +# _validate_amount (pure function — no DB needed) +# --------------------------------------------------------------------------- + + +def test_validate_amount_valid(): + assert _validate_amount("100.00") is True + assert _validate_amount("0.01") is True + assert _validate_amount("1") is True + + +def test_validate_amount_zero(): + result = _validate_amount("0") + assert result is not True + assert "positive" in result + + +def test_validate_amount_negative(): + result = _validate_amount("-5") + assert result is not True + assert "positive" in result + + +def test_validate_amount_non_numeric(): + result = _validate_amount("abc") + assert result is not True + assert "number" in result + + +# --------------------------------------------------------------------------- +# _validate_date (pure function — no DB needed) +# --------------------------------------------------------------------------- + + +def test_validate_date_valid(): + assert _validate_date("2026-06-15") is True + assert _validate_date("2024-01-01") is True + + +def test_validate_date_invalid(): + result = _validate_date("not-a-date") + assert result is not True + assert "YYYY-MM-DD" in result + + +def test_validate_date_bad_format(): + result = _validate_date("15/06/2026") + assert result is not True + assert "YYYY-MM-DD" in result diff --git a/tests/test_tui/test_footer.py b/tests/test_tui/test_footer.py index 9b05d7f..727a138 100644 --- a/tests/test_tui/test_footer.py +++ b/tests/test_tui/test_footer.py @@ -28,7 +28,7 @@ def _hidden_keys(footer) -> list: async def test_footer_wraps_instead_of_clipping_when_narrow(app): async with app.run_test(size=(80, 24)) as pilot: - await pilot.press("2") # timesheet has the most bindings + await pilot.press("2") # log has the most bindings await pilot.pause() await pilot.pause() footer = app.screen.query_one("AdaptiveFooter") @@ -80,7 +80,7 @@ async def test_wrapped_second_row_keys_still_dispatch_actions(app): await pilot.pause() await pilot.pause() footer = app.screen.query_one("AdaptiveFooter") - target = next(k for k in footer.query("FooterKey") if k.description == "today") + target = next(k for k in footer.query("FooterKey") if k.description == "this month") assert target.region.y > footer.region.y # really on a wrapped row await pilot.click(target) await pilot.pause() diff --git a/tests/test_tui/test_render_modal.py b/tests/test_tui/test_render_modal.py new file mode 100644 index 0000000..4ca0888 --- /dev/null +++ b/tests/test_tui/test_render_modal.py @@ -0,0 +1,144 @@ +"""Tests for RenderFormatModal reactivity and _write_selected_formats.""" + +from datetime import date +from decimal import Decimal + +from textual.app import App +from textual.widgets import Checkbox, Static + +from ttd.config.schema import InvoiceConfig, Settings + + +class ModalHostApp(App): + """Bare host app that pushes a modal on mount, for isolated modal testing.""" + + CSS_PATH = "../../src/ttd/tui/ttd.tcss" + + def __init__(self, modal): + super().__init__() + self._modal = modal + self.result = "UNSET" + + async def on_mount(self) -> None: + def _done(value): + self.result = value + + await self.push_screen(self._modal, _done) + + +async def test_modal_receipts_on_disables_markdown(): + from ttd.tui.screens.invoices import RenderFormatModal + + modal = RenderFormatModal(has_receipts=True) + app = ModalHostApp(modal) + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + assert isinstance(app.screen, RenderFormatModal) + # receipts enabled + on; markdown disabled at start (receipts default on) + assert modal.query_one("#receipts", Checkbox).disabled is False + assert modal.query_one("#receipts", Checkbox).value is True + assert modal.query_one("#md", Checkbox).disabled is True + # turn receipts off -> markdown re-enabled + modal.query_one("#receipts", Checkbox).value = False + await pilot.pause() + assert modal.query_one("#md", Checkbox).disabled is False + + +async def test_modal_no_receipts_disables_receipts_switch(): + from ttd.tui.screens.invoices import RenderFormatModal + + modal = RenderFormatModal(has_receipts=False) + app = ModalHostApp(modal) + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + assert modal.query_one("#receipts", Checkbox).disabled is True + assert modal.query_one("#md", Checkbox).disabled is False + + +async def test_write_selected_formats_pdf_with_receipts(db, tmp_path): + # build an invoice whose expense has a receipt, then render via the helper + from fpdf import FPDF + from pypdf import PdfReader + + from ttd.reporting import periods + from ttd.services import clients as client_svc + from ttd.services import expenses as expense_svc + from ttd.services import invoicing as svc + from ttd.services import projects as project_svc + from ttd.tui.screens.invoices import _write_selected_formats + + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + exp = await expense_svc.add_expense( + "api-rewrite", "Claude", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + rp = tmp_path / "r.pdf" + r = FPDF() + r.add_page() + r.set_font("helvetica", size=12) + r.cell(0, 10, "RECEIPT") + r.output(str(rp)) + await expense_svc.add_receipt(str(exp.id)[:8], rp) + period = periods.range_period(date(2026, 6, 1), date(2026, 6, 30)) + settings = Settings(invoice=InvoiceConfig(output_dir=tmp_path / "out")) + invoice = await svc.persist_draft( + await svc.build_draft("acme-corp", period, settings), settings + ) + view = await svc.get_invoice(invoice.number) + + await _write_selected_formats(view, settings, {"pdf": True, "md": False, "receipts": False}) + base_pdf = tmp_path / "out" / f"{invoice.number}-acme-corp.pdf" + base_pages = len(PdfReader(str(base_pdf)).pages) + with_r = await _write_selected_formats( + view, settings, {"pdf": True, "md": False, "receipts": True} + ) + assert len(PdfReader(str(base_pdf)).pages) > base_pages # receipts appended + assert any(".pdf" in name for name in with_r) + + +async def test_render_modal_no_format_selected(): + from ttd.tui.screens.invoices import RenderFormatModal + + modal = RenderFormatModal(has_receipts=False) + app = ModalHostApp(modal) + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + assert isinstance(app.screen, RenderFormatModal) + # turn both checkboxes off + modal.query_one("#pdf", Checkbox).value = False + modal.query_one("#md", Checkbox).value = False + await pilot.pause() + # click render button + await pilot.click("#render") + await pilot.pause() + # modal should still be displayed (not dismissed) + assert isinstance(app.screen, RenderFormatModal) + # error message should be non-empty + error_widget = modal.query_one("#render-error", Static) + assert error_widget.content + + +async def test_write_selected_formats_markdown(db, tmp_path): + from ttd.reporting import periods + from ttd.services import clients as client_svc + from ttd.services import expenses as expense_svc + from ttd.services import invoicing as svc + from ttd.services import projects as project_svc + from ttd.tui.screens.invoices import _write_selected_formats + + await client_svc.create_client("Acme Corp", hourly_rate=Decimal("150")) + await project_svc.create_project("API Rewrite", "acme-corp") + await expense_svc.add_expense( + "api-rewrite", "Claude", Decimal("100"), incurred_date=date(2026, 6, 15) + ) + period = periods.range_period(date(2026, 6, 1), date(2026, 6, 30)) + settings = Settings(invoice=InvoiceConfig(output_dir=tmp_path / "out")) + invoice = await svc.persist_draft( + await svc.build_draft("acme-corp", period, settings), settings + ) + view = await svc.get_invoice(invoice.number) + wrote = await _write_selected_formats( + view, settings, {"pdf": False, "md": True, "receipts": False} + ) + assert (tmp_path / "out" / f"{invoice.number}-acme-corp.md").exists() + assert any(name.endswith(".md") for name in wrote) diff --git a/uv.lock b/uv.lock index 3ee0052..e5db9f5 100644 --- a/uv.lock +++ b/uv.lock @@ -1431,6 +1431,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, ] +[[package]] +name = "pypdf" +version = "6.14.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/72/7dfd5ff1c9c37de97a731701f51af091325f123d9d4270361c9c69e4431f/pypdf-6.14.2.tar.gz", hash = "sha256:7873f502fe4385e79539b21d872392dc0c4e3714327c15881cbc7fbfd1f95b25", size = 6491182, upload-time = "2026-06-23T14:18:30.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/e6/136aa8993a2ae7214e0b0ef2edaa0d2e08d1d4e4982635b08a835ff31ec8/pypdf-6.14.2-py3-none-any.whl", hash = "sha256:3f07891af76dc002657e04993ab9b4de81de29f9013b9761d0b7968bff12e946", size = 349514, upload-time = "2026-06-23T14:18:28.867Z" }, +] + [[package]] name = "pytest" version = "8.4.2" @@ -1844,6 +1853,7 @@ dependencies = [ { name = "openpyxl" }, { name = "platformdirs" }, { name = "pydantic" }, + { name = "pypdf" }, { name = "questionary" }, { name = "rich" }, { name = "textual" }, @@ -1877,6 +1887,7 @@ requires-dist = [ { name = "openpyxl", specifier = ">=3.1" }, { name = "platformdirs", specifier = ">=4" }, { name = "pydantic", specifier = ">=2.7" }, + { name = "pypdf", specifier = ">=6.14.2" }, { name = "questionary", specifier = ">=2.0" }, { name = "rich", specifier = ">=14" }, { name = "textual", specifier = ">=3.0" },