Commit 3f8f039d by Clinton Blackburn Committed by Clinton Blackburn

Sending notification after refund approval

An email is now sent to learners after a credit is issued for a refund. This functionality can be controlled for each site, and is only applicable for refunds of a course seat.

ECOM-6975
parent 4b72e75d
......@@ -106,6 +106,11 @@ class Command(BaseCommand):
type=str,
required=False,
help='URL displayed to user for payment support')
parser.add_argument('--send-refund-notifications',
action='store_true',
dest='send_refund_notifications',
default=False,
help='Enable refund notification emails')
def handle(self, *args, **options):
site_id = options.get('site_id')
......@@ -149,6 +154,7 @@ class Command(BaseCommand):
'segment_key': segment_key,
'from_email': from_email,
'enable_enrollment_codes': enable_enrollment_codes,
'send_refund_notifications': options['send_refund_notifications'],
'oauth_settings': {
'SOCIAL_AUTH_EDX_OIDC_URL_ROOT': '{lms_url_root}/oauth2'.format(lms_url_root=lms_url_root),
'SOCIAL_AUTH_EDX_OIDC_KEY': client_id,
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0022_auto_20161108_2101'),
]
operations = [
migrations.AddField(
model_name='siteconfiguration',
name='send_refund_notifications',
field=models.BooleanField(default=False, verbose_name='Send refund email notification'),
),
]
......@@ -126,6 +126,11 @@ class SiteConfiguration(models.Model):
blank=True,
default=False
)
send_refund_notifications = models.BooleanField(
verbose_name=_('Send refund email notification'),
blank=True,
default=False
)
class Meta(object):
unique_together = ('site', 'partner')
......
......@@ -44,7 +44,7 @@ class CreateOrUpdateSiteCommandTests(TestCase):
def _call_command(self, site_domain, partner_code, lms_url_root, client_id, client_secret, from_email,
site_id=None, site_name=None, partner_name=None, theme_scss_path=None,
payment_processors=None, segment_key=None, enable_enrollment_codes=False,
payment_support_email=None, payment_support_url=None):
payment_support_email=None, payment_support_url=None, send_refund_notifications=False):
"""
Internal helper method for interacting with the create_or_update_site management command
"""
......@@ -85,6 +85,10 @@ class CreateOrUpdateSiteCommandTests(TestCase):
command_args.append('--payment-support-url={payment_support_url}'.format(
payment_support_url=payment_support_url
))
if send_refund_notifications:
command_args.append('--send-refund-notifications')
call_command(self.command_name, *command_args)
def test_create_site(self):
......@@ -108,6 +112,7 @@ class CreateOrUpdateSiteCommandTests(TestCase):
self._check_site_configuration(site, partner)
self.assertFalse(site.siteconfiguration.enable_enrollment_codes)
self.assertFalse(site.siteconfiguration.send_refund_notifications)
def test_update_site(self):
""" Verify the command updates Site and creates Partner, and SiteConfiguration """
......@@ -130,7 +135,8 @@ class CreateOrUpdateSiteCommandTests(TestCase):
from_email=self.from_email,
enable_enrollment_codes=True,
payment_support_email=self.payment_support_email,
payment_support_url=self.payment_support_url
payment_support_url=self.payment_support_url,
send_refund_notifications=True
)
site = Site.objects.get(id=site.id)
......@@ -139,9 +145,12 @@ class CreateOrUpdateSiteCommandTests(TestCase):
self.assertEqual(site.domain, updated_site_domain)
self.assertEqual(site.name, updated_site_name)
self._check_site_configuration(site, partner)
self.assertTrue(site.siteconfiguration.enable_enrollment_codes)
self.assertEqual(site.siteconfiguration.payment_support_email, self.payment_support_email)
self.assertEqual(site.siteconfiguration.payment_support_url, self.payment_support_url)
site_configuration = site.siteconfiguration
self.assertTrue(site_configuration.enable_enrollment_codes)
self.assertEqual(site_configuration.payment_support_email, self.payment_support_email)
self.assertEqual(site_configuration.payment_support_url, self.payment_support_url)
self.assertTrue(site_configuration.send_refund_notifications)
@data(
['--site-id=1'],
......
......@@ -228,7 +228,7 @@ class RefundProcessViewTests(ThrottlingMixin, TestCase):
refund = Refund.objects.get(id=self.refund.id)
self.assertEqual(response.data['status'], refund.status)
self.assertEqual(response.data['status'], "Complete")
patched_log.info.assert_called_once_with(
patched_log.info.assert_called_with(
"Skipping the revocation step for refund [%d].", self.refund.id)
@ddt.data(
......
import logging
import urllib
from babel.numbers import format_currency
from babel.numbers import format_currency as default_format_currency
from django.conf import settings
from django.core.urlresolvers import reverse
from django.utils.translation import get_language, to_locale
......@@ -9,7 +9,6 @@ from edx_rest_api_client.client import EdxRestApiClient
from requests.exceptions import ConnectionError, Timeout
from slumber.exceptions import SlumberHttpBaseException
logger = logging.getLogger(__name__)
......@@ -60,6 +59,18 @@ def get_receipt_page_url(site_configuration, order_number=None, override_url=Non
)
def format_currency(currency, amount, format=None, locale=None): # pylint: disable=redefined-builtin
locale = locale or to_locale(get_language())
format = format or getattr(settings, 'OSCAR_CURRENCY_FORMAT', None)
return default_format_currency(
amount,
currency,
format=format,
locale=locale
)
def add_currency(amount):
""" Adds currency to the price amount.
......@@ -69,9 +80,4 @@ def add_currency(amount):
Returns:
str: Formatted price with currency.
"""
return format_currency(
amount,
settings.OSCAR_DEFAULT_CURRENCY,
format=u'#,##0.00',
locale=to_locale(get_language())
)
return format_currency(settings.OSCAR_DEFAULT_CURRENCY, amount, u'#,##0.00')
from __future__ import unicode_literals
import logging
from django.conf import settings
from django.db import models
from django.utils.translation import ugettext_lazy as _
from django_extensions.db.models import TimeStampedModel
from ecommerce_worker.sailthru.v1.tasks import send_course_refund_email
from oscar.apps.payment.exceptions import PaymentError
from oscar.core.loading import get_class, get_model
from oscar.core.utils import get_default_currency
from simple_history.models import HistoricalRecords
from ecommerce.core.constants import SEAT_PRODUCT_CLASS_NAME
from ecommerce.extensions.analytics.utils import audit_log
from ecommerce.extensions.checkout.utils import get_receipt_page_url, format_currency
from ecommerce.extensions.fulfillment.api import revoke_fulfillment_for_refund
from ecommerce.extensions.order.constants import PaymentEventTypeName
from ecommerce.extensions.payment.helpers import get_processor_class_by_name
......@@ -186,6 +191,39 @@ class Refund(StatusMixin, TimeStampedModel):
# This occurs when attempting to refund free orders.
logger.info("No payments to credit for Refund [%d]", self.id)
def _notify_purchaser(self):
""" Notify the purchaser that the refund has been processed. """
site_configuration = self.order.site.siteconfiguration
site_code = site_configuration.partner.short_code
if not site_configuration.send_refund_notifications:
logger.info(
'Refund notifications are disabled for Partner [%s]. No notification will be sent for Refund [%d]',
site_code, self.id
)
return
# NOTE (CCB): The initial version of the refund email only supports refunding a single course.
product = self.lines.first().order_line.product
product_class = product.get_product_class().name
if product_class != SEAT_PRODUCT_CLASS_NAME:
logger.warning(
('No refund notification will be sent for Refund [%d]. The notification supports product lines '
'of type Course, not [%s].'),
self.id, product_class
)
return
course_name = self.lines.first().order_line.product.course.name
order_number = self.order.number
order_url = get_receipt_page_url(site_configuration, order_number)
amount = format_currency(self.currency, self.total_credit_excl_tax)
send_course_refund_email.delay(self.user.email, self.id, amount, course_name, order_number,
order_url, site_code=site_code)
logger.info('Course refund notification scheduled for Refund [%d].', self.id)
def _revoke_lines(self):
"""Revoke fulfillment for the lines in this Refund."""
if revoke_fulfillment_for_refund(self):
......@@ -202,6 +240,7 @@ class Refund(StatusMixin, TimeStampedModel):
try:
self._issue_credit()
self.set_status(REFUND.PAYMENT_REFUNDED)
self._notify_purchaser()
except PaymentError:
logger.exception('Failed to issue credit for refund [%d].', self.id)
self.set_status(REFUND.PAYMENT_REFUND_ERROR)
......
......@@ -70,8 +70,8 @@ class RefundTestMixin(CourseCatalogTestMixin):
self.assertEqual(refund_line.line_credit_excl_tax, order_line.line_price_excl_tax)
self.assertEqual(refund_line.quantity, order_line.quantity)
def create_refund(self, processor_name=DummyProcessor.NAME):
refund = RefundFactory()
def create_refund(self, processor_name=DummyProcessor.NAME, **kwargs):
refund = RefundFactory(**kwargs)
order = refund.order
source_type, __ = SourceType.objects.get_or_create(name=processor_name)
Source.objects.create(source_type=source_type, order=order, currency=refund.currency,
......
from __future__ import unicode_literals
from decimal import Decimal
import ddt
import httpretty
import mock
from django.conf import settings
from oscar.apps.payment.exceptions import PaymentError
from oscar.core.loading import get_model, get_class
from oscar.test.factories import create_basket
from oscar.test.newfactories import UserFactory
from testfixtures import LogCapture
from ecommerce.core.constants import SEAT_PRODUCT_CLASS_NAME
from ecommerce.core.url_utils import get_lms_enrollment_api_url
from ecommerce.courses.tests.factories import CourseFactory
from ecommerce.extensions.checkout.utils import get_receipt_page_url, format_currency
from ecommerce.extensions.payment.tests.processors import DummyProcessor
from ecommerce.extensions.refund import models
from ecommerce.extensions.refund.exceptions import InvalidStatus
from ecommerce.extensions.refund.status import REFUND, REFUND_LINE
from ecommerce.extensions.refund.tests.factories import RefundFactory, RefundLineFactory
from ecommerce.extensions.refund.tests.mixins import RefundTestMixin
from ecommerce.extensions.test.factories import create_order
from ecommerce.tests.testcases import TestCase
PaymentEventType = get_model('order', 'PaymentEventType')
......@@ -22,6 +31,7 @@ Refund = get_model('refund', 'Refund')
Source = get_model('payment', 'Source')
LOGGER_NAME = 'ecommerce.extensions.analytics.utils'
REFUND_MODEL_LOGGER_NAME = 'ecommerce.extensions.refund.models'
class StatusTestsMixin(object):
......@@ -213,10 +223,14 @@ class RefundTests(RefundTestMixin, StatusTestsMixin, TestCase):
RefundLine objects to Complete, and return True.
"""
self.site.siteconfiguration.segment_key = None
self.site.siteconfiguration.send_refund_notifications = True
refund = self.create_refund()
source = refund.order.sources.first()
with LogCapture(LOGGER_NAME) as l:
self.approve(refund)
with mock.patch.object(Refund, '_notify_purchaser', return_value=None) as mock_notify:
self.approve(refund)
l.check(
(
......@@ -243,6 +257,9 @@ class RefundTests(RefundTestMixin, StatusTestsMixin, TestCase):
self.assert_valid_payment_event_fields(payment_event, refund.total_credit_excl_tax, paid_type,
DummyProcessor.NAME, DummyProcessor.REFUND_TRANSACTION_ID)
# Verify an attempt is made to send a notification
mock_notify.assert_called_once_with()
def test_approve_payment_error(self):
"""
If payment refund fails, the Refund status should be set to Payment Refund Error, and the RefundLine
......@@ -309,11 +326,10 @@ class RefundTests(RefundTestMixin, StatusTestsMixin, TestCase):
# Make RefundLine.deny() raise an exception
with mock.patch('ecommerce.extensions.refund.models.RefundLine.deny', side_effect=Exception):
logger_name = 'ecommerce.extensions.refund.models'
with LogCapture(logger_name) as l:
with LogCapture(REFUND_MODEL_LOGGER_NAME) as l:
self.assertFalse(refund.deny())
l.check((logger_name, 'ERROR', 'Failed to deny RefundLine [{}].'.format(refund.lines.first().id)))
l.check((REFUND_MODEL_LOGGER_NAME, 'ERROR',
'Failed to deny RefundLine [{}].'.format(refund.lines.first().id)))
@ddt.data(REFUND.REVOCATION_ERROR, REFUND.PAYMENT_REFUNDED, REFUND.PAYMENT_REFUND_ERROR, REFUND.COMPLETE)
def test_deny_wrong_state(self, status):
......@@ -326,6 +342,80 @@ class RefundTests(RefundTestMixin, StatusTestsMixin, TestCase):
self.assertEqual(refund.status, status)
self.assert_line_status(refund, REFUND_LINE.OPEN)
@mock.patch('ecommerce_worker.sailthru.v1.tasks.send_course_refund_email.delay')
def test_notify_purchaser(self, mock_task):
""" Verify the notification is scheduled if the site has notifications enabled
and the refund is for a course seat.
"""
site_configuration = self.site.siteconfiguration
site_configuration.send_refund_notifications = True
user = UserFactory()
course = CourseFactory()
price = Decimal(100.00)
product = course.create_or_update_seat('verified', True, price, self.partner)
basket = create_basket(empty=True)
basket.site = self.site
basket.add_product(product)
order = create_order(basket=basket, user=user)
order_url = get_receipt_page_url(site_configuration, order.number)
refund = Refund.create_with_lines(order, order.lines.all())
with LogCapture(REFUND_MODEL_LOGGER_NAME) as l:
refund._notify_purchaser() # pylint: disable=protected-access
msg = 'Course refund notification scheduled for Refund [{}].'.format(refund.id)
l.check(
(REFUND_MODEL_LOGGER_NAME, 'INFO', msg)
)
amount = format_currency(order.currency, price)
mock_task.assert_called_once_with(
user.email, refund.id, amount, course.name, order.number, order_url, site_code=self.partner.short_code
)
@mock.patch('ecommerce_worker.sailthru.v1.tasks.send_course_refund_email.delay')
def test_notify_purchaser_with_notifications_disabled(self, mock_task):
""" Verify no notification is sent if the functionality is disabled for the site. """
self.site.siteconfiguration.send_refund_notifications = False
order = create_order(site=self.site)
refund = self.create_refund(order=order)
with LogCapture(REFUND_MODEL_LOGGER_NAME) as l:
refund._notify_purchaser() # pylint: disable=protected-access
msg = 'Refund notifications are disabled for Partner [{code}]. ' \
'No notification will be sent for Refund [{id}]'.format(code=self.partner.short_code, id=refund.id)
l.check(
(REFUND_MODEL_LOGGER_NAME, 'INFO', msg)
)
self.assertFalse(mock_task.called)
@mock.patch('ecommerce_worker.sailthru.v1.tasks.send_course_refund_email.delay')
def test_notify_purchaser_without_course_product_class(self, mock_task):
""" Verify a notification is not sent if the refunded item is not a course seat. """
self.site.siteconfiguration.send_refund_notifications = True
order = create_order(site=self.site)
product_class = order.lines.first().product.get_product_class().name
self.assertNotEqual(product_class, SEAT_PRODUCT_CLASS_NAME)
refund = self.create_refund(order=order)
with LogCapture(REFUND_MODEL_LOGGER_NAME) as l:
refund._notify_purchaser() # pylint: disable=protected-access
msg = ('No refund notification will be sent for Refund [{id}]. The notification supports product '
'lines of type Course, not [{product_class}].').format(product_class=product_class, id=refund.id)
l.check(
(REFUND_MODEL_LOGGER_NAME, 'WARNING', msg)
)
self.assertFalse(mock_task.called)
class RefundLineTests(StatusTestsMixin, TestCase):
pipeline = settings.OSCAR_REFUND_LINE_STATUS_PIPELINE
......
......@@ -28,6 +28,7 @@ class SiteConfigurationFactory(factory.DjangoModelFactory):
lms_url_root = factory.LazyAttribute(lambda obj: "http://lms.testserver.fake")
site = factory.SubFactory(SiteFactory)
partner = factory.SubFactory(PartnerFactory)
send_refund_notifications = False
class StockRecordFactory(OscarStockRecordFactory):
......
......@@ -19,7 +19,7 @@ edx-auth-backends==0.6.0
edx-django-release-util==0.2.0
edx-django-sites-extensions==1.0.0
edx-drf-extensions==1.2.2
edx-ecommerce-worker==0.5.0
edx-ecommerce-worker==0.6.0
edx-opaque-keys==0.3.1
edx-rest-api-client==1.6.0
jsonfield==1.0.3
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment