diff --git a/nau_extensions/settings/test.py b/nau_extensions/settings/test.py index d224fe4..6940e50 100644 --- a/nau_extensions/settings/test.py +++ b/nau_extensions/settings/test.py @@ -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" diff --git a/nau_extensions/urls.py b/nau_extensions/urls.py index baf1503..f40f7ee 100644 --- a/nau_extensions/urls.py +++ b/nau_extensions/urls.py @@ -1,7 +1,8 @@ from django.conf.urls import url from nau_extensions.views import ( BasketBillingInformationAddressCreateUpdateView, - BasketBillingInformationVATINCreateUpdateView, ReceiptLinkView) + BasketBillingInformationVATINCreateUpdateView, OrderPaymentStatusView, + ReceiptLinkView) app_name = "ecommerce_nau_extensions" @@ -9,19 +10,22 @@ 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", + ), ] diff --git a/nau_extensions/views.py b/nau_extensions/views.py index 0a9eaba..c101db0 100644 --- a/nau_extensions/views.py +++ b/nau_extensions/views.py @@ -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") @@ -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, + }