103 lines
4.1 KiB
Python
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,
|
|
},
|
|
)
|