- Добавлено поле wallet_balance в модель Customer - Создана модель WalletTransaction для истории операций - Реализован сервис WalletService с методами: * add_overpayment - автоматическое зачисление переплаты * pay_with_wallet - оплата заказа из кошелька * adjust_balance - ручная корректировка баланса - Интеграция с Payment.save() для автоматической обработки переплат - UI для оплаты из кошелька в деталях заказа - Отображение баланса и долга на странице клиента - Админка с inline транзакций и запретом ручного создания - Добавлен способ оплаты account_balance - Миграция 0004 для customers приложения
28 KiB
План реализации системы личного счета клиента
Обзор
Реализация системы личного счета для клиентов цветочного магазина с поддержкой резервирования средств, смешанной оплаты, автоматического пополнения при переплате и полной историей транзакций.
Ключевые бизнес-требования
- Баланс счета: У каждого клиента есть личный счет (может быть положительным или отрицательным)
- Пополнение: Вручную администратором или автоматически при переплате заказа
- Кредитование: Разрешен отрицательный баланс для доверенных клиентов
- История операций: Полный аудит всех операций со счетом
- Смешанная оплата: Можно комбинировать с другими способами оплаты
- Резервирование: При создании заказа средства резервируются, при завершении списываются
- Управление: Только администраторы/менеджеры имеют доступ
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
Создать сервис с методами:
Основные методы:
-
deposit(customer, amount, description, user, notes)- Пополнение счета вручную администратором
- Увеличивает
account_balanceиavailable_balance - Создает транзакцию типа
deposit
-
auto_deposit_from_overpayment(order, overpayment_amount, user)- Автоматическое пополнение при переплате
- Вызывается когда
order.amount_paid > order.total_amount - Создает транзакцию типа
auto_deposit
-
reserve_balance(customer, order, amount, user)- Резервирование средств при создании заказа с оплатой со счета
- Проверяет достаточность средств (с учетом кредита)
- Уменьшает
available_balance, увеличиваетreserved_balance - Создает транзакцию типа
reservationсо статусомactive
-
charge_reserved_balance(reservation_transaction, user)- Списание зарезервированных средств при завершении заказа
- Уменьшает
account_balanceиreserved_balance - Обновляет статус резервирования на
completed - Создает транзакцию типа
charge
-
release_reservation(reservation_transaction, user)- Снятие резервирования при отмене заказа
- Увеличивает
available_balance, уменьшаетreserved_balance - Обновляет статус резервирования на
cancelled - Создает транзакцию типа
reservation_release
-
refund(customer, amount, order, description, user, notes)- Возврат средств на счет
- Используется при индивидуальных решениях по возвратам
- Создает транзакцию типа
refund
-
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
Изменения:
-
Добавить поля баланса в list_display:
list_display = ( 'full_name', 'email', 'phone', 'account_balance_colored', # новое 'available_balance_display', # новое 'reserved_balance_display', # новое 'total_spent', 'is_system_customer', 'created_at' ) -
Добавить фильтр по кредиту:
list_filter = ( IsSystemCustomerFilter, 'allow_negative_balance', # новое 'created_at' ) -
Добавить секцию баланса в fieldsets:
('Баланс лицевого счета', { 'fields': ( 'account_balance', 'available_balance', 'reserved_balance', 'allow_negative_balance', 'negative_balance_limit', ), 'classes': ('wide',), }), -
Добавить inline для транзакций:
class AccountTransactionInline(admin.TabularInline): model = AccountTransaction extra = 0 can_delete = False readonly_fields = [...] inlines = [AccountTransactionInline] -
Добавить 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. Миграции
Последовательность миграций:
-
Добавить поля баланса в Customer:
python manage.py makemigrations customers --name add_account_balance_fields -
Создать модель AccountTransaction:
python manage.py makemigrations customers --name create_account_transaction_model -
Создать индексы и ограничения:
python manage.py makemigrations customers --name add_balance_constraints -
Инициализация данных (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 ) -
Добавить способ оплаты:
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: Заказ с полной оплатой со счета
- Клиент создает заказ на 540 руб.
- На балансе 1000 руб.
- Выбирается способ оплаты "С баланса счета"
- Создается RESERVATION на 540 руб.:
available_balance: 1000→460,reserved_balance: 0→540 - При выполнении заказа создается CHARGE:
account_balance: 1000→460,reserved_balance: 540→0
Сценарий 2: Смешанная оплата
- Заказ на 540 руб.
- На балансе 300 руб.
- Создается Payment со счета на 300 руб. → RESERVATION 300 руб.
- Создается Payment наличными на 240 руб.
- При выполнении → CHARGE 300 руб. со счета
Сценарий 3: Переплата с авто-пополнением
- Заказ на 540 руб.
- Клиент платит наличными 1000 руб.
order.amount_paid = 1000,order.total_amount = 540- Система создает AUTO_DEPOSIT на 460 руб.
- Баланс клиента увеличивается на 460 руб.
Сценарий 4: Отмена заказа
- Заказ на 540 руб. с резервированием
reserved_balance = 540,available_balance = 460- Заказ меняет статус на "Отменен" (
is_negative_end=True) - Сигнал создает RESERVATION_RELEASE
available_balance: 460→1000,reserved_balance: 540→0
Сценарий 5: Кредит доверенного клиента
- У клиента баланс 0 руб., но
allow_negative_balance=True - Заказ на 540 руб.
- Создается RESERVATION на 540 руб.
account_balance: 0→0,available_balance: 0→-540,reserved_balance: 0→540- При выполнении CHARGE:
account_balance: 0→-540
11. Тестирование
Unit Tests
Файл: myproject/customers/tests/test_account_balance_service.py
Тесты:
test_deposit_increases_balancetest_reserve_decreases_availabletest_charge_decreases_account_balancetest_release_increases_availabletest_overpayment_creates_auto_deposittest_negative_balance_validationtest_credit_limit_enforcementtest_concurrent_operations
Integration Tests
Файл: myproject/orders/tests/test_order_with_account_balance.py
Тесты:
test_order_with_account_paymenttest_mixed_payment_scenariotest_order_completion_charges_balancetest_order_cancellation_releases_reservationtest_overpayment_auto_deposit
12. Критические файлы для реализации
-
myproject/customers/models.py- Добавить поля баланса в Customer
- Создать модель AccountTransaction
-
myproject/customers/services/account_balance_service.py(НОВЫЙ)- Все методы управления балансом
-
myproject/orders/models/payment.py- Модифицировать
save()для обработки оплаты со счета
- Модифицировать
-
myproject/orders/signals.py(НОВЫЙ)- Обработка изменения статуса заказа
-
myproject/customers/admin.py- Расширить CustomerAdmin
- Создать AccountTransactionAdmin
-
myproject/customers/admin_views.py(НОВЫЙ)- Views для пополнения/возврата/корректировки
-
myproject/customers/forms.py(НОВЫЙ)- Формы для операций с балансом
-
myproject/orders/management/commands/create_payment_methods.py- Добавить способ оплаты 'account_balance'
-
myproject/orders/apps.py- Подключить сигналы
13. Последовательность реализации
Фаза 1: Модели и миграции (основа)
- Добавить поля в Customer
- Создать AccountTransaction
- Создать миграции
- Инициализировать данные
Фаза 2: Бизнес-логика (ядро)
- Создать AccountBalanceService со всеми методами
- Покрыть unit-тестами
Фаза 3: Интеграция с заказами (связывание)
- Модифицировать Payment.save()
- Создать signals.py
- Добавить способ оплаты
- Покрыть integration-тестами
Фаза 4: Административный интерфейс (управление)
- Расширить CustomerAdmin
- Создать AccountTransactionAdmin
- Создать формы и views для операций
- Настроить URLs
Фаза 5: UI/UX улучшения (удобство)
- Отображение баланса в форме заказа
- JS-валидация при оплате
- Виджеты истории транзакций
Фаза 6: Тестирование и документация (качество)
- Полное покрытие тестами
- Ручное тестирование сценариев
- Документация для администраторов
14. Безопасность и права доступа
- Только
staff_member_requiredдля admin views - Транзакции нельзя удалять (
has_delete_permission = False) - Транзакции нельзя создавать вручную (
has_add_permission = False) - Все операции требуют
created_by(аудит) - Mandatory
descriptionдля adjustment
Заключение
Данная архитектура обеспечивает:
- ✅ Полную историю операций (аудит)
- ✅ Атомарность операций (транзакции БД)
- ✅ Защиту от race conditions (блокировки)
- ✅ Гибкость (смешанная оплата, кредит)
- ✅ Интеграцию с существующей системой
- ✅ Простоту управления (admin interface)
- ✅ Безопасность (только администраторы)
Решение готово к production-использованию после прохождения всех фаз тестирования.