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"""
Это тестовая страница. Она нужна, чтобы проверить сценарий оплаты без реальной WATA.