431 lines
16 KiB
Python
431 lines
16 KiB
Python
from typing import Dict, Any
|
|
import logging
|
|
import re
|
|
import uuid
|
|
|
|
from django import urls
|
|
from django.conf import settings
|
|
from django.contrib.auth.models import User
|
|
from django.db import models
|
|
|
|
from tickets.saleor_client import get_order_by_token, get_product_variant
|
|
import conference_main.model_mixins as mixins
|
|
import conference_main.util as util
|
|
import tickets.stripe_utils
|
|
import tickets.tasks as tasks
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _get_upload_to_for_invoice(instance, filename):
|
|
return f'invoice/{str(uuid.uuid4())}/{filename}'
|
|
|
|
|
|
class Ticket(mixins.RecordModificationMixin, mixins.CreatedUpdatedMixin, models.Model):
|
|
record_modification_fields = {'is_paid', 'is_free', 'quantity', 'invoice_url'}
|
|
|
|
edition = models.ForeignKey('conference_main.Edition', on_delete=models.CASCADE)
|
|
product = models.ForeignKey('tickets.Product', blank=True, null=True, on_delete=models.CASCADE)
|
|
is_free = models.BooleanField(default=False, null=False, blank=False)
|
|
is_paid = models.BooleanField(
|
|
default=False,
|
|
null=False,
|
|
blank=False,
|
|
help_text=(
|
|
'Indicates if the order has been paid for already or not. '
|
|
' Must be changed manually for order paid via bank transfers.'
|
|
),
|
|
)
|
|
token = models.UUIDField(
|
|
unique=True,
|
|
blank=False,
|
|
default=uuid.uuid4,
|
|
editable=False,
|
|
null=False,
|
|
help_text='Internal token of the ticket. Used for generating ticket claim URL.',
|
|
)
|
|
user = models.ForeignKey(
|
|
User,
|
|
on_delete=models.CASCADE,
|
|
related_name='managed_tickets',
|
|
help_text='Whomever originally bought the ticket(s).',
|
|
)
|
|
attendees = models.ManyToManyField(
|
|
User,
|
|
through='TicketClaim',
|
|
related_name='tickets',
|
|
help_text=(
|
|
'Whomever claimed the available tickets. '
|
|
'Total count of these must not exceed quantity.'
|
|
),
|
|
)
|
|
sku = models.CharField(
|
|
max_length=255,
|
|
null=False,
|
|
blank=False,
|
|
help_text=(
|
|
'SKU of the ticket variant in this ticket order '
|
|
'(e.g. one day ticket "BC221DAY" for one day entry). '
|
|
'For simplicity, a single order can only contain one ticket variant.<br>'
|
|
'This value is displayed to the attendee in My Tickets and emails.'
|
|
),
|
|
)
|
|
order_number = models.CharField(
|
|
blank=True,
|
|
max_length=64,
|
|
null=True,
|
|
help_text=(
|
|
'Customer-facing order number in the payment system.<br>'
|
|
'If available, this value is displayed to the attendee in My Tickets and emails.'
|
|
),
|
|
)
|
|
quantity = models.PositiveSmallIntegerField(
|
|
default=1,
|
|
blank=False,
|
|
null=False,
|
|
help_text='Total number of tickets bought and available to be claimed.',
|
|
)
|
|
order_token = models.UUIDField(
|
|
unique=True,
|
|
blank=True,
|
|
editable=False,
|
|
null=True,
|
|
help_text=(
|
|
'Internal token of the order in the payment system. '
|
|
'Used for retrieving ticket order info shown in My Tickets.'
|
|
),
|
|
)
|
|
order_id = models.CharField(
|
|
unique=True,
|
|
blank=True,
|
|
max_length=64,
|
|
null=True,
|
|
help_text=(
|
|
'Internal ID of the order in the payment system. '
|
|
'Used for generating an admin link to the order in the payment system.'
|
|
),
|
|
)
|
|
invoice_url = models.URLField(
|
|
unique=True,
|
|
blank=True,
|
|
null=True,
|
|
help_text='URL of the invoice, if one was automatically generated by the payment system.',
|
|
)
|
|
invoice = models.FileField(
|
|
upload_to=_get_upload_to_for_invoice,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Invoice PDF in case it had to be created manually.',
|
|
)
|
|
|
|
checkout_data = models.JSONField(
|
|
blank=True,
|
|
null=True,
|
|
help_text=(
|
|
'Contains order or checkout data as returned by the payment system. '
|
|
'Used for displaying ticket order info, can change in case of refund and bank transfer.'
|
|
),
|
|
)
|
|
|
|
def __str__(self) -> str:
|
|
paid = self.is_free and '🆓' or self.is_paid and '(paid)' or '(pending payment)'
|
|
order = f', order #{self.order_number} ' if self.order_number else ''
|
|
return f'Ticket {self.sku}{order}{paid}'
|
|
|
|
class Meta:
|
|
indexes = [
|
|
models.Index(fields=['token']),
|
|
models.Index(fields=['edition', 'is_free']),
|
|
]
|
|
ordering = ['-created_at']
|
|
|
|
def get_absolute_url(self):
|
|
return urls.reverse('tickets:detail', kwargs={'ticket_token': self.token})
|
|
|
|
@property
|
|
def is_saleor(self) -> bool:
|
|
"""If it has a token and doesn't look like Stripe's, this must've been paid via Saleor."""
|
|
return self.order_token and not self.is_stripe
|
|
|
|
@property
|
|
def is_stripe(self) -> bool:
|
|
"""If order ID looks like Payment Intent ID, this must've been paid via Stripe."""
|
|
return self.order_id and self.order_id.startswith('pi_')
|
|
|
|
def get_admin_order_url(self) -> str:
|
|
if not self.order_id:
|
|
return ''
|
|
if self.is_stripe:
|
|
prefix = 'test' in settings.STRIPE_API_KEY and 'test/' or ''
|
|
return f'https://dashboard.stripe.com/{prefix}payments/{self.order_id}'
|
|
base_url = settings.SALEOR_API_URL.replace('/graphql/', '')
|
|
return f'{base_url}/dashboard/orders/{self.order_id}'
|
|
|
|
@property
|
|
def unclaimed(self):
|
|
return self.quantity - self.attendees.count()
|
|
|
|
def is_claimed_by(self, user_id: int) -> bool:
|
|
return self.attendees.filter(pk=user_id).exists()
|
|
|
|
@property
|
|
def invoice_download_url(self) -> str:
|
|
"""Link to invoice PDF."""
|
|
if self.invoice:
|
|
return self.invoice.url
|
|
return self.invoice_url or ''
|
|
|
|
@property
|
|
def claim_url(self) -> str:
|
|
"""Link that attendees can use to claim a ticket.
|
|
|
|
Only usable after ticket order is paid.
|
|
"""
|
|
if not self.unclaimed:
|
|
return ''
|
|
return urls.reverse('tickets:claim', kwargs={'ticket_token': self.token})
|
|
|
|
def clean(self):
|
|
super().clean()
|
|
# TODO(anna): either is_free or order_* fields must be set.
|
|
|
|
def save(self, *args: Any, **kwargs: Any) -> None:
|
|
"""Record state changes and act on some of them."""
|
|
self.full_clean()
|
|
was_modified, old_state = self.pre_save_record(*args, **kwargs)
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
if 'is_paid' in old_state and old_state['is_paid'] != self.is_paid:
|
|
if self.is_paid:
|
|
tasks.send_mail_tickets_paid(ticket_id=self.pk)
|
|
for claim in self.claims.all():
|
|
if not claim.confirmation_sent_at:
|
|
tasks.send_mail_confirm_tickets(ticket_id=self.pk, user_id=claim.user_id)
|
|
# Also send out general info
|
|
if not claim.general_info_sent_at:
|
|
tasks.send_mail_general_info(ticket_id=self.pk, user_id=claim.user_id)
|
|
|
|
for field in self.record_modification_fields:
|
|
if field in old_state and old_state[field] != getattr(self, field):
|
|
message = f'"{field}" changed from "{old_state[field]}" to "{getattr(self, field)}"'
|
|
util.attach_log_entry(self, message)
|
|
|
|
@property
|
|
def order(self) -> Dict[str, Any]:
|
|
"""Retrieve order from payment system or construct a fake one based on ticket's SKU."""
|
|
if self.is_free:
|
|
return {
|
|
'lines': [
|
|
{
|
|
'variant': {
|
|
'name': '-',
|
|
'product': {
|
|
'name': self.sku,
|
|
},
|
|
},
|
|
}
|
|
]
|
|
}
|
|
if self.is_stripe:
|
|
# Use saved checkout session data, if available
|
|
if self.checkout_data:
|
|
return tickets.stripe_utils._construct_checkout_session(self.checkout_data)
|
|
# Otherwise fetch checkout session data from Stripe API
|
|
checkout_data = tickets.stripe_utils.retrieve_order(self.order_id, self.sku)
|
|
return checkout_data
|
|
if self.is_saleor:
|
|
return get_order_by_token(self.order_token)
|
|
|
|
# Neither order token nor order ID is set, need to return a stub product
|
|
# (e.g. to display bank transfer tickets with are handled manually)
|
|
if settings.ACTIVE_PAYMENT_BACKEND == 'saleor':
|
|
return {'lines': [{'variant': get_product_variant('default-channel', self.sku)}]}
|
|
if settings.ACTIVE_PAYMENT_BACKEND == 'stripe':
|
|
return 'TODO STRIPE STUB PRODUCT'
|
|
|
|
@property
|
|
def refund_status(self) -> str:
|
|
if self.is_stripe:
|
|
order = self.order
|
|
charge = order.payment_intent.latest_charge
|
|
if not charge:
|
|
return ''
|
|
amount_refunded = charge.amount_refunded
|
|
amount = charge.amount
|
|
if amount_refunded > 0:
|
|
if amount_refunded == amount:
|
|
return 'full'
|
|
return 'partial'
|
|
return ''
|
|
|
|
def get_tax_details(self, session) -> dict:
|
|
"""Return tax breakdown for given Stripe's checkout session.
|
|
|
|
Session is passed here because it might come from the API, or from saved `.checkout_data`.
|
|
"""
|
|
if not self.product or not self.product.taxes:
|
|
return {}
|
|
|
|
# Will contain tax per line item and tax rate, in the same order as line items
|
|
# (for use in the invoice template)
|
|
per_line_item = []
|
|
# Will contain summed tax totals per tax rate
|
|
tax_totals = [{**tax, 'amount_total': 0} for tax in self.product.taxes]
|
|
for item in session.line_items:
|
|
for i in range(len(self.product.taxes)):
|
|
tax = self.product.taxes[i]
|
|
item_amount_total = tax['amount'] * item.quantity
|
|
per_line_item.append({**tax, 'amount_total': item_amount_total})
|
|
tax_totals[i]['amount_total'] += item_amount_total
|
|
return {'per_line_item': per_line_item, 'tax_totals': tax_totals}
|
|
|
|
@property
|
|
def should_send_mail_bank_transfer_required(self) -> bool:
|
|
if not self.is_paid and not self.is_free and self.order_id:
|
|
if not self.is_stripe:
|
|
return True
|
|
order = self.order
|
|
# Only send instructions for a Stripe if instructions were provided by Stripe.
|
|
# If instructions weren't provided, it might be another kind of delayed payment.
|
|
return bool(
|
|
order.payment_intent.next_action
|
|
and order.payment_intent.next_action.get('display_bank_transfer_instructions', None)
|
|
)
|
|
return False
|
|
|
|
def process_new_ticket(self):
|
|
"""Send emails, auto-claim newly created ticket, if necessary and so on.
|
|
|
|
Must be explicitly called after new ticket is created,
|
|
otherwise --- no emails, no claiming, no updated profile.
|
|
Signals aren't used for this because when Stripe checkout creates a new ticket record,
|
|
it doesn't yet have all required fields when `post_save` signal fires
|
|
(e.g. `order_number` requires non-null `Ticket.pk` so it's `None` in first `post_save`).
|
|
"""
|
|
# Looks like bank transfer was selected, instructions should be sent.
|
|
if self.should_send_mail_bank_transfer_required:
|
|
tickets.tasks.send_mail_bank_transfer_required(ticket_id=self.pk)
|
|
|
|
# If order is paid, generate the invoice immediately
|
|
if self.is_paid and not self.is_free and self.order_id:
|
|
tasks.generate_invoice(ticket_id=self.pk)
|
|
|
|
# Claim the ticket immediately if only one was bought and user doesn't have other tickets.
|
|
self.claim_if_paid_for_one()
|
|
|
|
# Confirm paid tickets, including the info about claim URL, if more than one was bought.
|
|
if self.is_paid and self.unclaimed:
|
|
tasks.send_mail_tickets_paid(ticket_id=self.pk)
|
|
|
|
# In case profile is not filled in, use billing address to update it.
|
|
tasks.update_profile_from_ticket_order(ticket_id=self.pk)
|
|
|
|
def claim_if_paid_for_one(self):
|
|
"""Claim the ticket if only one was bought and user doesn't have other tickets."""
|
|
user = self.user
|
|
if (
|
|
self.is_paid
|
|
and self.quantity == 1
|
|
and not self.refund_status
|
|
and not user.tickets.filter(edition=self.edition).exists()
|
|
):
|
|
self.attendees.add(user)
|
|
logger.info(
|
|
'Ticket pk=%s is now fully paid and claimed by user pk=%s', self.pk, user.pk
|
|
)
|
|
|
|
|
|
class TicketClaim(mixins.CreatedUpdatedMixin, models.Model):
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
ticket = models.ForeignKey('Ticket', on_delete=models.CASCADE, related_name='claims')
|
|
confirmation_sent_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text='Date when ticket confirmation email was sent to this attendee.',
|
|
)
|
|
general_info_sent_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text='Date when general info email was sent to this attendee.',
|
|
)
|
|
|
|
class Meta:
|
|
unique_together = ('user', 'ticket')
|
|
verbose_name_plural = 'Attendees'
|
|
|
|
@property
|
|
def full_name(self):
|
|
return self.user.profile.full_name
|
|
|
|
@property
|
|
def title(self):
|
|
return self.user.profile.title
|
|
|
|
@property
|
|
def company(self):
|
|
return self.user.profile.company
|
|
|
|
@property
|
|
def country(self):
|
|
return self.user.profile.country
|
|
|
|
@property
|
|
def email(self):
|
|
return self.user.email
|
|
|
|
|
|
class Product(mixins.CreatedUpdatedMixin, models.Model):
|
|
slug = models.CharField(
|
|
max_length=32,
|
|
unique=True,
|
|
help_text=(
|
|
'Part of URL initiating ticket checkout and copied to'
|
|
' `Ticket.sku` field when a ticket is created from this product.'
|
|
' E.g "BC24", "BC241DAY", etc.'
|
|
),
|
|
)
|
|
name = models.CharField(
|
|
max_length=255,
|
|
help_text=(
|
|
'Shown in emails and ticket detail pages for tickets linked to this product.'
|
|
' E.g. "BCON24 Full Ticket", "BCON24 One Day Ticket", etc.'
|
|
),
|
|
)
|
|
is_featured = models.BooleanField(
|
|
default=False, help_text='Show this ticket in the product table.'
|
|
)
|
|
price = models.DecimalField(max_digits=5, decimal_places=2)
|
|
taxes = models.JSONField(
|
|
blank=True,
|
|
null=True,
|
|
help_text=(
|
|
'Breakdown of taxes: only used for display, not for calculating paid totals. '
|
|
"Total price is set on the payment backend's product, "
|
|
'and assumed to always include all taxes.'
|
|
),
|
|
)
|
|
currency = models.CharField(max_length=3, help_text='Currency such as EUR, USD, etc.')
|
|
description = models.TextField(help_text='Description of the product, can be HTML.')
|
|
url = models.URLField(help_text='Product link to Stripe, or similar.')
|
|
|
|
def get_absolute_url(self) -> str:
|
|
return urls.reverse('tickets:stripe-buy', kwargs={'slug': self.slug})
|
|
|
|
@property
|
|
def price_id(self) -> str:
|
|
price_id_match = re.match(r'.*/(price_\w+)', self.url)
|
|
if price_id_match:
|
|
return price_id_match.groups()[0]
|
|
return ''
|
|
|
|
@property
|
|
def price_label(self) -> str:
|
|
label = f'{self.price} {self.currency}'
|
|
if self.currency == 'USD':
|
|
label = f'${label}'
|
|
return label
|
|
|
|
def __str__(self) -> str:
|
|
return f'{self.name} - {self.price_label}'
|