diff --git a/README.md b/README.md index a964176..16520f2 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,14 @@ O alvo executa testes, lint e verificação de tipos no backend e no frontend, a - [Fase 2 — Domínio (concluída)](docs/PHASE_2.md) - [Fase 3 — Simulação e leituras (concluída)](docs/PHASE_3.md) - [Fase 4 — Gestão energética (concluída)](docs/PHASE_4.md) +- [Fase 5 — Billing e histórico de invoices (concluída)](docs/PHASE_5.md) ## Estado atual -As Fases 1 (fundação), 2 (domínio), 3 (simulação e leituras) e 4 (gestão energética) -estão concluídas, com critérios de saída e evidências documentados. As migrations -da Fase 3 foram validadas online em PostgreSQL. O backend entrega +As Fases 1 (fundação), 2 (domínio), 3 (simulação e leituras), 4 (gestão +energética) e 5 (billing e histórico de invoices) estão concluídas, com +critérios de saída e evidências documentados. As migrations das Fases 3 e 5 +foram validadas online em PostgreSQL. O backend entrega Users/Auth, Vehicles, Stations, Chargers e Sessions sob `/api/v1`, com JWT, autorização por papel e propriedade, persistência via Alembic e regras de início/encerramento de sessão na camada de serviço. @@ -100,3 +102,10 @@ duplicatas mesmo em escrita concorrente, e uma falha desfaz todo o tick. O relógio avança apenas depois do commit. O chamador deve passar uma sessão de banco sem transação ativa e um relógio iniciado; em caso de erro pode repetir o mesmo tick após corrigir a causa. + +Na Fase 5, a sessão captura a tarifa ativa válida no início. Ao encerrar, o +backend calcula o custo Pay-per-Use, cria uma invoice `CLOSED` e registra um +alerta na mesma transação. O histórico de invoices está disponível em +`GET /api/v1/billing/invoices` e `GET /api/v1/billing/invoices/{invoice_id}`, +com acesso restrito às próprias invoices para usuários comuns. Consulte +[a validação da Fase 5](docs/PHASE_5.md) para os critérios de aceite e testes. diff --git a/backend/app/api/routes/billing.py b/backend/app/api/routes/billing.py index 80ea7bf..52b7d63 100644 --- a/backend/app/api/routes/billing.py +++ b/backend/app/api/routes/billing.py @@ -5,8 +5,16 @@ from fastapi import APIRouter, HTTPException, Query, status from sqlalchemy import select, update -from app.api.routes.common import DbSession, commit_or_conflict, get_or_404 +from app.api.dependencies import AdminUser, CurrentUser +from app.api.routes.common import ( + NOT_FOUND_RESPONSE, + UNAUTHORIZED_RESPONSE, + DbSession, + commit_or_conflict, + get_or_404, +) from app.models.billing import Invoice, InvoiceStatus, Tariff +from app.models.user import UserRole from app.schemas.billing import InvoiceResponse, TariffCreate, TariffResponse, TariffUpdate router = APIRouter(tags=["billing"]) @@ -29,12 +37,12 @@ async def list_tariffs(db: DbSession) -> list[Tariff]: @router.post("/tariffs", response_model=TariffResponse, status_code=status.HTTP_201_CREATED) -async def create_tariff(payload: TariffCreate, db: DbSession) -> Tariff: +async def create_tariff(payload: TariffCreate, db: DbSession, _admin: AdminUser) -> Tariff: tariff = Tariff(**payload.model_dump()) if tariff.is_active: deactivate_other_tariffs(db) db.add(tariff) - commit_or_conflict(db) + commit_or_conflict(db, {"uq_tariffs_one_active": "Only one tariff can be active"}) db.refresh(tariff) return tariff @@ -45,7 +53,9 @@ async def get_tariff(tariff_id: UUID, db: DbSession) -> Tariff: @router.patch("/tariffs/{tariff_id}", response_model=TariffResponse) -async def update_tariff(payload: TariffUpdate, tariff_id: UUID, db: DbSession) -> Tariff: +async def update_tariff( + payload: TariffUpdate, tariff_id: UUID, db: DbSession, _admin: AdminUser +) -> Tariff: tariff = get_or_404(db, Tariff, tariff_id) changes = payload.model_dump(exclude_unset=True) valid_from = changes.get("valid_from", tariff.valid_from) @@ -56,18 +66,27 @@ async def update_tariff(payload: TariffUpdate, tariff_id: UUID, db: DbSession) - deactivate_other_tariffs(db, tariff.id) for field, value in changes.items(): setattr(tariff, field, value) - commit_or_conflict(db) + commit_or_conflict(db, {"uq_tariffs_one_active": "Only one tariff can be active"}) db.refresh(tariff) return tariff -@router.get("/billing/invoices", response_model=list[InvoiceResponse]) +@router.get( + "/billing/invoices", + response_model=list[InvoiceResponse], + responses=UNAUTHORIZED_RESPONSE | NOT_FOUND_RESPONSE, +) async def list_invoices( db: DbSession, + current_user: CurrentUser, user_id: UUID | None = None, invoice_status: Annotated[InvoiceStatus | None, Query(alias="status")] = None, ) -> list[Invoice]: statement = select(Invoice) + if current_user.role != UserRole.ADMIN: + if user_id is not None and user_id != current_user.id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Resource not found") + statement = statement.where(Invoice.user_id == current_user.id) if user_id is not None: statement = statement.where(Invoice.user_id == user_id) if invoice_status is not None: @@ -75,6 +94,13 @@ async def list_invoices( return list(db.scalars(statement.order_by(Invoice.created_at, Invoice.id)).all()) -@router.get("/billing/invoices/{invoice_id}", response_model=InvoiceResponse) -async def get_invoice(invoice_id: UUID, db: DbSession) -> Invoice: - return get_or_404(db, Invoice, invoice_id) +@router.get( + "/billing/invoices/{invoice_id}", + response_model=InvoiceResponse, + responses=UNAUTHORIZED_RESPONSE | NOT_FOUND_RESPONSE, +) +async def get_invoice(invoice_id: UUID, db: DbSession, current_user: CurrentUser) -> Invoice: + invoice = get_or_404(db, Invoice, invoice_id) + if current_user.role != UserRole.ADMIN and invoice.user_id != current_user.id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Resource not found") + return invoice diff --git a/backend/app/models/billing.py b/backend/app/models/billing.py index 4365262..5c89732 100644 --- a/backend/app/models/billing.py +++ b/backend/app/models/billing.py @@ -3,7 +3,18 @@ from decimal import Decimal from enum import StrEnum -from sqlalchemy import Boolean, CheckConstraint, DateTime, Enum, ForeignKey, Numeric, String, func +from sqlalchemy import ( + Boolean, + CheckConstraint, + DateTime, + Enum, + ForeignKey, + Index, + Numeric, + String, + func, + text, +) from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -22,6 +33,10 @@ class Tariff(Base): CheckConstraint( "valid_until IS NULL OR valid_until > valid_from", name="ck_tariffs_valid_period" ), + Index( + "uq_tariffs_one_active", "is_active", unique=True, + postgresql_where=text("is_active"), sqlite_where=text("is_active = 1"), + ), ) id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) diff --git a/backend/app/services/billing.py b/backend/app/services/billing.py new file mode 100644 index 0000000..78bd567 --- /dev/null +++ b/backend/app/services/billing.py @@ -0,0 +1,10 @@ +from decimal import ROUND_HALF_UP, Decimal + +CENT = Decimal("0.01") + + +def calculate_pay_per_use_total(energy_kwh: float, tariff_per_kwh: Decimal) -> Decimal: + """Price accumulated energy at the tariff captured when the session started.""" + return (Decimal(str(energy_kwh)) * tariff_per_kwh).quantize( + CENT, rounding=ROUND_HALF_UP + ) diff --git a/backend/app/services/charging_sessions.py b/backend/app/services/charging_sessions.py index 7ec45ce..85ef04c 100644 --- a/backend/app/services/charging_sessions.py +++ b/backend/app/services/charging_sessions.py @@ -10,6 +10,7 @@ from app.models.infrastructure import Charger, ChargerStatus from app.models.user import User from app.models.vehicle import Vehicle +from app.services.billing import calculate_pay_per_use_total from app.services.errors import DomainConflictError, DomainResourceNotFoundError ACTIVE_SESSION_STATUSES = ( @@ -94,6 +95,7 @@ def start_charging_session( Tariff.valid_from <= started_at, or_(Tariff.valid_until.is_(None), Tariff.valid_until > started_at), ) + .order_by(Tariff.valid_from.desc(), Tariff.created_at.desc(), Tariff.id.desc()) ) if tariff is None: raise DomainConflictError("No active tariff is valid for the session start time") @@ -123,8 +125,8 @@ def stop_charging_session(db: Session, session: ChargingSession, charger: Charge closed_at = datetime.now(UTC) session.ended_at = closed_at session.allocated_power_kw = 0 - subtotal = (Decimal(str(session.energy_consumed_kwh)) * session.tariff_per_kwh).quantize( - Decimal("0.01") + subtotal = calculate_pay_per_use_total( + session.energy_consumed_kwh, session.tariff_per_kwh ) session.total_cost = subtotal charger.status = ChargerStatus.AVAILABLE diff --git a/backend/migrations/versions/20260916_0011_one_active_tariff.py b/backend/migrations/versions/20260916_0011_one_active_tariff.py new file mode 100644 index 0000000..e958942 --- /dev/null +++ b/backend/migrations/versions/20260916_0011_one_active_tariff.py @@ -0,0 +1,45 @@ +"""Keep at most one active default tariff. + +Revision ID: 20260916_0011 +Revises: 20260916_0010 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260916_0011" +down_revision: str | None = "20260916_0010" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + tariffs = sa.table( + "tariffs", + sa.column("id", sa.Uuid()), + sa.column("is_active", sa.Boolean()), + sa.column("valid_from", sa.DateTime(timezone=True)), + sa.column("created_at", sa.DateTime(timezone=True)), + ) + ranked = sa.select( + tariffs.c.id, + sa.func.row_number().over( + order_by=( + tariffs.c.valid_from.desc(), tariffs.c.created_at.desc(), tariffs.c.id.desc() + ) + ).label("position"), + ).where(tariffs.c.is_active.is_(True)).subquery() + obsolete_ids = sa.select(ranked.c.id).where(ranked.c.position > 1) + op.execute( + sa.update(tariffs).where(tariffs.c.id.in_(obsolete_ids)).values(is_active=False) + ) + op.create_index( + "uq_tariffs_one_active", "tariffs", ["is_active"], unique=True, + postgresql_where=sa.text("is_active"), sqlite_where=sa.text("is_active = 1"), + ) + + +def downgrade() -> None: + op.drop_index("uq_tariffs_one_active", table_name="tariffs") diff --git a/backend/tests/test_billing_alerts.py b/backend/tests/test_billing_alerts.py index ee0cb10..a44c608 100644 --- a/backend/tests/test_billing_alerts.py +++ b/backend/tests/test_billing_alerts.py @@ -1,13 +1,106 @@ +from datetime import UTC, datetime, timedelta +from decimal import Decimal from uuid import UUID import pytest from httpx import AsyncClient +from sqlalchemy import event, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session -from app.models.energy import ChargingSession +from app.core.security import hash_password +from app.models.alert import Alert +from app.models.billing import Invoice, Tariff +from app.models.energy import ChargingSession, ChargingSessionStatus +from app.models.infrastructure import Charger, ChargerStatus +from app.models.user import User, UserRole +from tests.conftest import ADMIN_EMAIL, ADMIN_PASSWORD +from tests.test_auth_authorization import login_headers +from tests.test_charging_session_domain import ( + create_charger, + create_user_and_headers, + create_vehicle, + start_session, +) from tests.test_energy import create_session_dependencies +@pytest.mark.parametrize( + ("energy_kwh", "expected_total"), + [(25, "23.00"), (0.125, "0.12"), (0, "0.00")], +) +@pytest.mark.anyio +async def test_stop_bills_accumulated_energy_once( + client: AsyncClient, db_session: Session, energy_kwh: float, expected_total: str +) -> None: + _, headers = await create_user_and_headers(client, suffix=f"billing-{energy_kwh}") + vehicle = await create_vehicle(client, headers, suffix=f"billing-{energy_kwh}") + charger = await create_charger(client, suffix=f"billing-{energy_kwh}") + started = await start_session(client, headers, vehicle, charger) + session_id = UUID(started.json()["id"]) + session = db_session.get(ChargingSession, session_id) + assert session is not None + session.energy_consumed_kwh = energy_kwh + db_session.commit() + + stopped = await client.post(f"/api/v1/sessions/{session_id}/stop", headers=headers) + assert stopped.status_code == 200 + assert stopped.json()["total_cost"] == expected_total + assert stopped.json()["status"] == "COMPLETED" + assert (await client.get(f"/api/v1/chargers/{charger['id']}")).json()["status"] == "AVAILABLE" + invoices = list(db_session.scalars(select(Invoice).where(Invoice.session_id == session_id))) + assert len(invoices) == 1 + assert invoices[0].status.value == "CLOSED" + assert invoices[0].total == invoices[0].subtotal == Decimal(expected_total) + assert invoices[0].tariff_per_kwh == Decimal("0.9200") + assert invoices[0].energy_kwh == Decimal(str(energy_kwh)) + + repeated = await client.post(f"/api/v1/sessions/{session_id}/stop", headers=headers) + assert repeated.status_code == 409 + invoices = list(db_session.scalars(select(Invoice).where(Invoice.session_id == session_id))) + assert len(invoices) == 1 + + +@pytest.mark.anyio +async def test_invoice_failure_rolls_back_session_charger_and_alert( + client: AsyncClient, db_session: Session +) -> None: + _, headers = await create_user_and_headers(client, suffix="invoice-rollback") + vehicle = await create_vehicle(client, headers, suffix="invoice-rollback") + charger = await create_charger(client, suffix="invoice-rollback") + started = await start_session(client, headers, vehicle, charger) + session_id = UUID(started.json()["id"]) + session = db_session.get(ChargingSession, session_id) + assert session is not None + session.energy_consumed_kwh = 25 + db_session.commit() + + def reject_invoice(*_: object) -> None: + raise IntegrityError("INSERT invoice", {}, Exception("simulated invoice failure")) + + event.listen(Invoice, "before_insert", reject_invoice) + try: + with pytest.raises(IntegrityError, match="simulated invoice failure"): + await client.post(f"/api/v1/sessions/{session_id}/stop", headers=headers) + finally: + event.remove(Invoice, "before_insert", reject_invoice) + + db_session.expire_all() + session = db_session.get(ChargingSession, session_id) + assert session is not None + assert session.status == ChargingSessionStatus.CHARGING + assert session.ended_at is None + assert session.total_cost == Decimal("0.00") + stored_charger = db_session.get(Charger, UUID(charger["id"])) + assert stored_charger is not None + assert stored_charger.status == ChargerStatus.CHARGING + assert db_session.scalar(select(Invoice).where(Invoice.session_id == session_id)) is None + station_alert = db_session.scalar( + select(Alert).where(Alert.station_id == UUID(charger["station_id"])) + ) + assert station_alert is None + + @pytest.mark.anyio async def test_tariff_crud_keeps_only_latest_active(client: AsyncClient) -> None: first = await client.post( @@ -43,6 +136,92 @@ async def test_tariff_crud_keeps_only_latest_active(client: AsyncClient) -> None assert missing.status_code == 404 +@pytest.mark.anyio +async def test_only_admin_can_change_tariffs(client: AsyncClient, db_session: Session) -> None: + user = User( + name="Driver", email="tariff-driver@example.com", + password_hash=hash_password("secret123"), role=UserRole.USER, + ) + db_session.add(user) + db_session.commit() + headers = await login_headers(client, user.email, "secret123") + tariff = db_session.query(Tariff).first() + assert tariff is not None + payload = { + "name": "Unauthorized", "price_per_kwh": "0.0100", + "valid_from": "2020-01-01T00:00:00Z", + } + assert (await client.post("/api/v1/tariffs", headers=headers, json=payload)).status_code == 403 + assert ( + await client.patch( + f"/api/v1/tariffs/{tariff.id}", headers=headers, + json={"price_per_kwh": "0.0100"}, + ) + ).status_code == 403 + client.headers.pop("Authorization") + assert (await client.post("/api/v1/tariffs", json=payload)).status_code == 401 + assert ( + await client.patch(f"/api/v1/tariffs/{tariff.id}", json={"price_per_kwh": "0.0100"}) + ).status_code == 401 + db_session.refresh(tariff) + assert tariff.price_per_kwh == Decimal("0.9200") + + +@pytest.mark.anyio +async def test_database_rejects_two_active_tariffs( + db_session: Session, client: AsyncClient +) -> None: + db_session.add( + Tariff( + name="Duplicate", price_per_kwh=Decimal("1.0000"), currency="BRL", + is_active=True, valid_from=datetime(2020, 1, 1, tzinfo=UTC), + ) + ) + with pytest.raises(IntegrityError): + db_session.commit() + db_session.rollback() + + +@pytest.mark.anyio +async def test_tariff_switch_respects_validity_and_preserves_session_price( + client: AsyncClient, db_session: Session +) -> None: + _, headers = await create_user_and_headers(client, suffix="tariff-switch") + first_vehicle = await create_vehicle(client, headers, suffix="tariff-switch-a") + first_charger = await create_charger(client, suffix="tariff-switch-a") + first = await start_session(client, headers, first_vehicle, first_charger) + assert first.status_code == 201 + admin_headers = await login_headers(client, ADMIN_EMAIL, ADMIN_PASSWORD) + future = await client.post( + "/api/v1/tariffs", headers=admin_headers, + json={ + "name": "Future", "price_per_kwh": "1.5000", + "valid_from": (datetime.now(UTC) + timedelta(days=1)).isoformat(), + }, + ) + assert future.status_code == 201 + second_vehicle = await create_vehicle(client, headers, suffix="tariff-switch-b") + second_charger = await create_charger(client, suffix="tariff-switch-b") + assert (await start_session(client, headers, second_vehicle, second_charger)).status_code == 409 + activated = await client.patch( + f"/api/v1/tariffs/{future.json()['id']}", headers=admin_headers, + json={"valid_from": (datetime.now(UTC) - timedelta(minutes=1)).isoformat()}, + ) + assert activated.status_code == 200 + second = await start_session(client, headers, second_vehicle, second_charger) + assert second.status_code == 201 + assert first.json()["tariff_per_kwh"] == "0.9200" + assert second.json()["tariff_per_kwh"] == "1.5000" + session = db_session.get(ChargingSession, UUID(first.json()["id"])) + assert session is not None + session.energy_consumed_kwh = 10 + db_session.commit() + stopped = await client.post(f"/api/v1/sessions/{session.id}/stop", headers=headers) + assert stopped.status_code == 200 + assert stopped.json()["tariff_per_kwh"] == "0.9200" + assert stopped.json()["total_cost"] == "9.20" + + @pytest.mark.anyio async def test_stopping_session_creates_closed_invoice_and_alert( client: AsyncClient, db_session: Session diff --git a/backend/tests/test_invoice_history.py b/backend/tests/test_invoice_history.py new file mode 100644 index 0000000..a8defda --- /dev/null +++ b/backend/tests/test_invoice_history.py @@ -0,0 +1,119 @@ +from datetime import UTC, datetime +from decimal import Decimal +from uuid import UUID, uuid4 + +import pytest +from httpx import AsyncClient +from sqlalchemy.orm import Session + +from app.models.billing import Invoice, InvoiceStatus, Tariff +from tests.test_charging_session_domain import ( + create_charger, + create_user_and_headers, + create_vehicle, + start_session, +) + + +async def closed_invoice( + client: AsyncClient, db: Session, *, suffix: str, energy_kwh: float +) -> tuple[dict[str, object], dict[str, str], Invoice]: + user, headers = await create_user_and_headers(client, suffix=suffix) + vehicle = await create_vehicle(client, headers, suffix=suffix) + charger = await create_charger(client, suffix=suffix) + started = await start_session(client, headers, vehicle, charger) + assert started.status_code == 201 + from app.models.energy import ChargingSession + + session = db.get(ChargingSession, UUID(started.json()["id"])) + assert session is not None + session.energy_consumed_kwh = energy_kwh + db.commit() + stopped = await client.post(f"/api/v1/sessions/{session.id}/stop", headers=headers) + assert stopped.status_code == 200 + invoice = db.query(Invoice).filter_by(session_id=session.id).one() + return user, headers, invoice + + +@pytest.mark.anyio +async def test_invoice_history_is_scoped_and_admin_can_filter( + client: AsyncClient, db_session: Session +) -> None: + first_user, first_headers, first = await closed_invoice( + client, db_session, suffix="history-a", energy_kwh=10 + ) + second_user, second_headers, second = await closed_invoice( + client, db_session, suffix="history-b", energy_kwh=20 + ) + base = "/api/v1/billing/invoices" + + own = await client.get(base, headers=first_headers) + assert own.status_code == 200 + assert [item["id"] for item in own.json()] == [str(first.id)] + own_filter = await client.get( + base, headers=first_headers, params={"user_id": str(first_user["id"])} + ) + assert own_filter.status_code == 200 + assert (await client.get(f"{base}/{first.id}", headers=first_headers)).status_code == 200 + assert (await client.get(base, headers=second_headers)).json()[0]["id"] == str(second.id) + + for response in ( + await client.get(f"{base}/{second.id}", headers=first_headers), + await client.get(base, headers=first_headers, params={"user_id": str(second_user["id"])}), + await client.get(f"{base}/{uuid4()}", headers=first_headers), + ): + assert response.status_code == 404 + assert response.json() == {"detail": "Resource not found"} + + all_invoices = await client.get(base) + assert all_invoices.status_code == 200 + assert {item["id"] for item in all_invoices.json()} == {str(first.id), str(second.id)} + admin_filter = await client.get(base, params={"user_id": str(second_user["id"])}) + assert [item["id"] for item in admin_filter.json()] == [str(second.id)] + assert (await client.get(f"{base}/{second.id}")).status_code == 200 + assert (await client.get(base, params={"status": "OPEN"})).json() == [] + assert len((await client.get(base, params={"status": "CLOSED"})).json()) == 2 + combined = await client.get( + base, params={"user_id": str(first_user["id"]), "status": "CLOSED"} + ) + assert [item["id"] for item in combined.json()] == [str(first.id)] + assert (await client.get(base, params={"status": "UNKNOWN"})).status_code == 422 + + client.headers.pop("Authorization") + for path in (base, f"{base}/{first.id}"): + response = await client.get(path) + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer" + invalid = await client.get(path, headers={"Authorization": "Bearer invalid"}) + assert invalid.status_code == 401 + + +@pytest.mark.anyio +async def test_invoice_order_is_stable_and_tariff_changes_do_not_reprice_history( + client: AsyncClient, db_session: Session +) -> None: + user, headers, first = await closed_invoice( + client, db_session, suffix="stable-a", energy_kwh=10 + ) + _, _, second = await closed_invoice(client, db_session, suffix="stable-b", energy_kwh=20) + timestamp = datetime(2026, 9, 1, tzinfo=UTC) + first.created_at = second.created_at = timestamp + first.status = InvoiceStatus.OPEN + tariff = db_session.query(Tariff).one() + tariff.price_per_kwh = Decimal("2.0000") + db_session.commit() + + base = "/api/v1/billing/invoices" + expected_order = [str(invoice.id) for invoice in sorted((first, second), key=lambda i: i.id)] + for _ in range(2): + response = await client.get(base) + assert [item["id"] for item in response.json()] == expected_order + open_filter = await client.get(base, params={"status": "OPEN"}) + assert [item["id"] for item in open_filter.json()] == [str(first.id)] + assert (await client.get(base, headers=headers, params={"status": "CLOSED"})).json() == [] + detail = (await client.get(f"{base}/{first.id}", headers=headers)).json() + assert detail["user_id"] == str(user["id"]) + assert detail["energy_kwh"] == "10.0000" + assert detail["tariff_per_kwh"] == "0.9200" + assert detail["subtotal"] == detail["total"] == "9.20" + assert detail["closed_at"] is not None diff --git a/backend/tests/test_openapi.py b/backend/tests/test_openapi.py index fc91fe2..d9751af 100644 --- a/backend/tests/test_openapi.py +++ b/backend/tests/test_openapi.py @@ -26,6 +26,8 @@ async def test_openapi_is_available() -> None: assert "/api/v1/solar/history" in paths assert "/api/v1/predictions/demand" in paths assert "/api/v1/system-configuration" in paths + assert "/api/v1/billing/invoices" in paths + assert "/api/v1/billing/invoices/{invoice_id}" in paths bearer = [{"HTTPBearer": []}] protected_operations = { @@ -51,6 +53,8 @@ async def test_openapi_is_available() -> None: ("/api/v1/sessions/{session_id}", "get"), ("/api/v1/sessions/start", "post"), ("/api/v1/sessions/{session_id}/stop", "post"), + ("/api/v1/billing/invoices", "get"), + ("/api/v1/billing/invoices/{invoice_id}", "get"), } for path, method in protected_operations: assert paths[path][method]["security"] == bearer @@ -82,6 +86,8 @@ async def test_openapi_is_available() -> None: ("/api/v1/sessions/{session_id}", "get"): {"401", "404"}, ("/api/v1/sessions/start", "post"): {"401", "403", "404", "409"}, ("/api/v1/sessions/{session_id}/stop", "post"): {"401", "403", "404", "409"}, + ("/api/v1/billing/invoices", "get"): {"401", "404"}, + ("/api/v1/billing/invoices/{invoice_id}", "get"): {"401", "404"}, } for (path, method), expected_statuses in expected_error_responses.items(): responses = paths[path][method]["responses"] @@ -89,3 +95,8 @@ async def test_openapi_is_available() -> None: for response_status in expected_statuses: schema = responses[response_status]["content"]["application/json"]["schema"] assert schema == {"$ref": "#/components/schemas/ErrorResponse"} + + invoice_parameters = paths["/api/v1/billing/invoices"]["get"]["parameters"] + assert {(item["name"], item["in"]) for item in invoice_parameters} == { + ("user_id", "query"), ("status", "query") + } diff --git a/backend/tests/test_simulation_api.py b/backend/tests/test_simulation_api.py index c8d4db9..faf0468 100644 --- a/backend/tests/test_simulation_api.py +++ b/backend/tests/test_simulation_api.py @@ -12,6 +12,7 @@ from app.core.security import create_access_token from app.main import app +from app.models.billing import Invoice from app.models.energy import ChargingSession, ChargingSessionStatus, EnergyReading, SolarReading from app.models.infrastructure import Charger, ChargingStation from app.models.prediction import SystemConfiguration @@ -20,10 +21,101 @@ from app.services.energy_allocation import PowerBreakdown, SessionPowerRequest from app.simulation.clock import SimulationClock from app.simulation.control import SimulationController, get_simulation_controller +from tests.test_charging_session_domain import ( + create_user_and_headers, + create_vehicle, + start_session, +) INSTANT = datetime(2026, 9, 16, 12, tzinfo=UTC) +@pytest.mark.anyio +async def test_phase_5_session_ticks_close_and_invoice_history( + client: AsyncClient, db_session: Session, control: SimulationController +) -> None: + user, headers = await create_user_and_headers(client, suffix="phase-5-flow") + vehicle = await create_vehicle(client, headers, suffix="phase-5-flow") + station_response = await client.post( + "/api/v1/stations", + json={"name": "Billing station", "grid_limit_kw": 20, "station_peak_solar_kw": 3}, + ) + assert station_response.status_code == 201 + station = station_response.json() + charger_response = await client.post( + "/api/v1/chargers", + json={ + "station_id": station["id"], "name": "Billing charger", "code": "PHASE-5", + "max_power_kw": 11, + }, + ) + assert charger_response.status_code == 201 + charger = charger_response.json() + + started = await start_session(client, headers, vehicle, charger) + assert started.status_code == 201 + session_id = started.json()["id"] + assert started.json()["status"] == "CHARGING" + assert started.json()["tariff_per_kwh"] == "0.9200" + assert (await client.get(f"/api/v1/chargers/{charger['id']}")).json()["status"] == "CHARGING" + + assert (await client.post("/api/v1/simulation/start")).status_code == 200 + for _ in range(3): + tick = await client.post("/api/v1/simulation/ticks") + assert tick.status_code == 200 + assert tick.json()["energy_readings_created"] == 1 + assert control.clock.current_instant == INSTANT + timedelta(minutes=3) + + db_session.rollback() + readings = list( + db_session.scalars( + select(EnergyReading) + .where(EnergyReading.session_id == UUID(session_id)) + .order_by(EnergyReading.timestamp) + ) + ) + assert len(readings) == 3 + assert [reading.timestamp for reading in readings] == [ + INSTANT.replace(tzinfo=None) + timedelta(minutes=minute) for minute in range(3) + ] + assert all(reading.allocated_power_kw == 11 for reading in readings) + assert all(reading.grid_power_kw <= station["grid_limit_kw"] for reading in readings) + energy = sum(reading.interval_energy_kwh for reading in readings) + solar = sum(reading.solar_energy_kwh for reading in readings) + grid = sum(reading.grid_energy_kwh for reading in readings) + assert energy == pytest.approx(0.55) + assert solar > 0 + assert grid > 0 + assert energy == pytest.approx(solar + grid) + + stopped = await client.post(f"/api/v1/sessions/{session_id}/stop", headers=headers) + assert stopped.status_code == 200 + assert stopped.json()["status"] == "COMPLETED" + assert stopped.json()["energy_consumed_kwh"] == pytest.approx(energy) + assert stopped.json()["solar_energy_kwh"] == pytest.approx(solar) + assert stopped.json()["grid_energy_kwh"] == pytest.approx(grid) + assert stopped.json()["allocated_power_kw"] == 0 + assert stopped.json()["ended_at"] is not None + assert stopped.json()["total_cost"] == "0.51" + assert (await client.get(f"/api/v1/chargers/{charger['id']}")).json()["status"] == "AVAILABLE" + + history = await client.get("/api/v1/billing/invoices", headers=headers) + assert history.status_code == 200 + assert len(history.json()) == 1 + invoice = history.json()[0] + assert invoice["session_id"] == session_id + assert invoice["user_id"] == user["id"] + assert invoice["status"] == "CLOSED" + assert Decimal(invoice["energy_kwh"]) == Decimal("0.5500") + assert invoice["tariff_per_kwh"] == "0.9200" + assert invoice["subtotal"] == invoice["total"] == "0.51" + assert invoice["closed_at"] is not None + detail = await client.get(f"/api/v1/billing/invoices/{invoice['id']}", headers=headers) + assert detail.status_code == 200 + assert detail.json() == invoice + assert db_session.query(Invoice).filter_by(session_id=UUID(session_id)).count() == 1 + + @pytest.fixture def control() -> SimulationController: value = SimulationController(SimulationClock(initial_instant=INSTANT)) diff --git a/docs/PHASE_5.md b/docs/PHASE_5.md new file mode 100644 index 0000000..be503ae --- /dev/null +++ b/docs/PHASE_5.md @@ -0,0 +1,82 @@ +# Fase 5 — Billing e histórico de invoices + +**Status: concluída.** + +## Escopo e critérios + +Revisão final feita contra `SPEC.md` (tarifa, invoice, cobrança Pay-per-Use, +fechamento e critérios de aceite 59–61) e `BRIEFING.md` (Fase 5: tarifas, +custo, invoices e histórico). O billing é simulado, sem processamento de +pagamento. A interface de dashboards pertence à Fase 6. + +## Comportamento entregue + +Ao encerrar uma sessão, o backend calcula `energy_kwh × tariff_per_kwh`, +arredonda o total para centavos e cria uma invoice `CLOSED` na mesma transação. +A invoice guarda energia, tarifa aplicada, subtotal, total e datas do fechamento. +Esses valores são registros históricos: alterar a tarifa posteriormente não +recalcula invoices existentes. + +O início exige uma tarifa ativa válida no instante real de abertura da sessão; +a sessão guarda o preço selecionado. A migration `20260916_0011` desativa +tarifas ativas antigas, mantendo a mais recente por `valid_from`, `created_at` +e `id`, e cria um índice único para permitir no máximo uma tarifa ativa. Nesta +revisão, a atualização foi expressa em SQL para funcionar também na geração +offline do Alembic. + +`GET /api/v1/billing/invoices` lista invoices por `created_at` e `id`, em ordem +crescente estável. Aceita os filtros opcionais `user_id` (UUID) e `status` +(`OPEN`, `CLOSED` ou `CANCELLED`), que podem ser combinados. Uma lista sem +resultados retorna `[]`. `GET /api/v1/billing/invoices/{invoice_id}` retorna a +invoice solicitada ou 404 se ela não existir ou não estiver visível. + +Ambos os endpoints exigem bearer token (401 quando ausente ou inválido). ADMIN +pode listar e consultar qualquer invoice. USER vê apenas as próprias; um filtro +`user_id` de outra pessoa retorna 404, assim como o acesso por ID a uma invoice +alheia. O filtro pelo próprio ID é permitido. + +## Fluxo integrado executado + +O teste `test_phase_5_session_ticks_close_and_invoice_history` cria usuário, +veículo, estação e carregador; usa a tarifa ativa válida de R$ 0,92/kWh; +inicia a sessão; executa três ticks de 60 segundos a 11 kW; encerra a sessão; +e consulta a invoice pela listagem e por ID. As três leituras somam 0,55 kWh, +com parcelas solar e da rede positivas cuja soma corresponde ao total. A +sessão termina `COMPLETED`, com potência alocada zero e `total_cost` de +R$ 0,51. O carregador volta a `AVAILABLE`. A única invoice da sessão fica +`CLOSED`, registra 0,5500 kWh e R$ 0,9200/kWh, com subtotal e total de +R$ 0,51 (`0,55 × 0,92`, arredondado para centavos). + +## Validação e comandos executados + +Os testes cobrem autorização por papel e propriedade, 401, 404, filtros, +ordenação com timestamps iguais e preservação dos valores históricos após +alteração da tarifa. O OpenAPI declara autenticação e respostas de erro para +ambos os endpoints, além dos parâmetros de filtro. + +Em 16/09/2026, em `backend/`, foram executados: + +```text +.venv/bin/pytest -q tests/test_simulation_api.py::test_phase_5_session_ticks_close_and_invoice_history # 1 passou +.venv/bin/pytest -q --no-cov # 166 passaram +.venv/bin/ruff check . # passou +.venv/bin/mypy app # passou, 62 arquivos +.venv/bin/alembic heads # 20260916_0011 (head) +.venv/bin/alembic upgrade head --sql # passou após correção +``` + +Um PostgreSQL 16 temporário e isolado recebeu `alembic upgrade head` até +`20260916_0011`; `alembic current` confirmou o head e `alembic check` não +encontrou operações pendentes. Depois de `alembic downgrade 20260916_0010`, +foram inseridas duas tarifas ativas legadas e executado novamente +`alembic upgrade head`: a tarifa mais recente ficou ativa, a antiga foi +desativada e o índice `uq_tariffs_one_active` foi criado. O contêiner foi +removido após a validação. `git diff --check` passou. + +## Limitações concretas + +O fluxo integrado de API foi executado com SQLite em memória, como os demais +testes backend; no PostgreSQL foram validadas as migrations e a estrutura, +sem repetir o fluxo HTTP completo. A simulação avança por ticks manuais do +ADMIN e não tem agendamento automático. A interface de dashboards da Fase 6 +não faz parte desta entrega.