Основные изменения: - Переход от денормализованного поля 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
359 lines
12 KiB
Python
359 lines
12 KiB
Python
"""
|
||
Сервис для работы с кошельком клиента.
|
||
Все операции создают транзакции в WalletTransaction.
|
||
Баланс вычисляется как SUM(signed_amount).
|
||
"""
|
||
from decimal import Decimal, ROUND_HALF_UP
|
||
|
||
from django.db import transaction
|
||
|
||
|
||
# Константа для округления до 2 знаков
|
||
QUANTIZE_2D = Decimal('0.01')
|
||
|
||
|
||
def _quantize(value):
|
||
"""Округление до 2 знаков после запятой"""
|
||
if isinstance(value, (int, float)):
|
||
value = Decimal(str(value))
|
||
return value.quantize(QUANTIZE_2D, rounding=ROUND_HALF_UP)
|
||
|
||
|
||
class WalletService:
|
||
"""
|
||
Сервис для управления кошельком клиента.
|
||
|
||
Архитектура:
|
||
- Баланс = 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):
|
||
"""
|
||
Оплата заказа из кошелька клиента.
|
||
|
||
Args:
|
||
order: Заказ для оплаты
|
||
amount: Запрашиваемая сумма
|
||
user: Пользователь
|
||
|
||
Returns:
|
||
Decimal: Фактически списанная сумма или None
|
||
"""
|
||
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, wallet_balance, amount_due)
|
||
usable_amount = _quantize(usable_amount)
|
||
|
||
if usable_amount <= 0:
|
||
return None
|
||
|
||
# Создаём транзакцию платежа
|
||
# Transaction.save() вызовет create_wallet_spend()
|
||
TransactionService.create_payment(
|
||
order=order,
|
||
amount=usable_amount,
|
||
payment_method='account_balance',
|
||
user=user,
|
||
notes='Оплата из кошелька клиента'
|
||
)
|
||
|
||
return usable_amount
|
||
|
||
@staticmethod
|
||
@transaction.atomic
|
||
def refund_wallet_payment(order, amount, user):
|
||
"""
|
||
Возврат средств в кошелёк.
|
||
|
||
Args:
|
||
order: Заказ
|
||
amount: Сумма возврата
|
||
user: Пользователь
|
||
|
||
Returns:
|
||
Decimal: Возвращённая сумма
|
||
"""
|
||
from orders.services.transaction_service import TransactionService
|
||
|
||
amount = _quantize(amount)
|
||
if amount <= 0:
|
||
return None
|
||
|
||
# Создаём транзакцию возврата
|
||
# Transaction.save() вызовет create_wallet_deposit()
|
||
TransactionService.create_refund(
|
||
order=order,
|
||
amount=amount,
|
||
payment_method='account_balance',
|
||
user=user,
|
||
reason='Возврат в кошелёк'
|
||
)
|
||
|
||
return amount
|
||
|
||
@staticmethod
|
||
@transaction.atomic
|
||
def adjust_balance(customer_id, amount, description, user):
|
||
"""
|
||
Корректировка баланса (обёртка для обратной совместимости).
|
||
|
||
Args:
|
||
customer_id: ID клиента
|
||
amount: Сумма (может быть отрицательной)
|
||
description: Описание
|
||
user: Пользователь
|
||
|
||
Returns:
|
||
WalletTransaction
|
||
"""
|
||
return WalletService.create_adjustment(
|
||
customer=customer_id,
|
||
amount=amount,
|
||
description=description,
|
||
user=user
|
||
)
|
||
|
||
# ========== МЕТОДЫ ДЛЯ ВЫЗОВА ИЗ 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
|
||
# )
|