Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/parsian_gateway.py

373 lines
11 KiB
Python

"""اتصال SOAP رسمی درگاه پارسیان (PEC NewIPG).
مرجع: افزونه رسمی ووکامرس https://pgw.pec.ir/Content/Plugin/wc-pec-gateway.zip
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple
from xml.etree import ElementTree as ET
from xml.sax.saxutils import escape as xml_escape
import httpx
logger = logging.getLogger(__name__)
PROD_HOST = "https://pec.shaparak.ir"
SANDBOX_HOST = "https://sandbox.banktest.ir/parsian"
SALE_PATH = "/NewIPGServices/Sale/SaleService.asmx"
CONFIRM_PATH = "/NewIPGServices/Confirm/ConfirmService.asmx"
REVERSE_PATH = "/NewIPGServices/Reverse/ReversalService.asmx"
STARTPAY_PATH = "/NewIPG/?token="
SALE_NS = "https://pec.Shaparak.ir/NewIPGServices/Sale/SaleService"
CONFIRM_NS = "https://pec.Shaparak.ir/NewIPGServices/Confirm/ConfirmService"
REVERSE_NS = "https://pec.Shaparak.ir/NewIPGServices/Reversal/ReversalService"
SALE_SOAP_ACTION = f"{SALE_NS}/SalePaymentRequest"
CONFIRM_SOAP_ACTION = f"{CONFIRM_NS}/ConfirmPayment"
REVERSE_SOAP_ACTION = f"{REVERSE_NS}/ReversalRequest"
WALLET_ORDER_BASE = 1_000_000_000
SUPPORT_ORDER_BASE = 2_000_000_000
_SOAP_TIMEOUT = 15.0
@dataclass
class ParsianSaleResult:
ok: bool
token: Optional[str]
status: Optional[str]
message: Optional[str]
raw: str = ""
@dataclass
class ParsianConfirmResult:
ok: bool
status: Optional[str]
token: Optional[str]
rrn: Optional[str]
card_number: Optional[str]
message: Optional[str]
raw: str = ""
def resolve_login_account(cfg: Dict[str, Any]) -> str:
"""PIN پذیرنده برای LoginAccount.
ترتیب: pin → login_account → merchant_id → terminal_id
در UI قبلی PIN معمولاً در merchant_id («کد پذیرنده») ذخیره می‌شد.
"""
for key in ("pin", "login_account", "merchant_id", "terminal_id"):
val = str(cfg.get(key) or "").strip()
if val:
return val
return ""
def make_order_id(scope: str, internal_id: int) -> int:
"""OrderId یکتا برای پارسیان؛ کیف‌پول و پشتیبانی را جدا می‌کند."""
internal_id = int(internal_id)
if scope == "support":
return SUPPORT_ORDER_BASE + internal_id
return WALLET_ORDER_BASE + internal_id
def decode_order_id(order_id: int) -> Tuple[str, int]:
oid = int(order_id or 0)
if oid >= SUPPORT_ORDER_BASE:
return "support", oid - SUPPORT_ORDER_BASE
if oid >= WALLET_ORDER_BASE:
return "wallet", oid - WALLET_ORDER_BASE
return "unknown", oid
def is_test_token(token: Optional[str]) -> bool:
t = str(token or "")
return t.startswith("TEST-TOKEN-") or t.startswith("TEST-PARSIAN-")
def callback_paid(status: Any) -> bool:
"""موفقیت برگشت از درگاه: status === 0 مطابق افزونه رسمی."""
if status is None:
return False
s = str(status).strip().lower()
return s in ("0", "0.0")
def parse_amount(value: Any) -> int:
s = str(value or "").replace(",", "").replace("،", "").strip()
if not s:
return 0
try:
return int(round(float(s)))
except (TypeError, ValueError):
return 0
def _host(is_sandbox: bool) -> str:
return SANDBOX_HOST if is_sandbox else PROD_HOST
def resolve_sale_url(cfg: Dict[str, Any], *, is_sandbox: bool) -> str:
explicit = str(cfg.get("sale_url") or cfg.get("token_url") or "").strip()
if explicit:
return explicit
base = str(cfg.get("api_base") or _host(is_sandbox)).rstrip("/")
return f"{base}{SALE_PATH}"
def resolve_confirm_url(cfg: Dict[str, Any], *, is_sandbox: bool) -> str:
explicit = str(cfg.get("confirm_url") or "").strip()
if explicit:
return explicit
base = str(cfg.get("api_base") or _host(is_sandbox)).rstrip("/")
return f"{base}{CONFIRM_PATH}"
def resolve_startpay_base(cfg: Dict[str, Any], *, is_sandbox: bool) -> str:
explicit = str(cfg.get("startpay_base") or "").strip()
if explicit:
return explicit
return f"{_host(is_sandbox).rstrip('/')}{STARTPAY_PATH}"
def build_startpay_url(base: str, token: str) -> str:
b = (base or "").strip() or f"{PROD_HOST}{STARTPAY_PATH}"
if re.search(r"[?&][Tt]oken=?$", b):
return b + ("" if b.endswith("=") else "=") + token
if b.endswith("="):
return b + token
joiner = "&" if "?" in b else "?"
return f"{b}{joiner}token={token}"
def build_sale_envelope(
*,
login_account: str,
order_id: int,
amount: int,
callback_url: str,
additional_data: str = "",
originator: str = "",
) -> str:
return (
'<?xml version="1.0" encoding="utf-8"?>'
'<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
' xmlns:xsd="http://www.w3.org/2001/XMLSchema"'
' xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">'
"<soap:Body>"
f'<SalePaymentRequest xmlns="{SALE_NS}">'
"<requestData>"
f"<LoginAccount>{xml_escape(login_account)}</LoginAccount>"
f"<OrderId>{int(order_id)}</OrderId>"
f"<Amount>{int(amount)}</Amount>"
f"<CallBackUrl>{xml_escape(callback_url)}</CallBackUrl>"
f"<AdditionalData>{xml_escape(additional_data)}</AdditionalData>"
f"<Originator>{xml_escape(originator)}</Originator>"
"</requestData>"
"</SalePaymentRequest>"
"</soap:Body>"
"</soap:Envelope>"
)
def build_confirm_envelope(
*,
login_account: str,
token: str,
) -> str:
return (
'<?xml version="1.0" encoding="utf-8"?>'
'<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
' xmlns:xsd="http://www.w3.org/2001/XMLSchema"'
' xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">'
"<soap:Body>"
f'<ConfirmPayment xmlns="{CONFIRM_NS}">'
"<requestData>"
f"<LoginAccount>{xml_escape(login_account)}</LoginAccount>"
f"<Token>{xml_escape(str(token))}</Token>"
"</requestData>"
"</ConfirmPayment>"
"</soap:Body>"
"</soap:Envelope>"
)
def _local_name(tag: str) -> str:
if tag.startswith("{"):
return tag.rsplit("}", 1)[-1]
if ":" in tag:
return tag.split(":")[-1]
return tag
def _first_text(root: ET.Element, *names: str) -> Optional[str]:
wanted = {n.lower() for n in names}
for el in root.iter():
if _local_name(el.tag).lower() in wanted:
text = (el.text or "").strip()
if text:
return text
return None
def _parse_xml(raw: str) -> Optional[ET.Element]:
text = (raw or "").strip()
if not text:
return None
# افزونه رسمی soap:Body را جدا می‌کند؛ ما کل سند را با local-name می‌خوانیم.
try:
return ET.fromstring(text)
except ET.ParseError:
try:
start = text.find("<")
if start > 0:
return ET.fromstring(text[start:])
except ET.ParseError:
return None
return None
def _token_ok(token: Optional[str]) -> bool:
if not token:
return False
t = token.strip()
if not t or t in ("0", "-1"):
return False
try:
if int(t) <= 0:
return False
except ValueError:
pass
return True
def parse_sale_response(raw: str) -> ParsianSaleResult:
root = _parse_xml(raw)
if root is None:
return ParsianSaleResult(ok=False, token=None, status="-138", message="پاسخ نامعتبر از درگاه پارسیان", raw=raw)
fault = _first_text(root, "faultstring", "FaultString")
status = _first_text(root, "Status")
token = _first_text(root, "Token")
message = _first_text(root, "Message") or fault
ok = _token_ok(token) and str(status or "0").strip() in ("0", "0.0")
return ParsianSaleResult(ok=ok, token=token if _token_ok(token) else None, status=status, message=message, raw=raw)
def parse_confirm_response(raw: str) -> ParsianConfirmResult:
root = _parse_xml(raw)
if root is None:
return ParsianConfirmResult(
ok=False, status="-138", token=None, rrn=None, card_number=None,
message="پاسخ نامعتبر از درگاه پارسیان", raw=raw,
)
status = _first_text(root, "Status")
ok = str(status or "").strip() == "0"
return ParsianConfirmResult(
ok=ok,
status=status,
token=_first_text(root, "Token"),
rrn=_first_text(root, "RRN"),
card_number=_first_text(root, "CardNumberMasked"),
message=_first_text(root, "Message", "faultstring"),
raw=raw,
)
def _soap_post(url: str, envelope: str, soap_action: str) -> str:
headers = {
"Content-Type": "text/xml; charset=utf-8",
"Accept": "text/xml",
"SOAPAction": soap_action,
}
with httpx.Client(timeout=_SOAP_TIMEOUT) as client:
resp = client.post(url, content=envelope.encode("utf-8"), headers=headers)
return resp.text or ""
def sale_payment_request(
cfg: Dict[str, Any],
*,
is_sandbox: bool,
order_id: int,
amount: int,
callback_url: str,
) -> ParsianSaleResult:
login = resolve_login_account(cfg)
if not login:
return ParsianSaleResult(ok=False, token=None, status=None, message="شناسه پذیرنده (PIN) تنظیم نشده است")
url = resolve_sale_url(cfg, is_sandbox=is_sandbox)
envelope = build_sale_envelope(
login_account=login,
order_id=order_id,
amount=amount,
callback_url=callback_url,
)
try:
raw = _soap_post(url, envelope, SALE_SOAP_ACTION)
except Exception as ex:
logger.warning("parsian_sale_http_failed url=%s err=%s", url, ex)
return ParsianSaleResult(ok=False, token=None, status="-138", message="ارتباط با درگاه پارسیان برقرار نشد")
result = parse_sale_response(raw)
if not result.ok:
logger.warning(
"parsian_sale_rejected status=%s message=%s",
result.status,
(result.message or "")[:200],
)
return result
def confirm_payment(
cfg: Dict[str, Any],
*,
is_sandbox: bool,
token: str,
order_id: int,
amount: int,
) -> ParsianConfirmResult:
login = resolve_login_account(cfg)
if not login or not token:
return ParsianConfirmResult(
ok=False, status=None, token=None, rrn=None, card_number=None,
message="PIN یا توکن برای تأیید پارسیان موجود نیست",
)
url = resolve_confirm_url(cfg, is_sandbox=is_sandbox)
envelope = build_confirm_envelope(
login_account=login,
token=token,
)
try:
raw = _soap_post(url, envelope, CONFIRM_SOAP_ACTION)
except Exception as ex:
logger.warning("parsian_confirm_http_failed url=%s err=%s", url, ex)
return ParsianConfirmResult(
ok=False, status="-138", token=None, rrn=None, card_number=None,
message="ارتباط با درگاه پارسیان برای تأیید برقرار نشد",
)
result = parse_confirm_response(raw)
if not result.ok:
logger.warning(
"parsian_confirm_rejected status=%s message=%s",
result.status,
(result.message or "")[:200],
)
return result
def sale_error_message(result: ParsianSaleResult) -> str:
msg = (result.message or "").strip()
if result.status and msg:
return f"خطای پارسیان (کد {result.status}): {msg}"
if msg:
return msg
if result.status:
return f"ایجاد تراکنش پارسیان ناموفق بود (کد {result.status})"
return "امکان ایجاد تراکنش در پارسیان نیست"