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