diff --git a/counter/forms.py b/counter/forms.py index 1794e2ba..cd99c95a 100644 --- a/counter/forms.py +++ b/counter/forms.py @@ -3,6 +3,7 @@ import math import uuid from collections import defaultdict from datetime import date, datetime, timezone +from typing import ClassVar from dateutil.relativedelta import relativedelta from django import forms @@ -11,6 +12,7 @@ from django.core.exceptions import ValidationError from django.db.models import Exists, OuterRef, Q from django.forms import BaseModelFormSet from django.http import HttpRequest +from django.utils.functional import cached_property from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from django_celery_beat.models import ClockedSchedule @@ -600,6 +602,10 @@ class BasketItemForm(forms.Form): class BaseBasketForm(forms.BaseFormSet): + # Minimum amount of money there must be on the account after the transaction + # If None, the min balance check is skipped + min_result_balance: ClassVar[int | None] = 0 + def __init__(self, *args, customer: Customer, counter: Counter, **kwargs): super().__init__(*args, **kwargs) self.customer = customer @@ -614,8 +620,7 @@ class BaseBasketForm(forms.BaseFormSet): self._check_forms_have_errors() self._check_product_are_unique() self._check_recorded_products() - self._check_enough_money() - self._check_refills() + self._check_account_balance() def _check_forms_have_errors(self): if any(len(form.errors) > 0 for form in self): @@ -626,10 +631,33 @@ class BaseBasketForm(forms.BaseFormSet): if len(price_ids) != len(self.forms): raise forms.ValidationError(_("Duplicated product entries.")) - def _check_enough_money(self): - self.total_price = sum([data["total_price"] for data in self.cleaned_data]) - if self.total_price > self.customer.amount: + @cached_property + def total_price(self): + refill = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING + total_other = sum( + form.cleaned_data["total_price"] + for form in self.forms + if form.price.product.product_type_id != refill + ) + total_refill = sum( + form.cleaned_data["total_price"] + for form in self.forms + if form.price.product.product_type_id == refill + ) + return total_other - total_refill + + def _check_account_balance(self): + result_balance = self.customer.amount - self.total_price + if ( + self.min_result_balance is not None + and self.min_result_balance > result_balance + ): raise forms.ValidationError(_("Not enough money")) + if result_balance > settings.SITH_ACCOUNT_MAX_MONEY: + raise ValidationError( + _("There cannot be more than %(money)d€ on an AE account") + % {"money": settings.SITH_ACCOUNT_MAX_MONEY} + ) def _check_recorded_products(self): """Check for, among other things, ecocups and pitchers""" @@ -659,13 +687,6 @@ class BaseBasketForm(forms.BaseFormSet): % ", ".join([str(p) for p in limit_reached]) ) - def _check_refills(self): - refill_type_id = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING - if any(f.price.product.product_type_id == refill_type_id for f in self.forms): - raise ValidationError( - _("Refill bonds cannot be purchased outside of the eboutic") - ) - BasketForm = forms.formset_factory( BasketItemForm, formset=BaseBasketForm, absolute_max=None, min_num=1 diff --git a/counter/models.py b/counter/models.py index cd88e7e5..cda5ac1b 100644 --- a/counter/models.py +++ b/counter/models.py @@ -99,7 +99,7 @@ class Customer(models.Model): user = models.OneToOneField(User, primary_key=True, on_delete=models.CASCADE) account_id = models.CharField(_("account id"), max_length=10, unique=True) - amount = CurrencyField( + amount: CurrencyField = CurrencyField( _("amount"), max_value=settings.SITH_ACCOUNT_MAX_MONEY, default=0 ) diff --git a/counter/tests/test_counter.py b/counter/tests/test_counter.py index e7682848..69f3bff6 100644 --- a/counter/tests/test_counter.py +++ b/counter/tests/test_counter.py @@ -535,6 +535,19 @@ class TestCounterClick(TestFullClickBase): assert self.updated_amount(self.customer) == Decimal(10) + def test_unrecord_above_limit_fails(self): + """Test that it's forbidden to give back a recorded product + if it puts the account balance above the limit. + """ + self.login_in_bar() + limit = settings.SITH_ACCOUNT_MAX_MONEY + # put the account balance just at the limit + baker.make(Refilling, customer=self.customer.customer, amount=limit) + response = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)]) + assert response.status_code == 200 # no redirect = failure + self.customer.customer.refresh_from_db() + assert self.updated_amount(self.customer) == limit + def test_annotate_has_barman_queryset(self): """Test if the custom queryset method `annotate_has_barman` works as intended.""" counters = Counter.objects.annotate_has_barman(self.barmen) diff --git a/eboutic/static/bundled/eboutic/eboutic-index.ts b/eboutic/static/bundled/eboutic/eboutic-index.ts index e2bbe9ec..50a7f715 100644 --- a/eboutic/static/bundled/eboutic/eboutic-index.ts +++ b/eboutic/static/bundled/eboutic/eboutic-index.ts @@ -65,11 +65,15 @@ document.addEventListener("alpine:init", () => { ); }, - getTotalRefill() { + /** + * Get the total of money that would be added to the AE account on basket purchase. + */ + getTotalAdded() { return this.basket - .filter((item) => item.isRefill) + .filter((item) => item.isRefill || item.unitPrice < 0) .reduce( - (acc: number, item: BasketItem) => acc + item.quantity * item.unitPrice, + (acc: number, item: BasketItem) => + acc + Math.abs(item.quantity * item.unitPrice), 0, ); }, diff --git a/eboutic/templates/eboutic/eboutic_main.jinja b/eboutic/templates/eboutic/eboutic_main.jinja index fe5227d7..78f5429c 100644 --- a/eboutic/templates/eboutic/eboutic_main.jinja +++ b/eboutic/templates/eboutic/eboutic_main.jinja @@ -58,7 +58,7 @@ {% endif %} -