2026-07-03 09:07:21 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
|
import hmac
|
|
|
|
|
import json
|
2026-07-03 09:34:07 +08:00
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from datetime import timedelta
|
2026-07-03 09:07:21 +08:00
|
|
|
from decimal import Decimal
|
|
|
|
|
from decimal import InvalidOperation
|
|
|
|
|
from pathlib import Path
|
2026-07-03 09:34:07 +08:00
|
|
|
from urllib.parse import quote
|
2026-07-03 09:07:21 +08:00
|
|
|
|
|
|
|
|
from django.conf import settings
|
|
|
|
|
from django.utils import timezone
|
|
|
|
|
from django.utils.dateparse import parse_datetime
|
|
|
|
|
|
|
|
|
|
from .models import RechargeOrder
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PaymentVerificationError(RuntimeError):
|
|
|
|
|
code = "signature_invalid"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PaymentQueryUnavailableError(RuntimeError):
|
|
|
|
|
code = "payment_query_unavailable"
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 09:34:07 +08:00
|
|
|
class PaymentOrderCreateError(RuntimeError):
|
|
|
|
|
code = "payment_order_create_failed"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class PaymentOrderCode:
|
|
|
|
|
code_url: str
|
|
|
|
|
expires_at: object
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 11:52:49 +08:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class PaymentReceipt:
|
|
|
|
|
order_no: str
|
|
|
|
|
pay_method: str
|
|
|
|
|
amount: Decimal
|
|
|
|
|
transaction_id: str
|
|
|
|
|
paid_at: object | None = None
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 09:07:21 +08:00
|
|
|
def payment_callback_mode() -> str:
|
|
|
|
|
return str(getattr(settings, "PAYMENT_CALLBACK_MODE", "") or "").strip().lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mock_secret() -> str:
|
|
|
|
|
return str(getattr(settings, "PAYMENT_MOCK_CALLBACK_SECRET", "") or "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _require_mock_secret() -> bytes:
|
|
|
|
|
secret = _mock_secret()
|
|
|
|
|
if not secret:
|
|
|
|
|
raise PaymentVerificationError("Mock payment callback secret is not configured.")
|
|
|
|
|
return secret.encode("utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_mock_body_signature(body: bytes) -> str:
|
|
|
|
|
return hmac.new(_require_mock_secret(), body, hashlib.sha256).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_mock_alipay_signature(data: dict) -> str:
|
|
|
|
|
canonical = "&".join(
|
|
|
|
|
f"{key}={data[key]}"
|
|
|
|
|
for key in sorted(data)
|
|
|
|
|
if key not in {"sign", "sign_type"}
|
|
|
|
|
)
|
|
|
|
|
return hmac.new(
|
|
|
|
|
_require_mock_secret(),
|
|
|
|
|
canonical.encode("utf-8"),
|
|
|
|
|
hashlib.sha256,
|
|
|
|
|
).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_paid_at(value: str | None):
|
|
|
|
|
if not value:
|
|
|
|
|
return timezone.now()
|
|
|
|
|
parsed = parse_datetime(str(value))
|
|
|
|
|
if parsed is None:
|
|
|
|
|
return timezone.now()
|
|
|
|
|
if timezone.is_naive(parsed):
|
|
|
|
|
return timezone.make_aware(parsed, timezone.get_current_timezone())
|
|
|
|
|
return parsed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _decimal_money(value) -> Decimal:
|
|
|
|
|
try:
|
|
|
|
|
return Decimal(str(value)).quantize(Decimal("0.01"))
|
|
|
|
|
except (InvalidOperation, TypeError, ValueError) as exc:
|
|
|
|
|
raise PaymentVerificationError("Invalid payment amount.") from exc
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 09:34:07 +08:00
|
|
|
def _format_money(value) -> str:
|
|
|
|
|
return f"{_decimal_money(value):.2f}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _wechat_amount_cents(value) -> int:
|
|
|
|
|
return int((_decimal_money(value) * Decimal("100")).to_integral_value())
|
|
|
|
|
|
|
|
|
|
|
2026-07-06 08:56:30 +08:00
|
|
|
def _wechat_response_payload(response) -> dict:
|
|
|
|
|
if isinstance(response, dict):
|
|
|
|
|
return response
|
|
|
|
|
if isinstance(response, tuple) and len(response) >= 2:
|
|
|
|
|
payload = response[1]
|
|
|
|
|
if isinstance(payload, dict):
|
|
|
|
|
return payload
|
|
|
|
|
if isinstance(payload, bytes):
|
|
|
|
|
payload = payload.decode("utf-8")
|
|
|
|
|
if isinstance(payload, str):
|
|
|
|
|
try:
|
|
|
|
|
parsed = json.loads(payload)
|
|
|
|
|
except json.JSONDecodeError as exc:
|
|
|
|
|
raise ValueError("WeChat API response is not valid JSON.") from exc
|
|
|
|
|
if isinstance(parsed, dict):
|
|
|
|
|
return parsed
|
|
|
|
|
raise ValueError("WeChat API response has an unsupported format.")
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 09:34:07 +08:00
|
|
|
def _default_expires_at():
|
|
|
|
|
minutes = int(getattr(settings, "PAYMENT_QR_EXPIRES_MINUTES", 10) or 10)
|
|
|
|
|
return timezone.now() + timedelta(minutes=minutes)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _require_payment_config(values: dict[str, str], gateway: str) -> None:
|
|
|
|
|
missing = [name for name, value in values.items() if not str(value or "").strip()]
|
|
|
|
|
if missing:
|
|
|
|
|
raise PaymentOrderCreateError(
|
|
|
|
|
f"{gateway} payment config is incomplete: {', '.join(missing)}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 09:07:21 +08:00
|
|
|
def _verify_mock_wechat_signature(headers, body: bytes) -> None:
|
|
|
|
|
expected = build_mock_body_signature(body)
|
|
|
|
|
provided = headers.get("Wechatpay-Signature") or headers.get("X-Cmhub-Mock-Signature")
|
|
|
|
|
if not provided or not hmac.compare_digest(str(provided), expected):
|
|
|
|
|
raise PaymentVerificationError("Invalid mock WeChat callback signature.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_mock_alipay_signature(data: dict) -> None:
|
|
|
|
|
expected = build_mock_alipay_signature(data)
|
|
|
|
|
provided = data.get("sign")
|
|
|
|
|
if not provided or not hmac.compare_digest(str(provided), expected):
|
|
|
|
|
raise PaymentVerificationError("Invalid mock Alipay callback signature.")
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 11:52:49 +08:00
|
|
|
def verify_wechat_callback(headers, body: bytes) -> PaymentReceipt:
|
2026-07-03 09:07:21 +08:00
|
|
|
if payment_callback_mode() == "mock":
|
|
|
|
|
_verify_mock_wechat_signature(headers, body)
|
|
|
|
|
try:
|
|
|
|
|
payload = json.loads(body.decode("utf-8"))
|
|
|
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
|
|
|
raise PaymentVerificationError("Invalid mock WeChat callback payload.") from exc
|
|
|
|
|
|
|
|
|
|
resource = payload.get("resource") or {}
|
|
|
|
|
if payload.get("event_type") != "TRANSACTION.SUCCESS":
|
|
|
|
|
raise PaymentVerificationError("Unsupported WeChat callback event.")
|
|
|
|
|
if resource.get("trade_state") != "SUCCESS":
|
|
|
|
|
raise PaymentVerificationError("WeChat transaction is not successful.")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
total_cents = Decimal(str((resource.get("amount") or {}).get("total")))
|
|
|
|
|
except (InvalidOperation, TypeError, ValueError) as exc:
|
|
|
|
|
raise PaymentVerificationError("Invalid WeChat payment amount.") from exc
|
2026-07-21 11:52:49 +08:00
|
|
|
return PaymentReceipt(
|
2026-07-03 09:07:21 +08:00
|
|
|
order_no=str(resource.get("out_trade_no") or ""),
|
|
|
|
|
pay_method=RechargeOrder.PayMethod.WEIXIN,
|
|
|
|
|
amount=(total_cents / Decimal("100")).quantize(Decimal("0.01")),
|
|
|
|
|
transaction_id=str(resource.get("transaction_id") or ""),
|
|
|
|
|
paid_at=_parse_paid_at(resource.get("success_time")),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return _verify_wechat_callback_with_sdk(headers, body)
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 11:52:49 +08:00
|
|
|
def verify_alipay_callback(data: dict) -> PaymentReceipt:
|
2026-07-03 09:07:21 +08:00
|
|
|
callback_data = {key: str(value) for key, value in data.items()}
|
|
|
|
|
if payment_callback_mode() == "mock":
|
|
|
|
|
_verify_mock_alipay_signature(callback_data)
|
|
|
|
|
else:
|
|
|
|
|
_verify_alipay_callback_with_sdk(callback_data)
|
|
|
|
|
|
|
|
|
|
if callback_data.get("trade_status") not in {"TRADE_SUCCESS", "TRADE_FINISHED"}:
|
|
|
|
|
raise PaymentVerificationError("Alipay trade is not successful.")
|
|
|
|
|
|
2026-07-21 11:52:49 +08:00
|
|
|
return PaymentReceipt(
|
2026-07-03 09:07:21 +08:00
|
|
|
order_no=str(callback_data.get("out_trade_no") or ""),
|
|
|
|
|
pay_method=RechargeOrder.PayMethod.ALIPAY,
|
|
|
|
|
amount=_decimal_money(callback_data.get("total_amount")),
|
|
|
|
|
transaction_id=str(callback_data.get("trade_no") or ""),
|
|
|
|
|
paid_at=_parse_paid_at(callback_data.get("gmt_payment")),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 11:52:49 +08:00
|
|
|
def create_payment_order(
|
|
|
|
|
order,
|
|
|
|
|
*,
|
|
|
|
|
description: str = "cmhub points recharge",
|
|
|
|
|
wechat_notify_url: str | None = None,
|
|
|
|
|
alipay_notify_url: str | None = None,
|
|
|
|
|
) -> PaymentOrderCode:
|
2026-07-03 09:34:07 +08:00
|
|
|
if payment_callback_mode() == "mock":
|
|
|
|
|
return _create_mock_payment_order(order)
|
|
|
|
|
if order.pay_method == RechargeOrder.PayMethod.WEIXIN:
|
2026-07-21 11:52:49 +08:00
|
|
|
return _create_wechat_payment_order_with_sdk(
|
|
|
|
|
order,
|
|
|
|
|
description=description,
|
|
|
|
|
notify_url=wechat_notify_url,
|
|
|
|
|
)
|
2026-07-03 09:34:07 +08:00
|
|
|
if order.pay_method == RechargeOrder.PayMethod.ALIPAY:
|
2026-07-21 11:52:49 +08:00
|
|
|
return _create_alipay_payment_order_with_sdk(
|
|
|
|
|
order,
|
|
|
|
|
description=description,
|
|
|
|
|
notify_url=alipay_notify_url,
|
|
|
|
|
)
|
2026-07-03 09:34:07 +08:00
|
|
|
raise PaymentOrderCreateError("Unsupported payment method.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _create_mock_payment_order(order: RechargeOrder) -> PaymentOrderCode:
|
|
|
|
|
escaped_order_no = quote(order.order_no, safe="")
|
|
|
|
|
if order.pay_method == RechargeOrder.PayMethod.WEIXIN:
|
|
|
|
|
code_url = (
|
|
|
|
|
"weixin://wxpay/cmhub-mock"
|
|
|
|
|
f"?out_trade_no={escaped_order_no}&total={_wechat_amount_cents(order.amount_money)}"
|
|
|
|
|
)
|
|
|
|
|
elif order.pay_method == RechargeOrder.PayMethod.ALIPAY:
|
|
|
|
|
code_url = (
|
|
|
|
|
"https://qr.alipay.com/cmhub-mock"
|
|
|
|
|
f"?out_trade_no={escaped_order_no}&total_amount={quote(_format_money(order.amount_money), safe='')}"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
raise PaymentOrderCreateError("Unsupported payment method.")
|
|
|
|
|
return PaymentOrderCode(code_url=code_url, expires_at=_default_expires_at())
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 11:52:49 +08:00
|
|
|
def _create_wechat_payment_order_with_sdk(
|
|
|
|
|
order,
|
|
|
|
|
*,
|
|
|
|
|
description: str = "cmhub points recharge",
|
|
|
|
|
notify_url: str | None = None,
|
|
|
|
|
) -> PaymentOrderCode:
|
2026-07-03 09:34:07 +08:00
|
|
|
try:
|
2026-07-06 08:56:30 +08:00
|
|
|
from wechatpayv3 import WeChatPay, WeChatPayType # type: ignore
|
2026-07-03 09:34:07 +08:00
|
|
|
except ImportError as exc:
|
|
|
|
|
raise PaymentOrderCreateError("wechatpayv3 is not installed.") from exc
|
|
|
|
|
|
2026-07-21 11:52:49 +08:00
|
|
|
effective_notify_url = str(notify_url or settings.WECHAT_PAY_NOTIFY_URL or "").strip()
|
2026-07-03 09:34:07 +08:00
|
|
|
_require_payment_config(
|
|
|
|
|
{
|
|
|
|
|
"WECHAT_PAY_APPID": settings.WECHAT_PAY_APPID,
|
|
|
|
|
"WECHAT_PAY_MCHID": settings.WECHAT_PAY_MCHID,
|
|
|
|
|
"WECHAT_PAY_API_V3_KEY": settings.WECHAT_PAY_API_V3_KEY,
|
|
|
|
|
"WECHAT_PAY_CERT_SERIAL_NO": settings.WECHAT_PAY_CERT_SERIAL_NO,
|
|
|
|
|
"WECHAT_PAY_PRIVATE_KEY_PATH": settings.WECHAT_PAY_PRIVATE_KEY_PATH,
|
2026-07-21 11:52:49 +08:00
|
|
|
"WECHAT_PAY_NOTIFY_URL": effective_notify_url,
|
2026-07-03 09:34:07 +08:00
|
|
|
},
|
|
|
|
|
"WeChat",
|
|
|
|
|
)
|
|
|
|
|
private_key = Path(settings.WECHAT_PAY_PRIVATE_KEY_PATH).read_text(encoding="utf-8")
|
|
|
|
|
client = WeChatPay(
|
|
|
|
|
wechatpay_type="NATIVE",
|
|
|
|
|
mchid=settings.WECHAT_PAY_MCHID,
|
|
|
|
|
private_key=private_key,
|
|
|
|
|
cert_serial_no=settings.WECHAT_PAY_CERT_SERIAL_NO,
|
|
|
|
|
apiv3_key=settings.WECHAT_PAY_API_V3_KEY,
|
|
|
|
|
appid=settings.WECHAT_PAY_APPID,
|
2026-07-21 11:52:49 +08:00
|
|
|
notify_url=effective_notify_url,
|
2026-07-03 09:34:07 +08:00
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
response = client.pay(
|
2026-07-21 11:52:49 +08:00
|
|
|
description=description,
|
2026-07-03 09:34:07 +08:00
|
|
|
out_trade_no=order.order_no,
|
|
|
|
|
amount={
|
|
|
|
|
"total": _wechat_amount_cents(order.amount_money),
|
|
|
|
|
"currency": order.currency,
|
|
|
|
|
},
|
2026-07-06 08:56:30 +08:00
|
|
|
pay_type=WeChatPayType.NATIVE,
|
2026-07-03 09:34:07 +08:00
|
|
|
)
|
|
|
|
|
except Exception as exc: # pragma: no cover - depends on merchant SDK/runtime.
|
|
|
|
|
raise PaymentOrderCreateError("WeChat native order creation failed.") from exc
|
|
|
|
|
|
2026-07-06 08:56:30 +08:00
|
|
|
try:
|
|
|
|
|
payload = _wechat_response_payload(response)
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
raise PaymentOrderCreateError("WeChat native order response was invalid.") from exc
|
|
|
|
|
code_url = payload.get("code_url")
|
2026-07-03 09:34:07 +08:00
|
|
|
if not code_url:
|
|
|
|
|
raise PaymentOrderCreateError("WeChat native order did not return code_url.")
|
|
|
|
|
return PaymentOrderCode(code_url=str(code_url), expires_at=_default_expires_at())
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 11:52:49 +08:00
|
|
|
def _create_alipay_payment_order_with_sdk(
|
|
|
|
|
order,
|
|
|
|
|
*,
|
|
|
|
|
description: str = "cmhub points recharge",
|
|
|
|
|
notify_url: str | None = None,
|
|
|
|
|
) -> PaymentOrderCode:
|
2026-07-03 09:34:07 +08:00
|
|
|
try:
|
|
|
|
|
from alipay import AliPay # type: ignore
|
|
|
|
|
except ImportError as exc:
|
|
|
|
|
raise PaymentOrderCreateError("python-alipay-sdk is not installed.") from exc
|
|
|
|
|
|
2026-07-21 11:52:49 +08:00
|
|
|
effective_notify_url = str(notify_url or settings.ALIPAY_NOTIFY_URL or "").strip()
|
2026-07-03 09:34:07 +08:00
|
|
|
_require_payment_config(
|
|
|
|
|
{
|
|
|
|
|
"ALIPAY_APPID": settings.ALIPAY_APPID,
|
|
|
|
|
"ALIPAY_APP_PRIVATE_KEY_PATH": settings.ALIPAY_APP_PRIVATE_KEY_PATH,
|
|
|
|
|
"ALIPAY_PUBLIC_KEY_PATH": settings.ALIPAY_PUBLIC_KEY_PATH,
|
2026-07-21 11:52:49 +08:00
|
|
|
"ALIPAY_NOTIFY_URL": effective_notify_url,
|
2026-07-03 09:34:07 +08:00
|
|
|
},
|
|
|
|
|
"Alipay",
|
|
|
|
|
)
|
|
|
|
|
app_private_key = Path(settings.ALIPAY_APP_PRIVATE_KEY_PATH).read_text(
|
|
|
|
|
encoding="utf-8"
|
|
|
|
|
)
|
|
|
|
|
alipay_public_key = Path(settings.ALIPAY_PUBLIC_KEY_PATH).read_text(
|
|
|
|
|
encoding="utf-8"
|
|
|
|
|
)
|
|
|
|
|
client = AliPay(
|
|
|
|
|
appid=settings.ALIPAY_APPID,
|
2026-07-21 11:52:49 +08:00
|
|
|
app_notify_url=effective_notify_url,
|
2026-07-03 09:34:07 +08:00
|
|
|
app_private_key_string=app_private_key,
|
|
|
|
|
alipay_public_key_string=alipay_public_key,
|
|
|
|
|
sign_type="RSA2",
|
|
|
|
|
debug=settings.ALIPAY_DEBUG,
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
response = client.api_alipay_trade_precreate(
|
2026-07-21 11:52:49 +08:00
|
|
|
subject=description,
|
2026-07-03 09:34:07 +08:00
|
|
|
out_trade_no=order.order_no,
|
|
|
|
|
total_amount=_format_money(order.amount_money),
|
2026-07-21 11:52:49 +08:00
|
|
|
notify_url=effective_notify_url,
|
2026-07-03 09:34:07 +08:00
|
|
|
)
|
|
|
|
|
except Exception as exc: # pragma: no cover - depends on merchant SDK/runtime.
|
|
|
|
|
raise PaymentOrderCreateError("Alipay precreate order failed.") from exc
|
|
|
|
|
|
|
|
|
|
qr_code = response.get("qr_code") if isinstance(response, dict) else None
|
|
|
|
|
if not qr_code:
|
|
|
|
|
raise PaymentOrderCreateError("Alipay precreate order did not return qr_code.")
|
|
|
|
|
return PaymentOrderCode(code_url=str(qr_code), expires_at=_default_expires_at())
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 11:52:49 +08:00
|
|
|
def _verify_wechat_callback_with_sdk(headers, body: bytes) -> PaymentReceipt:
|
2026-07-03 09:07:21 +08:00
|
|
|
try:
|
|
|
|
|
from wechatpayv3 import WeChatPay # type: ignore
|
|
|
|
|
except ImportError as exc:
|
|
|
|
|
raise PaymentVerificationError("wechatpayv3 is not installed.") from exc
|
|
|
|
|
|
|
|
|
|
private_key_path = getattr(settings, "WECHAT_PAY_PRIVATE_KEY_PATH", "")
|
|
|
|
|
if not private_key_path:
|
|
|
|
|
raise PaymentVerificationError("WeChat Pay private key path is not configured.")
|
|
|
|
|
|
|
|
|
|
private_key = Path(private_key_path).read_text(encoding="utf-8")
|
|
|
|
|
client = WeChatPay(
|
|
|
|
|
wechatpay_type="NATIVE",
|
|
|
|
|
mchid=settings.WECHAT_PAY_MCHID,
|
|
|
|
|
private_key=private_key,
|
|
|
|
|
cert_serial_no=settings.WECHAT_PAY_CERT_SERIAL_NO,
|
|
|
|
|
apiv3_key=settings.WECHAT_PAY_API_V3_KEY,
|
|
|
|
|
appid=settings.WECHAT_PAY_APPID,
|
|
|
|
|
notify_url=settings.WECHAT_PAY_NOTIFY_URL,
|
|
|
|
|
)
|
|
|
|
|
resource = client.callback(headers, body)
|
|
|
|
|
if resource.get("trade_state") != "SUCCESS":
|
|
|
|
|
raise PaymentVerificationError("WeChat transaction is not successful.")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
total_cents = Decimal(str((resource.get("amount") or {}).get("total")))
|
|
|
|
|
except (InvalidOperation, TypeError, ValueError) as exc:
|
|
|
|
|
raise PaymentVerificationError("Invalid WeChat payment amount.") from exc
|
2026-07-21 11:52:49 +08:00
|
|
|
return PaymentReceipt(
|
2026-07-03 09:07:21 +08:00
|
|
|
order_no=str(resource.get("out_trade_no") or ""),
|
|
|
|
|
pay_method=RechargeOrder.PayMethod.WEIXIN,
|
|
|
|
|
amount=(total_cents / Decimal("100")).quantize(Decimal("0.01")),
|
|
|
|
|
transaction_id=str(resource.get("transaction_id") or ""),
|
|
|
|
|
paid_at=_parse_paid_at(resource.get("success_time")),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_alipay_callback_with_sdk(data: dict) -> None:
|
|
|
|
|
try:
|
|
|
|
|
from alipay import AliPay # type: ignore
|
|
|
|
|
except ImportError as exc:
|
|
|
|
|
raise PaymentVerificationError("python-alipay-sdk is not installed.") from exc
|
|
|
|
|
|
|
|
|
|
app_private_key = Path(settings.ALIPAY_APP_PRIVATE_KEY_PATH).read_text(
|
|
|
|
|
encoding="utf-8"
|
|
|
|
|
)
|
|
|
|
|
alipay_public_key = Path(settings.ALIPAY_PUBLIC_KEY_PATH).read_text(
|
|
|
|
|
encoding="utf-8"
|
|
|
|
|
)
|
|
|
|
|
sign = data.pop("sign", "")
|
|
|
|
|
client = AliPay(
|
|
|
|
|
appid=settings.ALIPAY_APPID,
|
|
|
|
|
app_notify_url=settings.ALIPAY_NOTIFY_URL,
|
|
|
|
|
app_private_key_string=app_private_key,
|
|
|
|
|
alipay_public_key_string=alipay_public_key,
|
|
|
|
|
sign_type="RSA2",
|
|
|
|
|
debug=settings.ALIPAY_DEBUG,
|
|
|
|
|
)
|
|
|
|
|
if not client.verify(data, sign):
|
|
|
|
|
raise PaymentVerificationError("Invalid Alipay callback signature.")
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 11:52:49 +08:00
|
|
|
def query_payment_order(order) -> PaymentReceipt:
|
2026-07-03 09:34:07 +08:00
|
|
|
if payment_callback_mode() == "mock":
|
|
|
|
|
raise PaymentQueryUnavailableError(
|
|
|
|
|
f"Mock payment query for {order.order_no} is not configured."
|
|
|
|
|
)
|
|
|
|
|
if order.pay_method == RechargeOrder.PayMethod.WEIXIN:
|
|
|
|
|
return _query_wechat_payment_order_with_sdk(order)
|
|
|
|
|
if order.pay_method == RechargeOrder.PayMethod.ALIPAY:
|
|
|
|
|
return _query_alipay_payment_order_with_sdk(order)
|
2026-07-03 09:07:21 +08:00
|
|
|
raise PaymentQueryUnavailableError(
|
|
|
|
|
f"Active payment query for {order.pay_method} is not configured."
|
|
|
|
|
)
|
2026-07-03 09:34:07 +08:00
|
|
|
|
|
|
|
|
|
2026-07-21 11:52:49 +08:00
|
|
|
def _query_wechat_payment_order_with_sdk(order) -> PaymentReceipt:
|
2026-07-03 09:34:07 +08:00
|
|
|
try:
|
|
|
|
|
from wechatpayv3 import WeChatPay # type: ignore
|
|
|
|
|
except ImportError as exc:
|
|
|
|
|
raise PaymentQueryUnavailableError("wechatpayv3 is not installed.") from exc
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
_require_payment_config(
|
|
|
|
|
{
|
|
|
|
|
"WECHAT_PAY_APPID": settings.WECHAT_PAY_APPID,
|
|
|
|
|
"WECHAT_PAY_MCHID": settings.WECHAT_PAY_MCHID,
|
|
|
|
|
"WECHAT_PAY_API_V3_KEY": settings.WECHAT_PAY_API_V3_KEY,
|
|
|
|
|
"WECHAT_PAY_CERT_SERIAL_NO": settings.WECHAT_PAY_CERT_SERIAL_NO,
|
|
|
|
|
"WECHAT_PAY_PRIVATE_KEY_PATH": settings.WECHAT_PAY_PRIVATE_KEY_PATH,
|
|
|
|
|
"WECHAT_PAY_NOTIFY_URL": settings.WECHAT_PAY_NOTIFY_URL,
|
|
|
|
|
},
|
|
|
|
|
"WeChat",
|
|
|
|
|
)
|
|
|
|
|
except PaymentOrderCreateError as exc:
|
|
|
|
|
raise PaymentQueryUnavailableError(str(exc)) from exc
|
|
|
|
|
private_key = Path(settings.WECHAT_PAY_PRIVATE_KEY_PATH).read_text(encoding="utf-8")
|
|
|
|
|
client = WeChatPay(
|
|
|
|
|
wechatpay_type="NATIVE",
|
|
|
|
|
mchid=settings.WECHAT_PAY_MCHID,
|
|
|
|
|
private_key=private_key,
|
|
|
|
|
cert_serial_no=settings.WECHAT_PAY_CERT_SERIAL_NO,
|
|
|
|
|
apiv3_key=settings.WECHAT_PAY_API_V3_KEY,
|
|
|
|
|
appid=settings.WECHAT_PAY_APPID,
|
|
|
|
|
notify_url=settings.WECHAT_PAY_NOTIFY_URL,
|
|
|
|
|
)
|
|
|
|
|
query = getattr(client, "query", None)
|
|
|
|
|
if query is None:
|
|
|
|
|
raise PaymentQueryUnavailableError(
|
|
|
|
|
"WeChat active payment query SDK binding is not configured."
|
|
|
|
|
)
|
|
|
|
|
try:
|
2026-07-06 08:56:30 +08:00
|
|
|
resource = _wechat_response_payload(query(out_trade_no=order.order_no))
|
2026-07-03 09:34:07 +08:00
|
|
|
except Exception as exc: # pragma: no cover - depends on merchant SDK/runtime.
|
|
|
|
|
raise PaymentQueryUnavailableError("WeChat trade query failed.") from exc
|
|
|
|
|
if resource.get("trade_state") != "SUCCESS":
|
|
|
|
|
raise PaymentQueryUnavailableError("WeChat trade is not paid yet.")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
total_cents = Decimal(str((resource.get("amount") or {}).get("total")))
|
|
|
|
|
except (InvalidOperation, TypeError, ValueError) as exc:
|
|
|
|
|
raise PaymentQueryUnavailableError("Invalid WeChat payment amount.") from exc
|
2026-07-21 11:52:49 +08:00
|
|
|
return PaymentReceipt(
|
2026-07-03 09:34:07 +08:00
|
|
|
order_no=str(resource.get("out_trade_no") or order.order_no),
|
|
|
|
|
pay_method=RechargeOrder.PayMethod.WEIXIN,
|
|
|
|
|
amount=(total_cents / Decimal("100")).quantize(Decimal("0.01")),
|
|
|
|
|
transaction_id=str(resource.get("transaction_id") or ""),
|
|
|
|
|
paid_at=_parse_paid_at(resource.get("success_time")),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 11:52:49 +08:00
|
|
|
def _query_alipay_payment_order_with_sdk(order) -> PaymentReceipt:
|
2026-07-03 09:34:07 +08:00
|
|
|
try:
|
|
|
|
|
from alipay import AliPay # type: ignore
|
|
|
|
|
except ImportError as exc:
|
|
|
|
|
raise PaymentQueryUnavailableError("python-alipay-sdk is not installed.") from exc
|
|
|
|
|
|
|
|
|
|
_require_payment_config(
|
|
|
|
|
{
|
|
|
|
|
"ALIPAY_APPID": settings.ALIPAY_APPID,
|
|
|
|
|
"ALIPAY_APP_PRIVATE_KEY_PATH": settings.ALIPAY_APP_PRIVATE_KEY_PATH,
|
|
|
|
|
"ALIPAY_PUBLIC_KEY_PATH": settings.ALIPAY_PUBLIC_KEY_PATH,
|
|
|
|
|
},
|
|
|
|
|
"Alipay",
|
|
|
|
|
)
|
|
|
|
|
app_private_key = Path(settings.ALIPAY_APP_PRIVATE_KEY_PATH).read_text(
|
|
|
|
|
encoding="utf-8"
|
|
|
|
|
)
|
|
|
|
|
alipay_public_key = Path(settings.ALIPAY_PUBLIC_KEY_PATH).read_text(
|
|
|
|
|
encoding="utf-8"
|
|
|
|
|
)
|
|
|
|
|
client = AliPay(
|
|
|
|
|
appid=settings.ALIPAY_APPID,
|
|
|
|
|
app_notify_url=settings.ALIPAY_NOTIFY_URL,
|
|
|
|
|
app_private_key_string=app_private_key,
|
|
|
|
|
alipay_public_key_string=alipay_public_key,
|
|
|
|
|
sign_type="RSA2",
|
|
|
|
|
debug=settings.ALIPAY_DEBUG,
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
response = client.api_alipay_trade_query(out_trade_no=order.order_no)
|
|
|
|
|
except Exception as exc: # pragma: no cover - depends on merchant SDK/runtime.
|
|
|
|
|
raise PaymentQueryUnavailableError("Alipay trade query failed.") from exc
|
|
|
|
|
|
|
|
|
|
trade_status = response.get("trade_status") if isinstance(response, dict) else None
|
|
|
|
|
if trade_status not in {"TRADE_SUCCESS", "TRADE_FINISHED"}:
|
|
|
|
|
raise PaymentQueryUnavailableError("Alipay trade is not paid yet.")
|
2026-07-21 11:52:49 +08:00
|
|
|
return PaymentReceipt(
|
2026-07-03 09:34:07 +08:00
|
|
|
order_no=str(response.get("out_trade_no") or order.order_no),
|
|
|
|
|
pay_method=RechargeOrder.PayMethod.ALIPAY,
|
|
|
|
|
amount=_decimal_money(response.get("total_amount")),
|
|
|
|
|
transaction_id=str(response.get("trade_no") or ""),
|
|
|
|
|
paid_at=_parse_paid_at(response.get("send_pay_date") or response.get("gmt_payment")),
|
|
|
|
|
)
|