From bd9c1eebb20372e25cdbe49177dea354495c1a57 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 17 May 2026 10:25:04 +0200 Subject: [PATCH] feat: basket timeout --- eboutic/api.py | 11 +++- eboutic/models.py | 20 +++++++ .../static/bundled/eboutic/checkout-index.ts | 60 +++++++++++++++++-- .../eboutic/eboutic_billing_info.jinja | 1 + .../templates/eboutic/eboutic_checkout.jinja | 26 +++++--- eboutic/tests/test_payment.py | 28 +++++++-- eboutic/views.py | 27 +++++++-- locale/fr/LC_MESSAGES/django.po | 11 +++- locale/fr/LC_MESSAGES/djangojs.po | 9 ++- sith/settings.py | 5 ++ 10 files changed, 170 insertions(+), 28 deletions(-) diff --git a/eboutic/api.py b/eboutic/api.py index c44a8cc9..f1c6ccc2 100644 --- a/eboutic/api.py +++ b/eboutic/api.py @@ -1,3 +1,6 @@ +from typing import Any + +from ninja import Status from ninja_extra import ControllerBase, api_controller, route from ninja_extra.exceptions import NotFound @@ -8,13 +11,19 @@ from eboutic.models import Basket @api_controller("/etransaction", permissions=[CanView]) class EtransactionInfoController(ControllerBase): - @route.get("/data/{basket_id}", url_name="etransaction_data") + @route.get( + "/data/{basket_id}", + url_name="etransaction_data", + response={200: dict[str, Any], 410: str}, + ) def fetch_etransaction_data(self, basket_id: int): """Generate the data to pay an eboutic command with paybox. The data is generated with the basket that is used by the current session. """ basket: Basket = self.get_object_or_exception(Basket, pk=basket_id) + if basket.is_expired: + return Status(410, "This basket is expired.") try: return dict(basket.get_e_transaction_data()) except BillingInfo.DoesNotExist as e: diff --git a/eboutic/models.py b/eboutic/models.py index cf6e15ab..af029ae1 100644 --- a/eboutic/models.py +++ b/eboutic/models.py @@ -24,6 +24,7 @@ from django.conf import settings from django.db import DataError, models from django.db.models import F, OuterRef, Subquery, Sum from django.utils.functional import cached_property +from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from core.models import User @@ -95,6 +96,10 @@ class Basket(models.Model): ] ) + @property + def is_expired(self) -> bool: + return (self.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT) <= now() + def generate_sales( self, counter, seller: User, payment_method: Selling.PaymentMethod ): @@ -133,9 +138,20 @@ class Basket(models.Model): ] def get_e_transaction_data(self) -> list[tuple[str, str]]: + """Get data for etransaction payment. + + Raises: + Customer.DoesNotExist: if the user linked to this basket + has no customer account + BillingInfo.DoesNotExist: if the user linked to this basket has no + billing infos, or incorrect billing infos. + ValueError: if this is called on a basket which payment delay is expired. + """ user = self.user if not hasattr(user, "customer"): raise Customer.DoesNotExist + if self.is_expired: + raise ValueError("This method cannot be called on an expired basket.") customer = user.customer if ( not hasattr(user.customer, "billing_infos") @@ -155,6 +171,10 @@ class Basket(models.Model): ("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT), ("PBX_TOTAL", str(int(self.total * 100))), ("PBX_DEVISE", "978"), # This is Euro + ( + "PBX_DISPLAY", + str(int(settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT.total_seconds())), + ), ("PBX_CMD", str(self.id)), ("PBX_PORTEUR", user.email), ("PBX_RETOUR", "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"), diff --git a/eboutic/static/bundled/eboutic/checkout-index.ts b/eboutic/static/bundled/eboutic/checkout-index.ts index cb4be7f0..08683218 100644 --- a/eboutic/static/bundled/eboutic/checkout-index.ts +++ b/eboutic/static/bundled/eboutic/checkout-index.ts @@ -1,21 +1,71 @@ +import { type Notification, NotificationLevel } from "#core:utils/notifications"; import { etransactioninfoFetchEtransactionData } from "#openapi"; +interface Basket { + id: number; + timeout: Date; +} document.addEventListener("alpine:init", () => { - Alpine.data("etransaction", (initialData, basketId: number) => ({ + Alpine.data("etransaction", (initialData, basket: Basket) => ({ data: initialData, isCbAvailable: Object.keys(initialData).length > 0, + isSithAvailable: true, + init() { + const now = new Date(); + const timeout = basket.timeout.getTime() - now.getTime(); + if (timeout <= 0) { + // basket was already outdated at initial page load + this.timeoutBasket(); + } else { + setTimeout(() => this.timeoutBasket(), timeout); + } + }, + + /** + * Make this basket into a timeout state. + * All submission inputs are disabled, and an error message is displayed. + */ + timeoutBasket() { + this.isCbAvailable = false; + this.isSithAvailable = false; + const message = gettext("Basket expired"); + + const existingNotif: Notification | undefined = this.$notifications + .getAll() + .find( + (n: Notification) => + n.tag === NotificationLevel.Error && n.message === message, + ); + if (existingNotif === undefined) { + this.$notifications.error(message); + } + }, + + /** + * Refresh the data used for etransaction. + * + * Note: if this is called while the basket is expired, it will be a no-op + */ async fill() { + if (new Date() > basket.timeout) { + // refresh etransaction data only if the basket is still valid. + this.timeoutBasket(); + return; + } this.isCbAvailable = false; const res = await etransactioninfoFetchEtransactionData({ - path: { - // biome-ignore lint/style/useNamingConvention: api is in snake_case - basket_id: basketId, - }, + // biome-ignore lint/style/useNamingConvention: api is in snake_case + path: { basket_id: basket.id }, }); if (res.response.ok) { this.data = res.data; this.isCbAvailable = true; + } else if (res.response.status === 410) { + // The basket is expired, so no payment method should be available at all. + // This shouldn't happen, because we don't send the request + // when the timeout is passed, but we are better safe than sorry + this.timeoutBasket(); } }, })); diff --git a/eboutic/templates/eboutic/eboutic_billing_info.jinja b/eboutic/templates/eboutic/eboutic_billing_info.jinja index 4084ee47..307442c3 100644 --- a/eboutic/templates/eboutic/eboutic_billing_info.jinja +++ b/eboutic/templates/eboutic/eboutic_billing_info.jinja @@ -21,6 +21,7 @@ hx-swap="outerHTML" hx-target="#billing-infos-fragment" x-show="collapsed" + x-cloak > {% csrf_token %} {{ form.as_p() }} diff --git a/eboutic/templates/eboutic/eboutic_checkout.jinja b/eboutic/templates/eboutic/eboutic_checkout.jinja index 369c5d44..03707469 100644 --- a/eboutic/templates/eboutic/eboutic_checkout.jinja +++ b/eboutic/templates/eboutic/eboutic_checkout.jinja @@ -15,11 +15,10 @@ {% block content %}

{% trans %}Eboutic{% endtrans %}

- - -
+

{% trans %}Basket: {% endtrans %}

@@ -72,7 +71,11 @@ x-cloak type="submit" id="bank-submit-button" - :disabled="!isCbAvailable" + {% if basket.is_expired %} + disabled="disabled" + {% else %} + :disabled="!isCbAvailable" + {% endif %} class="btn btn-blue" value="{% trans %}Pay with credit card{% endtrans %}" /> @@ -93,7 +96,16 @@ {% else %} {% csrf_token %} - + {% endif %} diff --git a/eboutic/tests/test_payment.py b/eboutic/tests/test_payment.py index 5c4b1da1..0a568015 100644 --- a/eboutic/tests/test_payment.py +++ b/eboutic/tests/test_payment.py @@ -3,6 +3,7 @@ import urllib from decimal import Decimal from typing import TYPE_CHECKING +import freezegun from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey from cryptography.hazmat.primitives.hashes import SHA1 @@ -105,7 +106,7 @@ class TestPaymentSith(TestPaymentBase): ), reverse("eboutic:payment_result", kwargs={"result": "success"}), ) - assert Basket.objects.filter(id=self.basket.id).first() is None + assert not Basket.objects.filter(id=self.basket.id).exists() self.customer.customer.refresh_from_db() assert self.customer.customer.amount == Decimal(1) @@ -139,10 +140,7 @@ class TestPaymentSith(TestPaymentBase): assert len(messages) == 1 assert messages[0].level == DEFAULT_LEVELS["ERROR"] assert messages[0].message == "Solde insuffisant" - - assert Basket.objects.contains(self.basket), ( - "After an unsuccessful request, the basket should be kept" - ) + assert not Basket.objects.filter(id=self.basket.id).exists() def test_refilling_in_basket(self): BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save() @@ -157,7 +155,7 @@ class TestPaymentSith(TestPaymentBase): response, reverse("eboutic:payment_result", kwargs={"result": "failure"}), ) - assert Basket.objects.filter(id=self.basket.id).first() is not None + assert not Basket.objects.filter(id=self.basket.id).exists() messages = list(get_messages(response.wsgi_request)) assert messages[0].level == DEFAULT_LEVELS["ERROR"] assert ( @@ -167,6 +165,24 @@ class TestPaymentSith(TestPaymentBase): self.customer.customer.refresh_from_db() assert self.customer.customer.amount == initial_account_balance + def test_basket_expired(self): + self.client.force_login(self.customer) + initial_account_balance = self.customer.customer.amount + with freezegun.freeze_time(settings.SITH_EBOUTIC_BASKET_TIMEOUT): + response = self.client.post( + reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id}) + ) + assertRedirects( + response, + reverse("eboutic:payment_result", kwargs={"result": "failure"}), + ) + messages = list(get_messages(response.wsgi_request)) + assert messages[0].level == DEFAULT_LEVELS["ERROR"] + assert messages[0].message == "Panier expiré" + assert not Basket.objects.filter(id=self.basket.id).exists() + self.customer.customer.refresh_from_db() + assert self.customer.customer.amount == initial_account_balance + class TestPaymentCard(TestPaymentBase): def generate_bank_valid_answer(self, basket: Basket): diff --git a/eboutic/views.py b/eboutic/views.py index 82704be3..c892a104 100644 --- a/eboutic/views.py +++ b/eboutic/views.py @@ -39,6 +39,8 @@ from django.db.utils import cached_property from django.http import HttpResponse from django.shortcuts import redirect, render from django.urls import reverse +from django.utils.formats import localize +from django.utils.timezone import localtime from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_GET from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View @@ -187,9 +189,7 @@ class BillingInfoFormFragment( def get_initial(self): if self.object is None: - return { - "country": Country(code="FR"), - } + return {"country": Country(code="FR")} return {} def render_fragment(self, request, **kwargs) -> SafeString: @@ -255,10 +255,19 @@ class EbouticCheckout(CanViewMixin, UseFragmentsMixin, DetailView): kwargs["customer_amount"] = None kwargs["billing_infos"] = {} - with contextlib.suppress(BillingInfo.DoesNotExist): - kwargs["billing_infos"] = json.dumps( - dict(self.object.get_e_transaction_data()) + if self.object.is_expired: + messages.error(self.request, _("Basket expired")) + else: + timeout = self.object.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT + messages.warning( + self.request, + _("Basket available until %(until)s") + % {"until": localize(localtime(timeout).time())}, ) + with contextlib.suppress(BillingInfo.DoesNotExist): + kwargs["billing_infos"] = json.dumps( + dict(self.object.get_e_transaction_data()) + ) return kwargs @@ -268,9 +277,14 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View): def post(self, request, *args, **kwargs): basket = self.get_object() + if basket.is_expired: + messages.error(self.request, _("Basket expired")) + basket.delete() + return redirect("eboutic:payment_result", "failure") refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING if basket.items.filter(product__product_type_id=refilling).exists(): messages.error(self.request, _("You can't buy a refilling with sith money")) + basket.delete() return redirect("eboutic:payment_result", "failure") eboutic = get_eboutic() @@ -288,6 +302,7 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View): except DatabaseError as e: sentry_sdk.capture_exception(e) except ValidationError as e: + basket.delete() messages.error(self.request, e.message) return redirect("eboutic:payment_result", "failure") diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 7791ae3f..0a5e3721 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-12 09:48+0200\n" +"POT-Creation-Date: 2026-05-15 11:46+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -4333,6 +4333,15 @@ msgstr "" "souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux " "données que vous aviez déjà fourni." +#: eboutic/views.py +msgid "Basket expired" +msgstr "Panier expiré" + +#: eboutic/views.py +#, python-format +msgid "Basket available until %(until)s" +msgstr "Panier disponible jusqu'à %(until)s" + #: eboutic/views.py msgid "You can't buy a refilling with sith money" msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith" diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index 9b598aee..4f69b8e9 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-26 15:45+0100\n" +"POT-Creation-Date: 2026-05-17 10:03+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n" "Last-Translator: Sli \n" "Language-Team: AE info \n" @@ -255,6 +255,10 @@ msgstr "Types de produits réordonnés !" msgid "Product type reorganisation failed with status code : %d" msgstr "La réorganisation des types de produit a échoué avec le code : %d" +#: eboutic/static/bundled/eboutic/checkout-index.ts +msgid "Basket expired" +msgstr "Panier expiré" + #: sas/static/bundled/sas/pictures-download-index.ts msgid "pictures.%(extension)s" msgstr "photos.%(extension)s" @@ -271,4 +275,5 @@ msgstr "Il n'a pas été possible de supprimer l'image" msgid "" "Wrong timetable format. Make sure you copied if from your student folder." msgstr "" -"Mauvais format d'emploi du temps. Assurez-vous que vous l'avez copié depuis votre dossier étudiants." +"Mauvais format d'emploi du temps. Assurez-vous que vous l'avez copié depuis " +"votre dossier étudiants." diff --git a/sith/settings.py b/sith/settings.py index 872c259e..25e90656 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -566,6 +566,11 @@ SITH_BARMAN_TIMEOUT = 30 # Minutes to delete the last operations SITH_LAST_OPERATIONS_LIMIT = 10 +# time before a basket is considered expired +SITH_EBOUTIC_BASKET_TIMEOUT = timedelta(minutes=10) +# time that a user can spend on the CB payment page before it to timeout +SITH_EBOUTIC_ETRANSACTION_TIMEOUT = timedelta(minutes=10) + # ET variables SITH_EBOUTIC_CB_ENABLED = env.bool("SITH_EBOUTIC_CB_ENABLED", default=True) SITH_EBOUTIC_ET_URL = env.str(