hesabixArc/hesabixAPI/app/services/payment_service.py
2025-11-09 05:16:37 +00:00

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}