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 from .services import RechargePayment 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 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) -> 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")), ) 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, WeChatPayType # 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, }, 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: 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()) 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: 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: 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 = _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 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")), )