Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from frappe.model.document import Document
from frappe.utils import cstr, get_datetime, now

from ecommerce_integrations.shopify.connection import temp_shopify_session


class EcommerceItem(Document):
erpnext_item_code: str # item_code in ERPNext
Expand All @@ -23,6 +25,46 @@ def validate(self):
def before_insert(self):
self.check_unique_constraints()

# handles deletion of item both from shopify and erpnext
@temp_shopify_session
def on_trash(self):
from ecommerce_integrations.shopify.product import delete_from_shopify

frappe.logger().info(
f"[EcommerceItem:on_trash] Triggered for {self.name} | Integration: {self.integration}"
)

sales_order = frappe.db.exists("Sales Order Item", {"item_code": self.erpnext_item_code})
sales_invoice = frappe.db.exists("Sales Invoice Item", {"item_code": self.erpnext_item_code})

if not (sales_order or sales_invoice):
if self.integration and self.integration.lower() == "shopify" and self.integration_item_code:
frappe.logger().info(
f"[Shopify] Preparing to delete. has_variants={self.has_variants}, "
f"variant_id={self.variant_id}, integration_item_code={self.integration_item_code}"
)

product_gid = f"gid://shopify/Product/{self.integration_item_code}"
result = delete_from_shopify(product_id=product_gid)

if self.erpnext_item_code and frappe.db.exists("Item", self.erpnext_item_code):
item_doc = frappe.get_doc("Item", self.erpnext_item_code)
if not item_doc.disabled:
item_doc.db_set("disabled", 1)
frappe.logger().info(
f"[ERPNext] Item '{self.erpnext_item_code}' disabled (instead of deleted)."
)
else:
frappe.logger().info(f"[ERPNext] Item '{self.erpnext_item_code}' already disabled.")

frappe.logger().info(f"[Shopify] Deleted Product {product_gid} | Response: {result}")
else:
frappe.logger().info(
f"[Shopify] Skipped — no integration or integration_item_code for {self.name}"
)
else:
frappe.throw(_("Item Cannot be Deleted — linked with Sales Order or Invoice."))

def check_unique_constraints(self) -> None:
filters = []

Expand Down Expand Up @@ -64,7 +106,10 @@ def is_synced(
integration: shopify,
integration_item_code: TSHIRT
"""
filter = {"integration": integration, "integration_item_code": integration_item_code}
filter = {
"integration": integration,
"integration_item_code": integration_item_code,
}

if variant_id:
filter.update({"variant_id": variant_id})
Expand All @@ -87,7 +132,10 @@ def get_erpnext_item_code(
variant_id: str | None = None,
has_variants: int | None = 0,
) -> str | None:
filters = {"integration": integration, "integration_item_code": integration_item_code}
filters = {
"integration": integration,
"integration_item_code": integration_item_code,
}
if variant_id:
filters.update({"variant_id": variant_id})
elif has_variants:
Expand All @@ -111,11 +159,16 @@ def get_erpnext_item(
item_code = None
if sku:
item_code = frappe.db.get_value(
"Ecommerce Item", {"sku": sku, "integration": integration}, fieldname="erpnext_item_code"
"Ecommerce Item",
{"sku": sku, "integration": integration},
fieldname="erpnext_item_code",
)
if not item_code:
item_code = get_erpnext_item_code(
integration, integration_item_code, variant_id=variant_id, has_variants=has_variants
integration,
integration_item_code,
variant_id=variant_id,
has_variants=has_variants,
)

if item_code:
Expand Down
178 changes: 152 additions & 26 deletions ecommerce_integrations/shopify/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@

import frappe
from frappe import _
from shopify.resources import Webhook
from shopify.session import Session
from shopify import GraphQL, Session

from ecommerce_integrations.shopify.constants import (
API_VERSION,
Expand All @@ -29,52 +28,178 @@ def wrapper(*args, **kwargs):

setting = frappe.get_doc(SETTING_DOCTYPE)
if setting.is_enabled():
auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password"))
auth_details = (
setting.shopify_url,
API_VERSION,
setting.get_password("password"),
)

with Session.temp(*auth_details):
return func(*args, **kwargs)

return wrapper


def register_webhooks(shopify_url: str, password: str) -> list[Webhook]:
"""Register required webhooks with shopify and return registered webhooks."""
def register_webhooks(shopify_url: str, password: str) -> list[dict]:
"""Register required webhooks using Shopify GraphQL API."""

new_webhooks = []

# clear all stale webhooks matching current site url before registering new ones
# Remove old webhooks
unregister_webhooks(shopify_url, password)

mutation = """
mutation webhookSubscriptionCreate($topic: WebhookSubscriptionTopic!, $callbackUrl: URL!) {
webhookSubscriptionCreate(
topic: $topic
webhookSubscription: { format: JSON, callbackUrl: $callbackUrl }
) {
webhookSubscription {
id
topic
endpoint {
__typename
... on WebhookHttpEndpoint {
callbackUrl
}
}
}
userErrors {
field
message
}
}
}
"""

with Session.temp(shopify_url, API_VERSION, password):
for topic in WEBHOOK_EVENTS:
webhook = Webhook.create({"topic": topic, "address": get_callback_url(), "format": "json"})
# Ensure JSON object result
raw = GraphQL().execute(
mutation,
{
"topic": topic,
"callbackUrl": get_callback_url(),
},
)

try:
result = json.loads(raw) if isinstance(raw, str) else raw
except Exception:
create_shopify_log(status="Error", message="Invalid GraphQL response", response_data=raw)
continue

# Core nodes
root = result.get("data")
errors = result.get("errors")

# If `data` is None => fatal GraphQL error
if root is None:
msg = errors[0].get("message") if errors else "Unknown Shopify GraphQL error"
create_shopify_log(
status="Error",
message=msg,
response_data=result,
exception=errors,
)
continue

if webhook.is_valid():
new_webhooks.append(webhook)
else:
create_node = root.get("webhookSubscriptionCreate")

if not create_node:
create_shopify_log(
status="Error",
message="Missing webhookSubscriptionCreate",
response_data=result,
)
continue

# User errors
user_errors = create_node.get("userErrors") or []
if user_errors:
msg = user_errors[0].get("message")
create_shopify_log(
status="Error",
response_data=webhook.to_dict(),
exception=webhook.errors.full_messages(),
message=msg,
response_data=result,
exception=user_errors,
)
continue

webhook = create_node.get("webhookSubscription")
if webhook:
new_webhooks.append(webhook)
query = """
query {
webhookSubscriptionsCount {
count
precision
}
}
"""
response = GraphQL().execute(query)
create_shopify_log(
status="Success", message="Webhooks added to current url", response_data=response
)
return new_webhooks


def unregister_webhooks(shopify_url: str, password: str) -> None:
"""Unregister all webhooks from shopify that correspond to current site url."""
url = get_current_domain_name()
"""Unregister all GraphQL webhooks for the current site URL."""

query = """
{
webhookSubscriptions(first: 250) {
edges {
node {
id
endpoint {
__typename
... on WebhookHttpEndpoint {
callbackUrl
}
}
}
}
}
}
"""

delete_mutation = """
mutation webhookSubscriptionDelete($id: ID!) {
webhookSubscriptionDelete(id: $id) {
deletedWebhookSubscriptionId
userErrors {
field
message
}
}
}
"""

with Session.temp(shopify_url, API_VERSION, password):
for webhook in Webhook.find():
if url in webhook.address:
webhook.destroy()
result_raw = GraphQL().execute(query)

try:
result = json.loads(result_raw) if isinstance(result_raw, str) else result_raw
except Exception:
frappe.log_error(f"Invalid GraphQL response: {result_raw}", "Shopify Unregister Webhooks")
return

edges = result.get("data", {}).get("webhookSubscriptions", {}).get("edges", [])

for edge in edges:
node = edge.get("node", {})
webhook_id = node.get("id")
if webhook_id:
with Session.temp(shopify_url, API_VERSION, password):
response = GraphQL().execute(delete_mutation, {"id": webhook_id})
create_shopify_log(
status="Success", message="Webhook deleted for the current url", response_data=response
)


def get_current_domain_name() -> str:
"""Get current site domain name. E.g. test.erpnext.com

If developer_mode is enabled and localtunnel_url is set in site config then domain is set to localtunnel_url.
"""
if frappe.conf.developer_mode and frappe.conf.localtunnel_url:
return frappe.conf.localtunnel_url
else:
Expand All @@ -99,16 +224,15 @@ def store_request_data() -> None:
_validate_request(frappe.request, hmac_header)

data = json.loads(frappe.request.data)

event = frappe.request.headers.get("X-Shopify-Topic")

process_request(data, event)


def process_request(data, event):
# create log
log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data)

# enqueue backround job
frappe.enqueue(
method=EVENT_MAPPER[event],
queue="short",
Expand All @@ -121,9 +245,11 @@ def process_request(data, event):
def _validate_request(req, hmac_header):
settings = frappe.get_doc(SETTING_DOCTYPE)
secret_key = settings.shared_secret
raw_body = req.get_data()

sig = base64.b64encode(hmac.new(secret_key.encode("utf8"), req.data, hashlib.sha256).digest())
computed_hmac = base64.b64encode(
hmac.new(secret_key.encode("utf-8"), raw_body, hashlib.sha256).digest()
).decode()

if sig != bytes(hmac_header.encode()):
create_shopify_log(status="Error", request_data=req.data)
if not hmac.compare_digest(computed_hmac, hmac_header):
frappe.throw(_("Unverified Webhook Data"))
30 changes: 21 additions & 9 deletions ecommerce_integrations/shopify/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@
SETTING_DOCTYPE = "Shopify Setting"
OLD_SETTINGS_DOCTYPE = "Shopify Settings"

API_VERSION = "2024-01"
API_VERSION = "2025-04"

WEBHOOK_EVENTS = [
"orders/create",
"orders/paid",
"orders/fulfilled",
"orders/cancelled",
"orders/partially_fulfilled",
"ORDERS_CANCELLED",
"ORDERS_CREATE",
"ORDERS_FULFILLED",
"ORDERS_PAID",
"ORDERS_PARTIALLY_FULFILLED",
"PRODUCTS_CREATE",
"RETURNS_APPROVE",
"REFUNDS_CREATE",
]

EVENT_MAPPER = {
Expand All @@ -22,10 +25,11 @@
"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",
"products/create": "ecommerce_integrations.shopify.product.create_item",
"returns/approve": "ecommerce_integrations.shopify.return.process_shopify_return",
"refunds/create": "ecommerce_integrations.shopify.return.process_invoice_return",
}

SHOPIFY_VARIANTS_ATTR_LIST = ["option1", "option2", "option3"]

# custom fields

CUSTOMER_ID_FIELD = "shopify_customer_id"
Expand All @@ -37,6 +41,14 @@
ADDRESS_ID_FIELD = "shopify_address_id"
ORDER_ITEM_DISCOUNT_FIELD = "shopify_item_discount"
ITEM_SELLING_RATE_FIELD = "shopify_selling_rate"
SHOPIFY_LINE_ITEM_ID_FIELD = "shopify_line_item_id"
SHOPIFY_RETURN_ID_FIELD = "shopify_return_id"


# ERPNext already defines the default UOMs from Shopify but names are different
WEIGHT_TO_ERPNEXT_UOM_MAP = {"kg": "Kg", "g": "Gram", "oz": "Ounce", "lb": "Pound"}
WEIGHT_TO_ERPNEXT_UOM_MAP = {
"KILOGRAMS": "Kg",
"GRAMS": "Gram",
"POUNDS": "Lb",
"OUNCES": "Oz",
}
Loading
Loading