feat: add recharge create and status APIs
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.billing.models import RechargeOrder
|
||||
|
||||
|
||||
class GenerateTitleRequestSerializer(serializers.Serializer):
|
||||
prompt = serializers.CharField(trim_whitespace=True, allow_blank=False)
|
||||
@@ -43,3 +47,20 @@ class GenerateImageRequestSerializer(serializers.Serializer):
|
||||
default="1:1",
|
||||
)
|
||||
parameters = serializers.DictField(required=False, default=dict)
|
||||
|
||||
|
||||
class RechargeCreateRequestSerializer(serializers.Serializer):
|
||||
amount = serializers.DecimalField(
|
||||
max_digits=12,
|
||||
decimal_places=2,
|
||||
min_value=Decimal("0.01"),
|
||||
)
|
||||
pay_method = serializers.ChoiceField(choices=RechargeOrder.PayMethod.values)
|
||||
|
||||
|
||||
class RechargeStatusRequestSerializer(serializers.Serializer):
|
||||
order_no = serializers.CharField(
|
||||
trim_whitespace=True,
|
||||
allow_blank=False,
|
||||
max_length=64,
|
||||
)
|
||||
|
||||
+171
-1
@@ -10,6 +10,7 @@ from cryptography.fernet import Fernet
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import path
|
||||
from django.utils import timezone
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -22,11 +23,18 @@ from apps.ai.providers import (
|
||||
ImageGenerationResult,
|
||||
TextGenerationResult,
|
||||
)
|
||||
from apps.billing.models import CallRecord, PointsLedger, PricingRule, RechargeOrder
|
||||
from apps.billing.models import (
|
||||
CallRecord,
|
||||
ExchangeRate,
|
||||
PointsLedger,
|
||||
PricingRule,
|
||||
RechargeOrder,
|
||||
)
|
||||
from apps.billing.payment_gateways import (
|
||||
build_mock_alipay_signature,
|
||||
build_mock_body_signature,
|
||||
)
|
||||
from apps.billing.services import RechargePayment
|
||||
from apps.users.models import ApiKey
|
||||
from apps.users.models import UserWallet
|
||||
|
||||
@@ -375,6 +383,168 @@ class RechargeCallbackApiTests(TestCase):
|
||||
self.assertFalse(PointsLedger.objects.filter(ref_order_id=order.id).exists())
|
||||
|
||||
|
||||
@override_settings(
|
||||
PAYMENT_CALLBACK_MODE="mock",
|
||||
PAYMENT_MOCK_CALLBACK_SECRET="test-payment-callback-secret",
|
||||
PAYMENT_QR_EXPIRES_MINUTES=15,
|
||||
)
|
||||
class RechargeCreateStatusApiTests(TestCase):
|
||||
create_url = "/api/v1/recharge/create"
|
||||
status_url = "/api/v1/recharge/status"
|
||||
|
||||
def setUp(self):
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username=f"recharge-create-{suffix}",
|
||||
email=f"recharge-create-{suffix}@example.com",
|
||||
password="password",
|
||||
)
|
||||
self.other_user = get_user_model().objects.create_user(
|
||||
username=f"recharge-other-{suffix}",
|
||||
email=f"recharge-other-{suffix}@example.com",
|
||||
password="password",
|
||||
)
|
||||
self.wallet = UserWallet.objects.create(user=self.user, points_balance=100)
|
||||
self.api_key, self.raw_key = ApiKey.create_for_user(self.user, name="recharge")
|
||||
self.exchange_rate = ExchangeRate.objects.create(
|
||||
currency="CNY",
|
||||
points_per_unit=Decimal("10.0000"),
|
||||
effective_from=timezone.now(),
|
||||
)
|
||||
self.client = APIClient()
|
||||
|
||||
def create_order(
|
||||
self,
|
||||
*,
|
||||
user=None,
|
||||
amount="20.00",
|
||||
points_granted=200,
|
||||
pay_method=RechargeOrder.PayMethod.WEIXIN,
|
||||
):
|
||||
return RechargeOrder.objects.create(
|
||||
user=user or self.user,
|
||||
order_no=f"R{uuid.uuid4().hex[:12]}",
|
||||
amount_money=Decimal(amount),
|
||||
pay_method=pay_method,
|
||||
exchange_rate=Decimal("10.0000"),
|
||||
points_granted=points_granted,
|
||||
code_url=f"mockpay://{pay_method}/existing",
|
||||
)
|
||||
|
||||
def test_recharge_create_requires_web_session_not_api_key(self):
|
||||
response = self.client.post(
|
||||
self.create_url,
|
||||
{"amount": "20.00", "pay_method": "weixin"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.raw_key}",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertFalse(RechargeOrder.objects.filter(user=self.user).exists())
|
||||
|
||||
def test_recharge_create_is_session_authenticated_and_locks_quote(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.post(
|
||||
self.create_url,
|
||||
{"amount": "20.00", "pay_method": "weixin"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.data["amount"], "20.00")
|
||||
self.assertEqual(response.data["exchange_rate"], "10.0000")
|
||||
self.assertEqual(response.data["points_granted"], 200)
|
||||
self.assertEqual(response.data["pay_method"], RechargeOrder.PayMethod.WEIXIN)
|
||||
self.assertEqual(response.data["status"], RechargeOrder.Status.PENDING)
|
||||
self.assertTrue(response.data["code_url"].startswith("weixin://wxpay/cmhub-mock"))
|
||||
self.assertIsNotNone(response.data["expires_at"])
|
||||
|
||||
order = RechargeOrder.objects.get(order_no=response.data["order_no"])
|
||||
self.assertEqual(order.user, self.user)
|
||||
self.assertEqual(order.exchange_rate, Decimal("10.0000"))
|
||||
self.assertEqual(order.points_granted, 200)
|
||||
self.wallet.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 100)
|
||||
self.assertFalse(PointsLedger.objects.filter(ref_order_id=order.id).exists())
|
||||
|
||||
def test_recharge_create_supports_alipay_mock_qr_code(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.post(
|
||||
self.create_url,
|
||||
{"amount": "30.00", "pay_method": "alipay"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.data["pay_method"], RechargeOrder.PayMethod.ALIPAY)
|
||||
self.assertTrue(response.data["code_url"].startswith("https://qr.alipay.com/cmhub-mock"))
|
||||
|
||||
def test_recharge_create_enforces_csrf_for_real_session_clients(self):
|
||||
csrf_client = APIClient(enforce_csrf_checks=True)
|
||||
csrf_client.force_login(self.user)
|
||||
|
||||
response = csrf_client.post(
|
||||
self.create_url,
|
||||
{"amount": "20.00", "pay_method": "weixin"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertFalse(RechargeOrder.objects.filter(user=self.user).exists())
|
||||
|
||||
def test_recharge_status_returns_pending_order_for_owner_only(self):
|
||||
order = self.create_order()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get(self.status_url, {"order_no": order.order_no})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["order_no"], order.order_no)
|
||||
self.assertEqual(response.data["status"], RechargeOrder.Status.PENDING)
|
||||
|
||||
self.client.force_login(self.other_user)
|
||||
denied = self.client.get(self.status_url, {"order_no": order.order_no})
|
||||
|
||||
self.assertEqual(denied.status_code, 404)
|
||||
self.assertEqual(denied.data["error"]["code"], "order_not_found")
|
||||
|
||||
def test_recharge_status_active_query_can_apply_paid_order_once(self):
|
||||
order = self.create_order(amount="20.00", points_granted=200)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def fake_query(queried_order):
|
||||
return RechargePayment(
|
||||
order_no=queried_order.order_no,
|
||||
pay_method=queried_order.pay_method,
|
||||
amount=queried_order.amount_money,
|
||||
transaction_id="queried-txn-001",
|
||||
paid_at=timezone.now(),
|
||||
)
|
||||
|
||||
with patch("apps.api.views.query_payment_order", side_effect=fake_query) as query:
|
||||
first = self.client.get(self.status_url, {"order_no": order.order_no})
|
||||
second = self.client.get(self.status_url, {"order_no": order.order_no})
|
||||
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(second.status_code, 200)
|
||||
self.assertEqual(first.data["status"], RechargeOrder.Status.PAID)
|
||||
self.assertEqual(second.data["status"], RechargeOrder.Status.PAID)
|
||||
self.assertEqual(query.call_count, 1)
|
||||
self.wallet.refresh_from_db()
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 300)
|
||||
self.assertEqual(order.payment_txn_no, "queried-txn-001")
|
||||
self.assertEqual(
|
||||
PointsLedger.objects.filter(
|
||||
ref_order_id=order.id,
|
||||
change_type=PointsLedger.ChangeType.RECHARGE,
|
||||
).count(),
|
||||
1,
|
||||
)
|
||||
|
||||
|
||||
class FakeGenerationProvider:
|
||||
def __init__(self, *, capabilities=None):
|
||||
self._capabilities = set(capabilities or {"text", "image", "vision"})
|
||||
|
||||
@@ -5,6 +5,8 @@ from .views import (
|
||||
BalanceView,
|
||||
GenerateImageView,
|
||||
GenerateTitleView,
|
||||
RechargeCreateView,
|
||||
RechargeStatusView,
|
||||
WechatRechargeCallbackView,
|
||||
)
|
||||
|
||||
@@ -12,6 +14,8 @@ urlpatterns = [
|
||||
path("v1/balance", BalanceView.as_view(), name="api-balance"),
|
||||
path("v1/generate/title", GenerateTitleView.as_view(), name="api-generate-title"),
|
||||
path("v1/generate/image", GenerateImageView.as_view(), name="api-generate-image"),
|
||||
path("v1/recharge/create", RechargeCreateView.as_view(), name="api-recharge-create"),
|
||||
path("v1/recharge/status", RechargeStatusView.as_view(), name="api-recharge-status"),
|
||||
path(
|
||||
"v1/recharge/callback/wechat",
|
||||
WechatRechargeCallbackView.as_view(),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import logging
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
@@ -19,20 +21,30 @@ from apps.api.generation import (
|
||||
from apps.api.serializers import (
|
||||
GenerateImageRequestSerializer,
|
||||
GenerateTitleRequestSerializer,
|
||||
RechargeCreateRequestSerializer,
|
||||
RechargeStatusRequestSerializer,
|
||||
)
|
||||
from apps.billing.models import RechargeOrder
|
||||
from apps.billing.payment_gateways import (
|
||||
PaymentOrderCreateError,
|
||||
PaymentQueryUnavailableError,
|
||||
PaymentVerificationError,
|
||||
query_payment_order,
|
||||
verify_alipay_callback,
|
||||
verify_wechat_callback,
|
||||
)
|
||||
from apps.billing.pricing import NoExchangeRateError
|
||||
from apps.billing.services import (
|
||||
InvalidRechargeOrderStateError,
|
||||
RechargeAmountMismatchError,
|
||||
RechargeCallbackError,
|
||||
RechargeOrderCreateError,
|
||||
RechargeOrderNotFoundError,
|
||||
RechargePayMethodMismatchError,
|
||||
apply_recharge_payment,
|
||||
create_recharge_order,
|
||||
get_balance_snapshot,
|
||||
query_and_apply_recharge_payment,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -99,6 +111,106 @@ class BalanceView(ExternalApiView):
|
||||
)
|
||||
|
||||
|
||||
class PortalSessionApiView(APIView):
|
||||
authentication_classes = (SessionAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
|
||||
def _recharge_order_response(order: RechargeOrder) -> dict:
|
||||
is_expired = bool(
|
||||
order.status == RechargeOrder.Status.PENDING
|
||||
and order.expires_at is not None
|
||||
and order.expires_at <= timezone.now()
|
||||
)
|
||||
return {
|
||||
"order_no": order.order_no,
|
||||
"amount": f"{order.amount_money:.2f}",
|
||||
"currency": order.currency,
|
||||
"exchange_rate": f"{order.exchange_rate:.4f}",
|
||||
"points_granted": order.points_granted,
|
||||
"pay_method": order.pay_method,
|
||||
"status": order.status,
|
||||
"code_url": order.code_url,
|
||||
"expires_at": order.expires_at.isoformat() if order.expires_at else None,
|
||||
"paid_at": order.paid_at.isoformat() if order.paid_at else None,
|
||||
"is_expired": is_expired,
|
||||
}
|
||||
|
||||
|
||||
class RechargeCreateView(PortalSessionApiView):
|
||||
def post(self, request):
|
||||
serializer = RechargeCreateRequestSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
api_error("bad_request", "参数错误"),
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
order = create_recharge_order(
|
||||
user=request.user,
|
||||
amount=serializer.validated_data["amount"],
|
||||
pay_method=serializer.validated_data["pay_method"],
|
||||
)
|
||||
except NoExchangeRateError:
|
||||
return Response(
|
||||
api_error("no_exchange_rate", "未配置当前币种汇率"),
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except RechargeOrderCreateError:
|
||||
return Response(
|
||||
api_error("bad_request", "充值下单参数错误"),
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except PaymentOrderCreateError:
|
||||
return Response(
|
||||
api_error("payment_order_create_failed", "支付下单失败"),
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
|
||||
return Response(_recharge_order_response(order), status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class RechargeStatusView(PortalSessionApiView):
|
||||
def get(self, request):
|
||||
serializer = RechargeStatusRequestSerializer(data=request.query_params)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
api_error("bad_request", "参数错误"),
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
order_no = serializer.validated_data["order_no"]
|
||||
order = RechargeOrder.objects.filter(
|
||||
user=request.user,
|
||||
order_no=order_no,
|
||||
).first()
|
||||
if order is None:
|
||||
return Response(
|
||||
api_error("order_not_found", "充值订单不存在"),
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if order.status == RechargeOrder.Status.PENDING:
|
||||
try:
|
||||
result = query_and_apply_recharge_payment(order.order_no, query_payment_order)
|
||||
order = result.order
|
||||
except (PaymentQueryUnavailableError, PaymentOrderCreateError):
|
||||
order.refresh_from_db()
|
||||
except RechargeCallbackError as exc:
|
||||
logger.warning(
|
||||
"Rejected active recharge query result for %s: %s",
|
||||
order.order_no,
|
||||
exc.__class__.__name__,
|
||||
)
|
||||
return Response(
|
||||
api_error(exc.code, "支付查单结果与本地订单不一致"),
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
return Response(_recharge_order_response(order), status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class RechargeCallbackView(APIView):
|
||||
authentication_classes = ()
|
||||
permission_classes = ()
|
||||
|
||||
@@ -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")),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user