""" Сервис для работы с финансовыми транзакциями заказа. Обрабатывает платежи и возвраты. """ from decimal import Decimal, ROUND_HALF_UP from django.db import transaction from django.core.exceptions import ValidationError # Константа для округления до 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 TransactionService: """ Сервис для управления финансовыми транзакциями заказа. Все операции атомарны. """ @staticmethod @transaction.atomic def create_payment(order, amount, payment_method, user, notes=None): """ Создать платёж по заказу. Args: order: Заказ для оплаты amount: Сумма платежа payment_method: Способ оплаты (PaymentMethod или code) user: Пользователь, создающий платёж notes: Примечания Returns: Transaction: Созданная транзакция платежа """ from orders.models import Transaction, PaymentMethod # Округляем сумму amount = _quantize(amount) if amount <= 0: raise ValueError('Сумма платежа должна быть положительной') # Получаем PaymentMethod если передан code if isinstance(payment_method, str): try: payment_method = PaymentMethod.objects.get(code=payment_method) except PaymentMethod.DoesNotExist: raise ValueError(f'Способ оплаты "{payment_method}" не найден') # Ограничение для кошелька: нельзя оплатить больше чем к оплате if payment_method.code == 'account_balance' and amount > order.amount_due: raise ValidationError( f'Сумма оплаты из кошелька ({amount} руб.) не может превышать ' f'остаток к оплате ({order.amount_due} руб.)' ) # Создаём транзакцию txn = Transaction.objects.create( order=order, transaction_type='payment', amount=amount, payment_method=payment_method, created_by=user, notes=notes or f'Платёж {payment_method.name}' ) return txn @staticmethod @transaction.atomic def create_refund(order, amount, payment_method, user, reason=None, notes=None, related_payment=None): """ Создать возврат по заказу. Args: order: Заказ для возврата amount: Сумма возврата payment_method: Способ возврата (PaymentMethod или code) user: Пользователь, создающий возврат reason: Причина возврата notes: Примечания related_payment: Связанный платёж (Transaction ID или объект) Returns: Transaction: Созданная транзакция возврата """ from orders.models import Transaction, PaymentMethod # Округляем сумму amount = _quantize(amount) if amount <= 0: raise ValueError('Сумма возврата должна быть положительной') # Проверяем, что не возвращаем больше чем оплачено if amount > order.amount_paid: raise ValidationError( f'Сумма возврата ({amount}) превышает оплаченную сумму ({order.amount_paid})' ) # Получаем PaymentMethod если передан code if isinstance(payment_method, str): try: payment_method = PaymentMethod.objects.get(code=payment_method) except PaymentMethod.DoesNotExist: raise ValueError(f'Способ оплаты "{payment_method}" не найден') # Получаем related_payment если передан ID if isinstance(related_payment, int): try: related_payment = Transaction.objects.get(pk=related_payment, transaction_type='payment') except Transaction.DoesNotExist: raise ValueError(f'Платёж #{related_payment} не найден') # Создаём транзакцию возврата txn = Transaction.objects.create( order=order, transaction_type='refund', amount=amount, payment_method=payment_method, related_payment=related_payment, reason=reason or 'Возврат средств', notes=notes, created_by=user ) return txn @staticmethod @transaction.atomic def create_full_refund(order, user, reason=None): """ Полный возврат всей оплаченной суммы заказа. Возвращает средства пропорционально платежам (LIFO - последний вошёл, первый вышел). Args: order: Заказ для возврата user: Пользователь, создающий возврат reason: Причина возврата Returns: list[Transaction]: Список созданных транзакций возврата """ from orders.models import Transaction if order.amount_paid <= 0: raise ValidationError('Нет средств для возврата') refunds = [] remaining_to_refund = order.amount_paid # Берём платежи в обратном порядке (LIFO) payments = order.transactions.filter(transaction_type='payment').order_by('-transaction_date') for payment in payments: if remaining_to_refund <= 0: break # Сколько уже вернули по этому платежу from django.db.models import Sum already_refunded = payment.refunds.aggregate( total=Sum('amount') )['total'] or Decimal('0') # Сколько можно вернуть по этому платежу available_for_refund = payment.amount - already_refunded if available_for_refund <= 0: continue # Возвращаем минимум из доступного и оставшегося refund_amount = min(available_for_refund, remaining_to_refund) # Создаём возврат refund = TransactionService.create_refund( order=order, amount=refund_amount, payment_method=payment.payment_method, user=user, reason=reason or 'Полный возврат заказа', related_payment=payment ) refunds.append(refund) remaining_to_refund -= refund_amount return refunds @staticmethod def get_payment_summary(order): """ Получить сводку по платежам и возвратам заказа. Args: order: Заказ Returns: dict: Сводка с суммами платежей, возвратов и балансом """ from django.db.models import Sum from decimal import Decimal payments_sum = order.transactions.filter( transaction_type='payment' ).aggregate(total=Sum('amount'))['total'] or Decimal('0') refunds_sum = order.transactions.filter( transaction_type='refund' ).aggregate(total=Sum('amount'))['total'] or Decimal('0') return { 'payments_total': payments_sum, 'refunds_total': refunds_sum, 'amount_paid': payments_sum - refunds_sum, 'amount_due': max(order.total_amount - (payments_sum - refunds_sum), Decimal('0')), 'payments_count': order.transactions.filter(transaction_type='payment').count(), 'refunds_count': order.transactions.filter(transaction_type='refund').count(), } @staticmethod def get_refundable_amount(order): """ Получить сумму, доступную для возврата. Args: order: Заказ Returns: Decimal: Сумма, которую можно вернуть """ return max(order.amount_paid, Decimal('0')) @staticmethod def get_available_payment_methods(exclude_codes=None, only_active=True): """ Получить список доступных способов оплаты. Args: exclude_codes: list[str] - коды для исключения (например ['legal_entity']) only_active: bool - только активные методы Returns: QuerySet[PaymentMethod] """ from orders.models import PaymentMethod qs = PaymentMethod.objects.all() if only_active: qs = qs.filter(is_active=True) if exclude_codes: qs = qs.exclude(code__in=exclude_codes) return qs.order_by('order', 'name') @staticmethod @transaction.atomic def create_multiple_payments(order, payments_list, user): """ Создать несколько платежей за одну транзакцию (смешанная оплата). Args: order: Order payments_list: list[dict] - [{'payment_method': code_or_object, 'amount': Decimal, 'notes': str}, ...] user: CustomUser Returns: list[Transaction] Raises: ValidationError: если сумма превышает amount_due или недостаточно средств """ from orders.models import Transaction transactions = [] total_amount = Decimal('0') # Валидация общей суммы for payment_data in payments_list: amount = _quantize(payment_data['amount']) if amount <= 0: raise ValueError(f'Сумма платежа должна быть положительной: {amount}') total_amount += amount if total_amount > order.amount_due: raise ValidationError( f'Общая сумма платежей ({total_amount}) превышает сумму к оплате ({order.amount_due})' ) # Создаём транзакции for payment_data in payments_list: txn = TransactionService.create_payment( order=order, amount=payment_data['amount'], payment_method=payment_data['payment_method'], user=user, notes=payment_data.get('notes') ) transactions.append(txn) return transactions