From e8b1c48e47ad3daf95eb287185047e93bbb1f4f9 Mon Sep 17 00:00:00 2001 From: James Kachel Date: Thu, 29 Feb 2024 11:16:04 -0600 Subject: [PATCH] Adds the payments app (APIs for checkout), adds cart test mule app --- cart/__init__.py | 0 cart/apps.py | 10 + cart/migrations/__init__.py | 0 cart/templates/base.html | 19 + cart/templates/cart.html | 125 ++++ cart/templates/checkout_interstitial.html | 34 + cart/urls.py | 19 + cart/views.py | 186 +++++ payments/__init__.py | 0 payments/admin.py | 143 ++++ payments/api.py | 404 +++++++++++ payments/api_test.py | 553 +++++++++++++++ payments/apps.py | 10 + payments/exceptions.py | 12 + payments/factories.py | 70 ++ .../0001_add_order_and_basket_models.py | 301 +++++++++ .../0002_remove_system_slug_from_basket.py | 16 + payments/migrations/__init__.py | 0 payments/models.py | 637 ++++++++++++++++++ payments/serializers/__init__.py | 0 payments/serializers/v0/__init__.py | 111 +++ payments/urls.py | 5 + payments/views/__init__.py | 0 payments/views/v0/__init__.py | 231 +++++++ payments/views/v0/urls.py | 43 ++ 25 files changed, 2929 insertions(+) create mode 100644 cart/__init__.py create mode 100644 cart/apps.py create mode 100644 cart/migrations/__init__.py create mode 100644 cart/templates/base.html create mode 100644 cart/templates/cart.html create mode 100644 cart/templates/checkout_interstitial.html create mode 100644 cart/urls.py create mode 100644 cart/views.py create mode 100644 payments/__init__.py create mode 100644 payments/admin.py create mode 100644 payments/api.py create mode 100644 payments/api_test.py create mode 100644 payments/apps.py create mode 100644 payments/exceptions.py create mode 100644 payments/factories.py create mode 100644 payments/migrations/0001_add_order_and_basket_models.py create mode 100644 payments/migrations/0002_remove_system_slug_from_basket.py create mode 100644 payments/migrations/__init__.py create mode 100644 payments/models.py create mode 100644 payments/serializers/__init__.py create mode 100644 payments/serializers/v0/__init__.py create mode 100644 payments/urls.py create mode 100644 payments/views/__init__.py create mode 100644 payments/views/v0/__init__.py create mode 100644 payments/views/v0/urls.py diff --git a/cart/__init__.py b/cart/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cart/apps.py b/cart/apps.py new file mode 100644 index 00000000..f243fc4c --- /dev/null +++ b/cart/apps.py @@ -0,0 +1,10 @@ +"""App initialization for cart""" + +from django.apps import AppConfig + + +class CartConfig(AppConfig): + """Config for the cart app""" + + default_auto_field = "django.db.models.BigAutoField" + name = "cart" diff --git a/cart/migrations/__init__.py b/cart/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cart/templates/base.html b/cart/templates/base.html new file mode 100644 index 00000000..44ea1965 --- /dev/null +++ b/cart/templates/base.html @@ -0,0 +1,19 @@ + + + MIT ODL Ecommerce - {% block title %}{% endblock title %} + + + + + + +
+
+
+

MIT ODL Ecommerce - {% block innertitle %}{% endblock innertitle %}

+
+
+ {% block body %}{% endblock body %} +
+ + diff --git a/cart/templates/cart.html b/cart/templates/cart.html new file mode 100644 index 00000000..ebdc34be --- /dev/null +++ b/cart/templates/cart.html @@ -0,0 +1,125 @@ +{% extends "base.html" %} + +{% block title %}Cart{% endblock %} +{% block innertitle %}Cart{% endblock %} + +{% block body %} +
+
+

+ This is the current cart information. + +

+
+
+{% if basket %} +
+
+ + + + + + + + + + + {% if basket_items|length == 0 %} + + + + {% endif %} + {% for item in basket_items %} + + + + + + + {% endfor %} + + + + + + +
ProductPriceQuantityTotal
No items in the basket.
{{ item.product }}{{ item.product.price }}{{ item.quantity }}{{ item.product.price }}
+ Check Out +
+
+
+{% endif %} +
+
+

Add a product to the basket:

+ +
+ {% csrf_token %} + +
+ + +
+ +
+ + +
+
+
+
+ + +{% endblock body %} diff --git a/cart/templates/checkout_interstitial.html b/cart/templates/checkout_interstitial.html new file mode 100644 index 00000000..d5f40a4e --- /dev/null +++ b/cart/templates/checkout_interstitial.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% load i18n static %} + +{% block title %}{% trans "Complete Payment" %}{% endblock %} +{% block innertitle %}{% trans "Complete Payment" %}{% endblock %} + +{% block body %} +
+
+
+
+
+

Redirecting to the payment processor...

+ +
+ {% for key, value in form.items %} +
+
+ {% endfor %} + +
+ +
+
+ + +
+
+
+
+
+{% endblock %} diff --git a/cart/urls.py b/cart/urls.py new file mode 100644 index 00000000..9849db70 --- /dev/null +++ b/cart/urls.py @@ -0,0 +1,19 @@ +"""Routes for the cart app.""" + +from django.urls import path + +from cart.views import CartView, CheckoutCallbackView, CheckoutInterstitialView + +urlpatterns = [ + path( + r"checkout/result/", + CheckoutCallbackView.as_view(), + name="checkout-result-callback", + ), + path( + "checkout/to_payment", + CheckoutInterstitialView.as_view(), + name="checkout_interstitial_page", + ), + path("", CartView.as_view(), name="cart"), +] diff --git a/cart/views.py b/cart/views.py new file mode 100644 index 00000000..61acead5 --- /dev/null +++ b/cart/views.py @@ -0,0 +1,186 @@ +"""Views for the cart app.""" + +import logging + +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 payments import api +from payments.models import Basket, Order +from system_meta.models import Product +from unified_ecommerce.constants import ( + 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__) + + +class CartView(LoginRequiredMixin, TemplateView): + """View for the cart page.""" + + template_name = "cart.html" + extra_context = {"title": "Cart", "innertitle": "Cart"} + + def get(self, request: HttpRequest) -> HttpResponse: + """Render the cart page.""" + basket = Basket.establish_basket(request) + products = Product.objects.all() + + if not request.user.is_authenticated: + msg = "User is not authenticated" + raise ValueError(msg) + + return render( + request, + self.template_name, + { + **self.extra_context, + "basket": basket, + "basket_items": basket.basket_items.all(), + "products": products, + }, + ) + + +@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 + ) + + 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. + + This is a simple page that just includes the checkout payload, renders a + form and then submits the form so the user gets thrown to the payment + gateway. They can then complete the payment process. + """ + + template_name = "checkout_interstitial.html" + + def get(self, request): + """Render the checkout interstitial page.""" + try: + checkout_payload = api.generate_checkout_payload(request) + except ObjectDoesNotExist: + return HttpResponse("No basket") + if ( + "country_blocked" in checkout_payload + or "no_checkout" in checkout_payload + or "purchased_same_courserun" in checkout_payload + or "purchased_non_upgradeable_courserun" in checkout_payload + or "invalid_discounts" in checkout_payload + ): + return checkout_payload["response"] + + return render( + request, + self.template_name, + {"checkout_payload": checkout_payload, "form": checkout_payload["payload"]}, + ) diff --git a/payments/__init__.py b/payments/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/admin.py b/payments/admin.py new file mode 100644 index 00000000..a20ec7b6 --- /dev/null +++ b/payments/admin.py @@ -0,0 +1,143 @@ +"""Django Admin for system_meta app""" + +from django.contrib import admin +from django.contrib.admin.decorators import display +from fsm_admin.mixins import FSMTransitionMixin +from mitol.common.admin import TimestampedModelAdmin +from reversion.admin import VersionAdmin + +from payments import models + + +@admin.register(models.Basket) +class BasketAdmin(VersionAdmin): + """Admin for Basket""" + + model = models.Basket + search_fields = ["user__email", "user__username"] + list_display = ["id", "user"] + raw_id_fields = ("user",) + + +@admin.register(models.BasketItem) +class BasketItemAdmin(VersionAdmin): + """Admin for BasketItem""" + + model = models.BasketItem + search_fields = ["product__description", "product__price"] + list_display = ["id", "product", "quantity"] + raw_id_fields = ("basket", "product") + + +class OrderLineInline(admin.TabularInline): + """Inline editor for lines""" + + model = models.Line + readonly_fields = ["unit_price", "total_price", "discounted_price"] + min_num = 1 + extra = 0 + + +class OrderTransactionInline(admin.TabularInline): + """Inline editor for transactions for an Order""" + + def has_add_permission(self, request, obj=None): # noqa: ARG002 + """Disable adding transactions""" + return False + + model = models.Transaction + readonly_fields = ["order", "amount", "data"] + min_num = 0 + extra = 0 + can_delete = False + can_add = False + + +class BaseOrderAdmin(FSMTransitionMixin, TimestampedModelAdmin): + """Base admin for Order""" + + search_fields = [ + "id", + "purchaser__email", + "purchaser__username", + "reference_number", + ] + list_display = ["id", "state", "get_purchaser", "total_price_paid"] + list_fields = ["state"] + list_filter = ["state"] + inlines = [OrderLineInline, OrderTransactionInline] + readonly_fields = ["reference_number"] + + def has_change_permission(self, request, obj=None): # noqa: ARG002 + """Disable adding orders""" + return False + + @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})" + + def get_queryset(self, request): + """Filter only to pending orders""" + return ( + super() + .get_queryset(request) + .prefetch_related("purchaser", "lines__product_version") + ) + + +@admin.register(models.Order) +class OrderAdmin(BaseOrderAdmin): + """Admin for Order""" + + list_display = ["id", "state", "purchaser", "total_price_paid", "reference_number"] + model = models.Order + + +@admin.register(models.PendingOrder) +class PendingOrderAdmin(BaseOrderAdmin): + """Admin for PendingOrder""" + + model = models.PendingOrder + + def get_queryset(self, request): + """Filter only to pending orders""" + return super().get_queryset(request).filter(state=models.Order.STATE.PENDING) + + +@admin.register(models.CanceledOrder) +class CanceledOrderAdmin(BaseOrderAdmin): + """Admin for CanceledOrder""" + + model = models.CanceledOrder + + def get_queryset(self, request): + """Filter only to canceled orders""" + return super().get_queryset(request).filter(state=models.Order.STATE.CANCELED) + + +@admin.register(models.FulfilledOrder) +class FulfilledOrderAdmin(BaseOrderAdmin): + """Admin for FulfilledOrder""" + + model = models.FulfilledOrder + + def get_queryset(self, request): + """Filter only to fulfilled orders""" + return ( + super() + .get_queryset(request) + .prefetch_related("purchaser", "lines__product_version") + .filter(state=models.Order.STATE.FULFILLED) + ) + + +@admin.register(models.RefundedOrder) +class RefundedOrderAdmin(BaseOrderAdmin): + """Admin for RefundedOrder""" + + model = models.RefundedOrder + + def get_queryset(self, request): + """Filter only to refunded orders""" + return super().get_queryset(request).filter(state=models.Order.STATE.REFUNDED) diff --git a/payments/api.py b/payments/api.py new file mode 100644 index 00000000..c26bbf1d --- /dev/null +++ b/payments/api.py @@ -0,0 +1,404 @@ +"""Ecommerce APIs""" + +import logging + +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError +from django.db import transaction +from django.urls import reverse +from ipware import get_client_ip +from mitol.payment_gateway.api import CartItem as GatewayCartItem +from mitol.payment_gateway.api import Order as GatewayOrder +from mitol.payment_gateway.api import PaymentGateway, ProcessorResponse + +from payments.exceptions import PaymentGatewayError, PaypalRefundError +from payments.models import ( + Basket, + FulfilledOrder, + Order, + PendingOrder, +) +from system_meta.models import IntegratedSystem +from unified_ecommerce.constants import ( + CYBERSOURCE_ACCEPT_CODES, + CYBERSOURCE_ERROR_CODES, + CYBERSOURCE_REASON_CODE_SUCCESS, + REFUND_SUCCESS_STATES, + USER_MSG_TYPE_PAYMENT_ACCEPTED_NOVALUE, + ZERO_PAYMENT_DATA, +) +from unified_ecommerce.utils import redirect_with_user_message + +log = logging.getLogger(__name__) + + +def generate_checkout_payload(request): + """Generate the payload to send to the payment gateway.""" + basket = Basket.establish_basket(request) + + # Notes for future implementation: this used to check for + # * Blocked products (by country) + # * Re-purchases of the same product + # * Purchasing a product that is expired + # These are all cleared for now, but will need to go back here later. + # For completeness, this is also where discounts were checked for validity. + + order = PendingOrder.create_from_basket(basket) + total_price = 0 + + ip = get_client_ip(request)[0] + + gateway_order = GatewayOrder( + username=request.user.username, + ip_address=ip, + reference=order.reference_number, + items=[], + ) + + for line_item in order.lines.all(): + field_dict = line_item.product_version.field_dict + system = IntegratedSystem.objects.get(pk=field_dict["system_id"]) + sku = f"{system.slug}-{field_dict['sku']}" + gateway_order.items.append( + GatewayCartItem( + code=sku, + name=field_dict["description"], + quantity=1, + sku=sku, + unitprice=line_item.discounted_price, + taxable=0, + ) + ) + total_price += line_item.discounted_price + + if total_price == 0: + with transaction.atomic(): + fulfill_completed_order( + order, payment_data=ZERO_PAYMENT_DATA, basket=basket + ) + return { + "no_checkout": True, + "response": redirect_with_user_message( + reverse("cart"), + { + "type": USER_MSG_TYPE_PAYMENT_ACCEPTED_NOVALUE, + "run": order.lines.first().purchased_object.course.title, + }, + ), + } + + callback_uri = request.build_absolute_uri(reverse("checkout-result-callback")) + + return PaymentGateway.start_payment( + settings.ECOMMERCE_DEFAULT_PAYMENT_GATEWAY, + gateway_order, + callback_uri, + callback_uri, + merchant_fields=[basket.id], + ) + + +def fulfill_completed_order(order, payment_data, basket=None): + """Fulfill the order.""" + order.fulfill(payment_data) + order.save() + + if basket and basket.compare_to_order(order): + basket.delete() + + +def get_order_from_cybersource_payment_response(request): + """Figure out the order from the payment response from Cybersource.""" + payment_data = request.POST + converted_order = PaymentGateway.get_gateway_class( + settings.ECOMMERCE_DEFAULT_PAYMENT_GATEWAY + ).convert_to_order(payment_data) + order_id = Order.decode_reference_number(converted_order.reference) + + try: + order = Order.objects.select_for_update().get(pk=order_id) + except ObjectDoesNotExist: + order = None + return order + + +def process_cybersource_payment_response(request, order): + """ + Update the order and basket based on the payment request from Cybersource. + Returns the order state after applying update operations corresponding to + the request. + + Args: + - request (HttpRequest): The payment request received from Cybersource. + - order (Order): The order corresponding to the request payload. + Returns: + Order.state + """ + + if not PaymentGateway.validate_processor_response( + settings.ECOMMERCE_DEFAULT_PAYMENT_GATEWAY, request + ): + msg = "Could not validate response from the payment processor." + raise PermissionDenied(msg) + + processor_response = PaymentGateway.get_formatted_response( + settings.ECOMMERCE_DEFAULT_PAYMENT_GATEWAY, request + ) + + reason_code = processor_response.response_code + transaction_id = processor_response.transaction_id + if reason_code and reason_code.isdigit(): + reason_code = int(reason_code) + message = ( + "Transaction was not successful. " + "Transaction ID:%s Reason Code:%d Message:%s" + ) + if reason_code in CYBERSOURCE_ERROR_CODES: + # Log the errors as errors, so they make Sentry logs. + log.error(message, transaction_id, reason_code, processor_response.message) + elif reason_code not in CYBERSOURCE_ACCEPT_CODES: + # These may be declines or reviews - only log in debug mode. + log.debug(message, transaction_id, reason_code, processor_response.message) + + return_message = "" + + if processor_response.state == ProcessorResponse.STATE_DECLINED: + # Transaction declined for some reason + # This probably means the order needed to go through the process + # again so maybe tell the user to do a thing. + msg = f"Transaction declined: {processor_response.message}" + log.debug(msg) + order.decline() + order.save() + return_message = order.state + elif processor_response.state == ProcessorResponse.STATE_ERROR: + # Error - something went wrong with the request + msg = f"Error happened submitting the transaction: {processor_response.message}" + log.debug(msg) + order.error() + order.save() + return_message = order.state + elif processor_response.state in [ + ProcessorResponse.STATE_CANCELLED, + ProcessorResponse.STATE_REVIEW, + ]: + # Transaction cancelled or reviewed + # Transaction could be cancelled for reasons that don't necessarily + # mean that the entire order is invalid, so we'll do nothing with + # the order here (other than set it to Cancelled). + # Transaction could be + msg = f"Transaction cancelled/reviewed: {processor_response.messages}" + log.debug(msg) + order.cancel() + order.save() + return_message = order.state + + elif ( + processor_response.state == ProcessorResponse.STATE_ACCEPTED + or reason_code == CYBERSOURCE_REASON_CODE_SUCCESS + ): + # It actually worked here + basket = Basket.objects.filter(user=order.purchaser).first() + try: + msg = f"Transaction accepted!: {processor_response.message}" + log.debug(msg) + fulfill_completed_order(order, request.POST, basket) + except ValidationError: + msg = ( + "Missing transaction id from transaction response: " + f"{processor_response.message}" + ) + log.debug(msg) + raise + + return_message = order.state + else: + msg = ( + f"Unknown state {processor_response.state} found: transaction ID" + f"{transaction_id}, reason code {reason_code}, response message" + f" {processor_response.message}" + ) + log.error(msg) + order.cancel() + order.save() + return_message = order.state + + return return_message + + +def refund_order( + *, order_id: int | None = None, reference_number: str | None = None, **kwargs +): + """ + Refund the specified order. + + Args: + order_id (int): ID of the order which is being refunded + reference_number (str): Reference number of the order + kwargs (dict): Dictionary of the other attributes that are passed e.g. + refund amount, refund reason + If no refund_amount is provided it will use refund amount from + Transaction obj + + Returns: + bool : A boolean identifying if an order refund was successful + """ + refund_amount = kwargs.get("refund_amount") + refund_reason = kwargs.get("refund_reason", "") + message = "" + if reference_number is not None: + order = FulfilledOrder.objects.get(reference_number=reference_number) + elif order_id is not None: + order = FulfilledOrder.objects.get(pk=order_id) + else: + message = "Either order_id or reference_number is required to fetch the Order." + log.error(message) + return False, message + if order.state != Order.STATE.FULFILLED: + message = f"Order with order_id {order.id} is not in fulfilled state." + log.error(message) + return False, message + + order_recent_transaction = order.transactions.first() + + if not order_recent_transaction: + message = f"There is no associated transaction against order_id {order.id}" + log.error(message) + return False, message + + transaction_dict = order_recent_transaction.data + + # Check for a PayPal payment - if there's one, we can't process it + if "paypal_token" in transaction_dict: + msg = ( + f"PayPal: Order {order.reference_number} contains a PayPal" + "transaction. Please contact Finance to refund this order." + ) + raise PaypalRefundError(msg) + + # The refund amount can be different then the payment amount, so we override + # that before PaymentGateway processing. + # e.g. While refunding order from Django Admin we can select custom amount. + if refund_amount: + transaction_dict["req_amount"] = refund_amount + + refund_gateway_request = PaymentGateway.create_refund_request( + settings.ECOMMERCE_DEFAULT_PAYMENT_GATEWAY, transaction_dict + ) + + response = PaymentGateway.start_refund( + settings.ECOMMERCE_DEFAULT_PAYMENT_GATEWAY, + refund_gateway_request, + ) + + if response.state in REFUND_SUCCESS_STATES: + # Record refund transaction with PaymentGateway's refund response + order.refund( + api_response_data=response.response_data, + amount=transaction_dict["req_amount"], + reason=refund_reason, + ) + else: + log.error( + "There was an error with the Refund API request %s", + response.message, + ) + # PaymentGateway didn't raise an exception and instead gave a + # Response but the response status was not success so we manually + # rollback the transaction in this case. + msg = f"Payment gateway returned an error: {response.message}" + raise PaymentGatewayError(msg) + + return True, message + + +def check_and_process_pending_orders_for_resolution(refnos=None): + """ + Check pending orders for resolution. By default, this will pull all the + pending orders that are in the system. + + Args: + - refnos (list or None): check specific reference numbers + Returns: + - Tuple of counts: fulfilled count, cancelled count, error count + + """ + + gateway = PaymentGateway.get_gateway_class( + settings.ECOMMERCE_DEFAULT_PAYMENT_GATEWAY + ) + + if refnos is not None: + pending_orders = PendingOrder.objects.filter( + state=PendingOrder.STATE.PENDING, reference_number__in=refnos + ).values_list("reference_number", flat=True) + else: + pending_orders = PendingOrder.objects.filter( + state=PendingOrder.STATE.PENDING + ).values_list("reference_number", flat=True) + + if len(pending_orders) == 0: + return (0, 0, 0) + + msg = f"Resolving {len(pending_orders)} orders" + log.info(msg) + + results = gateway.find_and_get_transactions(pending_orders) + + if len(results.keys()) == 0: + msg = "No orders found to resolve." + log.info(msg) + return (0, 0, 0) + + fulfilled_count = cancel_count = error_count = 0 + + for result in results: + payload = results[result] + if int(payload["reason_code"]) == CYBERSOURCE_REASON_CODE_SUCCESS: + try: + order = PendingOrder.objects.filter( + state=PendingOrder.STATE.PENDING, + reference_number=payload["req_reference_number"], + ).get() + + order.fulfill(payload) + order.save() + fulfilled_count += 1 + + msg = f"Fulfilled order {order.reference_number}." + log.info(msg) + except Exception as e: + msg = ( + "Couldn't process pending order for fulfillment " + f"{payload['req_reference_number']}: {e!s}" + ) + log.exception(msg) + error_count += 1 + else: + try: + order = PendingOrder.objects.filter( + state=PendingOrder.STATE.PENDING, + reference_number=payload["req_reference_number"], + ).get() + + order.cancel() + order.transactions.create( + transaction_id=payload["transaction_id"], + amount=order.total_price_paid, + data=payload, + reason=f"Cancelled due to processor code {payload['reason_code']}", + ) + order.save() + cancel_count += 1 + + msg = f"Cancelled order {order.reference_number}." + log.info(msg) + except Exception as e: + msg = ( + "Couldn't process pending order for cancellation " + f"{payload['req_reference_number']}: {e!s}" + ) + log.exception(msg) + error_count += 1 + + return (fulfilled_count, cancel_count, error_count) diff --git a/payments/api_test.py b/payments/api_test.py new file mode 100644 index 00000000..4ba3457d --- /dev/null +++ b/payments/api_test.py @@ -0,0 +1,553 @@ +"""Tests for Ecommerce api""" + +import random +import uuid + +import pytest +import reversion +from CyberSource.rest import ApiException +from django.conf import settings +from django.urls import reverse +from factory import Faker, fuzzy +from mitol.payment_gateway.api import PaymentGateway, ProcessorResponse + +from payments.api import ( + check_and_process_pending_orders_for_resolution, + generate_checkout_payload, + process_cybersource_payment_response, + refund_order, +) +from payments.exceptions import PaymentGatewayError, PaypalRefundError +from payments.factories import ( + OrderFactory, + TransactionFactory, +) +from payments.models import ( + Basket, + BasketItem, + FulfilledOrder, + Order, + Transaction, +) +from system_meta.factories import ProductFactory +from unified_ecommerce.constants import ( + TRANSACTION_TYPE_PAYMENT, + TRANSACTION_TYPE_REFUND, +) +from unified_ecommerce.factories import UserFactory +from unified_ecommerce.test_utils import generate_mocked_request + +pytestmark = [pytest.mark.django_db] + + +@pytest.fixture() +def fulfilled_order(): + """Fixture for creating a fulfilled order""" + return OrderFactory.create(state=Order.STATE.FULFILLED) + + +@pytest.fixture() +def fulfilled_transaction(fulfilled_order): + """Fixture to creating a fulfilled transaction""" + payment_amount = 10.00 + fulfilled_sample = { + "transaction_id": "1234", + "req_amount": payment_amount, + "req_currency": "USD", + } + + return TransactionFactory.create( + transaction_id="1234", + transaction_type=TRANSACTION_TYPE_PAYMENT, + data=fulfilled_sample, + order=fulfilled_order, + ) + + +@pytest.fixture() +def fulfilled_paypal_transaction(fulfilled_order): + """Fixture to creating a fulfilled transaction""" + payment_amount = 10.00 + fulfilled_sample = { + "transaction_id": "1234", + "req_amount": payment_amount, + "req_currency": "USD", + "paypal_token": "EC-" + str(fuzzy.FuzzyText(length=17)), + "paypal_payer_id": str(fuzzy.FuzzyText(length=13)), + "paypal_fee_amount": payment_amount, + "paypal_payer_status": "unverified", + "paypal_address_status": "Confirmed", + "paypal_customer_email": str(Faker("ascii_email")), + "paypal_payment_status": "Completed", + "paypal_pending_reason": "order", + } + + return TransactionFactory.create( + transaction_id="1234", + transaction_type=TRANSACTION_TYPE_PAYMENT, + data=fulfilled_sample, + order=fulfilled_order, + ) + + +@pytest.fixture() +def products(): + """Create products""" + with reversion.create_revision(): + return ProductFactory.create_batch(5) + + +@pytest.fixture() +def user(db): + """Create a user""" + return UserFactory.create() + + +@pytest.fixture(autouse=True) +def _payment_gateway_settings(): + """Mock payment gateway settings""" + settings.MITOL_PAYMENT_GATEWAY_CYBERSOURCE_SECURITY_KEY = "Test Security Key" + settings.MITOL_PAYMENT_GATEWAY_CYBERSOURCE_ACCESS_KEY = "Test Access Key" + settings.MITOL_PAYMENT_GATEWAY_CYBERSOURCE_PROFILE_ID = uuid.uuid4() + + +def test_cybersource_refund_no_order(): + """Test that refund_order throws FulfilledOrder.DoesNotExist exception when the order doesn't exist""" + + with pytest.raises(FulfilledOrder.DoesNotExist): + refund_order(order_id=1) # Caling refund with random Id + + +def create_basket(user, products): + """ + Bootstrap a basket with a product in it for testing the discount + redemption APIs + """ + basket = Basket(user=user) + basket.save() + + basket_item = BasketItem( + product=products[random.randrange(0, len(products))], # noqa: S311 + basket=basket, + quantity=1, + ) + basket_item.save() + + return basket + + +@pytest.mark.parametrize( + "order_state", + [ + Order.STATE.REFUNDED, + Order.STATE.ERRORED, + Order.STATE.PENDING, + Order.STATE.DECLINED, + Order.STATE.CANCELED, + Order.STATE.REVIEW, + ], +) +def test_cybersource_refund_no_fulfilled_order(order_state): + """ + Test that refund_order returns logs properly and False when there is no + Fulfilled order against the given order_id + """ + + unfulfilled_order = OrderFactory.create(state=order_state) + refund_response, message = refund_order(order_id=unfulfilled_order.id) + assert ( + f"Order with order_id {unfulfilled_order.id} is not in fulfilled state." + in message + ) + assert refund_response is False + + +def test_cybersource_refund_no_order_id(): + """ + Test that refund_order returns logs properly and False when there is no + Fulfilled order against the given order_id + """ + + refund_response, message = refund_order() + assert ( + "Either order_id or reference_number is required to fetch the Order." in message + ) + assert refund_response is False + + +def test_cybersource_order_no_transaction(fulfilled_order): + """ + Test that refund_order returns False when there is no transaction against a + fulfilled order. Ideally, there should be a payment type transaction for a + fulfilled order. + """ + + fulfilled_order = OrderFactory.create(state=Order.STATE.FULFILLED) + refund_response, message = refund_order(order_id=fulfilled_order.id) + assert ( + f"There is no associated transaction against order_id {fulfilled_order.id}" + in message + ) + assert refund_response is False + + +@pytest.mark.parametrize( + ("order_state"), + [ + (ProcessorResponse.STATE_PENDING), + (ProcessorResponse.STATE_DUPLICATE), + ], +) +def test_order_refund_success(mocker, order_state, fulfilled_transaction): + """ + Test that appropriate data is created for a successful refund and its + state changes to REFUNDED + """ + + sample_response_data = { + "id": "12345", # it only has id in refund response, no transaction_id + "refundAmountDetails": {"refundAmount": float(fulfilled_transaction.amount)}, + } + sample_response = ProcessorResponse( + state=order_state, + response_data=sample_response_data, + transaction_id="1234", + message="", + response_code="", + ) + + mocker.patch( + "mitol.payment_gateway.api.PaymentGateway.start_refund", + return_value=sample_response, + ) + + if order_state == ProcessorResponse.STATE_DUPLICATE: + with pytest.raises((PaypalRefundError, PaymentGatewayError)): + refund_success, _ = refund_order( + order_id=fulfilled_transaction.order.id, + ) + + return + else: + refund_success, _ = refund_order( + order_id=fulfilled_transaction.order.id, + ) + + # There should be two transaction objects (One for payment and other for refund) + assert ( + Transaction.objects.filter( + order=fulfilled_transaction.order.id, + transaction_type=TRANSACTION_TYPE_PAYMENT, + ).count() + == 1 + ) + assert ( + Transaction.objects.filter( + order=fulfilled_transaction.order.id, + transaction_type=TRANSACTION_TYPE_REFUND, + ).count() + == 1 + ) + assert refund_success is True + + # Refund transaction object should have appropriate data + refund_transaction = Transaction.objects.filter( + order=fulfilled_transaction.order.id, transaction_type=TRANSACTION_TYPE_REFUND + ).first() + + assert refund_transaction.data == sample_response_data + assert refund_transaction.amount == fulfilled_transaction.amount + + # The state of the order should be REFUNDED after a successful refund + fulfilled_transaction.order.refresh_from_db() + assert fulfilled_transaction.order.state == Order.STATE.REFUNDED + + +def test_order_refund_success_with_ref_num(mocker, fulfilled_transaction): + """Test a successful refund based only on reference number""" + sample_response_data = { + "id": "12345", + "refundAmountDetails": {"refundAmount": float(fulfilled_transaction.amount)}, + } + sample_response = ProcessorResponse( + state=ProcessorResponse.STATE_PENDING, + response_data=sample_response_data, + transaction_id="1234", + message="", + response_code="", + ) + mocker.patch( + "mitol.payment_gateway.api.PaymentGateway.start_refund", + return_value=sample_response, + ) + refund_success, message = refund_order( + reference_number=fulfilled_transaction.order.reference_number + ) + # There should be two transaction objects (One for payment and other for refund) + assert ( + Transaction.objects.filter( + order=fulfilled_transaction.order.id, + transaction_type=TRANSACTION_TYPE_PAYMENT, + ).count() + == 1 + ) + assert ( + Transaction.objects.filter( + order=fulfilled_transaction.order.id, + transaction_type=TRANSACTION_TYPE_REFUND, + ).count() + == 1 + ) + assert refund_success is True + assert message == "" + + # Refund transaction object should have appropriate data + refund_transaction = Transaction.objects.filter( + order=fulfilled_transaction.order.id, transaction_type=TRANSACTION_TYPE_REFUND + ).first() + + assert refund_transaction.data == sample_response_data + assert refund_transaction.amount == fulfilled_transaction.amount + + # The state of the order should be REFUNDED after a successful refund + fulfilled_transaction.order.refresh_from_db() + assert fulfilled_transaction.order.state == Order.STATE.REFUNDED + + +def test_order_refund_failure(mocker, fulfilled_transaction): + """ + Test that refund operation returns False when there was a failure in + refund + """ + mocker.patch( + "mitol.payment_gateway.api.PaymentGateway.start_refund", + side_effect=ApiException(), + ) + + def run_refund_order(order_id): + refund_response, _ = refund_order(order_id=order_id) + assert refund_response is False + + with pytest.raises(ApiException): + run_refund_order(order_id=fulfilled_transaction.order.id) + + assert ( + Transaction.objects.filter( + order=fulfilled_transaction.order.id, + transaction_type=TRANSACTION_TYPE_REFUND, + ).count() + == 0 + ) + + +def test_order_refund_failure_no_exception(mocker, fulfilled_transaction): + """ + Test that refund operation throws an exception if the gateway returns + an error state + """ + + class MockedGatewayResponse: + state = (ProcessorResponse.STATE_ERROR,) + message = ("This is an error message. Testing 123456",) + + error_return = MockedGatewayResponse() + + patched_refund_method = mocker.patch.object(PaymentGateway, "start_refund") + patched_refund_method.return_value = error_return + + with pytest.raises((PaymentGatewayError, PaypalRefundError)) as exc: + refund_order(order_id=fulfilled_transaction.order.id) + + assert "Testing 123456" in str(exc.value) + + assert ( + Transaction.objects.filter( + order=fulfilled_transaction.order.id, + transaction_type=TRANSACTION_TYPE_REFUND, + ).count() + == 0 + ) + + +def test_paypal_refunds(fulfilled_paypal_transaction): + """ + PayPal transactions should fail before they get to the payment gateway. + """ + + with pytest.raises((PaymentGatewayError, PaypalRefundError)) as exc: + refund_order(order_id=fulfilled_paypal_transaction.order.id) + + assert "PayPal" in str(exc.value) + + +def test_process_cybersource_payment_response(rf, mocker, user, products): + """ + Test that ensures the response from Cybersource for an ACCEPTed payment + updates the orders state + """ + mocker.patch( + "mitol.payment_gateway.api.PaymentGateway.validate_processor_response", + return_value=True, + ) + create_basket(user, products) + resp = generate_checkout_payload(generate_mocked_request(user)) + + payload = resp["payload"] + payload = { + **{f"req_{key}": value for key, value in payload.items()}, + "decision": "ACCEPT", + "message": "payment processor message", + "transaction_id": "12345", + } + + order = Order.objects.get(state=Order.STATE.PENDING, purchaser=user) + + assert order.reference_number == payload["req_reference_number"] + + request = rf.post(reverse("checkout-result-callback"), payload) + + # This is checked on the BackofficeCallbackView and CheckoutCallbackView + # POST endpoints since we expect to receive a response to both from + # Cybersource. If the current state is PENDING, then we should process + # the response. + assert order.state == Order.STATE.PENDING + result = process_cybersource_payment_response(request, order) + assert result == Order.STATE.FULFILLED + + +def test_process_cybersource_payment_decline_response( + rf, mocker, user_client, user, products +): + """ + Test that ensures the response from Cybersource for an DECLINEd payment + updates the orders state + """ + + mocker.patch( + "mitol.payment_gateway.api.PaymentGateway.validate_processor_response", + return_value=True, + ) + create_basket(user, products) + + resp = generate_checkout_payload(generate_mocked_request(user)) + + payload = resp["payload"] + payload = { + **{f"req_{key}": value for key, value in payload.items()}, + "decision": "DECLINE", + "message": "payment processor message", + "transaction_id": "12345", + } + + order = Order.objects.get(state=Order.STATE.PENDING, purchaser=user) + + assert order.reference_number == payload["req_reference_number"] + + request = rf.post(reverse("checkout-result-callback"), payload) + + # This is checked on the BackofficeCallbackView and CheckoutCallbackView + # POST endpoints since we expect to receive a response to both from + # Cybersource. If the current state is PENDING, then we should process + # the response. + assert order.state == Order.STATE.PENDING + + result = process_cybersource_payment_response(request, order) + assert result == Order.STATE.DECLINED + order.refresh_from_db() + + +@pytest.mark.parametrize("test_type", [None, "fail", "empty"]) +def test_check_and_process_pending_orders_for_resolution(mocker, test_type): + """ + Tests the pending order check. test_type can be: + - None - there's an order and it was found + - fail - there's an order but the payment failed (failed status in CyberSource) + - empty - order isn't pending + """ + order = OrderFactory.create(state=Order.STATE.PENDING) + + test_payload = { + "utf8": "", + "message": "Request was processed successfully.", + "decision": "100", + "auth_code": "888888", + "auth_time": "2023-02-09T20:06:51Z", + "signature": "", + "req_amount": "999", + "req_locale": "en-us", + "auth_amount": "999", + "reason_code": "100", + "req_currency": "USD", + "auth_avs_code": "X", + "auth_response": "100", + "req_card_type": "", + "request_token": "", + "card_type_name": "", + "req_access_key": "", + "req_item_0_sku": "60-2", + "req_profile_id": "2BA30484-75E7-4C99-A7D4-8BD7ADE4552D", + "transaction_id": "6759732112426719104003", + "req_card_number": "", + "req_consumer_id": "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918", + "req_item_0_code": "60", + "req_item_0_name": "course-v1:edX+E2E-101+course", + "signed_date_time": "2023-02-09T20:06:51Z", + "auth_avs_code_raw": "I1", + "auth_trans_ref_no": "123456789619999", + "bill_trans_ref_no": "123456789619999", + "req_bill_to_email": "testlearner@odl.local", + "req_payment_method": "card", + "signed_field_names": "", + "req_bill_to_surname": "LEARNER", + "req_item_0_quantity": 1, + "req_line_item_count": 1, + "req_bill_to_forename": "TEST", + "req_card_expiry_date": "02-2025", + "req_reference_number": f"{order.reference_number}", + "req_transaction_type": "sale", + "req_transaction_uuid": "", + "req_item_0_tax_amount": "0", + "req_item_0_unit_price": "999", + "req_customer_ip_address": "172.19.0.8", + "req_bill_to_address_city": "Tallahasseeeeeeee", + "req_bill_to_address_line1": "555 123 Place", + "req_bill_to_address_state": "FL", + "req_merchant_defined_data1": "1", + "req_bill_to_address_country": "US", + "req_bill_to_address_postal_code": "81992", + "req_override_custom_cancel_page": "https://rc.mitxonline.mit.edu/checkout/result/", + "req_override_custom_receipt_page": "https://rc.mitxonline.mit.edu/checkout/result/", + "req_card_type_selection_indicator": "001", + } + + retval = {} + + if test_type == "fail": + test_payload["reason_code"] = "999" + + if test_type == "empty": + order.state = Order.STATE.CANCELED + order.save() + order.refresh_from_db() + + if test_type is None or test_type == "fail": + retval = {f"{order.reference_number}": test_payload} + + mocked_gateway_func = mocker.patch( + "mitol.payment_gateway.api.CyberSourcePaymentGateway.find_and_get_transactions", + return_value=retval, + ) + + (fulfilled, cancelled, errored) = check_and_process_pending_orders_for_resolution() + + if test_type == "empty": + assert not mocked_gateway_func.called + assert (fulfilled, cancelled, errored) == (0, 0, 0) + elif test_type == "fail": + order.refresh_from_db() + assert order.state == Order.STATE.CANCELED + assert (fulfilled, cancelled, errored) == (0, 1, 0) + else: + order.refresh_from_db() + assert order.state == Order.STATE.FULFILLED + assert (fulfilled, cancelled, errored) == (1, 0, 0) diff --git a/payments/apps.py b/payments/apps.py new file mode 100644 index 00000000..b46e6372 --- /dev/null +++ b/payments/apps.py @@ -0,0 +1,10 @@ +"""App initialization for payments""" + +from django.apps import AppConfig + + +class PaymentsConfig(AppConfig): + """Config for the payments app""" + + default_auto_field = "django.db.models.BigAutoField" + name = "payments" diff --git a/payments/exceptions.py b/payments/exceptions.py new file mode 100644 index 00000000..a3037e5d --- /dev/null +++ b/payments/exceptions.py @@ -0,0 +1,12 @@ +"""Exceptions for payments app.""" + + +class PaypalRefundError(Exception): + """Raised when attempting to refund an order that was paid via PayPal.""" + + +class PaymentGatewayError(Exception): + """ + Raised when the payment gateway gives us an error, but didn't raise its own + exception. + """ diff --git a/payments/factories.py b/payments/factories.py new file mode 100644 index 00000000..8de51b87 --- /dev/null +++ b/payments/factories.py @@ -0,0 +1,70 @@ +"""Test factories for payments""" + +import faker +from factory import SubFactory, fuzzy +from factory.django import DjangoModelFactory + +from payments import models +from system_meta.factories import ProductFactory +from unified_ecommerce.factories import UserFactory + +FAKE = faker.Factory.create() + + +class BasketFactory(DjangoModelFactory): + """Factory for Basket""" + + user = SubFactory(UserFactory) + + class Meta: + """Meta options for BasketFactory""" + + model = models.Basket + + +class BasketItemFactory(DjangoModelFactory): + """Factory for BasketItem""" + + product = SubFactory(ProductFactory) + basket = SubFactory(BasketFactory) + + class Meta: + """Meta options for BasketFactory""" + + model = models.BasketItem + + +class OrderFactory(DjangoModelFactory): + """Factory for Order""" + + total_price_paid = fuzzy.FuzzyDecimal(10.00, 10.00) + purchaser = SubFactory(UserFactory) + + class Meta: + """Meta options for BasketFactory""" + + model = models.Order + + +class TransactionFactory(DjangoModelFactory): + """Factory for Transaction""" + + order = SubFactory(OrderFactory) + amount = fuzzy.FuzzyDecimal(10.00, 10.00) + + class Meta: + """Meta options for BasketFactory""" + + model = models.Transaction + + +class LineFactory(DjangoModelFactory): + """Factory for Line""" + + quantity = 1 + order = SubFactory(OrderFactory) + + class Meta: + """Meta options for BasketFactory""" + + model = models.Line diff --git a/payments/migrations/0001_add_order_and_basket_models.py b/payments/migrations/0001_add_order_and_basket_models.py new file mode 100644 index 00000000..02b8ae78 --- /dev/null +++ b/payments/migrations/0001_add_order_and_basket_models.py @@ -0,0 +1,301 @@ +# Generated by Django 4.2.8 on 2024-01-11 16:49 +import django.db.models.deletion +import django_fsm +from django.conf import settings +from django.db import migrations, models + +import payments.models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("reversion", "0002_add_index_on_version_for_content_type_and_db"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("system_meta", "0002_add_system_slug"), + ] + + operations = [ + migrations.CreateModel( + name="Basket", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ( + "integrated_system", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to="system_meta.integratedsystem", + ), + ), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="basket", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="Order", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ( + "state", + django_fsm.FSMField( + choices=[ + ("pending", "Pending"), + ("fulfilled", "Fulfilled"), + ("canceled", "Canceled"), + ("refunded", "Refunded"), + ("declined", "Declined"), + ("errored", "Errored"), + ("review", "Review"), + ("partially_refunded", "Partially Refunded"), + ], + default="pending", + max_length=50, + ), + ), + ( + "total_price_paid", + models.DecimalField(decimal_places=5, max_digits=20), + ), + ( + "reference_number", + models.CharField(blank=True, default="", max_length=255), + ), + ( + "purchaser", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="orders", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="Transaction", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ("transaction_id", models.CharField(max_length=255, unique=True)), + ("amount", models.DecimalField(decimal_places=5, max_digits=20)), + ("data", models.JSONField()), + ( + "transaction_type", + models.TextField( + choices=[("payment", "payment"), ("refund", "refund")], + default="payment", + max_length=20, + ), + ), + ("reason", models.CharField(blank=True, max_length=255)), + ( + "order", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="transactions", + to="payments.order", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="Line", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ("quantity", models.PositiveIntegerField()), + ( + "order", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="lines", + to="payments.order", + ), + ), + ( + "product_version", + models.ForeignKey( + limit_choices_to=payments.models.Line._order_line_product_versions, # noqa: SLF001 + on_delete=django.db.models.deletion.CASCADE, + to="reversion.version", + ), + ), + ], + ), + migrations.CreateModel( + name="BasketItem", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ("quantity", models.PositiveIntegerField(default=1)), + ( + "basket", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="basket_items", + to="payments.basket", + ), + ), + ( + "product", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="basket_item", + to="system_meta.product", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="CanceledOrder", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("payments.order",), + ), + migrations.CreateModel( + name="DeclinedOrder", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("payments.order",), + ), + migrations.CreateModel( + name="ErroredOrder", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("payments.order",), + ), + migrations.CreateModel( + name="FulfilledOrder", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("payments.order",), + ), + migrations.CreateModel( + name="PartiallyRefundedOrder", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("payments.order",), + ), + migrations.CreateModel( + name="PendingOrder", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=(payments.models.FulfillableOrder, "payments.order"), + ), + migrations.CreateModel( + name="RefundedOrder", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("payments.order",), + ), + migrations.CreateModel( + name="ReviewOrder", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=(payments.models.FulfillableOrder, "payments.order"), + ), + migrations.AddConstraint( + model_name="line", + constraint=models.UniqueConstraint( + fields=("order_id", "product_version_id"), + name="unique_order_purchased_object", + ), + ), + ] diff --git a/payments/migrations/0002_remove_system_slug_from_basket.py b/payments/migrations/0002_remove_system_slug_from_basket.py new file mode 100644 index 00000000..d0d0ec6b --- /dev/null +++ b/payments/migrations/0002_remove_system_slug_from_basket.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2.9 on 2024-02-08 16:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("payments", "0001_add_order_and_basket_models"), + ] + + operations = [ + migrations.RemoveField( + model_name="basket", + name="integrated_system", + ), + ] diff --git a/payments/migrations/__init__.py b/payments/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/models.py b/payments/models.py new file mode 100644 index 00000000..3469e892 --- /dev/null +++ b/payments/models.py @@ -0,0 +1,637 @@ +"""Models for payment processing.""" +# ruff: noqa: TD002,TD003,FIX002 + +import logging +import re +import uuid + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db import models, transaction +from django.utils.functional import cached_property +from django_fsm import FSMField, transition +from mitol.common.models import TimestampedModel +from reversion.models import Version + +from system_meta.models import Product +from unified_ecommerce.constants import ( + TRANSACTION_TYPE_PAYMENT, + TRANSACTION_TYPE_REFUND, + TRANSACTION_TYPES, +) + +User = get_user_model() +logger = logging.getLogger(__name__) + + +class Basket(TimestampedModel): + """Represents a User's basket.""" + + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="basket") + + def compare_to_order(self, order): + """ + Compare this basket with the specified order. An order is considered + equal to the basket if it meets these criteria: + - Users match + - Products match on each line + - Discounts match + """ + if self.user != order.purchaser: + return False + + all_items_found = self.basket_items.count() == order.lines.count() + + if all_items_found: + for basket_item in self.basket_items.all(): + for order_item in order.lines.all(): + if order_item.product != basket_item.product: + all_items_found = False + + return all_items_found + + def get_products(self): + """ + Return the products that have been added to the basket so far. + """ + + return [item.product for item in self.basket_items.all()] + + @staticmethod + def establish_basket(request): + """ + Get or create the user's basket. + + Args: + request (HttpRequest): The HTTP request. + system (IntegratedSystem): The system to associate with the basket. + """ + user = request.user + (basket, is_new) = Basket.objects.filter(user=user).get_or_create( + defaults={"user": user} + ) + + if is_new: + basket.save() + + return basket + + +class BasketItem(TimestampedModel): + """Represents one or more products in a user's basket.""" + + product = models.ForeignKey( + Product, on_delete=models.CASCADE, related_name="basket_item" + ) + basket = models.ForeignKey( + Basket, on_delete=models.CASCADE, related_name="basket_items" + ) + quantity = models.PositiveIntegerField(default=1) + + @cached_property + def discounted_price(self): + """ + Return the price of the product with discounts. + + TODO: we don't have discounts yet, so this needs to be filled out when we do. + """ + return self.base_price + + @cached_property + def base_price(self): + """Return the total price of the basket item without discounts.""" + return self.product.price * self.quantity + + +class Order(TimestampedModel): + """An order containing information for a purchase.""" + + class STATE: + """Possible states for an order.""" + + PENDING = "pending" + FULFILLED = "fulfilled" + CANCELED = "canceled" + DECLINED = "declined" + ERRORED = "errored" + REFUNDED = "refunded" + REVIEW = "review" + PARTIALLY_REFUNDED = "partially_refunded" + + @classmethod + def choices(cls): + """Return the valid choices, their human-readable names, and the order class + they belong to. + """ + return ( + (cls.PENDING, "Pending", "PendingOrder"), + (cls.FULFILLED, "Fulfilled", "FulfilledOrder"), + (cls.CANCELED, "Canceled", "CanceledOrder"), + (cls.REFUNDED, "Refunded", "RefundedOrder"), + (cls.DECLINED, "Declined", "DeclinedOrder"), + (cls.ERRORED, "Errored", "ErroredOrder"), + (cls.REVIEW, "Review", "ReviewOrder"), + ( + cls.PARTIALLY_REFUNDED, + "Partially Refunded", + "PartiallyRefundedOrder", + ), + ) + + state = FSMField(default=STATE.PENDING, state_choices=STATE.choices()) + purchaser = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="orders", + ) + total_price_paid = models.DecimalField( + decimal_places=5, + max_digits=20, + ) + reference_number = models.CharField(max_length=255, default="", blank=True) + + # override save method to auto-fill generated_rerefence_number + def save(self, *args, **kwargs): + """Save the order.""" + + logger.info("Saving order %s", self.id) + + # initial save in order to get primary key for new order + super().save(*args, **kwargs) + + # can't insert twice because it'll try to insert with a PK now + kwargs.pop("force_insert", None) + + # if we don't have a generated reference number, we generate one and save again + if self.reference_number is None or len(self.reference_number) == 0: + logger.info("Generating reference number for order %s", self.id) + self.reference_number = self._generate_reference_number() + super().save(*args, **kwargs) + + # Flag to determine if the order is in review status - if it is, then + # we need to not step on the basket that may or may not exist when it is + # accepted + @property + def is_review(self): + """Return if the order is in review status""" + return self.state == Order.STATE.REVIEW + + @property + def is_fulfilled(self): + """Return if the order is fulfilled""" + return self.state == Order.STATE.FULFILLED + + def fulfill(self, payment_data): + """Fulfill this order""" + raise NotImplementedError + + def cancel(self): + """Cancel this order""" + raise NotImplementedError + + def decline(self): + """Decline this order""" + raise NotImplementedError + + def review(self): + """Place order in review""" + raise NotImplementedError + + def errored(self): + """Error this order""" + raise NotImplementedError + + def refund(self, *, api_response_data, **kwargs): + """Issue a refund""" + raise NotImplementedError + + def _generate_reference_number(self): + """Generate the order reference number""" + return ( + f"{settings.MITOL_UE_REFERENCE_NUMBER_PREFIX}-" + f"{settings.ENVIRONMENT}-{self.id}" + ) + + def __str__(self): + """Generate a string representation of the order""" + return ( + f"{self.state.capitalize()} Order for {self.purchaser.username}" + f" ({self.purchaser.email})" + ) + + @staticmethod + def decode_reference_number(refno): + """Decode the reference number""" + return re.sub(rf"^.*-{settings.ENVIRONMENT}-", "", refno) + + +class FulfillableOrder: + """class to handle common logics like fulfill, enrollment etc""" + + def create_transaction(self, payment_data): + """ + Create the transaction record for the order. This contains payment + processor-specific data. + """ + transaction_id = payment_data.get("transaction_id") + amount = payment_data.get("amount") + # There are two use cases: + # No payment required - no cybersource involved, so we need to generate + # a UUID as transaction id + # Payment STATE_ACCEPTED - there should always be transaction_id in payment + # data, if not, throw ValidationError + if amount == 0 and transaction_id is None: + transaction_id = uuid.uuid1() + elif transaction_id is None: + exception_message = ( + "Failed to record transaction: Missing transaction id" + " from payment API response" + ) + raise ValidationError(exception_message) + + self.transactions.get_or_create( + transaction_id=transaction_id, + data=payment_data, + amount=self.total_price_paid, + ) + + def handle_post_sale(self): + """ + Trigger post-sale events. This is where we used to have the logic to create + courseruns enrollments and stuff. + + TODO: this should be implemented using Pluggy to figure out what to send back + to the connected system. + """ + + def send_ecommerce_order_receipt(self): + """ + Send the receipt email. + + TODO: add email + """ + + @transition( + field="state", + source=Order.STATE.PENDING, + target=Order.STATE.FULFILLED, + ) + def fulfill(self, payment_data): + """Fufill the order.""" + # record the transaction + self.create_transaction(payment_data) + + # trigger post-sale events + transaction.on_commit(self.handle_post_sale) + + # send the receipt emails + transaction.on_commit(self.send_ecommerce_order_receipt) + + +class PendingOrder(FulfillableOrder, Order): + """An order that is pending payment""" + + @transaction.atomic + def _get_or_create(self, products: list[Product], user: User): + """ + Return a singleton PendingOrder for the given products and user. + + Args: + - products (List[Product]): List of Products associated with the + PendingOrder. + - user (User): The user expected to be associated with the PendingOrder. + - discounts (List[Discounts]): List of Discounts to apply to each Line + associated with the order. + (TODO: update when the discounts code is migrated.) + + Returns: + PendingOrder: the retrieved or created PendingOrder. + """ + # Get the details from each Product. + product_versions = [ + Version.objects.get_for_object(product).first() for product in products + ] + + # Get or create a PendingOrder + # TODO: we prefetched the discounts here + orders = Order.objects.select_for_update().filter( + lines__product_version__in=product_versions, + state=Order.STATE.PENDING, + purchaser=user, + ) + # Previously, multiple PendingOrders could be created for a single user + # for the same product, if multiple exist, grab the first. + if orders: + order = orders.first() + # TODO: this should clear discounts from the order here + + order.refresh_from_db() + else: + order = Order.objects.create( + state=Order.STATE.PENDING, + purchaser=user, + total_price_paid=0, + ) + + # TODO: Apply any discounts to the PendingOrder + + # Create or get Line for each product. + # Calculate the Order total based on Lines and discount. + total = 0 + for i, _ in enumerate(products): + line, _ = order.lines.get_or_create( + order=order, + defaults={ + "product_version": product_versions[i], + "quantity": 1, + }, + ) + total += line.discounted_price + + order.total_price_paid = total + + order.save() + + return order + + @classmethod + def create_from_basket(cls, basket: Basket): + """ + Create a new pending order from a basket + + Args: + basket (Basket): the user's basket to create an order for + + Returns: + PendingOrder: the created pending order + """ + products = basket.get_products() + return cls._get_or_create(cls, products, basket.user) + + @classmethod + def create_from_product(cls, product: Product, user: User): + """ + Create a new pending order from a product + + Args: + - product (Product): the product to create an order for + - user (User): the user to create an order for + - discount (Discount): the discount code to create an order discount redemption + + Returns: + PendingOrder: the created pending order + """ + + return cls._get_or_create(cls, [product], user) + + @transition(field="state", source=Order.STATE.PENDING, target=Order.STATE.CANCELED) + def cancel(self): + """Cancel this order""" + + @transition(field="state", source=Order.STATE.PENDING, target=Order.STATE.DECLINED) + def decline(self): + """ + Decline this order. This additionally clears the discount redemptions + for the order so the discounts can be reused. + """ + self.state = Order.STATE.DECLINED + self.save() + + return self + + @transition(field="state", source=Order.STATE.PENDING, target=Order.STATE.ERRORED) + def error(self): + """Error this order""" + + class Meta: + """Model meta options""" + + proxy = True + + +class FulfilledOrder(Order): + """An order that has a fulfilled payment""" + + @transition(field="state", source=Order.STATE.FULFILLED, target=Order.STATE.ERRORED) + def error(self): + """Error this order""" + + @transition( + field="state", + source=Order.STATE.FULFILLED, + target=Order.STATE.REFUNDED, + custom={"admin": False}, + ) + def refund(self, *, api_response_data: dict | None = None, **kwargs): + """ + Record the refund, then trigger any post-refund events. + + Args: + - api_response_data (dict|None): Response from the payment gateway for the + refund, if any + + Keyword Args: + - amount: amount that was refunded + - reason: reason for refunding the order + + Returns: + - Object (Transaction): the refund transaction object for the refund. + """ + amount = kwargs.get("amount") + reason = kwargs.get("reason") + + transaction_id = api_response_data.get("id") + if transaction_id is None: + exception_message = ( + "Failed to record transaction: Missing transaction id" + " from refund API response" + ) + raise ValidationError(exception_message) + + refund_transaction, _ = self.transactions.get_or_create( + transaction_id=transaction_id, + data=api_response_data, + amount=amount, + transaction_type=TRANSACTION_TYPE_REFUND, + reason=reason, + ) + self.state = Order.STATE.REFUNDED + self.save() + + # TODO: send_order_refund_email.delay(self.id) + # (and any other post-refund events) + + return refund_transaction + + class Meta: + """Model meta options.""" + + proxy = True + + +class ReviewOrder(FulfillableOrder, Order): + """An order that has been placed under review by the payment processor.""" + + class Meta: + """Model meta options.""" + + proxy = True + + +class CanceledOrder(Order): + """ + An order that is canceled. + + The state of this can't be altered further. + """ + + @transition(field="state", source=Order.STATE.CANCELED, target=Order.STATE.ERRORED) + def error(self): + """Error this order""" + + class Meta: + """Model meta options.""" + + proxy = True + + +class RefundedOrder(Order): + """ + An order that is refunded. + + The state of this can't be altered further. + """ + + class Meta: + """Model meta options.""" + + proxy = True + + +class DeclinedOrder(Order): + """ + An order that is declined. + + The state of this can't be altered further. + """ + + @transition(field="state", source=Order.STATE.DECLINED, target=Order.STATE.ERRORED) + def error(self): + """Error this order""" + + class Meta: + """Model meta options.""" + + proxy = True + + +class ErroredOrder(Order): + """ + An order that is errored. + + The state of this can't be altered further. + """ + + class Meta: + """Model meta options.""" + + proxy = True + + +class PartiallyRefundedOrder(Order): + """ + An order that is partially refunded. + + The state of this can't be altered further. + """ + + class Meta: + """Model meta options.""" + + proxy = True + + +class Line(TimestampedModel): + """A line in an Order.""" + + def _order_line_product_versions(): + """Return a Q object filtering to Versions for Products""" + return models.Q() + + order = models.ForeignKey( + "payments.Order", + on_delete=models.CASCADE, + related_name="lines", + ) + product_version = models.ForeignKey( + Version, + limit_choices_to=_order_line_product_versions, + on_delete=models.CASCADE, + ) + quantity = models.PositiveIntegerField() + + class Meta: + """Model meta options.""" + + constraints = [ + models.UniqueConstraint( + fields=["order_id", "product_version_id"], + name="unique_order_purchased_object", + ) + ] + + @property + def item_description(self): + """Return the item description""" + return self.product_version.field_dict["description"] + + @property + def unit_price(self): + """Return the price of the product""" + return self.product_version.field_dict["price"] + + @cached_property + def total_price(self): + """Return the price of the product""" + return self.unit_price * self.quantity + + @cached_property + def discounted_price(self): + """Return the price of the product with discounts""" + return self.total_price + + @cached_property + def product(self): + """Return the product associated with the line""" + return Product.resolve_product_version( + Product.all_objects.get(pk=self.product_version.field_dict["id"]), + self.product_version, + ) + + def __str__(self): + """Return string version of the line.""" + return f"{self.product_version}" + + +class Transaction(TimestampedModel): + """A transaction on an order, generally a payment but can also cover refunds""" + + # Per CyberSourse, Request ID should be 22 digits + transaction_id = models.CharField(max_length=255, unique=True) + + order = models.ForeignKey( + "payments.Order", on_delete=models.CASCADE, related_name="transactions" + ) + amount = models.DecimalField( + decimal_places=5, + max_digits=20, + ) + data = models.JSONField() + transaction_type = models.TextField( + choices=TRANSACTION_TYPES, + default=TRANSACTION_TYPE_PAYMENT, + null=False, + max_length=20, + ) + reason = models.CharField(max_length=255, blank=True) diff --git a/payments/serializers/__init__.py b/payments/serializers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/serializers/v0/__init__.py b/payments/serializers/v0/__init__.py new file mode 100644 index 00000000..eb83c132 --- /dev/null +++ b/payments/serializers/v0/__init__.py @@ -0,0 +1,111 @@ +"""Serializers for payments.""" + +from rest_framework import serializers + +from payments.models import Basket, BasketItem +from system_meta.models import Product +from system_meta.serializers import ProductSerializer + + +class BasketItemSerializer(serializers.ModelSerializer): + """BasketItem model serializer""" + + def perform_create(self, validated_data): + """ + Create a BasketItem instance based on the validated data. + + Args: + validated_data (dict): The validated data with which to create the + BasketIteminstance. + + Returns: + BasketItem: The created BasketItem instance. + """ + basket = Basket.objects.get(user=validated_data["user"]) + # Product queryset returns active Products by default + product = Product.objects.get(id=validated_data["product"]) + item, _ = BasketItem.objects.get_or_create(basket=basket, product=product) + return item + + class Meta: + """Meta options for BasketItemSerializer""" + + model = BasketItem + fields = [ + "basket", + "product", + "id", + ] + + +class BasketSerializer(serializers.ModelSerializer): + """Basket model serializer""" + + basket_items = serializers.SerializerMethodField() + + def get_basket_items(self, instance): + """Get items in the basket""" + return [ + BasketItemSerializer(instance=basket, context=self.context).data + for basket in instance.basket_items.all() + ] + + class Meta: + """Meta options for BasketSerializer""" + + fields = [ + "id", + "user", + "basket_items", + ] + model = Basket + + +class BasketItemWithProductSerializer(serializers.ModelSerializer): + """Basket item model serializer with product information""" + + product = serializers.SerializerMethodField() + + def get_product(self, instance): + """Get the product associated with the basket item""" + return ProductSerializer(instance=instance.product, context=self.context).data + + class Meta: + """Meta options for BasketItemWithProductSerializer""" + + model = BasketItem + fields = ["basket", "product", "id"] + depth = 1 + + +class BasketWithProductSerializer(serializers.ModelSerializer): + """Basket model serializer with items and products""" + + basket_items = serializers.SerializerMethodField() + total_price = serializers.SerializerMethodField() + discounted_price = serializers.SerializerMethodField() + discounts = serializers.SerializerMethodField() + + def get_basket_items(self, instance): + """Get the items in the basket""" + return [ + BasketItemWithProductSerializer(instance=basket, context=self.context).data + for basket in instance.basket_items.all() + ] + + def get_total_price(self, instance): + """Get the total price for the basket""" + return sum( + basket_item.base_price for basket_item in instance.basket_items.all() + ) + + class Meta: + """Meta options for BasketWithProductSerializer""" + + fields = [ + "id", + "user", + "basket_items", + "total_price", + ] + model = Basket diff --git a/payments/urls.py b/payments/urls.py new file mode 100644 index 00000000..1cc72f03 --- /dev/null +++ b/payments/urls.py @@ -0,0 +1,5 @@ +"""URLs for the payments app.""" + +from payments.views.v0.urls import urlpatterns as v0_urls + +urlpatterns = v0_urls diff --git a/payments/views/__init__.py b/payments/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/views/v0/__init__.py b/payments/views/v0/__init__.py new file mode 100644 index 00000000..5889dbc3 --- /dev/null +++ b/payments/views/v0/__init__.py @@ -0,0 +1,231 @@ +"""Views for the REST API for payments.""" + +import logging + +from django.core.exceptions import ObjectDoesNotExist +from django.db import transaction +from django.http import Http404 +from django.shortcuts import redirect +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from rest_framework import mixins, status +from rest_framework.decorators import action, api_view, permission_classes +from rest_framework.generics import ListCreateAPIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.viewsets import ( + GenericViewSet, + ViewSet, +) +from rest_framework_extensions.mixins import NestedViewSetMixin + +from payments import api +from payments.models import Basket, BasketItem, Order +from payments.serializers.v0 import BasketItemSerializer, BasketSerializer +from system_meta.models import IntegratedSystem, Product + +log = logging.getLogger(__name__) + +# Baskets + + +class BasketViewSet( + NestedViewSetMixin, mixins.ListModelMixin, mixins.RetrieveModelMixin, GenericViewSet +): + """API view set for Basket""" + + serializer_class = BasketSerializer + permission_classes = [IsAuthenticated] + lookup_field = "user__username" + lookup_url_kwarg = "username" + + def get_object(self): + """ + Retrieve basket for the authenticated user. + + Returns: + Basket: basket for the authenticated user + """ + return Basket.objects.get(user=self.request.user) + + def get_queryset(self): + """ + Return all baskets for the authenticated user. + + Returns: + QuerySet: all baskets for the authenticated user + """ + return Basket.objects.filter(user=self.request.user).all() + + +class BasketItemViewSet( + NestedViewSetMixin, ListCreateAPIView, mixins.DestroyModelMixin, GenericViewSet +): + """API view set for BasketItem""" + + serializer_class = BasketItemSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """ + Return all basket items for the authenticated user. + + Returns: + QuerySet: all basket items for the authenticated user + """ + return BasketItem.objects.filter(basket__user=self.request.user) + + def create(self, request): + """ + Create a new basket item. + + Args: + request (HttpRequest): HTTP request + + Returns: + Response: HTTP response + """ + basket = Basket.objects.get(user=request.user) + product_id = request.data.get("product") + serializer = self.get_serializer( + data={"product": product_id, "basket": basket.id} + ) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def create_basket_from_product(request, system_slug, sku): + """ + Create a new basket item from a product for the currently logged in user. Reuse + the existing basket object if it exists. + + If the checkout flag is set in the POST data, then this will create the + basket, then immediately flip the user to the checkout interstitial (which + then redirects to the payment gateway). + + Args: + system_slug (str): system slug + sku (str): product slug + + POST Args: + quantity (int): quantity of the product to add to the basket (defaults to 1) + checkout (bool): redirect to checkout interstitial (defaults to False) + + Returns: + Response: HTTP response + """ + system = IntegratedSystem.objects.get(slug=system_slug) + basket = Basket.establish_basket(request) + quantity = request.data.get("quantity", 1) + checkout = request.data.get("checkout", False) + + try: + product = Product.objects.get(system=system, sku=sku) + except Product.DoesNotExist: + return Response( + {"error": "Product not found"}, status=status.HTTP_404_NOT_FOUND + ) + + (_, created) = BasketItem.objects.update_or_create( + basket=basket, product=product, defaults={"quantity": quantity} + ) + basket.refresh_from_db() + + if checkout: + return redirect("checkout_interstitial_page") + + return Response( + BasketSerializer(basket).data, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + +@api_view(["DELETE"]) +@permission_classes([IsAuthenticated]) +def clear_basket(request): + """ + Clear the basket for the current user. + + Returns: + Response: HTTP response + """ + basket = Basket.establish_basket(request) + + basket.delete() + + return Response(None, status=status.HTTP_204_NO_CONTENT) + + +# Checkout + + +class CheckoutApiViewSet(ViewSet): + """Handles checkout.""" + + permission_classes = (IsAuthenticated,) + + @action( + detail=False, methods=["post"], name="Start Checkout", url_name="start_checkout" + ) + def start_checkout(self, request): + """ + Start the checkout process. This assembles the basket items + into an Order with Lines for each item, applies the attached basket + discounts, and then calls the payment gateway to prepare for payment. + + This is expected to be called from within the Ecommerce cart app, not + from an integrated system. + + Returns: + - JSON payload from the ol-django payment gateway app. The payment + gateway returns data necessary to construct a form that will + ultimately POST to the actual payment processor. + """ + try: + payload = api.generate_checkout_payload(request) + except ObjectDoesNotExist: + return Response("No basket", status=status.HTTP_406_NOT_ACCEPTABLE) + + return Response(payload) + + +@method_decorator(csrf_exempt, name="dispatch") +class BackofficeCallbackView(APIView): + """ + Provides the webhook that the payment gateway uses to signal that a + transaction's status has changed. + """ + + authentication_classes = [] # disables authentication + permission_classes = [] # disables permission + + def post(self, request): + """ + Handle webhook call from the payment gateway when the user has + completed a transaction. + + Returns: + - HTTP_200_OK if the Order is found. + + Raises: + - Http404 if the Order is not found. + """ + with transaction.atomic(): + order = api.get_order_from_cybersource_payment_response(request) + + # We only want to process responses related to orders which are PENDING + # otherwise we can conclude that we already received a response through + # the user's browser. + if order is None: + raise Http404 + elif order.state == Order.STATE.PENDING: + api.process_cybersource_payment_response(request, order) + + return Response(status=status.HTTP_200_OK) diff --git a/payments/views/v0/urls.py b/payments/views/v0/urls.py new file mode 100644 index 00000000..09066cb7 --- /dev/null +++ b/payments/views/v0/urls.py @@ -0,0 +1,43 @@ +"""Routes specific to this version of the payments API.""" +from django.urls import include, path, re_path + +from payments.views.v0 import ( + BackofficeCallbackView, + BasketItemViewSet, + BasketViewSet, + CheckoutApiViewSet, + clear_basket, + create_basket_from_product, +) +from unified_ecommerce.routers import SimpleRouterWithNesting + +router = SimpleRouterWithNesting() + +basket_router = router.register(r"baskets", BasketViewSet, basename="basket") +basket_router.register( + r"items", + BasketItemViewSet, + basename="basket-items", + parents_query_lookups=["basket"], +) + +router.register(r"checkout", CheckoutApiViewSet, basename="checkout") + +urlpatterns = [ + path( + "baskets/create_from_product///", + create_basket_from_product, + name="create_from_product", + ), + path( + "baskets/clear/", + clear_basket, + name="clear_basket", + ), + path( + "checkout/callback/", + BackofficeCallbackView.as_view(), + name="checkout-callback", + ), + re_path("^", include(router.urls)), +]