From e2a40dae973083d6c32fa2b56b028295bbefad2b Mon Sep 17 00:00:00 2001
From: Yohann D'ANELLO <ynerant@crans.org>
Date: Fri, 18 Jun 2021 18:27:22 +0200
Subject: [PATCH] Split billing views into subfiles

---
 billing/views.py          | 455 --------------------------------------
 billing/views/__init__.py |   9 +
 billing/views/comnpay.py  | 175 +++++++++++++++
 billing/views/purchase.py | 110 +++++++++
 billing/views/stripe.py   | 184 +++++++++++++++
 5 files changed, 478 insertions(+), 455 deletions(-)
 delete mode 100644 billing/views.py
 create mode 100644 billing/views/__init__.py
 create mode 100644 billing/views/comnpay.py
 create mode 100644 billing/views/purchase.py
 create mode 100644 billing/views/stripe.py

diff --git a/billing/views.py b/billing/views.py
deleted file mode 100644
index e6fb779..0000000
--- a/billing/views.py
+++ /dev/null
@@ -1,455 +0,0 @@
-import base64
-import hashlib
-import re
-from collections import OrderedDict
-from urllib.parse import quote_plus
-
-import requests
-from crispy_forms.helper import FormHelper
-from crispy_forms.layout import Button, Submit
-from django.conf import settings
-from django.contrib import messages
-from django.contrib.auth.mixins import LoginRequiredMixin
-from django.contrib.auth.models import User
-from django.contrib.sites.models import Site
-from django.core.exceptions import PermissionDenied
-from django.db import transaction
-from django.db.models import Q
-from django.forms.models import modelformset_factory
-from django.http import HttpResponse, HttpResponseBadRequest, Http404
-from django.shortcuts import redirect
-from django.urls import reverse_lazy, reverse
-from django.utils import translation, timezone
-from django.utils.decorators import method_decorator
-from django.utils.translation import gettext_lazy as _
-from django.views.decorators.csrf import csrf_exempt
-from django.views.generic import CreateView, DetailView, RedirectView, View
-from django_tables2 import SingleTableView
-
-from . import forms
-from .models import Entry, Product, Invoice, PaymentMethod
-from .tables import InvoiceTable
-
-
-class PurchaseView(LoginRequiredMixin, CreateView):
-    model = Invoice
-    form_class = forms.CreateInvoiceForm
-    template_name = 'billing/purchase.html'
-
-    def get_context_data(self, **kwargs):
-        context = super().get_context_data(**kwargs)
-        context['formset'] = self.get_formset_class()(data=self.request.POST or None, queryset=Entry.objects.none())
-        helper = FormHelper()
-        helper.template = 'bootstrap5/table_inline_formset.html'
-        helper.form_tag = False
-        context['helper'] = helper
-        helper.add_input(Button("add-product", _("Add product")))
-        helper.add_input(Submit("submit", _("Buy")))
-        context['user_object'] = context['form'].instance.target
-        return context
-
-    def get_form(self, form_class=None):
-        form = super().get_form(form_class)
-        if not self.request.user.has_perm('billing.view_paymentmethod'):
-            form.fields['payment_method'].queryset = PaymentMethod.objects.filter(visible=True)
-
-        user_qs = User.objects.filter(pk=self.kwargs['pk'])
-        if not self.request.user.has_perm('billing.add_invoice'):
-            user_qs = user_qs.filter(pk=self.request.user.pk)
-        if not user_qs.exists():
-            raise Http404
-        form.instance.target = user_qs.get()
-
-        return form
-
-    def get_formset_class(self):
-        # Restrict visible products to buyable products
-        payer_type = 'per' if self.request.user.profile.is_person else 'org'
-        member_privilege = (payer_type == 'per' and self.request.user.profile.has_current_membership)
-
-        class RestrictedEntryForm(forms.EntryForm):
-            def __init__(self, *args, **kwargs):
-                super().__init__(*args, **kwargs)
-                allowed = Q(payer_type='all') | Q(payer_type=payer_type)
-                if member_privilege:
-                    allowed |= Q(payer_type='mem')  # valid membership unlocks member-restricted products
-                self.fields['product'].queryset = Product.objects.filter(allowed)
-
-        return modelformset_factory(Entry, RestrictedEntryForm, extra=0, min_num=1, validate_min=True)
-
-    @transaction.atomic
-    def form_valid(self, form):
-        invoice = form.instance
-
-        formset = self.get_formset_class()(data=self.request.POST, queryset=Entry.objects.none())
-        if not formset.is_valid():
-            return self.form_invalid(form)
-
-        invoice.save()
-
-        empty = True
-        for product_form in formset.forms:
-            if product_form.instance.number > 0:
-                product_form.instance.invoice = invoice
-                empty = False
-        if empty:
-            return self.form_invalid(form)
-
-        formset.save()
-
-        return super().form_valid(form)
-
-    def get_success_url(self):
-        return self.object.payment_method.get_payment_url(self.object)
-
-
-class InvoiceListView(LoginRequiredMixin, SingleTableView):
-    model = Invoice
-    table_class = InvoiceTable
-    template_name = 'billing/invoice_list.html'
-
-    def get_queryset(self):
-        # filter only for current user
-        return super().get_queryset().filter(target_id=self.kwargs["pk"])
-
-
-class RenderInvoiceView(LoginRequiredMixin, DetailView):
-    model = Invoice
-    template_name = 'billing/invoice_template.html'
-
-    def get(self, request, *args, **kwargs):
-        if request.user != self.get_object().target and not request.user.has_perm('billing.view_invoice'):
-            raise PermissionDenied
-        if self.get_object().valid and self.get_object().html:
-            return HttpResponse(content=self.get_object().html)
-        return super().get(request, *args, **kwargs)
-
-
-# COMNPAY
-
-
-class RedirectComnpayView(LoginRequiredMixin, DetailView):
-    """
-    This view is used after the creation of an invoice.
-    When someone purchases something and wants to pay with Comnpay,
-    it is automatically redirected here.
-
-    Information about Comnpay payment are provided, like the VAD number,
-    redirect URLs or payment signature. This creates a hidden form, and
-    the user automatically makes a POST request to Comnpay with valid information.
-
-    3 URL are given: a success payment URL, a failure URL and an URL that Comnpay will
-    use to validate the payment.
-    """
-    model = Invoice
-    # Don't pay validated invoices
-    queryset = Invoice.objects.filter(valid=False, payment_method__comnpaypaymentmethod__isnull=False)
-    template_name = 'billing/redirect_comnpay.html'
-
-    def get_queryset(self):
-        # Superusers and treasurers can access to all invoices,
-        # normal users can only manage their own invoices
-        qs = super().get_queryset()
-        return qs if self.request.user.has_perm('billing.change_invoice') else qs.filter(target=self.request.user)
-
-    def dispatch(self, request, *args, **kwargs):
-        # Save invoice and related entries to ensure to have have coherent prices
-        with transaction.atomic():
-            invoice = self.get_object()
-            invoice.save()
-            for entry in invoice.entries.all():
-                entry.save()
-
-        return super().dispatch(request, *args, **kwargs)
-
-    def get_context_data(self, **kwargs):
-        context = super().get_context_data(**kwargs)
-
-        invoice = self.get_object()
-
-        # Init comnpay data
-        form = OrderedDict(
-            montant=f"{invoice.price / 100:.02f}",
-            idTPE=settings.COMNPAY_ID_TPE,
-            idTransaction=f"{invoice.pk:06d}-{settings.COMNPAY_ID_TPE}"
-                          f"-{timezone.now().date()}-{str(timezone.now().time())[:8]}",
-            idCommande=f"{invoice.pk:06d}",
-            devise="EUR",
-            lang=translation.get_language()[:2],
-            nom_produit=", ".join(product.name for product in invoice.products.all()),
-            urlRetourOK=f"{self.request.scheme}://{self.request.site.domain}{reverse_lazy('billing:comnpay_success')}",
-            urlRetourNOK=f"{self.request.scheme}://{self.request.site.domain}{reverse_lazy('billing:comnpay_fail')}",
-            urlIPN=f"https://{Site.objects.first().domain}{reverse_lazy('billing:comnpay_ipn')}",
-            typeTr="D",
-        )
-        # Calculate payment signature
-        form['key'] = settings.COMNPAY_SECRET_KEY
-        str_with_key = base64.b64encode('|'.join(form.values()).encode('UTF-8'))
-        form['sec'] = hashlib.sha512(str_with_key).hexdigest()
-        del form['key']
-
-        context['form'] = form
-        context['comnpay_url'] = settings.COMNPAY_URL
-
-        return context
-
-
-@method_decorator(csrf_exempt, name='dispatch')
-class IPNComnpayView(View):
-    """
-    This view is accessed by the Comnpay servers to validate the payment.
-    It makes a POST request, of course without any CSRF token, with the
-    data of the payment.
-    We check if the data are valid.
-    In case of an error, it raises a Bad Request (400).
-    If an invoice was found and successfully validated, no content is returned (204).
-    If the invoice was already valid, then nothing is processed and a 302 error is returned.
-    """
-
-    @transaction.atomic
-    def post(self, request, *args, **kwargs):
-        data = OrderedDict(request.POST)
-
-        if 'result' not in data or 'sec' not in data:
-            return HttpResponseBadRequest()
-
-        result = data['result']
-
-        given_sec = data['sec'].lower()
-        del data['sec']
-
-        # Calculate expected signature
-        data['key'] = settings.COMNPAY_SECRET_KEY
-        str_with_key = base64.b64encode('|'.join(data.values()).encode('UTF-8'))
-        sec = hashlib.sha512(str_with_key).hexdigest()
-        del data['key']
-
-        # Check that signature is valid
-        if sec != given_sec or result != 'OK':
-            return HttpResponseBadRequest()
-
-        # Query concerned invoice
-        transaction_id = data['idTransaction']
-        try:
-            invoice_id = int(transaction_id.split('-')[0])
-        except ValueError:
-            return HttpResponseBadRequest()
-
-        if not Invoice.objects.filter(pk=invoice_id).exists():
-            return HttpResponseBadRequest()
-
-        invoice = Invoice.objects.get(pk=invoice_id)
-
-        if invoice.valid:
-            # Invoice is already valid, don't modify anything
-            return HttpResponse(status=304)
-
-        # Finally validate invoice
-        invoice.valid = True
-        invoice.save()
-
-        return HttpResponse(status=204)
-
-
-@method_decorator(csrf_exempt, name='dispatch')
-class ComnpaySuccessView(LoginRequiredMixin, RedirectView):
-    """
-    This view is used when Comnpay is redirecting the user to the main site
-    when the payment is successful.
-    We only redirect the user to the main page with a success message.
-    """
-
-    def dispatch(self, request, *args, **kwargs):
-        messages.success(request, _("Successful payment!"))
-        return super().dispatch(request, *args, **kwargs)
-
-    def get_redirect_url(self, *args, **kwargs):
-        return reverse_lazy('index')
-
-
-@method_decorator(csrf_exempt, name='dispatch')
-class ComnpayFailView(LoginRequiredMixin, RedirectView):
-    """
-    This view is used when Comnpay is redirecting the user to the main site
-    when the payment is unsuccessful.
-    We only redirect the user to the main page with a failure message.
-    """
-
-    def dispatch(self, request, *args, **kwargs):
-        messages.error(request, _("Payment failed:") + " "
-                       + request.POST['codeReponse'] + ": " + request.POST['reason'])
-        return super().dispatch(request, *args, **kwargs)
-
-    def get_redirect_url(self, *args, **kwargs):
-        return reverse_lazy('index')
-
-
-# STRIPE
-
-
-class RedirectStripeView(LoginRequiredMixin, DetailView):
-    """
-    This view is used after the creation of an invoice.
-    When someone purchases something and wants to pay with Stripe,
-    it is automatically redirected here.
-
-    A checkout is created on Stripe, and then the user is automatically
-    redirected on this checkout.
-    """
-    model = Invoice
-    # Don't pay validated invoices
-    queryset = Invoice.objects.filter(valid=False, payment_method__stripepaymentmethod__isnull=False)
-    template_name = 'billing/redirect_stripe.html'
-
-    def get_queryset(self):
-        # Superusers and treasurers can access to all invoices,
-        # normal users can only manage their own invoices
-        qs = super().get_queryset()
-        return qs if self.request.user.has_perm('billing.change_invoice') else qs.filter(target=self.request.user)
-
-    def dispatch(self, request, *args, **kwargs):
-        # Save invoice and related entries to ensure to have have coherent prices
-        with transaction.atomic():
-            invoice = self.get_object()
-            invoice.save()
-            for entry in invoice.entries.all():
-                entry.save()
-
-        return super().dispatch(request, *args, **kwargs)
-
-    def get_context_data(self, **kwargs):
-        """
-        Build a request to start the negociation with Stripe by using
-        an invoice id, the price and the secret transaction data stored in
-        the preferences.
-        """
-        context = super().get_context_data(**kwargs)
-
-        invoice = self.get_object()
-        host = self.request.get_host()
-
-        data = dict(
-            client_reference_id=str(invoice.id),
-            customer_email=invoice.target.email,
-            payment_method_types=['card'],
-            line_items=[
-                {
-                    'price_data': {
-                        'currency': 'eur',
-                        'unit_amount': entry.product.price,
-                        'product_data': {
-                            'name': entry.product.name,
-                            'images': ['https://www.crans.org/images/crans_black.svg'],
-                        },
-                    },
-                    'quantity': entry.number,
-                }
-                for entry in invoice.entries.all()
-            ],
-            mode='payment',
-            success_url=self.request.scheme + "://" + host + reverse("billing:stripe_success")
-                        + "?session_id={CHECKOUT_SESSION_ID}",
-            cancel_url=self.request.scheme + "://" + host + reverse("billing:stripe_fail"),
-        )
-
-        api_key = settings.STRIPE_PRIVATE_KEY
-        response = requests.post("https://api.stripe.com/v1/checkout/sessions",
-                                 data=RedirectStripeView.json_to_urlencode(data),
-                                 headers={'Authorization': f'Bearer {api_key}'})
-
-        context['session'] = response.json()
-        context['api_key'] = settings.STRIPE_PUBLIC_KEY
-
-        return context
-
-    @staticmethod
-    def json_to_urlencode(value, key=None):
-        def quot(v, **kargs):
-            if v is None:
-                return ''
-            return quote_plus('{!s}'.format(v), **kargs)
-
-        def enc(k, v):
-            return '{}={}'.format(quot(k, safe='[]'), quot(v))
-
-        if isinstance(value, dict):
-            iterator = value.items()
-        elif isinstance(value, list):
-            iterator = enumerate(value)
-        elif key is None:
-            # top level object was neither list nor dictionary
-            raise TypeError('Only lists and dictionaries supported')
-        else:
-            return enc(key, value)
-
-        res_l = []
-        for k, v in iterator:
-            if key is None:
-                this_key = k
-            else:
-                 this_key = '{}[{}]'.format(key, k)
-            res_l.append(RedirectStripeView.json_to_urlencode(v, key=this_key))
-        return '&'.join(res_l)
-
-
-@method_decorator(csrf_exempt, name='dispatch')
-class StripeSuccessView(LoginRequiredMixin, RedirectView):
-    """
-    This view is used when Stripe is redirecting the user to the main site
-    when the payment is successful.
-    The invoice is checked, then if the payment was successful, we validate it.
-    """
-
-    def dispatch(self, request, *args, **kwargs):
-        api_key = settings.STRIPE_PRIVATE_KEY
-
-        if 'session_id' not in request.GET:
-            # No session ID was given
-            return redirect(reverse("billing:stripe_fail"))
-
-        session_id = request.GET['session_id']
-        if not re.match('^[A-Za-z0-9_]+$', session_id):
-            # Prevent illegal accesses to Stripe API
-            messages.error(request, _("Invalid session ID. Please don't try to hack Stripe."))
-            return redirect(reverse("billing:stripe_fail"))
-
-        session = requests.get(f'https://api.stripe.com/v1/checkout/sessions/{session_id}',
-                               headers={'Authorization': f'Bearer {api_key}'}).json()
-
-        if 'payment_status' not in session:
-            # Invalid session ID
-            messages.error(request, _("Unknown payment."))
-            return redirect(reverse("billing:stripe_fail"))
-
-        if session['payment_status'].lower() != 'paid':
-            # Payment must be valid
-            return redirect(reverse("billing:stripe_fail"))
-
-        invoice_id = session['client_reference_id']
-
-        invoice = Invoice.objects.get(pk=invoice_id)
-        invoice.valid = True
-        invoice.save()
-
-        messages.success(request, _("Successful payment!"))
-
-        return super().dispatch(request, *args, **kwargs)
-
-    def get_redirect_url(self, *args, **kwargs):
-        return reverse_lazy('index')
-
-
-@method_decorator(csrf_exempt, name='dispatch')
-class StripeFailView(LoginRequiredMixin, RedirectView):
-    """
-    This view is used when Stripe is redirecting the user to the main site
-    when the payment is unsuccessful.
-    We only redirect the user to the main page with a failure message.
-    """
-
-    def dispatch(self, request, *args, **kwargs):
-        messages.error(request, _("The payment was refused."))
-        return super().dispatch(request, *args, **kwargs)
-
-    def get_redirect_url(self, *args, **kwargs):
-        return reverse_lazy('index')
diff --git a/billing/views/__init__.py b/billing/views/__init__.py
new file mode 100644
index 0000000..623dffe
--- /dev/null
+++ b/billing/views/__init__.py
@@ -0,0 +1,9 @@
+from .comnpay import ComnpayFailView, ComnpaySuccessView, IPNComnpayView, RedirectComnpayView
+from .purchase import InvoiceListView, PurchaseView, RenderInvoiceView
+from .stripe import StripeFailView, StripeSuccessView, RedirectStripeView
+
+__all__ = [
+    "ComnpayFailView", "ComnpaySuccessView", "InvoiceListView", "IPNComnpayView",
+    "PurchaseView", "RedirectComnpayView", "RedirectStripeView", "RenderInvoiceView",
+    "StripeFailView", "StripeSuccessView",
+]
diff --git a/billing/views/comnpay.py b/billing/views/comnpay.py
new file mode 100644
index 0000000..c1a29b5
--- /dev/null
+++ b/billing/views/comnpay.py
@@ -0,0 +1,175 @@
+import base64
+import hashlib
+from collections import OrderedDict
+
+from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.sites.models import Site
+from django.db import transaction
+from django.http import HttpResponse, HttpResponseBadRequest
+from django.urls import reverse_lazy
+from django.utils import timezone, translation
+from django.utils.decorators import method_decorator
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from django.views.decorators.csrf import csrf_exempt
+from django.views.generic import DetailView, RedirectView
+
+from ..models import Invoice
+
+
+class RedirectComnpayView(LoginRequiredMixin, DetailView):
+    """
+    This view is used after the creation of an invoice.
+    When someone purchases something and wants to pay with Comnpay,
+    it is automatically redirected here.
+
+    Information about Comnpay payment are provided, like the VAD number,
+    redirect URLs or payment signature. This creates a hidden form, and
+    the user automatically makes a POST request to Comnpay with valid information.
+
+    3 URL are given: a success payment URL, a failure URL and an URL that Comnpay will
+    use to validate the payment.
+    """
+    model = Invoice
+    # Don't pay validated invoices
+    queryset = Invoice.objects.filter(valid=False, payment_method__comnpaypaymentmethod__isnull=False)
+    template_name = 'billing/redirect_comnpay.html'
+
+    def get_queryset(self):
+        # Superusers and treasurers can access to all invoices,
+        # normal users can only manage their own invoices
+        qs = super().get_queryset()
+        return qs if self.request.user.has_perm('billing.change_invoice') else qs.filter(target=self.request.user)
+
+    def dispatch(self, request, *args, **kwargs):
+        # Save invoice and related entries to ensure to have have coherent prices
+        with transaction.atomic():
+            invoice = self.get_object()
+            invoice.save()
+            for entry in invoice.entries.all():
+                entry.save()
+
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+
+        invoice = self.get_object()
+
+        # Init comnpay data
+        form = OrderedDict(
+            montant=f"{invoice.price / 100:.02f}",
+            idTPE=settings.COMNPAY_ID_TPE,
+            idTransaction=f"{invoice.pk:06d}-{settings.COMNPAY_ID_TPE}"
+                          f"-{timezone.now().date()}-{str(timezone.now().time())[:8]}",
+            idCommande=f"{invoice.pk:06d}",
+            devise="EUR",
+            lang=translation.get_language()[:2],
+            nom_produit=", ".join(product.name for product in invoice.products.all()),
+            urlRetourOK=f"{self.request.scheme}://{self.request.site.domain}{reverse_lazy('billing:comnpay_success')}",
+            urlRetourNOK=f"{self.request.scheme}://{self.request.site.domain}{reverse_lazy('billing:comnpay_fail')}",
+            urlIPN=f"https://{Site.objects.first().domain}{reverse_lazy('billing:comnpay_ipn')}",
+            typeTr="D",
+        )
+        # Calculate payment signature
+        form['key'] = settings.COMNPAY_SECRET_KEY
+        str_with_key = base64.b64encode('|'.join(form.values()).encode('UTF-8'))
+        form['sec'] = hashlib.sha512(str_with_key).hexdigest()
+        del form['key']
+
+        context['form'] = form
+        context['comnpay_url'] = settings.COMNPAY_URL
+
+        return context
+
+
+@method_decorator(csrf_exempt, name='dispatch')
+class IPNComnpayView(View):
+    """
+    This view is accessed by the Comnpay servers to validate the payment.
+    It makes a POST request, of course without any CSRF token, with the
+    data of the payment.
+    We check if the data are valid.
+    In case of an error, it raises a Bad Request (400).
+    If an invoice was found and successfully validated, no content is returned (204).
+    If the invoice was already valid, then nothing is processed and a 302 error is returned.
+    """
+
+    @transaction.atomic
+    def post(self, request, *args, **kwargs):
+        data = OrderedDict(request.POST)
+
+        if 'result' not in data or 'sec' not in data:
+            return HttpResponseBadRequest()
+
+        result = data['result']
+
+        given_sec = data['sec'].lower()
+        del data['sec']
+
+        # Calculate expected signature
+        data['key'] = settings.COMNPAY_SECRET_KEY
+        str_with_key = base64.b64encode('|'.join(data.values()).encode('UTF-8'))
+        sec = hashlib.sha512(str_with_key).hexdigest()
+        del data['key']
+
+        # Check that signature is valid
+        if sec != given_sec or result != 'OK':
+            return HttpResponseBadRequest()
+
+        # Query concerned invoice
+        transaction_id = data['idTransaction']
+        try:
+            invoice_id = int(transaction_id.split('-')[0])
+        except ValueError:
+            return HttpResponseBadRequest()
+
+        if not Invoice.objects.filter(pk=invoice_id).exists():
+            return HttpResponseBadRequest()
+
+        invoice = Invoice.objects.get(pk=invoice_id)
+
+        if invoice.valid:
+            # Invoice is already valid, don't modify anything
+            return HttpResponse(status=304)
+
+        # Finally validate invoice
+        invoice.valid = True
+        invoice.save()
+
+        return HttpResponse(status=204)
+
+
+@method_decorator(csrf_exempt, name='dispatch')
+class ComnpaySuccessView(LoginRequiredMixin, RedirectView):
+    """
+    This view is used when Comnpay is redirecting the user to the main site
+    when the payment is successful.
+    We only redirect the user to the main page with a success message.
+    """
+
+    def dispatch(self, request, *args, **kwargs):
+        messages.success(request, _("Successful payment!"))
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_redirect_url(self, *args, **kwargs):
+        return reverse_lazy('index')
+
+
+@method_decorator(csrf_exempt, name='dispatch')
+class ComnpayFailView(LoginRequiredMixin, RedirectView):
+    """
+    This view is used when Comnpay is redirecting the user to the main site
+    when the payment is unsuccessful.
+    We only redirect the user to the main page with a failure message.
+    """
+
+    def dispatch(self, request, *args, **kwargs):
+        messages.error(request, _("Payment failed:") + " "
+                       + request.POST['codeReponse'] + ": " + request.POST['reason'])
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_redirect_url(self, *args, **kwargs):
+        return reverse_lazy('index')
diff --git a/billing/views/purchase.py b/billing/views/purchase.py
new file mode 100644
index 0000000..a78c289
--- /dev/null
+++ b/billing/views/purchase.py
@@ -0,0 +1,110 @@
+from crispy_forms.helper import FormHelper
+from crispy_forms.layout import Button, Submit
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.auth.models import User
+from django.core.exceptions import PermissionDenied
+from django.db import transaction
+from django.db.models import Q
+from django.forms import modelformset_factory
+from django.http import Http404, HttpResponse
+from django.utils.translation import gettext_lazy as _
+from django.views.generic import CreateView, DetailView
+from django_tables2 import SingleTableView
+
+from .. import forms
+from ..models import Entry, Invoice, PaymentMethod, Product
+from ..tables import InvoiceTable
+
+
+class PurchaseView(LoginRequiredMixin, CreateView):
+    model = Invoice
+    form_class = forms.CreateInvoiceForm
+    template_name = 'billing/purchase.html'
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context['formset'] = self.get_formset_class()(data=self.request.POST or None, queryset=Entry.objects.none())
+        helper = FormHelper()
+        helper.template = 'bootstrap5/table_inline_formset.html'
+        helper.form_tag = False
+        context['helper'] = helper
+        helper.add_input(Button("add-product", _("Add product")))
+        helper.add_input(Submit("submit", _("Buy")))
+        context['user_object'] = context['form'].instance.target
+        return context
+
+    def get_form(self, form_class=None):
+        form = super().get_form(form_class)
+        if not self.request.user.has_perm('billing.view_paymentmethod'):
+            form.fields['payment_method'].queryset = PaymentMethod.objects.filter(visible=True)
+
+        user_qs = User.objects.filter(pk=self.kwargs['pk'])
+        if not self.request.user.has_perm('billing.add_invoice'):
+            user_qs = user_qs.filter(pk=self.request.user.pk)
+        if not user_qs.exists():
+            raise Http404
+        form.instance.target = user_qs.get()
+
+        return form
+
+    def get_formset_class(self):
+        # Restrict visible products to buyable products
+        payer_type = 'per' if self.request.user.profile.is_person else 'org'
+        member_privilege = (payer_type == 'per' and self.request.user.profile.has_current_membership)
+
+        class RestrictedEntryForm(forms.EntryForm):
+            def __init__(self, *args, **kwargs):
+                super().__init__(*args, **kwargs)
+                allowed = Q(payer_type='all') | Q(payer_type=payer_type)
+                if member_privilege:
+                    allowed |= Q(payer_type='mem')  # valid membership unlocks member-restricted products
+                self.fields['product'].queryset = Product.objects.filter(allowed)
+
+        return modelformset_factory(Entry, RestrictedEntryForm, extra=0, min_num=1, validate_min=True)
+
+    @transaction.atomic
+    def form_valid(self, form):
+        invoice = form.instance
+
+        formset = self.get_formset_class()(data=self.request.POST, queryset=Entry.objects.none())
+        if not formset.is_valid():
+            return self.form_invalid(form)
+
+        invoice.save()
+
+        empty = True
+        for product_form in formset.forms:
+            if product_form.instance.number > 0:
+                product_form.instance.invoice = invoice
+                empty = False
+        if empty:
+            return self.form_invalid(form)
+
+        formset.save()
+
+        return super().form_valid(form)
+
+    def get_success_url(self):
+        return self.object.payment_method.get_payment_url(self.object)
+
+
+class InvoiceListView(LoginRequiredMixin, SingleTableView):
+    model = Invoice
+    table_class = InvoiceTable
+    template_name = 'billing/invoice_list.html'
+
+    def get_queryset(self):
+        # filter only for current user
+        return super().get_queryset().filter(target_id=self.kwargs["pk"])
+
+
+class RenderInvoiceView(LoginRequiredMixin, DetailView):
+    model = Invoice
+    template_name = 'billing/invoice_template.html'
+
+    def get(self, request, *args, **kwargs):
+        if request.user != self.get_object().target and not request.user.has_perm('billing.view_invoice'):
+            raise PermissionDenied
+        if self.get_object().valid and self.get_object().html:
+            return HttpResponse(content=self.get_object().html)
+        return super().get(request, *args, **kwargs)
\ No newline at end of file
diff --git a/billing/views/stripe.py b/billing/views/stripe.py
new file mode 100644
index 0000000..1c9e0fd
--- /dev/null
+++ b/billing/views/stripe.py
@@ -0,0 +1,184 @@
+import re
+from urllib.parse import quote_plus
+
+import requests
+from django.conf import settings
+from django.contrib import messages
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.db import transaction
+from django.shortcuts import redirect
+from django.urls import reverse_lazy, reverse
+from django.utils.decorators import method_decorator
+from django.utils.translation import gettext_lazy as _
+from django.views.decorators.csrf import csrf_exempt
+from django.views.generic import RedirectView, DetailView
+
+from ..models import Invoice
+
+
+class RedirectStripeView(LoginRequiredMixin, DetailView):
+    """
+    This view is used after the creation of an invoice.
+    When someone purchases something and wants to pay with Stripe,
+    it is automatically redirected here.
+
+    A checkout is created on Stripe, and then the user is automatically
+    redirected on this checkout.
+    """
+    model = Invoice
+    # Don't pay validated invoices
+    queryset = Invoice.objects.filter(valid=False, payment_method__stripepaymentmethod__isnull=False)
+    template_name = 'billing/redirect_stripe.html'
+
+    def get_queryset(self):
+        # Superusers and treasurers can access to all invoices,
+        # normal users can only manage their own invoices
+        qs = super().get_queryset()
+        return qs if self.request.user.has_perm('billing.change_invoice') else qs.filter(target=self.request.user)
+
+    def dispatch(self, request, *args, **kwargs):
+        # Save invoice and related entries to ensure to have have coherent prices
+        with transaction.atomic():
+            invoice = self.get_object()
+            invoice.save()
+            for entry in invoice.entries.all():
+                entry.save()
+
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_context_data(self, **kwargs):
+        """
+        Build a request to start the negociation with Stripe by using
+        an invoice id, the price and the secret transaction data stored in
+        the preferences.
+        """
+        context = super().get_context_data(**kwargs)
+
+        invoice = self.get_object()
+        host = self.request.get_host()
+
+        data = dict(
+            client_reference_id=str(invoice.id),
+            customer_email=invoice.target.email,
+            payment_method_types=['card'],
+            line_items=[
+                {
+                    'price_data': {
+                        'currency': 'eur',
+                        'unit_amount': entry.product.price,
+                        'product_data': {
+                            'name': entry.product.name,
+                            'images': ['https://www.crans.org/images/crans_black.svg'],
+                        },
+                    },
+                    'quantity': entry.number,
+                }
+                for entry in invoice.entries.all()
+            ],
+            mode='payment',
+            success_url=self.request.scheme + "://" + host + reverse("billing:stripe_success")
+                        + "?session_id={CHECKOUT_SESSION_ID}",
+            cancel_url=self.request.scheme + "://" + host + reverse("billing:stripe_fail"),
+        )
+
+        api_key = settings.STRIPE_PRIVATE_KEY
+        response = requests.post("https://api.stripe.com/v1/checkout/sessions",
+                                 data=RedirectStripeView.json_to_urlencode(data),
+                                 headers={'Authorization': f'Bearer {api_key}'})
+
+        context['session'] = response.json()
+        context['api_key'] = settings.STRIPE_PUBLIC_KEY
+
+        return context
+
+    @staticmethod
+    def json_to_urlencode(value, key=None):
+        def quot(v, **kargs):
+            if v is None:
+                return ''
+            return quote_plus('{!s}'.format(v), **kargs)
+
+        def enc(k, v):
+            return '{}={}'.format(quot(k, safe='[]'), quot(v))
+
+        if isinstance(value, dict):
+            iterator = value.items()
+        elif isinstance(value, list):
+            iterator = enumerate(value)
+        elif key is None:
+            # top level object was neither list nor dictionary
+            raise TypeError('Only lists and dictionaries supported')
+        else:
+            return enc(key, value)
+
+        res_l = []
+        for k, v in iterator:
+            if key is None:
+                this_key = k
+            else:
+                 this_key = '{}[{}]'.format(key, k)
+            res_l.append(RedirectStripeView.json_to_urlencode(v, key=this_key))
+        return '&'.join(res_l)
+
+
+@method_decorator(csrf_exempt, name='dispatch')
+class StripeSuccessView(LoginRequiredMixin, RedirectView):
+    """
+    This view is used when Stripe is redirecting the user to the main site
+    when the payment is successful.
+    The invoice is checked, then if the payment was successful, we validate it.
+    """
+
+    def dispatch(self, request, *args, **kwargs):
+        api_key = settings.STRIPE_PRIVATE_KEY
+
+        if 'session_id' not in request.GET:
+            # No session ID was given
+            return redirect(reverse("billing:stripe_fail"))
+
+        session_id = request.GET['session_id']
+        if not re.match('^[A-Za-z0-9_]+$', session_id):
+            # Prevent illegal accesses to Stripe API
+            messages.error(request, _("Invalid session ID. Please don't try to hack Stripe."))
+            return redirect(reverse("billing:stripe_fail"))
+
+        session = requests.get(f'https://api.stripe.com/v1/checkout/sessions/{session_id}',
+                               headers={'Authorization': f'Bearer {api_key}'}).json()
+
+        if 'payment_status' not in session:
+            # Invalid session ID
+            messages.error(request, _("Unknown payment."))
+            return redirect(reverse("billing:stripe_fail"))
+
+        if session['payment_status'].lower() != 'paid':
+            # Payment must be valid
+            return redirect(reverse("billing:stripe_fail"))
+
+        invoice_id = session['client_reference_id']
+
+        invoice = Invoice.objects.get(pk=invoice_id)
+        invoice.valid = True
+        invoice.save()
+
+        messages.success(request, _("Successful payment!"))
+
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_redirect_url(self, *args, **kwargs):
+        return reverse_lazy('index')
+
+
+@method_decorator(csrf_exempt, name='dispatch')
+class StripeFailView(LoginRequiredMixin, RedirectView):
+    """
+    This view is used when Stripe is redirecting the user to the main site
+    when the payment is unsuccessful.
+    We only redirect the user to the main page with a failure message.
+    """
+
+    def dispatch(self, request, *args, **kwargs):
+        messages.error(request, _("The payment was refused."))
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_redirect_url(self, *args, **kwargs):
+        return reverse_lazy('index')
-- 
GitLab