DealDocumentScreening/src/contract_check/api/billing/pay_page.py

103 lines
4.1 KiB
Python

"""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,
},
)