diff --git a/CHANGELOG.md b/CHANGELOG.md index 42de173..5feaafb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed +### Added -- Car-type filtering now matches case-insensitively: the live API reports the - free-floating type as `cityflitzer`, not `cityFlitzer`, which made - `cars list` and `autobook` match zero cars. -- Price parsing now understands the live `/prices` schema - (`priceItems[].priceIncl.amount` + `code`); the legacy `gross`/`currency` - shape remains as a fallback. Previously every price came back "unknown". +- `teilauto bills` — `list`, `download`: bill (Gebühren) summaries with the + accumulated total per currency, a `--year` filter, and download of the + itemized bill PDF (`billDocument`, inline base64 or reference URL). + Endpoint mapped from the official Android app. ### Changed @@ -23,6 +21,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 kilometers) and the backend rewrites the booking end to the real trip end, so the window is a formality that only affects the availability check. +### Fixed + +- Car-type filtering now matches case-insensitively: the live API reports the + free-floating type as `cityflitzer`, not `cityFlitzer`, which made + `cars list` and `autobook` match zero cars. +- Price parsing now understands the live `/prices` schema + (`priceItems[].priceIncl.amount` + `code`); the legacy `gross`/`currency` + shape remains as a fallback. Previously every price came back "unknown". + ### Removed - `autobook --max-price` and the per-candidate price lookup. The API prices diff --git a/README.md b/README.md index 7ceef8a..d1f68ec 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,8 @@ teilauto notify test | `teilauto bookings show BOOKING_ID` | One booking in detail. | | `teilauto bookings create CAR_ID` | Book a car for a time window (asks for confirmation; skip with `--yes`, required if you also pass `--json`). | | `teilauto bookings cancel BOOKING_ID` | Cancel a booking. | +| `teilauto bills list` | Your bills, newest first, with the accumulated total. | +| `teilauto bills download BILL_ID` | Download a bill's itemized PDF. | | `teilauto watch` | Poll for newly available cars and notify — never books. | | `teilauto autobook` | Poll and book the first match automatically. | | `teilauto notify test` | Send a test message through every configured channel. | diff --git a/src/teilauto/api/client.py b/src/teilauto/api/client.py index fc4aa06..57aa6ee 100644 --- a/src/teilauto/api/client.py +++ b/src/teilauto/api/client.py @@ -9,6 +9,8 @@ from teilauto.api.errors import ApiError, AuthRequiredError, NotFoundError, RateLimitedError from teilauto.api.models import ( + Bill, + BillDocument, Booking, Car, Price, @@ -186,3 +188,25 @@ def create_booking(self, bookee_id: str, start: datetime, end: datetime) -> Book def cancel_booking(self, booking_id: str) -> None: self._request("DELETE", f"/bookings/{booking_id}") + + def list_bills(self, *, expand_document: bool = False) -> list[Bill]: + expand = ["customerId"] + if expand_document: + expand.append("billDocument") + data = self._request("GET", "/bills", params={"expand": expand, "sort": "-billDate"}) + # Tolerate a wrapped object; the app treats the response as a plain array. + if isinstance(data, dict): + data = data.get("items") or data.get("bills") or [] + return [Bill.from_api(item) for item in data or []] + + def download_bill_document(self, document: BillDocument) -> bytes: + if document.base64_string: + return base64.b64decode(document.base64_string) + if document.reference: + resp = self._http.get(document.reference, follow_redirects=True) + if resp.status_code == 401: + raise AuthRequiredError() + if resp.status_code >= 400: + raise ApiError(f"Document download failed ({resp.status_code}).") + return resp.content + raise ApiError("Bill has no downloadable document.") diff --git a/src/teilauto/api/models.py b/src/teilauto/api/models.py index fa07cca..fc0208a 100644 --- a/src/teilauto/api/models.py +++ b/src/teilauto/api/models.py @@ -111,6 +111,57 @@ def from_api(cls, data: dict) -> Booking: ) +@dataclass(frozen=True) +class BillDocument: + reference: str | None + base64_string: str | None + mime_type: str | None + file_name: str | None + + @classmethod + def from_api(cls, data: dict) -> BillDocument: + return cls( + reference=data.get("reference"), + base64_string=data.get("base64String"), + mime_type=data.get("mimeType"), + file_name=data.get("fileName"), + ) + + +@dataclass(frozen=True) +class Bill: + id: str + bill_number: str | None + bill_date: str | None + gross_amount: float | None + currency: str | None + document: BillDocument | None + raw: dict = field(default_factory=dict) + + @classmethod + def from_api(cls, data: dict) -> Bill: + # Wire shape: the amount is integer cents in `sum.grossAmount` and the + # currency is a top-level bill field. A Price-style nested `sum.gross` + # is tolerated as fallback. + total = data.get("sum") or {} + amount = total.get("grossAmount") + currency = total.get("currency") or data.get("currency") + if amount is None: + gross = total.get("gross") or {} + amount = gross.get("amount") + currency = gross.get("currency") or currency + document = data.get("billDocument") + return cls( + id=str(data.get("id")), + bill_number=data.get("billNumber"), + bill_date=data.get("billDate"), + gross_amount=amount / 100 if amount is not None else None, + currency=currency, + document=BillDocument.from_api(document) if document else None, + raw=data, + ) + + @dataclass(frozen=True) class Unavailability: start: str | None diff --git a/src/teilauto/cli/__init__.py b/src/teilauto/cli/__init__.py index b525c5f..6d9dd95 100644 --- a/src/teilauto/cli/__init__.py +++ b/src/teilauto/cli/__init__.py @@ -66,6 +66,7 @@ def cli(ctx: click.Context, verbose: bool, quiet: bool) -> None: from teilauto.cli.auth import auth # noqa: E402 from teilauto.cli.autobook import autobook_cmd # noqa: E402 +from teilauto.cli.bills import bills # noqa: E402 from teilauto.cli.bookings import bookings # noqa: E402 from teilauto.cli.cars import cars # noqa: E402 from teilauto.cli.config_cmd import config_group # noqa: E402 @@ -75,6 +76,7 @@ def cli(ctx: click.Context, verbose: bool, quiet: bool) -> None: cli.add_command(auth) cli.add_command(autobook_cmd) +cli.add_command(bills) cli.add_command(bookings) cli.add_command(cars) cli.add_command(config_group) diff --git a/src/teilauto/cli/bills.py b/src/teilauto/cli/bills.py new file mode 100644 index 0000000..e5ee9e4 --- /dev/null +++ b/src/teilauto/cli/bills.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from pathlib import Path + +import rich_click as click + +from teilauto.api.errors import ApiError, NotFoundError +from teilauto.api.models import Bill +from teilauto.cli.options import json_option +from teilauto.output.json_out import print_json +from teilauto.output.render import bills_table, console + + +def _bill_year(bill: Bill) -> str | None: + return bill.bill_date[:4] if bill.bill_date and len(bill.bill_date) >= 4 else None + + +def totals_by_currency(bills: list[Bill]) -> dict[str, float]: + totals: dict[str, float] = {} + for bill in bills: + if bill.gross_amount is None: + continue + key = bill.currency or "?" + totals[key] = round(totals.get(key, 0.0) + bill.gross_amount, 2) + return totals + + +@click.group() +def bills() -> None: + """List your bills and download their PDFs (login required).""" + + +@bills.command("list") +@click.option("--year", type=int, default=None, metavar="YYYY", help="Only bills from this year.") +@json_option +@click.pass_obj +def list_bills(app, year, as_json) -> None: + """Show your bills, newest first, with the accumulated total.""" + with app.require_auth_client() as client: + items = client.list_bills() + if year is not None: + items = [b for b in items if _bill_year(b) == str(year)] + if as_json: + print_json(items) + return + if not items: + console.print("No bills." if year is None else f"No bills in {year}.") + return + console.print(bills_table(items, totals_by_currency(items))) + + +@bills.command("download") +@click.argument("bill_id") +@click.option( + "--out", + "out_path", + type=click.Path(dir_okay=False, writable=True, path_type=Path), + default=None, + help="Where to write the PDF (default: the bill's own file name).", +) +@click.pass_obj +def download_bill(app, bill_id, out_path) -> None: + """Download the itemized PDF for BILL_ID.""" + with app.require_auth_client() as client: + items = client.list_bills(expand_document=True) + bill = next((b for b in items if b.id == bill_id), None) + if bill is None: + raise NotFoundError(f"No bill with id {bill_id}.") + if bill.document is None: + raise ApiError(f"Bill {bill_id} has no document attached.") + content = client.download_bill_document(bill.document) + if out_path is None: + name = bill.document.file_name or f"bill-{bill.bill_number or bill.id}.pdf" + out_path = Path(name) + out_path.write_bytes(content) + console.print(f"[green]✓ Saved[/green] {out_path} ({len(content)} bytes)") diff --git a/src/teilauto/output/render.py b/src/teilauto/output/render.py index 38bc1aa..8b5bc51 100644 --- a/src/teilauto/output/render.py +++ b/src/teilauto/output/render.py @@ -7,7 +7,7 @@ from rich.table import Table from rich.text import Text -from teilauto.api.models import Booking, Car, Unavailability +from teilauto.api.models import Bill, Booking, Car, Unavailability from teilauto.services.availability import CarAvailability console = Console() @@ -83,6 +83,36 @@ def unavailability_table(items: list[Unavailability]) -> Table: return table +def bill_date_text(iso: str | None) -> str: + """Render an API bill date (ISO date or timestamp) as '%Y-%m-%d'.""" + if not iso: + return "—" + try: + dt = datetime.fromisoformat(iso.replace("Z", "+00:00")) + except ValueError: + return iso + return dt.strftime("%Y-%m-%d") + + +def bills_table(bills: list[Bill], totals: dict[str, float]) -> Table: + total_text = ", ".join(f"{amount:.2f} {currency}" for currency, amount in totals.items()) + table = Table( + title="Bills", + header_style="bold", + caption=f"{len(bills)} bill(s) · total {total_text or '—'}", + ) + for column in ("ID", "Number", "Date", "Amount"): + table.add_column(column, justify="right" if column == "Amount" else "left") + for bill in bills: + amount = ( + f"{bill.gross_amount:.2f} {bill.currency or ''}".rstrip() + if bill.gross_amount is not None + else "—" + ) + table.add_row(bill.id, bill.bill_number or "—", bill_date_text(bill.bill_date), amount) + return table + + def bookings_table(bookings: list[Booking]) -> Table: table = Table(title="Bookings", header_style="bold") for column in ("ID", "Car", "From", "Until", "State"): diff --git a/tests/conftest.py b/tests/conftest.py index 334bb21..499a3ad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,6 +36,11 @@ def bookings_json(): return load_fixture("bookings.json") +@pytest.fixture +def bills_json(): + return load_fixture("bills.json") + + @pytest.fixture def token_json(): return load_fixture("token.json") diff --git a/tests/fixtures/bills.json b/tests/fixtures/bills.json new file mode 100644 index 0000000..8bf8b1e --- /dev/null +++ b/tests/fixtures/bills.json @@ -0,0 +1,10 @@ +[ + {"id": "r-3", "billNumber": "2026-07-001", "billDate": "2026-07-01T12:08:09+02:00", + "provId": "19", "currency": "EUR", "sum": {"grossAmount": 2450}, "vatSums": []}, + {"id": "r-2", "billNumber": "2026-06-001", "billDate": "2026-06-01", + "sum": {"gross": {"amount": 1200, "currency": "EUR"}}, + "billDocument": {"reference": null, "base64String": "JVBERi10ZXN0", + "mimeType": "application/pdf", "fileName": "rechnung-2026-06.pdf"}}, + {"id": "r-1", "billNumber": "2025-12-001", "billDate": "2025-12-01", + "currency": "EUR", "sum": {"grossAmount": 990}} +] diff --git a/tests/test_cli_bills.py b/tests/test_cli_bills.py new file mode 100644 index 0000000..81d0e25 --- /dev/null +++ b/tests/test_cli_bills.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json + +import httpx + +from teilauto.api.client import BASE_URL +from teilauto.api.errors import ApiError, NotFoundError +from teilauto.cli import cli + + +def test_bills_require_auth(runner, isolated_config): + result = runner.invoke(cli, ["bills", "list"]) + assert result.exit_code != 0 + assert isinstance(result.exception, Exception) + + +def test_bills_list_table_with_total(runner, logged_in, respx_mock, bills_json): + respx_mock.get(f"{BASE_URL}/bills").mock(return_value=httpx.Response(200, json=bills_json)) + result = runner.invoke(cli, ["bills", "list"]) + assert result.exit_code == 0, result.output + assert "2026-07-001" in result.output + assert "24.50 EUR" in result.output + assert "46.40 EUR" in result.output # 24.50 + 12.00 + 9.90 accumulated + + +def test_bills_list_year_filter(runner, logged_in, respx_mock, bills_json): + respx_mock.get(f"{BASE_URL}/bills").mock(return_value=httpx.Response(200, json=bills_json)) + result = runner.invoke(cli, ["bills", "list", "--year", "2025", "--json"]) + assert result.exit_code == 0, result.output + assert [b["id"] for b in json.loads(result.output)] == ["r-1"] + + +def test_bills_list_json_parses_both_sum_shapes(runner, logged_in, respx_mock, bills_json): + respx_mock.get(f"{BASE_URL}/bills").mock(return_value=httpx.Response(200, json=bills_json)) + result = runner.invoke(cli, ["bills", "list", "--json"]) + assert result.exit_code == 0, result.output + by_id = {b["id"]: b for b in json.loads(result.output)} + assert by_id["r-3"]["gross_amount"] == 24.50 # sum.grossAmount + assert by_id["r-2"]["gross_amount"] == 12.00 # sum.gross.amount + assert by_id["r-2"]["currency"] == "EUR" + + +def test_bills_download_writes_pdf(runner, logged_in, respx_mock, bills_json, tmp_path): + route = respx_mock.get(f"{BASE_URL}/bills").mock( + return_value=httpx.Response(200, json=bills_json) + ) + out = tmp_path / "bill.pdf" + result = runner.invoke(cli, ["bills", "download", "r-2", "--out", str(out)]) + assert result.exit_code == 0, result.output + assert out.read_bytes() == b"%PDF-test" + assert "expand=billDocument" in str(route.calls.last.request.url) + + +def test_bills_download_unknown_id_is_not_found(runner, logged_in, respx_mock, bills_json): + respx_mock.get(f"{BASE_URL}/bills").mock(return_value=httpx.Response(200, json=bills_json)) + result = runner.invoke(cli, ["bills", "download", "nope"]) + assert result.exit_code != 0 + assert isinstance(result.exception, NotFoundError) + + +def test_bills_download_without_document_fails(runner, logged_in, respx_mock, bills_json): + respx_mock.get(f"{BASE_URL}/bills").mock(return_value=httpx.Response(200, json=bills_json)) + result = runner.invoke(cli, ["bills", "download", "r-3"]) + assert result.exit_code != 0 + assert isinstance(result.exception, ApiError) + + +def test_bills_download_follows_reference_url(runner, logged_in, respx_mock, bills_json, tmp_path): + with_ref = [dict(b) for b in bills_json] + with_ref[1]["billDocument"] = { + "reference": "https://docs.example/bill.pdf", + "base64String": None, + "mimeType": "application/pdf", + "fileName": "rechnung-2026-06.pdf", + } + respx_mock.get(f"{BASE_URL}/bills").mock(return_value=httpx.Response(200, json=with_ref)) + respx_mock.get("https://docs.example/bill.pdf").mock( + return_value=httpx.Response(200, content=b"%PDF-ref") + ) + out = tmp_path / "bill.pdf" + result = runner.invoke(cli, ["bills", "download", "r-2", "--out", str(out)]) + assert result.exit_code == 0, result.output + assert out.read_bytes() == b"%PDF-ref" diff --git a/tests/test_models.py b/tests/test_models.py index 98fa6d3..b3d1fa8 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -8,6 +8,7 @@ TeilautoError, ) from teilauto.api.models import ( + Bill, Booking, Car, Price, @@ -112,3 +113,26 @@ def test_price_parses_live_price_incl_items(): ) assert price.total == 60.00 assert price.currency == "EUR" + + +def test_bill_from_api_wire_shape(bills_json): + bill = Bill.from_api(bills_json[0]) + assert (bill.id, bill.bill_number) == ("r-3", "2026-07-001") + assert bill.bill_date == "2026-07-01T12:08:09+02:00" + assert (bill.gross_amount, bill.currency) == (24.50, "EUR") # currency is top-level + assert bill.document is None + + +def test_bill_from_api_nested_gross_and_document(bills_json): + bill = Bill.from_api(bills_json[1]) + assert (bill.gross_amount, bill.currency) == (12.00, "EUR") + assert bill.document is not None + assert bill.document.file_name == "rechnung-2026-06.pdf" + assert bill.document.base64_string == "JVBERi10ZXN0" + + +def test_bill_from_api_missing_sum_gives_none(): + bill = Bill.from_api({"id": 7, "billNumber": "X"}) + assert bill.id == "7" + assert bill.gross_amount is None + assert bill.currency is None