Рефакторинг системы кошелька клиентов
Основные изменения: - Переход от денормализованного поля wallet_balance к вычисляемому балансу - Баланс теперь вычисляется как SUM(signed_amount) транзакций - Добавлено кеширование баланса для производительности (5 минут) - Новая модель WalletTransaction с полем signed_amount (может быть +/-) - WalletService для всех операций с кошельком (deposit, spend, adjustment) - Защита от отрицательного баланса и race conditions через select_for_update - Добавлен balance_after в каждую транзакцию для аудита - Обновлены миграции для переноса данных из старой схемы Улучшения безопасности: - Атомарные транзакции для всех операций с балансом - Блокировка строк при модификации баланса - Валидация недостаточности средств - Обязательное описание для корректировок баланса UI/UX изменения: - Обновлён вывод баланса кошелька в деталях клиента - Добавлена история транзакций с типами и описаниями - Цветовая индикация положительных транзакций (зелёный) Техническая документация: - Добавлены docstrings для всех методов WalletService - Комментарии к критичным участкам кода - Примеры использования в docstrings
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
Сервис для работы с кошельком клиента.
|
||||
Обрабатывает пополнения, списания и корректировки баланса.
|
||||
Все операции создают транзакции в WalletTransaction.
|
||||
Баланс вычисляется как SUM(signed_amount).
|
||||
"""
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
|
||||
@@ -20,20 +22,168 @@ def _quantize(value):
|
||||
class WalletService:
|
||||
"""
|
||||
Сервис для управления кошельком клиента.
|
||||
Все операции атомарны и блокируют запись клиента для избежания race conditions.
|
||||
|
||||
Архитектура:
|
||||
- Баланс = SUM(signed_amount) транзакций (нет денормализованного поля)
|
||||
- Все операции атомарны с блокировкой строк
|
||||
- Кеширование баланса для производительности
|
||||
- Инвалидация кеша при каждой транзакции
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_transaction(
|
||||
customer,
|
||||
amount,
|
||||
transaction_type,
|
||||
category='money',
|
||||
order=None,
|
||||
description='',
|
||||
user=None
|
||||
):
|
||||
"""
|
||||
Создать транзакцию кошелька (базовый метод).
|
||||
|
||||
Args:
|
||||
customer: Customer или customer_id
|
||||
amount: Decimal - положительная сумма
|
||||
transaction_type: str - 'deposit', 'spend', 'adjustment'
|
||||
category: str - 'money' или 'bonus'
|
||||
order: Order - связанный заказ (опционально)
|
||||
description: str - описание
|
||||
user: CustomUser - кто создал
|
||||
|
||||
Returns:
|
||||
WalletTransaction
|
||||
|
||||
Raises:
|
||||
ValueError: если некорректные данные или недостаточно средств
|
||||
"""
|
||||
from customers.models import Customer, WalletTransaction
|
||||
|
||||
# Получаем и блокируем клиента
|
||||
if isinstance(customer, int):
|
||||
customer = Customer.objects.select_for_update().get(pk=customer)
|
||||
else:
|
||||
customer = Customer.objects.select_for_update().get(pk=customer.pk)
|
||||
|
||||
amount = _quantize(amount)
|
||||
if amount <= 0:
|
||||
raise ValueError('Сумма должна быть положительной')
|
||||
|
||||
# Определяем знак суммы
|
||||
if transaction_type == 'spend':
|
||||
signed_amount = -amount
|
||||
else:
|
||||
signed_amount = amount
|
||||
|
||||
# Получаем текущий баланс (без кеша для точности)
|
||||
current_balance = customer.get_wallet_balance(category=category, use_cache=False)
|
||||
|
||||
# Проверяем баланс для списания
|
||||
if signed_amount < 0:
|
||||
if current_balance + signed_amount < 0:
|
||||
raise ValueError(
|
||||
f'Недостаточно средств. Баланс: {current_balance}, '
|
||||
f'запрошено: {abs(signed_amount)}'
|
||||
)
|
||||
|
||||
# Вычисляем баланс после транзакции
|
||||
balance_after = current_balance + signed_amount
|
||||
|
||||
# Создаём транзакцию
|
||||
txn = WalletTransaction.objects.create(
|
||||
customer=customer,
|
||||
signed_amount=signed_amount,
|
||||
transaction_type=transaction_type,
|
||||
balance_category=category,
|
||||
order=order,
|
||||
description=description,
|
||||
created_by=user,
|
||||
balance_after=balance_after
|
||||
)
|
||||
|
||||
# Инвалидируем кеш
|
||||
customer.invalidate_wallet_cache(category=category)
|
||||
|
||||
return txn
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_adjustment(customer, amount, description, user, category='money'):
|
||||
"""
|
||||
Корректировка баланса (может быть положительной или отрицательной).
|
||||
|
||||
Используется для административных операций:
|
||||
- Пополнение кошелька
|
||||
- Списание средств
|
||||
- Исправление ошибок
|
||||
|
||||
Args:
|
||||
customer: Customer или customer_id
|
||||
amount: Decimal - сумма (может быть отрицательной)
|
||||
description: str - обязательное описание
|
||||
user: CustomUser
|
||||
category: str - 'money' или 'bonus'
|
||||
|
||||
Returns:
|
||||
WalletTransaction
|
||||
"""
|
||||
from customers.models import Customer, WalletTransaction
|
||||
|
||||
if not description or not description.strip():
|
||||
raise ValueError('Описание обязательно для корректировки баланса')
|
||||
|
||||
amount = _quantize(amount)
|
||||
if amount == 0:
|
||||
raise ValueError('Сумма корректировки не может быть нулевой')
|
||||
|
||||
# Получаем и блокируем клиента
|
||||
if isinstance(customer, int):
|
||||
customer = Customer.objects.select_for_update().get(pk=customer)
|
||||
else:
|
||||
customer = Customer.objects.select_for_update().get(pk=customer.pk)
|
||||
|
||||
# Получаем текущий баланс
|
||||
current_balance = customer.get_wallet_balance(category=category, use_cache=False)
|
||||
|
||||
# Проверяем, что баланс не уйдёт в минус
|
||||
if current_balance + amount < 0:
|
||||
raise ValueError(
|
||||
f'Корректировка приведёт к отрицательному балансу. '
|
||||
f'Текущий баланс: {current_balance}, корректировка: {amount}'
|
||||
)
|
||||
|
||||
# Вычисляем баланс после
|
||||
balance_after = current_balance + amount
|
||||
|
||||
# Создаём транзакцию
|
||||
txn = WalletTransaction.objects.create(
|
||||
customer=customer,
|
||||
signed_amount=amount, # Может быть положительной или отрицательной
|
||||
transaction_type='adjustment',
|
||||
balance_category=category,
|
||||
order=None,
|
||||
description=description,
|
||||
created_by=user,
|
||||
balance_after=balance_after
|
||||
)
|
||||
|
||||
# Инвалидируем кеш
|
||||
customer.invalidate_wallet_cache(category=category)
|
||||
|
||||
return txn
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def pay_with_wallet(order, amount, user):
|
||||
"""
|
||||
Оплата заказа из кошелька клиента.
|
||||
Создаёт транзакцию. Списание из кошелька происходит автоматически в Transaction.save().
|
||||
|
||||
Args:
|
||||
order: Заказ для оплаты
|
||||
amount: Запрашиваемая сумма для списания
|
||||
user: Пользователь, инициировавший операцию
|
||||
amount: Запрашиваемая сумма
|
||||
user: Пользователь
|
||||
|
||||
Returns:
|
||||
Decimal: Фактически списанная сумма или None
|
||||
@@ -41,26 +191,28 @@ class WalletService:
|
||||
from customers.models import Customer
|
||||
from orders.services.transaction_service import TransactionService
|
||||
|
||||
# Округляем запрошенную сумму
|
||||
amount = _quantize(amount)
|
||||
if amount <= 0:
|
||||
return None
|
||||
|
||||
# Блокируем запись клиента для проверки баланса
|
||||
# Блокируем клиента для проверки баланса
|
||||
customer = Customer.objects.select_for_update().get(pk=order.customer_id)
|
||||
|
||||
# Остаток к оплате по заказу
|
||||
# Текущий баланс
|
||||
wallet_balance = customer.get_wallet_balance(use_cache=False)
|
||||
|
||||
# Остаток к оплате
|
||||
amount_due = order.total_amount - order.amount_paid
|
||||
|
||||
# Определяем фактическую сумму списания (минимум из трёх)
|
||||
usable_amount = min(amount, customer.wallet_balance, amount_due)
|
||||
# Фактическая сумма (минимум из трёх)
|
||||
usable_amount = min(amount, wallet_balance, amount_due)
|
||||
usable_amount = _quantize(usable_amount)
|
||||
|
||||
if usable_amount <= 0:
|
||||
return None
|
||||
|
||||
# Создаём транзакцию
|
||||
# Transaction.save() автоматически спишет из кошелька и создаст WalletTransaction
|
||||
# Создаём транзакцию платежа
|
||||
# Transaction.save() вызовет create_wallet_spend()
|
||||
TransactionService.create_payment(
|
||||
order=order,
|
||||
amount=usable_amount,
|
||||
@@ -76,12 +228,11 @@ class WalletService:
|
||||
def refund_wallet_payment(order, amount, user):
|
||||
"""
|
||||
Возврат средств в кошелёк.
|
||||
Используется для создания транзакции возврата с кошельком.
|
||||
|
||||
Args:
|
||||
order: Заказ, по которому был платёж
|
||||
order: Заказ
|
||||
amount: Сумма возврата
|
||||
user: Пользователь, инициировавший возврат
|
||||
user: Пользователь
|
||||
|
||||
Returns:
|
||||
Decimal: Возвращённая сумма
|
||||
@@ -93,7 +244,7 @@ class WalletService:
|
||||
return None
|
||||
|
||||
# Создаём транзакцию возврата
|
||||
# Transaction.save() автоматически вернёт в кошелёк и создаст WalletTransaction
|
||||
# Transaction.save() вызовет create_wallet_deposit()
|
||||
TransactionService.create_refund(
|
||||
order=order,
|
||||
amount=amount,
|
||||
@@ -108,51 +259,100 @@ class WalletService:
|
||||
@transaction.atomic
|
||||
def adjust_balance(customer_id, amount, description, user):
|
||||
"""
|
||||
Корректировка баланса кошелька администратором.
|
||||
Может быть как положительной (пополнение), так и отрицательной (списание).
|
||||
Корректировка баланса (обёртка для обратной совместимости).
|
||||
|
||||
Args:
|
||||
customer_id: ID клиента
|
||||
amount: Сумма корректировки (может быть отрицательной)
|
||||
description: Обязательное описание причины корректировки
|
||||
user: Пользователь, выполнивший корректировку
|
||||
amount: Сумма (может быть отрицательной)
|
||||
description: Описание
|
||||
user: Пользователь
|
||||
|
||||
Returns:
|
||||
WalletTransaction: Созданная транзакция
|
||||
WalletTransaction
|
||||
"""
|
||||
from customers.models import Customer, WalletTransaction
|
||||
|
||||
if not description or not description.strip():
|
||||
raise ValueError('Описание обязательно для корректировки баланса')
|
||||
|
||||
amount = _quantize(amount)
|
||||
if amount == 0:
|
||||
raise ValueError('Сумма корректировки не может быть нулевой')
|
||||
|
||||
# Блокируем запись клиента
|
||||
customer = Customer.objects.select_for_update().get(pk=customer_id)
|
||||
|
||||
# Применяем корректировку
|
||||
new_balance = _quantize(customer.wallet_balance + amount)
|
||||
|
||||
# Проверяем, что баланс не уйдёт в минус
|
||||
if new_balance < 0:
|
||||
raise ValueError(
|
||||
f'Корректировка приведёт к отрицательному балансу '
|
||||
f'({new_balance} руб.). Операция отклонена.'
|
||||
)
|
||||
|
||||
customer.wallet_balance = new_balance
|
||||
customer.save(update_fields=['wallet_balance'])
|
||||
|
||||
# Создаём транзакцию
|
||||
txn = WalletTransaction.objects.create(
|
||||
customer=customer,
|
||||
amount=abs(amount),
|
||||
transaction_type='adjustment',
|
||||
order=None,
|
||||
return WalletService.create_adjustment(
|
||||
customer=customer_id,
|
||||
amount=amount,
|
||||
description=description,
|
||||
created_by=user
|
||||
user=user
|
||||
)
|
||||
|
||||
return txn
|
||||
# ========== МЕТОДЫ ДЛЯ ВЫЗОВА ИЗ Transaction.save() ==========
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_wallet_spend(order, amount, user):
|
||||
"""
|
||||
Списание из кошелька при оплате заказа.
|
||||
Вызывается из Transaction.save() при payment.
|
||||
|
||||
Args:
|
||||
order: Заказ
|
||||
amount: Сумма списания
|
||||
user: Пользователь
|
||||
|
||||
Returns:
|
||||
WalletTransaction
|
||||
"""
|
||||
return WalletService.create_transaction(
|
||||
customer=order.customer,
|
||||
amount=amount,
|
||||
transaction_type='spend',
|
||||
order=order,
|
||||
description=f'Оплата по заказу #{order.order_number}',
|
||||
user=user
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_wallet_deposit(order, amount, user):
|
||||
"""
|
||||
Пополнение кошелька при возврате.
|
||||
Вызывается из Transaction.save() при refund.
|
||||
|
||||
Args:
|
||||
order: Заказ
|
||||
amount: Сумма возврата
|
||||
user: Пользователь
|
||||
|
||||
Returns:
|
||||
WalletTransaction
|
||||
"""
|
||||
return WalletService.create_transaction(
|
||||
customer=order.customer,
|
||||
amount=amount,
|
||||
transaction_type='deposit',
|
||||
order=order,
|
||||
description=f'Возврат по заказу #{order.order_number}',
|
||||
user=user
|
||||
)
|
||||
|
||||
# ========== МЕТОДЫ ДЛЯ БУДУЩЕЙ БОНУСНОЙ СИСТЕМЫ ==========
|
||||
|
||||
# @staticmethod
|
||||
# @transaction.atomic
|
||||
# def accrue_bonus(customer, amount, reason, user=None, order=None):
|
||||
# """Начислить бонусные баллы."""
|
||||
# return WalletService.create_transaction(
|
||||
# customer=customer,
|
||||
# amount=amount,
|
||||
# transaction_type='bonus_accrual',
|
||||
# category='bonus',
|
||||
# order=order,
|
||||
# description=reason,
|
||||
# user=user
|
||||
# )
|
||||
|
||||
# @staticmethod
|
||||
# @transaction.atomic
|
||||
# def spend_bonus(customer, amount, order, user):
|
||||
# """Списать бонусы за оплату."""
|
||||
# return WalletService.create_transaction(
|
||||
# customer=customer,
|
||||
# amount=amount,
|
||||
# transaction_type='bonus_spend',
|
||||
# category='bonus',
|
||||
# order=order,
|
||||
# description=f'Оплата бонусами по заказу #{order.order_number}',
|
||||
# user=user
|
||||
# )
|
||||
|
||||
Reference in New Issue
Block a user