Skip to content

Commit

Permalink
[Bulk] Switch to Stripe Checkout.
Browse files Browse the repository at this point in the history
  • Loading branch information
dracos committed Oct 9, 2024
1 parent 70cbf9c commit f423707
Show file tree
Hide file tree
Showing 13 changed files with 95 additions and 180 deletions.
23 changes: 0 additions & 23 deletions bulk_lookup/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@

import re
from django import forms
from django.utils.crypto import get_random_string
from django.utils.encoding import smart_str

import stripe
from ukpostcodeutils.validation import is_valid_postcode

from .models import OutputOption
Expand Down Expand Up @@ -114,24 +112,3 @@ class PersonalDetailsForm(forms.Form):
description = forms.CharField(
required=False,
help_text='You can add a note here if you need to keep track of multiple files.')
charge_id = forms.CharField(widget=forms.HiddenInput, required=False)

def __init__(self, amount, free, *args, **kwargs):
self.amount = amount
self.free = free
super(PersonalDetailsForm, self).__init__(*args, **kwargs)

def clean(self):
"""
We get here if we're doing it for free, or if they have paid already
"""
super(PersonalDetailsForm, self).clean()
# If we're doing this for free
if self.free:
self.cleaned_data['charge_id'] = 'r_%s' % get_random_string(12)
elif not self.cleaned_data['charge_id']:
raise forms.ValidationError("You need to pay for the lookup")
else:
intent = stripe.PaymentIntent.retrieve(self.cleaned_data['charge_id'])
if intent.status != 'succeeded':
raise forms.ValidationError("You need to pay for the lookup")
3 changes: 2 additions & 1 deletion bulk_lookup/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,14 @@ def needs_processing(self):
"""
Bulk lookups that need processing:
- Haven't already started
- Have been paid (or got something in charge_id)
- Have failed less than MAX_RETRIES times already
- Last failed more than RETRY_INTERVAL minutes ago (if they've ever failed)
"""
retry_minutes = settings.RETRY_INTERVAL
retry_time = timezone.now() - timedelta(minutes=retry_minutes)
retry_count = settings.MAX_RETRIES
return self.filter(started__isnull=True, error_count__lt=retry_count).filter(
return self.filter(started__isnull=True, error_count__lt=retry_count).exclude(charge_id='').filter(
models.Q(last_error__lt=retry_time) | models.Q(last_error=None)
).distinct()

Expand Down
2 changes: 1 addition & 1 deletion bulk_lookup/templates/bulk_lookup/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ <h1>Spreadsheet data mapper</h1>
you request, sending you the results by email.</p>

<p>This service is free for ‘Metropolis’ MapIt subscribers: for all others
there is a charge of £{{ amount }} per file processed. You will be charged at the end of
there is a charge of £{{ BULK_LOOKUP_AMOUNT }} per file processed. You will be charged at the end of
the upload process: payment is by debit or credit card.</p>
{% endblock %}

Expand Down
18 changes: 18 additions & 0 deletions bulk_lookup/templates/bulk_lookup/payment.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{% extends 'bulk_lookup/base.html' %}

{% block title %}Where should we send the results?{% endblock %}

{% block content %}
<h1>Payment</h1>

<div id="checkout"></div>

<script src="https://js.stripe.com/v3"></script>
<script>
var stripe = Stripe('{{ STRIPE_PUBLIC_KEY }}', { apiVersion: '{{ STRIPE_API_VERSION }}' });
var fetchClientSecret = () => '{{ clientSecret }}';
stripe.initEmbeddedCheckout({ fetchClientSecret }).then(function(checkout) {
checkout.mount('#checkout');
});
</script>
{% endblock %}
110 changes: 0 additions & 110 deletions bulk_lookup/templates/bulk_lookup/personal_details.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,117 +14,7 @@ <h1>Step 4: Where should we send the results?</h1>
<br>You can pay via credit or debit card.
Payment is handled by Stripe, a secure online service.
</p>

<div class="account-form__field">
<div class="account-form__label"><label for="id_personal_details-name">Name on card:</label></div>
<div class="account-form__input"><input type="text" name="personal_details-name" required="" id="id_personal_details-name"></div>
</div>

<div class="account-form__field">
<div class="account-form__label"><label for="card-element">Credit or debit card details:</label></div>
<div id="card-element" class="form-control"><!-- A Stripe Element will be inserted here. --></div>
<div id="card-errors" role="alert"></div>
</div>

{% else %}
<p>We’ll match {{ num_good_rows }} postcodes.</p>
{% endif %}
{% endblock %}

{% block extra_form_end %}
{% if price %}

<script src="https://js.stripe.com/v3"></script>
<script>
var stripe = Stripe('{{ STRIPE_PUBLIC_KEY }}', { apiVersion: '{{ STRIPE_API_VERSION }}' });
var elements = stripe.elements();
var card = elements.create('card');
card.mount('#card-element');

function showError(msg) {
var displayError = document.getElementById('card-errors');
displayError.innerHTML = msg ? '<div class="account-form__errors">' + msg + '</div>' : '';
}

card.addEventListener('change', function(event) {
showError(event.error ? event.error.message : '');
});

var button_clicked;

document.getElementById('btn-back').addEventListener('click', function(e) {
button_clicked = 'back';
});
document.getElementById('btn-submit').addEventListener('click', function(e) {
button_clicked = 'submit';
});

document.forms[0].addEventListener('submit', function(e) {
if (button_clicked === 'back') {
return;
}
e.preventDefault();
var name = document.getElementById('id_personal_details-name'),
email = document.getElementById('id_personal_details-email'),
desc = document.getElementById('id_personal_details-description');
if (!email.value) {
$(email).parents('.account-form__field').addClass('account-form__field--error');
$(email).parent().after('<div class="account-form__errors">Please provide your email</div>');
}
if (!name.value) {
$(name).parents('.account-form__field').addClass('account-form__field--error');
$(name).parent().after('<div class="account-form__errors">Please provide your name</div>');
}
if (!email.value || !name.value) {
return;
}

document.getElementById('btn-submit').disabled = true;
document.getElementById('spinner').style.display = 'inline-block';
stripe.createPaymentMethod('card', card, {
billing_details: {
name: name.value
}
}).then(handleStripeResult);
});

function handleStripeResult(result) {
if (result.error) {
document.getElementById('btn-submit').disabled = false;
document.getElementById('spinner').style.display = 'none';
showError(result.error.message);
} else {
var data;
var formElement = document.forms[0];
var formData = new FormData(formElement);
if (result.paymentMethod) {
formData.append("payment_method_id", result.paymentMethod.id);
} else {
formData.append("payment_intent_id", result.paymentIntent.id);
}
var request = new XMLHttpRequest();
request.open("POST", "/bulk/ajax-confirm");
request.addEventListener("load", function() {
var json = JSON.parse(request.responseText);
handleServerResponse(json);
});
request.send(formData);
}
}

function handleServerResponse(response) {
if (response.error) {
document.getElementById('btn-submit').disabled = false;
document.getElementById('spinner').style.display = 'none';
showError(response.error);
} else if (response.requires_action) {
stripe.handleCardAction(response.payment_intent_client_secret).then(handleStripeResult);
} else {
document.getElementById('id_personal_details-charge_id').value = response.charge_id;
document.forms[0].submit();
}
}

</script>
{% endif %}
{% endblock %}
3 changes: 1 addition & 2 deletions bulk_lookup/urls.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
from django.urls import path

from .views import AjaxConfirmView, WizardView, FinishedView
from .views import WizardView, FinishedView

urlpatterns = [
path('', WizardView.as_view(), name='home'),
path('ajax-confirm', AjaxConfirmView, name='ajax-confirm'),
path('<int:pk>/<token>', FinishedView.as_view(), name='finished'),
]
95 changes: 56 additions & 39 deletions bulk_lookup/views.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import re

from django.conf import settings
from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import PermissionDenied
from django.core.files.storage import FileSystemStorage
from django.shortcuts import redirect
from django.shortcuts import redirect, render
from django.views.generic import DetailView
from django.utils.crypto import get_random_string
from django.utils.encoding import force_str
from formtools.wizard.views import SessionWizardView
import stripe

from .models import BulkLookup, cache
from .forms import CSVForm, PostcodeFieldForm, OutputOptionsForm, PersonalDetailsForm

from mapit.shortcuts import output_json
from mapit_mysociety_org.mixins import NeverCacheMixin
from subscriptions.views import StripeObjectMixin

Expand Down Expand Up @@ -63,14 +64,6 @@ def get_form_kwargs(self, step):
kwargs = super(WizardView, self).get_form_kwargs(step)
if step == 'postcode_field':
kwargs['bulk_lookup'] = self.get_cleaned_csv_data
elif step == 'personal_details':
kwargs['amount'] = settings.BULK_LOOKUP_PRICE
kwargs['free'] = False
if self.request.user.is_authenticated:
if not self.object:
self.object = self.get_object()
if self.object and self.object.plan.id == settings.PRICING[-1]['plan']:
kwargs['free'] = True
return kwargs

def get_form_initial(self, step):
Expand All @@ -89,7 +82,6 @@ def get_form_initial(self, step):
def get_context_data(self, form, **kwargs):
context = super(WizardView, self).get_context_data(form=form, **kwargs)
if self.steps.current == 'csv':
context['amount'] = settings.BULK_LOOKUP_PRICE
return context

bulk_lookup = self.get_cleaned_csv_data
Expand All @@ -107,45 +99,60 @@ def get_context_data(self, form, **kwargs):
raise WizardError
context['num_good_rows'] = pc_data['num_rows'] - pc_data['bad_rows']
if self.steps.current == 'personal_details':
context['price'] = settings.BULK_LOOKUP_PRICE
context['price'] = settings.BULK_LOOKUP_AMOUNT
if self.object and self.object.plan.id == settings.PRICING[-1]['plan']:
context['price'] = 0
return context

def done(self, form_list, form_dict, **kwargs):
data = {}

for form_obj in form_list:
data.update(form_obj.cleaned_data)

free = False
if self.request.user.is_authenticated:
if not self.object:
self.object = self.get_object()
if self.object and self.object.plan.id == settings.PRICING[-1]['plan']:
free = True
data['charge_id'] = 'r_%s' % get_random_string(12)

output_options = data.pop('output_options')
data.pop('num_rows')
bulk_lookup = BulkLookup.objects.create(**data)
bulk_lookup.output_options.add(*output_options)
return redirect('finished', pk=bulk_lookup.id, token=bulk_lookup.charge_id)


# The flow is from https://docs.stripe.com/payments/accept-a-payment-synchronously
def AjaxConfirmView(request):
intent = None
try:
if 'payment_method_id' in request.POST:
intent = stripe.PaymentIntent.create(
payment_method=request.POST['payment_method_id'],
amount=settings.BULK_LOOKUP_PRICE * 100,
currency='gbp',
description='[MapIt] %s' % (request.POST['personal_details-description'] or 'Bulk lookup',),
receipt_email=request.POST['personal_details-email'],
confirmation_method='manual',
confirm=True,
payment_method_types=['card'],
)
elif 'payment_intent_id' in request.POST:
intent = stripe.PaymentIntent.confirm(request.POST['payment_intent_id'])
except stripe.error.CardError as e:
return output_json({'error': e.user_message}, 200)

data, code = generate_payment_response(intent)
return output_json(data, code)

if not free:
context = payment_view_context(bulk_lookup)
return render(self.request, 'bulk_lookup/payment.html', context)
else:
return redirect('finished', pk=bulk_lookup.id, token=bulk_lookup.charge_id)


def payment_view_context(bulk_lookup):
context = {}
return_url = ''.join([
'https://',
get_current_site(None).domain,
f'/bulk/{bulk_lookup.id}/{{CHECKOUT_SESSION_ID}}',
])
description = bulk_lookup.description or 'Bulk lookup'
session = stripe.checkout.Session.create(
line_items=[{
'price': 'price_1Q7y4zLoAAr9vgdbwPHVezZ6',
'tax_rates': [settings.STRIPE_TAX_RATE],
'quantity': 1,
}],
customer_email=bulk_lookup.email,
metadata={"mapit_id": bulk_lookup.id},
mode='payment',
ui_mode='embedded',
return_url=return_url,
payment_intent_data={"description": f'[MapIt] {description}'},
)
context['clientSecret'] = session.client_secret
return context


def generate_payment_response(intent):
Expand All @@ -166,6 +173,16 @@ class FinishedView(NeverCacheMixin, DetailView):

def get_object(self, *args, **kwargs):
obj = super(FinishedView, self).get_object(*args, **kwargs)
if self.kwargs['token'] != obj.charge_id:
raise PermissionDenied

if self.kwargs['token'].startswith('r_'):
if self.kwargs['token'] != obj.charge_id:
raise PermissionDenied
else:
checkout_session = stripe.checkout.Session.retrieve(self.kwargs['token'], expand=['line_items'])
if checkout_session.metadata['mapit_id'] != str(obj.id):
raise PermissionDenied
if checkout_session.payment_status != 'unpaid' and not obj.charge_id:
obj.charge_id = self.kwargs['token']
obj.save()

return obj
3 changes: 3 additions & 0 deletions conf/general.yml-example
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ STRIPE_PUBLIC_KEY: ''
STRIPE_API_VERSION: ''
STRIPE_TAX_RATE: ''

BULK_LOOKUP_AMOUNT: 50
BULK_LOOKUK_PRICE_ID: 'price_123'

# Mapped to Django's DEBUG and TEMPLATE_DEBUG settings. Optional, defaults to True.
DEBUG: True

Expand Down
2 changes: 1 addition & 1 deletion mapit_mysociety_org/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ def add_settings(request):
return {
'CONTACT_EMAIL': settings.CONTACT_EMAIL,
'PRICING': settings.PRICING,
'BULK_LOOKUP_PRICE': settings.BULK_LOOKUP_PRICE,
'BULK_LOOKUP_AMOUNT': settings.BULK_LOOKUP_AMOUNT,
'STRIPE_PUBLIC_KEY': settings.STRIPE_PUBLIC_KEY,
'STRIPE_API_VERSION': settings.STRIPE_API_VERSION,
}
3 changes: 2 additions & 1 deletion mapit_mysociety_org/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,8 @@ def allow_migrate(self, db, app_label, model_name=None, **hints):
# Bulk lookup
MAX_RETRIES = 3
RETRY_INTERVAL = 0
BULK_LOOKUP_PRICE = 50
BULK_LOOKUP_AMOUNT = config.get('BULK_LOOKUP_AMOUNT')
BULK_LOOKUP_PRICE_ID = config.get('BULK_LOOKUP_PRICE_ID')

# API subscriptions
PRICING = [
Expand Down
2 changes: 1 addition & 1 deletion mapit_mysociety_org/templates/account/_form_fields.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
{% endcomment %}
{% if form.charitable_tick %}
{% include 'account/_form_fields_signup.html' %}
{% elif form.email and not form.charge_id %}
{% elif form.email and not form.description %}
{% include 'account/_form_field.html' with field=form.email %}
{% include 'account/_form_field.html' with field=form.password %}
{% include 'account/_form_field.html' with field=form.password_confirm %}
Expand Down
Loading

0 comments on commit f423707

Please sign in to comment.