From b6699217b1dcff68f81bcf4bdcf158efcdaf0e1e Mon Sep 17 00:00:00 2001 From: Taylor Date: Fri, 12 Jun 2026 10:10:19 -0400 Subject: [PATCH] feat(reports): show per-project estimated tax and take-home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reports now answer "what do I actually keep?" per project. When tax.set_aside_rate is configured, the TUI reports screen and the CLI report week/month/range project and client views gain est. tax and take-home columns next to billable value, and every report's totals line includes both figures. Rate 0 keeps reports exactly as before, matching the taxes dashboard's "unset means off" behavior. Amounts reuse the same cents-aligned set-aside math as the quarterly tax dashboard, and totals are summed from the displayed rows so the table always adds up. The TUI rebuilds its column set per render, since config reloads fresh — setting or clearing the rate mid-session updates the table on the next refresh. --- src/ttd/cli/_output.py | 2 ++ src/ttd/cli/reports.py | 26 ++++++++++++++++--- src/ttd/tui/screens/reports.py | 42 +++++++++++++++++++++++++------ tests/test_cli/test_report_cli.py | 30 ++++++++++++++++++++++ tests/test_tui/test_app.py | 20 +++++++++++++++ 5 files changed, 110 insertions(+), 10 deletions(-) diff --git a/src/ttd/cli/_output.py b/src/ttd/cli/_output.py index 5a7b890..657d670 100644 --- a/src/ttd/cli/_output.py +++ b/src/ttd/cli/_output.py @@ -39,6 +39,8 @@ def table(*columns: str, title: str | None = None) -> Table: "set aside", "remitted", "balance", + "est. tax", + "take-home", ) for col in columns: t.add_column(col, justify="right" if col.lower() in right_cols else "left") diff --git a/src/ttd/cli/reports.py b/src/ttd/cli/reports.py index 89cbbc2..69492ed 100644 --- a/src/ttd/cli/reports.py +++ b/src/ttd/cli/reports.py @@ -12,6 +12,7 @@ from ttd.core.errors import TtdError from ttd.core.money import format_hours from ttd.core.rollup import EntryFacts, amount, rollup_days, seconds_by_date +from ttd.core.taxes import compute_set_aside from ttd.reporting import periods from ttd.reporting.render import day_series, hours_cell, money_cell, sparkline from ttd.services import entries as entry_svc @@ -71,6 +72,8 @@ def _render(period: periods.Period, facts, rates, meta, by: str) -> None: days = period.days() total_seconds = sum(f.seconds for f in facts) total_amount = Decimal("0") + total_tax = Decimal("0") + set_aside_rate = settings.tax.set_aside_rate # 0 hides the tax columns any_rate = False if by == "day": @@ -82,6 +85,7 @@ def _render(period: periods.Period, facts, rates, meta, by: str) -> None: value = amount(billed, rates[cell.project_id]) if value is not None: total_amount += value + total_tax += compute_set_aside(value, set_aside_rate) any_rate = True day_label = cell.work_date.strftime("%a %b %-d") t.add_row( @@ -100,7 +104,10 @@ def _render(period: periods.Period, facts, rates, meta, by: str) -> None: groups: dict = {} for cell in cells: groups.setdefault(key_of(cell), []).append(cell) - t = table("Client" if by == "client" else "Project", "Days", "Hours", "Activity", "Amount") + headers = ["Client" if by == "client" else "Project", "Days", "Hours", "Activity", "Amount"] + if set_aside_rate > 0: + headers += ["Est. Tax", "Take-Home"] + t = table(*headers) for _, group in sorted(groups.items(), key=lambda kv: -sum(c.seconds for c in kv[1])): project, client = meta[group[0].project_id] label = client.slug if by == "client" else f"{client.slug}/{project.slug}" @@ -113,23 +120,36 @@ def _render(period: periods.Period, facts, rates, meta, by: str) -> None: if v is not None: value += v has_rate = True + tax = compute_set_aside(value, set_aside_rate) if has_rate: total_amount += value + total_tax += tax any_rate = True group_by_date = seconds_by_date([f for f in facts if key_of_fact(f, by, meta) == label]) - t.add_row( + row = [ label, str(len({c.work_date for c in group})), hours_cell(seconds, billable), sparkline(day_series(group_by_date, days)) if len(days) > 1 else "", money_cell(value if has_rate else None, client.currency), - ) + ] + if set_aside_rate > 0: + row += [ + money_cell(tax if has_rate else None, client.currency), + money_cell(value - tax if has_rate else None, client.currency), + ] + t.add_row(*row) console.print(t) summary = f"Total: [bold]{format_hours(total_seconds)}[/bold]" if any_rate: currency = next(iter(meta.values()))[1].currency summary += f" · [bold]{money_cell(total_amount, currency)}[/bold] billable value" + if set_aside_rate > 0: + summary += ( + f" · [bold]{money_cell(total_tax, currency)}[/bold] est. tax" + f" · [bold]{money_cell(total_amount - total_tax, currency)}[/bold] take-home" + ) console.print(summary) diff --git a/src/ttd/tui/screens/reports.py b/src/ttd/tui/screens/reports.py index 9167240..d08c6c6 100644 --- a/src/ttd/tui/screens/reports.py +++ b/src/ttd/tui/screens/reports.py @@ -12,6 +12,7 @@ from ttd.config.loader import get_settings from ttd.core.money import format_hours, format_money from ttd.core.rollup import EntryFacts, amount, rollup_days +from ttd.core.taxes import compute_set_aside from ttd.reporting import periods from ttd.services import entries as entry_svc from ttd.services import projects as project_svc @@ -60,8 +61,7 @@ def compose_content(self) -> ComposeResult: yield Label("", id="report-total", classes="muted") def setup(self) -> None: - table = self.query_one("#report-table", DataTable) - table.add_columns("project", "days", "hours", "activity", "value") + self._table_columns: tuple[str, ...] = () def _period(self) -> periods.Period: today = date.today() @@ -101,7 +101,17 @@ async def render_data(self) -> None: for cell in cells: groups.setdefault(cell.project_id, []).append(cell) + # The set-aside rate is config, reloaded each render — rebuild the + # column set when it crosses zero (0 disables the tax columns). + set_aside_rate = settings.tax.set_aside_rate + columns = ("project", "days", "hours", "activity", "value") + if set_aside_rate > 0: + columns += ("est. tax", "take-home") table = self.query_one("#report-table", DataTable) + if columns != self._table_columns: + table.clear(columns=True) + table.add_columns(*columns) + self._table_columns = columns table.clear() days = period.days() day_totals: dict[date, int] = {} @@ -110,6 +120,7 @@ async def render_data(self) -> None: self.query_one("#report-chart", ReportChart).update_data(days, day_totals) total_seconds = sum(f.seconds for f in facts) total_value = Decimal("0") + total_tax = Decimal("0") any_rate = False for project_id, group in sorted( groups.items(), key=lambda kv: -sum(c.seconds for c in kv[1]) @@ -125,20 +136,37 @@ async def render_data(self) -> None: if v is not None: value += v has_rate = True - if has_rate: - total_value += value - any_rate = True - table.add_row( + tax = compute_set_aside(value, set_aside_rate) + row = [ labels[project_id], str(len({c.work_date for c in group})), format_hours(seconds), _heat_strip([by_date.get(d, 0) for d in days]), format_money(value, currencies[project_id]) if has_rate else "—", - ) + ] + if set_aside_rate > 0: + row += ( + [ + format_money(tax, currencies[project_id]), + format_money(value - tax, currencies[project_id]), + ] + if has_rate + else ["—", "—"] + ) + if has_rate: + total_value += value + total_tax += tax + any_rate = True + table.add_row(*row) self.query_one("#report-title", Label).update(f"{period.label}") total = f"total {format_hours(total_seconds)}" if any_rate: total += f" · {format_money(total_value, 'USD')} billable" + if set_aside_rate > 0: + total += ( + f" · {format_money(total_tax, 'USD')} est. tax" + f" · {format_money(total_value - total_tax, 'USD')} take-home" + ) self.query_one("#report-total", Label).update( f"{total} [dim]w/m switch period · \\[ ] older/newer[/dim]" ) diff --git a/tests/test_cli/test_report_cli.py b/tests/test_cli/test_report_cli.py index bc081f7..9385cca 100644 --- a/tests/test_cli/test_report_cli.py +++ b/tests/test_cli/test_report_cli.py @@ -35,6 +35,36 @@ def test_report_week_by_client(isolated_config): assert "acme" in result.output +def test_report_tax_columns_with_rate(isolated_config): + _seed() + runner.invoke(app, ["config", "set", "tax.set_aside_rate", "0.32"]) + result = runner.invoke(app, ["report", "week"]) + assert result.exit_code == 0, result.output + assert "Est. Tax" in result.output + assert "192.00" in result.output # 32% of $600 + assert "408.00" in result.output # take-home + assert "est. tax" in result.output # totals line + assert "take-home" in result.output + + +def test_report_tax_summary_in_day_view(isolated_config): + _seed() + runner.invoke(app, ["config", "set", "tax.set_aside_rate", "0.32"]) + result = runner.invoke(app, ["report", "day"]) + assert result.exit_code == 0, result.output + assert "Est. Tax" not in result.output # day view keeps per-row columns lean + assert "est. tax" in result.output + assert "take-home" in result.output + + +def test_report_hides_tax_columns_without_rate(isolated_config): + _seed() + result = runner.invoke(app, ["report", "week"]) + assert result.exit_code == 0, result.output + assert "Est. Tax" not in result.output + assert "take-home" not in result.output + + def test_report_month_empty(isolated_config): runner.invoke(app, ["client", "add", "Acme"]) result = runner.invoke(app, ["report", "month", "--last"]) diff --git a/tests/test_tui/test_app.py b/tests/test_tui/test_app.py index 0744c9f..beeea81 100644 --- a/tests/test_tui/test_app.py +++ b/tests/test_tui/test_app.py @@ -177,6 +177,26 @@ async def test_reports_screen_modes(seeded_app): ) +async def test_reports_screen_tax_columns(seeded_app, monkeypatch): + monkeypatch.setenv("TTD_TAX__SET_ASIDE_RATE", "0.32") + async with seeded_app.run_test(size=(140, 40)) as pilot: + await pilot.press("4") + await pilot.pause() + screen = seeded_app.screen + table = screen.query_one("#report-table") + labels = [str(col.label) for col in table.columns.values()] + assert labels[-2:] == ["est. tax", "take-home"] + total = str(screen.query_one("#report-total").content) + assert "est. tax" in total + assert "take-home" in total + # rate cleared mid-session → columns drop on the next render + monkeypatch.delenv("TTD_TAX__SET_ASIDE_RATE") + await pilot.press("r") + await pilot.pause() + labels = [str(col.label) for col in table.columns.values()] + assert labels == ["project", "days", "hours", "activity", "value"] + + async def test_invoices_screen_create_flow(seeded_app): async with seeded_app.run_test(size=(120, 40)) as pilot: await pilot.press("5")