diff options
| author | Carlton Gibson <carlton@noumenal.es> | 2021-01-04 12:37:28 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2021-01-04 12:37:28 +0100 |
| commit | cc80dfa1864009580ec67e05a15ed52c6000e3a9 (patch) | |
| tree | b087cc51c6cc5b6380d6811cec00fa12875b92b7 /fundraising | |
| parent | 46f36024123ff0d853017451dd53704c7a20d76d (diff) | |
Update Stripe integration for main fundraising page.
Thanks to Mariusz Felisiak for review.
Diffstat (limited to 'fundraising')
| -rw-r--r-- | fundraising/forms.py | 137 | ||||
| -rw-r--r-- | fundraising/models.py | 1 | ||||
| -rw-r--r-- | fundraising/templatetags/fundraising_extras.py | 4 | ||||
| -rw-r--r-- | fundraising/tests/test_forms.py | 110 | ||||
| -rw-r--r-- | fundraising/tests/test_views.py | 172 | ||||
| -rw-r--r-- | fundraising/urls.py | 5 | ||||
| -rw-r--r-- | fundraising/views.py | 194 |
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) |
