Use htmx to fill up billing info

This commit is contained in:
Antoine Bartuccio 2025-04-11 00:48:13 +02:00
parent ed52a4f828
commit 5c2f324e13
8 changed files with 143 additions and 213 deletions

View File

@ -43,6 +43,7 @@ class BillingInfoForm(forms.ModelForm):
] ]
widgets = { widgets = {
"phone_number": RegionalPhoneNumberWidget, "phone_number": RegionalPhoneNumberWidget,
"country": AutoCompleteSelect,
} }

View File

@ -1,31 +1,13 @@
from django.shortcuts import get_object_or_404
from ninja_extra import ControllerBase, api_controller, route from ninja_extra import ControllerBase, api_controller, route
from ninja_extra.exceptions import NotFound, PermissionDenied from ninja_extra.exceptions import NotFound
from ninja_extra.permissions import IsAuthenticated from ninja_extra.permissions import IsAuthenticated
from pydantic import NonNegativeInt
from core.models import User from counter.models import BillingInfo
from counter.models import BillingInfo, Customer
from eboutic.models import Basket from eboutic.models import Basket
from eboutic.schemas import BillingInfoSchema
@api_controller("/etransaction", permissions=[IsAuthenticated]) @api_controller("/etransaction", permissions=[IsAuthenticated])
class EtransactionInfoController(ControllerBase): class EtransactionInfoController(ControllerBase):
@route.put("/billing-info/{user_id}", url_name="put_billing_info")
def put_user_billing_info(self, user_id: NonNegativeInt, info: BillingInfoSchema):
"""Update or create the billing info of this user."""
if user_id == self.context.request.user.id:
user = self.context.request.user
elif self.context.request.user.is_root:
user = get_object_or_404(User, pk=user_id)
else:
raise PermissionDenied
customer, _ = Customer.get_or_create(user)
BillingInfo.objects.update_or_create(
customer=customer, defaults=info.model_dump(exclude_none=True)
)
@route.get("/data", url_name="etransaction_data") @route.get("/data", url_name="etransaction_data")
def fetch_etransaction_data(self): def fetch_etransaction_data(self):
"""Generate the data to pay an eboutic command with paybox. """Generate the data to pay an eboutic command with paybox.
@ -35,4 +17,7 @@ class EtransactionInfoController(ControllerBase):
basket = Basket.from_session(self.context.request.session) basket = Basket.from_session(self.context.request.session)
if basket is None: if basket is None:
raise NotFound raise NotFound
return dict(basket.get_e_transaction_data()) try:
return dict(basket.get_e_transaction_data())
except BillingInfo.DoesNotExist as e:
raise NotFound from e

View File

@ -16,6 +16,7 @@ from __future__ import annotations
import hmac import hmac
from datetime import datetime from datetime import datetime
from enum import Enum
from typing import Any, Self from typing import Any, Self
from dict2xml import dict2xml from dict2xml import dict2xml
@ -44,6 +45,28 @@ def get_eboutic_products(user: User) -> list[Product]:
return [p for p in products if p.can_be_sold_to(user)] return [p for p in products if p.can_be_sold_to(user)]
class BillingInfoState(Enum):
VALID = 1
EMPTY = 2
MISSING_PHONE_NUMBER = 3
@classmethod
def from_model(cls, info: BillingInfo) -> BillingInfoState:
for attr in [
"first_name",
"last_name",
"address_1",
"zip_code",
"city",
"country",
]:
if getattr(info, attr) == "":
return cls.EMPTY
if info.phone_number is None:
return cls.MISSING_PHONE_NUMBER
return cls.VALID
class Basket(models.Model): class Basket(models.Model):
"""Basket is built when the user connects to an eboutic page.""" """Basket is built when the user connects to an eboutic page."""
@ -127,7 +150,11 @@ class Basket(models.Model):
if not hasattr(user, "customer"): if not hasattr(user, "customer"):
raise Customer.DoesNotExist raise Customer.DoesNotExist
customer = user.customer customer = user.customer
if not hasattr(user.customer, "billing_infos"): if (
not hasattr(user.customer, "billing_infos")
or BillingInfoState.from_model(user.customer.billing_infos)
!= BillingInfoState.VALID
):
raise BillingInfo.DoesNotExist raise BillingInfo.DoesNotExist
cart = { cart = {
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}} "shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}

View File

@ -1,91 +1,17 @@
import { exportToHtml } from "#core:utils/globals"; import { etransactioninfoFetchEtransactionData } from "#openapi";
import {
type BillingInfoSchema,
etransactioninfoFetchEtransactionData,
etransactioninfoPutUserBillingInfo,
} from "#openapi";
enum BillingInfoReqState {
Success = "0",
Failure = "1",
Sending = "2",
}
exportToHtml("BillingInfoReqState", BillingInfoReqState);
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("etransactionData", (initialData) => ({ Alpine.data("etransaction", (initialData) => ({
data: initialData, data: initialData,
isCbAvailable: Object.keys(initialData).length > 0,
async fill() { async fill() {
const button = document.getElementById("bank-submit-button") as HTMLButtonElement; this.isCbAvailable = false;
button.disabled = true;
const res = await etransactioninfoFetchEtransactionData(); const res = await etransactioninfoFetchEtransactionData();
if (res.response.ok) { if (res.response.ok) {
this.data = res.data; this.data = res.data;
button.disabled = false; this.isCbAvailable = true;
} }
}, },
})); }));
Alpine.data("billing_infos", (userId: number) => ({
/** @type {BillingInfoReqState | null} */
reqState: null,
async sendForm() {
this.reqState = BillingInfoReqState.Sending;
const form = document.getElementById("billing_info_form");
const submitButton = document.getElementById(
"bank-submit-button",
) as HTMLButtonElement;
submitButton.disabled = true;
const payload = Object.fromEntries(
Array.from(form.querySelectorAll("input, select"))
.filter((elem: HTMLInputElement) => elem.type !== "submit" && elem.value)
.map((elem: HTMLInputElement) => [elem.name, elem.value]),
);
const res = await etransactioninfoPutUserBillingInfo({
// biome-ignore lint/style/useNamingConvention: API is snake_case
path: { user_id: userId },
body: payload as unknown as BillingInfoSchema,
});
this.reqState = res.response.ok
? BillingInfoReqState.Success
: BillingInfoReqState.Failure;
if (res.response.status === 422) {
const errors = await res.response
.json()
.detail.flatMap((err: Record<"loc", string>) => err.loc);
for (const elem of Array.from(form.querySelectorAll("input")).filter((elem) =>
errors.includes(elem.name),
)) {
elem.setCustomValidity(gettext("Incorrect value"));
elem.reportValidity();
elem.oninput = () => elem.setCustomValidity("");
}
} else if (res.response.ok) {
this.$dispatch("billing-infos-filled");
}
},
getAlertColor() {
if (this.reqState === BillingInfoReqState.Success) {
return "green";
}
if (this.reqState === BillingInfoReqState.Failure) {
return "red";
}
return "";
},
getAlertMessage() {
if (this.reqState === BillingInfoReqState.Success) {
return gettext("Billing info registration success");
}
if (this.reqState === BillingInfoReqState.Failure) {
return gettext("Billing info registration failure");
}
return "";
},
}));
}); });

View File

@ -0,0 +1,50 @@
<span>
<div
class="collapse"
:class="{'shadow': collapsed}"
x-data="{collapsed: !{{ "true" if billing_infos_state == BillingInfoState.VALID and not form.errors else "false" }}}"
>
<div class="collapse-header clickable" @click="collapsed = !collapsed">
<span class="collapse-header-text">
{% trans %}Billing information{% endtrans %}
</span>
<span class="collapse-header-icon" :class="{'reverse': collapsed}">
<i class="fa fa-caret-down"></i>
</span>
</div>
<form
class="collapse-body"
hx-trigger="submit"
hx-post="{{ action }}"
hx-swap="outerHTML settle:100"
hx-target="closest span"
x-show="collapsed"
>
{% csrf_token %}
{{ form.as_p() }}
<input
type="submit" class="btn btn-blue clickable"
value="{% trans %}Validate{% endtrans %}"
>
</form>
</div>
<br>
{% if billing_infos_state == BillingInfoState.EMPTY %}
<div class="alert alert-yellow">
{% trans trimmed %}
You must fill your billing infos if you want to pay with your credit card
{% endtrans %}
</div>
{% elif billing_infos_state == BillingInfoState.MISSING_PHONE_NUMBER %}
<div class="alert alert-yellow">
{% trans trimmed %}
The Crédit Agricole changed its policy related to the billing
information that must be provided in order to pay with a credit card.
If you want to pay with your credit card, you must add a phone number
to the data you already provided.
{% endtrans %}
</div>
{% endif %}
</span>

View File

@ -15,7 +15,11 @@
{% block content %} {% block content %}
<h3>{% trans %}Eboutic{% endtrans %}</h3> <h3>{% trans %}Eboutic{% endtrans %}</h3>
<div> <script type="text/javascript">
let billingInfos = {{ billing_infos|tojson }};
</script>
<div x-data="etransaction(billingInfos)">
<p>{% trans %}Basket: {% endtrans %}</p> <p>{% trans %}Basket: {% endtrans %}</p>
<table> <table>
<thead> <thead>
@ -53,80 +57,21 @@
</p> </p>
<br> <br>
{% if settings.SITH_EBOUTIC_CB_ENABLED %} {% if settings.SITH_EBOUTIC_CB_ENABLED %}
<div <div @htmx:after-request="fill">
class="collapse" {{ billing_infos_form }}
:class="{'shadow': collapsed}"
x-data="{collapsed: !{{ "true" if billing_infos else "false" }}}"
x-cloak
>
<div class="collapse-header clickable" @click="collapsed = !collapsed">
<span class="collapse-header-text">
{% trans %}Billing information{% endtrans %}
</span>
<span class="collapse-header-icon" :class="{'reverse': collapsed}">
<i class="fa fa-caret-down"></i>
</span>
</div>
<form
class="collapse-body"
id="billing_info_form"
x-data="billing_infos({{ user.id }})"
x-show="collapsed"
x-transition.scale.origin.top
@submit.prevent="await sendForm()"
>
{% csrf_token %}
{{ billing_form }}
<br />
<div
x-show="[BillingInfoReqState.Success, BillingInfoReqState.Failure].includes(reqState)"
class="alert"
:class="'alert-' + getAlertColor()"
x-transition
>
<div class="alert-main" x-text="getAlertMessage()"></div>
<div class="clickable" @click="reqState = null">
<i class="fa fa-close"></i>
</div>
</div>
<input
type="submit" class="btn btn-blue clickable"
value="{% trans %}Validate{% endtrans %}"
:disabled="reqState === BillingInfoReqState.Sending"
>
</form>
</div> </div>
<br>
{% if billing_infos_state == BillingInfoState.EMPTY %}
<div class="alert alert-yellow">
{% trans trimmed %}
You must fill your billing infos if you want to pay with your credit card
{% endtrans %}
</div>
{% elif billing_infos_state == BillingInfoState.MISSING_PHONE_NUMBER %}
<div class="alert alert-yellow">
{% trans trimmed %}
The Crédit Agricole changed its policy related to the billing
information that must be provided in order to pay with a credit card.
If you want to pay with your credit card, you must add a phone number
to the data you already provided.
{% endtrans %}
</div>
{% endif %}
<form <form
method="post" method="post"
action="{{ settings.SITH_EBOUTIC_ET_URL }}" action="{{ settings.SITH_EBOUTIC_ET_URL }}"
name="bank-pay-form"
x-data="etransactionData(initialEtData)"
@billing-infos-filled.window="await fill()"
> >
<template x-for="[key, value] in Object.entries(data)" :key="key"> <template x-for="[key, value] in Object.entries(data)" :key="key">
<input type="hidden" :name="key" :value="value"> <input type="hidden" :name="key" :value="value">
</template> </template>
<input <input
x-cloak
type="submit" type="submit"
id="bank-submit-button" id="bank-submit-button"
{% if billing_infos_state != BillingInfoState.VALID %}disabled="disabled"{% endif %} :disabled="!isCbAvailable"
value="{% trans %}Pay with credit card{% endtrans %}" value="{% trans %}Pay with credit card{% endtrans %}"
/> />
</form> </form>
@ -143,16 +88,4 @@
</form> </form>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
{% block script %}
<script>
{% if billing_infos -%}
const initialEtData = {{ billing_infos|safe }}
{%- else -%}
const initialEtData = {}
{%- endif %}
</script>
{{ super() }}
{% endblock %}

View File

@ -17,7 +17,7 @@
# details. # details.
# #
# You should have received a copy of the GNU General Public License along with # You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple # this program; if not, write to the Free Software Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
@ -26,9 +26,9 @@ from django.urls import path, register_converter
from eboutic.converters import PaymentResultConverter from eboutic.converters import PaymentResultConverter
from eboutic.views import ( from eboutic.views import (
BillingInfoFormFragment,
EbouticCommand, EbouticCommand,
EtransactionAutoAnswer, EtransactionAutoAnswer,
e_transaction_data,
eboutic_main, eboutic_main,
pay_with_sith, pay_with_sith,
payment_result, payment_result,
@ -40,9 +40,9 @@ urlpatterns = [
# Subscription views # Subscription views
path("", eboutic_main, name="main"), path("", eboutic_main, name="main"),
path("command/", EbouticCommand.as_view(), name="command"), path("command/", EbouticCommand.as_view(), name="command"),
path("billing-infos/", BillingInfoFormFragment.as_view(), name="billing_infos"),
path("pay/sith/", pay_with_sith, name="pay_with_sith"), path("pay/sith/", pay_with_sith, name="pay_with_sith"),
path("pay/<res:result>/", payment_result, name="payment_result"), path("pay/<res:result>/", payment_result, name="payment_result"),
path("et_data/", e_transaction_data, name="et_data"),
path( path(
"et_autoanswer", "et_autoanswer",
EtransactionAutoAnswer.as_view(), EtransactionAutoAnswer.as_view(),

View File

@ -13,10 +13,11 @@
# #
# #
from __future__ import annotations
import base64 import base64
import json import contextlib
from datetime import datetime from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import sentry_sdk import sentry_sdk
@ -33,16 +34,19 @@ from django.core.exceptions import SuspiciousOperation
from django.db import DatabaseError, transaction from django.db import DatabaseError, transaction
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.http import require_GET, require_POST from django.views.decorators.http import require_GET, require_POST
from django.views.generic import TemplateView, View from django.views.generic import TemplateView, UpdateView, View
from core.views.mixins import FragmentMixin, UseFragmentsMixin
from counter.forms import BillingInfoForm from counter.forms import BillingInfoForm
from counter.models import Counter, Customer, Product from counter.models import BillingInfo, Counter, Customer, Product
from eboutic.forms import BasketForm from eboutic.forms import BasketForm
from eboutic.models import ( from eboutic.models import (
Basket, Basket,
BasketItem, BasketItem,
BillingInfoState,
Invoice, Invoice,
InvoiceItem, InvoiceItem,
get_eboutic_products, get_eboutic_products,
@ -88,15 +92,38 @@ def payment_result(request, result: str) -> HttpResponse:
return render(request, "eboutic/eboutic_payment_result.jinja", context) return render(request, "eboutic/eboutic_payment_result.jinja", context)
class BillingInfoState(Enum): class BillingInfoFormFragment(LoginRequiredMixin, FragmentMixin, UpdateView):
VALID = 1 """Update billing info"""
EMPTY = 2
MISSING_PHONE_NUMBER = 3 model = BillingInfo
form_class = BillingInfoForm
template_name = "eboutic/eboutic_billing_info.jinja"
def get_object(self, *args, **kwargs):
customer, _ = Customer.get_or_create(self.request.user)
if not hasattr(customer, "billing_infos"):
customer.billing_infos = BillingInfo()
return customer.billing_infos
def get_context_data(self, **kwargs):
if not hasattr(self, "object"):
self.object = self.get_object()
kwargs = super().get_context_data(**kwargs)
kwargs["action"] = reverse("eboutic:billing_infos")
kwargs["BillingInfoState"] = BillingInfoState
kwargs["billing_infos_state"] = BillingInfoState.from_model(self.object)
return kwargs
def get_success_url(self, **kwargs):
return self.request.path
class EbouticCommand(LoginRequiredMixin, TemplateView): class EbouticCommand(LoginRequiredMixin, UseFragmentsMixin, TemplateView):
template_name = "eboutic/eboutic_makecommand.jinja" template_name = "eboutic/eboutic_makecommand.jinja"
basket: Basket basket: Basket
fragments = {
"billing_infos_form": BillingInfoFormFragment,
}
@method_decorator(login_required) @method_decorator(login_required)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -134,6 +161,7 @@ class EbouticCommand(LoginRequiredMixin, TemplateView):
return super().get(request) return super().get(request)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
default_billing_info = None default_billing_info = None
if hasattr(self.request.user, "customer"): if hasattr(self.request.user, "customer"):
customer = self.request.user.customer customer = self.request.user.customer
@ -142,34 +170,14 @@ class EbouticCommand(LoginRequiredMixin, TemplateView):
default_billing_info = customer.billing_infos default_billing_info = customer.billing_infos
else: else:
kwargs["customer_amount"] = None kwargs["customer_amount"] = None
# make the enum available in the template
kwargs["BillingInfoState"] = BillingInfoState
if default_billing_info is None:
kwargs["billing_infos_state"] = BillingInfoState.EMPTY
elif default_billing_info.phone_number is None:
kwargs["billing_infos_state"] = BillingInfoState.MISSING_PHONE_NUMBER
else:
kwargs["billing_infos_state"] = BillingInfoState.VALID
if kwargs["billing_infos_state"] == BillingInfoState.VALID:
# the user has already filled all of its billing_infos, thus we can
# get it without expecting an error
kwargs["billing_infos"] = dict(self.basket.get_e_transaction_data())
kwargs["basket"] = self.basket kwargs["basket"] = self.basket
kwargs["billing_form"] = BillingInfoForm(instance=default_billing_info) kwargs["billing_form"] = BillingInfoForm(instance=default_billing_info)
kwargs["billing_infos"] = {}
with contextlib.suppress(BillingInfo.DoesNotExist):
kwargs["billing_infos"] = dict(self.basket.get_e_transaction_data())
return kwargs return kwargs
@login_required
@require_GET
def e_transaction_data(request):
basket = Basket.from_session(request.session)
if basket is None:
return HttpResponse(status=404, content=json.dumps({"data": []}))
data = basket.get_e_transaction_data()
data = {"data": [{"key": key, "value": val} for key, val in data]}
return HttpResponse(status=200, content=json.dumps(data))
@login_required @login_required
@require_POST @require_POST
def pay_with_sith(request): def pay_with_sith(request):