From 931a91c88661b986a17088ccc805560503045b61 Mon Sep 17 00:00:00 2001 From: CP Date: Fri, 1 May 2026 12:05:46 -0400 Subject: [PATCH 1/4] Deduplicate deals and use consistent unique_app_id Prevent duplicate cart-add deals by looking up existing deals by dealname and a new consistent unique_app_id before creating; update existing deals when found. Change OrderToDealSerializer.get_unique_app_id to generate a stable ID based on purchaser email and the purchasable object (fallback to order id for orders without lines) so cart-add and checkout flows use the same identifier. Update tests to assert the new unique_app_id generation logic. --- hubspot_sync/api.py | 31 +++++++++++++++++++++++++------ hubspot_sync/serializers.py | 14 +++++++++++++- hubspot_sync/serializers_test.py | 30 ++++++++++++++++++++++++++++-- 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/hubspot_sync/api.py b/hubspot_sync/api.py index 2abb0d761b..a601f77b1a 100644 --- a/hubspot_sync/api.py +++ b/hubspot_sync/api.py @@ -2191,14 +2191,33 @@ def _ensure_hubspot_contact_for_user( def _sync_cart_add_deal_with_hubspot( order: Order, contact_id: str, hubspot_client: HubspotApi ) -> SimplePublicObject: - """Create cart-add deal and line-item objects and associate them in target account.""" + """Create or update cart-add deal and line-item objects and associate them in target account.""" deal_input = _build_target_deal_message(order, hubspot_client) - - wait_for_hubspot_rate_limit() - deal = hubspot_client.crm.objects.basic_api.create( - object_type=HubspotObjectType.DEALS.value, - simple_public_object_input_for_create=deal_input, + + # Extract unique_app_id from deal input to check for existing deals + unique_app_id = deal_input.properties.get("unique_app_id") + + # Use the same dual lookup logic as checkout flow to prevent duplicates + existing_deal_id = _find_target_deal_id_by_dealname( + hubspot_client, deal_input.properties.get("dealname") ) + if not existing_deal_id and unique_app_id: + existing_deal_id = _find_target_deal_id_by_unique_app_id(hubspot_client, unique_app_id) + + wait_for_hubspot_rate_limit() + if existing_deal_id: + # Update existing deal + deal = hubspot_client.crm.objects.basic_api.update( + object_type=HubspotObjectType.DEALS.value, + object_id=existing_deal_id, + simple_public_object_input=deal_input, + ) + else: + # Create new deal + deal = hubspot_client.crm.objects.basic_api.create( + object_type=HubspotObjectType.DEALS.value, + simple_public_object_input_for_create=deal_input, + ) wait_for_hubspot_rate_limit() hubspot_client.crm.associations.v4.basic_api.create_default( diff --git a/hubspot_sync/serializers.py b/hubspot_sync/serializers.py index 7ff079400b..4db6ed4e74 100644 --- a/hubspot_sync/serializers.py +++ b/hubspot_sync/serializers.py @@ -187,7 +187,19 @@ def _get_discount(self, instance): return self._discount def get_unique_app_id(self, instance): - """Get the app_id for the object""" + """Get the app_id for the object - based on user and purchasable object for consistency""" + # For consistency between cart-add and checkout, base the unique_app_id on + # the user and purchasable object rather than the order ID + first_line = instance.lines.first() + if first_line and first_line.purchased_object: + # Generate consistent ID based on user email and purchasable object + user_identifier = instance.purchaser.email.lower() + object_type = first_line.purchased_content_type.model + object_id = first_line.purchased_object_id + consistent_id = f"{user_identifier}-{object_type}-{object_id}" + return format_app_id(consistent_id) + + # Fallback to original behavior for orders without lines return format_app_id(instance.id) def get_dealname(self, instance): diff --git a/hubspot_sync/serializers_test.py b/hubspot_sync/serializers_test.py index 56dff18a63..280f90c268 100644 --- a/hubspot_sync/serializers_test.py +++ b/hubspot_sync/serializers_test.py @@ -117,6 +117,19 @@ def test_serialize_order(settings, hubspot_order, status): """Test that OrderToDealSerializer produces the correct serialized data""" hubspot_order.state = status serialized_data = OrderToDealSerializer(instance=hubspot_order).data + + # Calculate expected unique_app_id based on new logic (user email + purchasable object) + first_line = hubspot_order.lines.first() + if first_line and first_line.purchased_object: + user_identifier = hubspot_order.purchaser.email.lower() + object_type = first_line.purchased_content_type.model + object_id = first_line.purchased_object_id + consistent_id = f"{user_identifier}-{object_type}-{object_id}" + expected_unique_app_id = format_app_id(consistent_id) + else: + # Fallback to original behavior for orders without lines + expected_unique_app_id = format_app_id(hubspot_order.id) + assert serialized_data == { "dealname": f"MITXONLINE-ORDER-{hubspot_order.id}", "dealstage": ORDER_STATUS_MAPPING[status], @@ -132,7 +145,7 @@ def test_serialize_order(settings, hubspot_order, status): "coupon_code": None, "status": hubspot_order.state, "pipeline": settings.HUBSPOT_PIPELINE_ID, - "unique_app_id": format_app_id(hubspot_order.id), + "unique_app_id": expected_unique_app_id, } @@ -174,6 +187,19 @@ def test_serialize_order_with_coupon( # noqa: PLR0913 ) serialized_data = OrderToDealSerializer(instance=hubspot_order).data + + # Calculate expected unique_app_id based on new logic (user email + purchasable object) + first_line = hubspot_order.lines.first() + if first_line and first_line.purchased_object: + user_identifier = hubspot_order.purchaser.email.lower() + object_type = first_line.purchased_content_type.model + object_id = first_line.purchased_object_id + consistent_id = f"{user_identifier}-{object_type}-{object_id}" + expected_unique_app_id = format_app_id(consistent_id) + else: + # Fallback to original behavior for orders without lines + expected_unique_app_id = format_app_id(hubspot_order.id) + assert serialized_data == { "dealname": f"MITXONLINE-ORDER-{hubspot_order.id}", "dealstage": ORDER_STATUS_MAPPING[hubspot_order.state], @@ -189,7 +215,7 @@ def test_serialize_order_with_coupon( # noqa: PLR0913 "discount_percent": percent_off, "status": hubspot_order.state, "pipeline": settings.HUBSPOT_PIPELINE_ID, - "unique_app_id": format_app_id(hubspot_order.id), + "unique_app_id": expected_unique_app_id, } From 1bd6d1887837c86ec4849c3af75ed983e3c24d54 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 16:10:04 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- hubspot_sync/api.py | 10 ++++++---- hubspot_sync/serializers.py | 4 ++-- hubspot_sync/serializers_test.py | 8 ++++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/hubspot_sync/api.py b/hubspot_sync/api.py index a601f77b1a..5ed047dbea 100644 --- a/hubspot_sync/api.py +++ b/hubspot_sync/api.py @@ -2193,17 +2193,19 @@ def _sync_cart_add_deal_with_hubspot( ) -> SimplePublicObject: """Create or update cart-add deal and line-item objects and associate them in target account.""" deal_input = _build_target_deal_message(order, hubspot_client) - + # Extract unique_app_id from deal input to check for existing deals unique_app_id = deal_input.properties.get("unique_app_id") - + # Use the same dual lookup logic as checkout flow to prevent duplicates existing_deal_id = _find_target_deal_id_by_dealname( hubspot_client, deal_input.properties.get("dealname") ) if not existing_deal_id and unique_app_id: - existing_deal_id = _find_target_deal_id_by_unique_app_id(hubspot_client, unique_app_id) - + existing_deal_id = _find_target_deal_id_by_unique_app_id( + hubspot_client, unique_app_id + ) + wait_for_hubspot_rate_limit() if existing_deal_id: # Update existing deal diff --git a/hubspot_sync/serializers.py b/hubspot_sync/serializers.py index 4db6ed4e74..5e22486795 100644 --- a/hubspot_sync/serializers.py +++ b/hubspot_sync/serializers.py @@ -188,7 +188,7 @@ def _get_discount(self, instance): def get_unique_app_id(self, instance): """Get the app_id for the object - based on user and purchasable object for consistency""" - # For consistency between cart-add and checkout, base the unique_app_id on + # For consistency between cart-add and checkout, base the unique_app_id on # the user and purchasable object rather than the order ID first_line = instance.lines.first() if first_line and first_line.purchased_object: @@ -198,7 +198,7 @@ def get_unique_app_id(self, instance): object_id = first_line.purchased_object_id consistent_id = f"{user_identifier}-{object_type}-{object_id}" return format_app_id(consistent_id) - + # Fallback to original behavior for orders without lines return format_app_id(instance.id) diff --git a/hubspot_sync/serializers_test.py b/hubspot_sync/serializers_test.py index 280f90c268..6cec8520a7 100644 --- a/hubspot_sync/serializers_test.py +++ b/hubspot_sync/serializers_test.py @@ -117,7 +117,7 @@ def test_serialize_order(settings, hubspot_order, status): """Test that OrderToDealSerializer produces the correct serialized data""" hubspot_order.state = status serialized_data = OrderToDealSerializer(instance=hubspot_order).data - + # Calculate expected unique_app_id based on new logic (user email + purchasable object) first_line = hubspot_order.lines.first() if first_line and first_line.purchased_object: @@ -129,7 +129,7 @@ def test_serialize_order(settings, hubspot_order, status): else: # Fallback to original behavior for orders without lines expected_unique_app_id = format_app_id(hubspot_order.id) - + assert serialized_data == { "dealname": f"MITXONLINE-ORDER-{hubspot_order.id}", "dealstage": ORDER_STATUS_MAPPING[status], @@ -187,7 +187,7 @@ def test_serialize_order_with_coupon( # noqa: PLR0913 ) serialized_data = OrderToDealSerializer(instance=hubspot_order).data - + # Calculate expected unique_app_id based on new logic (user email + purchasable object) first_line = hubspot_order.lines.first() if first_line and first_line.purchased_object: @@ -199,7 +199,7 @@ def test_serialize_order_with_coupon( # noqa: PLR0913 else: # Fallback to original behavior for orders without lines expected_unique_app_id = format_app_id(hubspot_order.id) - + assert serialized_data == { "dealname": f"MITXONLINE-ORDER-{hubspot_order.id}", "dealstage": ORDER_STATUS_MAPPING[hubspot_order.state], From fde9dbfc285a78a0a2a8de9a344ead722f94a331 Mon Sep 17 00:00:00 2001 From: CP Date: Fri, 1 May 2026 12:13:07 -0400 Subject: [PATCH 3/4] lint --- drf_lint_baseline.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/drf_lint_baseline.json b/drf_lint_baseline.json index c800c23341..0034d34d0c 100644 --- a/drf_lint_baseline.json +++ b/drf_lint_baseline.json @@ -81,8 +81,11 @@ "hubspot_sync/serializers.py:171:22:ORM001", "hubspot_sync/serializers.py:183:25:ORM002", "hubspot_sync/serializers.py:186:33:ORM002", + "hubspot_sync/serializers.py:193:21:ORM002", "hubspot_sync/serializers.py:312:33:ORM001", "hubspot_sync/serializers.py:323:36:ORM001", + "hubspot_sync/serializers.py:324:33:ORM001", + "hubspot_sync/serializers.py:335:36:ORM001", "hubspot_sync/serializers.py:64:22:ORM002", "hubspot_sync/serializers.py:76:31:ORM002", "hubspot_sync/serializers.py:80:31:ORM002", From 73f25972a6ea0c2bcfe6c3a8672e66198185d41b Mon Sep 17 00:00:00 2001 From: CP Date: Fri, 1 May 2026 14:35:39 -0400 Subject: [PATCH 4/4] Use global ID instead of email --- hubspot_sync/serializers.py | 4 ++-- hubspot_sync/serializers_test.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/hubspot_sync/serializers.py b/hubspot_sync/serializers.py index 5e22486795..42e03ea5fb 100644 --- a/hubspot_sync/serializers.py +++ b/hubspot_sync/serializers.py @@ -192,8 +192,8 @@ def get_unique_app_id(self, instance): # the user and purchasable object rather than the order ID first_line = instance.lines.first() if first_line and first_line.purchased_object: - # Generate consistent ID based on user email and purchasable object - user_identifier = instance.purchaser.email.lower() + # Generate consistent ID based on user and purchasable object + user_identifier = instance.purchaser.global_id object_type = first_line.purchased_content_type.model object_id = first_line.purchased_object_id consistent_id = f"{user_identifier}-{object_type}-{object_id}" diff --git a/hubspot_sync/serializers_test.py b/hubspot_sync/serializers_test.py index 6cec8520a7..07ef02aa19 100644 --- a/hubspot_sync/serializers_test.py +++ b/hubspot_sync/serializers_test.py @@ -118,10 +118,10 @@ def test_serialize_order(settings, hubspot_order, status): hubspot_order.state = status serialized_data = OrderToDealSerializer(instance=hubspot_order).data - # Calculate expected unique_app_id based on new logic (user email + purchasable object) + # Calculate expected unique_app_id based on new logic (user global_id + purchasable object) first_line = hubspot_order.lines.first() if first_line and first_line.purchased_object: - user_identifier = hubspot_order.purchaser.email.lower() + user_identifier = hubspot_order.purchaser.global_id object_type = first_line.purchased_content_type.model object_id = first_line.purchased_object_id consistent_id = f"{user_identifier}-{object_type}-{object_id}" @@ -188,10 +188,10 @@ def test_serialize_order_with_coupon( # noqa: PLR0913 serialized_data = OrderToDealSerializer(instance=hubspot_order).data - # Calculate expected unique_app_id based on new logic (user email + purchasable object) + # Calculate expected unique_app_id based on new logic (user global_id + purchasable object) first_line = hubspot_order.lines.first() if first_line and first_line.purchased_object: - user_identifier = hubspot_order.purchaser.email.lower() + user_identifier = hubspot_order.purchaser.global_id object_type = first_line.purchased_content_type.model object_id = first_line.purchased_object_id consistent_id = f"{user_identifier}-{object_type}-{object_id}"