conference-website/tickets/stripe_utils.py

279 lines
9.8 KiB
Python

from decimal import Decimal
from functools import lru_cache
import logging
import time
from django.conf import settings
from django.contrib.sites.models import Site
from django.db import transaction
import django.http.request
import stripe
from conference_main.util import absolute_url
import tickets.models
stripe.api_key = settings.STRIPE_API_KEY
logger = logging.getLogger(__name__)
SESSION_EXPANDS = [
'customer',
'line_items',
'line_items.data.price',
'line_items.data.price.product',
'payment_intent',
'payment_intent.customer',
'payment_intent.latest_charge',
'payment_intent.latest_charge.refunds',
'payment_link',
]
def _cents_to_decimal(value):
_cents = int(value)
whole = _cents // 100
cents = _cents % 100
return Decimal(f'{whole}.{cents:02}')
def get_ttl_hash(seconds=60):
"""Return the same value withing `seconds` time period"""
return round(time.time() / seconds)
def retrieve_session(session_id: str) -> 'stripe.checkout.Session':
"""Retrieve Stripe checkout session with a given ID."""
return stripe.checkout.Session.retrieve(session_id, expand=SESSION_EXPANDS)
def list_sessions(payment_intent_id: str) -> list:
"""List checkout sessions linked to given payment intent ID."""
return stripe.checkout.Session.list(payment_intent=payment_intent_id).data
def retrieve_session_for_payment_intent(payment_intent_id: str) -> 'stripe.checkout.Session':
"""Retrieve Stripe checkout session for a given PaymentIntent ID.
Make 2 API requests: it first lists checkout session for this PaymentIntent ID
to get CheckoutSession's ID, then retrieves the CheckoutSession itself.
This is done in order to get line item's product expanded, otherwise it doesn't fit into
the "no more than 4 levels deep" restriction.
"""
session = list_sessions(payment_intent_id)[0]
return retrieve_session(session['id'])
@lru_cache(maxsize=10)
def retrieve_product(name: str, ttl_hash=None) -> 'stripe.Product':
"""Retrieve a first Stripe product with a matching name."""
products = stripe.Product.list()
return next((_ for _ in products.data if _.name == name), None)
def retrieve_order(payment_intent_id: str, name: str) -> dict:
"""Construct a dict with enough info to both show at ticket details page and on an invoice."""
session = retrieve_session_for_payment_intent(payment_intent_id)
return _construct_checkout_session(session)
def _construct_checkout_session(session: dict):
assert session
if not isinstance(session, stripe.checkout.Session):
session = stripe.checkout.Session.construct_from(session, '')
return session
def format_payment_method(payment_method: dict) -> str:
"""Format a payment method from `payment_method_details` received from Stipe API."""
_type = payment_method['type']
data = payment_method[_type]
if _type == 'card':
return f'{data["brand"].upper()} credit card ending in {data["last4"]}'
if _type == 'paypal':
return 'PayPal account {payer_email}'.format(**data)
if _type == 'customer_balance':
return 'Bank transfer'
if _type in {'ideal', 'giropay', 'sofort', 'sepa_debit'}:
bic = data.get('bic') or ''
last4 = data.get('last4', data.get('iban_last4')) or ''
_title = bic
if last4:
_title += f'{"*" * 10}{last4}'
if _type == 'ideal':
_title += ' via iDEAL'
elif _type == 'sepa_debit':
_title += ' via SEPA Direct Debit'
else:
_title += f' via {_type.upper()}'
return _title
return _type.upper()
def construct_event_from_request(
request: 'django.http.request.HttpRequest',
) -> 'stripe.api_resources.Event':
"""Verify webhook payload and return an Event object constructed from request body."""
payload = request.body
sig_header = request.META['HTTP_STRIPE_SIGNATURE']
return stripe.Webhook.construct_event(payload, sig_header, settings.STRIPE_ENDPOINT_SECRET)
def construct_event(payload) -> 'stripe.api_resources.Event':
"""
Return Event object constructed from given payload.
When handling webhook events must only be used after request signature was verified.
"""
return stripe.Event.construct_from(payload, settings.STRIPE_ENDPOINT_SECRET)
def _update_checkout_data(ticket, checkout_session):
ticket.checkout_data = checkout_session
ticket.save(update_fields={'checkout_data'})
def _upsert_ticket_from_payment_intent(user_id, sku, quantity, payment_intent, product=None):
is_new = False
is_paid = payment_intent['status'] == 'succeeded'
try:
ticket = tickets.models.Ticket.objects.get(order_id=payment_intent['id'])
except tickets.models.Ticket.DoesNotExist:
ticket = tickets.models.Ticket(
edition=Site.objects.get_current().settings.current_edition,
# Must be created paid if the payment was immediate,
# otherwise updates triggered by other webhook events might cause duplicate emails
is_paid=is_paid,
order_id=payment_intent['id'],
product=product,
quantity=quantity,
sku=product and product.slug or sku,
user_id=user_id,
)
ticket.save()
is_new = True
update_fields = set()
if not ticket.product and product:
ticket.product = product
update_fields.add('product')
if not ticket.order_number:
ticket.order_number = f'ST-{ticket.id}'
update_fields.add('order_number')
if not ticket.invoice_url:
invoice_url = absolute_url('tickets:invoice-pdf', kwargs={'ticket_token': ticket.token})
ticket.invoice_url = invoice_url
update_fields.add('invoice_url')
if not ticket.is_paid and is_paid:
ticket.is_paid = True
update_fields.add('is_paid')
if update_fields:
ticket.save(update_fields=update_fields)
if is_new:
ticket.process_new_ticket()
else:
ticket.claim_if_paid_for_one()
return ticket
def process_charge_refunded(payload):
"""Clear attendees from matching ticket, if it's been fully refunded.
Also, update stored checkout session data.
"""
charge = payload.get('data', {}).get('object', {})
payment_intent_id = charge.payment_intent
session = retrieve_session_for_payment_intent(payment_intent_id)
ticket_q = tickets.models.Ticket.objects.select_for_update().filter(order_id=payment_intent_id)
with transaction.atomic():
ticket = ticket_q.first()
if not ticket:
logger.info('No ticket found for refund of %s, ignoring', payment_intent_id)
return
is_full_refund = charge.amount == charge.amount_refunded
if is_full_refund:
logger.info('Removing all attendees from a fully refunded ticket pk=%s', ticket.pk)
ticket.attendees.clear()
_update_checkout_data(ticket, session)
def _product_by_payment_link(checkout_session):
if not checkout_session.payment_link:
return None
url = checkout_session.payment_link.url
return tickets.models.Product.objects.filter(url__contains=url).first()
def _product_by_price_id(line_item):
price_id = line_item.price['id']
return tickets.models.Product.objects.filter(url__contains=price_id).first()
def process_checkout_session_completed(session_id: str) -> 'tickets.models.Ticket':
"""Create or update a ticket based on a completed Stripe checkout."""
checkout_session = retrieve_session(session_id)
# If something is missing from checkout_session, this will raise an exception.
# This is something we want.
line_item = checkout_session['line_items']['data'][0]
payment_intent = checkout_session['payment_intent']
product_name = line_item.price.product.name
quantity = line_item['quantity']
user_id = int(checkout_session['client_reference_id'])
# TODO: just use metadata['product_id']?
product = _product_by_price_id(line_item) or _product_by_payment_link(checkout_session)
with transaction.atomic():
ticket = _upsert_ticket_from_payment_intent(
user_id, product_name, quantity, payment_intent, product=product
)
_update_checkout_data(ticket, checkout_session)
return ticket
def create_checkout_session(
product: object,
client_reference_id: int,
customer_email: str,
cancel_url: str,
success_url: str,
) -> stripe.api_resources.checkout.Session:
"""Create a checkout session with given parameters."""
# we have to do it to avoid uri-encoding of curly braces,
# otherwise stripe doesn't do the template substitution
existing_customers = stripe.Customer.list(email=customer_email, limit=1)
if existing_customers.data:
customer = existing_customers.data[0]
else:
customer = stripe.Customer.create(
email=customer_email, metadata={'conference_user_id': client_reference_id}
)
success_url = success_url + '?checkout_session_id={CHECKOUT_SESSION_ID}'
line_items = [
{
'adjustable_quantity': {'enabled': True},
'price': product.price_id,
'quantity': 1,
},
]
payment_intent_data = {
'metadata': {'product_id': product.pk},
}
return stripe.checkout.Session.create(
allow_promotion_codes=True,
automatic_tax={'enabled': False},
billing_address_collection='required',
cancel_url=cancel_url,
client_reference_id=client_reference_id,
custom_fields=product.custom_fields,
customer=customer.id,
customer_update={'name': 'auto', 'address': 'auto'},
line_items=line_items,
mode='payment',
payment_intent_data=payment_intent_data,
submit_type='book',
success_url=success_url,
tax_id_collection={'enabled': True},
ui_mode='hosted',
)