diff --git a/ecommerce/extensions/iap/api/v1/constants.py b/ecommerce/extensions/iap/api/v1/constants.py index 03b8f8dc267..4b99c10ca55 100644 --- a/ecommerce/extensions/iap/api/v1/constants.py +++ b/ecommerce/extensions/iap/api/v1/constants.py @@ -15,6 +15,12 @@ ERROR_DURING_POST_ORDER_OP = "An error occurred during post order operations." FOUND_MULTIPLE_PRODUCTS_ERROR = "Found unexpected number of products for course [%s]" GOOGLE_PUBLISHER_API_SCOPE = "https://www.googleapis.com/auth/androidpublisher" +IOS_PRODUCT_REVIEW_NOTE = ('This in-app purchase will unlock all the content of the course {course_name}\n\n' + 'For testing the end-to-end payment flow, please follow the following steps:\n1. ' + 'Go to the Discover tab\n2. Search for "{course_name}"\n3. Enroll in the course' + ' "{course_name}"\n4. Hit \"Upgrade to access more features\", it will open a ' + 'detail unlock features page\n5. Hit "Upgrade now for ${course_price}" from the' + ' detail page') IGNORE_NON_REFUND_NOTIFICATION_FROM_APPLE = "Ignoring notification from apple since we are only expecting" \ " refund notifications" LOGGER_BASKET_ALREADY_PURCHASED = "Basket creation failed for user [%s] with SKUS [%s]. Products already purchased" diff --git a/ecommerce/extensions/iap/api/v1/tests/test_utils.py b/ecommerce/extensions/iap/api/v1/tests/test_utils.py index e0367cb7c8d..aeb3f247aa9 100644 --- a/ecommerce/extensions/iap/api/v1/tests/test_utils.py +++ b/ecommerce/extensions/iap/api/v1/tests/test_utils.py @@ -1,7 +1,17 @@ import mock +from django.conf import settings from ecommerce.courses.tests.factories import CourseFactory -from ecommerce.extensions.iap.api.v1.utils import products_in_basket_already_purchased +from ecommerce.extensions.iap.api.v1.utils import ( + AppStoreRequestException, + apply_price_of_inapp_purchase, + create_inapp_purchase, + get_auth_headers, + localize_inapp_purchase, + products_in_basket_already_purchased, + submit_in_app_purchase_for_review, + upload_screenshot_of_inapp_purchase +) from ecommerce.extensions.order.utils import UserAlreadyPlacedOrder from ecommerce.extensions.test.factories import create_basket, create_order from ecommerce.tests.testcases import TestCase @@ -37,3 +47,162 @@ def test_not_purchased_yet(self): with mock.patch.object(UserAlreadyPlacedOrder, 'user_already_placed_order', return_value=False): return_value = products_in_basket_already_purchased(self.user, self.basket, self.site) self.assertFalse(return_value) + + +@mock.patch('ecommerce.extensions.iap.api.v1.utils.jwt.encode', return_value='Test token') +class TestCreateIosProducts(TestCase): + """ Tests for ios product creation on appstore. """ + + def setUp(self): + super(TestCreateIosProducts, self).setUp() + self.configuration = settings.PAYMENT_PROCESSOR_CONFIG['edx']['ios-iap'] + + def test_get_auth_headers(self, _): + """ + Test auth headers are returned in required format + """ + headers = { + "Authorization": "Bearer Test token", + "Content-Type": "application/json" + } + self.assertEqual(headers, get_auth_headers(self.configuration)) + + def test_create_inapp_purchase(self, _): + """ + Test create in app product call and its exception working properly. + """ + + with mock.patch('ecommerce.extensions.iap.api.v1.utils.requests.Session.post') as post_call: + post_call.return_value.status_code = 201 + course = { + 'key': 'test', + 'name': 'test', + 'price': '123' + } + headers = get_auth_headers(self.configuration) + create_inapp_purchase(course, 'test.sku', '123', headers) + create_url = 'https://api.appstoreconnect.apple.com/v2/inAppPurchases' + self.assertEqual(post_call.call_args[0][0], create_url) + self.assertEqual(post_call.call_args[1]['headers'], headers) + with self.assertRaises(AppStoreRequestException, msg="Couldn't create inapp purchase id"): + post_call.return_value.status_code = 500 + create_inapp_purchase(course, 'test.sku', '123', headers) + + def test_localize_inapp_purchase(self, _): + """ + Test localize in app product call and its exception working properly. + """ + with mock.patch('ecommerce.extensions.iap.api.v1.utils.requests.Session.post') as post_call: + post_call.return_value.status_code = 201 + headers = get_auth_headers(self.configuration) + localize_inapp_purchase('123', headers) + localize_url = 'https://api.appstoreconnect.apple.com/v1/inAppPurchaseLocalizations' + self.assertEqual(post_call.call_args[0][0], localize_url) + self.assertEqual(post_call.call_args[1]['headers'], headers) + + with self.assertRaises(AppStoreRequestException, msg="Couldn't localize purchase"): + post_call.return_value.status_code = 500 + localize_inapp_purchase('123', headers) + + def test_apply_price_of_inapp_purchase(self, _): + """ + Test applying price on in app product call and its exception working properly. + """ + headers = get_auth_headers(self.configuration) + with mock.patch('ecommerce.extensions.iap.api.v1.utils.requests.Session.post') as post_call, \ + mock.patch('ecommerce.extensions.iap.api.v1.utils.requests.Session.get') as get_call: + with self.assertRaises(AppStoreRequestException, msg="Couldn't fetch price points"): + get_call.return_value.status_code = 500 + apply_price_of_inapp_purchase(100, '123', headers) + + get_call.return_value.status_code = 200 + get_call.return_value.json.return_value = { + 'data': [ + { + 'id': '1234', + 'attributes': { + 'customerPrice': '99' + } + } + ] + } + 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) + + post_call.return_value.status_code = 201 + apply_price_of_inapp_purchase(100, '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) + + with self.assertRaises(AppStoreRequestException, msg="Couldn't apply price"): + post_call.return_value.status_code = 500 + apply_price_of_inapp_purchase(100, '123', headers) + + def test_upload_screenshot_of_inapp_purchase(self, _): + """ + Test image uploading call for app product and its exception working properly. + """ + headers = get_auth_headers(self.configuration) + with mock.patch('ecommerce.extensions.iap.api.v1.utils.requests.Session.post') as post_call, \ + mock.patch('ecommerce.extensions.iap.api.v1.utils.requests.Session.put') as put_call, \ + mock.patch('ecommerce.extensions.iap.api.v1.utils.requests.Session.patch') as patch_call, \ + mock.patch('django.contrib.staticfiles.storage.staticfiles_storage.open'): + + with self.assertRaises(AppStoreRequestException, msg="Couldn't get screenshot url"): + post_call.return_value.status_code = 500 + upload_screenshot_of_inapp_purchase('100', headers) + + post_call.return_value.status_code = 201 + post_call.return_value.json.return_value = { + 'data': { + 'id': '1234', + 'attributes': { + 'uploadOperations': [ + {'url': 'https://image-url.com'} + ] + } + } + } + + with self.assertRaises(AppStoreRequestException, msg="Couldn't upload screenshot"): + # Make sure it doesn't select higher price point + upload_screenshot_of_inapp_purchase('123', headers) + + put_call.return_value.status_code = 200 + + with self.assertRaises(AppStoreRequestException, msg="Couldn't finalize screenshot"): + # Make sure it doesn't select higher price point + upload_screenshot_of_inapp_purchase('123', headers) + + patch_call.return_value.status_code = 200 + + upload_screenshot_of_inapp_purchase('123', headers) + img_url = 'https://api.appstoreconnect.apple.com/v1/inAppPurchaseAppStoreReviewScreenshots' + self.assertEqual(post_call.call_args[0][0], img_url) + self.assertEqual(post_call.call_args[1]['headers'], headers) + + self.assertEqual(put_call.call_args[0][0], 'https://image-url.com') + img_headers = headers.copy() + img_headers['Content-Type'] = 'image/png' + self.assertEqual(put_call.call_args[1]['headers'], img_headers) + + img_patch_url = 'https://api.appstoreconnect.apple.com/v1/inAppPurchaseAppStoreReviewScreenshots/1234' + self.assertEqual(patch_call.call_args[0][0], img_patch_url) + self.assertEqual(patch_call.call_args[1]['headers'], headers) + + def submit_in_app_purchase_for_review(self, _): + """ + Test submitting in app product call and its exception working properly. + """ + headers = get_auth_headers(self.configuration) + with mock.patch('ecommerce.extensions.iap.api.v1.utils.requests.Session.post') as post_call: + with self.assertRaises(AppStoreRequestException, msg="Couldn't submit purchase"): + post_call.return_value.status_code = 500 + submit_in_app_purchase_for_review('100', headers) + + post_call.return_value.status_code = 201 + submit_url = 'https://api.appstoreconnect.apple.com/v1/inAppPurchaseSubmissions' + self.assertEqual(post_call.call_args[0][0], submit_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 c9f0d306c7f..b814b369517 100644 --- a/ecommerce/extensions/iap/api/v1/tests/test_views.py +++ b/ecommerce/extensions/iap/api/v1/tests/test_views.py @@ -1074,14 +1074,17 @@ def test_empty_list(self): self.assertEqual(response.status_code, 200) result = json.loads(response.content) expected_result = { - "new_mobile_skus": {}, - "failed_course_ids": [], - "missing_course_runs": [] + 'new_mobile_skus': {}, + 'failed_course_ids': [], + 'missing_course_runs': [], + 'failed_ios_products': [] } self.assertEqual(result, expected_result) - def test_missing_and_new_skus_in_course(self): + @mock.patch('ecommerce.extensions.iap.api.v1.views.create_ios_product') + def test_missing_and_new_skus_in_course(self, create_ios_product_patch): """ Verify the view differentiate between a correct and non-existent course id """ + create_ios_product_patch.return_value = None with LogCapture(self.logger_name) as logger: post_data = {"courses": ["course:wrong-id", self.course.id]} response = self.client.post(self.path, data=post_data, content_type="application/json") @@ -1092,15 +1095,16 @@ def test_missing_and_new_skus_in_course(self): stock_record = StockRecord.objects.get(product=self.product) expected_result = { - "new_mobile_skus": { + 'new_mobile_skus': { self.course.id: [ - "mobile.android.{}".format(stock_record.partner_sku.lower()), - "mobile.ios.{}".format(stock_record.partner_sku.lower()), + 'mobile.android.{}'.format(stock_record.partner_sku.lower()), + 'mobile.ios.{}'.format(stock_record.partner_sku.lower()), ] }, - "failed_course_ids": [], - "missing_course_runs": ['course:wrong-id'] + 'failed_course_ids': [], + 'missing_course_runs': ['course:wrong-id'], + 'failed_ios_products': [] } self.assertEqual(result, expected_result) diff --git a/ecommerce/extensions/iap/api/v1/utils.py b/ecommerce/extensions/iap/api/v1/utils.py index cb694ce6895..055d4c07939 100644 --- a/ecommerce/extensions/iap/api/v1/utils.py +++ b/ecommerce/extensions/iap/api/v1/utils.py @@ -1,10 +1,20 @@ +import logging +import time - +import jwt +import requests +from django.contrib.staticfiles.storage import staticfiles_storage from oscar.core.loading import get_model +from requests import Session +from requests.adapters import HTTPAdapter +from urllib3.util import Retry +from ecommerce.extensions.iap.api.v1.constants import IOS_PRODUCT_REVIEW_NOTE from ecommerce.extensions.order.utils import UserAlreadyPlacedOrder Product = get_model('catalogue', 'Product') +APP_STORE_BASE_URL = "https://api.appstoreconnect.apple.com" +logger = logging.getLogger(__name__) def products_in_basket_already_purchased(user, basket, site): @@ -17,3 +27,285 @@ def products_in_basket_already_purchased(user, basket, site): UserAlreadyPlacedOrder.user_already_placed_order(user=user, product=product, site=site): return True return False + + +def create_ios_product(course, ios_sku, configuration): + """ + Create in app ios product on connect store. + return error message in case of failure. + """ + headers = get_auth_headers(configuration) + try: + in_app_purchase_id = create_inapp_purchase(course, ios_sku, configuration['apple_id'], headers) + localize_inapp_purchase(in_app_purchase_id, headers) + apply_price_of_inapp_purchase(course['price'], in_app_purchase_id, headers) + upload_screenshot_of_inapp_purchase(in_app_purchase_id, headers) + return submit_in_app_purchase_for_review(in_app_purchase_id, headers) + except AppStoreRequestException as store_exception: + sku_error_msg = "{} for course {} with sku {}".format(str(store_exception), course['key'], ios_sku) + logger.error(sku_error_msg) + return sku_error_msg + + +def request_connect_store(url, headers, data=None, method="post"): + """ Request the given endpoint with multiple tries and backoff time """ + # Adding backoff and retries because of following two reasons + # 1. In case there is a connection error or server is busy. + # 2. Product data needs sometime before it gets updated on server for the final submit call, + # If we submit product right after image uploading it will return 409 error + # We will try 3 times with backoff time of 1.5, 3, 12 seconds + retries = Retry( + total=3, + backoff_factor=3, + status_forcelist=[502, 503, 504, 408, 429, 409], + method_whitelist={'POST', "GET", "PUT", "PATCH"}, + ) + http = Session() + http.mount('https://', HTTPAdapter(max_retries=retries)) + try: + if method == "post": + response = http.post(url, json=data, headers=headers) + elif method == "patch": + response = http.patch(url, json=data, headers=headers) + elif method == "put": + response = http.put(url, data=data, headers=headers) + elif method == "get": + response = http.get(url, headers=headers) + except requests.RequestException as request_exc: + raise AppStoreRequestException(request_exc) from request_exc + + return response + + +def get_auth_headers(configuration): + """ Get Bearer token with headers to call appstore """ + + headers = { + "kid": configuration['key_id'], + "typ": "JWT", + "alg": "ES256" + } + + payload = { + "iss": configuration['issuer_id'], + "exp": round(time.time()) + 60 * 20, # Token expiration time (20 minutes) + "aud": "appstoreconnect-v1", + "bid": configuration['ios_bundle_id'] + } + + private_key = configuration['private_key'] + token = jwt.encode(payload, private_key, algorithm="ES256", headers=headers) + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + return headers + + +def create_inapp_purchase(course, ios_sku, apple_id, headers): + """ Create in app product and return its id. """ + + url = APP_STORE_BASE_URL + "/v2/inAppPurchases" + data = { + "data": { + "type": "inAppPurchases", + "attributes": { + "name": course['key'], + "productId": ios_sku, + "inAppPurchaseType": "NON_CONSUMABLE", + "reviewNote": IOS_PRODUCT_REVIEW_NOTE.format(course_name=course['name'], + course_price=course['price']), + "availableInAllTerritories": True + }, + "relationships": { + "app": { + "data": { + "type": "apps", + "id": apple_id + } + } + } + } + } + response = request_connect_store(url=url, data=data, headers=headers) + if response.status_code == 201: + return response.json()["data"]["id"] + + raise AppStoreRequestException("Couldn't create inapp purchase id") + + +def localize_inapp_purchase(in_app_purchase_id, headers): + """ Localize given in app product with US locale. """ + + url = APP_STORE_BASE_URL + "/v1/inAppPurchaseLocalizations" + data = { + "data": { + "type": "inAppPurchaseLocalizations", + "attributes": { + "locale": "en-US", + "name": "Upgrade Course", + "description": "Unlock course activities & certificate" + }, + "relationships": { + "inAppPurchaseV2": { + "data": { + "type": "inAppPurchases", + "id": in_app_purchase_id + } + } + } + } + } + response = request_connect_store(url=url, data=data, headers=headers) + if response.status_code != 201: + raise AppStoreRequestException("Couldn't localize purchase") + + +def apply_price_of_inapp_purchase(price, in_app_purchase_id, headers): + """ Apply price tier to the given in app product. """ + + url = APP_STORE_BASE_URL + ("/v2/inAppPurchases/v2/inAppPurchases/{}/pricePoints?filter[territory]=USA" + "&include=territory&limit=8000").format(in_app_purchase_id) + + response = request_connect_store(url=url, headers=headers, method='get') + if response.status_code != 200: + raise AppStoreRequestException("Couldn't fetch price points") + + nearest_low_price = nearest_low_price_id = 0 + 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 not nearest_low_price: + raise AppStoreRequestException("Couldn't find nearest low price point") + + url = APP_STORE_BASE_URL + "/v1/inAppPurchasePriceSchedules" + data = { + "data": { + "type": "inAppPurchasePriceSchedules", + "attributes": {}, + "relationships": { + "inAppPurchase": { + "data": { + "type": "inAppPurchases", + "id": in_app_purchase_id + } + }, + "manualPrices": { + "data": [ + { + "type": "inAppPurchasePrices", + "id": "${price}" + } + ] + }, + "baseTerritory": { + "data": { + "type": "territories", + "id": "USA" + } + } + } + }, + "included": [ + { + "id": "${price}", + "relationships": { + "inAppPurchasePricePoint": { + "data": { + "type": "inAppPurchasePricePoints", + "id": nearest_low_price_id + } + } + }, + "type": "inAppPurchasePrices", + "attributes": { + "startDate": None + } + } + ] + } + response = request_connect_store(url=url, data=data, headers=headers) + if response.status_code != 201: + raise AppStoreRequestException("Couldn't apply price") + + +def upload_screenshot_of_inapp_purchase(in_app_purchase_id, headers): + """ Upload screenshot for the given product. """ + url = APP_STORE_BASE_URL + "/v1/inAppPurchaseAppStoreReviewScreenshots" + data = { + "data": { + "type": "inAppPurchaseAppStoreReviewScreenshots", + "attributes": { + "fileName": "iOS_IAP.png", + "fileSize": 124790 + }, + "relationships": { + "inAppPurchaseV2": { + "data": { + "id": in_app_purchase_id, + "type": "inAppPurchases" + } + } + } + } + } + + response = request_connect_store(url, headers, data=data) + if response.status_code != 201: + raise AppStoreRequestException("Couldn't get screenshot url") + + response = response.json() + screenshot_id = response['data']['id'] + url = response['data']['attributes']['uploadOperations'][0]['url'] + with staticfiles_storage.open('images/mobile_ios_product_screenshot.png', 'rb') as image: + img_headers = headers.copy() + img_headers['Content-Type'] = 'image/png' + response = request_connect_store(url, headers=img_headers, data=image.read(), method='put') + + if response.status_code != 200: + raise AppStoreRequestException("Couldn't upload screenshot") + + url = APP_STORE_BASE_URL + "/v1/inAppPurchaseAppStoreReviewScreenshots/{}".format(screenshot_id) + data = { + "data": { + "type": "inAppPurchaseAppStoreReviewScreenshots", + "id": screenshot_id, + "attributes": { + "uploaded": True, + "sourceFileChecksum": "" + } + } + } + + response = request_connect_store(url, headers, data=data, method='patch') + + if response.status_code != 200: + raise AppStoreRequestException("Couldn't finalize screenshot") + + +def submit_in_app_purchase_for_review(in_app_purchase_id, headers): + """ Submit in app purchase for the final review by appstore. """ + url = APP_STORE_BASE_URL + "/v1/inAppPurchaseSubmissions" + data = { + "data": { + "type": "inAppPurchaseSubmissions", + "relationships": { + "inAppPurchaseV2": { + "data": { + "type": "inAppPurchases", + "id": in_app_purchase_id + } + } + } + } + } + response = request_connect_store(url=url, data=data, headers=headers) + if response.status_code != 201: + raise AppStoreRequestException("Couldn't submit purchase") + + +class AppStoreRequestException(Exception): + pass diff --git a/ecommerce/extensions/iap/api/v1/views.py b/ecommerce/extensions/iap/api/v1/views.py index 1d939d0c36e..07e1bbaf798 100644 --- a/ecommerce/extensions/iap/api/v1/views.py +++ b/ecommerce/extensions/iap/api/v1/views.py @@ -80,7 +80,7 @@ ) from ecommerce.extensions.iap.api.v1.exceptions import RefundCompletionException from ecommerce.extensions.iap.api.v1.serializers import MobileOrderSerializer -from ecommerce.extensions.iap.api.v1.utils import products_in_basket_already_purchased +from ecommerce.extensions.iap.api.v1.utils import create_ios_product, products_in_basket_already_purchased from ecommerce.extensions.iap.models import IAPProcessorConfiguration from ecommerce.extensions.iap.processors.android_iap import AndroidIAP from ecommerce.extensions.iap.processors.ios_iap import IOSIAP @@ -385,6 +385,7 @@ def post(self, request): missing_course_runs = [] failed_course_runs = [] created_skus = {} + failed_ios_products = [] course_run_keys = request.data.get('courses', []) for course_run_key in course_run_keys: @@ -429,13 +430,27 @@ def post(self, request): logger.error(SKUS_CREATION_FAILURE, course_run_key) continue - created_skus[course_run_key] = [mobile_products[0].partner_sku, mobile_products[1].partner_sku] course = Course.objects.get(id=course_run) course.publish_to_lms() + created_skus[course_run_key] = [mobile_products[0].partner_sku, mobile_products[1].partner_sku] + + # create ios product on appstore + partner_short_code = request.site.siteconfiguration.partner.short_code + configuration = settings.PAYMENT_PROCESSOR_CONFIG[partner_short_code.lower()][IOSIAP.NAME.lower()] + ios_product = list((filter(lambda sku: 'ios' in sku.partner_sku, mobile_products)))[0] + course_data = { + 'price': ios_product.price_excl_tax, + 'name': course.name, + 'key': course_run_key + } + error_msg = create_ios_product(course_data, ios_product.partner_sku, configuration) + if error_msg: + failed_ios_products.append(error_msg) result = { 'new_mobile_skus': created_skus, 'failed_course_ids': failed_course_runs, - 'missing_course_runs': missing_course_runs + 'missing_course_runs': missing_course_runs, + 'failed_ios_products': failed_ios_products } return JsonResponse(result, status=status.HTTP_200_OK) diff --git a/ecommerce/settings/base.py b/ecommerce/settings/base.py index f128e8d8554..6e2ebf1d542 100644 --- a/ecommerce/settings/base.py +++ b/ecommerce/settings/base.py @@ -826,7 +826,12 @@ 'google_service_account_key_file': 'SET-ME-PLEASE' }, 'ios-iap': { + "apple_id": "", 'ios_bundle_id': 'org.edx.mobile', + 'issuer_id': '', + 'key_id': '', + 'private_key': "" + }, 'stripe': { 'api_version': '2022-08-01; server_side_confirmation_beta=v1', diff --git a/ecommerce/settings/devstack.py b/ecommerce/settings/devstack.py index a1b8168f790..d45291a1635 100644 --- a/ecommerce/settings/devstack.py +++ b/ecommerce/settings/devstack.py @@ -115,7 +115,11 @@ 'google_service_account_key_file': '' }, 'ios-iap': { + "apple_id": "", 'ios_bundle_id': 'org.edx.mobile', + 'issuer_id': '', + 'key_id': '', + 'private_key': "" } }, } diff --git a/ecommerce/settings/test.py b/ecommerce/settings/test.py index ec949a39785..33c09ef3b6d 100644 --- a/ecommerce/settings/test.py +++ b/ecommerce/settings/test.py @@ -107,7 +107,11 @@ 'google_service_account_key_file': '' }, 'ios-iap': { + "apple_id": "", 'ios_bundle_id': '', + 'issuer_id': '', + 'key_id': '', + 'private_key': "" } }, 'other': { diff --git a/ecommerce/static/images/mobile_ios_product_screenshot.png b/ecommerce/static/images/mobile_ios_product_screenshot.png new file mode 100644 index 00000000000..8d5731ef597 Binary files /dev/null and b/ecommerce/static/images/mobile_ios_product_screenshot.png differ