Skip to content

Commit

Permalink
Adds the payments app (APIs for checkout), adds cart test mule app
Browse files Browse the repository at this point in the history
  • Loading branch information
jkachel committed Feb 29, 2024
1 parent cb1908b commit e8b1c48
Show file tree
Hide file tree
Showing 25 changed files with 2,929 additions and 0 deletions.
Empty file added cart/__init__.py
Empty file.
10 changes: 10 additions & 0 deletions cart/apps.py
Original file line number Diff line number Diff line change
@@ -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"
Empty file added cart/migrations/__init__.py
Empty file.
19 changes: 19 additions & 0 deletions cart/templates/base.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<head>
<title>MIT ODL Ecommerce - {% block title %}{% endblock title %}</title>

<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous"><!-- pragma: allowlist secret -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script><!-- pragma: allowlist secret -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
</head>
<body class="bg-body-secondary">
<div id="body-container" class="w-75 mx-auto flex container mt-5 bg-light p-4">
<div class="row mb-3">
<div class="col-12">
<h1 class="border-bottom border-5 border-black">MIT ODL Ecommerce - {% block innertitle %}{% endblock innertitle %}</h1>
</div>
</div>
{% block body %}{% endblock body %}
</div>
</body>
</html>
125 changes: 125 additions & 0 deletions cart/templates/cart.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
{% extends "base.html" %}

{% block title %}Cart{% endblock %}
{% block innertitle %}Cart{% endblock %}

{% block body %}
<div class="row">
<div class="col-12">
<p>
This is the current cart information.
<button type="button" id="clear-button" class="btn btn-secondary float-right">Clear Cart</button>
</p>
</div>
</div>
{% if basket %}
<div class="row">
<div class="col-12">
<table class="table">
<thead>
<tr>
<th scope="col">Product</th>
<th scope="col">Price</th>
<th scope="col">Quantity</th>
<th scope="col">Total</th>
</tr>
</thead>
<tbody>
{% if basket_items|length == 0 %}
<tr>
<td colspan="4">No items in the basket.</td>
</tr>
{% endif %}
{% for item in basket_items %}
<tr>
<td>{{ item.product }}</td>
<td>{{ item.product.price }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.product.price }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="4">
<a href="{% url 'checkout_interstitial_page' %}" class="btn btn-primary float-right">Check Out</a>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
{% endif %}
<div class="row">
<div class="col-12">
<p>Add a product to the basket:</p>

<form method="post" id="cartform">
{% csrf_token %}
<input type="hidden" name="checkout" value="1" />
<div class="form-group">
<label for="product">Product</label>
<select name="product" id="product">
<option></option>
{% for product in products %}
<option value="{{ product.system.slug }}/{{ product.sku }}">{{ product.system.name }} - {{ product.name }} ${{ product.price }}</option>
{% endfor %}
</select>
</div>

<div class="form-group d-flex align-items-end">
<button type="submit" class="wl-auto btn btn-primary">Add Item</button>
<button type="submit" id="add-and-checkout" class="wl-auto btn btn-success">Add Item &amp; Check Out</button>
</div>
</form>
</div>
</div>

<script type="text/javascript">
function add_to_cart(event) {
console.log("adding to cart");

var product_raw = document.getElementById("product").value;
var csrfmiddlewaretoken = document.querySelector("input[name='csrfmiddlewaretoken']").value

if (event.submitter.id === "add-and-checkout") {
var form = event.target;

form.setAttribute("action", `http://ue.odl.local/api/v0/payments/baskets/create_from_product/${product_raw}/`)
return true;
}

event.preventDefault();

axios.post(`http://ue.odl.local/api/v0/payments/baskets/create_from_product/${product_raw}/`, {}, { headers: { "X-CSRFToken": csrfmiddlewaretoken }})
.then(function (response) {
window.location.reload();
})
.catch(function (error) {
alert(error);
});

return false;
}

function clear_cart(event) {
event.preventDefault();

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

axios.delete(`http://ue.odl.local/api/v0/payments/baskets/clear/`, { headers: { "X-CSRFToken": csrfmiddlewaretoken } })
.then(function (response) {
window.location.reload();
})
.catch(function (error) {
alert(error);
});

return false;
}

document.getElementById("cartform").addEventListener("submit", add_to_cart);
document.getElementById("clear-button").addEventListener("click", clear_cart);
</script>
{% endblock body %}
34 changes: 34 additions & 0 deletions cart/templates/checkout_interstitial.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% load i18n static %}

{% block title %}{% trans "Complete Payment" %}{% endblock %}
{% block innertitle %}{% trans "Complete Payment" %}{% endblock %}

{% block body %}
<section id="main" class="info-block">
<div class="container">
<div class="content-row">
<div class="content-col">
<div class="text">
<h1 class="mt-4">Redirecting to the payment processor...</h1>

<form id="checkout_form" method="post" action="{{ checkout_payload.url }}">
{% for key, value in form.items %}
<div><label for="{{ key }}">{{ key }}</label>
<input type="text" readonly="readonly" name="{{ key }}" value="{{ value }}" /></div>
{% endfor %}

<div>
<button class="btn btn-primary" type="submit">CyberSource It</button>
</div>
</form>

<script type="text/javascript">
//document.getElementById('checkout_form').submit();
</script>
</div>
</div>
</div>
</div>
</section>
{% endblock %}
19 changes: 19 additions & 0 deletions cart/urls.py
Original file line number Diff line number Diff line change
@@ -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"),
]
186 changes: 186 additions & 0 deletions cart/views.py
Original file line number Diff line number Diff line change
@@ -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"]},
)
Empty file added payments/__init__.py
Empty file.
Loading

0 comments on commit e8b1c48

Please sign in to comment.