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
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
44 changes: 35 additions & 9 deletions backend/app/api/routes/billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand All @@ -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

Expand All @@ -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)
Expand All @@ -56,25 +66,41 @@ 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:
statement = statement.where(Invoice.status == invoice_status)
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
17 changes: 16 additions & 1 deletion backend/app/models/billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions backend/app/services/billing.py
Original file line number Diff line number Diff line change
@@ -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
)
6 changes: 4 additions & 2 deletions backend/app/services/charging_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions backend/migrations/versions/20260916_0011_one_active_tariff.py
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading