"""Token-gated pay-page routes (ticket 014). A short-lived signed JWT lets users return from a payment provider and see invoice status without exposing raw database ids or requiring login. """ from __future__ import annotations import uuid from typing import Annotated import jwt from fastapi import APIRouter, HTTPException, Query, Request, status from fastapi.responses import HTMLResponse, RedirectResponse from src.contract_check.api.billing.templating import templates from src.contract_check.core.billing.errors import PaymentProviderError from src.contract_check.core.billing.yookassa import build_payment_provider from src.contract_check.core.config import get_settings from src.contract_check.core.logging import get_logger log = get_logger(__name__) router = APIRouter(tags=["billing"]) def verify_pay_token(token: str | None) -> uuid.UUID: """Validate a pay-page token and return the invoice id.""" settings = get_settings() secret = settings.billing_return_jwt_secret or settings.jwt_secret if not secret or token is None: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="missing token", ) try: payload = jwt.decode(token, secret, algorithms=["HS256"]) except jwt.ExpiredSignatureError as exc: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="token expired") from exc except jwt.InvalidTokenError as exc: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="invalid token") from exc if payload.get("sub") != "pay" or "inv" not in payload: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="invalid token") try: return uuid.UUID(payload["inv"]) except ValueError as exc: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="invalid token") from exc @router.get( "/pay/{invoice_id}", response_class=HTMLResponse, response_model=None, include_in_schema=False ) async def pay_page( request: Request, invoice_id: uuid.UUID, token: Annotated[str | None, Query(min_length=1)] = None, ) -> HTMLResponse | RedirectResponse: """HTML status page returned after a payment provider redirect.""" token_invoice_id = verify_pay_token(token) if token_invoice_id != invoice_id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="token mismatch") # If we can reach the provider, re-fetch to show the most current status. settings = get_settings() payment_url: str | None = None status_value = "pending" amount_rub = "0.00" if settings.yookassa_enabled: try: provider = build_payment_provider(settings) async with request.app.state.db_session_factory() as session: from src.contract_check.core.db.repositories import InvoicesRepository row = await InvoicesRepository(session).get(invoice_id) if row is not None: status_value = row["status"] amount_rub = f"{row['amount'] / 100:.2f}" payment_url = row.get("confirmation_url") if status_value == "pending" and row.get("external_id"): try: info = await provider.get_payment(row["external_id"]) if info.status in ("succeeded", "cancelled", "canceled"): # Let the webhook/worker own DB writes; just render current status. status_value = ( "succeeded" if info.status == "succeeded" else "cancelled" ) except PaymentProviderError: pass except Exception: log.exception("pay_page_provider_fetch_failed", invoice_id=str(invoice_id)) return templates.TemplateResponse( request, "pay.html", { "request": request, "error": None, "status": status_value, "invoice_id": str(invoice_id), "amount_rub": amount_rub, "payment_url": payment_url, }, )