conference-website/tickets/tasks.py
Anna Sirota 2d66f8dfee Stripe: ignore all next_action expect bank transfer
All other ticket orders should only be created on checkout_session.completed.
2024-06-06 17:39:55 +02:00

288 lines
10 KiB
Python

"""Tickets tasks, such as sending of emails and requesting of invoices."""
from typing import Dict, Any, List
import logging
from background_task import background
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.conf import settings
from background_task.tasks import TaskSchedule
from emails.util import get_template_context, is_noreply, construct_and_send_email
from .saleor_client import get_order, request_invoice
import tickets.models
import tickets.stripe_utils
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
User = get_user_model()
def _send_mail_to(user: User, email_name: str, **email_context) -> int:
email = user.email
if is_noreply(email):
logger.warning('Not sending %s to a no-reply address %s', email_name, email)
return 0
logger.debug('Sending %s to %s', email_name, email)
context = {
'user': user,
**email_context,
**get_template_context(),
}
return construct_and_send_email(email_name, context, recipient_list=[email])
@background()
def send_mail_bank_transfer_required(
ticket_id: int, email_name: str = 'bank_transfer_required'
) -> int:
"""Send out an email notifying about the required bank transfer payment.
`email_name` can be used to override the email text for
sending other messages about unpaid ticket orders, e.g. via admin actions.
"""
ticket = tickets.models.Ticket.objects.get(pk=ticket_id)
if ticket.is_paid or ticket.is_free:
logger.error(
'Ticket pk=%s is either paid or free, not sending payment instructions',
ticket_id,
)
return False
user = ticket.user
res = _send_mail_to(user, email_name=email_name, ticket=ticket, order=ticket.order)
return res
@background()
def send_mail_confirm_tickets(
ticket_id: int, user_id: int, email_name: str = 'ticket_confirmed'
) -> int:
"""Send an email to a confirmed Conference attendee.
First and foremost, this task is used to automatically confirm paid tickets
via email, however `email_name` can be used to override the email text for
sending other messages to attendees, e.g. via admin actions.
"""
ticket = tickets.models.Ticket.objects.get(pk=ticket_id)
if not ticket.is_claimed_by(user_id=user_id):
logger.error('User pk=%s is not listed in ticket pk=%s claims', user_id, ticket_id)
return 0
claim = ticket.claims.get(user_id=user_id)
if email_name == 'ticket_confirmed' and claim.confirmation_sent_at:
logger.error('Ticket pk=%s confirmation was already sent to user pk=%s', ticket_id, user_id)
return 0
if not ticket.is_paid and not ticket.is_free:
logger.error(
'Ticket pk=%s is neither paid nor free, not sending %s email',
email_name,
ticket_id,
)
return 0
user = User.objects.get(pk=user_id)
res = _send_mail_to(user, email_name=email_name, ticket=ticket, order=ticket.order)
if email_name == 'ticket_confirmed':
claim.confirmation_sent_at = timezone.now()
claim.save(update_fields={'confirmation_sent_at'})
return res
@background()
def send_mail_tickets_paid(ticket_id: int, email_name: str = 'tickets_paid') -> int:
"""Send out an email with instruction about ticket claim link."""
ticket = tickets.models.Ticket.objects.get(pk=ticket_id)
if not ticket.is_paid and not ticket.is_free:
logger.error(
'Ticket pk=%s is neither paid nor free, not sending confirmation for paid tickets',
ticket_id,
)
return 0
if not ticket.unclaimed:
logger.error(
'All tickets pk=%s are claimed, not sending additional confirmation for paid tickets',
ticket_id,
)
return 0
user = ticket.user
res = _send_mail_to(user, email_name=email_name, ticket=ticket, order=ticket.order)
return res
@background()
def send_mail_general_info(ticket_id: int, user_id: int) -> int:
"""Send out an email with general info about Conference ticket to an attendee."""
ticket = tickets.models.Ticket.objects.get(pk=ticket_id)
if not ticket.is_claimed_by(user_id=user_id):
logger.error('User pk=%s is not listed in ticket pk=%s claims', user_id, ticket_id)
return 0
claim = ticket.claims.get(user_id=user_id)
if claim.general_info_sent_at:
logger.error('Ticket pk=%s general info was already sent to user pk=%s', ticket_id, user_id)
return 0
if not ticket.is_paid and not ticket.is_free:
logger.error(
'Ticket pk=%s is neither paid nor free, not sending general info email',
ticket_id,
)
return 0
user = User.objects.get(pk=user_id)
email_name = 'general_information'
res = _send_mail_to(user, email_name=email_name, ticket=ticket, order=ticket.order)
claim.general_info_sent_at = timezone.now()
claim.save(update_fields={'general_info_sent_at'})
return res
@background
def generate_invoice(ticket_id: int) -> Dict[Any, Any]:
if settings.ACTIVE_PAYMENT_BACKEND == 'stripe':
return {}
ticket = tickets.models.Ticket.objects.get(pk=ticket_id)
if not ticket.order_id:
logger.error(
'Unable to request an invoice, ticket pk=%s is missing an order ID',
ticket.pk,
)
return
invoice = request_invoice(order_id=ticket.order_id)
# Save invoice URL to display it in My Tickets
ticket.invoice_url = invoice['url']
ticket.save(update_fields={'invoice_url'})
logger.info('Generated an invoice for ticket pk=%s', ticket_id)
return invoice
def _set_profile_fields_from_saleor_order(ticket, profile):
order = get_order(ticket.order_id)
billing_address = order['billingAddress']
update_fields = []
if not profile.full_name:
profile.full_name = f"{billing_address['firstName']} {billing_address['lastName']}"
update_fields.append('full_name')
if not profile.country:
profile.country = billing_address['country']['code']
update_fields.append('country')
if not profile.company and billing_address['companyName']:
profile.company = billing_address['companyName']
update_fields.append('company')
return update_fields
def _set_profile_fields_from_stripe_order(ticket, profile):
update_fields = []
order = ticket.order
payment_intent = order.payment_intent
charge = payment_intent.latest_charge
update_fields = []
customer_details = order.customer_details
billing_details = charge.billing_details if charge else None
name = address = None
if billing_details:
name = billing_details.name
address = billing_details.address
if not profile.full_name and name:
profile.full_name = name
update_fields.append('full_name')
if not profile.country and address:
profile.country = address['country']
update_fields.append('country')
if not profile.company and customer_details.tax_ids:
profile.company = customer_details.name
update_fields.append('company')
return update_fields
@background()
def update_profile_from_ticket_order(ticket_id: int) -> List[str]:
"""Update profile using billing address from ticket order."""
ticket = tickets.models.Ticket.objects.get(pk=ticket_id)
user = ticket.user
profile = user.profile
update_fields = None
if ticket.is_saleor:
update_fields = _set_profile_fields_from_saleor_order(ticket, profile)
elif ticket.is_stripe:
update_fields = _set_profile_fields_from_stripe_order(ticket, profile)
if update_fields:
profile.save(update_fields=update_fields)
logger.info(
'Updated fields %s of profile user pk=%s based on billing address',
update_fields,
profile.user.pk,
)
return update_fields
@background()
def handle_order_fully_paid(payload: List[Dict[str, Any]]) -> bool:
"""Mark a matching ticket order as paid."""
order_token = payload[0]['token']
ticket = tickets.models.Ticket.objects.filter(order_token=order_token, is_paid=False).first()
if not ticket:
return False
ticket.is_paid = True
ticket.save(update_fields={'is_paid'})
logger.info('Ticket pk=%s is now fully paid', ticket.pk)
# Claim the ticket if only one was bought and user doesn't have other tickets.
ticket.claim_if_paid_for_one()
# Request invoice unless invoice URL is already set
if not ticket.invoice_url:
generate_invoice(ticket_id=ticket.pk)
return True
@background(schedule={'action': TaskSchedule.RESCHEDULE_EXISTING})
def handle_charge_refunded(payload: dict):
"""Handle payload of Stripe's charge.refunded event."""
payload = tickets.stripe_utils.construct_event(payload)
tickets.stripe_utils.process_charge_refunded(payload)
@background(schedule={'action': TaskSchedule.RESCHEDULE_EXISTING})
def handle_checkout_session_completed(payload: dict, **kwargs):
"""Handle payload of Stripe's checkout_session.completed event."""
payload = tickets.stripe_utils.construct_event(payload)
session = payload.get('data', {}).get('object', {})
assert session['object'] == 'checkout.session'
tickets.stripe_utils.process_checkout_session_completed(session['id'])
@background(schedule={'action': TaskSchedule.RESCHEDULE_EXISTING})
def handle_payment_intent_requires_action(payload: dict):
"""Create an unpaid Ticket based onpayment_intent.requires_action event."""
payload = tickets.stripe_utils.construct_event(payload)
payment_intent = payload.get('data', {}).get('object', {})
# We only expect to have to handle next actions for bank transfers
next_action_type = payment_intent['next_action']['type']
if next_action_type not in {'display_bank_transfer_instructions'}:
logger.warning('Ignoring payment intent with next action "%s"', next_action_type)
return
session = tickets.stripe_utils.retrieve_session_for_payment_intent(payment_intent['id'])
tickets.stripe_utils.process_checkout_session_completed(session['id'])