Files
cmhub/apps/billing/payment_gateways.py
T

521 lines
19 KiB
Python

from __future__ import annotations
import hashlib
import hmac
import json
from dataclasses import dataclass
from datetime import timedelta
from decimal import Decimal
from decimal import InvalidOperation
from pathlib import Path
from urllib.parse import quote
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"
class PaymentOrderCreateError(RuntimeError):
code = "payment_order_create_failed"
@dataclass(frozen=True)
class PaymentOrderCode:
code_url: str
expires_at: object
@dataclass(frozen=True)
class PaymentReceipt:
order_no: str
pay_method: str
amount: Decimal
transaction_id: str
paid_at: object | None = None
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
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 _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.")
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)}"
)
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) -> PaymentReceipt:
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 PaymentReceipt(
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) -> PaymentReceipt:
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 PaymentReceipt(
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")),
)
def create_payment_order(
order,
*,
description: str = "cmhub points recharge",
wechat_notify_url: str | None = None,
alipay_notify_url: str | None = None,
) -> 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,
description=description,
notify_url=wechat_notify_url,
)
if order.pay_method == RechargeOrder.PayMethod.ALIPAY:
return _create_alipay_payment_order_with_sdk(
order,
description=description,
notify_url=alipay_notify_url,
)
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,
*,
description: str = "cmhub points recharge",
notify_url: str | None = None,
) -> PaymentOrderCode:
try:
from wechatpayv3 import WeChatPay, WeChatPayType # type: ignore
except ImportError as exc:
raise PaymentOrderCreateError("wechatpayv3 is not installed.") from exc
effective_notify_url = str(notify_url or settings.WECHAT_PAY_NOTIFY_URL or "").strip()
_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": effective_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=effective_notify_url,
)
try:
response = client.pay(
description=description,
out_trade_no=order.order_no,
amount={
"total": _wechat_amount_cents(order.amount_money),
"currency": order.currency,
},
pay_type=WeChatPayType.NATIVE,
)
except Exception as exc: # pragma: no cover - depends on merchant SDK/runtime.
raise PaymentOrderCreateError("WeChat native order creation failed.") from exc
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")
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,
*,
description: str = "cmhub points recharge",
notify_url: str | None = None,
) -> PaymentOrderCode:
try:
from alipay import AliPay # type: ignore
except ImportError as exc:
raise PaymentOrderCreateError("python-alipay-sdk is not installed.") from exc
effective_notify_url = str(notify_url or settings.ALIPAY_NOTIFY_URL or "").strip()
_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": effective_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=effective_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=description,
out_trade_no=order.order_no,
total_amount=_format_money(order.amount_money),
notify_url=effective_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())
def _verify_wechat_callback_with_sdk(headers, body: bytes) -> PaymentReceipt:
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 PaymentReceipt(
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) -> PaymentReceipt:
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)
raise PaymentQueryUnavailableError(
f"Active payment query for {order.pay_method} is not configured."
)
def _query_wechat_payment_order_with_sdk(order) -> PaymentReceipt:
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 = _wechat_response_payload(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 PaymentReceipt(
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) -> PaymentReceipt:
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 PaymentReceipt(
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")),
)