diff --git a/ecommerce/extensions/iap/admin.py b/ecommerce/extensions/iap/admin.py index e69de29bb2d..728f061d441 100644 --- a/ecommerce/extensions/iap/admin.py +++ b/ecommerce/extensions/iap/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin +from solo.admin import SingletonModelAdmin + +from ecommerce.extensions.iap.models import IAPProcessorConfiguration, PaymentProcessorResponseExtension + +admin.site.register(IAPProcessorConfiguration, SingletonModelAdmin) + + +@admin.register(PaymentProcessorResponseExtension) +class PaymentProcessorResponseExtensionAdmin(admin.ModelAdmin): + list_display = ('original_transaction_id', 'processor_response') diff --git a/ecommerce/extensions/iap/api/v1/constants.py b/ecommerce/extensions/iap/api/v1/constants.py index 20aec9a6603..58d412bddeb 100644 --- a/ecommerce/extensions/iap/api/v1/constants.py +++ b/ecommerce/extensions/iap/api/v1/constants.py @@ -7,8 +7,12 @@ ERROR_BASKET_ID_NOT_PROVIDED = "Basket id is not provided" ERROR_DURING_ORDER_CREATION = "An error occurred during order creation." ERROR_DURING_PAYMENT_HANDLING = "An error occurred during payment handling." +ERROR_ORDER_NOT_FOUND_FOR_REFUND = "Could not find any order to refund for [%s] by processor [%s]" +ERROR_REFUND_NOT_COMPLETED = "Could not complete refund for user [%s] in course [%s] by processor [%s]" +ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND = "Could not find any transaction to refund for [%s] by processor [%s]" ERROR_DURING_POST_ORDER_OP = "An error occurred during post order operations." ERROR_WHILE_OBTAINING_BASKET_FOR_USER = "An unexpected exception occurred while obtaining basket for user [{}]." +GOOGLE_PUBLISHER_API_SCOPE = "https://www.googleapis.com/auth/androidpublisher" LOGGER_BASKET_NOT_FOUND = "Basket [%s] not found." LOGGER_PAYMENT_APPROVED = "Payment [%s] approved by payer [%s]" LOGGER_PAYMENT_FAILED_FOR_BASKET = "Attempts to handle payment for basket [%d] failed." diff --git a/ecommerce/extensions/iap/api/v1/exceptions.py b/ecommerce/extensions/iap/api/v1/exceptions.py new file mode 100644 index 00000000000..c1834f1ad3a --- /dev/null +++ b/ecommerce/extensions/iap/api/v1/exceptions.py @@ -0,0 +1,9 @@ +""" +Exceptions used by the iap v1 api. +""" + + +class RefundCompletionException(Exception): + """ + Exception if a refund is not approved + """ diff --git a/ecommerce/extensions/iap/api/v1/tests/test_views.py b/ecommerce/extensions/iap/api/v1/tests/test_views.py index b224fd9e1e3..2dd55fb804d 100644 --- a/ecommerce/extensions/iap/api/v1/tests/test_views.py +++ b/ecommerce/extensions/iap/api/v1/tests/test_views.py @@ -1,4 +1,5 @@ import datetime +import json import urllib.error import urllib.parse @@ -8,17 +9,22 @@ from django.conf import settings from django.test import override_settings from django.urls import reverse +from oauth2client.service_account import ServiceAccountCredentials from oscar.apps.order.exceptions import UnableToPlaceOrder from oscar.apps.payment.exceptions import PaymentError from oscar.core.loading import get_class, get_model +from oscar.test.factories import BasketFactory +from rest_framework import status from testfixtures import LogCapture from ecommerce.core.tests import toggle_switch from ecommerce.coupons.tests.mixins import DiscoveryMockMixin from ecommerce.courses.tests.factories import CourseFactory from ecommerce.enterprise.tests.mixins import EnterpriseServiceMockMixin +from ecommerce.extensions.api.tests.test_authentication import AccessTokenMixin from ecommerce.extensions.basket.constants import EMAIL_OPT_IN_ATTRIBUTE from ecommerce.extensions.basket.tests.mixins import BasketMixin +from ecommerce.extensions.fulfillment.status import ORDER from ecommerce.extensions.iap.api.v1.constants import ( COURSE_ALREADY_PAID_ON_DEVICE, ERROR_ALREADY_PURCHASED, @@ -27,6 +33,9 @@ ERROR_DURING_ORDER_CREATION, ERROR_DURING_PAYMENT_HANDLING, ERROR_DURING_POST_ORDER_OP, + ERROR_ORDER_NOT_FOUND_FOR_REFUND, + ERROR_REFUND_NOT_COMPLETED, + ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND, ERROR_WHILE_OBTAINING_BASKET_FOR_USER, LOGGER_BASKET_NOT_FOUND, LOGGER_PAYMENT_FAILED_FOR_BASKET, @@ -36,15 +45,18 @@ from ecommerce.extensions.iap.api.v1.google_validator import GooglePlayValidator from ecommerce.extensions.iap.api.v1.ios_validator import IOSValidator from ecommerce.extensions.iap.api.v1.serializers import MobileOrderSerializer -from ecommerce.extensions.iap.api.v1.views import MobileCoursePurchaseExecutionView +from ecommerce.extensions.iap.api.v1.views import AndroidRefund, MobileCoursePurchaseExecutionView from ecommerce.extensions.iap.processors.android_iap import AndroidIAP from ecommerce.extensions.iap.processors.ios_iap import IOSIAP from ecommerce.extensions.order.utils import UserAlreadyPlacedOrder from ecommerce.extensions.payment.exceptions import RedundantPaymentNotificationError +from ecommerce.extensions.payment.models import PaymentProcessorResponse from ecommerce.extensions.payment.tests.mixins import PaymentEventsMixin +from ecommerce.extensions.refund.status import REFUND, REFUND_LINE +from ecommerce.extensions.refund.tests.mixins import RefundTestMixin from ecommerce.extensions.test.factories import create_basket, create_order from ecommerce.tests.factories import ProductFactory, StockRecordFactory -from ecommerce.tests.mixins import LmsApiMockMixin +from ecommerce.tests.mixins import JwtMixin, LmsApiMockMixin from ecommerce.tests.testcases import TestCase Basket = get_model('basket', 'Basket') @@ -57,6 +69,9 @@ Selector = get_class('partner.strategy', 'Selector') StockRecord = get_model('partner', 'StockRecord') Voucher = get_model('voucher', 'Voucher') +Option = get_model('catalogue', 'Option') +Refund = get_model('refund', 'Refund') +post_refund = get_class('refund.signals', 'post_refund') @ddt.ddt @@ -516,3 +531,212 @@ def test_view_response(self): response_data = response.json() self.assertIn(reverse('iap:iap-execute'), response_data['payment_page_url']) self.assertEqual(response_data['payment_processor'], self.processor_name) + + +class BaseRefundTests(RefundTestMixin, AccessTokenMixin, JwtMixin, TestCase): + MODEL_LOGGER_NAME = 'ecommerce.core.models' + path = reverse('iap:android-refund') + + def setUp(self): + super(BaseRefundTests, self).setUp() + self.course_id = 'edX/DemoX/Demo_Course' + self.invalid_transaction_id = "invalid transaction" + self.valid_transaction_id = "123456" + self.entitlement_option = Option.objects.get(code='course_entitlement') + self.user = self.create_user() + self.logger_name = 'ecommerce.extensions.iap.api.v1.views' + + def assert_ok_response(self, response): + """ Assert the response has HTTP status 200 and no data. """ + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), []) + + def test_transaction_id_not_found(self): + """ If the transaction id doesn't match, no refund IDs should be created. """ + with LogCapture(self.logger_name) as logger: + AndroidRefund().refund(self.invalid_transaction_id, {}) + msg = ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND % (self.invalid_transaction_id, + AndroidRefund.processor_name) + logger.check((self.logger_name, 'ERROR', msg),) + + @staticmethod + def _revoke_lines(refund): + for line in refund.lines.all(): + line.set_status(REFUND_LINE.COMPLETE) + + refund.set_status(REFUND.COMPLETE) + + def assert_refund_and_order(self, refund, order, basket, processor_response, refund_response): + """ Check if we refunded the correct order """ + self.assertEqual(refund.order, order) + self.assertEqual(refund.user, order.user) + self.assertEqual(refund.status, 'Complete') + self.assertEqual(refund.total_credit_excl_tax, order.total_excl_tax) + self.assertEqual(refund.lines.count(), order.lines.count()) + + self.assertEqual(basket, processor_response.basket) + self.assertEqual(refund_response.transaction_id, processor_response.transaction_id) + self.assertNotEqual(refund_response.id, processor_response.id) + + def test_refund_completion_error(self): + """ + View should create a refund if an order/line are found eligible for refund. + """ + order = self.create_order() + PaymentProcessorResponse.objects.create(basket=order.basket, + transaction_id=self.valid_transaction_id, + processor_name=AndroidRefund.processor_name, + response=json.dumps({'state': 'approved'})) + + def _revoke_lines(refund): + for line in refund.lines.all(): + line.set_status(REFUND_LINE.COMPLETE) + + refund.set_status(REFUND.REVOCATION_ERROR) + + with mock.patch.object(Refund, '_revoke_lines', side_effect=_revoke_lines, autospec=True): + refund_payload = {"state": "refund"} + msg = ERROR_REFUND_NOT_COMPLETED % (self.user.username, self.course_id, AndroidRefund.processor_name) + + with LogCapture(self.logger_name) as logger: + AndroidRefund().refund(self.valid_transaction_id, refund_payload) + self.assertFalse(Refund.objects.exists()) + self.assertEqual(len(PaymentProcessorResponse.objects.all()), 1) + # logger.check((self.logger_name, 'ERROR', msg),) + + # A second call should ensure the atomicity of the refund logic + AndroidRefund().refund(self.valid_transaction_id, refund_payload) + self.assertFalse(Refund.objects.exists()) + self.assertEqual(len(PaymentProcessorResponse.objects.all()), 1) + logger.check( + (self.logger_name, 'ERROR', msg), + (self.logger_name, 'ERROR', msg) + ) + + def test_valid_order(self): + """ + View should create a refund if an order/line are found eligible for refund. + """ + order = self.create_order() + basket = order.basket + self.assertFalse(Refund.objects.exists()) + processor_response = PaymentProcessorResponse.objects.create(basket=basket, + transaction_id=self.valid_transaction_id, + processor_name=AndroidRefund.processor_name, + response=json.dumps({'state': 'approved'})) + + with mock.patch.object(Refund, '_revoke_lines', side_effect=BaseRefundTests._revoke_lines, autospec=True): + refund_payload = {"state": "refund"} + AndroidRefund().refund(self.valid_transaction_id, refund_payload) + refund = Refund.objects.latest() + refund_response = PaymentProcessorResponse.objects.latest() + + self.assert_refund_and_order(refund, order, basket, processor_response, refund_response) + + # A second call should result in no additional refunds being created + with LogCapture(self.logger_name) as logger: + AndroidRefund().refund(self.valid_transaction_id, {}) + msg = ERROR_ORDER_NOT_FOUND_FOR_REFUND % (self.valid_transaction_id, AndroidRefund.processor_name) + logger.check((self.logger_name, 'ERROR', msg),) + + +class AndroidRefundTests(BaseRefundTests): + MODEL_LOGGER_NAME = 'ecommerce.core.models' + path = reverse('iap:android-refund') + mock_android_response = { + "voidedPurchases": [ + { + "purchaseToken": "purchase_token", + "purchaseTimeMillis": "1677275637963", + "voidedTimeMillis": "1677650787656", + "orderId": "1234", + "voidedSource": 1, + "voidedReason": 1, + "kind": "androidpublisher#voidedPurchase" + }, + { + "purchaseToken": "purchase_token", + "purchaseTimeMillis": "1674131262110", + "voidedTimeMillis": "1677671872090", + "orderId": "5678", + "voidedSource": 0, + "voidedReason": 0, + "kind": "androidpublisher#voidedPurchase" + } + ] + } + + def assert_ok_response(self, response): + """ Assert the response has HTTP status 200 and no data. """ + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def check_record_not_found_log(self, logger, msg_t): + response = self.client.get(self.path) + self.assert_ok_response(response) + refunds = self.mock_android_response['voidedPurchases'] + msgs = [msg_t % (refund['orderId'], AndroidRefund.processor_name) for refund in refunds] + logger.check( + (self.logger_name, 'ERROR', msgs[0]), + (self.logger_name, 'ERROR', msgs[1]) + ) + + def test_transaction_id_not_found(self): + """ If the transaction id doesn't match, no refund IDs should be created. """ + + with mock.patch.object(ServiceAccountCredentials, 'from_json_keyfile_dict') as mock_credential_method, \ + mock.patch('ecommerce.extensions.iap.api.v1.views.build') as mock_build, \ + LogCapture(self.logger_name) as logger, \ + mock.patch('httplib2.Http'): + + mock_credential_method.return_value.authorize.return_value = None + mock_build.return_value.purchases.return_value.voidedpurchases.return_value\ + .list.return_value.execute.return_value = self.mock_android_response + self.check_record_not_found_log(logger, ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND) + + def test_valid_orders(self): + """ + View should create a refund if an order/line are found eligible for refund. + """ + orders = [self.create_order()] + self.assertFalse(Refund.objects.exists()) + baskets = [BasketFactory(site=self.site, owner=self.user)] + baskets[0].add_product(self.verified_product) + + second_course = CourseFactory( + id=u'edX/DemoX/Demo_Coursesecond', name=u'edX Demó Course second', partner=self.partner + ) + second_verified_product = second_course.create_or_update_seat('verified', True, 10) + baskets.append(BasketFactory(site=self.site, owner=self.user)) + baskets[1].add_product(second_verified_product) + orders.append(create_order(basket=baskets[1], user=self.user)) + orders[1].status = ORDER.COMPLETE + + payment_processor_responses = [] + for index in range(len(baskets)): + transaction_id = self.mock_android_response['voidedPurchases'][index]['orderId'] + payment_processor_responses.append( + PaymentProcessorResponse.objects.create(basket=baskets[0], transaction_id=transaction_id, + processor_name=AndroidRefund.processor_name, + response=json.dumps({'state': 'approved'}))) + + with mock.patch.object(Refund, '_revoke_lines', side_effect=BaseRefundTests._revoke_lines, autospec=True), \ + mock.patch.object(ServiceAccountCredentials, 'from_json_keyfile_dict') as mock_credential_method, \ + mock.patch('ecommerce.extensions.iap.api.v1.views.build') as mock_build, \ + mock.patch('httplib2.Http'): + + mock_credential_method.return_value.authorize.return_value = None + mock_build.return_value.purchases.return_value.voidedpurchases.return_value.\ + list.return_value.execute.return_value = self.mock_android_response + + response = self.client.get(self.path) + self.assert_ok_response(response) + + refunds = Refund.objects.all() + refund_responses = PaymentProcessorResponse.objects.all().order_by('-id')[:1] + for index, _ in enumerate(refunds): + self.assert_refund_and_order(refunds[index], orders[index], baskets[index], + payment_processor_responses[index], refund_responses[index]) + + # A second call should result in no additional refunds being created + with LogCapture(self.logger_name) as logger: + self.check_record_not_found_log(logger, ERROR_ORDER_NOT_FOUND_FOR_REFUND) diff --git a/ecommerce/extensions/iap/api/v1/urls.py b/ecommerce/extensions/iap/api/v1/urls.py index 925f36fc211..3fa3bc6054d 100644 --- a/ecommerce/extensions/iap/api/v1/urls.py +++ b/ecommerce/extensions/iap/api/v1/urls.py @@ -1,6 +1,7 @@ from django.conf.urls import url from ecommerce.extensions.iap.api.v1.views import ( + AndroidRefund, MobileBasketAddItemsView, MobileCheckoutView, MobileCoursePurchaseExecutionView @@ -10,4 +11,5 @@ url(r'^basket/add/$', MobileBasketAddItemsView.as_view(), name='mobile-basket-add'), url(r'^execute/$', MobileCoursePurchaseExecutionView.as_view(), name='iap-execute'), url(r'^checkout/$', MobileCheckoutView.as_view(), name='iap-checkout'), + url(r'^android/refund/$', AndroidRefund.as_view(), name='android-refund') ] diff --git a/ecommerce/extensions/iap/api/v1/views.py b/ecommerce/extensions/iap/api/v1/views.py index 6f8d12dc7ed..fb75714a5a0 100644 --- a/ecommerce/extensions/iap/api/v1/views.py +++ b/ecommerce/extensions/iap/api/v1/views.py @@ -1,16 +1,23 @@ +import datetime import logging import time +import httplib2 +from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.http import JsonResponse from django.utils.decorators import method_decorator from django.utils.html import escape from django.utils.translation import ugettext as _ +from edx_django_utils import monitoring as monitoring_utils +from googleapiclient.discovery import build +from oauth2client.service_account import ServiceAccountCredentials from oscar.apps.basket.views import * # pylint: disable=wildcard-import, unused-wildcard-import from oscar.apps.payment.exceptions import PaymentError from oscar.core.loading import get_class, get_model from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response from rest_framework.views import APIView from ecommerce.extensions.analytics.utils import track_segment_event @@ -32,7 +39,11 @@ ERROR_DURING_ORDER_CREATION, ERROR_DURING_PAYMENT_HANDLING, ERROR_DURING_POST_ORDER_OP, + ERROR_ORDER_NOT_FOUND_FOR_REFUND, + ERROR_REFUND_NOT_COMPLETED, + ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND, ERROR_WHILE_OBTAINING_BASKET_FOR_USER, + GOOGLE_PUBLISHER_API_SCOPE, LOGGER_BASKET_NOT_FOUND, LOGGER_PAYMENT_APPROVED, LOGGER_PAYMENT_FAILED_FOR_BASKET, @@ -43,12 +54,16 @@ SEGMENT_MOBILE_BASKET_ADD, SEGMENT_MOBILE_PURCHASE_VIEW ) +from ecommerce.extensions.iap.api.v1.exceptions import RefundCompletionException from ecommerce.extensions.iap.api.v1.serializers import MobileOrderSerializer +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 from ecommerce.extensions.order.exceptions import AlreadyPlacedOrderException from ecommerce.extensions.partner.shortcuts import get_partner_for_site from ecommerce.extensions.payment.exceptions import RedundantPaymentNotificationError +from ecommerce.extensions.refund.api import create_refunds, find_orders_associated_with_course +from ecommerce.extensions.refund.status import REFUND Applicator = get_class('offer.applicator', 'Applicator') BasketAttribute = get_model('basket', 'BasketAttribute') @@ -218,3 +233,80 @@ def post(self, request): return JsonResponse({'error': response.content.decode()}, status=response.status_code) return response + + +class BaseRefund(APIView): + """ Base refund class for iOS and Android refunds """ + authentication_classes = () + + def refund(self, transaction_id, processor_response): + """ Get a transaction id and create a refund against that transaction. """ + original_purchase = PaymentProcessorResponse.objects.filter(transaction_id=transaction_id, + processor_name=self.processor_name).first() + if not original_purchase: + logger.error(ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND, transaction_id, self.processor_name) + return + + basket = original_purchase.basket + user = basket.owner + course_key = basket.all_lines().first().product.attr.course_key + orders = find_orders_associated_with_course(user, course_key) + try: + with transaction.atomic(): + refunds = create_refunds(orders, course_key) + if not refunds: + monitoring_utils.set_custom_attribute('iap_no_order_to_refund', transaction_id) + logger.error(ERROR_ORDER_NOT_FOUND_FOR_REFUND, transaction_id, self.processor_name) + return + + refund = refunds[0] + refund.approve(revoke_fulfillment=True) + if refund.status != REFUND.COMPLETE: + monitoring_utils.set_custom_attribute('iap_unrefunded_order', transaction_id) + raise RefundCompletionException + + PaymentProcessorResponse.objects.create(processor_name=self.processor_name, + transaction_id=transaction_id, + response=processor_response, basket=basket) + except RefundCompletionException: + logger.exception(ERROR_REFUND_NOT_COMPLETED, user.username, course_key, self.processor_name) + + +class AndroidRefund(BaseRefund): + """ + Create refunds for orders refunded by google and un-enroll users from relevant courses + """ + processor_name = AndroidIAP.NAME + timeout = 30 + + def get(self, request): + """ + Get all refunds in last 3 days from voidedpurchases api + and call refund method on every refund. + """ + + partner_short_code = request.site.siteconfiguration.partner.short_code + configuration = settings.PAYMENT_PROCESSOR_CONFIG[partner_short_code.lower()][self.processor_name.lower()] + service = self._get_service(configuration) + + refunds_age = IAPProcessorConfiguration.get_solo().android_refunds_age_in_days + refunds_time = datetime.datetime.now() - datetime.timedelta(days=refunds_age) + refunds_time_in_ms = round(refunds_time.timestamp() * 1000) + refund_list = service.purchases().voidedpurchases() + refunds = refund_list.list(packageName=configuration['google_bundle_id'], + startTime=refunds_time_in_ms).execute() + for refund in refunds.get('voidedPurchases', []): + self.refund(refund['orderId'], refund) + + return Response() + + def _get_service(self, configuration): + """ Create a service to interact with google api. """ + play_console_credentials = configuration.get('google_service_account_key_file') + credentials = ServiceAccountCredentials.from_json_keyfile_dict(play_console_credentials, + GOOGLE_PUBLISHER_API_SCOPE) + http = httplib2.Http(timeout=self.timeout) + http = credentials.authorize(http) + + service = build("androidpublisher", "v3", http=http) + return service diff --git a/ecommerce/extensions/iap/migrations/0003_iapprocessorconfiguration_android_refunds_age_in_days.py b/ecommerce/extensions/iap/migrations/0003_iapprocessorconfiguration_android_refunds_age_in_days.py new file mode 100644 index 00000000000..c67bb6e6b7b --- /dev/null +++ b/ecommerce/extensions/iap/migrations/0003_iapprocessorconfiguration_android_refunds_age_in_days.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.15 on 2023-03-13 22:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('iap', '0002_paymentprocessorresponseextension'), + ] + + operations = [ + migrations.AddField( + model_name='iapprocessorconfiguration', + name='android_refunds_age_in_days', + field=models.PositiveSmallIntegerField(default=3, verbose_name='Past number of days to fetch Android refunds for.'), + ), + ] diff --git a/ecommerce/extensions/iap/models.py b/ecommerce/extensions/iap/models.py index 1a8beeb0619..adce1871dfe 100644 --- a/ecommerce/extensions/iap/models.py +++ b/ecommerce/extensions/iap/models.py @@ -14,6 +14,13 @@ class IAPProcessorConfiguration(SingletonModel): ) ) + android_refunds_age_in_days = models.PositiveSmallIntegerField( + default=3, + verbose_name=_( + 'Past number of days to fetch Android refunds for.' + ) + ) + class Meta: verbose_name = "IAP Processor Configuration" diff --git a/ecommerce/extensions/iap/processors/base_iap.py b/ecommerce/extensions/iap/processors/base_iap.py index 772dbafcc5c..76fca24bbbc 100644 --- a/ecommerce/extensions/iap/processors/base_iap.py +++ b/ecommerce/extensions/iap/processors/base_iap.py @@ -170,7 +170,10 @@ def record_processor_response(self, response, transaction_id=None, basket=None, return processor_response def issue_credit(self, order_number, basket, reference_number, amount, currency): - raise NotImplementedError('The {} payment processor does not support credit issuance.'.format(self.NAME)) + """ + In case of mobile refund identifier is same as of transaction id or reference number. + """ + return reference_number def _get_attribute_from_receipt(self, validated_receipt, attribute): value = None diff --git a/ecommerce/extensions/iap/tests/processors/test_android_iap.py b/ecommerce/extensions/iap/tests/processors/test_android_iap.py index ea6f0dcdc01..b3e1523d74a 100644 --- a/ecommerce/extensions/iap/tests/processors/test_android_iap.py +++ b/ecommerce/extensions/iap/tests/processors/test_android_iap.py @@ -139,10 +139,14 @@ def test_issue_credit(self): """ Tests issuing credit/refund with AndroidInAppPurchase processor. """ - self.assertRaises(NotImplementedError, self.processor.issue_credit, None, None, None, None, None) + refund_id = "test id" + result = self.processor.issue_credit(refund_id, refund_id, refund_id, refund_id, refund_id) + self.assertEqual(refund_id, result) def test_issue_credit_error(self): """ Tests issuing credit/refund with AndroidInAppPurchase processor. """ - self.assertRaises(NotImplementedError, self.processor.issue_credit, None, None, None, None, None) + refund_id = "test id" + result = self.processor.issue_credit(refund_id, refund_id, refund_id, refund_id, refund_id) + self.assertEqual(refund_id, result) diff --git a/ecommerce/extensions/iap/tests/processors/test_ios_iap.py b/ecommerce/extensions/iap/tests/processors/test_ios_iap.py index 6c3c2c658ea..3616447f75a 100644 --- a/ecommerce/extensions/iap/tests/processors/test_ios_iap.py +++ b/ecommerce/extensions/iap/tests/processors/test_ios_iap.py @@ -172,10 +172,14 @@ def test_issue_credit(self): """ Tests issuing credit/refund with IOSInAppPurchase processor. """ - self.assertRaises(NotImplementedError, self.processor.issue_credit, None, None, None, None, None) + refund_id = "test id" + result = self.processor.issue_credit(refund_id, refund_id, refund_id, refund_id, refund_id) + self.assertEqual(refund_id, result) def test_issue_credit_error(self): """ - Tests issuing credit/refund with IOsInAppPurchase processor. + Tests issuing credit/refund with IOSInAppPurchase processor. """ - self.assertRaises(NotImplementedError, self.processor.issue_credit, None, None, None, None, None) + refund_id = "test id" + result = self.processor.issue_credit(refund_id, refund_id, refund_id, refund_id, refund_id) + self.assertEqual(refund_id, result)