Files
octopus/myproject/orders/services/transaction_service.py
Andrey Smakotin 1cda9086d0 Реализована полноценная система оплаты для POS-терминала
Добавлена интеграция оплаты в POS с поддержкой одиночной и смешанной оплаты,
работой с кошельком клиента и автоматическим созданием заказов.

Backend изменения:
- TransactionService: добавлены методы get_available_payment_methods() и create_multiple_payments()
  для фильтрации способов оплаты и атомарного создания нескольких платежей
- POS API: новый endpoint pos_checkout() для создания заказов со статусом "Выполнен"
  с обработкой платежей, освобождением блокировок и очисткой корзины
- Template tags: payment_tags.py для получения способов оплаты в шаблонах

Frontend изменения:
- PaymentWidget: переиспользуемый ES6 класс с поддержкой single/mixed режимов,
  автоматической валидацией и интеграцией с кошельком клиента
- terminal.html: компактное модальное окно (70vw) с оптимизированной компоновкой,
  удален функционал скидок, добавлен показ баланса кошелька
- terminal.js: динамическая загрузка PaymentWidget, интеграция с backend API,
  обработка успешной оплаты и ошибок

Поддерживаемые способы оплаты: наличные, карта, онлайн, баланс счёта.
Смешанная оплата позволяет комбинировать несколько способов в одной транзакции.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 15:38:35 +03:00

309 lines
12 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'))
@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