Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Bulk] Switch to Stripe Checkout. #162

Merged
merged 1 commit into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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']:
dracos marked this conversation as resolved.
Show resolved Hide resolved
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': settings.BULK_LOOKUP_PRICE_ID,
'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_LOOKUP_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