From 1bb5ed93b59f654cd658b0f3e0148b73b0f7b756 Mon Sep 17 00:00:00 2001
From: Yohann D'ANELLO <ynerant@crans.org>
Date: Fri, 18 Jun 2021 18:17:31 +0200
Subject: [PATCH] Implement Stripe payment

---
 billing/fixtures/initial.json                 | 115 -----------
 billing/models/__init__.py                    |   4 +-
 billing/models/payment_methods.py             |   9 +
 .../templates/billing/redirect_stripe.html    |  10 +
 billing/urls.py                               |   5 +
 billing/views.py                              | 184 +++++++++++++++++-
 constellation/settings.py                     |   5 +
 member/templates/member/user_detail.html      |   2 +-
 8 files changed, 213 insertions(+), 121 deletions(-)
 delete mode 100644 billing/fixtures/initial.json
 create mode 100644 billing/templates/billing/redirect_stripe.html

diff --git a/billing/fixtures/initial.json b/billing/fixtures/initial.json
deleted file mode 100644
index a34b4c9..0000000
--- a/billing/fixtures/initial.json
+++ /dev/null
@@ -1,115 +0,0 @@
-[
-{
-  "model": "billing.product",
-  "pk": 1,
-  "fields": {
-    "polymorphic_ctype": [
-      "billing",
-      "membershipproduct"
-    ],
-    "name": "Adh\u00e9sion 1 an",
-    "price": 1000,
-    "payer_type": "per"
-  }
-},
-{
-  "model": "billing.product",
-  "pk": 2,
-  "fields": {
-    "polymorphic_ctype": [
-      "billing",
-      "product"
-    ],
-    "name": "Cl\u00e9 USB",
-    "price": 500,
-    "payer_type": "mem"
-  }
-},
-{
-  "model": "billing.product",
-  "pk": 3,
-  "fields": {
-    "polymorphic_ctype": [
-      "billing",
-      "product"
-    ],
-    "name": "Cr\u00e9dit impression x100",
-    "price": 400,
-    "payer_type": "mem"
-  }
-},
-{
-  "model": "billing.product",
-  "pk": 4,
-  "fields": {
-    "polymorphic_ctype": [
-      "billing",
-      "product"
-    ],
-    "name": "C\u00e2ble Ethernet",
-    "price": 300,
-    "payer_type": "org"
-  }
-},
-{
-  "model": "billing.product",
-  "pk": 5,
-  "fields": {
-    "polymorphic_ctype": [
-      "billing",
-      "product"
-    ],
-    "name": "Sticker x10",
-    "price": 200,
-    "payer_type": "mem"
-  }
-},
-{
-  "model": "billing.membershipproduct",
-  "pk": 1,
-  "fields": {
-    "duration": "366 00:00:00"
-  }
-},
-{
-  "model": "billing.paymentmethod",
-  "pk": 1,
-  "fields": {
-    "polymorphic_ctype": [
-      "billing",
-      "comnpaypaymentmethod"
-    ],
-    "name": "Com'N'Pay (Carte en ligne)",
-    "visible": true
-  }
-},
-{
-  "model": "billing.paymentmethod",
-  "pk": 2,
-  "fields": {
-    "polymorphic_ctype": [
-      "billing",
-      "paymentmethod"
-    ],
-    "name": "Esp\u00e8ces",
-    "visible": true
-  }
-},
-{
-  "model": "billing.paymentmethod",
-  "pk": 3,
-  "fields": {
-    "polymorphic_ctype": [
-      "billing",
-      "paymentmethod"
-    ],
-    "name": "Ch\u00e8que",
-    "visible": true
-  }
-},
-{
-  "model": "billing.comnpaypaymentmethod",
-  "pk": 1,
-  "fields": {}
-}
-]
diff --git a/billing/models/__init__.py b/billing/models/__init__.py
index 8202651..25bc93e 100644
--- a/billing/models/__init__.py
+++ b/billing/models/__init__.py
@@ -1,9 +1,9 @@
 from .invoices import Entry, Invoice, Product
 from .memberships import MembershipProduct, MembershipPurchase
-from .payment_methods import PaymentMethod, ComnpayPaymentMethod
+from .payment_methods import ComnpayPaymentMethod, PaymentMethod, StripePaymentMethod
 
 __all__ = [
     'Entry', 'Invoice', 'Product',
-    'PaymentMethod', 'ComnpayPaymentMethod',
+    'PaymentMethod', 'ComnpayPaymentMethod', 'StripePaymentMethod',
     'MembershipProduct', 'MembershipPurchase',
 ]
diff --git a/billing/models/payment_methods.py b/billing/models/payment_methods.py
index d99cf6b..a0f0f1e 100644
--- a/billing/models/payment_methods.py
+++ b/billing/models/payment_methods.py
@@ -39,3 +39,12 @@ class ComnpayPaymentMethod(PaymentMethod):
     class Meta:
         verbose_name = _("comnpay payment method")
         verbose_name_plural = _("comnpay payment methods")
+
+
+class StripePaymentMethod(PaymentMethod):
+    def get_payment_url(self, invoice):
+        return reverse_lazy('billing:stripe_redirect', args=(invoice.pk,))
+
+    class Meta:
+        verbose_name = _("stripe payment method")
+        verbose_name_plural = _("stripe payment methods")
diff --git a/billing/templates/billing/redirect_stripe.html b/billing/templates/billing/redirect_stripe.html
new file mode 100644
index 0000000..7715edd
--- /dev/null
+++ b/billing/templates/billing/redirect_stripe.html
@@ -0,0 +1,10 @@
+<html>
+    <head>
+        <script src="https://js.stripe.com/v3/"></script>
+    </head>
+
+    <script type="text/javascript">
+        var stripe = Stripe("{{ api_key }}");
+        stripe.redirectToCheckout({ sessionId: '{{ session.id }}' });
+    </script>
+</html>
\ No newline at end of file
diff --git a/billing/urls.py b/billing/urls.py
index c86aaff..9b5a4b5 100644
--- a/billing/urls.py
+++ b/billing/urls.py
@@ -8,8 +8,13 @@ urlpatterns = [
     path("purchase/<int:pk>/", views.PurchaseView.as_view(), name='purchase'),
     path("invoice_list/<int:pk>/", views.InvoiceListView.as_view(), name='invoice_list'),
     path('render-invoice/<int:pk>/', views.RenderInvoiceView.as_view(), name='render_invoice'),
+
     path('comnpay/redirect/<int:pk>/', views.RedirectComnpayView.as_view(), name='comnpay_redirect'),
     path('comnpay/ipn/', views.IPNComnpayView.as_view(), name='comnpay_ipn'),
     path('comnpay/success/', views.ComnpaySuccessView.as_view(), name='comnpay_success'),
     path('comnpay/fail/', views.ComnpayFailView.as_view(), name='comnpay_fail'),
+
+    path('stripe/redirect/<int:pk>/', views.RedirectStripeView.as_view(), name='stripe_redirect'),
+    path('stripe/success/', views.StripeSuccessView.as_view(), name='stripe_success'),
+    path('stripe/fail/', views.StripeFailView.as_view(), name='stripe_fail'),
 ]
diff --git a/billing/views.py b/billing/views.py
index 720e5d5..e6fb779 100644
--- a/billing/views.py
+++ b/billing/views.py
@@ -1,7 +1,10 @@
 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
@@ -14,7 +17,8 @@ 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.urls import reverse_lazy
+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 _
@@ -121,6 +125,9 @@ class RenderInvoiceView(LoginRequiredMixin, DetailView):
         return super().get(request, *args, **kwargs)
 
 
+# COMNPAY
+
+
 class RedirectComnpayView(LoginRequiredMixin, DetailView):
     """
     This view is used after the creation of an invoice.
@@ -245,7 +252,7 @@ class IPNComnpayView(View):
 
 
 @method_decorator(csrf_exempt, name='dispatch')
-class ComnpaySuccessView(RedirectView):
+class ComnpaySuccessView(LoginRequiredMixin, RedirectView):
     """
     This view is used when Comnpay is redirecting the user to the main site
     when the payment is successful.
@@ -261,7 +268,7 @@ class ComnpaySuccessView(RedirectView):
 
 
 @method_decorator(csrf_exempt, name='dispatch')
-class ComnpayFailView(RedirectView):
+class ComnpayFailView(LoginRequiredMixin, RedirectView):
     """
     This view is used when Comnpay is redirecting the user to the main site
     when the payment is unsuccessful.
@@ -275,3 +282,174 @@ class ComnpayFailView(RedirectView):
 
     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/constellation/settings.py b/constellation/settings.py
index 42c2eba..4e1105a 100644
--- a/constellation/settings.py
+++ b/constellation/settings.py
@@ -239,9 +239,14 @@ CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
 CRISPY_TEMPLATE_PACK = "bootstrap5"
 
 # Change in local settings
+# We put these settings in the application settings rather than in database
+# to make easier data dumps from a production server to a development server.
 COMNPAY_ID_TPE = 'CHANGEME'
 COMNPAY_SECRET_KEY = 'CHANGEME'
 
+STRIPE_PUBLIC_KEY = "CHANGEME"
+STRIPE_PRIVATE_KEY = "CHANGEME"
+
 try:
     from .settings_local import *  # noqa: F401, F403&
 except ImportError:
diff --git a/member/templates/member/user_detail.html b/member/templates/member/user_detail.html
index ea4eda3..d40c59d 100644
--- a/member/templates/member/user_detail.html
+++ b/member/templates/member/user_detail.html
@@ -44,7 +44,7 @@
                 <dt class="col-sm-6">{% trans "members"|capfirst %} :</dt>
                 <dd class="col-sm-6">{{ user_object.profile.members.all|join:", " }}</dd>
             {% endif %}
-            {% if not user_object.profile.has_current_membership %}
+            {% if user_object.profile.has_current_membership %}
                 <dt class="col-sm-6">{% trans "valid membership"|capfirst %} :</dt>
                 <dd class="col-sm-6">
                     {{ user_object.profile.current_membership }}
-- 
GitLab