Защита от переплаты кошельком и улучшение отображения транзакций

Изменения в UI (order_form.html):
- Добавлен data-code к опциям способов оплаты для идентификации метода кошелька
- ID для селекта способа оплаты (payment-method-select) и поля суммы (payment-amount-input)
- Динамическое ограничение max на поле суммы платежа при выборе кошелька
- Подсказка 'Макс: X руб.' отображается только для оплаты кошельком
- Для внешних методов (карта, наличные) ограничение отсутствует — переплата допустима

Логика JS:
- При выборе метода с code == 'account_balance' устанавливается max = order.amount_due
- Для остальных методов max удаляется — оператор может внести сумму больше остатка
- Переплата по внешним методам корректно зачисляется в кошелёк через WalletService.add_overpayment

Серверная защита (transaction_service.py):
- В TransactionService.create_payment добавлена проверка:
  если payment_method.code == 'account_balance' и amount > order.amount_due — ValidationError
- Сообщение: 'Сумма оплаты из кошелька (X руб.) не может превышать остаток к оплате (Y руб.)'
- Защита от обхода UI через API или прямой вызов

Улучшение отображения (order_form.html, order_detail.html):
- Для возврата в кошелёк (transaction_type == 'refund' и code == 'account_balance') показываем 'на баланс счёта' вместо названия метода
- История становится понятнее: '−750,00 руб. Возврат 29.11.2025 на баланс счёта'

Сценарий:
- Кошелёк клиента 500 руб., заказ 65 руб.
- Оператор выбирает оплату из кошелька — поле суммы ограничено 65 руб.
- Попытка ввести 500 заблокирована UI и серверной валидацией
- Для внешней оплаты (карта онлайн) можно внести 500 руб. — остаток 435 автоматически зачислится в кошелёк как переплата

Цель:
- Исключить путаницу в истории транзакций при оплате кошельком
- Разграничить поведение: кошелёк строго ограничен, внешние методы допускают переплату
- Обеспечить прозрачность движения средств для операторов
This commit is contained in:
2025-11-29 16:54:24 +03:00
parent 312cd808e6
commit 3f22677573
4 changed files with 53 additions and 5 deletions

View File

View File

@@ -54,6 +54,13 @@ class TransactionService:
except PaymentMethod.DoesNotExist: except PaymentMethod.DoesNotExist:
raise ValueError(f'Способ оплаты "{payment_method}" не найден') raise ValueError(f'Способ оплаты "{payment_method}" не найден')
# Ограничение для кошелька: нельзя оплатить больше чем к оплате
if payment_method.code == 'account_balance' and amount > order.amount_due:
raise ValidationError(
f'Сумма оплаты из кошелька ({amount} руб.) не может превышать '
f'остаток к оплате ({order.amount_due} руб.)'
)
# Создаём транзакцию # Создаём транзакцию
txn = Transaction.objects.create( txn = Transaction.objects.create(
order=order, order=order,

View File

@@ -358,7 +358,11 @@
</div> </div>
<small class="text-muted"> <small class="text-muted">
{{ transaction.transaction_date|date:"d.m.Y H:i" }}<br> {{ transaction.transaction_date|date:"d.m.Y H:i" }}<br>
{% if transaction.transaction_type == 'refund' and transaction.payment_method.code == 'account_balance' %}
Возврат на баланс счёта
{% else %}
{{ transaction.payment_method.name }} {{ transaction.payment_method.name }}
{% endif %}
{% if transaction.notes or transaction.reason %} {% if transaction.notes or transaction.reason %}
<br><em>{{ transaction.notes|default:transaction.reason }}</em> <br><em>{{ transaction.notes|default:transaction.reason }}</em>
{% endif %} {% endif %}

View File

@@ -671,7 +671,13 @@
{{ transaction.transaction_date|date:"d.m.Y H:i" }} {{ transaction.transaction_date|date:"d.m.Y H:i" }}
</small> </small>
</td> </td>
<td>{{ transaction.payment_method.name }}</td> <td>
{% if transaction.transaction_type == 'refund' and transaction.payment_method.code == 'account_balance' %}
Возврат на баланс счёта
{% else %}
{{ transaction.payment_method.name }}
{% endif %}
</td>
<td class="text-end"> <td class="text-end">
<strong class="{% if transaction.transaction_type == 'payment' %}text-success{% else %}text-danger{% endif %}"> <strong class="{% if transaction.transaction_type == 'payment' %}text-success{% else %}text-danger{% endif %}">
{% if transaction.transaction_type == 'refund' %}{% else %}+{% endif %}{{ transaction.amount|floatformat:2 }} {% if transaction.transaction_type == 'refund' %}{% else %}+{% endif %}{{ transaction.amount|floatformat:2 }}
@@ -727,12 +733,12 @@
<div class="row align-items-end"> <div class="row align-items-end">
<div class="col-12 mb-2"> <div class="col-12 mb-2">
<label class="form-label fw-bold">Способ оплаты <span class="text-danger">*</span></label> <label class="form-label fw-bold">Способ оплаты <span class="text-danger">*</span></label>
<select name="payment_method" class="form-select" required> <select name="payment_method" class="form-select" id="payment-method-select" required>
<option value="">Выберите способ...</option> <option value="">Выберите способ...</option>
{% load orders_tags %} {% load orders_tags %}
{% get_payment_methods as payment_methods %} {% get_payment_methods as payment_methods %}
{% for pm in payment_methods %} {% for pm in payment_methods %}
<option value="{{ pm.id }}">{{ pm.name }}{% if pm.code == 'account_balance' %} (кошелёк клиента){% endif %}</option> <option value="{{ pm.id }}" data-code="{{ pm.code }}">{{ pm.name }}{% if pm.code == 'account_balance' %} (кошелёк клиента){% endif %}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -740,10 +746,11 @@
<label class="form-label fw-bold">Сумма <span class="text-danger">*</span></label> <label class="form-label fw-bold">Сумма <span class="text-danger">*</span></label>
<div class="input-group"> <div class="input-group">
<input type="number" name="amount" step="0.01" min="0.01" <input type="number" name="amount" step="0.01" min="0.01"
class="form-control" placeholder="0.00" class="form-control" placeholder="0.00" id="payment-amount-input"
{% if order.amount_due > 0 %}value="{{ order.amount_due|unlocalize }}"{% endif %} required> {% if order.amount_due > 0 %}value="{{ order.amount_due|unlocalize }}"{% endif %} required>
<span class="input-group-text">руб.</span> <span class="input-group-text">руб.</span>
</div> </div>
<small class="text-muted" id="payment-limit-hint" style="display:none;">Макс: <span id="payment-max-value"></span> руб.</small>
</div> </div>
<div class="col-md-6 mb-2"> <div class="col-md-6 mb-2">
<label class="form-label fw-bold">Остаток</label> <label class="form-label fw-bold">Остаток</label>
@@ -887,6 +894,36 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
</script> </script>
<script>
// Ограничение оплаты из кошелька
document.addEventListener('DOMContentLoaded', function() {
const paymentMethodSelect = document.getElementById('payment-method-select');
const paymentAmountInput = document.getElementById('payment-amount-input');
const paymentLimitHint = document.getElementById('payment-limit-hint');
const paymentMaxValue = document.getElementById('payment-max-value');
if (paymentMethodSelect && paymentAmountInput) {
const amountDue = {{ order.amount_due|default:0|unlocalize }};
paymentMethodSelect.addEventListener('change', function() {
const selectedOption = this.options[this.selectedIndex];
const paymentCode = selectedOption ? selectedOption.getAttribute('data-code') : null;
if (paymentCode === 'account_balance') {
// Кошелёк: ограничиваем максимумом
paymentAmountInput.setAttribute('max', amountDue);
paymentMaxValue.textContent = amountDue.toFixed(2);
paymentLimitHint.style.display = 'block';
} else {
// Внешние методы: снимаем ограничение
paymentAmountInput.removeAttribute('max');
paymentLimitHint.style.display = 'none';
}
});
}
});
</script>
<script> <script>
// Глобально определяем initOrderItemSelect2 // Глобально определяем initOrderItemSelect2
window.initOrderItemSelect2 = function(element) { window.initOrderItemSelect2 = function(element) {