diff --git a/stripe_checkout/stripe_checkout/management/commands/processevents.py b/stripe_checkout/stripe_checkout/management/commands/processevents.py index c87940da33a349409a3d10c69a2d7a7bd3a3c491..7753166d0991e4d210e5390fe5e2c5130fea23d4 100644 --- a/stripe_checkout/stripe_checkout/management/commands/processevents.py +++ b/stripe_checkout/stripe_checkout/management/commands/processevents.py @@ -1,5 +1,7 @@ import traceback from typing import Optional + +import stripe from django.core.management.base import BaseCommand from django.db import transaction from stripe_checkout.stripe_checkout.models import Event, ItemKind, Order @@ -69,6 +71,8 @@ class Command(BaseCommand): return processed 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: return self._notify_unprocessed_payment(payment_intent) api = VisitorAPI() @@ -83,6 +87,7 @@ class Command(BaseCommand): api.update_visitor(visitor) order.paid = True + order.save() def _handle_pi_canceled(self, invoice: dict, order: Optional[Order]): if order is None: @@ -112,3 +117,17 @@ class Command(BaseCommand): recipient_list=send_mail_to, ) 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 diff --git a/stripe_checkout/stripe_checkout/migrations/0008_order_created_alter_order_stripe_id.py b/stripe_checkout/stripe_checkout/migrations/0008_order_created_alter_order_stripe_id.py new file mode 100644 index 0000000000000000000000000000000000000000..266ec3ab91f54d476254ace825d7af55749e88f4 --- /dev/null +++ b/stripe_checkout/stripe_checkout/migrations/0008_order_created_alter_order_stripe_id.py @@ -0,0 +1,27 @@ +# 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), + ), + ] diff --git a/stripe_checkout/stripe_checkout/models.py b/stripe_checkout/stripe_checkout/models.py index 6e62f7d757c0f7019d1f895b013cdf2da8f6df79..3bbd90adda9732c33d0eda4981157ace681331cf 100644 --- a/stripe_checkout/stripe_checkout/models.py +++ b/stripe_checkout/stripe_checkout/models.py @@ -70,7 +70,7 @@ class ExchangeRate(models.Model): class Order(models.Model): 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") paid = models.BooleanField(default=False) canceled = models.BooleanField(default=False) @@ -81,6 +81,7 @@ class Order(models.Model): null=True, related_name="orders", ) + created = models.DateTimeField(auto_now_add=True) class Event(models.Model): diff --git a/stripe_checkout/stripe_checkout/shopping_cart.py b/stripe_checkout/stripe_checkout/shopping_cart.py index 0123494ba3220bdb851a004e2e0d4a6c75a6aab4..b9452a0400de9b23721af0d20715b6eaac1827e1 100644 --- a/stripe_checkout/stripe_checkout/shopping_cart.py +++ b/stripe_checkout/stripe_checkout/shopping_cart.py @@ -1,9 +1,15 @@ from __future__ import annotations +from datetime import timedelta from typing import List, Sequence +from django.db.models import Q +from django.utils import timezone + from . import models, stripe_ as stripe +ORDER_PROCESSING_TIMEOUT_DURATION = timedelta(minutes=10) + class ShoppingCart: def __init__(self, items: Sequence[models.PricedItem], all_prices: dict = None): @@ -24,14 +30,12 @@ class ShoppingCart: return cls(items=to_pay) - def create_order(self, visitor: dict, stripe_obj: dict): - payment_intent = stripe_obj["payment_intent"] - assert payment_intent is not None - - order = models.Order.objects.create( - visitor_id=visitor["id"], stripe_id=payment_intent - ) + def create_order(self, visitor: dict, invoice: dict | None = None) -> models.Order: + order = models.Order.objects.create(visitor_id=visitor["id"]) order.items.set(sc_item.item for sc_item in self) + if invoice: + order.stripe_id = invoice["payment_intent"] + order.save() return order def __getitem__(self, index): @@ -110,3 +114,11 @@ def get_answers(visitor, question_id) -> set[str]: def has_answer(visitor, question_id, answer_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() diff --git a/stripe_checkout/stripe_checkout/static/main.css b/stripe_checkout/stripe_checkout/static/main.css index 2a40bb31f767e3f12a5142d70bda05bf2f788479..5a75fcde4ffa889b6e9182cac3adb2239d653ea5 100644 --- a/stripe_checkout/stripe_checkout/static/main.css +++ b/stripe_checkout/stripe_checkout/static/main.css @@ -128,6 +128,12 @@ body { 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 { width: 100%; } diff --git a/stripe_checkout/stripe_checkout/stripe_.py b/stripe_checkout/stripe_checkout/stripe_.py index 27e06f4c589af6bbd6fa6089e88841768e05adb9..4b96623059d16fcfd4b780fe4ac64021468dfccf 100644 --- a/stripe_checkout/stripe_checkout/stripe_.py +++ b/stripe_checkout/stripe_checkout/stripe_.py @@ -78,7 +78,10 @@ def create_invoice( purchase_order=None, vat_number=None, gbp_exchange_rate: Optional[ExchangeRate] = None, + metadata=None, ): + if metadata is None: + metadata = {} stripe.api_key = settings.STRIPE_API_KEY custom_fields = [] if purchase_order: @@ -99,6 +102,7 @@ def create_invoice( days_until_due=30, custom_fields=custom_fields, rendering={"template": settings.STRIPE_INVOICE_TEMPLATE_ID}, + metadata=metadata, ) stripe.Invoice.add_lines( invoice["id"], diff --git a/stripe_checkout/stripe_checkout/templates/checkout-pending.html b/stripe_checkout/stripe_checkout/templates/checkout-pending.html new file mode 100644 index 0000000000000000000000000000000000000000..c4a8cc599a3d6e5ec71f0849d28392f6674e9349 --- /dev/null +++ b/stripe_checkout/stripe_checkout/templates/checkout-pending.html @@ -0,0 +1,17 @@ +{% 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 diff --git a/stripe_checkout/stripe_checkout/templates/checkout.html b/stripe_checkout/stripe_checkout/templates/checkout.html index 28c196c90997d3d830be3402aa805cbfcf9c3e5f..ed2305569b13062e0f5b9392074d5628be5fa0df 100644 --- a/stripe_checkout/stripe_checkout/templates/checkout.html +++ b/stripe_checkout/stripe_checkout/templates/checkout.html @@ -31,7 +31,8 @@ {% endfor %} {% csrf_token %} <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> </form> </div> diff --git a/stripe_checkout/stripe_checkout/visit_views.py b/stripe_checkout/stripe_checkout/visit_views.py index d994451e01f4db92993c576b7809bce988e52f81..05a908699f4cb121908e87c470b76d3aa099ac7f 100644 --- a/stripe_checkout/stripe_checkout/visit_views.py +++ b/stripe_checkout/stripe_checkout/visit_views.py @@ -10,7 +10,7 @@ from django.views.decorators.http import require_GET, require_http_methods, requ from . import stripe_ as stripe from .models import Event, ExchangeRate -from .shopping_cart import ShoppingCart +from .shopping_cart import ShoppingCart, has_outstanding_order from .utils import whitelist_ips from .visit import VisitorAPI @@ -28,17 +28,33 @@ class PaymentDetailsForm(forms.Form): @require_http_methods(["GET", "POST"]) def checkout(request, visitor_id): - form = None 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) shopping_cart = get_shopping_cart(visitor_id) if request.POST and shopping_cart: if (form := PaymentDetailsForm(request.POST)).is_valid(): data = form.cleaned_data - obj = create_invoice(visitor, data) - shopping_cart.create_order(visitor, stripe_obj=obj) - response = redirect(obj["hosted_invoice_url"]) + order = shopping_cart.create_order(visitor) + invoice = create_invoice(visitor, data, shopping_cart, order) + order.stripe_id = invoice["payment_intent"] + order.save() + response = redirect(invoice["hosted_invoice_url"]) response.status_code = 303 return response @@ -58,17 +74,19 @@ def checkout(request, visitor_id): ) -def create_invoice(visitor, data): - shopping_cart = get_shopping_cart(visitor) +def create_invoice(visitor, data, shopping_cart, order): customer = stripe.get_or_create_customer(visitor) exchange_rate = ExchangeRate.objects.order_by("-date").first() + invoice_metadata = {"order_id": order.id, "visitor_id": visitor["id"]} + return stripe.create_invoice( shopping_cart, customer, purchase_order=data["purchase_order"], vat_number=data["vat_number"], gbp_exchange_rate=exchange_rate, + metadata=invoice_metadata, ) diff --git a/test/conftest.py b/test/conftest.py index bdd4f6b4103bff09b6a72552825c2d31d23656a4..cc449131fa641cc4e248b12c7253575706c9c4aa 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -187,6 +187,7 @@ def mock_stripe(item_data): patch("stripe.Customer.create", 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.retrieve", return_value={"id": "stripe-inv-id"}), patch("stripe.InvoiceItem.create", return_value={"id": "stripe-invitem-id"}), patch( "stripe.Invoice.send_invoice", diff --git a/test/test_processevent.py b/test/test_processevent.py index fe4f0e4d0167e4c537f71b975c915172e045fc99..98546c708e4d3691a766f161e024b7a8b7d510ca 100644 --- a/test/test_processevent.py +++ b/test/test_processevent.py @@ -1,3 +1,4 @@ +import stripe from django.core.management import call_command import pytest import responses @@ -37,7 +38,7 @@ def order(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() return order @@ -133,3 +134,28 @@ def test_sends_email_on_unknown_payment(emails): assert PI_ID in emails[0].body assert INVOICE_ID 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 diff --git a/test/test_shopping_cart.py b/test/test_shopping_cart.py index 7cda1a7cdc584513f3cac8f2c270a2557414d329..947bd9c9c3d6ea02c28cc159739cbf3b2f0c9e6f 100644 --- a/test/test_shopping_cart.py +++ b/test/test_shopping_cart.py @@ -27,13 +27,13 @@ def test_create_order_with_items(default_visitor): } ] cart = ShoppingCart.from_visitor(default_visitor) - stripe_obj = {"id": "stripe-invoice", "payment_intent": "pi_12345"} - cart.create_order(default_visitor, stripe_obj=stripe_obj) + invoice = {"id": "stripe-invoice", "payment_intent": "pi_12345"} + cart.create_order(default_visitor, invoice=invoice) all_orders = Order.objects.all() assert len(all_orders) == 1 order = all_orders[0] 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 not order.paid @@ -49,8 +49,8 @@ def test_price(default_visitor): @pytest.mark.django_db def test_no_double_ordering_unless_canceled(default_visitor): cart = ShoppingCart.from_visitor(default_visitor) - stripe_obj = {"id": "stripe-invoice", "payment_intent": "pi_12345"} - order = cart.create_order(default_visitor, stripe_obj) + invoice = {"id": "stripe-invoice", "payment_intent": "pi_12345"} + order = cart.create_order(default_visitor, invoice) cart = ShoppingCart.from_visitor(default_visitor) assert len(cart) == 0 diff --git a/test/test_visit.py b/test/test_visit.py index 4f0206da7f274c7a447156ba14a4514c76bf1ca4..56635ce47abb0f995494cd416112414de478f4c0 100644 --- a/test/test_visit.py +++ b/test/test_visit.py @@ -84,3 +84,42 @@ def test_event_webhook_disallowed_when_not_whitelisted(client, settings): content_type="application/json", ) 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, + }