""" Сервис для работы с кошельком клиента. Все операции создают транзакции в 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 # )