feat: add recharge create and status APIs

This commit is contained in:
QiuSW
2026-07-03 09:34:07 +08:00
parent c15a07a5c2
commit fa87359a2c
18 changed files with 808 additions and 22 deletions
+259
View File
@@ -3,9 +3,12 @@ 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
@@ -23,6 +26,16 @@ 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()
@@ -73,6 +86,27 @@ def _decimal_money(value) -> Decimal:
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 _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")
@@ -135,6 +169,123 @@ def verify_alipay_callback(data: dict) -> RechargePayment:
)
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())
def _verify_wechat_callback_with_sdk(headers, body: bytes) -> RechargePayment:
try:
from wechatpayv3 import WeChatPay # type: ignore
@@ -198,6 +349,114 @@ def _verify_alipay_callback_with_sdk(data: dict) -> None:
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 = 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")),
)
+90 -2
View File
@@ -1,8 +1,9 @@
from __future__ import annotations
import secrets
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from decimal import Decimal, InvalidOperation
from django.db import transaction
from django.db.models import Sum
@@ -11,6 +12,7 @@ from django.utils import timezone
from apps.users.models import UserWallet
from .models import CallRecord, PointsLedger, RechargeOrder, normalize_resolution
from .pricing import quote_recharge_points
class BillingOperationError(RuntimeError):
@@ -71,6 +73,18 @@ class InvalidRechargeOrderStateError(RechargeCallbackError):
super().__init__("Recharge order cannot be paid from its current state.")
class RechargeOrderCreateError(BillingOperationError):
code = "bad_request"
class InvalidRechargePayMethodError(RechargeOrderCreateError):
code = "bad_request"
def __init__(self, pay_method: str):
self.pay_method = pay_method
super().__init__("Unsupported recharge payment method.")
@dataclass(frozen=True)
class CallCharge:
call_record: CallRecord
@@ -124,13 +138,87 @@ def _locked_wallet_for_user(user) -> UserWallet:
def _normalize_money(value) -> Decimal:
return Decimal(str(value)).quantize(Decimal("0.01"))
try:
return Decimal(str(value)).quantize(Decimal("0.01"))
except (InvalidOperation, TypeError, ValueError) as exc:
raise RechargeOrderCreateError("Invalid recharge amount.") from exc
def _normalize_pay_method(value: str) -> str:
return str(value or "").strip().lower()
def _validate_recharge_pay_method(value: str) -> str:
pay_method = _normalize_pay_method(value)
if pay_method not in {
RechargeOrder.PayMethod.WEIXIN,
RechargeOrder.PayMethod.ALIPAY,
}:
raise InvalidRechargePayMethodError(pay_method)
return pay_method
def _generate_recharge_order_no() -> str:
for _attempt in range(10):
timestamp = timezone.now().strftime("%Y%m%d%H%M%S")
order_no = f"R{timestamp}{secrets.token_hex(4).upper()}"
if not RechargeOrder.objects.filter(order_no=order_no).exists():
return order_no
raise RechargeOrderCreateError("Could not generate unique recharge order number.")
def create_recharge_order(
*,
user,
amount,
pay_method: str,
currency: str = "CNY",
payment_order_func=None,
) -> RechargeOrder:
normalized_amount = _normalize_money(amount)
if normalized_amount <= 0:
raise RechargeOrderCreateError("Recharge amount must be greater than zero.")
normalized_pay_method = _validate_recharge_pay_method(pay_method)
quote = quote_recharge_points(normalized_amount, currency=currency)
if quote.points_granted <= 0:
raise RechargeOrderCreateError(
"Recharge amount is too small for the current exchange rate."
)
order = RechargeOrder.objects.create(
user=user,
order_no=_generate_recharge_order_no(),
amount_money=quote.amount,
currency=quote.currency,
pay_method=normalized_pay_method,
exchange_rate=quote.points_per_unit,
points_granted=quote.points_granted,
status=RechargeOrder.Status.PENDING,
)
if payment_order_func is None:
from .payment_gateways import create_payment_order
payment_order_func = create_payment_order
try:
payment_order = payment_order_func(order)
code_url = str(getattr(payment_order, "code_url", "") or "").strip()
expires_at = getattr(payment_order, "expires_at", None)
if not code_url:
raise RechargeOrderCreateError("Payment gateway did not return a QR code URL.")
except Exception:
order.status = RechargeOrder.Status.FAILED
order.save(update_fields=("status", "updated_at"))
raise
order.code_url = code_url
order.expires_at = expires_at
order.save(update_fields=("code_url", "expires_at", "updated_at"))
return order
def get_balance_snapshot(user) -> BalanceSnapshot:
points_balance = (
UserWallet.objects.filter(user=user)
+60
View File
@@ -27,12 +27,15 @@ from apps.billing.pricing import (
get_pricing_rule,
quote_recharge_points,
)
from apps.billing.payment_gateways import PaymentOrderCode
from apps.billing.services import (
InsufficientPointsError,
InvalidCallStateError,
RechargeAmountMismatchError,
RechargeOrderCreateError,
RechargePayment,
apply_recharge_payment,
create_recharge_order,
mark_call_success,
precharge_call,
query_and_apply_recharge_payment,
@@ -409,6 +412,63 @@ class BillingServiceTests(TestCase):
points_granted=points_granted,
)
def create_exchange_rate(self, *, points_per_unit="10.0000", effective_from=None):
return ExchangeRate.objects.create(
currency="CNY",
points_per_unit=Decimal(points_per_unit),
effective_from=effective_from or timezone.now() - timedelta(minutes=1),
)
def test_create_recharge_order_locks_exchange_rate_points_and_qr_code(self):
self.create_exchange_rate(points_per_unit="12.5000")
expires_at = timezone.now() + timedelta(minutes=10)
def fake_payment_order(order):
return PaymentOrderCode(
code_url=f"mockpay://{order.pay_method}/{order.order_no}",
expires_at=expires_at,
)
order = create_recharge_order(
user=self.user,
amount=Decimal("8.88"),
pay_method=RechargeOrder.PayMethod.WEIXIN,
payment_order_func=fake_payment_order,
)
self.assertEqual(order.user, self.user)
self.assertEqual(order.status, RechargeOrder.Status.PENDING)
self.assertEqual(order.amount_money, Decimal("8.88"))
self.assertEqual(order.currency, "CNY")
self.assertEqual(order.pay_method, RechargeOrder.PayMethod.WEIXIN)
self.assertEqual(order.exchange_rate, Decimal("12.5000"))
self.assertEqual(order.points_granted, 111)
self.assertTrue(order.code_url.startswith("mockpay://weixin/"))
self.assertEqual(order.expires_at, expires_at)
self.create_exchange_rate(points_per_unit="99.0000")
order.refresh_from_db()
self.assertEqual(order.exchange_rate, Decimal("12.5000"))
self.assertEqual(order.points_granted, 111)
def test_create_recharge_order_marks_order_failed_when_gateway_fails(self):
self.create_exchange_rate(points_per_unit="10.0000")
def failing_payment_order(_order):
raise RechargeOrderCreateError("gateway failed")
with self.assertRaises(RechargeOrderCreateError):
create_recharge_order(
user=self.user,
amount=Decimal("20.00"),
pay_method=RechargeOrder.PayMethod.ALIPAY,
payment_order_func=failing_payment_order,
)
order = RechargeOrder.objects.get(user=self.user, amount_money=Decimal("20.00"))
self.assertEqual(order.status, RechargeOrder.Status.FAILED)
self.assertEqual(order.code_url, "")
def test_precharge_call_debits_wallet_and_writes_pending_call_and_consume_ledger(self):
charge = precharge_call(
user=self.user,