Files
octopus/nested-singing-rainbow.md
Andrey Smakotin 5ead7fdd2e Реализация системы кошелька клиента для переплат
- Добавлено поле wallet_balance в модель Customer
- Создана модель WalletTransaction для истории операций
- Реализован сервис WalletService с методами:
  * add_overpayment - автоматическое зачисление переплаты
  * pay_with_wallet - оплата заказа из кошелька
  * adjust_balance - ручная корректировка баланса
- Интеграция с Payment.save() для автоматической обработки переплат
- UI для оплаты из кошелька в деталях заказа
- Отображение баланса и долга на странице клиента
- Админка с inline транзакций и запретом ручного создания
- Добавлен способ оплаты account_balance
- Миграция 0004 для customers приложения
2025-11-26 14:47:11 +03:00

28 KiB
Raw Blame History

План реализации системы личного счета клиента

Обзор

Реализация системы личного счета для клиентов цветочного магазина с поддержкой резервирования средств, смешанной оплаты, автоматического пополнения при переплате и полной историей транзакций.

Ключевые бизнес-требования

  1. Баланс счета: У каждого клиента есть личный счет (может быть положительным или отрицательным)
  2. Пополнение: Вручную администратором или автоматически при переплате заказа
  3. Кредитование: Разрешен отрицательный баланс для доверенных клиентов
  4. История операций: Полный аудит всех операций со счетом
  5. Смешанная оплата: Можно комбинировать с другими способами оплаты
  6. Резервирование: При создании заказа средства резервируются, при завершении списываются
  7. Управление: Только администраторы/менеджеры имеют доступ

1. Изменения в базе данных

1.1 Расширение модели Customer

Файл: myproject/customers/models.py

Добавить поля для управления балансом:

# Поля баланса
account_balance = models.DecimalField(
    max_digits=10,
    decimal_places=2,
    default=0,
    verbose_name="Баланс счета",
    help_text="Текущий баланс лицевого счета клиента"
)

available_balance = models.DecimalField(
    max_digits=10,
    decimal_places=2,
    default=0,
    verbose_name="Доступный баланс",
    help_text="Баланс за вычетом зарезервированных средств"
)

reserved_balance = models.DecimalField(
    max_digits=10,
    decimal_places=2,
    default=0,
    verbose_name="Зарезервировано",
    help_text="Сумма, зарезервированная под активные заказы"
)

allow_negative_balance = models.BooleanField(
    default=False,
    verbose_name="Разрешить отрицательный баланс",
    help_text="Позволяет клиенту уходить в минус"
)

negative_balance_limit = models.DecimalField(
    max_digits=10,
    decimal_places=2,
    default=0,
    verbose_name="Лимит кредита",
    help_text="Максимальная сумма отрицательного баланса (0 = без лимита)"
)

Взаимосвязь полей:

  • account_balance = Общий баланс клиента
  • reserved_balance = Сумма, зарезервированная под заказы
  • available_balance = account_balance - reserved_balance

1.2 Новая модель AccountTransaction

Файл: myproject/customers/models.py (или отдельный файл в models/)

Модель для хранения истории всех операций со счетом:

class AccountTransaction(models.Model):
    """
    Транзакция по лицевому счету клиента.
    """
    TRANSACTION_TYPE_CHOICES = [
        ('deposit', 'Пополнение вручную'),
        ('auto_deposit', 'Авто-пополнение (переплата)'),
        ('reservation', 'Резервирование'),
        ('reservation_release', 'Снятие резерва'),
        ('charge', 'Списание за заказ'),
        ('refund', 'Возврат средств'),
        ('adjustment', 'Корректировка баланса'),
    ]

    STATUS_CHOICES = [
        ('active', 'Активна'),
        ('completed', 'Завершена'),
        ('cancelled', 'Отменена'),
    ]

    customer = models.ForeignKey(
        'Customer',
        on_delete=models.PROTECT,
        related_name='account_transactions'
    )

    transaction_type = models.CharField(max_length=30, choices=TRANSACTION_TYPE_CHOICES)
    amount = models.DecimalField(max_digits=10, decimal_places=2)

    balance_before = models.DecimalField(max_digits=10, decimal_places=2)
    balance_after = models.DecimalField(max_digits=10, decimal_places=2)

    order = models.ForeignKey('orders.Order', null=True, blank=True, on_delete=models.PROTECT)
    payment = models.ForeignKey('orders.Payment', null=True, blank=True, on_delete=models.SET_NULL)
    related_transaction = models.ForeignKey('self', null=True, blank=True, on_delete=models.SET_NULL)

    description = models.TextField()
    notes = models.TextField(blank=True)
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='completed')

    created_at = models.DateTimeField(auto_now_add=True)
    created_by = models.ForeignKey('accounts.CustomUser', null=True, blank=True, on_delete=models.SET_NULL)

    class Meta:
        ordering = ['-created_at']
        indexes = [
            models.Index(fields=['customer', '-created_at']),
            models.Index(fields=['transaction_type']),
            models.Index(fields=['order']),
            models.Index(fields=['status']),
        ]

1.3 Новый способ оплаты

Добавить в команду create_payment_methods.py:

{
    'code': 'account_balance',
    'name': 'С баланса счета',
    'description': 'Списание с личного счета клиента',
    'is_system': True,
    'order': 0  # Первый в списке
}

2. Бизнес-логика: AccountBalanceService

Новый файл: myproject/customers/services/account_balance_service.py

Создать сервис с методами:

Основные методы:

  1. deposit(customer, amount, description, user, notes)

    • Пополнение счета вручную администратором
    • Увеличивает account_balance и available_balance
    • Создает транзакцию типа deposit
  2. auto_deposit_from_overpayment(order, overpayment_amount, user)

    • Автоматическое пополнение при переплате
    • Вызывается когда order.amount_paid > order.total_amount
    • Создает транзакцию типа auto_deposit
  3. reserve_balance(customer, order, amount, user)

    • Резервирование средств при создании заказа с оплатой со счета
    • Проверяет достаточность средств (с учетом кредита)
    • Уменьшает available_balance, увеличивает reserved_balance
    • Создает транзакцию типа reservation со статусом active
  4. charge_reserved_balance(reservation_transaction, user)

    • Списание зарезервированных средств при завершении заказа
    • Уменьшает account_balance и reserved_balance
    • Обновляет статус резервирования на completed
    • Создает транзакцию типа charge
  5. release_reservation(reservation_transaction, user)

    • Снятие резервирования при отмене заказа
    • Увеличивает available_balance, уменьшает reserved_balance
    • Обновляет статус резервирования на cancelled
    • Создает транзакцию типа reservation_release
  6. refund(customer, amount, order, description, user, notes)

    • Возврат средств на счет
    • Используется при индивидуальных решениях по возвратам
    • Создает транзакцию типа refund
  7. adjustment(customer, amount, description, user, notes)

    • Корректировка баланса администратором
    • Может быть положительной или отрицательной
    • Требует обязательное описание

Ключевые особенности реализации:

  • Все методы используют @transaction.atomic для атомарности
  • select_for_update() для блокировки записи клиента при изменении
  • Проверка лимитов кредита перед резервированием
  • Запись balance_before и balance_after для аудита

3. Интеграция с существующей системой платежей

3.1 Модификация Payment.save()

Файл: myproject/orders/models/payment.py

В методе save() добавить логику:

def save(self, *args, **kwargs):
    is_new = self.pk is None
    super().save(*args, **kwargs)

    # Пересчитываем сумму оплаты
    self.order.amount_paid = sum(p.amount for p in self.order.payments.all())

    # Обработка оплаты с баланса счета
    if self.payment_method.code == 'account_balance' and is_new:
        from customers.services.account_balance_service import AccountBalanceService
        AccountBalanceService.reserve_balance(
            customer=self.order.customer,
            order=self.order,
            amount=self.amount,
            user=self.created_by
        )

    self.order.update_payment_status()

    # Проверка переплаты
    if self.order.amount_paid > self.order.total_amount:
        overpayment = self.order.amount_paid - self.order.total_amount
        from customers.services.account_balance_service import AccountBalanceService
        AccountBalanceService.auto_deposit_from_overpayment(
            order=self.order,
            overpayment_amount=overpayment,
            user=self.created_by
        )

3.2 Обработка изменения статуса заказа

Новый файл: myproject/orders/signals.py

Создать сигналы для автоматической обработки:

from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Order

@receiver(post_save, sender=Order)
def handle_order_status_change(sender, instance, created, **kwargs):
    """Обработка изменения статуса заказа"""
    if created or not instance.status:
        return

    from customers.models import AccountTransaction
    from customers.services.account_balance_service import AccountBalanceService

    # Заказ выполнен успешно - списываем
    if instance.status.is_positive_end:
        reservations = AccountTransaction.objects.filter(
            order=instance,
            transaction_type='reservation',
            status='active'
        )
        for reservation in reservations:
            AccountBalanceService.charge_reserved_balance(
                reservation_transaction=reservation,
                user=instance.modified_by
            )

    # Заказ отменен - снимаем резерв
    elif instance.status.is_negative_end:
        reservations = AccountTransaction.objects.filter(
            order=instance,
            transaction_type='reservation',
            status='active'
        )
        for reservation in reservations:
            AccountBalanceService.release_reservation(
                reservation_transaction=reservation,
                user=instance.modified_by
            )

Подключить сигналы в myproject/orders/apps.py:

class OrdersConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'orders'

    def ready(self):
        import orders.signals  # noqa

4. Административный интерфейс

4.1 Расширение CustomerAdmin

Файл: myproject/customers/admin.py

Изменения:

  1. Добавить поля баланса в list_display:

    list_display = (
        'full_name', 'email', 'phone',
        'account_balance_colored',  # новое
        'available_balance_display',  # новое
        'reserved_balance_display',  # новое
        'total_spent',
        'is_system_customer',
        'created_at'
    )
    
  2. Добавить фильтр по кредиту:

    list_filter = (
        IsSystemCustomerFilter,
        'allow_negative_balance',  # новое
        'created_at'
    )
    
  3. Добавить секцию баланса в fieldsets:

    ('Баланс лицевого счета', {
        'fields': (
            'account_balance',
            'available_balance',
            'reserved_balance',
            'allow_negative_balance',
            'negative_balance_limit',
        ),
        'classes': ('wide',),
    }),
    
  4. Добавить inline для транзакций:

    class AccountTransactionInline(admin.TabularInline):
        model = AccountTransaction
        extra = 0
        can_delete = False
        readonly_fields = [...]
    
    inlines = [AccountTransactionInline]
    
  5. Добавить actions:

    actions = [
        'add_deposit',
        'add_refund',
        'add_adjustment',
        'enable_negative_balance',
    ]
    

4.2 Новый AccountTransactionAdmin

Файл: myproject/customers/admin.py

Создать отдельную админку для просмотра всех транзакций:

@admin.register(AccountTransaction)
class AccountTransactionAdmin(admin.ModelAdmin):
    list_display = [
        'created_at', 'customer_link', 'transaction_type',
        'amount_colored', 'balance_after', 'order_link', 'status'
    ]
    list_filter = ['transaction_type', 'status', 'created_at']
    search_fields = ['customer__name', 'customer__email', 'description']
    readonly_fields = [все поля]

    def has_add_permission(self, request):
        return False  # Только через сервис

    def has_delete_permission(self, request, obj=None):
        return False  # Аудит, нельзя удалять

4.3 Кастомные views для операций

Новый файл: myproject/customers/admin_views.py

Создать views для:

  • Пополнения баланса (/admin/customers/deposit/)
  • Возврата средств (/admin/customers/refund/)
  • Корректировки (/admin/customers/adjustment/)

Новый файл: myproject/customers/admin_urls.py

from django.urls import path
from . import admin_views

urlpatterns = [
    path('deposit/', admin_views.deposit_view, name='customer_deposit'),
    path('refund/', admin_views.refund_view, name='customer_refund'),
    path('adjustment/', admin_views.adjustment_view, name='customer_adjustment'),
]

Подключить в основной urls.py.


5. Формы для операций

Новый файл: myproject/customers/forms.py

Создать формы:

class DepositForm(forms.Form):
    """Форма пополнения баланса"""
    customer = forms.ModelChoiceField(queryset=Customer.objects.all())
    amount = forms.DecimalField(min_value=0.01, max_digits=10, decimal_places=2)
    description = forms.CharField(widget=forms.Textarea)
    notes = forms.CharField(widget=forms.Textarea, required=False)

class RefundForm(forms.Form):
    """Форма возврата средств"""
    customer = forms.ModelChoiceField(queryset=Customer.objects.all())
    amount = forms.DecimalField(min_value=0.01, max_digits=10, decimal_places=2)
    order = forms.ModelChoiceField(queryset=Order.objects.all(), required=False)
    description = forms.CharField(widget=forms.Textarea)
    notes = forms.CharField(widget=forms.Textarea, required=False)

class AdjustmentForm(forms.Form):
    """Форма корректировки баланса"""
    customer = forms.ModelChoiceField(queryset=Customer.objects.all())
    amount = forms.DecimalField(max_digits=10, decimal_places=2)  # может быть отрицательным
    description = forms.CharField(widget=forms.Textarea)
    notes = forms.CharField(widget=forms.Textarea, required=False)

6. UI/UX улучшения

6.1 Отображение баланса в форме заказа

Файл: myproject/orders/templates/orders/order_form.html

Добавить блок с информацией о балансе клиента:

{% if order.customer %}
<div class="alert alert-info">
    <h5>Баланс клиента</h5>
    <ul>
        <li>Общий баланс: <strong>{{ order.customer.account_balance }} руб.</strong></li>
        <li>Доступно: <strong>{{ order.customer.available_balance }} руб.</strong></li>
        <li>Зарезервировано: <strong>{{ order.customer.reserved_balance }} руб.</strong></li>
    </ul>
</div>
{% endif %}

6.2 Валидация при выборе оплаты со счета

Файл: myproject/orders/static/orders/js/payment_validation.js

Добавить JS-валидацию:

// Проверка достаточности средств при выборе оплаты со счета
function validateAccountBalance(paymentMethodCode, amount, availableBalance, allowNegative, creditLimit) {
    if (paymentMethodCode === 'account_balance') {
        if (amount > availableBalance && !allowNegative) {
            alert('Недостаточно средств на счете клиента!');
            return false;
        }
        if (allowNegative && creditLimit > 0) {
            let potentialBalance = availableBalance - amount;
            if (Math.abs(potentialBalance) > creditLimit) {
                alert('Превышен лимит кредита клиента!');
                return false;
            }
        }
    }
    return true;
}

7. Миграции

Последовательность миграций:

  1. Добавить поля баланса в Customer:

    python manage.py makemigrations customers --name add_account_balance_fields
    
  2. Создать модель AccountTransaction:

    python manage.py makemigrations customers --name create_account_transaction_model
    
  3. Создать индексы и ограничения:

    python manage.py makemigrations customers --name add_balance_constraints
    
  4. Инициализация данных (data migration):

    def initialize_customer_balances(apps, schema_editor):
        Customer = apps.get_model('customers', 'Customer')
        Customer.objects.all().update(
            account_balance=0,
            available_balance=0,
            reserved_balance=0,
            allow_negative_balance=False,
            negative_balance_limit=0
        )
    
  5. Добавить способ оплаты:

    python manage.py create_payment_methods
    

8. Обеспечение целостности данных

8.1 Транзакции и блокировки

  • Все операции в @transaction.atomic
  • Использование select_for_update() для блокировки записи клиента
  • Проверка статуса транзакции перед обработкой

8.2 Ограничения БД

# В миграции
models.CheckConstraint(
    check=models.Q(reserved_balance__gte=0),
    name='reserved_balance_non_negative'
)

models.CheckConstraint(
    check=models.Q(account_balance__gte=models.F('reserved_balance') * -1),
    name='available_balance_consistency'
)

8.3 Предотвращение дублирования

  • Проверка status='active' перед обработкой резервирования
  • Связь related_transaction для отслеживания цепочки операций
  • Валидация перед созданием транзакции

9. Типы транзакций: Подробное описание

DEPOSIT (Пополнение)

  • Когда: Администратор вручную пополняет счет
  • Эффект: account_balance ↑, available_balance ↑
  • Статус: completed

AUTO_DEPOSIT (Авто-пополнение)

  • Когда: order.amount_paid > order.total_amount
  • Эффект: account_balance ↑, available_balance ↑
  • Статус: completed

RESERVATION (Резервирование)

  • Когда: Создание заказа с оплатой со счета
  • Эффект: available_balance ↓, reserved_balance ↑
  • Статус: active → меняется при charge/release

CHARGE (Списание)

  • Когда: Заказ выполнен (is_positive_end=True)
  • Эффект: account_balance ↓, reserved_balance ↓
  • Статус: completed

RESERVATION_RELEASE (Снятие резерва)

  • Когда: Заказ отменен (is_negative_end=True)
  • Эффект: available_balance ↑, reserved_balance ↓
  • Статус: completed

REFUND (Возврат)

  • Когда: Администратор принимает решение о возврате
  • Эффект: account_balance ↑, available_balance ↑
  • Статус: completed

ADJUSTMENT (Корректировка)

  • Когда: Ручная корректировка администратором
  • Эффект: account_balance ±, available_balance ±
  • Статус: completed

10. Сценарии использования

Сценарий 1: Заказ с полной оплатой со счета

  1. Клиент создает заказ на 540 руб.
  2. На балансе 1000 руб.
  3. Выбирается способ оплаты "С баланса счета"
  4. Создается RESERVATION на 540 руб.: available_balance: 1000→460, reserved_balance: 0→540
  5. При выполнении заказа создается CHARGE: account_balance: 1000→460, reserved_balance: 540→0

Сценарий 2: Смешанная оплата

  1. Заказ на 540 руб.
  2. На балансе 300 руб.
  3. Создается Payment со счета на 300 руб. → RESERVATION 300 руб.
  4. Создается Payment наличными на 240 руб.
  5. При выполнении → CHARGE 300 руб. со счета

Сценарий 3: Переплата с авто-пополнением

  1. Заказ на 540 руб.
  2. Клиент платит наличными 1000 руб.
  3. order.amount_paid = 1000, order.total_amount = 540
  4. Система создает AUTO_DEPOSIT на 460 руб.
  5. Баланс клиента увеличивается на 460 руб.

Сценарий 4: Отмена заказа

  1. Заказ на 540 руб. с резервированием
  2. reserved_balance = 540, available_balance = 460
  3. Заказ меняет статус на "Отменен" (is_negative_end=True)
  4. Сигнал создает RESERVATION_RELEASE
  5. available_balance: 460→1000, reserved_balance: 540→0

Сценарий 5: Кредит доверенного клиента

  1. У клиента баланс 0 руб., но allow_negative_balance=True
  2. Заказ на 540 руб.
  3. Создается RESERVATION на 540 руб.
  4. account_balance: 0→0, available_balance: 0→-540, reserved_balance: 0→540
  5. При выполнении CHARGE: account_balance: 0→-540

11. Тестирование

Unit Tests

Файл: myproject/customers/tests/test_account_balance_service.py

Тесты:

  • test_deposit_increases_balance
  • test_reserve_decreases_available
  • test_charge_decreases_account_balance
  • test_release_increases_available
  • test_overpayment_creates_auto_deposit
  • test_negative_balance_validation
  • test_credit_limit_enforcement
  • test_concurrent_operations

Integration Tests

Файл: myproject/orders/tests/test_order_with_account_balance.py

Тесты:

  • test_order_with_account_payment
  • test_mixed_payment_scenario
  • test_order_completion_charges_balance
  • test_order_cancellation_releases_reservation
  • test_overpayment_auto_deposit

12. Критические файлы для реализации

  1. myproject/customers/models.py

    • Добавить поля баланса в Customer
    • Создать модель AccountTransaction
  2. myproject/customers/services/account_balance_service.py (НОВЫЙ)

    • Все методы управления балансом
  3. myproject/orders/models/payment.py

    • Модифицировать save() для обработки оплаты со счета
  4. myproject/orders/signals.py (НОВЫЙ)

    • Обработка изменения статуса заказа
  5. myproject/customers/admin.py

    • Расширить CustomerAdmin
    • Создать AccountTransactionAdmin
  6. myproject/customers/admin_views.py (НОВЫЙ)

    • Views для пополнения/возврата/корректировки
  7. myproject/customers/forms.py (НОВЫЙ)

    • Формы для операций с балансом
  8. myproject/orders/management/commands/create_payment_methods.py

    • Добавить способ оплаты 'account_balance'
  9. myproject/orders/apps.py

    • Подключить сигналы

13. Последовательность реализации

Фаза 1: Модели и миграции (основа)

  1. Добавить поля в Customer
  2. Создать AccountTransaction
  3. Создать миграции
  4. Инициализировать данные

Фаза 2: Бизнес-логика (ядро)

  1. Создать AccountBalanceService со всеми методами
  2. Покрыть unit-тестами

Фаза 3: Интеграция с заказами (связывание)

  1. Модифицировать Payment.save()
  2. Создать signals.py
  3. Добавить способ оплаты
  4. Покрыть integration-тестами

Фаза 4: Административный интерфейс (управление)

  1. Расширить CustomerAdmin
  2. Создать AccountTransactionAdmin
  3. Создать формы и views для операций
  4. Настроить URLs

Фаза 5: UI/UX улучшения (удобство)

  1. Отображение баланса в форме заказа
  2. JS-валидация при оплате
  3. Виджеты истории транзакций

Фаза 6: Тестирование и документация (качество)

  1. Полное покрытие тестами
  2. Ручное тестирование сценариев
  3. Документация для администраторов

14. Безопасность и права доступа

  • Только staff_member_required для admin views
  • Транзакции нельзя удалять (has_delete_permission = False)
  • Транзакции нельзя создавать вручную (has_add_permission = False)
  • Все операции требуют created_by (аудит)
  • Mandatory description для adjustment

Заключение

Данная архитектура обеспечивает:

  • Полную историю операций (аудит)
  • Атомарность операций (транзакции БД)
  • Защиту от race conditions (блокировки)
  • Гибкость (смешанная оплата, кредит)
  • Интеграцию с существующей системой
  • Простоту управления (admin interface)
  • Безопасность (только администраторы)

Решение готово к production-использованию после прохождения всех фаз тестирования.