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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/ttd/cli/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
26 changes: 23 additions & 3 deletions src/ttd/cli/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand All @@ -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(
Expand All @@ -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}"
Expand All @@ -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)


Expand Down
42 changes: 35 additions & 7 deletions src/ttd/tui/screens/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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] = {}
Expand All @@ -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])
Expand All @@ -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]"
)
Expand Down
30 changes: 30 additions & 0 deletions tests/test_cli/test_report_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
20 changes: 20 additions & 0 deletions tests/test_tui/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading