supporters: Handle Stripe sustainer renewals and ACH delayed payments
This commit is contained in:
parent
dc133ff0cd
commit
1c3c803ee1
7 changed files with 364 additions and 55 deletions
|
@ -63,7 +63,12 @@ LOGGING = {
|
|||
'handlers': ['console'],
|
||||
'level': 'DEBUG',
|
||||
'propagate': False,
|
||||
}
|
||||
},
|
||||
'conservancy.supporters': {
|
||||
'handlers': ['console'],
|
||||
'level': 'DEBUG',
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from .models import Supporter, SustainerOrder
|
||||
from .models import Supporter, SustainerOrder, SustainerPayment
|
||||
|
||||
|
||||
@admin.register(Supporter)
|
||||
|
@ -8,13 +8,38 @@ class SupporterAdmin(admin.ModelAdmin):
|
|||
list_display = ('display_name', 'display_until_date')
|
||||
|
||||
|
||||
class SustainerPaymentInline(admin.TabularInline):
|
||||
model = SustainerPayment
|
||||
fields = [
|
||||
'paid_time',
|
||||
'amount',
|
||||
'stripe_payment_intent_ref',
|
||||
'stripe_invoice_ref',
|
||||
]
|
||||
can_delete = False
|
||||
readonly_fields = [
|
||||
'paid_time',
|
||||
'amount',
|
||||
'stripe_payment_intent_ref',
|
||||
'stripe_invoice_ref',
|
||||
]
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@admin.register(SustainerOrder)
|
||||
class SustainerOrderAdmin(admin.ModelAdmin):
|
||||
fields = [
|
||||
'created_time',
|
||||
'paid_time',
|
||||
'payment_method',
|
||||
'payment_id',
|
||||
'stripe_customer_ref',
|
||||
'stripe_subscription_ref',
|
||||
'recurring',
|
||||
'name',
|
||||
'email',
|
||||
|
@ -28,7 +53,21 @@ class SustainerOrderAdmin(admin.ModelAdmin):
|
|||
'zip_code',
|
||||
'country',
|
||||
]
|
||||
|
||||
readonly_fields = ['created_time', 'paid_time', 'payment_method', 'payment_id', 'recurring']
|
||||
inlines = [SustainerPaymentInline]
|
||||
readonly_fields = ['created_time', 'paid_time', 'payment_method', 'stripe_customer_ref', 'stripe_subscription_ref', 'recurring']
|
||||
list_display = ['created_time', 'name', 'email', 'amount', 'recurring', 'paid_time']
|
||||
list_filter = ['paid_time']
|
||||
|
||||
|
||||
@admin.register(SustainerPayment)
|
||||
class SustainerPaymentAdmin(admin.ModelAdmin):
|
||||
fields = [
|
||||
'order',
|
||||
'paid_time',
|
||||
'amount',
|
||||
'stripe_invoice_ref',
|
||||
'stripe_payment_intent_ref',
|
||||
]
|
||||
readonly_fields = ['order', 'paid_time', 'amount', 'stripe_invoice_ref', 'stripe_payment_intent_ref']
|
||||
list_display = ['order', 'paid_time', 'amount']
|
||||
list_filter = ['paid_time']
|
||||
|
|
|
@ -3,7 +3,7 @@ from django.template.loader import render_to_string
|
|||
|
||||
|
||||
def make_stripe_email(order) -> EmailMessage:
|
||||
subject = 'Thanks for your sustainer payment!'
|
||||
subject = 'Thanks for being a sustainer!'
|
||||
email_body = render_to_string(
|
||||
'supporters/mail/sustainer_thanks.txt',
|
||||
{'order': order},
|
||||
|
|
|
@ -2,25 +2,26 @@ import csv
|
|||
import sys
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from ...models import SustainerOrder
|
||||
from ...models import SustainerPayment
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Closes the specified poll for voting"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
orders = SustainerOrder.objects.filter(paid_time__isnull=False).order_by('paid_time')
|
||||
payments = SustainerPayment.objects.select_related('order').order_by('paid_time')
|
||||
columns = ['order_time', 'payment_time', 'name', 'email', 'amount', 'transaction_id', 'public_ack', 'shirt_size', 'join_list', 'street', 'city', 'state', 'zip_code', 'country']
|
||||
writer = csv.writer(sys.stdout)
|
||||
writer.writerow(columns)
|
||||
for order in orders:
|
||||
for payment in payments:
|
||||
order = payment.order
|
||||
writer.writerow([
|
||||
order.created_time,
|
||||
order.paid_time,
|
||||
payment.paid_time,
|
||||
order.name,
|
||||
order.email,
|
||||
order.amount,
|
||||
order.payment_id,
|
||||
payment.amount,
|
||||
payment.stripe_payment_intent_ref,
|
||||
order.acknowledge_publicly,
|
||||
repr(order.tshirt_size if order.tshirt_size else ''),
|
||||
order.add_to_mailing_list,
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
# Generated by Django 5.1.2 on 2024-11-15 02:56
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('supporters', '0005_alter_sustainerorder_amount_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='sustainerorder',
|
||||
name='payment_id',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sustainerorder',
|
||||
name='stripe_checkout_session_data',
|
||||
field=models.JSONField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sustainerorder',
|
||||
name='stripe_customer_ref',
|
||||
field=models.CharField(max_length=50, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sustainerorder',
|
||||
name='stripe_initial_payment_intent_ref',
|
||||
field=models.CharField(max_length=50, null=True, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sustainerorder',
|
||||
name='stripe_subscription_ref',
|
||||
field=models.CharField(max_length=50, null=True, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sustainerorder',
|
||||
name='amount',
|
||||
field=models.DecimalField(decimal_places=2, max_digits=7),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SustainerPayment',
|
||||
fields=[
|
||||
(
|
||||
'id',
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name='ID',
|
||||
),
|
||||
),
|
||||
('paid_time', models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
'stripe_invoice_ref',
|
||||
models.CharField(max_length=50, null=True, unique=True),
|
||||
),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=7)),
|
||||
(
|
||||
'stripe_payment_intent_ref',
|
||||
models.CharField(max_length=50, null=True, unique=True),
|
||||
),
|
||||
('stripe_invoice_data', models.JSONField(null=True)),
|
||||
(
|
||||
'order',
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to='supporters.sustainerorder',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -64,14 +64,17 @@ class SustainerOrder(models.Model):
|
|||
]
|
||||
|
||||
created_time = models.DateTimeField(auto_now_add=True)
|
||||
stripe_customer_ref = models.CharField(max_length=50, null=True)
|
||||
stripe_subscription_ref = models.CharField(max_length=50, null=True, unique=True)
|
||||
stripe_initial_payment_intent_ref = models.CharField(max_length=50, null=True, unique=True)
|
||||
stripe_checkout_session_data = models.JSONField(null=True)
|
||||
name = models.CharField(max_length=255)
|
||||
email = models.EmailField()
|
||||
amount = models.PositiveIntegerField()
|
||||
amount = models.DecimalField(max_digits=7, decimal_places=2)
|
||||
recurring = models.CharField(
|
||||
max_length=10, choices=RENEW_CHOICES, blank=True, default=''
|
||||
)
|
||||
payment_method = models.CharField(max_length=10, default='Stripe')
|
||||
payment_id = models.CharField(max_length=255, blank=True)
|
||||
paid_time = models.DateTimeField(null=True, blank=True)
|
||||
acknowledge_publicly = models.BooleanField(default=True)
|
||||
add_to_mailing_list = models.BooleanField(default=True)
|
||||
|
@ -89,3 +92,12 @@ class SustainerOrder(models.Model):
|
|||
|
||||
def paid(self):
|
||||
return self.paid_time is not None
|
||||
|
||||
|
||||
class SustainerPayment(models.Model):
|
||||
order = models.ForeignKey(SustainerOrder, on_delete=models.CASCADE)
|
||||
paid_time = models.DateTimeField(auto_now_add=True)
|
||||
stripe_invoice_ref = models.CharField(max_length=50, null=True, unique=True)
|
||||
amount = models.DecimalField(max_digits=7, decimal_places=2)
|
||||
stripe_payment_intent_ref = models.CharField(max_length=50, null=True, unique=True)
|
||||
stripe_invoice_data = models.JSONField(null=True)
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
from datetime import datetime
|
||||
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
|
||||
|
@ -9,7 +11,7 @@ import stripe
|
|||
|
||||
from .. import ParameterValidator
|
||||
from . import forms, mail
|
||||
from .models import Supporter, SustainerOrder
|
||||
from .models import Supporter, SustainerOrder, SustainerPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -35,7 +37,7 @@ def sponsors(request):
|
|||
|
||||
Performs object queries necessary to render the sponsors page.
|
||||
"""
|
||||
supporters = Supporter.objects.all().filter(display_until_date__gte=datetime.now())
|
||||
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')
|
||||
|
@ -82,6 +84,96 @@ 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)
|
||||
|
@ -108,61 +200,123 @@ if stripe.api_key == '':
|
|||
logger.warning('Missing STRIPE_API_KEY')
|
||||
|
||||
|
||||
def fulfill_checkout(session_id):
|
||||
print("Fulfilling Checkout Session", session_id)
|
||||
def fulfill_signup(session):
|
||||
session_id = session["id"]
|
||||
logger.debug(f'Fulfilling checkout session {session_id}')
|
||||
|
||||
# TODO: Make this function safe to run multiple times,
|
||||
# even concurrently, with the same 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.
|
||||
|
||||
# TODO: Make sure fulfillment hasn't already been
|
||||
# peformed for this Checkout Session
|
||||
|
||||
# Retrieve the Checkout Session from the API with line_items expanded
|
||||
checkout_session = stripe.checkout.Session.retrieve(
|
||||
# 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=['line_items', 'invoice'],
|
||||
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 checkout_session.payment_status != 'unpaid':
|
||||
# TODO: Perform fulfillment of the line items
|
||||
|
||||
# TODO: Record/save fulfillment status for this
|
||||
# Checkout Session
|
||||
logger.info(f'Session ID {session_id} PAID!')
|
||||
if session.payment_status != 'unpaid':
|
||||
logger.debug(f'Actioning paid session {session_id}')
|
||||
try:
|
||||
order = SustainerOrder.objects.get(
|
||||
id=checkout_session['client_reference_id'], paid_time=None
|
||||
)
|
||||
order.paid_time = timezone.now()
|
||||
if checkout_session['payment_intent']:
|
||||
# Payments get a payment intent directly
|
||||
order.payment_id = checkout_session['payment_intent']
|
||||
else:
|
||||
# Subscriptions go get a payment intent generated on the invoice
|
||||
order.payment_id = checkout_session['invoice']['payment_intent']
|
||||
order.save()
|
||||
logger.info(f'Marked sustainer order {order.id} (order.email) as paid')
|
||||
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('No action')
|
||||
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):
|
||||
fulfill_checkout(request.GET['session_id'])
|
||||
"""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 webhook dashboard
|
||||
# From the "event destinations" page in Stripe's "developer tools" area.
|
||||
endpoint_secret = settings.STRIPE_ENDPOINT_SECRET
|
||||
if endpoint_secret == '':
|
||||
if not endpoint_secret:
|
||||
logger.warning('Missing STRIPE_ENDPOINT_SECRET')
|
||||
|
||||
try:
|
||||
|
@ -174,10 +328,33 @@ def webhook(request):
|
|||
# Invalid signature
|
||||
return HttpResponse(status=400)
|
||||
|
||||
if (
|
||||
event['type'] == 'checkout.session.completed'
|
||||
or event['type'] == 'checkout.session.async_payment_succeeded'
|
||||
):
|
||||
fulfill_checkout(event['data']['object']['id'])
|
||||
|
||||
# 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)
|
||||
|
|
Loading…
Reference in a new issue