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)