Skip to content

Commit 7a30054

Browse files
authored
Adds the checkout API to the OpenAPI-compatible API list (#3178)
1 parent 6815144 commit 7a30054

9 files changed

Lines changed: 634 additions & 2 deletions

File tree

ecommerce/api.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@
5050
from flexiblepricing.api import determine_courseware_flexible_price_discount
5151
from hubspot_sync.task_helpers import sync_hubspot_deal
5252
from main.constants import (
53+
USER_MSG_TYPE_B2B_ERROR_MISSING_ENROLLMENT_CODE,
54+
USER_MSG_TYPE_B2B_INVALID_BASKET,
55+
USER_MSG_TYPE_BASKET_EMPTY,
5356
USER_MSG_TYPE_COURSE_NON_UPGRADABLE,
5457
USER_MSG_TYPE_DISCOUNT_INVALID,
5558
USER_MSG_TYPE_ENROLL_BLOCKED,
@@ -74,6 +77,7 @@ def generate_checkout_payload(request): # noqa: PLR0911
7477
if basket.has_user_blocked_products(request.user):
7578
return {
7679
"country_blocked": True,
80+
"error": USER_MSG_TYPE_ENROLL_BLOCKED,
7781
"response": redirect_with_user_message(
7882
reverse("user-dashboard"),
7983
{"type": USER_MSG_TYPE_ENROLL_BLOCKED},
@@ -83,6 +87,7 @@ def generate_checkout_payload(request): # noqa: PLR0911
8387
if basket.has_user_purchased_same_courserun(request.user):
8488
return {
8589
"purchased_same_courserun": True,
90+
"error": USER_MSG_TYPE_ENROLL_DUPLICATED,
8691
"response": redirect_with_user_message(
8792
reverse("cart"),
8893
{"type": USER_MSG_TYPE_ENROLL_DUPLICATED},
@@ -92,6 +97,7 @@ def generate_checkout_payload(request): # noqa: PLR0911
9297
if basket.has_user_purchased_non_upgradable_courserun():
9398
return {
9499
"purchased_non_upgradeable_courserun": True,
100+
"error": USER_MSG_TYPE_COURSE_NON_UPGRADABLE,
95101
"response": redirect_with_user_message(
96102
reverse("cart"),
97103
{"type": USER_MSG_TYPE_COURSE_NON_UPGRADABLE},
@@ -104,6 +110,7 @@ def generate_checkout_payload(request): # noqa: PLR0911
104110
apply_user_discounts(request)
105111
return {
106112
"invalid_discounts": True,
113+
"error": USER_MSG_TYPE_DISCOUNT_INVALID,
107114
"response": redirect_with_user_message(
108115
reverse("cart"),
109116
{"type": USER_MSG_TYPE_DISCOUNT_INVALID},
@@ -115,6 +122,7 @@ def generate_checkout_payload(request): # noqa: PLR0911
115122
if not is_discount_supplied_for_b2b_purchase(request, active_contracts):
116123
return {
117124
"invalid_discounts": True,
125+
"error": USER_MSG_TYPE_B2B_ERROR_MISSING_ENROLLMENT_CODE,
118126
"response": redirect_with_user_message(
119127
reverse("cart"),
120128
{"type": USER_MSG_TYPE_REQUIRED_ENROLLMENT_CODE_EMPTY},
@@ -124,12 +132,23 @@ def generate_checkout_payload(request): # noqa: PLR0911
124132
if not validate_basket_for_b2b_purchase(request, active_contracts):
125133
return {
126134
"invalid_discounts": True,
135+
"error": USER_MSG_TYPE_B2B_INVALID_BASKET,
127136
"response": redirect_with_user_message(
128137
reverse("cart"),
129138
{"type": USER_MSG_TYPE_DISCOUNT_INVALID},
130139
),
131140
}
132141

142+
if not basket.basket_items.count():
143+
return {
144+
"basket_empty": True,
145+
"error": USER_MSG_TYPE_BASKET_EMPTY,
146+
"response": redirect_with_user_message(
147+
reverse("cart"),
148+
{"type": USER_MSG_TYPE_BASKET_EMPTY},
149+
),
150+
}
151+
133152
order = PendingOrder.create_from_basket(basket)
134153
total_price = 0
135154

ecommerce/serializers/v0/__init__.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,24 @@
2222
)
2323
from ecommerce.models import Basket, BasketItem, Order, Product
2424
from flexiblepricing.api import determine_courseware_flexible_price_discount
25+
from main.constants import (
26+
USER_MSG_TYPE_B2B_ERROR_MISSING_ENROLLMENT_CODE,
27+
USER_MSG_TYPE_B2B_INVALID_BASKET,
28+
USER_MSG_TYPE_BASKET_EMPTY,
29+
USER_MSG_TYPE_COURSE_NON_UPGRADABLE,
30+
USER_MSG_TYPE_DISCOUNT_INVALID,
31+
USER_MSG_TYPE_ENROLL_BLOCKED,
32+
USER_MSG_TYPE_ENROLL_DUPLICATED,
33+
)
2534
from main.settings import TIME_ZONE
2635
from users.serializers import ExtendedLegalAddressSerializer, UserSerializer
2736

2837
User = get_user_model()
2938

3039

3140
class V0DiscountSerializer(serializers.ModelSerializer):
41+
"""Serializes a discount."""
42+
3243
class Meta:
3344
model = models.Discount
3445
fields = [
@@ -911,3 +922,53 @@ def get_purchaser(self, instance):
911922
class Meta:
912923
fields = ["purchaser", "lines", "coupon", "order", "receipt"]
913924
model = models.Order
925+
926+
927+
class CheckoutPayloadSerializer(serializers.Serializer):
928+
"""Serializes the payload for the checkout data."""
929+
930+
no_checkout = serializers.BooleanField(
931+
read_only=True,
932+
required=False,
933+
default=False,
934+
help_text="Set if the order was automatically completed and no checkout process is required.",
935+
)
936+
url = serializers.CharField(
937+
read_only=True,
938+
required=False,
939+
default="",
940+
help_text="The URL to POST the form to.",
941+
)
942+
method = serializers.CharField(
943+
read_only=True,
944+
required=False,
945+
default="POST",
946+
help_text="The method to use for the checkout form (always POST).",
947+
)
948+
payload = serializers.JSONField(
949+
read_only=True, required=False, default={}, help_text="The data for the form."
950+
)
951+
order_id = serializers.IntegerField(
952+
read_only=True,
953+
required=False,
954+
default=0,
955+
help_text="If the order was automatically completed, the ID of the new order.",
956+
)
957+
error = serializers.ChoiceField(
958+
read_only=True,
959+
required=False,
960+
default=None,
961+
help_text="Error message for the order, if there is one.",
962+
choices=(
963+
USER_MSG_TYPE_ENROLL_BLOCKED,
964+
USER_MSG_TYPE_ENROLL_DUPLICATED,
965+
USER_MSG_TYPE_COURSE_NON_UPGRADABLE,
966+
USER_MSG_TYPE_DISCOUNT_INVALID,
967+
USER_MSG_TYPE_B2B_ERROR_MISSING_ENROLLMENT_CODE,
968+
USER_MSG_TYPE_B2B_INVALID_BASKET,
969+
USER_MSG_TYPE_BASKET_EMPTY,
970+
),
971+
)
972+
973+
class Meta:
974+
fields = ["no_checkout", "url", "method", "payload", "order_id", "error"]

ecommerce/views/v0/__init__.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import django_filters
88
from django.contrib.auth import get_user_model
9+
from django.core.exceptions import ObjectDoesNotExist
910
from django.db.models import Count, Q
1011
from django.shortcuts import redirect
1112
from django_filters import rest_framework as filters
@@ -31,6 +32,7 @@
3132
from ecommerce.api import (
3233
apply_discount_to_basket,
3334
establish_basket,
35+
generate_checkout_payload,
3436
generate_discount_code,
3537
get_auto_apply_discounts_for_basket,
3638
)
@@ -48,6 +50,7 @@
4850
BasketItemSerializer,
4951
BasketWithProductSerializer,
5052
BulkDiscountSerializer,
53+
CheckoutPayloadSerializer,
5154
DiscountProductSerializer,
5255
DiscountRedemptionSerializer,
5356
ProductFlexiblePriceSerializer,
@@ -400,6 +403,37 @@ def clear_basket(request):
400403
return Response(None, status=status.HTTP_204_NO_CONTENT)
401404

402405

406+
@extend_schema(
407+
description=(
408+
"Returns the payload necessary to redirect the user to CyberSource for payment."
409+
),
410+
methods=["GET"],
411+
responses=CheckoutPayloadSerializer,
412+
)
413+
@api_view(["GET"])
414+
@permission_classes([IsAuthenticated])
415+
def checkout_basket(request):
416+
"""
417+
Generate the data for checkout and return it.
418+
419+
This gathers and converts the data in the current user's Basket, makes it
420+
into an Order, and returns the form data needed to start the checkout process
421+
in CyberSource. The frontend app then needs to pull the data into a form and
422+
POST it to the appropriate URL to send the user over to CyberSource so we can
423+
collect payment.
424+
"""
425+
426+
try:
427+
payload = generate_checkout_payload(request)
428+
req_status = (
429+
status.HTTP_400_BAD_REQUEST if "error" in payload else status.HTTP_200_OK
430+
)
431+
432+
return Response(CheckoutPayloadSerializer(payload).data, status=req_status)
433+
except ObjectDoesNotExist:
434+
return Response("No basket", status=status.HTTP_406_NOT_ACCEPTABLE)
435+
436+
403437
class ProductsPagination(LimitOffsetPagination):
404438
"""Sets a default limit for the product list API."""
405439

ecommerce/views/v0/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
NestedUserDiscountViewSet,
1414
ProductViewSet,
1515
add_discount_to_basket,
16+
checkout_basket,
1617
clear_basket,
1718
create_basket_from_product,
1819
create_basket_from_product_with_discount,
@@ -87,6 +88,11 @@
8788
add_discount_to_basket,
8889
name="baskets_api-add_discount",
8990
),
91+
path(
92+
"baskets/checkout/",
93+
checkout_basket,
94+
name="baskets_api-checkout",
95+
),
9096
re_path(
9197
r"^",
9298
include(

0 commit comments

Comments
 (0)