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,
+    }