Реализация системы кошелька клиента для переплат

- Добавлено поле wallet_balance в модель Customer
- Создана модель WalletTransaction для истории операций
- Реализован сервис WalletService с методами:
  * add_overpayment - автоматическое зачисление переплаты
  * pay_with_wallet - оплата заказа из кошелька
  * adjust_balance - ручная корректировка баланса
- Интеграция с Payment.save() для автоматической обработки переплат
- UI для оплаты из кошелька в деталях заказа
- Отображение баланса и долга на странице клиента
- Админка с inline транзакций и запретом ручного создания
- Добавлен способ оплаты account_balance
- Миграция 0004 для customers приложения
This commit is contained in:
2025-11-26 14:47:11 +03:00
parent 0653ec0545
commit 5ead7fdd2e
16 changed files with 1401 additions and 3 deletions

View File

@@ -0,0 +1,193 @@
"""
Сервис для работы с кошельком клиента.
Обрабатывает пополнения, списания и корректировки баланса.
"""
from decimal import Decimal, ROUND_HALF_UP
from django.db import transaction
# Константа для округления до 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 WalletService:
"""
Сервис для управления кошельком клиента.
Все операции атомарны и блокируют запись клиента для избежания race conditions.
"""
@staticmethod
@transaction.atomic
def add_overpayment(order, user):
"""
Обработка переплаты по заказу.
Переносит излишек в кошелёк клиента и нормализует amount_paid заказа.
Args:
order: Заказ с переплатой
user: Пользователь, инициировавший операцию
Returns:
Decimal: Сумма переплаты или None, если переплаты нет
"""
from customers.models import Customer, WalletTransaction
overpayment = order.amount_paid - order.total_amount
if overpayment <= 0:
return None
# Блокируем запись клиента для обновления
customer = Customer.objects.select_for_update().get(pk=order.customer_id)
# Округляем переплату до 2 знаков
overpayment = _quantize(overpayment)
# Увеличиваем баланс кошелька
customer.wallet_balance = _quantize(customer.wallet_balance + overpayment)
customer.save(update_fields=['wallet_balance'])
# Создаём транзакцию для аудита
WalletTransaction.objects.create(
customer=customer,
amount=overpayment,
transaction_type='deposit',
order=order,
description=f'Переплата по заказу #{order.order_number}',
created_by=user
)
# Нормализуем amount_paid заказа до total_amount
order.amount_paid = order.total_amount
order.save(update_fields=['amount_paid'])
return overpayment
@staticmethod
@transaction.atomic
def pay_with_wallet(order, amount, user):
"""
Оплата заказа из кошелька клиента.
Списывает средства с кошелька и создаёт платёж в заказе.
Args:
order: Заказ для оплаты
amount: Запрашиваемая сумма для списания
user: Пользователь, инициировавший операцию
Returns:
Decimal: Фактически списанная сумма или None
"""
from customers.models import Customer, WalletTransaction
from orders.models import Payment, PaymentMethod
# Округляем запрошенную сумму
amount = _quantize(amount)
if amount <= 0:
return None
# Блокируем запись клиента
customer = Customer.objects.select_for_update().get(pk=order.customer_id)
# Остаток к оплате по заказу
amount_due = order.total_amount - order.amount_paid
# Определяем фактическую сумму списания (минимум из трёх)
usable_amount = min(amount, customer.wallet_balance, amount_due)
usable_amount = _quantize(usable_amount)
if usable_amount <= 0:
return None
# Получаем способ оплаты "С баланса счёта"
try:
payment_method = PaymentMethod.objects.get(code='account_balance')
except PaymentMethod.DoesNotExist:
raise ValueError(
'Способ оплаты "account_balance" не найден. '
'Запустите команду create_payment_methods.'
)
# Создаём платёж в заказе
Payment.objects.create(
order=order,
amount=usable_amount,
payment_method=payment_method,
created_by=user,
notes='Оплата из кошелька клиента'
)
# Уменьшаем баланс кошелька
customer.wallet_balance = _quantize(customer.wallet_balance - usable_amount)
customer.save(update_fields=['wallet_balance'])
# Создаём транзакцию для аудита
WalletTransaction.objects.create(
customer=customer,
amount=usable_amount,
transaction_type='spend',
order=order,
description=f'Оплата заказа #{order.order_number} из кошелька',
created_by=user
)
return usable_amount
@staticmethod
@transaction.atomic
def adjust_balance(customer_id, amount, description, user):
"""
Корректировка баланса кошелька администратором.
Может быть как положительной (пополнение), так и отрицательной (списание).
Args:
customer_id: ID клиента
amount: Сумма корректировки (может быть отрицательной)
description: Обязательное описание причины корректировки
user: Пользователь, выполнивший корректировку
Returns:
WalletTransaction: Созданная транзакция
"""
from customers.models import Customer, WalletTransaction
if not description or not description.strip():
raise ValueError('Описание обязательно для корректировки баланса')
amount = _quantize(amount)
if amount == 0:
raise ValueError('Сумма корректировки не может быть нулевой')
# Блокируем запись клиента
customer = Customer.objects.select_for_update().get(pk=customer_id)
# Применяем корректировку
new_balance = _quantize(customer.wallet_balance + amount)
# Проверяем, что баланс не уйдёт в минус
if new_balance < 0:
raise ValueError(
f'Корректировка приведёт к отрицательному балансу '
f'({new_balance} руб.). Операция отклонена.'
)
customer.wallet_balance = new_balance
customer.save(update_fields=['wallet_balance'])
# Создаём транзакцию
txn = WalletTransaction.objects.create(
customer=customer,
amount=abs(amount),
transaction_type='adjustment',
order=None,
description=description,
created_by=user
)
return txn