230 lines
8.9 KiB
Python
230 lines
8.9 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")
|
|
api_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"))
|
|
startpay_base = str(cfg.get("startpay_base") or ("https://sandbox.zarinpal.com/pg/StartPay" if gw.is_sandbox else "https://www.zarinpal.com/pg/StartPay"))
|
|
currency = str(cfg.get("currency") or "IRR").upper()
|
|
# 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}"
|
|
|
|
# Convert amount to rial if needed (assuming system base is IRR already; adapter can be extended)
|
|
req_payload = {
|
|
"MerchantID": merchant_id,
|
|
"Amount": int(round(float(amount))), # expect rial
|
|
"Description": description,
|
|
"CallbackURL": cb_url,
|
|
}
|
|
authority: Optional[str] = None
|
|
try:
|
|
with httpx.Client(timeout=10.0) as client:
|
|
resp = client.post(f"{api_base}/PaymentRequest.json", json=req_payload)
|
|
data = resp.json() if resp.headers.get("content-type","").startswith("application/json") 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)
|
|
success = status in ("ok", "ok.", "success", "succeeded")
|
|
fee_amount = None
|
|
# Optionally call VerifyPayment here if needed (requires merchant_id); skipping network verify to keep flow simple
|
|
# Confirm
|
|
if tx_id > 0:
|
|
confirm_top_up(db, tx_id, success=success, external_ref=authority or None)
|
|
return {"transaction_id": tx_id, "success": success, "external_ref": authority, "fee_amount": fee_amount}
|
|
|
|
|
|
# --------------------------
|
|
# 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}
|
|
|
|
|
|
|