Skip to content
Open
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
21 changes: 14 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
24 changes: 24 additions & 0 deletions src/teilauto/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

from teilauto.api.errors import ApiError, AuthRequiredError, NotFoundError, RateLimitedError
from teilauto.api.models import (
Bill,
BillDocument,
Booking,
Car,
Price,
Expand Down Expand Up @@ -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.")
51 changes: 51 additions & 0 deletions src/teilauto/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/teilauto/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
76 changes: 76 additions & 0 deletions src/teilauto/cli/bills.py
Original file line number Diff line number Diff line change
@@ -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)")
32 changes: 31 additions & 1 deletion src/teilauto/output/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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"):
Expand Down
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
10 changes: 10 additions & 0 deletions tests/fixtures/bills.json
Original file line number Diff line number Diff line change
@@ -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}}
]
84 changes: 84 additions & 0 deletions tests/test_cli_bills.py
Original file line number Diff line number Diff line change
@@ -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"
Loading