import json import secrets from datetime import datetime, timezone from sqlalchemy import select from sqlalchemy.orm import Session, joinedload from app.core.config import Settings, get_settings from app.core.errors import AppError from app.models.enums import OrderStatus from app.models.orders import Order from app.models.payments import PaymentWebhookEvent from app.services.orders import fulfill_order_payment, get_order_by_provider_order_id from app.schemas.orders import PaymentCheckoutPublic from app.services.payments.gateway import PaymentGateway, PaymentVerification, get_payment_gateway class PaymentError(AppError): pass def _utcnow() -> datetime: return datetime.now(timezone.utc) def _record_webhook_event( db: Session, *, provider: str, provider_event_id: str, event_type: str, payload: dict, ) -> bool: """Return False if event was already processed (idempotent).""" existing = db.scalar( select(PaymentWebhookEvent).where(PaymentWebhookEvent.provider_event_id == provider_event_id) ) if existing: return False db.add( PaymentWebhookEvent( provider=provider, provider_event_id=provider_event_id, event_type=event_type, payload=json.dumps(payload), ) ) db.flush() return True def confirm_payment_for_order( db: Session, order: Order, verification: PaymentVerification, *, provider_payment_id: str, ) -> Order: if order.status == OrderStatus.paid: return order if order.status not in (OrderStatus.pending,): raise PaymentError("Order is not awaiting payment", status_code=409) if order.provider_order_id and verification.provider_order_id != order.provider_order_id: raise PaymentError("Payment order mismatch", status_code=400) if verification.amount_paise != order.total_paise: raise PaymentError("Payment amount mismatch", status_code=400) if verification.currency.upper() != order.currency.upper(): raise PaymentError("Payment currency mismatch", status_code=400) return fulfill_order_payment(db, order, provider_payment_id=provider_payment_id) def verify_checkout_and_confirm( db: Session, *, order: Order, provider_order_id: str, provider_payment_id: str, signature: str, gateway: PaymentGateway | None = None, ) -> Order: gateway = gateway or get_payment_gateway() if order.provider_order_id != provider_order_id: raise PaymentError("Invalid payment order", status_code=400) try: gateway.verify_checkout_signature( provider_order_id=provider_order_id, provider_payment_id=provider_payment_id, signature=signature, ) except Exception as exc: raise PaymentError("Payment verification failed", status_code=400) from exc verification = PaymentVerification( provider_order_id=provider_order_id, provider_payment_id=provider_payment_id, amount_paise=order.total_paise, currency=order.currency, ) return confirm_payment_for_order(db, order, verification, provider_payment_id=provider_payment_id) def process_webhook( db: Session, *, body: bytes, signature: str | None, gateway: PaymentGateway | None = None, ) -> dict: gateway = gateway or get_payment_gateway() try: gateway.verify_webhook_signature(body, signature) except Exception as exc: raise PaymentError("Invalid webhook signature", status_code=400) from exc payload = json.loads(body.decode()) event_type = payload.get("event", "unknown") provider_event_id = payload.get("id") or payload.get("event_id") or secrets.token_hex(8) if not _record_webhook_event( db, provider=gateway.provider_name, provider_event_id=str(provider_event_id), event_type=event_type, payload=payload, ): db.commit() return {"status": "duplicate_ignored"} if event_type == "payment.failed": order = _order_from_webhook_payload(db, payload) if order and order.status == OrderStatus.pending: from app.services.orders import cancel_pending_order cancel_pending_order(db, order, status=OrderStatus.failed) db.commit() return {"status": "payment_failed_handled"} verification = gateway.parse_webhook_payment(payload) if verification is None: db.commit() return {"status": "ignored"} order = get_order_by_provider_order_id(db, verification.provider_order_id) if not order: db.commit() return {"status": "order_not_found"} confirm_payment_for_order( db, order, verification, provider_payment_id=verification.provider_payment_id, ) db.commit() return {"status": "paid"} def _order_from_webhook_payload(db: Session, payload: dict) -> Order | None: entity = payload.get("payload", {}).get("payment", {}).get("entity", {}) provider_order_id = entity.get("order_id") if not provider_order_id: return None return get_order_by_provider_order_id(db, provider_order_id) def build_checkout_payload(order: Order, gateway: PaymentGateway | None = None) -> PaymentCheckoutPublic: gateway = gateway or get_payment_gateway() settings = get_settings() return PaymentCheckoutPublic( provider=gateway.provider_name, key_id=gateway.public_key_id, provider_order_id=order.provider_order_id or "", amount_paise=order.total_paise, currency=order.currency, order_id=order.id, expires_at=order.expires_at, timeout_minutes=settings.order_payment_timeout_minutes, ) def mock_complete_pending_order(db: Session, order: Order) -> Order: settings = get_settings() if settings.payment_provider.lower() != "mock": raise PaymentError("Mock completion is not enabled", status_code=404) gateway = get_payment_gateway(settings) from app.services.payments.gateway import MockPaymentGateway if not isinstance(gateway, MockPaymentGateway): raise PaymentError("Mock gateway required", status_code=404) provider_order_id = order.provider_order_id or "" provider_payment_id = f"mock_pay_{order.id}" signature = gateway.build_mock_checkout_signature(provider_order_id, provider_payment_id) return verify_checkout_and_confirm( db, order=order, provider_order_id=provider_order_id, provider_payment_id=provider_payment_id, signature=signature, gateway=gateway, )