first commit
This commit is contained in:
commit
34ea15a54d
246 changed files with 18660 additions and 0 deletions
3
backend/app/services/payments/__init__.py
Normal file
3
backend/app/services/payments/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from app.services.payments.gateway import get_payment_gateway
|
||||
|
||||
__all__ = ["get_payment_gateway"]
|
||||
220
backend/app/services/payments/gateway.py
Normal file
220
backend/app/services/payments/gateway.py
Normal 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)
|
||||
203
backend/app/services/payments/service.py
Normal file
203
backend/app/services/payments/service.py
Normal 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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue