From 2f56b5a8a9540fba1c5e0715d9fa923584f09fd8 Mon Sep 17 00:00:00 2001 From: cp-at-mit Date: Wed, 16 Oct 2024 11:50:37 -0400 Subject: [PATCH] 5654 update apis for shopping cartbasket (#160) * Commit changes * running * add back cart app * change * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Working checkout * Hold * format * Format * ruff * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Code review comment --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- cart/urls.py | 7 +- cart/views.py | 116 +------------- config/apisix/apisix.yaml | 2 +- payments/api.py | 2 +- payments/api_test.py | 4 +- payments/views/v0/__init__.py | 142 +++++++++++++++++- payments/views/v0/urls.py | 6 + ...atedsystem_payment_process_redirect_url.py | 20 +++ system_meta/models.py | 1 + 9 files changed, 174 insertions(+), 126 deletions(-) create mode 100644 system_meta/migrations/0005_integratedsystem_payment_process_redirect_url.py diff --git a/cart/urls.py b/cart/urls.py index 9849db70..88aa1da5 100644 --- a/cart/urls.py +++ b/cart/urls.py @@ -2,14 +2,9 @@ from django.urls import path -from cart.views import CartView, CheckoutCallbackView, CheckoutInterstitialView +from cart.views import CartView, CheckoutInterstitialView urlpatterns = [ - path( - r"checkout/result/", - CheckoutCallbackView.as_view(), - name="checkout-result-callback", - ), path( "checkout/to_payment", CheckoutInterstitialView.as_view(), diff --git a/cart/views.py b/cart/views.py index e9e86340..c0c6e719 100644 --- a/cart/views.py +++ b/cart/views.py @@ -5,28 +5,14 @@ from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import ObjectDoesNotExist -from django.db import transaction from django.http import HttpResponse from django.http.request import HttpRequest from django.shortcuts import render -from django.urls import reverse -from django.utils.decorators import method_decorator -from django.views.decorators.csrf import csrf_exempt -from django.views.generic import TemplateView, View -from mitol.payment_gateway.api import PaymentGateway +from django.views.generic import TemplateView from payments import api -from payments.models import Basket, Order +from payments.models import Basket 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.utils import redirect_with_user_message log = logging.getLogger(__name__) @@ -60,104 +46,6 @@ def get(self, request: HttpRequest) -> HttpResponse: ) -@method_decorator(csrf_exempt, name="dispatch") -class CheckoutCallbackView(View): - """ - Handles the redirect from the payment gateway after the user has completed - checkout. This may not always happen as the redirect back to the app - occasionally fails. If it does, then the payment gateway should trigger - things via the backoffice webhook. - """ - - def post_checkout_redirect(self, order_state, request): - """ - Redirect the user with a message depending on the provided state. - - Args: - - order_state (str): the order state to consider - - order (Order): the order itself - - request (HttpRequest): the request - - Returns: HttpResponse - """ - if order_state == Order.STATE.CANCELED: - return redirect_with_user_message( - reverse("cart"), {"type": USER_MSG_TYPE_PAYMENT_CANCELLED} - ) - elif order_state == Order.STATE.ERRORED: - return redirect_with_user_message( - reverse("cart"), {"type": USER_MSG_TYPE_PAYMENT_ERROR} - ) - elif order_state == Order.STATE.DECLINED: - return redirect_with_user_message( - reverse("cart"), {"type": USER_MSG_TYPE_PAYMENT_DECLINED} - ) - elif order_state == Order.STATE.FULFILLED: - return redirect_with_user_message( - reverse("cart"), - { - "type": USER_MSG_TYPE_PAYMENT_ACCEPTED, - }, - ) - else: - if not PaymentGateway.validate_processor_response( - settings.ECOMMERCE_DEFAULT_PAYMENT_GATEWAY, request - ): - log.info("Could not validate payment response for order") - else: - processor_response = PaymentGateway.get_formatted_response( - settings.ECOMMERCE_DEFAULT_PAYMENT_GATEWAY, request - ) - log.error( - ( - "Checkout callback unknown error for transaction_id %s, state" - " %s, reason_code %s, message %s, and ProcessorResponse %s" - ), - processor_response.transaction_id, - order_state, - processor_response.response_code, - processor_response.message, - processor_response, - ) - return redirect_with_user_message( - reverse("cart"), - {"type": USER_MSG_TYPE_PAYMENT_ERROR_UNKNOWN}, - ) - - def post(self, request): - """ - Handle successfully completed transactions. - - This does a handful of things: - 1. Verifies the incoming payload, which should be signed by the - processor - 2. Finds and fulfills the order in the system (which should also then - clear out the stored basket) - 3. Perform any enrollments, account status changes, etc. - """ - - with transaction.atomic(): - order = api.get_order_from_cybersource_payment_response(request) - if order is None: - return HttpResponse("Order not found") - - # Only process the response if the database record in pending status - # If it is, then we can process the response as per usual. - # If it isn't, then we just need to redirect the user with the - # proper message. - - if order.state == Order.STATE.PENDING: - processed_order_state = api.process_cybersource_payment_response( - request, - order, - POST_SALE_SOURCE_REDIRECT, - ) - - return self.post_checkout_redirect(processed_order_state, request) - else: - return self.post_checkout_redirect(order.state, request) - - class CheckoutInterstitialView(LoginRequiredMixin, TemplateView): """ Redirects the user to the payment gateway. diff --git a/config/apisix/apisix.yaml b/config/apisix/apisix.yaml index 7b3985e0..8880718c 100644 --- a/config/apisix/apisix.yaml +++ b/config/apisix/apisix.yaml @@ -12,7 +12,7 @@ routes: upstream_id: 1 plugins: {} uris: - - "/checkout/result/*" + - "/api/v0/payments/checkout/result/*" - "/static/*" - "/api/v0/schema/*" - id: 2 diff --git a/payments/api.py b/payments/api.py index d32c5d1b..a7f84161 100644 --- a/payments/api.py +++ b/payments/api.py @@ -91,7 +91,7 @@ def generate_checkout_payload(request): ), } - callback_uri = request.build_absolute_uri(reverse("checkout-result-callback")) + callback_uri = request.build_absolute_uri(reverse("v0:checkout-result-callback")) log.debug("Gateway order for %s: %s", order.reference_number, gateway_order) diff --git a/payments/api_test.py b/payments/api_test.py index db485163..3ecfa8e0 100644 --- a/payments/api_test.py +++ b/payments/api_test.py @@ -428,7 +428,7 @@ def test_process_cybersource_payment_response(rf, mocker, user, products): assert order.reference_number == payload["req_reference_number"] - request = rf.post(reverse("checkout-result-callback"), payload) + request = rf.post(reverse("v0:checkout-result-callback"), payload) # This is checked on the BackofficeCallbackView and CheckoutCallbackView # POST endpoints since we expect to receive a response to both from @@ -467,7 +467,7 @@ def test_process_cybersource_payment_decline_response( assert order.reference_number == payload["req_reference_number"] - request = rf.post(reverse("checkout-result-callback"), payload) + request = rf.post(reverse("v0:checkout-result-callback"), payload) # This is checked on the BackofficeCallbackView and CheckoutCallbackView # POST endpoints since we expect to receive a response to both from diff --git a/payments/views/v0/__init__.py b/payments/views/v0/__init__.py index d34bdd22..cd9c157c 100644 --- a/payments/views/v0/__init__.py +++ b/payments/views/v0/__init__.py @@ -4,11 +4,13 @@ from django.core.exceptions import ObjectDoesNotExist from django.db import transaction -from django.http import Http404 +from django.http import Http404, HttpResponse from django.shortcuts import redirect from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt +from django.views.generic import View from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema +from mitol.payment_gateway.api import PaymentGateway from rest_framework import mixins, status from rest_framework.decorators import action, api_view, permission_classes from rest_framework.generics import ListCreateAPIView @@ -30,7 +32,17 @@ OrderHistorySerializer, ) from system_meta.models import IntegratedSystem, Product -from unified_ecommerce.constants import POST_SALE_SOURCE_BACKOFFICE +from unified_ecommerce import settings +from unified_ecommerce.constants import ( + POST_SALE_SOURCE_BACKOFFICE, + 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.utils import redirect_with_user_message log = logging.getLogger(__name__) @@ -223,9 +235,135 @@ def start_checkout(self, request): except ObjectDoesNotExist: return Response("No basket", status=status.HTTP_406_NOT_ACCEPTABLE) + if ( + "country_blocked" in payload + or "no_checkout" in payload + or "purchased_same_courserun" in payload + or "purchased_non_upgradeable_courserun" in payload + or "invalid_discounts" in payload + ): + return payload["response"] + return Response(payload) +@method_decorator(csrf_exempt, name="dispatch") +class CheckoutCallbackView(View): + """ + Handles the redirect from the payment gateway after the user has completed + checkout. This may not always happen as the redirect back to the app + occasionally fails. If it does, then the payment gateway should trigger + things via the backoffice webhook. + """ + + def _get_payment_process_redirect_url_from_line_items(self, request): + """ + Returns the payment process redirect URL + from the line item added most recently to the order. + + Args: + request: Callback request from Cybersource + after completing the payment process. + + Returns: + URLField: The Line item's payment process + redirect URL from the line item added most recently to the order. + """ # noqa: D401 + order = api.get_order_from_cybersource_payment_response(request) + return order.lines.last().product.system.payment_process_redirect_url + + def post_checkout_redirect(self, order_state, request): + """ + Redirect the user with a message depending on the provided state. + + Args: + - order_state (str): the order state to consider + - order (Order): the order itself + - request (HttpRequest): the request + + Returns: HttpResponse + """ + if order_state == Order.STATE.CANCELED: + return redirect_with_user_message( + self._get_payment_process_redirect_url_from_line_items(request), + {"type": USER_MSG_TYPE_PAYMENT_CANCELLED}, + ) + elif order_state == Order.STATE.ERRORED: + return redirect_with_user_message( + self._get_payment_process_redirect_url_from_line_items(request), + {"type": USER_MSG_TYPE_PAYMENT_ERROR}, + ) + elif order_state == Order.STATE.DECLINED: + return redirect_with_user_message( + self._get_payment_process_redirect_url_from_line_items(request), + {"type": USER_MSG_TYPE_PAYMENT_DECLINED}, + ) + elif order_state == Order.STATE.FULFILLED: + return redirect_with_user_message( + self._get_payment_process_redirect_url_from_line_items(request), + { + "type": USER_MSG_TYPE_PAYMENT_ACCEPTED, + }, + ) + else: + if not PaymentGateway.validate_processor_response( + settings.ECOMMERCE_DEFAULT_PAYMENT_GATEWAY, request + ): + log.info("Could not validate payment response for order") + else: + processor_response = PaymentGateway.get_formatted_response( + settings.ECOMMERCE_DEFAULT_PAYMENT_GATEWAY, request + ) + log.error( + ( + "Checkout callback unknown error for transaction_id %s, state" + " %s, reason_code %s, message %s, and ProcessorResponse %s" + ), + processor_response.transaction_id, + order_state, + processor_response.response_code, + processor_response.message, + processor_response, + ) + return redirect_with_user_message( + self._get_payment_process_redirect_url_from_line_items(request), + {"type": USER_MSG_TYPE_PAYMENT_ERROR_UNKNOWN}, + ) + + def post(self, request): + """ + Handle successfully completed transactions. + + This does a handful of things: + 1. Verifies the incoming payload, which should be signed by the + processor + 2. Finds and fulfills the order in the system (which should also then + clear out the stored basket) + 3. Perform any enrollments, account status changes, etc. + """ + + with transaction.atomic(): + order = api.get_order_from_cybersource_payment_response(request) + if order is None: + return HttpResponse("Order not found") + + # Only process the response if the database record in pending status + # If it is, then we can process the response as per usual. + # If it isn't, then we just need to redirect the user with the + # proper message. + + if order.state == Order.STATE.PENDING: + processed_order_state = api.process_cybersource_payment_response( + request, + order, + POST_SALE_SOURCE_REDIRECT, + ) + + return self.post_checkout_redirect(processed_order_state, request) + else: + return self.post_checkout_redirect(order.state, request) + + @method_decorator(csrf_exempt, name="dispatch") class BackofficeCallbackView(APIView): """ diff --git a/payments/views/v0/urls.py b/payments/views/v0/urls.py index 327af3ee..065c63bb 100644 --- a/payments/views/v0/urls.py +++ b/payments/views/v0/urls.py @@ -6,6 +6,7 @@ BackofficeCallbackView, BasketViewSet, CheckoutApiViewSet, + CheckoutCallbackView, OrderHistoryViewSet, clear_basket, create_basket_from_product, @@ -37,4 +38,9 @@ name="checkout-callback", ), re_path("^", include(router.urls)), + path( + "checkout/result/", + CheckoutCallbackView.as_view(), + name="checkout-result-callback", + ), ] diff --git a/system_meta/migrations/0005_integratedsystem_payment_process_redirect_url.py b/system_meta/migrations/0005_integratedsystem_payment_process_redirect_url.py new file mode 100644 index 00000000..7f9ca433 --- /dev/null +++ b/system_meta/migrations/0005_integratedsystem_payment_process_redirect_url.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.16 on 2024-10-15 14:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "system_meta", + "0004_rename_sale_succeeded_webhook_url_integratedsystem_webhook_url_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="integratedsystem", + name="payment_process_redirect_url", + field=models.URLField(blank=True, default=""), + ), + ] diff --git a/system_meta/models.py b/system_meta/models.py index 10bd550d..77766d95 100644 --- a/system_meta/models.py +++ b/system_meta/models.py @@ -32,6 +32,7 @@ class IntegratedSystem(SafeDeleteModel, SoftDeleteActiveModel, TimestampedModel) # Webhook URLs webhook_url = models.URLField(blank=True, default="") + payment_process_redirect_url = models.URLField(blank=True, default="") objects = SafeDeleteManager() all_objects = models.Manager()