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

fix: Update existing Payment Intent on every capture-context call #4159

Merged
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 6 additions & 1 deletion ecommerce/extensions/payment/processors/stripe.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,12 @@ def get_capture_context(self, request):
# This includes canceled status, since if one is create with idempotency key for an existing
# payment with canceled status, it will not create a new Payment Intent.
stripe_response = self.cancel_and_create_new_payment_intent_for_basket(basket, payment_intent_id)

else:
# Update the Payment Intent with the latest item in the cart
stripe.PaymentIntent.modify(
payment_intent_id,
**self._build_payment_intent_parameters(basket),
)
# If a Payment Intent exists in a confirmable status, it will skip the below else statement,
# aka not create another intent with the idempotency key this time around.

Expand Down
48 changes: 46 additions & 2 deletions ecommerce/extensions/payment/tests/views/test_stripe.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from ecommerce.courses.tests.factories import CourseFactory
from ecommerce.entitlements.utils import create_or_update_course_entitlement
from ecommerce.extensions.basket.constants import PAYMENT_INTENT_ID_ATTRIBUTE
from ecommerce.extensions.basket.utils import basket_add_payment_intent_id_attribute
from ecommerce.extensions.basket.utils import basket_add_payment_intent_id_attribute, get_basket_courses_list
from ecommerce.extensions.checkout.utils import get_receipt_page_url
from ecommerce.extensions.order.constants import PaymentEventTypeName
from ecommerce.extensions.payment.constants import STRIPE_CARD_TYPE_MAP
Expand Down Expand Up @@ -244,7 +244,7 @@ def test_payment_flow(
basket=basket
)

def test_capture_context_basket_price_change(self):
def test_capture_context_basket_change(self):
"""
Verify that existing payment intent is retrieved,
and that we do not error with an IdempotencyError in this case: capture
Expand Down Expand Up @@ -289,6 +289,50 @@ def test_capture_context_basket_price_change(self):
mock_retrieve.assert_called_once()
assert mock_retrieve.call_args.kwargs['id'] == 'pi_3LsftNIadiFyUl1x2TWxaADZ'

def test_capture_context_basket_price_change(self):
"""
Verify that when capture-context is hit, if the basket has a pre-existing Payment Intent,
we keep the Payment Intent updated in case the contents of the basket has changed, especially the amount.
"""
# Create a basket with an existing Payment Intent
payment_intent_id = 'pi_3LsftNIadiFyUl1x2TWxaADZ'
basket = self.create_basket(product_class=SEAT_PRODUCT_CLASS_NAME)
basket_add_payment_intent_id_attribute(basket, payment_intent_id)

# Hit the capture-context endpoint where the basket already has a Payment Intent
# and should make a modify call to Stripe.
with mock.patch('stripe.PaymentIntent.create') as mock_create:
with mock.patch('stripe.PaymentIntent.retrieve') as mock_retrieve:
mock_retrieve.return_value = {
'id': payment_intent_id,
'client_secret': 'pi_3LsftNIadiFyUl1x2TWxaADZ_secret_VxRx7Y1skyp0jKtq7Gdu80Xnh',
'status': 'requires_payment_method'
}
with mock.patch('stripe.PaymentIntent.modify') as mock_modify:
mock_modify.return_value = {
'id': payment_intent_id,
'client_secret': 'pi_3LsftNIadiFyUl1x2TWxaADZ_secret_VxRx7Y1skyp0jKtq7Gdu80Xnh',
'status': 'requires_payment_method',
'amount': basket.total_incl_tax
}
courses = get_basket_courses_list(basket)
courses_metadata = str(courses)[:499] if courses else None
payment_intent_parameters = {
'amount': str((basket.total_incl_tax * 100).to_integral_value()),
'currency': basket.currency,
'description': basket.order_number,
'metadata': {
'order_number': basket.order_number,
'courses': courses_metadata,
},
}

self.client.get(self.capture_context_url)
mock_create.assert_not_called()
mock_retrieve.assert_called_once()
mock_modify.assert_called_once_with(payment_intent_id, **payment_intent_parameters)
assert mock_retrieve.call_args.kwargs['id'] == payment_intent_id

def test_capture_context_empty_basket(self):
basket = create_basket(owner=self.user, site=self.site)
basket.flush()
Expand Down
Loading