feat: add recharge callback processing
This commit is contained in:
+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,
|
||||
|
||||
Reference in New Issue
Block a user