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 %}
{% 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(