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