Files
cmhub/apps/billing/payment_gateways.py
T

204 lines
7.3 KiB
Python

from __future__ import annotations
import hashlib
import hmac
import json
from decimal import Decimal
from decimal import InvalidOperation
from pathlib import Path
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"
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 _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 _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:
raise PaymentQueryUnavailableError(
f"Active payment query for {order.pay_method} is not configured."
)