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
19 changes: 19 additions & 0 deletions paygate/processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,25 @@ def cancel_url(self):
)
)

@property
def thank_you_url(self):
"""
The destination Thank-You page URL where the user is redirected after the PayGate
redirects him back to the Open edX Ecommerce success callback.

This URL replaces the previous behaviour of synchronously running
`handle_payment_and_create_order` on the success callback, which was failing for
asynchronous payments (MB) because the upstream payment is not yet confirmed
when the user comes back. The Thank-You page is part of the ecommerce micro-frontend
and includes a link to the user's Order History page where the payment status is then
lazily resolved per basket.

The URL is configurable by setting `thank_you_url` on the payment processor
configuration. If not set, falls back to the previous receipt page behaviour by
returning ``None`` (callers must use the receipt URL in that case).
"""
return self.configuration.get("thank_you_url", None)

def get_transaction_parameters(
self, basket, request=None, use_client_side_checkout=False, **kwargs
): # pylint: disable=unused-argument, too-many-locals
Expand Down
75 changes: 75 additions & 0 deletions paygate/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,3 +412,78 @@ def test_callback_server_duc(self, mock__make_api_json_request):
order = Order.objects.all().first()
self.assertEqual(order.basket.id, basket.id)
self.assertTrue(len(Order.objects.all()) == 1)


class PayGateCallbackSuccessThankYouTests(TestCase):
"""
Tests for the new asynchronous payment flow: after the PayGate "success"
callback we no longer call `handle_processor_response` synchronously,
instead we redirect the user to the Thank-You page (configurable via the
``thank_you_url`` payment processor configuration).

This makes the flow safe for asynchronous payment methods (MB, MBWAY)
that are not yet confirmed when the user is redirected back. The actual
payment status is later resolved lazily by the Order History page.
"""

@override_settings(
PAYMENT_PROCESSOR_CONFIG={
"edx": {
**settings.PAYMENT_PROCESSOR_CONFIG["edx"],
**{
"paygate": {
"access_token": "PwdX_XXXX_YYYY",
"merchant_code": "NAU",
"api_checkout_url": "https://test.optimistic.blue/paygateWS/api/CheckOut",
"api_back_search_transactions": (
"https://test.optimistic.blue/paygateWS/api/BackOfficeSearchTransactions"
),
"api_basic_auth_user": "NAU",
"api_basic_auth_pass": "APassword",
"thank_you_url": "https://orders.example.com/thank-you",
}
},
}
}
)
@mock.patch.object(PayGate, "_make_api_json_request")
def test_success_callback_redirects_to_thank_you_without_calling_processor(
self, mock__make_api_json_request,
):
"""
With ``thank_you_url`` configured the success callback must:
* NOT call the PayGate BackOfficeSearchTransactions API
(handle_processor_response).
* NOT create an Order (it will be created later by the server
callback or lazily by the Order History page).
* redirect the user to the configured Thank-You URL with the
basket order number as a query string parameter.
"""
course = CourseFactory(id='a/b/c', name='Demo Course', partner=self.partner)
product = course.create_or_update_seat('test-certificate-type', False, 20)
basket = create_basket(site=self.site, owner=UserFactory(), empty=True)
basket.add_product(product)
basket.save()

callback_success_data = {
"is_paid": False,
"StatusCode": "P",
'payment_ref': basket.order_number,
"paymentValue": "20.00EUR",
"payment_type_code": "REFMB",
}

response = self.client.get(
reverse("ecommerce_plugin_paygate:callback_success"),
callback_success_data,
)

self.assertEqual(response.status_code, 302)
self.assertIn("https://orders.example.com/thank-you", response['Location'])
self.assertIn(f"order_number={basket.order_number}", response['Location'])

self.assertFalse(
Order.objects.filter(number=basket.order_number).exists()
)

mock__make_api_json_request.assert_not_called()
33 changes: 30 additions & 3 deletions paygate/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,25 @@ class PayGateCallbackSuccessResponseView(PayGateCallbackBaseResponseView):
success.
This callback should NOT be used to fullfill the order.

Internally this method will call the BackOfficeSearchTransactions to double check that the
transaction is really payed. With this design decision we don't need to protect the
callbacks URLs by IP.
Behaviour:

- For synchronous payment methods (e.g. credit cards) the server-to-server callback
will normally already have fulfilled the order before the user is redirected here.
- For asynchronous payment methods (e.g. MB references) the upstream payment is
NOT yet confirmed when the user clicks "Continuar" and is redirected here. Calling
``handle_payment_and_create_order`` synchronously would call
``handle_processor_response`` on PayGate which would return an empty list, raise a
``GatewayError`` and mislead the user with a "You have not been charged" page.

To avoid that, this view now only records the callback response and redirects the
authenticated user to the ecommerce micro-frontend Thank-You page (configurable via
the ``thank_you_url`` payment processor configuration). The Thank-You page links the
user to the Order History page where the payment status is then lazily resolved per
basket (see ``nau_extensions`` ``BasketPaymentStatusView``).

Backwards compatibility: if no ``thank_you_url`` is configured, the original
behaviour of running ``handle_payment_and_create_order`` and redirecting to the
receipt page is kept so existing deployments do not regress.
"""

def get(
Expand All @@ -242,6 +258,17 @@ def get(
logger.warning("PayGate no basket found on the callback success")
return redirect(self.payment_processor.failure_url)

thank_you_url = self.payment_processor.thank_you_url
if thank_you_url:
logger.info(
"PayGate success callback for basket [%d]: redirecting user to Thank-You URL",
basket.id,
)
separator = "&" if "?" in thank_you_url else "?"
return redirect(
f"{thank_you_url}{separator}order_number={basket.order_number}"
)

receipt_url = get_receipt_page_url(
self.request,
order_number=basket.order_number,
Expand Down
Loading