diff --git a/.gitignore b/.gitignore index 7ba46dbf9..5631d3133 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ tags ecommerce_integrations/docs/current .aider* -.helix \ No newline at end of file +.helix +.claude/settings.json +.playwright-mcp/ 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/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/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.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..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 @@ -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() @@ -96,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", @@ -109,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/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..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,7 +147,12 @@ def create_ecommerce_item( "doctype": "Item", "is_stock_item": 1, "is_sales_item": 1, - "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/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/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/ONBOARDING.md b/ecommerce_integrations/shopify/ONBOARDING.md new file mode 100644 index 000000000..2182f3cfb --- /dev/null +++ b/ecommerce_integrations/shopify/ONBOARDING.md @@ -0,0 +1,694 @@ +# 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 + +> **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** → 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. + +### 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"*). 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`) + +#### 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 | + +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. + +> **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 + +| 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/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..a03fb6597 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -12,29 +12,95 @@ 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 +from ecommerce_integrations.shopify.utils import create_shopify_log, get_user_shopify_account -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.""" +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 - @functools.wraps(func) - def wrapper(*args, **kwargs): - # no auth in testing - if frappe.flags.in_test: - return func(*args, **kwargs) + 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"), + ) - setting = frappe.get_doc(SETTING_DOCTYPE) - if setting.is_enabled(): - auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password")) + return setting.get_password("password") - with Session.temp(*auth_details): + +def temp_shopify_session(shopify_account=None): + """Decorator for functions that need a temporary Shopify session. + + Supports both Static Token and OAuth 2.0 Client Credentials per 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) - return wrapper + # 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 runtime_account: + account = runtime_account + elif shopify_account is None: + 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 + + # 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)) + with Session.temp(*auth_details): + return func(*args, **kwargs) + + return wrapper + + return decorator + + +def get_auth_details(setting) -> tuple[str, str, str]: + """Get authentication details for Shopify API.""" + return setting.shopify_url, API_VERSION, _get_access_token(setting) + + +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]: @@ -92,36 +158,46 @@ 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") + settings = frappe.get_doc(ACCOUNT_DOCTYPE, shop_domain) - _validate_request(frappe.request, hmac_header) + # 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") - process_request(data, event) + process_request(data, event, shopify_account=settings) -def process_request(data, event): - # create log - log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data) - - # enqueue backround job +def process_request(data, event, shopify_account=None): + log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data, shopify_account=shopify_account) 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): - settings = frappe.get_doc(SETTING_DOCTYPE) - 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/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..01a911e5b 100644 --- a/ecommerce_integrations/shopify/customer.py +++ b/ecommerce_integrations/shopify/customer.py @@ -9,24 +9,22 @@ ADDRESS_ID_FIELD, CUSTOMER_ID_FIELD, MODULE_NAME, - SETTING_DOCTYPE, ) +from ecommerce_integrations.shopify.utils import get_company_shopify_account class ShopifyCustomer(EcommerceCustomer): def __init__(self, customer_id: str): - 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: + 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/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/docs/images/01-list-empty-state.png b/ecommerce_integrations/shopify/docs/images/01-list-empty-state.png new file mode 100644 index 000000000..8191ed4e5 Binary files /dev/null and b/ecommerce_integrations/shopify/docs/images/01-list-empty-state.png differ diff --git a/ecommerce_integrations/shopify/docs/images/02-new-form-static-token.png b/ecommerce_integrations/shopify/docs/images/02-new-form-static-token.png new file mode 100644 index 000000000..ab2e77084 Binary files /dev/null and b/ecommerce_integrations/shopify/docs/images/02-new-form-static-token.png differ 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 000000000..df5511624 Binary files /dev/null and b/ecommerce_integrations/shopify/docs/images/03-new-form-oauth.png differ 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 000000000..e5019ddca Binary files /dev/null and b/ecommerce_integrations/shopify/docs/images/04-full-form-oauth.png differ diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/__init__.py b/ecommerce_integrations/shopify/doctype/shopify_account/__init__.py similarity index 100% rename from ecommerce_integrations/shopify/doctype/shopify_setting/__init__.py rename to ecommerce_integrations/shopify/doctype/shopify_account/__init__.py diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.js b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js similarity index 95% rename from ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.js rename to ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js index 45be645ac..cab7119cb 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.js +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.js @@ -1,9 +1,9 @@ // Copyright (c) 2021, Frappe and contributors // For license information, please see LICENSE -frappe.provide("ecommerce_integrations.shopify.shopify_setting"); +frappe.provide("ecommerce_integrations.shopify.shopify_account"); -frappe.ui.form.on("Shopify Setting", { +frappe.ui.form.on("Shopify Account", { onload: function (frm) { frappe.call({ method: "ecommerce_integrations.utils.naming_series.get_series", diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json similarity index 83% rename from ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json rename to ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json index 01722169b..1d15e56b4 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.json @@ -1,6 +1,7 @@ { "actions": [], - "creation": "2021-04-13 13:30:54.909583", + "autoname": "field:shopify_url", + "creation": "2024-01-01 00:00:00.000000", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", @@ -8,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", @@ -82,23 +88,66 @@ "fieldtype": "Data", "in_list_view": 1, "label": "Shop URL", - "mandatory_depends_on": "eval:doc.enable_shopify" + "mandatory_depends_on": "eval:doc.enable_shopify", + "unique": 1 }, { "fieldname": "column_break_3", "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, @@ -153,9 +202,10 @@ { "fieldname": "company", "fieldtype": "Link", + "in_list_view": 1, "label": "Company", - "mandatory_depends_on": "eval:doc.enable_shopify", - "options": "Company" + "options": "Company", + "reqd": 1 }, { "fieldname": "cash_bank_account", @@ -229,7 +279,6 @@ "fieldname": "taxes", "fieldtype": "Table", "label": "Shopify Tax Account", - "mandatory_depends_on": "eval:doc.enable_shopify", "options": "Shopify Tax Account" }, { @@ -390,12 +439,13 @@ } ], "index_web_pages_for_search": 1, - "issingle": 1, + "issingle": 0, "links": [], - "modified": "2023-10-24 10:38:49.247431", + "modified": "2024-01-01 00:00:00.000000", "modified_by": "Administrator", "module": "shopify", - "name": "Shopify Setting", + "name": "Shopify Account", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { @@ -413,4 +463,4 @@ "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_account/shopify_account.py similarity index 57% rename from ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py rename to ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py index dc974e70e..6d0c4d3f5 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py +++ b/ecommerce_integrations/shopify/doctype/shopify_account/shopify_account.py @@ -1,4 +1,4 @@ -# Copyright (c) 2021, Frappe and contributors +# Copyright (c) 2024, Frappe and contributors # For license information, please see LICENSE import frappe @@ -25,21 +25,37 @@ 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, ) -class ShopifySetting(SettingController): +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): - ensure_old_connector_is_disabled() + # 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() @@ -51,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.") + "
" @@ -65,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 @@ -79,7 +179,7 @@ def _initalize_default_values(self): self.last_inventory_sync = get_datetime("1970-01-01") @frappe.whitelist() - @connection.temp_shopify_session + @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.""" @@ -92,6 +192,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] 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/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) diff --git a/ecommerce_integrations/shopify/fulfillment.py b/ecommerce_integrations/shopify/fulfillment.py index 5ffc0ebae..0d336e61d 100644 --- a/ecommerce_integrations/shopify/fulfillment.py +++ b/ecommerce_integrations/shopify/fulfillment.py @@ -8,28 +8,28 @@ 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 -def prepare_delivery_note(payload, request_id=None): +def prepare_delivery_note(payload, request_id=None, shopify_account=None): frappe.set_user("Administrator") - setting = frappe.get_doc(SETTING_DOCTYPE) 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 = 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 diff --git a/ecommerce_integrations/shopify/inventory.py b/ecommerce_integrations/shopify/inventory.py index 526107dd3..f793a9e2f 100644 --- a/ecommerce_integrations/shopify/inventory.py +++ b/ecommerce_integrations/shopify/inventory.py @@ -10,8 +10,8 @@ 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.constants import MODULE_NAME, SETTING_DOCTYPE +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 @@ -20,23 +20,29 @@ def update_inventory_on_shopify() -> None: Called by scheduler on configured interval. """ - setting = frappe.get_doc(SETTING_DOCTYPE) + 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(SETTING_DOCTYPE, "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 -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): @@ -65,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" @@ -90,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 f841cb416..14189e7b2 100644 --- a/ecommerce_integrations/shopify/invoice.py +++ b/ecommerce_integrations/shopify/invoice.py @@ -5,29 +5,29 @@ 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): +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 = frappe.get_doc(SETTING_DOCTYPE) 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/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..f7c0b117d --- /dev/null +++ b/ecommerce_integrations/shopify/module_onboarding/shopify/shopify.json @@ -0,0 +1,43 @@ +{ + "allow_roles": [ + { + "role": "System Manager" + }, + { + "role": "Sales Manager" + } + ], + "creation": "2026-05-04 21:00:00.000000", + "docstatus": 0, + "doctype": "Module Onboarding", + "idx": 0, + "is_complete": 0, + "modified": "2026-05-04 22:30: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/oauth.py b/ecommerce_integrations/shopify/oauth.py new file mode 100644 index 000000000..8f23da6ef --- /dev/null +++ b/ecommerce_integrations/shopify/oauth.py @@ -0,0 +1,175 @@ +# 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"), + ) + + # 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, + client_secret, + ) + + expires_at = calculate_token_expiry(token_data.get("expires_in", 86399)) + + 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, + ) + + # Keep caller doc in sync without forcing a reload. + setting.token_expires_at = get_datetime_str(expires_at) + 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")) 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/order.py b/ecommerce_integrations/shopify/order.py index 0570d035b..86a5ca3e9 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, @@ -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,13 +30,18 @@ } -def sync_sales_order(payload, request_id=None): +def sync_sales_order(payload, request_id=None, shopify_account=None): order = payload + 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"])}): - 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 {} @@ -44,19 +50,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): @@ -97,7 +104,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 "" @@ -112,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, @@ -203,9 +211,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"), @@ -259,17 +267,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"))) @@ -277,12 +285,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", ) @@ -321,7 +329,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, @@ -334,7 +342,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"], @@ -356,7 +364,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. @@ -369,6 +377,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"] @@ -377,7 +386,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}) @@ -395,28 +404,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 = frappe.get_cached_doc(SETTING_DOCTYPE) - 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 = frappe.get_doc(SETTING_DOCTYPE) - 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/page/shopify_import_products/shopify_import_products.js b/ecommerce_integrations/shopify/page/shopify_import_products/shopify_import_products.js index 62db3c216..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() { @@ -30,8 +31,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 +118,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}`)); } } @@ -294,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(); } @@ -318,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", @@ -331,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 e30a102e9..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,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, get_user_shopify_account # constants SYNC_JOB_NAME = "shopify.job.sync.all.products" @@ -46,7 +47,7 @@ def fetch_all_products(from_=None): } -@temp_shopify_session +@temp_shopify_session(shopify_account=None) def _fetch_products_from_shopify(from_=None, limit=20): if from_: collection = Product.find(from_=from_) @@ -57,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, @@ -73,7 +74,7 @@ def get_product_count(): } -@temp_shopify_session +@temp_shopify_session(shopify_account=None) def get_shopify_product_count(): return Product.count() @@ -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 @@ -95,7 +96,7 @@ def resync_product(product): return _resync_product(product) -@temp_shopify_session +@temp_shopify_session(shopify_account=None) def _resync_product(product): savepoint = "shopify_resync_product" try: @@ -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 @@ -118,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: @@ -147,29 +160,49 @@ 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() + 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 diff --git a/ecommerce_integrations/shopify/product.py b/ecommerce_integrations/shopify/product.py index 92c31f467..7dcbd0210 100644 --- a/ecommerce_integrations/shopify/product.py +++ b/ecommerce_integrations/shopify/product.py @@ -7,16 +7,15 @@ 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, - 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_company_shopify_account, get_user_shopify_account class ShopifyProduct: @@ -26,13 +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 = frappe.get_doc(SETTING_DOCTYPE) - + 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.")) @@ -53,12 +53,12 @@ def get_erpnext_item(self): has_variants=self.has_variants, ) - @temp_shopify_session 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) @@ -73,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): @@ -121,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, @@ -137,8 +140,12 @@ 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: + 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"] @@ -174,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): @@ -211,7 +219,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): @@ -301,13 +312,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() @@ -328,7 +339,6 @@ def get_item_code(shopify_item): return item.item_code -@temp_shopify_session def upload_erpnext_item(doc, method=None): """This hook is called when inserting new or updating existing `Item`. @@ -340,7 +350,15 @@ def upload_erpnext_item(doc, method=None): if item.flags.from_integration: return - setting = frappe.get_doc(SETTING_DOCTYPE) + # TODO: Handle if doc.custom_company is None + if doc.get("custom_company", None): + 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 @@ -369,104 +387,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): @@ -549,7 +568,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()) @@ -560,6 +579,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( @@ -567,4 +587,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 06bf1f582..815fc4fa7 100644 --- a/ecommerce_integrations/shopify/utils.py +++ b/ecommerce_integrations/shopify/utils.py @@ -9,11 +9,46 @@ ) from ecommerce_integrations.shopify.constants import ( MODULE_NAME, - OLD_SETTINGS_DOCTYPE, - SETTING_DOCTYPE, + # OLD_SETTINGS_DOCTYPE, + # SETTING_DOCTYPE, + ACCOUNT_DOCTYPE, ) +@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) + 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 get_company_shopify_account(company_id) + return None + + +def get_company_shopify_account(company): + 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) @@ -38,16 +73,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 +102,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() 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" +} 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