summaryrefslogtreecommitdiff
path: root/fundraising
diff options
context:
space:
mode:
authorCarlton Gibson <carlton@noumenal.es>2021-01-04 12:37:28 +0100
committerGitHub <noreply@github.com>2021-01-04 12:37:28 +0100
commitcc80dfa1864009580ec67e05a15ed52c6000e3a9 (patch)
treeb087cc51c6cc5b6380d6811cec00fa12875b92b7 /fundraising
parent46f36024123ff0d853017451dd53704c7a20d76d (diff)
Update Stripe integration for main fundraising page.
Thanks to Mariusz Felisiak for review.
Diffstat (limited to 'fundraising')
-rw-r--r--fundraising/forms.py137
-rw-r--r--fundraising/models.py1
-rw-r--r--fundraising/templatetags/fundraising_extras.py4
-rw-r--r--fundraising/tests/test_forms.py110
-rw-r--r--fundraising/tests/test_views.py172
-rw-r--r--fundraising/urls.py5
-rw-r--r--fundraising/views.py194
7 files changed, 170 insertions, 453 deletions
diff --git a/fundraising/forms.py b/fundraising/forms.py
index 8a5b93d5..7e269cd2 100644
--- a/fundraising/forms.py
+++ b/fundraising/forms.py
@@ -1,13 +1,7 @@
import stripe
-from captcha.fields import ReCaptchaField
-from captcha.widgets import ReCaptchaV3
from django import forms
-from django.conf import settings
-from django.core.mail import send_mail
-from django.template.loader import render_to_string
from django.utils.safestring import mark_safe
-from .exceptions import DonationError
from .models import (
INTERVAL_CHOICES, LEADERSHIP_LEVEL_AMOUNT, DjangoHero, Donation,
)
@@ -113,6 +107,9 @@ class StripeTextInput(forms.TextInput):
class DonateForm(forms.Form):
+ """
+ Used to generate the HTML form in the fundraising page.
+ """
AMOUNT_CHOICES = (
(25, 'US $25'),
(50, 'US $50'),
@@ -131,6 +128,9 @@ class DonateForm(forms.Form):
class DonationForm(forms.ModelForm):
+ """
+ Used in the manage donations view.
+ """
subscription_amount = forms.DecimalField(max_digits=9, decimal_places=2, required=True)
# here we're removing "onetime" option from interval choices:
interval = forms.ChoiceField(choices=INTERVAL_CHOICES[:3], required=True)
@@ -147,14 +147,21 @@ class DonationForm(forms.ModelForm):
# Send data to Stripe
customer = stripe.Customer.retrieve(donation.stripe_customer_id)
subscription = customer.subscriptions.retrieve(donation.stripe_subscription_id)
+ # TODO: Setting the plan is deprecated — use Price API instead.
subscription.plan = interval
subscription.quantity = int(amount)
+
subscription.save()
return donation
class PaymentForm(forms.Form):
+ """
+ Used to validate values when configuring the Stripe Session.
+
+ `amount` can be any integer, so a ChoiceField is not appropriate.
+ """
amount = forms.IntegerField(
required=True,
min_value=1, # Minimum payment from Stripe API
@@ -163,121 +170,3 @@ class PaymentForm(forms.Form):
required=True,
choices=INTERVAL_CHOICES,
)
- receipt_email = forms.CharField(required=True)
- # added by the donation form JavaScript via Stripe.js
- stripe_token = forms.CharField(widget=forms.HiddenInput())
- token_type = forms.CharField(widget=forms.HiddenInput())
-
- def make_donation(self):
- receipt_email = self.cleaned_data['receipt_email']
- amount = self.cleaned_data['amount']
- stripe_token = self.cleaned_data['stripe_token']
- token_type = self.cleaned_data['token_type']
- interval = self.cleaned_data['interval']
- is_bitcoin = token_type == 'source_bitcoin'
-
- hero = DjangoHero.objects.filter(email=receipt_email).first()
-
- try:
- if hero and hero.stripe_customer_id:
- # Update old customer with new payment source, unless the
- # source is bitcoin.
- customer = stripe.Customer.retrieve(hero.stripe_customer_id)
- if is_bitcoin:
- customer.sources.create(source=stripe_token)
- else:
- customer.source = stripe_token
- customer.save()
- else:
- customer = stripe.Customer.create(source=stripe_token, email=receipt_email)
-
- # Only perform one-time charges with bitcoin as bitcoins can't be
- # used for subscriptions.
- if interval == 'onetime' or is_bitcoin:
- subscription_id = ''
- charge_info = {
- 'amount': int(amount * 100),
- 'currency': 'usd',
- 'customer': customer.id,
- 'receipt_email': receipt_email
- }
- if is_bitcoin:
- charge_info['source'] = stripe_token
- charge = stripe.Charge.create(**charge_info)
- charge_id = charge.id
- else:
- charge_id = ''
- subscription = customer.subscriptions.create(
- plan=interval,
- quantity=int(amount),
- )
- subscription_id = subscription.id
-
- except stripe.error.CardError as card_error:
- raise DonationError(
- "We're sorry but we had problems charging your card. "
- 'Here is what Stripe replied: "%s"' % str(card_error))
-
- except stripe.error.InvalidRequestError:
- # Invalid parameters were supplied to Stripe's API
- raise DonationError(
- "We're sorry but something went wrong while processing "
- "your card details. No charge was done. Please try "
- "again or get in touch with us.")
-
- except stripe.error.APIConnectionError:
- # Network communication with Stripe failed
- raise DonationError(
- "We're sorry but we have technical difficulties "
- "reaching our payment processor Stripe. No charge "
- "was done. Please try again later.")
-
- except stripe.error.AuthenticationError:
- # Authentication with Stripe's API failed
- raise
-
- except (stripe.error.StripeError, Exception):
- # The card has been declined, we want to see what happened
- # in Sentry
- raise
-
- else:
- if not hero:
- hero = DjangoHero.objects.create(
- email=receipt_email,
- stripe_customer_id=customer.id,
- )
- # Finally create the donation and return it
- donation = Donation.objects.create(
- interval=interval,
- subscription_amount=amount,
- stripe_customer_id=customer.id,
- stripe_subscription_id=subscription_id,
- receipt_email=receipt_email,
- donor=hero,
- )
- # Only one-time donations are created here. Recurring payments are
- # created by Stripe webhooks.
- if charge_id:
- donation.payment_set.create(
- amount=amount,
- stripe_charge_id=charge_id,
- )
-
- # Send an email message about managing your donation
- message = render_to_string(
- 'fundraising/email/thank-you.html',
- {'donation': donation}
- )
- send_mail(
- 'Thank you for your donation to the Django Software Foundation',
- message,
- settings.FUNDRAISING_DEFAULT_FROM_EMAIL,
- [donation.receipt_email]
- )
-
- return donation
-
-
-class ReCaptchaForm(forms.Form):
- captcha = ReCaptchaField(widget=ReCaptchaV3)
diff --git a/fundraising/models.py b/fundraising/models.py
index 0889aa7b..ff8d1479 100644
--- a/fundraising/models.py
+++ b/fundraising/models.py
@@ -48,6 +48,7 @@ class FundraisingModel(models.Model):
class DjangoHero(FundraisingModel):
email = models.EmailField(blank=True)
+ # TODO: Make this unique.
stripe_customer_id = models.CharField(max_length=100, blank=True)
logo = ImageField(upload_to="fundraising/logos/", blank=True)
url = models.URLField(blank=True, verbose_name='URL')
diff --git a/fundraising/templatetags/fundraising_extras.py b/fundraising/templatetags/fundraising_extras.py
index 4d86573f..150317b7 100644
--- a/fundraising/templatetags/fundraising_extras.py
+++ b/fundraising/templatetags/fundraising_extras.py
@@ -5,7 +5,7 @@ from django.conf import settings
from django.db import models
from django.template.defaultfilters import floatformat
-from fundraising.forms import DonateForm, ReCaptchaForm
+from fundraising.forms import DonateForm
from fundraising.models import (
DEFAULT_DONATION_AMOUNT, DISPLAY_DONOR_DAYS, GOAL_AMOUNT, GOAL_START_DATE,
LEADERSHIP_LEVEL_AMOUNT, DjangoHero, InKindDonor, Payment,
@@ -51,7 +51,6 @@ def donation_form_with_heart(context):
form = DonateForm(initial={
'amount': DEFAULT_DONATION_AMOUNT,
})
- form_captcha = ReCaptchaForm()
return {
'goal_amount': GOAL_AMOUNT,
@@ -59,7 +58,6 @@ def donation_form_with_heart(context):
'donated_amount': donated_amount,
'total_donors': total_donors,
'form': form,
- 'form_captcha': form_captcha,
'display_logo_amount': LEADERSHIP_LEVEL_AMOUNT,
'stripe_publishable_key': settings.STRIPE_PUBLISHABLE_KEY,
'user': user,
diff --git a/fundraising/tests/test_forms.py b/fundraising/tests/test_forms.py
index 4f1131f9..d66d6b51 100644
--- a/fundraising/tests/test_forms.py
+++ b/fundraising/tests/test_forms.py
@@ -1,120 +1,12 @@
-from unittest.mock import patch
-
from django.test import TestCase
-from stripe.error import StripeError
from ..forms import PaymentForm
-from ..models import DjangoHero
class TestPaymentForm(TestCase):
- @patch('stripe.Customer.create')
- @patch('stripe.Charge.create')
- def test_make_donation(self, charge_create, customer_create):
- customer_create.return_value.id = 'xxxx'
- charge_create.return_value.id = 'xxxx'
- form = PaymentForm(data={
- 'amount': 100,
- 'stripe_token': 'xxxx',
- 'token_type': 'card',
- 'interval': 'onetime',
- 'receipt_email': 'django@example.com',
- })
- self.assertTrue(form.is_valid())
- donation = form.make_donation()
- self.assertEqual(100, donation.payment_set.first().amount)
-
- @patch('stripe.Customer.create')
- @patch('stripe.Charge.create')
- def test_make_donation_with_bitcoin(self, charge_create, customer_create):
- customer_create.return_value.id = 'xxxx'
- charge_create.return_value.id = 'xxxx'
- form = PaymentForm(data={
- 'amount': 100,
- 'stripe_token': 'xxxx',
- 'token_type': 'source_bitcoin',
- 'interval': 'onetime',
- 'receipt_email': 'django@example.com',
- })
- self.assertTrue(form.is_valid())
- donation = form.make_donation()
- self.assertEqual(100, donation.payment_set.first().amount)
-
- @patch('stripe.Customer.retrieve')
- @patch('stripe.Charge.create')
- def test_make_donation_with_existing_hero(self, charge_create, customer_retrieve):
- charge_create.return_value.id = 'XYZ'
- customer_retrieve.return_value.id = '12345'
- hero = DjangoHero.objects.create(
- email='django@example.com',
- stripe_customer_id=customer_retrieve.return_value.id,
- )
- form = PaymentForm(data={
- 'amount': 100,
- 'stripe_token': 'xxxx',
- 'token_type': 'card',
- 'interval': 'onetime',
- 'receipt_email': 'django@example.com',
- })
- self.assertTrue(form.is_valid())
- donation = form.make_donation()
- self.assertEqual(100, donation.payment_set.first().amount)
- self.assertEqual(hero, donation.donor)
- self.assertEqual(hero.stripe_customer_id, donation.stripe_customer_id)
-
- @patch('stripe.Customer.retrieve')
- @patch('stripe.Charge.create')
- def test_make_donation_with_existing_hero_with_bitcoin(self, charge_create, customer_retrieve):
- charge_create.return_value.id = 'XYZ'
- customer_retrieve.return_value.id = '12345'
- hero = DjangoHero.objects.create(
- email='django@example.com',
- stripe_customer_id=customer_retrieve.return_value.id,
- )
- form = PaymentForm(data={
- 'amount': 100,
- 'stripe_token': 'xxxx',
- 'token_type': 'source_bitcoin',
- 'interval': 'onetime',
- 'receipt_email': 'django@example.com',
- })
- self.assertTrue(form.is_valid())
- donation = form.make_donation()
- self.assertEqual(100, donation.payment_set.first().amount)
- self.assertEqual(hero, donation.donor)
- self.assertEqual(hero.stripe_customer_id, donation.stripe_customer_id)
-
- @patch('stripe.Customer.create')
- @patch('stripe.Charge.create')
- def test_make_donation_exception(self, charge_create, customer_create):
- customer_create.side_effect = ValueError("Something is wrong")
- form = PaymentForm(data={
- 'amount': 100,
- 'stripe_token': 'xxxx',
- 'token_type': 'card',
- 'interval': 'onetime',
- 'receipt_email': 'django@example.com',
- })
- self.assertTrue(form.is_valid())
- with self.assertRaises(ValueError):
- donation = form.make_donation()
- self.assertIsNone(donation)
-
- @patch('stripe.Customer.create')
- @patch('stripe.Charge.create')
- def test_make_donation_stripe_exception(self, charge_create, customer_create):
- customer_create.return_value.id = 'xxxx'
- charge_create.side_effect = StripeError('Payment failed')
+ def test_basics(self):
form = PaymentForm(data={
'amount': 100,
- 'stripe_token': 'xxxx',
- 'token_type': 'card',
'interval': 'onetime',
- 'receipt_email': 'django@example.com',
})
self.assertTrue(form.is_valid())
- with self.assertRaisesMessage(StripeError, 'Payment failed'):
- donation = form.make_donation()
- self.assertIsNone(donation)
- # Hero shouldn't be created.
- self.assertFalse(DjangoHero.objects.exists())
diff --git a/fundraising/tests/test_views.py b/fundraising/tests/test_views.py
index 8a9144fe..381cde2b 100644
--- a/fundraising/tests/test_views.py
+++ b/fundraising/tests/test_views.py
@@ -9,9 +9,7 @@ from django.test import TestCase
from django.urls import reverse
from django_hosts.resolvers import reverse as django_hosts_reverse
-from ..exceptions import DonationError
-from ..forms import PaymentForm
-from ..models import DjangoHero, Donation, Payment
+from ..models import DjangoHero, Donation
class TestIndex(TestCase):
@@ -41,139 +39,27 @@ class TestCampaign(TestCase):
response = self.client.get(self.index_url)
self.assertContains(response, 'Anonymous Hero')
- def test_submitting_donation_form_missing_token(self):
- url = reverse('fundraising:donate')
- response = self.client.post(url, {'amount': 100})
- content = json.loads(response.content.decode())
- self.assertEqual(200, response.status_code)
- self.assertFalse(content['success'])
-
def test_submitting_donation_form_invalid_amount(self):
- url = reverse('fundraising:donate')
+ url = reverse('fundraising:donation-session')
response = self.client.post(url, {
'amount': 'superbad',
- 'stripe_token': 'test',
'interval': 'onetime',
})
content = json.loads(response.content.decode())
self.assertEqual(200, response.status_code)
self.assertFalse(content['success'])
- @patch('stripe.Customer.create')
- @patch('stripe.Charge.create')
- def test_submitting_donation_form(self, charge_create, customer_create):
- charge_create.return_value.id = 'XYZ'
- customer_create.return_value.id = '1234'
- self.client.post(reverse('fundraising:donate'), {
- 'amount': 100,
- 'stripe_token': 'test',
- 'token_type': 'card',
- 'receipt_email': 'test@example.com',
- 'interval': 'onetime',
- })
- donations = Donation.objects.all()
- self.assertEqual(donations.count(), 1)
- self.assertEqual(donations[0].subscription_amount, 100)
- self.assertEqual(donations[0].total_payments(), 100)
- self.assertEqual(donations[0].receipt_email, 'test@example.com')
- self.assertEqual(donations[0].stripe_subscription_id, '')
-
- @patch('stripe.Customer.create')
- @patch('stripe.Charge.create')
- def test_submitting_donation_form_recurring(self, charge_create, customer_create):
- customer_create.return_value.id = '1234'
- customer_create.return_value.subscriptions.create.return_value.id = 'XYZ'
- self.client.post(reverse('fundraising:donate'), {
- 'amount': 100,
- 'stripe_token': 'test',
- 'token_type': 'card',
- 'receipt_email': 'test@example.com',
- 'interval': 'monthly',
- })
- donations = Donation.objects.all()
- self.assertEqual(donations.count(), 1)
- self.assertEqual(donations[0].subscription_amount, 100)
- self.assertEqual(donations[0].receipt_email, 'test@example.com')
- self.assertEqual(donations[0].payment_set.count(), 0)
- customer_create.assert_called_with(email='test@example.com', source='test')
-
- @patch('stripe.Customer.create')
- @patch('stripe.Charge.create')
- def test_submitting_donation_form_onetime(self, charge_create, customer_create):
- charge_create.return_value.id = 'XYZ'
- customer_create.return_value.id = '1234'
- self.client.post(reverse('fundraising:donate'), {
+ @patch('stripe.checkout.Session.create')
+ def test_submitting_donation_form_valid(self, session_create):
+ session_create.return_value = {'id': 'TEST_ID'}
+ response = self.client.post(reverse('fundraising:donation-session'), {
'amount': 100,
- 'stripe_token': 'test',
- 'token_type': 'card',
'interval': 'onetime',
- 'receipt_email': 'django@example.com',
- })
- donations = Donation.objects.all()
- self.assertEqual(donations.count(), 1)
- self.assertEqual(donations[0].total_payments(), 100)
- self.assertEqual(donations[0].payment_set.first().stripe_charge_id, 'XYZ')
-
- @patch('stripe.Customer.create')
- @patch('stripe.Charge.create')
- def test_submitting_donation_form_error_handling(self, charge_create, customer_create):
- data = {
- 'amount': 100,
- 'stripe_token': 'xxxx',
- 'token_type': 'card',
- 'interval': 'onetime',
- 'receipt_email': 'django@example.com',
- }
- form = PaymentForm(data=data)
-
- self.assertTrue(form.is_valid())
-
- # some errors are shows as user facting DonationErrors to the user
- # some are bubbling up to raise a 500 to trigger Sentry reports
- errors = [
- [stripe.error.CardError, DonationError],
- [stripe.error.InvalidRequestError, DonationError],
- [stripe.error.APIConnectionError, DonationError],
- [stripe.error.AuthenticationError, None],
- [stripe.error.StripeError, None],
- [ValueError, None],
- ]
-
- for backend_exception, user_exception in errors:
- customer_create.side_effect = backend_exception('message', 'param', 'code')
-
- if user_exception is None:
- self.assertRaises(backend_exception, form.make_donation)
- else:
- response = self.client.post(reverse('fundraising:donate'), data)
- content = json.loads(response.content.decode())
- self.assertFalse(content['success'])
-
- @patch('fundraising.forms.PaymentForm.make_donation')
- def test_submitting_donation_form_valid(self, make_donation):
- amount = 100
- donor = DjangoHero.objects.create()
- donation = Donation.objects.create(
- donor=donor,
- stripe_customer_id='xxxx',
- )
- Payment.objects.create(
- donation=donation,
- amount=amount,
- stripe_charge_id='xxxx',
- )
- make_donation.return_value = donation
- response = self.client.post(reverse('fundraising:donate'), {
- 'amount': amount,
- 'stripe_token': 'xxxx',
- 'token_type': 'card',
- 'interval': 'onetime',
- 'receipt_email': 'django@example.com',
})
content = json.loads(response.content.decode())
self.assertEqual(response.status_code, 200)
self.assertTrue(content['success'])
- self.assertEqual(content['redirect'], donation.get_absolute_url())
+ self.assertEqual(content['sessionId'], 'TEST_ID')
@patch('stripe.Customer.retrieve')
def test_cancel_donation(self, retrieve_customer):
@@ -202,49 +88,11 @@ class TestCampaign(TestCase):
class TestThankYou(TestCase):
def setUp(self):
- self.hero = DjangoHero.objects.create(
- email='django@example.net',
- stripe_customer_id='1234',
- name='Under Dog',
- )
- self.donation = Donation.objects.create(
- donor=self.hero,
- stripe_customer_id='cu_123',
- receipt_email='django@example.com',
- )
- Payment.objects.create(
- donation=self.donation,
- amount='20',
- )
- self.url = reverse('fundraising:thank-you', args=[self.donation.pk])
- self.hero_form_data = {
- 'hero_type': DjangoHero.HERO_TYPE_CHOICES[1][0],
- 'name': 'Django Inc',
- 'location': 'Lawrence, KS',
- }
+ self.url = reverse('fundraising:thank-you')
def test_template(self):
response = self.client.get(self.url)
- self.assertEqual(response.context['form'].instance, self.donation.donor)
-
- @patch('stripe.Customer.retrieve')
- def test_update_hero(self, retrieve_customer):
- response = self.client.post(self.url, self.hero_form_data)
- self.assertRedirects(response, reverse('fundraising:index'))
- self.hero.refresh_from_db()
- self.assertEqual(self.hero.name, self.hero_form_data['name'])
- self.assertEqual(self.hero.location, self.hero_form_data['location'])
-
- retrieve_customer.assert_called_once_with(self.hero.stripe_customer_id)
- customer = retrieve_customer.return_value
- self.assertEqual(customer.description, self.hero.name)
- self.assertEqual(customer.email, self.hero.email)
- customer.save.assert_called_once_with()
-
- def test_create_hero_for_donation(self):
- with patch('stripe.Customer.retrieve'):
- response = self.client.post(self.url, self.hero_form_data)
- self.assertRedirects(response, reverse('fundraising:index'))
+ self.assertTemplateUsed(response, 'fundraising/thank-you.html')
class TestManageDonations(TestCase):
@@ -331,7 +179,7 @@ class TestWebhooks(TestCase):
donation = Donation.objects.get(id=self.donation.id)
self.assertEqual(donation.stripe_subscription_id, '')
self.assertEqual(len(mail.outbox), 1)
- expected_url = django_hosts_reverse('fundraising:donate')
+ expected_url = django_hosts_reverse('fundraising:index')
self.assertTrue(expected_url in mail.outbox[0].body)
@patch('stripe.Event.retrieve')
diff --git a/fundraising/urls.py b/fundraising/urls.py
index 16f5fd9b..6a0c35bf 100644
--- a/fundraising/urls.py
+++ b/fundraising/urls.py
@@ -5,9 +5,8 @@ from . import views
app_name = 'fundraising'
urlpatterns = [
path('', views.index, name='index'),
- path('donate/', views.donate, name='donate'),
- path('verify/', views.verify_captcha, name='verify-captcha'),
- path('thank-you/<donation>/', views.thank_you, name='thank-you'),
+ path('donation-session', views.configure_checkout_session, name='donation-session'),
+ path('thank-you/', views.thank_you, name='thank-you'),
path('manage-donations/<hero>/', views.manage_donations, name='manage-donations'),
path('manage-donations/<hero>/cancel/', views.cancel_donation, name='cancel-donation'),
path('receive-webhook/', views.receive_webhook, name='receive-webhook'),
diff --git a/fundraising/views.py b/fundraising/views.py
index 47b2cc6e..ca6a4a3a 100644
--- a/fundraising/views.py
+++ b/fundraising/views.py
@@ -1,5 +1,6 @@
import decimal
import json
+import logging
import stripe
from django.conf import settings
@@ -9,15 +10,15 @@ from django.forms.models import modelformset_factory
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
+from django.urls import reverse
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
-from .exceptions import DonationError
-from .forms import DjangoHeroForm, DonationForm, PaymentForm, ReCaptchaForm
-from .models import (
- LEADERSHIP_LEVEL_AMOUNT, DjangoHero, Donation, Payment, Testimonial,
-)
+from .forms import DjangoHeroForm, DonationForm, PaymentForm
+from .models import DjangoHero, Donation, Payment, Testimonial
+
+logger = logging.getLogger(__name__)
def index(request):
@@ -28,68 +29,88 @@ def index(request):
@require_POST
-def verify_captcha(request):
- form = ReCaptchaForm(request.POST)
+def configure_checkout_session(request):
+ """
+ Configure the payment session for Stripe.
+ Return the Session ID.
- if form.is_valid():
- data = {'success': True}
- else:
+ Key attributes are:
+
+ - mode: payment (for one-time charge) or subscription
+ - line_items: including price_data because users configure the donation
+ price.
+
+ TODOs
+
+ - Standard amounts could use active Prices, rather than ad-hoc price_data.
+ - Tie Stripe customers to site User accounts.
+ - If a user is logged in, we can create the session for the correct
+ customer.
+ - Stripe's documented flows are VERY keen that we create the customer
+ first, although the session will do that if we don't.
+ - Allow selecting currency. (Smaller task.) Users receive an additional
+ charge making payments in foreign currencies. Stripe will convert all
+ payments without further charge.
+ """
+
+ # Form data:
+ # - The interval: which determines the Product and the mode.
+ # - The amount: which goes to the Price data.
+ form = PaymentForm(request.POST)
+ if not form.is_valid():
data = {
'success': False,
'error': form.errors
}
- return JsonResponse(data)
+ return JsonResponse(data)
+ amount = form.cleaned_data["amount"]
+ interval = form.cleaned_data["interval"]
-@require_POST
-def donate(request):
- form = PaymentForm(request.POST)
+ product_details = settings.PRODUCTS[interval]
+ is_subscription = product_details.get('recurring', True)
- if form.is_valid():
- # Try to create the charge on Stripe's servers - this will charge the user's card
- try:
- donation = form.make_donation()
- except DonationError as donation_error:
- data = {
- 'success': False,
- 'error': str(donation_error),
- }
- else:
- data = {
- 'success': True,
- 'redirect': donation.get_absolute_url(),
- }
- else:
- data = {
- 'success': False,
- 'error': form.errors.as_json(),
+ price_data = {
+ 'currency': 'usd',
+ 'unit_amount': amount * 100,
+ 'product': product_details['product_id']
+ }
+ if is_subscription:
+ price_data['recurring'] = {
+ 'interval': product_details['interval'],
+ "interval_count": product_details["interval_count"],
}
- return JsonResponse(data)
-
-def thank_you(request, donation):
- donation = get_object_or_404(Donation, pk=donation)
- if request.method == 'POST':
- form = DjangoHeroForm(
- data=request.POST,
- files=request.FILES,
- instance=donation.donor,
+ try:
+ session = stripe.checkout.Session.create(
+ payment_method_types=['card'],
+ line_items=[{'price_data': price_data, 'quantity': 1}],
+ mode='subscription' if is_subscription else 'payment',
+ success_url=request.build_absolute_uri(
+ reverse('fundraising:thank-you')
+ ),
+ cancel_url=request.build_absolute_uri(
+ reverse('fundraising:index')
+ ),
+ # TODO: Drop this when updating API.
+ stripe_version="2020-08-27",
)
- if form.is_valid():
- form.save()
- messages.success(request, "Thank you! You're a Hero.")
- return redirect('fundraising:index')
- else:
- form = DjangoHeroForm(instance=donation.donor)
+ return JsonResponse({'success': True, "sessionId": session["id"]})
+ except Exception as e:
+ logger.exception('Error configuring Stripe session.')
+ return JsonResponse({'success': False, "error": str(e)})
- return render(request, 'fundraising/thank-you.html', {
- 'donation': donation,
- 'form': form,
- 'leadership_level_amount': LEADERSHIP_LEVEL_AMOUNT,
- })
+
+def thank_you(request):
+ """
+ Generic thank you page. In theory only reached via successful payment, but
+ no information is passed from Stripe to be sure.
+ """
+ return render(request, 'fundraising/thank-you.html', {})
+# TODO: Use Stripe's customer portal.
@never_cache
def manage_donations(request, hero):
hero = get_object_or_404(DjangoHero, pk=hero)
@@ -170,6 +191,7 @@ def receive_webhook(request):
return HttpResponse(422)
# For security, re-request the event object from Stripe.
+ # TODO: Verify shared secret here?
try:
event = stripe.Event.retrieve(data['id'])
except stripe.error.InvalidRequestError:
@@ -187,6 +209,7 @@ class WebhookHandler:
'invoice.payment_succeeded': self.payment_succeeded,
'invoice.payment_failed': self.payment_failed,
'customer.subscription.deleted': self.subscription_cancelled,
+ 'checkout.session.completed': self.checkout_session_completed,
}
handler = handlers.get(self.event.type, lambda: HttpResponse(422))
return handler()
@@ -229,3 +252,70 @@ class WebhookHandler:
settings.DEFAULT_FROM_EMAIL, [donation.donor.email])
return HttpResponse(status=204)
+
+ def get_donation_interval(self, session):
+ """
+ Helper to determine Donation.interval from completed Stripe Session.
+ """
+ if session.mode == 'payment':
+ return 'onetime'
+
+ # Access the interval via the attached price object.
+ # See https://stripe.com/docs/api/subscriptions/object
+ # TODO: remove stripe_version when updating account settings.
+ subscription = stripe.Subscription.retrieve(session.subscription, stripe_version="2020-08-27")
+ recurrance = subscription["items"].data[0].price.recurring
+ if recurrance.interval == 'year':
+ return 'yearly'
+ elif recurrance.interval_count == 3:
+ return 'quarterly'
+ else:
+ return 'monthly'
+
+ def checkout_session_completed(self):
+ """
+ > Occurs when a Checkout Session has been successfully completed.
+ https://stripe.com/docs/api/events/types#event_types-checkout.session.completed
+ """
+ session = self.event.data.object
+ # TODO: remove stripe_version when updating account settings.
+ customer = stripe.Customer.retrieve(session.customer, stripe_version="2020-08-27")
+ hero, _ = DjangoHero.objects.get_or_create(
+ stripe_customer_id=customer.id,
+ defaults={
+ "email": customer.email,
+ }
+ )
+ interval = self.get_donation_interval(session)
+ dollar_amount = decimal.Decimal(
+ session.amount_total / 100
+ ).quantize(decimal.Decimal('.01'), rounding=decimal.ROUND_HALF_UP)
+ donation = Donation.objects.create(
+ donor=hero,
+ stripe_customer_id=customer.id,
+ receipt_email=customer.email,
+ subscription_amount=dollar_amount,
+ interval=interval,
+ stripe_subscription_id=session.subscription or '',
+ )
+ if interval == 'onetime':
+ payment_intent = stripe.PaymentIntent.retrieve(session.payment_intent)
+ charge = payment_intent.charges.data[0]
+ donation.payment_set.create(
+ amount=dollar_amount,
+ stripe_charge_id=charge.id,
+ )
+
+ # Send an email message about managing your donation
+ message = render_to_string(
+ 'fundraising/email/thank-you.html',
+ {'donation': donation}
+ )
+ send_mail(
+ 'Thank you for your donation to the Django Software Foundation',
+ message,
+ settings.FUNDRAISING_DEFAULT_FROM_EMAIL,
+ [donation.receipt_email]
+ )
+
+ return HttpResponse(status=204)