203 lines
6.5 KiB
Python
203 lines
6.5 KiB
Python
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,
|
|
)
|