From 0be941b224f2bd0183b21be9a7b29c2666cc78ee Mon Sep 17 00:00:00 2001
From: Sameen Fatima <55431213+sameenfatima78@users.noreply.github.com>
Date: Mon, 24 May 2021 14:50:34 +0500
Subject: [PATCH] ENT-4095: Handle coupon expiration date scenario in LMS
(#27704)
---
common/djangoapps/student/models.py | 88 ++++++++++++++-----
.../djangoapps/student/tests/test_refunds.py | 84 +++++++++++++++++-
common/djangoapps/student/views/dashboard.py | 7 ++
lms/envs/common.py | 1 +
lms/envs/production.py | 3 +
lms/static/js/dashboard/legacy.js | 8 +-
lms/templates/dashboard.html | 3 +-
.../dashboard/_dashboard_course_listing.html | 3 +-
.../features/enterprise_support/signals.py | 2 +
.../enterprise_support/tests/test_signals.py | 26 ++++--
themes/edx.org/lms/templates/dashboard.html | 3 +-
11 files changed, 195 insertions(+), 33 deletions(-)
diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py
index bde8b1e062..48b05287f5 100644
--- a/common/djangoapps/student/models.py
+++ b/common/djangoapps/student/models.py
@@ -43,7 +43,7 @@ from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ugettext_noop
from django_countries.fields import CountryField
-from edx_django_utils.cache import RequestCache
+from edx_django_utils.cache import RequestCache, TieredCache, get_cache_key
from edx_django_utils import monitoring
from edx_rest_api_client.exceptions import SlumberBaseException
from eventtracking import tracker
@@ -1890,8 +1890,7 @@ class CourseEnrollment(models.Model):
def refund_cutoff_date(self):
""" Calculate and return the refund window end date. """
# NOTE: This is here to avoid circular references
- from openedx.core.djangoapps.commerce.utils import ecommerce_api_client, ECOMMERCE_DATE_FORMAT
-
+ from openedx.core.djangoapps.commerce.utils import ECOMMERCE_DATE_FORMAT
date_placed = self.get_order_attribute_value('date_placed')
if not date_placed:
@@ -1899,20 +1898,67 @@ class CourseEnrollment(models.Model):
if not order_number:
return None
+ date_placed = self.get_order_attribute_from_ecommerce('date_placed')
+ if not date_placed:
+ return None
+
+ # also save the attribute so that we don't need to call ecommerce again.
+ username = self.user.username
+ enrollment_attributes = get_enrollment_attributes(username, str(self.course_id))
+ enrollment_attributes.append(
+ {
+ "namespace": "order",
+ "name": "date_placed",
+ "value": date_placed,
+ }
+ )
+ set_enrollment_attributes(username, str(self.course_id), enrollment_attributes)
+
+ refund_window_start_date = max(
+ datetime.strptime(date_placed, ECOMMERCE_DATE_FORMAT),
+ self.course_overview.start.replace(tzinfo=None)
+ )
+
+ return refund_window_start_date.replace(tzinfo=UTC) + EnrollmentRefundConfiguration.current().refund_window
+
+ def is_order_voucher_refundable(self):
+ """ Checks if the coupon batch expiration date has passed to determine whether order voucher is refundable. """
+ from openedx.core.djangoapps.commerce.utils import ECOMMERCE_DATE_FORMAT
+ vouchers = self.get_order_attribute_from_ecommerce('vouchers')
+ if not vouchers:
+ return False
+ voucher_end_datetime_str = vouchers[0]['end_datetime']
+ voucher_expiration_date = datetime.strptime(voucher_end_datetime_str, ECOMMERCE_DATE_FORMAT).replace(tzinfo=UTC)
+ return datetime.now(UTC) < voucher_expiration_date
+
+ def get_order_attribute_from_ecommerce(self, attribute_name):
+ """
+ Fetches the order details from ecommerce to return the value of the attribute passed as argument.
+
+ Arguments:
+ attribute_name (str): The name of the attribute that you want to fetch from response e:g 'number' or
+ 'vouchers', etc.
+
+ Returns:
+ (str | array | None): Returns the attribute value if it exists, returns None if the order doesn't exist or
+ attribute doesn't exist in the response.
+ """
+
+ # NOTE: This is here to avoid circular references
+ from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
+ order_number = self.get_order_attribute_value('order_number')
+ if not order_number:
+ return None
+
+ # check if response is already cached
+ cache_key = get_cache_key(user_id=self.user.id, order_number=order_number)
+ cached_response = TieredCache.get_cached_response(cache_key)
+ if cached_response.is_found:
+ order = cached_response.value
+ else:
try:
+ # response is not cached, so make a call to ecommerce to fetch order details
order = ecommerce_api_client(self.user).orders(order_number).get()
- date_placed = order['date_placed']
- # also save the attribute so that we don't need to call ecommerce again.
- username = self.user.username
- enrollment_attributes = get_enrollment_attributes(username, str(self.course_id))
- enrollment_attributes.append(
- {
- "namespace": "order",
- "name": "date_placed",
- "value": date_placed,
- }
- )
- set_enrollment_attributes(username, str(self.course_id), enrollment_attributes)
except HttpClientError:
log.warning(
"Encountered HttpClientError while getting order details from ecommerce. "
@@ -1931,12 +1977,12 @@ class CourseEnrollment(models.Model):
"Order={number} and user {user}".format(number=order_number, user=self.user.id))
return None
- refund_window_start_date = max(
- datetime.strptime(date_placed, ECOMMERCE_DATE_FORMAT),
- self.course_overview.start.replace(tzinfo=None)
- )
-
- return refund_window_start_date.replace(tzinfo=UTC) + EnrollmentRefundConfiguration.current().refund_window
+ cache_time_out = getattr(settings, 'ECOMMERCE_ORDERS_API_CACHE_TIMEOUT', 3600)
+ TieredCache.set_all_tiers(cache_key, order, cache_time_out)
+ try:
+ return order[attribute_name]
+ except KeyError:
+ return None
def get_order_attribute_value(self, attr_name):
""" Get and return course enrollment order attribute's value."""
diff --git a/common/djangoapps/student/tests/test_refunds.py b/common/djangoapps/student/tests/test_refunds.py
index aa100ef2ad..0c3e4ff16f 100644
--- a/common/djangoapps/student/tests/test_refunds.py
+++ b/common/djangoapps/student/tests/test_refunds.py
@@ -2,7 +2,7 @@
Tests for enrollment refund capabilities.
"""
-
+import json
import logging
import unittest
from datetime import datetime, timedelta
@@ -17,6 +17,7 @@ from django.conf import settings
from django.test.client import Client
from django.test.utils import override_settings
from django.urls import reverse
+from edx_django_utils.cache import TieredCache, get_cache_key
# These imports refer to lms djangoapps.
# Their testcases are only run under lms.
@@ -165,10 +166,91 @@ class RefundableTest(SharedModuleStoreTestCase):
assert expected_date_placed_attr in CourseEnrollmentAttribute.get_enrollment_attributes(self.enrollment)
+ @ddt.data(
+ (datetime.now(pytz.UTC) + timedelta(days=1), True),
+ (datetime.now(pytz.UTC) - timedelta(days=1), False),
+ (datetime.now(pytz.UTC) - timedelta(minutes=5), False),
+ )
+ @ddt.unpack
+ @httpretty.activate
+ @override_settings(ECOMMERCE_API_URL=TEST_API_URL)
+ def test_is_order_voucher_refundable(self, voucher_expiration_date, expected):
+ """
+ Assert that the correct value is returned based on voucher expiration date.
+ """
+ voucher_expiration_date_str = voucher_expiration_date.strftime(ECOMMERCE_DATE_FORMAT)
+ response = json.dumps({"vouchers": [{"end_datetime": voucher_expiration_date_str}]})
+ httpretty.register_uri(
+ httpretty.GET,
+ f'{TEST_API_URL}/orders/{self.ORDER_NUMBER}/',
+ status=200, body=response,
+ adding_headers={'Content-Type': JSON}
+ )
+
+ self.enrollment.attributes.create(
+ enrollment=self.enrollment,
+ namespace='order',
+ name='order_number',
+ value=self.ORDER_NUMBER
+ )
+ assert self.enrollment.is_order_voucher_refundable() == expected
+
def test_refund_cutoff_date_no_attributes(self):
""" Assert that the None is returned when no order number attribute is found."""
assert self.enrollment.refund_cutoff_date() is None
+ @httpretty.activate
+ @override_settings(ECOMMERCE_API_URL=TEST_API_URL)
+ def test_is_order_voucher_refundable_no_attributes(self, ):
+ """ Assert that False is returned when no order number or vouchers attribute is found in response."""
+ # no order number attribute
+ assert self.enrollment.is_order_voucher_refundable() is False
+
+ # no voucher information in orders api response
+ response = json.dumps({"vouchers": []})
+ httpretty.register_uri(
+ httpretty.GET,
+ f'{TEST_API_URL}/orders/{self.ORDER_NUMBER}/',
+ status=200, body=response,
+ adding_headers={'Content-Type': JSON}
+ )
+
+ self.enrollment.attributes.create(
+ enrollment=self.enrollment,
+ namespace='order',
+ name='order_number',
+ value=self.ORDER_NUMBER
+ )
+ assert self.enrollment.is_order_voucher_refundable() is False
+
+ response = json.dumps({"vouchers": None})
+ httpretty.register_uri(
+ httpretty.GET,
+ f'{TEST_API_URL}/orders/{self.ORDER_NUMBER}/',
+ status=200, body=response,
+ adding_headers={'Content-Type': JSON}
+ )
+ assert self.enrollment.is_order_voucher_refundable() is False
+
+ @patch('openedx.core.djangoapps.commerce.utils.ecommerce_api_client')
+ def test_get_order_attribute_from_ecommerce(self, mock_ecommerce_api_client):
+ """
+ Assert that the get_order_attribute_from_ecommerce method returns order details if it's already cached,
+ without calling ecommerce.
+ """
+ order_details = {"number": self.ORDER_NUMBER, "vouchers": [{"end_datetime": '2025-09-25T00:00:00Z'}]}
+ cache_key = get_cache_key(user_id=self.user.id, order_number=self.ORDER_NUMBER)
+ TieredCache.set_all_tiers(cache_key, order_details, 60)
+
+ self.enrollment.attributes.create(
+ enrollment=self.enrollment,
+ namespace='order',
+ name='order_number',
+ value=self.ORDER_NUMBER
+ )
+ assert self.enrollment.get_order_attribute_from_ecommerce("vouchers") == order_details["vouchers"]
+ mock_ecommerce_api_client.assert_not_called()
+
@patch('openedx.core.djangoapps.commerce.utils.ecommerce_api_client')
def test_refund_cutoff_date_with_date_placed_attr(self, mock_ecommerce_api_client):
"""
diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py
index 0eead5ab38..c6d31c588a 100644
--- a/common/djangoapps/student/views/dashboard.py
+++ b/common/djangoapps/student/views/dashboard.py
@@ -706,6 +706,12 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem
if enrollment.is_paid_course()
)
+ # Checks if a course enrollment redeemed using a voucher is refundable
+ enrolled_courses_voucher_refundable = frozenset(
+ enrollment.course_id for enrollment in course_enrollments
+ if enrollment.is_order_voucher_refundable()
+ )
+
# If there are *any* denied reverifications that have not been toggled off,
# we'll display the banner
denied_banner = any(item.display for item in reverifications["denied"])
@@ -775,6 +781,7 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem
'logout_url': reverse('logout'),
'platform_name': platform_name,
'enrolled_courses_either_paid': enrolled_courses_either_paid,
+ 'enrolled_courses_voucher_refundable': enrolled_courses_voucher_refundable,
'provider_states': [],
'courses_requirements_not_met': courses_requirements_not_met,
'nav_hidden': True,
diff --git a/lms/envs/common.py b/lms/envs/common.py
index a4b51690c8..281a5cf189 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -3936,6 +3936,7 @@ SOCIAL_PLATFORMS = {
ECOMMERCE_PUBLIC_URL_ROOT = 'http://localhost:8002'
ECOMMERCE_API_URL = 'http://localhost:8002/api/v2'
ECOMMERCE_API_TIMEOUT = 5
+ECOMMERCE_ORDERS_API_CACHE_TIMEOUT = 3600
ECOMMERCE_SERVICE_WORKER_USERNAME = 'ecommerce_worker'
ECOMMERCE_API_SIGNING_KEY = 'SET-ME-PLEASE'
diff --git a/lms/envs/production.py b/lms/envs/production.py
index 2723ea9980..4639bbeaaf 100644
--- a/lms/envs/production.py
+++ b/lms/envs/production.py
@@ -722,6 +722,9 @@ DEFAULT_MOBILE_AVAILABLE = ENV_TOKENS.get(
# Enrollment API Cache Timeout
ENROLLMENT_COURSE_DETAILS_CACHE_TIMEOUT = ENV_TOKENS.get('ENROLLMENT_COURSE_DETAILS_CACHE_TIMEOUT', 60)
+# Ecommerce Orders API Cache Timeout
+ECOMMERCE_ORDERS_API_CACHE_TIMEOUT = ENV_TOKENS.get('ECOMMERCE_ORDERS_API_CACHE_TIMEOUT', 3600)
+
if FEATURES.get('ENABLE_COURSEWARE_SEARCH') or \
FEATURES.get('ENABLE_DASHBOARD_SEARCH') or \
FEATURES.get('ENABLE_COURSE_DISCOVERY') or \
diff --git a/lms/static/js/dashboard/legacy.js b/lms/static/js/dashboard/legacy.js
index 29f7ccb777..966dc4c93d 100644
--- a/lms/static/js/dashboard/legacy.js
+++ b/lms/static/js/dashboard/legacy.js
@@ -84,7 +84,7 @@
return properties;
}
- function setDialogAttributes(isPaidCourse, certNameLong,
+ function setDialogAttributes(isPaidCourse, isCourseVoucherRefundable, certNameLong,
courseNumber, courseName, enrollmentMode, showRefundOption, courseKey) {
var diagAttr = {};
@@ -99,6 +99,9 @@
} else if (enrollmentMode !== 'verified') {
diagAttr['data-track-info'] = gettext('Are you sure you want to unenroll from {courseName} ' +
'({courseNumber})?');
+ } else if (showRefundOption && !isCourseVoucherRefundable) {
+ diagAttr['data-track-info'] = gettext('Are you sure you want to unenroll from the verified ' +
+ '{certNameLong} track of {courseName} ({courseNumber})?');
} else if (showRefundOption) {
diagAttr['data-track-info'] = gettext('Are you sure you want to unenroll from the verified ' +
'{certNameLong} track of {courseName} ({courseNumber})?');
@@ -134,6 +137,7 @@
});
$('.action-unenroll').click(function(event) {
var isPaidCourse = $(event.target).data('course-is-paid-course') === 'True',
+ isCourseVoucherRefundable = $(event.target).data('is-course-voucher-refundable') === 'True',
certNameLong = $(event.target).data('course-cert-name-long'),
enrollmentMode = $(event.target).data('course-enrollment-mode'),
courseNumber = $(event.target).data('course-number'),
@@ -149,7 +153,7 @@
});
request.success(function(data, textStatus, xhr) {
if (xhr.status === 200) {
- dialogMessageAttr = setDialogAttributes(isPaidCourse, certNameLong,
+ dialogMessageAttr = setDialogAttributes(isPaidCourse, isCourseVoucherRefundable, certNameLong,
courseNumber, courseName, enrollmentMode, data.course_refundable_status, courseKey);
$('#track-info').empty();
diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html
index 5369df1551..b0351a41cb 100644
--- a/lms/templates/dashboard.html
+++ b/lms/templates/dashboard.html
@@ -210,13 +210,14 @@ from common.djangoapps.student.models import CourseEnrollment
credit_status = credit_statuses.get(session_id)
course_mode_info = all_course_modes.get(session_id)
is_paid_course = True if entitlement else (session_id in enrolled_courses_either_paid)
+ is_course_voucher_refundable = (session_id in enrolled_courses_voucher_refundable)
course_verification_status = verification_status_by_course.get(session_id, {})
course_requirements = courses_requirements_not_met.get(session_id)
related_programs = inverted_programs.get(six.text_type(entitlement.course_uuid if is_unfulfilled_entitlement else session_id))
show_consent_link = (session_id in consent_required_courses)
resume_button_url = resume_button_urls[dashboard_index]
%>
- <%include file='dashboard/_dashboard_course_listing.html' args='course_overview=course_overview, course_card_index=dashboard_index, enrollment=enrollment, is_unfulfilled_entitlement=is_unfulfilled_entitlement, is_fulfilled_entitlement=is_fulfilled_entitlement, entitlement=entitlement, entitlement_session=entitlement_session, entitlement_available_sessions=entitlement_available_sessions, entitlement_expiration_date=entitlement_expiration_date, entitlement_expired_at=entitlement_expired_at, show_courseware_link=show_courseware_link, cert_status=cert_status, can_refund_entitlement=can_refund_entitlement, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs, display_course_modes_on_dashboard=display_course_modes_on_dashboard, show_consent_link=show_consent_link, enterprise_customer_name=enterprise_customer_name, resume_button_url=resume_button_url, partner_managed_enrollment=partner_managed_enrollment' />
+ <%include file='dashboard/_dashboard_course_listing.html' args='course_overview=course_overview, course_card_index=dashboard_index, enrollment=enrollment, is_unfulfilled_entitlement=is_unfulfilled_entitlement, is_fulfilled_entitlement=is_fulfilled_entitlement, entitlement=entitlement, entitlement_session=entitlement_session, entitlement_available_sessions=entitlement_available_sessions, entitlement_expiration_date=entitlement_expiration_date, entitlement_expired_at=entitlement_expired_at, show_courseware_link=show_courseware_link, cert_status=cert_status, can_refund_entitlement=can_refund_entitlement, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, is_course_voucher_refundable=is_course_voucher_refundable, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs, display_course_modes_on_dashboard=display_course_modes_on_dashboard, show_consent_link=show_consent_link, enterprise_customer_name=enterprise_customer_name, resume_button_url=resume_button_url, partner_managed_enrollment=partner_managed_enrollment' />
% endfor
% if show_load_all_courses_link:
diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html
index 1a0e733822..95a67035f8 100644
--- a/lms/templates/dashboard/_dashboard_course_listing.html
+++ b/lms/templates/dashboard/_dashboard_course_listing.html
@@ -1,4 +1,4 @@
-<%page args="course_overview, enrollment, entitlement, entitlement_session, course_card_index, is_unfulfilled_entitlement, is_fulfilled_entitlement, entitlement_available_sessions, entitlement_expiration_date, entitlement_expired_at, show_courseware_link, cert_status, can_refund_entitlement, can_unenroll, credit_status, show_email_settings, course_mode_info, is_paid_course, verification_status, course_requirements, dashboard_index, share_settings, related_programs, display_course_modes_on_dashboard, show_consent_link, enterprise_customer_name, resume_button_url, partner_managed_enrollment" expression_filter="h"/>
+<%page args="course_overview, enrollment, entitlement, entitlement_session, course_card_index, is_unfulfilled_entitlement, is_fulfilled_entitlement, entitlement_available_sessions, entitlement_expiration_date, entitlement_expired_at, show_courseware_link, cert_status, can_refund_entitlement, can_unenroll, credit_status, show_email_settings, course_mode_info, is_paid_course, is_course_voucher_refundable, verification_status, course_requirements, dashboard_index, share_settings, related_programs, display_course_modes_on_dashboard, show_consent_link, enterprise_customer_name, resume_button_url, partner_managed_enrollment" expression_filter="h"/>
<%!
import datetime
@@ -266,6 +266,7 @@ from lms.djangoapps.experiments.utils import UPSELL_TRACKING_FLAG
data-dashboard-index="${dashboard_index}"
data-course-refund-url="${course_refund_url}"
data-course-is-paid-course="${is_paid_course}"
+ data-is-course-voucher-refundable="${is_course_voucher_refundable}"
data-course-cert-name-long="${cert_name_long}"
data-course-enrollment-mode="${enrollment.mode}">
${_('Unenroll')}
diff --git a/openedx/features/enterprise_support/signals.py b/openedx/features/enterprise_support/signals.py
index 84d321c985..454141c100 100644
--- a/openedx/features/enterprise_support/signals.py
+++ b/openedx/features/enterprise_support/signals.py
@@ -97,6 +97,8 @@ def refund_order_voucher(sender, course_enrollment, skip_refund=False, **kwargs)
return
if not course_enrollment.refundable():
return
+ if not course_enrollment.is_order_voucher_refundable():
+ return
if not EnterpriseCourseEnrollment.objects.filter(
enterprise_customer_user__user_id=course_enrollment.user_id,
course_id=str(course_enrollment.course.id)
diff --git a/openedx/features/enterprise_support/tests/test_signals.py b/openedx/features/enterprise_support/tests/test_signals.py
index 9143e0e355..b101ebf0b5 100644
--- a/openedx/features/enterprise_support/tests/test_signals.py
+++ b/openedx/features/enterprise_support/tests/test_signals.py
@@ -133,28 +133,42 @@ class EnterpriseSupportSignals(SharedModuleStoreTestCase):
return enrollment
+ @patch('common.djangoapps.student.models.CourseEnrollment.is_order_voucher_refundable')
@ddt.data(
- (True, True, 2, False), # test if skip_refund
- (False, True, 20, False), # test refundable time passed
- (False, False, 2, False), # test not enterprise enrollment
- (False, True, 2, True), # success: no skip_refund, is enterprise enrollment and still in refundable window.
+ (True, True, 2, True, False), # test if skip_refund
+ (False, True, 20, True, False), # test refundable time passed
+ (False, False, 2, True, False), # test not enterprise enrollment
+ (False, True, 2, False, False), # test order voucher expiration date has already passed
+ (False, True, 2, True, True), # success: no skip_refund, is enterprise enrollment, coupon voucher is refundable
+ # and is still in refundable window.
)
@ddt.unpack
- def test_refund_order_voucher(self, skip_refund, enterprise_enrollment_exists, no_of_days_placed, api_called):
+ def test_refund_order_voucher(
+ self,
+ skip_refund,
+ enterprise_enrollment_exists,
+ no_of_days_placed,
+ order_voucher_refundable,
+ api_called,
+ mock_is_order_voucher_refundable
+ ):
"""Test refund_order_voucher signal"""
+ mock_is_order_voucher_refundable.return_value = order_voucher_refundable
enrollment = self._create_enrollment_to_refund(no_of_days_placed, enterprise_enrollment_exists)
with patch('openedx.features.enterprise_support.signals.ecommerce_api_client') as mock_ecommerce_api_client:
enrollment.update_enrollment(is_active=False, skip_refund=skip_refund)
assert mock_ecommerce_api_client.called == api_called
+ @patch('common.djangoapps.student.models.CourseEnrollment.is_order_voucher_refundable')
@ddt.data(
(HttpClientError, 'INFO'),
(HttpServerError, 'ERROR'),
(Exception, 'ERROR'),
)
@ddt.unpack
- def test_refund_order_voucher_with_client_errors(self, mock_error, log_level):
+ def test_refund_order_voucher_with_client_errors(self, mock_error, log_level, mock_is_order_voucher_refundable):
"""Test refund_order_voucher signal client_error"""
+ mock_is_order_voucher_refundable.return_value = True
enrollment = self._create_enrollment_to_refund()
with patch('openedx.features.enterprise_support.signals.ecommerce_api_client') as mock_ecommerce_api_client:
client_instance = mock_ecommerce_api_client.return_value
diff --git a/themes/edx.org/lms/templates/dashboard.html b/themes/edx.org/lms/templates/dashboard.html
index a91f29d443..2406ae90b7 100644
--- a/themes/edx.org/lms/templates/dashboard.html
+++ b/themes/edx.org/lms/templates/dashboard.html
@@ -241,6 +241,7 @@ from common.djangoapps.student.models import CourseEnrollment
credit_status = credit_statuses.get(session_id)
course_mode_info = all_course_modes.get(session_id)
is_paid_course = True if entitlement else (session_id in enrolled_courses_either_paid)
+ is_course_voucher_refundable = (session_id in enrolled_courses_voucher_refundable)
course_verification_status = verification_status_by_course.get(session_id, {})
course_requirements = courses_requirements_not_met.get(session_id)
related_programs = inverted_programs.get(six.text_type(entitlement.course_uuid if is_unfulfilled_entitlement else session_id))
@@ -248,7 +249,7 @@ from common.djangoapps.student.models import CourseEnrollment
course_overview = enrollment.course_overview
resume_button_url = resume_button_urls[dashboard_index]
%>
- <%include file='dashboard/_dashboard_course_listing.html' args='course_overview=course_overview, course_card_index=dashboard_index, enrollment=enrollment, is_unfulfilled_entitlement=is_unfulfilled_entitlement, is_fulfilled_entitlement=is_fulfilled_entitlement, entitlement=entitlement, entitlement_session=entitlement_session, entitlement_available_sessions=entitlement_available_sessions, entitlement_expiration_date=entitlement_expiration_date, entitlement_expired_at=entitlement_expired_at, show_courseware_link=show_courseware_link, cert_status=cert_status, can_refund_entitlement=can_refund_entitlement, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs, display_course_modes_on_dashboard=display_course_modes_on_dashboard, show_consent_link=show_consent_link, enterprise_customer_name=enterprise_customer_name, resume_button_url=resume_button_url, partner_managed_enrollment=partner_managed_enrollment' />
+ <%include file='dashboard/_dashboard_course_listing.html' args='course_overview=course_overview, course_card_index=dashboard_index, enrollment=enrollment, is_unfulfilled_entitlement=is_unfulfilled_entitlement, is_fulfilled_entitlement=is_fulfilled_entitlement, entitlement=entitlement, entitlement_session=entitlement_session, entitlement_available_sessions=entitlement_available_sessions, entitlement_expiration_date=entitlement_expiration_date, entitlement_expired_at=entitlement_expired_at, show_courseware_link=show_courseware_link, cert_status=cert_status, can_refund_entitlement=can_refund_entitlement, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, is_course_voucher_refundable=is_course_voucher_refundable, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs, display_course_modes_on_dashboard=display_course_modes_on_dashboard, show_consent_link=show_consent_link, enterprise_customer_name=enterprise_customer_name, resume_button_url=resume_button_url, partner_managed_enrollment=partner_managed_enrollment' />
% endfor
% if show_load_all_courses_link: