import html import json import logging from time import perf_counter from datetime import datetime, timezone from urllib.parse import urlencode from fastapi import FastAPI, Header, HTTPException, Query, Request from fastapi.responses import HTMLResponse, RedirectResponse from database.orm import ORM from services.wata import WataClient from utils.logging_config import setup_logging setup_logging(service_name="webhooks") logger = logging.getLogger(__name__) app = FastAPI(title="WeechatPayBot Webhooks") orm = ORM() wata_client = WataClient() @app.get("/health") async def healthcheck(): return {"status": "ok"} @app.on_event("startup") async def on_startup(): await orm.proceed_schemas() logger.info("Webhook service startup completed") @app.middleware("http") async def log_http_requests(request: Request, call_next): started_at = perf_counter() try: response = await call_next(request) except Exception: duration_ms = round((perf_counter() - started_at) * 1000, 2) logger.exception( "HTTP request failed with unhandled exception", extra={ "method": request.method, "path": request.url.path, "client_ip": request.client.host if request.client else None, "duration_ms": duration_ms, }, ) raise duration_ms = round((perf_counter() - started_at) * 1000, 2) logger.info( "HTTP request handled", extra={ "method": request.method, "path": request.url.path, "status_code": response.status_code, "client_ip": request.client.host if request.client else None, "duration_ms": duration_ms, }, ) return response @app.post("/webhooks/wata") async def wata_webhook(request: Request, x_signature: str | None = Header(default=None)): raw_body = await request.body() if not x_signature: logger.warning("Webhook rejected because X-Signature header is missing") raise HTTPException(status_code=400, detail="Missing X-Signature header") is_valid_signature = await wata_client.verify_webhook_signature(raw_body, x_signature) if not is_valid_signature: logger.warning("Webhook rejected because signature verification failed") raise HTTPException(status_code=400, detail="Invalid signature") try: payload = json.loads(raw_body) except json.JSONDecodeError as exc: logger.warning("Webhook rejected because payload is not valid JSON") raise HTTPException(status_code=400, detail="Invalid JSON payload") from exc order_id = payload.get("orderId") transaction_status = payload.get("transactionStatus") if not order_id or not transaction_status: logger.warning( "Webhook rejected because required fields are missing", extra={ "has_order_id": bool(order_id), "has_transaction_status": bool(transaction_status), }, ) raise HTTPException(status_code=400, detail="orderId and transactionStatus are required") paid_at = None if payload.get("paymentTime"): paid_at = datetime.fromisoformat( payload["paymentTime"].strip().replace("Z", "+00:00") ) local_status = "pending" if transaction_status == "Paid": local_status = "paid" elif transaction_status == "Declined": local_status = "declined" await orm.update_payment_status( order_id=order_id, status=local_status, transaction_status=transaction_status, transaction_id=payload.get("transactionId"), error_code=payload.get("errorCode"), error_description=payload.get("errorDescription"), updated_at=datetime.now(timezone.utc), paid_at=paid_at, ) logger.info( "WATA webhook processed", extra={ "order_id": order_id, "transaction_status": transaction_status, "transaction_id": payload.get("transactionId"), "local_status": local_status, }, ) return {"ok": True} @app.get("/mock/wata/pay/{order_id}", response_class=HTMLResponse) async def mock_wata_payment_page( order_id: str, success_redirect_url: str = Query(...), fail_redirect_url: str = Query(...), ): if not wata_client.is_mock_mode: raise HTTPException(status_code=404, detail="Mock mode is disabled") payment = await orm.get_payment_by_order_id(order_id) if payment is None: raise HTTPException(status_code=404, detail="Payment not found") paid_url = "/mock/wata/complete/{order_id}?{query}".format( order_id=order_id, query=urlencode( {"status": "paid", "redirect_url": success_redirect_url}, ), ) declined_url = "/mock/wata/complete/{order_id}?{query}".format( order_id=order_id, query=urlencode( {"status": "declined", "redirect_url": fail_redirect_url}, ), ) amount_text = html.escape(str(payment.amount)) order_id_text = html.escape(order_id) return f""" Mock WATA Checkout

Mock-оплата WATA

Это тестовая страница. Она нужна, чтобы проверить сценарий оплаты без реальной WATA.

Заказ: {order_id_text}

Сумма: {amount_text} RUB

Успешная оплата Отклонить оплату
""" @app.get("/mock/wata/complete/{order_id}") async def mock_wata_complete_payment( order_id: str, status: str = Query(...), redirect_url: str = Query(...), ): if not wata_client.is_mock_mode: raise HTTPException(status_code=404, detail="Mock mode is disabled") payment = await orm.get_payment_by_order_id(order_id) if payment is None: raise HTTPException(status_code=404, detail="Payment not found") if status not in {"paid", "declined"}: raise HTTPException(status_code=400, detail="Invalid mock payment status") now = datetime.now(timezone.utc) transaction_status = "Paid" if status == "paid" else "Declined" error_description = None if status == "paid" else "Mock declined payment" await orm.update_payment_status( order_id=order_id, status=status, transaction_status=transaction_status, transaction_id=f"mock-{order_id}", error_description=error_description, updated_at=now, paid_at=now if status == "paid" else None, ) logger.info( "Mock payment completed", extra={ "order_id": order_id, "transaction_status": transaction_status, }, ) return RedirectResponse(url=redirect_url, status_code=302)