From 6d77e7a35aedd1a9ce8fa31748ff9fdc0e39f174 Mon Sep 17 00:00:00 2001 From: mubbsharanwar Date: Tue, 10 Dec 2024 14:18:49 +0500 Subject: [PATCH 01/27] fix: handle paypal payment recipt handle paypal payment recipt url SONIC-784 --- commerce_coordinator/apps/paypal/apps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commerce_coordinator/apps/paypal/apps.py b/commerce_coordinator/apps/paypal/apps.py index 1a28f8a2..bec32e40 100644 --- a/commerce_coordinator/apps/paypal/apps.py +++ b/commerce_coordinator/apps/paypal/apps.py @@ -4,5 +4,5 @@ from django.apps import AppConfig -class PayPalConfig(AppConfig): +class PaypalConfig(AppConfig): name = 'commerce_coordinator.apps.paypal' From f649df1371b41e9ce9e3050a54d0c0d3070885eb Mon Sep 17 00:00:00 2001 From: mubbsharanwar Date: Fri, 13 Dec 2024 19:10:28 +0500 Subject: [PATCH 02/27] fix: address review comments --- commerce_coordinator/apps/paypal/apps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commerce_coordinator/apps/paypal/apps.py b/commerce_coordinator/apps/paypal/apps.py index bec32e40..1a28f8a2 100644 --- a/commerce_coordinator/apps/paypal/apps.py +++ b/commerce_coordinator/apps/paypal/apps.py @@ -4,5 +4,5 @@ from django.apps import AppConfig -class PaypalConfig(AppConfig): +class PayPalConfig(AppConfig): name = 'commerce_coordinator.apps.paypal' From 811431cdfb79a47a6738a9cea0bd838de9ab7029 Mon Sep 17 00:00:00 2001 From: mubbsharanwar Date: Tue, 10 Dec 2024 14:18:49 +0500 Subject: [PATCH 03/27] fix: handle paypal payment recipt handle paypal payment recipt url SONIC-784 --- .../apps/commercetools/catalog_info/edx_utils.py | 5 +++++ commerce_coordinator/apps/stripe/pipeline.py | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/commerce_coordinator/apps/commercetools/catalog_info/edx_utils.py b/commerce_coordinator/apps/commercetools/catalog_info/edx_utils.py index ed7a5edc..3c76f414 100644 --- a/commerce_coordinator/apps/commercetools/catalog_info/edx_utils.py +++ b/commerce_coordinator/apps/commercetools/catalog_info/edx_utils.py @@ -75,6 +75,11 @@ def get_edx_successful_payment_info(order: CTOrder): return None, None +def get_edx_payment_service_provider(order: CTOrder) -> Union[str, None]: + for pr in order.payment_info.payments: + return pr.obj.payment_method_info.payment_interface + + def get_edx_order_workflow_state_key(order: CTOrder) -> Optional[str]: order_workflow_state = None if order.state and order.state.obj: # it should never be that we have one and not the other. # pragma no cover diff --git a/commerce_coordinator/apps/stripe/pipeline.py b/commerce_coordinator/apps/stripe/pipeline.py index 22583dd3..1c1a8725 100644 --- a/commerce_coordinator/apps/stripe/pipeline.py +++ b/commerce_coordinator/apps/stripe/pipeline.py @@ -234,7 +234,6 @@ def run_filter(self, psp=None, payment_intent_id=None, **params): 'payment_intent': payment_intent, 'redirect_url': receipt_url } - return None class RefundPaymentIntent(PipelineStep): From 33d937259523a666be98293e91a1aa36619af8fd Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan Date: Fri, 13 Dec 2024 16:24:52 +0500 Subject: [PATCH 04/27] feat: Implement Paypal webhook refunds --- .../apps/commercetools/clients.py | 489 ++++++++++++------ .../apps/commercetools/signals.py | 38 +- .../apps/commercetools/tasks.py | 30 +- .../apps/commercetools/utils.py | 8 +- .../migrations/0001_key_value_cache_model.py | 22 + commerce_coordinator/apps/paypal/models.py | 9 + commerce_coordinator/apps/paypal/signals.py | 7 + commerce_coordinator/apps/paypal/urls.py | 12 + commerce_coordinator/apps/paypal/views.py | 107 ++++ commerce_coordinator/settings/base.py | 3 + commerce_coordinator/urls.py | 2 + 11 files changed, 541 insertions(+), 186 deletions(-) create mode 100644 commerce_coordinator/apps/paypal/migrations/0001_key_value_cache_model.py create mode 100644 commerce_coordinator/apps/paypal/models.py create mode 100644 commerce_coordinator/apps/paypal/signals.py create mode 100644 commerce_coordinator/apps/paypal/urls.py create mode 100644 commerce_coordinator/apps/paypal/views.py diff --git a/commerce_coordinator/apps/commercetools/clients.py b/commerce_coordinator/apps/commercetools/clients.py index fad2cd67..ed7c38b2 100644 --- a/commerce_coordinator/apps/commercetools/clients.py +++ b/commerce_coordinator/apps/commercetools/clients.py @@ -13,9 +13,17 @@ from commercetools import Client as CTClient from commercetools import CommercetoolsError from commercetools.platform.models import Customer as CTCustomer -from commercetools.platform.models import CustomerChangeEmailAction, CustomerSetCustomFieldAction -from commercetools.platform.models import CustomerSetCustomTypeAction as CTCustomerSetCustomTypeAction -from commercetools.platform.models import CustomerSetFirstNameAction, CustomerSetLastNameAction +from commercetools.platform.models import ( + CustomerChangeEmailAction, + CustomerSetCustomFieldAction, +) +from commercetools.platform.models import ( + CustomerSetCustomTypeAction as CTCustomerSetCustomTypeAction, +) +from commercetools.platform.models import ( + CustomerSetFirstNameAction, + CustomerSetLastNameAction, +) from commercetools.platform.models import FieldContainer as CTFieldContainer from commercetools.platform.models import Money as CTMoney from commercetools.platform.models import Order as CTOrder @@ -23,10 +31,13 @@ OrderAddReturnInfoAction, OrderSetReturnItemCustomTypeAction, OrderSetReturnPaymentStateAction, - OrderTransitionLineItemStateAction + OrderTransitionLineItemStateAction, ) from commercetools.platform.models import Payment as CTPayment -from commercetools.platform.models import PaymentAddTransactionAction, PaymentSetTransactionCustomTypeAction +from commercetools.platform.models import ( + PaymentAddTransactionAction, + PaymentSetTransactionCustomTypeAction, +) from commercetools.platform.models import ProductVariant as CTProductVariant from commercetools.platform.models import ( ReturnItemDraft, @@ -34,24 +45,35 @@ ReturnShipmentState, StateResourceIdentifier, TransactionDraft, - TransactionType + TransactionType, ) from commercetools.platform.models import Type as CTType from commercetools.platform.models import TypeDraft as CTTypeDraft -from commercetools.platform.models import TypeResourceIdentifier as CTTypeResourceIdentifier +from commercetools.platform.models import ( + TypeResourceIdentifier as CTTypeResourceIdentifier, +) from commercetools.platform.models.state import State as CTLineItemState from django.conf import settings from openedx_filters.exceptions import OpenEdxFilterException -from commerce_coordinator.apps.commercetools.catalog_info.constants import DEFAULT_ORDER_EXPANSION, EdXFieldNames -from commerce_coordinator.apps.commercetools.catalog_info.foundational_types import TwoUCustomTypes +from commerce_coordinator.apps.commercetools.catalog_info.constants import ( + DEFAULT_ORDER_EXPANSION, + EDX_PAYPAL_PAYMENT_INTERFACE_NAME, + EdXFieldNames, +) +from commerce_coordinator.apps.commercetools.catalog_info.foundational_types import ( + TwoUCustomTypes, +) from commerce_coordinator.apps.commercetools.utils import ( find_refund_transaction, handle_commercetools_error, - translate_stripe_refund_status_to_transaction_status + translate_refund_status_to_transaction_status, ) from commerce_coordinator.apps.core.constants import ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT +from typing import TypedDict, Union +from commerce_coordinator.apps.commercetools.catalog_info.constants import EDX_STRIPE_PAYMENT_INTERFACE_NAME + logger = logging.getLogger(__name__) T = TypeVar("T") @@ -60,7 +82,8 @@ class PaginatedResult(Generic[T]): - """ Planned paginated response wrapper """ + """Planned paginated response wrapper""" + results: List[T] total: int offset: int @@ -84,8 +107,17 @@ def rebuild(self, results: List[T]): return PaginatedResult(results, total=self.total, offset=self.offset) +class Refund(TypedDict): + id: str + amount: Union[str, int] + currency: str + created: Union[str, int] + status: str + + class CommercetoolsAPIClient: - """ Commercetools API Client """ + """Commercetools API Client""" + base_client = None def __init__(self): @@ -101,10 +133,10 @@ def __init__(self): self.base_client = CTClient( client_id=config["clientId"], client_secret=config["clientSecret"], - scope=config["scopes"].split(' '), + scope=config["scopes"].split(" "), url=config["apiUrl"], token_url=config["authUrl"], - project_key=config["projectKey"] + project_key=config["projectKey"], ) def ensure_custom_type_exists(self, type_def: CTTypeDraft) -> Optional[CTType]: @@ -124,7 +156,9 @@ def ensure_custom_type_exists(self, type_def: CTTypeDraft) -> Optional[CTType]: except CommercetoolsError as _: # pragma: no cover # commercetools.exceptions.CommercetoolsError: The Resource with key 'edx-user_information' was not found. pass - except requests.exceptions.HTTPError as _: # The test framework doesn't wrap to CommercetoolsError + except ( + requests.exceptions.HTTPError + ) as _: # The test framework doesn't wrap to CommercetoolsError pass if not type_exists: @@ -132,7 +166,9 @@ def ensure_custom_type_exists(self, type_def: CTTypeDraft) -> Optional[CTType]: return type_object - def tag_customer_with_lms_user_info(self, customer: CTCustomer, lms_user_id: int, lms_user_name: str) -> CTCustomer: + def tag_customer_with_lms_user_info( + self, customer: CTCustomer, lms_user_id: int, lms_user_name: str + ) -> CTCustomer: """ Updates a CoCo Customer Object with what we are currently using for LMS Identifiers Args: @@ -146,28 +182,38 @@ def tag_customer_with_lms_user_info(self, customer: CTCustomer, lms_user_id: int # All updates to CT Core require the version of the object you are working on as protection from out of band # updates; this does mean we have to fetch every (primary) object we want to chain. - type_object = self.ensure_custom_type_exists(TwoUCustomTypes.CUSTOMER_TYPE_DRAFT) + type_object = self.ensure_custom_type_exists( + TwoUCustomTypes.CUSTOMER_TYPE_DRAFT + ) # A customer can only have one custom type associated to it, and thus only one set of custom fields. THUS... # They can't be required, and shouldn't entirely be relied upon; Once a proper Type is changed, the old values # are LOST. if customer.custom and not customer.custom.type.id == type_object.id: - raise ValueError("User already has a custom type, and its not the one were expecting, Refusing to update. " - "(Updating will eradicate the values from the other type, as an object may only have one " - "Custom Type)") + raise ValueError( + "User already has a custom type, and its not the one were expecting, Refusing to update. " + "(Updating will eradicate the values from the other type, as an object may only have one " + "Custom Type)" + ) - ret = self.base_client.customers.update_by_id(customer.id, customer.version, actions=[ - CTCustomerSetCustomTypeAction( - type=CTTypeResourceIdentifier( - key=TwoUCustomTypes.CUSTOMER_TYPE_DRAFT.key, + ret = self.base_client.customers.update_by_id( + customer.id, + customer.version, + actions=[ + CTCustomerSetCustomTypeAction( + type=CTTypeResourceIdentifier( + key=TwoUCustomTypes.CUSTOMER_TYPE_DRAFT.key, + ), + fields=CTFieldContainer( + { + EdXFieldNames.LMS_USER_ID: f"{lms_user_id}", + EdXFieldNames.LMS_USER_NAME: lms_user_name, + } + ), ), - fields=CTFieldContainer({ - EdXFieldNames.LMS_USER_ID: f"{lms_user_id}", - EdXFieldNames.LMS_USER_NAME: lms_user_name - }) - ), - ]) + ], + ) return ret @@ -183,15 +229,17 @@ def get_customer_by_lms_user_id(self, lms_user_id: int) -> Optional[CTCustomer]: is returned. """ - logger.info(f"[CommercetoolsAPIClient] - Attempting to get customer with LMS user id: {lms_user_id}") + logger.info( + f"[CommercetoolsAPIClient] - Attempting to get customer with LMS user id: {lms_user_id}" + ) edx_lms_user_id_key = EdXFieldNames.LMS_USER_ID start_time = datetime.datetime.now() results = self.base_client.customers.query( - where=f'custom(fields({edx_lms_user_id_key}=:id))', + where=f"custom(fields({edx_lms_user_id_key}=:id))", limit=2, - predicate_var={'id': f"{lms_user_id}"} + predicate_var={"id": f"{lms_user_id}"}, ) duration = (datetime.datetime.now() - start_time).total_seconds() logger.info(f"[Performance Check] - customerId query took {duration} seconds") @@ -199,20 +247,29 @@ def get_customer_by_lms_user_id(self, lms_user_id: int) -> Optional[CTCustomer]: if results.count > 1: # We are unable due to CT Limitations to enforce unique LMS ID values on Customers on the catalog side, so # let's do a backhanded check by trying to pull 2 users and erroring if we find a discrepancy. - logger.info(f"[CommercetoolsAPIClient] - More than one customer found with LMS " - f"user id: {lms_user_id}, raising error") - raise ValueError("More than one user was returned from the catalog with this edX LMS User ID, these must " - "be unique.") + logger.info( + f"[CommercetoolsAPIClient] - More than one customer found with LMS " + f"user id: {lms_user_id}, raising error" + ) + raise ValueError( + "More than one user was returned from the catalog with this edX LMS User ID, these must " + "be unique." + ) if results.count == 0: - logger.info(f"[CommercetoolsAPIClient] - No customer found with LMS user id: {lms_user_id}") + logger.info( + f"[CommercetoolsAPIClient] - No customer found with LMS user id: {lms_user_id}" + ) return None else: - logger.info(f"[CommercetoolsAPIClient] - Customer found with LMS user id: {lms_user_id}") + logger.info( + f"[CommercetoolsAPIClient] - Customer found with LMS user id: {lms_user_id}" + ) return results.results[0] - def get_order_by_id(self, order_id: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION) \ - -> CTOrder: + def get_order_by_id( + self, order_id: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION + ) -> CTOrder: """ Fetch an order by the Order ID (UUID) @@ -222,11 +279,14 @@ def get_order_by_id(self, order_id: str, expand: ExpandList = DEFAULT_ORDER_EXPA Returns (CTOrder): Order with Expanded Properties """ - logger.info(f"[CommercetoolsAPIClient] - Attempting to find order with id: {order_id}") + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find order with id: {order_id}" + ) return self.base_client.orders.get_by_id(order_id, expand=list(expand)) - def get_order_by_number(self, order_number: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION) \ - -> CTOrder: + def get_order_by_number( + self, order_number: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION + ) -> CTOrder: """ Fetch an order by the Order Number (Human readable order number) @@ -236,14 +296,21 @@ def get_order_by_number(self, order_number: str, expand: ExpandList = DEFAULT_OR Returns (CTOrder): Order with Expanded Properties """ - logger.info(f"[CommercetoolsAPIClient] - Attempting to find order with number {order_number}") - return self.base_client.orders.get_by_order_number(order_number, expand=list(expand)) - - def get_orders(self, customer_id: str, offset=0, - limit=ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT, - expand: ExpandList = DEFAULT_ORDER_EXPANSION, - order_state="Complete") -> PaginatedResult[CTOrder]: + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find order with number {order_number}" + ) + return self.base_client.orders.get_by_order_number( + order_number, expand=list(expand) + ) + def get_orders( + self, + customer_id: str, + offset=0, + limit=ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT, + expand: ExpandList = DEFAULT_ORDER_EXPANSION, + order_state="Complete", + ) -> PaginatedResult[CTOrder]: """ Call commercetools API overview endpoint for data about historical orders. @@ -259,27 +326,35 @@ def get_orders(self, customer_id: str, offset=0, See sample response in tests.py """ - logger.info(f"[CommercetoolsAPIClient] - Attempting to find all completed orders for " - f"customer with ID {customer_id}") - order_where_clause = f"orderState=\"{order_state}\"" + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find all completed orders for " + f"customer with ID {customer_id}" + ) + order_where_clause = f'orderState="{order_state}"' start_time = datetime.datetime.now() values = self.base_client.orders.query( where=["customerId=:cid", order_where_clause], - predicate_var={'cid': customer_id}, + predicate_var={"cid": customer_id}, sort=["completedAt desc", "lastModifiedAt desc"], limit=limit, offset=offset, - expand=list(expand) + expand=list(expand), ) duration = (datetime.datetime.now() - start_time).total_seconds() logger.info(f"[Performance Check] get_orders call took {duration} seconds") return PaginatedResult(values.results, values.total, values.offset) - def get_orders_for_customer(self, edx_lms_user_id: int, offset=0, limit=ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT, - customer_id=None, email=None, - username=None) -> (PaginatedResult[CTOrder], CTCustomer): + def get_orders_for_customer( + self, + edx_lms_user_id: int, + offset=0, + limit=ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT, + customer_id=None, + email=None, + username=None, + ) -> (PaginatedResult[CTOrder], CTCustomer): """ Args: @@ -291,21 +366,21 @@ def get_orders_for_customer(self, edx_lms_user_id: int, offset=0, limit=ORDER_HI customer = self.get_customer_by_lms_user_id(edx_lms_user_id) if customer is None: # pragma: no cover - raise ValueError(f'Unable to locate customer with ID #{edx_lms_user_id}') + raise ValueError( + f"Unable to locate customer with ID #{edx_lms_user_id}" + ) customer_id = customer.id else: if email is None or username is None: # pragma: no cover - raise ValueError("If customer_id is provided, both email and username must be provided") + raise ValueError( + "If customer_id is provided, both email and username must be provided" + ) customer = SimpleNamespace( id=customer_id, email=email, - custom=SimpleNamespace( - fields={ - EdXFieldNames.LMS_USER_NAME: username - } - ) + custom=SimpleNamespace(fields={EdXFieldNames.LMS_USER_NAME: username}), ) orders = self.get_orders(customer_id, offset, limit) @@ -313,58 +388,80 @@ def get_orders_for_customer(self, edx_lms_user_id: int, offset=0, limit=ORDER_HI return orders, customer def get_customer_by_id(self, customer_id: str) -> CTCustomer: - logger.info(f"[CommercetoolsAPIClient] - Attempting to find customer with ID {customer_id}") + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find customer with ID {customer_id}" + ) return self.base_client.customers.get_by_id(customer_id) def get_state_by_id(self, state_id: str) -> CTLineItemState: - logger.info(f"[CommercetoolsAPIClient] - Attempting to find state with id {state_id}") + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find state with id {state_id}" + ) return self.base_client.states.get_by_id(state_id) def get_state_by_key(self, state_key: str) -> CTLineItemState: - logger.info(f"[CommercetoolsAPIClient] - Attempting to find state with key {state_key}") + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find state with key {state_key}" + ) return self.base_client.states.get_by_key(state_key) def get_payment_by_key(self, payment_key: str) -> CTPayment: - logger.info(f"[CommercetoolsAPIClient] - Attempting to find payment with key {payment_key}") + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find payment with key {payment_key}" + ) return self.base_client.payments.get_by_key(payment_key) - def get_product_variant_by_course_run(self, cr_id: str) -> Optional[CTProductVariant]: + def get_payment_by_transaction_interaction_id( + self, interaction_id: str + ) -> CTPayment: + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find payment with interaction ID {interaction_id}" + ) + return self.base_client.payments.query( + where=f'transactions(interactionId="{interaction_id}")' + ).results[0] + + def get_product_variant_by_course_run( + self, cr_id: str + ) -> Optional[CTProductVariant]: """ Args: cr_id: variant course run key """ start_time = datetime.datetime.now() - results = self.base_client.product_projections.search(False, filter=f"variants.sku:\"{cr_id}\"").results + results = self.base_client.product_projections.search( + False, filter=f'variants.sku:"{cr_id}"' + ).results duration = (datetime.datetime.now() - start_time).total_seconds() logger.info( - f"[Performance Check] get_product_variant_by_course_run took {duration} seconds") + f"[Performance Check] get_product_variant_by_course_run took {duration} seconds" + ) if len(results) < 1: # pragma no cover return None # Make 2D List of all variants from all results, and then flatten - all_variants = [listitem for sublist in - list( - map( - lambda selection: [selection.master_variant, *selection.variants], - results - ) - ) - for listitem in sublist] - - matching_variant_list = list( - filter( - lambda v: v.sku == cr_id, - all_variants + all_variants = [ + listitem + for sublist in list( + map( + lambda selection: [selection.master_variant, *selection.variants], + results, + ) ) - ) + for listitem in sublist + ] + + matching_variant_list = list(filter(lambda v: v.sku == cr_id, all_variants)) if len(matching_variant_list) < 1: # pragma no cover return None return matching_variant_list[0] - def create_return_for_order(self, order_id: str, order_version: int, order_line_item_id: str) -> CTOrder: + def create_return_for_order( + self, order_id: str, order_version: int, order_line_item_id: str + ) -> CTOrder: """ Creates refund/return for Commercetools order Args: @@ -376,8 +473,10 @@ def create_return_for_order(self, order_id: str, order_version: int, order_line_ """ try: - return_item_draft_comment = f'Creating return item for order {order_id} with ' \ - f'order line item ID {order_line_item_id}' + return_item_draft_comment = ( + f"Creating return item for order {order_id} with " + f"order line item ID {order_line_item_id}" + ) logger.info(f"[CommercetoolsAPIClient] - {return_item_draft_comment}") @@ -388,25 +487,26 @@ def create_return_for_order(self, order_id: str, order_version: int, order_line_ shipment_state=ReturnShipmentState.RETURNED, ) - add_return_info_action = OrderAddReturnInfoAction( - items=[return_item_draft] - ) + add_return_info_action = OrderAddReturnInfoAction(items=[return_item_draft]) returned_order = self.base_client.orders.update_by_id( - id=order_id, - version=order_version, - actions=[add_return_info_action] + id=order_id, version=order_version, actions=[add_return_info_action] ) return returned_order except CommercetoolsError as err: - handle_commercetools_error(err, f"Unable to create return for order {order_id}") + handle_commercetools_error( + err, f"Unable to create return for order {order_id}" + ) raise err - def update_return_payment_state_after_successful_refund(self, order_id: str, - order_version: int, - return_line_item_return_id: str, - payment_intent_id: str, - amount_in_cents: decimal) -> Union[CTOrder, None]: + def update_return_payment_state_after_successful_refund( + self, + order_id: str, + order_version: int, + return_line_item_return_id: str, + payment_intent_id: str, + amount_in_cents: decimal, + ) -> Union[CTOrder, None]: """ Update paymentState on the LineItemReturnItem attached to the order. Updated by the Order ID (UUID) @@ -420,80 +520,110 @@ def update_return_payment_state_after_successful_refund(self, order_id: str, Raises Exception: Error if update was unsuccessful. """ try: - logger.info(f"[CommercetoolsAPIClient] - Updating payment state for return " - f"with id {return_line_item_return_id} to '{ReturnPaymentState.REFUNDED}'.") + logger.info( + f"[CommercetoolsAPIClient] - Updating payment state for return " + f"with id {return_line_item_return_id} to '{ReturnPaymentState.REFUNDED}'." + ) return_payment_state_action = OrderSetReturnPaymentStateAction( return_item_id=return_line_item_return_id, - payment_state=ReturnPaymentState.REFUNDED + payment_state=ReturnPaymentState.REFUNDED, ) if not payment_intent_id: - payment_intent_id = '' - logger.info(f'Creating return for order - payment_intent_id: {payment_intent_id}') + payment_intent_id = "" + logger.info( + f"Creating return for order - payment_intent_id: {payment_intent_id}" + ) payment = self.get_payment_by_key(payment_intent_id) logger.info(f"Payment found: {payment}") transaction_id = find_refund_transaction(payment, amount_in_cents) update_transaction_id_action = OrderSetReturnItemCustomTypeAction( return_item_id=return_line_item_return_id, type=CTTypeResourceIdentifier( - key='returnItemCustomType', + key="returnItemCustomType", ), - fields=CTFieldContainer({ - 'transactionId': transaction_id - }) + fields=CTFieldContainer({"transactionId": transaction_id}), + ) + return_transaction_return_item_action = ( + PaymentSetTransactionCustomTypeAction( + transaction_id=transaction_id, + type=CTTypeResourceIdentifier(key="transactionCustomType"), + fields=CTFieldContainer( + {"returnItemId": return_line_item_return_id} + ), + ) ) - return_transaction_return_item_action = PaymentSetTransactionCustomTypeAction( - transaction_id=transaction_id, - type=CTTypeResourceIdentifier(key='transactionCustomType'), - fields=CTFieldContainer({ - 'returnItemId': return_line_item_return_id - }) + logger.info( + f"Update return payment state after successful refund - payment_intent_id: {payment_intent_id}" ) - logger.info(f"Update return payment state after successful refund - payment_intent_id: {payment_intent_id}") updated_order = self.base_client.orders.update_by_id( id=order_id, version=order_version, - actions=[return_payment_state_action, update_transaction_id_action] + actions=[return_payment_state_action, update_transaction_id_action], ) self.base_client.payments.update_by_id( id=payment.id, version=payment.version, - actions=[return_transaction_return_item_action] + actions=[return_transaction_return_item_action], ) logger.info("Updated transaction with return item id") return updated_order except CommercetoolsError as err: - handle_commercetools_error(err, f"Unable to update ReturnPaymentState of order {order_id}") + handle_commercetools_error( + err, f"Unable to update ReturnPaymentState of order {order_id}" + ) raise OpenEdxFilterException(str(err)) from err + + def _preprocess_refund_object(self, refund: Refund, psp:str) -> Refund: + """ + Pre process refund object based on PSP + """ + if psp == EDX_PAYPAL_PAYMENT_INTERFACE_NAME: + refund["amount"] = float(refund["amount"]) * 100 + refund["created"] = datetime.datetime.fromisoformat(refund["created"]) + else: + refund["created"] = datetime.datetime.utcfromtimestamp(refund["created"]) + + refund["status"] = translate_refund_status_to_transaction_status(refund["status"]) + refund["currency"] = refund["currency"].upper() + return refund def create_return_payment_transaction( - self, payment_id: str, - payment_version: int, - stripe_refund: stripe.Refund) -> CTPayment: + self, + payment_id: str, + payment_version: int, + refund: Refund, + psp=EDX_STRIPE_PAYMENT_INTERFACE_NAME + ) -> CTPayment: """ Create Commercetools payment transaction for refund Args: payment_id (str): Payment ID (UUID) payment_version (int): Current version of payment - stripe_refund (stripe.Refund): Stripe's refund object + refund (stripe.Refund): Refund object Returns (CTPayment): Updated payment object or Raises Exception: Error if creation was unsuccessful. """ try: - logger.info(f"[CommercetoolsAPIClient] - Creating refund transaction for payment with ID {payment_id} " - f"following successful Stripe refund {stripe_refund.id}") + logger.info( + f"[CommercetoolsAPIClient] - Creating refund transaction for payment with ID {payment_id} " + f"following successful refund {refund['id']}" + ) + refund = self._preprocess_refund_object(refund, psp) amount_as_money = CTMoney( - cent_amount=stripe_refund.amount, - currency_code=stripe_refund.currency.upper() + cent_amount=float( + refund["amount"] + ), + currency_code=refund["currency"], ) transaction_draft = TransactionDraft( type=TransactionType.REFUND, amount=amount_as_money, - timestamp=datetime.datetime.utcfromtimestamp(stripe_refund.created), - state=translate_stripe_refund_status_to_transaction_status(stripe_refund.status), - interaction_id=stripe_refund.id + timestamp=refund["created"], + state=refund["status"], + interaction_id=refund["id"], ) add_transaction_action = PaymentAddTransactionAction( @@ -501,21 +631,27 @@ def create_return_payment_transaction( ) returned_payment = self.base_client.payments.update_by_id( - id=payment_id, - version=payment_version, - actions=[add_transaction_action] + id=payment_id, version=payment_version, actions=[add_transaction_action] ) return returned_payment except CommercetoolsError as err: - context = f"Unable to create refund payment transaction for "\ - f"payment {payment_id} and stripe refund {stripe_refund.id}" + context = ( + f"Unable to create refund payment transaction for " + f"payment {payment_id} and refund {refund['id']}" + ) handle_commercetools_error(err, context) raise err - def update_line_item_transition_state_on_fulfillment(self, order_id: str, order_version: int, - line_item_id: str, item_quantity: int, - from_state_id: str, new_state_key: str) -> CTOrder: + def update_line_item_transition_state_on_fulfillment( + self, + order_id: str, + order_version: int, + line_item_id: str, + item_quantity: int, + from_state_id: str, + new_state_key: str, + ) -> CTOrder: """ Update Commercetools order line item state Args: @@ -532,8 +668,10 @@ def update_line_item_transition_state_on_fulfillment(self, order_id: str, order_ from_state_key = self.get_state_by_id(from_state_id).key - logger.info(f"[CommercetoolsAPIClient] - Transitioning line item state for order with ID {order_id}" - f"from {from_state_key} to {new_state_key}") + logger.info( + f"[CommercetoolsAPIClient] - Transitioning line item state for order with ID {order_id}" + f"from {from_state_key} to {new_state_key}" + ) try: if new_state_key != from_state_key: @@ -541,28 +679,40 @@ def update_line_item_transition_state_on_fulfillment(self, order_id: str, order_ line_item_id=line_item_id, quantity=item_quantity, from_state=StateResourceIdentifier(key=from_state_key), - to_state=StateResourceIdentifier(key=new_state_key) + to_state=StateResourceIdentifier(key=new_state_key), ) - updated_fulfillment_line_item_order = self.base_client.orders.update_by_id( - id=order_id, - version=order_version, - actions=[transition_line_item_state_action], + updated_fulfillment_line_item_order = ( + self.base_client.orders.update_by_id( + id=order_id, + version=order_version, + actions=[transition_line_item_state_action], + ) ) return updated_fulfillment_line_item_order else: - logger.info(f"The line item {line_item_id} already has the correct state {new_state_key}. " - "Not attempting to transition LineItemState") + logger.info( + f"The line item {line_item_id} already has the correct state {new_state_key}. " + "Not attempting to transition LineItemState" + ) return self.get_order_by_id(order_id) except CommercetoolsError as err: # Logs & ignores version conflict errors due to duplicate Commercetools messages - handle_commercetools_error(err, f"Unable to update LineItemState of order {order_id}", True) + handle_commercetools_error( + err, f"Unable to update LineItemState of order {order_id}", True + ) return None - def retire_customer_anonymize_fields(self, customer_id: str, customer_version: int, - retired_first_name: str, retired_last_name: str, - retired_email: str, retired_lms_username: str) -> CTCustomer: + def retire_customer_anonymize_fields( + self, + customer_id: str, + customer_version: int, + retired_first_name: str, + retired_last_name: str, + retired_email: str, + retired_lms_username: str, + ) -> CTCustomer: """ Update Commercetools customer with anonymized fields Args: @@ -585,31 +735,30 @@ def retire_customer_anonymize_fields(self, customer_id: str, customer_version: i last_name=retired_last_name ) - update_retired_email_action = CustomerChangeEmailAction( - email=retired_email - ) + update_retired_email_action = CustomerChangeEmailAction(email=retired_email) update_retired_lms_username_action = CustomerSetCustomFieldAction( - name="edx-lms_user_name", - value=retired_lms_username + name="edx-lms_user_name", value=retired_lms_username ) - actions.extend([ - update_retired_first_name_action, - update_retired_last_name_action, - update_retired_email_action, - update_retired_lms_username_action - ]) + actions.extend( + [ + update_retired_first_name_action, + update_retired_last_name_action, + update_retired_email_action, + update_retired_lms_username_action, + ] + ) try: retired_customer = self.base_client.customers.update_by_id( - id=customer_id, - version=customer_version, - actions=actions + id=customer_id, version=customer_version, actions=actions ) return retired_customer except CommercetoolsError as err: - logger.error(f"[CommercetoolsError] Unable to anonymize customer fields for customer " - f"with ID: {customer_id}, after LMS retirement with " - f"error correlation id {err.correlation_id} and error/s: {err.errors}") + logger.error( + f"[CommercetoolsError] Unable to anonymize customer fields for customer " + f"with ID: {customer_id}, after LMS retirement with " + f"error correlation id {err.correlation_id} and error/s: {err.errors}" + ) raise err diff --git a/commerce_coordinator/apps/commercetools/signals.py b/commerce_coordinator/apps/commercetools/signals.py index 73beedd8..1f2ddf8f 100644 --- a/commerce_coordinator/apps/commercetools/signals.py +++ b/commerce_coordinator/apps/commercetools/signals.py @@ -1,14 +1,19 @@ """ Commercetools signals and receivers. """ + import logging from commerce_coordinator.apps.commercetools.catalog_info.constants import TwoUKeys from commerce_coordinator.apps.commercetools.tasks import ( refund_from_stripe_task, - update_line_item_state_on_fulfillment_completion + refund_from_paypal_task, + update_line_item_state_on_fulfillment_completion, +) +from commerce_coordinator.apps.core.signal_helpers import ( + CoordinatorSignal, + log_receiver, ) -from commerce_coordinator.apps.core.signal_helpers import CoordinatorSignal, log_receiver logger = logging.getLogger(__name__) @@ -21,7 +26,7 @@ def fulfill_order_completed_send_line_item_state(**kwargs): Update the line item state of the order placed in Commercetools based on LMS enrollment """ - is_fulfilled = kwargs['is_fulfilled'] + is_fulfilled = kwargs["is_fulfilled"] if is_fulfilled: to_state_key = TwoUKeys.SUCCESS_FULFILMENT_STATE @@ -29,12 +34,12 @@ def fulfill_order_completed_send_line_item_state(**kwargs): to_state_key = TwoUKeys.FAILURE_FULFILMENT_STATE result = update_line_item_state_on_fulfillment_completion( - order_id=kwargs['order_id'], - order_version=kwargs['order_version'], - line_item_id=kwargs['line_item_id'], - item_quantity=kwargs['item_quantity'], - from_state_id=kwargs['line_item_state_id'], - to_state_key=to_state_key + order_id=kwargs["order_id"], + order_version=kwargs["order_version"], + line_item_id=kwargs["line_item_id"], + item_quantity=kwargs["item_quantity"], + from_state_id=kwargs["line_item_state_id"], + to_state_key=to_state_key, ) return result @@ -46,7 +51,18 @@ def refund_from_stripe(**kwargs): Create a refund transaction in Commercetools based on a refund created from the Stripe dashboard """ async_result = refund_from_stripe_task.delay( - payment_intent_id=kwargs['payment_intent_id'], - stripe_refund=kwargs['stripe_refund'], + payment_intent_id=kwargs["payment_intent_id"], + stripe_refund=kwargs["stripe_refund"], + ) + return async_result.id + + +@log_receiver(logger) +def refund_from_paypal(**kwargs): + """ + Create a refund transaction in Commercetools based on a refund created from the PayPal dashboard + """ + async_result = refund_from_paypal_task.delay( + paypal_order_id=kwargs["paypal_order_id"], refund=kwargs["refund"] ) return async_result.id diff --git a/commerce_coordinator/apps/commercetools/tasks.py b/commerce_coordinator/apps/commercetools/tasks.py index dab53759..5784023b 100644 --- a/commerce_coordinator/apps/commercetools/tasks.py +++ b/commerce_coordinator/apps/commercetools/tasks.py @@ -11,6 +11,7 @@ from .clients import CommercetoolsAPIClient from .utils import has_full_refund_transaction +from commerce_coordinator.apps.commercetools.catalog_info.constants import EDX_PAYPAL_PAYMENT_INTERFACE_NAME logger = logging.getLogger(__name__) @@ -55,7 +56,6 @@ def refund_from_stripe_task( refund payment transaction record via Commercetools API. """ # Celery serializes stripe_refund to a dict, so we need to explictly convert it back to a Refund object - stripe_refund = stripe.Refund.construct_from(stripe_refund, stripe.api_key) client = CommercetoolsAPIClient() try: payment = client.get_payment_by_key(payment_intent_id) @@ -66,7 +66,7 @@ def refund_from_stripe_task( updated_payment = client.create_return_payment_transaction( payment_id=payment.id, payment_version=payment.version, - stripe_refund=stripe_refund + refund=stripe_refund ) return updated_payment except CommercetoolsError as err: @@ -74,3 +74,29 @@ def refund_from_stripe_task( f"on Stripe refund {stripe_refund.id} " f"with error {err.errors} and correlation id {err.correlation_id}") return None + + +@shared_task(autoretry_for=(CommercetoolsError,), retry_kwargs={'max_retries': 5, 'countdown': 3}) +def refund_from_paypal_task( + paypal_order_id, + refund +): + """ + Celery task for a refund registered in PayPal dashboard and need to create + refund payment transaction record via Commercetools API. + """ + client = CommercetoolsAPIClient() + try: + payment = client.get_payment_by_key(paypal_order_id) + updated_payment = client.create_return_payment_transaction( + payment_id=payment.id, + payment_version=payment.version, + refund=refund, + psp=EDX_PAYPAL_PAYMENT_INTERFACE_NAME, + ) + return updated_payment + except CommercetoolsError as err: + logger.error(f"Unable to create refund transaction for payment [ {paypal_order_id} ] " + f"on PayPal refund {refund.id} " + f"with error {err.errors} and correlation id {err.correlation_id}") + return None diff --git a/commerce_coordinator/apps/commercetools/utils.py b/commerce_coordinator/apps/commercetools/utils.py index f7e8b415..c6e5fc15 100644 --- a/commerce_coordinator/apps/commercetools/utils.py +++ b/commerce_coordinator/apps/commercetools/utils.py @@ -198,16 +198,18 @@ def find_refund_transaction(payment: Payment, amount: decimal): return {} -def translate_stripe_refund_status_to_transaction_status(stripe_refund_status: str): +def translate_refund_status_to_transaction_status(refund_status: str): """ - Utility to translate stripe's refund object's status attribute to a valid CT transaction state + Utility to translate refund object's status attribute to a valid CT transaction state """ translations = { 'succeeded': TransactionState.SUCCESS, + 'completed': TransactionState.SUCCESS, 'pending': TransactionState.PENDING, 'failed': TransactionState.FAILURE, + 'canceled': TransactionState.FAILURE, } - return translations.get(stripe_refund_status.lower(), stripe_refund_status) + return translations.get(refund_status.lower(), refund_status) def _create_retired_hash_withsalt(value_to_retire, salt): diff --git a/commerce_coordinator/apps/paypal/migrations/0001_key_value_cache_model.py b/commerce_coordinator/apps/paypal/migrations/0001_key_value_cache_model.py new file mode 100644 index 00000000..02c25dac --- /dev/null +++ b/commerce_coordinator/apps/paypal/migrations/0001_key_value_cache_model.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.13 on 2024-12-12 07:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='KeyValueCache', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(max_length=255, unique=True)), + ('value', models.TextField()), + ], + ), + ] diff --git a/commerce_coordinator/apps/paypal/models.py b/commerce_coordinator/apps/paypal/models.py new file mode 100644 index 00000000..d3a16e47 --- /dev/null +++ b/commerce_coordinator/apps/paypal/models.py @@ -0,0 +1,9 @@ +""" +Models for Paypal app +""" +from django.db import models + + +class KeyValueCache(models.Model): + key = models.CharField(max_length=255, unique=True) + value = models.TextField() diff --git a/commerce_coordinator/apps/paypal/signals.py b/commerce_coordinator/apps/paypal/signals.py new file mode 100644 index 00000000..6e74096c --- /dev/null +++ b/commerce_coordinator/apps/paypal/signals.py @@ -0,0 +1,7 @@ +""" +Paypal signals and receivers. +""" + +from commerce_coordinator.apps.core.signal_helpers import CoordinatorSignal + +payment_refunded_signal = CoordinatorSignal() diff --git a/commerce_coordinator/apps/paypal/urls.py b/commerce_coordinator/apps/paypal/urls.py new file mode 100644 index 00000000..9d6c37d1 --- /dev/null +++ b/commerce_coordinator/apps/paypal/urls.py @@ -0,0 +1,12 @@ +""" +Paypal app urls +""" + +from django.urls import path + +from commerce_coordinator.apps.paypal.views import PayPalWebhookView + +app_name = 'paypal' +urlpatterns = [ + path('webhook/', PayPalWebhookView.as_view(), name='paypal_webhook'), +] diff --git a/commerce_coordinator/apps/paypal/views.py b/commerce_coordinator/apps/paypal/views.py new file mode 100644 index 00000000..0d7dbf9a --- /dev/null +++ b/commerce_coordinator/apps/paypal/views.py @@ -0,0 +1,107 @@ +""" +Paypal app views +""" + +import logging +import base64 +import zlib + +import requests +from cryptography import x509 +from rest_framework.permissions import AllowAny +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding +from django.conf import settings +from rest_framework import status +from rest_framework.response import Response +from commerce_coordinator.apps.core.views import SingleInvocationAPIView +from commerce_coordinator.apps.commercetools.clients import CommercetoolsAPIClient + +from commerce_coordinator.apps.paypal.signals import payment_refunded_signal + +from .models import KeyValueCache + +logger = logging.getLogger(__name__) + + +class PayPalWebhookView(SingleInvocationAPIView): + http_method_names = ["post"] + authentication_classes = [] + permission_classes = [AllowAny] + + def get_certificate(self, url): + try: + cache = KeyValueCache.objects.get(key=url) + return cache.value + except KeyValueCache.DoesNotExist: + r = requests.get(url) + KeyValueCache.objects.create(key=url, value=r.text) + return r.text + + def post(self, request): + tag = type(self).__name__ + body = request.body + + transmission_id = request.headers.get("paypal-transmission-id") + if self._is_running(tag, transmission_id): # pragma no cover + self.meta_should_mark_not_running = False + return Response(status=status.HTTP_200_OK) + else: + self.mark_running(tag, transmission_id) + + timestamp = request.headers.get("paypal-transmission-time") + crc = zlib.crc32(body) + webhook_id = settings.PAYPAL_WEBHOOK_ID + message = f"{transmission_id}|{timestamp}|{webhook_id}|{crc}" + + signature = base64.b64decode(request.headers.get("paypal-transmission-sig")) + + certificate = self.get_certificate(request.headers.get("paypal-cert-url")) + cert = x509.load_pem_x509_certificate( + certificate.encode("utf-8"), default_backend() + ) + public_key = cert.public_key() + + try: + # TODO: In future we can move this logic over to redis to avoid hitting the database + public_key.verify( + signature, message.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256() + ) + except Exception: + return Response(status=status.HTTP_400_BAD_REQUEST) + + if request.data.get("event_type") == "PAYMENT.CAPTURE.REFUNDED": + twou_order_number = request.data.get("resource").get("invoice_id", None) + capture_url = request.data.get("resource").get("links", []) + capture_id = None + for link in capture_url: + if link.get("rel") == "up" and "captures" in link.get("href"): + capture_id = link.get("href").split("/")[-1] + break + ct_api_client = CommercetoolsAPIClient() + payment = ct_api_client.get_payment_by_transaction_interaction_id( + capture_id + ) + paypal_order_id = payment.key + + logger.info( + "[Paypal webhooks] refund event %s with order_number [%s], paypal_order_id [%s] received", + request.data.get("event_type"), + twou_order_number, + paypal_order_id, + ) + + refund = { + "id": request.data.get("resource").get("id"), + "created": request.data.get("resource").get("create_time"), + "status": request.data.get("resource").get("status"), + "amount": request.data.get("resource").get("amount").get("value"), + "currency": request.data.get("resource").get("amount").get("currency_code"), + } + + payment_refunded_signal.send_robust( + sender=self.__class__, paypal_order_id=paypal_order_id, refund=refund + ) + + return Response(status=status.HTTP_200_OK) diff --git a/commerce_coordinator/settings/base.py b/commerce_coordinator/settings/base.py index 33f16926..4a474243 100644 --- a/commerce_coordinator/settings/base.py +++ b/commerce_coordinator/settings/base.py @@ -331,6 +331,9 @@ def root(*path_fragments): 'commerce_coordinator.apps.stripe.signals.payment_refunded_signal': [ 'commerce_coordinator.apps.commercetools.signals.refund_from_stripe', ], + "commerce_coordinator.apps.paypal.signals.payment_refunded_signal": [ + "commerce_coordinator.apps.commercetools.signals.refund_from_paypal", + ], } # Default timeouts for requests diff --git a/commerce_coordinator/urls.py b/commerce_coordinator/urls.py index 56013a56..6abafd94 100644 --- a/commerce_coordinator/urls.py +++ b/commerce_coordinator/urls.py @@ -40,6 +40,7 @@ from commerce_coordinator.apps.lms import urls as lms_urls from commerce_coordinator.apps.stripe import urls as stripe_urls from commerce_coordinator.apps.titan import urls as titan_urls +from commerce_coordinator.apps.paypal import urls as paypal_urls from commerce_coordinator.settings.base import FAVICON_URL admin.autodiscover() @@ -63,6 +64,7 @@ re_path(r'^orders/unified/', include(unified_orders_urls), name='frontend_app_ecommerce'), re_path(r'^frontend-app-payment/', include(frontend_app_payment_urls)), re_path(r'^stripe/', include(stripe_urls)), + re_path(r'^paypal/', include(paypal_urls)), # Browser automated hits, this will limit 404s in logging re_path(r'^$', lambda r: JsonResponse(data=[ From 4a5f9b0da76c5ab16c75ec3b15a60ba9bc98655e Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan Date: Fri, 13 Dec 2024 16:40:57 +0500 Subject: [PATCH 05/27] fix: fixes --- .../apps/commercetools/clients.py | 489 ++++++------------ .../apps/commercetools/tests/test_utils.py | 10 +- 2 files changed, 175 insertions(+), 324 deletions(-) diff --git a/commerce_coordinator/apps/commercetools/clients.py b/commerce_coordinator/apps/commercetools/clients.py index ed7c38b2..1d2ec44c 100644 --- a/commerce_coordinator/apps/commercetools/clients.py +++ b/commerce_coordinator/apps/commercetools/clients.py @@ -13,17 +13,9 @@ from commercetools import Client as CTClient from commercetools import CommercetoolsError from commercetools.platform.models import Customer as CTCustomer -from commercetools.platform.models import ( - CustomerChangeEmailAction, - CustomerSetCustomFieldAction, -) -from commercetools.platform.models import ( - CustomerSetCustomTypeAction as CTCustomerSetCustomTypeAction, -) -from commercetools.platform.models import ( - CustomerSetFirstNameAction, - CustomerSetLastNameAction, -) +from commercetools.platform.models import CustomerChangeEmailAction, CustomerSetCustomFieldAction +from commercetools.platform.models import CustomerSetCustomTypeAction as CTCustomerSetCustomTypeAction +from commercetools.platform.models import CustomerSetFirstNameAction, CustomerSetLastNameAction from commercetools.platform.models import FieldContainer as CTFieldContainer from commercetools.platform.models import Money as CTMoney from commercetools.platform.models import Order as CTOrder @@ -31,13 +23,10 @@ OrderAddReturnInfoAction, OrderSetReturnItemCustomTypeAction, OrderSetReturnPaymentStateAction, - OrderTransitionLineItemStateAction, + OrderTransitionLineItemStateAction ) from commercetools.platform.models import Payment as CTPayment -from commercetools.platform.models import ( - PaymentAddTransactionAction, - PaymentSetTransactionCustomTypeAction, -) +from commercetools.platform.models import PaymentAddTransactionAction, PaymentSetTransactionCustomTypeAction from commercetools.platform.models import ProductVariant as CTProductVariant from commercetools.platform.models import ( ReturnItemDraft, @@ -45,35 +34,24 @@ ReturnShipmentState, StateResourceIdentifier, TransactionDraft, - TransactionType, + TransactionType ) from commercetools.platform.models import Type as CTType from commercetools.platform.models import TypeDraft as CTTypeDraft -from commercetools.platform.models import ( - TypeResourceIdentifier as CTTypeResourceIdentifier, -) +from commercetools.platform.models import TypeResourceIdentifier as CTTypeResourceIdentifier from commercetools.platform.models.state import State as CTLineItemState from django.conf import settings from openedx_filters.exceptions import OpenEdxFilterException -from commerce_coordinator.apps.commercetools.catalog_info.constants import ( - DEFAULT_ORDER_EXPANSION, - EDX_PAYPAL_PAYMENT_INTERFACE_NAME, - EdXFieldNames, -) -from commerce_coordinator.apps.commercetools.catalog_info.foundational_types import ( - TwoUCustomTypes, -) +from commerce_coordinator.apps.commercetools.catalog_info.constants import DEFAULT_ORDER_EXPANSION, EdXFieldNames +from commerce_coordinator.apps.commercetools.catalog_info.foundational_types import TwoUCustomTypes from commerce_coordinator.apps.commercetools.utils import ( find_refund_transaction, handle_commercetools_error, - translate_refund_status_to_transaction_status, + translate_refund_status_to_transaction_status ) from commerce_coordinator.apps.core.constants import ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT -from typing import TypedDict, Union -from commerce_coordinator.apps.commercetools.catalog_info.constants import EDX_STRIPE_PAYMENT_INTERFACE_NAME - logger = logging.getLogger(__name__) T = TypeVar("T") @@ -82,8 +60,7 @@ class PaginatedResult(Generic[T]): - """Planned paginated response wrapper""" - + """ Planned paginated response wrapper """ results: List[T] total: int offset: int @@ -107,17 +84,8 @@ def rebuild(self, results: List[T]): return PaginatedResult(results, total=self.total, offset=self.offset) -class Refund(TypedDict): - id: str - amount: Union[str, int] - currency: str - created: Union[str, int] - status: str - - class CommercetoolsAPIClient: - """Commercetools API Client""" - + """ Commercetools API Client """ base_client = None def __init__(self): @@ -133,10 +101,10 @@ def __init__(self): self.base_client = CTClient( client_id=config["clientId"], client_secret=config["clientSecret"], - scope=config["scopes"].split(" "), + scope=config["scopes"].split(' '), url=config["apiUrl"], token_url=config["authUrl"], - project_key=config["projectKey"], + project_key=config["projectKey"] ) def ensure_custom_type_exists(self, type_def: CTTypeDraft) -> Optional[CTType]: @@ -156,9 +124,7 @@ def ensure_custom_type_exists(self, type_def: CTTypeDraft) -> Optional[CTType]: except CommercetoolsError as _: # pragma: no cover # commercetools.exceptions.CommercetoolsError: The Resource with key 'edx-user_information' was not found. pass - except ( - requests.exceptions.HTTPError - ) as _: # The test framework doesn't wrap to CommercetoolsError + except requests.exceptions.HTTPError as _: # The test framework doesn't wrap to CommercetoolsError pass if not type_exists: @@ -166,9 +132,7 @@ def ensure_custom_type_exists(self, type_def: CTTypeDraft) -> Optional[CTType]: return type_object - def tag_customer_with_lms_user_info( - self, customer: CTCustomer, lms_user_id: int, lms_user_name: str - ) -> CTCustomer: + def tag_customer_with_lms_user_info(self, customer: CTCustomer, lms_user_id: int, lms_user_name: str) -> CTCustomer: """ Updates a CoCo Customer Object with what we are currently using for LMS Identifiers Args: @@ -182,38 +146,28 @@ def tag_customer_with_lms_user_info( # All updates to CT Core require the version of the object you are working on as protection from out of band # updates; this does mean we have to fetch every (primary) object we want to chain. - type_object = self.ensure_custom_type_exists( - TwoUCustomTypes.CUSTOMER_TYPE_DRAFT - ) + type_object = self.ensure_custom_type_exists(TwoUCustomTypes.CUSTOMER_TYPE_DRAFT) # A customer can only have one custom type associated to it, and thus only one set of custom fields. THUS... # They can't be required, and shouldn't entirely be relied upon; Once a proper Type is changed, the old values # are LOST. if customer.custom and not customer.custom.type.id == type_object.id: - raise ValueError( - "User already has a custom type, and its not the one were expecting, Refusing to update. " - "(Updating will eradicate the values from the other type, as an object may only have one " - "Custom Type)" - ) + raise ValueError("User already has a custom type, and its not the one were expecting, Refusing to update. " + "(Updating will eradicate the values from the other type, as an object may only have one " + "Custom Type)") - ret = self.base_client.customers.update_by_id( - customer.id, - customer.version, - actions=[ - CTCustomerSetCustomTypeAction( - type=CTTypeResourceIdentifier( - key=TwoUCustomTypes.CUSTOMER_TYPE_DRAFT.key, - ), - fields=CTFieldContainer( - { - EdXFieldNames.LMS_USER_ID: f"{lms_user_id}", - EdXFieldNames.LMS_USER_NAME: lms_user_name, - } - ), + ret = self.base_client.customers.update_by_id(customer.id, customer.version, actions=[ + CTCustomerSetCustomTypeAction( + type=CTTypeResourceIdentifier( + key=TwoUCustomTypes.CUSTOMER_TYPE_DRAFT.key, ), - ], - ) + fields=CTFieldContainer({ + EdXFieldNames.LMS_USER_ID: f"{lms_user_id}", + EdXFieldNames.LMS_USER_NAME: lms_user_name + }) + ), + ]) return ret @@ -229,17 +183,15 @@ def get_customer_by_lms_user_id(self, lms_user_id: int) -> Optional[CTCustomer]: is returned. """ - logger.info( - f"[CommercetoolsAPIClient] - Attempting to get customer with LMS user id: {lms_user_id}" - ) + logger.info(f"[CommercetoolsAPIClient] - Attempting to get customer with LMS user id: {lms_user_id}") edx_lms_user_id_key = EdXFieldNames.LMS_USER_ID start_time = datetime.datetime.now() results = self.base_client.customers.query( - where=f"custom(fields({edx_lms_user_id_key}=:id))", + where=f'custom(fields({edx_lms_user_id_key}=:id))', limit=2, - predicate_var={"id": f"{lms_user_id}"}, + predicate_var={'id': f"{lms_user_id}"} ) duration = (datetime.datetime.now() - start_time).total_seconds() logger.info(f"[Performance Check] - customerId query took {duration} seconds") @@ -247,29 +199,20 @@ def get_customer_by_lms_user_id(self, lms_user_id: int) -> Optional[CTCustomer]: if results.count > 1: # We are unable due to CT Limitations to enforce unique LMS ID values on Customers on the catalog side, so # let's do a backhanded check by trying to pull 2 users and erroring if we find a discrepancy. - logger.info( - f"[CommercetoolsAPIClient] - More than one customer found with LMS " - f"user id: {lms_user_id}, raising error" - ) - raise ValueError( - "More than one user was returned from the catalog with this edX LMS User ID, these must " - "be unique." - ) + logger.info(f"[CommercetoolsAPIClient] - More than one customer found with LMS " + f"user id: {lms_user_id}, raising error") + raise ValueError("More than one user was returned from the catalog with this edX LMS User ID, these must " + "be unique.") if results.count == 0: - logger.info( - f"[CommercetoolsAPIClient] - No customer found with LMS user id: {lms_user_id}" - ) + logger.info(f"[CommercetoolsAPIClient] - No customer found with LMS user id: {lms_user_id}") return None else: - logger.info( - f"[CommercetoolsAPIClient] - Customer found with LMS user id: {lms_user_id}" - ) + logger.info(f"[CommercetoolsAPIClient] - Customer found with LMS user id: {lms_user_id}") return results.results[0] - def get_order_by_id( - self, order_id: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION - ) -> CTOrder: + def get_order_by_id(self, order_id: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION) \ + -> CTOrder: """ Fetch an order by the Order ID (UUID) @@ -279,14 +222,11 @@ def get_order_by_id( Returns (CTOrder): Order with Expanded Properties """ - logger.info( - f"[CommercetoolsAPIClient] - Attempting to find order with id: {order_id}" - ) + logger.info(f"[CommercetoolsAPIClient] - Attempting to find order with id: {order_id}") return self.base_client.orders.get_by_id(order_id, expand=list(expand)) - def get_order_by_number( - self, order_number: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION - ) -> CTOrder: + def get_order_by_number(self, order_number: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION) \ + -> CTOrder: """ Fetch an order by the Order Number (Human readable order number) @@ -296,21 +236,14 @@ def get_order_by_number( Returns (CTOrder): Order with Expanded Properties """ - logger.info( - f"[CommercetoolsAPIClient] - Attempting to find order with number {order_number}" - ) - return self.base_client.orders.get_by_order_number( - order_number, expand=list(expand) - ) + logger.info(f"[CommercetoolsAPIClient] - Attempting to find order with number {order_number}") + return self.base_client.orders.get_by_order_number(order_number, expand=list(expand)) + + def get_orders(self, customer_id: str, offset=0, + limit=ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT, + expand: ExpandList = DEFAULT_ORDER_EXPANSION, + order_state="Complete") -> PaginatedResult[CTOrder]: - def get_orders( - self, - customer_id: str, - offset=0, - limit=ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT, - expand: ExpandList = DEFAULT_ORDER_EXPANSION, - order_state="Complete", - ) -> PaginatedResult[CTOrder]: """ Call commercetools API overview endpoint for data about historical orders. @@ -326,35 +259,27 @@ def get_orders( See sample response in tests.py """ - logger.info( - f"[CommercetoolsAPIClient] - Attempting to find all completed orders for " - f"customer with ID {customer_id}" - ) - order_where_clause = f'orderState="{order_state}"' + logger.info(f"[CommercetoolsAPIClient] - Attempting to find all completed orders for " + f"customer with ID {customer_id}") + order_where_clause = f"orderState=\"{order_state}\"" start_time = datetime.datetime.now() values = self.base_client.orders.query( where=["customerId=:cid", order_where_clause], - predicate_var={"cid": customer_id}, + predicate_var={'cid': customer_id}, sort=["completedAt desc", "lastModifiedAt desc"], limit=limit, offset=offset, - expand=list(expand), + expand=list(expand) ) duration = (datetime.datetime.now() - start_time).total_seconds() logger.info(f"[Performance Check] get_orders call took {duration} seconds") return PaginatedResult(values.results, values.total, values.offset) - def get_orders_for_customer( - self, - edx_lms_user_id: int, - offset=0, - limit=ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT, - customer_id=None, - email=None, - username=None, - ) -> (PaginatedResult[CTOrder], CTCustomer): + def get_orders_for_customer(self, edx_lms_user_id: int, offset=0, limit=ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT, + customer_id=None, email=None, + username=None) -> (PaginatedResult[CTOrder], CTCustomer): """ Args: @@ -366,21 +291,21 @@ def get_orders_for_customer( customer = self.get_customer_by_lms_user_id(edx_lms_user_id) if customer is None: # pragma: no cover - raise ValueError( - f"Unable to locate customer with ID #{edx_lms_user_id}" - ) + raise ValueError(f'Unable to locate customer with ID #{edx_lms_user_id}') customer_id = customer.id else: if email is None or username is None: # pragma: no cover - raise ValueError( - "If customer_id is provided, both email and username must be provided" - ) + raise ValueError("If customer_id is provided, both email and username must be provided") customer = SimpleNamespace( id=customer_id, email=email, - custom=SimpleNamespace(fields={EdXFieldNames.LMS_USER_NAME: username}), + custom=SimpleNamespace( + fields={ + EdXFieldNames.LMS_USER_NAME: username + } + ) ) orders = self.get_orders(customer_id, offset, limit) @@ -388,80 +313,58 @@ def get_orders_for_customer( return orders, customer def get_customer_by_id(self, customer_id: str) -> CTCustomer: - logger.info( - f"[CommercetoolsAPIClient] - Attempting to find customer with ID {customer_id}" - ) + logger.info(f"[CommercetoolsAPIClient] - Attempting to find customer with ID {customer_id}") return self.base_client.customers.get_by_id(customer_id) def get_state_by_id(self, state_id: str) -> CTLineItemState: - logger.info( - f"[CommercetoolsAPIClient] - Attempting to find state with id {state_id}" - ) + logger.info(f"[CommercetoolsAPIClient] - Attempting to find state with id {state_id}") return self.base_client.states.get_by_id(state_id) def get_state_by_key(self, state_key: str) -> CTLineItemState: - logger.info( - f"[CommercetoolsAPIClient] - Attempting to find state with key {state_key}" - ) + logger.info(f"[CommercetoolsAPIClient] - Attempting to find state with key {state_key}") return self.base_client.states.get_by_key(state_key) def get_payment_by_key(self, payment_key: str) -> CTPayment: - logger.info( - f"[CommercetoolsAPIClient] - Attempting to find payment with key {payment_key}" - ) + logger.info(f"[CommercetoolsAPIClient] - Attempting to find payment with key {payment_key}") return self.base_client.payments.get_by_key(payment_key) - def get_payment_by_transaction_interaction_id( - self, interaction_id: str - ) -> CTPayment: - logger.info( - f"[CommercetoolsAPIClient] - Attempting to find payment with interaction ID {interaction_id}" - ) - return self.base_client.payments.query( - where=f'transactions(interactionId="{interaction_id}")' - ).results[0] - - def get_product_variant_by_course_run( - self, cr_id: str - ) -> Optional[CTProductVariant]: + def get_product_variant_by_course_run(self, cr_id: str) -> Optional[CTProductVariant]: """ Args: cr_id: variant course run key """ start_time = datetime.datetime.now() - results = self.base_client.product_projections.search( - False, filter=f'variants.sku:"{cr_id}"' - ).results + results = self.base_client.product_projections.search(False, filter=f"variants.sku:\"{cr_id}\"").results duration = (datetime.datetime.now() - start_time).total_seconds() logger.info( - f"[Performance Check] get_product_variant_by_course_run took {duration} seconds" - ) + f"[Performance Check] get_product_variant_by_course_run took {duration} seconds") if len(results) < 1: # pragma no cover return None # Make 2D List of all variants from all results, and then flatten - all_variants = [ - listitem - for sublist in list( - map( - lambda selection: [selection.master_variant, *selection.variants], - results, - ) + all_variants = [listitem for sublist in + list( + map( + lambda selection: [selection.master_variant, *selection.variants], + results + ) + ) + for listitem in sublist] + + matching_variant_list = list( + filter( + lambda v: v.sku == cr_id, + all_variants ) - for listitem in sublist - ] - - matching_variant_list = list(filter(lambda v: v.sku == cr_id, all_variants)) + ) if len(matching_variant_list) < 1: # pragma no cover return None return matching_variant_list[0] - def create_return_for_order( - self, order_id: str, order_version: int, order_line_item_id: str - ) -> CTOrder: + def create_return_for_order(self, order_id: str, order_version: int, order_line_item_id: str) -> CTOrder: """ Creates refund/return for Commercetools order Args: @@ -473,10 +376,8 @@ def create_return_for_order( """ try: - return_item_draft_comment = ( - f"Creating return item for order {order_id} with " - f"order line item ID {order_line_item_id}" - ) + return_item_draft_comment = f'Creating return item for order {order_id} with ' \ + f'order line item ID {order_line_item_id}' logger.info(f"[CommercetoolsAPIClient] - {return_item_draft_comment}") @@ -487,26 +388,25 @@ def create_return_for_order( shipment_state=ReturnShipmentState.RETURNED, ) - add_return_info_action = OrderAddReturnInfoAction(items=[return_item_draft]) + add_return_info_action = OrderAddReturnInfoAction( + items=[return_item_draft] + ) returned_order = self.base_client.orders.update_by_id( - id=order_id, version=order_version, actions=[add_return_info_action] + id=order_id, + version=order_version, + actions=[add_return_info_action] ) return returned_order except CommercetoolsError as err: - handle_commercetools_error( - err, f"Unable to create return for order {order_id}" - ) + handle_commercetools_error(err, f"Unable to create return for order {order_id}") raise err - def update_return_payment_state_after_successful_refund( - self, - order_id: str, - order_version: int, - return_line_item_return_id: str, - payment_intent_id: str, - amount_in_cents: decimal, - ) -> Union[CTOrder, None]: + def update_return_payment_state_after_successful_refund(self, order_id: str, + order_version: int, + return_line_item_return_id: str, + payment_intent_id: str, + amount_in_cents: decimal) -> Union[CTOrder, None]: """ Update paymentState on the LineItemReturnItem attached to the order. Updated by the Order ID (UUID) @@ -520,110 +420,80 @@ def update_return_payment_state_after_successful_refund( Raises Exception: Error if update was unsuccessful. """ try: - logger.info( - f"[CommercetoolsAPIClient] - Updating payment state for return " - f"with id {return_line_item_return_id} to '{ReturnPaymentState.REFUNDED}'." - ) + logger.info(f"[CommercetoolsAPIClient] - Updating payment state for return " + f"with id {return_line_item_return_id} to '{ReturnPaymentState.REFUNDED}'.") return_payment_state_action = OrderSetReturnPaymentStateAction( return_item_id=return_line_item_return_id, - payment_state=ReturnPaymentState.REFUNDED, + payment_state=ReturnPaymentState.REFUNDED ) if not payment_intent_id: - payment_intent_id = "" - logger.info( - f"Creating return for order - payment_intent_id: {payment_intent_id}" - ) + payment_intent_id = '' + logger.info(f'Creating return for order - payment_intent_id: {payment_intent_id}') payment = self.get_payment_by_key(payment_intent_id) logger.info(f"Payment found: {payment}") transaction_id = find_refund_transaction(payment, amount_in_cents) update_transaction_id_action = OrderSetReturnItemCustomTypeAction( return_item_id=return_line_item_return_id, type=CTTypeResourceIdentifier( - key="returnItemCustomType", + key='returnItemCustomType', ), - fields=CTFieldContainer({"transactionId": transaction_id}), - ) - return_transaction_return_item_action = ( - PaymentSetTransactionCustomTypeAction( - transaction_id=transaction_id, - type=CTTypeResourceIdentifier(key="transactionCustomType"), - fields=CTFieldContainer( - {"returnItemId": return_line_item_return_id} - ), - ) + fields=CTFieldContainer({ + 'transactionId': transaction_id + }) ) - logger.info( - f"Update return payment state after successful refund - payment_intent_id: {payment_intent_id}" + return_transaction_return_item_action = PaymentSetTransactionCustomTypeAction( + transaction_id=transaction_id, + type=CTTypeResourceIdentifier(key='transactionCustomType'), + fields=CTFieldContainer({ + 'returnItemId': return_line_item_return_id + }) ) + logger.info(f"Update return payment state after successful refund - payment_intent_id: {payment_intent_id}") updated_order = self.base_client.orders.update_by_id( id=order_id, version=order_version, - actions=[return_payment_state_action, update_transaction_id_action], + actions=[return_payment_state_action, update_transaction_id_action] ) self.base_client.payments.update_by_id( id=payment.id, version=payment.version, - actions=[return_transaction_return_item_action], + actions=[return_transaction_return_item_action] ) logger.info("Updated transaction with return item id") return updated_order except CommercetoolsError as err: - handle_commercetools_error( - err, f"Unable to update ReturnPaymentState of order {order_id}" - ) + handle_commercetools_error(err, f"Unable to update ReturnPaymentState of order {order_id}") raise OpenEdxFilterException(str(err)) from err - - def _preprocess_refund_object(self, refund: Refund, psp:str) -> Refund: - """ - Pre process refund object based on PSP - """ - if psp == EDX_PAYPAL_PAYMENT_INTERFACE_NAME: - refund["amount"] = float(refund["amount"]) * 100 - refund["created"] = datetime.datetime.fromisoformat(refund["created"]) - else: - refund["created"] = datetime.datetime.utcfromtimestamp(refund["created"]) - - refund["status"] = translate_refund_status_to_transaction_status(refund["status"]) - refund["currency"] = refund["currency"].upper() - return refund def create_return_payment_transaction( - self, - payment_id: str, - payment_version: int, - refund: Refund, - psp=EDX_STRIPE_PAYMENT_INTERFACE_NAME - ) -> CTPayment: + self, payment_id: str, + payment_version: int, + stripe_refund: stripe.Refund) -> CTPayment: """ Create Commercetools payment transaction for refund Args: payment_id (str): Payment ID (UUID) payment_version (int): Current version of payment - refund (stripe.Refund): Refund object + stripe_refund (stripe.Refund): Stripe's refund object Returns (CTPayment): Updated payment object or Raises Exception: Error if creation was unsuccessful. """ try: - logger.info( - f"[CommercetoolsAPIClient] - Creating refund transaction for payment with ID {payment_id} " - f"following successful refund {refund['id']}" - ) - refund = self._preprocess_refund_object(refund, psp) + logger.info(f"[CommercetoolsAPIClient] - Creating refund transaction for payment with ID {payment_id} " + f"following successful Stripe refund {stripe_refund.id}") amount_as_money = CTMoney( - cent_amount=float( - refund["amount"] - ), - currency_code=refund["currency"], + cent_amount=stripe_refund.amount, + currency_code=stripe_refund.currency.upper() ) transaction_draft = TransactionDraft( type=TransactionType.REFUND, amount=amount_as_money, - timestamp=refund["created"], - state=refund["status"], - interaction_id=refund["id"], + timestamp=datetime.datetime.utcfromtimestamp(stripe_refund.created), + state=translate_refund_status_to_transaction_status(stripe_refund.status), + interaction_id=stripe_refund.id ) add_transaction_action = PaymentAddTransactionAction( @@ -631,27 +501,21 @@ def create_return_payment_transaction( ) returned_payment = self.base_client.payments.update_by_id( - id=payment_id, version=payment_version, actions=[add_transaction_action] + id=payment_id, + version=payment_version, + actions=[add_transaction_action] ) return returned_payment except CommercetoolsError as err: - context = ( - f"Unable to create refund payment transaction for " - f"payment {payment_id} and refund {refund['id']}" - ) + context = f"Unable to create refund payment transaction for "\ + f"payment {payment_id} and stripe refund {stripe_refund.id}" handle_commercetools_error(err, context) raise err - def update_line_item_transition_state_on_fulfillment( - self, - order_id: str, - order_version: int, - line_item_id: str, - item_quantity: int, - from_state_id: str, - new_state_key: str, - ) -> CTOrder: + def update_line_item_transition_state_on_fulfillment(self, order_id: str, order_version: int, + line_item_id: str, item_quantity: int, + from_state_id: str, new_state_key: str) -> CTOrder: """ Update Commercetools order line item state Args: @@ -668,10 +532,8 @@ def update_line_item_transition_state_on_fulfillment( from_state_key = self.get_state_by_id(from_state_id).key - logger.info( - f"[CommercetoolsAPIClient] - Transitioning line item state for order with ID {order_id}" - f"from {from_state_key} to {new_state_key}" - ) + logger.info(f"[CommercetoolsAPIClient] - Transitioning line item state for order with ID {order_id}" + f"from {from_state_key} to {new_state_key}") try: if new_state_key != from_state_key: @@ -679,40 +541,28 @@ def update_line_item_transition_state_on_fulfillment( line_item_id=line_item_id, quantity=item_quantity, from_state=StateResourceIdentifier(key=from_state_key), - to_state=StateResourceIdentifier(key=new_state_key), + to_state=StateResourceIdentifier(key=new_state_key) ) - updated_fulfillment_line_item_order = ( - self.base_client.orders.update_by_id( - id=order_id, - version=order_version, - actions=[transition_line_item_state_action], - ) + updated_fulfillment_line_item_order = self.base_client.orders.update_by_id( + id=order_id, + version=order_version, + actions=[transition_line_item_state_action], ) return updated_fulfillment_line_item_order else: - logger.info( - f"The line item {line_item_id} already has the correct state {new_state_key}. " - "Not attempting to transition LineItemState" - ) + logger.info(f"The line item {line_item_id} already has the correct state {new_state_key}. " + "Not attempting to transition LineItemState") return self.get_order_by_id(order_id) except CommercetoolsError as err: # Logs & ignores version conflict errors due to duplicate Commercetools messages - handle_commercetools_error( - err, f"Unable to update LineItemState of order {order_id}", True - ) + handle_commercetools_error(err, f"Unable to update LineItemState of order {order_id}", True) return None - def retire_customer_anonymize_fields( - self, - customer_id: str, - customer_version: int, - retired_first_name: str, - retired_last_name: str, - retired_email: str, - retired_lms_username: str, - ) -> CTCustomer: + def retire_customer_anonymize_fields(self, customer_id: str, customer_version: int, + retired_first_name: str, retired_last_name: str, + retired_email: str, retired_lms_username: str) -> CTCustomer: """ Update Commercetools customer with anonymized fields Args: @@ -735,30 +585,31 @@ def retire_customer_anonymize_fields( last_name=retired_last_name ) - update_retired_email_action = CustomerChangeEmailAction(email=retired_email) + update_retired_email_action = CustomerChangeEmailAction( + email=retired_email + ) update_retired_lms_username_action = CustomerSetCustomFieldAction( - name="edx-lms_user_name", value=retired_lms_username + name="edx-lms_user_name", + value=retired_lms_username ) - actions.extend( - [ - update_retired_first_name_action, - update_retired_last_name_action, - update_retired_email_action, - update_retired_lms_username_action, - ] - ) + actions.extend([ + update_retired_first_name_action, + update_retired_last_name_action, + update_retired_email_action, + update_retired_lms_username_action + ]) try: retired_customer = self.base_client.customers.update_by_id( - id=customer_id, version=customer_version, actions=actions + id=customer_id, + version=customer_version, + actions=actions ) return retired_customer except CommercetoolsError as err: - logger.error( - f"[CommercetoolsError] Unable to anonymize customer fields for customer " - f"with ID: {customer_id}, after LMS retirement with " - f"error correlation id {err.correlation_id} and error/s: {err.errors}" - ) + logger.error(f"[CommercetoolsError] Unable to anonymize customer fields for customer " + f"with ID: {customer_id}, after LMS retirement with " + f"error correlation id {err.correlation_id} and error/s: {err.errors}") raise err diff --git a/commerce_coordinator/apps/commercetools/tests/test_utils.py b/commerce_coordinator/apps/commercetools/tests/test_utils.py index 43c7e53b..efe6e417 100644 --- a/commerce_coordinator/apps/commercetools/tests/test_utils.py +++ b/commerce_coordinator/apps/commercetools/tests/test_utils.py @@ -29,7 +29,7 @@ has_refund_transaction, send_order_confirmation_email, send_unsupported_mode_fulfillment_error_email, - translate_stripe_refund_status_to_transaction_status + translate_refund_status_to_transaction_status ) @@ -313,17 +313,17 @@ class TestTranslateStripeRefundStatus(unittest.TestCase): """ def test_translate_stripe_refund_status_succeeded(self): - self.assertEqual(translate_stripe_refund_status_to_transaction_status('succeeded'), TransactionState.SUCCESS) + self.assertEqual(translate_refund_status_to_transaction_status('succeeded'), TransactionState.SUCCESS) def test_translate_stripe_refund_status_pending(self): - self.assertEqual(translate_stripe_refund_status_to_transaction_status('pending'), TransactionState.PENDING) + self.assertEqual(translate_refund_status_to_transaction_status('pending'), TransactionState.PENDING) def test_translate_stripe_refund_status_failed(self): - self.assertEqual(translate_stripe_refund_status_to_transaction_status('failed'), TransactionState.FAILURE) + self.assertEqual(translate_refund_status_to_transaction_status('failed'), TransactionState.FAILURE) def test_translate_stripe_refund_status_other(self): # Test for an unknown status - self.assertEqual(translate_stripe_refund_status_to_transaction_status('unknown_status'), 'unknown_status') + self.assertEqual(translate_refund_status_to_transaction_status('unknown_status'), 'unknown_status') class TestRetirementAnonymizingTestCase(unittest.TestCase): From 52deaa107583c92523a1c2ae9eb704d64f7bf671 Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan Date: Fri, 13 Dec 2024 16:52:19 +0500 Subject: [PATCH 06/27] fix: fixes --- .../apps/paypal/migrations/0001_key_value_cache_model.py | 6 +++--- commerce_coordinator/apps/paypal/models.py | 4 ++-- commerce_coordinator/apps/paypal/views.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/commerce_coordinator/apps/paypal/migrations/0001_key_value_cache_model.py b/commerce_coordinator/apps/paypal/migrations/0001_key_value_cache_model.py index 02c25dac..11ff1a74 100644 --- a/commerce_coordinator/apps/paypal/migrations/0001_key_value_cache_model.py +++ b/commerce_coordinator/apps/paypal/migrations/0001_key_value_cache_model.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.13 on 2024-12-12 07:45 +# Generated by Django 4.2.13 on 2024-12-13 11:51 from django.db import migrations, models @@ -15,8 +15,8 @@ class Migration(migrations.Migration): name='KeyValueCache', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('key', models.CharField(max_length=255, unique=True)), - ('value', models.TextField()), + ('cache_key', models.CharField(max_length=255, unique=True)), + ('cache_value', models.TextField()), ], ), ] diff --git a/commerce_coordinator/apps/paypal/models.py b/commerce_coordinator/apps/paypal/models.py index d3a16e47..d81825a5 100644 --- a/commerce_coordinator/apps/paypal/models.py +++ b/commerce_coordinator/apps/paypal/models.py @@ -5,5 +5,5 @@ class KeyValueCache(models.Model): - key = models.CharField(max_length=255, unique=True) - value = models.TextField() + cache_key = models.CharField(max_length=255, unique=True) + cache_value = models.TextField() diff --git a/commerce_coordinator/apps/paypal/views.py b/commerce_coordinator/apps/paypal/views.py index 0d7dbf9a..e503f657 100644 --- a/commerce_coordinator/apps/paypal/views.py +++ b/commerce_coordinator/apps/paypal/views.py @@ -32,11 +32,11 @@ class PayPalWebhookView(SingleInvocationAPIView): def get_certificate(self, url): try: - cache = KeyValueCache.objects.get(key=url) + cache = KeyValueCache.objects.get(cache_key=url) return cache.value except KeyValueCache.DoesNotExist: r = requests.get(url) - KeyValueCache.objects.create(key=url, value=r.text) + KeyValueCache.objects.create(cache_key=url, cache_value=r.text) return r.text def post(self, request): From caf5754e4db2f7f189cded3eb406e07b2e57a432 Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan Date: Fri, 13 Dec 2024 17:16:55 +0500 Subject: [PATCH 07/27] fix: fixes --- commerce_coordinator/apps/paypal/views.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/commerce_coordinator/apps/paypal/views.py b/commerce_coordinator/apps/paypal/views.py index e503f657..da60c997 100644 --- a/commerce_coordinator/apps/paypal/views.py +++ b/commerce_coordinator/apps/paypal/views.py @@ -21,23 +21,39 @@ from commerce_coordinator.apps.paypal.signals import payment_refunded_signal from .models import KeyValueCache +from urllib.parse import urlparse + logger = logging.getLogger(__name__) class PayPalWebhookView(SingleInvocationAPIView): + ALLOWED_DOMAINS = ['www.paypal.com', 'api.paypal.com', 'api.sandbox.paypal.com', 'www.sandbox.paypal.com'] http_method_names = ["post"] authentication_classes = [] permission_classes = [AllowAny] - def get_certificate(self, url): + def _get_certificate(self, url): try: cache = KeyValueCache.objects.get(cache_key=url) return cache.value except KeyValueCache.DoesNotExist: + if not self.is_valid_url(url): + raise ValueError("Invalid or untrusted URL provided") r = requests.get(url) KeyValueCache.objects.create(cache_key=url, cache_value=r.text) return r.text + + def _is_valid_url(self, url): + try: + parsed_url = urlparse(url) + if parsed_url.scheme not in ['http', 'https']: + return False + if parsed_url.netloc not in self.ALLOWED_DOMAINS: + return False + return True + except Exception: + return False def post(self, request): tag = type(self).__name__ @@ -57,7 +73,7 @@ def post(self, request): signature = base64.b64decode(request.headers.get("paypal-transmission-sig")) - certificate = self.get_certificate(request.headers.get("paypal-cert-url")) + certificate = self._get_certificate(request.headers.get("paypal-cert-url")) cert = x509.load_pem_x509_certificate( certificate.encode("utf-8"), default_backend() ) From 8a97671e8e468861dc76bcfba2d4773ba0cebfc9 Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan Date: Fri, 13 Dec 2024 17:45:27 +0500 Subject: [PATCH 08/27] fix: fixes --- .../apps/commercetools/clients.py | 497 ++++++++++++------ .../apps/commercetools/pipeline.py | 2 +- .../apps/commercetools/tasks.py | 3 +- .../apps/commercetools/tests/test_clients.py | 2 +- commerce_coordinator/apps/paypal/views.py | 31 +- 5 files changed, 351 insertions(+), 184 deletions(-) diff --git a/commerce_coordinator/apps/commercetools/clients.py b/commerce_coordinator/apps/commercetools/clients.py index 1d2ec44c..eccdaec2 100644 --- a/commerce_coordinator/apps/commercetools/clients.py +++ b/commerce_coordinator/apps/commercetools/clients.py @@ -6,16 +6,23 @@ import decimal import logging from types import SimpleNamespace -from typing import Generic, List, Optional, Tuple, TypeVar, Union +from typing import Generic, List, Optional, Tuple, TypeVar, TypedDict, Union import requests -import stripe from commercetools import Client as CTClient from commercetools import CommercetoolsError from commercetools.platform.models import Customer as CTCustomer -from commercetools.platform.models import CustomerChangeEmailAction, CustomerSetCustomFieldAction -from commercetools.platform.models import CustomerSetCustomTypeAction as CTCustomerSetCustomTypeAction -from commercetools.platform.models import CustomerSetFirstNameAction, CustomerSetLastNameAction +from commercetools.platform.models import ( + CustomerChangeEmailAction, + CustomerSetCustomFieldAction, +) +from commercetools.platform.models import ( + CustomerSetCustomTypeAction as CTCustomerSetCustomTypeAction, +) +from commercetools.platform.models import ( + CustomerSetFirstNameAction, + CustomerSetLastNameAction, +) from commercetools.platform.models import FieldContainer as CTFieldContainer from commercetools.platform.models import Money as CTMoney from commercetools.platform.models import Order as CTOrder @@ -23,10 +30,13 @@ OrderAddReturnInfoAction, OrderSetReturnItemCustomTypeAction, OrderSetReturnPaymentStateAction, - OrderTransitionLineItemStateAction + OrderTransitionLineItemStateAction, ) from commercetools.platform.models import Payment as CTPayment -from commercetools.platform.models import PaymentAddTransactionAction, PaymentSetTransactionCustomTypeAction +from commercetools.platform.models import ( + PaymentAddTransactionAction, + PaymentSetTransactionCustomTypeAction, +) from commercetools.platform.models import ProductVariant as CTProductVariant from commercetools.platform.models import ( ReturnItemDraft, @@ -34,24 +44,36 @@ ReturnShipmentState, StateResourceIdentifier, TransactionDraft, - TransactionType + TransactionType, ) from commercetools.platform.models import Type as CTType from commercetools.platform.models import TypeDraft as CTTypeDraft -from commercetools.platform.models import TypeResourceIdentifier as CTTypeResourceIdentifier +from commercetools.platform.models import ( + TypeResourceIdentifier as CTTypeResourceIdentifier, +) from commercetools.platform.models.state import State as CTLineItemState from django.conf import settings from openedx_filters.exceptions import OpenEdxFilterException -from commerce_coordinator.apps.commercetools.catalog_info.constants import DEFAULT_ORDER_EXPANSION, EdXFieldNames -from commerce_coordinator.apps.commercetools.catalog_info.foundational_types import TwoUCustomTypes +from commerce_coordinator.apps.commercetools.catalog_info.constants import ( + DEFAULT_ORDER_EXPANSION, + EdXFieldNames, +) +from commerce_coordinator.apps.commercetools.catalog_info.foundational_types import ( + TwoUCustomTypes, +) from commerce_coordinator.apps.commercetools.utils import ( find_refund_transaction, handle_commercetools_error, - translate_refund_status_to_transaction_status + translate_refund_status_to_transaction_status, ) from commerce_coordinator.apps.core.constants import ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT +from commerce_coordinator.apps.commercetools.catalog_info.constants import ( + EDX_STRIPE_PAYMENT_INTERFACE_NAME, + EDX_PAYPAL_PAYMENT_INTERFACE_NAME +) + logger = logging.getLogger(__name__) T = TypeVar("T") @@ -60,7 +82,8 @@ class PaginatedResult(Generic[T]): - """ Planned paginated response wrapper """ + """Planned paginated response wrapper""" + results: List[T] total: int offset: int @@ -83,9 +106,19 @@ def __getitem__(self, item): def rebuild(self, results: List[T]): return PaginatedResult(results, total=self.total, offset=self.offset) +class Refund(TypedDict): + """ + Refund object definition + """ + id: str + amount: Union[str, int] + currency: str + created: Union[str, int] + status: str class CommercetoolsAPIClient: - """ Commercetools API Client """ + """Commercetools API Client""" + base_client = None def __init__(self): @@ -101,10 +134,10 @@ def __init__(self): self.base_client = CTClient( client_id=config["clientId"], client_secret=config["clientSecret"], - scope=config["scopes"].split(' '), + scope=config["scopes"].split(" "), url=config["apiUrl"], token_url=config["authUrl"], - project_key=config["projectKey"] + project_key=config["projectKey"], ) def ensure_custom_type_exists(self, type_def: CTTypeDraft) -> Optional[CTType]: @@ -124,7 +157,9 @@ def ensure_custom_type_exists(self, type_def: CTTypeDraft) -> Optional[CTType]: except CommercetoolsError as _: # pragma: no cover # commercetools.exceptions.CommercetoolsError: The Resource with key 'edx-user_information' was not found. pass - except requests.exceptions.HTTPError as _: # The test framework doesn't wrap to CommercetoolsError + except ( + requests.exceptions.HTTPError + ) as _: # The test framework doesn't wrap to CommercetoolsError pass if not type_exists: @@ -132,7 +167,9 @@ def ensure_custom_type_exists(self, type_def: CTTypeDraft) -> Optional[CTType]: return type_object - def tag_customer_with_lms_user_info(self, customer: CTCustomer, lms_user_id: int, lms_user_name: str) -> CTCustomer: + def tag_customer_with_lms_user_info( + self, customer: CTCustomer, lms_user_id: int, lms_user_name: str + ) -> CTCustomer: """ Updates a CoCo Customer Object with what we are currently using for LMS Identifiers Args: @@ -146,28 +183,38 @@ def tag_customer_with_lms_user_info(self, customer: CTCustomer, lms_user_id: int # All updates to CT Core require the version of the object you are working on as protection from out of band # updates; this does mean we have to fetch every (primary) object we want to chain. - type_object = self.ensure_custom_type_exists(TwoUCustomTypes.CUSTOMER_TYPE_DRAFT) + type_object = self.ensure_custom_type_exists( + TwoUCustomTypes.CUSTOMER_TYPE_DRAFT + ) # A customer can only have one custom type associated to it, and thus only one set of custom fields. THUS... # They can't be required, and shouldn't entirely be relied upon; Once a proper Type is changed, the old values # are LOST. if customer.custom and not customer.custom.type.id == type_object.id: - raise ValueError("User already has a custom type, and its not the one were expecting, Refusing to update. " - "(Updating will eradicate the values from the other type, as an object may only have one " - "Custom Type)") + raise ValueError( + "User already has a custom type, and its not the one were expecting, Refusing to update. " + "(Updating will eradicate the values from the other type, as an object may only have one " + "Custom Type)" + ) - ret = self.base_client.customers.update_by_id(customer.id, customer.version, actions=[ - CTCustomerSetCustomTypeAction( - type=CTTypeResourceIdentifier( - key=TwoUCustomTypes.CUSTOMER_TYPE_DRAFT.key, + ret = self.base_client.customers.update_by_id( + customer.id, + customer.version, + actions=[ + CTCustomerSetCustomTypeAction( + type=CTTypeResourceIdentifier( + key=TwoUCustomTypes.CUSTOMER_TYPE_DRAFT.key, + ), + fields=CTFieldContainer( + { + EdXFieldNames.LMS_USER_ID: f"{lms_user_id}", + EdXFieldNames.LMS_USER_NAME: lms_user_name, + } + ), ), - fields=CTFieldContainer({ - EdXFieldNames.LMS_USER_ID: f"{lms_user_id}", - EdXFieldNames.LMS_USER_NAME: lms_user_name - }) - ), - ]) + ], + ) return ret @@ -183,15 +230,17 @@ def get_customer_by_lms_user_id(self, lms_user_id: int) -> Optional[CTCustomer]: is returned. """ - logger.info(f"[CommercetoolsAPIClient] - Attempting to get customer with LMS user id: {lms_user_id}") + logger.info( + f"[CommercetoolsAPIClient] - Attempting to get customer with LMS user id: {lms_user_id}" + ) edx_lms_user_id_key = EdXFieldNames.LMS_USER_ID start_time = datetime.datetime.now() results = self.base_client.customers.query( - where=f'custom(fields({edx_lms_user_id_key}=:id))', + where=f"custom(fields({edx_lms_user_id_key}=:id))", limit=2, - predicate_var={'id': f"{lms_user_id}"} + predicate_var={"id": f"{lms_user_id}"}, ) duration = (datetime.datetime.now() - start_time).total_seconds() logger.info(f"[Performance Check] - customerId query took {duration} seconds") @@ -199,20 +248,29 @@ def get_customer_by_lms_user_id(self, lms_user_id: int) -> Optional[CTCustomer]: if results.count > 1: # We are unable due to CT Limitations to enforce unique LMS ID values on Customers on the catalog side, so # let's do a backhanded check by trying to pull 2 users and erroring if we find a discrepancy. - logger.info(f"[CommercetoolsAPIClient] - More than one customer found with LMS " - f"user id: {lms_user_id}, raising error") - raise ValueError("More than one user was returned from the catalog with this edX LMS User ID, these must " - "be unique.") + logger.info( + f"[CommercetoolsAPIClient] - More than one customer found with LMS " + f"user id: {lms_user_id}, raising error" + ) + raise ValueError( + "More than one user was returned from the catalog with this edX LMS User ID, these must " + "be unique." + ) if results.count == 0: - logger.info(f"[CommercetoolsAPIClient] - No customer found with LMS user id: {lms_user_id}") + logger.info( + f"[CommercetoolsAPIClient] - No customer found with LMS user id: {lms_user_id}" + ) return None else: - logger.info(f"[CommercetoolsAPIClient] - Customer found with LMS user id: {lms_user_id}") + logger.info( + f"[CommercetoolsAPIClient] - Customer found with LMS user id: {lms_user_id}" + ) return results.results[0] - def get_order_by_id(self, order_id: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION) \ - -> CTOrder: + def get_order_by_id( + self, order_id: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION + ) -> CTOrder: """ Fetch an order by the Order ID (UUID) @@ -222,11 +280,14 @@ def get_order_by_id(self, order_id: str, expand: ExpandList = DEFAULT_ORDER_EXPA Returns (CTOrder): Order with Expanded Properties """ - logger.info(f"[CommercetoolsAPIClient] - Attempting to find order with id: {order_id}") + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find order with id: {order_id}" + ) return self.base_client.orders.get_by_id(order_id, expand=list(expand)) - def get_order_by_number(self, order_number: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION) \ - -> CTOrder: + def get_order_by_number( + self, order_number: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION + ) -> CTOrder: """ Fetch an order by the Order Number (Human readable order number) @@ -236,14 +297,21 @@ def get_order_by_number(self, order_number: str, expand: ExpandList = DEFAULT_OR Returns (CTOrder): Order with Expanded Properties """ - logger.info(f"[CommercetoolsAPIClient] - Attempting to find order with number {order_number}") - return self.base_client.orders.get_by_order_number(order_number, expand=list(expand)) - - def get_orders(self, customer_id: str, offset=0, - limit=ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT, - expand: ExpandList = DEFAULT_ORDER_EXPANSION, - order_state="Complete") -> PaginatedResult[CTOrder]: + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find order with number {order_number}" + ) + return self.base_client.orders.get_by_order_number( + order_number, expand=list(expand) + ) + def get_orders( + self, + customer_id: str, + offset=0, + limit=ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT, + expand: ExpandList = DEFAULT_ORDER_EXPANSION, + order_state="Complete", + ) -> PaginatedResult[CTOrder]: """ Call commercetools API overview endpoint for data about historical orders. @@ -259,27 +327,35 @@ def get_orders(self, customer_id: str, offset=0, See sample response in tests.py """ - logger.info(f"[CommercetoolsAPIClient] - Attempting to find all completed orders for " - f"customer with ID {customer_id}") - order_where_clause = f"orderState=\"{order_state}\"" + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find all completed orders for " + f"customer with ID {customer_id}" + ) + order_where_clause = f'orderState="{order_state}"' start_time = datetime.datetime.now() values = self.base_client.orders.query( where=["customerId=:cid", order_where_clause], - predicate_var={'cid': customer_id}, + predicate_var={"cid": customer_id}, sort=["completedAt desc", "lastModifiedAt desc"], limit=limit, offset=offset, - expand=list(expand) + expand=list(expand), ) duration = (datetime.datetime.now() - start_time).total_seconds() logger.info(f"[Performance Check] get_orders call took {duration} seconds") return PaginatedResult(values.results, values.total, values.offset) - def get_orders_for_customer(self, edx_lms_user_id: int, offset=0, limit=ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT, - customer_id=None, email=None, - username=None) -> (PaginatedResult[CTOrder], CTCustomer): + def get_orders_for_customer( + self, + edx_lms_user_id: int, + offset=0, + limit=ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT, + customer_id=None, + email=None, + username=None, + ) -> (PaginatedResult[CTOrder], CTCustomer): """ Args: @@ -291,21 +367,21 @@ def get_orders_for_customer(self, edx_lms_user_id: int, offset=0, limit=ORDER_HI customer = self.get_customer_by_lms_user_id(edx_lms_user_id) if customer is None: # pragma: no cover - raise ValueError(f'Unable to locate customer with ID #{edx_lms_user_id}') + raise ValueError( + f"Unable to locate customer with ID #{edx_lms_user_id}" + ) customer_id = customer.id else: if email is None or username is None: # pragma: no cover - raise ValueError("If customer_id is provided, both email and username must be provided") + raise ValueError( + "If customer_id is provided, both email and username must be provided" + ) customer = SimpleNamespace( id=customer_id, email=email, - custom=SimpleNamespace( - fields={ - EdXFieldNames.LMS_USER_NAME: username - } - ) + custom=SimpleNamespace(fields={EdXFieldNames.LMS_USER_NAME: username}), ) orders = self.get_orders(customer_id, offset, limit) @@ -313,58 +389,83 @@ def get_orders_for_customer(self, edx_lms_user_id: int, offset=0, limit=ORDER_HI return orders, customer def get_customer_by_id(self, customer_id: str) -> CTCustomer: - logger.info(f"[CommercetoolsAPIClient] - Attempting to find customer with ID {customer_id}") + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find customer with ID {customer_id}" + ) return self.base_client.customers.get_by_id(customer_id) def get_state_by_id(self, state_id: str) -> CTLineItemState: - logger.info(f"[CommercetoolsAPIClient] - Attempting to find state with id {state_id}") + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find state with id {state_id}" + ) return self.base_client.states.get_by_id(state_id) def get_state_by_key(self, state_key: str) -> CTLineItemState: - logger.info(f"[CommercetoolsAPIClient] - Attempting to find state with key {state_key}") + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find state with key {state_key}" + ) return self.base_client.states.get_by_key(state_key) def get_payment_by_key(self, payment_key: str) -> CTPayment: - logger.info(f"[CommercetoolsAPIClient] - Attempting to find payment with key {payment_key}") + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find payment with key {payment_key}" + ) return self.base_client.payments.get_by_key(payment_key) - def get_product_variant_by_course_run(self, cr_id: str) -> Optional[CTProductVariant]: + def get_payment_by_transaction_interaction_id( + self, interaction_id: str + ) -> CTPayment: + """ + Fetch a payment by the transaction interaction ID + """ + logger.info( + f"[CommercetoolsAPIClient] - Attempting to find payment with interaction ID {interaction_id}" + ) + return self.base_client.payments.query( + where=f'transactions(interactionId="{interaction_id}")' + ).results[0] + + def get_product_variant_by_course_run( + self, cr_id: str + ) -> Optional[CTProductVariant]: """ Args: cr_id: variant course run key """ start_time = datetime.datetime.now() - results = self.base_client.product_projections.search(False, filter=f"variants.sku:\"{cr_id}\"").results + results = self.base_client.product_projections.search( + False, filter=f'variants.sku:"{cr_id}"' + ).results duration = (datetime.datetime.now() - start_time).total_seconds() logger.info( - f"[Performance Check] get_product_variant_by_course_run took {duration} seconds") + f"[Performance Check] get_product_variant_by_course_run took {duration} seconds" + ) if len(results) < 1: # pragma no cover return None # Make 2D List of all variants from all results, and then flatten - all_variants = [listitem for sublist in - list( - map( - lambda selection: [selection.master_variant, *selection.variants], - results - ) - ) - for listitem in sublist] - - matching_variant_list = list( - filter( - lambda v: v.sku == cr_id, - all_variants + all_variants = [ + listitem + for sublist in list( + map( + lambda selection: [selection.master_variant, *selection.variants], + results, + ) ) - ) + for listitem in sublist + ] + + matching_variant_list = list(filter(lambda v: v.sku == cr_id, all_variants)) if len(matching_variant_list) < 1: # pragma no cover return None return matching_variant_list[0] - def create_return_for_order(self, order_id: str, order_version: int, order_line_item_id: str) -> CTOrder: + def create_return_for_order( + self, order_id: str, order_version: int, order_line_item_id: str + ) -> CTOrder: """ Creates refund/return for Commercetools order Args: @@ -376,8 +477,10 @@ def create_return_for_order(self, order_id: str, order_version: int, order_line_ """ try: - return_item_draft_comment = f'Creating return item for order {order_id} with ' \ - f'order line item ID {order_line_item_id}' + return_item_draft_comment = ( + f"Creating return item for order {order_id} with " + f"order line item ID {order_line_item_id}" + ) logger.info(f"[CommercetoolsAPIClient] - {return_item_draft_comment}") @@ -388,25 +491,26 @@ def create_return_for_order(self, order_id: str, order_version: int, order_line_ shipment_state=ReturnShipmentState.RETURNED, ) - add_return_info_action = OrderAddReturnInfoAction( - items=[return_item_draft] - ) + add_return_info_action = OrderAddReturnInfoAction(items=[return_item_draft]) returned_order = self.base_client.orders.update_by_id( - id=order_id, - version=order_version, - actions=[add_return_info_action] + id=order_id, version=order_version, actions=[add_return_info_action] ) return returned_order except CommercetoolsError as err: - handle_commercetools_error(err, f"Unable to create return for order {order_id}") + handle_commercetools_error( + err, f"Unable to create return for order {order_id}" + ) raise err - def update_return_payment_state_after_successful_refund(self, order_id: str, - order_version: int, - return_line_item_return_id: str, - payment_intent_id: str, - amount_in_cents: decimal) -> Union[CTOrder, None]: + def update_return_payment_state_after_successful_refund( + self, + order_id: str, + order_version: int, + return_line_item_return_id: str, + payment_intent_id: str, + amount_in_cents: decimal, + ) -> Union[CTOrder, None]: """ Update paymentState on the LineItemReturnItem attached to the order. Updated by the Order ID (UUID) @@ -420,80 +524,110 @@ def update_return_payment_state_after_successful_refund(self, order_id: str, Raises Exception: Error if update was unsuccessful. """ try: - logger.info(f"[CommercetoolsAPIClient] - Updating payment state for return " - f"with id {return_line_item_return_id} to '{ReturnPaymentState.REFUNDED}'.") + logger.info( + f"[CommercetoolsAPIClient] - Updating payment state for return " + f"with id {return_line_item_return_id} to '{ReturnPaymentState.REFUNDED}'." + ) return_payment_state_action = OrderSetReturnPaymentStateAction( return_item_id=return_line_item_return_id, - payment_state=ReturnPaymentState.REFUNDED + payment_state=ReturnPaymentState.REFUNDED, ) if not payment_intent_id: - payment_intent_id = '' - logger.info(f'Creating return for order - payment_intent_id: {payment_intent_id}') + payment_intent_id = "" + logger.info( + f"Creating return for order - payment_intent_id: {payment_intent_id}" + ) payment = self.get_payment_by_key(payment_intent_id) logger.info(f"Payment found: {payment}") transaction_id = find_refund_transaction(payment, amount_in_cents) update_transaction_id_action = OrderSetReturnItemCustomTypeAction( return_item_id=return_line_item_return_id, type=CTTypeResourceIdentifier( - key='returnItemCustomType', + key="returnItemCustomType", ), - fields=CTFieldContainer({ - 'transactionId': transaction_id - }) + fields=CTFieldContainer({"transactionId": transaction_id}), + ) + return_transaction_return_item_action = ( + PaymentSetTransactionCustomTypeAction( + transaction_id=transaction_id, + type=CTTypeResourceIdentifier(key="transactionCustomType"), + fields=CTFieldContainer( + {"returnItemId": return_line_item_return_id} + ), + ) ) - return_transaction_return_item_action = PaymentSetTransactionCustomTypeAction( - transaction_id=transaction_id, - type=CTTypeResourceIdentifier(key='transactionCustomType'), - fields=CTFieldContainer({ - 'returnItemId': return_line_item_return_id - }) + logger.info( + f"Update return payment state after successful refund - payment_intent_id: {payment_intent_id}" ) - logger.info(f"Update return payment state after successful refund - payment_intent_id: {payment_intent_id}") updated_order = self.base_client.orders.update_by_id( id=order_id, version=order_version, - actions=[return_payment_state_action, update_transaction_id_action] + actions=[return_payment_state_action, update_transaction_id_action], ) self.base_client.payments.update_by_id( id=payment.id, version=payment.version, - actions=[return_transaction_return_item_action] + actions=[return_transaction_return_item_action], ) logger.info("Updated transaction with return item id") return updated_order except CommercetoolsError as err: - handle_commercetools_error(err, f"Unable to update ReturnPaymentState of order {order_id}") + handle_commercetools_error( + err, f"Unable to update ReturnPaymentState of order {order_id}" + ) raise OpenEdxFilterException(str(err)) from err + def _preprocess_refund_object(self, refund: Refund, psp:str) -> Refund: + """ + Pre process refund object based on PSP + """ + if psp == EDX_PAYPAL_PAYMENT_INTERFACE_NAME: + refund["amount"] = float(refund["amount"]) * 100 + refund["created"] = datetime.datetime.fromisoformat(refund["created"]) + else: + refund["created"] = datetime.datetime.utcfromtimestamp(refund["created"]) + + refund["status"] = translate_refund_status_to_transaction_status(refund["status"]) + refund["currency"] = refund["currency"].upper() + return refund + def create_return_payment_transaction( - self, payment_id: str, - payment_version: int, - stripe_refund: stripe.Refund) -> CTPayment: + self, + payment_id: str, + payment_version: int, + refund: Refund, + psp=EDX_STRIPE_PAYMENT_INTERFACE_NAME + ) -> CTPayment: """ Create Commercetools payment transaction for refund Args: payment_id (str): Payment ID (UUID) payment_version (int): Current version of payment - stripe_refund (stripe.Refund): Stripe's refund object + refund (stripe.Refund): Refund object Returns (CTPayment): Updated payment object or Raises Exception: Error if creation was unsuccessful. """ try: - logger.info(f"[CommercetoolsAPIClient] - Creating refund transaction for payment with ID {payment_id} " - f"following successful Stripe refund {stripe_refund.id}") + logger.info( + f"[CommercetoolsAPIClient] - Creating refund transaction for payment with ID {payment_id} " + f"following successful refund {refund['id']}" + ) + refund = self._preprocess_refund_object(refund, psp) amount_as_money = CTMoney( - cent_amount=stripe_refund.amount, - currency_code=stripe_refund.currency.upper() + cent_amount=float( + refund["amount"] + ), + currency_code=refund["currency"], ) transaction_draft = TransactionDraft( type=TransactionType.REFUND, amount=amount_as_money, - timestamp=datetime.datetime.utcfromtimestamp(stripe_refund.created), - state=translate_refund_status_to_transaction_status(stripe_refund.status), - interaction_id=stripe_refund.id + timestamp=refund["created"], + state=refund["status"], + interaction_id=refund["id"], ) add_transaction_action = PaymentAddTransactionAction( @@ -501,21 +635,27 @@ def create_return_payment_transaction( ) returned_payment = self.base_client.payments.update_by_id( - id=payment_id, - version=payment_version, - actions=[add_transaction_action] + id=payment_id, version=payment_version, actions=[add_transaction_action] ) return returned_payment except CommercetoolsError as err: - context = f"Unable to create refund payment transaction for "\ - f"payment {payment_id} and stripe refund {stripe_refund.id}" + context = ( + f"Unable to create refund payment transaction for " + f"payment {payment_id} and refund {refund['id']}" + ) handle_commercetools_error(err, context) raise err - def update_line_item_transition_state_on_fulfillment(self, order_id: str, order_version: int, - line_item_id: str, item_quantity: int, - from_state_id: str, new_state_key: str) -> CTOrder: + def update_line_item_transition_state_on_fulfillment( + self, + order_id: str, + order_version: int, + line_item_id: str, + item_quantity: int, + from_state_id: str, + new_state_key: str, + ) -> CTOrder: """ Update Commercetools order line item state Args: @@ -532,8 +672,10 @@ def update_line_item_transition_state_on_fulfillment(self, order_id: str, order_ from_state_key = self.get_state_by_id(from_state_id).key - logger.info(f"[CommercetoolsAPIClient] - Transitioning line item state for order with ID {order_id}" - f"from {from_state_key} to {new_state_key}") + logger.info( + f"[CommercetoolsAPIClient] - Transitioning line item state for order with ID {order_id}" + f"from {from_state_key} to {new_state_key}" + ) try: if new_state_key != from_state_key: @@ -541,28 +683,40 @@ def update_line_item_transition_state_on_fulfillment(self, order_id: str, order_ line_item_id=line_item_id, quantity=item_quantity, from_state=StateResourceIdentifier(key=from_state_key), - to_state=StateResourceIdentifier(key=new_state_key) + to_state=StateResourceIdentifier(key=new_state_key), ) - updated_fulfillment_line_item_order = self.base_client.orders.update_by_id( - id=order_id, - version=order_version, - actions=[transition_line_item_state_action], + updated_fulfillment_line_item_order = ( + self.base_client.orders.update_by_id( + id=order_id, + version=order_version, + actions=[transition_line_item_state_action], + ) ) return updated_fulfillment_line_item_order else: - logger.info(f"The line item {line_item_id} already has the correct state {new_state_key}. " - "Not attempting to transition LineItemState") + logger.info( + f"The line item {line_item_id} already has the correct state {new_state_key}. " + "Not attempting to transition LineItemState" + ) return self.get_order_by_id(order_id) except CommercetoolsError as err: # Logs & ignores version conflict errors due to duplicate Commercetools messages - handle_commercetools_error(err, f"Unable to update LineItemState of order {order_id}", True) + handle_commercetools_error( + err, f"Unable to update LineItemState of order {order_id}", True + ) return None - def retire_customer_anonymize_fields(self, customer_id: str, customer_version: int, - retired_first_name: str, retired_last_name: str, - retired_email: str, retired_lms_username: str) -> CTCustomer: + def retire_customer_anonymize_fields( + self, + customer_id: str, + customer_version: int, + retired_first_name: str, + retired_last_name: str, + retired_email: str, + retired_lms_username: str, + ) -> CTCustomer: """ Update Commercetools customer with anonymized fields Args: @@ -585,31 +739,30 @@ def retire_customer_anonymize_fields(self, customer_id: str, customer_version: i last_name=retired_last_name ) - update_retired_email_action = CustomerChangeEmailAction( - email=retired_email - ) + update_retired_email_action = CustomerChangeEmailAction(email=retired_email) update_retired_lms_username_action = CustomerSetCustomFieldAction( - name="edx-lms_user_name", - value=retired_lms_username + name="edx-lms_user_name", value=retired_lms_username ) - actions.extend([ - update_retired_first_name_action, - update_retired_last_name_action, - update_retired_email_action, - update_retired_lms_username_action - ]) + actions.extend( + [ + update_retired_first_name_action, + update_retired_last_name_action, + update_retired_email_action, + update_retired_lms_username_action, + ] + ) try: retired_customer = self.base_client.customers.update_by_id( - id=customer_id, - version=customer_version, - actions=actions + id=customer_id, version=customer_version, actions=actions ) return retired_customer except CommercetoolsError as err: - logger.error(f"[CommercetoolsError] Unable to anonymize customer fields for customer " - f"with ID: {customer_id}, after LMS retirement with " - f"error correlation id {err.correlation_id} and error/s: {err.errors}") + logger.error( + f"[CommercetoolsError] Unable to anonymize customer fields for customer " + f"with ID: {customer_id}, after LMS retirement with " + f"error correlation id {err.correlation_id} and error/s: {err.errors}" + ) raise err diff --git a/commerce_coordinator/apps/commercetools/pipeline.py b/commerce_coordinator/apps/commercetools/pipeline.py index f5e4a368..ec4bb555 100644 --- a/commerce_coordinator/apps/commercetools/pipeline.py +++ b/commerce_coordinator/apps/commercetools/pipeline.py @@ -331,7 +331,7 @@ def run_filter( updated_payment = ct_api_client.create_return_payment_transaction( payment_id=payment_on_order.id, payment_version=payment_on_order.version, - stripe_refund=refund_response + refund=refund_response ) return { diff --git a/commerce_coordinator/apps/commercetools/tasks.py b/commerce_coordinator/apps/commercetools/tasks.py index 5784023b..50ec5a63 100644 --- a/commerce_coordinator/apps/commercetools/tasks.py +++ b/commerce_coordinator/apps/commercetools/tasks.py @@ -9,9 +9,10 @@ from commercetools import CommercetoolsError from django.conf import settings +from commerce_coordinator.apps.commercetools.catalog_info.constants import EDX_PAYPAL_PAYMENT_INTERFACE_NAME from .clients import CommercetoolsAPIClient from .utils import has_full_refund_transaction -from commerce_coordinator.apps.commercetools.catalog_info.constants import EDX_PAYPAL_PAYMENT_INTERFACE_NAME + logger = logging.getLogger(__name__) diff --git a/commerce_coordinator/apps/commercetools/tests/test_clients.py b/commerce_coordinator/apps/commercetools/tests/test_clients.py index ff17e316..424bfa43 100644 --- a/commerce_coordinator/apps/commercetools/tests/test_clients.py +++ b/commerce_coordinator/apps/commercetools/tests/test_clients.py @@ -598,7 +598,7 @@ def test_create_refund_transaction_exception(self): self.client_set.client.create_return_payment_transaction( payment_id="mock_payment_id", payment_version=1, - stripe_refund=mock_stripe_refund + refund=mock_stripe_refund ) exception = cm.exception diff --git a/commerce_coordinator/apps/paypal/views.py b/commerce_coordinator/apps/paypal/views.py index da60c997..ea1568b2 100644 --- a/commerce_coordinator/apps/paypal/views.py +++ b/commerce_coordinator/apps/paypal/views.py @@ -2,8 +2,9 @@ Paypal app views """ -import logging import base64 +import logging +from urllib.parse import urlparse import zlib import requests @@ -21,30 +22,39 @@ from commerce_coordinator.apps.paypal.signals import payment_refunded_signal from .models import KeyValueCache -from urllib.parse import urlparse + logger = logging.getLogger(__name__) class PayPalWebhookView(SingleInvocationAPIView): + """ + PayPal webhook view + """ ALLOWED_DOMAINS = ['www.paypal.com', 'api.paypal.com', 'api.sandbox.paypal.com', 'www.sandbox.paypal.com'] http_method_names = ["post"] authentication_classes = [] permission_classes = [AllowAny] def _get_certificate(self, url): + """ + Get certificate from the given URL + """ try: cache = KeyValueCache.objects.get(cache_key=url) return cache.value - except KeyValueCache.DoesNotExist: - if not self.is_valid_url(url): - raise ValueError("Invalid or untrusted URL provided") - r = requests.get(url) + except Exception as e: # pylint: disable=broad-exception-caught + if not self._is_valid_url(url): + raise ValueError("Invalid or untrusted URL provided") from e + r = requests.get(url) # pylint: disable=missing-timeout KeyValueCache.objects.create(cache_key=url, cache_value=r.text) return r.text - + def _is_valid_url(self, url): + """ + Check if the given URL is valid + """ try: parsed_url = urlparse(url) if parsed_url.scheme not in ['http', 'https']: @@ -52,10 +62,13 @@ def _is_valid_url(self, url): if parsed_url.netloc not in self.ALLOWED_DOMAINS: return False return True - except Exception: + except Exception: # pylint: disable=broad-exception-caught return False def post(self, request): + """ + Handle POST request + """ tag = type(self).__name__ body = request.body @@ -84,7 +97,7 @@ def post(self, request): public_key.verify( signature, message.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256() ) - except Exception: + except Exception: # pylint: disable=broad-exception-caught return Response(status=status.HTTP_400_BAD_REQUEST) if request.data.get("event_type") == "PAYMENT.CAPTURE.REFUNDED": From f2a8107960c53d1daf72219fd843fcc02179051f Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan Date: Fri, 13 Dec 2024 17:49:02 +0500 Subject: [PATCH 09/27] fix: fixes --- .annotation_safe_list.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.annotation_safe_list.yml b/.annotation_safe_list.yml index 45c6a18f..a15e1572 100644 --- a/.annotation_safe_list.yml +++ b/.annotation_safe_list.yml @@ -15,6 +15,8 @@ auth.Permission: ".. no_pii:": "This model has no PII" contenttypes.ContentType: ".. no_pii:": "This model has no PII" +paypal.KeyValueCache: + ".. no_pii:": "This model has no PII" sessions.Session: ".. no_pii:": "This model has no PII" social_django.Association: From d36be53863ae64ba3f93dc00e3333f9d5a558e5b Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan Date: Fri, 13 Dec 2024 17:50:19 +0500 Subject: [PATCH 10/27] fix: fixes --- commerce_coordinator/apps/paypal/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/commerce_coordinator/apps/paypal/views.py b/commerce_coordinator/apps/paypal/views.py index ea1568b2..03cea41c 100644 --- a/commerce_coordinator/apps/paypal/views.py +++ b/commerce_coordinator/apps/paypal/views.py @@ -42,11 +42,11 @@ def _get_certificate(self, url): Get certificate from the given URL """ try: + if not self._is_valid_url(url): + raise ValueError("Invalid or untrusted URL provided") from e cache = KeyValueCache.objects.get(cache_key=url) return cache.value except Exception as e: # pylint: disable=broad-exception-caught - if not self._is_valid_url(url): - raise ValueError("Invalid or untrusted URL provided") from e r = requests.get(url) # pylint: disable=missing-timeout KeyValueCache.objects.create(cache_key=url, cache_value=r.text) return r.text From cba7e7f74e2fd5b8defaabfdaac72ab17047ff03 Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan Date: Fri, 13 Dec 2024 17:56:10 +0500 Subject: [PATCH 11/27] fix: fixes --- commerce_coordinator/apps/paypal/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/commerce_coordinator/apps/paypal/views.py b/commerce_coordinator/apps/paypal/views.py index 03cea41c..94e5f984 100644 --- a/commerce_coordinator/apps/paypal/views.py +++ b/commerce_coordinator/apps/paypal/views.py @@ -41,12 +41,12 @@ def _get_certificate(self, url): """ Get certificate from the given URL """ + if not self._is_valid_url(url): + raise ValueError("Invalid or untrusted URL provided") try: - if not self._is_valid_url(url): - raise ValueError("Invalid or untrusted URL provided") from e cache = KeyValueCache.objects.get(cache_key=url) return cache.value - except Exception as e: # pylint: disable=broad-exception-caught + except KeyValueCache.DoesNotExist: r = requests.get(url) # pylint: disable=missing-timeout KeyValueCache.objects.create(cache_key=url, cache_value=r.text) return r.text From ef8af11826c0bc98510445732681f6ae5478e230 Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan Date: Fri, 13 Dec 2024 18:00:37 +0500 Subject: [PATCH 12/27] fix: fixes --- commerce_coordinator/apps/commercetools/clients.py | 4 +++- commerce_coordinator/apps/paypal/views.py | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/commerce_coordinator/apps/commercetools/clients.py b/commerce_coordinator/apps/commercetools/clients.py index eccdaec2..65d37fad 100644 --- a/commerce_coordinator/apps/commercetools/clients.py +++ b/commerce_coordinator/apps/commercetools/clients.py @@ -106,6 +106,7 @@ def __getitem__(self, item): def rebuild(self, results: List[T]): return PaginatedResult(results, total=self.total, offset=self.offset) + class Refund(TypedDict): """ Refund object definition @@ -116,6 +117,7 @@ class Refund(TypedDict): created: Union[str, int] status: str + class CommercetoolsAPIClient: """Commercetools API Client""" @@ -578,7 +580,7 @@ def update_return_payment_state_after_successful_refund( ) raise OpenEdxFilterException(str(err)) from err - def _preprocess_refund_object(self, refund: Refund, psp:str) -> Refund: + def _preprocess_refund_object(self, refund: Refund, psp: str) -> Refund: """ Pre process refund object based on PSP """ diff --git a/commerce_coordinator/apps/paypal/views.py b/commerce_coordinator/apps/paypal/views.py index 94e5f984..5286cfab 100644 --- a/commerce_coordinator/apps/paypal/views.py +++ b/commerce_coordinator/apps/paypal/views.py @@ -24,7 +24,6 @@ from .models import KeyValueCache - logger = logging.getLogger(__name__) @@ -47,7 +46,7 @@ def _get_certificate(self, url): cache = KeyValueCache.objects.get(cache_key=url) return cache.value except KeyValueCache.DoesNotExist: - r = requests.get(url) # pylint: disable=missing-timeout + r = requests.get(url) # pylint: disable=missing-timeout KeyValueCache.objects.create(cache_key=url, cache_value=r.text) return r.text From 61c6a4ca03684013c750cdb3a5b52c51e9509dd0 Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan Date: Fri, 13 Dec 2024 18:05:31 +0500 Subject: [PATCH 13/27] fix: fixes --- .../apps/commercetools/clients.py | 44 ++++++------------- .../apps/commercetools/signals.py | 9 ++-- .../apps/commercetools/tasks.py | 2 +- commerce_coordinator/apps/paypal/views.py | 9 ++-- commerce_coordinator/urls.py | 2 +- 5 files changed, 22 insertions(+), 44 deletions(-) diff --git a/commerce_coordinator/apps/commercetools/clients.py b/commerce_coordinator/apps/commercetools/clients.py index 65d37fad..1090ff0d 100644 --- a/commerce_coordinator/apps/commercetools/clients.py +++ b/commerce_coordinator/apps/commercetools/clients.py @@ -6,23 +6,15 @@ import decimal import logging from types import SimpleNamespace -from typing import Generic, List, Optional, Tuple, TypeVar, TypedDict, Union +from typing import Generic, List, Optional, Tuple, TypedDict, TypeVar, Union import requests from commercetools import Client as CTClient from commercetools import CommercetoolsError from commercetools.platform.models import Customer as CTCustomer -from commercetools.platform.models import ( - CustomerChangeEmailAction, - CustomerSetCustomFieldAction, -) -from commercetools.platform.models import ( - CustomerSetCustomTypeAction as CTCustomerSetCustomTypeAction, -) -from commercetools.platform.models import ( - CustomerSetFirstNameAction, - CustomerSetLastNameAction, -) +from commercetools.platform.models import CustomerChangeEmailAction, CustomerSetCustomFieldAction +from commercetools.platform.models import CustomerSetCustomTypeAction as CTCustomerSetCustomTypeAction +from commercetools.platform.models import CustomerSetFirstNameAction, CustomerSetLastNameAction from commercetools.platform.models import FieldContainer as CTFieldContainer from commercetools.platform.models import Money as CTMoney from commercetools.platform.models import Order as CTOrder @@ -30,13 +22,10 @@ OrderAddReturnInfoAction, OrderSetReturnItemCustomTypeAction, OrderSetReturnPaymentStateAction, - OrderTransitionLineItemStateAction, + OrderTransitionLineItemStateAction ) from commercetools.platform.models import Payment as CTPayment -from commercetools.platform.models import ( - PaymentAddTransactionAction, - PaymentSetTransactionCustomTypeAction, -) +from commercetools.platform.models import PaymentAddTransactionAction, PaymentSetTransactionCustomTypeAction from commercetools.platform.models import ProductVariant as CTProductVariant from commercetools.platform.models import ( ReturnItemDraft, @@ -44,36 +33,29 @@ ReturnShipmentState, StateResourceIdentifier, TransactionDraft, - TransactionType, + TransactionType ) from commercetools.platform.models import Type as CTType from commercetools.platform.models import TypeDraft as CTTypeDraft -from commercetools.platform.models import ( - TypeResourceIdentifier as CTTypeResourceIdentifier, -) +from commercetools.platform.models import TypeResourceIdentifier as CTTypeResourceIdentifier from commercetools.platform.models.state import State as CTLineItemState from django.conf import settings from openedx_filters.exceptions import OpenEdxFilterException from commerce_coordinator.apps.commercetools.catalog_info.constants import ( DEFAULT_ORDER_EXPANSION, - EdXFieldNames, -) -from commerce_coordinator.apps.commercetools.catalog_info.foundational_types import ( - TwoUCustomTypes, + EDX_PAYPAL_PAYMENT_INTERFACE_NAME, + EDX_STRIPE_PAYMENT_INTERFACE_NAME, + EdXFieldNames ) +from commerce_coordinator.apps.commercetools.catalog_info.foundational_types import TwoUCustomTypes from commerce_coordinator.apps.commercetools.utils import ( find_refund_transaction, handle_commercetools_error, - translate_refund_status_to_transaction_status, + translate_refund_status_to_transaction_status ) from commerce_coordinator.apps.core.constants import ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT -from commerce_coordinator.apps.commercetools.catalog_info.constants import ( - EDX_STRIPE_PAYMENT_INTERFACE_NAME, - EDX_PAYPAL_PAYMENT_INTERFACE_NAME -) - logger = logging.getLogger(__name__) T = TypeVar("T") diff --git a/commerce_coordinator/apps/commercetools/signals.py b/commerce_coordinator/apps/commercetools/signals.py index 1f2ddf8f..b568226b 100644 --- a/commerce_coordinator/apps/commercetools/signals.py +++ b/commerce_coordinator/apps/commercetools/signals.py @@ -6,14 +6,11 @@ from commerce_coordinator.apps.commercetools.catalog_info.constants import TwoUKeys from commerce_coordinator.apps.commercetools.tasks import ( - refund_from_stripe_task, refund_from_paypal_task, - update_line_item_state_on_fulfillment_completion, -) -from commerce_coordinator.apps.core.signal_helpers import ( - CoordinatorSignal, - log_receiver, + refund_from_stripe_task, + update_line_item_state_on_fulfillment_completion ) +from commerce_coordinator.apps.core.signal_helpers import CoordinatorSignal, log_receiver logger = logging.getLogger(__name__) diff --git a/commerce_coordinator/apps/commercetools/tasks.py b/commerce_coordinator/apps/commercetools/tasks.py index 50ec5a63..f775c76f 100644 --- a/commerce_coordinator/apps/commercetools/tasks.py +++ b/commerce_coordinator/apps/commercetools/tasks.py @@ -10,10 +10,10 @@ from django.conf import settings from commerce_coordinator.apps.commercetools.catalog_info.constants import EDX_PAYPAL_PAYMENT_INTERFACE_NAME + from .clients import CommercetoolsAPIClient from .utils import has_full_refund_transaction - logger = logging.getLogger(__name__) stripe.api_key = settings.PAYMENT_PROCESSOR_CONFIG['edx']['stripe']['secret_key'] diff --git a/commerce_coordinator/apps/paypal/views.py b/commerce_coordinator/apps/paypal/views.py index 5286cfab..fc0c0a30 100644 --- a/commerce_coordinator/apps/paypal/views.py +++ b/commerce_coordinator/apps/paypal/views.py @@ -4,26 +4,25 @@ import base64 import logging -from urllib.parse import urlparse import zlib +from urllib.parse import urlparse import requests from cryptography import x509 -from rest_framework.permissions import AllowAny from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import padding from django.conf import settings from rest_framework import status +from rest_framework.permissions import AllowAny from rest_framework.response import Response -from commerce_coordinator.apps.core.views import SingleInvocationAPIView -from commerce_coordinator.apps.commercetools.clients import CommercetoolsAPIClient +from commerce_coordinator.apps.commercetools.clients import CommercetoolsAPIClient +from commerce_coordinator.apps.core.views import SingleInvocationAPIView from commerce_coordinator.apps.paypal.signals import payment_refunded_signal from .models import KeyValueCache - logger = logging.getLogger(__name__) diff --git a/commerce_coordinator/urls.py b/commerce_coordinator/urls.py index 6abafd94..9e4fbc79 100644 --- a/commerce_coordinator/urls.py +++ b/commerce_coordinator/urls.py @@ -38,9 +38,9 @@ from commerce_coordinator.apps.frontend_app_ecommerce import urls as unified_orders_urls from commerce_coordinator.apps.frontend_app_payment import urls as frontend_app_payment_urls from commerce_coordinator.apps.lms import urls as lms_urls +from commerce_coordinator.apps.paypal import urls as paypal_urls from commerce_coordinator.apps.stripe import urls as stripe_urls from commerce_coordinator.apps.titan import urls as titan_urls -from commerce_coordinator.apps.paypal import urls as paypal_urls from commerce_coordinator.settings.base import FAVICON_URL admin.autodiscover() From 6ddb69c485223b91977758147463cf5cd31de92e Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan Date: Fri, 13 Dec 2024 18:48:33 +0500 Subject: [PATCH 14/27] fix: fixes --- commerce_coordinator/apps/commercetools/tasks.py | 3 +-- commerce_coordinator/apps/commercetools/tests/test_clients.py | 2 +- commerce_coordinator/apps/commercetools/tests/test_tasks.py | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/commerce_coordinator/apps/commercetools/tasks.py b/commerce_coordinator/apps/commercetools/tasks.py index f775c76f..688a19c6 100644 --- a/commerce_coordinator/apps/commercetools/tasks.py +++ b/commerce_coordinator/apps/commercetools/tasks.py @@ -56,7 +56,6 @@ def refund_from_stripe_task( Celery task for a refund registered in Stripe dashboard and need to create refund payment transaction record via Commercetools API. """ - # Celery serializes stripe_refund to a dict, so we need to explictly convert it back to a Refund object client = CommercetoolsAPIClient() try: payment = client.get_payment_by_key(payment_intent_id) @@ -72,7 +71,7 @@ def refund_from_stripe_task( return updated_payment except CommercetoolsError as err: logger.error(f"Unable to create refund transaction for payment [ {payment.id} ] " - f"on Stripe refund {stripe_refund.id} " + f"on Stripe refund {stripe_refund['id']} " f"with error {err.errors} and correlation id {err.correlation_id}") return None diff --git a/commerce_coordinator/apps/commercetools/tests/test_clients.py b/commerce_coordinator/apps/commercetools/tests/test_clients.py index 424bfa43..08357646 100644 --- a/commerce_coordinator/apps/commercetools/tests/test_clients.py +++ b/commerce_coordinator/apps/commercetools/tests/test_clients.py @@ -605,7 +605,7 @@ def test_create_refund_transaction_exception(self): expected_message = ( f"[CommercetoolsError] Unable to create refund payment transaction for " - f"payment mock_payment_id and stripe refund {mock_stripe_refund.id} " + f"payment mock_payment_id and refund {mock_stripe_refund.id} " f"- Correlation ID: {exception.correlation_id}, Details: {exception.errors}" ) diff --git a/commerce_coordinator/apps/commercetools/tests/test_tasks.py b/commerce_coordinator/apps/commercetools/tests/test_tasks.py index 3b2e6863..15dcd241 100644 --- a/commerce_coordinator/apps/commercetools/tests/test_tasks.py +++ b/commerce_coordinator/apps/commercetools/tests/test_tasks.py @@ -119,7 +119,7 @@ def test_correct_arguments_passed(self, mock_client): mock_client().create_return_payment_transaction.assert_called_once_with( payment_id=mock_payment.id, payment_version=mock_payment.version, - stripe_refund=mock_stripe_refund + refund=mock_stripe_refund ) def test_full_refund_already_exists(self, mock_client): From caed9404c66ba0f499594e3962207881259263b6 Mon Sep 17 00:00:00 2001 From: Shafqat Farhan Date: Fri, 13 Dec 2024 19:47:41 +0500 Subject: [PATCH 15/27] feat: Added tests for PayPal views file --- .../apps/paypal/tests/test_views.py | 114 ++++++++++++++++++ commerce_coordinator/apps/paypal/views.py | 2 +- 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 commerce_coordinator/apps/paypal/tests/test_views.py diff --git a/commerce_coordinator/apps/paypal/tests/test_views.py b/commerce_coordinator/apps/paypal/tests/test_views.py new file mode 100644 index 00000000..59543672 --- /dev/null +++ b/commerce_coordinator/apps/paypal/tests/test_views.py @@ -0,0 +1,114 @@ +import base64 +import zlib +from unittest.mock import patch, MagicMock + +from django.conf import settings +from django.urls import reverse +from rest_framework.test import APITestCase + +from commerce_coordinator.apps.paypal.views import PayPalWebhookView +from commerce_coordinator.apps.paypal.models import KeyValueCache + + +class PayPalWebhookViewTests(APITestCase): + """ Tests for PayPalWebhookView """ + + def setUp(self): + super().setUp() + self.url = reverse('paypal:paypal_webhook') + self.headers = { + 'paypal-transmission-id': 'test-transmission-id', + 'paypal-transmission-time': '2023-01-01T00:00:00Z', + 'paypal-transmission-sig': base64.b64encode(b'test-signature').decode('utf-8'), + 'paypal-cert-url': 'https://www.paypal.com/cert.pem', + } + self.body = b'test-body' + self.crc = zlib.crc32(self.body) + self.message = f"{self.headers['paypal-transmission-id']}|{self.headers['paypal-transmission-time']}|{settings.PAYPAL_WEBHOOK_ID}|{self.crc}" + + @patch('requests.get') + @patch('commerce_coordinator.apps.paypal.views.x509.load_pem_x509_certificate') + @patch('commerce_coordinator.apps.paypal.views.CommercetoolsAPIClient') + def test_post_refund_event(self, mock_commercetools_client, mock_load_cert, mock_requests_get): + mock_requests_get.return_value.text = 'test-cert' + mock_load_cert.return_value.public_key.return_value.verify = MagicMock() + + mock_commercetools_client.return_value.get_payment_by_transaction_interaction_id.return_value = MagicMock(key='test-paypal-order-id') + + data = { + "event_type": "PAYMENT.CAPTURE.REFUNDED", + "resource": { + "id": "test-refund-id", + "create_time": "2023-01-01T00:00:00Z", + "status": "COMPLETED", + "amount": { + "value": "100.00", + "currency_code": "USD" + }, + "invoice_id": "test-order-number", + "links": [ + {"rel": "up", "href": "https://api.paypal.com/v2/payments/captures/test-capture-id"} + ] + } + } + + response = self.client.post(self.url, data, format='json', **self.headers) + self.assertEqual(response.status_code, 200) + + @patch('requests.get') + @patch('commerce_coordinator.apps.paypal.views.x509.load_pem_x509_certificate') + def test_post_invalid_signature(self, mock_load_cert, mock_requests_get): + mock_requests_get.return_value.text = 'test-cert' + mock_load_cert.return_value.public_key.return_value.verify.side_effect = Exception("Invalid signature") + + data = { + "event_type": "PAYMENT.CAPTURE.REFUNDED", + "resource": {} + } + + response = self.client.post(self.url, data, format='json', **self.headers) + self.assertEqual(response.status_code, 400) + + @patch('requests.get') + def test_get_certificate_from_cache(self, mock_requests_get): + KeyValueCache.objects.create(cache_key=self.headers['paypal-cert-url'], cache_value='cached-cert') + view = PayPalWebhookView() + cert = view._get_certificate(self.headers['paypal-cert-url']) + self.assertEqual(cert, 'cached-cert') + mock_requests_get.assert_not_called() + + @patch('requests.get') + def test_get_certificate_from_url(self, mock_requests_get): + mock_requests_get.return_value.text = 'test-cert' + view = PayPalWebhookView() + cert = view._get_certificate(self.headers['paypal-cert-url']) + self.assertEqual(cert, 'test-cert') + mock_requests_get.assert_called_once_with(self.headers['paypal-cert-url']) + + def test_is_valid_url(self): + view = PayPalWebhookView() + self.assertTrue(view._is_valid_url('https://www.paypal.com/cert.pem')) + self.assertFalse(view._is_valid_url('ftp://www.paypal.com/cert.pem')) + self.assertFalse(view._is_valid_url('https://www.untrusted.com/cert.pem')) + + def test_missing_headers(self): + data = { + "event_type": "PAYMENT.CAPTURE.REFUNDED", + "resource": {} + } + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, 400) + + @patch('requests.get') + @patch('commerce_coordinator.apps.paypal.views.x509.load_pem_x509_certificate') + def test_invalid_event_type(self, mock_load_cert, mock_requests_get): + mock_requests_get.return_value.text = 'test-cert' + mock_load_cert.return_value.public_key.return_value.verify = MagicMock() + + data = { + "event_type": "INVALID.EVENT.TYPE", + "resource": {} + } + + response = self.client.post(self.url, data, format='json', **self.headers) + self.assertEqual(response.status_code, 200) diff --git a/commerce_coordinator/apps/paypal/views.py b/commerce_coordinator/apps/paypal/views.py index fc0c0a30..1f9a8fc5 100644 --- a/commerce_coordinator/apps/paypal/views.py +++ b/commerce_coordinator/apps/paypal/views.py @@ -43,7 +43,7 @@ def _get_certificate(self, url): raise ValueError("Invalid or untrusted URL provided") try: cache = KeyValueCache.objects.get(cache_key=url) - return cache.value + return cache.cache_value except KeyValueCache.DoesNotExist: r = requests.get(url) # pylint: disable=missing-timeout KeyValueCache.objects.create(cache_key=url, cache_value=r.text) From 24965934a9a45f22c37f6a6b78dffc10bec16504 Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan Date: Fri, 13 Dec 2024 19:51:57 +0500 Subject: [PATCH 16/27] fix: fixes --- .../apps/commercetools/clients.py | 181 +++++------------- 1 file changed, 51 insertions(+), 130 deletions(-) diff --git a/commerce_coordinator/apps/commercetools/clients.py b/commerce_coordinator/apps/commercetools/clients.py index 1090ff0d..e9564741 100644 --- a/commerce_coordinator/apps/commercetools/clients.py +++ b/commerce_coordinator/apps/commercetools/clients.py @@ -22,7 +22,7 @@ OrderAddReturnInfoAction, OrderSetReturnItemCustomTypeAction, OrderSetReturnPaymentStateAction, - OrderTransitionLineItemStateAction + OrderTransitionLineItemStateAction, ) from commercetools.platform.models import Payment as CTPayment from commercetools.platform.models import PaymentAddTransactionAction, PaymentSetTransactionCustomTypeAction @@ -33,7 +33,7 @@ ReturnShipmentState, StateResourceIdentifier, TransactionDraft, - TransactionType + TransactionType, ) from commercetools.platform.models import Type as CTType from commercetools.platform.models import TypeDraft as CTTypeDraft @@ -46,13 +46,13 @@ DEFAULT_ORDER_EXPANSION, EDX_PAYPAL_PAYMENT_INTERFACE_NAME, EDX_STRIPE_PAYMENT_INTERFACE_NAME, - EdXFieldNames + EdXFieldNames, ) from commerce_coordinator.apps.commercetools.catalog_info.foundational_types import TwoUCustomTypes from commerce_coordinator.apps.commercetools.utils import ( find_refund_transaction, handle_commercetools_error, - translate_refund_status_to_transaction_status + translate_refund_status_to_transaction_status, ) from commerce_coordinator.apps.core.constants import ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT @@ -93,6 +93,7 @@ class Refund(TypedDict): """ Refund object definition """ + id: str amount: Union[str, int] currency: str @@ -141,9 +142,7 @@ def ensure_custom_type_exists(self, type_def: CTTypeDraft) -> Optional[CTType]: except CommercetoolsError as _: # pragma: no cover # commercetools.exceptions.CommercetoolsError: The Resource with key 'edx-user_information' was not found. pass - except ( - requests.exceptions.HTTPError - ) as _: # The test framework doesn't wrap to CommercetoolsError + except requests.exceptions.HTTPError as _: # The test framework doesn't wrap to CommercetoolsError pass if not type_exists: @@ -151,9 +150,7 @@ def ensure_custom_type_exists(self, type_def: CTTypeDraft) -> Optional[CTType]: return type_object - def tag_customer_with_lms_user_info( - self, customer: CTCustomer, lms_user_id: int, lms_user_name: str - ) -> CTCustomer: + def tag_customer_with_lms_user_info(self, customer: CTCustomer, lms_user_id: int, lms_user_name: str) -> CTCustomer: """ Updates a CoCo Customer Object with what we are currently using for LMS Identifiers Args: @@ -167,9 +164,7 @@ def tag_customer_with_lms_user_info( # All updates to CT Core require the version of the object you are working on as protection from out of band # updates; this does mean we have to fetch every (primary) object we want to chain. - type_object = self.ensure_custom_type_exists( - TwoUCustomTypes.CUSTOMER_TYPE_DRAFT - ) + type_object = self.ensure_custom_type_exists(TwoUCustomTypes.CUSTOMER_TYPE_DRAFT) # A customer can only have one custom type associated to it, and thus only one set of custom fields. THUS... # They can't be required, and shouldn't entirely be relied upon; Once a proper Type is changed, the old values @@ -214,9 +209,7 @@ def get_customer_by_lms_user_id(self, lms_user_id: int) -> Optional[CTCustomer]: is returned. """ - logger.info( - f"[CommercetoolsAPIClient] - Attempting to get customer with LMS user id: {lms_user_id}" - ) + logger.info(f"[CommercetoolsAPIClient] - Attempting to get customer with LMS user id: {lms_user_id}") edx_lms_user_id_key = EdXFieldNames.LMS_USER_ID @@ -237,24 +230,17 @@ def get_customer_by_lms_user_id(self, lms_user_id: int) -> Optional[CTCustomer]: f"user id: {lms_user_id}, raising error" ) raise ValueError( - "More than one user was returned from the catalog with this edX LMS User ID, these must " - "be unique." + "More than one user was returned from the catalog with this edX LMS User ID, these must " "be unique." ) if results.count == 0: - logger.info( - f"[CommercetoolsAPIClient] - No customer found with LMS user id: {lms_user_id}" - ) + logger.info(f"[CommercetoolsAPIClient] - No customer found with LMS user id: {lms_user_id}") return None else: - logger.info( - f"[CommercetoolsAPIClient] - Customer found with LMS user id: {lms_user_id}" - ) + logger.info(f"[CommercetoolsAPIClient] - Customer found with LMS user id: {lms_user_id}") return results.results[0] - def get_order_by_id( - self, order_id: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION - ) -> CTOrder: + def get_order_by_id(self, order_id: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION) -> CTOrder: """ Fetch an order by the Order ID (UUID) @@ -264,14 +250,10 @@ def get_order_by_id( Returns (CTOrder): Order with Expanded Properties """ - logger.info( - f"[CommercetoolsAPIClient] - Attempting to find order with id: {order_id}" - ) + logger.info(f"[CommercetoolsAPIClient] - Attempting to find order with id: {order_id}") return self.base_client.orders.get_by_id(order_id, expand=list(expand)) - def get_order_by_number( - self, order_number: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION - ) -> CTOrder: + def get_order_by_number(self, order_number: str, expand: ExpandList = DEFAULT_ORDER_EXPANSION) -> CTOrder: """ Fetch an order by the Order Number (Human readable order number) @@ -281,12 +263,8 @@ def get_order_by_number( Returns (CTOrder): Order with Expanded Properties """ - logger.info( - f"[CommercetoolsAPIClient] - Attempting to find order with number {order_number}" - ) - return self.base_client.orders.get_by_order_number( - order_number, expand=list(expand) - ) + logger.info(f"[CommercetoolsAPIClient] - Attempting to find order with number {order_number}") + return self.base_client.orders.get_by_order_number(order_number, expand=list(expand)) def get_orders( self, @@ -312,8 +290,7 @@ def get_orders( """ logger.info( - f"[CommercetoolsAPIClient] - Attempting to find all completed orders for " - f"customer with ID {customer_id}" + f"[CommercetoolsAPIClient] - Attempting to find all completed orders for " f"customer with ID {customer_id}" ) order_where_clause = f'orderState="{order_state}"' @@ -351,16 +328,12 @@ def get_orders_for_customer( customer = self.get_customer_by_lms_user_id(edx_lms_user_id) if customer is None: # pragma: no cover - raise ValueError( - f"Unable to locate customer with ID #{edx_lms_user_id}" - ) + raise ValueError(f"Unable to locate customer with ID #{edx_lms_user_id}") customer_id = customer.id else: if email is None or username is None: # pragma: no cover - raise ValueError( - "If customer_id is provided, both email and username must be provided" - ) + raise ValueError("If customer_id is provided, both email and username must be provided") customer = SimpleNamespace( id=customer_id, @@ -373,57 +346,37 @@ def get_orders_for_customer( return orders, customer def get_customer_by_id(self, customer_id: str) -> CTCustomer: - logger.info( - f"[CommercetoolsAPIClient] - Attempting to find customer with ID {customer_id}" - ) + logger.info(f"[CommercetoolsAPIClient] - Attempting to find customer with ID {customer_id}") return self.base_client.customers.get_by_id(customer_id) def get_state_by_id(self, state_id: str) -> CTLineItemState: - logger.info( - f"[CommercetoolsAPIClient] - Attempting to find state with id {state_id}" - ) + logger.info(f"[CommercetoolsAPIClient] - Attempting to find state with id {state_id}") return self.base_client.states.get_by_id(state_id) def get_state_by_key(self, state_key: str) -> CTLineItemState: - logger.info( - f"[CommercetoolsAPIClient] - Attempting to find state with key {state_key}" - ) + logger.info(f"[CommercetoolsAPIClient] - Attempting to find state with key {state_key}") return self.base_client.states.get_by_key(state_key) def get_payment_by_key(self, payment_key: str) -> CTPayment: - logger.info( - f"[CommercetoolsAPIClient] - Attempting to find payment with key {payment_key}" - ) + logger.info(f"[CommercetoolsAPIClient] - Attempting to find payment with key {payment_key}") return self.base_client.payments.get_by_key(payment_key) - def get_payment_by_transaction_interaction_id( - self, interaction_id: str - ) -> CTPayment: + def get_payment_by_transaction_interaction_id(self, interaction_id: str) -> CTPayment: """ Fetch a payment by the transaction interaction ID """ - logger.info( - f"[CommercetoolsAPIClient] - Attempting to find payment with interaction ID {interaction_id}" - ) - return self.base_client.payments.query( - where=f'transactions(interactionId="{interaction_id}")' - ).results[0] + logger.info(f"[CommercetoolsAPIClient] - Attempting to find payment with interaction ID {interaction_id}") + return self.base_client.payments.query(where=f'transactions(interactionId="{interaction_id}")').results[0] - def get_product_variant_by_course_run( - self, cr_id: str - ) -> Optional[CTProductVariant]: + def get_product_variant_by_course_run(self, cr_id: str) -> Optional[CTProductVariant]: """ Args: cr_id: variant course run key """ start_time = datetime.datetime.now() - results = self.base_client.product_projections.search( - False, filter=f'variants.sku:"{cr_id}"' - ).results + results = self.base_client.product_projections.search(False, filter=f'variants.sku:"{cr_id}"').results duration = (datetime.datetime.now() - start_time).total_seconds() - logger.info( - f"[Performance Check] get_product_variant_by_course_run took {duration} seconds" - ) + logger.info(f"[Performance Check] get_product_variant_by_course_run took {duration} seconds") if len(results) < 1: # pragma no cover return None @@ -447,9 +400,7 @@ def get_product_variant_by_course_run( return matching_variant_list[0] - def create_return_for_order( - self, order_id: str, order_version: int, order_line_item_id: str - ) -> CTOrder: + def create_return_for_order(self, order_id: str, order_version: int, order_line_item_id: str) -> CTOrder: """ Creates refund/return for Commercetools order Args: @@ -462,8 +413,7 @@ def create_return_for_order( try: return_item_draft_comment = ( - f"Creating return item for order {order_id} with " - f"order line item ID {order_line_item_id}" + f"Creating return item for order {order_id} with " f"order line item ID {order_line_item_id}" ) logger.info(f"[CommercetoolsAPIClient] - {return_item_draft_comment}") @@ -482,9 +432,7 @@ def create_return_for_order( ) return returned_order except CommercetoolsError as err: - handle_commercetools_error( - err, f"Unable to create return for order {order_id}" - ) + handle_commercetools_error(err, f"Unable to create return for order {order_id}") raise err def update_return_payment_state_after_successful_refund( @@ -518,9 +466,7 @@ def update_return_payment_state_after_successful_refund( ) if not payment_intent_id: payment_intent_id = "" - logger.info( - f"Creating return for order - payment_intent_id: {payment_intent_id}" - ) + logger.info(f"Creating return for order - payment_intent_id: {payment_intent_id}") payment = self.get_payment_by_key(payment_intent_id) logger.info(f"Payment found: {payment}") transaction_id = find_refund_transaction(payment, amount_in_cents) @@ -531,18 +477,12 @@ def update_return_payment_state_after_successful_refund( ), fields=CTFieldContainer({"transactionId": transaction_id}), ) - return_transaction_return_item_action = ( - PaymentSetTransactionCustomTypeAction( - transaction_id=transaction_id, - type=CTTypeResourceIdentifier(key="transactionCustomType"), - fields=CTFieldContainer( - {"returnItemId": return_line_item_return_id} - ), - ) - ) - logger.info( - f"Update return payment state after successful refund - payment_intent_id: {payment_intent_id}" + return_transaction_return_item_action = PaymentSetTransactionCustomTypeAction( + transaction_id=transaction_id, + type=CTTypeResourceIdentifier(key="transactionCustomType"), + fields=CTFieldContainer({"returnItemId": return_line_item_return_id}), ) + logger.info(f"Update return payment state after successful refund - payment_intent_id: {payment_intent_id}") updated_order = self.base_client.orders.update_by_id( id=order_id, @@ -557,9 +497,7 @@ def update_return_payment_state_after_successful_refund( logger.info("Updated transaction with return item id") return updated_order except CommercetoolsError as err: - handle_commercetools_error( - err, f"Unable to update ReturnPaymentState of order {order_id}" - ) + handle_commercetools_error(err, f"Unable to update ReturnPaymentState of order {order_id}") raise OpenEdxFilterException(str(err)) from err def _preprocess_refund_object(self, refund: Refund, psp: str) -> Refund: @@ -577,11 +515,7 @@ def _preprocess_refund_object(self, refund: Refund, psp: str) -> Refund: return refund def create_return_payment_transaction( - self, - payment_id: str, - payment_version: int, - refund: Refund, - psp=EDX_STRIPE_PAYMENT_INTERFACE_NAME + self, payment_id: str, payment_version: int, refund: Refund, psp=EDX_STRIPE_PAYMENT_INTERFACE_NAME ) -> CTPayment: """ Create Commercetools payment transaction for refund @@ -600,9 +534,7 @@ def create_return_payment_transaction( refund = self._preprocess_refund_object(refund, psp) amount_as_money = CTMoney( - cent_amount=float( - refund["amount"] - ), + cent_amount=float(refund["amount"]), currency_code=refund["currency"], ) @@ -614,9 +546,7 @@ def create_return_payment_transaction( interaction_id=refund["id"], ) - add_transaction_action = PaymentAddTransactionAction( - transaction=transaction_draft - ) + add_transaction_action = PaymentAddTransactionAction(transaction=transaction_draft) returned_payment = self.base_client.payments.update_by_id( id=payment_id, version=payment_version, actions=[add_transaction_action] @@ -625,8 +555,7 @@ def create_return_payment_transaction( return returned_payment except CommercetoolsError as err: context = ( - f"Unable to create refund payment transaction for " - f"payment {payment_id} and refund {refund['id']}" + f"Unable to create refund payment transaction for " f"payment {payment_id} and refund {refund['id']}" ) handle_commercetools_error(err, context) raise err @@ -670,12 +599,10 @@ def update_line_item_transition_state_on_fulfillment( to_state=StateResourceIdentifier(key=new_state_key), ) - updated_fulfillment_line_item_order = ( - self.base_client.orders.update_by_id( - id=order_id, - version=order_version, - actions=[transition_line_item_state_action], - ) + updated_fulfillment_line_item_order = self.base_client.orders.update_by_id( + id=order_id, + version=order_version, + actions=[transition_line_item_state_action], ) return updated_fulfillment_line_item_order @@ -687,9 +614,7 @@ def update_line_item_transition_state_on_fulfillment( return self.get_order_by_id(order_id) except CommercetoolsError as err: # Logs & ignores version conflict errors due to duplicate Commercetools messages - handle_commercetools_error( - err, f"Unable to update LineItemState of order {order_id}", True - ) + handle_commercetools_error(err, f"Unable to update LineItemState of order {order_id}", True) return None def retire_customer_anonymize_fields( @@ -715,13 +640,9 @@ def retire_customer_anonymize_fields( """ actions = [] - update_retired_first_name_action = CustomerSetFirstNameAction( - first_name=retired_first_name - ) + update_retired_first_name_action = CustomerSetFirstNameAction(first_name=retired_first_name) - update_retired_last_name_action = CustomerSetLastNameAction( - last_name=retired_last_name - ) + update_retired_last_name_action = CustomerSetLastNameAction(last_name=retired_last_name) update_retired_email_action = CustomerChangeEmailAction(email=retired_email) From 99d0e43e78bef5ccaf6e98637cba58964c6212b2 Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan Date: Fri, 13 Dec 2024 22:29:09 +0500 Subject: [PATCH 17/27] fix: fixes --- .annotation_safe_list.yml | 2 - .../apps/commercetools/clients.py | 14 ++-- .../apps/commercetools/signals.py | 2 +- .../apps/commercetools/tasks.py | 6 +- .../apps/commercetools/tests/test_clients.py | 2 +- .../migrations/0001_key_value_cache_model.py | 22 ------ commerce_coordinator/apps/paypal/models.py | 9 --- .../apps/paypal/tests/test_views.py | 44 ++++-------- commerce_coordinator/apps/paypal/views.py | 69 ++++++++++--------- 9 files changed, 64 insertions(+), 106 deletions(-) delete mode 100644 commerce_coordinator/apps/paypal/migrations/0001_key_value_cache_model.py delete mode 100644 commerce_coordinator/apps/paypal/models.py diff --git a/.annotation_safe_list.yml b/.annotation_safe_list.yml index a15e1572..45c6a18f 100644 --- a/.annotation_safe_list.yml +++ b/.annotation_safe_list.yml @@ -15,8 +15,6 @@ auth.Permission: ".. no_pii:": "This model has no PII" contenttypes.ContentType: ".. no_pii:": "This model has no PII" -paypal.KeyValueCache: - ".. no_pii:": "This model has no PII" sessions.Session: ".. no_pii:": "This model has no PII" social_django.Association: diff --git a/commerce_coordinator/apps/commercetools/clients.py b/commerce_coordinator/apps/commercetools/clients.py index e9564741..7f828f57 100644 --- a/commerce_coordinator/apps/commercetools/clients.py +++ b/commerce_coordinator/apps/commercetools/clients.py @@ -230,7 +230,7 @@ def get_customer_by_lms_user_id(self, lms_user_id: int) -> Optional[CTCustomer]: f"user id: {lms_user_id}, raising error" ) raise ValueError( - "More than one user was returned from the catalog with this edX LMS User ID, these must " "be unique." + "More than one user was returned from the catalog with this edX LMS User ID, these must be unique." ) if results.count == 0: @@ -505,6 +505,7 @@ def _preprocess_refund_object(self, refund: Refund, psp: str) -> Refund: Pre process refund object based on PSP """ if psp == EDX_PAYPAL_PAYMENT_INTERFACE_NAME: + # Paypal sends amount in dollars and CT expects it in cents refund["amount"] = float(refund["amount"]) * 100 refund["created"] = datetime.datetime.fromisoformat(refund["created"]) else: @@ -522,19 +523,19 @@ def create_return_payment_transaction( Args: payment_id (str): Payment ID (UUID) payment_version (int): Current version of payment - refund (stripe.Refund): Refund object + refund (Refund): Refund object Returns (CTPayment): Updated payment object or Raises Exception: Error if creation was unsuccessful. """ try: logger.info( f"[CommercetoolsAPIClient] - Creating refund transaction for payment with ID {payment_id} " - f"following successful refund {refund['id']}" + f"following successful refund {refund['id']} in PSP: {psp}" ) refund = self._preprocess_refund_object(refund, psp) amount_as_money = CTMoney( - cent_amount=float(refund["amount"]), + cent_amount=int(refund["amount"]), currency_code=refund["currency"], ) @@ -555,7 +556,8 @@ def create_return_payment_transaction( return returned_payment except CommercetoolsError as err: context = ( - f"Unable to create refund payment transaction for " f"payment {payment_id} and refund {refund['id']}" + f"Unable to create refund payment transaction for payment {payment_id}, refund {refund['id']} " + f"with PSP: {psp}" ) handle_commercetools_error(err, context) raise err @@ -586,7 +588,7 @@ def update_line_item_transition_state_on_fulfillment( from_state_key = self.get_state_by_id(from_state_id).key logger.info( - f"[CommercetoolsAPIClient] - Transitioning line item state for order with ID {order_id}" + f"[CommercetoolsAPIClient] - Transitioning line item state for order with ID {order_id} " f"from {from_state_key} to {new_state_key}" ) diff --git a/commerce_coordinator/apps/commercetools/signals.py b/commerce_coordinator/apps/commercetools/signals.py index b568226b..e79945a9 100644 --- a/commerce_coordinator/apps/commercetools/signals.py +++ b/commerce_coordinator/apps/commercetools/signals.py @@ -60,6 +60,6 @@ def refund_from_paypal(**kwargs): Create a refund transaction in Commercetools based on a refund created from the PayPal dashboard """ async_result = refund_from_paypal_task.delay( - paypal_order_id=kwargs["paypal_order_id"], refund=kwargs["refund"] + paypal_capture_id=kwargs["paypal_capture_id"], refund=kwargs["refund"] ) return async_result.id diff --git a/commerce_coordinator/apps/commercetools/tasks.py b/commerce_coordinator/apps/commercetools/tasks.py index 688a19c6..d61eb8da 100644 --- a/commerce_coordinator/apps/commercetools/tasks.py +++ b/commerce_coordinator/apps/commercetools/tasks.py @@ -78,7 +78,7 @@ def refund_from_stripe_task( @shared_task(autoretry_for=(CommercetoolsError,), retry_kwargs={'max_retries': 5, 'countdown': 3}) def refund_from_paypal_task( - paypal_order_id, + paypal_capture_id, refund ): """ @@ -87,7 +87,7 @@ def refund_from_paypal_task( """ client = CommercetoolsAPIClient() try: - payment = client.get_payment_by_key(paypal_order_id) + payment = client.get_payment_by_transaction_interaction_id(paypal_capture_id) updated_payment = client.create_return_payment_transaction( payment_id=payment.id, payment_version=payment.version, @@ -96,7 +96,7 @@ def refund_from_paypal_task( ) return updated_payment except CommercetoolsError as err: - logger.error(f"Unable to create refund transaction for payment [ {paypal_order_id} ] " + logger.error(f"Unable to create refund transaction for payment {payment.key} " f"on PayPal refund {refund.id} " f"with error {err.errors} and correlation id {err.correlation_id}") return None diff --git a/commerce_coordinator/apps/commercetools/tests/test_clients.py b/commerce_coordinator/apps/commercetools/tests/test_clients.py index 08357646..a3b6cfde 100644 --- a/commerce_coordinator/apps/commercetools/tests/test_clients.py +++ b/commerce_coordinator/apps/commercetools/tests/test_clients.py @@ -605,7 +605,7 @@ def test_create_refund_transaction_exception(self): expected_message = ( f"[CommercetoolsError] Unable to create refund payment transaction for " - f"payment mock_payment_id and refund {mock_stripe_refund.id} " + f"payment mock_payment_id, refund {mock_stripe_refund.id} with PSP: stripe_edx " f"- Correlation ID: {exception.correlation_id}, Details: {exception.errors}" ) diff --git a/commerce_coordinator/apps/paypal/migrations/0001_key_value_cache_model.py b/commerce_coordinator/apps/paypal/migrations/0001_key_value_cache_model.py deleted file mode 100644 index 11ff1a74..00000000 --- a/commerce_coordinator/apps/paypal/migrations/0001_key_value_cache_model.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.13 on 2024-12-13 11:51 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='KeyValueCache', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('cache_key', models.CharField(max_length=255, unique=True)), - ('cache_value', models.TextField()), - ], - ), - ] diff --git a/commerce_coordinator/apps/paypal/models.py b/commerce_coordinator/apps/paypal/models.py deleted file mode 100644 index d81825a5..00000000 --- a/commerce_coordinator/apps/paypal/models.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -Models for Paypal app -""" -from django.db import models - - -class KeyValueCache(models.Model): - cache_key = models.CharField(max_length=255, unique=True) - cache_value = models.TextField() diff --git a/commerce_coordinator/apps/paypal/tests/test_views.py b/commerce_coordinator/apps/paypal/tests/test_views.py index 59543672..3ad9ea3b 100644 --- a/commerce_coordinator/apps/paypal/tests/test_views.py +++ b/commerce_coordinator/apps/paypal/tests/test_views.py @@ -1,3 +1,6 @@ +""" +Paypal views test cases +""" import base64 import zlib from unittest.mock import patch, MagicMock @@ -7,7 +10,6 @@ from rest_framework.test import APITestCase from commerce_coordinator.apps.paypal.views import PayPalWebhookView -from commerce_coordinator.apps.paypal.models import KeyValueCache class PayPalWebhookViewTests(APITestCase): @@ -24,17 +26,17 @@ def setUp(self): } self.body = b'test-body' self.crc = zlib.crc32(self.body) - self.message = f"{self.headers['paypal-transmission-id']}|{self.headers['paypal-transmission-time']}|{settings.PAYPAL_WEBHOOK_ID}|{self.crc}" + self.message = ( + f"{self.headers['paypal-transmission-id']}|{self.headers['paypal-transmission-time']}|" + f"{settings.PAYPAL_WEBHOOK_ID}|{self.crc}" + ) @patch('requests.get') @patch('commerce_coordinator.apps.paypal.views.x509.load_pem_x509_certificate') - @patch('commerce_coordinator.apps.paypal.views.CommercetoolsAPIClient') - def test_post_refund_event(self, mock_commercetools_client, mock_load_cert, mock_requests_get): + def test_post_refund_event(self, mock_load_cert, mock_requests_get): mock_requests_get.return_value.text = 'test-cert' mock_load_cert.return_value.public_key.return_value.verify = MagicMock() - mock_commercetools_client.return_value.get_payment_by_transaction_interaction_id.return_value = MagicMock(key='test-paypal-order-id') - data = { "event_type": "PAYMENT.CAPTURE.REFUNDED", "resource": { @@ -52,7 +54,7 @@ def test_post_refund_event(self, mock_commercetools_client, mock_load_cert, mock } } - response = self.client.post(self.url, data, format='json', **self.headers) + response = self.client.post(self.url, data, format='json', headers=self.headers) self.assertEqual(response.status_code, 200) @patch('requests.get') @@ -66,38 +68,22 @@ def test_post_invalid_signature(self, mock_load_cert, mock_requests_get): "resource": {} } - response = self.client.post(self.url, data, format='json', **self.headers) + response = self.client.post(self.url, data, format='json', headers=self.headers) self.assertEqual(response.status_code, 400) - @patch('requests.get') - def test_get_certificate_from_cache(self, mock_requests_get): - KeyValueCache.objects.create(cache_key=self.headers['paypal-cert-url'], cache_value='cached-cert') - view = PayPalWebhookView() - cert = view._get_certificate(self.headers['paypal-cert-url']) - self.assertEqual(cert, 'cached-cert') - mock_requests_get.assert_not_called() - @patch('requests.get') def test_get_certificate_from_url(self, mock_requests_get): mock_requests_get.return_value.text = 'test-cert' view = PayPalWebhookView() - cert = view._get_certificate(self.headers['paypal-cert-url']) + cert = view._get_certificate(self.headers['paypal-cert-url']) # pylint: disable=protected-access self.assertEqual(cert, 'test-cert') mock_requests_get.assert_called_once_with(self.headers['paypal-cert-url']) def test_is_valid_url(self): view = PayPalWebhookView() - self.assertTrue(view._is_valid_url('https://www.paypal.com/cert.pem')) - self.assertFalse(view._is_valid_url('ftp://www.paypal.com/cert.pem')) - self.assertFalse(view._is_valid_url('https://www.untrusted.com/cert.pem')) - - def test_missing_headers(self): - data = { - "event_type": "PAYMENT.CAPTURE.REFUNDED", - "resource": {} - } - response = self.client.post(self.url, data, format='json') - self.assertEqual(response.status_code, 400) + self.assertTrue(view._is_valid_url('https://www.paypal.com/cert.pem')) # pylint: disable=protected-access + self.assertFalse(view._is_valid_url('ftp://www.paypal.com/cert.pem')) # pylint: disable=protected-access + self.assertFalse(view._is_valid_url('https://www.untrusted.com/cert.pem')) # pylint: disable=protected-access @patch('requests.get') @patch('commerce_coordinator.apps.paypal.views.x509.load_pem_x509_certificate') @@ -110,5 +96,5 @@ def test_invalid_event_type(self, mock_load_cert, mock_requests_get): "resource": {} } - response = self.client.post(self.url, data, format='json', **self.headers) + response = self.client.post(self.url, data, format='json', headers=self.headers) self.assertEqual(response.status_code, 200) diff --git a/commerce_coordinator/apps/paypal/views.py b/commerce_coordinator/apps/paypal/views.py index 1f9a8fc5..5e61fb28 100644 --- a/commerce_coordinator/apps/paypal/views.py +++ b/commerce_coordinator/apps/paypal/views.py @@ -16,12 +16,11 @@ from rest_framework import status from rest_framework.permissions import AllowAny from rest_framework.response import Response +from rest_framework.throttling import UserRateThrottle -from commerce_coordinator.apps.commercetools.clients import CommercetoolsAPIClient from commerce_coordinator.apps.core.views import SingleInvocationAPIView from commerce_coordinator.apps.paypal.signals import payment_refunded_signal -from .models import KeyValueCache logger = logging.getLogger(__name__) @@ -34,6 +33,8 @@ class PayPalWebhookView(SingleInvocationAPIView): http_method_names = ["post"] authentication_classes = [] permission_classes = [AllowAny] + # TODO: Limit the view to our paypal webhook servers only and remove throttling + throttle_classes = [UserRateThrottle] def _get_certificate(self, url): """ @@ -41,13 +42,8 @@ def _get_certificate(self, url): """ if not self._is_valid_url(url): raise ValueError("Invalid or untrusted URL provided") - try: - cache = KeyValueCache.objects.get(cache_key=url) - return cache.cache_value - except KeyValueCache.DoesNotExist: - r = requests.get(url) # pylint: disable=missing-timeout - KeyValueCache.objects.create(cache_key=url, cache_value=r.text) - return r.text + r = requests.get(url) # pylint: disable=missing-timeout + return r.text def _is_valid_url(self, url): """ @@ -67,16 +63,12 @@ def post(self, request): """ Handle POST request """ - tag = type(self).__name__ body = request.body + tag = type(self).__name__ + twou_order_number = request.data.get("resource").get("invoice_id", None) + event_type = request.data.get("event_type")\ transmission_id = request.headers.get("paypal-transmission-id") - if self._is_running(tag, transmission_id): # pragma no cover - self.meta_should_mark_not_running = False - return Response(status=status.HTTP_200_OK) - else: - self.mark_running(tag, transmission_id) - timestamp = request.headers.get("paypal-transmission-time") crc = zlib.crc32(body) webhook_id = settings.PAYPAL_WEBHOOK_ID @@ -85,6 +77,7 @@ def post(self, request): signature = base64.b64decode(request.headers.get("paypal-transmission-sig")) certificate = self._get_certificate(request.headers.get("paypal-cert-url")) + cert = x509.load_pem_x509_certificate( certificate.encode("utf-8"), default_backend() ) @@ -95,32 +88,36 @@ def post(self, request): public_key.verify( signature, message.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256() ) - except Exception: # pylint: disable=broad-exception-caught + except Exception as error: # pylint: disable=broad-exception-caught + logger.exception("Encountered exception %s verifying paypal certificate for ct_order: %s for event %s", + error, + twou_order_number, + event_type) return Response(status=status.HTTP_400_BAD_REQUEST) - if request.data.get("event_type") == "PAYMENT.CAPTURE.REFUNDED": - twou_order_number = request.data.get("resource").get("invoice_id", None) - capture_url = request.data.get("resource").get("links", []) - capture_id = None - for link in capture_url: + if event_type == "PAYMENT.CAPTURE.REFUNDED": + refund_id = request.data.get("resource").get("id") + if self._is_running(tag, refund_id): # pragma no cover + self.meta_should_mark_not_running = False + return Response(status=status.HTTP_200_OK) + else: + self.mark_running(tag, refund_id) + refund_urls = request.data.get("resource").get("links", []) + paypal_capture_id = None + for link in refund_urls: if link.get("rel") == "up" and "captures" in link.get("href"): - capture_id = link.get("href").split("/")[-1] + paypal_capture_id = link.get("href").split("/")[-1] break - ct_api_client = CommercetoolsAPIClient() - payment = ct_api_client.get_payment_by_transaction_interaction_id( - capture_id - ) - paypal_order_id = payment.key logger.info( - "[Paypal webhooks] refund event %s with order_number [%s], paypal_order_id [%s] received", - request.data.get("event_type"), + "[Paypal webhooks] refund event %s with order_number %s, paypal_capture_id %s received", + event_type, twou_order_number, - paypal_order_id, + paypal_capture_id, ) refund = { - "id": request.data.get("resource").get("id"), + "id": refund_id, "created": request.data.get("resource").get("create_time"), "status": request.data.get("resource").get("status"), "amount": request.data.get("resource").get("amount").get("value"), @@ -128,7 +125,13 @@ def post(self, request): } payment_refunded_signal.send_robust( - sender=self.__class__, paypal_order_id=paypal_order_id, refund=refund + sender=self.__class__, paypal_capture_id=paypal_capture_id, refund=refund + ) + else: + logger.info( + "[Paypal webhooks] Unhandled Paypal event %s received with payload %s", + event_type, + request.data, ) return Response(status=status.HTTP_200_OK) From 33ef20a161010447fe8c03018ee8b0b7530694d6 Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan Date: Fri, 13 Dec 2024 22:42:05 +0500 Subject: [PATCH 18/27] fix: fixes --- commerce_coordinator/apps/paypal/views.py | 6 ++++-- commerce_coordinator/settings/base.py | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/commerce_coordinator/apps/paypal/views.py b/commerce_coordinator/apps/paypal/views.py index 5e61fb28..ad474eee 100644 --- a/commerce_coordinator/apps/paypal/views.py +++ b/commerce_coordinator/apps/paypal/views.py @@ -24,6 +24,7 @@ logger = logging.getLogger(__name__) +WEBHOOK_ID = settings.PAYMENT_PROCESSOR_CONFIG['edx']['paypal']['paypal_webhook_id'] class PayPalWebhookView(SingleInvocationAPIView): """ @@ -66,12 +67,13 @@ def post(self, request): body = request.body tag = type(self).__name__ twou_order_number = request.data.get("resource").get("invoice_id", None) - event_type = request.data.get("event_type")\ + event_type = request.data.get("event_type") + webhook_id = WEBHOOK_ID transmission_id = request.headers.get("paypal-transmission-id") timestamp = request.headers.get("paypal-transmission-time") crc = zlib.crc32(body) - webhook_id = settings.PAYPAL_WEBHOOK_ID + message = f"{transmission_id}|{timestamp}|{webhook_id}|{crc}" signature = base64.b64decode(request.headers.get("paypal-transmission-sig")) diff --git a/commerce_coordinator/settings/base.py b/commerce_coordinator/settings/base.py index 4a474243..27aebe0d 100644 --- a/commerce_coordinator/settings/base.py +++ b/commerce_coordinator/settings/base.py @@ -419,6 +419,7 @@ def root(*path_fragments): } STRIPE_WEBHOOK_ENDPOINT_SECRET = 'SET-ME-PLEASE' +PAYPAL_WEBHOOK_ID="" # PAYMENT PROCESSING PAYMENT_PROCESSOR_CONFIG = { @@ -436,6 +437,7 @@ def root(*path_fragments): }, 'paypal': { 'user_activity_page_url': '', + 'paypal_webhook_id': PAYPAL_WEBHOOK_ID, }, }, } From a66b362fa90f558de95217bd277eed72d61e0986 Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan Date: Fri, 13 Dec 2024 22:51:25 +0500 Subject: [PATCH 19/27] fix: fixes --- commerce_coordinator/settings/base.py | 2 +- commerce_coordinator/settings/local.py | 1 + commerce_coordinator/settings/test.py | 115 +++++++++++++------------ 3 files changed, 61 insertions(+), 57 deletions(-) diff --git a/commerce_coordinator/settings/base.py b/commerce_coordinator/settings/base.py index 27aebe0d..f37acf95 100644 --- a/commerce_coordinator/settings/base.py +++ b/commerce_coordinator/settings/base.py @@ -419,7 +419,7 @@ def root(*path_fragments): } STRIPE_WEBHOOK_ENDPOINT_SECRET = 'SET-ME-PLEASE' -PAYPAL_WEBHOOK_ID="" +PAYPAL_WEBHOOK_ID="SET-ME-PLEASE" # PAYMENT PROCESSING PAYMENT_PROCESSOR_CONFIG = { diff --git a/commerce_coordinator/settings/local.py b/commerce_coordinator/settings/local.py index 6fbc5911..a41afccd 100644 --- a/commerce_coordinator/settings/local.py +++ b/commerce_coordinator/settings/local.py @@ -146,6 +146,7 @@ }, 'paypal': { 'user_activity_page_url': 'https://sandbox.paypal.com/myaccount/activities/', + 'paypal_webhook_id': 'SET-ME-PLEASE', }, }, } diff --git a/commerce_coordinator/settings/test.py b/commerce_coordinator/settings/test.py index 0d0b3f93..491f68b0 100644 --- a/commerce_coordinator/settings/test.py +++ b/commerce_coordinator/settings/test.py @@ -3,17 +3,20 @@ from commerce_coordinator.settings.base import * PAYMENT_PROCESSOR_CONFIG = { - 'edx': { - 'stripe': { - 'api_version': '2022-08-01; server_side_confirmation_beta=v1', - 'enable_telemetry': None, - 'log_level': 'info', - 'max_network_retries': 0, - 'proxy': None, - 'publishable_key': 'SET-ME-PLEASE', - 'secret_key': 'SET-ME-PLEASE', - 'source_system_identifier': 'edx/commerce_coordinator?v=1', - 'webhook_endpoint_secret': 'SET-ME-PLEASE', + "edx": { + "stripe": { + "api_version": "2022-08-01; server_side_confirmation_beta=v1", + "enable_telemetry": None, + "log_level": "info", + "max_network_retries": 0, + "proxy": None, + "publishable_key": "SET-ME-PLEASE", + "secret_key": "SET-ME-PLEASE", + "source_system_identifier": "edx/commerce_coordinator?v=1", + "webhook_endpoint_secret": "SET-ME-PLEASE", + }, + "paypal": { + "paypal_webhook_id": "SET-ME-PLEASE", }, 'paypal': { 'user_activity_page_url': 'https://test.paypal.com/myaccount/activities/', @@ -24,13 +27,13 @@ # IN-MEMORY TEST DATABASE DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', - 'USER': '', - 'PASSWORD': '', - 'HOST': '', - 'PORT': '', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + "USER": "", + "PASSWORD": "", + "HOST": "", + "PORT": "", }, } # END IN-MEMORY TEST DATABASE @@ -38,66 +41,66 @@ # CACHE CONFIGURATION # See: https://docs.djangoproject.com/en/dev/ref/settings/#caches CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", } } CC_SIGNALS = { # DEMO: This configuration is just for proof-of-concept and can # be removed once we have real signals - 'commerce_coordinator.apps.core.signals.test_signal': [ - 'commerce_coordinator.apps.demo_lms.signals.test_receiver', - 'commerce_coordinator.apps.core.signals.test_receiver_exception', - 'commerce_coordinator.apps.core.signals.test_celery_task', + "commerce_coordinator.apps.core.signals.test_signal": [ + "commerce_coordinator.apps.demo_lms.signals.test_receiver", + "commerce_coordinator.apps.core.signals.test_receiver_exception", + "commerce_coordinator.apps.core.signals.test_celery_task", ], - 'commerce_coordinator.apps.demo_lms.signals.purchase_complete_signal': [ - 'commerce_coordinator.apps.demo_lms.signals.demo_purchase_complete_order_history', - 'commerce_coordinator.apps.demo_lms.signals.demo_purchase_complete_send_confirmation_email', - 'commerce_coordinator.apps.demo_lms.signals.demo_purchase_complete_enroll_in_course', + "commerce_coordinator.apps.demo_lms.signals.purchase_complete_signal": [ + "commerce_coordinator.apps.demo_lms.signals.demo_purchase_complete_order_history", + "commerce_coordinator.apps.demo_lms.signals.demo_purchase_complete_send_confirmation_email", + "commerce_coordinator.apps.demo_lms.signals.demo_purchase_complete_enroll_in_course", ], - 'commerce_coordinator.apps.demo_lms.signals.enroll_learner_signal': [ - 'commerce_coordinator.apps.demo_lms.signals.demo_enroll_learner_in_course', + "commerce_coordinator.apps.demo_lms.signals.enroll_learner_signal": [ + "commerce_coordinator.apps.demo_lms.signals.demo_enroll_learner_in_course", ], # Actual Production Signals - 'commerce_coordinator.apps.ecommerce.signals.enrollment_code_redemption_requested_signal': [ - 'commerce_coordinator.apps.titan.signals.enrollment_code_redemption_requested_create_order', + "commerce_coordinator.apps.ecommerce.signals.enrollment_code_redemption_requested_signal": [ + "commerce_coordinator.apps.titan.signals.enrollment_code_redemption_requested_create_order", ], - 'commerce_coordinator.apps.titan.signals.fulfill_order_placed_signal': [ - 'commerce_coordinator.apps.lms.signal_handlers.fulfill_order_placed_send_enroll_in_course', + "commerce_coordinator.apps.titan.signals.fulfill_order_placed_signal": [ + "commerce_coordinator.apps.lms.signal_handlers.fulfill_order_placed_send_enroll_in_course", ], - 'commerce_coordinator.apps.ecommerce.signals.order_created_signal': [ - 'commerce_coordinator.apps.titan.signals.order_created_save', + "commerce_coordinator.apps.ecommerce.signals.order_created_signal": [ + "commerce_coordinator.apps.titan.signals.order_created_save", ], - 'commerce_coordinator.apps.stripe.signals.payment_processed_signal': [ - 'commerce_coordinator.apps.titan.signals.payment_processed_save', + "commerce_coordinator.apps.stripe.signals.payment_processed_signal": [ + "commerce_coordinator.apps.titan.signals.payment_processed_save", ], - 'commerce_coordinator.apps.commercetools.signals.fulfill_order_placed_signal': [ - 'commerce_coordinator.apps.lms.signal_handlers.fulfill_order_placed_send_enroll_in_course', + "commerce_coordinator.apps.commercetools.signals.fulfill_order_placed_signal": [ + "commerce_coordinator.apps.lms.signal_handlers.fulfill_order_placed_send_enroll_in_course", ], - 'commerce_coordinator.apps.lms.signals.fulfillment_completed_signal': [ - 'commerce_coordinator.apps.commercetools.signals.fulfill_order_completed_send_line_item_state', + "commerce_coordinator.apps.lms.signals.fulfillment_completed_signal": [ + "commerce_coordinator.apps.commercetools.signals.fulfill_order_completed_send_line_item_state", ], - 'commerce_coordinator.apps.commercetools.sub_messages.signals_dispatch.fulfill_order_placed_message_signal': [ - 'commerce_coordinator.apps.commercetools.sub_messages.signals_delayed.fulfill_order_placed_message_signal', + "commerce_coordinator.apps.commercetools.sub_messages.signals_dispatch.fulfill_order_placed_message_signal": [ + "commerce_coordinator.apps.commercetools.sub_messages.signals_delayed.fulfill_order_placed_message_signal", ], - 'commerce_coordinator.apps.commercetools.sub_messages.signals_dispatch.fulfill_order_sanctioned_message_signal': [ - 'commerce_coordinator.apps.commercetools.sub_messages.signals_delayed.fulfill_order_sanctioned_message_signal', + "commerce_coordinator.apps.commercetools.sub_messages.signals_dispatch.fulfill_order_sanctioned_message_signal": [ + "commerce_coordinator.apps.commercetools.sub_messages.signals_delayed.fulfill_order_sanctioned_message_signal", ], - 'commerce_coordinator.apps.commercetools.sub_messages.signals_dispatch.fulfill_order_returned_signal': [ - 'commerce_coordinator.apps.commercetools.sub_messages.signals_delayed.fulfill_order_returned_signal', + "commerce_coordinator.apps.commercetools.sub_messages.signals_dispatch.fulfill_order_returned_signal": [ + "commerce_coordinator.apps.commercetools.sub_messages.signals_delayed.fulfill_order_returned_signal", ], - 'commerce_coordinator.apps.stripe.signals.payment_refunded_signal': [ - 'commerce_coordinator.apps.commercetools.signals.refund_from_stripe', + "commerce_coordinator.apps.stripe.signals.payment_refunded_signal": [ + "commerce_coordinator.apps.commercetools.signals.refund_from_stripe", ], } COMMERCETOOLS_CONFIG = { # These values have special meaning to the CT SDK Unit Testing, and will fail if changed. - 'clientId': "mock-client-id", - 'clientSecret': "mock-client-secret", - 'scopes': "manage_project:test", - 'apiUrl': "https://localhost", - 'authUrl': "https://localhost/oauth/token", - 'projectKey': "test", + "clientId": "mock-client-id", + "clientSecret": "mock-client-secret", + "scopes": "manage_project:test", + "apiUrl": "https://localhost", + "authUrl": "https://localhost/oauth/token", + "projectKey": "test", } From c4a4fbb0870c31938481526f161e04afa7a5ec4e Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan Date: Fri, 13 Dec 2024 22:54:03 +0500 Subject: [PATCH 20/27] fix: fixes --- commerce_coordinator/settings/test.py | 112 +++++++++++++------------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/commerce_coordinator/settings/test.py b/commerce_coordinator/settings/test.py index 491f68b0..1961c571 100644 --- a/commerce_coordinator/settings/test.py +++ b/commerce_coordinator/settings/test.py @@ -3,17 +3,17 @@ from commerce_coordinator.settings.base import * PAYMENT_PROCESSOR_CONFIG = { - "edx": { - "stripe": { - "api_version": "2022-08-01; server_side_confirmation_beta=v1", - "enable_telemetry": None, - "log_level": "info", - "max_network_retries": 0, - "proxy": None, - "publishable_key": "SET-ME-PLEASE", - "secret_key": "SET-ME-PLEASE", - "source_system_identifier": "edx/commerce_coordinator?v=1", - "webhook_endpoint_secret": "SET-ME-PLEASE", + 'edx': { + 'stripe': { + 'api_version': '2022-08-01; server_side_confirmation_beta=v1', + 'enable_telemetry': None, + 'log_level': 'info', + 'max_network_retries': 0, + 'proxy': None, + 'publishable_key': 'SET-ME-PLEASE', + 'secret_key': 'SET-ME-PLEASE', + 'source_system_identifier': 'edx/commerce_coordinator?v=1', + 'webhook_endpoint_secret': 'SET-ME-PLEASE', }, "paypal": { "paypal_webhook_id": "SET-ME-PLEASE", @@ -27,13 +27,13 @@ # IN-MEMORY TEST DATABASE DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": ":memory:", - "USER": "", - "PASSWORD": "", - "HOST": "", - "PORT": "", + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + 'USER': '', + 'PASSWORD': '', + 'HOST': '', + 'PORT': '', }, } # END IN-MEMORY TEST DATABASE @@ -41,66 +41,66 @@ # CACHE CONFIGURATION # See: https://docs.djangoproject.com/en/dev/ref/settings/#caches CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', } } CC_SIGNALS = { # DEMO: This configuration is just for proof-of-concept and can # be removed once we have real signals - "commerce_coordinator.apps.core.signals.test_signal": [ - "commerce_coordinator.apps.demo_lms.signals.test_receiver", - "commerce_coordinator.apps.core.signals.test_receiver_exception", - "commerce_coordinator.apps.core.signals.test_celery_task", + 'commerce_coordinator.apps.core.signals.test_signal': [ + 'commerce_coordinator.apps.demo_lms.signals.test_receiver', + 'commerce_coordinator.apps.core.signals.test_receiver_exception', + 'commerce_coordinator.apps.core.signals.test_celery_task', ], - "commerce_coordinator.apps.demo_lms.signals.purchase_complete_signal": [ - "commerce_coordinator.apps.demo_lms.signals.demo_purchase_complete_order_history", - "commerce_coordinator.apps.demo_lms.signals.demo_purchase_complete_send_confirmation_email", - "commerce_coordinator.apps.demo_lms.signals.demo_purchase_complete_enroll_in_course", + 'commerce_coordinator.apps.demo_lms.signals.purchase_complete_signal': [ + 'commerce_coordinator.apps.demo_lms.signals.demo_purchase_complete_order_history', + 'commerce_coordinator.apps.demo_lms.signals.demo_purchase_complete_send_confirmation_email', + 'commerce_coordinator.apps.demo_lms.signals.demo_purchase_complete_enroll_in_course', ], - "commerce_coordinator.apps.demo_lms.signals.enroll_learner_signal": [ - "commerce_coordinator.apps.demo_lms.signals.demo_enroll_learner_in_course", + 'commerce_coordinator.apps.demo_lms.signals.enroll_learner_signal': [ + 'commerce_coordinator.apps.demo_lms.signals.demo_enroll_learner_in_course', ], # Actual Production Signals - "commerce_coordinator.apps.ecommerce.signals.enrollment_code_redemption_requested_signal": [ - "commerce_coordinator.apps.titan.signals.enrollment_code_redemption_requested_create_order", + 'commerce_coordinator.apps.ecommerce.signals.enrollment_code_redemption_requested_signal': [ + 'commerce_coordinator.apps.titan.signals.enrollment_code_redemption_requested_create_order', ], - "commerce_coordinator.apps.titan.signals.fulfill_order_placed_signal": [ - "commerce_coordinator.apps.lms.signal_handlers.fulfill_order_placed_send_enroll_in_course", + 'commerce_coordinator.apps.titan.signals.fulfill_order_placed_signal': [ + 'commerce_coordinator.apps.lms.signal_handlers.fulfill_order_placed_send_enroll_in_course', ], - "commerce_coordinator.apps.ecommerce.signals.order_created_signal": [ - "commerce_coordinator.apps.titan.signals.order_created_save", + 'commerce_coordinator.apps.ecommerce.signals.order_created_signal': [ + 'commerce_coordinator.apps.titan.signals.order_created_save', ], - "commerce_coordinator.apps.stripe.signals.payment_processed_signal": [ - "commerce_coordinator.apps.titan.signals.payment_processed_save", + 'commerce_coordinator.apps.stripe.signals.payment_processed_signal': [ + 'commerce_coordinator.apps.titan.signals.payment_processed_save', ], - "commerce_coordinator.apps.commercetools.signals.fulfill_order_placed_signal": [ - "commerce_coordinator.apps.lms.signal_handlers.fulfill_order_placed_send_enroll_in_course", + 'commerce_coordinator.apps.commercetools.signals.fulfill_order_placed_signal': [ + 'commerce_coordinator.apps.lms.signal_handlers.fulfill_order_placed_send_enroll_in_course', ], - "commerce_coordinator.apps.lms.signals.fulfillment_completed_signal": [ - "commerce_coordinator.apps.commercetools.signals.fulfill_order_completed_send_line_item_state", + 'commerce_coordinator.apps.lms.signals.fulfillment_completed_signal': [ + 'commerce_coordinator.apps.commercetools.signals.fulfill_order_completed_send_line_item_state', ], - "commerce_coordinator.apps.commercetools.sub_messages.signals_dispatch.fulfill_order_placed_message_signal": [ - "commerce_coordinator.apps.commercetools.sub_messages.signals_delayed.fulfill_order_placed_message_signal", + 'commerce_coordinator.apps.commercetools.sub_messages.signals_dispatch.fulfill_order_placed_message_signal': [ + 'commerce_coordinator.apps.commercetools.sub_messages.signals_delayed.fulfill_order_placed_message_signal', ], - "commerce_coordinator.apps.commercetools.sub_messages.signals_dispatch.fulfill_order_sanctioned_message_signal": [ - "commerce_coordinator.apps.commercetools.sub_messages.signals_delayed.fulfill_order_sanctioned_message_signal", + 'commerce_coordinator.apps.commercetools.sub_messages.signals_dispatch.fulfill_order_sanctioned_message_signal': [ + 'commerce_coordinator.apps.commercetools.sub_messages.signals_delayed.fulfill_order_sanctioned_message_signal', ], - "commerce_coordinator.apps.commercetools.sub_messages.signals_dispatch.fulfill_order_returned_signal": [ - "commerce_coordinator.apps.commercetools.sub_messages.signals_delayed.fulfill_order_returned_signal", + 'commerce_coordinator.apps.commercetools.sub_messages.signals_dispatch.fulfill_order_returned_signal': [ + 'commerce_coordinator.apps.commercetools.sub_messages.signals_delayed.fulfill_order_returned_signal', ], - "commerce_coordinator.apps.stripe.signals.payment_refunded_signal": [ - "commerce_coordinator.apps.commercetools.signals.refund_from_stripe", + 'commerce_coordinator.apps.stripe.signals.payment_refunded_signal': [ + 'commerce_coordinator.apps.commercetools.signals.refund_from_stripe', ], } COMMERCETOOLS_CONFIG = { # These values have special meaning to the CT SDK Unit Testing, and will fail if changed. - "clientId": "mock-client-id", - "clientSecret": "mock-client-secret", - "scopes": "manage_project:test", - "apiUrl": "https://localhost", - "authUrl": "https://localhost/oauth/token", - "projectKey": "test", + 'clientId': "mock-client-id", + 'clientSecret': "mock-client-secret", + 'scopes': "manage_project:test", + 'apiUrl': "https://localhost", + 'authUrl': "https://localhost/oauth/token", + 'projectKey': "test", } From 923be541a01825437f683eef633af34208119270 Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan Date: Fri, 13 Dec 2024 23:32:08 +0500 Subject: [PATCH 21/27] fix: fixes --- commerce_coordinator/apps/commercetools/utils.py | 2 +- commerce_coordinator/apps/paypal/views.py | 4 ---- commerce_coordinator/settings/base.py | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/commerce_coordinator/apps/commercetools/utils.py b/commerce_coordinator/apps/commercetools/utils.py index c6e5fc15..95557128 100644 --- a/commerce_coordinator/apps/commercetools/utils.py +++ b/commerce_coordinator/apps/commercetools/utils.py @@ -209,7 +209,7 @@ def translate_refund_status_to_transaction_status(refund_status: str): 'failed': TransactionState.FAILURE, 'canceled': TransactionState.FAILURE, } - return translations.get(refund_status.lower(), refund_status) + return translations.get(refund_status.lower(), TransactionState.SUCCESS) def _create_retired_hash_withsalt(value_to_retire, salt): diff --git a/commerce_coordinator/apps/paypal/views.py b/commerce_coordinator/apps/paypal/views.py index ad474eee..dd35b606 100644 --- a/commerce_coordinator/apps/paypal/views.py +++ b/commerce_coordinator/apps/paypal/views.py @@ -75,18 +75,14 @@ def post(self, request): crc = zlib.crc32(body) message = f"{transmission_id}|{timestamp}|{webhook_id}|{crc}" - signature = base64.b64decode(request.headers.get("paypal-transmission-sig")) - certificate = self._get_certificate(request.headers.get("paypal-cert-url")) - cert = x509.load_pem_x509_certificate( certificate.encode("utf-8"), default_backend() ) public_key = cert.public_key() try: - # TODO: In future we can move this logic over to redis to avoid hitting the database public_key.verify( signature, message.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256() ) diff --git a/commerce_coordinator/settings/base.py b/commerce_coordinator/settings/base.py index f37acf95..8bcec13a 100644 --- a/commerce_coordinator/settings/base.py +++ b/commerce_coordinator/settings/base.py @@ -419,7 +419,7 @@ def root(*path_fragments): } STRIPE_WEBHOOK_ENDPOINT_SECRET = 'SET-ME-PLEASE' -PAYPAL_WEBHOOK_ID="SET-ME-PLEASE" +PAYPAL_WEBHOOK_ID = 'SET-ME-PLEASE' # PAYMENT PROCESSING PAYMENT_PROCESSOR_CONFIG = { From a4b56cfb9297c55d05f837122c25d43a48b56ac6 Mon Sep 17 00:00:00 2001 From: Shafqat Farhan Date: Mon, 16 Dec 2024 14:15:26 +0500 Subject: [PATCH 22/27] feat: removed unused fuction --- .../apps/commercetools/catalog_info/edx_utils.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/commerce_coordinator/apps/commercetools/catalog_info/edx_utils.py b/commerce_coordinator/apps/commercetools/catalog_info/edx_utils.py index 3c76f414..ed7a5edc 100644 --- a/commerce_coordinator/apps/commercetools/catalog_info/edx_utils.py +++ b/commerce_coordinator/apps/commercetools/catalog_info/edx_utils.py @@ -75,11 +75,6 @@ def get_edx_successful_payment_info(order: CTOrder): return None, None -def get_edx_payment_service_provider(order: CTOrder) -> Union[str, None]: - for pr in order.payment_info.payments: - return pr.obj.payment_method_info.payment_interface - - def get_edx_order_workflow_state_key(order: CTOrder) -> Optional[str]: order_workflow_state = None if order.state and order.state.obj: # it should never be that we have one and not the other. # pragma no cover From f631e9bec2bdb12d796df6bb1e636216bf750476 Mon Sep 17 00:00:00 2001 From: Shafqat Farhan Date: Mon, 16 Dec 2024 14:24:13 +0500 Subject: [PATCH 23/27] feat: added PayPal config in test.py --- commerce_coordinator/settings/test.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/commerce_coordinator/settings/test.py b/commerce_coordinator/settings/test.py index 1961c571..11e80417 100644 --- a/commerce_coordinator/settings/test.py +++ b/commerce_coordinator/settings/test.py @@ -15,11 +15,9 @@ 'source_system_identifier': 'edx/commerce_coordinator?v=1', 'webhook_endpoint_secret': 'SET-ME-PLEASE', }, - "paypal": { - "paypal_webhook_id": "SET-ME-PLEASE", - }, 'paypal': { 'user_activity_page_url': 'https://test.paypal.com/myaccount/activities/', + 'paypal_webhook_id': PAYPAL_WEBHOOK_ID, }, }, } From 06e4c18974564d8911ee2c0dfc68fba9e144f800 Mon Sep 17 00:00:00 2001 From: Shafqat Farhan Date: Mon, 16 Dec 2024 14:37:33 +0500 Subject: [PATCH 24/27] feat: fixed CI failurs --- commerce_coordinator/apps/commercetools/tests/test_utils.py | 2 +- commerce_coordinator/apps/stripe/pipeline.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/commerce_coordinator/apps/commercetools/tests/test_utils.py b/commerce_coordinator/apps/commercetools/tests/test_utils.py index efe6e417..c54f36de 100644 --- a/commerce_coordinator/apps/commercetools/tests/test_utils.py +++ b/commerce_coordinator/apps/commercetools/tests/test_utils.py @@ -323,7 +323,7 @@ def test_translate_stripe_refund_status_failed(self): def test_translate_stripe_refund_status_other(self): # Test for an unknown status - self.assertEqual(translate_refund_status_to_transaction_status('unknown_status'), 'unknown_status') + self.assertEqual(translate_refund_status_to_transaction_status('unknown_status'), TransactionState.SUCCESS) class TestRetirementAnonymizingTestCase(unittest.TestCase): diff --git a/commerce_coordinator/apps/stripe/pipeline.py b/commerce_coordinator/apps/stripe/pipeline.py index 1c1a8725..1840223d 100644 --- a/commerce_coordinator/apps/stripe/pipeline.py +++ b/commerce_coordinator/apps/stripe/pipeline.py @@ -235,6 +235,8 @@ def run_filter(self, psp=None, payment_intent_id=None, **params): 'redirect_url': receipt_url } + return PipelineCommand.CONTINUE.value + class RefundPaymentIntent(PipelineStep): """ From 29b322240f03fd03bad8a7d5aa56fc6367f73311 Mon Sep 17 00:00:00 2001 From: Shafqat Farhan Date: Mon, 16 Dec 2024 14:52:55 +0500 Subject: [PATCH 25/27] feat: fixed CI failures --- commerce_coordinator/apps/paypal/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/commerce_coordinator/apps/paypal/views.py b/commerce_coordinator/apps/paypal/views.py index dd35b606..eb9fd7fc 100644 --- a/commerce_coordinator/apps/paypal/views.py +++ b/commerce_coordinator/apps/paypal/views.py @@ -26,6 +26,7 @@ WEBHOOK_ID = settings.PAYMENT_PROCESSOR_CONFIG['edx']['paypal']['paypal_webhook_id'] + class PayPalWebhookView(SingleInvocationAPIView): """ PayPal webhook view From e8a7b942985df7c05ba637e8d078171718fdd206 Mon Sep 17 00:00:00 2001 From: Shafqat Farhan Date: Mon, 16 Dec 2024 15:10:59 +0500 Subject: [PATCH 26/27] feat: fixed CI failures --- commerce_coordinator/apps/commercetools/clients.py | 8 ++++---- commerce_coordinator/apps/paypal/tests/test_views.py | 2 +- commerce_coordinator/apps/paypal/views.py | 1 - 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/commerce_coordinator/apps/commercetools/clients.py b/commerce_coordinator/apps/commercetools/clients.py index 7f828f57..e6dc617d 100644 --- a/commerce_coordinator/apps/commercetools/clients.py +++ b/commerce_coordinator/apps/commercetools/clients.py @@ -22,7 +22,7 @@ OrderAddReturnInfoAction, OrderSetReturnItemCustomTypeAction, OrderSetReturnPaymentStateAction, - OrderTransitionLineItemStateAction, + OrderTransitionLineItemStateAction ) from commercetools.platform.models import Payment as CTPayment from commercetools.platform.models import PaymentAddTransactionAction, PaymentSetTransactionCustomTypeAction @@ -33,7 +33,7 @@ ReturnShipmentState, StateResourceIdentifier, TransactionDraft, - TransactionType, + TransactionType ) from commercetools.platform.models import Type as CTType from commercetools.platform.models import TypeDraft as CTTypeDraft @@ -46,13 +46,13 @@ DEFAULT_ORDER_EXPANSION, EDX_PAYPAL_PAYMENT_INTERFACE_NAME, EDX_STRIPE_PAYMENT_INTERFACE_NAME, - EdXFieldNames, + EdXFieldNames ) from commerce_coordinator.apps.commercetools.catalog_info.foundational_types import TwoUCustomTypes from commerce_coordinator.apps.commercetools.utils import ( find_refund_transaction, handle_commercetools_error, - translate_refund_status_to_transaction_status, + translate_refund_status_to_transaction_status ) from commerce_coordinator.apps.core.constants import ORDER_HISTORY_PER_SYSTEM_REQ_LIMIT diff --git a/commerce_coordinator/apps/paypal/tests/test_views.py b/commerce_coordinator/apps/paypal/tests/test_views.py index 3ad9ea3b..d58570e6 100644 --- a/commerce_coordinator/apps/paypal/tests/test_views.py +++ b/commerce_coordinator/apps/paypal/tests/test_views.py @@ -3,7 +3,7 @@ """ import base64 import zlib -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch from django.conf import settings from django.urls import reverse diff --git a/commerce_coordinator/apps/paypal/views.py b/commerce_coordinator/apps/paypal/views.py index eb9fd7fc..da3bdd34 100644 --- a/commerce_coordinator/apps/paypal/views.py +++ b/commerce_coordinator/apps/paypal/views.py @@ -21,7 +21,6 @@ from commerce_coordinator.apps.core.views import SingleInvocationAPIView from commerce_coordinator.apps.paypal.signals import payment_refunded_signal - logger = logging.getLogger(__name__) WEBHOOK_ID = settings.PAYMENT_PROCESSOR_CONFIG['edx']['paypal']['paypal_webhook_id'] From 29ba28fa9c4929ad8afc3f5338ea5ca9208cb83a Mon Sep 17 00:00:00 2001 From: Shafqat Farhan Date: Mon, 16 Dec 2024 15:14:34 +0500 Subject: [PATCH 27/27] feat: added a comment --- commerce_coordinator/apps/paypal/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/commerce_coordinator/apps/paypal/views.py b/commerce_coordinator/apps/paypal/views.py index da3bdd34..448acf10 100644 --- a/commerce_coordinator/apps/paypal/views.py +++ b/commerce_coordinator/apps/paypal/views.py @@ -102,6 +102,8 @@ def post(self, request): self.mark_running(tag, refund_id) refund_urls = request.data.get("resource").get("links", []) paypal_capture_id = None + # Getting the capture ID from the links in the refund event as it is not + # present in the payload and is required for initiating the refund. for link in refund_urls: if link.get("rel") == "up" and "captures" in link.get("href"): paypal_capture_id = link.get("href").split("/")[-1]