Files
bacchus/apps/backend/app/api/paypal_ipn.py
2025-09-28 19:13:01 +02:00

80 lines
3.0 KiB
Python

# app/api/paypal_ipn.py
import httpx, urllib.parse, os, re
from fastapi import APIRouter, Request, Response, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.topup import Topup, TopupStatus
from app.models.user import User
router = APIRouter(prefix="/paypal", tags=["paypal"])
PP_IPN_VERIFY_URL = "https://ipnpb.paypal.com/cgi-bin/webscr" # live
# Sandbox: "https://ipnpb.sandbox.paypal.com/cgi-bin/webscr"
def parse_custom(s: str):
# erwartet "topup:<id>|code:<CODE>"
data = {}
for part in (s or "").split("|"):
if ":" in part:
k,v = part.split(":",1)
data[k.strip()] = v.strip()
return data
@router.post("/ipn")
async def handle_ipn(request: Request, db: Session = Depends(get_db)):
raw = await request.body()
params = urllib.parse.parse_qs(raw.decode("utf-8"), keep_blank_values=True)
# 1) an PayPal zurückposten:
verify_payload = "cmd=_notify-validate&" + raw.decode("utf-8")
async with httpx.AsyncClient(timeout=15) as c:
vr = await c.post(PP_IPN_VERIFY_URL, data=verify_payload,
headers={"Content-Type":"application/x-www-form-urlencoded"})
if vr.text.strip() != "VERIFIED":
return Response("ignored", status_code=400)
payment_status = (params.get("payment_status",[None])[0] or "").lower()
receiver_email = (params.get("receiver_email",[None])[0] or "").lower()
mc_currency = params.get("mc_currency",[None])[0] or ""
mc_gross = params.get("mc_gross",[None])[0] or ""
custom = params.get("custom",[None])[0] or ""
item_name = params.get("item_name",[None])[0] or ""
txn_id = params.get("txn_id",[None])[0] or ""
# Grundvalidierungen
if payment_status != "completed":
return {"ok": True} # nur completed interessiert
# Optional: Empfänger-Email prüfen (gegen Config)
# if receiver_email != CONFIG.paypal_receiver.lower(): return Response("wrong receiver", 400)
if mc_currency != "EUR":
return Response("wrong currency", 400)
meta = parse_custom(custom)
topup_id = int(meta.get("topup") or 0)
code = meta.get("code") or ""
t = db.query(Topup).filter(Topup.id == topup_id).first()
if not t or t.status == TopupStatus.confirmed:
return {"ok": True}
# Betrag prüfen
try:
cents_from_paypal = int(round(float(mc_gross.replace(",", ".")) * 100))
except Exception:
return Response("bad amount", 400)
if int(t.amount_cents or 0) != cents_from_paypal:
return Response("amount mismatch", 400)
# Sicherheits-Match: Code im item_name?
if code and code not in (item_name or ""):
# nicht hart abbrechen, aber flaggen wäre möglich
pass
# Topup bestätigen + Saldo buchen (idempotent)
t.status = TopupStatus.confirmed
u = db.query(User).filter(User.id == t.user_id).first()
u.balance_cents = int(u.balance_cents or 0) + int(t.amount_cents or 0)
# Optional: t.paypal_txn_id = txn_id
db.commit()
return {"ok": True}