Files
cmhub/apps/billing/payment_gateways.py
T

463 lines
18 KiB
Python
Raw Normal View History

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
from .services import RechargePayment
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-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())
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.")
def verify_wechat_callback(headers, body: bytes) -> RechargePayment:
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
return RechargePayment(
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)
def verify_alipay_callback(data: dict) -> RechargePayment:
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.")
return RechargePayment(
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-03 09:34:07 +08:00
def create_payment_order(order: RechargeOrder) -> PaymentOrderCode:
if payment_callback_mode() == "mock":
return _create_mock_payment_order(order)
if order.pay_method == RechargeOrder.PayMethod.WEIXIN:
return _create_wechat_payment_order_with_sdk(order)
if order.pay_method == RechargeOrder.PayMethod.ALIPAY:
return _create_alipay_payment_order_with_sdk(order)
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())
def _create_wechat_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderCode:
try:
from wechatpayv3 import WeChatPay # type: ignore
except ImportError as exc:
raise PaymentOrderCreateError("wechatpayv3 is not installed.") from exc
_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",
)
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,
)
try:
response = client.pay(
description="cmhub points recharge",
out_trade_no=order.order_no,
amount={
"total": _wechat_amount_cents(order.amount_money),
"currency": order.currency,
},
)
except Exception as exc: # pragma: no cover - depends on merchant SDK/runtime.
raise PaymentOrderCreateError("WeChat native order creation failed.") from exc
code_url = response.get("code_url") if isinstance(response, dict) else None
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())
def _create_alipay_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderCode:
try:
from alipay import AliPay # type: ignore
except ImportError as exc:
raise PaymentOrderCreateError("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_NOTIFY_URL": settings.ALIPAY_NOTIFY_URL,
},
"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_precreate(
subject="cmhub points recharge",
out_trade_no=order.order_no,
total_amount=_format_money(order.amount_money),
notify_url=settings.ALIPAY_NOTIFY_URL,
)
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-03 09:07:21 +08:00
def _verify_wechat_callback_with_sdk(headers, body: bytes) -> RechargePayment:
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
return RechargePayment(
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.")
def query_payment_order(order: RechargeOrder) -> RechargePayment:
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
def _query_wechat_payment_order_with_sdk(order: RechargeOrder) -> RechargePayment:
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:
resource = query(out_trade_no=order.order_no)
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
return RechargePayment(
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")),
)
def _query_alipay_payment_order_with_sdk(order: RechargeOrder) -> RechargePayment:
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.")
return RechargePayment(
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")),
)