Skip to content

Commit

Permalink
5654 update apis for shopping cartbasket (#160)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
cp-at-mit and pre-commit-ci[bot] authored Oct 16, 2024
1 parent dde29a2 commit 2f56b5a
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 126 deletions.
7 changes: 1 addition & 6 deletions cart/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
116 changes: 2 additions & 114 deletions cart/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion config/apisix/apisix.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ routes:
upstream_id: 1
plugins: {}
uris:
- "/checkout/result/*"
- "/api/v0/payments/checkout/result/*"
- "/static/*"
- "/api/v0/schema/*"
- id: 2
Expand Down
2 changes: 1 addition & 1 deletion payments/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions payments/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
142 changes: 140 additions & 2 deletions payments/views/v0/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__)

Expand Down Expand Up @@ -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):
"""
Expand Down
6 changes: 6 additions & 0 deletions payments/views/v0/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
BackofficeCallbackView,
BasketViewSet,
CheckoutApiViewSet,
CheckoutCallbackView,
OrderHistoryViewSet,
clear_basket,
create_basket_from_product,
Expand Down Expand Up @@ -37,4 +38,9 @@
name="checkout-callback",
),
re_path("^", include(router.urls)),
path(
"checkout/result/",
CheckoutCallbackView.as_view(),
name="checkout-result-callback",
),
]
Original file line number Diff line number Diff line change
@@ -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=""),
),
]
Loading

0 comments on commit 2f56b5a

Please sign in to comment.