feat: add recharge callback processing
This commit is contained in:
+25
-1
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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")),
|
||||
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user