Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
718 changes: 718 additions & 0 deletions SHOPIFY_NULL_PRODUCT_ID_TECHNICAL_DOCUMENTATION.md

Large diffs are not rendered by default.

902 changes: 902 additions & 0 deletions SHOPIFY_ORDER_UPDATE_WEBHOOK_DOCUMENTATION.md

Large diffs are not rendered by default.

713 changes: 623 additions & 90 deletions ecommerce_integrations/shopify/connection.py

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions ecommerce_integrations/shopify/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"orders/fulfilled",
"orders/cancelled",
"orders/partially_fulfilled",
"orders/edited", # Clean signal for line item edits
"orders/updated", # Kept only for address / notes changes (filtered via fingerprint)
]

EVENT_MAPPER = {
Expand All @@ -22,6 +24,8 @@
"orders/fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note",
"orders/cancelled": "ecommerce_integrations.shopify.order.cancel_order",
"orders/partially_fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note",
"orders/edited": "ecommerce_integrations.shopify.order.handle_order_edited",
"orders/updated": "ecommerce_integrations.shopify.order.handle_order_updated",
}

SHOPIFY_VARIANTS_ATTR_LIST = ["option1", "option2", "option3"]
Expand Down
26 changes: 24 additions & 2 deletions ecommerce_integrations/shopify/customer.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,19 @@ def sync_customer(self, customer: dict[str, Any]) -> None:
customer_group = self.setting.customer_group
super().sync_customer(customer_name, customer_group)

billing_address = customer.get("billing_address", {}) or customer.get("default_address")
# Handle billing address: only use shipping if billing is explicitly null/empty
# (Shopify indicates "same as shipping" by having billing_address as null)
billing_address = customer.get("billing_address")
shipping_address = customer.get("shipping_address", {})

# Check if billing is explicitly null/empty (Shopify "same as shipping" case)
if billing_address is None or (isinstance(billing_address, dict) and not any(billing_address.values())):
# Billing is same as shipping - use shipping address
if shipping_address:
billing_address = shipping_address
# Fallback to customer default address only if no shipping
elif not billing_address:
billing_address = customer.get("default_address", {})

if billing_address:
self.create_customer_address(
Expand All @@ -54,8 +65,19 @@ def create_customer_address(
super().create_customer_address(address_fields)

def update_existing_addresses(self, customer):
billing_address = customer.get("billing_address", {}) or customer.get("default_address")
# Handle billing address: only use shipping if billing is explicitly null/empty
# (Shopify indicates "same as shipping" by having billing_address as null)
billing_address = customer.get("billing_address")
shipping_address = customer.get("shipping_address", {})

# Check if billing is explicitly null/empty (Shopify "same as shipping" case)
if billing_address is None or (isinstance(billing_address, dict) and not any(billing_address.values())):
# Billing is same as shipping - use shipping address
if shipping_address:
billing_address = shipping_address
# Fallback to customer default address only if no shipping
elif not billing_address:
billing_address = customer.get("default_address", {})

customer_name = cstr(customer.get("first_name")) + " " + cstr(customer.get("last_name"))
email = customer.get("email")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@
"shared_secret",
"section_break_4",
"webhooks",
"store_2_section",
"enable_store_2",
"store_2_name",
"column_break_store_2",
"shopify_url_2",
"password_2",
"shared_secret_2",
"webhooks_section_2",
"webhooks_2",
"customer_settings_section",
"default_customer",
"column_break_14",
Expand Down Expand Up @@ -113,6 +122,67 @@
"options": "Shopify Webhooks",
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "store_2_section",
"fieldtype": "Section Break",
"label": "Store 2 - Additional Shopify Store"
},
{
"default": "0",
"fieldname": "enable_store_2",
"fieldtype": "Check",
"label": "Enable Store 2"
},
{
"default": "Store 2",
"depends_on": "enable_store_2",
"description": "User-friendly name for reporting (e.g., 'ZipCovers')",
"fieldname": "store_2_name",
"fieldtype": "Data",
"label": "Store 2 Name"
},
{
"fieldname": "column_break_store_2",
"fieldtype": "Column Break"
},
{
"depends_on": "enable_store_2",
"description": "e.g., zipcovers.myshopify.com",
"fieldname": "shopify_url_2",
"fieldtype": "Data",
"label": "Shop URL (Store 2)",
"mandatory_depends_on": "eval:doc.enable_store_2"
},
{
"depends_on": "enable_store_2",
"fieldname": "password_2",
"fieldtype": "Password",
"label": "Password / Access Token (Store 2)",
"mandatory_depends_on": "eval:doc.enable_store_2"
},
{
"depends_on": "enable_store_2",
"fieldname": "shared_secret_2",
"fieldtype": "Data",
"label": "Shared Secret / API Secret (Store 2)",
"mandatory_depends_on": "eval:doc.enable_store_2"
},
{
"collapsible": 1,
"depends_on": "enable_store_2",
"fieldname": "webhooks_section_2",
"fieldtype": "Section Break",
"label": "Webhooks (Store 2)"
},
{
"depends_on": "enable_store_2",
"fieldname": "webhooks_2",
"fieldtype": "Table",
"label": "Webhooks",
"options": "Shopify Webhook Store 2",
"read_only": 1
},
{
"fieldname": "customer_settings_section",
"fieldtype": "Section Break",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ def validate(self):

if self.shopify_url:
self.shopify_url = self.shopify_url.replace("https://", "")

# Clean Store 2 URL if provided
if self.shopify_url_2:
self.shopify_url_2 = self.shopify_url_2.replace("https://", "")

self._handle_webhooks()
self._validate_warehouse_links()
self._initalize_default_values()
Expand All @@ -52,22 +57,68 @@ def on_update(self):
migrate_from_old_connector()

def _handle_webhooks(self):
"""Handle webhook registration/unregistration for both stores."""

# Handle Store 1 webhooks
if self.is_enabled() and not self.webhooks:
new_webhooks = connection.register_webhooks(self.shopify_url, self.get_password("password"))

if not new_webhooks:
msg = _("Failed to register webhooks with Shopify.") + "<br>"
msg = _("Failed to register webhooks with Shopify Store 1.") + "<br>"
msg += _("Please check credentials and retry.") + " "
msg += _("Disabling and re-enabling the integration might also help.")
frappe.throw(msg)

for webhook in new_webhooks:
self.append("webhooks", {"webhook_id": webhook.id, "method": webhook.topic})

frappe.logger().info(f"Registered {len(new_webhooks)} webhooks for Store 1")

elif not self.is_enabled():
connection.unregister_webhooks(self.shopify_url, self.get_password("password"))

self.webhooks = list() # remove all webhooks
frappe.logger().info("Unregistered Store 1 webhooks")

# Handle Store 2 webhooks
if self.enable_store_2 and self.shopify_url_2 and not self.webhooks_2:
try:
new_webhooks_2 = connection.register_webhooks(
self.shopify_url_2,
self.get_password("password_2")
)

if not new_webhooks_2:
msg = _("Failed to register webhooks with Shopify Store 2.") + "<br>"
msg += _("Please check Store 2 credentials and retry.")
frappe.throw(msg)

for webhook in new_webhooks_2:
self.append("webhooks_2", {"webhook_id": webhook.id, "method": webhook.topic})

frappe.logger().info(f"Registered {len(new_webhooks_2)} webhooks for Store 2 ({self.store_2_name})")

except Exception as e:
frappe.log_error(
title="Store 2 Webhook Registration Failed",
message=f"Error registering webhooks for Store 2: {str(e)}"
)
# Don't throw - allow Store 1 to continue working
frappe.msgprint(
_("Warning: Failed to register Store 2 webhooks. Store 1 will continue working. Error: {0}").format(str(e)),
alert=True
)

elif not self.enable_store_2 and self.shopify_url_2 and self.webhooks_2:
# Store 2 disabled but webhooks exist - unregister them
try:
connection.unregister_webhooks(self.shopify_url_2, self.get_password("password_2"))
self.webhooks_2 = list() # remove all Store 2 webhooks
frappe.logger().info("Unregistered Store 2 webhooks")
except Exception as e:
frappe.log_error(
title="Store 2 Webhook Unregistration Failed",
message=f"Error unregistering webhooks for Store 2: {str(e)}"
)

def _validate_warehouse_links(self):
for wh_map in self.shopify_warehouse_mapping:
Expand Down Expand Up @@ -171,6 +222,18 @@ def setup_custom_fields():
read_only=1,
print_hide=1,
),
# Fingerprint used to filter noisy `orders/updated` webhooks.
# Hidden technical field – do not show in UI.
dict(
fieldname="custom_shopify_fingerprint",
label="Shopify Fingerprint",
fieldtype="Small Text",
insert_after=ORDER_STATUS_FIELD,
read_only=1,
print_hide=1,
hidden=1,
no_copy=1,
),
],
"Sales Order Item": [
dict(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"actions": [],
"creation": "2025-01-02 00:00:00.000000",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"webhook_id",
"method"
],
"fields": [
{
"fieldname": "webhook_id",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Webhook ID",
"read_only": 1
},
{
"fieldname": "method",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Method",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-01-02 00:00:00.000000",
"modified_by": "Administrator",
"module": "Shopify",
"name": "Shopify Webhook Store 2",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Copyright (c) 2025, Frappe and contributors
# For license information, please see LICENSE

from frappe.model.document import Document


class ShopifyWebhookStore2(Document):
pass

14 changes: 13 additions & 1 deletion ecommerce_integrations/shopify/fulfillment.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,24 @@
from ecommerce_integrations.shopify.utils import create_shopify_log


def prepare_delivery_note(payload, request_id=None):
def prepare_delivery_note(payload, request_id=None, store_name=None):
"""Prepare delivery note from Shopify fulfillment.

Args:
payload: Order data from Shopify
request_id: Shopify Log entry ID
store_name: Name of the store (for logging)
"""
frappe.set_user("Administrator")
setting = frappe.get_doc(SETTING_DOCTYPE)
frappe.flags.request_id = request_id

order = payload

# Set store context for API calls in this background job
if store_name:
frappe.logger().info(f"Preparing delivery note for order from {store_name}")
frappe.local.shopify_store_name = store_name

try:
sales_order = get_sales_order(cstr(order["id"]))
Expand Down
14 changes: 13 additions & 1 deletion ecommerce_integrations/shopify/invoice.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,26 @@
from ecommerce_integrations.shopify.utils import create_shopify_log


def prepare_sales_invoice(payload, request_id=None):
def prepare_sales_invoice(payload, request_id=None, store_name=None):
"""Prepare sales invoice from Shopify order.

Args:
payload: Order data from Shopify
request_id: Shopify Log entry ID
store_name: Name of the store (for logging)
"""
from ecommerce_integrations.shopify.order import get_sales_order

order = payload

frappe.set_user("Administrator")
setting = frappe.get_doc(SETTING_DOCTYPE)
frappe.flags.request_id = request_id

# Set store context for API calls in this background job
if store_name:
frappe.logger().info(f"Preparing sales invoice for order from {store_name}")
frappe.local.shopify_store_name = store_name

try:
sales_order = get_sales_order(cstr(order["id"]))
Expand Down
Loading