Skip to content
Snippets Groups Projects
Commit 90c91c90 authored by Pelle Koster's avatar Pelle Koster
Browse files

Merge branch 'feature/multiple-order-prevention' into 'develop'

Make order processing more resilient

See merge request geant-swd/stripe-checkout!3
parents cd0a320e 9f5941af
Branches
Tags
No related merge requests found
Showing
with 193 additions and 22 deletions
import traceback import traceback
from typing import Optional from typing import Optional
import stripe
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import transaction from django.db import transaction
from stripe_checkout.stripe_checkout.models import Event, ItemKind, Order from stripe_checkout.stripe_checkout.models import Event, ItemKind, Order
...@@ -69,6 +71,8 @@ class Command(BaseCommand): ...@@ -69,6 +71,8 @@ class Command(BaseCommand):
return processed return processed
def _handle_pi_succeeded(self, payment_intent: dict, order: Optional[Order]): def _handle_pi_succeeded(self, payment_intent: dict, order: Optional[Order]):
if order is None:
order = self._infer_order_from_payment_intent(payment_intent)
if order is None: if order is None:
return self._notify_unprocessed_payment(payment_intent) return self._notify_unprocessed_payment(payment_intent)
api = VisitorAPI() api = VisitorAPI()
...@@ -83,6 +87,7 @@ class Command(BaseCommand): ...@@ -83,6 +87,7 @@ class Command(BaseCommand):
api.update_visitor(visitor) api.update_visitor(visitor)
order.paid = True order.paid = True
order.save()
def _handle_pi_canceled(self, invoice: dict, order: Optional[Order]): def _handle_pi_canceled(self, invoice: dict, order: Optional[Order]):
if order is None: if order is None:
...@@ -112,3 +117,17 @@ class Command(BaseCommand): ...@@ -112,3 +117,17 @@ class Command(BaseCommand):
recipient_list=send_mail_to, recipient_list=send_mail_to,
) )
return True return True
def _infer_order_from_payment_intent(self, payment_intent: dict) -> Optional[Order]:
if payment_intent["invoice"] is None:
return None
invoice = stripe.Invoice.retrieve(payment_intent["invoice"])
if metadata := invoice.get("metadata"):
order_id = metadata.get("order_id")
visitor_id = metadata.get("visitor_id")
if order_id and visitor_id:
order = Order.objects.filter(pk=order_id, visitor_id=visitor_id).first()
order.stripe_id = payment_intent["id"]
order.save()
return order
return None
# Generated by Django 4.2.19 on 2025-02-11 12:14
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
("stripe_checkout", "0007_order_canceled"),
]
operations = [
migrations.AddField(
model_name="order",
name="created",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AlterField(
model_name="order",
name="stripe_id",
field=models.CharField(blank=True, max_length=255),
),
]
...@@ -70,7 +70,7 @@ class ExchangeRate(models.Model): ...@@ -70,7 +70,7 @@ class ExchangeRate(models.Model):
class Order(models.Model): class Order(models.Model):
visitor_id = models.CharField(max_length=40, blank=False) visitor_id = models.CharField(max_length=40, blank=False)
stripe_id = models.CharField(max_length=255, blank=False, unique=True) stripe_id = models.CharField(max_length=255, blank=True, unique=False)
items = models.ManyToManyField(to=PricedItem, related_name="order") items = models.ManyToManyField(to=PricedItem, related_name="order")
paid = models.BooleanField(default=False) paid = models.BooleanField(default=False)
canceled = models.BooleanField(default=False) canceled = models.BooleanField(default=False)
...@@ -81,6 +81,7 @@ class Order(models.Model): ...@@ -81,6 +81,7 @@ class Order(models.Model):
null=True, null=True,
related_name="orders", related_name="orders",
) )
created = models.DateTimeField(auto_now_add=True)
class Event(models.Model): class Event(models.Model):
......
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
from typing import List, Sequence from typing import List, Sequence
from django.db.models import Q
from django.utils import timezone
from . import models, stripe_ as stripe from . import models, stripe_ as stripe
ORDER_PROCESSING_TIMEOUT_DURATION = timedelta(minutes=10)
class ShoppingCart: class ShoppingCart:
def __init__(self, items: Sequence[models.PricedItem], all_prices: dict = None): def __init__(self, items: Sequence[models.PricedItem], all_prices: dict = None):
...@@ -24,14 +30,12 @@ class ShoppingCart: ...@@ -24,14 +30,12 @@ class ShoppingCart:
return cls(items=to_pay) return cls(items=to_pay)
def create_order(self, visitor: dict, stripe_obj: dict): def create_order(self, visitor: dict, invoice: dict | None = None) -> models.Order:
payment_intent = stripe_obj["payment_intent"] order = models.Order.objects.create(visitor_id=visitor["id"])
assert payment_intent is not None
order = models.Order.objects.create(
visitor_id=visitor["id"], stripe_id=payment_intent
)
order.items.set(sc_item.item for sc_item in self) order.items.set(sc_item.item for sc_item in self)
if invoice:
order.stripe_id = invoice["payment_intent"]
order.save()
return order return order
def __getitem__(self, index): def __getitem__(self, index):
...@@ -110,3 +114,11 @@ def get_answers(visitor, question_id) -> set[str]: ...@@ -110,3 +114,11 @@ def get_answers(visitor, question_id) -> set[str]:
def has_answer(visitor, question_id, answer_id): def has_answer(visitor, question_id, answer_id):
return answer_id in get_answers(visitor, question_id) return answer_id in get_answers(visitor, question_id)
def has_outstanding_order(visitor_id) -> bool:
offset_time = timezone.now() - ORDER_PROCESSING_TIMEOUT_DURATION
orders = models.Order.objects.filter(
created__gte=offset_time, visitor_id=visitor_id
).filter(Q(stripe_id__isnull=True) | Q(stripe_id__exact=""))
return orders.exists()
...@@ -128,6 +128,12 @@ body { ...@@ -128,6 +128,12 @@ body {
background-color: color-mix(in srgb, var(--primary), black 10%); background-color: color-mix(in srgb, var(--primary), black 10%);
} }
.button:disabled {
background-color: color-mix(in srgb, var(--primary), white 20%);
color: color-mix(in srgb, var(--text-primary), white 20%);
cursor: progress;
}
#checkout-form { #checkout-form {
width: 100%; width: 100%;
} }
......
...@@ -78,7 +78,10 @@ def create_invoice( ...@@ -78,7 +78,10 @@ def create_invoice(
purchase_order=None, purchase_order=None,
vat_number=None, vat_number=None,
gbp_exchange_rate: Optional[ExchangeRate] = None, gbp_exchange_rate: Optional[ExchangeRate] = None,
metadata=None,
): ):
if metadata is None:
metadata = {}
stripe.api_key = settings.STRIPE_API_KEY stripe.api_key = settings.STRIPE_API_KEY
custom_fields = [] custom_fields = []
if purchase_order: if purchase_order:
...@@ -99,6 +102,7 @@ def create_invoice( ...@@ -99,6 +102,7 @@ def create_invoice(
days_until_due=30, days_until_due=30,
custom_fields=custom_fields, custom_fields=custom_fields,
rendering={"template": settings.STRIPE_INVOICE_TEMPLATE_ID}, rendering={"template": settings.STRIPE_INVOICE_TEMPLATE_ID},
metadata=metadata,
) )
stripe.Invoice.add_lines( stripe.Invoice.add_lines(
invoice["id"], invoice["id"],
......
{% extends "tnc-base.html" %}
{% load static %}
{% block title %}
Checkout Pending
{% endblock title %}
{% block content %}
<div class="checkout flex center">
<div class="card flex center column">
<p>
Your order is currently being processed. You should receive an email with an invoice shortly.
</p>
<p>
If you haven't received an invoice within the next 10 minutes, please refresh this page and try again.
</p>
</div>
</div>
{% endblock content %}
\ No newline at end of file
...@@ -31,7 +31,8 @@ ...@@ -31,7 +31,8 @@
{% endfor %} {% endfor %}
{% csrf_token %} {% csrf_token %}
<div class="confirm flex center"> <div class="confirm flex center">
<input type="submit" class="button bg-primary text-primary" value="confirm"> <input type="submit" class="button bg-primary text-primary" value="confirm"
onclick="this.disabled = true;this.value = 'Please wait...';this.form.submit()">
</div> </div>
</form> </form>
</div> </div>
......
...@@ -10,7 +10,7 @@ from django.views.decorators.http import require_GET, require_http_methods, requ ...@@ -10,7 +10,7 @@ from django.views.decorators.http import require_GET, require_http_methods, requ
from . import stripe_ as stripe from . import stripe_ as stripe
from .models import Event, ExchangeRate from .models import Event, ExchangeRate
from .shopping_cart import ShoppingCart from .shopping_cart import ShoppingCart, has_outstanding_order
from .utils import whitelist_ips from .utils import whitelist_ips
from .visit import VisitorAPI from .visit import VisitorAPI
...@@ -28,17 +28,33 @@ class PaymentDetailsForm(forms.Form): ...@@ -28,17 +28,33 @@ class PaymentDetailsForm(forms.Form):
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def checkout(request, visitor_id): def checkout(request, visitor_id):
form = None
status_code = 200 status_code = 200
if has_outstanding_order(visitor_id):
if request.POST:
response = redirect(
"stripe_checkout:checkout-entrypoint", visitor_id=visitor_id
)
return response
else:
return render(
request,
"checkout-pending.html",
context={"body_classes": "tnc-background"},
status=status_code,
)
form = None
visitor = get_visitor(visitor_id) visitor = get_visitor(visitor_id)
shopping_cart = get_shopping_cart(visitor_id) shopping_cart = get_shopping_cart(visitor_id)
if request.POST and shopping_cart: if request.POST and shopping_cart:
if (form := PaymentDetailsForm(request.POST)).is_valid(): if (form := PaymentDetailsForm(request.POST)).is_valid():
data = form.cleaned_data data = form.cleaned_data
obj = create_invoice(visitor, data) order = shopping_cart.create_order(visitor)
shopping_cart.create_order(visitor, stripe_obj=obj) invoice = create_invoice(visitor, data, shopping_cart, order)
response = redirect(obj["hosted_invoice_url"]) order.stripe_id = invoice["payment_intent"]
order.save()
response = redirect(invoice["hosted_invoice_url"])
response.status_code = 303 response.status_code = 303
return response return response
...@@ -58,17 +74,19 @@ def checkout(request, visitor_id): ...@@ -58,17 +74,19 @@ def checkout(request, visitor_id):
) )
def create_invoice(visitor, data): def create_invoice(visitor, data, shopping_cart, order):
shopping_cart = get_shopping_cart(visitor)
customer = stripe.get_or_create_customer(visitor) customer = stripe.get_or_create_customer(visitor)
exchange_rate = ExchangeRate.objects.order_by("-date").first() exchange_rate = ExchangeRate.objects.order_by("-date").first()
invoice_metadata = {"order_id": order.id, "visitor_id": visitor["id"]}
return stripe.create_invoice( return stripe.create_invoice(
shopping_cart, shopping_cart,
customer, customer,
purchase_order=data["purchase_order"], purchase_order=data["purchase_order"],
vat_number=data["vat_number"], vat_number=data["vat_number"],
gbp_exchange_rate=exchange_rate, gbp_exchange_rate=exchange_rate,
metadata=invoice_metadata,
) )
......
...@@ -187,6 +187,7 @@ def mock_stripe(item_data): ...@@ -187,6 +187,7 @@ def mock_stripe(item_data):
patch("stripe.Customer.create", return_value={"id": "stripe-cus-id"}), patch("stripe.Customer.create", return_value={"id": "stripe-cus-id"}),
patch("stripe.Customer.modify", return_value={"id": "stripe-cus-id"}), patch("stripe.Customer.modify", return_value={"id": "stripe-cus-id"}),
patch("stripe.Invoice.create", return_value={"id": "stripe-inv-id"}), patch("stripe.Invoice.create", return_value={"id": "stripe-inv-id"}),
patch("stripe.Invoice.retrieve", return_value={"id": "stripe-inv-id"}),
patch("stripe.InvoiceItem.create", return_value={"id": "stripe-invitem-id"}), patch("stripe.InvoiceItem.create", return_value={"id": "stripe-invitem-id"}),
patch( patch(
"stripe.Invoice.send_invoice", "stripe.Invoice.send_invoice",
......
import stripe
from django.core.management import call_command from django.core.management import call_command
import pytest import pytest
import responses import responses
...@@ -37,7 +38,7 @@ def order(default_visitor): ...@@ -37,7 +38,7 @@ def order(default_visitor):
} }
] ]
cart = ShoppingCart.from_visitor(default_visitor) cart = ShoppingCart.from_visitor(default_visitor)
order = cart.create_order(default_visitor, stripe_obj=an_invoice()) order = cart.create_order(default_visitor, invoice=an_invoice())
order.save() order.save()
return order return order
...@@ -133,3 +134,28 @@ def test_sends_email_on_unknown_payment(emails): ...@@ -133,3 +134,28 @@ def test_sends_email_on_unknown_payment(emails):
assert PI_ID in emails[0].body assert PI_ID in emails[0].body
assert INVOICE_ID in emails[0].body assert INVOICE_ID in emails[0].body
assert "EUR 1" in emails[0].body assert "EUR 1" in emails[0].body
@responses.activate
@pytest.mark.django_db
def test_process_invoice_paid_with_no_matching_event(order):
order.stripe_id = ""
order.save()
stripe.Invoice.retrieve.return_value = {
"id": INVOICE_ID,
"payment_intent": PI_ID,
"metadata": {"order_id": order.pk, "visitor_id": order.visitor_id},
}
Event.objects.create(
payload={
"type": "payment_intent.succeeded",
"data": {"object": a_payment_intent()},
}
)
call_command("processevents")
order.refresh_from_db()
assert order.stripe_id == PI_ID
assert order.paid
...@@ -27,13 +27,13 @@ def test_create_order_with_items(default_visitor): ...@@ -27,13 +27,13 @@ def test_create_order_with_items(default_visitor):
} }
] ]
cart = ShoppingCart.from_visitor(default_visitor) cart = ShoppingCart.from_visitor(default_visitor)
stripe_obj = {"id": "stripe-invoice", "payment_intent": "pi_12345"} invoice = {"id": "stripe-invoice", "payment_intent": "pi_12345"}
cart.create_order(default_visitor, stripe_obj=stripe_obj) cart.create_order(default_visitor, invoice=invoice)
all_orders = Order.objects.all() all_orders = Order.objects.all()
assert len(all_orders) == 1 assert len(all_orders) == 1
order = all_orders[0] order = all_orders[0]
assert order.visitor_id == default_visitor["id"] assert order.visitor_id == default_visitor["id"]
assert order.stripe_id == stripe_obj["payment_intent"] assert order.stripe_id == invoice["payment_intent"]
assert len(order.items.all()) == 2 assert len(order.items.all()) == 2
assert not order.paid assert not order.paid
...@@ -49,8 +49,8 @@ def test_price(default_visitor): ...@@ -49,8 +49,8 @@ def test_price(default_visitor):
@pytest.mark.django_db @pytest.mark.django_db
def test_no_double_ordering_unless_canceled(default_visitor): def test_no_double_ordering_unless_canceled(default_visitor):
cart = ShoppingCart.from_visitor(default_visitor) cart = ShoppingCart.from_visitor(default_visitor)
stripe_obj = {"id": "stripe-invoice", "payment_intent": "pi_12345"} invoice = {"id": "stripe-invoice", "payment_intent": "pi_12345"}
order = cart.create_order(default_visitor, stripe_obj) order = cart.create_order(default_visitor, invoice)
cart = ShoppingCart.from_visitor(default_visitor) cart = ShoppingCart.from_visitor(default_visitor)
assert len(cart) == 0 assert len(cart) == 0
......
...@@ -84,3 +84,42 @@ def test_event_webhook_disallowed_when_not_whitelisted(client, settings): ...@@ -84,3 +84,42 @@ def test_event_webhook_disallowed_when_not_whitelisted(client, settings):
content_type="application/json", content_type="application/json",
) )
assert rv.status_code == 403 assert rv.status_code == 403
@responses.activate
@pytest.mark.django_db
def test_prevent_duplicate_order_submissions_post(client, visitor_id):
Order.objects.create(visitor_id=visitor_id)
rv = client.post(f"/checkout/{visitor_id}/", data={"payment_method": "invoice"})
assert rv.status_code == 302
assert rv.headers["Location"] == f"/checkout/{visitor_id}/"
@responses.activate
@pytest.mark.django_db
def test_prevent_duplicate_order_submissions_get(client, visitor_id):
Order.objects.create(visitor_id=visitor_id)
rv = client.get(f"/checkout/{visitor_id}/")
assert rv.status_code == 200
assert "Your order is currently being processed" in rv.content.decode()
@responses.activate
@pytest.mark.django_db
def test_order_is_created_during_checkout_even_during_failure(client, visitor_id):
assert not Order.objects.exists()
stripe.Customer.search.side_effect = ValueError
with pytest.raises(ValueError):
client.post(f"/checkout/{visitor_id}/", data={"payment_method": "invoice"})
assert Order.objects.exists()
@responses.activate
@pytest.mark.django_db
def test_invoice_has_correct_metadata(client, visitor_id):
client.post(f"/checkout/{visitor_id}/", data={"payment_method": "invoice"})
call_args = stripe.Invoice.create.call_args[1]
assert call_args["metadata"] == {
"order_id": Order.objects.first().id,
"visitor_id": visitor_id,
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment