Anna Sirota
ba96ba9937
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
266 lines
9.9 KiB
Python
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)
|