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

feat: Add Dynamic Payment Methods BNPL #4115

Merged
merged 11 commits into from
Apr 17, 2024
Merged
1 change: 1 addition & 0 deletions ecommerce/extensions/basket/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
EMAIL_OPT_IN_ATTRIBUTE = "email_opt_in"
PURCHASER_BEHALF_ATTRIBUTE = "purchased_for_organization"
PAYMENT_INTENT_ID_ATTRIBUTE = "payment_intent_id"
DYNAMIC_PAYMENT_METHODS_ENABLED = "dynamic_payment_methods_enabled"

# .. toggle_name: enable_stripe_payment_processor
# .. toggle_type: waffle_flag
Expand Down
42 changes: 41 additions & 1 deletion ecommerce/extensions/basket/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@
from ecommerce.extensions.basket.constants import EMAIL_OPT_IN_ATTRIBUTE, ENABLE_STRIPE_PAYMENT_PROCESSOR
from ecommerce.extensions.basket.tests.mixins import BasketMixin
from ecommerce.extensions.basket.tests.test_utils import TEST_BUNDLE_ID
from ecommerce.extensions.basket.utils import _set_basket_bundle_status, apply_voucher_on_basket_and_check_discount
from ecommerce.extensions.basket.utils import (
_set_basket_bundle_status,
apply_voucher_on_basket_and_check_discount,
basket_add_dynamic_payment_methods_enabled,
basket_add_payment_intent_id_attribute
)
from ecommerce.extensions.catalogue.tests.mixins import DiscoveryTestMixin
from ecommerce.extensions.offer.constants import DYNAMIC_DISCOUNT_FLAG
from ecommerce.extensions.offer.utils import format_benefit_value
Expand Down Expand Up @@ -325,6 +330,13 @@ def create_basket_and_add_product(self, product):
basket.add_product(product, 1)
return basket

def create_basket_and_add_product_stripe(self, product, payment_intent_id, payment_intent):
basket = self.create_empty_basket()
basket.add_product(product, 1)
basket_add_dynamic_payment_methods_enabled(basket, payment_intent)
basket_add_payment_intent_id_attribute(basket, payment_intent_id)
return basket

def create_seat(self, course, seat_price=100, cert_type='verified'):
return course.create_or_update_seat(cert_type, True, seat_price)

Expand Down Expand Up @@ -404,6 +416,7 @@ def assert_expected_response(
self,
basket,
enable_stripe_payment_processor=False,
is_dynamic_payment_methods_enabled=None,
url=None,
response=None,
status_code=200,
Expand All @@ -421,6 +434,7 @@ def assert_expected_response(
subject=None,
messages=None,
summary_discounts=None,
payment_intent_id=None,
**kwargs
):
if response is None:
Expand Down Expand Up @@ -458,6 +472,7 @@ def assert_expected_response(
'basket_id': basket.id,
'currency': currency,
'enable_stripe_payment_processor': enable_stripe_payment_processor,
'is_dynamic_payment_methods_enabled': is_dynamic_payment_methods_enabled,
'offers': offers,
'coupons': coupons,
'messages': messages if messages else [],
Expand All @@ -466,6 +481,7 @@ def assert_expected_response(
'summary_discounts': summary_discounts,
'summary_price': summary_price,
'order_total': order_total,
'payment_intent_id': payment_intent_id,
'products': [
{
'product_type': product_type,
Expand Down Expand Up @@ -712,6 +728,30 @@ def test_enable_stripe_payment_processor_flag(self, enable_stripe_payment_proces
enable_stripe_payment_processor=enable_stripe_payment_processor,
)

def test_cart_with_stripe_data(self):
"""
For Dynamic Payment Methods, the basket will contain Payment Intent information.
Verify that the basket contains Payment Intent ID and if DPM is enabled.
"""
payment_intent_id = 'pi_3OqcQ5H4caH7G0X11y8NKNa4'
julianajlk marked this conversation as resolved.
Show resolved Hide resolved
payment_intent = {
'payment_method_types': [
'card',
'klarna',
]
}
seat = self.create_seat(self.course)
basket = self.create_basket_and_add_product_stripe(
seat, payment_intent_id, payment_intent
)
response = self.client.get(self.path)
self.assert_expected_response(
basket,
response=response,
is_dynamic_payment_methods_enabled=len(payment_intent['payment_method_types']) > 1,
payment_intent_id=payment_intent_id
)

@responses.activate
def test_enterprise_free_basket_redirect(self):
"""
Expand Down
19 changes: 19 additions & 0 deletions ecommerce/extensions/basket/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from ecommerce.courses.utils import mode_for_product
from ecommerce.extensions.analytics.utils import track_segment_event
from ecommerce.extensions.basket.constants import (
DYNAMIC_PAYMENT_METHODS_ENABLED,
EMAIL_OPT_IN_ATTRIBUTE,
ENABLE_STRIPE_PAYMENT_PROCESSOR,
PAYMENT_INTENT_ID_ATTRIBUTE,
Expand Down Expand Up @@ -394,6 +395,24 @@ def basket_add_organization_attribute(basket, request_data):
)


@newrelic.agent.function_trace()
def basket_add_dynamic_payment_methods_enabled(basket, payment_intent):
"""
Adds a boolean value which is True if there is more than
'card' payment method type in the Stripe Payment Intent.
julianajlk marked this conversation as resolved.
Show resolved Hide resolved
"""
dynamic_payment_methods_enabled_attribute, __ = BasketAttributeType.objects.get_or_create(
name=DYNAMIC_PAYMENT_METHODS_ENABLED)
# Do a get_or_create and update value_text after (instead of update_or_create)
# to prevent a particularly slow full table scan that uses a LIKE
basket_attribute, __ = BasketAttribute.objects.get_or_create(
attribute_type=dynamic_payment_methods_enabled_attribute,
basket=basket,
)
basket_attribute.value_text = len(payment_intent['payment_method_types']) > 1
basket_attribute.save()


@newrelic.agent.function_trace()
def basket_add_payment_intent_id_attribute(basket, payment_intent_id):
"""
Expand Down
32 changes: 31 additions & 1 deletion ecommerce/extensions/basket/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@
translate_basket_line_for_segment
)
from ecommerce.extensions.basket import message_utils
from ecommerce.extensions.basket.constants import ENABLE_STRIPE_PAYMENT_PROCESSOR
from ecommerce.extensions.basket.constants import (
DYNAMIC_PAYMENT_METHODS_ENABLED,
ENABLE_STRIPE_PAYMENT_PROCESSOR,
PAYMENT_INTENT_ID_ATTRIBUTE
)
from ecommerce.extensions.basket.exceptions import BadRequestException, RedirectException, VoucherException
from ecommerce.extensions.basket.utils import (
add_invalid_code_message_to_url,
Expand Down Expand Up @@ -677,6 +681,8 @@ def _serialize_context(self, context, lines_data):
self._add_offers(response)
self._add_coupons(response, context)
self._add_enable_stripe_payment_processor(response)
self._add_payment_intent_id(response, self.request.basket)
self._add_is_dynamic_payment_methods(response, self.request.basket)
return response

def _add_products(self, response, lines_data):
Expand Down Expand Up @@ -739,6 +745,30 @@ def _add_enable_stripe_payment_processor(self, response):
self.request, ENABLE_STRIPE_PAYMENT_PROCESSOR
)

def _add_payment_intent_id(self, response, basket):
try:
payment_intent_id_attribute = BasketAttributeType.objects.get(name=PAYMENT_INTENT_ID_ATTRIBUTE)
payment_intent_attr = BasketAttribute.objects.get(
basket=basket,
attribute_type=payment_intent_id_attribute
)
response['payment_intent_id'] = payment_intent_attr.value_text.strip()
except (BasketAttribute.DoesNotExist, BasketAttributeType.DoesNotExist):
response['payment_intent_id'] = None

def _add_is_dynamic_payment_methods(self, response, basket):
try:
dynamic_payment_methods_enabled_attribute = BasketAttributeType.objects.get(
name=DYNAMIC_PAYMENT_METHODS_ENABLED)
payment_intent_attr = BasketAttribute.objects.get(
basket=basket,
attribute_type=dynamic_payment_methods_enabled_attribute
)
is_dynamic_payment_methods_enabled = payment_intent_attr.value_text.strip()
response['is_dynamic_payment_methods_enabled'] = is_dynamic_payment_methods_enabled == 'True'
except (BasketAttribute.DoesNotExist, BasketAttributeType.DoesNotExist):
response['is_dynamic_payment_methods_enabled'] = None

def _get_response_status(self, response):
return message_utils.get_response_status(response['messages'])

Expand Down
20 changes: 16 additions & 4 deletions ecommerce/extensions/checkout/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def add_payment_event(self, event): # pylint: disable = arguments-differ
self._payment_events = []
self._payment_events.append(event)

def handle_payment(self, response, basket): # pylint: disable=arguments-differ
def handle_payment(self, response, basket): # pylint: disable=arguments-differ, inconsistent-return-statements
"""
Handle any payment processing and record payment sources and events.

Expand All @@ -111,14 +111,26 @@ def handle_payment(self, response, basket): # pylint: disable=arguments-differ
# If payment didn't go through, the handle_processor_response function will raise an error. We want to
# send the event regardless of if the payment didn't go through.
try:
handled_processor_response = self.payment_processor.handle_processor_response(response, basket=basket)
processor_response = self.payment_processor.handle_processor_response(response, basket=basket)
except Exception as ex:
properties.update({'success': False, 'payment_error': type(ex).__name__, })
raise
else:
# If the processor_response has a status, it's a InProgressProcessorResponse,
# which means the payment is part of dynamic payment methods
if 'status' in processor_response._fields:
logger.info(
'Dynamic Payment Method in progress for edX order %s and basket %s, '
'returning Payment Intent %s with status %s to the payment MFE.',
processor_response.order_number,
processor_response.basket_id,
processor_response.transaction_id,
processor_response.status,
)
return processor_response
julianajlk marked this conversation as resolved.
Show resolved Hide resolved
# We only record successful payments in the database.
self.record_payment(basket, handled_processor_response)
properties.update({'total': handled_processor_response.total, 'success': True, })
self.record_payment(basket, processor_response)
properties.update({'total': processor_response.total, 'success': True, })
finally:
track_segment_event(basket.site, basket.owner, 'Payment Processor Response', properties)

Expand Down
4 changes: 4 additions & 0 deletions ecommerce/extensions/payment/processors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
HandledProcessorResponse = namedtuple('HandledProcessorResponse',
['transaction_id', 'total', 'currency', 'card_number', 'card_type'])

InProgressProcessorResponse = namedtuple('InProgressProcessorResponse',
['basket_id', 'order_number', 'transaction_id', 'confirmation_client_secret',
'status', 'payment_method', 'total'])

logger = logging.getLogger(__name__)


Expand Down
Loading
Loading