feat: add recharge callback processing

This commit is contained in:
QiuSW
2026-07-03 09:07:21 +08:00
parent 8931960b35
commit c15a07a5c2
20 changed files with 1073 additions and 25 deletions
+25 -1
View File
@@ -1,6 +1,6 @@
from django.contrib import admin
from .models import CallRecord, ExchangeRate, PointsLedger, PricingRule
from .models import CallRecord, ExchangeRate, PointsLedger, PricingRule, RechargeOrder
class ReadOnlyLedgerAdmin(admin.ModelAdmin):
@@ -68,6 +68,30 @@ class ExchangeRateAdmin(admin.ModelAdmin):
readonly_fields = ("created_at", "updated_at")
@admin.register(RechargeOrder)
class RechargeOrderAdmin(ReadOnlyLedgerAdmin):
list_display = (
"created_at",
"order_no",
"user",
"pay_method",
"status",
"amount_money",
"currency",
"points_granted",
"payment_txn_no",
"paid_at",
)
list_filter = ("pay_method", "status", "currency", "created_at")
search_fields = (
"order_no",
"user__username",
"user__email",
"payment_txn_no",
)
ordering = ("-created_at", "-id")
@admin.register(CallRecord)
class CallRecordAdmin(ReadOnlyLedgerAdmin):
list_display = (
@@ -0,0 +1,72 @@
# Generated by Django 5.2.15 on 2026-07-03 00:45
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0003_pointsledger_unique_ledger_change_type_per_call'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='RechargeOrder',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order_no', models.CharField(max_length=64, unique=True)),
('amount_money', models.DecimalField(decimal_places=2, max_digits=12)),
('currency', models.CharField(default='CNY', max_length=10)),
('pay_method', models.CharField(choices=[('weixin', 'Weixin'), ('alipay', 'Alipay')], max_length=20)),
('exchange_rate', models.DecimalField(decimal_places=4, max_digits=12)),
('points_granted', models.BigIntegerField()),
('status', models.CharField(choices=[('pending', 'Pending'), ('paid', 'Paid'), ('failed', 'Failed'), ('expired', 'Expired')], default='pending', max_length=20)),
('code_url', models.TextField(blank=True)),
('expires_at', models.DateTimeField(blank=True, null=True)),
('payment_txn_no', models.CharField(blank=True, max_length=128)),
('paid_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'recharge_order',
'ordering': ('-created_at', '-id'),
},
),
migrations.AddConstraint(
model_name='pointsledger',
constraint=models.UniqueConstraint(fields=('ref_order_id', 'change_type'), name='unique_ledger_change_type_per_order'),
),
migrations.AddField(
model_name='rechargeorder',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recharge_orders', to=settings.AUTH_USER_MODEL),
),
migrations.AddIndex(
model_name='rechargeorder',
index=models.Index(fields=['user', 'created_at'], name='recharge_or_user_id_312e87_idx'),
),
migrations.AddIndex(
model_name='rechargeorder',
index=models.Index(fields=['status', 'created_at'], name='recharge_or_status_139277_idx'),
),
migrations.AddIndex(
model_name='rechargeorder',
index=models.Index(fields=['pay_method', 'status'], name='recharge_or_pay_met_4c0043_idx'),
),
migrations.AddConstraint(
model_name='rechargeorder',
constraint=models.CheckConstraint(condition=models.Q(('amount_money__gt', 0)), name='recharge_order_amount_money_positive'),
),
migrations.AddConstraint(
model_name='rechargeorder',
constraint=models.CheckConstraint(condition=models.Q(('exchange_rate__gt', 0)), name='recharge_order_exchange_rate_positive'),
),
migrations.AddConstraint(
model_name='rechargeorder',
constraint=models.CheckConstraint(condition=models.Q(('points_granted__gt', 0)), name='recharge_order_points_granted_positive'),
),
]
+78
View File
@@ -154,6 +154,80 @@ class ExchangeRate(models.Model):
super().save(*args, **kwargs)
class RechargeOrder(models.Model):
class PayMethod(models.TextChoices):
WEIXIN = "weixin", "Weixin"
ALIPAY = "alipay", "Alipay"
class Status(models.TextChoices):
PENDING = "pending", "Pending"
PAID = "paid", "Paid"
FAILED = "failed", "Failed"
EXPIRED = "expired", "Expired"
order_no = models.CharField(max_length=64, unique=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="recharge_orders",
)
amount_money = models.DecimalField(max_digits=12, decimal_places=2)
currency = models.CharField(max_length=10, default="CNY")
pay_method = models.CharField(max_length=20, choices=PayMethod.choices)
exchange_rate = models.DecimalField(max_digits=12, decimal_places=4)
points_granted = models.BigIntegerField()
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.PENDING,
)
code_url = models.TextField(blank=True)
expires_at = models.DateTimeField(null=True, blank=True)
payment_txn_no = models.CharField(max_length=128, blank=True)
paid_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "recharge_order"
ordering = ("-created_at", "-id")
constraints = [
models.CheckConstraint(
condition=Q(amount_money__gt=0),
name="recharge_order_amount_money_positive",
),
models.CheckConstraint(
condition=Q(exchange_rate__gt=0),
name="recharge_order_exchange_rate_positive",
),
models.CheckConstraint(
condition=Q(points_granted__gt=0),
name="recharge_order_points_granted_positive",
),
]
indexes = [
models.Index(fields=("user", "created_at")),
models.Index(fields=("status", "created_at")),
models.Index(fields=("pay_method", "status")),
]
def __str__(self) -> str:
return f"{self.order_no} {self.status}"
def clean(self) -> None:
super().clean()
self.order_no = str(self.order_no or "").strip()
self.currency = str(self.currency or "").strip().upper()
if not self.order_no:
raise ValidationError({"order_no": "Order number is required."})
if not self.currency:
raise ValidationError({"currency": "Currency is required."})
def save(self, *args, **kwargs) -> None:
self.full_clean()
super().save(*args, **kwargs)
class PointsLedger(models.Model):
class ChangeType(models.TextChoices):
RECHARGE = "recharge", "Recharge"
@@ -196,6 +270,10 @@ class PointsLedger(models.Model):
fields=("ref_call", "change_type"),
name="unique_ledger_change_type_per_call",
),
models.UniqueConstraint(
fields=("ref_order_id", "change_type"),
name="unique_ledger_change_type_per_order",
),
]
indexes = [
models.Index(fields=("user", "created_at")),
+203
View File
@@ -0,0 +1,203 @@
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."
)
+160 -1
View File
@@ -1,13 +1,16 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from django.db import transaction
from django.db.models import Sum
from django.utils import timezone
from apps.users.models import UserWallet
from .models import CallRecord, PointsLedger, normalize_resolution
from .models import CallRecord, PointsLedger, RechargeOrder, normalize_resolution
class BillingOperationError(RuntimeError):
@@ -30,6 +33,44 @@ class InvalidCallStateError(BillingOperationError):
super().__init__(message)
class RechargeCallbackError(BillingOperationError):
code = "recharge_callback_error"
class RechargeOrderNotFoundError(RechargeCallbackError):
code = "order_not_found"
def __init__(self, order_no: str):
self.order_no = order_no
super().__init__("Recharge order was not found.")
class RechargeAmountMismatchError(RechargeCallbackError):
code = "amount_mismatch"
def __init__(self, *, order_amount: Decimal, callback_amount: Decimal):
self.order_amount = order_amount
self.callback_amount = callback_amount
super().__init__("Payment callback amount does not match local recharge order.")
class RechargePayMethodMismatchError(RechargeCallbackError):
code = "bad_request"
def __init__(self, *, order_pay_method: str, callback_pay_method: str):
self.order_pay_method = order_pay_method
self.callback_pay_method = callback_pay_method
super().__init__("Payment callback method does not match local recharge order.")
class InvalidRechargeOrderStateError(RechargeCallbackError):
code = "bad_request"
def __init__(self, *, status: str):
self.status = status
super().__init__("Recharge order cannot be paid from its current state.")
@dataclass(frozen=True)
class CallCharge:
call_record: CallRecord
@@ -53,6 +94,24 @@ class BalanceSnapshot:
ledger_balance: int
@dataclass(frozen=True)
class RechargePayment:
order_no: str
pay_method: str
amount: Decimal
transaction_id: str
paid_at: datetime | None = None
@dataclass(frozen=True)
class RechargeResult:
order: RechargeOrder
ledger_entry: PointsLedger | None
points_granted: int
balance_after: int
applied: bool
def _validate_positive_points(points: int) -> int:
if isinstance(points, bool) or not isinstance(points, int) or points <= 0:
raise ValueError("points must be a positive integer")
@@ -64,6 +123,14 @@ def _locked_wallet_for_user(user) -> UserWallet:
return wallet
def _normalize_money(value) -> Decimal:
return Decimal(str(value)).quantize(Decimal("0.01"))
def _normalize_pay_method(value: str) -> str:
return str(value or "").strip().lower()
def get_balance_snapshot(user) -> BalanceSnapshot:
points_balance = (
UserWallet.objects.filter(user=user)
@@ -79,6 +146,98 @@ def get_balance_snapshot(user) -> BalanceSnapshot:
)
def apply_recharge_payment(payment: RechargePayment) -> RechargeResult:
order_no = str(payment.order_no or "").strip()
pay_method = _normalize_pay_method(payment.pay_method)
callback_amount = _normalize_money(payment.amount)
paid_at = payment.paid_at or timezone.now()
with transaction.atomic():
try:
order = (
RechargeOrder.objects.select_for_update()
.select_related("user")
.get(order_no=order_no)
)
except RechargeOrder.DoesNotExist as exc:
raise RechargeOrderNotFoundError(order_no) from exc
if order.status == RechargeOrder.Status.PAID:
ledger_entry = (
PointsLedger.objects.filter(
ref_order_id=order.id,
change_type=PointsLedger.ChangeType.RECHARGE,
)
.order_by("id")
.first()
)
wallet_balance = (
UserWallet.objects.filter(user=order.user)
.values_list("points_balance", flat=True)
.first()
)
return RechargeResult(
order=order,
ledger_entry=ledger_entry,
points_granted=0,
balance_after=int(wallet_balance or 0),
applied=False,
)
if order.status != RechargeOrder.Status.PENDING:
raise InvalidRechargeOrderStateError(status=order.status)
if _normalize_pay_method(order.pay_method) != pay_method:
raise RechargePayMethodMismatchError(
order_pay_method=order.pay_method,
callback_pay_method=pay_method,
)
order_amount = _normalize_money(order.amount_money)
if order_amount != callback_amount:
raise RechargeAmountMismatchError(
order_amount=order_amount,
callback_amount=callback_amount,
)
points_granted = _validate_positive_points(order.points_granted)
wallet = _locked_wallet_for_user(order.user)
wallet.points_balance += points_granted
wallet.save(update_fields=("points_balance", "updated_at"))
order.status = RechargeOrder.Status.PAID
order.payment_txn_no = str(payment.transaction_id or "").strip()
order.paid_at = paid_at
order.save(update_fields=("status", "payment_txn_no", "paid_at", "updated_at"))
ledger_entry = PointsLedger.objects.create(
user=order.user,
change_type=PointsLedger.ChangeType.RECHARGE,
points_delta=points_granted,
balance_after=wallet.points_balance,
ref_order_id=order.id,
reason=f"Recharge paid via {order.pay_method}: {order.order_no}",
)
return RechargeResult(
order=order,
ledger_entry=ledger_entry,
points_granted=points_granted,
balance_after=ledger_entry.balance_after,
applied=True,
)
def query_and_apply_recharge_payment(order_no: str, query_func) -> RechargeResult:
normalized_order_no = str(order_no or "").strip()
try:
order = RechargeOrder.objects.get(order_no=normalized_order_no)
except RechargeOrder.DoesNotExist as exc:
raise RechargeOrderNotFoundError(normalized_order_no) from exc
payment = query_func(order)
return apply_recharge_payment(payment)
def precharge_call(
*,
user,
+166 -1
View File
@@ -13,7 +13,13 @@ from django.test import TestCase, TransactionTestCase, override_settings
from django.utils import timezone
from apps.ai.models import AiModel, ModelAlias
from apps.billing.models import CallRecord, ExchangeRate, PointsLedger, PricingRule
from apps.billing.models import (
CallRecord,
ExchangeRate,
PointsLedger,
PricingRule,
RechargeOrder,
)
from apps.billing.pricing import (
NoPricingRuleError,
calculate_points_cost,
@@ -24,8 +30,12 @@ from apps.billing.pricing import (
from apps.billing.services import (
InsufficientPointsError,
InvalidCallStateError,
RechargeAmountMismatchError,
RechargePayment,
apply_recharge_payment,
mark_call_success,
precharge_call,
query_and_apply_recharge_payment,
refund_call_points,
)
from apps.users.models import ApiKey, UserWallet
@@ -178,11 +188,55 @@ class BillingCoreModelTests(TestCase):
ref_call=call,
)
def test_points_ledger_rejects_duplicate_recharge_for_same_order(self):
order = RechargeOrder.objects.create(
user=self.user,
order_no=f"R{uuid.uuid4().hex[:12]}",
amount_money=Decimal("20.00"),
pay_method=RechargeOrder.PayMethod.WEIXIN,
exchange_rate=Decimal("10.0000"),
points_granted=200,
)
other_order = RechargeOrder.objects.create(
user=self.user,
order_no=f"R{uuid.uuid4().hex[:12]}",
amount_money=Decimal("30.00"),
pay_method=RechargeOrder.PayMethod.WEIXIN,
exchange_rate=Decimal("10.0000"),
points_granted=300,
)
PointsLedger.objects.create(
user=self.user,
change_type=PointsLedger.ChangeType.RECHARGE,
points_delta=200,
balance_after=200,
ref_order_id=order.id,
)
PointsLedger.objects.create(
user=self.user,
change_type=PointsLedger.ChangeType.RECHARGE,
points_delta=300,
balance_after=500,
ref_order_id=other_order.id,
)
with self.assertRaises(IntegrityError):
with transaction.atomic():
PointsLedger.objects.create(
user=self.user,
change_type=PointsLedger.ChangeType.RECHARGE,
points_delta=200,
balance_after=700,
ref_order_id=order.id,
)
def test_billing_models_are_registered_in_admin(self):
self.assertIn(UserWallet, admin.site._registry)
self.assertIn(ApiKey, admin.site._registry)
self.assertIn(PointsLedger, admin.site._registry)
self.assertIn(CallRecord, admin.site._registry)
self.assertIn(RechargeOrder, admin.site._registry)
@override_settings(AI_KEY_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY)
@@ -339,6 +393,22 @@ class BillingServiceTests(TestCase):
self.wallet = UserWallet.objects.create(user=self.user, points_balance=100)
self.api_key, _raw_key = ApiKey.create_for_user(self.user, name="server")
def create_recharge_order(
self,
*,
amount="20.00",
points_granted=200,
pay_method=RechargeOrder.PayMethod.WEIXIN,
) -> RechargeOrder:
return RechargeOrder.objects.create(
user=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,
)
def test_precharge_call_debits_wallet_and_writes_pending_call_and_consume_ledger(self):
charge = precharge_call(
user=self.user,
@@ -458,6 +528,101 @@ class BillingServiceTests(TestCase):
with self.assertRaises(InvalidCallStateError):
refund_call_points(call, error_message="late failure")
def test_apply_recharge_payment_credits_wallet_writes_ledger_and_marks_paid(self):
order = self.create_recharge_order(amount="20.00", points_granted=200)
result = apply_recharge_payment(
RechargePayment(
order_no=order.order_no,
pay_method=RechargeOrder.PayMethod.WEIXIN,
amount=Decimal("20.00"),
transaction_id="wx-txn-001",
paid_at=timezone.now(),
)
)
self.wallet.refresh_from_db()
order.refresh_from_db()
self.assertTrue(result.applied)
self.assertEqual(result.points_granted, 200)
self.assertEqual(result.balance_after, 300)
self.assertEqual(self.wallet.points_balance, 300)
self.assertEqual(order.status, RechargeOrder.Status.PAID)
self.assertEqual(order.payment_txn_no, "wx-txn-001")
self.assertIsNotNone(order.paid_at)
ledger = PointsLedger.objects.get(ref_order_id=order.id)
self.assertEqual(ledger.change_type, PointsLedger.ChangeType.RECHARGE)
self.assertEqual(ledger.points_delta, 200)
self.assertEqual(ledger.balance_after, 300)
self.assertEqual(ledger.user, self.user)
def test_apply_recharge_payment_is_idempotent_for_duplicate_callback(self):
order = self.create_recharge_order(amount="20.00", points_granted=200)
payment = RechargePayment(
order_no=order.order_no,
pay_method=RechargeOrder.PayMethod.WEIXIN,
amount=Decimal("20.00"),
transaction_id="wx-txn-duplicate",
paid_at=timezone.now(),
)
first = apply_recharge_payment(payment)
second = apply_recharge_payment(payment)
self.wallet.refresh_from_db()
self.assertTrue(first.applied)
self.assertFalse(second.applied)
self.assertEqual(self.wallet.points_balance, 300)
self.assertEqual(
PointsLedger.objects.filter(
ref_order_id=order.id,
change_type=PointsLedger.ChangeType.RECHARGE,
).count(),
1,
)
def test_apply_recharge_payment_rejects_amount_mismatch_without_crediting(self):
order = self.create_recharge_order(amount="20.00", points_granted=200)
with self.assertRaises(RechargeAmountMismatchError):
apply_recharge_payment(
RechargePayment(
order_no=order.order_no,
pay_method=RechargeOrder.PayMethod.WEIXIN,
amount=Decimal("19.99"),
transaction_id="wx-txn-bad-amount",
paid_at=timezone.now(),
)
)
self.wallet.refresh_from_db()
order.refresh_from_db()
self.assertEqual(self.wallet.points_balance, 100)
self.assertEqual(order.status, RechargeOrder.Status.PENDING)
self.assertFalse(PointsLedger.objects.filter(ref_order_id=order.id).exists())
def test_query_and_apply_recharge_payment_uses_same_idempotent_path(self):
order = self.create_recharge_order(amount="30.00", points_granted=300)
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(),
)
result = query_and_apply_recharge_payment(order.order_no, fake_query)
self.wallet.refresh_from_db()
order.refresh_from_db()
self.assertTrue(result.applied)
self.assertEqual(self.wallet.points_balance, 400)
self.assertEqual(order.status, RechargeOrder.Status.PAID)
self.assertEqual(order.payment_txn_no, "queried-txn-001")
class ConcurrentDebitTests(TransactionTestCase):
def setUp(self):