diff --git a/Changelog.md b/Changelog.md index 0053dc49251339465bf4d6f6947df36bad43d989..3042de4b1a1e44397841e642d2a056b1df463a82 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,5 +2,11 @@ All notable changes to this project will be documented in this file. +## [0.9] - 2025-02-12 +- added repeated confirm click prevention +- added emails sent out when a payment is received with no associated order +- added metadata to invoices connecting to customer and order ids +- handling for when an order doesn't have its payment intent id committed to the db + ## [0.1] - 2025-01-09 - first build diff --git a/config-example.json b/config-example.json index e4fd6a521eac6340bd7467b2449e6253ddfe3e11..cd4f15baf86097c2359cc7cba752da06e6280cdd 100644 --- a/config-example.json +++ b/config-example.json @@ -1,8 +1,9 @@ { "STRIPE_API_KEY": "sk_test_Gx4mWEgHtCMr4DYMUIqfIrsz", "STRIPE_SIGNING_SECRET": "<retrieve this from the stripe workbench webhook destination details>", - "STRIPE_INVOICE_TEMPLATE_ID":"inrtem_1QfJ5aDSpyjzuj5pVVTuINiG", + "STRIPE_INVOICE_TEMPLATE_ID": "inrtem_1QfJ5aDSpyjzuj5pVVTuINiG", "STRIPE_TAX_RATE_ID": "txr_1QeddlDSpyjzuj5pPwUcMwTd", "VISIT_API_KEY": "<visit api key>", - "VISIT_EXPO_ID":"18lm2fafttito" -} + "VISIT_EXPO_ID": "18lm2fafttito", + "UNPROCESSED_PAYMENT_EMAIL_TO": [] +} \ No newline at end of file diff --git a/setup.py b/setup.py index 9cbaa5967b022fa3a5b1f798bb19a3d69b4e236b..9caed8a00265d611494318df2fe077f549b6aecd 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="stripe-checkout", - version="0.8", + version="0.9", author="GEANT", author_email="swd@geant.org", description="Stripe custom checkout support service", diff --git a/stripe_checkout/settings/base.py b/stripe_checkout/settings/base.py index 10bf54f2e047202171b4ee9a9ca2ea0b9f7fa2e9..c3f8c20d4717da30edc2a7644e30280a69b9c8af 100644 --- a/stripe_checkout/settings/base.py +++ b/stripe_checkout/settings/base.py @@ -138,3 +138,6 @@ LOGGING = { } STRIPE_WEBHOOK_ALLOWED_IPS = ["*"] + +DEFAULT_FROM_EMAIL = "noreply@geant.org" +UNPROCESSED_PAYMENT_EMAIL_TO = [] diff --git a/stripe_checkout/stripe_checkout/management/commands/processevents.py b/stripe_checkout/stripe_checkout/management/commands/processevents.py index d4c30d03ce8b723bc05c37e26292197d819afe6f..7753166d0991e4d210e5390fe5e2c5130fea23d4 100644 --- a/stripe_checkout/stripe_checkout/management/commands/processevents.py +++ b/stripe_checkout/stripe_checkout/management/commands/processevents.py @@ -1,8 +1,13 @@ 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 from stripe_checkout.stripe_checkout.visit import VisitorAPI +from django.conf import settings +from django.core.mail import send_mail PAYMENT_INTENT_SUCCEEDED = "payment_intent.succeeded" PAYMENT_INTENT_CANCELED = "payment_intent.canceled" @@ -13,6 +18,15 @@ VALID_EVENTS = [ RAISE_EXCEPTIONS = True +UNPROCESSED_PAYMENT_EMAIL_TEMPLATE = """\ +A payment was made in Stripe that could not be linked to a Visitor. Please process this +payment manually: + +ID: {pi_id} +Invoice ID: {invoice_id} +Amount: EUR {amount} +""" + class Command(BaseCommand): def handle(self, *args, **options): @@ -47,17 +61,20 @@ class Command(BaseCommand): .prefetch_related("items") .first() ) - if order is None: - return False if event.type == PAYMENT_INTENT_SUCCEEDED: - self._handle_pi_succeeded(event.object, order) + processed = self._handle_pi_succeeded(event.object, order) if event.type == PAYMENT_INTENT_CANCELED: - self._handle_pi_canceled(event.object, order) - order.save() - return True + processed = self._handle_pi_canceled(event.object, order) + if order is not None: + order.save() + return processed - def _handle_pi_succeeded(self, payment_intent: dict, order: 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: + return self._notify_unprocessed_payment(payment_intent) api = VisitorAPI() visitor = api.get_visitor(order.visitor_id) for item in order.items.all(): @@ -70,10 +87,47 @@ class Command(BaseCommand): api.update_visitor(visitor) order.paid = True + order.save() - def _handle_pi_canceled(self, invoice: dict, order: Order): + def _handle_pi_canceled(self, invoice: dict, order: Optional[Order]): + if order is None: + return False api = VisitorAPI() visitor = api.get_visitor(order.visitor_id) visitor.canceled = True api.update_visitor(visitor) order.canceled = True + return True + + def _notify_unprocessed_payment(self, payment_intent: dict): + send_mail_to = settings.UNPROCESSED_PAYMENT_EMAIL_TO + if not send_mail_to: + return False + if not isinstance(send_mail_to, list): + send_mail_to = [send_mail_to] + + send_mail( + subject="Unrecognized payment in Stripe", + from_email=None, + message=UNPROCESSED_PAYMENT_EMAIL_TEMPLATE.format( + pi_id=payment_intent["id"], + invoice_id=payment_intent["invoice"], + amount=payment_intent["amount"] // 100, + ), + 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 9654af0df53459d68672f8cd7770d32c905a816c..b9452a0400de9b23721af0d20715b6eaac1827e1 100644 --- a/stripe_checkout/stripe_checkout/shopping_cart.py +++ b/stripe_checkout/stripe_checkout/shopping_cart.py @@ -1,17 +1,24 @@ 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): self.all_prices = all_prices or stripe.get_product_prices() self.items = [ - ShoppingCartItem(item, stripe_price=self.all_prices[item.stripe_product_id]) + ShoppingCartItem(item, stripe_price=stripe_price) for item in items + if (stripe_price := self.all_prices.get(item.stripe_product_id)) is not None ] @classmethod @@ -23,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): @@ -109,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 0f970e456b4cd5204320b9f503151d51f5de11d0..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"], @@ -107,6 +111,8 @@ def create_invoice( for item in shopping_cart ], ) + # Stripe automatically finalizes the invoice when sending it (as an email), so we + # don't need to do this as a separate step invoice = stripe.Invoice.send_invoice(invoice["id"]) return invoice 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.py b/stripe_checkout/stripe_checkout/visit.py index d3415829dc9ef4390c7a2546ddaaddcaf39771b8..1832d2144755a20753c3c4246d39919b2348f604 100644 --- a/stripe_checkout/stripe_checkout/visit.py +++ b/stripe_checkout/stripe_checkout/visit.py @@ -25,7 +25,11 @@ class VisitorAPI: return response.json() def list_visitors(self): - all_visitors = self._request("get", f"{BASE_URL}/visitors/{self.expo_id}") + all_visitors = self._request( + "get", + f"{BASE_URL}/visitors/{self.expo_id}", + params={"showDeleted": "false"}, + ) return [ Visitor.from_api(data) for data in filter(lambda v: not v["deleted"], all_visitors) 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 cdee88564c4fcc97c61cf3669318d5ba1c9b4862..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", @@ -245,6 +246,7 @@ def config_file(stripe_api_key, visit_api_key, visit_expo_id, tmp_path): "STRIPE_SIGNING_SECRET": "stripe-signing-secret", "STRIPE_INVOICE_TEMPLATE_ID": "stripe-invoice-template-id", "STRIPE_TAX_RATE_ID": "stripe-tax-rate-id", + "UNPROCESSED_PAYMENT_EMAIL_TO": ["test@geant.org"], } ) ) diff --git a/test/test_processevent.py b/test/test_processevent.py index b95ae79daac92d248f2d29bb2fd72d51b9874b83..98546c708e4d3691a766f161e024b7a8b7d510ca 100644 --- a/test/test_processevent.py +++ b/test/test_processevent.py @@ -1,17 +1,31 @@ +import stripe from django.core.management import call_command import pytest import responses from stripe_checkout.stripe_checkout.models import Event, Order, PricedItem from stripe_checkout.stripe_checkout.shopping_cart import ShoppingCart +from django.core import mail + + +INVOICE_ID = "in_5555" +PI_ID = "pi_12345" def a_payment_intent(): - return {"id": "pi_12345"} + return {"id": PI_ID, "amount": 100, "invoice": INVOICE_ID} def an_invoice(): - return {"id": "stripe-invoice", "payment_intent": a_payment_intent()["id"]} + return {"id": INVOICE_ID, "payment_intent": PI_ID} + + +@pytest.fixture +def emails(settings): + assert settings.EMAIL_BACKEND == "django.core.mail.backends.locmem.EmailBackend" + mail.outbox = [] + yield mail.outbox + mail.outbox = [] @pytest.fixture @@ -24,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 @@ -101,3 +115,47 @@ def test_updates_visitor_canceled(order, default_visitor): assert "PAID" not in default_visitor["tags"] assert "CANCELLED" in default_visitor["tags"] + + +@responses.activate +@pytest.mark.django_db +def test_sends_email_on_unknown_payment(emails): + + Event.objects.create( + payload={ + "type": "payment_intent.succeeded", + "data": {"object": a_payment_intent()}, + } + ) + call_command("processevents") + + assert len(emails) == 1 + assert emails[0].subject == "Unrecognized payment in Stripe" + 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, + }