Files
octopus/myproject/customers/services/wallet_service.py
Andrey Smakotin b1855cc9f0 Рефакторинг системы кошелька клиентов
Основные изменения:
- Переход от денормализованного поля 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
2025-12-28 00:02:09 +03:00

359 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Сервис для работы с кошельком клиента.
Все операции создают транзакции в 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
# )