Рефакторинг системы кошелька клиентов

Основные изменения:
- Переход от денормализованного поля 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:
2025-12-28 00:02:09 +03:00
parent 65b3055755
commit b1855cc9f0
9 changed files with 800 additions and 170 deletions

View File

@@ -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
# )