first commit
This commit is contained in:
commit
34ea15a54d
246 changed files with 18660 additions and 0 deletions
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