Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions nau_extensions/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,13 @@

INSTALLED_APPS += ("nau_extensions",)

# Mount the nau_extensions URLs the same way the deployment does, so the tests
# exercise the real paths. See `ECOMMERCE_EXTRA_PAYMENT_PROCESSOR_URLS` in
# nau-tutor-configs' config-fragment.yml, which produces `/payment/nau_extensions/`.
EXTRA_PAYMENT_PROCESSOR_URLS = {
**EXTRA_PAYMENT_PROCESSOR_URLS,
"nau_extensions": "nau_extensions.urls",
}

# This setting needs to be specified on this level.
NAU_EXTENSION_OSCAR_RATE_TAX_STRATEGY_CLASS = "nau_extensions.strategy.SettingFixedRateTax"
14 changes: 9 additions & 5 deletions nau_extensions/urls.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,31 @@
from django.conf.urls import url
from nau_extensions.views import (
BasketBillingInformationAddressCreateUpdateView,
BasketBillingInformationVATINCreateUpdateView, ReceiptLinkView)
BasketBillingInformationVATINCreateUpdateView, OrderPaymentStatusView,
ReceiptLinkView)

app_name = "ecommerce_nau_extensions"

urlpatterns = [
url(
r"basket-billing-information/address/$",
BasketBillingInformationAddressCreateUpdateView.as_view(),
name="nau-basket-billing-information-address",
name="basket_billing_information_address_create_update",
),

url(
r"basket-billing-information/vatin/$",
BasketBillingInformationVATINCreateUpdateView.as_view(),
name="nau-basket-billing-information-vatin",
name="basket_billing_information_vatin_create_update",
),

url(
r"receipt-link/$",
ReceiptLinkView.as_view(),
name="receipt_link_view",
),
url(
r"order-payment-status/$",
OrderPaymentStatusView.as_view(),
name="order_payment_status",
),

]
135 changes: 135 additions & 0 deletions nau_extensions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,23 @@
from nau_extensions.models import BasketBillingInformation
from nau_extensions.serializers import OrderReceiptLinkSerializer
from nau_extensions.utils import get_default_country
from oscar.apps.payment.exceptions import GatewayError, PaymentError
from oscar.core.loading import get_class, get_model
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

from ecommerce.extensions.api.permissions import IsStaffOrOwner
from ecommerce.extensions.api.throttles import ServiceUserThrottle
from ecommerce.extensions.fulfillment.status import ORDER

logger = logging.getLogger(__name__)

UserAddress = get_model("address", "UserAddress")
Basket = get_model("basket", "Basket")
Order = get_model('order', 'Order')
PaymentProcessorResponse = get_model("payment", "PaymentProcessorResponse")

AbstractAddressForm = get_class("address.forms", "AbstractAddressForm")

Expand Down Expand Up @@ -223,3 +227,134 @@ def get(self, request):
logging.info("For Order id=[%s] returning receipt_link=[%s]", order_id, receipt_link)
return HttpResponse(receipt_link if receipt_link else '')
raise Http404("No id parameter found")


class OrderPaymentStatusView(APIView):
"""
API GET /payment/nau_extensions/order-payment-status/?order_number=OPENEDX-100012

Lazily resolves the payment status of a single order.

Asynchronous payment methods -- on NAU, the Multibanco reference (`REFMB`) --
are not confirmed while the user is still in the browser: PayGate hands out a
reference that can be paid days later. When the user comes back from PayGate an
order is placed in the `Pending` status, unpaid and unfulfilled, so the payment
is visible on the Order History page.

This endpoint is what turns a `Pending` order into a paid one. There is no
background job and no polling: the Order History page calls this once per
pending row when the user opens it, and it runs exactly the same
`handle_payment` the synchronous checkout flow runs.

The `status` returned is the ecommerce order status, so it shares a vocabulary
with the `status` field of `/api/v2/orders/`:

* ``Complete`` : paid and fulfilled.
* ``Open`` : paid, fulfilment pending or failed.
* ``Pending`` : PayGate has not confirmed the payment yet.
* ``Payment Error`` : the payment failed and will not resolve itself.

Only the order owner (or a staff user) may query it.
"""

permission_classes = (IsAuthenticated,)
throttle_classes = (ServiceUserThrottle,)

@method_decorator(transaction.non_atomic_requests)
def dispatch(self, request, *args, **kwargs):
"""
Disable atomicity for this view. Fulfilment is triggered from here and, as
in the checkout views, the order has to be committed before the fulfilment
tasks run.
"""
return super().dispatch(request, *args, **kwargs)

def get(self, request):
order_number = request.query_params.get("order_number")
if not order_number:
raise Http404("Missing 'order_number' query parameter")

order = Order.objects.filter(number=order_number).first()
if not order:
raise Http404(f"No order found for order_number [{order_number}]")

if not request.user.is_staff and order.user_id != request.user.id:
raise PermissionDenied

# Anything that is not pending is already settled: never call the payment
# processor for it, otherwise opening the Order History page would re-run
# handle_payment against every order the user ever placed.
if order.status != ORDER.PENDING:
return Response(self._serialize(order))

return Response(self._serialize(self._resolve(request, order)))

def _resolve(self, request, order):
"""
Ask the payment processor whether the pending `order` has been paid, and
fulfil it when it has.
"""
# Imported lazily so that `nau_extensions` keeps working on a deployment
# that does not install the PayGate plugin.
try:
from paygate.pending_orders import ( # pylint: disable=import-outside-toplevel
confirm_pending_order, mark_order_as_payment_error)
from paygate.utils import \
get_basket # pylint: disable=import-outside-toplevel
except ImportError:
logger.warning(
"Cannot resolve the payment of order [%s]: the paygate plugin is not installed",
order.number,
)
return order

# `get_basket` assigns the strategy the basket needs to price its lines.
basket = get_basket(order.basket_id, request)
if basket is None:
logger.warning(
"Cannot resolve the payment of order [%s]: it has no basket",
order.number,
)
return order

last_response = (
PaymentProcessorResponse.objects
.filter(basket=basket, processor_name="paygate")
.order_by("-created")
.first()
)

try:
order = confirm_pending_order(
request,
basket,
order,
last_response.response if last_response else {},
)
except GatewayError:
# Expected while a Multibanco reference has not been paid yet. The
# order stays pending and the user can check again later.
logger.info(
"Order [%s] is still not confirmed as payed by PayGate", order.number
)
except PaymentError:
logger.exception(
"Payment error while resolving the payment of order [%s]", order.number
)
order = mark_order_as_payment_error(order)
except Exception: # pylint: disable=broad-except
# Never let an unexpected failure break the Order History page: the row
# simply stays pending and can be resolved on the next visit.
logger.exception(
"Unexpected error while resolving the payment of order [%s]",
order.number,
)

return order

@staticmethod
def _serialize(order):
return {
"order_number": order.number,
"status": order.status,
}
Loading