Files
octopus/myproject/orders/services/transaction_service.py
Andrey Smakotin 3f22677573 Защита от переплаты кошельком и улучшение отображения транзакций
Изменения в UI (order_form.html):
- Добавлен data-code к опциям способов оплаты для идентификации метода кошелька
- ID для селекта способа оплаты (payment-method-select) и поля суммы (payment-amount-input)
- Динамическое ограничение max на поле суммы платежа при выборе кошелька
- Подсказка 'Макс: X руб.' отображается только для оплаты кошельком
- Для внешних методов (карта, наличные) ограничение отсутствует — переплата допустима

Логика JS:
- При выборе метода с code == 'account_balance' устанавливается max = order.amount_due
- Для остальных методов max удаляется — оператор может внести сумму больше остатка
- Переплата по внешним методам корректно зачисляется в кошелёк через WalletService.add_overpayment

Серверная защита (transaction_service.py):
- В TransactionService.create_payment добавлена проверка:
  если payment_method.code == 'account_balance' и amount > order.amount_due — ValidationError
- Сообщение: 'Сумма оплаты из кошелька (X руб.) не может превышать остаток к оплате (Y руб.)'
- Защита от обхода UI через API или прямой вызов

Улучшение отображения (order_form.html, order_detail.html):
- Для возврата в кошелёк (transaction_type == 'refund' и code == 'account_balance') показываем 'на баланс счёта' вместо названия метода
- История становится понятнее: '−750,00 руб. Возврат 29.11.2025 на баланс счёта'

Сценарий:
- Кошелёк клиента 500 руб., заказ 65 руб.
- Оператор выбирает оплату из кошелька — поле суммы ограничено 65 руб.
- Попытка ввести 500 заблокирована UI и серверной валидацией
- Для внешней оплаты (карта онлайн) можно внести 500 руб. — остаток 435 автоматически зачислится в кошелёк как переплата

Цель:
- Исключить путаницу в истории транзакций при оплате кошельком
- Разграничить поведение: кошелёк строго ограничен, внешние методы допускают переплату
- Обеспечить прозрачность движения средств для операторов
2025-11-29 16:54:24 +03:00

238 lines
9.1 KiB
Python
Raw 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.
"""
Сервис для работы с финансовыми транзакциями заказа.
Обрабатывает платежи и возвраты.
"""
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'))