Skip to content

Commit

Permalink
Add events system (#135)
Browse files Browse the repository at this point in the history
Adds events system, and adds support to call webhooks when certain transaction events happen. Fixes some bugs in the cart and checkout processes.
  • Loading branch information
jkachel committed Sep 27, 2024
1 parent 28a75ec commit 63e8a3e
Show file tree
Hide file tree
Showing 24 changed files with 711 additions and 26 deletions.
1 change: 0 additions & 1 deletion cart/templates/cart.html
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@
function clear_cart(event) {
event.preventDefault();

var slug = "{{ basket.integrated_system.slug }}";
var csrfmiddlewaretoken = encodeURIComponent(document.querySelector("input[name='csrfmiddlewaretoken']").value);

axios.delete(`/api/v0/payments/baskets/clear/`, { headers: { "X-CSRFToken": csrfmiddlewaretoken } })
Expand Down
11 changes: 10 additions & 1 deletion cart/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,18 @@
from payments.models import Basket, Order
from system_meta.models import Product
from unified_ecommerce.constants import (
POST_SALE_SOURCE_REDIRECT,
USER_MSG_TYPE_PAYMENT_ACCEPTED,
USER_MSG_TYPE_PAYMENT_CANCELLED,
USER_MSG_TYPE_PAYMENT_DECLINED,
USER_MSG_TYPE_PAYMENT_ERROR,
USER_MSG_TYPE_PAYMENT_ERROR_UNKNOWN,
)
from unified_ecommerce.plugin_manager import get_plugin_manager
from unified_ecommerce.utils import redirect_with_user_message

log = logging.getLogger(__name__)
pm = get_plugin_manager()


class CartView(LoginRequiredMixin, TemplateView):
Expand Down Expand Up @@ -149,6 +152,8 @@ def post(self, request):
processed_order_state = api.process_cybersource_payment_response(
request, order
)
pm.hook.post_sale(order_id=order.id, source=POST_SALE_SOURCE_REDIRECT)

return self.post_checkout_redirect(processed_order_state, request)
else:
return self.post_checkout_redirect(order.state, request)
Expand Down Expand Up @@ -183,5 +188,9 @@ def get(self, request):
return render(
request,
self.template_name,
{"checkout_payload": checkout_payload, "form": checkout_payload["payload"]},
{
"checkout_payload": checkout_payload,
"form": checkout_payload["payload"],
"debug_mode": settings.MITOL_UE_PAYMENT_INTERSTITIAL_DEBUG,
},
)
2 changes: 1 addition & 1 deletion payments/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def has_change_permission(self, request, obj=None): # noqa: ARG002
@display(description="Purchaser")
def get_purchaser(self, obj: models.Order):
"""Return the purchaser information for the order"""
return f"{obj.purchaser.name} ({obj.purchaser.email})"
return f"{obj.purchaser.email}"

def get_queryset(self, request):
"""Filter only to pending orders"""
Expand Down
5 changes: 4 additions & 1 deletion payments/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,10 @@ def generate_checkout_payload(request):
)

for line_item in order.lines.all():
log.debug("Adding line item %s", line_item)
field_dict = line_item.product_version.field_dict
system = IntegratedSystem.objects.get(pk=field_dict["system_id"])
sku = f"{system.slug}-{field_dict['sku']}"
sku = f"{system.slug}!{field_dict['sku']}"
gateway_order.items.append(
GatewayCartItem(
code=sku,
Expand Down Expand Up @@ -89,6 +90,8 @@ def generate_checkout_payload(request):

callback_uri = request.build_absolute_uri(reverse("checkout-result-callback"))

log.debug("Gateway order for %s: %s", order.reference_number, gateway_order)

return PaymentGateway.start_payment(
settings.ECOMMERCE_DEFAULT_PAYMENT_GATEWAY,
gateway_order,
Expand Down
13 changes: 13 additions & 0 deletions payments/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""
Constants for the payments app.
"""

PAYMENT_HOOK_ACTION_PRE_SALE = "presale"
PAYMENT_HOOK_ACTION_POST_SALE = "postsale"
PAYMENT_HOOK_ACTION_POST_REFUND = "postrefund"

PAYMENT_HOOK_ACTIONS = [
PAYMENT_HOOK_ACTION_PRE_SALE,
PAYMENT_HOOK_ACTION_POST_SALE,
PAYMENT_HOOK_ACTION_POST_REFUND,
]
Empty file added payments/hooks/__init__.py
Empty file.
90 changes: 90 additions & 0 deletions payments/hooks/post_sale.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Post-sale hook implementations for payments."""

import logging

import pluggy
import requests

from payments.constants import PAYMENT_HOOK_ACTION_POST_SALE
from payments.models import Order
from payments.serializers.v0 import WebhookBase, WebhookBaseSerializer, WebhookOrder

hookimpl = pluggy.HookimplMarker("unified_ecommerce")


class PostSaleSendEmails:
"""Send email when the order is fulfilled."""

@hookimpl
def post_sale(self, order_id, source):
"""Send email when the order is fulfilled."""
log = logging.getLogger(__name__)

msg = "Sending email for order %s with source %s"
log.info(msg, order_id, source)


class IntegratedSystemWebhooks:
"""Figures out what webhook endpoints to call, and calls them."""

def post_sale_impl(self, order_id, source):
"""Call the webhook endpoints for the order."""

log = logging.getLogger(__name__)

log.info(
"Calling webhook endpoints for order %s with source %s", order_id, source
)

order = Order.objects.prefetch_related("lines", "lines__product_version").get(
pk=order_id
)

systems = [
product.system
for product in [
line.product_version._object_version.object # noqa: SLF001
for line in order.lines.all()
]
]

for system in systems:
system_webhook_url = system.webhook_url
system_slug = system.slug
if system_webhook_url:
log.info(
("Calling webhook endpoint %s for order %s with source %s"),
system_webhook_url,
order_id,
source,
)

order_info = WebhookOrder(
order=order,
lines=[
line
for line in order.lines.all()
if line.product.system.slug == system_slug
],
)

webhook_data = WebhookBase(
type=PAYMENT_HOOK_ACTION_POST_SALE,
system_key=system.api_key,
user=order.purchaser,
data=order_info,
)

serializer = WebhookBaseSerializer(webhook_data)

requests.post(
system_webhook_url,
json=serializer.data,
timeout=30,
)

@hookimpl
def post_sale(self, order_id, source):
"""Call the implementation of this, so we can test it more easily."""

self.post_sale_impl(order_id, source)
111 changes: 111 additions & 0 deletions payments/hooks/post_sale_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Tests for post-sale hooks."""

import pytest
import reversion
from reversion.models import Version

from payments.constants import PAYMENT_HOOK_ACTION_POST_SALE
from payments.factories import LineFactory, OrderFactory
from payments.hooks.post_sale import IntegratedSystemWebhooks
from payments.models import Order
from payments.serializers.v0 import WebhookBase, WebhookBaseSerializer, WebhookOrder
from system_meta.factories import ProductFactory
from system_meta.models import IntegratedSystem
from unified_ecommerce.constants import (
POST_SALE_SOURCE_BACKOFFICE,
POST_SALE_SOURCE_REDIRECT,
)

pytestmark = [pytest.mark.django_db]


@pytest.fixture()
def fulfilled_order():
"""Create a fulfilled order."""

order = OrderFactory.create(state=Order.STATE.FULFILLED)

with reversion.create_revision():
product = ProductFactory.create()

product_version = Version.objects.get_for_object(product).first()
LineFactory.create(order=order, product_version=product_version)

return order


@pytest.mark.parametrize(
"source", [POST_SALE_SOURCE_BACKOFFICE, POST_SALE_SOURCE_REDIRECT]
)
def test_integrated_system_webhook(mocker, fulfilled_order, source):
"""Test fire the webhook."""

mocked_request = mocker.patch("requests.post")
webhook = IntegratedSystemWebhooks()
system_id = fulfilled_order.lines.first().product_version.field_dict["system_id"]
system = IntegratedSystem.objects.get(pk=system_id)

order_info = WebhookOrder(
order=fulfilled_order,
lines=fulfilled_order.lines.all(),
)

webhook_data = WebhookBase(
type=PAYMENT_HOOK_ACTION_POST_SALE,
system_key=system.api_key,
user=fulfilled_order.purchaser,
data=order_info,
)

serialized_webhook_data = WebhookBaseSerializer(webhook_data)

webhook.post_sale_impl(fulfilled_order.id, source)

mocked_request.assert_called_with(
system.webhook_url, json=serialized_webhook_data.data, timeout=30
)


@pytest.mark.parametrize(
"source", [POST_SALE_SOURCE_BACKOFFICE, POST_SALE_SOURCE_REDIRECT]
)
def test_integrated_system_webhook_multisystem(mocker, fulfilled_order, source):
"""Test fire the webhook with an order with lines from >1 system."""

with reversion.create_revision():
product = ProductFactory.create()

product_version = Version.objects.get_for_object(product).first()
LineFactory.create(order=fulfilled_order, product_version=product_version)

mocked_request = mocker.patch("requests.post")
webhook = IntegratedSystemWebhooks()

serialized_calls = []

for system in IntegratedSystem.objects.all():
order_info = WebhookOrder(
order=fulfilled_order,
lines=[
line
for line in fulfilled_order.lines.all()
if line.product.system.slug == system.slug
],
)

webhook_data = WebhookBase(
type=PAYMENT_HOOK_ACTION_POST_SALE,
system_key=system.api_key,
user=fulfilled_order.purchaser,
data=order_info,
)

serialized_order = WebhookBaseSerializer(webhook_data).data
serialized_calls.append(
mocker.call(system.webhook_url, json=serialized_order, timeout=30)
)

webhook.post_sale_impl(fulfilled_order.id, source)

assert mocked_request.call_count == 2
mocked_request.assert_has_calls(serialized_calls, any_order=True)
36 changes: 36 additions & 0 deletions payments/hookspecs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Hookspecs for the payments app."""
# ruff: noqa: ARG001

import pluggy

hookspec = pluggy.HookspecMarker("unified_ecommerce")


@hookspec
def post_sale(order_id, source):
"""
Trigger post-sale events.
This happens when the order has been completed, either via the browser
redirect or via the back-office webhook. The caller should specify the
source from the POST_SALE_SOURCES list (in unified_ecommerce.constants).
Args:
order_id (int): ID of the order that has been completed.
source (str): Source of the order that has been completed; one of POST_SALE_SOURCES.
"""


@hookspec
def post_refund(order_id, source):
"""
Trigger post-refund events.
This happens when the order has been refunded. These generally should just
come back from the back-office webhook but the source option is specified
in case that changes.
Args:
order_id (int): ID of the order that has been completed.
source (str): Source of the order that has been completed; one of POST_SALE_SOURCES.
"""
Loading

0 comments on commit 63e8a3e

Please sign in to comment.