conference-website/tickets/views/checkout.py
Anna Sirota ba96ba9937 Support delayed Stripe payments; Invoice PDFs
The following has changed:

* Products have their own /tickets/book/../ URLs
* Products only show up in product table if featured
* Products should link to Stripe prices instead of payment links
* Products have `taxes` field, which is a list of tax rules:
    * this field is used to display VAT lines in invoices
* Tickets store Stripe checkout session data (to too many API calls)
* Ticket page shows Stripe's product image, if available
* Stripe webhook endpoint created, expecting the following events:
    * `checkout.session.completed`
    * `checkout.session.async_payment_succeeded`
    * `payment_intent.requires_action`
    * `charge.refunded`
* Full refund will un-claim everyone who claimed the affected ticket
* Invoice PDFs:
    * with Stripe's bank transfer instructions
    * refund date and amount
* CSV report supports Stripe-paid tickets
* CSV has new columns: VAT and refund
2024-05-27 22:32:15 +02:00

266 lines
9.9 KiB
Python

from typing import Dict, Any
import logging
from django import urls
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.sites.shortcuts import get_current_site
from django.db.models.aggregates import Sum
from django.http import Http404
from django.shortcuts import redirect
from django.views.generic import FormView, View, ListView
from django.views.generic.detail import SingleObjectMixin
from tickets.saleor_client import (
APIError,
address_validation_rules,
checkout_complete,
checkout_create,
checkout_email_update,
checkout_payment_create,
checkout_query,
get_product_variant,
update_metadata,
)
import tickets.forms
import tickets.models
import tickets.stripe_utils
logger = logging.getLogger(__name__)
class TicketBuyView(FormView):
template_name = 'tickets/buy.html'
form_class = tickets.forms.NewCheckoutForm
def get_initial(self):
"""Add variant ID to the form."""
initial = super().get_initial()
sku = self.request.resolver_match.kwargs['sku']
self.variant = get_product_variant('default-channel', sku)
if not self.variant:
raise Http404()
initial['variant_id'] = self.variant['id']
return initial
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['variant'] = self.variant
site_settings = get_current_site(self.request).settings
context['edition'] = site_settings.current_edition
return context
def form_valid(self, form):
"""Create a checkout based on submitted form data.
Always create a checkout in this view, to make sure
it's not possible to order different types of tickets with a single order.
"""
try:
checkout = checkout_create(
channel=self.variant['channel'],
variant_id=form.cleaned_data['variant_id'],
quantity=form.cleaned_data['quantity'],
)
except APIError as e:
logger.exception(e.message)
for field, errors in e.as_dict.items():
if field in {'lines'}:
field = '__all__'
form.add_error(field, errors)
return super().form_invalid(form)
# Add checkout token to the session
self.request.session['checkout_token'] = checkout['token']
return super().form_valid(form)
@property
def success_url(self):
"""Redirect to the next step of the checkout or back to homepage."""
checkout_token = self.request.session.get('checkout_token')
if not checkout_token:
logger.error('Unable to redirect to checkout: no checkout token')
return urls.reverse('homepage_redirect')
return urls.reverse(
'tickets:checkout', kwargs={'checkout_token': self.request.session['checkout_token']}
)
class StripeTicketBuyView(LoginRequiredMixin, SingleObjectMixin, View):
model = tickets.models.Product
def get(self, request, *args, **kwargs):
"""Redirect to Stripe hosted page with a new checkout session for this product."""
product = self.get_object()
success_url = self.request.build_absolute_uri(urls.reverse('tickets:stripe-done'))
cancel_url = self.request.build_absolute_uri(urls.reverse('tickets:products-table'))
session = tickets.stripe_utils.create_checkout_session(
product=product,
client_reference_id=request.user.pk,
customer_email=request.user.email,
cancel_url=cancel_url,
success_url=success_url,
)
return redirect(session.url)
class CheckoutView(LoginRequiredMixin, FormView):
template_name = 'tickets/checkout.html'
form_class = tickets.forms.CheckoutCompleteForm
def _get_checkout(self) -> Dict[str, Any]:
checkout_token = self.request.session.get('checkout_token')
if not checkout_token:
logger.error('Unable to continue checkout flow: no checkout token')
return None
checkout_token_arg = self.request.resolver_match.kwargs['checkout_token']
if checkout_token != checkout_token_arg:
logger.error(
"URL doesn't match checkout token '%s' in the session",
checkout_token,
)
return None
checkout = checkout_query(token=checkout_token)
if not checkout:
logger.error("Not a valid checkout token: '%s'", checkout_token)
return None
return checkout
def get(self, *args, **kwargs):
self.checkout = self._get_checkout()
if not self.checkout:
return redirect('homepage_redirect')
# Set checkout email so that requests performed before checkout are allowed.
# For example: Adding a discount coupon requires for the email to be set.
if not self.checkout.get('email'):
checkout_email_update(self.checkout['id'], self.request.user.email)
return super().get(*args, **kwargs)
def post(self, *args, **kwargs):
self.checkout = self._get_checkout()
if not self.checkout:
return redirect('homepage_redirect')
return super().post(*args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['checkout'] = self.checkout
site_settings = get_current_site(self.request).settings
context['edition'] = site_settings.current_edition
context['address_rules'] = address_validation_rules
return context
def form_valid(self, form):
"""Finalise the checkout by creating a payment based on submitted form data."""
gateway = form.cleaned_data['gateway']
nonce = form.cleaned_data['payment_method_nonce']
metadata = {}
if gateway == 'braintree':
# Saleor isn't sending deviceData, but we save it in metadata just in case.
metadata = {'device_data': form.cleaned_data['device_data']}
checkout_token = self.checkout['token']
try:
if gateway == 'bank':
# Add metadata denoting that this checkout/order is using bank transfer
update_metadata(token=checkout_token, bank_transfer='Yes')
checkout_payment_create(
token=checkout_token,
gateway=gateway,
nonce=nonce,
**metadata,
)
order = checkout_complete(token=checkout_token)
except APIError as e:
logger.exception(e.message)
other_errors = []
for field, errors in e.as_dict.items():
if field in form.fields:
form.add_error(field, errors)
else:
other_errors.append((field, errors))
if other_errors:
error_message = '<br>'.join(
f'{k if k != "__all__" else ""}: {v}' for k, v in other_errors
)
messages.error(self.request, error_message)
return super().form_invalid(form)
quantity = order['lines'][0]['quantity']
is_paid = order['isPaid']
site_settings = get_current_site(self.request).settings
edition = site_settings.current_edition
sku = order['lines'][0]['variant']['sku']
ticket = tickets.models.Ticket(
edition=edition,
user=self.request.user,
is_paid=is_paid,
sku=sku,
quantity=quantity,
# Store this order's ID to display a link to dashboard in the admin
order_id=order['id'],
# Store this order's token so it could be later retrieved via API
order_token=order['token'],
order_number=order['number'],
)
ticket.save()
self.request.session['ticket_token'] = str(ticket.token)
# Remove checkout token from the session
del self.request.session['checkout_token']
ticket.process_new_ticket()
return super().form_valid(form)
@property
def success_url(self):
ticket_token = self.request.session.get('ticket_token')
if not ticket_token:
logger.error('Unable to finalise checkout flow: no ticket token')
return urls.reverse('tickets:list')
return urls.reverse(
'tickets:detail', kwargs={'ticket_token': self.request.session['ticket_token']}
)
class ProductsTableView(ListView):
model = tickets.models.Product
template_name = 'tickets/products_table.html'
redirect_field_name = 'next'
context_object_name = 'products'
def get_queryset(self):
"""Only show featured ticket products."""
return super().get_queryset().filter(is_featured=True)
def get_sold_tickets_quantity(self):
site_settings = get_current_site(self.request).settings
quantity_total = tickets.models.Ticket.objects.filter(
edition=site_settings.current_edition, is_paid=True
).aggregate(Sum('quantity'))
return quantity_total['quantity__sum'] or 0
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
ctx = super().get_context_data(**kwargs)
tickets_left_count = settings.TICKET_SALES_CAP - self.get_sold_tickets_quantity()
ctx['tickets_left_count'] = tickets_left_count
if tickets_left_count <= 0:
return ctx
if self.request.user.is_authenticated:
ctx['user_email'] = self.request.user.email
ctx['client_reference_id'] = self.request.user.id
return ctx
class StripeCheckoutDone(LoginRequiredMixin, View):
def get(self, request, *args, **kwargs):
session_id = self.request.GET.get('checkout_session_id')
ticket = tickets.stripe_utils.process_checkout_session_completed(session_id)
return redirect('tickets:detail', ticket_token=ticket.token)