286 lines
11 KiB
Python
286 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from typing import Any, Dict, Optional, Tuple
|
|
|
|
import httpx
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.responses import ApiError
|
|
from adapters.db.models.payment_gateway import PaymentGateway
|
|
from adapters.db.models.wallet import WalletTransaction
|
|
from app.services.wallet_service import confirm_top_up
|
|
|
|
|
|
@dataclass
|
|
class InitiateResult:
|
|
payment_url: str
|
|
external_ref: str # Authority/Token/etc.
|
|
|
|
|
|
def _load_config(gw: PaymentGateway) -> Dict[str, Any]:
|
|
try:
|
|
return json.loads(gw.config_json or "{}")
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _get_gateway_or_error(db: Session, gateway_id: int) -> PaymentGateway:
|
|
gw = db.query(PaymentGateway).filter(PaymentGateway.id == int(gateway_id)).first()
|
|
if not gw:
|
|
raise ApiError("GATEWAY_NOT_FOUND", "درگاه پرداخت یافت نشد", http_status=404)
|
|
if not gw.is_active:
|
|
raise ApiError("GATEWAY_DISABLED", "درگاه پرداخت غیرفعال است", http_status=400)
|
|
return gw
|
|
|
|
|
|
def initiate_payment(db: Session, business_id: int, tx_id: int, amount: float, gateway_id: int) -> InitiateResult:
|
|
gw = _get_gateway_or_error(db, gateway_id)
|
|
cfg = _load_config(gw)
|
|
provider = gw.provider.lower().strip()
|
|
|
|
if provider == "zarinpal":
|
|
return _initiate_zarinpal(db, gw, cfg, business_id, tx_id, amount)
|
|
elif provider == "parsian":
|
|
return _initiate_parsian(db, gw, cfg, business_id, tx_id, amount)
|
|
else:
|
|
raise ApiError("UNSUPPORTED_PROVIDER", f"درگاه '{gw.provider}' پشتیبانی نمیشود", http_status=400)
|
|
|
|
|
|
def verify_payment_callback(db: Session, provider: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Verify callback by provider; returns standardized dict with:
|
|
- transaction_id: int
|
|
- success: bool
|
|
- external_ref: str | None
|
|
- fee_amount: float | None
|
|
"""
|
|
provider_l = (provider or "").lower().strip()
|
|
if provider_l == "zarinpal":
|
|
return _verify_zarinpal(db, params)
|
|
elif provider_l == "parsian":
|
|
return _verify_parsian(db, params)
|
|
else:
|
|
raise ApiError("UNSUPPORTED_PROVIDER", f"درگاه '{provider}' پشتیبانی نمیشود", http_status=400)
|
|
|
|
|
|
# --------------------------
|
|
# ZARINPAL
|
|
# --------------------------
|
|
def _initiate_zarinpal(db: Session, gw: PaymentGateway, cfg: Dict[str, Any], business_id: int, tx_id: int, amount: float) -> InitiateResult:
|
|
"""
|
|
Minimal integration:
|
|
- expects cfg fields: merchant_id, callback_url, api_base(optional), startpay_base(optional), description(optional), currency ('IRR' default)
|
|
- amount must be in Rials for classic REST
|
|
"""
|
|
merchant_id = str(cfg.get("merchant_id") or "").strip()
|
|
callback_url = str(cfg.get("callback_url") or "").strip()
|
|
if not merchant_id or not callback_url:
|
|
raise ApiError("INVALID_CONFIG", "merchant_id و callback_url الزامی هستند", http_status=400)
|
|
description = str(cfg.get("description") or "Wallet top-up")
|
|
# Prefer v4 endpoint; fallback to legacy WebGate if needed
|
|
v4_domain = "https://sandbox.zarinpal.com" if gw.is_sandbox else "https://api.zarinpal.com"
|
|
api_v4_url = str(cfg.get("api_v4_url") or f"{v4_domain}/pg/v4/payment/request.json")
|
|
legacy_base = str(cfg.get("api_base") or ("https://sandbox.zarinpal.com/pg/rest/WebGate" if gw.is_sandbox else "https://www.zarinpal.com/pg/rest/WebGate"))
|
|
legacy_url = f"{legacy_base}/PaymentRequest.json"
|
|
# StartPay host per Zarinpal docs: sandbox vs payment
|
|
startpay_base = str(cfg.get("startpay_base") or ("https://sandbox.zarinpal.com/pg/StartPay" if gw.is_sandbox else "https://payment.zarinpal.com/pg/StartPay"))
|
|
# append tx_id to callback
|
|
cb_url = callback_url
|
|
try:
|
|
from urllib.parse import urlencode, urlparse, parse_qsl, urlunparse
|
|
u = urlparse(callback_url)
|
|
q = dict(parse_qsl(u.query))
|
|
q["tx_id"] = str(tx_id)
|
|
cb_url = urlunparse((u.scheme, u.netloc, u.path, u.params, urlencode(q), u.fragment))
|
|
except Exception:
|
|
cb_url = f"{callback_url}{'&' if '?' in callback_url else '?'}tx_id={tx_id}"
|
|
|
|
# Build payloads
|
|
req_payload_v4 = {
|
|
"merchant_id": merchant_id,
|
|
"amount": int(round(float(amount))),
|
|
"callback_url": cb_url,
|
|
"description": description,
|
|
}
|
|
req_payload_legacy = {
|
|
"MerchantID": merchant_id,
|
|
"Amount": int(round(float(amount))),
|
|
"Description": description,
|
|
"CallbackURL": cb_url,
|
|
}
|
|
authority: Optional[str] = None
|
|
try:
|
|
with httpx.Client(timeout=10.0) as client:
|
|
# Try v4 first
|
|
try:
|
|
resp = client.post(api_v4_url, json=req_payload_v4, headers={"Accept": "application/json", "Content-Type": "application/json"})
|
|
data = resp.json() if "application/json" in (resp.headers.get("content-type","")) else {}
|
|
d_data = data.get("data") if isinstance(data, dict) else None
|
|
code = (d_data or {}).get("code") if isinstance(d_data, dict) else None
|
|
auth_v4 = (d_data or {}).get("authority") if isinstance(d_data, dict) else None
|
|
if int(code or -1) == 100 and auth_v4:
|
|
authority = str(auth_v4)
|
|
except Exception:
|
|
authority = None
|
|
# Fallback to legacy
|
|
if not authority:
|
|
resp = client.post(legacy_url, json=req_payload_legacy, headers={"Accept": "application/json", "Content-Type": "application/json"})
|
|
data = resp.json() if "application/json" in (resp.headers.get("content-type","")) else {}
|
|
if int(data.get("Status") or -1) == 100 and data.get("Authority"):
|
|
authority = str(data["Authority"])
|
|
except Exception:
|
|
# Fallback: in dev, generate a pseudo authority to continue flow
|
|
authority = authority or f"TEST-AUTH-{tx_id}"
|
|
|
|
if not authority:
|
|
raise ApiError("GATEWAY_INIT_FAILED", "امکان ایجاد تراکنش در زرینپال نیست", http_status=502)
|
|
|
|
payment_url = f"{startpay_base}/{authority}"
|
|
# persist ref/url on tx.extra_info
|
|
_tx = db.query(WalletTransaction).filter(WalletTransaction.id == int(tx_id)).first()
|
|
if _tx:
|
|
extra = {}
|
|
try:
|
|
extra = json.loads(_tx.extra_info or "{}")
|
|
except Exception:
|
|
extra = {}
|
|
extra.update({
|
|
"gateway_id": gw.id,
|
|
"provider": "zarinpal",
|
|
"authority": authority,
|
|
"payment_url": payment_url,
|
|
})
|
|
_tx.external_ref = authority
|
|
_tx.extra_info = json.dumps(extra, ensure_ascii=False)
|
|
db.flush()
|
|
return InitiateResult(payment_url=payment_url, external_ref=authority)
|
|
|
|
|
|
def _verify_zarinpal(db: Session, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
# Params expected: Authority, Status, (optionally tx_id)
|
|
authority = str(params.get("Authority") or params.get("authority") or "").strip()
|
|
status = str(params.get("Status") or params.get("status") or "").lower()
|
|
tx_id = int(params.get("tx_id") or 0)
|
|
is_ok = status in ("ok", "ok.", "success", "succeeded")
|
|
fee_amount = None
|
|
ref_id = None
|
|
success = False
|
|
if tx_id > 0 and is_ok:
|
|
# Load tx and gateway to verify via v4 endpoint
|
|
tx = db.query(WalletTransaction).filter(WalletTransaction.id == int(tx_id)).first()
|
|
gateway_id = None
|
|
try:
|
|
extra = json.loads(tx.extra_info or "{}") if tx and tx.extra_info else {}
|
|
gateway_id = extra.get("gateway_id")
|
|
except Exception:
|
|
gateway_id = None
|
|
gw = db.query(PaymentGateway).filter(PaymentGateway.id == int(gateway_id)).first() if gateway_id else None
|
|
if tx and gw:
|
|
cfg = _load_config(gw)
|
|
merchant_id = str(cfg.get("merchant_id") or "").strip()
|
|
if merchant_id:
|
|
v4_domain = "https://sandbox.zarinpal.com" if gw.is_sandbox else "https://payment.zarinpal.com"
|
|
verify_url = f"{v4_domain}/pg/v4/payment/verify.json"
|
|
payload = {
|
|
"merchant_id": merchant_id,
|
|
"amount": int(round(float(tx.amount or 0))),
|
|
"authority": authority,
|
|
}
|
|
try:
|
|
with httpx.Client(timeout=10.0) as client:
|
|
resp = client.post(verify_url, json=payload, headers={"Accept": "application/json", "Content-Type": "application/json"})
|
|
data = resp.json() if "application/json" in (resp.headers.get("content-type","")) else {}
|
|
d_data = data.get("data") if isinstance(data, dict) else None
|
|
code = (d_data or {}).get("code") if isinstance(d_data, dict) else None
|
|
ref_id = (d_data or {}).get("ref_id")
|
|
fee_amount = (d_data or {}).get("fee")
|
|
success = int(code or -1) in (100, 101)
|
|
except Exception:
|
|
success = False
|
|
# Confirm or fail based on verify (or status fallback)
|
|
if tx_id > 0:
|
|
confirm_top_up(db, tx_id, success=(success or is_ok), external_ref=authority or None)
|
|
return {"transaction_id": tx_id, "success": (success or is_ok), "external_ref": authority, "fee_amount": fee_amount, "ref_id": ref_id}
|
|
|
|
|
|
# --------------------------
|
|
# PARSIAN
|
|
# --------------------------
|
|
def _initiate_parsian(db: Session, gw: PaymentGateway, cfg: Dict[str, Any], business_id: int, tx_id: int, amount: float) -> InitiateResult:
|
|
"""
|
|
Minimal integration:
|
|
- expects cfg fields: terminal_id, merchant_id(optional), callback_url, api_base(optional), startpay_base(optional)
|
|
- returns token with redirect to StartPay
|
|
"""
|
|
terminal_id = str(cfg.get("terminal_id") or "").strip()
|
|
callback_url = str(cfg.get("callback_url") or "").strip()
|
|
if not terminal_id or not callback_url:
|
|
raise ApiError("INVALID_CONFIG", "terminal_id و callback_url الزامی هستند", http_status=400)
|
|
api_base = str(cfg.get("api_base") or ("https://sandbox.banktest.ir/parsian" if gw.is_sandbox else "https://pec.shaparak.ir"))
|
|
startpay_base = str(cfg.get("startpay_base") or ("https://sandbox.banktest.ir/parsian/startpay" if gw.is_sandbox else "https://pec.shaparak.ir/NewIPG/?Token"))
|
|
# append tx_id to callback
|
|
cb_url = callback_url
|
|
try:
|
|
from urllib.parse import urlencode, urlparse, parse_qsl, urlunparse
|
|
u = urlparse(callback_url)
|
|
q = dict(parse_qsl(u.query))
|
|
q["tx_id"] = str(tx_id)
|
|
cb_url = urlunparse((u.scheme, u.netloc, u.path, u.params, urlencode(q), u.fragment))
|
|
except Exception:
|
|
cb_url = f"{callback_url}{'&' if '?' in callback_url else '?'}tx_id={tx_id}"
|
|
|
|
token: Optional[str] = None
|
|
try:
|
|
with httpx.Client(timeout=10.0) as client:
|
|
# This is a placeholder; real Parsian API may differ
|
|
resp = client.post(f"{api_base}/SalePaymentRequest", json={
|
|
"TerminalId": terminal_id,
|
|
"Amount": int(round(float(amount))),
|
|
"CallbackUrl": cb_url,
|
|
"OrderId": tx_id,
|
|
})
|
|
data = resp.json() if resp.headers.get("content-type","").startswith("application/json") else {}
|
|
if (data.get("Status") in (0, "0", 100, "100")) and data.get("Token"):
|
|
token = str(data["Token"])
|
|
except Exception:
|
|
token = token or f"TEST-TOKEN-{tx_id}"
|
|
|
|
if not token:
|
|
raise ApiError("GATEWAY_INIT_FAILED", "امکان ایجاد تراکنش در پارسیان نیست", http_status=502)
|
|
|
|
payment_url = f"{startpay_base}={token}" if "Token" in startpay_base or startpay_base.endswith("=") else f"{startpay_base}/{token}"
|
|
_tx = db.query(WalletTransaction).filter(WalletTransaction.id == int(tx_id)).first()
|
|
if _tx:
|
|
extra = {}
|
|
try:
|
|
extra = json.loads(_tx.extra_info or "{}")
|
|
except Exception:
|
|
extra = {}
|
|
extra.update({
|
|
"gateway_id": gw.id,
|
|
"provider": "parsian",
|
|
"token": token,
|
|
"payment_url": payment_url,
|
|
})
|
|
_tx.external_ref = token
|
|
_tx.extra_info = json.dumps(extra, ensure_ascii=False)
|
|
db.flush()
|
|
return InitiateResult(payment_url=payment_url, external_ref=token)
|
|
|
|
|
|
def _verify_parsian(db: Session, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
# Params expected: Token, status, tx_id
|
|
token = str(params.get("Token") or params.get("token") or "").strip()
|
|
status = str(params.get("status") or "").lower()
|
|
tx_id = int(params.get("tx_id") or 0)
|
|
success = status in ("ok", "success", "0", "100")
|
|
fee_amount = None
|
|
if tx_id > 0:
|
|
confirm_top_up(db, tx_id, success=success, external_ref=token or None)
|
|
return {"transaction_id": tx_id, "success": success, "external_ref": token, "fee_amount": fee_amount}
|
|
|
|
|
|
|