Merge pull request #17097 from edx/aj/LEARNER-3629_refund_api_refactor
Corrected incorrect refund data in refund logic
This commit is contained in:
@@ -1,16 +1,24 @@
|
||||
"""Utilities to assist with commerce tasks."""
|
||||
import json
|
||||
import logging
|
||||
from urllib import urlencode
|
||||
from urlparse import urljoin
|
||||
|
||||
import requests
|
||||
import waffle
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client, is_commerce_service_configured
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangoapps.theming import helpers as theming_helpers
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
from .models import CommerceConfiguration
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_account_activation_requirement_disabled():
|
||||
"""
|
||||
@@ -109,3 +117,264 @@ class EcommerceService(object):
|
||||
else:
|
||||
return reverse('verify_student_upgrade_and_verify', args=(course_key,))
|
||||
return None
|
||||
|
||||
|
||||
def refund_entitlement(course_entitlement):
|
||||
"""
|
||||
Attempt a refund of a course entitlement. Verify the User before calling this refund method
|
||||
|
||||
Returns:
|
||||
bool: True if the Refund is successfully processed.
|
||||
"""
|
||||
user_model = get_user_model()
|
||||
enrollee = course_entitlement.user
|
||||
entitlement_uuid = str(course_entitlement.uuid)
|
||||
|
||||
if not is_commerce_service_configured():
|
||||
log.error(
|
||||
'Ecommerce service is not configured, cannot refund for user [%s], course entitlement [%s].',
|
||||
enrollee.id,
|
||||
entitlement_uuid
|
||||
)
|
||||
return False
|
||||
|
||||
service_user = user_model.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME)
|
||||
api_client = ecommerce_api_client(service_user)
|
||||
|
||||
log.info(
|
||||
'Attempting to create a refund for user [%s], course entitlement [%s]...',
|
||||
enrollee.id,
|
||||
entitlement_uuid
|
||||
)
|
||||
|
||||
try:
|
||||
refund_ids = api_client.refunds.post(
|
||||
{
|
||||
'order_number': course_entitlement.order_number,
|
||||
'username': enrollee.username,
|
||||
'entitlement_uuid': entitlement_uuid,
|
||||
}
|
||||
)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# Catch any possible exceptions from the Ecommerce service to ensure we fail gracefully
|
||||
log.exception(
|
||||
"Unexpected exception while attempting to initiate refund for user [%s], "
|
||||
"course entitlement [%s] message: [%s]",
|
||||
enrollee.id,
|
||||
course_entitlement.uuid,
|
||||
str(exc)
|
||||
)
|
||||
return False
|
||||
|
||||
if refund_ids:
|
||||
log.info(
|
||||
'Refund successfully opened for user [%s], course entitlement [%s]: %r',
|
||||
enrollee.id,
|
||||
entitlement_uuid,
|
||||
refund_ids,
|
||||
)
|
||||
|
||||
return _process_refund(
|
||||
refund_ids=refund_ids,
|
||||
api_client=api_client,
|
||||
mode=course_entitlement.mode,
|
||||
user=enrollee,
|
||||
always_notify=True,
|
||||
)
|
||||
else:
|
||||
log.warn('No refund opened for user [%s], course entitlement [%s]', enrollee.id, entitlement_uuid)
|
||||
return False
|
||||
|
||||
|
||||
def refund_seat(course_enrollment):
|
||||
"""
|
||||
Attempt to initiate a refund for any orders associated with the seat being unenrolled,
|
||||
using the commerce service.
|
||||
|
||||
Arguments:
|
||||
course_enrollment (CourseEnrollment): a student enrollment
|
||||
|
||||
Returns:
|
||||
A list of the external service's IDs for any refunds that were initiated
|
||||
(may be empty).
|
||||
|
||||
Raises:
|
||||
exceptions.SlumberBaseException: for any unhandled HTTP error during communication with the E-Commerce Service.
|
||||
exceptions.Timeout: if the attempt to reach the commerce service timed out.
|
||||
"""
|
||||
User = get_user_model() # pylint:disable=invalid-name
|
||||
course_key_str = unicode(course_enrollment.course_id)
|
||||
enrollee = course_enrollment.user
|
||||
|
||||
service_user = User.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME)
|
||||
api_client = ecommerce_api_client(service_user)
|
||||
|
||||
log.info('Attempting to create a refund for user [%s], course [%s]...', enrollee.id, course_key_str)
|
||||
|
||||
refund_ids = api_client.refunds.post({'course_id': course_key_str, 'username': enrollee.username})
|
||||
|
||||
if refund_ids:
|
||||
log.info('Refund successfully opened for user [%s], course [%s]: %r', enrollee.id, course_key_str, refund_ids)
|
||||
|
||||
_process_refund(
|
||||
refund_ids=refund_ids,
|
||||
api_client=api_client,
|
||||
mode=course_enrollment.mode,
|
||||
user=enrollee,
|
||||
)
|
||||
else:
|
||||
log.info('No refund opened for user [%s], course [%s]', enrollee.id, course_key_str)
|
||||
|
||||
return refund_ids
|
||||
|
||||
|
||||
def _process_refund(refund_ids, api_client, mode, user, always_notify=False):
|
||||
"""
|
||||
Helper method to process a refund for a given course_product. This method assumes that the User has already
|
||||
been unenrolled.
|
||||
|
||||
Arguments:
|
||||
refund_ids: List of refund ids to be processed
|
||||
api_client: The API Client used in the processing of refunds
|
||||
mode: The mode that the refund should be processed for
|
||||
user: The user that the refund is being processed for
|
||||
always_notify (bool): This will enable always notifying support with Zendesk tickets when
|
||||
an approval is required
|
||||
|
||||
Returns:
|
||||
bool: True if the refund process was successful, False if there are any Errors that are not handled
|
||||
"""
|
||||
config = CommerceConfiguration.current()
|
||||
|
||||
if config.enable_automatic_refund_approval:
|
||||
refunds_requiring_approval = []
|
||||
|
||||
for refund_id in refund_ids:
|
||||
try:
|
||||
# NOTE: The following assumes that the user has already been unenrolled.
|
||||
# We are then able to approve payment. Additionally, this ensures we don't tie up an
|
||||
# additional web worker when the E-Commerce Service tries to unenroll the learner.
|
||||
api_client.refunds(refund_id).process.put({'action': 'approve_payment_only'})
|
||||
log.info('Refund [%d] successfully approved.', refund_id)
|
||||
except: # pylint: disable=bare-except
|
||||
# Push the refund to Support to process
|
||||
log.exception('Failed to automatically approve refund [%d]!', refund_id)
|
||||
refunds_requiring_approval.append(refund_id)
|
||||
else:
|
||||
refunds_requiring_approval = refund_ids
|
||||
|
||||
if refunds_requiring_approval:
|
||||
# XCOM-371: this is a temporary measure to suppress refund-related email
|
||||
# notifications to students and support for free enrollments. This
|
||||
# condition should be removed when the CourseEnrollment.refundable() logic
|
||||
# is updated to be more correct, or when we implement better handling (and
|
||||
# notifications) in Otto for handling reversal of $0 transactions.
|
||||
if mode != 'verified' and not always_notify:
|
||||
# 'verified' is the only enrollment mode that should presently
|
||||
# result in opening a refund request.
|
||||
log.info(
|
||||
'Skipping refund support notification for non-verified mode for user [%s], mode: [%s]',
|
||||
user.id,
|
||||
mode,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
return _send_refund_notification(user, refunds_requiring_approval)
|
||||
except: # pylint: disable=bare-except
|
||||
# Unable to send notification to Support, do not break as this method is used by Signals
|
||||
log.warning('Could not send support notification for refund.', exc_info=True)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _send_refund_notification(user, refund_ids):
|
||||
"""
|
||||
Notify the support team of the refund request.
|
||||
|
||||
Returns:
|
||||
bool: True if we are able to send the notification. In this case that means we were able to create
|
||||
a ZenDesk ticket
|
||||
"""
|
||||
|
||||
tags = ['auto_refund']
|
||||
|
||||
if theming_helpers.is_request_in_themed_site():
|
||||
# this is not presently supported with the external service.
|
||||
raise NotImplementedError("Unable to send refund processing emails to support teams.")
|
||||
|
||||
# Build the information for the ZenDesk ticket
|
||||
student = user
|
||||
subject = _("[Refund] User-Requested Refund")
|
||||
body = _generate_refund_notification_body(student, refund_ids)
|
||||
requester_name = student.profile.name or student.username
|
||||
|
||||
return create_zendesk_ticket(requester_name, student.email, subject, body, tags)
|
||||
|
||||
|
||||
def _generate_refund_notification_body(student, refund_ids): # pylint: disable=invalid-name
|
||||
""" Returns a refund notification message body. """
|
||||
msg = _(
|
||||
'A refund request has been initiated for {username} ({email}). '
|
||||
'To process this request, please visit the link(s) below.'
|
||||
).format(username=student.username, email=student.email)
|
||||
|
||||
ecommerce_url_root = configuration_helpers.get_value(
|
||||
'ECOMMERCE_PUBLIC_URL_ROOT', settings.ECOMMERCE_PUBLIC_URL_ROOT,
|
||||
)
|
||||
refund_urls = [urljoin(ecommerce_url_root, '/dashboard/refunds/{}/'.format(refund_id))
|
||||
for refund_id in refund_ids]
|
||||
|
||||
# emails contained in this message could contain unicode characters so encode as such
|
||||
return u'{msg}\n\n{urls}'.format(msg=msg, urls='\n'.join(refund_urls))
|
||||
|
||||
|
||||
def create_zendesk_ticket(requester_name, requester_email, subject, body, tags=None):
|
||||
"""
|
||||
Create a Zendesk ticket via API.
|
||||
|
||||
Returns:
|
||||
bool: False if we are unable to create the ticket for any reason
|
||||
"""
|
||||
if not (settings.ZENDESK_URL and settings.ZENDESK_USER and settings.ZENDESK_API_KEY):
|
||||
log.error('Zendesk is not configured. Cannot create a ticket.')
|
||||
return False
|
||||
|
||||
# Copy the tags to avoid modifying the original list.
|
||||
tags = set(tags or [])
|
||||
tags.add('LMS')
|
||||
tags = list(tags)
|
||||
|
||||
data = {
|
||||
'ticket': {
|
||||
'requester': {
|
||||
'name': requester_name,
|
||||
'email': unicode(requester_email)
|
||||
},
|
||||
'subject': subject,
|
||||
'comment': {'body': body},
|
||||
'tags': tags
|
||||
}
|
||||
}
|
||||
|
||||
# Encode the data to create a JSON payload
|
||||
payload = json.dumps(data)
|
||||
|
||||
# Set the request parameters
|
||||
url = urljoin(settings.ZENDESK_URL, '/api/v2/tickets.json')
|
||||
user = '{}/token'.format(settings.ZENDESK_USER)
|
||||
pwd = settings.ZENDESK_API_KEY
|
||||
headers = {'content-type': 'application/json'}
|
||||
|
||||
try:
|
||||
response = requests.post(url, data=payload, auth=(user, pwd), headers=headers)
|
||||
|
||||
# Check for HTTP codes other than 201 (Created)
|
||||
if response.status_code != 201:
|
||||
log.error('Failed to create ticket. Status: [%d], Body: [%s]', response.status_code, response.content)
|
||||
return False
|
||||
else:
|
||||
log.debug('Successfully created ticket.')
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception('Failed to create ticket.')
|
||||
return False
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user