diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2dabcd2bcf..ef394f47bc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v2 - name: Start container run: | - docker-compose -f ./.ci/docker-compose-ci.yml up -d + docker compose -f ./.ci/docker-compose-ci.yml up -d - name: Install dependencies run: | docker exec -t ecommerce_testing bash -c " diff --git a/ecommerce/extensions/iap/api/v1/constants.py b/ecommerce/extensions/iap/api/v1/constants.py index 8c4e65f4fbe..76e7144bfe9 100644 --- a/ecommerce/extensions/iap/api/v1/constants.py +++ b/ecommerce/extensions/iap/api/v1/constants.py @@ -7,6 +7,7 @@ ERROR_ALREADY_PURCHASED = "You have already purchased these products" ERROR_BASKET_NOT_FOUND = "Basket [{}] not found." ERROR_BASKET_ID_NOT_PROVIDED = "Basket id is not provided" +ERROR_USER_CANCELLED_PAYMENT = "User cancelled this payment." ERROR_DURING_IOS_REFUND_EXECUTION = "Could not execute IOS refund." ERROR_DURING_ORDER_CREATION = "An error occurred during order creation." ERROR_DURING_PAYMENT_HANDLING = "An error occurred during payment handling." @@ -32,6 +33,8 @@ LOGGER_CHECKOUT_ERROR = "Checkout failed with the error [%s] and status code [%s]." LOGGER_EXECUTE_ALREADY_PURCHASED = "Execute payment failed for user [%s] and basket [%s]. " \ "Products already purchased." +LOGGER_EXECUTE_CANCELLED_PAYMENT_ERROR = "Execute payment failed for user [%s] and basket [%s]. " \ + "Payment error [%s]." LOGGER_EXECUTE_GATEWAY_ERROR = "Execute payment validation failed for user [%s] and basket [%s]. Error: [%s]" LOGGER_EXECUTE_ORDER_CREATION_FAILED = "Execute payment failed for user [%s] and basket [%s]. " \ "Order Creation failed with error [%s]." diff --git a/ecommerce/extensions/iap/api/v1/tests/test_utils.py b/ecommerce/extensions/iap/api/v1/tests/test_utils.py index f68946fdfeb..51e4eb86746 100644 --- a/ecommerce/extensions/iap/api/v1/tests/test_utils.py +++ b/ecommerce/extensions/iap/api/v1/tests/test_utils.py @@ -73,17 +73,25 @@ def test_create_ios_product(self, _, __, ___, ____, _____, ______, _______): course = { 'key': 'test', 'name': 'test', - 'price': '123' + 'price': 123 } error_msg = create_ios_product(course, self.ios_seat, self.configuration) self.assertEqual(error_msg, None) - # @mock.patch('ecommerce.extensions.iap.api.v1.utils.create_inapp_purchase') + def test_error_on_ios_product_price_threshhold(self, _,): + course = { + 'key': 'test', + 'name': 'test', + 'price': 1001 + } + error_msg = create_ios_product(course, self.ios_seat, self.configuration) + self.assertEqual(error_msg, 'Error: Appstore does not allow price > 1000') + def test_create_ios_product_with_failure(self, _): course = { 'key': 'test', 'name': 'test', - 'price': '123' + 'price': 123 } error_msg = create_ios_product(course, self.ios_seat, self.configuration) expected_msg = "[Couldn't create inapp purchase id] for course [{}] with sku [{}]".format( @@ -110,7 +118,7 @@ def test_create_inapp_purchase(self, _): course = { 'key': 'test', 'name': 'test', - 'price': '123' + 'price': 123 } headers = get_auth_headers(self.configuration) create_inapp_purchase(course, 'test.sku', '123', headers) @@ -149,6 +157,7 @@ def test_apply_price_of_inapp_purchase(self, _): apply_price_of_inapp_purchase(100, '123', headers) get_call.return_value.status_code = 200 + post_call.return_value.status_code = 201 get_call.return_value.json.return_value = { 'data': [ { @@ -159,12 +168,11 @@ def test_apply_price_of_inapp_purchase(self, _): } ] } - with self.assertRaises(AppStoreRequestException, msg="Couldn't find nearest low price point"): - # Make sure it doesn't select higher price point - apply_price_of_inapp_purchase(80, '123', headers) + with self.assertRaises(AppStoreRequestException, msg="Couldn't find nearest high price point"): + # Make sure it doesn't select lower price point + apply_price_of_inapp_purchase(100, '123', headers) - post_call.return_value.status_code = 201 - apply_price_of_inapp_purchase(100, '123', headers) + apply_price_of_inapp_purchase(98, '123', headers) price_url = 'https://api.appstoreconnect.apple.com/v1/inAppPurchasePriceSchedules' self.assertEqual(post_call.call_args[0][0], price_url) self.assertEqual(post_call.call_args[1]['headers'], headers) diff --git a/ecommerce/extensions/iap/api/v1/tests/test_views.py b/ecommerce/extensions/iap/api/v1/tests/test_views.py index d48d8a56a54..7db577f6f20 100644 --- a/ecommerce/extensions/iap/api/v1/tests/test_views.py +++ b/ecommerce/extensions/iap/api/v1/tests/test_views.py @@ -12,7 +12,7 @@ from django.urls import reverse from oauth2client.service_account import ServiceAccountCredentials from oscar.apps.order.exceptions import UnableToPlaceOrder -from oscar.apps.payment.exceptions import GatewayError, PaymentError +from oscar.apps.payment.exceptions import GatewayError, PaymentError, UserCancelled from oscar.core.loading import get_class, get_model from oscar.test.factories import BasketFactory from rest_framework import status @@ -38,6 +38,7 @@ ERROR_ORDER_NOT_FOUND_FOR_REFUND, ERROR_REFUND_NOT_COMPLETED, ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND, + ERROR_USER_CANCELLED_PAYMENT, IGNORE_NON_REFUND_NOTIFICATION_FROM_APPLE, LOGGER_BASKET_ALREADY_PURCHASED, LOGGER_BASKET_CREATED, @@ -339,6 +340,30 @@ def test_payment_error(self): ), ) + def test_user_cancelled_payment_error(self): + """ + Verify that user cancelled payment error is returned for Usercancelled exception + """ + with mock.patch.object(MobileCoursePurchaseExecutionView, 'handle_payment', + side_effect=UserCancelled('Test Error')) as fake_handle_payment: + with LogCapture(self.logger_name) as logger: + self._assert_response({'error': ERROR_USER_CANCELLED_PAYMENT}) + self.assertTrue(fake_handle_payment.called) + + logger.check( + ( + self.logger_name, + 'INFO', + LOGGER_EXECUTE_STARTED % (self.user.username, self.basket.id, self.processor_name) + ), + ( + self.logger_name, + 'ERROR', + LOGGER_EXECUTE_PAYMENT_ERROR % (self.user.username, self.basket.id, + str(fake_handle_payment.side_effect)) + ), + ) + def test_gateway_error(self): """ Verify that an error is thrown when an approved payment fails to execute diff --git a/ecommerce/extensions/iap/api/v1/utils.py b/ecommerce/extensions/iap/api/v1/utils.py index 6a62ac5a877..918b5af005b 100644 --- a/ecommerce/extensions/iap/api/v1/utils.py +++ b/ecommerce/extensions/iap/api/v1/utils.py @@ -34,6 +34,9 @@ def create_ios_product(course, ios_product, configuration): Create in app ios product on connect store. return error message in case of failure. """ + if course['price'] > 1000: + return 'Error: Appstore does not allow price > 1000' + headers = get_auth_headers(configuration) try: in_app_purchase_id = get_or_create_inapp_purchase(ios_product, course, configuration, headers) @@ -188,15 +191,16 @@ def apply_price_of_inapp_purchase(price, in_app_purchase_id, headers): if response.status_code != 200: raise AppStoreRequestException("Couldn't fetch price points") - nearest_low_price = nearest_low_price_id = 0 + # Apple doesn't allow in app price > 1000 + nearest_high_price = nearest_high_price_id = 1001 for price_point in response.json()['data']: customer_price = float(price_point['attributes']['customerPrice']) - if nearest_low_price < customer_price <= price: - nearest_low_price = customer_price - nearest_low_price_id = price_point['id'] + if nearest_high_price > customer_price >= price: + nearest_high_price = customer_price + nearest_high_price_id = price_point['id'] - if not nearest_low_price: - raise AppStoreRequestException("Couldn't find nearest low price point") + if nearest_high_price == 1001: + raise AppStoreRequestException("Couldn't find nearest high price point") url = APP_STORE_BASE_URL + "/v1/inAppPurchasePriceSchedules" data = { @@ -233,7 +237,7 @@ def apply_price_of_inapp_purchase(price, in_app_purchase_id, headers): "inAppPurchasePricePoint": { "data": { "type": "inAppPurchasePricePoints", - "id": nearest_low_price_id + "id": nearest_high_price_id } } }, diff --git a/ecommerce/extensions/iap/api/v1/views.py b/ecommerce/extensions/iap/api/v1/views.py index e9fd354eabe..a896421dd22 100644 --- a/ecommerce/extensions/iap/api/v1/views.py +++ b/ecommerce/extensions/iap/api/v1/views.py @@ -17,7 +17,7 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from oscar.apps.basket.views import * # pylint: disable=wildcard-import, unused-wildcard-import -from oscar.apps.payment.exceptions import GatewayError, PaymentError +from oscar.apps.payment.exceptions import GatewayError, PaymentError, UserCancelled from oscar.core.loading import get_class, get_model from rest_framework import status from rest_framework.permissions import IsAdminUser, IsAuthenticated @@ -51,6 +51,7 @@ ERROR_ORDER_NOT_FOUND_FOR_REFUND, ERROR_REFUND_NOT_COMPLETED, ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND, + ERROR_USER_CANCELLED_PAYMENT, FOUND_MULTIPLE_PRODUCTS_ERROR, GOOGLE_PUBLISHER_API_SCOPE, IGNORE_NON_REFUND_NOTIFICATION_FROM_APPLE, @@ -60,6 +61,7 @@ LOGGER_BASKET_NOT_FOUND, LOGGER_CHECKOUT_ERROR, LOGGER_EXECUTE_ALREADY_PURCHASED, + LOGGER_EXECUTE_CANCELLED_PAYMENT_ERROR, LOGGER_EXECUTE_GATEWAY_ERROR, LOGGER_EXECUTE_ORDER_CREATION_FAILED, LOGGER_EXECUTE_PAYMENT_ERROR, @@ -302,6 +304,9 @@ def post(self, request): except RedundantPaymentNotificationError: logger.exception(LOGGER_EXECUTE_REDUNDANT_PAYMENT, request.user.username, basket_id) return JsonResponse({'error': COURSE_ALREADY_PAID_ON_DEVICE}, status=409) + except UserCancelled as exception: + logger.exception(LOGGER_EXECUTE_CANCELLED_PAYMENT_ERROR, request.user.username, basket_id, str(exception)) + return JsonResponse({'error': ERROR_USER_CANCELLED_PAYMENT}, status=400) except PaymentError as exception: logger.exception(LOGGER_EXECUTE_PAYMENT_ERROR, request.user.username, basket_id, str(exception)) return JsonResponse({'error': ERROR_DURING_PAYMENT_HANDLING}, status=400) diff --git a/ecommerce/extensions/iap/processors/base_iap.py b/ecommerce/extensions/iap/processors/base_iap.py index 2f28d6a79db..cde23e9a5d6 100644 --- a/ecommerce/extensions/iap/processors/base_iap.py +++ b/ecommerce/extensions/iap/processors/base_iap.py @@ -212,6 +212,7 @@ def record_processor_response(self, response, transaction_id=None, basket=None, basket=basket) meta_data = self._get_metadata(currency_code=currency_code, price=price) + original_transaction_id = original_transaction_id or transaction_id PaymentProcessorResponseExtension.objects.create( processor_response=processor_response, original_transaction_id=original_transaction_id, meta_data=meta_data)