Основные изменения: - Переход от денормализованного поля wallet_balance к вычисляемому балансу - Баланс теперь вычисляется как SUM(signed_amount) транзакций - Добавлено кеширование баланса для производительности (5 минут) - Новая модель WalletTransaction с полем signed_amount (может быть +/-) - WalletService для всех операций с кошельком (deposit, spend, adjustment) - Защита от отрицательного баланса и race conditions через select_for_update - Добавлен balance_after в каждую транзакцию для аудита - Обновлены миграции для переноса данных из старой схемы Улучшения безопасности: - Атомарные транзакции для всех операций с балансом - Блокировка строк при модификации баланса - Валидация недостаточности средств - Обязательное описание для корректировок баланса UI/UX изменения: - Обновлён вывод баланса кошелька в деталях клиента - Добавлена история транзакций с типами и описаниями - Цветовая индикация положительных транзакций (зелёный) Техническая документация: - Добавлены docstrings для всех методов WalletService - Комментарии к критичным участкам кода - Примеры использования в docstrings
154 lines
5.5 KiB
Python
154 lines
5.5 KiB
Python
from django.db import models
|
||
from accounts.models import CustomUser
|
||
from decimal import Decimal
|
||
from django.db import transaction
|
||
from django.core.exceptions import ValidationError
|
||
|
||
|
||
class Transaction(models.Model):
|
||
"""
|
||
Финансовая транзакция по заказу.
|
||
Хранит историю всех платежей и возвратов.
|
||
|
||
Транзакция может быть:
|
||
- payment: клиент платит (увеличивает amount_paid)
|
||
- refund: возврат клиенту (уменьшает amount_paid)
|
||
|
||
Поддерживает:
|
||
- Смешанную оплату (несколько транзакций разными способами)
|
||
- Частичные возвраты (любая сумма)
|
||
- Полную историю движения денег
|
||
"""
|
||
|
||
TRANSACTION_TYPE_CHOICES = [
|
||
('payment', 'Платёж'),
|
||
('refund', 'Возврат'),
|
||
]
|
||
|
||
order = models.ForeignKey(
|
||
'Order',
|
||
on_delete=models.CASCADE,
|
||
related_name='transactions',
|
||
verbose_name="Заказ"
|
||
)
|
||
|
||
transaction_type = models.CharField(
|
||
max_length=20,
|
||
choices=TRANSACTION_TYPE_CHOICES,
|
||
default='payment',
|
||
verbose_name="Тип транзакции"
|
||
)
|
||
|
||
amount = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
verbose_name="Сумма",
|
||
help_text="Всегда положительная. Для возврата используется transaction_type='refund'"
|
||
)
|
||
|
||
payment_method = models.ForeignKey(
|
||
'PaymentMethod',
|
||
on_delete=models.PROTECT,
|
||
related_name='transactions',
|
||
verbose_name="Способ оплаты/возврата"
|
||
)
|
||
|
||
# Для возвратов - опциональная связь с исходным платежом
|
||
related_payment = models.ForeignKey(
|
||
'self',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='refunds',
|
||
verbose_name="Связанный платёж",
|
||
help_text="Для возвратов - на какой платёж ссылается этот возврат"
|
||
)
|
||
|
||
transaction_date = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name="Дата и время транзакции"
|
||
)
|
||
|
||
created_by = models.ForeignKey(
|
||
CustomUser,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='transactions_created',
|
||
verbose_name="Создал"
|
||
)
|
||
|
||
notes = models.TextField(
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Примечания"
|
||
)
|
||
|
||
reason = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Причина",
|
||
help_text="Причина возврата или особенности платежа"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Транзакция"
|
||
verbose_name_plural = "Транзакции"
|
||
ordering = ['-transaction_date']
|
||
indexes = [
|
||
models.Index(fields=['order', '-transaction_date']),
|
||
models.Index(fields=['transaction_type']),
|
||
models.Index(fields=['payment_method']),
|
||
models.Index(fields=['transaction_date']),
|
||
]
|
||
|
||
def __str__(self):
|
||
type_name = self.get_transaction_type_display()
|
||
return f"{type_name} {self.amount} руб. по заказу #{self.order.order_number}"
|
||
|
||
def clean(self):
|
||
"""Валидация транзакции"""
|
||
super().clean()
|
||
|
||
# Сумма всегда положительная
|
||
if self.amount <= 0:
|
||
raise ValidationError({'amount': 'Сумма должна быть положительной'})
|
||
|
||
# Для возврата related_payment должен быть payment
|
||
if self.transaction_type == 'refund' and self.related_payment:
|
||
if self.related_payment.transaction_type != 'payment':
|
||
raise ValidationError({
|
||
'related_payment': 'Связанная транзакция должна быть платежом'
|
||
})
|
||
|
||
def save(self, *args, **kwargs):
|
||
"""При сохранении обновляем баланс заказа и обрабатываем кошелёк"""
|
||
is_new = self.pk is None
|
||
|
||
with transaction.atomic():
|
||
super().save(*args, **kwargs)
|
||
|
||
# Пересчитываем баланс заказа
|
||
self.order.recalculate_amount_paid()
|
||
|
||
# Обработка кошелька только для новых транзакций
|
||
if is_new and self.payment_method.code == 'account_balance':
|
||
from customers.services.wallet_service import WalletService
|
||
|
||
if self.transaction_type == 'payment':
|
||
# Списание из кошелька
|
||
WalletService.create_wallet_spend(
|
||
order=self.order,
|
||
amount=self.amount,
|
||
user=self.created_by
|
||
)
|
||
|
||
elif self.transaction_type == 'refund':
|
||
# Возврат в кошелёк
|
||
WalletService.create_wallet_deposit(
|
||
order=self.order,
|
||
amount=self.amount,
|
||
user=self.created_by
|
||
)
|