Добавлена интеграция оплаты в 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>
309 lines
12 KiB
Python
309 lines
12 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'))
|
||
|
||
@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
|