360 lines
17 KiB
Python
360 lines
17 KiB
Python
import datetime
|
|
import decimal
|
|
import logging
|
|
|
|
from django.conf import settings
|
|
from django.db import transaction
|
|
from django.http import HttpResponse
|
|
from django.shortcuts import render, redirect
|
|
from django.utils import timezone
|
|
import stripe
|
|
|
|
from .. import ParameterValidator
|
|
from . import forms, mail
|
|
from .models import Supporter, SustainerOrder, SustainerPayment
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def sustainers(request):
|
|
with ParameterValidator(request.GET, 'upgrade_id') as validator:
|
|
try:
|
|
amount_param = float(request.GET['upgrade'])
|
|
except (KeyError, ValueError):
|
|
validator.fail()
|
|
else:
|
|
validator.validate('{:.2f}'.format(amount_param))
|
|
partial_amount = amount_param if validator.valid else 0
|
|
context = {
|
|
'partial_amount': partial_amount,
|
|
'minimum_amount': 120 - partial_amount,
|
|
}
|
|
return render(request, "supporters/sustainers.html", context)
|
|
|
|
|
|
def sponsors(request):
|
|
"""Conservancy Sponsors Page view
|
|
|
|
Performs object queries necessary to render the sponsors page.
|
|
"""
|
|
supporters = Supporter.objects.all().filter(display_until_date__gte=datetime.datetime.now())
|
|
supporters_count = len(supporters)
|
|
anonymous_count = len(supporters.filter(display_name='Anonymous'))
|
|
supporters = supporters.exclude(display_name='Anonymous').order_by('ledger_entity_id')
|
|
c = {
|
|
'supporters': supporters,
|
|
'supporters_count': supporters_count,
|
|
'anonymous_count': anonymous_count,
|
|
}
|
|
return render(request, "supporters/sponsors.html", c)
|
|
|
|
|
|
def create_checkout_session(
|
|
reference_id, email: str, amount: int, recurring: str, base_url: str
|
|
):
|
|
# https://docs.stripe.com/payments/accept-a-payment
|
|
# https://docs.stripe.com/api/checkout/sessions
|
|
YOUR_DOMAIN = base_url
|
|
try:
|
|
checkout_session = stripe.checkout.Session.create(
|
|
client_reference_id=str(reference_id),
|
|
line_items=[
|
|
{
|
|
'price_data': {
|
|
'currency': 'usd',
|
|
'product_data': {'name': 'Contribution'},
|
|
'unit_amount': amount * 100, # in cents
|
|
# https://docs.stripe.com/products-prices/pricing-models#variable-pricing
|
|
'recurring': {'interval': recurring} if recurring else None,
|
|
},
|
|
'quantity': 1,
|
|
},
|
|
],
|
|
customer_email=email,
|
|
mode='subscription' if recurring else 'payment',
|
|
success_url=YOUR_DOMAIN + '/sustainer/success/?session_id={CHECKOUT_SESSION_ID}',
|
|
cancel_url=YOUR_DOMAIN + '/sustainer/stripe/',
|
|
)
|
|
except Exception as e:
|
|
return str(e)
|
|
return checkout_session.url
|
|
|
|
|
|
def sustainers_paypal(request):
|
|
return render(request, 'supporters/sustainers_paypal.html')
|
|
|
|
|
|
# Sustainers via Stripe
|
|
# =====================
|
|
#
|
|
# Background and problem
|
|
# ----------------------
|
|
#
|
|
# Conservancy accepts both one-off and monthly/annual recurring sustainer
|
|
# payments. Currently we used PayPal for this to avoid the compliance work and cost
|
|
# associated with PCI compliance. The relevant sustainer details and are sent to PayPal
|
|
# as custom fields, the donor pays via the PayPal hosted payment form, receives a
|
|
# receipt from PayPal and then later Bradley runs a batch script that takes PayPal data
|
|
# and sends a custom thanks email. (Where does the data come from? Is it a dashboard
|
|
# export or an API call?)
|
|
#
|
|
# The problem here is firstly that PayPal are difficult and somewhat risky to deal with
|
|
# in a business sense - they have been known to shut you down on a whim. Secondly we're
|
|
# heavily tied to PayPal - we're using them as a sustainer database to capture things
|
|
# like T-shirt size and address before these are imported into Bradley's
|
|
# supporter.db. To be less tied to PayPal, we would need to capture these details in our
|
|
# own database and only pass the necessary minimum details to the payment provider to
|
|
# take the payment (ie. email and payment amount).
|
|
#
|
|
# We also use PayPal to manage billing for recurring monthly/annual subscriptions, but
|
|
# that's less of an issue because that's more difficult to do reliably ourselves.
|
|
#
|
|
# We would like to integrate Stripe as a payment provider, possibly eventually replacing
|
|
# PayPal entirely for new sustainers. We have to be careful though. While Stripe were
|
|
# once focused on just accepting credit card payments, they've now moved into the
|
|
# billing and "financial automation" market so we could easily tie ourselves to Stripe
|
|
# if we're not careful.
|
|
#
|
|
# The first thing we need to do is keep our own database of sustainer orders. When a
|
|
# sustainer signs up, we record all their information and unpaid order status there and
|
|
# pass only the necessary info across to Stripe.
|
|
#
|
|
# The second thing is to produce a CSV of payments to be processed by Bradley's
|
|
# fulfilment scripts that creates Beancount bookkeeping entries, updates the
|
|
# acknowledgements on the sponsors page and determines who to send a T-shirts to (not
|
|
# immediate for monthly donors).
|
|
#
|
|
#
|
|
# Approach to integrating with Stripe
|
|
# -----------------------------------
|
|
#
|
|
# The simplest approach to integrate Stripe seems to be to use their hosted checkout
|
|
# page. It's a currently recommended approaches as of 2024 (ie. not legacy),
|
|
# requires relatively little code, can handle complicated bank verification processes
|
|
# like 3-D Secure, allows us to switch on/of additional payment methods such as
|
|
# ACH/direct debit and avoids running proprietary JavaScript within an sfconservancy.org
|
|
# page. The tradeoff is the slightly visually jarring transition to stripe.com and
|
|
# back. With relatively little efforte we instead use an embedded Stripe form or Stripe
|
|
# widgets in our own form; this would just require marginally more code and would run
|
|
# proprietary JS within the sfconservancy.org page
|
|
# (https://docs.stripe.com/payments/accept-a-payment). From a sustainer's perspective
|
|
# it's proprietary JS either way, but it feels conceptually cleaner to isolate
|
|
# it. Nonetheless this is a slipperly SAAS slope so we need to take care.
|
|
#
|
|
# To use Stripe hosted checkout, we first accept the sustainer sign-up and populate our
|
|
# database with an unpaid order. We then create/register a Stripe checkout session via
|
|
# the API with with the donor's email, amount and renewal period if relevant and forward
|
|
# the donor across to the session's unique stripe.com URL.
|
|
#
|
|
# The donor pays on stripe.com and is then redirected to a "success_url" along with an
|
|
# ID parameter we can use to look up the payment details to determine their payment
|
|
# status. Stripe also allow you to register to accept webhook HTTP requests
|
|
# corresponding to various events in their system. One of those is
|
|
# "checkout.session.completed", which corresponds to the redirect to "success_url". The
|
|
# Stripe fulfillment docs (https://docs.stripe.com/checkout/fulfillment) advise handling
|
|
# both the "success_url" redirect and the "checkout.session.completed" webhook in case
|
|
# the redirect fails. If this was a credit card payment, we know then an there that it
|
|
# was successful. If it's an ACH/direct debit payment, it will take a few days to be
|
|
# processed and confirmed paid "checkout.session.async_payment_succeeded".
|
|
#
|
|
# We record this initial payment success against our order in the sustainer database.
|
|
#
|
|
# If auto-renewing, Stripe will transparently set up what they call a "Subscription" and
|
|
# will automatically bill the donor again next month or year
|
|
# (https://docs.stripe.com/billing/subscriptions/overview). This is a GOOD THING,
|
|
# because it avoids us having to worry about missing billing people, or worse,
|
|
# double-billing. I've been there before. Stripe
|
|
# offer an additional self-service subscription management portal for donors to use, but
|
|
# by default they don't communicate with donors directly and leave you to manage
|
|
# subscriptions manually from within the Stripe dashboard.
|
|
#
|
|
# To find out about subscription renewals, we can either batch query the Stripe API or
|
|
# we can register for webhook events. If using webhooks, there are plenty of subtle
|
|
# pitfalls such as event ordering, duplication and race conditions that could lead to us
|
|
# messing up fulfillment. The other challenge with webhooks events is that you need to
|
|
# link them back to the sustainer order they relate to in our database.
|
|
|
|
def sustainers_stripe(request):
|
|
if request.method == 'POST':
|
|
form = forms.SustainerForm(request.POST)
|
|
if form.is_valid():
|
|
order = form.save()
|
|
base_url = f'{request.scheme}://{request.get_host()}'
|
|
# There are a few options for integrating with Stripe. A common one, and
|
|
# possibly the least intrusive is to use the proprietary
|
|
# https://js.stripe.com/v3/ to embed Stripe form fields into your own
|
|
# form. Another embeds a hosted form in your page. The approach we've used
|
|
# is to redirect to a hosted checkout page. This is far from perfect, but it
|
|
# avoids adding proprietary JS on sfconservancy.org.
|
|
stripe_checkout_url = create_checkout_session(
|
|
order.id, order.email, order.amount, order.recurring, base_url
|
|
)
|
|
return redirect(stripe_checkout_url)
|
|
else:
|
|
form = forms.SustainerForm()
|
|
return render(request, 'supporters/sustainers_stripe.html', {'form': form})
|
|
|
|
|
|
stripe.api_key = settings.STRIPE_API_KEY
|
|
if stripe.api_key == '':
|
|
logger.warning('Missing STRIPE_API_KEY')
|
|
|
|
|
|
def fulfill_signup(session):
|
|
session_id = session["id"]
|
|
logger.debug(f'Fulfilling checkout session {session_id}')
|
|
|
|
# TODO: Clean up orders that have been unpaid for, say, 14 days.
|
|
# TODO: Consider emailing ACH/direct-debit donors immediately to say pending.
|
|
|
|
# Retrieve the Checkout Session from the API with line_items expanded so we can get
|
|
# the payment intent ID for subscriptions.
|
|
session = stripe.checkout.Session.retrieve(
|
|
session_id,
|
|
expand=['invoice'],
|
|
)
|
|
|
|
# This ensure's we're looking at a sustainer checkout, not some other
|
|
# unrelated Stripe checkout.
|
|
sustainerorder_id = session['client_reference_id']
|
|
|
|
# Check the Checkout Session's payment_status property
|
|
# to determine if fulfillment should be peformed
|
|
if session.payment_status != 'unpaid':
|
|
logger.debug(f'Actioning paid session {session_id}')
|
|
try:
|
|
with transaction.atomic():
|
|
# Lock this order to prevent a race condition from multiple webhooks.
|
|
order = SustainerOrder.objects.filter(id=sustainerorder_id, paid_time=None).select_for_update().get()
|
|
order.stripe_customer_ref = session['customer']
|
|
order.stripe_subscription_ref = session['subscription']
|
|
order.stripe_checkout_session_data = session
|
|
order.stripe_initial_payment_intent_ref = (
|
|
# One-off sustainer
|
|
session['payment_intent']
|
|
# Subscription sustainer
|
|
or session['invoice']['payment_intent']
|
|
)
|
|
order.paid_time = timezone.now()
|
|
order.save()
|
|
logger.info(f'Marked sustainer order {order.id} ({order.email}) as paid')
|
|
payment = SustainerPayment.objects.create(
|
|
order=order,
|
|
stripe_invoice_ref=session['invoice']['id'] if session['invoice'] else None,
|
|
amount=decimal.Decimal(session['amount_total']) / 100,
|
|
stripe_payment_intent_ref=order.stripe_initial_payment_intent_ref,
|
|
stripe_invoice_data=session['invoice'],
|
|
)
|
|
logger.info(f'Created sustainer payment {payment.id}')
|
|
email = mail.make_stripe_email(order)
|
|
email.send()
|
|
except SustainerOrder.DoesNotExist:
|
|
logger.info(f'No such unpaid SustainerOrder {sustainerorder_id} - no action')
|
|
else:
|
|
logger.debug(f'Unpaid session {session_id} - no action')
|
|
|
|
|
|
def fulfill_invoice_payment(invoice):
|
|
"""Handle (possible) renewal payment.
|
|
|
|
Annoyingly, this handler runs both for initial subscription and renewal payments. A
|
|
better option would be if there was an event that ran renewal payments ONLY. I
|
|
looked at "customer.subscription.updated", but couldn't seem to tell whether the
|
|
update was for a new successful renewal payment as opposed eg. someone changed the
|
|
subscription amount in the Stripe dashboard.
|
|
|
|
Scenarios:
|
|
|
|
1. This could be an initial subscription payment or a renewal payment. Only
|
|
action if payment intent ID doesn't match an initial sign-up payment in our
|
|
database.
|
|
|
|
2. This could also be an initial payment for a subsciption not yet in the
|
|
database (events came in out of order) or a payment for a non-sustainer
|
|
subscription. That's fine - we just ignore those cases.
|
|
"""
|
|
invoice_id = invoice.id
|
|
try:
|
|
with transaction.atomic():
|
|
# An alternative to comparing the payment intent reference would be to only
|
|
# consider orders paid > 28 days ago. Renewals should never happen before then.
|
|
order = SustainerOrder.objects.exclude(stripe_initial_payment_intent_ref=invoice['payment_intent']).get(
|
|
stripe_subscription_ref=invoice.subscription, paid_time__isnull=False,
|
|
)
|
|
payment = SustainerPayment.objects.create(
|
|
order=order,
|
|
stripe_invoice_ref=invoice.id,
|
|
amount=decimal.Decimal(invoice.total) / 100,
|
|
stripe_payment_intent_ref=invoice['payment_intent'],
|
|
stripe_invoice_data=invoice,
|
|
)
|
|
logger.info(f'Created sustainer payment {payment.id} for invoice {invoice_id}')
|
|
except SustainerOrder.DoesNotExist:
|
|
logger.info(f'No such subscription to renew {invoice.subscription} for invoice {invoice_id}')
|
|
|
|
|
|
|
|
def success(request):
|
|
"""Handle Stripe redirect after successful checkout."""
|
|
# We don't run the fulfillment here since it's unnecessarily complicated to run it
|
|
# both here and from webhooks.
|
|
return render(request, 'supporters/stripe_success.html', {})
|
|
|
|
|
|
def webhook(request):
|
|
"""Handle a request to our webhook endpoint.
|
|
|
|
Modelled on https://docs.stripe.com/checkout/fulfillment.
|
|
|
|
To test these, either use a service like Pagekite to set up a public link to your
|
|
development environment and configure webhooks for that, or use the Stripe CLI tool
|
|
to forward the events to your development environment.
|
|
"""
|
|
payload = request.body
|
|
sig_header = request.META['HTTP_STRIPE_SIGNATURE']
|
|
event = None
|
|
|
|
# From the "event destinations" page in Stripe's "developer tools" area.
|
|
endpoint_secret = settings.STRIPE_ENDPOINT_SECRET
|
|
if not endpoint_secret:
|
|
logger.warning('Missing STRIPE_ENDPOINT_SECRET')
|
|
|
|
try:
|
|
event = stripe.Webhook.construct_event(payload, sig_header, endpoint_secret)
|
|
except ValueError:
|
|
# Invalid payload
|
|
return HttpResponse(status=400)
|
|
except stripe.error.SignatureVerificationError:
|
|
# Invalid signature
|
|
return HttpResponse(status=400)
|
|
|
|
# Register for these webhook events the "event destinations" page. Must be
|
|
# individually enabled.
|
|
if event['type'] == 'checkout.session.completed':
|
|
# Successful Stripe checkout. For credit cards, this usually indicates that the
|
|
# payment was successful. For ACH/direct-debit the payment will not yet have
|
|
# been processed and may take a few days.
|
|
session = event['data']['object']
|
|
logger.debug(f'CHECKOUT.SESSION.COMPLETED webhook for session {session["id"]}')
|
|
fulfill_signup(session)
|
|
elif event['type'] == 'checkout.session.async_payment_succeeded':
|
|
# Runs for successful ACH/direct debit payments.
|
|
session = event['data']['object']
|
|
logger.debug(f'CHECKOUT.SESSION.ASYNC_PAYMENT_SUCCEEDED webhook for session {session["id"]}')
|
|
fulfill_signup(session)
|
|
elif event['type'] == 'invoice.payment_succeeded':
|
|
# Successful initial subscription or renewal payment (only care about renewals).
|
|
#
|
|
# It not clear that this is the *best* webhook or approach to use
|
|
# handle subscription renewals, but it works.
|
|
#
|
|
# You can simulate subscription renewals via the Stripe developers site:
|
|
# https://docs.stripe.com/billing/testing/test-clocks/simulate-subscriptions
|
|
#
|
|
# I found I had to advance time by 1 month first to create the invoice, then 1
|
|
# day for it to be billed. You can watch all the events via the "stripe listen"
|
|
# CLI command.
|
|
invoice = event['data']['object']
|
|
logger.debug(f'INVOICE.PAYMENT_SUCCEEDED webhook for invoice {invoice["id"]}')
|
|
fulfill_invoice_payment(invoice)
|
|
return HttpResponse(status=200)
|