Files
octopus/myproject/orders/models/transaction.py
Andrey Smakotin b1855cc9f0 Рефакторинг системы кошелька клиентов
Основные изменения:
- Переход от денормализованного поля 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
2025-12-28 00:02:09 +03:00

154 lines
5.5 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 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
)