From 0606d2f9ea944ffbf1cf197db6c7932da162dedf Mon Sep 17 00:00:00 2001 From: Ahmed Osama Date: Wed, 13 Aug 2025 15:35:53 +0000 Subject: [PATCH 01/30] feat(shopify): implement multi-tenant architecture for Shopify integration Add support for multiple Shopify stores with isolated configurations per company. Introduce Shopify Account doctype to replace legacy singleton settings. Update all core functions to be account-aware including product sync, order processing, inventory management, and webhook handling. Maintain backward compatibility with existing installations. - Add Shopify Account doctype with company-specific settings - Modify product, order, inventory, and fulfillment logic to support multi-tenancy - Update webhook handling to route events by shop domain - Add account selection UI to product import page - Include comprehensive tests and documentation --- ecommerce_integrations/hooks.py | 35 +- ecommerce_integrations/shopify/Readme.md | 288 ++++++++++++ ecommerce_integrations/shopify/connection.py | 123 ++++-- ecommerce_integrations/shopify/constants.py | 3 +- ecommerce_integrations/shopify/customer.py | 14 +- .../doctype/shopify_account/__init__.py | 0 .../shopify_account/shopify_account.js | 239 ++++++++++ .../shopify_account/shopify_account.json | 413 ++++++++++++++++++ .../shopify_account/shopify_account.py | 196 +++++++++ .../shopify_account/test_shopify_account.py | 203 +++++++++ ecommerce_integrations/shopify/fulfillment.py | 90 +++- ecommerce_integrations/shopify/inventory.py | 70 ++- ecommerce_integrations/shopify/invoice.py | 90 +++- ecommerce_integrations/shopify/order.py | 205 +++++++-- .../shopify_import_products.js | 146 ++++++- .../shopify_import_products.py | 81 +++- ecommerce_integrations/shopify/product.py | 86 +++- 17 files changed, 2119 insertions(+), 163 deletions(-) create mode 100644 ecommerce_integrations/shopify/Readme.md create mode 100644 ecommerce_integrations/shopify/doctype/shopify_account/__init__.py create mode 100644 ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js create mode 100644 ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json create mode 100644 ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py create mode 100644 ecommerce_integrations/shopify/doctype/shopify_account/test_shopify_account.py diff --git a/ecommerce_integrations/hooks.py b/ecommerce_integrations/hooks.py index 2f99e5e57..dafe1e09b 100644 --- a/ecommerce_integrations/hooks.py +++ b/ecommerce_integrations/hooks.py @@ -136,10 +136,14 @@ # --------------- scheduler_events = { - "all": ["ecommerce_integrations.shopify.inventory.update_inventory_on_shopify"], + "all": [ + # Updated to use multi-tenant inventory sync + "ecommerce_integrations.shopify.inventory.update_inventory_on_shopify" + ], "daily": [], "daily_long": ["ecommerce_integrations.zenoti.doctype.zenoti_settings.zenoti_settings.sync_stocks"], "hourly": [ + # Updated to use multi-tenant old orders sync "ecommerce_integrations.shopify.order.sync_old_orders", "ecommerce_integrations.amazon.doctype.amazon_sp_api_settings.amazon_sp_api_settings.schedule_get_order_details", ], @@ -198,21 +202,26 @@ # "filter_by": "{filter_by}", # "redact_fields": ["{field_1}", "{field_2}"], # "partial": 1, -# }, -# { -# "doctype": "{doctype_2}", -# "filter_by": "{filter_by}", -# "partial": 1, -# }, -# { -# "doctype": "{doctype_3}", -# "strict": False, -# }, -# { -# "doctype": "{doctype_4}" # } # ] +# Authentication and authorization +# -------------------------------- + +# auth_hooks = [ +# "ecommerce_integrations.auth.validate" +# ] + +# Translation +# -------------------------------- + +# Make property setters available in the translation file +# translate_linked_doctypes = ["DocType", "Role"] + +# Automatically update python controller files with type annotations for DocTypes +# Automatically add __init__.py files to the DocTypes +# be careful turning this on if you have customizations. +export_python_type_annotations = True default_log_clearing_doctypes = { "Ecommerce Integration Log": 120, diff --git a/ecommerce_integrations/shopify/Readme.md b/ecommerce_integrations/shopify/Readme.md new file mode 100644 index 000000000..03a597481 --- /dev/null +++ b/ecommerce_integrations/shopify/Readme.md @@ -0,0 +1,288 @@ +# Shopify Integration for ERPNext + +A **comprehensive, production-ready Shopify integration** for ERPNext, now with **multi-tenant** architecture β€” connect multiple Shopify stores to one ERPNext instance while ensuring complete **data isolation**, **company-specific configuration**, and **secure synchronization**. + +--- + +## πŸš€ Key Features + +* **Multi-Tenant Architecture** – Connect multiple Shopify stores, each isolated to its own ERPNext company. +* **Legacy Compatibility** – Backward-compatible with existing single-store setups. +* **Company-Bound Data** – Warehouses, customers, and transactions are strictly company-specific. +* **Secure Webhook Handling** – Domain-based routing and HMAC validation for all inbound events. +* **Real-Time Synchronization** – Bidirectional sync for products, orders, inventory, and customers. +* **Granular Control** – Per-store toggles for product creation, invoice/fulfillment sync, and more. + +--- + +## πŸ— Architecture + +### Multi-Tenant Design + +* **Shopify Account** *(Recommended)* – Modern, non-single DocType for per-store settings. +* **Shopify Setting** *(Deprecated)* – Legacy singleton for single-store setups. +* **Automatic Migration** – Legacy settings auto-migrate to new accounts on upgrade. + +### Data Isolation + +* Each **Shopify Account** is tied to one **ERPNext Company**. +* Warehouses, customers, and transactions are scoped to the company. +* No data leakage between stores β€” strict per-account mappings. + +--- + +## βš™οΈ Configuration + +### 1. Multi-Tenant Setup *(Recommended)* + +#### Create a Shopify Account + +1. Go to **Shopify Account** in ERPNext. +2. Create a record with the following: + +**Basic Info** + +| Field | Example | Notes | +| ------------- | ----------------------- | ------------------------------- | +| Enabled | βœ“ | Activate this store integration | +| Account Title | Main Store KSA | Friendly label | +| Shop Domain | `mystore.myshopify.com` | Exact Shopify domain | +| API Version | `2023-10` | Auto-managed | + +**Credentials** + +| Field | Example | Notes | +| -------------- | ---------------- | --------------------------- | +| Access Token | `shpat_xxxxx` | From Shopify Admin API | +| Shared Secret | `webhook-secret` | Used for HMAC validation | +| Public App Key | optional | Only for specific app flows | + +**Company & Defaults** + +| Field | Example | Notes | +| ------------------ | -------------------- | ---------------------------- | +| Company | Your ERPNext Company | Required | +| Selling Price List | Standard Selling | Optional | +| Cost Center | Main – Company | Required if SI/DN sync is on | +| Default Customer | Walk-in Customer | Fallback | + +**Document Series** + +| SO | SI | DN | +| ---------- | ------------ | ---------- | +| `SO-SHOP-` | `SINV-SHOP-` | `DN-SHOP-` | + +**Feature Toggles** + +* Create Customers +* Create Missing Items +* Sync Sales Invoice +* Sync Delivery Note +* Allow Backdated Sync +* Close Orders on Fulfillment + +**Product Upload Settings** + +* Upload new ERPNext Items to Shopify +* Update Shopify Items on ERPNext changes +* Sync New Items as Active +* Upload Variants as Shopify Items + +**Inventory Sync** + +* Update ERPNext stock levels to Shopify +* Sync frequency (15min / 30min / Hourly / 6hrs / Daily) + +**Old Orders Sync** + +* One-time historical order sync with date range. + +--- + +#### Warehouse Mappings + +Map Shopify locations to ERPNext warehouses: + +1. Fetch locations from Shopify. +2. Map each location to an ERPNext warehouse (must belong to the same company). + +#### Tax Mappings + +Map Shopify tax/shipping titles to ERPNext accounts: + +* Shopify Tax/Shipping Title: e.g., `VAT` +* ERPNext Account: e.g., `VAT Payable – Company` + +--- + +### 2. Legacy Setup *(Deprecated)* + +* Only for existing single-store installations. +* Automatically migrated to **Shopify Account** during upgrade. + +--- + +## πŸ“¦ Functional Areas + +### 1. Product Management + +* Bulk import via **Shopify Import Products** page. +* Account-aware upload and variant handling. +* SKU synchronization and price list integration. + +### 2. Order Processing + +* Webhook-driven real-time order creation. +* Auto-create customers with company isolation. +* Location-based inventory allocation. +* Tax mapping and shipping address handling. + +**Supported Events:** +`orders/create`, `orders/updated`, `orders/paid`, `orders/cancelled`, `orders/fulfilled`, `orders/partially_fulfilled` + +### 3. Fulfillment Management + +* Auto-create Delivery Notes. +* Sync tracking numbers to Shopify. +* Multi-location fulfillment support. + +### 4. Invoice Management + +* Auto-create Sales Invoices for paid orders. +* Company-specific tax mapping and payment terms. + +### 5. Inventory Synchronization + +* Configurable sync frequency. +* Multi-location tracking. +* Warehouse-specific stock updates. + +### 6. Customer Management + +* Per-account customer creation and address sync. +* Company-specific customer groups. + +--- + +## πŸ”” Webhook Handling + +### Automatic Setup + +* Enabled accounts auto-register required webhooks in Shopify. +* Events routed by `X-Shopify-Shop-Domain`. +* HMAC validation per account. + +**Monitored Events:** +`orders/create`, `orders/updated`, `orders/paid`, `orders/cancelled`, `orders/fulfilled`, `orders/partially_fulfilled`, `app/uninstalled` + +--- + +## πŸ“‘ Custom Fields + +**Item:** `shopify_selling_rate` +**Customer:** `shopify_customer_id` +**Supplier:** `shopify_supplier_id` +**Address:** `shopify_address_id` +**Sales Order:** `shopify_order_id`, `shopify_order_number`, `shopify_order_status` +**SO Item:** `shopify_discount_per_unit` +**Delivery Note:** `shopify_fulfillment_id` + +--- + +## πŸ›‘ Data Isolation Rules + +1. **Warehouse must match account company.** +2. **Tax account must match account company.** +3. **Customers & transactions** created in the account’s company only. +4. **Series & numbering** respect the account’s settings. + +--- + +## πŸ”„ Migration from Legacy + +**Automatic Process:** + +1. Detect existing **Shopify Setting**. +2. Create equivalent **Shopify Account**. +3. Validate data integrity. +4. Keep legacy mode as fallback until fully retired. + +--- + +## πŸ›  API Reference (Python) + +```python +# Product +get_shopify_products(from_=None, account=None) +sync_product(product_id, account=None) +import_all_products(account=None) + +# Orders +sync_sales_order(order_data, account=None) +create_order(order_data, account=None) + +# Accounts +get_shopify_accounts() +validate_account(account_name) + +# Inventory +update_inventory_levels(account=None) +sync_stock_to_shopify(account=None) +``` + +--- + +## πŸ§ͺ Testing Guidelines + +* **Unit:** Function-level with mocks. +* **Integration:** End-to-end flows per account. +* **Multi-Tenant:** Two accounts β†’ verify isolation. +* **Webhook:** Valid/invalid HMAC & domain routing. +* **Performance:** Large product/order datasets. + +--- + +## πŸ’‘ Best Practices + +**Account Setup** + +* Use descriptive names. +* Map all locations and tax accounts. +* Test webhook connectivity before live. + +**Warehouse** + +* Keep consistent naming. +* Align warehouses to the account’s company. + +**Security** + +* Rotate tokens regularly. +* Monitor webhook logs. +* Use strong, unique secrets. + +**Performance** + +* Bulk operations for large catalogs. +* Tune inventory sync frequency. +* Monitor queue lengths. + +--- + +## πŸ“… Maintenance + +* Review integration logs weekly. +* Update Shopify API version periodically. +* Apply security patches promptly. +* Monitor API usage and sync times. + +--- + +## πŸ“š Support + +* **Docs:** This README + inline code comments. +* **Logs:** Integration Log, Error Log, Webhook Log. +* **Community:** ERPNext forums, GitHub Issues. +* **Professional:** ERPNext support or implementation partners. + +--- diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index 4a4c7c86d..4837e8100 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -18,23 +18,61 @@ from ecommerce_integrations.shopify.utils import create_shopify_log -def temp_shopify_session(func): - """Any function that needs to access shopify api needs this decorator. The decorator starts a temp session that's destroyed when function returns.""" - - @functools.wraps(func) - def wrapper(*args, **kwargs): - # no auth in testing - if frappe.flags.in_test: - return func(*args, **kwargs) - - setting = frappe.get_doc(SETTING_DOCTYPE) - if setting.is_enabled(): - auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password")) - - with Session.temp(*auth_details): +def temp_shopify_session(func=None, *, account=None): + """Account-aware decorator for Shopify API access. + + Usage: + 1. As decorator with account parameter: @temp_shopify_session(account=account_doc) + 2. As decorator for methods that have account context: @temp_shopify_session + 3. Legacy mode (fallback to singleton): @temp_shopify_session + """ + + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + # no auth in testing + if frappe.flags.in_test: return func(*args, **kwargs) - - return wrapper + + # Determine account to use + shopify_account = None + + # Option 1: Account explicitly passed to decorator + if account: + shopify_account = account + # Option 2: Account in function arguments + elif args and hasattr(args[0], 'doctype') and args[0].doctype == "Shopify Account": + shopify_account = args[0] + # Option 3: Account passed as keyword argument + elif 'account' in kwargs: + shopify_account = kwargs.get('account') + # Option 4: Legacy fallback to singleton (for backward compatibility) + else: + setting = frappe.get_doc(SETTING_DOCTYPE) + if setting.is_enabled(): + auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password")) + with Session.temp(*auth_details): + return func(*args, **kwargs) + return + + # Use account-specific credentials + if shopify_account and shopify_account.is_enabled(): + auth_details = ( + shopify_account.shop_domain, + shopify_account.api_version or API_VERSION, + shopify_account.get_access_token() + ) + + with Session.temp(*auth_details): + return func(*args, **kwargs) + + return wrapper + + # Handle both @temp_shopify_session and @temp_shopify_session(account=...) + if func is None: + return decorator + else: + return decorator(func) def register_webhooks(shopify_url: str, password: str) -> list[Webhook]: @@ -95,35 +133,66 @@ def get_callback_url() -> str: def store_request_data() -> None: if frappe.request: hmac_header = frappe.get_request_header("X-Shopify-Hmac-Sha256") + shop_domain = frappe.get_request_header("X-Shopify-Shop-Domain") + + # Resolve account by shop domain + account = _get_account_by_domain(shop_domain) + if not account: + create_shopify_log( + status="Error", + request_data=frappe.request.data, + exception=f"No enabled Shopify Account found for domain: {shop_domain}" + ) + frappe.throw(_("No enabled Shopify Account found for domain: {0}").format(shop_domain)) - _validate_request(frappe.request, hmac_header) + _validate_request(frappe.request, hmac_header, account) data = json.loads(frappe.request.data) event = frappe.request.headers.get("X-Shopify-Topic") - process_request(data, event) + process_request(data, event, account) -def process_request(data, event): - # create log - log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data) +def process_request(data, event, account): + # create log with account context + log = create_shopify_log( + method=EVENT_MAPPER[event], + request_data=data, + reference_document=account.name + ) - # enqueue backround job + # enqueue background job with account context frappe.enqueue( method=EVENT_MAPPER[event], queue="short", timeout=300, is_async=True, - **{"payload": data, "request_id": log.name}, + **{"payload": data, "request_id": log.name, "account": account.name}, ) -def _validate_request(req, hmac_header): - settings = frappe.get_doc(SETTING_DOCTYPE) - secret_key = settings.shared_secret +def _validate_request(req, hmac_header, account): + """Validate webhook request using account-specific shared secret.""" + secret_key = account.get_shared_secret() sig = base64.b64encode(hmac.new(secret_key.encode("utf8"), req.data, hashlib.sha256).digest()) if sig != bytes(hmac_header.encode()): - create_shopify_log(status="Error", request_data=req.data) + create_shopify_log( + status="Error", + request_data=req.data, + reference_document=account.name, + exception="Invalid HMAC signature" + ) frappe.throw(_("Unverified Webhook Data")) + + +def _get_account_by_domain(shop_domain): + """Get enabled Shopify Account by shop domain.""" + if not shop_domain: + return None + + try: + return frappe.get_doc("Shopify Account", {"shop_domain": shop_domain, "enabled": 1}) + except frappe.DoesNotExistError: + return None diff --git a/ecommerce_integrations/shopify/constants.py b/ecommerce_integrations/shopify/constants.py index 47720e032..50fca8813 100644 --- a/ecommerce_integrations/shopify/constants.py +++ b/ecommerce_integrations/shopify/constants.py @@ -3,7 +3,8 @@ MODULE_NAME = "shopify" -SETTING_DOCTYPE = "Shopify Setting" +SETTING_DOCTYPE = "Shopify Setting" # Legacy singleton +ACCOUNT_DOCTYPE = "Shopify Account" # New multi-tenant account OLD_SETTINGS_DOCTYPE = "Shopify Settings" API_VERSION = "2024-01" diff --git a/ecommerce_integrations/shopify/customer.py b/ecommerce_integrations/shopify/customer.py index 3a0ee952f..34316e905 100644 --- a/ecommerce_integrations/shopify/customer.py +++ b/ecommerce_integrations/shopify/customer.py @@ -10,12 +10,22 @@ CUSTOMER_ID_FIELD, MODULE_NAME, SETTING_DOCTYPE, + ACCOUNT_DOCTYPE, ) class ShopifyCustomer(EcommerceCustomer): - def __init__(self, customer_id: str): - self.setting = frappe.get_doc(SETTING_DOCTYPE) + def __init__(self, customer_id: str, account=None): + # Multi-tenant: Use account-specific settings or fallback to legacy singleton + if account: + if isinstance(account, str): + self.setting = frappe.get_doc(ACCOUNT_DOCTYPE, account) + else: + self.setting = account + else: + # Legacy fallback for backward compatibility + self.setting = frappe.get_doc(SETTING_DOCTYPE) + super().__init__(customer_id, CUSTOMER_ID_FIELD, MODULE_NAME) def sync_customer(self, customer: dict[str, Any]) -> None: diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/__init__.py b/ecommerce_integrations/shopify/doctype/shopify_account/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js new file mode 100644 index 000000000..94d563472 --- /dev/null +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js @@ -0,0 +1,239 @@ +// Copyright (c) 2024, Frappe and contributors +// For license information, please see LICENSE + +frappe.provide("ecommerce_integrations.shopify.shopify_account"); + +frappe.ui.form.on("Shopify Account", { + onload: function (frm) { + // Load naming series for document series fields + frappe.call({ + method: "ecommerce_integrations.utils.naming_series.get_series", + callback: function (r) { + if (r.message) { + $.each(r.message, (key, value) => { + set_field_options(key, value); + }); + } + }, + }); + + // Set up form description + frm.set_intro(__("This record serves as the Shopify Settings for a single Shopify store. Create one record per store.")); + }, + + refresh: function (frm) { + // Add custom buttons + frm.add_custom_button(__("Import Products"), function () { + if (frm.doc.enabled && frm.doc.shop_domain) { + frappe.set_route("shopify-import-products", {"account": frm.doc.name}); + } else { + frappe.msgprint(__("Please enable the account and save before importing products")); + } + }); + + frm.add_custom_button(__("View Logs"), () => { + frappe.set_route("List", "Ecommerce Integration Log", { + integration: "Shopify", + reference_document: frm.doc.name + }); + }); + + frm.add_custom_button(__("Fetch Shopify Locations"), function () { + if (!frm.doc.enabled) { + frappe.msgprint(__("Please enable the account first")); + return; + } + + frappe.call({ + doc: frm.doc, + method: "fetch_shopify_locations", + callback: (r) => { + if (!r.exc) { + frm.refresh_field("warehouse_mappings"); + frappe.msgprint(__("Shopify locations fetched successfully")); + } + }, + }); + }); + + frm.trigger("setup_queries"); + frm.trigger("toggle_conditional_fields"); + frm.trigger("show_enabled_status"); + }, + + enabled: function (frm) { + frm.trigger("toggle_conditional_fields"); + frm.trigger("show_enabled_status"); + }, + + company: function (frm) { + if (frm.doc.company) { + // Warn user to review mappings when company changes + if (frm.doc.warehouse_mappings && frm.doc.warehouse_mappings.length > 0) { + frappe.msgprint({ + title: __("Company Changed"), + message: __("Please review warehouse and tax mappings to ensure they belong to the selected company."), + indicator: "orange" + }); + } + } + frm.trigger("setup_queries"); + }, + + shop_domain: function (frm) { + // Auto-format shop domain + if (frm.doc.shop_domain) { + let domain = frm.doc.shop_domain.replace(/^https?:\/\//, ""); + if (domain && !domain.endsWith(".myshopify.com")) { + // Don't auto-append, let validation handle it + frappe.msgprint({ + title: __("Invalid Domain"), + message: __("Shop domain must end with '.myshopify.com'"), + indicator: "red" + }); + } + frm.set_value("shop_domain", domain); + } + }, + + sync_sales_invoice: function (frm) { + frm.trigger("validate_sync_dependencies"); + }, + + sync_delivery_note: function (frm) { + frm.trigger("validate_sync_dependencies"); + }, + + create_customers: function (frm) { + if (!frm.doc.create_customers && !frm.doc.default_customer) { + frappe.msgprint({ + title: __("Default Customer Required"), + message: __("When automatic customer creation is disabled, a default customer should be set."), + indicator: "orange" + }); + } + }, + + toggle_conditional_fields: function (frm) { + // Show/hide fields based on enabled status + const is_enabled = frm.doc.enabled; + + // Make credentials mandatory when enabled + frm.toggle_reqd("access_token", is_enabled); + frm.toggle_reqd("shared_secret", is_enabled); + frm.toggle_reqd("company", is_enabled); + }, + + show_enabled_status: function (frm) { + // Show status indicator + if (frm.doc.enabled) { + if (!frm.doc.access_token || !frm.doc.shared_secret || !frm.doc.company) { + frm.dashboard.add_indicator(__("Incomplete Setup"), "orange"); + } else { + frm.dashboard.add_indicator(__("Enabled"), "green"); + } + } else { + frm.dashboard.add_indicator(__("Disabled"), "red"); + } + }, + + validate_sync_dependencies: function (frm) { + if ((frm.doc.sync_sales_invoice || frm.doc.sync_delivery_note) && !frm.doc.cost_center) { + frappe.msgprint({ + title: __("Cost Center Recommended"), + message: __("A cost center is recommended when Sales Invoice or Delivery Note sync is enabled."), + indicator: "orange" + }); + } + }, + + setup_queries: function (frm) { + // Warehouse queries - filter by company + const warehouse_query = () => { + return { + filters: { + company: frm.doc.company, + is_group: 0, + disabled: 0, + }, + }; + }; + + frm.set_query("erpnext_warehouse", "warehouse_mappings", warehouse_query); + + // Price list query - only selling price lists + frm.set_query("selling_price_list", () => { + return { + filters: { + selling: 1, + }, + }; + }); + + // Cost center query - filter by company + frm.set_query("cost_center", () => { + return { + filters: { + company: frm.doc.company, + is_group: "No", + }, + }; + }); + + // Customer query - filter by company if set + frm.set_query("default_customer", () => { + const filters = {disabled: 0}; + if (frm.doc.company) { + filters.company = frm.doc.company; + } + return {filters}; + }); + + // Tax account queries + const tax_query = () => { + return { + query: "erpnext.controllers.queries.tax_account_query", + filters: { + account_type: ["Tax", "Chargeable", "Expense Account"], + company: frm.doc.company, + }, + }; + }; + + frm.set_query("tax_account", "tax_mappings", tax_query); + }, +}); + +// Handle warehouse mapping child table events +frappe.ui.form.on("Shopify Warehouse Mapping", { + erpnext_warehouse: function (frm, cdt, cdn) { + const row = locals[cdt][cdn]; + if (row.erpnext_warehouse && frm.doc.company) { + // Validate warehouse belongs to the same company + frappe.db.get_value("Warehouse", row.erpnext_warehouse, "company") + .then(r => { + if (r.message && r.message.company !== frm.doc.company) { + frappe.msgprint(__("Selected warehouse does not belong to company {0}", [frm.doc.company])); + frappe.model.set_value(cdt, cdn, "erpnext_warehouse", ""); + } + }); + } + } +}); + +// Handle tax mapping child table events +frappe.ui.form.on("Shopify Tax Account", { + tax_account: function (frm, cdt, cdn) { + const row = locals[cdt][cdn]; + if (row.tax_account && frm.doc.company) { + // Validate tax account belongs to the same company + frappe.db.get_value("Account", row.tax_account, "company") + .then(r => { + if (r.message && r.message.company !== frm.doc.company) { + frappe.msgprint(__("Selected account does not belong to company {0}", [frm.doc.company])); + frappe.model.set_value(cdt, cdn, "tax_account", ""); + } + }); + } + } +}); diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json new file mode 100644 index 000000000..4a016c713 --- /dev/null +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json @@ -0,0 +1,413 @@ +{ + "actions": [], + "autoname": "field:shop_domain", + "creation": "2024-01-01 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "enabled", + "account_title", + "column_break_basic", + "shop_domain", + "api_version", + "section_break_credentials", + "access_token", + "shared_secret", + "column_break_credentials", + "public_app_key", + "section_break_company", + "company", + "selling_price_list", + "column_break_company", + "cost_center", + "default_customer", + "section_break_series", + "sales_order_series", + "sales_invoice_series", + "column_break_series", + "delivery_note_series", + "section_break_features", + "create_customers", + "create_missing_items", + "column_break_features1", + "sync_sales_invoice", + "sync_delivery_note", + "column_break_features2", + "allow_backdated_sync", + "close_orders_on_fulfillment", + "section_break_product_upload", + "upload_erpnext_items", + "update_shopify_item_on_update", + "column_break_product_upload", + "sync_new_item_as_active", + "upload_variants_as_items", + "section_break_inventory", + "update_erpnext_stock_levels_to_shopify", + "inventory_sync_frequency", + "column_break_inventory", + "last_inventory_sync", + "section_break_old_orders", + "sync_old_orders", + "old_orders_from", + "column_break_old_orders", + "old_orders_to", + "section_break_mappings", + "warehouse_mappings", + "tax_mappings", + "section_break_operational", + "last_sync_status", + "last_sync_at", + "column_break_operational", + "notes" + ], + "fields": [ + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, + { + "fieldname": "account_title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Account Title", + "description": "Friendly name for this Shopify store (e.g., 'Main KSA Store')" + }, + { + "fieldname": "column_break_basic", + "fieldtype": "Column Break" + }, + { + "fieldname": "shop_domain", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Shop Domain", + "reqd": 1, + "unique": 1, + "description": "Exact domain from Shopify (e.g., mystore.myshopify.com). Used to route webhooks." + }, + { + "default": "2023-10", + "fieldname": "api_version", + "fieldtype": "Data", + "label": "API Version", + "read_only": 1, + "description": "Shopify API version (auto-managed)" + }, + { + "collapsible": 0, + "fieldname": "section_break_credentials", + "fieldtype": "Section Break", + "label": "Credentials" + }, + { + "fieldname": "access_token", + "fieldtype": "Password", + "label": "Access Token", + "mandatory_depends_on": "eval:doc.enabled" + }, + { + "fieldname": "shared_secret", + "fieldtype": "Password", + "label": "Shared Secret", + "mandatory_depends_on": "eval:doc.enabled", + "description": "Used for webhook HMAC verification" + }, + { + "fieldname": "column_break_credentials", + "fieldtype": "Column Break" + }, + { + "fieldname": "public_app_key", + "fieldtype": "Data", + "label": "Public App Key", + "description": "Optional: Only needed for specific app flows" + }, + { + "fieldname": "section_break_company", + "fieldtype": "Section Break", + "label": "Company & Defaults" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "reqd": 1, + "description": "ERPNext legal entity receiving the documents" + }, + { + "fieldname": "selling_price_list", + "fieldtype": "Link", + "label": "Selling Price List", + "options": "Price List", + "description": "Used when pricing/valuation is needed" + }, + { + "fieldname": "column_break_company", + "fieldtype": "Column Break" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center", + "description": "Default for SI/DN if created" + }, + { + "fieldname": "default_customer", + "fieldtype": "Link", + "label": "Default Customer", + "options": "Customer", + "description": "Optional fallback if customer creation is off or mapping fails" + }, + { + "fieldname": "section_break_series", + "fieldtype": "Section Break", + "label": "Document Series" + }, + { + "fieldname": "sales_order_series", + "fieldtype": "Select", + "label": "Sales Order Series", + "description": "e.g., SO-SHOP-. If empty, safe defaults will be used." + }, + { + "fieldname": "sales_invoice_series", + "fieldtype": "Select", + "label": "Sales Invoice Series", + "description": "e.g., SINV-SHOP-. If empty, safe defaults will be used." + }, + { + "fieldname": "column_break_series", + "fieldtype": "Column Break" + }, + { + "fieldname": "delivery_note_series", + "fieldtype": "Select", + "label": "Delivery Note Series", + "description": "e.g., DN-SHOP-. If empty, safe defaults will be used." + }, + { + "fieldname": "section_break_features", + "fieldtype": "Section Break", + "label": "Feature Toggles" + }, + { + "default": "1", + "fieldname": "create_customers", + "fieldtype": "Check", + "label": "Create Customers" + }, + { + "default": "0", + "fieldname": "create_missing_items", + "fieldtype": "Check", + "label": "Create Missing Items" + }, + { + "fieldname": "column_break_features1", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "sync_sales_invoice", + "fieldtype": "Check", + "label": "Sync Sales Invoice" + }, + { + "default": "0", + "fieldname": "sync_delivery_note", + "fieldtype": "Check", + "label": "Sync Delivery Note" + }, + { + "fieldname": "column_break_features2", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "allow_backdated_sync", + "fieldtype": "Check", + "label": "Allow Backdated Sync", + "description": "If true, backfill jobs allowed; otherwise block with a warning" + }, + { + "default": "0", + "fieldname": "close_orders_on_fulfillment", + "fieldtype": "Check", + "label": "Close Orders on Fulfillment", + "description": "Auto-close orders when delivery note/fulfillment is created" + }, + { + "fieldname": "section_break_product_upload", + "fieldtype": "Section Break", + "label": "Product Upload Settings" + }, + { + "default": "0", + "fieldname": "upload_erpnext_items", + "fieldtype": "Check", + "label": "Upload new ERPNext Items to Shopify", + "description": "Automatically upload new ERPNext items to this Shopify store" + }, + { + "default": "0", + "depends_on": "eval:doc.upload_erpnext_items", + "fieldname": "update_shopify_item_on_update", + "fieldtype": "Check", + "label": "Update Shopify Item after updating ERPNext item", + "description": "Sync changes from ERPNext items to Shopify products" + }, + { + "fieldname": "column_break_product_upload", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "sync_new_item_as_active", + "fieldtype": "Check", + "label": "Sync New Items as Active", + "description": "New products will be published as active on Shopify" + }, + { + "default": "0", + "description": "Caution: Only 3 attributes will be accepted by Shopify", + "fieldname": "upload_variants_as_items", + "fieldtype": "Check", + "label": "Upload ERPNext Variants as Shopify Items" + }, + { + "default": "0", + "fieldname": "update_erpnext_stock_levels_to_shopify", + "fieldtype": "Check", + "label": "Update ERPNext Stock Levels to Shopify", + "description": "Enable automatic inventory sync from ERPNext to Shopify" + }, + { + "default": "Hourly", + "fieldname": "inventory_sync_frequency", + "fieldtype": "Select", + "label": "Inventory Sync Frequency", + "options": "Every 15 minutes\nEvery 30 minutes\nHourly\nEvery 6 hours\nDaily", + "depends_on": "eval:doc.update_erpnext_stock_levels_to_shopify", + "description": "How often to sync inventory levels" + }, + { + "fieldname": "column_break_inventory", + "fieldtype": "Column Break" + }, + { + "fieldname": "last_inventory_sync", + "fieldtype": "Datetime", + "label": "Last Inventory Sync", + "read_only": 1, + "description": "Timestamp of last inventory sync" + }, + { + "fieldname": "section_break_old_orders", + "fieldtype": "Section Break", + "label": "Old Orders Sync" + }, + { + "default": "0", + "fieldname": "sync_old_orders", + "fieldtype": "Check", + "label": "Sync Old Orders", + "description": "Enable one-time sync of historical orders from Shopify" + }, + { + "fieldname": "old_orders_from", + "fieldtype": "Datetime", + "label": "Old Orders From", + "depends_on": "eval:doc.sync_old_orders", + "description": "Start date/time for historical order sync" + }, + { + "fieldname": "column_break_old_orders", + "fieldtype": "Column Break" + }, + { + "fieldname": "old_orders_to", + "fieldtype": "Datetime", + "label": "Old Orders To", + "depends_on": "eval:doc.sync_old_orders", + "description": "End date/time for historical order sync" + }, + { + "fieldname": "section_break_mappings", + "fieldtype": "Section Break", + "label": "Account-Specific Mappings" + }, + { + "fieldname": "warehouse_mappings", + "fieldtype": "Table", + "label": "Warehouse Mappings", + "options": "Shopify Warehouse Mapping" + }, + { + "fieldname": "tax_mappings", + "fieldtype": "Table", + "label": "Tax Mappings", + "options": "Shopify Tax Account" + }, + { + "fieldname": "section_break_operational", + "fieldtype": "Section Break", + "label": "Operational Status" + }, + { + "default": "Idle", + "fieldname": "last_sync_status", + "fieldtype": "Select", + "label": "Last Sync Status", + "options": "Idle\nSuccess\nWarning\nError", + "read_only": 1 + }, + { + "fieldname": "last_sync_at", + "fieldtype": "Datetime", + "label": "Last Sync At", + "read_only": 1 + }, + { + "fieldname": "column_break_operational", + "fieldtype": "Column Break" + }, + { + "fieldname": "notes", + "fieldtype": "Small Text", + "label": "Notes", + "description": "Admin notes/audit comments" + } + ], + "index_web_pages_for_search": 1, + "issingle": 0, + "links": [], + "modified": "2024-01-01 00:00:00.000000", + "modified_by": "Administrator", + "module": "Shopify", + "name": "Shopify Account", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "account_title", + "track_changes": 1 +} diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py new file mode 100644 index 000000000..d2c170db1 --- /dev/null +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py @@ -0,0 +1,196 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see LICENSE + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import validate_email_address +from shopify.collection import PaginatedIterator +from shopify.resources import Location + +from ecommerce_integrations.shopify import connection + + +class ShopifyAccount(Document): + def validate(self): + """Validate Shopify Account settings before saving.""" + self.validate_shop_domain() + self.validate_required_when_enabled() + self.validate_company_consistency() + self.validate_warehouse_mappings() + self.validate_tax_mappings() + self.validate_feature_dependencies() + + def validate_shop_domain(self): + """Validate shop domain format.""" + if self.shop_domain: + # Remove https:// or http:// if present + self.shop_domain = self.shop_domain.replace("https://", "").replace("http://", "") + + # Ensure it ends with .myshopify.com + if not self.shop_domain.endswith(".myshopify.com"): + frappe.throw(_("Shop Domain must be a valid Shopify domain ending with '.myshopify.com'")) + + def validate_required_when_enabled(self): + """Validate required fields when account is enabled.""" + if self.enabled: + required_fields = ["shop_domain", "access_token", "shared_secret", "company"] + for field in required_fields: + if not self.get(field): + frappe.throw(_("{0} is required when account is enabled").format(self.meta.get_label(field))) + + def validate_company_consistency(self): + """Validate that all company-related fields belong to the same company.""" + if not self.company: + return + + # Validate cost center belongs to company + if self.cost_center: + cost_center_company = frappe.db.get_value("Cost Center", self.cost_center, "company") + if cost_center_company != self.company: + frappe.throw(_("Cost Center {0} does not belong to Company {1}").format( + self.cost_center, self.company)) + + # Validate default customer belongs to company + if self.default_customer: + customer_company = frappe.db.get_value("Customer", self.default_customer, "company") + # Customer might not have a company set, so only validate if it's set + if customer_company and customer_company != self.company: + frappe.throw(_("Default Customer {0} does not belong to Company {1}").format( + self.default_customer, self.company)) + + def validate_warehouse_mappings(self): + """Validate warehouse mappings.""" + if not self.warehouse_mappings: + return + + seen_locations = set() + default_count = 0 + + for mapping in self.warehouse_mappings: + # Check for duplicate Shopify location IDs + if mapping.shopify_location_id in seen_locations: + frappe.throw(_("Duplicate Shopify Location ID: {0}").format(mapping.shopify_location_id)) + seen_locations.add(mapping.shopify_location_id) + + # Validate warehouse belongs to the same company + if mapping.erpnext_warehouse: + warehouse_company = frappe.db.get_value("Warehouse", mapping.erpnext_warehouse, "company") + if warehouse_company != self.company: + frappe.throw(_("Warehouse {0} does not belong to Company {1}").format( + mapping.erpnext_warehouse, self.company)) + + # Count default warehouses (if we add is_default field later) + if hasattr(mapping, 'is_default') and mapping.is_default: + default_count += 1 + + # Ensure only one default warehouse (if we add is_default field later) + if default_count > 1: + frappe.throw(_("Only one warehouse can be marked as default")) + + def validate_tax_mappings(self): + """Validate tax mappings.""" + if not self.tax_mappings: + return + + seen_tax_keys = set() + + for mapping in self.tax_mappings: + # Check for duplicate tax keys + if mapping.shopify_tax in seen_tax_keys: + frappe.throw(_("Duplicate Shopify Tax/Shipping Title: {0}").format(mapping.shopify_tax)) + seen_tax_keys.add(mapping.shopify_tax) + + # Validate tax account belongs to the same company + if mapping.tax_account: + account_company = frappe.db.get_value("Account", mapping.tax_account, "company") + if account_company != self.company: + frappe.throw(_("Tax Account {0} does not belong to Company {1}").format( + mapping.tax_account, self.company)) + + def validate_feature_dependencies(self): + """Validate feature toggle dependencies.""" + warnings = [] + + # Warn if sync features are enabled but cost center is missing + if (self.sync_sales_invoice or self.sync_delivery_note) and not self.cost_center: + warnings.append(_("Cost Center is recommended when Sales Invoice or Delivery Note sync is enabled")) + + # Warn if customer creation is disabled but no default customer is set + if not self.create_customers and not self.default_customer: + warnings.append(_("Default Customer is required when automatic customer creation is disabled")) + + # Show warnings as messages (non-blocking) + for warning in warnings: + frappe.msgprint(warning, indicator="orange", alert=True) + + def is_enabled(self) -> bool: + """Check if this Shopify account is enabled.""" + return bool(self.enabled) + + def get_shop_url(self) -> str: + """Get the full shop URL with https prefix.""" + if self.shop_domain: + return f"https://{self.shop_domain}" + return "" + + def get_access_token(self) -> str: + """Get the decrypted access token.""" + return self.get_password("access_token") + + def get_shared_secret(self) -> str: + """Get the decrypted shared secret.""" + return self.get_password("shared_secret") + + @staticmethod + def get_account_by_domain(shop_domain: str): + """Get Shopify Account by shop domain.""" + return frappe.get_doc("Shopify Account", {"shop_domain": shop_domain, "enabled": 1}) + + @staticmethod + def get_enabled_accounts(): + """Get all enabled Shopify accounts.""" + return frappe.get_all("Shopify Account", + filters={"enabled": 1}, + fields=["name", "account_title", "shop_domain", "company"]) + + def update_sync_status(self, status: str, sync_time=None): + """Update the last sync status and time.""" + if status not in ["Idle", "Success", "Warning", "Error"]: + frappe.throw(_("Invalid sync status: {0}").format(status)) + + self.last_sync_status = status + if sync_time: + self.last_sync_at = sync_time + else: + self.last_sync_at = frappe.utils.now() + + # Save without calling validate again to avoid recursion + self.db_set("last_sync_status", self.last_sync_status) + self.db_set("last_sync_at", self.last_sync_at) + + @frappe.whitelist() + @connection.temp_shopify_session + def fetch_shopify_locations(self): + """Fetch locations from Shopify and add them to warehouse mapping table.""" + if not self.enabled: + frappe.throw(_("Account must be enabled to fetch locations")) + + if not self.get_access_token(): + frappe.throw(_("Access token is required to fetch locations")) + + # Clear existing mappings + self.warehouse_mappings = [] + + # Fetch locations from Shopify + try: + for locations in PaginatedIterator(Location.find()): + for location in locations: + self.append("warehouse_mappings", { + "shopify_location_id": location.id, + "shopify_location_name": location.name + }) + except Exception as e: + frappe.throw(_("Failed to fetch Shopify locations: {0}").format(str(e))) + + frappe.msgprint(_("Successfully fetched {0} locations from Shopify").format(len(self.warehouse_mappings))) diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/test_shopify_account.py b/ecommerce_integrations/shopify/doctype/shopify_account/test_shopify_account.py new file mode 100644 index 000000000..8ccfc70da --- /dev/null +++ b/ecommerce_integrations/shopify/doctype/shopify_account/test_shopify_account.py @@ -0,0 +1,203 @@ +# Copyright (c) 2024, Frappe and contributors +# For license information, please see LICENSE + +import frappe +import unittest +from frappe.test_runner import make_test_records + +from ecommerce_integrations.shopify.doctype.shopify_account.shopify_account import ShopifyAccount + + +class TestShopifyAccount(unittest.TestCase): + def setUp(self): + """Set up test data.""" + make_test_records("Company") + + # Create a test company if it doesn't exist + if not frappe.db.exists("Company", "Test Company"): + company = frappe.get_doc({ + "doctype": "Company", + "company_name": "Test Company", + "default_currency": "USD", + "country": "United States" + }) + company.insert() + + def tearDown(self): + """Clean up test data.""" + # Delete test Shopify accounts + frappe.db.delete("Shopify Account", {"shop_domain": ["like", "%test%"]}) + + def test_shop_domain_validation(self): + """Test shop domain format validation.""" + # Test valid domain + account = frappe.get_doc({ + "doctype": "Shopify Account", + "account_title": "Test Store", + "shop_domain": "test-store.myshopify.com", + "company": "Test Company" + }) + account.validate() # Should not raise any exception + + # Test invalid domain + account.shop_domain = "invalid-domain.com" + with self.assertRaises(frappe.ValidationError): + account.validate() + + # Test domain with https prefix (should be auto-corrected) + account.shop_domain = "https://test-store.myshopify.com" + account.validate() + self.assertEqual(account.shop_domain, "test-store.myshopify.com") + + def test_required_fields_when_enabled(self): + """Test required field validation when account is enabled.""" + account = frappe.get_doc({ + "doctype": "Shopify Account", + "enabled": 1, + "account_title": "Test Store" + }) + + # Should fail validation due to missing required fields + with self.assertRaises(frappe.ValidationError): + account.validate() + + # Add required fields + account.shop_domain = "test-store.myshopify.com" + account.access_token = "test_token" + account.shared_secret = "test_secret" + account.company = "Test Company" + + # Should pass validation now + account.validate() + + def test_company_consistency_validation(self): + """Test that all company-related fields belong to the same company.""" + # Create test cost center + if not frappe.db.exists("Cost Center", "Test Cost Center - TC"): + cost_center = frappe.get_doc({ + "doctype": "Cost Center", + "cost_center_name": "Test Cost Center", + "company": "Test Company", + "parent_cost_center": "Test Company - TC" + }) + cost_center.insert() + + account = frappe.get_doc({ + "doctype": "Shopify Account", + "account_title": "Test Store", + "shop_domain": "test-store.myshopify.com", + "company": "Test Company", + "cost_center": "Test Cost Center - TC" + }) + + # Should pass validation + account.validate() + + def test_warehouse_mapping_validation(self): + """Test warehouse mapping validation.""" + # Create test warehouse + if not frappe.db.exists("Warehouse", "Test Warehouse - TC"): + warehouse = frappe.get_doc({ + "doctype": "Warehouse", + "warehouse_name": "Test Warehouse", + "company": "Test Company" + }) + warehouse.insert() + + account = frappe.get_doc({ + "doctype": "Shopify Account", + "account_title": "Test Store", + "shop_domain": "test-store.myshopify.com", + "company": "Test Company" + }) + + # Add warehouse mapping + account.append("warehouse_mappings", { + "shopify_location_id": "12345", + "shopify_location_name": "Test Location", + "erpnext_warehouse": "Test Warehouse - TC" + }) + + # Should pass validation + account.validate() + + def test_duplicate_shop_domain(self): + """Test that shop domains must be unique.""" + # Create first account + account1 = frappe.get_doc({ + "doctype": "Shopify Account", + "account_title": "Test Store 1", + "shop_domain": "test-store.myshopify.com", + "company": "Test Company" + }) + account1.insert() + + # Try to create second account with same domain + account2 = frappe.get_doc({ + "doctype": "Shopify Account", + "account_title": "Test Store 2", + "shop_domain": "test-store.myshopify.com", + "company": "Test Company" + }) + + # Should fail due to unique constraint + with self.assertRaises(frappe.DuplicateEntryError): + account2.insert() + + def test_static_methods(self): + """Test static utility methods.""" + # Create a test account + account = frappe.get_doc({ + "doctype": "Shopify Account", + "account_title": "Test Store", + "shop_domain": "test-store.myshopify.com", + "company": "Test Company", + "enabled": 1 + }) + account.insert() + + # Test get_account_by_domain + found_account = ShopifyAccount.get_account_by_domain("test-store.myshopify.com") + self.assertEqual(found_account.name, account.name) + + # Test get_enabled_accounts + enabled_accounts = ShopifyAccount.get_enabled_accounts() + self.assertTrue(any(acc.name == account.name for acc in enabled_accounts)) + + def test_sync_status_update(self): + """Test sync status update functionality.""" + account = frappe.get_doc({ + "doctype": "Shopify Account", + "account_title": "Test Store", + "shop_domain": "test-store.myshopify.com", + "company": "Test Company" + }) + account.insert() + + # Test status update + account.update_sync_status("Success") + self.assertEqual(account.last_sync_status, "Success") + self.assertIsNotNone(account.last_sync_at) + + # Test invalid status + with self.assertRaises(frappe.ValidationError): + account.update_sync_status("InvalidStatus") + + def test_helper_methods(self): + """Test helper methods.""" + account = frappe.get_doc({ + "doctype": "Shopify Account", + "account_title": "Test Store", + "shop_domain": "test-store.myshopify.com", + "company": "Test Company", + "enabled": 1 + }) + + # Test is_enabled + self.assertTrue(account.is_enabled()) + + account.enabled = 0 + self.assertFalse(account.is_enabled()) + + # Test get_shop_url + self.assertEqual(account.get_shop_url(), "https://test-store.myshopify.com") diff --git a/ecommerce_integrations/shopify/fulfillment.py b/ecommerce_integrations/shopify/fulfillment.py index 5ffc0ebae..0d4574806 100644 --- a/ecommerce_integrations/shopify/fulfillment.py +++ b/ecommerce_integrations/shopify/fulfillment.py @@ -14,26 +14,44 @@ 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, account=None): frappe.set_user("Administrator") - setting = frappe.get_doc(SETTING_DOCTYPE) frappe.flags.request_id = request_id + # Get account context + if isinstance(account, str): + account = frappe.get_doc("Shopify Account", account) + elif not account: + # Fallback to legacy mode + account = frappe.get_doc(SETTING_DOCTYPE) + order = payload try: sales_order = get_sales_order(cstr(order["id"])) if sales_order: - create_delivery_note(order, setting, sales_order) - create_shopify_log(status="Success") + create_delivery_note(order, account, sales_order) + create_shopify_log( + status="Success", + reference_document=account.name if hasattr(account, 'name') else None + ) else: - create_shopify_log(status="Invalid", message="Sales Order not found for syncing delivery note.") + create_shopify_log( + status="Invalid", + message="Sales Order not found for syncing delivery note.", + reference_document=account.name if hasattr(account, 'name') else None + ) except Exception as e: - create_shopify_log(status="Error", exception=e, rollback=True) + create_shopify_log( + status="Error", + exception=e, + rollback=True, + reference_document=account.name if hasattr(account, 'name') else None + ) -def create_delivery_note(shopify_order, setting, so): - if not cint(setting.sync_delivery_note): +def create_delivery_note(shopify_order, account, so): + if not _should_sync_delivery_note(account): return for fulfillment in shopify_order.get("fulfillments"): @@ -47,9 +65,9 @@ def create_delivery_note(shopify_order, setting, so): setattr(dn, FULLFILLMENT_ID_FIELD, fulfillment.get("id")) dn.set_posting_time = 1 dn.posting_date = getdate(fulfillment.get("created_at")) - dn.naming_series = setting.delivery_note_series or "DN-Shopify-" + dn.naming_series = _get_delivery_note_series(account) dn.items = get_fulfillment_items( - dn.items, fulfillment.get("line_items"), fulfillment.get("location_id") + dn.items, fulfillment.get("line_items"), fulfillment.get("location_id"), account ) dn.flags.ignore_mandatory = True dn.save() @@ -59,15 +77,24 @@ def create_delivery_note(shopify_order, setting, so): dn.add_comment(text=f"Order Note: {shopify_order.get('note')}") -def get_fulfillment_items(dn_items, fulfillment_items, location_id=None): +def get_fulfillment_items(dn_items, fulfillment_items, location_id=None, account=None): # local import to avoid circular imports from ecommerce_integrations.shopify.product import get_item_code fulfillment_items = deepcopy(fulfillment_items) - setting = frappe.get_cached_doc(SETTING_DOCTYPE) - wh_map = setting.get_integration_to_erpnext_wh_mapping() - warehouse = wh_map.get(str(location_id)) or setting.warehouse + # Get warehouse mapping from account or legacy setting + if account and hasattr(account, 'warehouse_mappings'): + # Use account-specific warehouse mappings + wh_map = _get_warehouse_mapping(account) + default_warehouse = _get_default_warehouse(account) + else: + # Fallback to legacy setting + setting = frappe.get_cached_doc(SETTING_DOCTYPE) + wh_map = setting.get_integration_to_erpnext_wh_mapping() + default_warehouse = setting.warehouse + + warehouse = wh_map.get(str(location_id)) or default_warehouse final_items = [] @@ -84,3 +111,38 @@ def find_matching_fullfilement_item(dn_item): final_items.append(dn_item.update({"qty": shopify_item.get("quantity"), "warehouse": warehouse})) return final_items + + +# Helper functions for account-aware delivery note creation + +def _should_sync_delivery_note(account): + """Check if delivery note sync is enabled for this account.""" + if hasattr(account, 'sync_delivery_note'): + return cint(account.sync_delivery_note) + else: # Legacy setting + return cint(account.sync_delivery_note) + +def _get_delivery_note_series(account): + """Get delivery note series from account or legacy setting.""" + if hasattr(account, 'delivery_note_series'): + return account.delivery_note_series or "DN-Shopify-" + else: # Legacy setting + return account.delivery_note_series or "DN-Shopify-" + +def _get_warehouse_mapping(account): + """Get warehouse mapping from account.""" + if hasattr(account, 'warehouse_mappings'): + return { + mapping.shopify_location_id: mapping.erpnext_warehouse + for mapping in account.warehouse_mappings + } + return {} + +def _get_default_warehouse(account): + """Get default warehouse from account or legacy setting.""" + if hasattr(account, 'warehouse'): + return account.warehouse + elif hasattr(account, 'warehouse_mappings') and account.warehouse_mappings: + # Use first warehouse as default if no specific default warehouse field + return account.warehouse_mappings[0].erpnext_warehouse + return None diff --git a/ecommerce_integrations/shopify/inventory.py b/ecommerce_integrations/shopify/inventory.py index 526107dd3..789f90a68 100644 --- a/ecommerce_integrations/shopify/inventory.py +++ b/ecommerce_integrations/shopify/inventory.py @@ -11,15 +11,32 @@ ) from ecommerce_integrations.controllers.scheduling import need_to_run from ecommerce_integrations.shopify.connection import temp_shopify_session -from ecommerce_integrations.shopify.constants import MODULE_NAME, SETTING_DOCTYPE +from ecommerce_integrations.shopify.constants import MODULE_NAME, SETTING_DOCTYPE, ACCOUNT_DOCTYPE from ecommerce_integrations.shopify.utils import create_shopify_log def update_inventory_on_shopify() -> None: - """Upload stock levels from ERPNext to Shopify. + """Upload stock levels from ERPNext to Shopify for all enabled accounts. Called by scheduler on configured interval. """ + # Get all enabled Shopify accounts + enabled_accounts = frappe.get_all(ACCOUNT_DOCTYPE, + filters={"enabled": 1}, + fields=["name", "shop_domain"]) + + if not enabled_accounts: + # Fallback to legacy singleton for backward compatibility + _update_inventory_legacy() + return + + for account_data in enabled_accounts: + account = frappe.get_doc(ACCOUNT_DOCTYPE, account_data.name) + _update_inventory_for_account(account) + + +def _update_inventory_legacy(): + """Legacy inventory update using singleton (for backward compatibility).""" setting = frappe.get_doc(SETTING_DOCTYPE) if not setting.is_enabled() or not setting.update_erpnext_stock_levels_to_shopify: @@ -32,11 +49,43 @@ def update_inventory_on_shopify() -> None: inventory_levels = get_inventory_levels(tuple(warehous_map.keys()), MODULE_NAME) if inventory_levels: - upload_inventory_data_to_shopify(inventory_levels, warehous_map) + upload_inventory_data_to_shopify(inventory_levels, warehous_map, account=None) + + +def _update_inventory_for_account(account): + """Update inventory for a specific Shopify account.""" + if not account.is_enabled(): + return + + # Check if account has inventory sync enabled (assuming this field exists or will be added) + # For now, we'll assume all enabled accounts want inventory sync + # TODO: Add inventory sync toggle to Shopify Account doctype if needed + + # Use account-specific scheduling check + if not need_to_run(ACCOUNT_DOCTYPE, "inventory_sync_frequency", "last_inventory_sync", account.name): + return + + # Get warehouse mappings from account + warehous_map = {} + for mapping in account.warehouse_mappings or []: + if mapping.erpnext_warehouse and mapping.shopify_location_id: + warehous_map[mapping.erpnext_warehouse] = mapping.shopify_location_id + + if not warehous_map: + frappe.log_error( + f"No warehouse mappings configured for Shopify Account: {account.name}", + "Shopify Inventory Sync" + ) + return + + inventory_levels = get_inventory_levels(tuple(warehous_map.keys()), MODULE_NAME) + + if inventory_levels: + upload_inventory_data_to_shopify(inventory_levels, warehous_map, account=account) @temp_shopify_session -def upload_inventory_data_to_shopify(inventory_levels, warehous_map) -> None: +def upload_inventory_data_to_shopify(inventory_levels, warehous_map, account=None) -> None: synced_on = now() for inventory_sync_batch in create_batch(inventory_levels, 50): @@ -65,10 +114,10 @@ def upload_inventory_data_to_shopify(inventory_levels, warehous_map) -> None: frappe.db.commit() - _log_inventory_update_status(inventory_sync_batch) + _log_inventory_update_status(inventory_sync_batch, account) -def _log_inventory_update_status(inventory_levels) -> None: +def _log_inventory_update_status(inventory_levels, account=None) -> None: """Create log of inventory update.""" log_message = "variant_id,location_id,status,failure_reason\n" @@ -90,4 +139,11 @@ def _log_inventory_update_status(inventory_levels) -> None: log_message = f"Updated {percent_successful * 100}% items\n\n" + log_message - create_shopify_log(method="update_inventory_on_shopify", status=status, message=log_message) + # Include account reference in log if available + reference_document = account.name if account else None + create_shopify_log( + method="update_inventory_on_shopify", + status=status, + message=log_message, + reference_document=reference_document + ) diff --git a/ecommerce_integrations/shopify/invoice.py b/ecommerce_integrations/shopify/invoice.py index f841cb416..515aebbb0 100644 --- a/ecommerce_integrations/shopify/invoice.py +++ b/ecommerce_integrations/shopify/invoice.py @@ -10,32 +10,51 @@ 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, account=None): 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 + # Get account context + if isinstance(account, str): + account = frappe.get_doc("Shopify Account", account) + elif not account: + # Fallback to legacy mode + account = frappe.get_doc(SETTING_DOCTYPE) + try: sales_order = get_sales_order(cstr(order["id"])) if sales_order: - create_sales_invoice(order, setting, sales_order) - create_shopify_log(status="Success") + create_sales_invoice(order, account, sales_order) + create_shopify_log( + status="Success", + reference_document=account.name if hasattr(account, 'name') else None + ) else: - create_shopify_log(status="Invalid", message="Sales Order not found for syncing sales invoice.") + create_shopify_log( + status="Invalid", + message="Sales Order not found for syncing sales invoice.", + reference_document=account.name if hasattr(account, 'name') else None + ) except Exception as e: - create_shopify_log(status="Error", exception=e, rollback=True) + create_shopify_log( + status="Error", + exception=e, + rollback=True, + reference_document=account.name if hasattr(account, 'name') else None + ) -def create_sales_invoice(shopify_order, setting, so): +def create_sales_invoice(shopify_order, account, so): + # Check if should sync and if sales invoice already exists if ( not frappe.db.get_value("Sales Invoice", {ORDER_ID_FIELD: shopify_order.get("id")}, "name") and so.docstatus == 1 and not so.per_billed - and cint(setting.sync_sales_invoice) + and _should_sync_sales_invoice(account) ): posting_date = getdate(shopify_order.get("created_at")) or nowdate() @@ -45,13 +64,13 @@ def create_sales_invoice(shopify_order, setting, so): sales_invoice.set_posting_time = 1 sales_invoice.posting_date = posting_date sales_invoice.due_date = posting_date - sales_invoice.naming_series = setting.sales_invoice_series or "SI-Shopify-" + sales_invoice.naming_series = _get_sales_invoice_series(account) sales_invoice.flags.ignore_mandatory = True - set_cost_center(sales_invoice.items, setting.cost_center) + set_cost_center(sales_invoice.items, _get_cost_center(account)) sales_invoice.insert(ignore_mandatory=True) sales_invoice.submit() if sales_invoice.grand_total > 0: - make_payament_entry_against_sales_invoice(sales_invoice, setting, posting_date) + make_payament_entry_against_sales_invoice(sales_invoice, account, posting_date) if shopify_order.get("note"): sales_invoice.add_comment(text=f"Order Note: {shopify_order.get('note')}") @@ -62,13 +81,46 @@ def set_cost_center(items, cost_center): item.cost_center = cost_center -def make_payament_entry_against_sales_invoice(doc, setting, posting_date=None): +def make_payament_entry_against_sales_invoice(doc, account, posting_date=None): from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry - payment_entry = get_payment_entry(doc.doctype, doc.name, bank_account=setting.cash_bank_account) - payment_entry.flags.ignore_mandatory = True - payment_entry.reference_no = doc.name - payment_entry.posting_date = posting_date or nowdate() - payment_entry.reference_date = posting_date or nowdate() - payment_entry.insert(ignore_permissions=True) - payment_entry.submit() + bank_account = _get_cash_bank_account(account) + if bank_account: + payment_entry = get_payment_entry(doc.doctype, doc.name, bank_account=bank_account) + payment_entry.flags.ignore_mandatory = True + payment_entry.reference_no = doc.name + payment_entry.posting_date = posting_date or nowdate() + payment_entry.reference_date = posting_date or nowdate() + payment_entry.insert(ignore_permissions=True) + payment_entry.submit() + + +# Helper functions for account-aware invoice creation + +def _should_sync_sales_invoice(account): + """Check if sales invoice sync is enabled for this account.""" + if hasattr(account, 'sync_sales_invoice'): + return cint(account.sync_sales_invoice) + else: # Legacy setting + return cint(account.sync_sales_invoice) + +def _get_sales_invoice_series(account): + """Get sales invoice series from account or legacy setting.""" + if hasattr(account, 'sales_invoice_series'): + return account.sales_invoice_series or "SI-Shopify-" + else: # Legacy setting + return account.sales_invoice_series or "SI-Shopify-" + +def _get_cost_center(account): + """Get cost center from account or legacy setting.""" + if hasattr(account, 'cost_center'): + return account.cost_center + else: # Legacy setting + return account.cost_center + +def _get_cash_bank_account(account): + """Get cash/bank account from account or legacy setting.""" + if hasattr(account, 'cash_bank_account'): + return getattr(account, 'cash_bank_account', None) + else: # Legacy setting + return account.cash_bank_account diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index 0570d035b..8c470cb4a 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -29,13 +29,24 @@ } -def sync_sales_order(payload, request_id=None): +def sync_sales_order(payload, request_id=None, account=None): order = payload frappe.set_user("Administrator") frappe.flags.request_id = request_id + # Get account context + if isinstance(account, str): + account = frappe.get_doc("Shopify Account", account) + elif not account: + # Fallback to legacy mode + account = frappe.get_doc(SETTING_DOCTYPE) + if frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: cstr(order["id"])}): - create_shopify_log(status="Invalid", message="Sales order already exists, not synced") + create_shopify_log( + status="Invalid", + message="Sales order already exists, not synced", + reference_document=account.name if hasattr(account, 'name') else None + ) return try: shopify_customer = order.get("customer") if order.get("customer") is not None else {} @@ -43,38 +54,45 @@ def sync_sales_order(payload, request_id=None): shopify_customer["shipping_address"] = order.get("shipping_address", "") customer_id = shopify_customer.get("id") if customer_id: - customer = ShopifyCustomer(customer_id=customer_id) + customer = ShopifyCustomer(customer_id=customer_id, account=account) if not customer.is_synced(): customer.sync_customer(customer=shopify_customer) else: customer.update_existing_addresses(shopify_customer) - create_items_if_not_exist(order) + create_items_if_not_exist(order, account) - setting = frappe.get_doc(SETTING_DOCTYPE) - create_order(order, setting) + create_order(order, account) except Exception as e: - create_shopify_log(status="Error", exception=e, rollback=True) + create_shopify_log( + status="Error", + exception=e, + rollback=True, + reference_document=account.name if hasattr(account, 'name') else None + ) else: - create_shopify_log(status="Success") + create_shopify_log( + status="Success", + reference_document=account.name if hasattr(account, 'name') else None + ) -def create_order(order, setting, company=None): +def create_order(order, account, company=None): # local import to avoid circular dependencies from ecommerce_integrations.shopify.fulfillment import create_delivery_note from ecommerce_integrations.shopify.invoice import create_sales_invoice - so = create_sales_order(order, setting, company) + so = create_sales_order(order, account, company) if so: - if order.get("financial_status") == "paid": - create_sales_invoice(order, setting, so) + if order.get("financial_status") == "paid" and _should_sync_invoice(account): + create_sales_invoice(order, account, so) - if order.get("fulfillments"): - create_delivery_note(order, setting, so) + if order.get("fulfillments") and _should_sync_delivery_note(account): + create_delivery_note(order, account, so) -def create_sales_order(shopify_order, setting, company=None): - customer = setting.default_customer +def create_sales_order(shopify_order, account, company=None): + customer = _get_default_customer(account) if shopify_order.get("customer", {}): if customer_id := shopify_order.get("customer", {}).get("id"): customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") @@ -84,7 +102,7 @@ def create_sales_order(shopify_order, setting, company=None): if not so: items = get_order_items( shopify_order.get("line_items"), - setting, + account, getdate(shopify_order.get("created_at")), taxes_inclusive=shopify_order.get("taxes_included"), ) @@ -101,18 +119,18 @@ def create_sales_order(shopify_order, setting, company=None): return "" - taxes = get_order_taxes(shopify_order, setting, items) + taxes = get_order_taxes(shopify_order, account, items) so = frappe.get_doc( { "doctype": "Sales Order", - "naming_series": setting.sales_order_series or "SO-Shopify-", + "naming_series": _get_sales_order_series(account), ORDER_ID_FIELD: str(shopify_order.get("id")), ORDER_NUMBER_FIELD: shopify_order.get("name"), "customer": customer, "transaction_date": getdate(shopify_order.get("created_at")) or nowdate(), "delivery_date": getdate(shopify_order.get("created_at")) or nowdate(), - "company": setting.company, - "selling_price_list": get_dummy_price_list(), + "company": _get_company(account), + "selling_price_list": _get_selling_price_list(account), "ignore_pricing_rule": 1, "items": items, "taxes": taxes, @@ -136,7 +154,7 @@ def create_sales_order(shopify_order, setting, company=None): return so -def get_order_items(order_items, setting, delivery_date, taxes_inclusive): +def get_order_items(order_items, account, delivery_date, taxes_inclusive): items = [] all_product_exists = True product_not_exists = [] @@ -159,7 +177,7 @@ def get_order_items(order_items, setting, delivery_date, taxes_inclusive): "delivery_date": delivery_date, "qty": shopify_item.get("quantity"), "stock_uom": shopify_item.get("uom") or "Nos", - "warehouse": setting.warehouse, + "warehouse": _get_default_warehouse_for_order(account), ORDER_ITEM_DISCOUNT_FIELD: ( _get_total_discount(shopify_item) / cint(shopify_item.get("quantity")) ), @@ -356,7 +374,7 @@ def get_sales_order(order_id): return frappe.get_doc("Sales Order", sales_order) -def cancel_order(payload, request_id=None): +def cancel_order(payload, request_id=None, account=None): """Called by order/cancelled event. When shopify order is cancelled there could be many different someone handles it. @@ -368,6 +386,13 @@ def cancel_order(payload, request_id=None): frappe.set_user("Administrator") frappe.flags.request_id = request_id + # Get account context + if isinstance(account, str): + account = frappe.get_doc("Shopify Account", account) + elif not account: + # Fallback to legacy mode + account = frappe.get_doc(SETTING_DOCTYPE) + order = payload try: @@ -377,7 +402,11 @@ def cancel_order(payload, request_id=None): sales_order = get_sales_order(order_id) if not sales_order: - create_shopify_log(status="Invalid", message="Sales Order does not exist") + create_shopify_log( + status="Invalid", + message="Sales Order does not exist", + reference_document=account.name if hasattr(account, 'name') else None + ) return sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id}) @@ -395,13 +424,41 @@ def cancel_order(payload, request_id=None): frappe.db.set_value("Sales Order", sales_order.name, ORDER_STATUS_FIELD, order_status) except Exception as e: - create_shopify_log(status="Error", exception=e) + create_shopify_log( + status="Error", + exception=e, + reference_document=account.name if hasattr(account, 'name') else None + ) else: - create_shopify_log(status="Success") + create_shopify_log( + status="Success", + reference_document=account.name if hasattr(account, 'name') else None + ) @temp_shopify_session -def sync_old_orders(): +def sync_old_orders(account=None): + """Sync old orders for specific account or all enabled accounts.""" + if account: + # Sync for specific account + _sync_old_orders_for_account(account) + else: + # Sync for all enabled accounts + enabled_accounts = frappe.get_all(ACCOUNT_DOCTYPE, + filters={"enabled": 1}, + fields=["name"]) + + if not enabled_accounts: + # Fallback to legacy singleton + _sync_old_orders_legacy() + return + + for account_data in enabled_accounts: + account_doc = frappe.get_doc(ACCOUNT_DOCTYPE, account_data.name) + _sync_old_orders_for_account(account_doc) + +def _sync_old_orders_legacy(): + """Legacy old order sync using singleton.""" shopify_setting = frappe.get_cached_doc(SETTING_DOCTYPE) if not cint(shopify_setting.sync_old_orders): return @@ -418,6 +475,45 @@ def sync_old_orders(): shopify_setting.sync_old_orders = 0 shopify_setting.save() +def _sync_old_orders_for_account(account): + """Sync old orders for a specific Shopify account.""" + if not account.is_enabled(): + return + + # Check if account has old order sync enabled + # TODO: Add old_orders_sync fields to Shopify Account doctype + # For now, we'll assume accounts don't need old order sync by default + # This should be controlled by account-specific settings + + # Placeholder for account-specific old order sync logic + # This would need additional fields in Shopify Account doctype: + # - sync_old_orders (Check) + # - old_orders_from (Datetime) + # - old_orders_to (Datetime) + + if not hasattr(account, 'sync_old_orders') or not cint(account.sync_old_orders): + return + + # Use account-specific session + with temp_shopify_session(account=account): + orders = _fetch_old_orders( + getattr(account, 'old_orders_from', None), + getattr(account, 'old_orders_to', None) + ) + + for order in orders: + log = create_shopify_log( + method=EVENT_MAPPER["orders/create"], + request_data=json.dumps(order), + make_new=True, + reference_document=account.name + ) + sync_sales_order(order, request_id=log.name, account=account) + + # Update account sync status + account.sync_old_orders = 0 + account.save() + def _fetch_old_orders(from_time, to_time): """Fetch all shopify orders in specified range and return an iterator on fetched orders.""" @@ -433,3 +529,56 @@ def _fetch_old_orders(from_time, to_time): # Using generator instead of fetching all at once is better for # avoiding rate limits and reducing resource usage. yield order.to_dict() + + +# Helper functions for account-aware integration + +def _get_default_customer(account): + """Get default customer from account or legacy setting.""" + if hasattr(account, 'default_customer') and account.default_customer: + return account.default_customer + elif hasattr(account, 'default_customer'): # Shopify Account + return account.default_customer + else: # Legacy Shopify Setting + return account.default_customer + +def _get_company(account): + """Get company from account or legacy setting.""" + return account.company + +def _get_sales_order_series(account): + """Get sales order series from account or legacy setting.""" + if hasattr(account, 'sales_order_series'): + return account.sales_order_series or "SO-Shopify-" + else: # Legacy setting + return account.sales_order_series or "SO-Shopify-" + +def _get_selling_price_list(account): + """Get selling price list from account or fallback to dummy.""" + if hasattr(account, 'selling_price_list') and account.selling_price_list: + return account.selling_price_list + return get_dummy_price_list() + +def _should_sync_invoice(account): + """Check if sales invoice sync is enabled for this account.""" + if hasattr(account, 'sync_sales_invoice'): + return bool(account.sync_sales_invoice) + else: # Legacy setting + return bool(account.sync_sales_invoice) + +def _should_sync_delivery_note(account): + """Check if delivery note sync is enabled for this account.""" + if hasattr(account, 'sync_delivery_note'): + return bool(account.sync_delivery_note) + else: # Legacy setting + return bool(account.sync_delivery_note) + +def _get_default_warehouse_for_order(account): + """Get default warehouse for order items from account or legacy setting.""" + if hasattr(account, 'warehouse'): + return account.warehouse + elif hasattr(account, 'warehouse_mappings') and account.warehouse_mappings: + # Use first warehouse as default for new account + return account.warehouse_mappings[0].erpnext_warehouse + else: # Legacy setting + return account.warehouse diff --git a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js index 62db3c216..15ed86d60 100644 --- a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js +++ b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js @@ -15,6 +15,7 @@ shopify.ProductImporter = class { constructor(wrapper) { this.wrapper = $(wrapper).find(".layout-main-section"); this.page = wrapper.page; + this.selectedAccount = null; // Track selected account this.init(); this.syncRunning = false; } @@ -22,7 +23,7 @@ shopify.ProductImporter = class { init() { frappe.run_serially([ () => this.addMarkup(), - () => this.fetchProductCount(), + () => this.loadShopifyAccounts(), () => this.addTable(), () => this.checkSyncStatus(), () => this.listen(), @@ -63,11 +64,22 @@ shopify.ProductImporter = class {
+
+
Account Selection
+
+
+ + +
+
+
Synchronization Details
- +
@@ -99,19 +111,71 @@ shopify.ProductImporter = class { this.wrapper.append(_markup); } - async fetchProductCount() { + // NEW: Load Shopify accounts for multi-tenancy + async loadShopifyAccounts() { try { - const { - message: { erpnextCount, shopifyCount, syncedCount }, - } = await frappe.call({ - method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.get_product_count", + const { message: accounts } = await frappe.call({ + method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.get_shopify_accounts", + }); + + const accountSelect = this.wrapper.find("#shopify-account-select"); + accountSelect.empty(); + + if (accounts && accounts.length > 0) { + accountSelect.append(''); + accounts.forEach(account => { + accountSelect.append(``); + }); + + // Auto-select if only one account + if (accounts.length === 1) { + accountSelect.val(accounts[0].name); + this.onAccountChange(accounts[0].name); + } + } else { + accountSelect.append(''); + this.wrapper.find("#btn-sync-all").text("No Accounts Available").prop("disabled", true); + } + + // Add change event listener + accountSelect.on('change', (e) => { + this.onAccountChange(e.target.value); }); - this.wrapper.find("#count-products-shopify").text(shopifyCount); - this.wrapper.find("#count-products-erpnext").text(erpnextCount); - this.wrapper.find("#count-products-synced").text(syncedCount); } catch (error) { - frappe.throw(__("Error fetching product count.")); + console.error("Error loading Shopify accounts:", error); + this.wrapper.find("#shopify-account-select").html(''); + } + } + + // NEW: Handle account selection change + async onAccountChange(accountName) { + this.selectedAccount = accountName; + + if (accountName) { + // Enable sync button and update text + this.wrapper.find("#btn-sync-all") + .text("Sync All Products") + .prop("disabled", false) + .removeClass("btn-success") + .addClass("btn-primary"); + + // Refresh product count and table + await this.fetchProductCount(); + if (this.shopifyProductTable) { + const newProducts = await this.fetchShopifyProducts(); + this.shopifyProductTable.refresh(newProducts); + } + } else { + // Disable sync button + this.wrapper.find("#btn-sync-all") + .text("Select Account First") + .prop("disabled", true); + + // Clear counts + this.wrapper.find("#count-products-shopify").text("-"); + this.wrapper.find("#count-products-erpnext").text("-"); + this.wrapper.find("#count-products-synced").text("-"); } } @@ -159,13 +223,42 @@ shopify.ProductImporter = class { this.wrapper.find(".shopify-datatable-footer").show(); } + // UPDATED: Add account validation and parameter + async fetchProductCount() { + if (!this.selectedAccount) { + return; + } + + try { + const { + message: { erpnextCount, shopifyCount, syncedCount }, + } = await frappe.call({ + method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.get_product_count", + args: { account: this.selectedAccount }, // Add account parameter + }); + + this.wrapper.find("#count-products-shopify").text(shopifyCount); + this.wrapper.find("#count-products-erpnext").text(erpnextCount); + this.wrapper.find("#count-products-synced").text(syncedCount); + } catch (error) { + frappe.throw(__("Error fetching product count.")); + } + } + async fetchShopifyProducts(from_ = null) { + if (!this.selectedAccount) { + return []; + } + try { const { message: { products, nextUrl, prevUrl }, } = await frappe.call({ method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.get_shopify_products", - args: { from_ }, + args: { + from_, + account: this.selectedAccount // Add account parameter + }, }); this.nextUrl = nextUrl; this.prevUrl = prevUrl; @@ -256,10 +349,19 @@ shopify.ProductImporter = class { this.wrapper.on("click", "#btn-sync-all", (e) => this.syncAll(e)); } + // UPDATED: Add account validation and parameter async syncProduct(product) { + if (!this.selectedAccount) { + frappe.throw(__("Please select a Shopify account first.")); + return false; + } + const { message: status } = await frappe.call({ method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.sync_product", - args: { product }, + args: { + product, + account: this.selectedAccount // Add account parameter + }, }); if (status) this.fetchProductCount(); @@ -267,10 +369,19 @@ shopify.ProductImporter = class { return status; } + // UPDATED: Add account validation and parameter async resyncProduct(product) { + if (!this.selectedAccount) { + frappe.throw(__("Please select a Shopify account first.")); + return false; + } + const { message: status } = await frappe.call({ method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.resync_product", - args: { product }, + args: { + product, + account: this.selectedAccount // Add account parameter + }, }); if (status) this.fetchProductCount(); @@ -294,7 +405,13 @@ shopify.ProductImporter = class { this.shopifyProductTable.clearToastMessage(); } + // UPDATED: Add account validation and parameter syncAll() { + if (!this.selectedAccount) { + frappe.throw(__("Please select a Shopify account first.")); + return; + } + this.checkSyncStatus(); this.toggleSyncAllButton(); @@ -303,6 +420,7 @@ shopify.ProductImporter = class { } else { frappe.call({ method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.import_all_products", + args: { account: this.selectedAccount }, // Add account parameter }); } diff --git a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py index e30a102e9..cc1745762 100644 --- a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py +++ b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py @@ -6,7 +6,7 @@ from ecommerce_integrations.ecommerce_integrations.doctype.ecommerce_item import ecommerce_item from ecommerce_integrations.shopify.connection import temp_shopify_session -from ecommerce_integrations.shopify.constants import MODULE_NAME +from ecommerce_integrations.shopify.constants import MODULE_NAME, ACCOUNT_DOCTYPE, SETTING_DOCTYPE from ecommerce_integrations.shopify.product import ShopifyProduct # constants @@ -15,15 +15,15 @@ @frappe.whitelist() -def get_shopify_products(from_=None): - shopify_products = fetch_all_products(from_) +def get_shopify_products(from_=None, account=None): + shopify_products = fetch_all_products(from_, account) return shopify_products -def fetch_all_products(from_=None): +def fetch_all_products(from_=None, account=None): # format shopify collection for datatable - collection = _fetch_products_from_shopify(from_) + collection = _fetch_products_from_shopify(from_, account=account) products = [] for product in collection: @@ -47,7 +47,7 @@ def fetch_all_products(from_=None): @temp_shopify_session -def _fetch_products_from_shopify(from_=None, limit=20): +def _fetch_products_from_shopify(from_=None, limit=20, account=None): if from_: collection = Product.find(from_=from_) else: @@ -57,14 +57,14 @@ def _fetch_products_from_shopify(from_=None, limit=20): @frappe.whitelist() -def get_product_count(): +def get_product_count(account=None): items = frappe.db.get_list("Item", {"variant_of": ["is", "not set"]}) erpnext_count = len(items) sync_items = frappe.db.get_list("Ecommerce Item", {"variant_of": ["is", "not set"]}) synced_count = len(sync_items) - shopify_count = get_shopify_product_count() + shopify_count = get_shopify_product_count(account=account) return { "shopifyCount": shopify_count, @@ -74,14 +74,14 @@ def get_product_count(): @temp_shopify_session -def get_shopify_product_count(): +def get_shopify_product_count(account=None): return Product.count() @frappe.whitelist() -def sync_product(product): +def sync_product(product, account=None): try: - shopify_product = ShopifyProduct(product) + shopify_product = ShopifyProduct(product, account=account) shopify_product.sync_product() return True @@ -91,19 +91,19 @@ def sync_product(product): @frappe.whitelist() -def resync_product(product): - return _resync_product(product) +def resync_product(product, account=None): + return _resync_product(product, account=account) @temp_shopify_session -def _resync_product(product): +def _resync_product(product, account=None): savepoint = "shopify_resync_product" try: item = Product.find(product) frappe.db.savepoint(savepoint) for variant in item.variants: - shopify_product = ShopifyProduct(product, variant_id=variant.id) + shopify_product = ShopifyProduct(product, variant_id=variant.id, account=account) shopify_product.sync_product() return True @@ -117,26 +117,67 @@ def is_synced(product): @frappe.whitelist() -def import_all_products(): +def import_all_products(account=None): frappe.enqueue( queue_sync_all_products, queue="long", job_name=SYNC_JOB_NAME, key=REALTIME_KEY, + account=account, ) +@frappe.whitelist() +def get_shopify_accounts(): + """Get list of enabled Shopify accounts for account selection""" + try: + accounts = frappe.get_all( + ACCOUNT_DOCTYPE, + filters={"enabled": 1}, # Changed from "enable_shopify" to "enabled" + fields=["name", "shop_domain"], # Changed from "shopify_url" to "shop_domain" + order_by="creation desc" + ) + + # Format the shop_domain to include https:// for display purposes + for account in accounts: + if account.get("shop_domain"): + account["shopify_url"] = f"https://{account['shop_domain']}" + else: + account["shopify_url"] = "Not configured" + + # Add legacy option for backward compatibility + legacy_setting = frappe.db.exists(SETTING_DOCTYPE) + if legacy_setting: + try: + legacy_doc = frappe.get_doc(SETTING_DOCTYPE) + if legacy_doc.enable_shopify: + accounts.insert(0, { + "name": "Legacy Setting", + "shopify_url": legacy_doc.shopify_url or "Legacy" + }) + except Exception as e: + frappe.log_error(f"Error accessing legacy Shopify settings: {str(e)}", "Shopify Import Products") + + return accounts + + except Exception as e: + frappe.log_error(f"Error fetching Shopify accounts: {str(e)}", "Shopify Import Products") + # Return empty list instead of throwing error to prevent UI crash + return [] + + def queue_sync_all_products(*args, **kwargs): + account = kwargs.get('account') start_time = process_time() - counts = get_product_count() + counts = get_product_count(account=account) publish("Syncing all products...") if counts["shopifyCount"] < counts["syncedCount"]: publish("⚠ Shopify has less products than ERPNext.") _sync = True - collection = _fetch_products_from_shopify(limit=100) + collection = _fetch_products_from_shopify(limit=100, account=account) savepoint = "shopify_product_sync" while _sync: for product in collection: @@ -147,7 +188,7 @@ def queue_sync_all_products(*args, **kwargs): publish(f"Product {product.id} already synced. Skipping...") continue - shopify_product = ShopifyProduct(product.id) + shopify_product = ShopifyProduct(product.id, account=account) shopify_product.sync_product() publish(f"βœ… Synced Product {product.id}", synced=True) @@ -164,7 +205,7 @@ def queue_sync_all_products(*args, **kwargs): if collection.has_next_page(): frappe.db.commit() # prevents too many write request error - collection = _fetch_products_from_shopify(from_=collection.next_page_url) + collection = _fetch_products_from_shopify(from_=collection.next_page_url, account=account) else: _sync = False diff --git a/ecommerce_integrations/shopify/product.py b/ecommerce_integrations/shopify/product.py index 92c31f467..90244bebc 100644 --- a/ecommerce_integrations/shopify/product.py +++ b/ecommerce_integrations/shopify/product.py @@ -301,13 +301,18 @@ def _match_sku_and_link_item(item_dict, product_id, variant_id, variant_of=None, return False -def create_items_if_not_exist(order): - """Using shopify order, sync all items that are not already synced.""" +def create_items_if_not_exist(order, account=None): + """Using shopify order, sync all items that are not already synced. + + Args: + order: Shopify order dict + account: Shopify Account doc (optional, falls back to legacy singleton) + """ for item in order.get("line_items", []): product_id = item["product_id"] variant_id = item.get("variant_id") sku = item.get("sku") - product = ShopifyProduct(product_id, variant_id=variant_id, sku=sku) + product = ShopifyProduct(product_id, variant_id=variant_id, sku=sku, account=account) if not product.is_synced(): product.sync_product() @@ -332,19 +337,14 @@ def get_item_code(shopify_item): def upload_erpnext_item(doc, method=None): """This hook is called when inserting new or updating existing `Item`. - New items are pushed to shopify and changes to existing items are - updated depending on what is configured in "Shopify Setting" doctype. + New items are pushed to all enabled Shopify accounts and changes to existing items are + updated depending on what is configured in each "Shopify Account" doctype. """ template_item = item = doc # alias for readability - # a new item recieved from ecommerce_integrations is being inserted + # a new item received from ecommerce_integrations is being inserted if item.flags.from_integration: return - setting = frappe.get_doc(SETTING_DOCTYPE) - - if not setting.is_enabled() or not setting.upload_erpnext_items: - return - if frappe.flags.in_import: return @@ -355,8 +355,47 @@ def upload_erpnext_item(doc, method=None): msgprint(_("Template items/Items with 4 or more attributes can not be uploaded to Shopify.")) return - if doc.variant_of and not setting.upload_variants_as_items: - msgprint(_("Enable variant sync in setting to upload item to Shopify.")) + # Get all enabled Shopify accounts with product upload enabled + enabled_accounts = frappe.get_all( + ACCOUNT_DOCTYPE, + filters={"enabled": 1, "upload_erpnext_items": 1}, + pluck="name" + ) + + # Legacy fallback if no accounts found + if not enabled_accounts: + setting = frappe.get_doc(SETTING_DOCTYPE) + if setting.is_enabled() and setting.upload_erpnext_items: + _upload_item_to_account(item, template_item, None) # Use legacy singleton + return + + # Upload to all enabled accounts + for account_name in enabled_accounts: + try: + account = frappe.get_doc(ACCOUNT_DOCTYPE, account_name) + _upload_item_to_account(item, template_item, account) + except Exception as e: + frappe.log_error( + message=f"Failed to upload item {item.name} to Shopify Account {account_name}: {str(e)}", + title="Shopify Product Upload Error" + ) + + +def _upload_item_to_account(item, template_item, account): + """Upload item to a specific Shopify account or legacy singleton.""" + # Use account settings or fallback to legacy singleton + if account: + setting = account + account_context = f" (Account: {account.name})" + else: + setting = frappe.get_doc(SETTING_DOCTYPE) + account_context = " (Legacy)" + + if not setting.is_enabled() or not setting.upload_erpnext_items: + return + + if item.variant_of and not setting.upload_variants_as_items: + msgprint(_(f"Enable variant sync in setting to upload item to Shopify{account_context}.")) return if item.variant_of: @@ -369,6 +408,17 @@ def upload_erpnext_item(doc, method=None): ) is_new_product = not bool(product_id) + # Set up Shopify session context for this account + if account: + with temp_shopify_session(account): + _process_item_upload(item, template_item, setting, is_new_product, product_id, account_context) + else: + # Legacy singleton session + _process_item_upload(item, template_item, setting, is_new_product, product_id, account_context) + + +def _process_item_upload(item, template_item, setting, is_new_product, product_id, account_context): + """Process the actual item upload logic.""" if is_new_product: product = Product() product.published = False @@ -429,7 +479,7 @@ def upload_erpnext_item(doc, method=None): ) ecom_item.insert() - write_upload_log(status=is_successful, product=product, item=item) + write_upload_log(status=is_successful, product=product, item=item, account_context=account_context) elif setting.update_shopify_item_on_update: product = Product.find(product_id) if product: @@ -466,7 +516,7 @@ def upload_erpnext_item(doc, method=None): if is_successful and item.variant_of: map_erpnext_variant_to_shopify_variant(product, item, variant_attributes) - write_upload_log(status=is_successful, product=product, item=item, action="Updated") + write_upload_log(status=is_successful, product=product, item=item, action="Updated", account_context=account_context) def map_erpnext_variant_to_shopify_variant(shopify_product: Product, erpnext_item, variant_attributes): @@ -549,9 +599,9 @@ def update_default_variant_properties( default_variant.sku = sku -def write_upload_log(status: bool, product: Product, item, action="Created") -> None: +def write_upload_log(status: bool, product: Product, item, action="Created", account_context="") -> None: if not status: - msg = _("Failed to upload item to Shopify") + "
" + msg = _("Failed to upload item to Shopify") + account_context + "
" msg += _("Shopify reported errors:") + " " + ", ".join(product.errors.full_messages()) msgprint(msg, title="Note", indicator="orange") @@ -565,6 +615,6 @@ def write_upload_log(status: bool, product: Product, item, action="Created") -> create_shopify_log( status="Success", request_data=product.to_dict(), - message=f"{action} Item: {item.name}, shopify product: {product.id}", + message=f"{action} Item: {item.name}, shopify product: {product.id}{account_context}", method="upload_erpnext_item", ) From 4c668c3795e72dcb0933b418e44e42e0f2ff5b04 Mon Sep 17 00:00:00 2001 From: Ahmed Osama Date: Wed, 13 Aug 2025 16:49:23 +0000 Subject: [PATCH 02/30] refactor(shopify): standardize account resolution across modules Introduce resolve_account_context utility to handle account resolution consistently Update all modules to use the new standardized account resolution Simplify temp_shopify_session decorator with new account handling --- ecommerce_integrations/shopify/connection.py | 60 +++----- ecommerce_integrations/shopify/customer.py | 12 +- ecommerce_integrations/shopify/fulfillment.py | 9 +- ecommerce_integrations/shopify/inventory.py | 3 +- ecommerce_integrations/shopify/invoice.py | 9 +- ecommerce_integrations/shopify/order.py | 11 +- .../shopify_import_products.py | 140 ++++++++---------- ecommerce_integrations/shopify/product.py | 37 ++--- ecommerce_integrations/shopify/utils.py | 74 ++++++++- 9 files changed, 183 insertions(+), 172 deletions(-) diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index 4837e8100..7a4a49b53 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -19,56 +19,30 @@ def temp_shopify_session(func=None, *, account=None): - """Account-aware decorator for Shopify API access. - - Usage: - 1. As decorator with account parameter: @temp_shopify_session(account=account_doc) - 2. As decorator for methods that have account context: @temp_shopify_session - 3. Legacy mode (fallback to singleton): @temp_shopify_session - """ - - def decorator(func): - @functools.wraps(func) + """Enhanced decorator with account context support.""" + def decorator(f): + @functools.wraps(f) def wrapper(*args, **kwargs): - # no auth in testing - if frappe.flags.in_test: - return func(*args, **kwargs) + # Extract account from kwargs if not provided + account_param = account or kwargs.get('account') - # Determine account to use - shopify_account = None - - # Option 1: Account explicitly passed to decorator - if account: - shopify_account = account - # Option 2: Account in function arguments - elif args and hasattr(args[0], 'doctype') and args[0].doctype == "Shopify Account": - shopify_account = args[0] - # Option 3: Account passed as keyword argument - elif 'account' in kwargs: - shopify_account = kwargs.get('account') - # Option 4: Legacy fallback to singleton (for backward compatibility) + if account_param: + from ecommerce_integrations.shopify.utils import resolve_account_context + account_doc = resolve_account_context(account_param) + shopify_url = account_doc.get_shop_url() + password = account_doc.get_access_token() else: - setting = frappe.get_doc(SETTING_DOCTYPE) - if setting.is_enabled(): - auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password")) - with Session.temp(*auth_details): - return func(*args, **kwargs) - return + # Use standardized legacy fallback + from ecommerce_integrations.shopify.utils import resolve_account_context + setting = resolve_account_context(None) # Gets legacy setting + shopify_url = setting.shopify_url + password = setting.get_password("password") - # Use account-specific credentials - if shopify_account and shopify_account.is_enabled(): - auth_details = ( - shopify_account.shop_domain, - shopify_account.api_version or API_VERSION, - shopify_account.get_access_token() - ) + with Session.temp(shopify_url, API_VERSION, password): + return f(*args, **kwargs) - with Session.temp(*auth_details): - return func(*args, **kwargs) - return wrapper - # Handle both @temp_shopify_session and @temp_shopify_session(account=...) if func is None: return decorator else: diff --git a/ecommerce_integrations/shopify/customer.py b/ecommerce_integrations/shopify/customer.py index 34316e905..b6b6999cb 100644 --- a/ecommerce_integrations/shopify/customer.py +++ b/ecommerce_integrations/shopify/customer.py @@ -16,15 +16,9 @@ class ShopifyCustomer(EcommerceCustomer): def __init__(self, customer_id: str, account=None): - # Multi-tenant: Use account-specific settings or fallback to legacy singleton - if account: - if isinstance(account, str): - self.setting = frappe.get_doc(ACCOUNT_DOCTYPE, account) - else: - self.setting = account - else: - # Legacy fallback for backward compatibility - self.setting = frappe.get_doc(SETTING_DOCTYPE) + # Standardized account resolution + from ecommerce_integrations.shopify.utils import resolve_account_context + self.setting = resolve_account_context(account) super().__init__(customer_id, CUSTOMER_ID_FIELD, MODULE_NAME) diff --git a/ecommerce_integrations/shopify/fulfillment.py b/ecommerce_integrations/shopify/fulfillment.py index 0d4574806..e0df76d15 100644 --- a/ecommerce_integrations/shopify/fulfillment.py +++ b/ecommerce_integrations/shopify/fulfillment.py @@ -18,12 +18,9 @@ def prepare_delivery_note(payload, request_id=None, account=None): frappe.set_user("Administrator") frappe.flags.request_id = request_id - # Get account context - if isinstance(account, str): - account = frappe.get_doc("Shopify Account", account) - elif not account: - # Fallback to legacy mode - account = frappe.get_doc(SETTING_DOCTYPE) + # FIXED: Use standardized account resolution + from ecommerce_integrations.shopify.utils import resolve_account_context + account = resolve_account_context(account) order = payload diff --git a/ecommerce_integrations/shopify/inventory.py b/ecommerce_integrations/shopify/inventory.py index 789f90a68..94cf05db6 100644 --- a/ecommerce_integrations/shopify/inventory.py +++ b/ecommerce_integrations/shopify/inventory.py @@ -31,7 +31,8 @@ def update_inventory_on_shopify() -> None: return for account_data in enabled_accounts: - account = frappe.get_doc(ACCOUNT_DOCTYPE, account_data.name) + from ecommerce_integrations.shopify.utils import resolve_account_context + account = resolve_account_context(account_data.name) _update_inventory_for_account(account) diff --git a/ecommerce_integrations/shopify/invoice.py b/ecommerce_integrations/shopify/invoice.py index 515aebbb0..1236777c2 100644 --- a/ecommerce_integrations/shopify/invoice.py +++ b/ecommerce_integrations/shopify/invoice.py @@ -18,12 +18,9 @@ def prepare_sales_invoice(payload, request_id=None, account=None): frappe.set_user("Administrator") frappe.flags.request_id = request_id - # Get account context - if isinstance(account, str): - account = frappe.get_doc("Shopify Account", account) - elif not account: - # Fallback to legacy mode - account = frappe.get_doc(SETTING_DOCTYPE) + # FIXED: Use standardized account resolution + from ecommerce_integrations.shopify.utils import resolve_account_context + account = resolve_account_context(account) try: sales_order = get_sales_order(cstr(order["id"])) diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index 8c470cb4a..4b2b6623b 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -34,18 +34,15 @@ def sync_sales_order(payload, request_id=None, account=None): frappe.set_user("Administrator") frappe.flags.request_id = request_id - # Get account context - if isinstance(account, str): - account = frappe.get_doc("Shopify Account", account) - elif not account: - # Fallback to legacy mode - account = frappe.get_doc(SETTING_DOCTYPE) + # FIXED: Use standardized account resolution + from ecommerce_integrations.shopify.utils import resolve_account_context + account = resolve_account_context(account) if frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: cstr(order["id"])}): create_shopify_log( status="Invalid", message="Sales order already exists, not synced", - reference_document=account.name if hasattr(account, 'name') else None + account=account ) return try: diff --git a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py index cc1745762..d713a076b 100644 --- a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py +++ b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py @@ -80,91 +80,22 @@ def get_shopify_product_count(account=None): @frappe.whitelist() def sync_product(product, account=None): - try: - shopify_product = ShopifyProduct(product, account=account) - shopify_product.sync_product() - - return True - except Exception: - frappe.db.rollback() - return False - + """Sync a single product with account context.""" + shopify_product = ShopifyProduct(product, account=account) # Pass account parameter + shopify_product.sync_product() + return True @frappe.whitelist() def resync_product(product, account=None): + """Resync a single product with account context.""" return _resync_product(product, account=account) - @temp_shopify_session def _resync_product(product, account=None): - savepoint = "shopify_resync_product" - try: - item = Product.find(product) - - frappe.db.savepoint(savepoint) - for variant in item.variants: - shopify_product = ShopifyProduct(product, variant_id=variant.id, account=account) - shopify_product.sync_product() - - return True - except Exception: - frappe.db.rollback(save_point=savepoint) - return False - - -def is_synced(product): - return ecommerce_item.is_synced(MODULE_NAME, integration_item_code=product) - - -@frappe.whitelist() -def import_all_products(account=None): - frappe.enqueue( - queue_sync_all_products, - queue="long", - job_name=SYNC_JOB_NAME, - key=REALTIME_KEY, - account=account, - ) - - -@frappe.whitelist() -def get_shopify_accounts(): - """Get list of enabled Shopify accounts for account selection""" - try: - accounts = frappe.get_all( - ACCOUNT_DOCTYPE, - filters={"enabled": 1}, # Changed from "enable_shopify" to "enabled" - fields=["name", "shop_domain"], # Changed from "shopify_url" to "shop_domain" - order_by="creation desc" - ) - - # Format the shop_domain to include https:// for display purposes - for account in accounts: - if account.get("shop_domain"): - account["shopify_url"] = f"https://{account['shop_domain']}" - else: - account["shopify_url"] = "Not configured" - - # Add legacy option for backward compatibility - legacy_setting = frappe.db.exists(SETTING_DOCTYPE) - if legacy_setting: - try: - legacy_doc = frappe.get_doc(SETTING_DOCTYPE) - if legacy_doc.enable_shopify: - accounts.insert(0, { - "name": "Legacy Setting", - "shopify_url": legacy_doc.shopify_url or "Legacy" - }) - except Exception as e: - frappe.log_error(f"Error accessing legacy Shopify settings: {str(e)}", "Shopify Import Products") - - return accounts - - except Exception as e: - frappe.log_error(f"Error fetching Shopify accounts: {str(e)}", "Shopify Import Products") - # Return empty list instead of throwing error to prevent UI crash - return [] - + """Internal resync with account context.""" + shopify_product = ShopifyProduct(product, account=account) # Pass account parameter + shopify_product.sync_product() + return True def queue_sync_all_products(*args, **kwargs): account = kwargs.get('account') @@ -188,7 +119,7 @@ def queue_sync_all_products(*args, **kwargs): publish(f"Product {product.id} already synced. Skipping...") continue - shopify_product = ShopifyProduct(product.id, account=account) + shopify_product = ShopifyProduct(product.id, account=account) # Pass account parameter shopify_product.sync_product() publish(f"βœ… Synced Product {product.id}", synced=True) @@ -224,3 +155,54 @@ def publish(message, synced=False, error=False, done=False, br=True): "done": done, }, ) + + +@frappe.whitelist() +def get_shopify_accounts(): + """Get all enabled Shopify accounts with legacy fallback support.""" + from ecommerce_integrations.shopify.utils import resolve_account_context + + try: + # Get all enabled Shopify accounts + accounts = frappe.get_all( + ACCOUNT_DOCTYPE, + filters={"enabled": 1}, + fields=["name", "account_title", "shop_domain", "company"] + ) + + # If no multi-tenant accounts found, check legacy setting + if not accounts: + try: + legacy_setting = resolve_account_context() # Gets legacy setting + if legacy_setting.is_enabled(): + # Return legacy setting as a single account option + return [{ + "name": "legacy", + "shop_url": legacy_setting.shopify_url or "", + "company": legacy_setting.company or "", + "title": "Legacy Shopify Setting" + }] + except: + # No legacy setting available + pass + + return [] + + # Format multi-tenant accounts for frontend display + formatted_accounts = [] + for account in accounts: + formatted_accounts.append({ + "name": account.name, + "shop_url": f"https://{account.shop_domain}" if account.shop_domain else "", + "company": account.company or "", + "title": account.account_title or account.shop_domain + }) + + return formatted_accounts + + except Exception as e: + frappe.log_error( + message=f"Error fetching Shopify accounts: {str(e)}", + title="Shopify Import Products Error" + ) + return [] diff --git a/ecommerce_integrations/shopify/product.py b/ecommerce_integrations/shopify/product.py index 90244bebc..04c090b4d 100644 --- a/ecommerce_integrations/shopify/product.py +++ b/ecommerce_integrations/shopify/product.py @@ -26,12 +26,16 @@ def __init__( variant_id: str | None = None, sku: str | None = None, has_variants: int | None = 0, + account=None, # NEW: Add account parameter ): self.product_id = str(product_id) self.variant_id = str(variant_id) if variant_id else None self.sku = str(sku) if sku else None self.has_variants = has_variants - self.setting = frappe.get_doc(SETTING_DOCTYPE) + + # Use standardized account resolution + from ecommerce_integrations.shopify.utils import resolve_account_context + self.setting = resolve_account_context(account) if not self.setting.is_enabled(): frappe.throw(_("Can not create Shopify product when integration is disabled.")) @@ -362,17 +366,19 @@ def upload_erpnext_item(doc, method=None): pluck="name" ) - # Legacy fallback if no accounts found + # FIXED: Use standardized account resolution for legacy fallback if not enabled_accounts: - setting = frappe.get_doc(SETTING_DOCTYPE) + from ecommerce_integrations.shopify.utils import resolve_account_context + setting = resolve_account_context() # This will get legacy setting if setting.is_enabled() and setting.upload_erpnext_items: _upload_item_to_account(item, template_item, None) # Use legacy singleton return - # Upload to all enabled accounts + # FIXED: Use standardized resolution for account loading for account_name in enabled_accounts: try: - account = frappe.get_doc(ACCOUNT_DOCTYPE, account_name) + from ecommerce_integrations.shopify.utils import resolve_account_context + account = resolve_account_context(account_name) _upload_item_to_account(item, template_item, account) except Exception as e: frappe.log_error( @@ -383,13 +389,10 @@ def upload_erpnext_item(doc, method=None): def _upload_item_to_account(item, template_item, account): """Upload item to a specific Shopify account or legacy singleton.""" - # Use account settings or fallback to legacy singleton - if account: - setting = account - account_context = f" (Account: {account.name})" - else: - setting = frappe.get_doc(SETTING_DOCTYPE) - account_context = " (Legacy)" + # Use standardized account resolution + from ecommerce_integrations.shopify.utils import resolve_account_context + setting = resolve_account_context(account) + account_context = f" (Account: {setting.name})" if hasattr(setting, 'name') else " (Legacy)" if not setting.is_enabled() or not setting.upload_erpnext_items: return @@ -409,12 +412,12 @@ def _upload_item_to_account(item, template_item, account): is_new_product = not bool(product_id) # Set up Shopify session context for this account - if account: - with temp_shopify_session(account): + if hasattr(setting, 'name'): # Multi-tenant account + with temp_shopify_session(account=setting): + _process_item_upload(item, template_item, setting, is_new_product, product_id, account_context) + else: # Legacy singleton + with temp_shopify_session(): _process_item_upload(item, template_item, setting, is_new_product, product_id, account_context) - else: - # Legacy singleton session - _process_item_upload(item, template_item, setting, is_new_product, product_id, account_context) def _process_item_upload(item, template_item, setting, is_new_product, product_id, account_context): diff --git a/ecommerce_integrations/shopify/utils.py b/ecommerce_integrations/shopify/utils.py index 06bf1f582..d6728aa85 100644 --- a/ecommerce_integrations/shopify/utils.py +++ b/ecommerce_integrations/shopify/utils.py @@ -11,11 +11,77 @@ MODULE_NAME, OLD_SETTINGS_DOCTYPE, SETTING_DOCTYPE, + ACCOUNT_DOCTYPE, ) - - -def create_shopify_log(**kwargs): - return create_log(module_def=MODULE_NAME, **kwargs) +from frappe.model.document import Document + + +def resolve_account_context(account=None): + """Standardized account resolution with legacy fallback. + + This function serves as a unified resolver for Shopify account contexts, + handling both the new multi-tenant Shopify Account system and the legacy + Shopify Setting singleton pattern. + + Args: + account (None | str | Document, optional): Account context to resolve. + - None: Returns legacy Shopify Setting document for backward compatibility + - str: Account name to fetch the corresponding Shopify Account document + - Document: Assumes it's already a loaded Frappe document (Shopify Account + or Shopify Setting) and returns it directly without validation + + Returns: + Document: Either a Shopify Account document (new multi-tenant) or + Shopify Setting document (legacy singleton) + + Raises: + frappe.DoesNotExistError: If the specified account name doesn't exist + + Note: + The function assumes that any non-string, non-None parameter is a valid + Frappe document object. This assumption is based on the controlled usage + patterns within the Shopify integration system where only document objects + or account names are passed. No type validation is performed on document + objects for performance reasons. + + Examples: + >>> # Legacy fallback + >>> setting = resolve_account_context(None) + >>> + >>> # Fetch by account name + >>> account = resolve_account_context("My Shopify Store") + >>> + >>> # Pass existing document + >>> existing_doc = frappe.get_doc("Shopify Account", "My Store") + >>> same_doc = resolve_account_context(existing_doc) + >>> assert existing_doc is same_doc # Returns same object + """ + + if account: + if isinstance(account, str): + return frappe.get_doc(ACCOUNT_DOCTYPE, account) + elif isinstance(account, Document): # Check if it's a Frappe document + return account + else: + frappe.throw(f"Invalid account parameter type: {type(account)}") + else: + # Legacy fallback for backward compatibility + return frappe.get_doc(SETTING_DOCTYPE) + +def create_shopify_log(account=None, **kwargs): + """Enhanced logging with account context support.""" + reference_document = None + + if account: + account_doc = resolve_account_context(account) + if hasattr(account_doc, 'name') and account_doc.doctype == "Shopify Account": + reference_document = account_doc.name + + return create_log( + module_def=MODULE_NAME, + reference_document=reference_document, + **kwargs + ) def migrate_from_old_connector(payload=None, request_id=None): From 05a0562c4c3b915ae7c6d5d0eabb69bf57b9a48f Mon Sep 17 00:00:00 2001 From: Ahmed Osama Date: Wed, 13 Aug 2025 18:11:31 +0000 Subject: [PATCH 03/30] feat(shopify): improve account authentication and sync logic - Update Shopify Account doctype with clearer authentication labels and add shop URL field - Simplify old order sync logic by removing redundant checks - Refactor location fetching to use direct session management --- .../shopify_account/shopify_account.json | 24 ++++++++++++----- .../shopify_account/shopify_account.py | 27 +++++++++++-------- ecommerce_integrations/shopify/order.py | 24 +++++------------ 3 files changed, 41 insertions(+), 34 deletions(-) diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json index 4a016c713..2de70f41b 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json @@ -100,20 +100,32 @@ "collapsible": 0, "fieldname": "section_break_credentials", "fieldtype": "Section Break", - "label": "Credentials" + "label": "Authentication Details" }, { "fieldname": "access_token", "fieldtype": "Password", - "label": "Access Token", + "label": "Password / Access Token", "mandatory_depends_on": "eval:doc.enabled" }, { "fieldname": "shared_secret", - "fieldtype": "Password", - "label": "Shared Secret", - "mandatory_depends_on": "eval:doc.enabled", - "description": "Used for webhook HMAC verification" + "fieldtype": "Data", + "label": "Shared secret / API Secret", + "mandatory_depends_on": "eval:doc.enabled" + }, + { + "fieldname": "column_break_credentials", + "fieldtype": "Column Break" + }, + { + "description": "eg: frappe.myshopify.com", + "fieldname": "shop_domain", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Shop URL", + "reqd": 1, + "unique": 1 }, { "fieldname": "column_break_credentials", diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py index d2c170db1..1107c78a5 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py @@ -170,7 +170,6 @@ def update_sync_status(self, status: str, sync_time=None): self.db_set("last_sync_at", self.last_sync_at) @frappe.whitelist() - @connection.temp_shopify_session def fetch_shopify_locations(self): """Fetch locations from Shopify and add them to warehouse mapping table.""" if not self.enabled: @@ -182,15 +181,21 @@ def fetch_shopify_locations(self): # Clear existing mappings self.warehouse_mappings = [] - # Fetch locations from Shopify - try: - for locations in PaginatedIterator(Location.find()): - for location in locations: - self.append("warehouse_mappings", { - "shopify_location_id": location.id, - "shopify_location_name": location.name - }) - except Exception as e: - frappe.throw(_("Failed to fetch Shopify locations: {0}").format(str(e))) + # Import Session from shopify + from shopify.session import Session + from ecommerce_integrations.shopify.constants import API_VERSION + + # Use Session.temp directly with account credentials + with Session.temp(self.get_shop_url(), API_VERSION, self.get_access_token()): + # Fetch locations from Shopify + try: + for locations in PaginatedIterator(Location.find()): + for location in locations: + self.append("warehouse_mappings", { + "shopify_location_id": location.id, + "shopify_location_name": location.name + }) + except Exception as e: + frappe.throw(_("Failed to fetch Shopify locations: {0}").format(str(e))) frappe.msgprint(_("Successfully fetched {0} locations from Shopify").format(len(self.warehouse_mappings))) diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index 4b2b6623b..d7617a399 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -476,32 +476,22 @@ def _sync_old_orders_for_account(account): """Sync old orders for a specific Shopify account.""" if not account.is_enabled(): return - + # Check if account has old order sync enabled - # TODO: Add old_orders_sync fields to Shopify Account doctype - # For now, we'll assume accounts don't need old order sync by default - # This should be controlled by account-specific settings - - # Placeholder for account-specific old order sync logic - # This would need additional fields in Shopify Account doctype: - # - sync_old_orders (Check) - # - old_orders_from (Datetime) - # - old_orders_to (Datetime) - - if not hasattr(account, 'sync_old_orders') or not cint(account.sync_old_orders): + if not cint(account.sync_old_orders): return - + # Use account-specific session with temp_shopify_session(account=account): orders = _fetch_old_orders( - getattr(account, 'old_orders_from', None), - getattr(account, 'old_orders_to', None) + account.old_orders_from, + account.old_orders_to ) for order in orders: log = create_shopify_log( - method=EVENT_MAPPER["orders/create"], - request_data=json.dumps(order), + method=EVENT_MAPPER["orders/create"], + request_data=json.dumps(order), make_new=True, reference_document=account.name ) From 76e32ed38a4e0cc4b3cd11cb03eede7c770ed67a Mon Sep 17 00:00:00 2001 From: Ahmed Osama Date: Wed, 13 Aug 2025 19:54:20 +0000 Subject: [PATCH 04/30] refactor(shopify): extract location fetching logic to separate method Move Shopify location fetching logic to a dedicated method with proper error handling and logging. This improves code organization and maintainability while providing better error tracking. --- .../shopify_account/shopify_account.py | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py index 1107c78a5..74226c5a2 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py @@ -181,21 +181,33 @@ def fetch_shopify_locations(self): # Clear existing mappings self.warehouse_mappings = [] - # Import Session from shopify - from shopify.session import Session - from ecommerce_integrations.shopify.constants import API_VERSION + # Pass account parameter explicitly + self._fetch_locations_with_session(account=self) - # Use Session.temp directly with account credentials - with Session.temp(self.get_shop_url(), API_VERSION, self.get_access_token()): - # Fetch locations from Shopify - try: - for locations in PaginatedIterator(Location.find()): - for location in locations: - self.append("warehouse_mappings", { - "shopify_location_id": location.id, - "shopify_location_name": location.name - }) - except Exception as e: - frappe.throw(_("Failed to fetch Shopify locations: {0}").format(str(e))) - frappe.msgprint(_("Successfully fetched {0} locations from Shopify").format(len(self.warehouse_mappings))) + + @connection.temp_shopify_session + def _fetch_locations_with_session(self, account=None): + """Internal method to fetch locations with session context.""" + try: + for locations in PaginatedIterator(Location.find()): + for location in locations: + self.append("warehouse_mappings", { + "shopify_location_id": location.id, + "shopify_location_name": location.name + }) + except Exception as e: + # Import the logging function + from ecommerce_integrations.shopify.utils import create_shopify_log + + # Create error log entry + create_shopify_log( + status="Error", + method="fetch_shopify_locations", + message=f"Failed to fetch Shopify locations: {str(e)}", + exception=e, + account=account + ) + + # Then throw the exception for user feedback + frappe.throw(_("Failed to fetch Shopify locations: {0}").format(str(e))) From 86cfa5ec3ece48abf2c362e3d8093d9d0a228585 Mon Sep 17 00:00:00 2001 From: Ahmed Osama Date: Wed, 13 Aug 2025 20:22:18 +0000 Subject: [PATCH 05/30] refactor(shopify): standardize account parameter in logging functions Replace reference_document parameter with account parameter in create_shopify_log calls Update create_shopify_log to handle account context in message instead of reference_document --- ecommerce_integrations/shopify/connection.py | 4 ++-- .../doctype/shopify_account/shopify_account.py | 2 +- ecommerce_integrations/shopify/fulfillment.py | 6 +++--- ecommerce_integrations/shopify/inventory.py | 5 ++--- ecommerce_integrations/shopify/invoice.py | 6 +++--- ecommerce_integrations/shopify/order.py | 12 ++++++------ ecommerce_integrations/shopify/utils.py | 9 ++++++--- 7 files changed, 23 insertions(+), 21 deletions(-) diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index 7a4a49b53..e65d5a933 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -132,7 +132,7 @@ def process_request(data, event, account): log = create_shopify_log( method=EVENT_MAPPER[event], request_data=data, - reference_document=account.name + account=account ) # enqueue background job with account context @@ -155,7 +155,7 @@ def _validate_request(req, hmac_header, account): create_shopify_log( status="Error", request_data=req.data, - reference_document=account.name, + account=account, exception="Invalid HMAC signature" ) frappe.throw(_("Unverified Webhook Data")) diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py index 74226c5a2..cfeac7502 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py @@ -210,4 +210,4 @@ def _fetch_locations_with_session(self, account=None): ) # Then throw the exception for user feedback - frappe.throw(_("Failed to fetch Shopify locations: {0}").format(str(e))) + frappe.throw(_("Failed to fetch Shopify locations: {0}").format(str(e))) \ No newline at end of file diff --git a/ecommerce_integrations/shopify/fulfillment.py b/ecommerce_integrations/shopify/fulfillment.py index e0df76d15..20d62211e 100644 --- a/ecommerce_integrations/shopify/fulfillment.py +++ b/ecommerce_integrations/shopify/fulfillment.py @@ -30,20 +30,20 @@ def prepare_delivery_note(payload, request_id=None, account=None): create_delivery_note(order, account, sales_order) create_shopify_log( status="Success", - reference_document=account.name if hasattr(account, 'name') else None + account=account ) else: create_shopify_log( status="Invalid", message="Sales Order not found for syncing delivery note.", - reference_document=account.name if hasattr(account, 'name') else None + account=account ) except Exception as e: create_shopify_log( status="Error", exception=e, rollback=True, - reference_document=account.name if hasattr(account, 'name') else None + account=account ) diff --git a/ecommerce_integrations/shopify/inventory.py b/ecommerce_integrations/shopify/inventory.py index 94cf05db6..6a31a6646 100644 --- a/ecommerce_integrations/shopify/inventory.py +++ b/ecommerce_integrations/shopify/inventory.py @@ -140,11 +140,10 @@ def _log_inventory_update_status(inventory_levels, account=None) -> None: log_message = f"Updated {percent_successful * 100}% items\n\n" + log_message - # Include account reference in log if available - reference_document = account.name if account else None + create_shopify_log( method="update_inventory_on_shopify", status=status, message=log_message, - reference_document=reference_document + account=account ) diff --git a/ecommerce_integrations/shopify/invoice.py b/ecommerce_integrations/shopify/invoice.py index 1236777c2..7b9ee9f0e 100644 --- a/ecommerce_integrations/shopify/invoice.py +++ b/ecommerce_integrations/shopify/invoice.py @@ -28,20 +28,20 @@ def prepare_sales_invoice(payload, request_id=None, account=None): create_sales_invoice(order, account, sales_order) create_shopify_log( status="Success", - reference_document=account.name if hasattr(account, 'name') else None + account=account ) else: create_shopify_log( status="Invalid", message="Sales Order not found for syncing sales invoice.", - reference_document=account.name if hasattr(account, 'name') else None + account=account ) except Exception as e: create_shopify_log( status="Error", exception=e, rollback=True, - reference_document=account.name if hasattr(account, 'name') else None + account=account ) diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index d7617a399..9335d4c2d 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -65,12 +65,12 @@ def sync_sales_order(payload, request_id=None, account=None): status="Error", exception=e, rollback=True, - reference_document=account.name if hasattr(account, 'name') else None + account=account ) else: create_shopify_log( status="Success", - reference_document=account.name if hasattr(account, 'name') else None + account=account ) @@ -402,7 +402,7 @@ def cancel_order(payload, request_id=None, account=None): create_shopify_log( status="Invalid", message="Sales Order does not exist", - reference_document=account.name if hasattr(account, 'name') else None + account=account ) return @@ -424,12 +424,12 @@ def cancel_order(payload, request_id=None, account=None): create_shopify_log( status="Error", exception=e, - reference_document=account.name if hasattr(account, 'name') else None + account=account ) else: create_shopify_log( status="Success", - reference_document=account.name if hasattr(account, 'name') else None + account=account ) @@ -493,7 +493,7 @@ def _sync_old_orders_for_account(account): method=EVENT_MAPPER["orders/create"], request_data=json.dumps(order), make_new=True, - reference_document=account.name + account=account ) sync_sales_order(order, request_id=log.name, account=account) diff --git a/ecommerce_integrations/shopify/utils.py b/ecommerce_integrations/shopify/utils.py index d6728aa85..11c246042 100644 --- a/ecommerce_integrations/shopify/utils.py +++ b/ecommerce_integrations/shopify/utils.py @@ -70,16 +70,19 @@ def resolve_account_context(account=None): def create_shopify_log(account=None, **kwargs): """Enhanced logging with account context support.""" - reference_document = None + # Include account context in the message instead of using unsupported reference_document parameter if account: account_doc = resolve_account_context(account) if hasattr(account_doc, 'name') and account_doc.doctype == "Shopify Account": - reference_document = account_doc.name + # Include account info in message if not already present + if 'message' in kwargs and kwargs['message']: + kwargs['message'] = f"[Account: {account_doc.name}] {kwargs['message']}" + elif 'message' not in kwargs: + kwargs['message'] = f"Account: {account_doc.name}" return create_log( module_def=MODULE_NAME, - reference_document=reference_document, **kwargs ) From 042520280a83da73291f447d528162ed9bf69865 Mon Sep 17 00:00:00 2001 From: Ahmed Osama Date: Wed, 13 Aug 2025 20:30:43 +0000 Subject: [PATCH 06/30] refactor(shopify_account): Remove redundant shop_domain field and add inventory settings section for better organization --- .../shopify_account/shopify_account.json | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json index 2de70f41b..0fe159b51 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json @@ -118,19 +118,6 @@ "fieldname": "column_break_credentials", "fieldtype": "Column Break" }, - { - "description": "eg: frappe.myshopify.com", - "fieldname": "shop_domain", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Shop URL", - "reqd": 1, - "unique": 1 - }, - { - "fieldname": "column_break_credentials", - "fieldtype": "Column Break" - }, { "fieldname": "public_app_key", "fieldtype": "Data", @@ -292,6 +279,11 @@ "fieldtype": "Check", "label": "Upload ERPNext Variants as Shopify Items" }, + { + "fieldname": "section_break_inventory", + "fieldtype": "Section Break", + "label": "Inventory Settings" + }, { "default": "0", "fieldname": "update_erpnext_stock_levels_to_shopify", From 5a3e7a9150e17de639d5cfcc1b7a05bfb5b16575 Mon Sep 17 00:00:00 2001 From: Ahmed Osama Date: Wed, 13 Aug 2025 20:52:48 +0000 Subject: [PATCH 07/30] feat(shopify): add customer group field and refactor form handlers - Add required customer group field for customer sync - Move fetch_shopify_locations to separate handler - Clean up comments and redundant code - Improve form query setup organization --- .../shopify_account/shopify_account.js | 152 ++-- .../shopify_account/shopify_account.json | 852 +++++++++--------- 2 files changed, 499 insertions(+), 505 deletions(-) diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js index 94d563472..e07b7a3db 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js @@ -5,24 +5,27 @@ frappe.provide("ecommerce_integrations.shopify.shopify_account"); frappe.ui.form.on("Shopify Account", { onload: function (frm) { - // Load naming series for document series fields frappe.call({ method: "ecommerce_integrations.utils.naming_series.get_series", callback: function (r) { - if (r.message) { - $.each(r.message, (key, value) => { - set_field_options(key, value); - }); - } + $.each(r.message, (key, value) => { + set_field_options(key, value); + }); }, }); + }, - // Set up form description - frm.set_intro(__("This record serves as the Shopify Settings for a single Shopify store. Create one record per store.")); + fetch_shopify_locations: function (frm) { + frappe.call({ + doc: frm.doc, + method: "fetch_shopify_locations", + callback: (r) => { + if (!r.exc) refresh_field("warehouse_mappings"); + }, + }); }, refresh: function (frm) { - // Add custom buttons frm.add_custom_button(__("Import Products"), function () { if (frm.doc.enabled && frm.doc.shop_domain) { frappe.set_route("shopify-import-products", {"account": frm.doc.name}); @@ -30,37 +33,73 @@ frappe.ui.form.on("Shopify Account", { frappe.msgprint(__("Please enable the account and save before importing products")); } }); - frm.add_custom_button(__("View Logs"), () => { frappe.set_route("List", "Ecommerce Integration Log", { integration: "Shopify", reference_document: frm.doc.name }); }); - frm.add_custom_button(__("Fetch Shopify Locations"), function () { if (!frm.doc.enabled) { frappe.msgprint(__("Please enable the account first")); return; } - - frappe.call({ - doc: frm.doc, - method: "fetch_shopify_locations", - callback: (r) => { - if (!r.exc) { - frm.refresh_field("warehouse_mappings"); - frappe.msgprint(__("Shopify locations fetched successfully")); - } + frm.trigger("fetch_shopify_locations"); + }); + frm.trigger("setup_queries"); + }, + + setup_queries: function (frm) { + const warehouse_query = () => { + return { + filters: { + company: frm.doc.company, + is_group: 0, + disabled: 0, }, - }); + }; + }; + frm.set_query("erpnext_warehouse", "warehouse_mappings", warehouse_query); + + frm.set_query("selling_price_list", () => { + return { + filters: { + selling: 1, + }, + }; }); - frm.trigger("setup_queries"); - frm.trigger("toggle_conditional_fields"); - frm.trigger("show_enabled_status"); + frm.set_query("cost_center", () => { + return { + filters: { + company: frm.doc.company, + is_group: "No", + }, + }; + }); + + frm.set_query("default_customer", () => { + const filters = {disabled: 0}; + if (frm.doc.company) { + filters.company = frm.doc.company; + } + return {filters}; + }); + + const tax_query = () => { + return { + query: "erpnext.controllers.queries.tax_account_query", + filters: { + account_type: ["Tax", "Chargeable", "Expense Account"], + company: frm.doc.company, + }, + }; + }; + + frm.set_query("tax_account", "tax_mappings", tax_query); }, + // Additional event handlers specific to Shopify Account enabled: function (frm) { frm.trigger("toggle_conditional_fields"); frm.trigger("show_enabled_status"); @@ -68,7 +107,6 @@ frappe.ui.form.on("Shopify Account", { company: function (frm) { if (frm.doc.company) { - // Warn user to review mappings when company changes if (frm.doc.warehouse_mappings && frm.doc.warehouse_mappings.length > 0) { frappe.msgprint({ title: __("Company Changed"), @@ -81,11 +119,9 @@ frappe.ui.form.on("Shopify Account", { }, shop_domain: function (frm) { - // Auto-format shop domain if (frm.doc.shop_domain) { let domain = frm.doc.shop_domain.replace(/^https?:\/\//, ""); if (domain && !domain.endsWith(".myshopify.com")) { - // Don't auto-append, let validation handle it frappe.msgprint({ title: __("Invalid Domain"), message: __("Shop domain must end with '.myshopify.com'"), @@ -115,17 +151,13 @@ frappe.ui.form.on("Shopify Account", { }, toggle_conditional_fields: function (frm) { - // Show/hide fields based on enabled status const is_enabled = frm.doc.enabled; - - // Make credentials mandatory when enabled frm.toggle_reqd("access_token", is_enabled); frm.toggle_reqd("shared_secret", is_enabled); frm.toggle_reqd("company", is_enabled); }, show_enabled_status: function (frm) { - // Show status indicator if (frm.doc.enabled) { if (!frm.doc.access_token || !frm.doc.shared_secret || !frm.doc.company) { frm.dashboard.add_indicator(__("Incomplete Setup"), "orange"); @@ -146,62 +178,6 @@ frappe.ui.form.on("Shopify Account", { }); } }, - - setup_queries: function (frm) { - // Warehouse queries - filter by company - const warehouse_query = () => { - return { - filters: { - company: frm.doc.company, - is_group: 0, - disabled: 0, - }, - }; - }; - - frm.set_query("erpnext_warehouse", "warehouse_mappings", warehouse_query); - - // Price list query - only selling price lists - frm.set_query("selling_price_list", () => { - return { - filters: { - selling: 1, - }, - }; - }); - - // Cost center query - filter by company - frm.set_query("cost_center", () => { - return { - filters: { - company: frm.doc.company, - is_group: "No", - }, - }; - }); - - // Customer query - filter by company if set - frm.set_query("default_customer", () => { - const filters = {disabled: 0}; - if (frm.doc.company) { - filters.company = frm.doc.company; - } - return {filters}; - }); - - // Tax account queries - const tax_query = () => { - return { - query: "erpnext.controllers.queries.tax_account_query", - filters: { - account_type: ["Tax", "Chargeable", "Expense Account"], - company: frm.doc.company, - }, - }; - }; - - frm.set_query("tax_account", "tax_mappings", tax_query); - }, }); // Handle warehouse mapping child table events @@ -209,7 +185,6 @@ frappe.ui.form.on("Shopify Warehouse Mapping", { erpnext_warehouse: function (frm, cdt, cdn) { const row = locals[cdt][cdn]; if (row.erpnext_warehouse && frm.doc.company) { - // Validate warehouse belongs to the same company frappe.db.get_value("Warehouse", row.erpnext_warehouse, "company") .then(r => { if (r.message && r.message.company !== frm.doc.company) { @@ -226,7 +201,6 @@ frappe.ui.form.on("Shopify Tax Account", { tax_account: function (frm, cdt, cdn) { const row = locals[cdt][cdn]; if (row.tax_account && frm.doc.company) { - // Validate tax account belongs to the same company frappe.db.get_value("Account", row.tax_account, "company") .then(r => { if (r.message && r.message.company !== frm.doc.company) { diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json index 0fe159b51..ca6e4873c 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json @@ -1,417 +1,437 @@ { - "actions": [], - "autoname": "field:shop_domain", - "creation": "2024-01-01 00:00:00.000000", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "enabled", - "account_title", - "column_break_basic", - "shop_domain", - "api_version", - "section_break_credentials", - "access_token", - "shared_secret", - "column_break_credentials", - "public_app_key", - "section_break_company", - "company", - "selling_price_list", - "column_break_company", - "cost_center", - "default_customer", - "section_break_series", - "sales_order_series", - "sales_invoice_series", - "column_break_series", - "delivery_note_series", - "section_break_features", - "create_customers", - "create_missing_items", - "column_break_features1", - "sync_sales_invoice", - "sync_delivery_note", - "column_break_features2", - "allow_backdated_sync", - "close_orders_on_fulfillment", - "section_break_product_upload", - "upload_erpnext_items", - "update_shopify_item_on_update", - "column_break_product_upload", - "sync_new_item_as_active", - "upload_variants_as_items", - "section_break_inventory", - "update_erpnext_stock_levels_to_shopify", - "inventory_sync_frequency", - "column_break_inventory", - "last_inventory_sync", - "section_break_old_orders", - "sync_old_orders", - "old_orders_from", - "column_break_old_orders", - "old_orders_to", - "section_break_mappings", - "warehouse_mappings", - "tax_mappings", - "section_break_operational", - "last_sync_status", - "last_sync_at", - "column_break_operational", - "notes" - ], - "fields": [ - { - "default": "0", - "fieldname": "enabled", - "fieldtype": "Check", - "label": "Enabled" - }, - { - "fieldname": "account_title", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Account Title", - "description": "Friendly name for this Shopify store (e.g., 'Main KSA Store')" - }, - { - "fieldname": "column_break_basic", - "fieldtype": "Column Break" - }, - { - "fieldname": "shop_domain", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Shop Domain", - "reqd": 1, - "unique": 1, - "description": "Exact domain from Shopify (e.g., mystore.myshopify.com). Used to route webhooks." - }, - { - "default": "2023-10", - "fieldname": "api_version", - "fieldtype": "Data", - "label": "API Version", - "read_only": 1, - "description": "Shopify API version (auto-managed)" - }, - { - "collapsible": 0, - "fieldname": "section_break_credentials", - "fieldtype": "Section Break", - "label": "Authentication Details" - }, - { - "fieldname": "access_token", - "fieldtype": "Password", - "label": "Password / Access Token", - "mandatory_depends_on": "eval:doc.enabled" - }, - { - "fieldname": "shared_secret", - "fieldtype": "Data", - "label": "Shared secret / API Secret", - "mandatory_depends_on": "eval:doc.enabled" - }, - { - "fieldname": "column_break_credentials", - "fieldtype": "Column Break" - }, - { - "fieldname": "public_app_key", - "fieldtype": "Data", - "label": "Public App Key", - "description": "Optional: Only needed for specific app flows" - }, - { - "fieldname": "section_break_company", - "fieldtype": "Section Break", - "label": "Company & Defaults" - }, - { - "fieldname": "company", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Company", - "options": "Company", - "reqd": 1, - "description": "ERPNext legal entity receiving the documents" - }, - { - "fieldname": "selling_price_list", - "fieldtype": "Link", - "label": "Selling Price List", - "options": "Price List", - "description": "Used when pricing/valuation is needed" - }, - { - "fieldname": "column_break_company", - "fieldtype": "Column Break" - }, - { - "fieldname": "cost_center", - "fieldtype": "Link", - "label": "Cost Center", - "options": "Cost Center", - "description": "Default for SI/DN if created" - }, - { - "fieldname": "default_customer", - "fieldtype": "Link", - "label": "Default Customer", - "options": "Customer", - "description": "Optional fallback if customer creation is off or mapping fails" - }, - { - "fieldname": "section_break_series", - "fieldtype": "Section Break", - "label": "Document Series" - }, - { - "fieldname": "sales_order_series", - "fieldtype": "Select", - "label": "Sales Order Series", - "description": "e.g., SO-SHOP-. If empty, safe defaults will be used." - }, - { - "fieldname": "sales_invoice_series", - "fieldtype": "Select", - "label": "Sales Invoice Series", - "description": "e.g., SINV-SHOP-. If empty, safe defaults will be used." - }, - { - "fieldname": "column_break_series", - "fieldtype": "Column Break" - }, - { - "fieldname": "delivery_note_series", - "fieldtype": "Select", - "label": "Delivery Note Series", - "description": "e.g., DN-SHOP-. If empty, safe defaults will be used." - }, - { - "fieldname": "section_break_features", - "fieldtype": "Section Break", - "label": "Feature Toggles" - }, - { - "default": "1", - "fieldname": "create_customers", - "fieldtype": "Check", - "label": "Create Customers" - }, - { - "default": "0", - "fieldname": "create_missing_items", - "fieldtype": "Check", - "label": "Create Missing Items" - }, - { - "fieldname": "column_break_features1", - "fieldtype": "Column Break" - }, - { - "default": "0", - "fieldname": "sync_sales_invoice", - "fieldtype": "Check", - "label": "Sync Sales Invoice" - }, - { - "default": "0", - "fieldname": "sync_delivery_note", - "fieldtype": "Check", - "label": "Sync Delivery Note" - }, - { - "fieldname": "column_break_features2", - "fieldtype": "Column Break" - }, - { - "default": "0", - "fieldname": "allow_backdated_sync", - "fieldtype": "Check", - "label": "Allow Backdated Sync", - "description": "If true, backfill jobs allowed; otherwise block with a warning" - }, - { - "default": "0", - "fieldname": "close_orders_on_fulfillment", - "fieldtype": "Check", - "label": "Close Orders on Fulfillment", - "description": "Auto-close orders when delivery note/fulfillment is created" - }, - { - "fieldname": "section_break_product_upload", - "fieldtype": "Section Break", - "label": "Product Upload Settings" - }, - { - "default": "0", - "fieldname": "upload_erpnext_items", - "fieldtype": "Check", - "label": "Upload new ERPNext Items to Shopify", - "description": "Automatically upload new ERPNext items to this Shopify store" - }, - { - "default": "0", - "depends_on": "eval:doc.upload_erpnext_items", - "fieldname": "update_shopify_item_on_update", - "fieldtype": "Check", - "label": "Update Shopify Item after updating ERPNext item", - "description": "Sync changes from ERPNext items to Shopify products" - }, - { - "fieldname": "column_break_product_upload", - "fieldtype": "Column Break" - }, - { - "default": "0", - "fieldname": "sync_new_item_as_active", - "fieldtype": "Check", - "label": "Sync New Items as Active", - "description": "New products will be published as active on Shopify" - }, - { - "default": "0", - "description": "Caution: Only 3 attributes will be accepted by Shopify", - "fieldname": "upload_variants_as_items", - "fieldtype": "Check", - "label": "Upload ERPNext Variants as Shopify Items" - }, - { - "fieldname": "section_break_inventory", - "fieldtype": "Section Break", - "label": "Inventory Settings" - }, - { - "default": "0", - "fieldname": "update_erpnext_stock_levels_to_shopify", - "fieldtype": "Check", - "label": "Update ERPNext Stock Levels to Shopify", - "description": "Enable automatic inventory sync from ERPNext to Shopify" - }, - { - "default": "Hourly", - "fieldname": "inventory_sync_frequency", - "fieldtype": "Select", - "label": "Inventory Sync Frequency", - "options": "Every 15 minutes\nEvery 30 minutes\nHourly\nEvery 6 hours\nDaily", - "depends_on": "eval:doc.update_erpnext_stock_levels_to_shopify", - "description": "How often to sync inventory levels" - }, - { - "fieldname": "column_break_inventory", - "fieldtype": "Column Break" - }, - { - "fieldname": "last_inventory_sync", - "fieldtype": "Datetime", - "label": "Last Inventory Sync", - "read_only": 1, - "description": "Timestamp of last inventory sync" - }, - { - "fieldname": "section_break_old_orders", - "fieldtype": "Section Break", - "label": "Old Orders Sync" - }, - { - "default": "0", - "fieldname": "sync_old_orders", - "fieldtype": "Check", - "label": "Sync Old Orders", - "description": "Enable one-time sync of historical orders from Shopify" - }, - { - "fieldname": "old_orders_from", - "fieldtype": "Datetime", - "label": "Old Orders From", - "depends_on": "eval:doc.sync_old_orders", - "description": "Start date/time for historical order sync" - }, - { - "fieldname": "column_break_old_orders", - "fieldtype": "Column Break" - }, - { - "fieldname": "old_orders_to", - "fieldtype": "Datetime", - "label": "Old Orders To", - "depends_on": "eval:doc.sync_old_orders", - "description": "End date/time for historical order sync" - }, - { - "fieldname": "section_break_mappings", - "fieldtype": "Section Break", - "label": "Account-Specific Mappings" - }, - { - "fieldname": "warehouse_mappings", - "fieldtype": "Table", - "label": "Warehouse Mappings", - "options": "Shopify Warehouse Mapping" - }, - { - "fieldname": "tax_mappings", - "fieldtype": "Table", - "label": "Tax Mappings", - "options": "Shopify Tax Account" - }, - { - "fieldname": "section_break_operational", - "fieldtype": "Section Break", - "label": "Operational Status" - }, - { - "default": "Idle", - "fieldname": "last_sync_status", - "fieldtype": "Select", - "label": "Last Sync Status", - "options": "Idle\nSuccess\nWarning\nError", - "read_only": 1 - }, - { - "fieldname": "last_sync_at", - "fieldtype": "Datetime", - "label": "Last Sync At", - "read_only": 1 - }, - { - "fieldname": "column_break_operational", - "fieldtype": "Column Break" - }, - { - "fieldname": "notes", - "fieldtype": "Small Text", - "label": "Notes", - "description": "Admin notes/audit comments" - } - ], - "index_web_pages_for_search": 1, - "issingle": 0, - "links": [], - "modified": "2024-01-01 00:00:00.000000", - "modified_by": "Administrator", - "module": "Shopify", - "name": "Shopify Account", - "naming_rule": "By fieldname", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "sort_field": "modified", - "sort_order": "DESC", - "states": [], - "title_field": "account_title", - "track_changes": 1 -} + "actions": [], + "autoname": "field:shop_domain", + "creation": "2024-01-01 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "enabled", + "account_title", + "column_break_basic", + "shop_domain", + "api_version", + "section_break_credentials", + "access_token", + "shared_secret", + "column_break_credentials", + "public_app_key", + "section_break_company", + "company", + "selling_price_list", + "column_break_company", + "cost_center", + "section_break_customer", + "default_customer", + "column_break_customer", + "customer_group", + "section_break_series", + "sales_order_series", + "sales_invoice_series", + "column_break_series", + "delivery_note_series", + "section_break_features", + "create_customers", + "create_missing_items", + "column_break_features1", + "sync_sales_invoice", + "sync_delivery_note", + "column_break_features2", + "allow_backdated_sync", + "close_orders_on_fulfillment", + "section_break_product_upload", + "upload_erpnext_items", + "update_shopify_item_on_update", + "column_break_product_upload", + "sync_new_item_as_active", + "upload_variants_as_items", + "section_break_inventory", + "update_erpnext_stock_levels_to_shopify", + "inventory_sync_frequency", + "column_break_inventory", + "last_inventory_sync", + "section_break_old_orders", + "sync_old_orders", + "old_orders_from", + "column_break_old_orders", + "old_orders_to", + "section_break_mappings", + "warehouse_mappings", + "tax_mappings", + "section_break_operational", + "last_sync_status", + "last_sync_at", + "column_break_operational", + "notes" + ], + "fields": [ + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, + { + "fieldname": "account_title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Account Title", + "description": "Friendly name for this Shopify store (e.g., 'Main KSA Store')" + }, + { + "fieldname": "column_break_basic", + "fieldtype": "Column Break" + }, + { + "fieldname": "shop_domain", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Shop Domain", + "reqd": 1, + "unique": 1, + "description": "Exact domain from Shopify (e.g., mystore.myshopify.com). Used to route webhooks." + }, + { + "default": "2023-10", + "fieldname": "api_version", + "fieldtype": "Data", + "label": "API Version", + "read_only": 1, + "description": "Shopify API version (auto-managed)" + }, + { + "collapsible": 0, + "fieldname": "section_break_credentials", + "fieldtype": "Section Break", + "label": "Authentication Details" + }, + { + "fieldname": "access_token", + "fieldtype": "Password", + "label": "Password / Access Token", + "mandatory_depends_on": "eval:doc.enabled" + }, + { + "fieldname": "shared_secret", + "fieldtype": "Data", + "label": "Shared secret / API Secret", + "mandatory_depends_on": "eval:doc.enabled" + }, + { + "fieldname": "column_break_credentials", + "fieldtype": "Column Break" + }, + { + "fieldname": "public_app_key", + "fieldtype": "Data", + "label": "Public App Key", + "description": "Optional: Only needed for specific app flows" + }, + { + "fieldname": "section_break_company", + "fieldtype": "Section Break", + "label": "Company & Defaults" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "reqd": 1, + "description": "ERPNext legal entity receiving the documents" + }, + { + "fieldname": "selling_price_list", + "fieldtype": "Link", + "label": "Selling Price List", + "options": "Price List", + "description": "Used when pricing/valuation is needed" + }, + { + "fieldname": "column_break_company", + "fieldtype": "Column Break" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center", + "description": "Default for SI/DN if created" + }, + { + "fieldname": "section_break_customer", + "fieldtype": "Section Break", + "label": "Customer Settings" + }, + { + "fieldname": "default_customer", + "fieldtype": "Link", + "label": "Default Customer", + "options": "Customer", + "description": "Optional fallback if customer creation is off or mapping fails" + }, + { + "fieldname": "column_break_customer", + "fieldtype": "Column Break" + }, + { + "fieldname": "customer_group", + "fieldtype": "Link", + "label": "Customer Group", + "options": "Customer Group", + "reqd": 1, + "description": "Customer Group will be set to selected group while syncing customers from Shopify" + }, + { + "fieldname": "section_break_series", + "fieldtype": "Section Break", + "label": "Document Series" + }, + { + "fieldname": "sales_order_series", + "fieldtype": "Select", + "label": "Sales Order Series", + "description": "e.g., SO-SHOP-. If empty, safe defaults will be used." + }, + { + "fieldname": "sales_invoice_series", + "fieldtype": "Select", + "label": "Sales Invoice Series", + "description": "e.g., SINV-SHOP-. If empty, safe defaults will be used." + }, + { + "fieldname": "column_break_series", + "fieldtype": "Column Break" + }, + { + "fieldname": "delivery_note_series", + "fieldtype": "Select", + "label": "Delivery Note Series", + "description": "e.g., DN-SHOP-. If empty, safe defaults will be used." + }, + { + "fieldname": "section_break_features", + "fieldtype": "Section Break", + "label": "Feature Toggles" + }, + { + "default": "1", + "fieldname": "create_customers", + "fieldtype": "Check", + "label": "Create Customers" + }, + { + "default": "0", + "fieldname": "create_missing_items", + "fieldtype": "Check", + "label": "Create Missing Items" + }, + { + "fieldname": "column_break_features1", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "sync_sales_invoice", + "fieldtype": "Check", + "label": "Sync Sales Invoice" + }, + { + "default": "0", + "fieldname": "sync_delivery_note", + "fieldtype": "Check", + "label": "Sync Delivery Note" + }, + { + "fieldname": "column_break_features2", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "allow_backdated_sync", + "fieldtype": "Check", + "label": "Allow Backdated Sync", + "description": "If true, backfill jobs allowed; otherwise block with a warning" + }, + { + "default": "0", + "fieldname": "close_orders_on_fulfillment", + "fieldtype": "Check", + "label": "Close Orders on Fulfillment", + "description": "Auto-close orders when delivery note/fulfillment is created" + }, + { + "fieldname": "section_break_product_upload", + "fieldtype": "Section Break", + "label": "Product Upload Settings" + }, + { + "default": "0", + "fieldname": "upload_erpnext_items", + "fieldtype": "Check", + "label": "Upload new ERPNext Items to Shopify", + "description": "Automatically upload new ERPNext items to this Shopify store" + }, + { + "default": "0", + "depends_on": "eval:doc.upload_erpnext_items", + "fieldname": "update_shopify_item_on_update", + "fieldtype": "Check", + "label": "Update Shopify Item after updating ERPNext item", + "description": "Sync changes from ERPNext items to Shopify products" + }, + { + "fieldname": "column_break_product_upload", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "sync_new_item_as_active", + "fieldtype": "Check", + "label": "Sync New Items as Active", + "description": "New products will be published as active on Shopify" + }, + { + "default": "0", + "description": "Caution: Only 3 attributes will be accepted by Shopify", + "fieldname": "upload_variants_as_items", + "fieldtype": "Check", + "label": "Upload ERPNext Variants as Shopify Items" + }, + { + "fieldname": "section_break_inventory", + "fieldtype": "Section Break", + "label": "Inventory Settings" + }, + { + "default": "0", + "fieldname": "update_erpnext_stock_levels_to_shopify", + "fieldtype": "Check", + "label": "Update ERPNext Stock Levels to Shopify", + "description": "Enable automatic inventory sync from ERPNext to Shopify" + }, + { + "default": "Hourly", + "fieldname": "inventory_sync_frequency", + "fieldtype": "Select", + "label": "Inventory Sync Frequency", + "options": "Every 15 minutes\nEvery 30 minutes\nHourly\nEvery 6 hours\nDaily", + "depends_on": "eval:doc.update_erpnext_stock_levels_to_shopify", + "description": "How often to sync inventory levels" + }, + { + "fieldname": "column_break_inventory", + "fieldtype": "Column Break" + }, + { + "fieldname": "last_inventory_sync", + "fieldtype": "Datetime", + "label": "Last Inventory Sync", + "read_only": 1, + "description": "Timestamp of last inventory sync" + }, + { + "fieldname": "section_break_old_orders", + "fieldtype": "Section Break", + "label": "Old Orders Sync" + }, + { + "default": "0", + "fieldname": "sync_old_orders", + "fieldtype": "Check", + "label": "Sync Old Orders", + "description": "Enable one-time sync of historical orders from Shopify" + }, + { + "fieldname": "old_orders_from", + "fieldtype": "Datetime", + "label": "Old Orders From", + "depends_on": "eval:doc.sync_old_orders", + "description": "Start date/time for historical order sync" + }, + { + "fieldname": "column_break_old_orders", + "fieldtype": "Column Break" + }, + { + "fieldname": "old_orders_to", + "fieldtype": "Datetime", + "label": "Old Orders To", + "depends_on": "eval:doc.sync_old_orders", + "description": "End date/time for historical order sync" + }, + { + "fieldname": "section_break_mappings", + "fieldtype": "Section Break", + "label": "Account-Specific Mappings" + }, + { + "fieldname": "warehouse_mappings", + "fieldtype": "Table", + "label": "Warehouse Mappings", + "options": "Shopify Warehouse Mapping" + }, + { + "fieldname": "tax_mappings", + "fieldtype": "Table", + "label": "Tax Mappings", + "options": "Shopify Tax Account" + }, + { + "fieldname": "section_break_operational", + "fieldtype": "Section Break", + "label": "Operational Status" + }, + { + "default": "Idle", + "fieldname": "last_sync_status", + "fieldtype": "Select", + "label": "Last Sync Status", + "options": "Idle\nSuccess\nWarning\nError", + "read_only": 1 + }, + { + "fieldname": "last_sync_at", + "fieldtype": "Datetime", + "label": "Last Sync At", + "read_only": 1 + }, + { + "fieldname": "column_break_operational", + "fieldtype": "Column Break" + }, + { + "fieldname": "notes", + "fieldtype": "Small Text", + "label": "Notes", + "description": "Admin notes/audit comments" + } + ], + "index_web_pages_for_search": 1, + "issingle": 0, + "links": [], + "modified": "2024-01-01 00:00:00.000000", + "modified_by": "Administrator", + "module": "Shopify", + "name": "Shopify Account", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "account_title", + "track_changes": 1 +} \ No newline at end of file From 2e3a656caf9a60882a5a7d090d5a3b881304d940 Mon Sep 17 00:00:00 2001 From: Ahmed Osama Date: Wed, 13 Aug 2025 22:08:24 +0000 Subject: [PATCH 08/30] feat(shopify): add default tax and shipping accounts and form improvements - Add default_sales_tax_account and default_shipping_charges_account fields - Set form intro description for better user guidance - Make shop_domain read-only after save to prevent accidental changes --- .../doctype/shopify_account/shopify_account.js | 8 ++++++++ .../doctype/shopify_account/shopify_account.json | 16 ++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js index e07b7a3db..37738f2b2 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js @@ -13,6 +13,9 @@ frappe.ui.form.on("Shopify Account", { }); }, }); + + // Set up form description + frm.set_intro(__("This record serves as the Shopify Settings for a single Shopify store. Create one record per store.")); }, fetch_shopify_locations: function (frm) { @@ -26,6 +29,11 @@ frappe.ui.form.on("Shopify Account", { }, refresh: function (frm) { + // Make shop_domain read-only after save + if (!frm.doc.__islocal) { + frm.set_df_property("shop_domain", "read_only", 1); + } + frm.add_custom_button(__("Import Products"), function () { if (frm.doc.enabled && frm.doc.shop_domain) { frappe.set_route("shopify-import-products", {"account": frm.doc.name}); diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json index ca6e4873c..44025931a 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json @@ -58,6 +58,8 @@ "section_break_mappings", "warehouse_mappings", "tax_mappings", + "default_sales_tax_account", + "default_shipping_charges_account", "section_break_operational", "last_sync_status", "last_sync_at", @@ -378,6 +380,20 @@ "label": "Tax Mappings", "options": "Shopify Tax Account" }, + { + "fieldname": "default_sales_tax_account", + "fieldtype": "Link", + "label": "Default Sales Tax Account", + "options": "Account", + "description": "When no sales tax mapping is found this tax account will be used as the default account. This is only applied for Sales Tax. Any shipping related charges still need to be mapped separately." + }, + { + "fieldname": "default_shipping_charges_account", + "fieldtype": "Link", + "label": "Default Shipping Charges Account", + "options": "Account", + "description": "When no shipping charge account mapping is found this account will be used as the default account." + }, { "fieldname": "section_break_operational", "fieldtype": "Section Break", From ddd632aa480e71eb4358df691536089edea7ac66 Mon Sep 17 00:00:00 2001 From: Ahmed Osama Date: Thu, 14 Aug 2025 11:11:55 +0000 Subject: [PATCH 09/30] feat(shopify): add account parameter to product sync methods Pass account parameter through sync chain to ensure proper context Update method name from import_all_products to queue_sync_all_products Use centralized ecommerce_item.is_synced check for consistency --- .../shopify_import_products/shopify_import_products.js | 4 ++-- .../shopify_import_products/shopify_import_products.py | 9 +++++---- ecommerce_integrations/shopify/product.py | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js index 15ed86d60..8a9c4f778 100644 --- a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js +++ b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js @@ -419,8 +419,8 @@ shopify.ProductImporter = class { frappe.msgprint(__("Sync already in progress")); } else { frappe.call({ - method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.import_all_products", - args: { account: this.selectedAccount }, // Add account parameter + method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.queue_sync_all_products", + args: { account: this.selectedAccount }, }); } diff --git a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py index d713a076b..7d6127096 100644 --- a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py +++ b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py @@ -28,7 +28,7 @@ def fetch_all_products(from_=None, account=None): products = [] for product in collection: d = product.to_dict() - d["synced"] = is_synced(product.id) + d["synced"] = ecommerce_item.is_synced(MODULE_NAME, str(product.id)) products.append(d) next_url = None @@ -97,6 +97,7 @@ def _resync_product(product, account=None): shopify_product.sync_product() return True +@frappe.whitelist() def queue_sync_all_products(*args, **kwargs): account = kwargs.get('account') start_time = process_time() @@ -115,12 +116,12 @@ def queue_sync_all_products(*args, **kwargs): try: publish(f"Syncing product {product.id}", br=False) frappe.db.savepoint(savepoint) - if is_synced(product.id): + if ecommerce_item.is_synced(MODULE_NAME, str(product.id)): publish(f"Product {product.id} already synced. Skipping...") continue - shopify_product = ShopifyProduct(product.id, account=account) # Pass account parameter - shopify_product.sync_product() + shopify_product = ShopifyProduct(product.id, account=account) + shopify_product.sync_product(account=account) publish(f"βœ… Synced Product {product.id}", synced=True) diff --git a/ecommerce_integrations/shopify/product.py b/ecommerce_integrations/shopify/product.py index 04c090b4d..1eb85ab40 100644 --- a/ecommerce_integrations/shopify/product.py +++ b/ecommerce_integrations/shopify/product.py @@ -58,7 +58,7 @@ def get_erpnext_item(self): ) @temp_shopify_session - def sync_product(self): + def sync_product(self, account=None): if not self.is_synced(): shopify_product = Product.find(self.product_id) product_dict = shopify_product.to_dict() From ec2f944aff02e671038bf9f6c3fd8f250a73d70a Mon Sep 17 00:00:00 2001 From: ahmad Date: Sun, 21 Sep 2025 10:50:07 +0000 Subject: [PATCH 10/30] [CS-61 Fix the logic by adding setting.is_enabled() condition] --- ecommerce_integrations/shopify/connection.py | 40 +++++++++----------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index e65d5a933..e05c2bff4 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -18,35 +18,31 @@ from ecommerce_integrations.shopify.utils import create_shopify_log -def temp_shopify_session(func=None, *, account=None): +def temp_shopify_session(f=None, *, account=None): """Enhanced decorator with account context support.""" - def decorator(f): - @functools.wraps(f) - def wrapper(*args, **kwargs): - # Extract account from kwargs if not provided - account_param = account or kwargs.get('account') - + @functools.wraps(f) + def wrapper(*args, **kwargs): + # no auth in testing + if frappe.flags.in_test: + return f(*args, **kwargs) + + # Extract account from kwargs if not provided + account_param = account or kwargs.get('account') + + from ecommerce_integrations.shopify.utils import resolve_account_context + setting = resolve_account_context(account_param) if account_param else frappe.get_doc(SETTING_DOCTYPE) + + if setting.is_enabled(): if account_param: - from ecommerce_integrations.shopify.utils import resolve_account_context - account_doc = resolve_account_context(account_param) - shopify_url = account_doc.get_shop_url() - password = account_doc.get_access_token() + shopify_url = setting.get_shop_url() + password = setting.get_access_token() else: - # Use standardized legacy fallback - from ecommerce_integrations.shopify.utils import resolve_account_context - setting = resolve_account_context(None) # Gets legacy setting shopify_url = setting.shopify_url password = setting.get_password("password") - with Session.temp(shopify_url, API_VERSION, password): return f(*args, **kwargs) - - return wrapper - - if func is None: - return decorator - else: - return decorator(func) + + return wrapper def register_webhooks(shopify_url: str, password: str) -> list[Webhook]: From 21fa1c37b089dfad7b7b839e4da33b6f06535909 Mon Sep 17 00:00:00 2001 From: ahmad Date: Tue, 21 Oct 2025 12:22:18 +0000 Subject: [PATCH 11/30] sync shopify locations with erpnext warehouses --- ecommerce_integrations/shopify/connection.py | 107 +-- .../shopify_account/shopify_account.js | 2 +- .../shopify_account/shopify_account.json | 871 +++++++++--------- .../shopify_account/shopify_account.py | 432 +++++---- ecommerce_integrations/shopify/utils.py | 77 +- 5 files changed, 696 insertions(+), 793 deletions(-) diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index e05c2bff4..cfca026a3 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -12,37 +12,35 @@ from ecommerce_integrations.shopify.constants import ( API_VERSION, EVENT_MAPPER, - SETTING_DOCTYPE, + ACCOUNT_DOCTYPE, WEBHOOK_EVENTS, ) from ecommerce_integrations.shopify.utils import create_shopify_log -def temp_shopify_session(f=None, *, account=None): - """Enhanced decorator with account context support.""" - @functools.wraps(f) - def wrapper(*args, **kwargs): - # no auth in testing - if frappe.flags.in_test: - return f(*args, **kwargs) - - # Extract account from kwargs if not provided - account_param = account or kwargs.get('account') - - from ecommerce_integrations.shopify.utils import resolve_account_context - setting = resolve_account_context(account_param) if account_param else frappe.get_doc(SETTING_DOCTYPE) - - if setting.is_enabled(): - if account_param: - shopify_url = setting.get_shop_url() - password = setting.get_access_token() - else: - shopify_url = setting.shopify_url - password = setting.get_password("password") - with Session.temp(shopify_url, API_VERSION, password): - return f(*args, **kwargs) - - return wrapper +def temp_shopify_session(shopify_account): + """Decorator for functions that need a temporary Shopify session.""" + + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + # no auth in testing + if frappe.flags.in_test: + return func(*args, **kwargs) + + # If a callable is passed, call it with self to get the account + account = shopify_account(args[0]) if callable(shopify_account) else shopify_account + + setting = frappe.get_doc(ACCOUNT_DOCTYPE, account) + if setting.is_enabled(): + auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password")) + with Session.temp(*auth_details): + print("auth_details", auth_details) + return func(*args, **kwargs) + + return wrapper + + return decorator def register_webhooks(shopify_url: str, password: str) -> list[Webhook]: @@ -83,6 +81,8 @@ def get_current_domain_name() -> str: If developer_mode is enabled and localtunnel_url is set in site config then domain is set to localtunnel_url. """ + # TODO: Remove + return "70dc6bafa36a.ngrok-free.app" if frappe.conf.developer_mode and frappe.conf.localtunnel_url: return frappe.conf.localtunnel_url else: @@ -100,69 +100,38 @@ def get_callback_url() -> str: @frappe.whitelist(allow_guest=True) -def store_request_data() -> None: +def store_request_data(**kwargs) -> None: if frappe.request: hmac_header = frappe.get_request_header("X-Shopify-Hmac-Sha256") - shop_domain = frappe.get_request_header("X-Shopify-Shop-Domain") - - # Resolve account by shop domain - account = _get_account_by_domain(shop_domain) - if not account: - create_shopify_log( - status="Error", - request_data=frappe.request.data, - exception=f"No enabled Shopify Account found for domain: {shop_domain}" - ) - frappe.throw(_("No enabled Shopify Account found for domain: {0}").format(shop_domain)) - _validate_request(frappe.request, hmac_header, account) + # _validate_request(frappe.request, hmac_header, kwargs.get("shopify_account")) data = json.loads(frappe.request.data) event = frappe.request.headers.get("X-Shopify-Topic") - process_request(data, event, account) + process_request(data, event) -def process_request(data, event, account): - # create log with account context - log = create_shopify_log( - method=EVENT_MAPPER[event], - request_data=data, - account=account - ) +def process_request(data, event): + # create log + log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data) - # enqueue background job with account context + # enqueue backround job frappe.enqueue( method=EVENT_MAPPER[event], queue="short", timeout=300, is_async=True, - **{"payload": data, "request_id": log.name, "account": account.name}, + **{"payload": data, "request_id": log.name}, ) -def _validate_request(req, hmac_header, account): - """Validate webhook request using account-specific shared secret.""" - secret_key = account.get_shared_secret() +def _validate_request(req, hmac_header, shopify_account): + settings = frappe.get_doc(ACCOUNT_DOCTYPE, shopify_account) + secret_key = settings.shared_secret sig = base64.b64encode(hmac.new(secret_key.encode("utf8"), req.data, hashlib.sha256).digest()) if sig != bytes(hmac_header.encode()): - create_shopify_log( - status="Error", - request_data=req.data, - account=account, - exception="Invalid HMAC signature" - ) + create_shopify_log(status="Error", request_data=req.data) frappe.throw(_("Unverified Webhook Data")) - - -def _get_account_by_domain(shop_domain): - """Get enabled Shopify Account by shop domain.""" - if not shop_domain: - return None - - try: - return frappe.get_doc("Shopify Account", {"shop_domain": shop_domain, "enabled": 1}) - except frappe.DoesNotExistError: - return None diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js index 37738f2b2..fc3bb94ec 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js @@ -21,7 +21,7 @@ frappe.ui.form.on("Shopify Account", { fetch_shopify_locations: function (frm) { frappe.call({ doc: frm.doc, - method: "fetch_shopify_locations", + method: "update_location_table", callback: (r) => { if (!r.exc) refresh_field("warehouse_mappings"); }, diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json index 44025931a..1103db19a 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json @@ -1,453 +1,420 @@ { - "actions": [], - "autoname": "field:shop_domain", - "creation": "2024-01-01 00:00:00.000000", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "enabled", - "account_title", - "column_break_basic", - "shop_domain", - "api_version", - "section_break_credentials", - "access_token", - "shared_secret", - "column_break_credentials", - "public_app_key", - "section_break_company", - "company", - "selling_price_list", - "column_break_company", - "cost_center", - "section_break_customer", - "default_customer", - "column_break_customer", - "customer_group", - "section_break_series", - "sales_order_series", - "sales_invoice_series", - "column_break_series", - "delivery_note_series", - "section_break_features", - "create_customers", - "create_missing_items", - "column_break_features1", - "sync_sales_invoice", - "sync_delivery_note", - "column_break_features2", - "allow_backdated_sync", - "close_orders_on_fulfillment", - "section_break_product_upload", - "upload_erpnext_items", - "update_shopify_item_on_update", - "column_break_product_upload", - "sync_new_item_as_active", - "upload_variants_as_items", - "section_break_inventory", - "update_erpnext_stock_levels_to_shopify", - "inventory_sync_frequency", - "column_break_inventory", - "last_inventory_sync", - "section_break_old_orders", - "sync_old_orders", - "old_orders_from", - "column_break_old_orders", - "old_orders_to", - "section_break_mappings", - "warehouse_mappings", - "tax_mappings", - "default_sales_tax_account", - "default_shipping_charges_account", - "section_break_operational", - "last_sync_status", - "last_sync_at", - "column_break_operational", - "notes" - ], - "fields": [ - { - "default": "0", - "fieldname": "enabled", - "fieldtype": "Check", - "label": "Enabled" - }, - { - "fieldname": "account_title", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Account Title", - "description": "Friendly name for this Shopify store (e.g., 'Main KSA Store')" - }, - { - "fieldname": "column_break_basic", - "fieldtype": "Column Break" - }, - { - "fieldname": "shop_domain", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Shop Domain", - "reqd": 1, - "unique": 1, - "description": "Exact domain from Shopify (e.g., mystore.myshopify.com). Used to route webhooks." - }, - { - "default": "2023-10", - "fieldname": "api_version", - "fieldtype": "Data", - "label": "API Version", - "read_only": 1, - "description": "Shopify API version (auto-managed)" - }, - { - "collapsible": 0, - "fieldname": "section_break_credentials", - "fieldtype": "Section Break", - "label": "Authentication Details" - }, - { - "fieldname": "access_token", - "fieldtype": "Password", - "label": "Password / Access Token", - "mandatory_depends_on": "eval:doc.enabled" - }, - { - "fieldname": "shared_secret", - "fieldtype": "Data", - "label": "Shared secret / API Secret", - "mandatory_depends_on": "eval:doc.enabled" - }, - { - "fieldname": "column_break_credentials", - "fieldtype": "Column Break" - }, - { - "fieldname": "public_app_key", - "fieldtype": "Data", - "label": "Public App Key", - "description": "Optional: Only needed for specific app flows" - }, - { - "fieldname": "section_break_company", - "fieldtype": "Section Break", - "label": "Company & Defaults" - }, - { - "fieldname": "company", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Company", - "options": "Company", - "reqd": 1, - "description": "ERPNext legal entity receiving the documents" - }, - { - "fieldname": "selling_price_list", - "fieldtype": "Link", - "label": "Selling Price List", - "options": "Price List", - "description": "Used when pricing/valuation is needed" - }, - { - "fieldname": "column_break_company", - "fieldtype": "Column Break" - }, - { - "fieldname": "cost_center", - "fieldtype": "Link", - "label": "Cost Center", - "options": "Cost Center", - "description": "Default for SI/DN if created" - }, - { - "fieldname": "section_break_customer", - "fieldtype": "Section Break", - "label": "Customer Settings" - }, - { - "fieldname": "default_customer", - "fieldtype": "Link", - "label": "Default Customer", - "options": "Customer", - "description": "Optional fallback if customer creation is off or mapping fails" - }, - { - "fieldname": "column_break_customer", - "fieldtype": "Column Break" - }, - { - "fieldname": "customer_group", - "fieldtype": "Link", - "label": "Customer Group", - "options": "Customer Group", - "reqd": 1, - "description": "Customer Group will be set to selected group while syncing customers from Shopify" - }, - { - "fieldname": "section_break_series", - "fieldtype": "Section Break", - "label": "Document Series" - }, - { - "fieldname": "sales_order_series", - "fieldtype": "Select", - "label": "Sales Order Series", - "description": "e.g., SO-SHOP-. If empty, safe defaults will be used." - }, - { - "fieldname": "sales_invoice_series", - "fieldtype": "Select", - "label": "Sales Invoice Series", - "description": "e.g., SINV-SHOP-. If empty, safe defaults will be used." - }, - { - "fieldname": "column_break_series", - "fieldtype": "Column Break" - }, - { - "fieldname": "delivery_note_series", - "fieldtype": "Select", - "label": "Delivery Note Series", - "description": "e.g., DN-SHOP-. If empty, safe defaults will be used." - }, - { - "fieldname": "section_break_features", - "fieldtype": "Section Break", - "label": "Feature Toggles" - }, - { - "default": "1", - "fieldname": "create_customers", - "fieldtype": "Check", - "label": "Create Customers" - }, - { - "default": "0", - "fieldname": "create_missing_items", - "fieldtype": "Check", - "label": "Create Missing Items" - }, - { - "fieldname": "column_break_features1", - "fieldtype": "Column Break" - }, - { - "default": "0", - "fieldname": "sync_sales_invoice", - "fieldtype": "Check", - "label": "Sync Sales Invoice" - }, - { - "default": "0", - "fieldname": "sync_delivery_note", - "fieldtype": "Check", - "label": "Sync Delivery Note" - }, - { - "fieldname": "column_break_features2", - "fieldtype": "Column Break" - }, - { - "default": "0", - "fieldname": "allow_backdated_sync", - "fieldtype": "Check", - "label": "Allow Backdated Sync", - "description": "If true, backfill jobs allowed; otherwise block with a warning" - }, - { - "default": "0", - "fieldname": "close_orders_on_fulfillment", - "fieldtype": "Check", - "label": "Close Orders on Fulfillment", - "description": "Auto-close orders when delivery note/fulfillment is created" - }, - { - "fieldname": "section_break_product_upload", - "fieldtype": "Section Break", - "label": "Product Upload Settings" - }, - { - "default": "0", - "fieldname": "upload_erpnext_items", - "fieldtype": "Check", - "label": "Upload new ERPNext Items to Shopify", - "description": "Automatically upload new ERPNext items to this Shopify store" - }, - { - "default": "0", - "depends_on": "eval:doc.upload_erpnext_items", - "fieldname": "update_shopify_item_on_update", - "fieldtype": "Check", - "label": "Update Shopify Item after updating ERPNext item", - "description": "Sync changes from ERPNext items to Shopify products" - }, - { - "fieldname": "column_break_product_upload", - "fieldtype": "Column Break" - }, - { - "default": "0", - "fieldname": "sync_new_item_as_active", - "fieldtype": "Check", - "label": "Sync New Items as Active", - "description": "New products will be published as active on Shopify" - }, - { - "default": "0", - "description": "Caution: Only 3 attributes will be accepted by Shopify", - "fieldname": "upload_variants_as_items", - "fieldtype": "Check", - "label": "Upload ERPNext Variants as Shopify Items" - }, - { - "fieldname": "section_break_inventory", - "fieldtype": "Section Break", - "label": "Inventory Settings" - }, - { - "default": "0", - "fieldname": "update_erpnext_stock_levels_to_shopify", - "fieldtype": "Check", - "label": "Update ERPNext Stock Levels to Shopify", - "description": "Enable automatic inventory sync from ERPNext to Shopify" - }, - { - "default": "Hourly", - "fieldname": "inventory_sync_frequency", - "fieldtype": "Select", - "label": "Inventory Sync Frequency", - "options": "Every 15 minutes\nEvery 30 minutes\nHourly\nEvery 6 hours\nDaily", - "depends_on": "eval:doc.update_erpnext_stock_levels_to_shopify", - "description": "How often to sync inventory levels" - }, - { - "fieldname": "column_break_inventory", - "fieldtype": "Column Break" - }, - { - "fieldname": "last_inventory_sync", - "fieldtype": "Datetime", - "label": "Last Inventory Sync", - "read_only": 1, - "description": "Timestamp of last inventory sync" - }, - { - "fieldname": "section_break_old_orders", - "fieldtype": "Section Break", - "label": "Old Orders Sync" - }, - { - "default": "0", - "fieldname": "sync_old_orders", - "fieldtype": "Check", - "label": "Sync Old Orders", - "description": "Enable one-time sync of historical orders from Shopify" - }, - { - "fieldname": "old_orders_from", - "fieldtype": "Datetime", - "label": "Old Orders From", - "depends_on": "eval:doc.sync_old_orders", - "description": "Start date/time for historical order sync" - }, - { - "fieldname": "column_break_old_orders", - "fieldtype": "Column Break" - }, - { - "fieldname": "old_orders_to", - "fieldtype": "Datetime", - "label": "Old Orders To", - "depends_on": "eval:doc.sync_old_orders", - "description": "End date/time for historical order sync" - }, - { - "fieldname": "section_break_mappings", - "fieldtype": "Section Break", - "label": "Account-Specific Mappings" - }, - { - "fieldname": "warehouse_mappings", - "fieldtype": "Table", - "label": "Warehouse Mappings", - "options": "Shopify Warehouse Mapping" - }, - { - "fieldname": "tax_mappings", - "fieldtype": "Table", - "label": "Tax Mappings", - "options": "Shopify Tax Account" - }, - { - "fieldname": "default_sales_tax_account", - "fieldtype": "Link", - "label": "Default Sales Tax Account", - "options": "Account", - "description": "When no sales tax mapping is found this tax account will be used as the default account. This is only applied for Sales Tax. Any shipping related charges still need to be mapped separately." - }, - { - "fieldname": "default_shipping_charges_account", - "fieldtype": "Link", - "label": "Default Shipping Charges Account", - "options": "Account", - "description": "When no shipping charge account mapping is found this account will be used as the default account." - }, - { - "fieldname": "section_break_operational", - "fieldtype": "Section Break", - "label": "Operational Status" - }, - { - "default": "Idle", - "fieldname": "last_sync_status", - "fieldtype": "Select", - "label": "Last Sync Status", - "options": "Idle\nSuccess\nWarning\nError", - "read_only": 1 - }, - { - "fieldname": "last_sync_at", - "fieldtype": "Datetime", - "label": "Last Sync At", - "read_only": 1 - }, - { - "fieldname": "column_break_operational", - "fieldtype": "Column Break" - }, - { - "fieldname": "notes", - "fieldtype": "Small Text", - "label": "Notes", - "description": "Admin notes/audit comments" - } - ], - "index_web_pages_for_search": 1, - "issingle": 0, - "links": [], - "modified": "2024-01-01 00:00:00.000000", - "modified_by": "Administrator", - "module": "Shopify", - "name": "Shopify Account", - "naming_rule": "By fieldname", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "sort_field": "modified", - "sort_order": "DESC", - "states": [], - "title_field": "account_title", - "track_changes": 1 -} \ No newline at end of file + "actions": [], + "autoname": "field:shopify_url", + "creation": "2024-01-01 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "enable_shopify", + "column_break_4", + "section_break_2", + "shopify_url", + "column_break_3", + "password", + "shared_secret", + "section_break_4", + "webhooks", + "customer_settings_section", + "default_customer", + "column_break_14", + "customer_group", + "company_dependent_settings_section", + "company", + "cash_bank_account", + "column_break_19", + "cost_center", + "section_break_25", + "sales_order_series", + "delivery_note_series", + "sales_invoice_series", + "shipping_item", + "column_break_27", + "sync_delivery_note", + "sync_sales_invoice", + "add_shipping_as_item", + "consolidate_taxes", + "section_break_22", + "html_16", + "taxes", + "section_break_zeoy", + "default_sales_tax_account", + "column_break_qibo", + "default_shipping_charges_account", + "erpnext_to_shopify_sync_section", + "upload_erpnext_items", + "update_shopify_item_on_update", + "column_break_34", + "sync_new_item_as_active", + "upload_variants_as_items", + "inventory_sync_section", + "warehouse", + "update_erpnext_stock_levels_to_shopify", + "inventory_sync_frequency", + "fetch_shopify_locations", + "shopify_warehouse_mapping", + "sync_old_orders_section", + "sync_old_orders", + "column_break_45", + "old_orders_from", + "old_orders_to", + "is_old_data_migrated", + "last_inventory_sync" + ], + "fields": [ + { + "default": "0", + "fieldname": "enable_shopify", + "fieldtype": "Check", + "label": "Enable Shopify" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_2", + "fieldtype": "Section Break", + "label": "Authentication Details" + }, + { + "description": "eg: frappe.myshopify.com", + "fieldname": "shopify_url", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Shop URL", + "mandatory_depends_on": "eval:doc.enable_shopify", + "unique": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "password", + "fieldtype": "Password", + "label": "Password / Access Token", + "mandatory_depends_on": "eval:doc.enable_shopify" + }, + { + "fieldname": "shared_secret", + "fieldtype": "Data", + "label": "Shared secret / API Secret", + "mandatory_depends_on": "eval:doc.enable_shopify" + }, + { + "collapsible": 1, + "fieldname": "section_break_4", + "fieldtype": "Section Break", + "label": "Webhooks Details" + }, + { + "fieldname": "webhooks", + "fieldtype": "Table", + "label": "Webhooks", + "options": "Shopify Webhooks", + "read_only": 1 + }, + { + "fieldname": "customer_settings_section", + "fieldtype": "Section Break", + "label": "Customer Settings" + }, + { + "description": "Customer Group will set to selected group while syncing customers from Shopify", + "fieldname": "customer_group", + "fieldtype": "Link", + "label": "Customer Group", + "mandatory_depends_on": "eval:doc.enable_shopify", + "options": "Customer Group" + }, + { + "description": "If individual warehouse are not mapped, the default warehouse is considered for transactions.", + "fieldname": "warehouse", + "fieldtype": "Link", + "label": "Default warehouse", + "mandatory_depends_on": "eval:doc.enable_shopify", + "options": "Warehouse" + }, + { + "description": "If Shopify does not have a customer in the order, then while syncing the orders, the system will consider the default customer for the order", + "fieldname": "default_customer", + "fieldtype": "Link", + "label": "Default Customer", + "options": "Customer" + }, + { + "fieldname": "column_break_14", + "fieldtype": "Column Break" + }, + { + "fieldname": "company_dependent_settings_section", + "fieldtype": "Section Break", + "label": "Company Dependent settings" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fieldname": "cash_bank_account", + "fieldtype": "Link", + "label": "Cash/Bank Account", + "mandatory_depends_on": "eval:doc.enable_shopify", + "options": "Account" + }, + { + "fieldname": "column_break_19", + "fieldtype": "Column Break" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "mandatory_depends_on": "eval:doc.enable_shopify", + "options": "Cost Center" + }, + { + "fieldname": "section_break_25", + "fieldtype": "Section Break", + "label": "Order Sync Settings" + }, + { + "fieldname": "sales_order_series", + "fieldtype": "Select", + "label": "Sales Order Series", + "mandatory_depends_on": "eval:doc.enable_shopify" + }, + { + "fieldname": "column_break_27", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "sync_delivery_note", + "fieldtype": "Check", + "label": "Import Delivery Notes from Shopify on Shipment" + }, + { + "depends_on": "eval:doc.sync_delivery_note==1", + "fieldname": "delivery_note_series", + "fieldtype": "Select", + "label": "Delivery Note Series", + "mandatory_depends_on": "eval:doc.sync_delivery_note" + }, + { + "default": "0", + "fieldname": "sync_sales_invoice", + "fieldtype": "Check", + "label": "Import Sales Invoice from Shopify if Payment is marked" + }, + { + "depends_on": "eval:doc.sync_sales_invoice==1", + "fieldname": "sales_invoice_series", + "fieldtype": "Select", + "label": "Sales Invoice Series", + "mandatory_depends_on": "eval:doc.sync_sales_invoice" + }, + { + "fieldname": "section_break_22", + "fieldtype": "Section Break" + }, + { + "fieldname": "html_16", + "fieldtype": "HTML", + "options": "Map Shopify Taxes / Shipping Charges to ERPNext Account" + }, + { + "fieldname": "taxes", + "fieldtype": "Table", + "label": "Shopify Tax Account", + "mandatory_depends_on": "eval:doc.enable_shopify", + "options": "Shopify Tax Account" + }, + { + "fieldname": "erpnext_to_shopify_sync_section", + "fieldtype": "Section Break", + "label": "ERPNext to Shopify Sync" + }, + { + "default": "0", + "fieldname": "upload_erpnext_items", + "fieldtype": "Check", + "label": "Upload new ERPNext Items to Shopify" + }, + { + "default": "0", + "depends_on": "eval:doc.upload_erpnext_items", + "fieldname": "update_shopify_item_on_update", + "fieldtype": "Check", + "label": "Update Shopify Item after updating ERPNext item" + }, + { + "depends_on": "eval:doc.update_erpnext_stock_levels_to_shopify", + "fieldname": "shopify_warehouse_mapping", + "fieldtype": "Table", + "label": "Shopify Warehouse Mapping", + "mandatory_depends_on": "eval:doc.update_erpnext_stock_levels_to_shopify", + "options": "Shopify Warehouse Mapping" + }, + { + "depends_on": "eval:doc.update_erpnext_stock_levels_to_shopify", + "fieldname": "fetch_shopify_locations", + "fieldtype": "Button", + "label": "Fetch Shopify Locations" + }, + { + "fieldname": "inventory_sync_section", + "fieldtype": "Section Break", + "label": "Inventory Sync" + }, + { + "default": "0", + "fieldname": "update_erpnext_stock_levels_to_shopify", + "fieldtype": "Check", + "label": "Update ERPNext stock levels to Shopify" + }, + { + "default": "0", + "fieldname": "is_old_data_migrated", + "fieldtype": "Check", + "hidden": 1, + "label": "Is old data migrated" + }, + { + "default": "0", + "fieldname": "sync_old_orders", + "fieldtype": "Check", + "label": "Sync Old Orders" + }, + { + "fieldname": "sync_old_orders_section", + "fieldtype": "Section Break", + "label": "Sync Old Orders" + }, + { + "depends_on": "eval:doc.sync_old_orders", + "fieldname": "old_orders_from", + "fieldtype": "Datetime", + "label": "From", + "mandatory_depends_on": "eval:doc.sync_old_orders" + }, + { + "depends_on": "eval:doc.sync_old_orders", + "fieldname": "old_orders_to", + "fieldtype": "Datetime", + "label": "To", + "mandatory_depends_on": "eval:doc.sync_old_orders" + }, + { + "fieldname": "column_break_45", + "fieldtype": "Column Break" + }, + { + "default": "60", + "depends_on": "eval:doc.update_erpnext_stock_levels_to_shopify", + "fieldname": "inventory_sync_frequency", + "fieldtype": "Select", + "label": "Inventory Sync Frequency (In Minutes)", + "mandatory_depends_on": "eval:doc.update_erpnext_stock_levels_to_shopify", + "options": "5\n10\n15\n30\n60" + }, + { + "fieldname": "last_inventory_sync", + "fieldtype": "Datetime", + "hidden": 1, + "label": "Last Inventory Sync", + "read_only": 1 + }, + { + "fieldname": "column_break_34", + "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "Caution: Only 3 attributes will be accepted by Shopify", + "fieldname": "upload_variants_as_items", + "fieldtype": "Check", + "label": "Upload ERPNext Variants as Shopify Items" + }, + { + "default": "0", + "fieldname": "sync_new_item_as_active", + "fieldtype": "Check", + "label": "Sync New Items as Active" + }, + { + "default": "0", + "fieldname": "add_shipping_as_item", + "fieldtype": "Check", + "label": "Add Shipping Charge as an Item in Order" + }, + { + "depends_on": "add_shipping_as_item", + "fieldname": "shipping_item", + "fieldtype": "Link", + "label": "Shipping Item", + "mandatory_depends_on": "add_shipping_as_item", + "options": "Item" + }, + { + "default": "0", + "description": "By default, tax rows are added per item, checking this will consolidate them.", + "fieldname": "consolidate_taxes", + "fieldtype": "Check", + "label": "Consolidate Taxes in Order" + }, + { + "description": "When no sales tax mapping is found this tax account will be used as the default account. This is only applied for Sales Tax. Any shipping related charges still need to be mapped separately.", + "fieldname": "default_sales_tax_account", + "fieldtype": "Link", + "label": "Default Sales Tax Account", + "options": "Account" + }, + { + "fieldname": "section_break_zeoy", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "fieldname": "column_break_qibo", + "fieldtype": "Column Break" + }, + { + "description": "When no shipping charge account mapping is found this account will be used as the default account.", + "fieldname": "default_shipping_charges_account", + "fieldtype": "Link", + "label": "Default Shipping Charges Account", + "options": "Account" + } + ], + "index_web_pages_for_search": 1, + "issingle": 0, + "links": [], + "modified": "2024-01-01 00:00:00.000000", + "modified_by": "Administrator", + "module": "shopify", + "name": "Shopify Account", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py index cfeac7502..7dd8a7dc4 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py @@ -3,211 +3,247 @@ import frappe from frappe import _ -from frappe.model.document import Document -from frappe.utils import validate_email_address +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +from frappe.utils import get_datetime from shopify.collection import PaginatedIterator from shopify.resources import Location +from ecommerce_integrations.controllers.setting import ( + ERPNextWarehouse, + IntegrationWarehouse, + SettingController, +) from ecommerce_integrations.shopify import connection +from ecommerce_integrations.shopify.constants import ( + ADDRESS_ID_FIELD, + CUSTOMER_ID_FIELD, + FULLFILLMENT_ID_FIELD, + ITEM_SELLING_RATE_FIELD, + ORDER_ID_FIELD, + ORDER_ITEM_DISCOUNT_FIELD, + ORDER_NUMBER_FIELD, + ORDER_STATUS_FIELD, + SUPPLIER_ID_FIELD, +) +from ecommerce_integrations.shopify.utils import ( + ensure_old_connector_is_disabled, + migrate_from_old_connector, +) + + +class ShopifyAccount(SettingController): + def is_enabled(self) -> bool: + return bool(self.enable_shopify) - -class ShopifyAccount(Document): def validate(self): - """Validate Shopify Account settings before saving.""" - self.validate_shop_domain() - self.validate_required_when_enabled() - self.validate_company_consistency() - self.validate_warehouse_mappings() - self.validate_tax_mappings() - self.validate_feature_dependencies() - - def validate_shop_domain(self): - """Validate shop domain format.""" - if self.shop_domain: - # Remove https:// or http:// if present - self.shop_domain = self.shop_domain.replace("https://", "").replace("http://", "") - - # Ensure it ends with .myshopify.com - if not self.shop_domain.endswith(".myshopify.com"): - frappe.throw(_("Shop Domain must be a valid Shopify domain ending with '.myshopify.com'")) - - def validate_required_when_enabled(self): - """Validate required fields when account is enabled.""" - if self.enabled: - required_fields = ["shop_domain", "access_token", "shared_secret", "company"] - for field in required_fields: - if not self.get(field): - frappe.throw(_("{0} is required when account is enabled").format(self.meta.get_label(field))) - - def validate_company_consistency(self): - """Validate that all company-related fields belong to the same company.""" - if not self.company: - return - - # Validate cost center belongs to company - if self.cost_center: - cost_center_company = frappe.db.get_value("Cost Center", self.cost_center, "company") - if cost_center_company != self.company: - frappe.throw(_("Cost Center {0} does not belong to Company {1}").format( - self.cost_center, self.company)) - - # Validate default customer belongs to company - if self.default_customer: - customer_company = frappe.db.get_value("Customer", self.default_customer, "company") - # Customer might not have a company set, so only validate if it's set - if customer_company and customer_company != self.company: - frappe.throw(_("Default Customer {0} does not belong to Company {1}").format( - self.default_customer, self.company)) - - def validate_warehouse_mappings(self): - """Validate warehouse mappings.""" - if not self.warehouse_mappings: - return - - seen_locations = set() - default_count = 0 - - for mapping in self.warehouse_mappings: - # Check for duplicate Shopify location IDs - if mapping.shopify_location_id in seen_locations: - frappe.throw(_("Duplicate Shopify Location ID: {0}").format(mapping.shopify_location_id)) - seen_locations.add(mapping.shopify_location_id) - - # Validate warehouse belongs to the same company - if mapping.erpnext_warehouse: - warehouse_company = frappe.db.get_value("Warehouse", mapping.erpnext_warehouse, "company") - if warehouse_company != self.company: - frappe.throw(_("Warehouse {0} does not belong to Company {1}").format( - mapping.erpnext_warehouse, self.company)) - - # Count default warehouses (if we add is_default field later) - if hasattr(mapping, 'is_default') and mapping.is_default: - default_count += 1 - - # Ensure only one default warehouse (if we add is_default field later) - if default_count > 1: - frappe.throw(_("Only one warehouse can be marked as default")) - - def validate_tax_mappings(self): - """Validate tax mappings.""" - if not self.tax_mappings: - return - - seen_tax_keys = set() - - for mapping in self.tax_mappings: - # Check for duplicate tax keys - if mapping.shopify_tax in seen_tax_keys: - frappe.throw(_("Duplicate Shopify Tax/Shipping Title: {0}").format(mapping.shopify_tax)) - seen_tax_keys.add(mapping.shopify_tax) - - # Validate tax account belongs to the same company - if mapping.tax_account: - account_company = frappe.db.get_value("Account", mapping.tax_account, "company") - if account_company != self.company: - frappe.throw(_("Tax Account {0} does not belong to Company {1}").format( - mapping.tax_account, self.company)) - - def validate_feature_dependencies(self): - """Validate feature toggle dependencies.""" - warnings = [] - - # Warn if sync features are enabled but cost center is missing - if (self.sync_sales_invoice or self.sync_delivery_note) and not self.cost_center: - warnings.append(_("Cost Center is recommended when Sales Invoice or Delivery Note sync is enabled")) - - # Warn if customer creation is disabled but no default customer is set - if not self.create_customers and not self.default_customer: - warnings.append(_("Default Customer is required when automatic customer creation is disabled")) - - # Show warnings as messages (non-blocking) - for warning in warnings: - frappe.msgprint(warning, indicator="orange", alert=True) + # TODO: uncomment + # ensure_old_connector_is_disabled() - def is_enabled(self) -> bool: - """Check if this Shopify account is enabled.""" - return bool(self.enabled) - - def get_shop_url(self) -> str: - """Get the full shop URL with https prefix.""" - if self.shop_domain: - return f"https://{self.shop_domain}" - return "" - - def get_access_token(self) -> str: - """Get the decrypted access token.""" - return self.get_password("access_token") - - def get_shared_secret(self) -> str: - """Get the decrypted shared secret.""" - return self.get_password("shared_secret") - - @staticmethod - def get_account_by_domain(shop_domain: str): - """Get Shopify Account by shop domain.""" - return frappe.get_doc("Shopify Account", {"shop_domain": shop_domain, "enabled": 1}) - - @staticmethod - def get_enabled_accounts(): - """Get all enabled Shopify accounts.""" - return frappe.get_all("Shopify Account", - filters={"enabled": 1}, - fields=["name", "account_title", "shop_domain", "company"]) - - def update_sync_status(self, status: str, sync_time=None): - """Update the last sync status and time.""" - if status not in ["Idle", "Success", "Warning", "Error"]: - frappe.throw(_("Invalid sync status: {0}").format(status)) - - self.last_sync_status = status - if sync_time: - self.last_sync_at = sync_time - else: - self.last_sync_at = frappe.utils.now() - - # Save without calling validate again to avoid recursion - self.db_set("last_sync_status", self.last_sync_status) - self.db_set("last_sync_at", self.last_sync_at) + if self.shopify_url: + self.shopify_url = self.shopify_url.replace("https://", "") + self._handle_webhooks() + self._validate_warehouse_links() + self._initalize_default_values() + + if self.is_enabled(): + setup_custom_fields() + + def on_update(self): + if self.is_enabled() and not self.is_old_data_migrated: + migrate_from_old_connector() + + def _handle_webhooks(self): + 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.") + "
" + 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}) + + elif not self.is_enabled(): + connection.unregister_webhooks(self.shopify_url, self.get_password("password")) + + self.webhooks = list() # remove all webhooks + + def _validate_warehouse_links(self): + for wh_map in self.shopify_warehouse_mapping: + if not wh_map.erpnext_warehouse: + frappe.throw(_("ERPNext warehouse required in warehouse map table.")) + + def _initalize_default_values(self): + if not self.last_inventory_sync: + self.last_inventory_sync = get_datetime("1970-01-01") @frappe.whitelist() - def fetch_shopify_locations(self): - """Fetch locations from Shopify and add them to warehouse mapping table.""" - if not self.enabled: - frappe.throw(_("Account must be enabled to fetch locations")) - - if not self.get_access_token(): - frappe.throw(_("Access token is required to fetch locations")) - - # Clear existing mappings - self.warehouse_mappings = [] - - # Pass account parameter explicitly - self._fetch_locations_with_session(account=self) - - frappe.msgprint(_("Successfully fetched {0} locations from Shopify").format(len(self.warehouse_mappings))) - - @connection.temp_shopify_session - def _fetch_locations_with_session(self, account=None): - """Internal method to fetch locations with session context.""" - try: - for locations in PaginatedIterator(Location.find()): - for location in locations: - self.append("warehouse_mappings", { - "shopify_location_id": location.id, - "shopify_location_name": location.name - }) - except Exception as e: - # Import the logging function - from ecommerce_integrations.shopify.utils import create_shopify_log - - # Create error log entry - create_shopify_log( - status="Error", - method="fetch_shopify_locations", - message=f"Failed to fetch Shopify locations: {str(e)}", - exception=e, - account=account + @connection.temp_shopify_session(lambda self: self.shopify_url) + def update_location_table(self): + """Fetch locations from shopify and add it to child table so user can + map it with correct ERPNext warehouse.""" + + self.shopify_warehouse_mapping = [] + for locations in PaginatedIterator(Location.find()): + print("locations", locations) + for location in locations: + print("location", location, location.id, location.name) + self.append( + "shopify_warehouse_mapping", + {"shopify_location_id": location.id, "shopify_location_name": location.name}, + ) + + def get_erpnext_warehouses(self) -> list[ERPNextWarehouse]: + return [wh_map.erpnext_warehouse for wh_map in self.shopify_warehouse_mapping] + + def get_erpnext_to_integration_wh_mapping(self) -> dict[ERPNextWarehouse, IntegrationWarehouse]: + return { + wh_map.erpnext_warehouse: wh_map.shopify_location_id for wh_map in self.shopify_warehouse_mapping + } + + def get_integration_to_erpnext_wh_mapping(self) -> dict[IntegrationWarehouse, ERPNextWarehouse]: + return { + wh_map.shopify_location_id: wh_map.erpnext_warehouse for wh_map in self.shopify_warehouse_mapping + } + + +def setup_custom_fields(): + custom_fields = { + "Item": [ + dict( + fieldname=ITEM_SELLING_RATE_FIELD, + label="Shopify Selling Rate", + fieldtype="Currency", + insert_after="standard_rate", + ) + ], + "Customer": [ + dict( + fieldname=CUSTOMER_ID_FIELD, + label="Shopify Customer Id", + fieldtype="Data", + insert_after="series", + read_only=1, + print_hide=1, + ) + ], + "Supplier": [ + dict( + fieldname=SUPPLIER_ID_FIELD, + label="Shopify Supplier Id", + fieldtype="Data", + insert_after="supplier_name", + read_only=1, + print_hide=1, + ) + ], + "Address": [ + dict( + fieldname=ADDRESS_ID_FIELD, + label="Shopify Address Id", + fieldtype="Data", + insert_after="fax", + read_only=1, + print_hide=1, ) - - # Then throw the exception for user feedback - frappe.throw(_("Failed to fetch Shopify locations: {0}").format(str(e))) \ No newline at end of file + ], + "Sales Order": [ + dict( + fieldname=ORDER_ID_FIELD, + label="Shopify Order Id", + fieldtype="Small Text", + insert_after="title", + read_only=1, + print_hide=1, + ), + dict( + fieldname=ORDER_NUMBER_FIELD, + label="Shopify Order Number", + fieldtype="Small Text", + insert_after=ORDER_ID_FIELD, + read_only=1, + print_hide=1, + ), + dict( + fieldname=ORDER_STATUS_FIELD, + label="Shopify Order Status", + fieldtype="Small Text", + insert_after=ORDER_NUMBER_FIELD, + read_only=1, + print_hide=1, + ), + ], + "Sales Order Item": [ + dict( + fieldname=ORDER_ITEM_DISCOUNT_FIELD, + label="Shopify Discount per unit", + fieldtype="Float", + insert_after="discount_and_margin", + read_only=1, + ), + ], + "Delivery Note": [ + dict( + fieldname=ORDER_ID_FIELD, + label="Shopify Order Id", + fieldtype="Small Text", + insert_after="title", + read_only=1, + print_hide=1, + ), + dict( + fieldname=ORDER_NUMBER_FIELD, + label="Shopify Order Number", + fieldtype="Small Text", + insert_after=ORDER_ID_FIELD, + read_only=1, + print_hide=1, + ), + dict( + fieldname=ORDER_STATUS_FIELD, + label="Shopify Order Status", + fieldtype="Small Text", + insert_after=ORDER_NUMBER_FIELD, + read_only=1, + print_hide=1, + ), + dict( + fieldname=FULLFILLMENT_ID_FIELD, + label="Shopify Fulfillment Id", + fieldtype="Small Text", + insert_after="title", + read_only=1, + print_hide=1, + ), + ], + "Sales Invoice": [ + dict( + fieldname=ORDER_ID_FIELD, + label="Shopify Order Id", + fieldtype="Small Text", + insert_after="title", + read_only=1, + print_hide=1, + ), + dict( + fieldname=ORDER_NUMBER_FIELD, + label="Shopify Order Number", + fieldtype="Small Text", + insert_after=ORDER_ID_FIELD, + read_only=1, + print_hide=1, + ), + dict( + fieldname=ORDER_STATUS_FIELD, + label="Shopify Order Status", + fieldtype="Small Text", + insert_after=ORDER_ID_FIELD, + read_only=1, + print_hide=1, + ), + ], + } + + create_custom_fields(custom_fields) diff --git a/ecommerce_integrations/shopify/utils.py b/ecommerce_integrations/shopify/utils.py index 11c246042..06bf1f582 100644 --- a/ecommerce_integrations/shopify/utils.py +++ b/ecommerce_integrations/shopify/utils.py @@ -11,80 +11,11 @@ MODULE_NAME, OLD_SETTINGS_DOCTYPE, SETTING_DOCTYPE, - ACCOUNT_DOCTYPE, ) -from frappe.model.document import Document - - -def resolve_account_context(account=None): - """Standardized account resolution with legacy fallback. - - This function serves as a unified resolver for Shopify account contexts, - handling both the new multi-tenant Shopify Account system and the legacy - Shopify Setting singleton pattern. - - Args: - account (None | str | Document, optional): Account context to resolve. - - None: Returns legacy Shopify Setting document for backward compatibility - - str: Account name to fetch the corresponding Shopify Account document - - Document: Assumes it's already a loaded Frappe document (Shopify Account - or Shopify Setting) and returns it directly without validation - - Returns: - Document: Either a Shopify Account document (new multi-tenant) or - Shopify Setting document (legacy singleton) - - Raises: - frappe.DoesNotExistError: If the specified account name doesn't exist - - Note: - The function assumes that any non-string, non-None parameter is a valid - Frappe document object. This assumption is based on the controlled usage - patterns within the Shopify integration system where only document objects - or account names are passed. No type validation is performed on document - objects for performance reasons. - - Examples: - >>> # Legacy fallback - >>> setting = resolve_account_context(None) - >>> - >>> # Fetch by account name - >>> account = resolve_account_context("My Shopify Store") - >>> - >>> # Pass existing document - >>> existing_doc = frappe.get_doc("Shopify Account", "My Store") - >>> same_doc = resolve_account_context(existing_doc) - >>> assert existing_doc is same_doc # Returns same object - """ - - if account: - if isinstance(account, str): - return frappe.get_doc(ACCOUNT_DOCTYPE, account) - elif isinstance(account, Document): # Check if it's a Frappe document - return account - else: - frappe.throw(f"Invalid account parameter type: {type(account)}") - else: - # Legacy fallback for backward compatibility - return frappe.get_doc(SETTING_DOCTYPE) - -def create_shopify_log(account=None, **kwargs): - """Enhanced logging with account context support.""" - # Include account context in the message instead of using unsupported reference_document parameter - - if account: - account_doc = resolve_account_context(account) - if hasattr(account_doc, 'name') and account_doc.doctype == "Shopify Account": - # Include account info in message if not already present - if 'message' in kwargs and kwargs['message']: - kwargs['message'] = f"[Account: {account_doc.name}] {kwargs['message']}" - elif 'message' not in kwargs: - kwargs['message'] = f"Account: {account_doc.name}" - - return create_log( - module_def=MODULE_NAME, - **kwargs - ) + + +def create_shopify_log(**kwargs): + return create_log(module_def=MODULE_NAME, **kwargs) def migrate_from_old_connector(payload=None, request_id=None): From 40bcc1c17429b755e5c27cc10fe1ff5a199078cb Mon Sep 17 00:00:00 2001 From: ahmad Date: Tue, 21 Oct 2025 14:32:10 +0000 Subject: [PATCH 12/30] Complete syncing product creation on when create erpnext item --- ecommerce_integrations/shopify/connection.py | 12 ++- ecommerce_integrations/shopify/product.py | 98 +++++--------------- ecommerce_integrations/shopify/utils.py | 40 +++++--- 3 files changed, 57 insertions(+), 93 deletions(-) diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index cfca026a3..b9a8fabb1 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -15,11 +15,12 @@ ACCOUNT_DOCTYPE, WEBHOOK_EVENTS, ) -from ecommerce_integrations.shopify.utils import create_shopify_log +from ecommerce_integrations.shopify.utils import create_shopify_log, get_user_shopify_account -def temp_shopify_session(shopify_account): +def temp_shopify_session(shopify_account=None): """Decorator for functions that need a temporary Shopify session.""" + print("temp_shopify_session called with ", shopify_account) def decorator(func): @functools.wraps(func) @@ -29,13 +30,16 @@ def wrapper(*args, **kwargs): return func(*args, **kwargs) # If a callable is passed, call it with self to get the account - account = shopify_account(args[0]) if callable(shopify_account) else shopify_account + if shopify_account is None: + # TODO: handle if get_user_shopify_account returns None + account = get_user_shopify_account().name + else: + account = shopify_account(args[0]) if callable(shopify_account) else shopify_account setting = frappe.get_doc(ACCOUNT_DOCTYPE, account) if setting.is_enabled(): auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password")) with Session.temp(*auth_details): - print("auth_details", auth_details) return func(*args, **kwargs) return wrapper diff --git a/ecommerce_integrations/shopify/product.py b/ecommerce_integrations/shopify/product.py index 1eb85ab40..ee86e8043 100644 --- a/ecommerce_integrations/shopify/product.py +++ b/ecommerce_integrations/shopify/product.py @@ -11,12 +11,11 @@ from ecommerce_integrations.shopify.constants import ( ITEM_SELLING_RATE_FIELD, MODULE_NAME, - SETTING_DOCTYPE, SHOPIFY_VARIANTS_ATTR_LIST, SUPPLIER_ID_FIELD, WEIGHT_TO_ERPNEXT_UOM_MAP, ) -from ecommerce_integrations.shopify.utils import create_shopify_log +from ecommerce_integrations.shopify.utils import create_shopify_log, get_user_shopify_account class ShopifyProduct: @@ -26,16 +25,12 @@ def __init__( variant_id: str | None = None, sku: str | None = None, has_variants: int | None = 0, - account=None, # NEW: Add account parameter ): self.product_id = str(product_id) self.variant_id = str(variant_id) if variant_id else None self.sku = str(sku) if sku else None self.has_variants = has_variants - - # Use standardized account resolution - from ecommerce_integrations.shopify.utils import resolve_account_context - self.setting = resolve_account_context(account) + self.setting = get_user_shopify_account() if not self.setting.is_enabled(): frappe.throw(_("Can not create Shopify product when integration is disabled.")) @@ -58,7 +53,7 @@ def get_erpnext_item(self): ) @temp_shopify_session - def sync_product(self, account=None): + def sync_product(self): if not self.is_synced(): shopify_product = Product.find(self.product_id) product_dict = shopify_product.to_dict() @@ -305,18 +300,13 @@ def _match_sku_and_link_item(item_dict, product_id, variant_id, variant_of=None, return False -def create_items_if_not_exist(order, account=None): - """Using shopify order, sync all items that are not already synced. - - Args: - order: Shopify order dict - account: Shopify Account doc (optional, falls back to legacy singleton) - """ +def create_items_if_not_exist(order): + """Using shopify order, sync all items that are not already synced.""" for item in order.get("line_items", []): product_id = item["product_id"] variant_id = item.get("variant_id") sku = item.get("sku") - product = ShopifyProduct(product_id, variant_id=variant_id, sku=sku, account=account) + product = ShopifyProduct(product_id, variant_id=variant_id, sku=sku) if not product.is_synced(): product.sync_product() @@ -337,18 +327,23 @@ def get_item_code(shopify_item): return item.item_code -@temp_shopify_session +@temp_shopify_session(shopify_account=None) def upload_erpnext_item(doc, method=None): """This hook is called when inserting new or updating existing `Item`. - New items are pushed to all enabled Shopify accounts and changes to existing items are - updated depending on what is configured in each "Shopify Account" doctype. + New items are pushed to shopify and changes to existing items are + updated depending on what is configured in "Shopify Setting" doctype. """ template_item = item = doc # alias for readability - # a new item received from ecommerce_integrations is being inserted + # a new item recieved from ecommerce_integrations is being inserted if item.flags.from_integration: return + setting = get_user_shopify_account() + + if not setting.is_enabled() or not setting.upload_erpnext_items: + return + if frappe.flags.in_import: return @@ -359,46 +354,8 @@ def upload_erpnext_item(doc, method=None): msgprint(_("Template items/Items with 4 or more attributes can not be uploaded to Shopify.")) return - # Get all enabled Shopify accounts with product upload enabled - enabled_accounts = frappe.get_all( - ACCOUNT_DOCTYPE, - filters={"enabled": 1, "upload_erpnext_items": 1}, - pluck="name" - ) - - # FIXED: Use standardized account resolution for legacy fallback - if not enabled_accounts: - from ecommerce_integrations.shopify.utils import resolve_account_context - setting = resolve_account_context() # This will get legacy setting - if setting.is_enabled() and setting.upload_erpnext_items: - _upload_item_to_account(item, template_item, None) # Use legacy singleton - return - - # FIXED: Use standardized resolution for account loading - for account_name in enabled_accounts: - try: - from ecommerce_integrations.shopify.utils import resolve_account_context - account = resolve_account_context(account_name) - _upload_item_to_account(item, template_item, account) - except Exception as e: - frappe.log_error( - message=f"Failed to upload item {item.name} to Shopify Account {account_name}: {str(e)}", - title="Shopify Product Upload Error" - ) - - -def _upload_item_to_account(item, template_item, account): - """Upload item to a specific Shopify account or legacy singleton.""" - # Use standardized account resolution - from ecommerce_integrations.shopify.utils import resolve_account_context - setting = resolve_account_context(account) - account_context = f" (Account: {setting.name})" if hasattr(setting, 'name') else " (Legacy)" - - if not setting.is_enabled() or not setting.upload_erpnext_items: - return - - if item.variant_of and not setting.upload_variants_as_items: - msgprint(_(f"Enable variant sync in setting to upload item to Shopify{account_context}.")) + if doc.variant_of and not setting.upload_variants_as_items: + msgprint(_("Enable variant sync in setting to upload item to Shopify.")) return if item.variant_of: @@ -411,17 +368,6 @@ def _upload_item_to_account(item, template_item, account): ) is_new_product = not bool(product_id) - # Set up Shopify session context for this account - if hasattr(setting, 'name'): # Multi-tenant account - with temp_shopify_session(account=setting): - _process_item_upload(item, template_item, setting, is_new_product, product_id, account_context) - else: # Legacy singleton - with temp_shopify_session(): - _process_item_upload(item, template_item, setting, is_new_product, product_id, account_context) - - -def _process_item_upload(item, template_item, setting, is_new_product, product_id, account_context): - """Process the actual item upload logic.""" if is_new_product: product = Product() product.published = False @@ -482,7 +428,7 @@ def _process_item_upload(item, template_item, setting, is_new_product, product_i ) ecom_item.insert() - write_upload_log(status=is_successful, product=product, item=item, account_context=account_context) + write_upload_log(status=is_successful, product=product, item=item) elif setting.update_shopify_item_on_update: product = Product.find(product_id) if product: @@ -519,7 +465,7 @@ def _process_item_upload(item, template_item, setting, is_new_product, product_i if is_successful and item.variant_of: map_erpnext_variant_to_shopify_variant(product, item, variant_attributes) - write_upload_log(status=is_successful, product=product, item=item, action="Updated", account_context=account_context) + write_upload_log(status=is_successful, product=product, item=item, action="Updated") def map_erpnext_variant_to_shopify_variant(shopify_product: Product, erpnext_item, variant_attributes): @@ -602,9 +548,9 @@ def update_default_variant_properties( default_variant.sku = sku -def write_upload_log(status: bool, product: Product, item, action="Created", account_context="") -> None: +def write_upload_log(status: bool, product: Product, item, action="Created") -> None: if not status: - msg = _("Failed to upload item to Shopify") + account_context + "
" + msg = _("Failed to upload item to Shopify") + "
" msg += _("Shopify reported errors:") + " " + ", ".join(product.errors.full_messages()) msgprint(msg, title="Note", indicator="orange") @@ -618,6 +564,6 @@ def write_upload_log(status: bool, product: Product, item, action="Created", acc create_shopify_log( status="Success", request_data=product.to_dict(), - message=f"{action} Item: {item.name}, shopify product: {product.id}{account_context}", + message=f"{action} Item: {item.name}, shopify product: {product.id}", method="upload_erpnext_item", ) diff --git a/ecommerce_integrations/shopify/utils.py b/ecommerce_integrations/shopify/utils.py index 06bf1f582..f7fbb173e 100644 --- a/ecommerce_integrations/shopify/utils.py +++ b/ecommerce_integrations/shopify/utils.py @@ -9,11 +9,24 @@ ) from ecommerce_integrations.shopify.constants import ( MODULE_NAME, - OLD_SETTINGS_DOCTYPE, - SETTING_DOCTYPE, + # OLD_SETTINGS_DOCTYPE, + # SETTING_DOCTYPE, + ACCOUNT_DOCTYPE, ) +def get_user_shopify_account(): + user = frappe.session.user + print("get_user_shopify_account called for user ", user) + existing_permission = frappe.db.exists("User Permission", {"user": user, "allow": "Company"}) + has_company = bool(existing_permission) + if has_company: + company_id = frappe.db.get_value("User Permission", existing_permission, "for_value") + account = frappe.get_doc("Shopify Account", {"company": company_id}) + return account + return None + + def create_shopify_log(**kwargs): return create_log(module_def=MODULE_NAME, **kwargs) @@ -38,16 +51,17 @@ def migrate_from_old_connector(payload=None, request_id=None): def ensure_old_connector_is_disabled(): - try: - old_setting = frappe.get_doc(OLD_SETTINGS_DOCTYPE) - except Exception: - frappe.clear_last_message() - return + # try: + # old_setting = frappe.get_doc(OLD_SETTINGS_DOCTYPE) + # except Exception: + # frappe.clear_last_message() + # return - if old_setting.enable_shopify: - link = frappe.utils.get_link_to_form(OLD_SETTINGS_DOCTYPE, OLD_SETTINGS_DOCTYPE) - msg = _("Please disable old Shopify integration from {0} to proceed.").format(link) - frappe.throw(msg) + # if old_setting.enable_shopify: + # link = frappe.utils.get_link_to_form(OLD_SETTINGS_DOCTYPE, OLD_SETTINGS_DOCTYPE) + # msg = _("Please disable old Shopify integration from {0} to proceed.").format(link) + # frappe.throw(msg) + pass def _migrate_items_to_ecommerce_item(log): @@ -66,8 +80,8 @@ def _migrate_items_to_ecommerce_item(log): log.traceback = frappe.get_traceback() log.save() return - - frappe.db.set_value(SETTING_DOCTYPE, SETTING_DOCTYPE, "is_old_data_migrated", 1) + account_name = get_user_shopify_account().name + frappe.db.set_value(ACCOUNT_DOCTYPE, account_name, "is_old_data_migrated", 1) log.status = "Success" log.save() From ae56cdb6fea81a0b1d0a7eecfdab6e3122918983 Mon Sep 17 00:00:00 2001 From: ahmad Date: Wed, 22 Oct 2025 12:21:03 +0000 Subject: [PATCH 13/30] Refactor Shopify Integration: Transition to Account-Based Configuration - Removed legacy singleton references and replaced with account-based settings for Shopify integration. - Updated inventory synchronization logic to utilize user-specific Shopify accounts. - Simplified functions for handling sales invoices and orders by eliminating account parameters where unnecessary. - Enhanced product synchronization methods to operate without explicit account context. - Improved error handling and logging for better traceability during synchronization processes. - Streamlined JavaScript code for product import to remove account selection, focusing on a single account context. - Added utility functions to fetch Shopify accounts based on user permissions and company associations. --- .../controllers/scheduling.py | 9 +- ecommerce_integrations/shopify/connection.py | 3 +- ecommerce_integrations/shopify/customer.py | 10 +- .../shopify_account/shopify_account.js | 168 ++----------- .../shopify_account/shopify_account.py | 2 - ecommerce_integrations/shopify/fulfillment.py | 91 ++------ ecommerce_integrations/shopify/inventory.py | 79 +------ ecommerce_integrations/shopify/invoice.py | 91 ++------ ecommerce_integrations/shopify/order.py | 221 ++++-------------- .../shopify_import_products.js | 148 ++---------- .../shopify_import_products.py | 146 +++++------- ecommerce_integrations/shopify/product.py | 2 +- ecommerce_integrations/shopify/utils.py | 6 + .../unicommerce/inventory.py | 18 +- 14 files changed, 223 insertions(+), 771 deletions(-) diff --git a/ecommerce_integrations/controllers/scheduling.py b/ecommerce_integrations/controllers/scheduling.py index 9b59e30ae..bd0871497 100644 --- a/ecommerce_integrations/controllers/scheduling.py +++ b/ecommerce_integrations/controllers/scheduling.py @@ -2,7 +2,8 @@ from frappe.utils import add_to_date, cint, get_datetime, now -def need_to_run(setting, interval_field, timestamp_field) -> bool: +# TODO: handle unicommerce +def need_to_run(setting, doc_name, interval_field, timestamp_field) -> bool: """A utility function to make "configurable" scheduled events. If timestamp_field is older than current_time - inveterval_field then this function updates the timestamp_field to `now()` and returns True, @@ -13,11 +14,11 @@ def need_to_run(setting, interval_field, timestamp_field) -> bool: - timestamp field is datetime field. - This function is called from scheuled job with less frequency than lowest interval_field. Ideally, every minute. """ - interval = frappe.db.get_single_value(setting, interval_field, cache=True) - last_run = frappe.db.get_single_value(setting, timestamp_field) + interval = frappe.db.get_value(setting, doc_name, interval_field, cache=True) + last_run = frappe.db.get_value(setting, doc_name, timestamp_field) if last_run and get_datetime() < get_datetime(add_to_date(last_run, minutes=cint(interval, default=10))): return False - frappe.db.set_value(setting, None, timestamp_field, now(), update_modified=False) + frappe.db.set_value(setting, doc_name, timestamp_field, now(), update_modified=False) return True diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index b9a8fabb1..1bc0ad8a5 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -85,8 +85,6 @@ def get_current_domain_name() -> str: If developer_mode is enabled and localtunnel_url is set in site config then domain is set to localtunnel_url. """ - # TODO: Remove - return "70dc6bafa36a.ngrok-free.app" if frappe.conf.developer_mode and frappe.conf.localtunnel_url: return frappe.conf.localtunnel_url else: @@ -117,6 +115,7 @@ def store_request_data(**kwargs) -> None: def process_request(data, event): + print("Processing webhook event: ", event, "\n", data) # create log log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data) diff --git a/ecommerce_integrations/shopify/customer.py b/ecommerce_integrations/shopify/customer.py index b6b6999cb..0a1f93e85 100644 --- a/ecommerce_integrations/shopify/customer.py +++ b/ecommerce_integrations/shopify/customer.py @@ -9,17 +9,13 @@ ADDRESS_ID_FIELD, CUSTOMER_ID_FIELD, MODULE_NAME, - SETTING_DOCTYPE, - ACCOUNT_DOCTYPE, ) +from ecommerce_integrations.shopify.utils import get_user_shopify_account class ShopifyCustomer(EcommerceCustomer): - def __init__(self, customer_id: str, account=None): - # Standardized account resolution - from ecommerce_integrations.shopify.utils import resolve_account_context - self.setting = resolve_account_context(account) - + def __init__(self, customer_id: str): + self.setting = get_user_shopify_account() super().__init__(customer_id, CUSTOMER_ID_FIELD, MODULE_NAME) def sync_customer(self, customer: dict[str, Any]) -> None: diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js index fc3bb94ec..cab7119cb 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js @@ -1,4 +1,4 @@ -// Copyright (c) 2024, Frappe and contributors +// Copyright (c) 2021, Frappe and contributors // For license information, please see LICENSE frappe.provide("ecommerce_integrations.shopify.shopify_account"); @@ -13,9 +13,6 @@ frappe.ui.form.on("Shopify Account", { }); }, }); - - // Set up form description - frm.set_intro(__("This record serves as the Shopify Settings for a single Shopify store. Create one record per store.")); }, fetch_shopify_locations: function (frm) { @@ -23,37 +20,20 @@ frappe.ui.form.on("Shopify Account", { doc: frm.doc, method: "update_location_table", callback: (r) => { - if (!r.exc) refresh_field("warehouse_mappings"); + if (!r.exc) refresh_field("shopify_warehouse_mapping"); }, }); }, refresh: function (frm) { - // Make shop_domain read-only after save - if (!frm.doc.__islocal) { - frm.set_df_property("shop_domain", "read_only", 1); - } - frm.add_custom_button(__("Import Products"), function () { - if (frm.doc.enabled && frm.doc.shop_domain) { - frappe.set_route("shopify-import-products", {"account": frm.doc.name}); - } else { - frappe.msgprint(__("Please enable the account and save before importing products")); - } + frappe.set_route("shopify-import-products"); }); frm.add_custom_button(__("View Logs"), () => { frappe.set_route("List", "Ecommerce Integration Log", { integration: "Shopify", - reference_document: frm.doc.name }); }); - frm.add_custom_button(__("Fetch Shopify Locations"), function () { - if (!frm.doc.enabled) { - frappe.msgprint(__("Please enable the account first")); - return; - } - frm.trigger("fetch_shopify_locations"); - }); frm.trigger("setup_queries"); }, @@ -67,9 +47,14 @@ frappe.ui.form.on("Shopify Account", { }, }; }; - frm.set_query("erpnext_warehouse", "warehouse_mappings", warehouse_query); - - frm.set_query("selling_price_list", () => { + frm.set_query("warehouse", warehouse_query); + frm.set_query( + "erpnext_warehouse", + "shopify_warehouse_mapping", + warehouse_query + ); + + frm.set_query("price_list", () => { return { filters: { selling: 1, @@ -86,12 +71,15 @@ frappe.ui.form.on("Shopify Account", { }; }); - frm.set_query("default_customer", () => { - const filters = {disabled: 0}; - if (frm.doc.company) { - filters.company = frm.doc.company; - } - return {filters}; + frm.set_query("cash_bank_account", () => { + return { + filters: [ + ["Account", "account_type", "in", ["Cash", "Bank"]], + ["Account", "root_type", "=", "Asset"], + ["Account", "is_group", "=", 0], + ["Account", "company", "=", frm.doc.company], + ], + }; }); const tax_query = () => { @@ -104,118 +92,8 @@ frappe.ui.form.on("Shopify Account", { }; }; - frm.set_query("tax_account", "tax_mappings", tax_query); - }, - - // Additional event handlers specific to Shopify Account - enabled: function (frm) { - frm.trigger("toggle_conditional_fields"); - frm.trigger("show_enabled_status"); - }, - - company: function (frm) { - if (frm.doc.company) { - if (frm.doc.warehouse_mappings && frm.doc.warehouse_mappings.length > 0) { - frappe.msgprint({ - title: __("Company Changed"), - message: __("Please review warehouse and tax mappings to ensure they belong to the selected company."), - indicator: "orange" - }); - } - } - frm.trigger("setup_queries"); + frm.set_query("tax_account", "taxes", tax_query); + frm.set_query("default_sales_tax_account", tax_query); + frm.set_query("default_shipping_charges_account", tax_query); }, - - shop_domain: function (frm) { - if (frm.doc.shop_domain) { - let domain = frm.doc.shop_domain.replace(/^https?:\/\//, ""); - if (domain && !domain.endsWith(".myshopify.com")) { - frappe.msgprint({ - title: __("Invalid Domain"), - message: __("Shop domain must end with '.myshopify.com'"), - indicator: "red" - }); - } - frm.set_value("shop_domain", domain); - } - }, - - sync_sales_invoice: function (frm) { - frm.trigger("validate_sync_dependencies"); - }, - - sync_delivery_note: function (frm) { - frm.trigger("validate_sync_dependencies"); - }, - - create_customers: function (frm) { - if (!frm.doc.create_customers && !frm.doc.default_customer) { - frappe.msgprint({ - title: __("Default Customer Required"), - message: __("When automatic customer creation is disabled, a default customer should be set."), - indicator: "orange" - }); - } - }, - - toggle_conditional_fields: function (frm) { - const is_enabled = frm.doc.enabled; - frm.toggle_reqd("access_token", is_enabled); - frm.toggle_reqd("shared_secret", is_enabled); - frm.toggle_reqd("company", is_enabled); - }, - - show_enabled_status: function (frm) { - if (frm.doc.enabled) { - if (!frm.doc.access_token || !frm.doc.shared_secret || !frm.doc.company) { - frm.dashboard.add_indicator(__("Incomplete Setup"), "orange"); - } else { - frm.dashboard.add_indicator(__("Enabled"), "green"); - } - } else { - frm.dashboard.add_indicator(__("Disabled"), "red"); - } - }, - - validate_sync_dependencies: function (frm) { - if ((frm.doc.sync_sales_invoice || frm.doc.sync_delivery_note) && !frm.doc.cost_center) { - frappe.msgprint({ - title: __("Cost Center Recommended"), - message: __("A cost center is recommended when Sales Invoice or Delivery Note sync is enabled."), - indicator: "orange" - }); - } - }, -}); - -// Handle warehouse mapping child table events -frappe.ui.form.on("Shopify Warehouse Mapping", { - erpnext_warehouse: function (frm, cdt, cdn) { - const row = locals[cdt][cdn]; - if (row.erpnext_warehouse && frm.doc.company) { - frappe.db.get_value("Warehouse", row.erpnext_warehouse, "company") - .then(r => { - if (r.message && r.message.company !== frm.doc.company) { - frappe.msgprint(__("Selected warehouse does not belong to company {0}", [frm.doc.company])); - frappe.model.set_value(cdt, cdn, "erpnext_warehouse", ""); - } - }); - } - } -}); - -// Handle tax mapping child table events -frappe.ui.form.on("Shopify Tax Account", { - tax_account: function (frm, cdt, cdn) { - const row = locals[cdt][cdn]; - if (row.tax_account && frm.doc.company) { - frappe.db.get_value("Account", row.tax_account, "company") - .then(r => { - if (r.message && r.message.company !== frm.doc.company) { - frappe.msgprint(__("Selected account does not belong to company {0}", [frm.doc.company])); - frappe.model.set_value(cdt, cdn, "tax_account", ""); - } - }); - } - } }); diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py index 7dd8a7dc4..6089ae02a 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py @@ -87,9 +87,7 @@ def update_location_table(self): self.shopify_warehouse_mapping = [] for locations in PaginatedIterator(Location.find()): - print("locations", locations) for location in locations: - print("location", location, location.id, location.name) self.append( "shopify_warehouse_mapping", {"shopify_location_id": location.id, "shopify_location_name": location.name}, diff --git a/ecommerce_integrations/shopify/fulfillment.py b/ecommerce_integrations/shopify/fulfillment.py index 20d62211e..546616957 100644 --- a/ecommerce_integrations/shopify/fulfillment.py +++ b/ecommerce_integrations/shopify/fulfillment.py @@ -8,47 +8,32 @@ FULLFILLMENT_ID_FIELD, ORDER_ID_FIELD, ORDER_NUMBER_FIELD, - SETTING_DOCTYPE, + # SETTING_DOCTYPE, ) from ecommerce_integrations.shopify.order import get_sales_order -from ecommerce_integrations.shopify.utils import create_shopify_log +from ecommerce_integrations.shopify.utils import create_shopify_log, get_user_shopify_account -def prepare_delivery_note(payload, request_id=None, account=None): +def prepare_delivery_note(payload, request_id=None): frappe.set_user("Administrator") + setting = get_user_shopify_account() frappe.flags.request_id = request_id - # FIXED: Use standardized account resolution - from ecommerce_integrations.shopify.utils import resolve_account_context - account = resolve_account_context(account) - order = payload try: sales_order = get_sales_order(cstr(order["id"])) if sales_order: - create_delivery_note(order, account, sales_order) - create_shopify_log( - status="Success", - account=account - ) + create_delivery_note(order, setting, sales_order) + create_shopify_log(status="Success") else: - create_shopify_log( - status="Invalid", - message="Sales Order not found for syncing delivery note.", - account=account - ) + create_shopify_log(status="Invalid", message="Sales Order not found for syncing delivery note.") except Exception as e: - create_shopify_log( - status="Error", - exception=e, - rollback=True, - account=account - ) + create_shopify_log(status="Error", exception=e, rollback=True) -def create_delivery_note(shopify_order, account, so): - if not _should_sync_delivery_note(account): +def create_delivery_note(shopify_order, setting, so): + if not cint(setting.sync_delivery_note): return for fulfillment in shopify_order.get("fulfillments"): @@ -62,9 +47,9 @@ def create_delivery_note(shopify_order, account, so): setattr(dn, FULLFILLMENT_ID_FIELD, fulfillment.get("id")) dn.set_posting_time = 1 dn.posting_date = getdate(fulfillment.get("created_at")) - dn.naming_series = _get_delivery_note_series(account) + dn.naming_series = setting.delivery_note_series or "DN-Shopify-" dn.items = get_fulfillment_items( - dn.items, fulfillment.get("line_items"), fulfillment.get("location_id"), account + dn.items, fulfillment.get("line_items"), fulfillment.get("location_id") ) dn.flags.ignore_mandatory = True dn.save() @@ -74,24 +59,15 @@ def create_delivery_note(shopify_order, account, so): dn.add_comment(text=f"Order Note: {shopify_order.get('note')}") -def get_fulfillment_items(dn_items, fulfillment_items, location_id=None, account=None): +def get_fulfillment_items(dn_items, fulfillment_items, location_id=None): # local import to avoid circular imports from ecommerce_integrations.shopify.product import get_item_code fulfillment_items = deepcopy(fulfillment_items) - # Get warehouse mapping from account or legacy setting - if account and hasattr(account, 'warehouse_mappings'): - # Use account-specific warehouse mappings - wh_map = _get_warehouse_mapping(account) - default_warehouse = _get_default_warehouse(account) - else: - # Fallback to legacy setting - setting = frappe.get_cached_doc(SETTING_DOCTYPE) - wh_map = setting.get_integration_to_erpnext_wh_mapping() - default_warehouse = setting.warehouse - - warehouse = wh_map.get(str(location_id)) or default_warehouse + setting = get_user_shopify_account() + wh_map = setting.get_integration_to_erpnext_wh_mapping() + warehouse = wh_map.get(str(location_id)) or setting.warehouse final_items = [] @@ -108,38 +84,3 @@ def find_matching_fullfilement_item(dn_item): final_items.append(dn_item.update({"qty": shopify_item.get("quantity"), "warehouse": warehouse})) return final_items - - -# Helper functions for account-aware delivery note creation - -def _should_sync_delivery_note(account): - """Check if delivery note sync is enabled for this account.""" - if hasattr(account, 'sync_delivery_note'): - return cint(account.sync_delivery_note) - else: # Legacy setting - return cint(account.sync_delivery_note) - -def _get_delivery_note_series(account): - """Get delivery note series from account or legacy setting.""" - if hasattr(account, 'delivery_note_series'): - return account.delivery_note_series or "DN-Shopify-" - else: # Legacy setting - return account.delivery_note_series or "DN-Shopify-" - -def _get_warehouse_mapping(account): - """Get warehouse mapping from account.""" - if hasattr(account, 'warehouse_mappings'): - return { - mapping.shopify_location_id: mapping.erpnext_warehouse - for mapping in account.warehouse_mappings - } - return {} - -def _get_default_warehouse(account): - """Get default warehouse from account or legacy setting.""" - if hasattr(account, 'warehouse'): - return account.warehouse - elif hasattr(account, 'warehouse_mappings') and account.warehouse_mappings: - # Use first warehouse as default if no specific default warehouse field - return account.warehouse_mappings[0].erpnext_warehouse - return None diff --git a/ecommerce_integrations/shopify/inventory.py b/ecommerce_integrations/shopify/inventory.py index 6a31a6646..2ba6d4478 100644 --- a/ecommerce_integrations/shopify/inventory.py +++ b/ecommerce_integrations/shopify/inventory.py @@ -11,82 +11,33 @@ ) from ecommerce_integrations.controllers.scheduling import need_to_run from ecommerce_integrations.shopify.connection import temp_shopify_session -from ecommerce_integrations.shopify.constants import MODULE_NAME, SETTING_DOCTYPE, ACCOUNT_DOCTYPE -from ecommerce_integrations.shopify.utils import create_shopify_log +from ecommerce_integrations.shopify.constants import MODULE_NAME, ACCOUNT_DOCTYPE +from ecommerce_integrations.shopify.utils import create_shopify_log, get_user_shopify_account def update_inventory_on_shopify() -> None: - """Upload stock levels from ERPNext to Shopify for all enabled accounts. + """Upload stock levels from ERPNext to Shopify. Called by scheduler on configured interval. """ - # Get all enabled Shopify accounts - enabled_accounts = frappe.get_all(ACCOUNT_DOCTYPE, - filters={"enabled": 1}, - fields=["name", "shop_domain"]) - - if not enabled_accounts: - # Fallback to legacy singleton for backward compatibility - _update_inventory_legacy() - return - - for account_data in enabled_accounts: - from ecommerce_integrations.shopify.utils import resolve_account_context - account = resolve_account_context(account_data.name) - _update_inventory_for_account(account) - - -def _update_inventory_legacy(): - """Legacy inventory update using singleton (for backward compatibility).""" - setting = frappe.get_doc(SETTING_DOCTYPE) + setting = get_user_shopify_account() + print("Updating inventory on shopify for account ", setting.name) if not setting.is_enabled() or not setting.update_erpnext_stock_levels_to_shopify: return - if not need_to_run(SETTING_DOCTYPE, "inventory_sync_frequency", "last_inventory_sync"): + if not need_to_run(ACCOUNT_DOCTYPE, setting.name, "inventory_sync_frequency", "last_inventory_sync"): return warehous_map = setting.get_erpnext_to_integration_wh_mapping() inventory_levels = get_inventory_levels(tuple(warehous_map.keys()), MODULE_NAME) if inventory_levels: - upload_inventory_data_to_shopify(inventory_levels, warehous_map, account=None) - - -def _update_inventory_for_account(account): - """Update inventory for a specific Shopify account.""" - if not account.is_enabled(): - return - - # Check if account has inventory sync enabled (assuming this field exists or will be added) - # For now, we'll assume all enabled accounts want inventory sync - # TODO: Add inventory sync toggle to Shopify Account doctype if needed - - # Use account-specific scheduling check - if not need_to_run(ACCOUNT_DOCTYPE, "inventory_sync_frequency", "last_inventory_sync", account.name): - return - - # Get warehouse mappings from account - warehous_map = {} - for mapping in account.warehouse_mappings or []: - if mapping.erpnext_warehouse and mapping.shopify_location_id: - warehous_map[mapping.erpnext_warehouse] = mapping.shopify_location_id - - if not warehous_map: - frappe.log_error( - f"No warehouse mappings configured for Shopify Account: {account.name}", - "Shopify Inventory Sync" - ) - return - - inventory_levels = get_inventory_levels(tuple(warehous_map.keys()), MODULE_NAME) - - if inventory_levels: - upload_inventory_data_to_shopify(inventory_levels, warehous_map, account=account) + upload_inventory_data_to_shopify(inventory_levels, warehous_map) -@temp_shopify_session -def upload_inventory_data_to_shopify(inventory_levels, warehous_map, account=None) -> None: +@temp_shopify_session(shopify_account=None) +def upload_inventory_data_to_shopify(inventory_levels, warehous_map) -> None: synced_on = now() for inventory_sync_batch in create_batch(inventory_levels, 50): @@ -115,10 +66,10 @@ def upload_inventory_data_to_shopify(inventory_levels, warehous_map, account=Non frappe.db.commit() - _log_inventory_update_status(inventory_sync_batch, account) + _log_inventory_update_status(inventory_sync_batch) -def _log_inventory_update_status(inventory_levels, account=None) -> None: +def _log_inventory_update_status(inventory_levels) -> None: """Create log of inventory update.""" log_message = "variant_id,location_id,status,failure_reason\n" @@ -140,10 +91,4 @@ def _log_inventory_update_status(inventory_levels, account=None) -> None: log_message = f"Updated {percent_successful * 100}% items\n\n" + log_message - - create_shopify_log( - method="update_inventory_on_shopify", - status=status, - message=log_message, - account=account - ) + create_shopify_log(method="update_inventory_on_shopify", status=status, message=log_message) diff --git a/ecommerce_integrations/shopify/invoice.py b/ecommerce_integrations/shopify/invoice.py index 7b9ee9f0e..84bbaf35a 100644 --- a/ecommerce_integrations/shopify/invoice.py +++ b/ecommerce_integrations/shopify/invoice.py @@ -5,53 +5,37 @@ from ecommerce_integrations.shopify.constants import ( ORDER_ID_FIELD, ORDER_NUMBER_FIELD, - SETTING_DOCTYPE, + # SETTING_DOCTYPE, ) -from ecommerce_integrations.shopify.utils import create_shopify_log +from ecommerce_integrations.shopify.utils import create_shopify_log, get_user_shopify_account -def prepare_sales_invoice(payload, request_id=None, account=None): +def prepare_sales_invoice(payload, request_id=None): from ecommerce_integrations.shopify.order import get_sales_order order = payload frappe.set_user("Administrator") + setting = get_user_shopify_account() frappe.flags.request_id = request_id - # FIXED: Use standardized account resolution - from ecommerce_integrations.shopify.utils import resolve_account_context - account = resolve_account_context(account) - try: sales_order = get_sales_order(cstr(order["id"])) if sales_order: - create_sales_invoice(order, account, sales_order) - create_shopify_log( - status="Success", - account=account - ) + create_sales_invoice(order, setting, sales_order) + create_shopify_log(status="Success") else: - create_shopify_log( - status="Invalid", - message="Sales Order not found for syncing sales invoice.", - account=account - ) + create_shopify_log(status="Invalid", message="Sales Order not found for syncing sales invoice.") except Exception as e: - create_shopify_log( - status="Error", - exception=e, - rollback=True, - account=account - ) + create_shopify_log(status="Error", exception=e, rollback=True) -def create_sales_invoice(shopify_order, account, so): - # Check if should sync and if sales invoice already exists +def create_sales_invoice(shopify_order, setting, so): if ( not frappe.db.get_value("Sales Invoice", {ORDER_ID_FIELD: shopify_order.get("id")}, "name") and so.docstatus == 1 and not so.per_billed - and _should_sync_sales_invoice(account) + and cint(setting.sync_sales_invoice) ): posting_date = getdate(shopify_order.get("created_at")) or nowdate() @@ -61,13 +45,13 @@ def create_sales_invoice(shopify_order, account, so): sales_invoice.set_posting_time = 1 sales_invoice.posting_date = posting_date sales_invoice.due_date = posting_date - sales_invoice.naming_series = _get_sales_invoice_series(account) + sales_invoice.naming_series = setting.sales_invoice_series or "SI-Shopify-" sales_invoice.flags.ignore_mandatory = True - set_cost_center(sales_invoice.items, _get_cost_center(account)) + set_cost_center(sales_invoice.items, setting.cost_center) sales_invoice.insert(ignore_mandatory=True) sales_invoice.submit() if sales_invoice.grand_total > 0: - make_payament_entry_against_sales_invoice(sales_invoice, account, posting_date) + make_payament_entry_against_sales_invoice(sales_invoice, setting, posting_date) if shopify_order.get("note"): sales_invoice.add_comment(text=f"Order Note: {shopify_order.get('note')}") @@ -78,46 +62,13 @@ def set_cost_center(items, cost_center): item.cost_center = cost_center -def make_payament_entry_against_sales_invoice(doc, account, posting_date=None): +def make_payament_entry_against_sales_invoice(doc, setting, posting_date=None): from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry - bank_account = _get_cash_bank_account(account) - if bank_account: - payment_entry = get_payment_entry(doc.doctype, doc.name, bank_account=bank_account) - payment_entry.flags.ignore_mandatory = True - payment_entry.reference_no = doc.name - payment_entry.posting_date = posting_date or nowdate() - payment_entry.reference_date = posting_date or nowdate() - payment_entry.insert(ignore_permissions=True) - payment_entry.submit() - - -# Helper functions for account-aware invoice creation - -def _should_sync_sales_invoice(account): - """Check if sales invoice sync is enabled for this account.""" - if hasattr(account, 'sync_sales_invoice'): - return cint(account.sync_sales_invoice) - else: # Legacy setting - return cint(account.sync_sales_invoice) - -def _get_sales_invoice_series(account): - """Get sales invoice series from account or legacy setting.""" - if hasattr(account, 'sales_invoice_series'): - return account.sales_invoice_series or "SI-Shopify-" - else: # Legacy setting - return account.sales_invoice_series or "SI-Shopify-" - -def _get_cost_center(account): - """Get cost center from account or legacy setting.""" - if hasattr(account, 'cost_center'): - return account.cost_center - else: # Legacy setting - return account.cost_center - -def _get_cash_bank_account(account): - """Get cash/bank account from account or legacy setting.""" - if hasattr(account, 'cash_bank_account'): - return getattr(account, 'cash_bank_account', None) - else: # Legacy setting - return account.cash_bank_account + payment_entry = get_payment_entry(doc.doctype, doc.name, bank_account=setting.cash_bank_account) + payment_entry.flags.ignore_mandatory = True + payment_entry.reference_no = doc.name + payment_entry.posting_date = posting_date or nowdate() + payment_entry.reference_date = posting_date or nowdate() + payment_entry.insert(ignore_permissions=True) + payment_entry.submit() diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index 9335d4c2d..4496bd486 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -15,11 +15,12 @@ ORDER_ITEM_DISCOUNT_FIELD, ORDER_NUMBER_FIELD, ORDER_STATUS_FIELD, - SETTING_DOCTYPE, + ACCOUNT_DOCTYPE, + # SETTING_DOCTYPE, ) from ecommerce_integrations.shopify.customer import ShopifyCustomer from ecommerce_integrations.shopify.product import create_items_if_not_exist, get_item_code -from ecommerce_integrations.shopify.utils import create_shopify_log +from ecommerce_integrations.shopify.utils import create_shopify_log, get_user_shopify_account from ecommerce_integrations.utils.price_list import get_dummy_price_list from ecommerce_integrations.utils.taxation import get_dummy_tax_category @@ -29,21 +30,15 @@ } -def sync_sales_order(payload, request_id=None, account=None): +def sync_sales_order(payload, request_id=None): order = payload + print("sync_sales_order", payload) + setting = get_user_shopify_account() frappe.set_user("Administrator") frappe.flags.request_id = request_id - # FIXED: Use standardized account resolution - from ecommerce_integrations.shopify.utils import resolve_account_context - account = resolve_account_context(account) - if frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: cstr(order["id"])}): - create_shopify_log( - status="Invalid", - message="Sales order already exists, not synced", - account=account - ) + create_shopify_log(status="Invalid", message="Sales order already exists, not synced") return try: shopify_customer = order.get("customer") if order.get("customer") is not None else {} @@ -51,45 +46,38 @@ def sync_sales_order(payload, request_id=None, account=None): shopify_customer["shipping_address"] = order.get("shipping_address", "") customer_id = shopify_customer.get("id") if customer_id: - customer = ShopifyCustomer(customer_id=customer_id, account=account) + customer = ShopifyCustomer(customer_id=customer_id) if not customer.is_synced(): customer.sync_customer(customer=shopify_customer) else: customer.update_existing_addresses(shopify_customer) - create_items_if_not_exist(order, account) + create_items_if_not_exist(order) - create_order(order, account) + # setting = frappe.get_doc(SETTING_DOCTYPE) + create_order(order, setting) except Exception as e: - create_shopify_log( - status="Error", - exception=e, - rollback=True, - account=account - ) + create_shopify_log(status="Error", exception=e, rollback=True) else: - create_shopify_log( - status="Success", - account=account - ) + create_shopify_log(status="Success") -def create_order(order, account, company=None): +def create_order(order, setting, company=None): # local import to avoid circular dependencies from ecommerce_integrations.shopify.fulfillment import create_delivery_note from ecommerce_integrations.shopify.invoice import create_sales_invoice - so = create_sales_order(order, account, company) + so = create_sales_order(order, setting, company) if so: - if order.get("financial_status") == "paid" and _should_sync_invoice(account): - create_sales_invoice(order, account, so) + if order.get("financial_status") == "paid": + create_sales_invoice(order, setting, so) - if order.get("fulfillments") and _should_sync_delivery_note(account): - create_delivery_note(order, account, so) + if order.get("fulfillments"): + create_delivery_note(order, setting, so) -def create_sales_order(shopify_order, account, company=None): - customer = _get_default_customer(account) +def create_sales_order(shopify_order, setting, company=None): + customer = setting.default_customer if shopify_order.get("customer", {}): if customer_id := shopify_order.get("customer", {}).get("id"): customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") @@ -99,7 +87,7 @@ def create_sales_order(shopify_order, account, company=None): if not so: items = get_order_items( shopify_order.get("line_items"), - account, + setting, getdate(shopify_order.get("created_at")), taxes_inclusive=shopify_order.get("taxes_included"), ) @@ -116,18 +104,18 @@ def create_sales_order(shopify_order, account, company=None): return "" - taxes = get_order_taxes(shopify_order, account, items) + taxes = get_order_taxes(shopify_order, setting, items) so = frappe.get_doc( { "doctype": "Sales Order", - "naming_series": _get_sales_order_series(account), + "naming_series": setting.sales_order_series or "SO-Shopify-", ORDER_ID_FIELD: str(shopify_order.get("id")), ORDER_NUMBER_FIELD: shopify_order.get("name"), "customer": customer, "transaction_date": getdate(shopify_order.get("created_at")) or nowdate(), "delivery_date": getdate(shopify_order.get("created_at")) or nowdate(), - "company": _get_company(account), - "selling_price_list": _get_selling_price_list(account), + "company": setting.company, + "selling_price_list": get_dummy_price_list(), "ignore_pricing_rule": 1, "items": items, "taxes": taxes, @@ -151,7 +139,7 @@ def create_sales_order(shopify_order, account, company=None): return so -def get_order_items(order_items, account, delivery_date, taxes_inclusive): +def get_order_items(order_items, setting, delivery_date, taxes_inclusive): items = [] all_product_exists = True product_not_exists = [] @@ -174,7 +162,7 @@ def get_order_items(order_items, account, delivery_date, taxes_inclusive): "delivery_date": delivery_date, "qty": shopify_item.get("quantity"), "stock_uom": shopify_item.get("uom") or "Nos", - "warehouse": _get_default_warehouse_for_order(account), + "warehouse": setting.warehouse, ORDER_ITEM_DISCOUNT_FIELD: ( _get_total_discount(shopify_item) / cint(shopify_item.get("quantity")) ), @@ -218,9 +206,9 @@ def get_order_taxes(shopify_order, setting, items): taxes.append( { "charge_type": "Actual", - "account_head": get_tax_account_head(tax, charge_type="sales_tax"), + "account_head": get_tax_account_head(tax, setting, charge_type="sales_tax"), "description": ( - get_tax_account_description(tax) + get_tax_account_description(tax, setting) or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" ), "tax_amount": tax.get("price"), @@ -274,17 +262,17 @@ def consolidate_order_taxes(taxes): return tax_account_wise_data.values() -def get_tax_account_head(tax, charge_type: Literal["shipping", "sales_tax"] | None = None): +def get_tax_account_head(tax, setting, charge_type: Literal["shipping", "sales_tax"] | None = None): tax_title = str(tax.get("title")) tax_account = frappe.db.get_value( "Shopify Tax Account", - {"parent": SETTING_DOCTYPE, "shopify_tax": tax_title}, + {"parent": setting.name, "shopify_tax": tax_title}, "tax_account", ) if not tax_account and charge_type: - tax_account = frappe.db.get_single_value(SETTING_DOCTYPE, DEFAULT_TAX_FIELDS[charge_type]) + tax_account = frappe.db.get_value(ACCOUNT_DOCTYPE, setting.name, DEFAULT_TAX_FIELDS[charge_type]) if not tax_account: frappe.throw(_("Tax Account not specified for Shopify Tax {0}").format(tax.get("title"))) @@ -292,12 +280,12 @@ def get_tax_account_head(tax, charge_type: Literal["shipping", "sales_tax"] | No return tax_account -def get_tax_account_description(tax): +def get_tax_account_description(tax, setting): tax_title = tax.get("title") tax_description = frappe.db.get_value( "Shopify Tax Account", - {"parent": SETTING_DOCTYPE, "shopify_tax": tax_title}, + {"parent": setting.name, "shopify_tax": tax_title}, "tax_description", ) @@ -336,7 +324,7 @@ def update_taxes_with_shipping_lines(taxes, shipping_lines, setting, items, taxe { "charge_type": "Actual", "account_head": get_tax_account_head(shipping_charge, charge_type="shipping"), - "description": get_tax_account_description(shipping_charge) + "description": get_tax_account_description(shipping_charge, setting) or shipping_charge["title"], "tax_amount": shipping_charge_amount, "cost_center": setting.cost_center, @@ -349,7 +337,7 @@ def update_taxes_with_shipping_lines(taxes, shipping_lines, setting, items, taxe "charge_type": "Actual", "account_head": get_tax_account_head(tax, charge_type="sales_tax"), "description": ( - get_tax_account_description(tax) + get_tax_account_description(tax, setting) or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%" ), "tax_amount": tax["price"], @@ -371,7 +359,7 @@ def get_sales_order(order_id): return frappe.get_doc("Sales Order", sales_order) -def cancel_order(payload, request_id=None, account=None): +def cancel_order(payload, request_id=None): """Called by order/cancelled event. When shopify order is cancelled there could be many different someone handles it. @@ -383,13 +371,6 @@ def cancel_order(payload, request_id=None, account=None): frappe.set_user("Administrator") frappe.flags.request_id = request_id - # Get account context - if isinstance(account, str): - account = frappe.get_doc("Shopify Account", account) - elif not account: - # Fallback to legacy mode - account = frappe.get_doc(SETTING_DOCTYPE) - order = payload try: @@ -399,11 +380,7 @@ def cancel_order(payload, request_id=None, account=None): sales_order = get_sales_order(order_id) if not sales_order: - create_shopify_log( - status="Invalid", - message="Sales Order does not exist", - account=account - ) + create_shopify_log(status="Invalid", message="Sales Order does not exist") return sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id}) @@ -421,42 +398,14 @@ def cancel_order(payload, request_id=None, account=None): frappe.db.set_value("Sales Order", sales_order.name, ORDER_STATUS_FIELD, order_status) except Exception as e: - create_shopify_log( - status="Error", - exception=e, - account=account - ) + create_shopify_log(status="Error", exception=e) else: - create_shopify_log( - status="Success", - account=account - ) + create_shopify_log(status="Success") @temp_shopify_session -def sync_old_orders(account=None): - """Sync old orders for specific account or all enabled accounts.""" - if account: - # Sync for specific account - _sync_old_orders_for_account(account) - else: - # Sync for all enabled accounts - enabled_accounts = frappe.get_all(ACCOUNT_DOCTYPE, - filters={"enabled": 1}, - fields=["name"]) - - if not enabled_accounts: - # Fallback to legacy singleton - _sync_old_orders_legacy() - return - - for account_data in enabled_accounts: - account_doc = frappe.get_doc(ACCOUNT_DOCTYPE, account_data.name) - _sync_old_orders_for_account(account_doc) - -def _sync_old_orders_legacy(): - """Legacy old order sync using singleton.""" - shopify_setting = frappe.get_cached_doc(SETTING_DOCTYPE) +def sync_old_orders(): + shopify_setting = get_user_shopify_account() if not cint(shopify_setting.sync_old_orders): return @@ -468,39 +417,10 @@ def _sync_old_orders_legacy(): ) sync_sales_order(order, request_id=log.name) - shopify_setting = frappe.get_doc(SETTING_DOCTYPE) + shopify_setting = get_user_shopify_account() shopify_setting.sync_old_orders = 0 shopify_setting.save() -def _sync_old_orders_for_account(account): - """Sync old orders for a specific Shopify account.""" - if not account.is_enabled(): - return - - # Check if account has old order sync enabled - if not cint(account.sync_old_orders): - return - - # Use account-specific session - with temp_shopify_session(account=account): - orders = _fetch_old_orders( - account.old_orders_from, - account.old_orders_to - ) - - for order in orders: - log = create_shopify_log( - method=EVENT_MAPPER["orders/create"], - request_data=json.dumps(order), - make_new=True, - account=account - ) - sync_sales_order(order, request_id=log.name, account=account) - - # Update account sync status - account.sync_old_orders = 0 - account.save() - def _fetch_old_orders(from_time, to_time): """Fetch all shopify orders in specified range and return an iterator on fetched orders.""" @@ -516,56 +436,3 @@ def _fetch_old_orders(from_time, to_time): # Using generator instead of fetching all at once is better for # avoiding rate limits and reducing resource usage. yield order.to_dict() - - -# Helper functions for account-aware integration - -def _get_default_customer(account): - """Get default customer from account or legacy setting.""" - if hasattr(account, 'default_customer') and account.default_customer: - return account.default_customer - elif hasattr(account, 'default_customer'): # Shopify Account - return account.default_customer - else: # Legacy Shopify Setting - return account.default_customer - -def _get_company(account): - """Get company from account or legacy setting.""" - return account.company - -def _get_sales_order_series(account): - """Get sales order series from account or legacy setting.""" - if hasattr(account, 'sales_order_series'): - return account.sales_order_series or "SO-Shopify-" - else: # Legacy setting - return account.sales_order_series or "SO-Shopify-" - -def _get_selling_price_list(account): - """Get selling price list from account or fallback to dummy.""" - if hasattr(account, 'selling_price_list') and account.selling_price_list: - return account.selling_price_list - return get_dummy_price_list() - -def _should_sync_invoice(account): - """Check if sales invoice sync is enabled for this account.""" - if hasattr(account, 'sync_sales_invoice'): - return bool(account.sync_sales_invoice) - else: # Legacy setting - return bool(account.sync_sales_invoice) - -def _should_sync_delivery_note(account): - """Check if delivery note sync is enabled for this account.""" - if hasattr(account, 'sync_delivery_note'): - return bool(account.sync_delivery_note) - else: # Legacy setting - return bool(account.sync_delivery_note) - -def _get_default_warehouse_for_order(account): - """Get default warehouse for order items from account or legacy setting.""" - if hasattr(account, 'warehouse'): - return account.warehouse - elif hasattr(account, 'warehouse_mappings') and account.warehouse_mappings: - # Use first warehouse as default for new account - return account.warehouse_mappings[0].erpnext_warehouse - else: # Legacy setting - return account.warehouse diff --git a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js index 8a9c4f778..62db3c216 100644 --- a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js +++ b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js @@ -15,7 +15,6 @@ shopify.ProductImporter = class { constructor(wrapper) { this.wrapper = $(wrapper).find(".layout-main-section"); this.page = wrapper.page; - this.selectedAccount = null; // Track selected account this.init(); this.syncRunning = false; } @@ -23,7 +22,7 @@ shopify.ProductImporter = class { init() { frappe.run_serially([ () => this.addMarkup(), - () => this.loadShopifyAccounts(), + () => this.fetchProductCount(), () => this.addTable(), () => this.checkSyncStatus(), () => this.listen(), @@ -64,22 +63,11 @@ shopify.ProductImporter = class {
-
-
Account Selection
-
-
- - -
-
-
Synchronization Details
- +
@@ -111,71 +99,19 @@ shopify.ProductImporter = class { this.wrapper.append(_markup); } - // NEW: Load Shopify accounts for multi-tenancy - async loadShopifyAccounts() { + async fetchProductCount() { try { - const { message: accounts } = await frappe.call({ - method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.get_shopify_accounts", - }); - - const accountSelect = this.wrapper.find("#shopify-account-select"); - accountSelect.empty(); - - if (accounts && accounts.length > 0) { - accountSelect.append(''); - accounts.forEach(account => { - accountSelect.append(``); - }); - - // Auto-select if only one account - if (accounts.length === 1) { - accountSelect.val(accounts[0].name); - this.onAccountChange(accounts[0].name); - } - } else { - accountSelect.append(''); - this.wrapper.find("#btn-sync-all").text("No Accounts Available").prop("disabled", true); - } - - // Add change event listener - accountSelect.on('change', (e) => { - this.onAccountChange(e.target.value); + const { + message: { erpnextCount, shopifyCount, syncedCount }, + } = await frappe.call({ + method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.get_product_count", }); + this.wrapper.find("#count-products-shopify").text(shopifyCount); + this.wrapper.find("#count-products-erpnext").text(erpnextCount); + this.wrapper.find("#count-products-synced").text(syncedCount); } catch (error) { - console.error("Error loading Shopify accounts:", error); - this.wrapper.find("#shopify-account-select").html(''); - } - } - - // NEW: Handle account selection change - async onAccountChange(accountName) { - this.selectedAccount = accountName; - - if (accountName) { - // Enable sync button and update text - this.wrapper.find("#btn-sync-all") - .text("Sync All Products") - .prop("disabled", false) - .removeClass("btn-success") - .addClass("btn-primary"); - - // Refresh product count and table - await this.fetchProductCount(); - if (this.shopifyProductTable) { - const newProducts = await this.fetchShopifyProducts(); - this.shopifyProductTable.refresh(newProducts); - } - } else { - // Disable sync button - this.wrapper.find("#btn-sync-all") - .text("Select Account First") - .prop("disabled", true); - - // Clear counts - this.wrapper.find("#count-products-shopify").text("-"); - this.wrapper.find("#count-products-erpnext").text("-"); - this.wrapper.find("#count-products-synced").text("-"); + frappe.throw(__("Error fetching product count.")); } } @@ -223,42 +159,13 @@ shopify.ProductImporter = class { this.wrapper.find(".shopify-datatable-footer").show(); } - // UPDATED: Add account validation and parameter - async fetchProductCount() { - if (!this.selectedAccount) { - return; - } - - try { - const { - message: { erpnextCount, shopifyCount, syncedCount }, - } = await frappe.call({ - method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.get_product_count", - args: { account: this.selectedAccount }, // Add account parameter - }); - - this.wrapper.find("#count-products-shopify").text(shopifyCount); - this.wrapper.find("#count-products-erpnext").text(erpnextCount); - this.wrapper.find("#count-products-synced").text(syncedCount); - } catch (error) { - frappe.throw(__("Error fetching product count.")); - } - } - async fetchShopifyProducts(from_ = null) { - if (!this.selectedAccount) { - return []; - } - try { const { message: { products, nextUrl, prevUrl }, } = await frappe.call({ method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.get_shopify_products", - args: { - from_, - account: this.selectedAccount // Add account parameter - }, + args: { from_ }, }); this.nextUrl = nextUrl; this.prevUrl = prevUrl; @@ -349,19 +256,10 @@ shopify.ProductImporter = class { this.wrapper.on("click", "#btn-sync-all", (e) => this.syncAll(e)); } - // UPDATED: Add account validation and parameter async syncProduct(product) { - if (!this.selectedAccount) { - frappe.throw(__("Please select a Shopify account first.")); - return false; - } - const { message: status } = await frappe.call({ method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.sync_product", - args: { - product, - account: this.selectedAccount // Add account parameter - }, + args: { product }, }); if (status) this.fetchProductCount(); @@ -369,19 +267,10 @@ shopify.ProductImporter = class { return status; } - // UPDATED: Add account validation and parameter async resyncProduct(product) { - if (!this.selectedAccount) { - frappe.throw(__("Please select a Shopify account first.")); - return false; - } - const { message: status } = await frappe.call({ method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.resync_product", - args: { - product, - account: this.selectedAccount // Add account parameter - }, + args: { product }, }); if (status) this.fetchProductCount(); @@ -405,13 +294,7 @@ shopify.ProductImporter = class { this.shopifyProductTable.clearToastMessage(); } - // UPDATED: Add account validation and parameter syncAll() { - if (!this.selectedAccount) { - frappe.throw(__("Please select a Shopify account first.")); - return; - } - this.checkSyncStatus(); this.toggleSyncAllButton(); @@ -419,8 +302,7 @@ shopify.ProductImporter = class { frappe.msgprint(__("Sync already in progress")); } else { frappe.call({ - method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.queue_sync_all_products", - args: { account: this.selectedAccount }, + method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.import_all_products", }); } diff --git a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py index 7d6127096..67dab4b80 100644 --- a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py +++ b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py @@ -6,7 +6,7 @@ from ecommerce_integrations.ecommerce_integrations.doctype.ecommerce_item import ecommerce_item from ecommerce_integrations.shopify.connection import temp_shopify_session -from ecommerce_integrations.shopify.constants import MODULE_NAME, ACCOUNT_DOCTYPE, SETTING_DOCTYPE +from ecommerce_integrations.shopify.constants import MODULE_NAME from ecommerce_integrations.shopify.product import ShopifyProduct # constants @@ -15,20 +15,20 @@ @frappe.whitelist() -def get_shopify_products(from_=None, account=None): - shopify_products = fetch_all_products(from_, account) +def get_shopify_products(from_=None): + shopify_products = fetch_all_products(from_) return shopify_products -def fetch_all_products(from_=None, account=None): +def fetch_all_products(from_=None): # format shopify collection for datatable - collection = _fetch_products_from_shopify(from_, account=account) + collection = _fetch_products_from_shopify(from_) products = [] for product in collection: d = product.to_dict() - d["synced"] = ecommerce_item.is_synced(MODULE_NAME, str(product.id)) + d["synced"] = is_synced(product.id) products.append(d) next_url = None @@ -46,8 +46,8 @@ def fetch_all_products(from_=None, account=None): } -@temp_shopify_session -def _fetch_products_from_shopify(from_=None, limit=20, account=None): +@temp_shopify_session(shopify_account=None) +def _fetch_products_from_shopify(from_=None, limit=20): if from_: collection = Product.find(from_=from_) else: @@ -57,14 +57,14 @@ def _fetch_products_from_shopify(from_=None, limit=20, account=None): @frappe.whitelist() -def get_product_count(account=None): +def get_product_count(): items = frappe.db.get_list("Item", {"variant_of": ["is", "not set"]}) erpnext_count = len(items) sync_items = frappe.db.get_list("Ecommerce Item", {"variant_of": ["is", "not set"]}) synced_count = len(sync_items) - shopify_count = get_shopify_product_count(account=account) + shopify_count = get_shopify_product_count() return { "shopifyCount": shopify_count, @@ -73,55 +73,82 @@ def get_product_count(account=None): } -@temp_shopify_session -def get_shopify_product_count(account=None): +@temp_shopify_session(shopify_account=None) +def get_shopify_product_count(): return Product.count() @frappe.whitelist() -def sync_product(product, account=None): - """Sync a single product with account context.""" - shopify_product = ShopifyProduct(product, account=account) # Pass account parameter - shopify_product.sync_product() - return True +def sync_product(product): + try: + shopify_product = ShopifyProduct(product) + shopify_product.sync_product() + + return True + except Exception: + frappe.db.rollback() + return False + @frappe.whitelist() -def resync_product(product, account=None): - """Resync a single product with account context.""" - return _resync_product(product, account=account) - -@temp_shopify_session -def _resync_product(product, account=None): - """Internal resync with account context.""" - shopify_product = ShopifyProduct(product, account=account) # Pass account parameter - shopify_product.sync_product() - return True +def resync_product(product): + return _resync_product(product) + + +@temp_shopify_session(shopify_account=None) +def _resync_product(product): + savepoint = "shopify_resync_product" + try: + item = Product.find(product) + + frappe.db.savepoint(savepoint) + for variant in item.variants: + shopify_product = ShopifyProduct(product, variant_id=variant.id) + shopify_product.sync_product() + + return True + except Exception: + frappe.db.rollback(save_point=savepoint) + return False + + +def is_synced(product): + return ecommerce_item.is_synced(MODULE_NAME, integration_item_code=product) + @frappe.whitelist() +def import_all_products(): + frappe.enqueue( + queue_sync_all_products, + queue="long", + job_name=SYNC_JOB_NAME, + key=REALTIME_KEY, + ) + + def queue_sync_all_products(*args, **kwargs): - account = kwargs.get('account') start_time = process_time() - counts = get_product_count(account=account) + counts = get_product_count() publish("Syncing all products...") if counts["shopifyCount"] < counts["syncedCount"]: publish("⚠ Shopify has less products than ERPNext.") _sync = True - collection = _fetch_products_from_shopify(limit=100, account=account) + collection = _fetch_products_from_shopify(limit=100) savepoint = "shopify_product_sync" while _sync: for product in collection: try: publish(f"Syncing product {product.id}", br=False) frappe.db.savepoint(savepoint) - if ecommerce_item.is_synced(MODULE_NAME, str(product.id)): + if is_synced(product.id): publish(f"Product {product.id} already synced. Skipping...") continue - shopify_product = ShopifyProduct(product.id, account=account) - shopify_product.sync_product(account=account) + shopify_product = ShopifyProduct(product.id) + shopify_product.sync_product() publish(f"βœ… Synced Product {product.id}", synced=True) @@ -137,7 +164,7 @@ def queue_sync_all_products(*args, **kwargs): if collection.has_next_page(): frappe.db.commit() # prevents too many write request error - collection = _fetch_products_from_shopify(from_=collection.next_page_url, account=account) + collection = _fetch_products_from_shopify(from_=collection.next_page_url) else: _sync = False @@ -156,54 +183,3 @@ def publish(message, synced=False, error=False, done=False, br=True): "done": done, }, ) - - -@frappe.whitelist() -def get_shopify_accounts(): - """Get all enabled Shopify accounts with legacy fallback support.""" - from ecommerce_integrations.shopify.utils import resolve_account_context - - try: - # Get all enabled Shopify accounts - accounts = frappe.get_all( - ACCOUNT_DOCTYPE, - filters={"enabled": 1}, - fields=["name", "account_title", "shop_domain", "company"] - ) - - # If no multi-tenant accounts found, check legacy setting - if not accounts: - try: - legacy_setting = resolve_account_context() # Gets legacy setting - if legacy_setting.is_enabled(): - # Return legacy setting as a single account option - return [{ - "name": "legacy", - "shop_url": legacy_setting.shopify_url or "", - "company": legacy_setting.company or "", - "title": "Legacy Shopify Setting" - }] - except: - # No legacy setting available - pass - - return [] - - # Format multi-tenant accounts for frontend display - formatted_accounts = [] - for account in accounts: - formatted_accounts.append({ - "name": account.name, - "shop_url": f"https://{account.shop_domain}" if account.shop_domain else "", - "company": account.company or "", - "title": account.account_title or account.shop_domain - }) - - return formatted_accounts - - except Exception as e: - frappe.log_error( - message=f"Error fetching Shopify accounts: {str(e)}", - title="Shopify Import Products Error" - ) - return [] diff --git a/ecommerce_integrations/shopify/product.py b/ecommerce_integrations/shopify/product.py index ee86e8043..59f1b0ab0 100644 --- a/ecommerce_integrations/shopify/product.py +++ b/ecommerce_integrations/shopify/product.py @@ -52,7 +52,7 @@ def get_erpnext_item(self): has_variants=self.has_variants, ) - @temp_shopify_session + @temp_shopify_session(shopify_account=None) def sync_product(self): if not self.is_synced(): shopify_product = Product.find(self.product_id) diff --git a/ecommerce_integrations/shopify/utils.py b/ecommerce_integrations/shopify/utils.py index f7fbb173e..95e0b0bbb 100644 --- a/ecommerce_integrations/shopify/utils.py +++ b/ecommerce_integrations/shopify/utils.py @@ -27,6 +27,12 @@ def get_user_shopify_account(): return None +def get_company_shopify_account(company): + print("get_company_shopify_account called for company ", company) + account = frappe.get_doc("Shopify Account", {"company": company}) + return account + + def create_shopify_log(**kwargs): return create_log(module_def=MODULE_NAME, **kwargs) diff --git a/ecommerce_integrations/unicommerce/inventory.py b/ecommerce_integrations/unicommerce/inventory.py index d8f6961de..426a0917a 100644 --- a/ecommerce_integrations/unicommerce/inventory.py +++ b/ecommerce_integrations/unicommerce/inventory.py @@ -10,13 +10,25 @@ ) from ecommerce_integrations.controllers.scheduling import need_to_run from ecommerce_integrations.unicommerce.api_client import UnicommerceAPIClient -from ecommerce_integrations.unicommerce.constants import MODULE_NAME, SETTINGS_DOCTYPE +from ecommerce_integrations.unicommerce.constants import MODULE_NAME, ACCOUNT_DOCTYPE # Note: Undocumented but currently handles ~1000 inventory changes in one request. # Remaining to be done in next interval. MAX_INVENTORY_UPDATE_IN_REQUEST = 1000 +def get_user_shopify_account(): + user = frappe.session.user + print("get_user_shopify_account called for user ", user) + existing_permission = frappe.db.exists("User Permission", {"user": user, "allow": "Company"}) + has_company = bool(existing_permission) + if has_company: + company_id = frappe.db.get_value("User Permission", existing_permission, "for_value") + account = frappe.get_doc("Shopify Account", {"company": company_id}) + return account + return None + + def update_inventory_on_unicommerce(client=None, force=False): """Update ERPnext warehouse wise inventory to Unicommerce. @@ -25,13 +37,13 @@ def update_inventory_on_unicommerce(client=None, force=False): force=True ignores the set frequency. """ - settings = frappe.get_cached_doc(SETTINGS_DOCTYPE) + settings = get_user_shopify_account() if not settings.is_enabled() or not settings.enable_inventory_sync: return # check if need to run based on configured sync frequency - if not force and not need_to_run(SETTINGS_DOCTYPE, "inventory_sync_frequency", "last_inventory_sync"): + if not force and not need_to_run(ACCOUNT_DOCTYPE, settings, "inventory_sync_frequency", "last_inventory_sync"): return # get configured warehouses From 56e660b0e8c2c65bd9ec6f27f88c60ef0876ea76 Mon Sep 17 00:00:00 2001 From: ahmad Date: Wed, 22 Oct 2025 12:21:14 +0000 Subject: [PATCH 14/30] Remove Shopify Setting integration files and related test cases --- .../doctype/shopify_setting/__init__.py | 0 .../shopify_setting/shopify_setting.js | 99 ----- .../shopify_setting/shopify_setting.json | 416 ------------------ .../shopify_setting/shopify_setting.py | 246 ----------- .../shopify_setting/test_shopify_setting.py | 59 --- 5 files changed, 820 deletions(-) delete mode 100644 ecommerce_integrations/shopify/doctype/shopify_setting/__init__.py delete mode 100644 ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.js delete mode 100644 ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json delete mode 100644 ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py delete mode 100644 ecommerce_integrations/shopify/doctype/shopify_setting/test_shopify_setting.py diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/__init__.py b/ecommerce_integrations/shopify/doctype/shopify_setting/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.js b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.js deleted file mode 100644 index 45be645ac..000000000 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.js +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) 2021, Frappe and contributors -// For license information, please see LICENSE - -frappe.provide("ecommerce_integrations.shopify.shopify_setting"); - -frappe.ui.form.on("Shopify Setting", { - onload: function (frm) { - frappe.call({ - method: "ecommerce_integrations.utils.naming_series.get_series", - callback: function (r) { - $.each(r.message, (key, value) => { - set_field_options(key, value); - }); - }, - }); - }, - - fetch_shopify_locations: function (frm) { - frappe.call({ - doc: frm.doc, - method: "update_location_table", - callback: (r) => { - if (!r.exc) refresh_field("shopify_warehouse_mapping"); - }, - }); - }, - - refresh: function (frm) { - frm.add_custom_button(__("Import Products"), function () { - frappe.set_route("shopify-import-products"); - }); - frm.add_custom_button(__("View Logs"), () => { - frappe.set_route("List", "Ecommerce Integration Log", { - integration: "Shopify", - }); - }); - frm.trigger("setup_queries"); - }, - - setup_queries: function (frm) { - const warehouse_query = () => { - return { - filters: { - company: frm.doc.company, - is_group: 0, - disabled: 0, - }, - }; - }; - frm.set_query("warehouse", warehouse_query); - frm.set_query( - "erpnext_warehouse", - "shopify_warehouse_mapping", - warehouse_query - ); - - frm.set_query("price_list", () => { - return { - filters: { - selling: 1, - }, - }; - }); - - frm.set_query("cost_center", () => { - return { - filters: { - company: frm.doc.company, - is_group: "No", - }, - }; - }); - - frm.set_query("cash_bank_account", () => { - return { - filters: [ - ["Account", "account_type", "in", ["Cash", "Bank"]], - ["Account", "root_type", "=", "Asset"], - ["Account", "is_group", "=", 0], - ["Account", "company", "=", frm.doc.company], - ], - }; - }); - - const tax_query = () => { - return { - query: "erpnext.controllers.queries.tax_account_query", - filters: { - account_type: ["Tax", "Chargeable", "Expense Account"], - company: frm.doc.company, - }, - }; - }; - - frm.set_query("tax_account", "taxes", tax_query); - frm.set_query("default_sales_tax_account", tax_query); - frm.set_query("default_shipping_charges_account", tax_query); - }, -}); diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json deleted file mode 100644 index 01722169b..000000000 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json +++ /dev/null @@ -1,416 +0,0 @@ -{ - "actions": [], - "creation": "2021-04-13 13:30:54.909583", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "enable_shopify", - "column_break_4", - "section_break_2", - "shopify_url", - "column_break_3", - "password", - "shared_secret", - "section_break_4", - "webhooks", - "customer_settings_section", - "default_customer", - "column_break_14", - "customer_group", - "company_dependent_settings_section", - "company", - "cash_bank_account", - "column_break_19", - "cost_center", - "section_break_25", - "sales_order_series", - "delivery_note_series", - "sales_invoice_series", - "shipping_item", - "column_break_27", - "sync_delivery_note", - "sync_sales_invoice", - "add_shipping_as_item", - "consolidate_taxes", - "section_break_22", - "html_16", - "taxes", - "section_break_zeoy", - "default_sales_tax_account", - "column_break_qibo", - "default_shipping_charges_account", - "erpnext_to_shopify_sync_section", - "upload_erpnext_items", - "update_shopify_item_on_update", - "column_break_34", - "sync_new_item_as_active", - "upload_variants_as_items", - "inventory_sync_section", - "warehouse", - "update_erpnext_stock_levels_to_shopify", - "inventory_sync_frequency", - "fetch_shopify_locations", - "shopify_warehouse_mapping", - "sync_old_orders_section", - "sync_old_orders", - "column_break_45", - "old_orders_from", - "old_orders_to", - "is_old_data_migrated", - "last_inventory_sync" - ], - "fields": [ - { - "default": "0", - "fieldname": "enable_shopify", - "fieldtype": "Check", - "label": "Enable Shopify" - }, - { - "fieldname": "column_break_4", - "fieldtype": "Column Break" - }, - { - "fieldname": "section_break_2", - "fieldtype": "Section Break", - "label": "Authentication Details" - }, - { - "description": "eg: frappe.myshopify.com", - "fieldname": "shopify_url", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Shop URL", - "mandatory_depends_on": "eval:doc.enable_shopify" - }, - { - "fieldname": "column_break_3", - "fieldtype": "Column Break" - }, - { - "fieldname": "password", - "fieldtype": "Password", - "label": "Password / Access Token", - "mandatory_depends_on": "eval:doc.enable_shopify" - }, - { - "fieldname": "shared_secret", - "fieldtype": "Data", - "label": "Shared secret / API Secret", - "mandatory_depends_on": "eval:doc.enable_shopify" - }, - { - "collapsible": 1, - "fieldname": "section_break_4", - "fieldtype": "Section Break", - "label": "Webhooks Details" - }, - { - "fieldname": "webhooks", - "fieldtype": "Table", - "label": "Webhooks", - "options": "Shopify Webhooks", - "read_only": 1 - }, - { - "fieldname": "customer_settings_section", - "fieldtype": "Section Break", - "label": "Customer Settings" - }, - { - "description": "Customer Group will set to selected group while syncing customers from Shopify", - "fieldname": "customer_group", - "fieldtype": "Link", - "label": "Customer Group", - "mandatory_depends_on": "eval:doc.enable_shopify", - "options": "Customer Group" - }, - { - "description": "If individual warehouse are not mapped, the default warehouse is considered for transactions.", - "fieldname": "warehouse", - "fieldtype": "Link", - "label": "Default warehouse", - "mandatory_depends_on": "eval:doc.enable_shopify", - "options": "Warehouse" - }, - { - "description": "If Shopify does not have a customer in the order, then while syncing the orders, the system will consider the default customer for the order", - "fieldname": "default_customer", - "fieldtype": "Link", - "label": "Default Customer", - "options": "Customer" - }, - { - "fieldname": "column_break_14", - "fieldtype": "Column Break" - }, - { - "fieldname": "company_dependent_settings_section", - "fieldtype": "Section Break", - "label": "Company Dependent settings" - }, - { - "fieldname": "company", - "fieldtype": "Link", - "label": "Company", - "mandatory_depends_on": "eval:doc.enable_shopify", - "options": "Company" - }, - { - "fieldname": "cash_bank_account", - "fieldtype": "Link", - "label": "Cash/Bank Account", - "mandatory_depends_on": "eval:doc.enable_shopify", - "options": "Account" - }, - { - "fieldname": "column_break_19", - "fieldtype": "Column Break" - }, - { - "fieldname": "cost_center", - "fieldtype": "Link", - "label": "Cost Center", - "mandatory_depends_on": "eval:doc.enable_shopify", - "options": "Cost Center" - }, - { - "fieldname": "section_break_25", - "fieldtype": "Section Break", - "label": "Order Sync Settings" - }, - { - "fieldname": "sales_order_series", - "fieldtype": "Select", - "label": "Sales Order Series", - "mandatory_depends_on": "eval:doc.enable_shopify" - }, - { - "fieldname": "column_break_27", - "fieldtype": "Column Break" - }, - { - "default": "0", - "fieldname": "sync_delivery_note", - "fieldtype": "Check", - "label": "Import Delivery Notes from Shopify on Shipment" - }, - { - "depends_on": "eval:doc.sync_delivery_note==1", - "fieldname": "delivery_note_series", - "fieldtype": "Select", - "label": "Delivery Note Series", - "mandatory_depends_on": "eval:doc.sync_delivery_note" - }, - { - "default": "0", - "fieldname": "sync_sales_invoice", - "fieldtype": "Check", - "label": "Import Sales Invoice from Shopify if Payment is marked" - }, - { - "depends_on": "eval:doc.sync_sales_invoice==1", - "fieldname": "sales_invoice_series", - "fieldtype": "Select", - "label": "Sales Invoice Series", - "mandatory_depends_on": "eval:doc.sync_sales_invoice" - }, - { - "fieldname": "section_break_22", - "fieldtype": "Section Break" - }, - { - "fieldname": "html_16", - "fieldtype": "HTML", - "options": "Map Shopify Taxes / Shipping Charges to ERPNext Account" - }, - { - "fieldname": "taxes", - "fieldtype": "Table", - "label": "Shopify Tax Account", - "mandatory_depends_on": "eval:doc.enable_shopify", - "options": "Shopify Tax Account" - }, - { - "fieldname": "erpnext_to_shopify_sync_section", - "fieldtype": "Section Break", - "label": "ERPNext to Shopify Sync" - }, - { - "default": "0", - "fieldname": "upload_erpnext_items", - "fieldtype": "Check", - "label": "Upload new ERPNext Items to Shopify" - }, - { - "default": "0", - "depends_on": "eval:doc.upload_erpnext_items", - "fieldname": "update_shopify_item_on_update", - "fieldtype": "Check", - "label": "Update Shopify Item after updating ERPNext item" - }, - { - "depends_on": "eval:doc.update_erpnext_stock_levels_to_shopify", - "fieldname": "shopify_warehouse_mapping", - "fieldtype": "Table", - "label": "Shopify Warehouse Mapping", - "mandatory_depends_on": "eval:doc.update_erpnext_stock_levels_to_shopify", - "options": "Shopify Warehouse Mapping" - }, - { - "depends_on": "eval:doc.update_erpnext_stock_levels_to_shopify", - "fieldname": "fetch_shopify_locations", - "fieldtype": "Button", - "label": "Fetch Shopify Locations" - }, - { - "fieldname": "inventory_sync_section", - "fieldtype": "Section Break", - "label": "Inventory Sync" - }, - { - "default": "0", - "fieldname": "update_erpnext_stock_levels_to_shopify", - "fieldtype": "Check", - "label": "Update ERPNext stock levels to Shopify" - }, - { - "default": "0", - "fieldname": "is_old_data_migrated", - "fieldtype": "Check", - "hidden": 1, - "label": "Is old data migrated" - }, - { - "default": "0", - "fieldname": "sync_old_orders", - "fieldtype": "Check", - "label": "Sync Old Orders" - }, - { - "fieldname": "sync_old_orders_section", - "fieldtype": "Section Break", - "label": "Sync Old Orders" - }, - { - "depends_on": "eval:doc.sync_old_orders", - "fieldname": "old_orders_from", - "fieldtype": "Datetime", - "label": "From", - "mandatory_depends_on": "eval:doc.sync_old_orders" - }, - { - "depends_on": "eval:doc.sync_old_orders", - "fieldname": "old_orders_to", - "fieldtype": "Datetime", - "label": "To", - "mandatory_depends_on": "eval:doc.sync_old_orders" - }, - { - "fieldname": "column_break_45", - "fieldtype": "Column Break" - }, - { - "default": "60", - "depends_on": "eval:doc.update_erpnext_stock_levels_to_shopify", - "fieldname": "inventory_sync_frequency", - "fieldtype": "Select", - "label": "Inventory Sync Frequency (In Minutes)", - "mandatory_depends_on": "eval:doc.update_erpnext_stock_levels_to_shopify", - "options": "5\n10\n15\n30\n60" - }, - { - "fieldname": "last_inventory_sync", - "fieldtype": "Datetime", - "hidden": 1, - "label": "Last Inventory Sync", - "read_only": 1 - }, - { - "fieldname": "column_break_34", - "fieldtype": "Column Break" - }, - { - "default": "0", - "description": "Caution: Only 3 attributes will be accepted by Shopify", - "fieldname": "upload_variants_as_items", - "fieldtype": "Check", - "label": "Upload ERPNext Variants as Shopify Items" - }, - { - "default": "0", - "fieldname": "sync_new_item_as_active", - "fieldtype": "Check", - "label": "Sync New Items as Active" - }, - { - "default": "0", - "fieldname": "add_shipping_as_item", - "fieldtype": "Check", - "label": "Add Shipping Charge as an Item in Order" - }, - { - "depends_on": "add_shipping_as_item", - "fieldname": "shipping_item", - "fieldtype": "Link", - "label": "Shipping Item", - "mandatory_depends_on": "add_shipping_as_item", - "options": "Item" - }, - { - "default": "0", - "description": "By default, tax rows are added per item, checking this will consolidate them.", - "fieldname": "consolidate_taxes", - "fieldtype": "Check", - "label": "Consolidate Taxes in Order" - }, - { - "description": "When no sales tax mapping is found this tax account will be used as the default account. This is only applied for Sales Tax. Any shipping related charges still need to be mapped separately.", - "fieldname": "default_sales_tax_account", - "fieldtype": "Link", - "label": "Default Sales Tax Account", - "options": "Account" - }, - { - "fieldname": "section_break_zeoy", - "fieldtype": "Section Break", - "hide_border": 1 - }, - { - "fieldname": "column_break_qibo", - "fieldtype": "Column Break" - }, - { - "description": "When no shipping charge account mapping is found this account will be used as the default account.", - "fieldname": "default_shipping_charges_account", - "fieldtype": "Link", - "label": "Default Shipping Charges Account", - "options": "Account" - } - ], - "index_web_pages_for_search": 1, - "issingle": 1, - "links": [], - "modified": "2023-10-24 10:38:49.247431", - "modified_by": "Administrator", - "module": "shopify", - "name": "Shopify Setting", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "sort_field": "modified", - "sort_order": "DESC", - "states": [], - "track_changes": 1 -} \ No newline at end of file diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py deleted file mode 100644 index dc974e70e..000000000 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py +++ /dev/null @@ -1,246 +0,0 @@ -# Copyright (c) 2021, Frappe and contributors -# For license information, please see LICENSE - -import frappe -from frappe import _ -from frappe.custom.doctype.custom_field.custom_field import create_custom_fields -from frappe.utils import get_datetime -from shopify.collection import PaginatedIterator -from shopify.resources import Location - -from ecommerce_integrations.controllers.setting import ( - ERPNextWarehouse, - IntegrationWarehouse, - SettingController, -) -from ecommerce_integrations.shopify import connection -from ecommerce_integrations.shopify.constants import ( - ADDRESS_ID_FIELD, - CUSTOMER_ID_FIELD, - FULLFILLMENT_ID_FIELD, - ITEM_SELLING_RATE_FIELD, - ORDER_ID_FIELD, - ORDER_ITEM_DISCOUNT_FIELD, - ORDER_NUMBER_FIELD, - ORDER_STATUS_FIELD, - SUPPLIER_ID_FIELD, -) -from ecommerce_integrations.shopify.utils import ( - ensure_old_connector_is_disabled, - migrate_from_old_connector, -) - - -class ShopifySetting(SettingController): - def is_enabled(self) -> bool: - return bool(self.enable_shopify) - - def validate(self): - ensure_old_connector_is_disabled() - - if self.shopify_url: - self.shopify_url = self.shopify_url.replace("https://", "") - self._handle_webhooks() - self._validate_warehouse_links() - self._initalize_default_values() - - if self.is_enabled(): - setup_custom_fields() - - def on_update(self): - if self.is_enabled() and not self.is_old_data_migrated: - migrate_from_old_connector() - - def _handle_webhooks(self): - 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.") + "
" - 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}) - - elif not self.is_enabled(): - connection.unregister_webhooks(self.shopify_url, self.get_password("password")) - - self.webhooks = list() # remove all webhooks - - def _validate_warehouse_links(self): - for wh_map in self.shopify_warehouse_mapping: - if not wh_map.erpnext_warehouse: - frappe.throw(_("ERPNext warehouse required in warehouse map table.")) - - def _initalize_default_values(self): - if not self.last_inventory_sync: - self.last_inventory_sync = get_datetime("1970-01-01") - - @frappe.whitelist() - @connection.temp_shopify_session - def update_location_table(self): - """Fetch locations from shopify and add it to child table so user can - map it with correct ERPNext warehouse.""" - - self.shopify_warehouse_mapping = [] - for locations in PaginatedIterator(Location.find()): - for location in locations: - self.append( - "shopify_warehouse_mapping", - {"shopify_location_id": location.id, "shopify_location_name": location.name}, - ) - - def get_erpnext_warehouses(self) -> list[ERPNextWarehouse]: - return [wh_map.erpnext_warehouse for wh_map in self.shopify_warehouse_mapping] - - def get_erpnext_to_integration_wh_mapping(self) -> dict[ERPNextWarehouse, IntegrationWarehouse]: - return { - wh_map.erpnext_warehouse: wh_map.shopify_location_id for wh_map in self.shopify_warehouse_mapping - } - - def get_integration_to_erpnext_wh_mapping(self) -> dict[IntegrationWarehouse, ERPNextWarehouse]: - return { - wh_map.shopify_location_id: wh_map.erpnext_warehouse for wh_map in self.shopify_warehouse_mapping - } - - -def setup_custom_fields(): - custom_fields = { - "Item": [ - dict( - fieldname=ITEM_SELLING_RATE_FIELD, - label="Shopify Selling Rate", - fieldtype="Currency", - insert_after="standard_rate", - ) - ], - "Customer": [ - dict( - fieldname=CUSTOMER_ID_FIELD, - label="Shopify Customer Id", - fieldtype="Data", - insert_after="series", - read_only=1, - print_hide=1, - ) - ], - "Supplier": [ - dict( - fieldname=SUPPLIER_ID_FIELD, - label="Shopify Supplier Id", - fieldtype="Data", - insert_after="supplier_name", - read_only=1, - print_hide=1, - ) - ], - "Address": [ - dict( - fieldname=ADDRESS_ID_FIELD, - label="Shopify Address Id", - fieldtype="Data", - insert_after="fax", - read_only=1, - print_hide=1, - ) - ], - "Sales Order": [ - dict( - fieldname=ORDER_ID_FIELD, - label="Shopify Order Id", - fieldtype="Small Text", - insert_after="title", - read_only=1, - print_hide=1, - ), - dict( - fieldname=ORDER_NUMBER_FIELD, - label="Shopify Order Number", - fieldtype="Small Text", - insert_after=ORDER_ID_FIELD, - read_only=1, - print_hide=1, - ), - dict( - fieldname=ORDER_STATUS_FIELD, - label="Shopify Order Status", - fieldtype="Small Text", - insert_after=ORDER_NUMBER_FIELD, - read_only=1, - print_hide=1, - ), - ], - "Sales Order Item": [ - dict( - fieldname=ORDER_ITEM_DISCOUNT_FIELD, - label="Shopify Discount per unit", - fieldtype="Float", - insert_after="discount_and_margin", - read_only=1, - ), - ], - "Delivery Note": [ - dict( - fieldname=ORDER_ID_FIELD, - label="Shopify Order Id", - fieldtype="Small Text", - insert_after="title", - read_only=1, - print_hide=1, - ), - dict( - fieldname=ORDER_NUMBER_FIELD, - label="Shopify Order Number", - fieldtype="Small Text", - insert_after=ORDER_ID_FIELD, - read_only=1, - print_hide=1, - ), - dict( - fieldname=ORDER_STATUS_FIELD, - label="Shopify Order Status", - fieldtype="Small Text", - insert_after=ORDER_NUMBER_FIELD, - read_only=1, - print_hide=1, - ), - dict( - fieldname=FULLFILLMENT_ID_FIELD, - label="Shopify Fulfillment Id", - fieldtype="Small Text", - insert_after="title", - read_only=1, - print_hide=1, - ), - ], - "Sales Invoice": [ - dict( - fieldname=ORDER_ID_FIELD, - label="Shopify Order Id", - fieldtype="Small Text", - insert_after="title", - read_only=1, - print_hide=1, - ), - dict( - fieldname=ORDER_NUMBER_FIELD, - label="Shopify Order Number", - fieldtype="Small Text", - insert_after=ORDER_ID_FIELD, - read_only=1, - print_hide=1, - ), - dict( - fieldname=ORDER_STATUS_FIELD, - label="Shopify Order Status", - fieldtype="Small Text", - insert_after=ORDER_ID_FIELD, - read_only=1, - print_hide=1, - ), - ], - } - - create_custom_fields(custom_fields) diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/test_shopify_setting.py b/ecommerce_integrations/shopify/doctype/shopify_setting/test_shopify_setting.py deleted file mode 100644 index ab05370b9..000000000 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/test_shopify_setting.py +++ /dev/null @@ -1,59 +0,0 @@ -# Copyright (c) 2021, Frappe and Contributors -# See LICENSE - -import unittest - -import frappe - -from ecommerce_integrations.shopify.constants import ( - ADDRESS_ID_FIELD, - CUSTOMER_ID_FIELD, - FULLFILLMENT_ID_FIELD, - ITEM_SELLING_RATE_FIELD, - ORDER_ID_FIELD, - ORDER_ITEM_DISCOUNT_FIELD, - ORDER_NUMBER_FIELD, - ORDER_STATUS_FIELD, - SUPPLIER_ID_FIELD, -) - -from .shopify_setting import setup_custom_fields - - -class TestShopifySetting(unittest.TestCase): - @classmethod - def setUpClass(cls): - frappe.db.sql( - """delete from `tabCustom Field` - where name like '%shopify%'""" - ) - - def test_custom_field_creation(self): - setup_custom_fields() - - created_fields = frappe.get_all( - "Custom Field", - filters={"fieldname": ["LIKE", "%shopify%"]}, - fields="fieldName", - as_list=True, - order_by=None, - ) - - required_fields = set( - [ - ADDRESS_ID_FIELD, - CUSTOMER_ID_FIELD, - FULLFILLMENT_ID_FIELD, - ITEM_SELLING_RATE_FIELD, - ORDER_ID_FIELD, - ORDER_NUMBER_FIELD, - ORDER_STATUS_FIELD, - SUPPLIER_ID_FIELD, - ORDER_ITEM_DISCOUNT_FIELD, - ] - ) - - self.assertGreaterEqual(len(created_fields), 13) - created_fields_set = {d[0] for d in created_fields} - - self.assertEqual(created_fields_set, required_fields) From ee219793aaba1bf0bc52125c960d31237c11d712 Mon Sep 17 00:00:00 2001 From: ahmad Date: Tue, 28 Oct 2025 15:29:44 +0000 Subject: [PATCH 15/30] add company field to Ecommerce item to separate for multi-tenant --- .../doctype/ecommerce_item/ecommerce_item.json | 14 ++++++++++++-- .../doctype/ecommerce_item/ecommerce_item.py | 1 + 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.json b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.json index 08ad74011..0d120f3aa 100644 --- a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.json +++ b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.json @@ -9,6 +9,7 @@ "erpnext_item_code", "integration_item_code", "sku", + "company", "column_break_5", "has_variants", "variant_id", @@ -91,12 +92,20 @@ "fieldtype": "Datetime", "label": "Item Data Synced On", "read_only": 1 + }, + { + "fetch_from": "erpnext_item_code.custom_company", + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "read_only": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2022-11-15 11:36:04.733227", - "modified_by": "Administrator", + "modified": "2025-10-26 11:38:45.690155", + "modified_by": "user@nexus.com", "module": "Ecommerce Integrations", "name": "Ecommerce Item", "owner": "Administrator", @@ -114,6 +123,7 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "modified", "sort_order": "DESC", "states": [], diff --git a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.py b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.py index 81985d80c..f756593a6 100644 --- a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.py +++ b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.py @@ -147,6 +147,7 @@ def create_ecommerce_item( "doctype": "Item", "is_stock_item": 1, "is_sales_item": 1, + # TODO: see if get_default_company() should be replaced with item_dict.get("custom_company") "item_defaults": [{"company": get_default_company()}], } From 1635fba2f18fa57324027126b945b0620e00298a Mon Sep 17 00:00:00 2001 From: ahmad Date: Tue, 28 Oct 2025 15:31:14 +0000 Subject: [PATCH 16/30] Enhance Shopify integration: - Add company support for multi-tenant. - Complete integration functionalities including shopify hooks. - improve logging --- .../controllers/customer.py | 5 +- .../ecommerce_integration_log.json | 16 +- .../ecommerce_integration_log.py | 20 ++ ecommerce_integrations/shopify/connection.py | 36 ++- ecommerce_integrations/shopify/customer.py | 8 +- ecommerce_integrations/shopify/fulfillment.py | 19 +- ecommerce_integrations/shopify/inventory.py | 39 ++-- ecommerce_integrations/shopify/invoice.py | 12 +- ecommerce_integrations/shopify/order.py | 66 +++--- ecommerce_integrations/shopify/product.py | 216 ++++++++++-------- ecommerce_integrations/shopify/utils.py | 9 +- 11 files changed, 262 insertions(+), 184 deletions(-) diff --git a/ecommerce_integrations/controllers/customer.py b/ecommerce_integrations/controllers/customer.py index 4c543b7ac..b00d68c18 100644 --- a/ecommerce_integrations/controllers/customer.py +++ b/ecommerce_integrations/controllers/customer.py @@ -21,7 +21,7 @@ def get_customer_doc(self): else: raise frappe.DoesNotExistError() - def sync_customer(self, customer_name: str, customer_group: str) -> None: + def sync_customer(self, customer_name: str, customer_group: str, **kwargs) -> None: """Create customer in ERPNext if one does not exist already.""" customer = frappe.get_doc( { @@ -35,6 +35,9 @@ def sync_customer(self, customer_name: str, customer_group: str) -> None: } ) + if kwargs.get("company"): + customer.custom_company = kwargs["company"] + customer.flags.ignore_mandatory = True customer.insert(ignore_permissions=True) diff --git a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.json b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.json index 01722a1c2..704c5b99c 100644 --- a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.json +++ b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.json @@ -7,6 +7,7 @@ "field_order": [ "title", "integration", + "shopify_account", "status", "method", "message", @@ -66,12 +67,21 @@ "fieldtype": "Code", "label": "Response Data", "read_only": 1 + }, + { + "depends_on": "eval: doc.integration == \"shopify\"", + "fieldname": "shopify_account", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Shopify Account", + "options": "Shopify Account" } ], "in_create": 1, "links": [], - "modified": "2021-05-28 16:06:49.008875", - "modified_by": "Administrator", + "modified": "2025-10-28 16:06:18.084320", + "modified_by": "user@nexusdemo.com", "module": "Ecommerce Integrations", "name": "Ecommerce Integration Log", "owner": "Administrator", @@ -101,7 +111,9 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "title" } \ No newline at end of file diff --git a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.py b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.py index c5ed9f380..de156928f 100644 --- a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.py +++ b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.py @@ -13,6 +13,24 @@ class EcommerceIntegrationLog(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + integration: DF.Link | None + message: DF.Code | None + method: DF.SmallText | None + request_data: DF.Code | None + response_data: DF.Code | None + shopify_account: DF.Link | None + status: DF.Data | None + title: DF.Data | None + traceback: DF.Code | None + # end: auto-generated types def validate(self): self._set_title() @@ -47,6 +65,7 @@ def create_log( method=None, message=None, make_new=False, + shopify_account=None, ): make_new = make_new or not bool(frappe.flags.request_id) @@ -71,6 +90,7 @@ def create_log( log.request_data = request_data or log.request_data log.traceback = log.traceback or frappe.get_traceback() log.status = status + log.shopify_account = shopify_account log.save(ignore_permissions=True) frappe.db.commit() diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index 1bc0ad8a5..e1eb1d437 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -47,6 +47,18 @@ def wrapper(*args, **kwargs): return decorator +def get_auth_details(setting) -> tuple[str, str, str]: + """Get authentication details for Shopify API.""" + # setting = frappe.get_doc(ACCOUNT_DOCTYPE, setting) + return setting.shopify_url, API_VERSION, setting.get_password("password") + + +def get_temp_session_context(setting): + """Get a temporary Shopify session context manager.""" + auth_details = get_auth_details(setting) + return Session.temp(*auth_details) + + def register_webhooks(shopify_url: str, password: str) -> list[Webhook]: """Register required webhooks with shopify and return registered webhooks.""" new_webhooks = [] @@ -105,34 +117,34 @@ def get_callback_url() -> str: def store_request_data(**kwargs) -> None: if frappe.request: hmac_header = frappe.get_request_header("X-Shopify-Hmac-Sha256") + # Get shopify account + shop_domain = frappe.get_request_header("X-Shopify-Shop-Domain") + settings = frappe.get_doc(ACCOUNT_DOCTYPE, shop_domain) - # _validate_request(frappe.request, hmac_header, kwargs.get("shopify_account")) + _validate_request(frappe.request, hmac_header, secret_key=settings.shared_secret) data = json.loads(frappe.request.data) event = frappe.request.headers.get("X-Shopify-Topic") - process_request(data, event) + process_request(data, event, shopify_account=settings) -def process_request(data, event): - print("Processing webhook event: ", event, "\n", data) +def process_request(data, event, shopify_account=None): + print("Processing webhook event: ", event, "\n", shopify_account) # create log - log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data) - - # enqueue backround job + log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data, shopify_account=shopify_account.name) + print("log created") + # enqueue background job frappe.enqueue( method=EVENT_MAPPER[event], queue="short", timeout=300, is_async=True, - **{"payload": data, "request_id": log.name}, + **{"payload": data, "request_id": log.name, "shopify_account": shopify_account}, ) -def _validate_request(req, hmac_header, shopify_account): - settings = frappe.get_doc(ACCOUNT_DOCTYPE, shopify_account) - secret_key = settings.shared_secret - +def _validate_request(req, hmac_header, secret_key): sig = base64.b64encode(hmac.new(secret_key.encode("utf8"), req.data, hashlib.sha256).digest()) if sig != bytes(hmac_header.encode()): diff --git a/ecommerce_integrations/shopify/customer.py b/ecommerce_integrations/shopify/customer.py index 0a1f93e85..01a911e5b 100644 --- a/ecommerce_integrations/shopify/customer.py +++ b/ecommerce_integrations/shopify/customer.py @@ -10,23 +10,21 @@ CUSTOMER_ID_FIELD, MODULE_NAME, ) -from ecommerce_integrations.shopify.utils import get_user_shopify_account +from ecommerce_integrations.shopify.utils import get_company_shopify_account class ShopifyCustomer(EcommerceCustomer): def __init__(self, customer_id: str): - self.setting = get_user_shopify_account() super().__init__(customer_id, CUSTOMER_ID_FIELD, MODULE_NAME) - def sync_customer(self, customer: dict[str, Any]) -> None: + def sync_customer(self, customer: dict[str, Any], customer_group: str) -> None: """Create Customer in ERPNext using shopify's Customer dict.""" customer_name = cstr(customer.get("first_name")) + " " + cstr(customer.get("last_name")) if len(customer_name.strip()) == 0: customer_name = customer.get("email") - customer_group = self.setting.customer_group - super().sync_customer(customer_name, customer_group) + super().sync_customer(customer_name, customer_group, company=customer.get("company")) billing_address = customer.get("billing_address", {}) or customer.get("default_address") shipping_address = customer.get("shipping_address", {}) diff --git a/ecommerce_integrations/shopify/fulfillment.py b/ecommerce_integrations/shopify/fulfillment.py index 546616957..0d336e61d 100644 --- a/ecommerce_integrations/shopify/fulfillment.py +++ b/ecommerce_integrations/shopify/fulfillment.py @@ -11,25 +11,25 @@ # SETTING_DOCTYPE, ) from ecommerce_integrations.shopify.order import get_sales_order -from ecommerce_integrations.shopify.utils import create_shopify_log, get_user_shopify_account +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, shopify_account=None): frappe.set_user("Administrator") - setting = get_user_shopify_account() frappe.flags.request_id = request_id order = payload try: sales_order = get_sales_order(cstr(order["id"])) + shopify_account_name = shopify_account.name if shopify_account else None if sales_order: - create_delivery_note(order, setting, sales_order) - create_shopify_log(status="Success") + create_delivery_note(order, shopify_account, sales_order) + create_shopify_log(status="Success", shopify_account=shopify_account_name) else: - create_shopify_log(status="Invalid", message="Sales Order not found for syncing delivery note.") + create_shopify_log(status="Invalid", message="Sales Order not found for syncing delivery note.", shopify_account=shopify_account_name) except Exception as e: - create_shopify_log(status="Error", exception=e, rollback=True) + create_shopify_log(status="Error", exception=e, rollback=True, shopify_account=shopify_account_name) def create_delivery_note(shopify_order, setting, so): @@ -49,7 +49,7 @@ def create_delivery_note(shopify_order, setting, so): dn.posting_date = getdate(fulfillment.get("created_at")) dn.naming_series = setting.delivery_note_series or "DN-Shopify-" dn.items = get_fulfillment_items( - dn.items, fulfillment.get("line_items"), fulfillment.get("location_id") + dn.items, fulfillment.get("line_items"), setting, fulfillment.get("location_id") ) dn.flags.ignore_mandatory = True dn.save() @@ -59,13 +59,12 @@ def create_delivery_note(shopify_order, setting, so): dn.add_comment(text=f"Order Note: {shopify_order.get('note')}") -def get_fulfillment_items(dn_items, fulfillment_items, location_id=None): +def get_fulfillment_items(dn_items, fulfillment_items, setting, location_id=None): # local import to avoid circular imports from ecommerce_integrations.shopify.product import get_item_code fulfillment_items = deepcopy(fulfillment_items) - setting = get_user_shopify_account() wh_map = setting.get_integration_to_erpnext_wh_mapping() warehouse = wh_map.get(str(location_id)) or setting.warehouse diff --git a/ecommerce_integrations/shopify/inventory.py b/ecommerce_integrations/shopify/inventory.py index 2ba6d4478..f793a9e2f 100644 --- a/ecommerce_integrations/shopify/inventory.py +++ b/ecommerce_integrations/shopify/inventory.py @@ -10,9 +10,9 @@ update_inventory_sync_status, ) from ecommerce_integrations.controllers.scheduling import need_to_run -from ecommerce_integrations.shopify.connection import temp_shopify_session +from ecommerce_integrations.shopify.connection import get_temp_session_context from ecommerce_integrations.shopify.constants import MODULE_NAME, ACCOUNT_DOCTYPE -from ecommerce_integrations.shopify.utils import create_shopify_log, get_user_shopify_account +from ecommerce_integrations.shopify.utils import create_shopify_log def update_inventory_on_shopify() -> None: @@ -20,24 +20,29 @@ def update_inventory_on_shopify() -> None: Called by scheduler on configured interval. """ - setting = get_user_shopify_account() - print("Updating inventory on shopify for account ", setting.name) + all_accounts = frappe.get_all( + ACCOUNT_DOCTYPE, + filters={"enable_shopify": 1, "update_erpnext_stock_levels_to_shopify": 1}, + pluck="name", + ) + for account in all_accounts: + setting = frappe.get_doc(ACCOUNT_DOCTYPE, account) + if not setting.is_enabled() or not setting.update_erpnext_stock_levels_to_shopify: + continue - if not setting.is_enabled() or not setting.update_erpnext_stock_levels_to_shopify: - return + if not need_to_run(ACCOUNT_DOCTYPE, setting.name, "inventory_sync_frequency", "last_inventory_sync"): + continue - if not need_to_run(ACCOUNT_DOCTYPE, setting.name, "inventory_sync_frequency", "last_inventory_sync"): - return - warehous_map = setting.get_erpnext_to_integration_wh_mapping() - inventory_levels = get_inventory_levels(tuple(warehous_map.keys()), MODULE_NAME) + warehous_map = setting.get_erpnext_to_integration_wh_mapping() + inventory_levels = get_inventory_levels(tuple(warehous_map.keys()), MODULE_NAME) - if inventory_levels: - upload_inventory_data_to_shopify(inventory_levels, warehous_map) + if inventory_levels: + with get_temp_session_context(setting): + upload_inventory_data_to_shopify(inventory_levels, warehous_map, setting) -@temp_shopify_session(shopify_account=None) -def upload_inventory_data_to_shopify(inventory_levels, warehous_map) -> None: +def upload_inventory_data_to_shopify(inventory_levels, warehous_map, setting) -> None: synced_on = now() for inventory_sync_batch in create_batch(inventory_levels, 50): @@ -66,10 +71,10 @@ def upload_inventory_data_to_shopify(inventory_levels, warehous_map) -> None: frappe.db.commit() - _log_inventory_update_status(inventory_sync_batch) + _log_inventory_update_status(inventory_sync_batch, setting) -def _log_inventory_update_status(inventory_levels) -> None: +def _log_inventory_update_status(inventory_levels, setting) -> None: """Create log of inventory update.""" log_message = "variant_id,location_id,status,failure_reason\n" @@ -91,4 +96,4 @@ def _log_inventory_update_status(inventory_levels) -> None: log_message = f"Updated {percent_successful * 100}% items\n\n" + log_message - create_shopify_log(method="update_inventory_on_shopify", status=status, message=log_message) + create_shopify_log(method="update_inventory_on_shopify", status=status, message=log_message, shopify_account=setting.name) diff --git a/ecommerce_integrations/shopify/invoice.py b/ecommerce_integrations/shopify/invoice.py index 84bbaf35a..14189e7b2 100644 --- a/ecommerce_integrations/shopify/invoice.py +++ b/ecommerce_integrations/shopify/invoice.py @@ -10,24 +10,24 @@ from ecommerce_integrations.shopify.utils import create_shopify_log, get_user_shopify_account -def prepare_sales_invoice(payload, request_id=None): +def prepare_sales_invoice(payload, request_id=None, shopify_account=None): from ecommerce_integrations.shopify.order import get_sales_order order = payload frappe.set_user("Administrator") - setting = get_user_shopify_account() frappe.flags.request_id = request_id try: sales_order = get_sales_order(cstr(order["id"])) + shopify_account_name = shopify_account.name if shopify_account else None if sales_order: - create_sales_invoice(order, setting, sales_order) - create_shopify_log(status="Success") + create_sales_invoice(order, shopify_account, sales_order) + create_shopify_log(status="Success", shopify_account=shopify_account_name) else: - create_shopify_log(status="Invalid", message="Sales Order not found for syncing sales invoice.") + create_shopify_log(status="Invalid", message="Sales Order not found for syncing sales invoice.", shopify_account=shopify_account_name) except Exception as e: - create_shopify_log(status="Error", exception=e, rollback=True) + create_shopify_log(status="Error", exception=e, rollback=True, shopify_account=shopify_account_name) def create_sales_invoice(shopify_order, setting, so): diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index 4496bd486..f99a4e182 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -7,7 +7,7 @@ from shopify.collection import PaginatedIterator from shopify.resources import Order -from ecommerce_integrations.shopify.connection import temp_shopify_session +from ecommerce_integrations.shopify.connection import get_temp_session_context from ecommerce_integrations.shopify.constants import ( CUSTOMER_ID_FIELD, EVENT_MAPPER, @@ -30,15 +30,15 @@ } -def sync_sales_order(payload, request_id=None): +def sync_sales_order(payload, request_id=None, shopify_account=None): order = payload - print("sync_sales_order", payload) - setting = get_user_shopify_account() + frappe.set_user("Administrator") frappe.flags.request_id = request_id + shopify_account_name = shopify_account.name if shopify_account else None if frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: cstr(order["id"])}): - create_shopify_log(status="Invalid", message="Sales order already exists, not synced") + create_shopify_log(status="Invalid", message="Sales order already exists, not synced", shopify_account=shopify_account_name) return try: shopify_customer = order.get("customer") if order.get("customer") is not None else {} @@ -47,19 +47,20 @@ def sync_sales_order(payload, request_id=None): customer_id = shopify_customer.get("id") if customer_id: customer = ShopifyCustomer(customer_id=customer_id) + # Add company to shopify_customer for multi-company setups + shopify_customer['company'] = shopify_account.company if not customer.is_synced(): - customer.sync_customer(customer=shopify_customer) + customer.sync_customer(customer=shopify_customer, customer_group=shopify_account.customer_group) else: customer.update_existing_addresses(shopify_customer) - create_items_if_not_exist(order) + create_items_if_not_exist(order, company=shopify_account.company) - # setting = frappe.get_doc(SETTING_DOCTYPE) - create_order(order, setting) + create_order(order, shopify_account) except Exception as e: - create_shopify_log(status="Error", exception=e, rollback=True) + create_shopify_log(status="Error", exception=e, rollback=True, shopify_account=shopify_account_name) else: - create_shopify_log(status="Success") + create_shopify_log(status="Success", shopify_account=shopify_account_name) def create_order(order, setting, company=None): @@ -100,7 +101,7 @@ def create_sales_order(shopify_order, setting, company=None): product_not_exists = [] # TODO: fix missing items message += "\n" + ", ".join(product_not_exists) - create_shopify_log(status="Error", exception=message, rollback=True) + create_shopify_log(status="Error", exception=message, rollback=True, shopify_account=setting.name) return "" @@ -359,7 +360,7 @@ def get_sales_order(order_id): return frappe.get_doc("Sales Order", sales_order) -def cancel_order(payload, request_id=None): +def cancel_order(payload, request_id=None, shopify_account=None): """Called by order/cancelled event. When shopify order is cancelled there could be many different someone handles it. @@ -372,6 +373,7 @@ def cancel_order(payload, request_id=None): frappe.flags.request_id = request_id order = payload + shopify_account_name = shopify_account.name if shopify_account else None try: order_id = order["id"] @@ -380,7 +382,7 @@ def cancel_order(payload, request_id=None): sales_order = get_sales_order(order_id) if not sales_order: - create_shopify_log(status="Invalid", message="Sales Order does not exist") + create_shopify_log(status="Invalid", message="Sales Order does not exist", shopify_account=shopify_account_name) return sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id}) @@ -398,28 +400,32 @@ def cancel_order(payload, request_id=None): frappe.db.set_value("Sales Order", sales_order.name, ORDER_STATUS_FIELD, order_status) except Exception as e: - create_shopify_log(status="Error", exception=e) + create_shopify_log(status="Error", exception=e, shopify_account=shopify_account_name) else: - create_shopify_log(status="Success") + create_shopify_log(status="Success", shopify_account=shopify_account_name) -@temp_shopify_session def sync_old_orders(): - shopify_setting = get_user_shopify_account() - if not cint(shopify_setting.sync_old_orders): - return - - orders = _fetch_old_orders(shopify_setting.old_orders_from, shopify_setting.old_orders_to) + all_accounts = frappe.get_all( + ACCOUNT_DOCTYPE, + filters={"enable_shopify": 1, "sync_old_orders": 1}, + pluck="name", + ) + for account in all_accounts: + shopify_setting = frappe.get_doc(ACCOUNT_DOCTYPE, account) + if not cint(shopify_setting.sync_old_orders): + continue - for order in orders: - log = create_shopify_log( - method=EVENT_MAPPER["orders/create"], request_data=json.dumps(order), make_new=True - ) - sync_sales_order(order, request_id=log.name) + with get_temp_session_context(shopify_setting): + orders = _fetch_old_orders(shopify_setting.old_orders_from, shopify_setting.old_orders_to) + for order in orders: + log = create_shopify_log( + method=EVENT_MAPPER["orders/create"], request_data=json.dumps(order), make_new=True, shopify_account=shopify_setting.name + ) + sync_sales_order(order, request_id=log.name, setting=shopify_setting) - shopify_setting = get_user_shopify_account() - shopify_setting.sync_old_orders = 0 - shopify_setting.save() + shopify_setting.sync_old_orders = 0 + shopify_setting.save() def _fetch_old_orders(from_time, to_time): diff --git a/ecommerce_integrations/shopify/product.py b/ecommerce_integrations/shopify/product.py index 59f1b0ab0..3e94e7587 100644 --- a/ecommerce_integrations/shopify/product.py +++ b/ecommerce_integrations/shopify/product.py @@ -7,7 +7,7 @@ from shopify.resources import Product, Variant from ecommerce_integrations.ecommerce_integrations.doctype.ecommerce_item import ecommerce_item -from ecommerce_integrations.shopify.connection import temp_shopify_session +from ecommerce_integrations.shopify.connection import get_temp_session_context from ecommerce_integrations.shopify.constants import ( ITEM_SELLING_RATE_FIELD, MODULE_NAME, @@ -15,7 +15,7 @@ SUPPLIER_ID_FIELD, WEIGHT_TO_ERPNEXT_UOM_MAP, ) -from ecommerce_integrations.shopify.utils import create_shopify_log, get_user_shopify_account +from ecommerce_integrations.shopify.utils import create_shopify_log, get_company_shopify_account, get_user_shopify_account class ShopifyProduct: @@ -25,12 +25,14 @@ def __init__( variant_id: str | None = None, sku: str | None = None, has_variants: int | None = 0, + company: str | None = None, ): self.product_id = str(product_id) self.variant_id = str(variant_id) if variant_id else None self.sku = str(sku) if sku else None self.has_variants = has_variants - self.setting = get_user_shopify_account() + self.company = company + self.setting = get_company_shopify_account(company) if not self.setting.is_enabled(): frappe.throw(_("Can not create Shopify product when integration is disabled.")) @@ -52,12 +54,12 @@ def get_erpnext_item(self): has_variants=self.has_variants, ) - @temp_shopify_session(shopify_account=None) def sync_product(self): if not self.is_synced(): - shopify_product = Product.find(self.product_id) - product_dict = shopify_product.to_dict() - self._make_item(product_dict) + with get_temp_session_context(self.setting): + shopify_product = Product.find(self.product_id) + product_dict = shopify_product.to_dict() + self._make_item(product_dict) def _make_item(self, product_dict): _add_weight_details(product_dict) @@ -138,6 +140,9 @@ def _create_item(self, product_dict, warehouse, has_variant=0, attributes=None, "default_supplier": self._get_supplier(product_dict), } + if self.company: + item_dict["custom_company"] = self.company + integration_item_code = product_dict["id"] # shopify product_id variant_id = product_dict.get("variant_id", "") # shopify variant_id if has variants sku = item_dict["sku"] @@ -210,7 +215,10 @@ def _get_item_group(self, product_type=None): "parent_item_group": parent_item_group, "is_group": "No", } - ).insert() + ) + if self.company: + item_group["custom_company"] = self.company + item_group = item_group.insert() return item_group.name def _get_supplier(self, product_dict): @@ -300,13 +308,13 @@ def _match_sku_and_link_item(item_dict, product_id, variant_id, variant_of=None, return False -def create_items_if_not_exist(order): +def create_items_if_not_exist(order, company): """Using shopify order, sync all items that are not already synced.""" for item in order.get("line_items", []): product_id = item["product_id"] variant_id = item.get("variant_id") sku = item.get("sku") - product = ShopifyProduct(product_id, variant_id=variant_id, sku=sku) + product = ShopifyProduct(product_id, company=company, variant_id=variant_id, sku=sku) if not product.is_synced(): product.sync_product() @@ -327,7 +335,6 @@ def get_item_code(shopify_item): return item.item_code -@temp_shopify_session(shopify_account=None) def upload_erpnext_item(doc, method=None): """This hook is called when inserting new or updating existing `Item`. @@ -339,7 +346,15 @@ def upload_erpnext_item(doc, method=None): if item.flags.from_integration: return - setting = get_user_shopify_account() + # TODO: Handle if doc.custom_company is None + if doc.custom_company: + setting = get_company_shopify_account(company=doc.custom_company) + else: + setting = get_user_shopify_account() + + if not setting: + msgprint(_("Could not find Shopify Account for uploading item.")) + return if not setting.is_enabled() or not setting.upload_erpnext_items: return @@ -368,104 +383,105 @@ def upload_erpnext_item(doc, method=None): ) is_new_product = not bool(product_id) - if is_new_product: - product = Product() - product.published = False - product.status = "active" if setting.sync_new_item_as_active else "draft" - - map_erpnext_item_to_shopify(shopify_product=product, erpnext_item=template_item) - is_successful = product.save() - - if is_successful: - update_default_variant_properties( - product, - sku=template_item.item_code, - price=template_item.get(ITEM_SELLING_RATE_FIELD), - is_stock_item=template_item.is_stock_item, - ) - if item.variant_of: - product.options = [] - product.variants = [] - variant_attributes = { - "title": template_item.item_name, - "sku": item.item_code, - "price": item.get(ITEM_SELLING_RATE_FIELD), - } - max_index_range = min(3, len(template_item.attributes)) - for i in range(0, max_index_range): - attr = template_item.attributes[i] - product.options.append( - { - "name": attr.attribute, - "values": frappe.db.get_all( - "Item Attribute Value", {"parent": attr.attribute}, pluck="attribute_value" - ), - } - ) - try: - variant_attributes[f"option{i+1}"] = item.attributes[i].attribute_value - except IndexError: - frappe.throw( - _("Shopify Error: Missing value for attribute {}").format(attr.attribute) - ) - product.variants.append(Variant(variant_attributes)) - - product.save() # push variant - - ecom_items = list(set([item, template_item])) - for d in ecom_items: - ecom_item = frappe.get_doc( - { - "doctype": "Ecommerce Item", - "erpnext_item_code": d.name, - "integration": MODULE_NAME, - "integration_item_code": str(product.id), - "variant_id": "" if d.has_variants else str(product.variants[0].id), - "sku": "" if d.has_variants else str(product.variants[0].sku), - "has_variants": d.has_variants, - "variant_of": d.variant_of, - } - ) - ecom_item.insert() + with get_temp_session_context(setting): + if is_new_product: + product = Product() + product.published = False + product.status = "active" if setting.sync_new_item_as_active else "draft" - write_upload_log(status=is_successful, product=product, item=item) - elif setting.update_shopify_item_on_update: - product = Product.find(product_id) - if product: map_erpnext_item_to_shopify(shopify_product=product, erpnext_item=template_item) - if not item.variant_of: + is_successful = product.save() + + if is_successful: update_default_variant_properties( product, + sku=template_item.item_code, + price=template_item.get(ITEM_SELLING_RATE_FIELD), is_stock_item=template_item.is_stock_item, - price=item.get(ITEM_SELLING_RATE_FIELD), ) - else: - variant_attributes = {"sku": item.item_code, "price": item.get(ITEM_SELLING_RATE_FIELD)} - product.options = [] - max_index_range = min(3, len(template_item.attributes)) - for i in range(0, max_index_range): - attr = template_item.attributes[i] - product.options.append( + if item.variant_of: + product.options = [] + product.variants = [] + variant_attributes = { + "title": template_item.item_name, + "sku": item.item_code, + "price": item.get(ITEM_SELLING_RATE_FIELD), + } + max_index_range = min(3, len(template_item.attributes)) + for i in range(0, max_index_range): + attr = template_item.attributes[i] + product.options.append( + { + "name": attr.attribute, + "values": frappe.db.get_all( + "Item Attribute Value", {"parent": attr.attribute}, pluck="attribute_value" + ), + } + ) + try: + variant_attributes[f"option{i+1}"] = item.attributes[i].attribute_value + except IndexError: + frappe.throw( + _("Shopify Error: Missing value for attribute {}").format(attr.attribute) + ) + product.variants.append(Variant(variant_attributes)) + + product.save() # push variant + + ecom_items = list(set([item, template_item])) + for d in ecom_items: + ecom_item = frappe.get_doc( { - "name": attr.attribute, - "values": frappe.db.get_all( - "Item Attribute Value", {"parent": attr.attribute}, pluck="attribute_value" - ), + "doctype": "Ecommerce Item", + "erpnext_item_code": d.name, + "integration": MODULE_NAME, + "integration_item_code": str(product.id), + "variant_id": "" if d.has_variants else str(product.variants[0].id), + "sku": "" if d.has_variants else str(product.variants[0].sku), + "has_variants": d.has_variants, + "variant_of": d.variant_of, } ) - try: - variant_attributes[f"option{i+1}"] = item.attributes[i].attribute_value - except IndexError: - frappe.throw( - _("Shopify Error: Missing value for attribute {}").format(attr.attribute) + ecom_item.insert() + + write_upload_log(status=is_successful, product=product, item=item, shopify_account=setting.name) + elif setting.update_shopify_item_on_update: + product = Product.find(product_id) + if product: + map_erpnext_item_to_shopify(shopify_product=product, erpnext_item=template_item) + if not item.variant_of: + update_default_variant_properties( + product, + is_stock_item=template_item.is_stock_item, + price=item.get(ITEM_SELLING_RATE_FIELD), + ) + else: + variant_attributes = {"sku": item.item_code, "price": item.get(ITEM_SELLING_RATE_FIELD)} + product.options = [] + max_index_range = min(3, len(template_item.attributes)) + for i in range(0, max_index_range): + attr = template_item.attributes[i] + product.options.append( + { + "name": attr.attribute, + "values": frappe.db.get_all( + "Item Attribute Value", {"parent": attr.attribute}, pluck="attribute_value" + ), + } ) - product.variants.append(Variant(variant_attributes)) + try: + variant_attributes[f"option{i+1}"] = item.attributes[i].attribute_value + except IndexError: + frappe.throw( + _("Shopify Error: Missing value for attribute {}").format(attr.attribute) + ) + product.variants.append(Variant(variant_attributes)) - is_successful = product.save() - if is_successful and item.variant_of: - map_erpnext_variant_to_shopify_variant(product, item, variant_attributes) + is_successful = product.save() + if is_successful and item.variant_of: + map_erpnext_variant_to_shopify_variant(product, item, variant_attributes) - write_upload_log(status=is_successful, product=product, item=item, action="Updated") + write_upload_log(status=is_successful, product=product, item=item, action="Updated", shopify_account=setting.name) def map_erpnext_variant_to_shopify_variant(shopify_product: Product, erpnext_item, variant_attributes): @@ -548,7 +564,7 @@ def update_default_variant_properties( default_variant.sku = sku -def write_upload_log(status: bool, product: Product, item, action="Created") -> None: +def write_upload_log(status: bool, product: Product, item, action="Created", shopify_account=None) -> None: if not status: msg = _("Failed to upload item to Shopify") + "
" msg += _("Shopify reported errors:") + " " + ", ".join(product.errors.full_messages()) @@ -559,6 +575,7 @@ def write_upload_log(status: bool, product: Product, item, action="Created") -> request_data=product.to_dict(), message=msg, method="upload_erpnext_item", + shopify_account=shopify_account, ) else: create_shopify_log( @@ -566,4 +583,5 @@ def write_upload_log(status: bool, product: Product, item, action="Created") -> request_data=product.to_dict(), message=f"{action} Item: {item.name}, shopify product: {product.id}", method="upload_erpnext_item", + shopify_account=shopify_account, ) diff --git a/ecommerce_integrations/shopify/utils.py b/ecommerce_integrations/shopify/utils.py index 95e0b0bbb..31628e0b1 100644 --- a/ecommerce_integrations/shopify/utils.py +++ b/ecommerce_integrations/shopify/utils.py @@ -22,8 +22,7 @@ def get_user_shopify_account(): has_company = bool(existing_permission) if has_company: company_id = frappe.db.get_value("User Permission", existing_permission, "for_value") - account = frappe.get_doc("Shopify Account", {"company": company_id}) - return account + return get_company_shopify_account(company_id) return None @@ -33,6 +32,12 @@ def get_company_shopify_account(company): return account +def get_company_shopify_account(company): + print("get_company_shopify_account called for company ", company) + account = frappe.get_doc("Shopify Account", {"company": company}) + return account + + def create_shopify_log(**kwargs): return create_log(module_def=MODULE_NAME, **kwargs) From c06b19666faabe5978cfcc22f7be0ae6e715ed93 Mon Sep 17 00:00:00 2001 From: ahmad Date: Mon, 17 Nov 2025 15:08:07 +0000 Subject: [PATCH 17/30] fix products sync functionality --- .../shopify_import_products.js | 10 ++++++++-- .../shopify_import_products.py | 7 ++++--- ecommerce_integrations/shopify/utils.py | 19 +++++++++++++------ 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js index 62db3c216..664e564f2 100644 --- a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js +++ b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js @@ -30,8 +30,14 @@ shopify.ProductImporter = class { } async checkSyncStatus() { + // TODO: RQ Job is a virtual doctype and not persisted in the mariadb, should we get the result from redis?? + // frappe.call({ + // method: "ecommerce_integrations.shopify.utils.get_jobs" + // }) + // .then(r => console.log("result", r)) + // .catch(e => console.log("error", e)) const jobs = await frappe.db.get_list("RQ Job", { - filters: { status: ("in", ("queued", "started")) }, + filters: { status: ["in", ["queued", "started"]] }, }); this.syncRunning = jobs.find( @@ -111,7 +117,7 @@ shopify.ProductImporter = class { this.wrapper.find("#count-products-erpnext").text(erpnextCount); this.wrapper.find("#count-products-synced").text(syncedCount); } catch (error) { - frappe.throw(__("Error fetching product count.")); + frappe.throw(__(`Error fetching product count.\n ${error}`)); } } diff --git a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py index 67dab4b80..275a53567 100644 --- a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py +++ b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py @@ -8,6 +8,7 @@ from ecommerce_integrations.shopify.connection import temp_shopify_session from ecommerce_integrations.shopify.constants import MODULE_NAME from ecommerce_integrations.shopify.product import ShopifyProduct +from ecommerce_integrations.shopify.utils import get_user_company # constants SYNC_JOB_NAME = "shopify.job.sync.all.products" @@ -81,7 +82,7 @@ def get_shopify_product_count(): @frappe.whitelist() def sync_product(product): try: - shopify_product = ShopifyProduct(product) + shopify_product = ShopifyProduct(product, company=get_user_company(frappe.session.user)) shopify_product.sync_product() return True @@ -103,7 +104,7 @@ def _resync_product(product): frappe.db.savepoint(savepoint) for variant in item.variants: - shopify_product = ShopifyProduct(product, variant_id=variant.id) + shopify_product = ShopifyProduct(product, variant_id=variant.id, company=get_user_company(frappe.session.user)) shopify_product.sync_product() return True @@ -147,7 +148,7 @@ def queue_sync_all_products(*args, **kwargs): publish(f"Product {product.id} already synced. Skipping...") continue - shopify_product = ShopifyProduct(product.id) + shopify_product = ShopifyProduct(product.id, company=get_user_company(kwargs.get("user"))) shopify_product.sync_product() publish(f"βœ… Synced Product {product.id}", synced=True) diff --git a/ecommerce_integrations/shopify/utils.py b/ecommerce_integrations/shopify/utils.py index 31628e0b1..7b9ee39e3 100644 --- a/ecommerce_integrations/shopify/utils.py +++ b/ecommerce_integrations/shopify/utils.py @@ -15,6 +15,19 @@ ) +@frappe.whitelist() +def get_jobs(): + return frappe.utils.background_jobs.get_jobs(site=frappe.local.site) + + +def get_user_company(user): + existing_permission = frappe.db.exists("User Permission", {"user": user, "allow": "Company"}) + has_company = bool(existing_permission) + if has_company: + company_id = frappe.db.get_value("User Permission", existing_permission, "for_value") + return company_id + return None + def get_user_shopify_account(): user = frappe.session.user print("get_user_shopify_account called for user ", user) @@ -32,12 +45,6 @@ def get_company_shopify_account(company): return account -def get_company_shopify_account(company): - print("get_company_shopify_account called for company ", company) - account = frappe.get_doc("Shopify Account", {"company": company}) - return account - - def create_shopify_log(**kwargs): return create_log(module_def=MODULE_NAME, **kwargs) From b17ec1c0c00b19fc87a75b391a8361b44c1d1c40 Mon Sep 17 00:00:00 2001 From: ahmad Date: Tue, 9 Dec 2025 13:26:31 +0000 Subject: [PATCH 18/30] set shopify product price as a shopify_selling_rate in the newly created erpnext item --- .../doctype/ecommerce_item/ecommerce_item.py | 8 ++++++-- ecommerce_integrations/shopify/product.py | 6 +++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.py b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.py index f756593a6..541ec5203 100644 --- a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.py +++ b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_item/ecommerce_item.py @@ -147,8 +147,12 @@ def create_ecommerce_item( "doctype": "Item", "is_stock_item": 1, "is_sales_item": 1, - # TODO: see if get_default_company() should be replaced with item_dict.get("custom_company") - "item_defaults": [{"company": get_default_company()}], + "item_defaults": [ + { + "company": item_dict.get("custom_company"), + "default_warehouse": item_dict.get("default_warehouse"), + } + ], } item.update(item_dict) diff --git a/ecommerce_integrations/shopify/product.py b/ecommerce_integrations/shopify/product.py index 3e94e7587..7a480d68f 100644 --- a/ecommerce_integrations/shopify/product.py +++ b/ecommerce_integrations/shopify/product.py @@ -33,7 +33,6 @@ def __init__( self.has_variants = has_variants self.company = company self.setting = get_company_shopify_account(company) - if not self.setting.is_enabled(): frappe.throw(_("Can not create Shopify product when integration is disabled.")) @@ -74,6 +73,7 @@ def _make_item(self, product_dict): else: product_dict["variant_id"] = product_dict["variants"][0]["id"] + price = product_dict.get("variants", [{'price': None}])[0].get("price") self._create_item(product_dict, warehouse) def _create_attribute(self, product_dict): @@ -122,6 +122,8 @@ def _set_new_attribute_values(self, item_attr, values): item_attr.append("item_attribute_values", {"attribute_value": attr_value, "abbr": attr_value}) def _create_item(self, product_dict, warehouse, has_variant=0, attributes=None, variant_of=None): + price = product_dict.get("price") if variant_of else product_dict.get("variants", [{'price': None}])[0].get("price") + item_dict = { "variant_of": variant_of, "is_stock_item": 1, @@ -138,6 +140,7 @@ def _create_item(self, product_dict, warehouse, has_variant=0, attributes=None, "weight_uom": WEIGHT_TO_ERPNEXT_UOM_MAP[product_dict.get("weight_unit")], "weight_per_unit": product_dict.get("weight"), "default_supplier": self._get_supplier(product_dict), + "shopify_selling_rate": price, } if self.company: @@ -178,6 +181,7 @@ def _create_item_variants(self, product_dict, warehouse, attributes): "item_price": variant.get("price"), "weight_unit": variant.get("weight_unit"), "weight": variant.get("weight"), + "price": variant.get("price"), } for i, variant_attr in enumerate(SHOPIFY_VARIANTS_ATTR_LIST): From 0b168b32d4faea13f353a561103c0643a1692952 Mon Sep 17 00:00:00 2001 From: ahmad Date: Wed, 10 Dec 2025 08:16:31 +0000 Subject: [PATCH 19/30] Enhance retry functionality and Shopify account handling in Ecommerce Integration Log --- .../ecommerce_integration_log/ecommerce_integration_log.js | 4 +++- .../ecommerce_integration_log/ecommerce_integration_log.py | 7 +++++-- ecommerce_integrations/shopify/connection.py | 2 +- ecommerce_integrations/shopify/order.py | 3 +++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.js b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.js index 5e55650ce..8e0788a9f 100644 --- a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.js +++ b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.js @@ -3,7 +3,8 @@ frappe.ui.form.on("Ecommerce Integration Log", { refresh: function (frm) { - if (frm.doc.request_data && frm.doc.status == "Error") { + const retryStatusList = ["Error", "Invalid"] + if (frm.doc.request_data && retryStatusList.includes(frm.doc.status)) { frm.add_custom_button(__("Retry"), function () { frappe.call({ method: "ecommerce_integrations.ecommerce_integrations.doctype.ecommerce_integration_log.ecommerce_integration_log.resync", @@ -11,6 +12,7 @@ frappe.ui.form.on("Ecommerce Integration Log", { method: frm.doc.method, name: frm.doc.name, request_data: frm.doc.request_data, + shopify_account: frm.doc.shopify_account, }, callback: function (r) { frappe.msgprint(__("Reattempting to sync")); diff --git a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.py b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.py index de156928f..25c2117ea 100644 --- a/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.py +++ b/ecommerce_integrations/ecommerce_integrations/doctype/ecommerce_integration_log/ecommerce_integration_log.py @@ -116,12 +116,14 @@ def _retry_job(job: str): frappe.only_for("System Manager") doc = frappe.get_doc("Ecommerce Integration Log", job) - if not doc.method.startswith("ecommerce_integrations.") or doc.status != "Error": + retry_status_list = ["Error", "Invalid"] + if not doc.method.startswith("ecommerce_integrations.") or doc.status not in retry_status_list: return doc.db_set("status", "Queued", update_modified=False) doc.db_set("traceback", "", update_modified=False) - + shopify_account = frappe.get_doc("Shopify Account", doc.shopify_account) if doc.shopify_account else None + frappe.enqueue( method=doc.method, queue="short", @@ -129,6 +131,7 @@ def _retry_job(job: str): is_async=True, payload=json.loads(doc.request_data), request_id=doc.name, + shopify_account=shopify_account, enqueue_after_commit=True, ) diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index e1eb1d437..1fa87c28a 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -132,7 +132,7 @@ def store_request_data(**kwargs) -> None: def process_request(data, event, shopify_account=None): print("Processing webhook event: ", event, "\n", shopify_account) # create log - log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data, shopify_account=shopify_account.name) + log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data, shopify_account=shopify_account) print("log created") # enqueue background job frappe.enqueue( diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index f99a4e182..cf413ca7e 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -35,6 +35,9 @@ def sync_sales_order(payload, request_id=None, shopify_account=None): frappe.set_user("Administrator") frappe.flags.request_id = request_id + + if isinstance(shopify_account, str): + shopify_account = frappe.get_doc("Shopify Account", shopify_account) shopify_account_name = shopify_account.name if shopify_account else None if frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: cstr(order["id"])}): From 98ca788dd204210d167f1cb7f5a35f1cdf37d43e Mon Sep 17 00:00:00 2001 From: ahmad Date: Wed, 10 Dec 2025 13:30:06 +0000 Subject: [PATCH 20/30] create get_shopify_locations method for shopify account --- .../shopify/doctype/shopify_account/shopify_account.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py index 6089ae02a..6b957c73e 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py @@ -93,6 +93,16 @@ def update_location_table(self): {"shopify_location_id": location.id, "shopify_location_name": location.name}, ) + def get_shopify_locations(self): + """Fetch locations from shopify and add it to child table so user can + map it with correct ERPNext warehouse.""" + result = [] + with connection.get_temp_session_context(self): + for locations in PaginatedIterator(Location.find()): + for location in locations: + result.append(location) + return result + def get_erpnext_warehouses(self) -> list[ERPNextWarehouse]: return [wh_map.erpnext_warehouse for wh_map in self.shopify_warehouse_mapping] From fbc2a7ed9029cd54670d45b19beed6c749cc406b Mon Sep 17 00:00:00 2001 From: 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 Date: Sun, 8 Feb 2026 11:24:04 +0200 Subject: [PATCH 21/30] fixing issue with create item to check first if company account is exists or not --- ecommerce_integrations/shopify/product.py | 4 ++-- ecommerce_integrations/shopify/utils.py | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/ecommerce_integrations/shopify/product.py b/ecommerce_integrations/shopify/product.py index 7a480d68f..8e351382f 100644 --- a/ecommerce_integrations/shopify/product.py +++ b/ecommerce_integrations/shopify/product.py @@ -351,13 +351,13 @@ def upload_erpnext_item(doc, method=None): return # TODO: Handle if doc.custom_company is None - if doc.custom_company: + if doc.hasattr("custom_company"): setting = get_company_shopify_account(company=doc.custom_company) else: setting = get_user_shopify_account() if not setting: - msgprint(_("Could not find Shopify Account for uploading item.")) + # msgprint(_("Could not find Shopify Account for uploading item.")) return if not setting.is_enabled() or not setting.upload_erpnext_items: diff --git a/ecommerce_integrations/shopify/utils.py b/ecommerce_integrations/shopify/utils.py index 7b9ee39e3..815fc4fa7 100644 --- a/ecommerce_integrations/shopify/utils.py +++ b/ecommerce_integrations/shopify/utils.py @@ -40,10 +40,14 @@ def get_user_shopify_account(): def get_company_shopify_account(company): - print("get_company_shopify_account called for company ", company) - account = frappe.get_doc("Shopify Account", {"company": company}) - return account - + try: + sa_exists = frappe.db.exists("Shopify Account", {"company": company}) + if sa_exists: + account = frappe.get_doc("Shopify Account", sa_exists) + return account + return None + except Exception as e: + return None def create_shopify_log(**kwargs): return create_log(module_def=MODULE_NAME, **kwargs) From 8e28b7851f1f213ac425577a11e7a6a78002abac Mon Sep 17 00:00:00 2001 From: Ahmed Osama Date: Sun, 3 May 2026 15:24:47 +0300 Subject: [PATCH 22/30] feat(shopify): add OAuth 2.0 Client Credentials support per Shopify Account Shopify requires OAuth 2.0 Client Credentials for new custom apps created via the Dev Dashboard from Jan 1, 2026. Static Access Tokens still work for older apps. This adds dual auth support per Shopify Account row, keeping multi-tenancy intact. - New oauth.py: token generation/refresh, 5-min expiry buffer, single retry on transient failure. Operates per Shopify Account doc. - Shopify Account doctype: authentication_method (default Static Token), client_id, client_secret, hidden oauth_access_token + token_expires_at. password/shared_secret depend on Static Token method. - Shopify Account controller: validates required fields per auth method, pre-generates token in before_save on credential change, branches webhook registration to use the right token. - connection.py: _get_access_token branches per auth method; store_request_data picks client_secret for OAuth accounts and shared_secret for static-token accounts when verifying webhook HMAC. - Migration patch: defaults existing rows to Static Token. Co-Authored-By: Claude Opus 4.7 (1M context) --- ecommerce_integrations/patches.txt | 1 + .../set_default_shopify_auth_method.py | 35 ++++ ecommerce_integrations/shopify/connection.py | 92 ++++++---- .../shopify_account/shopify_account.json | 51 +++++- .../shopify_account/shopify_account.py | 105 +++++++++++- ecommerce_integrations/shopify/oauth.py | 162 ++++++++++++++++++ 6 files changed, 411 insertions(+), 35 deletions(-) create mode 100644 ecommerce_integrations/patches/set_default_shopify_auth_method.py create mode 100644 ecommerce_integrations/shopify/oauth.py diff --git a/ecommerce_integrations/patches.txt b/ecommerce_integrations/patches.txt index ea16f841a..af76a2c12 100644 --- a/ecommerce_integrations/patches.txt +++ b/ecommerce_integrations/patches.txt @@ -1,2 +1,3 @@ ecommerce_integrations.patches.update_shopify_custom_fields ecommerce_integrations.patches.set_default_amazon_item_fields_map +ecommerce_integrations.patches.set_default_shopify_auth_method diff --git a/ecommerce_integrations/patches/set_default_shopify_auth_method.py b/ecommerce_integrations/patches/set_default_shopify_auth_method.py new file mode 100644 index 000000000..178ce0bf7 --- /dev/null +++ b/ecommerce_integrations/patches/set_default_shopify_auth_method.py @@ -0,0 +1,35 @@ +import frappe + +from ecommerce_integrations.shopify.constants import ACCOUNT_DOCTYPE + + +def execute(): + """Set default authentication method to 'Static Token' on existing Shopify Account rows. + + Ensures backward compatibility for installations that pre-date OAuth 2.0 support. + """ + frappe.reload_doc("shopify", "doctype", "shopify_account") + + if not frappe.db.exists("DocType", ACCOUNT_DOCTYPE): + return + + rows = frappe.get_all( + ACCOUNT_DOCTYPE, + filters={"authentication_method": ("in", ("", None))}, + pluck="name", + ) + + for name in rows: + frappe.db.set_value( + ACCOUNT_DOCTYPE, + name, + "authentication_method", + "Static Token", + update_modified=False, + ) + + if rows: + frappe.db.commit() + frappe.logger().info( + f"Shopify Account: defaulted authentication_method to 'Static Token' for {len(rows)} row(s)" + ) diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index 1fa87c28a..0d1156fc5 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -18,39 +18,62 @@ from ecommerce_integrations.shopify.utils import create_shopify_log, get_user_shopify_account +def _get_access_token(setting) -> str: + """Return a valid access token for the given Shopify Account, branching on auth method.""" + if getattr(setting, "authentication_method", None) == "OAuth 2.0 Client Credentials": + from ecommerce_integrations.shopify.oauth import get_valid_access_token + + try: + return get_valid_access_token(setting) + except Exception as e: + create_shopify_log( + status="Error", + method="ecommerce_integrations.shopify.connection._get_access_token", + message=_("Failed to get valid OAuth access token"), + exception=str(e), + ) + frappe.throw( + _("Failed to authenticate with Shopify using OAuth 2.0: {0}").format(str(e)), + title=_("Authentication Error"), + ) + + return setting.get_password("password") + + def temp_shopify_session(shopify_account=None): - """Decorator for functions that need a temporary Shopify session.""" - print("temp_shopify_session called with ", shopify_account) + """Decorator for functions that need a temporary Shopify session. - def decorator(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - # no auth in testing - if frappe.flags.in_test: - return func(*args, **kwargs) + Supports both Static Token and OAuth 2.0 Client Credentials per Shopify Account. + """ - # If a callable is passed, call it with self to get the account - if shopify_account is None: - # TODO: handle if get_user_shopify_account returns None - account = get_user_shopify_account().name - else: - account = shopify_account(args[0]) if callable(shopify_account) else shopify_account + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + # no auth in testing + if frappe.flags.in_test: + return func(*args, **kwargs) + + # If a callable is passed, call it with self to get the account + if shopify_account is None: + # TODO: handle if get_user_shopify_account returns None + account = get_user_shopify_account().name + else: + account = shopify_account(args[0]) if callable(shopify_account) else shopify_account - setting = frappe.get_doc(ACCOUNT_DOCTYPE, account) - if setting.is_enabled(): - auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password")) - with Session.temp(*auth_details): - return func(*args, **kwargs) + setting = frappe.get_doc(ACCOUNT_DOCTYPE, account) + if setting.is_enabled(): + auth_details = (setting.shopify_url, API_VERSION, _get_access_token(setting)) + with Session.temp(*auth_details): + return func(*args, **kwargs) - return wrapper + return wrapper - return decorator + return decorator def get_auth_details(setting) -> tuple[str, str, str]: - """Get authentication details for Shopify API.""" - # setting = frappe.get_doc(ACCOUNT_DOCTYPE, setting) - return setting.shopify_url, API_VERSION, setting.get_password("password") + """Get authentication details for Shopify API.""" + return setting.shopify_url, API_VERSION, _get_access_token(setting) def get_temp_session_context(setting): @@ -117,11 +140,24 @@ def get_callback_url() -> str: def store_request_data(**kwargs) -> None: if frappe.request: hmac_header = frappe.get_request_header("X-Shopify-Hmac-Sha256") - # Get shopify account shop_domain = frappe.get_request_header("X-Shopify-Shop-Domain") settings = frappe.get_doc(ACCOUNT_DOCTYPE, shop_domain) - _validate_request(frappe.request, hmac_header, secret_key=settings.shared_secret) + # OAuth apps sign webhooks with client_secret; static-token apps use shared_secret. + if settings.authentication_method == "OAuth 2.0 Client Credentials": + secret_key = settings.get_password("client_secret") + else: + secret_key = settings.shared_secret + + if not secret_key: + create_shopify_log( + status="Error", + request_data=frappe.request.data, + exception="Webhook secret key not configured", + ) + frappe.throw(_("Webhook validation failed: Secret key not configured")) + + _validate_request(frappe.request, hmac_header, secret_key=secret_key) data = json.loads(frappe.request.data) event = frappe.request.headers.get("X-Shopify-Topic") @@ -130,11 +166,7 @@ def store_request_data(**kwargs) -> None: def process_request(data, event, shopify_account=None): - print("Processing webhook event: ", event, "\n", shopify_account) - # create log log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data, shopify_account=shopify_account) - print("log created") - # enqueue background job frappe.enqueue( method=EVENT_MAPPER[event], queue="short", diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json index 1103db19a..cd969df23 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json @@ -9,10 +9,15 @@ "enable_shopify", "column_break_4", "section_break_2", + "authentication_method", "shopify_url", "column_break_3", "password", "shared_secret", + "client_id", + "client_secret", + "oauth_access_token", + "token_expires_at", "section_break_4", "webhooks", "customer_settings_section", @@ -91,16 +96,58 @@ "fieldtype": "Column Break" }, { + "default": "Static Token", + "description": "Select Static Token for existing apps or OAuth 2.0 for apps created after Jan 1, 2026", + "fieldname": "authentication_method", + "fieldtype": "Select", + "label": "Authentication Method", + "options": "Static Token\nOAuth 2.0 Client Credentials" + }, + { + "depends_on": "eval:doc.authentication_method=='Static Token'", "fieldname": "password", "fieldtype": "Password", "label": "Password / Access Token", - "mandatory_depends_on": "eval:doc.enable_shopify" + "mandatory_depends_on": "eval:doc.enable_shopify && doc.authentication_method=='Static Token'" }, { + "depends_on": "eval:doc.authentication_method=='Static Token'", "fieldname": "shared_secret", "fieldtype": "Data", "label": "Shared secret / API Secret", - "mandatory_depends_on": "eval:doc.enable_shopify" + "mandatory_depends_on": "eval:doc.enable_shopify && doc.authentication_method=='Static Token'" + }, + { + "depends_on": "eval:doc.authentication_method=='OAuth 2.0 Client Credentials'", + "description": "Client ID from Shopify Partner Dashboard", + "fieldname": "client_id", + "fieldtype": "Data", + "label": "Client ID", + "mandatory_depends_on": "eval:doc.enable_shopify && doc.authentication_method=='OAuth 2.0 Client Credentials'" + }, + { + "depends_on": "eval:doc.authentication_method=='OAuth 2.0 Client Credentials'", + "description": "Client Secret from Shopify Partner Dashboard", + "fieldname": "client_secret", + "fieldtype": "Password", + "label": "Client Secret", + "mandatory_depends_on": "eval:doc.enable_shopify && doc.authentication_method=='OAuth 2.0 Client Credentials'" + }, + { + "description": "Auto-generated OAuth access token", + "fieldname": "oauth_access_token", + "fieldtype": "Password", + "hidden": 1, + "label": "OAuth Access Token", + "read_only": 1 + }, + { + "description": "OAuth token expiry time", + "fieldname": "token_expires_at", + "fieldtype": "Datetime", + "hidden": 1, + "label": "Token Expires At", + "read_only": 1 }, { "collapsible": 1, diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py index 6b957c73e..6d0c4d3f5 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py @@ -25,6 +25,7 @@ ORDER_STATUS_FIELD, SUPPLIER_ID_FIELD, ) +from ecommerce_integrations.shopify.oauth import validate_oauth_credentials from ecommerce_integrations.shopify.utils import ( ensure_old_connector_is_disabled, migrate_from_old_connector, @@ -35,12 +36,26 @@ class ShopifyAccount(SettingController): def is_enabled(self) -> bool: return bool(self.enable_shopify) + def _get_password_safe(self, fieldname: str) -> str: + """Get a password field without raising on new/unsaved docs.""" + try: + if not self.name or self.is_new(): + return "" + password = self.get_password(fieldname, raise_exception=False) + return password if password else "" + except Exception: + return "" + def validate(self): # TODO: uncomment # ensure_old_connector_is_disabled() if self.shopify_url: - self.shopify_url = self.shopify_url.replace("https://", "") + self.shopify_url = self.shopify_url.replace("https://", "").replace("http://", "") + + self._set_default_authentication_method() + self._validate_authentication_fields() + self._validate_oauth_credentials_if_needed() self._handle_webhooks() self._validate_warehouse_links() self._initalize_default_values() @@ -52,9 +67,86 @@ def on_update(self): if self.is_enabled() and not self.is_old_data_migrated: migrate_from_old_connector() + def before_save(self): + """Pre-generate the OAuth token on credential changes for better UX. Falls back to on-demand.""" + if not self.is_enabled(): + return + + if self.authentication_method == "OAuth 2.0 Client Credentials": + current_token = self._get_password_safe("oauth_access_token") + if ( + self.has_value_changed("client_id") + or self.has_value_changed("client_secret") + or not current_token + ): + try: + self._get_or_generate_oauth_token() + except Exception: + pass + + def _set_default_authentication_method(self): + if not self.authentication_method: + self.authentication_method = "Static Token" + + def _validate_authentication_fields(self): + if not self.is_enabled(): + return + + if self.authentication_method == "Static Token": + if not self._get_password_safe("password"): + frappe.throw(_("Password / Access Token is required for Static Token authentication")) + if not self.shared_secret: + frappe.throw(_("Shared secret / API Secret is required for Static Token authentication")) + + elif self.authentication_method == "OAuth 2.0 Client Credentials": + if not self.client_id: + frappe.throw(_("Client ID is required for OAuth 2.0 authentication")) + if not self._get_password_safe("client_secret"): + frappe.throw(_("Client Secret is required for OAuth 2.0 authentication")) + + def _validate_oauth_credentials_if_needed(self): + if not self.is_enabled(): + return + if self.authentication_method != "OAuth 2.0 Client Credentials": + return + + if self.has_value_changed("client_id") or self.has_value_changed("client_secret"): + client_secret = self._get_password_safe("client_secret") + if not client_secret: + return # caught by _validate_authentication_fields + + validate_oauth_credentials(self.shopify_url, self.client_id, client_secret) + frappe.msgprint( + _("OAuth credentials validated successfully. Token will be auto-generated on save."), + indicator="green", + alert=True, + ) + + def _get_or_generate_oauth_token(self) -> str: + """Return a valid OAuth token, refreshing if missing/expired.""" + from ecommerce_integrations.shopify.oauth import is_token_valid, refresh_oauth_token + + current_token = self._get_password_safe("oauth_access_token") + if current_token and is_token_valid(self.token_expires_at): + return current_token + + try: + return refresh_oauth_token(self) + except Exception as e: + frappe.throw( + _("Failed to generate OAuth token: {0}").format(str(e)), + title=_("OAuth Authentication Error"), + ) + + def _get_webhook_password(self) -> str: + """Return the access token to use when calling Shopify for webhook ops.""" + if self.authentication_method == "OAuth 2.0 Client Credentials": + return self._get_or_generate_oauth_token() + return self.get_password("password") + def _handle_webhooks(self): if self.is_enabled() and not self.webhooks: - new_webhooks = connection.register_webhooks(self.shopify_url, self.get_password("password")) + new_webhooks = connection.register_webhooks(self.shopify_url, self._get_webhook_password()) if not new_webhooks: msg = _("Failed to register webhooks with Shopify.") + "
" @@ -66,7 +158,14 @@ def _handle_webhooks(self): self.append("webhooks", {"webhook_id": webhook.id, "method": webhook.topic}) elif not self.is_enabled(): - connection.unregister_webhooks(self.shopify_url, self.get_password("password")) + # Use whichever token we still have; for OAuth use the cached one (don't refresh on disable) + if self.authentication_method == "OAuth 2.0 Client Credentials": + password = self._get_password_safe("oauth_access_token") + else: + password = self._get_password_safe("password") + + if password: + connection.unregister_webhooks(self.shopify_url, password) self.webhooks = list() # remove all webhooks diff --git a/ecommerce_integrations/shopify/oauth.py b/ecommerce_integrations/shopify/oauth.py new file mode 100644 index 000000000..72188829c --- /dev/null +++ b/ecommerce_integrations/shopify/oauth.py @@ -0,0 +1,162 @@ +# Copyright (c) 2026, Frappe and contributors +# For license information, please see LICENSE + +""" +OAuth 2.0 Client Credentials Flow for Shopify Apps. + +Implements token generation and refresh for apps created via the Shopify +Dev Dashboard. Required for all custom apps created on or after Jan 1, 2026. + +Operates per Shopify Account document (multi-tenant). +""" + +import json +import time +from datetime import datetime, timedelta + +import frappe +import requests +from frappe import _ +from frappe.utils import get_datetime, get_datetime_str, now_datetime +from frappe.utils.password import set_encrypted_password + +from ecommerce_integrations.shopify.constants import ACCOUNT_DOCTYPE +from ecommerce_integrations.shopify.utils import create_shopify_log + + +def get_oauth_token_endpoint(shopify_url: str) -> str: + shop_url = shopify_url.replace("https://", "").replace("http://", "") + return f"https://{shop_url}/admin/oauth/access_token" + + +def generate_oauth_token(shopify_url: str, client_id: str, client_secret: str) -> dict: + """POST client_credentials to Shopify and return the token payload.""" + token_endpoint = get_oauth_token_endpoint(shopify_url) + + payload = { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + } + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + try: + response = requests.post(token_endpoint, data=payload, headers=headers, timeout=30) + response.raise_for_status() + token_data = response.json() + + create_shopify_log( + status="Success", + method="ecommerce_integrations.shopify.oauth.generate_oauth_token", + message=_("OAuth token generated successfully"), + ) + return token_data + + except requests.exceptions.RequestException as e: + error_message = str(e) + error_response = None + + if hasattr(e, "response") and e.response is not None: + try: + error_response = e.response.json() + error_message = error_response.get("error_description", error_response.get("error", str(e))) + except json.JSONDecodeError: + error_message = e.response.text or str(e) + + sanitized_payload = payload.copy() + sanitized_payload["client_secret"] = "REDACTED" + + create_shopify_log( + status="Error", + method="ecommerce_integrations.shopify.oauth.generate_oauth_token", + message=_("Failed to generate OAuth token"), + exception=error_message, + request_data=sanitized_payload, + response_data=error_response, + ) + + frappe.throw( + _("Failed to generate OAuth token: {0}").format(error_message), + title=_("OAuth Authentication Error"), + ) + + +def is_token_valid(token_expires_at, buffer_minutes: int = 5) -> bool: + if not token_expires_at: + return False + expiry_datetime = get_datetime(token_expires_at) + buffer_time = now_datetime() + timedelta(minutes=buffer_minutes) + return expiry_datetime > buffer_time + + +def calculate_token_expiry(expires_in_seconds: int) -> datetime: + return now_datetime() + timedelta(seconds=expires_in_seconds) + + +def refresh_oauth_token(setting) -> str: + """Generate a fresh token for the given Shopify Account and persist it.""" + if setting.authentication_method != "OAuth 2.0 Client Credentials": + frappe.throw( + _("Token refresh is only applicable for OAuth 2.0 authentication"), + title=_("Invalid Authentication Method"), + ) + + setting.reload() + + token_data = generate_oauth_token( + setting.shopify_url, + setting.client_id, + setting.get_password("client_secret"), + ) + + expires_at = calculate_token_expiry(token_data.get("expires_in", 86399)) + + set_encrypted_password( + ACCOUNT_DOCTYPE, + setting.name, + token_data["access_token"], + fieldname="oauth_access_token", + ) + + frappe.db.set_value( + ACCOUNT_DOCTYPE, + setting.name, + "token_expires_at", + get_datetime_str(expires_at), + update_modified=False, + ) + + setting.reload() + return token_data["access_token"] + + +def get_valid_access_token(setting) -> str: + """Return a valid OAuth access token for the given Shopify Account, refreshing if needed.""" + if setting.authentication_method != "OAuth 2.0 Client Credentials": + frappe.throw( + _("This method is only for OAuth 2.0 authentication"), + title=_("Invalid Authentication Method"), + ) + + if is_token_valid(setting.token_expires_at): + current_token = setting.get_password("oauth_access_token", raise_exception=False) + if current_token: + return current_token + + try: + return refresh_oauth_token(setting) + except Exception as e: + create_shopify_log( + status="Warning", + method="ecommerce_integrations.shopify.oauth.get_valid_access_token", + message=_("Token refresh failed, retrying once..."), + exception=str(e), + ) + time.sleep(1) + return refresh_oauth_token(setting) + + +def validate_oauth_credentials(shopify_url: str, client_id: str, client_secret: str) -> bool: + """Verify credentials by attempting a token generation. Re-raises on failure.""" + token_data = generate_oauth_token(shopify_url, client_id, client_secret) + return bool(token_data.get("access_token")) From 9b092fe2dd3c67d0a622891f6de90648d9309a13 Mon Sep 17 00:00:00 2001 From: Ahmed Osama Date: Sun, 3 May 2026 15:56:30 +0300 Subject: [PATCH 23/30] docs(shopify): add onboarding & operations guide with screenshots Add ONBOARDING.md walking implementers through bringing a new Shopify store online: prerequisites, account setup with both Static Token and OAuth 2.0 Client Credentials auth, per-account configuration, sync setup, pricing flows (ERPNext shopify_selling_rate), day-2 operations, troubleshooting, and a field reference. Includes a printable per-client onboarding template. Embedded UI screenshots captured live from a working install: list empty-state, Static Token form, OAuth 2.0 form, and full layout. Also gitignore .claude/settings.json. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 +- ecommerce_integrations/shopify/ONBOARDING.md | 665 ++++++++++++++++++ .../docs/images/01-list-empty-state.png | Bin 0 -> 43119 bytes .../docs/images/02-new-form-static-token.png | Bin 0 -> 83530 bytes .../shopify/docs/images/03-new-form-oauth.png | Bin 0 -> 87239 bytes .../docs/images/04-full-form-oauth.png | Bin 0 -> 164271 bytes 6 files changed, 667 insertions(+), 1 deletion(-) create mode 100644 ecommerce_integrations/shopify/ONBOARDING.md create mode 100644 ecommerce_integrations/shopify/docs/images/01-list-empty-state.png create mode 100644 ecommerce_integrations/shopify/docs/images/02-new-form-static-token.png create mode 100644 ecommerce_integrations/shopify/docs/images/03-new-form-oauth.png create mode 100644 ecommerce_integrations/shopify/docs/images/04-full-form-oauth.png diff --git a/.gitignore b/.gitignore index 7ba46dbf9..0a6dbe4a3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ tags ecommerce_integrations/docs/current .aider* -.helix \ No newline at end of file +.helix +.claude/settings.json diff --git a/ecommerce_integrations/shopify/ONBOARDING.md b/ecommerce_integrations/shopify/ONBOARDING.md new file mode 100644 index 000000000..45cb094a9 --- /dev/null +++ b/ecommerce_integrations/shopify/ONBOARDING.md @@ -0,0 +1,665 @@ +# Shopify Account β€” Onboarding & Operations Guide + +End-to-end guide for setting up and running one or many Shopify stores against an existing ERPNext / Frappe site with `ecommerce_integrations` already installed. + +**Audience:** Implementers and operators bringing a new Shopify store online. Each store is a separate **Shopify Account** record, scoped to one ERPNext **Company**. + +--- + +## Table of contents + +1. [Quick start checklist](#1-quick-start-checklist) +2. [Prerequisites](#2-prerequisites) +3. [Part 1 β€” Account setup](#3-part-1--account-setup) +4. [Part 2 β€” Per-account configuration](#4-part-2--per-account-configuration) +5. [Part 3 β€” Sync setup](#5-part-3--sync-setup) +6. [Part 4 β€” Pricing flow](#6-part-4--pricing-flow) +7. [Part 5 β€” Day-2 operations](#7-part-5--day-2-operations) +8. [Part 6 β€” Troubleshooting](#8-part-6--troubleshooting) +9. [Reference](#9-reference) + +--- + +## 1. Quick start checklist + +For a fresh client onboarding, work through these in order. Each step has a section below. + +``` +Prerequisites +[ ] ERPNext Company exists for this client +[ ] Default warehouse, cost center, cash/bank account exist for that Company +[ ] Customer Group, Default Customer, Item Group exist +[ ] Shopify store admin access (or Partner Dashboard for OAuth apps) + +Part 1 β€” Account Setup +[ ] Decide auth method: Static Token or OAuth 2.0 Client Credentials +[ ] Create Shopify custom app and capture credentials +[ ] Create Shopify Account record with auth + Shop URL + Company +[ ] Save and verify webhooks were registered + +Part 2 β€” Configuration +[ ] Map Shopify locations β†’ ERPNext warehouses +[ ] Map Shopify taxes β†’ ERPNext accounts +[ ] Set default sales tax + shipping account +[ ] Set Sales Order / Sales Invoice / Delivery Note series + +Part 3 β€” Sync Setup +[ ] Choose sync direction: Shopify β†’ ERPNext, ERPNext β†’ Shopify, or both +[ ] Toggle product upload, inventory sync, invoice/DN creation +[ ] Run initial product import (Shopify β†’ ERPNext) +[ ] (Optional) Sync historical orders + +Part 4 β€” Pricing +[ ] Set Shopify Selling Rate on each item (or via Item Price) +[ ] Verify rate flows ERPNext β†’ Shopify on first push +[ ] Verify Shopify rate flows back to ERPNext on first product import + +Part 5 β€” Go live +[ ] Place a test order in Shopify and confirm Sales Order arrives +[ ] Mark it paid, fulfilled β€” confirm SI and DN are created +[ ] Watch Ecommerce Integration Log for first 24h +``` + +--- + +## 2. Prerequisites + +### 2.1 On the Frappe / ERPNext side + +| Item | Why | How to check | +|---|---|---| +| `ecommerce_integrations` installed on the site | Enables the Shopify Account doctype | Desk β†’ **Installed Applications** should list `ecommerce_integrations` | +| ERPNext **Company** | Multi-tenant requires one Company per Shopify store | Desk β†’ **Company** list | +| Default **Warehouse** under that company | Required as fallback when Shopify location isn't mapped | Filter Warehouses by company | +| **Cost Center** under that company | Required when invoice/DN sync is enabled | Cost Center list | +| **Cash/Bank Account** under that company | Required when Sales Invoice sync is enabled | Account list, type Cash or Bank | +| **Customer Group** | Used as default for synced customers | Customer Group list | +| **Default Customer** (e.g. *Walk-in*) | Fallback when Shopify order has no customer | Customer list | +| Document **Naming Series** for SO / SI / DN | Required if you want store-specific prefixes | Setup β†’ Naming Series | + +If any of these are missing, create them **before** creating the Shopify Account β€” saving the Shopify Account validates these references. + +### 2.2 On the Shopify side + +You need a custom app installed on the merchant's store, with the right scopes. The app gives you either a **static access token** (legacy) or **OAuth 2.0 client credentials** (apps created via the Dev Dashboard on/after **Jan 1, 2026**). + +**Required Admin API scopes (minimum):** + +``` +read_products, write_products +read_orders, write_orders +read_customers, write_customers +read_inventory, write_inventory +read_locations +read_fulfillments, write_fulfillments +read_assigned_fulfillment_orders +read_merchant_managed_fulfillment_orders +``` + +**Webhook scope** is implicit β€” the app must be allowed to register webhooks to your callback URL. + +--- + +## 3. Part 1 β€” Account setup + +### 3.1 Choose the authentication method + +| Method | Use when | Credentials needed | +|---|---|---| +| **Static Token** | Custom app created **before Jan 1, 2026**, or any app that still issues a long-lived access token (`shpat_…`) | `password` (Access Token), `shared_secret` (API Secret) | +| **OAuth 2.0 Client Credentials** | Custom app created via the **Shopify Dev Dashboard** on/after Jan 1, 2026 | `client_id`, `client_secret` | + +You can mix methods across accounts β€” one client on Static Token, another on OAuth, all on the same site. + +### 3.2 Create the Shopify custom app + +#### 3.2.1 Static Token path + +1. In Shopify Admin β†’ **Settings** β†’ **Apps and sales channels** β†’ **Develop apps** β†’ **Create an app**. +2. Configure Admin API scopes (list in Β§2.2). +3. **Install** the app. +4. From **API credentials**, copy: + - **Admin API access token** β†’ goes into the Shopify Account `Password / Access Token` field. + - **API secret key** β†’ goes into the `Shared secret / API Secret` field. + +#### 3.2.2 OAuth 2.0 Client Credentials path + +1. In the **Shopify Dev Dashboard** (`partners.shopify.com`), create an app or open the existing one. +2. Under app settings, enable **Client Credentials grant**. +3. Configure Admin API scopes. +4. Install the app on the merchant store. +5. Copy: + - **Client ID** β†’ goes into the Shopify Account `Client ID` field. + - **Client Secret** β†’ goes into the `Client Secret` field. + +> **Webhook signing:** under OAuth 2.0, Shopify signs webhooks with the **Client Secret**, not a separate shared secret. The integration handles this automatically β€” you never set `shared_secret` on an OAuth account. + +### 3.3 Create the Shopify Account record + +In the desk, go to **Shopify Account** (URL: `/app/shopify-account`) β†’ **+ New**. + +On a fresh install you'll see the empty list with a **Create your first Shopify Account** button: + +![Shopify Account list β€” empty state](docs/images/01-list-empty-state.png) + +**Authentication Details:** + +| Field | Static Token | OAuth 2.0 | +|---|---|---| +| Authentication Method | `Static Token` | `OAuth 2.0 Client Credentials` | +| Shop URL | `mystore.myshopify.com` (no `https://`) | same | +| Password / Access Token | `shpat_…` | *(hidden)* | +| Shared secret / API Secret | API secret | *(hidden)* | +| Client ID | *(hidden)* | from Dev Dashboard | +| Client Secret | *(hidden)* | from Dev Dashboard | + +The form swaps fields automatically based on your **Authentication Method** selection β€” you only see the fields relevant to the chosen method. + +**Static Token mode** (default; for apps created before Jan 1, 2026): + +![New Shopify Account β€” Static Token mode](docs/images/02-new-form-static-token.png) + +**OAuth 2.0 mode** (for apps created via Shopify Dev Dashboard on or after Jan 1, 2026): + +![New Shopify Account β€” OAuth 2.0 mode](docs/images/03-new-form-oauth.png) + +Notice that switching the dropdown hides `Password / Access Token` + `Shared secret / API Secret` and reveals `Client ID` + `Client Secret` (with the eye-toggle for the masked secret). The hidden token-storage fields (`oauth_access_token`, `token_expires_at`) stay invisible in both modes β€” they're auto-managed. + +**Company-Dependent settings:** + +| Field | Notes | +|---|---| +| Company | The ERPNext Company this store belongs to. **Cannot be changed later** without recreating the account. | +| Cash/Bank Account | Required if you'll sync Sales Invoices | +| Cost Center | Required if you'll sync SI / DN | + +Leave **Enable Shopify** **unchecked** for now β€” we'll turn it on once configuration is complete. + +Below are all the sections you'll see on the form (full-page view in OAuth mode): + +![Shopify Account β€” full form layout](docs/images/04-full-form-oauth.png) + +Click **Save**. + +### 3.4 What happens on first save with OAuth + +If you chose OAuth and saved with credentials filled in, the controller's `before_save` hook does this in order: + +1. **Validates credentials** by calling `https://{shop}/admin/oauth/access_token` with `grant_type=client_credentials`. +2. On success, mints a token (~24h validity, Shopify returns `expires_in: 86399`). +3. Stores the token in the hidden `oauth_access_token` field (encrypted) and the expiry datetime in `token_expires_at`. +4. You'll see a green flash: *"OAuth credentials validated successfully."* + +If validation fails, save is blocked and the error appears in **Ecommerce Integration Log**. Common causes: +- Wrong shop URL (typo, `https://` prefix, missing `.myshopify.com`) +- Client app not installed on the shop +- App doesn't have Client Credentials grant enabled +- Network can't reach Shopify + +### 3.5 Enable + register webhooks + +1. Tick **Enable Shopify**. +2. Save. + +The `_handle_webhooks` step now runs. It uses the right token automatically (OAuth: minted token; Static: the Access Token) to register webhooks for: + +- `orders/create` +- `orders/paid` +- `orders/fulfilled` +- `orders/cancelled` +- `orders/partially_fulfilled` + +If registration fails, you'll see *"Failed to register webhooks with Shopify. Please check credentials and retry. Disabling and re-enabling the integration might also help."* That message means Shopify rejected the registration β€” **almost always a credentials or scope issue**. + +You can verify in Shopify Admin β†’ **Settings** β†’ **Notifications** β†’ **Webhooks**, or via API: + +```bash +curl -H "X-Shopify-Access-Token: $TOKEN" \ + https://mystore.myshopify.com/admin/api/2024-01/webhooks.json +``` + +--- + +## 4. Part 2 β€” Per-account configuration + +These all live on the same Shopify Account form. + +### 4.1 Customer settings + +| Field | Notes | +|---|---| +| Default Customer | Used when Shopify order has no customer email | +| Customer Group | Applied to every Shopify-synced customer | + +### 4.2 Warehouse mappings (locations) + +Shopify exposes locations (warehouses, retail floor, etc.). Each must map to a real ERPNext warehouse **in the same company** as this account. + +**Steps:** + +1. On the Shopify Account form, click **Fetch Shopify Locations**. +2. The child table **Shopify Warehouse Mapping** populates with each Shopify location. +3. For each row, set **ERPNext Warehouse** (filtered to this company). +4. The first row's warehouse acts as the **Default warehouse** for that account if not separately set. + +If you skip mapping a location, orders fulfilled from that location will fall back to the default warehouse. If even that's missing, sync errors appear in the Integration Log. + +### 4.3 Tax mappings + +Shopify sends tax line items by **title** (e.g. *"VAT 15%"*, *"GST"*). Each title needs an ERPNext tax account. + +In the **Map Shopify Taxes / Shipping Charges to ERPNext Account** table: + +| Shopify Tax/Shipping Title | ERPNext Account | Description | +|---|---|---| +| `VAT 15%` | `VAT Payable - Company` | Saudi VAT | +| `Standard Shipping` | `Shipping Charges - Company` | Shipping line | + +For unmapped titles, the integration uses: + +- **Default Sales Tax Account** β€” fallback for any unmapped tax title +- **Default Shipping Charges Account** β€” fallback for unmapped shipping titles + +Both are required when sync is enabled. + +### 4.4 Document series + +| Field | Example | Required when | +|---|---|---| +| Sales Order Series | `SO-SHOP-` | Always (orders) | +| Sales Invoice Series | `SINV-SHOP-` | If "Import Sales Invoice from Shopify if Payment is marked" is on | +| Delivery Note Series | `DN-SHOP-` | If "Import Delivery Notes from Shopify on Shipment" is on | + +Use distinct prefixes per store for traceability (e.g. `SO-KSA-`, `SO-UAE-`). + +--- + +## 5. Part 3 β€” Sync setup + +### 5.1 Sync direction matrix + +| Direction | What syncs | Trigger | +|---|---|---| +| **Shopify β†’ ERPNext** (always on) | Orders, customers, addresses, fulfillments | Webhooks | +| **ERPNext β†’ Shopify** (opt-in) | Items, prices, stock levels | Item save / scheduled | +| **Shopify β†’ ERPNext** (one-time) | Existing products | Manual via *Shopify Import Products* page | +| **Shopify β†’ ERPNext** (one-time) | Historical orders | Manual via *Sync Old Orders* section | + +### 5.2 Order Sync Settings + +| Toggle | Effect | +|---|---| +| Import Delivery Notes from Shopify on Shipment | When `orders/fulfilled` arrives, create a Delivery Note | +| Import Sales Invoice from Shopify if Payment is marked | When `orders/paid` arrives, create a Sales Invoice | +| Add Shipping Charge as an Item in Order | Adds shipping as a separate Item line on the SO instead of a tax row | +| Consolidate Taxes in Order | Roll per-item tax rows into a single tax row | + +**Recommended starting setup:** turn ON DN-on-shipment and SI-on-paid; turn OFF the shipping-as-item unless you specifically need it as a stockable item. + +### 5.3 Inventory Sync (ERPNext β†’ Shopify) + +| Field | Notes | +|---|---| +| Update ERPNext stock levels to Shopify | Master switch | +| Inventory Sync Frequency | 5 / 10 / 15 / 30 / 60 min | +| Last Inventory Sync | Auto-managed read-only timestamp | + +When enabled, a scheduled job pushes ERPNext stock balances (per mapped warehouse) to the matching Shopify location. + +### 5.4 ERPNext β†’ Shopify product sync + +| Toggle | Effect | +|---|---| +| Upload new ERPNext Items to Shopify | When a new ERPNext Item is created (matching this company), push it to Shopify | +| Update Shopify Item after updating ERPNext item | On Item edit, push changes (price, name, description) to Shopify | +| Sync New Items as Active | New items appear as published, not draft | +| Upload ERPNext Variants as Shopify Items | Treat each variant as a separate Shopify product (only 3 attributes supported by Shopify) | + +### 5.5 Initial product import (Shopify β†’ ERPNext) + +For onboarding an existing store with an existing catalog: + +1. From the Shopify Account form β†’ **Import Products** button (top right). +2. The **Shopify Import Products** page opens. It paginates through all products on the store. +3. For each product, it creates an ERPNext Item (skips if the SKU already exists), creates the variant Items, sets `shopify_selling_rate`, and creates the corresponding **Ecommerce Item** linkage. + +Run this once during onboarding. After this, ongoing changes flow via webhooks (Shopify β†’ ERPNext) or scheduled jobs (ERPNext β†’ Shopify) depending on your toggles. + +### 5.6 Historical order sync (one-time) + +If you want orders that existed in Shopify *before* the integration was set up: + +1. In **Sync Old Orders** section, set **From** and **To** dates. +2. Tick **Sync Old Orders**. +3. Save. A background job paginates through Shopify orders in that range and creates SOs (and DNs/SIs based on order status). + +`is_old_data_migrated` is set to 1 after completion to prevent re-runs. + +--- + +## 6. Part 4 β€” Pricing flow + +This is where most clients trip up. There are two prices to think about: + +| Price | Lives in | Field | +|---|---|---| +| **Shopify Selling Rate** | ERPNext Item | Custom field `shopify_selling_rate` (Currency) | +| **ERPNext Selling Rate** | Item Price (linked to Item + Price List) | Standard `price_list_rate` | + +The integration uses **`shopify_selling_rate`** as the canonical price for Shopify sync. It's separate from the regular ERPNext `standard_rate` and `price_list_rate` so you can keep retail pricing decoupled from B2B/wholesale price lists. + +### 6.1 Setting price on an ERPNext Item (then push to Shopify) + +1. Open the **Item** in ERPNext. +2. Locate **Shopify Selling Rate** (sits next to `standard_rate`). +3. Set the value (e.g. `199.00`). +4. Save. + +If **Update Shopify Item after updating ERPNext item** is enabled on the corresponding Shopify Account, the new price is pushed to Shopify on save. Otherwise it goes out on the next manual upload. + +### 6.2 Setting price on Shopify (and importing it to ERPNext) + +When a product is imported via **Shopify Import Products** (or created on Shopify after integration), the integration: + +1. Creates the ERPNext Item if missing. +2. Reads Shopify's `variant.price` and writes it to the new Item's `shopify_selling_rate`. +3. **Does not** automatically create an Item Price record β€” that's intentional, so it doesn't conflict with your existing ERPNext price lists. + +If you also want it as an Item Price in ERPNext, create that manually or via a server script. + +### 6.3 Multi-store pricing strategy + +Since each Shopify Account is one Company, and `shopify_selling_rate` is one field on the Item, you have two patterns: + +**Pattern A β€” One Item per company (recommended for distinct catalogs):** +- Each company has its own Items (different `item_code`, e.g. `KSA-SKU-001` vs `UAE-SKU-001`). +- Each Item has its own `shopify_selling_rate`. +- Clean, but duplicates SKU rows. + +**Pattern B β€” Shared Item across companies (only when prices match):** +- Same Item used by both Shopify Accounts. +- One `shopify_selling_rate` value. +- Both stores must price the item identically. If they diverge, you must move to Pattern A or extend the schema. + +The integration as shipped supports Pattern A out of the box. Pattern B with diverging prices is a customization (per-company-per-item price field), not implemented here. + +### 6.4 Price flow diagram + +``` +ERPNext side Shopify side +───────────── ──────────── +Item.shopify_selling_rate ─────[push]─────► variant.price + (on save, if "Update Shopify + Item after updating ERPNext + item" is on) + +Item.shopify_selling_rate ◄─────[pull]───── variant.price + (on Shopify Import Products, + or first time a webhook + references a new variant) +``` + +--- + +## 7. Part 5 β€” Day-2 operations + +### 7.1 Adding a second (third, Nth) store + +1. Repeat **Part 1** β€” create another Shopify Account, with a different Company. +2. Repeat **Part 2** β€” its own warehouses, taxes, and series. +3. Each store is fully isolated; webhooks route by `X-Shopify-Shop-Domain` header to the right account. + +There's no per-installation limit β€” add as many as the bench can handle. + +### 7.2 Monitoring + +| Where | What to watch | +|---|---| +| **Ecommerce Integration Log** list (`/app/ecommerce-integration-log`) | Every webhook, every sync, every error. Filter by Status = Error | +| **Shopify Account** list | Disabled accounts, missing fields | +| **Item** list filtered by `shopify_product_id IS NOT NULL` | Items currently linked to Shopify | + +Set up an Auto Email Report on the Integration Log filtered by `status = Error` for daily summaries. + +### 7.3 OAuth token lifecycle + +OAuth tokens issued by Shopify last ~24 hours. The integration: + +1. **Caches** the token in `oauth_access_token` with `token_expires_at`. +2. Before each API call, `get_valid_access_token()` checks expiry with a 5-minute buffer. +3. If expired or expiring soon, calls `refresh_oauth_token()` to mint a new one. +4. **One retry** on transient failure with a 1-second pause. + +You don't need a cron job for token refresh β€” it happens on-demand. If `client_id` or `client_secret` change, the old cached token is invalidated automatically because `before_save` triggers a re-mint. + +### 7.4 Disabling a store (without deleting it) + +1. Open the Shopify Account β†’ uncheck **Enable Shopify** β†’ Save. +2. The integration unregisters all webhooks pointing at this site (using whatever token is still valid). +3. The account record stays in the DB; re-enabling re-registers webhooks. + +### 7.5 Migrating from Static Token to OAuth + +For an existing account on Static Token that needs to switch (e.g. Shopify deprecates the old custom app): + +1. Create the new OAuth app in the Dev Dashboard, install on the same shop. +2. Open the existing Shopify Account. +3. Change **Authentication Method** from `Static Token` to `OAuth 2.0 Client Credentials`. +4. Fill in `Client ID` and `Client Secret`. +5. Save. The validator hits Shopify with the new creds and mints a token. +6. Webhooks **may need to be re-registered** if the new app has a different signing secret. To force this: + - Untick **Enable Shopify** β†’ Save (unregisters) + - Tick **Enable Shopify** β†’ Save (registers using OAuth token) + +The legacy `password` and `shared_secret` fields are kept in the DB but ignored when method is OAuth. Clear them if you want, but they don't cause harm. + +--- + +## 8. Part 6 β€” Troubleshooting + +### 8.1 "Failed to authenticate with Shopify using OAuth 2.0" + +**Source:** `connection._get_access_token` log entry. + +| Cause | Fix | +|---|---| +| Wrong `shop_url` (e.g. `mystore.myshopify.com/admin`) | Strip path, just `mystore.myshopify.com` | +| Client app not installed on shop | Install via Partner Dashboard | +| Client Credentials grant not enabled in Dev Dashboard | Toggle it on the app settings page | +| Bench can't reach `accounts.shopify.com` (firewall, proxy) | Open egress, configure `https_proxy` | + +### 8.2 "Webhook validation failed: Secret key not configured" + +**Source:** `connection.store_request_data`. + +For OAuth accounts, the integration uses `client_secret` for HMAC verification. If empty, this error fires. + +Open the account, re-enter `Client Secret`, save. + +### 8.3 "Unverified Webhook Data" + +The HMAC the integration computed didn't match what Shopify sent. Possible causes: + +| Cause | Fix | +|---|---| +| Wrong secret in DB (e.g. someone copied the API key instead of the API secret) | Re-paste the right value | +| Mixed up `shared_secret` with OAuth `client_secret` after switching auth method | Make sure the right secret is set for the active method | +| Webhook was registered with one secret, then secret was rotated | Disable + re-enable the account to re-register | + +### 8.4 "Failed to register webhooks with Shopify" + +Almost always credentials or scopes: + +``` +1. Verify token works: + curl -H "X-Shopify-Access-Token: $TOKEN" https://mystore.myshopify.com/admin/api/2024-01/shop.json + +2. Verify scopes include orders/inventory/products: + curl -H "X-Shopify-Access-Token: $TOKEN" https://mystore.myshopify.com/admin/oauth/access_scopes.json + +3. Verify callback URL is reachable from Shopify (must be HTTPS, not 127.0.0.1): + The integration uses `https://{site}/api/method/ecommerce_integrations.shopify.connection.store_request_data` +``` + +For local dev, set `localtunnel_url` in your site config so webhooks resolve to a tunneled URL. + +### 8.5 Orders arrive but Sales Invoices/Delivery Notes aren't created + +Check on the Shopify Account: +- Is **Import Sales Invoice from Shopify if Payment is marked** ticked? (for SI) +- Is **Import Delivery Notes from Shopify on Shipment** ticked? (for DN) +- Is **Sales Invoice Series** / **Delivery Note Series** set? (mandatory if those toggles are on) +- Is **Cash/Bank Account** set? (mandatory for SI) +- Is **Cost Center** set? (mandatory for SI/DN) + +Then check **Ecommerce Integration Log** for the `orders/paid` or `orders/fulfilled` event β€” it'll show the actual error if creation failed. + +### 8.6 Token never refreshes (OAuth) + +Check `token_expires_at` in the DB: + +```sql +SELECT name, authentication_method, token_expires_at +FROM `tabShopify Account` WHERE name = 'mystore.myshopify.com'; +``` + +If it's old and the integration is making API calls, but the token isn't being refreshed, look at the log for `oauth.refresh_oauth_token` failures. The most common cause is the client app being uninstalled from the shop after the initial token was minted. + +--- + +## 9. Reference + +### 9.1 Shopify Account field reference + +| Section | Field | Type | Required | Notes | +|---|---|---|---|---| +| Header | `enable_shopify` | Check | β€” | Master on/off; controls webhook registration | +| Auth | `authentication_method` | Select | Yes | `Static Token` or `OAuth 2.0 Client Credentials` | +| Auth | `shopify_url` | Data | Yes | `xxx.myshopify.com` (no scheme, no path) | +| Auth | `password` | Password | Static only | Admin API access token | +| Auth | `shared_secret` | Data | Static only | API Secret for HMAC | +| Auth | `client_id` | Data | OAuth only | From Dev Dashboard | +| Auth | `client_secret` | Password | OAuth only | From Dev Dashboard | +| Auth | `oauth_access_token` | Password | auto | Hidden, encrypted, auto-managed | +| Auth | `token_expires_at` | Datetime | auto | Hidden, auto-managed | +| Customer | `default_customer` | Link Customer | recommended | Fallback on customerless orders | +| Customer | `customer_group` | Link Customer Group | yes (when enabled) | Applied to synced customers | +| Company | `company` | Link Company | yes | One company per account | +| Company | `cash_bank_account` | Link Account | yes (when enabled) | For SI | +| Company | `cost_center` | Link Cost Center | yes (when enabled) | For SI/DN | +| Order Sync | `sales_order_series` | Select | yes | Naming series | +| Order Sync | `sync_delivery_note` | Check | β€” | Toggle DN sync | +| Order Sync | `sync_sales_invoice` | Check | β€” | Toggle SI sync | +| Order Sync | `delivery_note_series` | Select | yes if DN on | | +| Order Sync | `sales_invoice_series` | Select | yes if SI on | | +| Order Sync | `add_shipping_as_item` | Check | β€” | | +| Order Sync | `consolidate_taxes` | Check | β€” | | +| Tax | `taxes` | Table | recommended | Title β†’ Account map | +| Tax | `default_sales_tax_account` | Link Account | recommended | Fallback | +| Tax | `default_shipping_charges_account` | Link Account | recommended | Fallback | +| Outbound | `upload_erpnext_items` | Check | β€” | Toggle ERPNext β†’ Shopify product create | +| Outbound | `update_shopify_item_on_update` | Check | β€” | Toggle ERPNext β†’ Shopify update | +| Outbound | `sync_new_item_as_active` | Check | β€” | | +| Outbound | `upload_variants_as_items` | Check | β€” | | +| Inventory | `update_erpnext_stock_levels_to_shopify` | Check | β€” | Master toggle | +| Inventory | `inventory_sync_frequency` | Select | yes if inventory on | Minutes | +| Inventory | `warehouse` | Link Warehouse | yes when enabled | Default warehouse | +| Inventory | `shopify_warehouse_mapping` | Table | yes when enabled | Per-location mapping | +| Inventory | `last_inventory_sync` | Datetime | auto | Read-only | +| Old Orders | `sync_old_orders` | Check | β€” | One-time historical sync | +| Old Orders | `old_orders_from`, `old_orders_to` | Datetime | yes if syncing | Range | +| Old Orders | `is_old_data_migrated` | Check | auto | Prevents re-runs | + +### 9.2 Webhook events + +The integration registers these on enable: + +| Topic | Handler | What it does | +|---|---|---| +| `orders/create` | `order.sync_sales_order` | Create Sales Order + Customer | +| `orders/paid` | `invoice.prepare_sales_invoice` | Create Sales Invoice (if enabled) | +| `orders/fulfilled` | `fulfillment.prepare_delivery_note` | Create Delivery Note (if enabled) | +| `orders/partially_fulfilled` | `fulfillment.prepare_delivery_note` | Same, partial | +| `orders/cancelled` | `order.cancel_order` | Cancel SO/SI/DN | + +Webhook routing: incoming requests are matched to a Shopify Account by the `X-Shopify-Shop-Domain` header. + +### 9.3 Custom fields added by the integration + +Created on `setup_custom_fields()`: + +| DocType | Field | Notes | +|---|---|---| +| Item | `shopify_selling_rate` | Canonical Shopify price | +| Item | `shopify_product_id`, `shopify_variant_id` | Linkage | +| Customer | `shopify_customer_id` | Linkage | +| Supplier | `shopify_supplier_id` | Linkage | +| Address | `shopify_address_id` | Linkage | +| Sales Order | `shopify_order_id`, `shopify_order_number`, `shopify_order_status` | Linkage + status mirror | +| Sales Order Item | `shopify_discount_per_unit` | Per-unit discount | +| Delivery Note | `shopify_order_id`, `shopify_order_number`, `shopify_order_status`, `shopify_fulfillment_id` | Linkage | +| Sales Invoice | `shopify_order_id`, `shopify_order_number`, `shopify_order_status` | Linkage | + +### 9.4 Files of interest (for developers) + +| File | Purpose | +|---|---| +| `shopify/oauth.py` | Token mint, refresh, validation | +| `shopify/connection.py` | Session decorator, webhook ingress, HMAC verification | +| `shopify/doctype/shopify_account/shopify_account.py` | DocType controller, validators, webhook registration | +| `shopify/doctype/shopify_account/shopify_account.json` | Field definitions | +| `shopify/order.py`, `invoice.py`, `fulfillment.py` | Webhook handlers | +| `shopify/product.py` | Product import + push | +| `shopify/inventory.py` | Stock sync | +| `patches/set_default_shopify_auth_method.py` | One-time backfill of auth method on existing accounts | + +### 9.5 API version + +The integration is pinned to `API_VERSION = "2024-01"` in `shopify/constants.py`. Bump this when you need access to newer Shopify features and validate against their migration notes. + +--- + +## Appendix A β€” Onboarding template + +Use this template when bringing a new client live. Fill it in before you start clicking. + +``` +Client: ________________________________________ +ERPNext Company: _______________________________ +Shop URL: ______________________________________ + +Auth method: [ ] Static Token [ ] OAuth 2.0 + If Static, token: shpat_____________________ + If OAuth, client id: ___________________________ + +Default Warehouse: ________________________________ +Cost Center: ________________________________ +Cash/Bank Account: ________________________________ +Default Customer: ________________________________ +Customer Group: ________________________________ + +Naming Series: + Sales Order: _______________ + Sales Invoice: _______________ + Delivery Note: _______________ + +Toggles: + [ ] Import DN on shipment + [ ] Import SI on paid + [ ] Add shipping as item + [ ] Consolidate taxes + [ ] Upload ERPNext items to Shopify + [ ] Update Shopify item on ERPNext update + [ ] Update ERPNext stock to Shopify (frequency: ____ min) + +Initial sync: + [ ] Run Shopify Import Products + [ ] Sync old orders from ____________ to ____________ + +Verification (after go-live): + [ ] Test order placed in Shopify, SO arrives + [ ] Order paid, SI created + [ ] Order fulfilled, DN created + [ ] Item edited in ERPNext, change visible in Shopify + [ ] Stock changed in ERPNext, level visible in Shopify +``` diff --git a/ecommerce_integrations/shopify/docs/images/01-list-empty-state.png b/ecommerce_integrations/shopify/docs/images/01-list-empty-state.png new file mode 100644 index 0000000000000000000000000000000000000000..8191ed4e5ab524434fb27dcc908deba1654723cc GIT binary patch literal 43119 zcmce;bwJZ!^fwL$d;|p*5NQi(P(qYc>Ct1OL#12k#sUQelgySQs7IWU-bcXaY4=dt7!Tgv1y%y9}Wi5a6 zn#b^)IKwFhhG%*IK?|d<4a&d2(Zqh;XE0&WukTpC?`ZJnTepgH8zb`(31eevV`F1k z-Z_0eCE=EqnP*#t;UY5^2cHhDtJvjZ?>rCXoVAov8 zh#(smd(C!#+GnYng~p5U>l(YOy^_{6in`*7UEkhLLVjV@%Tc^$vHq2Nj9O_nEs&L;c;zx1S1`rp$8Q@y?n$aAR+yjH%}l7j z&y%pN@*#ZE=vr{KwTg>|b?j~wF2q(e$EjO(x22zzjnntp*7BT`{#&~}SLr5??E1iv z(~>KTXJ|gntEs9Q#GM+Ce-Cwv30;JKf>ciPI#5vXr^Kz`hdMr!1Z~5p#EuZr|it)o&a_S@8cw5fT$KSdKD zWbXBl?$7C^`TV(Ouy}}XEG1kjit2g$QP`_#Z}w;s5@Hn-W#%mh`-ZY1LlNHZ{+>H0 zYINVkN2f$?rH*RAaO5gn>+=IbwnenrPbE7p*o{Y(4#^^o=`5IJ#* zrV-}mR&w^*o5ExI=Q|6%JBxiapCmzK79A?FboT2=i*&mdYTV!2+v?=_jg`dDYCpYn z%#W*{gFNM;zdWs zu~V(-$g@}br^RS!D&8`(i?5EB&;9&-le0gEfHCnLE=fCkP3o$&Q-2hT01=O>wC_%y zZGC5rgVhyY(<6P1L(ZN(NNEz9S;rk`kA}_WvyQy!6FV3p;z9nA;p4c?2t2pR};oXe|M$7k!} zl#c?2OUz0wYO2d7mIex1j*kxf*2c}sGL$ax$dP(wXL>&0e3zWGou-o|=lk^~Z3F`w z+On?VEX~jRpFe*V%K5iB+nOMtFAhh;VmM`p1Wa+bevVSplW(u1*+l5*=`9|j-$q1~ znpGC5MqQo`bbO9rqGm`r>UB%$7>V(uawF*Sl)g}QLNwj590kqO6}6CWgKgeyU%2Z7 zww|p|)|HB`x%m9s|Eb-nbCHW(-sKzqSs;Vu`35i7d2{0m5L2(IuTN1=iAsFpg$t>n zCZbsdS-E{~Nw1biOn63kUt9iAX+2Hwl zYVhAE6SR+mX+dUkIFFYK;))6Uy`@kFa@2SYpU^}d%=3ar^B)OPwy>Ulx9%;+ox77FagMjGDPWF7@P?2u%VwQik z=Zr_(SiLW1{F9-fq1IIUX3b2P(VTJ4eUY1QJ1h%|o!5@6UKm?$6s^r1*_->geQd$b z$T+6ny!K=J)bfDyB{nYE#h;x+vzKJ&`^M02-`eLHcRfrH*0}Er!;8n%J0St28CN}< z`#cMLe=~|ztN)Ow4?ICnvers{pU6av0*7(WrQsCMg47TcNcPT792qz$ow_bKdrj4dJ#exadB zd&MNEn{m&#&oI)(2x27fx2oBx6U(cJzK!M7CtmZMs4k5?)pEG~A!!WxbgxhO*i`c- zF}Z1fA*1)ppW(fkaP0XZG#takBj@yc!m|Y)-0Gz!#D@Le+A79u+8naK(Q;g793vO# z*|1hEVp0Y!6(O???Q29Pl&IWBa1)wC4+?)RX{%`w=`Ot}6~wJ#bz{2WuEtvnsiULH zyVeKto9R+Ivde}^^_(nH$D=~?xlb<$SBt>~`$uFmTCgoM$J#FpFO`cdaGP$m9{qkY z|`VwJbjITn0xjoANwQID%_*veT)8~Prl}dL;m1o(gmMPYAmn(bg9YAMsqeS zKcJjdNsU8)M&j<>yG1qvw{HFYAki#nS^FgrHYcE)E**Xpa=~YLaQ+)TpY&Xk3#@Lu zF|e{rm9oAxR#}`~25s0LO}%UD(36^1VB4E66TG)>)fD90?OXO6Bq2O1cY|{WmoJy9 zl%MW4QSELMN=`~Er6j<(55R?DDH7$8!N5@Yu{yr$Xa(PmroC}jIgIbR})NLVS#@bI!xk(v<54#)?YEhxG9#!T43+I5x4ki98?dS+fc z!;(DzwQFC)u1h)o!ZWZ7er0E%x%GI;TUd*q^65tM>txBKq}ORn&y6s{;f~)EC2Xvl z=KL7pF(XMLCc=oNj5-$rI=%S8l=iYK0S%`-GimYta7D~Vv%>PiI>-d*S&XLlN@z^9E?6lUY1+uPgGubPARJW3yeWSTDPnSHY( zyoIJ9I+^LIfPOZfn>Tp%Zg3Ef5Xi#_S}I26BU7&VJEc%cs)mdR&qcn#zn3Z_&1HE) zrL8q?@c8$LK!{28Lprcq1Kph&vRKY<3tt!4K_X7Cv`%rzr=&szIlowv=}X@*$nWng zj`-c_@Tinra3AzD;5S%S`_O;MMlCytOf)N7KL$k;vz04`az{G69E`|DE?d-ixL0T7 zV}~to;&iKe2`T96cWHHzxJm64ac3@Y3OnspK{rmZ2T;I!_CgFFx$S4f&wcY z!ujktrAgY0+ccLvORWC;Z);9Q{g~*x%zuka#}DZUg(uUUzxrvZ03C&z%8yPqyI^?N z`&Z_J@sC0KaLvL+F>Gi6+p{W4;C zofSKLbf?u>NZxPxc@hS66Rww2|A1koU2Lsvo?KYDr_c*XbBS@n)c+is<4X^-i|WH zuts~-&0o2&S0u?=+06aZ>^cEyuwZc)m0>Eg=*d2F;JNAN-~--arfboz~3xVN1LxL21`1rAE+&z{!JePjFl`4=sjEhMDqe342#r(@7{<-^T4R22)C`IV(B*tD{_ z&ko4{NZ%PHoTzMTASi6HNmm@%O{*g5nYjD&8~oPl9s77AKG|8L%13lTe_2dg(F;k$%TmI65*-*=mrA~ci)%7ji?RiI|r#IIW1gu*X1Ba9}+(GhrDUY--LJkhT$)i)_Bh@7 z{8B205e$iLuCQ)G^#cS4Pl|pY`#-TvdJi3=Qq5U~ zF0!!ieOm|(ek_sGaCv_(&)aa|c*;cS-fh3dBBK-uwc*-Qr>O>Qy2yVW4cEFPe%1uM z`~EHBb=hhQ=f0<~YoH1*-;$s%szY<;7xAazt^S&i&8{S>lQNnq>C#Pg|Pw{A4;$djE8P0dwy{mS>BEmC1om|cZQnFMXWNt(X_DT-m9gh93VRbHQpFW%9zK@y zL!#WR_>v0O8#a{S%B;+I-Ll#y3laoV==bbN##0A2{mfz0j22|x<=7*#jC6OgG%YAan_plH#}A+!oqq|#-6v}YtU1(l zaR#_Y_?xJog>I;uxz#VH;O4c@pEhxqrv6E$lSUL7=2mzwEACAe+(NzcVKwITnaMV% zRW!s}d6q{((Lv6U(dj{Pc+~*~mZ_I6bW2lrTZ=H#^CQ0ye=k65P~(#fzT+#?FYbE2 zl?T0J+2xJHxb#(rkHZFc`?@k&jmbr~$jH~pN~es22CQf2+M7qM5AxEnXjP$p7p$>F z9j9+ml#@|-FFjvKt_Dt^1++yh0(v?=m!e`vNXF&|WNfc5#H8y|qHe30@JMWQlo`&k zAxW#S<6IbRb91OjvU%82Pf_dQL}*QQk^a}*$azfS5w_siWlJ27G(0D4cnVThSq@zHIIrJK#*Fn4=>ZW}^ zM(L_Ki#NKd*_zyo5Eth;j~^>&jcZaZjaSl3UfIdP2b7`@0(x}^G!;WSOY&kJDfzOk zK{1)jOf*8p4wz;K7HbiHPAnxdNfKctI)bPa{)vpvhsK-lKo~cg56TqNmL~?cNT6Gt z08RAe*AL9cT3YAAvg)%vnEWF1KPbA*0=|%Jf3Br$}OFHA+$VN zzN2tcP{^P3m$zHzhxQv9s!15ARTqM+$WuU&an>9Bu!p{2&n4E@%$WG#=D^31*P=CR zPdXw4ux*zA+()gDT20o$r3hymu#akWVYCx7Ig{TFa~&yo=qRdD6B? z?fhUZLM1jnT8NJPtG_wa?xS2je{`@>W64fGE>cu8IM5{inKjh(^0eP~ucl?nWSRxz zcr4$sGVaXU#BQ;5D4+1Yi@tV%S4q$f*33zX^G0!v=Ece0N6klCS@G;VTssHTYfnF4 z>Ye7Wi?iI_D@ukFZ!4>G%NKS8{iFP2z0kR9wv)c$Hd^_7q_c9Md_HA+%N z!~x}iRq*qaz$1e}c0C=_o`D=a^~{)_3DNK;uU6_`Sx340){G{8ASoiIh1TF=mU!P{ zCN^)Mtt~Bu0e)`JaNG8b)pX^brO;7!n~-wP8KtDcDw663_rGq57GxzP>*?-Q-4ekh zd{?1INWKIu&2vT8e0;n$&IR7c7jORM$*VwVeknr{NUy!j8&Q)Bs#_}bO#JlZl4ayw zyPux|Y*yu}sMNg~>cO6@^XyKavc6AsP<>Xos7am*e^FuVrFB+c{Cy*q8EFbZQoBto z8{TAA?}MV8!J)-0s;fFMTct>c!9>Unh*D6DRuj}85-0ab@#x=NG(%8+o%+wL_;x@% z6y)dYSb%!;E=){wZf|48Q@>~=DY9mBIN9(WDn81bGkGPw{BQ9{K4H|?CQVhqXc*_} zF~030?m1r7*(B??yE;Z0X|5P&!FOVIqd4z4^obf3eI&w&0^}y%>aZ3F3o5N$9 z>FJQAoMoL$k685^$pl0*Iyygxt9JEpSpat7{i62fouOp}kAUveMKXN#`nNRchv+gI!O z!YF|Z+EC$-&49yLwLIp6jX$qwh{yw5 zhq*Rf?LP6AW$D1`0dYRX=7E9Q57RO?SePy?)g@H8`7bI@jI% zuw5*$PR0C(zS6j>gA++K#4(~w(Ic)iT57I;tcg9AWr0a4vX*oX;z4|JKF9~Iq&Lh; z1~tKr*)P1^Ta@g_YuFO-t?~HD#3I-#G`&8XoBvUmReEn%Cml3eC?v|@hj z7g`vuzsucX=aBkqP+|~u4}Qm@{|!O=qq5g*$9%AOW_|k5i02BCW#DePuX&=m{L1Dl zxZ~mXxf5nv`-IuP=-6icK3a)`m;1Mc{lm1^HS9WVYX|2w9zXz$EKHOHq=|8<`7&b= zz-w~G{Kpnr;{=j@o|W!t;vVJSTpV90$*>`3bp_ipobsW0&Y?FQA(5DPNS9bw5PLh; zzZaQz^WBY4`++tp^7WLPzJ<%vO~EZ;RHgmDEMz^Qo68s@nVH8C49@oPoLEWfXt}K~ z&2RuYVR^@aa^2_JKIo}%+Yn=lxlZSlHkpe2SgcxnGYHz^MniMQ8G_P!1$mbm&#z6? zClvJP>!r>9UF^?&bXtb$d%u6Ly17#(w8mqq=S49}w{;94hBCQ^1?tHw>Mkx7 zn5zg0N8jTycEXQ>UrIbLuZWo!-foCxL_ev`Sxi=dx8vEB+{dNK$}UQP#5k7eHp96P zpex>_r2%H;!!LDB2~3IrQo92hWhw_lOdxWB-T2J()O>%THzQ*!su|=ZCO|EQ0=!Lt zd)frD#X00N1@q7oNG9j|^JcuVmW$U=2F1F0v6=+G!OaeA)l<(Fs$Xg9U}9*pgCzd# z(l`!djhc4VEs3$UdAp#Oq3IgDzin?601Gm-qksg;hOD|vjzva1@z5w4uD1T1F01z8 zim!R$AT(aCp|5`$Q&NT_{6gs#D;jOQwu&0myNbP6dK#nTzIzc8^Khy7JwqmfbKfjbL?p$x*XRE#y2E7~msvYfz z<^CP5F~|C2*ZiwjeIanzkNN`h)5TIc;Y!OPbS9>-o{usI;pPcQT z7h?%&|DK$?#RZ40rC&vizXpn28)VonfBoi=!MM|KrH(<(f{k=xo4lpli-RsWEC~V9 zDX3a!)WBX{NNt93;XJ-@o!O&K+=|eQ-1sk4MW`y(CRa*UCH!EmhOJ6kHYtM?;em#( zR}MaXNY3w5I_R;c<!2T>s?x6FS2NV9YdieY_(fQzp;h zm32wcAt?Q8X*6GBey8s#>0S>#7Wp*l|E+mIun#$c1@!N3j5l1{ka$(n?tO%-;YP8f z_jk=@{=_~?8rM-~K{ImLu0kLvSqV-)o!KMP3o26xdUC)s%I}=~?6Sejv!OQ}kG=+iL zyC!`a<^#$4B`YmgONYaYc`1%N-NtwB+%b8XLo`IS%&V$P8oG2P3Q=6VNvedUjk-l% z{>%W!`!qC-M&@cLuC(erGzTakSZYSh6~nG` zT0z~k4?Hl4%pRu7a!V_8X5m*DdiqvS4e9UHe}<4>8kQ84|Ax`+tR&>MskeAK$*sH?nq7((!#gFPexHEh4GK zo_g8yePl#-dTRE!rjAZe?Tb0D#b2e_s*ETo;1gGl-39x$mgF;*Vps-01?$Mo=08N7 zL2$w(9jV`vZ{GRqqVq2ad1r-U$NaO%%*?vA5-udQQ>d3Te7$3maVC58`~=3Xz3^}Q zBWn>hxDbyvU=flB5!#s8OuVNC*c4q=)wgxn(=SQv7cYLw-lLON6J80gscYa}woK}J zXp-Nm_bx)cqumq*bT1P@i{{m%F;VR7m>{CD>{=q|w%^vY7(Fp?FAv!IvtV+wiW9)) z?rg;aP=&dH-B`{W+bpee2!~+Gu>v18;Ts07yQLeMd8Wm>k96;f{_P&ClQO z4&0U7bmDA7|IYor!~RRehezJ81Sk^>Frdo7#~^8t;8YiR`>w>Oj~^Ah{%OW$KJduo zVWRnDhU6%2<%kfvM%`~P-3+?@AL52?pkr)>0<{4umoLbAmX3}%)8Aj&+}sSPkfG)< z3JJBj`P^Orag$$!kQJMAKl`Qz(}j%f#P*DtbOYuYpdkbUKIH?u1h1Trps2#(&(8%w zew!A!cJ1XZ_orW^JgJs1A9WtRSZYZio#M_cERG_xO7s{e7Z)V%x}Cj6NR-Z$^|Xc~ zVR29$N%nA{#f)vlqv%uOWMrX6PW=vb}b^|!z`)Up?W8MpiGru{+_<@>Bf5OVR`ahDV0 zp*lJ`fOla_hPe*r1aAKwc-b!Kv}^;<_nPmr7f*rmcj-JoI^6&L`w10=hJk!sN+f~WpGpP!$9Krn;m5{MIDp5Hvj zz#wc^@wc>QdK@&5pst%v|M=f;PBYoOPxwFNUES_k%KpE9f_i7poDsAt+!S)IrAkc= zf-JXzGAjO+dUx({f9HzFlUJ`^F*krq`)ix&#E$U%f{U1{sw$G-{rZ}qA+o`0;7__y z(6a-_WCWjb*dcDj8VGr@XKN>YN30~8|K+C?3Y8^+Jsr8o!J!ok@g6S}YFzDLnNT(o zwMqyD-0WY$+6>Uv{_RW<+jw|#oSlZ=_k@MJUei0Zw*UBXOF3q-x@Av-3ea%I{TKOX z4{f5M|Lb?_9D>f}2no(h5cUVk+&r2sr@p6$Q+`x}r!u|VYk+C!xVy2E~{KM$xBB4!nK zC@2DGYVKA!?A8H!KaZV8K`6Jrhw}RN_O1-9ZOm)i)Oci_z2yMd>11}zxQsjyO@}}Q zYFw~8%Xp;`>}YA?*q5g&IZB~N)IFQv9zO9I&=LW;jq{$H6QUta%JB&7I%le+eb>Q8 zi>G=R&=A)hz3J%a%ts#0+-JNZ`sEnEY2n#4zqsgV?{xwpg2-qrcH`4^6;Ya(DYH!Q z|Ff80ZIk&~K&oH)bTvr_1WyV`5F#oF4LA|-$UN-yDXbS>O5zfZ)*S@V;#sf z_?!hL-XN#nUti76&KlRBh#3I-u_XiW;u)7I?_b6NMBsPNtN~0YUCAwRvc_F043}y2 znumz&aDhVy%I3jz@P-);&Bok7JIOXq5u9rdKlkwD@Mh7Ry$_X+z5OTILVTE z4iMuH07*UjJt85^;qTASAb0vv41mUb!j_-pQ?PWP^aX(=)f>?@!>4T(*Hvu8*)2EBw zWM`P=KInD`ukLzIHv&o2bQuF$e30Ki$PV7(3uXVvA$6PI|4=8}I)&rvohL7aLa2v3 zeKk{l8E0u8(i!eFnP9D5-l5jA@zVa2FWwDBnj;`dIxSD3_}Gx`P60q_X5G|?4Pc(~ z>J|fBX1TdJaF6W%1eH+Mb+~6_#Kfuwlp-O6+?VrO(RaJeZA;UD7+Ha-2g zkesLT4!;VsS8oOUIImL3Kg$$AM#!hfE#7M*8_fApNXstf#f9?MZ0);T5hr9j9gvS4 zy%OgsZL;LYiW|@p)A9g)!*O0oJIzva9H*>D2{r@p`?boPEG#>%%*qOzUr&SMz*sfx zsxT{_$b)<5SScS^g`m}LW56Atw9_L+fjM;n5#7>?X;(V^P2F2(AW>5)-6yI!?*+dU z#)Dp-kONdQ!d`9KDmd4QoZ!{byHXWlREie4UsXdR1J{)-mLcr_X$n;HPb$Ndop5-gSOSn+P_xs`9Nc zTEF4P!TpGch~){@tTPOc84QKDFcqFTXOOg^X3V?_rfg!N1hiR;(?C8Dnu)d-B?wEc zxu%jQ$nT6(7l)LC)3Nt)k>0rj-m51fx8mT@x3}w44Fd2+=;0lEk0v>`4{V;&AxDRS zrPT*^Xrn2hvL@Wth4u_F9xvvE<86zXCqQgJZJ7oMNtui{0>kD(G}=_XHml`3Hv@b=B1T{ES5+0$1R zOoF7(Pl{k0pXK~3Rf%L;Cr%9w{Fvml&{ZdIIsXO;2`(PJXX#sst|TaJnw4>VTkc0q zLoZ+~Dt%Xm7pgAt{PTf99Wf4NYNPswtqfl9sz)u%ics&a7{YO zPre$!bC(9c)ltK4xUJ4skM!~CJNyi_jol{ofE(Rvr9bku#H|0r)ySu1h*ieGYx2cL4YZgzNlKN+=$A*ON)2BwZS<(GVO&bm zpSt(Zo~F|QW(YBTLJS!qQvpC)5F&Xc6TZ>H7FP5TD$Iat&Z7;*DsKH?p==^^wD?(@ z^rMFL$+5z)rP4w6Xr*(>800gXwnEupP%AkN>TJ%kM2J@kIGzK%smMsRn-DIaC0 zy$CC$wo-DHB}(TY2bwU?kbpi+FDx9vtQOLSBBufEz?KV6s#KZ$sDfpRQKIpQuo^g5 z9%-jfOfXtn+VN_+FKPOyo6-8{7t8OFYAJ$--#;D_F<4QP;(~i09&|tUj2VOMvR|a3 z*e`X$4oewkomBeR%+6vMFE6&VKW(HpR+>B!E@Yx_Wj1EB;MGaMnN>Q}dCg0*AeF6xJ->f2h@W9~$!j7Ff9ohM z&7i}r4l+@6BUxzU_u}GWu_wK|CBhUpAfvt(oMa$v0E`}MTDRRsE9|?AEbTm8^XRCc zrI2}2CPzpq**)D&;LBBSJl7viC8tnwV5^+UVU6)g2PIVkDE~*NA@)Ir6c?qi!&>WH zj^$q=SfN)-Hlh9#Jm<|hO0ywaQwli6-3d#5Vtv=5^Qkr3?7T@_wS{}pVi2XA}?H@nX+{39B!)b3GGb3|;h^NTV1; z(!8Hmf72Hx$t>eBX{uqz!;pmHly=H(&vY>WwZ{HY$a#JwBb?;L`n9T4pPOWA|di)Kx6?i3uwU!YNPvbKS5?nuhNEhNiNYg+Z~- zP*BB(s6TS-c)g0P^u(m?Z{e=?jwy~auUXWKydkeo9Zc4FLv&qI(yO1HV7E7@E_5Vv zp5bwNRQxDV@1A6nffeT%ke-|daV=eRDK8@4rq`cyloR^T9#v4up{Os1c~&L*&#|U`r|2HPK9*^p`hQxu?TOZH3#56!9|R;q2%)- zX66>&7T)t(?)0JgxH=t;$k*H>5UN1$#&5^EQ&CF!huD>13x8vE{Z+l&bOo0zxDO6 zU^hGnYNnI(5WYLySUzYnr8Ffh*6rMMuxd3@f>`l zp?=4QRl8H-1JctX>*d1RrW0}D-E`*EqTcnjobV&V$_DbW6U2LR&mt9tTNIBZpjgQem5U9qU@Gbd)s#Eckxc<;MWCGl*#27=l z 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 literal 0 HcmV?d00001 diff --git a/ecommerce_integrations/shopify/docs/images/03-new-form-oauth.png b/ecommerce_integrations/shopify/docs/images/03-new-form-oauth.png new file mode 100644 index 0000000000000000000000000000000000000000..df55116244f39a2642c7cb59e8730ce76208090d GIT binary patch literal 87239 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 z≫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 literal 0 HcmV?d00001 diff --git a/ecommerce_integrations/shopify/docs/images/04-full-form-oauth.png b/ecommerce_integrations/shopify/docs/images/04-full-form-oauth.png new file mode 100644 index 0000000000000000000000000000000000000000..e5019ddca98b93dc36890a7df5e781abca0fe1f4 GIT binary patch literal 164271 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 H&wBqa71i`{ literal 0 HcmV?d00001 From 841f8481a514472caa8aeb8c6ddbe4d5cbb2bdab Mon Sep 17 00:00:00 2001 From: Ahmed Osama Date: Mon, 4 May 2026 20:38:12 +0300 Subject: [PATCH 24/30] fix(shopify): resolve product import, order currency, and tax-table validation issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small fixes uncovered during end-to-end shake-out: - product.py: item_group is a Document, not a dict β€” use attribute assignment instead of __setitem__ so product imports with a new product_type don't crash with TypeError on item creation. - order.py: pass shopify_order.currency through to the Sales Order so ERPNext doesn't fall back to frappe.defaults.get_default('currency'), which mismatches the company Receivable account currency on multi-currency setups. - shopify_account.json: drop mandatory_depends_on from the taxes child table. The integration falls back to default_sales_tax_account / default_shipping_charges_account for unmapped titles, so the table is genuinely optional. Marking it mandatory created an impossible UI state (empty row β†’ row fields required β†’ fill or delete β†’ table empty required). Doc updates: - ONBOARDING.md Β§3.2: Shopify retired the legacy 'Develop apps' page; Dev Dashboard is the only path now. Documents the scope-change/reinstall flow and how to invalidate the cached OAuth token after a scope update. - ONBOARDING.md Β§4.3: tax table is optional; emphasise the two default-account fields as the actually-required runtime setting. - New docs/SESSION-NOTES-2026-05-04.md: session log capturing the bench-level fixes, demo data, tunnel/webhook setup, scope changes, and known sharp edges. Misc: gitignore .playwright-mcp/. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + ecommerce_integrations/shopify/ONBOARDING.md | 77 +++++-- .../shopify/docs/SESSION-NOTES-2026-05-04.md | 213 ++++++++++++++++++ .../shopify_account/shopify_account.json | 1 - ecommerce_integrations/shopify/order.py | 1 + ecommerce_integrations/shopify/product.py | 2 +- 6 files changed, 269 insertions(+), 26 deletions(-) create mode 100644 ecommerce_integrations/shopify/docs/SESSION-NOTES-2026-05-04.md diff --git a/.gitignore b/.gitignore index 0a6dbe4a3..5631d3133 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ ecommerce_integrations/docs/current .aider* .helix .claude/settings.json +.playwright-mcp/ diff --git a/ecommerce_integrations/shopify/ONBOARDING.md b/ecommerce_integrations/shopify/ONBOARDING.md index 45cb094a9..2182f3cfb 100644 --- a/ecommerce_integrations/shopify/ONBOARDING.md +++ b/ecommerce_integrations/shopify/ONBOARDING.md @@ -113,24 +113,42 @@ You can mix methods across accounts β€” one client on Static Token, another on O ### 3.2 Create the Shopify custom app -#### 3.2.1 Static Token path - -1. In Shopify Admin β†’ **Settings** β†’ **Apps and sales channels** β†’ **Develop apps** β†’ **Create an app**. -2. Configure Admin API scopes (list in Β§2.2). -3. **Install** the app. -4. From **API credentials**, copy: - - **Admin API access token** β†’ goes into the Shopify Account `Password / Access Token` field. - - **API secret key** β†’ goes into the `Shared secret / API Secret` field. - -#### 3.2.2 OAuth 2.0 Client Credentials path - -1. In the **Shopify Dev Dashboard** (`partners.shopify.com`), create an app or open the existing one. -2. Under app settings, enable **Client Credentials grant**. -3. Configure Admin API scopes. -4. Install the app on the merchant store. -5. Copy: +> **As of late 2025, Shopify has retired the legacy "Develop apps" page** under Shopify Admin β†’ +> Settings. That URL now just redirects to the **Dev Dashboard** (`dev.shopify.com/dashboard`). +> Both Static Token and OAuth apps are managed there. + +#### 3.2.1 Static Token path (legacy custom apps) + +If you have a Static Token already (`shpat_…`) from an app created before the Dev Dashboard +migration, those tokens still work indefinitely β€” you don't need to migrate. New apps issue +OAuth credentials only. + +You'll be asked for: +- **Admin API access token** β†’ goes into the Shopify Account `Password / Access Token` field. +- **API secret key** β†’ goes into the `Shared secret / API Secret` field. + +#### 3.2.2 OAuth 2.0 Client Credentials path (recommended for all new apps) + +1. Go to the **Shopify Dev Dashboard**: `https://dev.shopify.com/dashboard//apps`. +2. Click **Create app** (or open an existing one). +3. Configure scopes (list in Β§2.2). Required for fulfillment work: + - `write_fulfillments`, `read_fulfillments` + - `write_assigned_fulfillment_orders`, `read_assigned_fulfillment_orders` + - `write_merchant_managed_fulfillment_orders`, `read_merchant_managed_fulfillment_orders` +4. **Release** a version of the app (Dev Dashboard versions configurations). +5. Click **Install app** (top-right of the app overview) β†’ choose your dev/test/prod store β†’ + approve the data-access prompt. +6. From the app overview, copy: - **Client ID** β†’ goes into the Shopify Account `Client ID` field. - - **Client Secret** β†’ goes into the `Client Secret` field. + - **Client Secret** β†’ reveal once and copy β†’ goes into the `Client Secret` field. +7. Whenever you change scopes later: create a new version, release it, click **Install app** + again on the same store; Shopify will prompt the merchant to approve the new permissions. + You don't need to copy new credentials β€” Client ID / Client Secret stay the same. To force + a fresh OAuth token after a scope change, clear the cached one: + + ```sql + UPDATE `tabShopify Account` SET token_expires_at=NULL WHERE name='.myshopify.com'; + ``` > **Webhook signing:** under OAuth 2.0, Shopify signs webhooks with the **Client Secret**, not a separate shared secret. The integration handles this automatically β€” you never set `shared_secret` on an OAuth account. @@ -246,21 +264,32 @@ If you skip mapping a location, orders fulfilled from that location will fall ba ### 4.3 Tax mappings -Shopify sends tax line items by **title** (e.g. *"VAT 15%"*, *"GST"*). Each title needs an ERPNext tax account. +Shopify sends tax line items by **title** (e.g. *"VAT 15%"*, *"GST"*). The integration uses +two fallback accounts for unmapped titles, **and** lets you override per-title via a table. + +#### Always set these two (they are not enforced at save time, but the first incoming order with a tax line will fail without them): + +- **Default Sales Tax Account** β€” fallback for any unmapped tax title (e.g. `Output Tax - Company`) +- **Default Shipping Charges Account** β€” fallback for unmapped shipping titles (e.g. `Shipping Charges - Company`) -In the **Map Shopify Taxes / Shipping Charges to ERPNext Account** table: +#### Optionally fill the per-title table + +Use the **Map Shopify Taxes / Shipping Charges to ERPNext Account** table only when you need +**different** Shopify tax titles to post to **different** ERPNext accounts. Most stores leave +this empty and let the two defaults above handle everything. | Shopify Tax/Shipping Title | ERPNext Account | Description | |---|---|---| | `VAT 15%` | `VAT Payable - Company` | Saudi VAT | | `Standard Shipping` | `Shipping Charges - Company` | Shipping line | -For unmapped titles, the integration uses: - -- **Default Sales Tax Account** β€” fallback for any unmapped tax title -- **Default Shipping Charges Account** β€” fallback for unmapped shipping titles +The **Shopify Tax/Shipping Title** must match exactly what Shopify sends in the order JSON +(case-sensitive). Don't guess β€” leave the table empty until you've placed a real test order +and inspected the `tax_lines[*].title` and `shipping_lines[*].title` values. -Both are required when sync is enabled. +> **Note:** earlier versions of this doctype required the table to have at least one row +> when `Enable Shopify` was on. That requirement was removed; the table is now genuinely +> optional. ### 4.4 Document series diff --git a/ecommerce_integrations/shopify/docs/SESSION-NOTES-2026-05-04.md b/ecommerce_integrations/shopify/docs/SESSION-NOTES-2026-05-04.md new file mode 100644 index 000000000..2eeb6c105 --- /dev/null +++ b/ecommerce_integrations/shopify/docs/SESSION-NOTES-2026-05-04.md @@ -0,0 +1,213 @@ +# Session notes β€” 2026-05-04 + +End-to-end shake-out of the Shopify integration on `nexus.local` against a real Shopify store +(`spqutq-ws.myshopify.com`, EGP). Captures every change made, every bug found, and the state +to expect when picking this up again. + +> ⚠️ This file is a session log, not a spec. The authoritative onboarding doc is `ONBOARDING.md`. +> When something here contradicts that doc, treat ONBOARDING.md as canonical. + +--- + +## TL;DR + +Brought the OAuth multi-tenant Shopify integration from "saved record, nothing flowing" to +"products / customers / orders / fulfillments / cancellations all syncing end-to-end." Found +and fixed three real bugs along the way. Stood up a fresh EGP company so future demo data and +screenshots use a currency that matches the Shopify store. + +--- + +## Bugs fixed in code (committable) + +| File | Line(s) | Change | Why | +|---|---|---|---| +| `shopify/product.py` | 224 | `item_group["custom_company"] = self.company` β†’ `item_group.custom_company = self.company` | `item_group` is a `Document`, not a dict; `[]=` raises `TypeError`. Broke any product import whose `product_type` didn't yet exist as an Item Group. | +| `shopify/order.py` | 121 (new line) | Add `"currency": shopify_order.get("currency"),` to the SO dict | Without an explicit currency, ERPNext falls back to `frappe.defaults.get_default("currency")` (e.g. SAR) which mismatches the company's Receivable account currency (e.g. EGP) β†’ "Party Account currency and document currency should be same". The Shopify order JSON has the right currency; just pass it through. | +| `shopify/doctype/shopify_account/shopify_account.json` | `taxes` field | Remove `mandatory_depends_on: eval:doc.enable_shopify` | The tax-mapping table is genuinely optional β€” the integration falls back to `default_sales_tax_account` and `default_shipping_charges_account` for unmapped titles. Marking it mandatory created an impossible UI state for stores that don't need per-title routing (empty-row β†’ row-fields-required β†’ fill-or-delete-row β†’ table-empty-required). | + +These three diffs are the only code changes and they are all surgical fixes to bugs we +hit. They are intentionally **not** committed yet β€” review them on the branch first. + +### Bugs we found and chose **not** to fix yet + +- **`shopify/utils.py:get_user_shopify_account`** β€” falls back to `None` when the user has no + `User Permission β†’ Company`, which silently breaks `get_product_count` and any session-resolved + call for users like `Administrator`. We tried adding a "single-tenant fallback" and reverted it + because it's a cross-tenant data-leak hazard (single-tenant today, multi-tenant tomorrow, + silent behavior change). Right fix is to require operator users to have `User Permission β†’ + Company` set; a deployment configuration, not a code change. + +- **Webhook duplication on save** β€” every save of a Shopify Account record re-runs + `_handle_webhooks`, which can leave stale subscriptions on Shopify if the public URL has + changed since the last save. We worked around it by manually deleting the stale ones via the + Webhook API. A proper fix would compare existing subscriptions to the desired set and only + diff. + +--- + +## Bench-level (non-code) fixes + +These are configuration changes on `nexus.local`. They aren't part of the integration; just +notes so the next operator doesn't re-debug the same things. + +| Fix | What we did | Why | +|---|---|---| +| Stale Property Setter on `Supplier.naming_series` | Deleted `Supplier-naming_series-fetch_from` from `tabProperty Setter` | It pointed at `supplier_group.naming_series_for_supplier_group` β€” a column that doesn't exist on `tabSupplier Group`. Blocked Supplier insert (and therefore product import). | +| Global default warehouse pointing at wrong company | `tabDefaultValue.parent='__default'.default_warehouse` was `Stores - B` (BrainWise) when `__default.company=BrainWise (Demo)` | Caused Item validation to fail with "Warehouse Stores - B doesn't belong to Company BrainWise (Demo)" any time `Item.update_defaults_from_item_group` fell through to global defaults. We set `default_warehouse` to `Stores - BD` to match. | +| Custom fields not installed | Ran `setup_custom_fields()` directly | The function runs on `before_save` of the Shopify Account, but every save we tried was failing earlier in validation, so the call never reached it. 19 `shopify_*` custom fields across 8 doctypes now exist. | +| `User Permission` for operator user | Added `ahmed.osama@brainwise.me β†’ Company β†’ BrainWise Egypt` (apply_to_all_doctypes=1) | Enables `get_user_shopify_account` to resolve this user's session to the right Shopify Account. | + +--- + +## Demo data added + +A second ERPNext company was created so the Shopify (EGP) store has a matching base currency. + +### `BrainWise Egypt` (company) + +| Field | Value | +|---|---| +| `default_currency` | EGP | +| `country` | Egypt | +| `abbr` | BE | +| Chart of Accounts | Standard Template (82 accounts) | +| Default warehouse | `Stores - BE` | +| Cost center | `Main - BE` | +| Cash account | `Cash - BE` | +| Default Customer (created) | `Shopify Walk-In (BE)` (currency=EGP) | +| Shipping Charges account (created) | `Shipping Charges - BE` (root_type=Income) | +| Naming series added | `SO-EG-.YYYY.-`, `SINV-EG-.YYYY.-`, `DN-EG-.YYYY.-` | +| Currency Exchange rates seeded | EGP↔SAR, EGP↔USD | + +The Shopify Account `spqutq-ws.myshopify.com` is now pointed at this company. + +### Shopify-side test data + +5 products in the live Shopify store: + +| SKU | Title | Price (EGP) | Stock | +|---|---|---|---| +| `TEST-001` | Test Product | 100 | 10 | +| `COFFEE-ETH-250` | Ethiopia Yirgacheffe 250g | 750 | 40 | +| `COFFEE-COL-250` | Colombia Huila 250g | 680 | 50 | +| `COFFEE-ESP-1KG` | Espresso Blend 1kg | 1850 | 25 | +| `BREW-V60-01` | Ceramic V60 Dripper | 1100 | 15 | + +3 customers: + +| Customer | Email | Role in demo | +|---|---|---| +| Test Buyer | testbuyer@example.com | Original test order (SAR β€” pre-Egypt-company artifact) | +| Mahmoud Hassan | mahmoud.hassan@example.com | Cairo customer; placed the fulfilled order | +| Sara Ahmed | sara.ahmed@example.com | Alexandria customer; placed the unfulfilled order and the cancelled one | + +Multiple Shopify orders were placed across two batches; the second batch (after the +`currency` fix) flowed cleanly into ERPNext as SO + SI (Paid). One was fulfilled (DN created), +one was cancelled, one is paid-but-unfulfilled. + +### ERPNext-side artifacts + +After the dust settled, on `BrainWise Egypt`: + +- 5 Sales Orders (`SAL-ORD-2026-00xx`) in EGP +- 5 Sales Invoices (`ACC-SINV-2026-00xx`), all status=Paid (the integration creates the SI + with payment when Shopify reports `financial_status=paid`) +- 1 Delivery Note (Mahmoud Hassan's fulfilled order) +- 4 ERPNext Items (synced from the Shopify products) +- 2 Customers with `shopify_customer_id` + +The earlier (`BrainWise (Demo)`, SAR) test orders from before we re-pointed are still in the +DB as historical artifacts. They aren't tied to the Egypt store and shouldn't be screenshot +fodder going forward. + +--- + +## Tunnel / webhooks + +`nexus.local` isn't publicly reachable, so we ran an `ngrok` tunnel for Shopify webhooks: + +- **Tunnel host** (free ngrok, transient): `036c-197-57-119-177.ngrok-free.app` +- **Site config additions**: `host_name`, `localtunnel_url` in `sites/nexus.local/site_config.json` +- **Webhooks registered**: `orders/create`, `orders/paid`, `orders/fulfilled`, `orders/cancelled`, + `orders/partially_fulfilled` β€” all pointing at the tunnel host. + +> ⚠️ ngrok-free URLs are **ephemeral**. When the tunnel restarts, the host changes and registered +> webhooks become stale. Either: (a) upgrade ngrok to a reserved domain, (b) switch to Cloudflare +> Tunnel for a stable URL, or (c) accept that you'll re-register webhooks each session. + +We had to clean up 5 stale webhooks pointing at a previous, dead tunnel host +(`remington-unlettered-inaptly.ngrok-free.dev`) β€” Shopify keeps subscriptions even after +repeated delivery failures, and they confuse routing behavior when duplicates exist. + +--- + +## Shopify app scopes + +The app is OAuth (Client Credentials grant). When we set up, scopes already included +`read/write_fulfillments` and the `read_*_fulfillment_orders` pair, but **`write_assigned_fulfillment_orders`** +and **`write_merchant_managed_fulfillment_orders`** were missing β€” required for fulfilling orders +via API. Added via Dev Dashboard β†’ New Version β†’ Release β†’ reinstall the app on the merchant +store. After reinstall, the cached OAuth token must be invalidated so the next `get_valid_access_token` +mints a fresh one with the new scopes: + +```sql +UPDATE `tabShopify Account` SET token_expires_at=NULL WHERE name='spqutq-ws.myshopify.com'; +``` + +--- + +## Things to watch for + +These came up during the session and could bite the next person. + +1. **Save flows on Shopify Account re-trigger webhook registration.** Each save calls + `_handle_webhooks`, which calls `register_webhooks` (which itself first calls + `unregister_webhooks` to clear stale ones at the *current* `localtunnel_url`). If you save + while the tunnel is down, registration will fail and the save throws. If the + `localtunnel_url` has changed since last save, expect duplicate subscriptions. + +2. **The Shopify Account's `company` field can drift unexpectedly** when a stale browser tab + saves the form against an old snapshot. Hit `Ctrl+Shift+R` before saving if you've made + DB-level changes recently. + +3. **Frappe `item_defaults` auto-population** uses `frappe.defaults.get_defaults()` as fallback, + pulling the `__default` company + warehouse. If those drift apart (e.g. company moves but + default warehouse doesn't), every new Item creation breaks with "Warehouse X doesn't belong + to Company Y". Keep `__default.company` and `__default.default_warehouse` in sync per site. + +4. **`get_user_shopify_account` is the choke point.** Any user who hits a `temp_shopify_session`-decorated + API endpoint must have `User Permission β†’ Company` set, or the session resolves to `None` + and the call silently no-ops. There's no helpful error. + +5. **The `taxes` table is now optional** (we removed the constraint). But the integration *does* + require **Default Sales Tax Account** and **Default Shipping Charges Account** at runtime β€” + they aren't enforced at save time but the first order with a tax line will fail if these are + blank. Set them. + +6. **Frappe sessions cache the doctype JSON.** After editing `shopify_account.json`, you need + `bench --site nexus.local migrate` AND a hard browser reload β€” old cached schema lingers + otherwise. + +--- + +## Open work (not done in this session) + +| What | Why deferred | +|---|---| +| Update `ONBOARDING.md` to reflect: (a) Dev Dashboard is the only path now (legacy "Develop apps" page redirects), (b) the tax table is optional, (c) the bugs in this session that operators no longer have to work around | Will follow this session-notes commit | +| `~18` screenshots end-to-end against the EGP demo data | Pending | +| Frappe Module Onboarding wizard (6 steps in the Shopify workspace) | Pending | +| Shopify workspace itself (currently not present in the app) | Pending | +| Webhook idempotency improvement (diff before re-registering) | Nice-to-have | +| Cleaner currency handling (per-customer default currency on creation) | Nice-to-have | + +--- + +## Credentials note + +Live credentials (OAuth `client_id` / `client_secret`) for the Shopify app were pasted into +chat early in this session. The user said they were not real, but as a matter of habit +**any credential pasted into a chat transcript should be treated as compromised** and rotated. +That has been flagged; rotation is the user's call. No live credentials appear in this file +or in any committed code. diff --git a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json index cd969df23..1d15e56b4 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json @@ -279,7 +279,6 @@ "fieldname": "taxes", "fieldtype": "Table", "label": "Shopify Tax Account", - "mandatory_depends_on": "eval:doc.enable_shopify", "options": "Shopify Tax Account" }, { diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py index cf413ca7e..86a5ca3e9 100644 --- a/ecommerce_integrations/shopify/order.py +++ b/ecommerce_integrations/shopify/order.py @@ -119,6 +119,7 @@ def create_sales_order(shopify_order, setting, company=None): "transaction_date": getdate(shopify_order.get("created_at")) or nowdate(), "delivery_date": getdate(shopify_order.get("created_at")) or nowdate(), "company": setting.company, + "currency": shopify_order.get("currency"), "selling_price_list": get_dummy_price_list(), "ignore_pricing_rule": 1, "items": items, diff --git a/ecommerce_integrations/shopify/product.py b/ecommerce_integrations/shopify/product.py index 8e351382f..badc937bf 100644 --- a/ecommerce_integrations/shopify/product.py +++ b/ecommerce_integrations/shopify/product.py @@ -221,7 +221,7 @@ def _get_item_group(self, product_type=None): } ) if self.company: - item_group["custom_company"] = self.company + item_group.custom_company = self.company item_group = item_group.insert() return item_group.name From d4b127260a68ef43587c6fdf3857b778d3c9348b Mon Sep 17 00:00:00 2001 From: Ahmed Osama Date: Mon, 4 May 2026 21:30:24 +0300 Subject: [PATCH 25/30] feat(shopify): add workspace and Module Onboarding wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dedicated Shopify workspace and a 6-step onboarding wizard so first-time operators can stand up a store without leaving the desk. Workspace layout: - Quick Access shortcuts: Shopify Account, Shopify Sales Orders (filtered by shopify_order_id is set), Shopify Import Products, Integration Log Errors (filtered by status=Error). - Three cards: Setup (Shopify Account, Ecommerce Item, Item, Customer), Documents (Sales Order, Sales Invoice, Delivery Note, Stock Reconciliation), Monitoring (Ecommerce Integration Log, Scheduled Job Type, Server Script). - Embedded onboarding wizard at the top of the workspace. - Roles: System Manager, Sales Manager. Onboarding steps (6, in order): 1. Create Shopify Account β€” Create Entry action; auto-completes on first record. 2. Connect and Enable Shopify β€” Update Settings; validates enable_shopify=1. 3. Map Shopify Locations β€” Update Settings; auto-completes on action click (Frappe v15 doesn't support a delegated row-existence validator). 4. Configure Tax and Shipping Defaults β€” Update Settings; validates default_sales_tax_account is set. 5. Configure Sync Settings β€” Update Settings; validates sales_order_series is set. 6. Run Initial Product Import β€” Go to Page; opens shopify-import-products. Wording uses "Nexus" rather than "ERPNext" per the platform branding on this site. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../shopify/module_onboarding/__init__.py | 0 .../module_onboarding/shopify/__init__.py | 0 .../module_onboarding/shopify/shopify.json | 44 ++++ .../shopify/onboarding_step/__init__.py | 0 .../configure_sync_settings/__init__.py | 0 .../configure_sync_settings.json | 23 ++ .../__init__.py | 0 .../configure_tax_and_shipping_defaults.json | 23 ++ .../connect_and_enable_shopify/__init__.py | 0 .../connect_and_enable_shopify.json | 23 ++ .../create_shopify_account/__init__.py | 0 .../create_shopify_account.json | 21 ++ .../map_shopify_locations/__init__.py | 0 .../map_shopify_locations.json | 21 ++ .../run_initial_product_import/__init__.py | 0 .../run_initial_product_import.json | 21 ++ .../shopify/workspace/__init__.py | 0 .../shopify/workspace/shopify/__init__.py | 0 .../shopify/workspace/shopify/shopify.json | 215 ++++++++++++++++++ 19 files changed, 391 insertions(+) create mode 100644 ecommerce_integrations/shopify/module_onboarding/__init__.py create mode 100644 ecommerce_integrations/shopify/module_onboarding/shopify/__init__.py create mode 100644 ecommerce_integrations/shopify/module_onboarding/shopify/shopify.json create mode 100644 ecommerce_integrations/shopify/onboarding_step/__init__.py create mode 100644 ecommerce_integrations/shopify/onboarding_step/configure_sync_settings/__init__.py create mode 100644 ecommerce_integrations/shopify/onboarding_step/configure_sync_settings/configure_sync_settings.json create mode 100644 ecommerce_integrations/shopify/onboarding_step/configure_tax_and_shipping_defaults/__init__.py create mode 100644 ecommerce_integrations/shopify/onboarding_step/configure_tax_and_shipping_defaults/configure_tax_and_shipping_defaults.json create mode 100644 ecommerce_integrations/shopify/onboarding_step/connect_and_enable_shopify/__init__.py create mode 100644 ecommerce_integrations/shopify/onboarding_step/connect_and_enable_shopify/connect_and_enable_shopify.json create mode 100644 ecommerce_integrations/shopify/onboarding_step/create_shopify_account/__init__.py create mode 100644 ecommerce_integrations/shopify/onboarding_step/create_shopify_account/create_shopify_account.json create mode 100644 ecommerce_integrations/shopify/onboarding_step/map_shopify_locations/__init__.py create mode 100644 ecommerce_integrations/shopify/onboarding_step/map_shopify_locations/map_shopify_locations.json create mode 100644 ecommerce_integrations/shopify/onboarding_step/run_initial_product_import/__init__.py create mode 100644 ecommerce_integrations/shopify/onboarding_step/run_initial_product_import/run_initial_product_import.json create mode 100644 ecommerce_integrations/shopify/workspace/__init__.py create mode 100644 ecommerce_integrations/shopify/workspace/shopify/__init__.py create mode 100644 ecommerce_integrations/shopify/workspace/shopify/shopify.json diff --git a/ecommerce_integrations/shopify/module_onboarding/__init__.py b/ecommerce_integrations/shopify/module_onboarding/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ecommerce_integrations/shopify/module_onboarding/shopify/__init__.py b/ecommerce_integrations/shopify/module_onboarding/shopify/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ecommerce_integrations/shopify/module_onboarding/shopify/shopify.json b/ecommerce_integrations/shopify/module_onboarding/shopify/shopify.json new file mode 100644 index 000000000..f858e1818 --- /dev/null +++ b/ecommerce_integrations/shopify/module_onboarding/shopify/shopify.json @@ -0,0 +1,44 @@ +{ + "allow_roles": [ + { + "role": "System Manager" + }, + { + "role": "Sales Manager" + } + ], + "creation": "2026-05-04 21:00:00.000000", + "docstatus": 0, + "doctype": "Module Onboarding", + "documentation_url": "https://github.com/frappe/ecommerce_integrations/blob/develop/ecommerce_integrations/shopify/ONBOARDING.md", + "idx": 0, + "is_complete": 0, + "modified": "2026-05-04 21:00:00.000000", + "modified_by": "Administrator", + "module": "shopify", + "name": "Shopify", + "owner": "Administrator", + "steps": [ + { + "step": "Create Shopify Account" + }, + { + "step": "Connect and Enable Shopify" + }, + { + "step": "Map Shopify Locations" + }, + { + "step": "Configure Tax and Shipping Defaults" + }, + { + "step": "Configure Sync Settings" + }, + { + "step": "Run Initial Product Import" + } + ], + "subtitle": "Connect a Shopify store to Nexus.", + "success_message": "Your Shopify store is connected. Watch the Ecommerce Integration Log for the first orders to flow through.", + "title": "Set up the Shopify integration" +} diff --git a/ecommerce_integrations/shopify/onboarding_step/__init__.py b/ecommerce_integrations/shopify/onboarding_step/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ecommerce_integrations/shopify/onboarding_step/configure_sync_settings/__init__.py b/ecommerce_integrations/shopify/onboarding_step/configure_sync_settings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ecommerce_integrations/shopify/onboarding_step/configure_sync_settings/configure_sync_settings.json b/ecommerce_integrations/shopify/onboarding_step/configure_sync_settings/configure_sync_settings.json new file mode 100644 index 000000000..0dc32ef61 --- /dev/null +++ b/ecommerce_integrations/shopify/onboarding_step/configure_sync_settings/configure_sync_settings.json @@ -0,0 +1,23 @@ +{ + "action": "Update Settings", + "action_label": "Configure sync settings", + "creation": "2026-05-04 21:00:00.000000", + "description": "# Configure sync direction & document series\n\nDecide what flows where, and what naming series to use.\n\n**Order Sync (always on, Shopify β†’ Nexus):**\n\n- **Sales Order Series** β€” required (e.g. `SO-SHOP-` or `SO-EG-.YYYY.-`).\n- **Import Sales Invoice from Shopify if Payment is marked** β€” recommended ON. Requires **Sales Invoice Series**, **Cash/Bank Account**, **Cost Center**.\n- **Import Delivery Notes from Shopify on Shipment** β€” recommended ON. Requires **Delivery Note Series**.\n\n**Outbound Sync (opt-in, Nexus β†’ Shopify):**\n\n- **Update Nexus stock levels to Shopify** β€” periodic stock push (5/10/15/30/60 min cadence).\n- **Upload new Nexus Items to Shopify** β€” auto-create Shopify products on Item save.\n- **Update Shopify Item after updating Nexus item** β€” push price/name/description changes.\n\nThis step auto-completes once **Sales Order Series** is set.", + "docstatus": 0, + "doctype": "Onboarding Step", + "field": "sales_order_series", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2026-05-04 21:00:00.000000", + "modified_by": "Administrator", + "name": "Configure Sync Settings", + "owner": "Administrator", + "reference_document": "Shopify Account", + "show_form_tour": 0, + "show_full_form": 1, + "title": "Configure sync direction & series", + "validate_action": 1, + "value_to_validate": "%" +} diff --git a/ecommerce_integrations/shopify/onboarding_step/configure_tax_and_shipping_defaults/__init__.py b/ecommerce_integrations/shopify/onboarding_step/configure_tax_and_shipping_defaults/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ecommerce_integrations/shopify/onboarding_step/configure_tax_and_shipping_defaults/configure_tax_and_shipping_defaults.json b/ecommerce_integrations/shopify/onboarding_step/configure_tax_and_shipping_defaults/configure_tax_and_shipping_defaults.json new file mode 100644 index 000000000..2842b2b6d --- /dev/null +++ b/ecommerce_integrations/shopify/onboarding_step/configure_tax_and_shipping_defaults/configure_tax_and_shipping_defaults.json @@ -0,0 +1,23 @@ +{ + "action": "Update Settings", + "action_label": "Set tax & shipping defaults", + "creation": "2026-05-04 21:00:00.000000", + "description": "# Set tax & shipping defaults\n\nThese two fields are not enforced at save time, but the **first incoming order with a tax line will fail** without them.\n\nOn the Shopify Account form, scroll to **Tax Account Details** and set:\n\n- **Default Sales Tax Account** β€” fallback for any unmapped tax title (e.g. `Output Tax - Company`).\n- **Default Shipping Charges Account** β€” fallback for unmapped shipping titles (e.g. `Shipping Charges - Company`).\n\nThe **Map Shopify Taxes / Shipping Charges to Nexus Account** table is **optional** β€” most stores leave it empty and let the two defaults above handle everything.\n\nThis step auto-completes once **Default Sales Tax Account** is set.", + "docstatus": 0, + "doctype": "Onboarding Step", + "field": "default_sales_tax_account", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2026-05-04 21:00:00.000000", + "modified_by": "Administrator", + "name": "Configure Tax and Shipping Defaults", + "owner": "Administrator", + "reference_document": "Shopify Account", + "show_form_tour": 0, + "show_full_form": 1, + "title": "Configure tax & shipping defaults", + "validate_action": 1, + "value_to_validate": "%" +} diff --git a/ecommerce_integrations/shopify/onboarding_step/connect_and_enable_shopify/__init__.py b/ecommerce_integrations/shopify/onboarding_step/connect_and_enable_shopify/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ecommerce_integrations/shopify/onboarding_step/connect_and_enable_shopify/connect_and_enable_shopify.json b/ecommerce_integrations/shopify/onboarding_step/connect_and_enable_shopify/connect_and_enable_shopify.json new file mode 100644 index 000000000..d58aca6c1 --- /dev/null +++ b/ecommerce_integrations/shopify/onboarding_step/connect_and_enable_shopify/connect_and_enable_shopify.json @@ -0,0 +1,23 @@ +{ + "action": "Update Settings", + "action_label": "Connect and enable", + "creation": "2026-05-04 21:00:00.000000", + "description": "# Connect and enable Shopify\n\nOpen your Shopify Account record and:\n\n1. Confirm your **Authentication Method** (Static Token or OAuth 2.0).\n2. Fill the credentials.\n3. Tick **Enable Shopify** at the top of the form.\n4. **Save**.\n\nOn save, the integration validates your credentials and registers the required webhooks (`orders/create`, `orders/paid`, `orders/fulfilled`, `orders/cancelled`, `orders/partially_fulfilled`).\n\nIf webhook registration fails, your bench may not be reachable from the public internet β€” see `ONBOARDING.md` Β§8.4.", + "docstatus": 0, + "doctype": "Onboarding Step", + "field": "enable_shopify", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2026-05-04 21:00:00.000000", + "modified_by": "Administrator", + "name": "Connect and Enable Shopify", + "owner": "Administrator", + "reference_document": "Shopify Account", + "show_form_tour": 0, + "show_full_form": 1, + "title": "Connect and enable Shopify", + "validate_action": 1, + "value_to_validate": "1" +} diff --git a/ecommerce_integrations/shopify/onboarding_step/create_shopify_account/__init__.py b/ecommerce_integrations/shopify/onboarding_step/create_shopify_account/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ecommerce_integrations/shopify/onboarding_step/create_shopify_account/create_shopify_account.json b/ecommerce_integrations/shopify/onboarding_step/create_shopify_account/create_shopify_account.json new file mode 100644 index 000000000..3d100b93d --- /dev/null +++ b/ecommerce_integrations/shopify/onboarding_step/create_shopify_account/create_shopify_account.json @@ -0,0 +1,21 @@ +{ + "action": "Create Entry", + "action_label": "Create your Shopify Account", + "creation": "2026-05-04 21:00:00.000000", + "description": "# Create a Shopify Account\n\nA **Shopify Account** record links one Shopify store to one Nexus Company. Each store gets its own record.\n\nBefore creating, make sure you have:\n\n- Your **Shop URL** (e.g. `mystore.myshopify.com`).\n- The **Nexus Company** this store belongs to.\n- Either a **Static Token** (legacy `shpat_…` Admin API token) **or** an **OAuth 2.0 Client ID + Client Secret** from the Shopify Dev Dashboard.\n\nClick the action button to open a fresh Shopify Account form.", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2026-05-04 21:00:00.000000", + "modified_by": "Administrator", + "name": "Create Shopify Account", + "owner": "Administrator", + "reference_document": "Shopify Account", + "show_form_tour": 0, + "show_full_form": 1, + "title": "Create a Shopify Account", + "validate_action": 1 +} diff --git a/ecommerce_integrations/shopify/onboarding_step/map_shopify_locations/__init__.py b/ecommerce_integrations/shopify/onboarding_step/map_shopify_locations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ecommerce_integrations/shopify/onboarding_step/map_shopify_locations/map_shopify_locations.json b/ecommerce_integrations/shopify/onboarding_step/map_shopify_locations/map_shopify_locations.json new file mode 100644 index 000000000..14c15b484 --- /dev/null +++ b/ecommerce_integrations/shopify/onboarding_step/map_shopify_locations/map_shopify_locations.json @@ -0,0 +1,21 @@ +{ + "action": "Update Settings", + "action_label": "Map Shopify locations", + "creation": "2026-05-04 21:00:00.000000", + "description": "# Map Shopify locations to Nexus warehouses\n\nShopify exposes locations (warehouses, retail floor, etc.). Each must map to a real Nexus warehouse **in the same company** as this account.\n\nOn the Shopify Account form:\n\n1. Click **Fetch Shopify Locations**.\n2. The **Shopify Warehouse Mapping** child table populates with each Shopify location.\n3. For each row, set **Nexus Warehouse** (filtered to this account's company).\n4. Save.\n\nThe first row's warehouse acts as the **Default Warehouse** for that account if not separately set.\n\n> Note: this step auto-completes when you click the action button. Make sure you've actually filled the mapping table β€” sync will fail later if rows are blank.", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2026-05-04 21:00:00.000000", + "modified_by": "Administrator", + "name": "Map Shopify Locations", + "owner": "Administrator", + "reference_document": "Shopify Account", + "show_form_tour": 0, + "show_full_form": 1, + "title": "Map Shopify locations to warehouses", + "validate_action": 0 +} diff --git a/ecommerce_integrations/shopify/onboarding_step/run_initial_product_import/__init__.py b/ecommerce_integrations/shopify/onboarding_step/run_initial_product_import/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ecommerce_integrations/shopify/onboarding_step/run_initial_product_import/run_initial_product_import.json b/ecommerce_integrations/shopify/onboarding_step/run_initial_product_import/run_initial_product_import.json new file mode 100644 index 000000000..64c006567 --- /dev/null +++ b/ecommerce_integrations/shopify/onboarding_step/run_initial_product_import/run_initial_product_import.json @@ -0,0 +1,21 @@ +{ + "action": "Go to Page", + "action_label": "Open Shopify Import Products", + "creation": "2026-05-04 21:00:00.000000", + "description": "# Run initial product import\n\nFor onboarding an existing store with an existing catalog, run a one-time import.\n\nClick the action to open the **Shopify Import Products** page. It paginates through all products on the store. For each product, it:\n\n1. Creates an Nexus Item (skips if the SKU already exists).\n2. Creates the variant Items.\n3. Sets `shopify_selling_rate` from Shopify's `variant.price`.\n4. Creates the corresponding **Ecommerce Item** linkage.\n\nRun this once during onboarding. After this, ongoing changes flow via webhooks (Shopify β†’ Nexus) or scheduled jobs (Nexus β†’ Shopify) depending on your toggles.\n\n> If your Shopify store is empty and you'll be uploading items from Nexus instead, you can skip this step.", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2026-05-04 21:00:00.000000", + "modified_by": "Administrator", + "name": "Run Initial Product Import", + "owner": "Administrator", + "path": "shopify-import-products", + "show_form_tour": 0, + "show_full_form": 0, + "title": "Run initial product import", + "validate_action": 0 +} diff --git a/ecommerce_integrations/shopify/workspace/__init__.py b/ecommerce_integrations/shopify/workspace/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ecommerce_integrations/shopify/workspace/shopify/__init__.py b/ecommerce_integrations/shopify/workspace/shopify/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ecommerce_integrations/shopify/workspace/shopify/shopify.json b/ecommerce_integrations/shopify/workspace/shopify/shopify.json new file mode 100644 index 000000000..ea3986dc1 --- /dev/null +++ b/ecommerce_integrations/shopify/workspace/shopify/shopify.json @@ -0,0 +1,215 @@ +{ + "charts": [], + "content": "[{\"id\":\"shopify_onboarding\",\"type\":\"onboarding\",\"data\":{\"onboarding_name\":\"Shopify\",\"col\":12}},{\"id\":\"shopify_quick_access_header\",\"type\":\"header\",\"data\":{\"text\":\"Quick Access\",\"col\":12}},{\"id\":\"sc_shopify_account\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Shopify Account\",\"col\":3}},{\"id\":\"sc_shopify_orders\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Shopify Sales Orders\",\"col\":3}},{\"id\":\"sc_import_products\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Shopify Import Products\",\"col\":3}},{\"id\":\"sc_integration_log\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Integration Log Errors\",\"col\":3}},{\"id\":\"shopify_spacer_1\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"shopify_masters_header\",\"type\":\"header\",\"data\":{\"text\":\"Masters & Operations\",\"col\":12}},{\"id\":\"card_setup\",\"type\":\"card\",\"data\":{\"card_name\":\"Setup\",\"col\":4}},{\"id\":\"card_documents\",\"type\":\"card\",\"data\":{\"card_name\":\"Documents\",\"col\":4}},{\"id\":\"card_monitoring\",\"type\":\"card\",\"data\":{\"card_name\":\"Monitoring\",\"col\":4}}]", + "creation": "2026-05-04 21:00:00.000000", + "custom_blocks": [], + "docstatus": 0, + "doctype": "Workspace", + "for_user": "", + "hide_custom": 0, + "icon": "shopping-cart", + "idx": 0, + "is_hidden": 0, + "label": "Shopify", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Setup", + "link_count": 0, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Shopify Account", + "link_count": 0, + "link_to": "Shopify Account", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Shopify Account", + "hidden": 0, + "is_query_report": 0, + "label": "Ecommerce Item", + "link_count": 0, + "link_to": "Ecommerce Item", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Shopify Account", + "hidden": 0, + "is_query_report": 0, + "label": "Item", + "link_count": 0, + "link_to": "Item", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Shopify Account", + "hidden": 0, + "is_query_report": 0, + "label": "Customer", + "link_count": 0, + "link_to": "Customer", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Documents", + "link_count": 0, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Shopify Account", + "hidden": 0, + "is_query_report": 0, + "label": "Sales Order", + "link_count": 0, + "link_to": "Sales Order", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Shopify Account", + "hidden": 0, + "is_query_report": 0, + "label": "Sales Invoice", + "link_count": 0, + "link_to": "Sales Invoice", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Shopify Account", + "hidden": 0, + "is_query_report": 0, + "label": "Delivery Note", + "link_count": 0, + "link_to": "Delivery Note", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Shopify Account", + "hidden": 0, + "is_query_report": 0, + "label": "Stock Reconciliation", + "link_count": 0, + "link_to": "Stock Reconciliation", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Monitoring", + "link_count": 0, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Ecommerce Integration Log", + "link_count": 0, + "link_to": "Ecommerce Integration Log", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Scheduled Job Type", + "link_count": 0, + "link_to": "Scheduled Job Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Server Script", + "link_count": 0, + "link_to": "Server Script", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2026-05-04 22:00:00.000000", + "modified_by": "Administrator", + "module": "shopify", + "name": "Shopify", + "owner": "Administrator", + "parent_page": "", + "pin_to_bottom": 0, + "pin_to_top": 0, + "public": 1, + "quick_lists": [], + "restrict_to_domain": "", + "roles": [ + { + "role": "System Manager" + }, + { + "role": "Sales Manager" + } + ], + "sequence_id": 50.0, + "shortcuts": [ + { + "color": "Green", + "doc_view": "List", + "label": "Shopify Account", + "link_to": "Shopify Account", + "type": "DocType" + }, + { + "color": "Green", + "doc_view": "List", + "format": "{}", + "label": "Shopify Sales Orders", + "link_to": "Sales Order", + "stats_filter": "{\"shopify_order_id\":[\"is\",\"set\"]}", + "type": "DocType" + }, + { + "color": "Blue", + "label": "Shopify Import Products", + "link_to": "shopify-import-products", + "type": "Page" + }, + { + "color": "Yellow", + "doc_view": "List", + "format": "{}", + "label": "Integration Log Errors", + "link_to": "Ecommerce Integration Log", + "stats_filter": "{\"status\":\"Error\"}", + "type": "DocType" + } + ], + "title": "Shopify" +} From ba7fffb753b740887a32b215381f160c5e22b7fb Mon Sep 17 00:00:00 2001 From: Ahmed Osama Date: Mon, 4 May 2026 21:34:05 +0300 Subject: [PATCH 26/30] chore(shopify): remove placeholder documentation_url from onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The URL was a guess β€” pointed at frappe/ecommerce_integrations:develop, but ONBOARDING.md only exists on the feat/shopify-oauth-multi-tenant branch and the public mirror layout isn't settled. Better to ship without a broken link; re-add once the doc has a stable canonical URL. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../shopify/module_onboarding/shopify/shopify.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ecommerce_integrations/shopify/module_onboarding/shopify/shopify.json b/ecommerce_integrations/shopify/module_onboarding/shopify/shopify.json index f858e1818..f7c0b117d 100644 --- a/ecommerce_integrations/shopify/module_onboarding/shopify/shopify.json +++ b/ecommerce_integrations/shopify/module_onboarding/shopify/shopify.json @@ -10,10 +10,9 @@ "creation": "2026-05-04 21:00:00.000000", "docstatus": 0, "doctype": "Module Onboarding", - "documentation_url": "https://github.com/frappe/ecommerce_integrations/blob/develop/ecommerce_integrations/shopify/ONBOARDING.md", "idx": 0, "is_complete": 0, - "modified": "2026-05-04 21:00:00.000000", + "modified": "2026-05-04 22:30:00.000000", "modified_by": "Administrator", "module": "shopify", "name": "Shopify", From e5f27aaa3a16b21b840344a9c2658570e4149f92 Mon Sep 17 00:00:00 2001 From: MohamedAliSmk Date: Tue, 5 May 2026 14:07:37 +0300 Subject: [PATCH 27/30] fix(shopify): improve OAuth token refresh logic Updated the refresh_oauth_token function to avoid reloading the setting document, preventing loss of unsaved changes. Introduced a persisted copy for secure retrieval of client secrets and ensured the caller's document remains in sync without forcing a reload. This enhances the reliability of the OAuth token management process. --- ecommerce_integrations/shopify/oauth.py | 47 ++++++++++++++++--------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/ecommerce_integrations/shopify/oauth.py b/ecommerce_integrations/shopify/oauth.py index 72188829c..8f23da6ef 100644 --- a/ecommerce_integrations/shopify/oauth.py +++ b/ecommerce_integrations/shopify/oauth.py @@ -101,32 +101,45 @@ def refresh_oauth_token(setting) -> str: title=_("Invalid Authentication Method"), ) - setting.reload() - + # Never reload the passed document here. This function is used during doc.save() + # and reload() would silently wipe unsaved field changes (e.g. enable_shopify). + # + # Use a persisted copy only for secret retrieval when available. + persisted_setting = None + if setting.name and not setting.is_new() and frappe.db.exists(ACCOUNT_DOCTYPE, setting.name): + persisted_setting = frappe.get_doc(ACCOUNT_DOCTYPE, setting.name) + + client_secret = ( + persisted_setting.get_password("client_secret") + if persisted_setting + else setting.get_password("client_secret") + ) token_data = generate_oauth_token( setting.shopify_url, setting.client_id, - setting.get_password("client_secret"), + client_secret, ) expires_at = calculate_token_expiry(token_data.get("expires_in", 86399)) - set_encrypted_password( - ACCOUNT_DOCTYPE, - setting.name, - token_data["access_token"], - fieldname="oauth_access_token", - ) + if persisted_setting: + set_encrypted_password( + ACCOUNT_DOCTYPE, + setting.name, + token_data["access_token"], + fieldname="oauth_access_token", + ) - frappe.db.set_value( - ACCOUNT_DOCTYPE, - setting.name, - "token_expires_at", - get_datetime_str(expires_at), - update_modified=False, - ) + frappe.db.set_value( + ACCOUNT_DOCTYPE, + setting.name, + "token_expires_at", + get_datetime_str(expires_at), + update_modified=False, + ) - setting.reload() + # Keep caller doc in sync without forcing a reload. + setting.token_expires_at = get_datetime_str(expires_at) return token_data["access_token"] From 6da16e842a6e0c6c9ae1d08465cbb3c9f4a465f3 Mon Sep 17 00:00:00 2001 From: MohamedAliSmk Date: Tue, 5 May 2026 14:47:31 +0300 Subject: [PATCH 28/30] fix(shopify): handle missing Shopify account gracefully Updated the temp_shopify_session function to check for a linked Shopify account. If no account is found, a user-friendly error message is thrown, prompting the user to configure their Shopify Account and User Permissions before proceeding with product imports. --- ecommerce_integrations/shopify/connection.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index 0d1156fc5..5fd002c28 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -55,8 +55,15 @@ def wrapper(*args, **kwargs): # If a callable is passed, call it with self to get the account if shopify_account is None: - # TODO: handle if get_user_shopify_account returns None - account = get_user_shopify_account().name + shopify_account_doc = get_user_shopify_account() + if not shopify_account_doc: + frappe.throw( + _( + "No Shopify account is linked to your user/company. Please configure Shopify Account and User Permissions before importing products." + ), + title=_("Shopify Account Not Configured"), + ) + account = shopify_account_doc.name else: account = shopify_account(args[0]) if callable(shopify_account) else shopify_account From 6139aa82c58849b9f9a9e5d8f18eb1abfda9a95b Mon Sep 17 00:00:00 2001 From: MohamedAliSmk Date: Tue, 5 May 2026 16:09:11 +0300 Subject: [PATCH 29/30] feat(shopify): enhance product sync process with job monitoring - Updated the `syncAll` method to include asynchronous handling and job monitoring for product synchronization, improving user feedback during the sync process. - Introduced `startJobFailureMonitor` and `stopJobFailureMonitor` methods to track job status and handle failures gracefully. - Enhanced the `import_all_products` function to ensure a linked Shopify account is available before proceeding with imports, throwing a user-friendly error if not. - Added logic to recover from accidental character-split account values in the `temp_shopify_session` function. - Improved the `get_product_count` function to accept a Shopify account parameter for better context during product count retrieval. --- ecommerce_integrations/shopify/connection.py | 16 +++- .../shopify_import_products.js | 92 +++++++++++++++++-- .../shopify_import_products.py | 46 ++++++++-- 3 files changed, 138 insertions(+), 16 deletions(-) diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index 5fd002c28..a03fb6597 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -53,8 +53,13 @@ def wrapper(*args, **kwargs): if frappe.flags.in_test: return func(*args, **kwargs) + # Accept account from kwargs for callers that pass it at runtime. + runtime_account = kwargs.pop("shopify_account", None) + # If a callable is passed, call it with self to get the account - if shopify_account is None: + if runtime_account: + account = runtime_account + elif shopify_account is None: shopify_account_doc = get_user_shopify_account() if not shopify_account_doc: frappe.throw( @@ -67,6 +72,15 @@ def wrapper(*args, **kwargs): else: account = shopify_account(args[0]) if callable(shopify_account) else shopify_account + # Recover from accidental character-split account values (e.g. passing *"My Account"). + if ( + isinstance(account, str) + and len(account) == 1 + and len(args) > 1 + and all(isinstance(arg, str) and len(arg) == 1 for arg in args) + ): + account = "".join(args) + setting = frappe.get_doc(ACCOUNT_DOCTYPE, account) if setting.is_enabled(): auth_details = (setting.shopify_url, API_VERSION, _get_access_token(setting)) diff --git a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js index 664e564f2..2654dd4e7 100644 --- a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js +++ b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js @@ -17,6 +17,7 @@ shopify.ProductImporter = class { this.page = wrapper.page; this.init(); this.syncRunning = false; + this.jobMonitor = null; } init() { @@ -300,18 +301,22 @@ shopify.ProductImporter = class { this.shopifyProductTable.clearToastMessage(); } - syncAll() { - this.checkSyncStatus(); - this.toggleSyncAllButton(); + async syncAll() { + await this.checkSyncStatus(); if (this.syncRunning) { - frappe.msgprint(__("Sync already in progress")); - } else { - frappe.call({ - method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.import_all_products", - }); + // frappe.msgprint(__("Sync already in progress")); + this.logSync(); + return; } + this.syncRunning = true; + this.toggleSyncAllButton(true); + + await frappe.call({ + method: "ecommerce_integrations.shopify.page.shopify_import_products.shopify_import_products.import_all_products", + }); + // sync progress this.logSync(); } @@ -324,6 +329,7 @@ shopify.ProductImporter = class { // define counters here to prevent calling jquery every time const _syncedCounter = $("#count-products-synced"); const _erpnextCounter = $("#count-products-erpnext"); + this.startJobFailureMonitor(_log); frappe.realtime.on( "shopify.key.sync.all.products", @@ -337,14 +343,84 @@ shopify.ProductImporter = class { if (done) { frappe.realtime.off("shopify.key.sync.all.products"); + this.stopJobFailureMonitor(); this.toggleSyncAllButton(false); this.fetchProductCount(); + this.refreshTableData(); this.syncRunning = false; } } ); } + async refreshTableData() { + if (!this.shopifyProductTable) return; + const rows = await this.fetchShopifyProducts(); + this.shopifyProductTable.refresh(rows); + } + + startJobFailureMonitor(logElement) { + this.stopJobFailureMonitor(); + + this.jobMonitor = setInterval(async () => { + try { + const jobs = await frappe.db.get_list("RQ Job", { + filters: { job_name: "shopify.job.sync.all.products" }, + fields: ["name", "status", "exc_info"], + order_by: "creation desc", + limit: 1, + }); + + if (!jobs.length) return; + + const job = jobs[0]; + if (job.status === "failed") { + const exception = (job.exc_info || "").trim(); + const message = exception + ? __("Sync failed: {0}", [frappe.utils.escape_html(exception)]) + : __("Sync failed. Check background job logs for details."); + + logElement.append(`

${message}
`); + logElement.scrollTop(logElement[0].scrollHeight); + + frappe.realtime.off("shopify.key.sync.all.products"); + this.stopJobFailureMonitor(); + this.toggleSyncAllButton(false); + this.syncRunning = false; + frappe.msgprint(__("Product sync failed and has been stopped.")); + return; + } + + // Realtime events can be missed (for example, page reconnects). + // Polling job status ensures the UI exits "Syncing..." state. + if (["finished", "stopped", "deferred"].includes(job.status)) { + logElement.append( + `
${__(
+							"Sync job ended. Review synced/errors counts in this panel."
+						)}
` + ); + logElement.scrollTop(logElement[0].scrollHeight); + + frappe.realtime.off("shopify.key.sync.all.products"); + this.stopJobFailureMonitor(); + this.toggleSyncAllButton(false); + this.fetchProductCount(); + this.refreshTableData(); + this.syncRunning = false; + } + } catch (e) { + // ignore transient errors while polling job state + } + }, 2000); + } + + stopJobFailureMonitor() { + if (this.jobMonitor) { + clearInterval(this.jobMonitor); + this.jobMonitor = null; + } + } + toggleSyncAllButton(disable = true) { const btn = $("#btn-sync-all"); diff --git a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py index 275a53567..6d5677890 100644 --- a/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py +++ b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.py @@ -8,7 +8,7 @@ from ecommerce_integrations.shopify.connection import temp_shopify_session from ecommerce_integrations.shopify.constants import MODULE_NAME from ecommerce_integrations.shopify.product import ShopifyProduct -from ecommerce_integrations.shopify.utils import get_user_company +from ecommerce_integrations.shopify.utils import get_user_company, get_user_shopify_account # constants SYNC_JOB_NAME = "shopify.job.sync.all.products" @@ -58,14 +58,14 @@ def _fetch_products_from_shopify(from_=None, limit=20): @frappe.whitelist() -def get_product_count(): +def get_product_count(shopify_account=None): items = frappe.db.get_list("Item", {"variant_of": ["is", "not set"]}) erpnext_count = len(items) sync_items = frappe.db.get_list("Ecommerce Item", {"variant_of": ["is", "not set"]}) synced_count = len(sync_items) - shopify_count = get_shopify_product_count() + shopify_count = get_shopify_product_count(shopify_account=shopify_account) return { "shopifyCount": shopify_count, @@ -119,25 +119,37 @@ def is_synced(product): @frappe.whitelist() def import_all_products(): + shopify_account = get_user_shopify_account() + if not shopify_account: + frappe.throw( + "No Shopify account is linked to your user/company. Please configure Shopify Account and User Permissions before importing products." + ) + frappe.enqueue( queue_sync_all_products, queue="long", job_name=SYNC_JOB_NAME, key=REALTIME_KEY, + user=frappe.session.user, + shopify_account=shopify_account.name, ) def queue_sync_all_products(*args, **kwargs): start_time = process_time() + shopify_account = kwargs.get("shopify_account") + synced_count = 0 + failed_count = 0 + failed_products = [] - counts = get_product_count() + counts = get_product_count(shopify_account=shopify_account) publish("Syncing all products...") if counts["shopifyCount"] < counts["syncedCount"]: publish("⚠ Shopify has less products than ERPNext.") _sync = True - collection = _fetch_products_from_shopify(limit=100) + collection = _fetch_products_from_shopify(limit=100, shopify_account=shopify_account) savepoint = "shopify_product_sync" while _sync: for product in collection: @@ -150,27 +162,47 @@ def queue_sync_all_products(*args, **kwargs): shopify_product = ShopifyProduct(product.id, company=get_user_company(kwargs.get("user"))) shopify_product.sync_product() + synced_count += 1 publish(f"βœ… Synced Product {product.id}", synced=True) except UniqueValidationError as e: + failed_count += 1 + failed_products.append(str(product.id)) publish(f"❌ Error Syncing Product {product.id} : {e!s}", error=True) frappe.db.rollback(save_point=savepoint) continue except Exception as e: + failed_count += 1 + failed_products.append(str(product.id)) publish(f"❌ Error Syncing Product {product.id} : {e!s}", error=True) frappe.db.rollback(save_point=savepoint) continue if collection.has_next_page(): frappe.db.commit() # prevents too many write request error - collection = _fetch_products_from_shopify(from_=collection.next_page_url) + collection = _fetch_products_from_shopify( + from_=collection.next_page_url, shopify_account=shopify_account + ) else: _sync = False end_time = process_time() - publish(f"πŸŽ‰ Done in {end_time - start_time}s", done=True) + if failed_count and not synced_count: + publish( + f"⚠ Sync finished with errors. Synced: {synced_count}, Failed: {failed_count}.", + error=True, + done=True, + ) + elif failed_count: + publish( + f"⚠ Sync completed with partial errors. Synced: {synced_count}, Failed: {failed_count}.", + error=True, + done=True, + ) + else: + publish(f"πŸŽ‰ Done in {end_time - start_time}s. Synced: {synced_count}", done=True) return True From 637dc9e3e114a43733e80291bbce51ead55ebd6b Mon Sep 17 00:00:00 2001 From: MostafaKadry Date: Sun, 10 May 2026 17:27:08 +0300 Subject: [PATCH 30/30] fix: update custom_company attribute check in upload_erpnext_item function --- ecommerce_integrations/shopify/product.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ecommerce_integrations/shopify/product.py b/ecommerce_integrations/shopify/product.py index badc937bf..7dcbd0210 100644 --- a/ecommerce_integrations/shopify/product.py +++ b/ecommerce_integrations/shopify/product.py @@ -351,7 +351,7 @@ def upload_erpnext_item(doc, method=None): return # TODO: Handle if doc.custom_company is None - if doc.hasattr("custom_company"): + if doc.get("custom_company", None): setting = get_company_shopify_account(company=doc.custom_company) else: setting = get_user_shopify_account()