Skip to content

Commit

Permalink
Adopt ceil method for ios inapp prices (#4)
Browse files Browse the repository at this point in the history
* fix: Adopt ceil method for ios inapp prices

* fix: Store android transaction id for android purchases

* fix: Added user cancelled payment error response in execute view

* fix: Added ios inapp price check

* chore: test removing the dash from docker-compose to fix ci (#5)

---------

Co-authored-by: Chris Pappas <[email protected]>
  • Loading branch information
jawad-khan and christopappas authored Aug 6, 2024
1 parent eba5641 commit 5ea69a6
Show file tree
Hide file tree
Showing 7 changed files with 65 additions and 19 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
3 changes: 3 additions & 0 deletions ecommerce/extensions/iap/api/v1/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand All @@ -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]."
Expand Down
26 changes: 17 additions & 9 deletions ecommerce/extensions/iap/api/v1/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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': [
{
Expand All @@ -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)
Expand Down
27 changes: 26 additions & 1 deletion ecommerce/extensions/iap/api/v1/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
18 changes: 11 additions & 7 deletions ecommerce/extensions/iap/api/v1/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
}
}
},
Expand Down
7 changes: 6 additions & 1 deletion ecommerce/extensions/iap/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions ecommerce/extensions/iap/processors/base_iap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit 5ea69a6

Please sign in to comment.