feat: basket timeout

This commit is contained in:
imperosol
2026-05-17 10:25:04 +02:00
parent 68aa4515f9
commit bd9c1eebb2
10 changed files with 170 additions and 28 deletions
+10 -1
View File
@@ -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:
+20
View File
@@ -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"),
@@ -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();
}
},
}));
@@ -21,6 +21,7 @@
hx-swap="outerHTML"
hx-target="#billing-infos-fragment"
x-show="collapsed"
x-cloak
>
{% csrf_token %}
{{ form.as_p() }}
@@ -15,11 +15,10 @@
{% block content %}
<h3>{% trans %}Eboutic{% endtrans %}</h3>
<script type="text/javascript">
let billingInfos = {{ billing_infos|safe }};
</script>
<div x-data="etransaction(billingInfos, {{ basket.id }})">
<div x-data='etransaction(
{{ billing_infos|tojson }},
{ id: {{ basket.id }}, timeout: new Date('{{ basket.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT }}') }
)'>
<p>{% trans %}Basket: {% endtrans %}</p>
<table>
<thead>
@@ -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 %}
<form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form">
{% csrf_token %}
<input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
<input
{% if basket.is_expired %}
disabled="disabled"
{% else %}
:disabled="!isSithAvailable"
{% endif %}
class="btn btn-blue"
type="submit"
value="{% trans %}Pay with Sith account{% endtrans %}"
/>
</form>
{% endif %}
</div>
+22 -6
View File
@@ -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):
+21 -6
View File
@@ -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")