Изменения в 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 автоматически зачислится в кошелёк как переплата Цель: - Исключить путаницу в истории транзакций при оплате кошельком - Разграничить поведение: кошелёк строго ограничен, внешние методы допускают переплату - Обеспечить прозрачность движения средств для операторов
238 lines
9.1 KiB
Python
238 lines
9.1 KiB
Python
"""
|
||
Сервис для работы с финансовыми транзакциями заказа.
|
||
Обрабатывает платежи и возвраты.
|
||
"""
|
||
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'))
|