204 lines
7.3 KiB
Python
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."
|
|
)
|