Skip to content
This repository has been archived by the owner on Nov 4, 2024. It is now read-only.

feat: Added Android refund api #3922

Merged
merged 8 commits into from
Mar 16, 2023
Merged
Show file tree
Hide file tree
Changes from 4 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
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,8 +7,11 @@
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_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."
Expand Down
198 changes: 196 additions & 2 deletions ecommerce/extensions/iap/api/v1/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import datetime
import json
import urllib.error
import urllib.parse

Expand All @@ -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,
Expand All @@ -27,6 +33,8 @@
ERROR_DURING_ORDER_CREATION,
ERROR_DURING_PAYMENT_HANDLING,
ERROR_DURING_POST_ORDER_OP,
ERROR_ORDER_NOT_FOUND_FOR_REFUND,
ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND,
ERROR_WHILE_OBTAINING_BASKET_FOR_USER,
LOGGER_BASKET_NOT_FOUND,
LOGGER_PAYMENT_FAILED_FOR_BASKET,
Expand All @@ -36,15 +44,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')
Expand All @@ -57,6 +68,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
Expand Down Expand Up @@ -516,3 +530,183 @@ 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),)

def assert_refund_and_order(self, refund, order, basket, processor_response, refund_response):
""" check if we refunded the correct order"""
jawad-khan marked this conversation as resolved.
Show resolved Hide resolved
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_valid_order(self):
"""
View should create a refund if an order/line are found eligible for refund.
"""
order = self.create_order()
self.assertFalse(Refund.objects.exists())
basket = BasketFactory(site=self.site, owner=self.user)
basket.add_product(self.verified_product)
processor_response = PaymentProcessorResponse.objects.create(basket=basket,
transaction_id=self.valid_transaction_id,
processor_name=AndroidRefund.processor_name,
response=json.dumps({'state': 'approved'}))

def _revoke_lines(r):
for line in r.lines.all():
line.set_status(REFUND_LINE.COMPLETE)

r.set_status(REFUND.COMPLETE)

with mock.patch.object(Refund, '_revoke_lines', side_effect=_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'})))

def _revoke_lines(refund):
for line in refund.lines.all():
line.set_status(REFUND_LINE.COMPLETE)

refund.set_status(REFUND.COMPLETE)

with mock.patch.object(Refund, '_revoke_lines', side_effect=_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)
2 changes: 2 additions & 0 deletions ecommerce/extensions/iap/api/v1/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.conf.urls import url

from ecommerce.extensions.iap.api.v1.views import (
AndroidRefund,
MobileBasketAddItemsView,
MobileCheckoutView,
MobileCoursePurchaseExecutionView
Expand All @@ -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')
]
79 changes: 79 additions & 0 deletions ecommerce/extensions/iap/api/v1/views.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
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 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
Expand All @@ -32,7 +38,10 @@
ERROR_DURING_ORDER_CREATION,
ERROR_DURING_PAYMENT_HANDLING,
ERROR_DURING_POST_ORDER_OP,
ERROR_ORDER_NOT_FOUND_FOR_REFUND,
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,
Expand All @@ -49,6 +58,7 @@
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

Applicator = get_class('offer.applicator', 'Applicator')
BasketAttribute = get_model('basket', 'BasketAttribute')
Expand Down Expand Up @@ -218,3 +228,72 @@ 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):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be missing something, but this function doesn't look idempotent. If you call this twice in a row with the same response, will it issue two refunds? I think we want to insure we only issue one?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will not create two refunds, since we use these two create_orders and create_with_lines functions. Both functions make sure that we refund just once.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll run a case on sandbox just for confirmation and will attach results here.

""" 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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks a little dangerous. If a user has purchased the same course multiple times -- for example, maybe they purchase, refund, then purchase again -- what happens here? Am I correct that it would try to issue another refund for the first purchase as well?

Copy link
Contributor Author

@jawad-khan jawad-khan Mar 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will get all the orders here but when we go for the refund it will create refunds only for the unrefunded orders.


with transaction.atomic():
refunds = create_refunds(orders, course_key)
if not refunds:
logger.error(ERROR_ORDER_NOT_FOUND_FOR_REFUND, transaction_id, self.processor_name)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we set up some sort of observability for this?

Copy link
Contributor Author

@jawad-khan jawad-khan Mar 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes a good idea. I'll add.

return

refunds[0].approve(revoke_fulfillment=True)

PaymentProcessorResponse.objects.create(processor_name=self.processor_name,
transaction_id=transaction_id,
response=processor_response, basket=basket)


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_time = datetime.datetime.now() - datetime.timedelta(days=3)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoding this to 3 days seems dangerous. What is something goes wrong and this is not called for a week? When we finally call it again, there is no way to process refunds >3 days ago.

What about adding a flag or something to allow processing all voided purchases in the list? Or at least to configure the date range outside of this function?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we should configure a flag for all purchases. But by default this api don't return refunds older than 30 days.

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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to return a Response body and a 200 status_code in case of success?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

by default status code is 200 here.


def _get_service(self, configuration):
""" Create a service to interact with google api. """
play_console_credentials = configuration.get('google_service_account_key_file')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move to constants.py

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need even dict keys to be stored in constants?

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
Loading