first commit

This commit is contained in:
Anurag Jangid 2026-10-08 02:52:58 +05:30
commit 34ea15a54d
246 changed files with 18660 additions and 0 deletions

View file

@ -0,0 +1,3 @@
from app.services.payments.gateway import get_payment_gateway
__all__ = ["get_payment_gateway"]

View file

@ -0,0 +1,220 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
from abc import ABC, abstractmethod
from dataclasses import dataclass
from app.core.config import Settings, get_settings
@dataclass(frozen=True)
class ProviderOrder:
provider_order_id: str
amount_paise: int
currency: str
@dataclass(frozen=True)
class PaymentVerification:
provider_order_id: str
provider_payment_id: str
amount_paise: int
currency: str
class PaymentGateway(ABC):
@property
@abstractmethod
def provider_name(self) -> str: ...
@property
@abstractmethod
def public_key_id(self) -> str: ...
@abstractmethod
def create_provider_order(
self,
*,
amount_paise: int,
currency: str,
receipt: str,
notes: dict[str, str],
) -> ProviderOrder: ...
@abstractmethod
def verify_checkout_signature(
self,
*,
provider_order_id: str,
provider_payment_id: str,
signature: str,
) -> None: ...
@abstractmethod
def verify_webhook_signature(self, body: bytes, signature: str | None) -> None: ...
@abstractmethod
def parse_webhook_payment(self, payload: dict) -> PaymentVerification | None: ...
class MockPaymentGateway(PaymentGateway):
"""Local/test gateway — signatures derived server-side only (never exposed to clients)."""
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._secret = settings.payment_mock_secret
@property
def provider_name(self) -> str:
return "mock"
@property
def public_key_id(self) -> str:
return "mock_public_key"
def create_provider_order(
self,
*,
amount_paise: int,
currency: str,
receipt: str,
notes: dict[str, str],
) -> ProviderOrder:
token = secrets.token_hex(6)
return ProviderOrder(
provider_order_id=f"mock_order_{receipt}_{token}",
amount_paise=amount_paise,
currency=currency,
)
def _expected_signature(self, provider_order_id: str, provider_payment_id: str) -> str:
message = f"{provider_order_id}|{provider_payment_id}".encode()
return hmac.new(self._secret.encode(), message, hashlib.sha256).hexdigest()
def verify_checkout_signature(
self,
*,
provider_order_id: str,
provider_payment_id: str,
signature: str,
) -> None:
expected = self._expected_signature(provider_order_id, provider_payment_id)
if not hmac.compare_digest(expected, signature):
raise ValueError("Invalid payment signature")
def verify_webhook_signature(self, body: bytes, signature: str | None) -> None:
if not signature:
raise ValueError("Missing webhook signature")
expected = hmac.new(self._secret.encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise ValueError("Invalid webhook signature")
def parse_webhook_payment(self, payload: dict) -> PaymentVerification | None:
event = payload.get("event")
if event != "payment.captured":
return None
entity = payload.get("payload", {}).get("payment", {}).get("entity", {})
if not entity:
return None
return PaymentVerification(
provider_order_id=entity.get("order_id", ""),
provider_payment_id=entity.get("id", ""),
amount_paise=int(entity.get("amount", 0)),
currency=entity.get("currency", "INR"),
)
def build_mock_checkout_signature(self, provider_order_id: str, provider_payment_id: str) -> str:
return self._expected_signature(provider_order_id, provider_payment_id)
class RazorpayPaymentGateway(PaymentGateway):
def __init__(self, settings: Settings) -> None:
if not settings.razorpay_key_id or not settings.razorpay_key_secret:
raise RuntimeError("Razorpay credentials are not configured")
import razorpay
self._client = razorpay.Client(auth=(settings.razorpay_key_id, settings.razorpay_key_secret))
self._key_id = settings.razorpay_key_id
self._webhook_secret = settings.razorpay_webhook_secret or ""
@property
def provider_name(self) -> str:
return "razorpay"
@property
def public_key_id(self) -> str:
return self._key_id
def create_provider_order(
self,
*,
amount_paise: int,
currency: str,
receipt: str,
notes: dict[str, str],
) -> ProviderOrder:
data = self._client.order.create(
{
"amount": amount_paise,
"currency": currency,
"receipt": receipt,
"notes": notes,
}
)
return ProviderOrder(
provider_order_id=data["id"],
amount_paise=int(data["amount"]),
currency=data["currency"],
)
def verify_checkout_signature(
self,
*,
provider_order_id: str,
provider_payment_id: str,
signature: str,
) -> None:
self._client.utility.verify_payment_signature(
{
"razorpay_order_id": provider_order_id,
"razorpay_payment_id": provider_payment_id,
"razorpay_signature": signature,
}
)
def verify_webhook_signature(self, body: bytes, signature: str | None) -> None:
if not self._webhook_secret:
raise RuntimeError("RAZORPAY_WEBHOOK_SECRET is not configured")
if not signature:
raise ValueError("Missing webhook signature")
self._client.utility.verify_webhook_signature(body.decode(), signature, self._webhook_secret)
def parse_webhook_payment(self, payload: dict) -> PaymentVerification | None:
event = payload.get("event")
if event not in ("payment.captured", "order.paid"):
return None
payment_entity = payload.get("payload", {}).get("payment", {}).get("entity")
if not payment_entity and event == "order.paid":
payment_entity = payload.get("payload", {}).get("order", {}).get("entity")
if not payment_entity:
return None
order_id = payment_entity.get("order_id") or payment_entity.get("id")
payment_id = payment_entity.get("id") if event == "payment.captured" else payment_entity.get("payment_id")
if not order_id or not payment_id:
return None
return PaymentVerification(
provider_order_id=order_id,
provider_payment_id=payment_id,
amount_paise=int(payment_entity.get("amount", 0)),
currency=payment_entity.get("currency", "INR"),
)
def get_payment_gateway(settings: Settings | None = None) -> PaymentGateway:
settings = settings or get_settings()
mode = settings.payment_provider.lower()
if mode == "razorpay":
return RazorpayPaymentGateway(settings)
return MockPaymentGateway(settings)

View file

@ -0,0 +1,203 @@
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,
)