diff --git a/myproject/customers/services/wallet_service.py b/myproject/customers/services/wallet_service.py index 8b94c04..4edd9eb 100644 --- a/myproject/customers/services/wallet_service.py +++ b/myproject/customers/services/wallet_service.py @@ -74,7 +74,7 @@ class WalletService: def pay_with_wallet(order, amount, user): """ Оплата заказа из кошелька клиента. - Списывает средства с кошелька и создаёт платёж в заказе. + Создаёт транзакцию. Списание из кошелька происходит автоматически в Transaction.save(). Args: order: Заказ для оплаты @@ -84,15 +84,15 @@ class WalletService: Returns: Decimal: Фактически списанная сумма или None """ - from customers.models import Customer, WalletTransaction - from orders.models import Payment, PaymentMethod + from customers.models import Customer + from orders.services.transaction_service import TransactionService # Округляем запрошенную сумму amount = _quantize(amount) if amount <= 0: return None - # Блокируем запись клиента + # Блокируем запись клиента для проверки баланса customer = Customer.objects.select_for_update().get(pk=order.customer_id) # Остаток к оплате по заказу @@ -105,46 +105,24 @@ class WalletService: 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( + # Создаём транзакцию + # Transaction.save() автоматически спишет из кошелька и создаст WalletTransaction + TransactionService.create_payment( order=order, amount=usable_amount, - payment_method=payment_method, - created_by=user, + payment_method='account_balance', + user=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 refund_wallet_payment(order, amount, user): """ - Возврат средств в кошелёк при удалении платежа. - Увеличивает баланс кошелька и создаёт транзакцию deposit. + Возврат средств в кошелёк. + Используется для создания транзакции возврата с кошельком. Args: order: Заказ, по которому был платёж @@ -154,27 +132,20 @@ class WalletService: Returns: Decimal: Возвращённая сумма """ - from customers.models import Customer, WalletTransaction + from orders.services.transaction_service import TransactionService amount = _quantize(amount) if amount <= 0: return None - # Блокируем запись клиента - customer = Customer.objects.select_for_update().get(pk=order.customer_id) - - # Увеличиваем баланс - customer.wallet_balance = _quantize(customer.wallet_balance + amount) - customer.save(update_fields=['wallet_balance']) - # Создаём транзакцию возврата - WalletTransaction.objects.create( - customer=customer, - amount=amount, - transaction_type='deposit', + # Transaction.save() автоматически вернёт в кошелёк и создаст WalletTransaction + TransactionService.create_refund( order=order, - description=f'Возврат платежа по заказу #{order.order_number}', - created_by=user + amount=amount, + payment_method='account_balance', + user=user, + reason='Возврат в кошелёк' ) return amount diff --git a/myproject/orders/admin.py b/myproject/orders/admin.py index a88c1b6..de1493c 100644 --- a/myproject/orders/admin.py +++ b/myproject/orders/admin.py @@ -1,17 +1,18 @@ # -*- coding: utf-8 -*- from django.contrib import admin from django.utils.html import format_html -from .models import Order, OrderItem, Payment, PaymentMethod, Address, OrderStatus +from .models import Order, OrderItem, Transaction, PaymentMethod, Address, OrderStatus -class PaymentInline(admin.TabularInline): +class TransactionInline(admin.TabularInline): """ - Inline для управления платежами по заказу. + Inline для управления транзакциями по заказу. """ - model = Payment - extra = 1 - fields = ['amount', 'payment_method', 'payment_date', 'created_by', 'notes'] - readonly_fields = ['payment_date'] + model = Transaction + extra = 0 + fields = ['transaction_type', 'amount', 'payment_method', 'transaction_date', 'created_by', 'reason', 'notes'] + readonly_fields = ['transaction_date'] + can_delete = False # Используйте refund вместо удаления class OrderItemInline(admin.TabularInline): @@ -110,7 +111,7 @@ class OrderAdmin(admin.ModelAdmin): }), ) - inlines = [OrderItemInline, PaymentInline] + inlines = [OrderItemInline, TransactionInline] actions = [ 'mark_as_confirmed', @@ -151,37 +152,41 @@ class OrderAdmin(admin.ModelAdmin): mark_as_paid.short_description = 'Отметить как оплаченные' -@admin.register(Payment) -class PaymentAdmin(admin.ModelAdmin): +@admin.register(Transaction) +class TransactionAdmin(admin.ModelAdmin): """ - Админ-панель для управления платежами. + Админ-панель для управления транзакциями. """ list_display = [ 'order', + 'transaction_type', 'amount', 'payment_method', - 'payment_date', + 'transaction_date', 'created_by', + 'reason', ] list_filter = [ + 'transaction_type', 'payment_method', - 'payment_date', + 'transaction_date', ] search_fields = [ 'order__order_number', + 'reason', 'notes', ] - readonly_fields = ['payment_date'] + readonly_fields = ['transaction_date'] fieldsets = ( - ('Информация о платеже', { - 'fields': ('order', 'amount', 'payment_method', 'payment_date') + ('Информация о транзакции', { + 'fields': ('order', 'transaction_type', 'amount', 'payment_method', 'transaction_date') }), ('Дополнительно', { - 'fields': ('created_by', 'notes') + 'fields': ('related_payment', 'reason', 'created_by', 'notes') }), ) diff --git a/myproject/orders/forms.py b/myproject/orders/forms.py index 2685d9a..6640a2d 100644 --- a/myproject/orders/forms.py +++ b/myproject/orders/forms.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from django import forms from django.forms import inlineformset_factory -from .models import Order, OrderItem, Payment, Address, OrderStatus +from .models import Order, OrderItem, Transaction, Address, OrderStatus from customers.models import Customer from inventory.models import Warehouse from products.models import Product, ProductKit @@ -472,15 +472,15 @@ TemporaryKitItemFormSet = formset_factory( ) -# === ПЛАТЕЖИ (СМЕШАННАЯ ОПЛАТА) === +# === ТРАНЗАКЦИИ (СМЕШАННАЯ ОПЛАТА И ВОЗВРАТЫ) === -class PaymentForm(forms.ModelForm): +class TransactionForm(forms.ModelForm): """ - Форма для создания платежа по заказу. - Поддерживает смешанную оплату (несколько платежей на один заказ). + Форма для создания транзакций по заказу. + Поддерживает смешанную оплату и возвраты. """ class Meta: - model = Payment + model = Transaction fields = ['payment_method', 'amount', 'notes'] widgets = { 'payment_method': forms.Select(attrs={'class': 'form-select'}), @@ -546,13 +546,13 @@ class PaymentForm(forms.ModelForm): return cleaned -# Formset для множественных платежей -PaymentFormSet = inlineformset_factory( +# Formset для множественных транзакций +TransactionFormSet = inlineformset_factory( Order, - Payment, - form=PaymentForm, + Transaction, + form=TransactionForm, extra=0, # Без пустых форм (добавляем через JavaScript) - can_delete=True, - min_num=0, # Платежи не обязательны при создании черновика + can_delete=False, # Используйте refund вместо удаления + min_num=0, # Транзакции не обязательны при создании черновика validate_min=False, ) diff --git a/myproject/orders/migrations/0006_transaction_delete_payment_and_more.py b/myproject/orders/migrations/0006_transaction_delete_payment_and_more.py new file mode 100644 index 0000000..b7da9d3 --- /dev/null +++ b/myproject/orders/migrations/0006_transaction_delete_payment_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 5.0.10 on 2025-11-29 09:42 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0005_remove_historicalorder_discount_amount_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Transaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('transaction_type', models.CharField(choices=[('payment', 'Платёж'), ('refund', 'Возврат')], default='payment', max_length=20, verbose_name='Тип транзакции')), + ('amount', models.DecimalField(decimal_places=2, help_text="Всегда положительная. Для возврата используется transaction_type='refund'", max_digits=10, verbose_name='Сумма')), + ('transaction_date', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время транзакции')), + ('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')), + ('reason', models.CharField(blank=True, help_text='Причина возврата или особенности платежа', max_length=255, null=True, verbose_name='Причина')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions_created', to=settings.AUTH_USER_MODEL, verbose_name='Создал')), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='orders.order', verbose_name='Заказ')), + ('payment_method', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='orders.paymentmethod', verbose_name='Способ оплаты/возврата')), + ('related_payment', models.ForeignKey(blank=True, help_text='Для возвратов - на какой платёж ссылается этот возврат', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refunds', to='orders.transaction', verbose_name='Связанный платёж')), + ], + options={ + 'verbose_name': 'Транзакция', + 'verbose_name_plural': 'Транзакции', + 'ordering': ['-transaction_date'], + }, + ), + migrations.DeleteModel( + name='Payment', + ), + migrations.AddIndex( + model_name='transaction', + index=models.Index(fields=['order', '-transaction_date'], name='orders_tran_order_i_dc90ee_idx'), + ), + migrations.AddIndex( + model_name='transaction', + index=models.Index(fields=['transaction_type'], name='orders_tran_transac_3d971d_idx'), + ), + migrations.AddIndex( + model_name='transaction', + index=models.Index(fields=['payment_method'], name='orders_tran_payment_7e354c_idx'), + ), + migrations.AddIndex( + model_name='transaction', + index=models.Index(fields=['transaction_date'], name='orders_tran_transac_1bae48_idx'), + ), + ] diff --git a/myproject/orders/models/__init__.py b/myproject/orders/models/__init__.py index ce9d131..caf26b5 100644 --- a/myproject/orders/models/__init__.py +++ b/myproject/orders/models/__init__.py @@ -7,13 +7,13 @@ - Order: Главная модель заказа - OrderItem: Позиции в заказе - PaymentMethod: Способы оплаты (справочник) -- Payment: Платежи по заказам (поддержка смешанной оплаты) +- Transaction: Финансовые транзакции (платежи и возвраты) """ # Порядок импортов по зависимостям: # 1. Независимые модели (справочники) from .status import OrderStatus -from .payment import PaymentMethod +from .payment_method import PaymentMethod # 2. Модели с зависимостями от справочников from .address import Address @@ -23,7 +23,7 @@ from .order import Order # 4. Зависимые модели from .order_item import OrderItem -from .payment import Payment +from .transaction import Transaction __all__ = [ 'OrderStatus', @@ -31,5 +31,5 @@ __all__ = [ 'Order', 'OrderItem', 'PaymentMethod', - 'Payment', + 'Transaction', ] diff --git a/myproject/orders/models/order.py b/myproject/orders/models/order.py index 4487ae0..c11a2cc 100644 --- a/myproject/orders/models/order.py +++ b/myproject/orders/models/order.py @@ -317,15 +317,38 @@ class Order(models.Model): self.delivery_cost = DeliveryCostCalculator.calculate(self) def calculate_total(self): - """Рассчитывает итоговую сумму заказа""" + """Рассчитывает итоговую сумму заказа и сохраняет её в БД""" items_total = sum(item.get_total_price() for item in self.items.all()) # Пересчитываем стоимость доставки если она автоматическая self.recalculate_delivery_cost() self.total_amount = items_total + self.delivery_cost + + # Сохраняем изменения в БД + self.save(update_fields=['total_amount', 'delivery_cost', 'is_custom_delivery_cost']) + return self.total_amount + def recalculate_amount_paid(self): + """ + Пересчитывает оплаченную сумму на основе транзакций. + amount_paid = сумма_платежей - сумма_возвратов + """ + from django.db.models import Sum, Q + from decimal import Decimal + + payments_sum = self.transactions.filter( + transaction_type='payment' + ).aggregate(total=Sum('amount'))['total'] or Decimal('0') + + refunds_sum = self.transactions.filter( + transaction_type='refund' + ).aggregate(total=Sum('amount'))['total'] or Decimal('0') + + self.amount_paid = payments_sum - refunds_sum + self.update_payment_status() + def update_payment_status(self): """Автоматически обновляет статус оплаты на основе amount_paid""" if self.amount_paid >= self.total_amount: @@ -337,7 +360,8 @@ class Order(models.Model): else: self.payment_status = 'unpaid' self.is_paid = False - self.save() + # Сохраняем только изменённые поля для оптимизации + self.save(update_fields=['payment_status', 'is_paid', 'amount_paid']) def is_draft(self): """Проверяет, является ли заказ черновиком""" @@ -348,6 +372,11 @@ class Order(models.Model): """Остаток к оплате""" return max(self.total_amount - self.amount_paid, 0) + @property + def subtotal(self): + """Сумма только товаров (без доставки)""" + return sum(item.get_total_price() for item in self.items.all()) + @property def delivery_cost_display(self): """ diff --git a/myproject/orders/models/payment.py b/myproject/orders/models/payment.py deleted file mode 100644 index 5df762c..0000000 --- a/myproject/orders/models/payment.py +++ /dev/null @@ -1,182 +0,0 @@ -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 PaymentMethod(models.Model): - """ - Способ оплаты заказа. - Справочник для управления доступными методами оплаты. - """ - - # Код для программного доступа - code = models.SlugField( - unique=True, - max_length=50, - verbose_name="Код способа оплаты", - help_text="Уникальный идентификатор (например: 'cash_to_courier', 'card_to_courier')" - ) - - # Отображаемое название - name = models.CharField( - max_length=100, - verbose_name="Название способа оплаты" - ) - - # Описание - description = models.TextField( - blank=True, - verbose_name="Описание", - help_text="Дополнительная информация о способе оплаты" - ) - - # Активность - is_active = models.BooleanField( - default=True, - verbose_name="Активен", - help_text="Отключенные способы оплаты не отображаются при создании заказа" - ) - - # Порядок отображения - order = models.PositiveIntegerField( - default=0, - verbose_name="Порядок отображения" - ) - - # Системный флаг - is_system = models.BooleanField( - default=False, - verbose_name="Системный", - help_text="Системные способы оплаты нельзя удалить через интерфейс" - ) - - # Аудит - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - created_by = models.ForeignKey( - CustomUser, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='created_payment_methods', - verbose_name="Создано" - ) - - class Meta: - verbose_name = "Способ оплаты" - verbose_name_plural = "Способы оплаты" - ordering = ['order', 'name'] - indexes = [ - models.Index(fields=['code']), - models.Index(fields=['is_active']), - models.Index(fields=['order']), - ] - - def __str__(self): - return self.name - - -class Payment(models.Model): - """ - Платеж по заказу. - Хранит историю всех платежей, включая частичные оплаты. - Поддерживает смешанную оплату (несколько платежей разными способами на один заказ). - """ - order = models.ForeignKey( - 'Order', - on_delete=models.CASCADE, - related_name='payments', - verbose_name="Заказ" - ) - - amount = models.DecimalField( - max_digits=10, - decimal_places=2, - verbose_name="Сумма платежа" - ) - - payment_method = models.ForeignKey( - 'PaymentMethod', - on_delete=models.PROTECT, - related_name='payments', - verbose_name="Способ оплаты", - help_text="Способ оплаты данного платежа" - ) - - payment_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='payments_created', - verbose_name="Принял платеж" - ) - - notes = models.TextField( - blank=True, - null=True, - verbose_name="Примечания", - help_text="Дополнительная информация о платеже" - ) - - class Meta: - verbose_name = "Платеж" - verbose_name_plural = "Платежи" - ordering = ['-payment_date'] - indexes = [ - models.Index(fields=['order']), - models.Index(fields=['payment_date']), - ] - - def __str__(self): - return f"Платеж {self.amount} руб. по заказу #{self.order.order_number}" - - def save(self, *args, **kwargs): - """При сохранении платежа обновляем сумму оплаты в заказе и обрабатываем кошелёк/переплаты""" - is_new = self.pk is None - with transaction.atomic(): - super().save(*args, **kwargs) - - # Пересчитываем общую сумму оплаты в заказе - self.order.amount_paid = sum(p.amount for p in self.order.payments.all()) - self.order.update_payment_status() - - # Списание из кошелька при новом платеже методом 'account_balance' - if is_new and self.payment_method.code == 'account_balance': - from customers.models import Customer, WalletTransaction - # Блокируем запись клиента - customer = Customer.objects.select_for_update().get(pk=self.order.customer_id) - if customer.wallet_balance < self.amount: - raise ValidationError(f'Недостаточно средств в кошельке (доступно {customer.wallet_balance} руб.)') - - # Списываем и округляем до 2 знаков - customer.wallet_balance = (customer.wallet_balance - self.amount).quantize(Decimal('0.01')) - customer.save(update_fields=['wallet_balance']) - - # Пишем историю - WalletTransaction.objects.create( - customer=customer, - amount=self.amount, - transaction_type='spend', - order=self.order, - description=f'Оплата из кошелька по заказу #{self.order.order_number}', - created_by=self.created_by - ) - - # Нормализация переплаты: лишнее в кошелёк, amount_paid = total_amount - # ТОЛЬКО для новых платежей, чтобы избежать дублирования при обновлении - if is_new: - try: - from customers.services.wallet_service import WalletService - WalletService.add_overpayment(self.order, self.created_by) - except Exception: - # Продолжаем, даже если нормализация переплаты не удалась - pass diff --git a/myproject/orders/models/payment_method.py b/myproject/orders/models/payment_method.py new file mode 100644 index 0000000..ecd63b5 --- /dev/null +++ b/myproject/orders/models/payment_method.py @@ -0,0 +1,76 @@ +from django.db import models +from accounts.models import CustomUser + + +class PaymentMethod(models.Model): + """ + Способ оплаты заказа. + Справочник для управления доступными методами оплаты. + """ + + # Код для программного доступа + code = models.SlugField( + unique=True, + max_length=50, + verbose_name="Код способа оплаты", + help_text="Уникальный идентификатор (например: 'cash_to_courier', 'card_to_courier')" + ) + + # Отображаемое название + name = models.CharField( + max_length=100, + verbose_name="Название способа оплаты" + ) + + # Описание + description = models.TextField( + blank=True, + verbose_name="Описание", + help_text="Дополнительная информация о способе оплаты" + ) + + # Активность + is_active = models.BooleanField( + default=True, + verbose_name="Активен", + help_text="Отключенные способы оплаты не отображаются при создании заказа" + ) + + # Порядок отображения + order = models.PositiveIntegerField( + default=0, + verbose_name="Порядок отображения" + ) + + # Системный флаг + is_system = models.BooleanField( + default=False, + verbose_name="Системный", + help_text="Системные способы оплаты нельзя удалить через интерфейс" + ) + + # Аудит + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + created_by = models.ForeignKey( + CustomUser, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='created_payment_methods', + verbose_name="Создано" + ) + + class Meta: + verbose_name = "Способ оплаты" + verbose_name_plural = "Способы оплаты" + ordering = ['order', 'name'] + indexes = [ + models.Index(fields=['code']), + models.Index(fields=['is_active']), + models.Index(fields=['order']), + ] + + def __str__(self): + return self.name diff --git a/myproject/orders/models/transaction.py b/myproject/orders/models/transaction.py new file mode 100644 index 0000000..44ea3c9 --- /dev/null +++ b/myproject/orders/models/transaction.py @@ -0,0 +1,182 @@ +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.models import Customer, WalletTransaction + + # Блокируем запись клиента + customer = Customer.objects.select_for_update().get(pk=self.order.customer_id) + + if self.transaction_type == 'payment': + # Списание из кошелька + if customer.wallet_balance < self.amount: + raise ValidationError( + f'Недостаточно средств в кошельке ' + f'(доступно {customer.wallet_balance} руб.)' + ) + + customer.wallet_balance = (customer.wallet_balance - self.amount).quantize(Decimal('0.01')) + customer.save(update_fields=['wallet_balance']) + + WalletTransaction.objects.create( + customer=customer, + amount=self.amount, + transaction_type='spend', + order=self.order, + description=f'Оплата из кошелька по заказу #{self.order.order_number}', + created_by=self.created_by + ) + + elif self.transaction_type == 'refund': + # Возврат в кошелёк + customer.wallet_balance = (customer.wallet_balance + self.amount).quantize(Decimal('0.01')) + customer.save(update_fields=['wallet_balance']) + + WalletTransaction.objects.create( + customer=customer, + amount=self.amount, + transaction_type='deposit', + order=self.order, + description=f'Возврат в кошелёк по заказу #{self.order.order_number}', + created_by=self.created_by + ) + + # Обработка переплаты (только для payment) + if is_new and self.transaction_type == 'payment': + try: + from customers.services.wallet_service import WalletService + WalletService.add_overpayment(self.order, self.created_by) + except Exception: + pass diff --git a/myproject/orders/services/transaction_service.py b/myproject/orders/services/transaction_service.py new file mode 100644 index 0000000..0d4145f --- /dev/null +++ b/myproject/orders/services/transaction_service.py @@ -0,0 +1,230 @@ +""" +Сервис для работы с финансовыми транзакциями заказа. +Обрабатывает платежи и возвраты. +""" +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}" не найден') + + # Создаём транзакцию + 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')) diff --git a/myproject/orders/templates/orders/order_detail.html b/myproject/orders/templates/orders/order_detail.html index 50ff32c..21fbd5d 100644 --- a/myproject/orders/templates/orders/order_detail.html +++ b/myproject/orders/templates/orders/order_detail.html @@ -296,15 +296,7 @@