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")