diff --git a/SHOPIFY_NULL_PRODUCT_ID_TECHNICAL_DOCUMENTATION.md b/SHOPIFY_NULL_PRODUCT_ID_TECHNICAL_DOCUMENTATION.md
new file mode 100644
index 000000000..d3eb41fe8
--- /dev/null
+++ b/SHOPIFY_NULL_PRODUCT_ID_TECHNICAL_DOCUMENTATION.md
@@ -0,0 +1,718 @@
+# Technical Documentation: Shopify Null Product ID Handling
+
+## Document Information
+- **Version:** 1.0
+- **Date:** December 16, 2025
+- **Branch:** `fix/shopify-null-product-id-fallback-mapping`
+- **Commit:** `eaf8d8a`
+- **Author:** Development Team
+
+---
+
+## Table of Contents
+1. [Problem Statement](#problem-statement)
+2. [Solution Overview](#solution-overview)
+3. [Files Modified](#files-modified)
+4. [Detailed Code Changes](#detailed-code-changes)
+5. [Function Reference](#function-reference)
+6. [Data Flow](#data-flow)
+7. [Testing Scenarios](#testing-scenarios)
+8. [Dependencies](#dependencies)
+
+---
+
+## Problem Statement
+
+### Issue
+Shopify orders containing line items without a `product_id` (such as tips, samples, rush fees, adjustments) were failing to sync to ERPNext with the error:
+
+```
+expected String to be a id
+```
+
+### Root Causes
+1. **Null Product ID Items**: Shopify allows line items without `product_id` for non-product charges (tips, samples, fees, adjustments)
+2. **Missing Fallback Logic**: The system attempted to sync these items as regular products, causing API errors
+3. **Early Filtering**: Items with null `product_id` were filtered out by `product_exists` check before reaching item mapping logic
+4. **Duplicate Item Errors**: Existing items with missing Ecommerce Item link records caused duplicate errors that crashed the sync process
+
+### Impact
+- Orders with tips, samples, or fees failed to sync completely
+- Sales Orders were not created for affected orders
+- Manual intervention required for each failed order
+
+---
+
+## Solution Overview
+
+### Approach
+Implemented a multi-layered solution:
+
+1. **Intelligent Fallback Mapping**: Map null `product_id` items to pre-configured fallback items based on line item title keywords
+2. **Early Processing**: Process null `product_id` items before `product_exists` validation
+3. **Error Handling**: Add comprehensive error handling to prevent sync failures
+4. **Enhanced Matching**: Improve item matching to handle both SKU and product_id based item codes
+
+### Fallback Item Mapping
+| Line Item Title Contains | Mapped To ERPNext Item |
+|-------------------------|------------------------|
+| "tip" | `SHOPIFY-TIP` |
+| "sample" | `SHOPIFY-SAMPLE` |
+| "rush", "rush order", "rush fee" | `SHOPIFY-RUSH-FEE` |
+| "adjustment", "price adjustment" | `SHOPIFY-ADJUSTMENT` |
+| Any other text or empty | `SHOPIFY-MISC` |
+
+**Note:** These fallback items must exist in ERPNext before deployment.
+
+---
+
+## Files Modified
+
+### Summary
+- **2 files modified**
+- **~95 lines added**
+- **~15 lines modified**
+
+### File List
+1. `ecommerce_integrations/shopify/product.py`
+2. `ecommerce_integrations/shopify/order.py`
+
+---
+
+## Detailed Code Changes
+
+### File 1: `ecommerce_integrations/shopify/product.py`
+
+#### Change 1: New Function - `get_shopify_fallback_item()`
+
+**Location:** Lines 22-46 (after imports, before `ShopifyProduct` class)
+
+**Purpose:** Maps Shopify line item titles to appropriate ERPNext fallback items
+
+**Code Added:**
+```python
+def get_shopify_fallback_item(title):
+ """Map Shopify line items without product_id to appropriate fallback items.
+
+ Args:
+ title (str): Line item title from Shopify
+
+ Returns:
+ str: ERPNext Item code
+ """
+ if not title:
+ return "SHOPIFY-MISC"
+
+ title_lower = title.lower()
+
+ # Map based on title keywords
+ if "tip" in title_lower:
+ return "SHOPIFY-TIP"
+ elif "sample" in title_lower:
+ return "SHOPIFY-SAMPLE"
+ elif "rush" in title_lower or "rush order" in title_lower or "rush fee" in title_lower:
+ return "SHOPIFY-RUSH-FEE"
+ elif "adjustment" in title_lower or "price adjustment" in title_lower:
+ return "SHOPIFY-ADJUSTMENT"
+ else:
+ return "SHOPIFY-MISC"
+```
+
+**Logic:**
+- Case-insensitive keyword matching
+- Priority order: tip → sample → rush → adjustment → misc
+- Returns default `SHOPIFY-MISC` for empty titles or unmatched items
+
+---
+
+#### Change 2: Enhanced Function - `_match_sku_and_link_item()`
+
+**Location:** Lines 301-351
+
+**Purpose:** Match existing ERPNext items by both SKU and product_id to prevent duplicate creation
+
+**Before:**
+```python
+def _match_sku_and_link_item(item_dict, product_id, variant_id, variant_of=None, has_variant=False) -> bool:
+ """Tries to match new item with existing item using Shopify SKU == item_code.
+
+ Returns true if matched and linked.
+ """
+ sku = item_dict["sku"]
+ if not sku or variant_of or has_variant:
+ return False
+
+ item_name = frappe.db.get_value("Item", {"item_code": sku})
+ if item_name:
+ try:
+ ecommerce_item = frappe.get_doc({...})
+ ecommerce_item.insert()
+ return True
+ except Exception:
+ return False
+```
+
+**After:**
+```python
+def _match_sku_and_link_item(item_dict, product_id, variant_id, variant_of=None, has_variant=False) -> bool:
+ """Tries to match new item with existing item using Shopify SKU == item_code or product_id.
+
+ Returns true if matched and linked.
+ """
+ sku = item_dict["sku"]
+ if variant_of or has_variant:
+ return False
+
+ # Try matching by SKU first
+ if sku:
+ item_name = frappe.db.get_value("Item", {"item_code": sku})
+ if item_name:
+ try:
+ ecommerce_item = frappe.get_doc({...})
+ ecommerce_item.insert()
+ return True
+ except Exception:
+ pass
+
+ # Also try matching by product_id as item_code
+ item_name = frappe.db.get_value("Item", {"item_code": product_id})
+ if item_name:
+ try:
+ ecommerce_item = frappe.get_doc({...})
+ ecommerce_item.insert()
+ return True
+ except Exception:
+ return False
+
+ return False
+```
+
+**Key Changes:**
+1. Removed `if not sku` early return - now continues even without SKU
+2. Added two-stage matching:
+ - **Stage 1:** Match by SKU (original logic)
+ - **Stage 2:** Match by product_id as item_code (new)
+3. Better error handling with `pass` in Stage 1 to allow Stage 2 attempt
+
+**Why:** Items created with product_id as item_code weren't found by SKU matching, causing duplicate errors.
+
+---
+
+#### Change 3: Enhanced Function - `create_items_if_not_exist()`
+
+**Location:** Lines 354-376
+
+**Purpose:** Skip syncing items with null product_id and add error handling
+
+**Before:**
+```python
+def create_items_if_not_exist(order):
+ """Using shopify order, sync all items that are not already synced."""
+ for item in order.get("line_items", []):
+ product_id = item["product_id"]
+ variant_id = item.get("variant_id")
+ sku = item.get("sku")
+ product = ShopifyProduct(product_id, variant_id=variant_id, sku=sku)
+
+ if not product.is_synced():
+ product.sync_product()
+```
+
+**After:**
+```python
+def create_items_if_not_exist(order):
+ """Using shopify order, sync all items that are not already synced."""
+ for item in order.get("line_items", []):
+ product_id = item.get("product_id")
+ variant_id = item.get("variant_id")
+ sku = item.get("sku")
+
+ # Skip items with null product_id - mapped to fallback items
+ if not product_id:
+ continue
+
+ try:
+ product = ShopifyProduct(product_id, variant_id=variant_id, sku=sku)
+ if not product.is_synced():
+ product.sync_product()
+ except frappe.DuplicateEntryError:
+ frappe.logger().info(f"Item {product_id} already exists, skipping")
+ continue
+ except Exception as e:
+ frappe.logger().error(f"Error syncing item {product_id}: {str(e)}")
+ if "IntegrityError" not in str(e):
+ raise
+ continue
+```
+
+**Key Changes:**
+1. Changed `item["product_id"]` to `item.get("product_id")` - handles null values
+2. Added null check: `if not product_id: continue` - skips null product_id items
+3. Wrapped sync logic in try-except block
+4. Catches `DuplicateEntryError` - logs and continues
+5. Catches `IntegrityError` - logs and continues (database constraint violations)
+6. Re-raises other exceptions - real errors still propagate
+
+**Why:** Prevents sync crashes when duplicate items exist or database constraints fail.
+
+---
+
+#### Change 4: Enhanced Function - `get_item_code()`
+
+**Location:** Lines 379-412
+
+**Purpose:** Handle null product_id items by mapping to fallback items
+
+**Before:**
+```python
+def get_item_code(shopify_item):
+ """Get item code using shopify_item dict.
+
+ Item should contain both product_id and variant_id."""
+
+ item = ecommerce_item.get_erpnext_item(
+ integration=MODULE_NAME,
+ integration_item_code=shopify_item.get("product_id"),
+ variant_id=shopify_item.get("variant_id"),
+ sku=shopify_item.get("sku"),
+ )
+ if item:
+ return item.item_code
+```
+
+**After:**
+```python
+def get_item_code(shopify_item):
+ """Get item code using shopify_item dict.
+
+ Item should contain both product_id and variant_id."""
+
+ product_id = shopify_item.get("product_id")
+ variant_id = shopify_item.get("variant_id")
+ sku = shopify_item.get("sku")
+ title = shopify_item.get("title", "")
+
+ # Handle items without product_id (tips, samples, fees)
+ if not product_id:
+ fallback_item = get_shopify_fallback_item(title)
+
+ frappe.logger().info(
+ f"Line item '{title}' has no product_id - mapped to: {fallback_item}"
+ )
+
+ if frappe.db.exists("Item", fallback_item):
+ return fallback_item
+ else:
+ frappe.throw(
+ f"Fallback item '{fallback_item}' not found for '{title}'"
+ )
+
+ # Original logic continues
+ item = ecommerce_item.get_erpnext_item(
+ integration=MODULE_NAME,
+ integration_item_code=product_id,
+ variant_id=variant_id,
+ sku=sku,
+ )
+ if item:
+ return item.item_code
+```
+
+**Key Changes:**
+1. Extract `product_id`, `variant_id`, `sku`, and `title` at function start
+2. Added null `product_id` check at beginning
+3. Call `get_shopify_fallback_item(title)` for mapping
+4. Log the mapping for debugging
+5. Verify fallback item exists in ERPNext
+6. Return fallback item code or throw error if not found
+7. Original logic continues for items with valid `product_id`
+
+**Why:** Provides intelligent mapping for non-product line items before they cause errors.
+
+---
+
+### File 2: `ecommerce_integrations/shopify/order.py`
+
+#### Change 1: Enhanced Function - `get_order_items()`
+
+**Location:** Lines 139-194
+
+**Purpose:** Process null product_id items before product_exists validation
+
+**Before:**
+```python
+def get_order_items(order_items, setting, delivery_date, taxes_inclusive):
+ items = []
+ all_product_exists = True
+ product_not_exists = []
+
+ for shopify_item in order_items:
+ if not shopify_item.get("product_exists"):
+ all_product_exists = False
+ product_not_exists.append({...})
+ continue
+
+ if all_product_exists:
+ item_code = get_item_code(shopify_item)
+ items.append({...})
+ else:
+ items = []
+
+ return items
+```
+
+**After:**
+```python
+def get_order_items(order_items, setting, delivery_date, taxes_inclusive):
+ items = []
+ all_product_exists = True
+ product_not_exists = []
+
+ for shopify_item in order_items:
+ product_id = shopify_item.get("product_id")
+
+ # Handle items without product_id (tips, samples, fees) - skip product_exists check
+ if not product_id:
+ item_code = get_item_code(shopify_item)
+ if item_code:
+ items.append(
+ {
+ "item_code": item_code,
+ "item_name": shopify_item.get("name") or shopify_item.get("title"),
+ "rate": _get_item_price(shopify_item, taxes_inclusive),
+ "delivery_date": delivery_date,
+ "qty": shopify_item.get("quantity"),
+ "stock_uom": "Nos",
+ "warehouse": setting.warehouse,
+ ORDER_ITEM_DISCOUNT_FIELD: (
+ _get_total_discount(shopify_item) / cint(shopify_item.get("quantity"))
+ ),
+ }
+ )
+ continue
+
+ # Original logic for items with product_id
+ if not shopify_item.get("product_exists"):
+ all_product_exists = False
+ product_not_exists.append({...})
+ continue
+
+ if all_product_exists:
+ item_code = get_item_code(shopify_item)
+ items.append({...})
+ else:
+ items = []
+
+ return items
+```
+
+**Key Changes:**
+1. Extract `product_id` at start of loop
+2. Added null `product_id` check **before** `product_exists` check
+3. For null `product_id`:
+ - Call `get_item_code()` which routes to fallback mapping
+ - Add item to list with proper structure
+ - Use `shopify_item.get("name") or shopify_item.get("title")` for item_name
+ - Use `"Nos"` as default stock_uom
+ - Continue to next item (skip `product_exists` validation)
+4. Original logic preserved for items with valid `product_id`
+
+**Why:** Null `product_id` items were being filtered out by `product_exists` check before reaching the mapping logic.
+
+---
+
+## Function Reference
+
+### New Functions
+
+#### `get_shopify_fallback_item(title: str) -> str`
+- **File:** `product.py`
+- **Line:** 22-46
+- **Parameters:**
+ - `title` (str): Line item title from Shopify order
+- **Returns:** ERPNext Item code (str)
+- **Description:** Maps line item titles to fallback items using keyword matching
+- **Dependencies:** None
+- **Side Effects:** None
+
+### Modified Functions
+
+#### `_match_sku_and_link_item()`
+- **File:** `product.py`
+- **Line:** 301-351
+- **Changes:** Two-stage matching (SKU + product_id)
+- **Impact:** Prevents duplicate item creation errors
+
+#### `create_items_if_not_exist()`
+- **File:** `product.py`
+- **Line:** 354-376
+- **Changes:** Null check + error handling
+- **Impact:** Skips null product_id items, handles duplicates gracefully
+
+#### `get_item_code()`
+- **File:** `product.py`
+- **Line:** 379-412
+- **Changes:** Null product_id handling with fallback mapping
+- **Impact:** Maps non-product items to fallback items
+
+#### `get_order_items()`
+- **File:** `order.py`
+- **Line:** 139-194
+- **Changes:** Process null product_id items early
+- **Impact:** Ensures null product_id items are included in Sales Orders
+
+---
+
+## Data Flow
+
+### Order Sync Flow (Before)
+```
+Shopify Order Received
+ ↓
+sync_sales_order()
+ ↓
+create_items_if_not_exist()
+ ↓
+For each line_item:
+ - product_id = item["product_id"] ← CRASH if null
+ - Try to sync product
+ ↓
+get_order_items()
+ ↓
+For each line_item:
+ - Check product_exists ← Filters out null product_id
+ - get_item_code() ← Never reached for null product_id
+ ↓
+Sales Order Created (incomplete)
+```
+
+### Order Sync Flow (After)
+```
+Shopify Order Received
+ ↓
+sync_sales_order()
+ ↓
+create_items_if_not_exist()
+ ↓
+For each line_item:
+ - product_id = item.get("product_id")
+ - if not product_id: continue ← Skip syncing
+ - Try to sync product
+ - Catch duplicate errors → log & continue
+ ↓
+get_order_items()
+ ↓
+For each line_item:
+ - product_id = shopify_item.get("product_id")
+ - if not product_id:
+ - get_item_code() → get_shopify_fallback_item()
+ - Map to SHOPIFY-TIP/SAMPLE/etc.
+ - Add to items list
+ - continue
+ - Original logic for valid product_id
+ ↓
+Sales Order Created (complete with all items)
+```
+
+### Fallback Mapping Flow
+```
+Line Item with null product_id
+ ↓
+get_item_code(shopify_item)
+ ↓
+Check: product_id is null?
+ ↓ YES
+get_shopify_fallback_item(title)
+ ↓
+Keyword Matching:
+ - "tip" → SHOPIFY-TIP
+ - "sample" → SHOPIFY-SAMPLE
+ - "rush" → SHOPIFY-RUSH-FEE
+ - "adjustment" → SHOPIFY-ADJUSTMENT
+ - else → SHOPIFY-MISC
+ ↓
+Verify item exists in ERPNext
+ ↓
+Return item_code
+```
+
+---
+
+## Testing Scenarios
+
+### Test Case 1: Order with Tip
+**Input:**
+- Line Item 1: Product (product_id: 12345)
+- Line Item 2: "Tip" (product_id: null)
+
+**Expected Output:**
+- Sales Order with 2 line items
+- Item 1: Actual product item
+- Item 2: SHOPIFY-TIP
+
+**Test Command:**
+```python
+test_order_sync("43-31469-21")
+```
+
+### Test Case 2: Order with Sample
+**Input:**
+- Line Item 1: Product (product_id: 12345)
+- Line Item 2: "Sample Fabric" (product_id: null)
+
+**Expected Output:**
+- Sales Order with 2 line items
+- Item 1: Actual product item
+- Item 2: SHOPIFY-SAMPLE
+
+### Test Case 3: Order with Rush Fee
+**Input:**
+- Line Item 1: Product (product_id: 12345)
+- Line Item 2: "Rush Order Fee" (product_id: null)
+
+**Expected Output:**
+- Sales Order with 2 line items
+- Item 1: Actual product item
+- Item 2: SHOPIFY-RUSH-FEE
+
+### Test Case 4: Order with Multiple Non-Product Items
+**Input:**
+- Line Item 1: Product (product_id: 12345)
+- Line Item 2: "Tip" (product_id: null)
+- Line Item 3: "Rush Fee" (product_id: null)
+- Line Item 4: "Order Adjustment" (product_id: null)
+
+**Expected Output:**
+- Sales Order with 4 line items
+- All items mapped correctly
+
+### Test Case 5: Duplicate Item Handling
+**Input:**
+- Order with existing item (product_id: 8706562621682)
+- Ecommerce Item link missing
+
+**Expected Output:**
+- Item matched by product_id
+- Ecommerce Item link created
+- No duplicate error
+- Order syncs successfully
+
+### Test Case 6: Missing Fallback Item
+**Input:**
+- Order with "Tip" (product_id: null)
+- SHOPIFY-TIP item not found in ERPNext
+
+**Expected Output:**
+- Error: "Fallback item 'SHOPIFY-TIP' not found for 'Tip'"
+- Order sync fails with clear error message
+
+---
+
+## Dependencies
+
+### Required ERPNext Items
+The following items must exist in ERPNext before deployment:
+
+1. **SHOPIFY-TIP**
+ - Item Code: `SHOPIFY-TIP`
+ - Item Name: "Customer Tip" (or similar)
+ - Item Group: As per business requirements
+ - Stock UOM: "Nos"
+
+2. **SHOPIFY-SAMPLE**
+ - Item Code: `SHOPIFY-SAMPLE`
+ - Item Name: "Sample Item" (or similar)
+
+3. **SHOPIFY-RUSH-FEE**
+ - Item Code: `SHOPIFY-RUSH-FEE`
+ - Item Name: "Rush Order Fee" (or similar)
+
+4. **SHOPIFY-ADJUSTMENT**
+ - Item Code: `SHOPIFY-ADJUSTMENT`
+ - Item Name: "Order Adjustment" (or similar)
+
+5. **SHOPIFY-MISC**
+ - Item Code: `SHOPIFY-MISC`
+ - Item Name: "Miscellaneous Charge" (or similar)
+
+### Code Dependencies
+- `frappe` - Core Frappe framework
+- `ecommerce_integrations.ecommerce_integrations.doctype.ecommerce_item` - Ecommerce Item doctype
+- `ecommerce_integrations.shopify.constants` - Module constants
+- `ecommerce_integrations.shopify.utils` - Utility functions
+
+### No Breaking Changes
+- All changes are backward compatible
+- Existing functionality preserved
+- Only adds new handling for edge cases
+
+---
+
+## Deployment Checklist
+
+- [ ] Verify all 5 fallback items exist in ERPNext
+- [ ] Test with sample orders containing tips/samples/fees
+- [ ] Verify error handling works for duplicate items
+- [ ] Check logs for fallback mapping messages
+- [ ] Test orders with mixed product and non-product items
+- [ ] Verify Sales Orders are created with correct line items
+- [ ] Monitor Integration Log for any new errors
+
+---
+
+## Rollback Procedure
+
+If issues occur, restore from backup files:
+
+```bash
+cd apps/ecommerce_integrations/ecommerce_integrations/shopify
+cp product.py.backup.YYYYMMDD_HHMM product.py
+cp order.py.backup.YYYYMMDD_HHMM order.py
+bench restart
+```
+
+---
+
+## Support & Troubleshooting
+
+### Common Issues
+
+1. **"Fallback item 'SHOPIFY-XXX' not found"**
+ - **Solution:** Create the missing fallback item in ERPNext
+
+2. **Orders still failing with duplicate errors**
+ - **Solution:** Check if `_match_sku_and_link_item()` is finding existing items correctly
+
+3. **Null product_id items not appearing in Sales Order**
+ - **Solution:** Verify `get_order_items()` is processing null items before `product_exists` check
+
+### Logging
+
+All fallback mappings are logged:
+```
+Line item 'Tip' has no product_id - mapped to: SHOPIFY-TIP
+```
+
+Check logs with:
+```bash
+bench --site [site] logs | grep -i "mapped to"
+```
+
+---
+
+## Version History
+
+- **v1.0** (2025-12-16): Initial implementation
+ - Added fallback item mapping
+ - Enhanced error handling
+ - Improved item matching logic
+
+---
+
+## Contact
+
+For questions or issues, refer to:
+- GitHub Repository: `sahilvikas/ecommerce_integrations`
+- Branch: `fix/shopify-null-product-id-fallback-mapping`
+- Commit: `eaf8d8a`
+
diff --git a/SHOPIFY_ORDER_UPDATE_WEBHOOK_DOCUMENTATION.md b/SHOPIFY_ORDER_UPDATE_WEBHOOK_DOCUMENTATION.md
new file mode 100644
index 000000000..8391391c4
--- /dev/null
+++ b/SHOPIFY_ORDER_UPDATE_WEBHOOK_DOCUMENTATION.md
@@ -0,0 +1,902 @@
+# Technical Documentation: Shopify Order Update Webhook with Comprehensive Logging
+
+## Document Information
+- **Version:** 1.0
+- **Date:** January 2025
+- **Branch:** `shopify-null-product-id-fallback-mapping`
+- **Author:** Priyanshi
+
+---
+
+## Table of Contents
+1. [Problem Statement](#problem-statement)
+2. [Solution Overview](#solution-overview)
+3. [Files Modified](#files-modified)
+4. [Detailed Code Changes](#detailed-code-changes)
+5. [Function Reference](#function-reference)
+6. [Data Flow](#data-flow)
+7. [Testing Scenarios](#testing-scenarios)
+8. [Dependencies](#dependencies)
+9. [Deployment Checklist](#deployment-checklist)
+10. [Rollback Procedure](#rollback-procedure)
+11. [Support & Troubleshooting](#support--troubleshooting)
+
+---
+
+## Problem Statement
+
+### Issue
+When orders are updated in Shopify (amounts changed, items added/removed, status changed), there was no mechanism to:
+- Track these updates in ERPNext
+- Log comprehensive details about what changed
+- Notify the OPS team about order modifications
+- Maintain an audit trail of all order changes
+- Capture all order references, primary keys, and amount details for communication purposes
+
+### Root Causes
+1. **Missing Webhook Handler** — No webhook event handler for `orders/updated` event
+2. **No Change Tracking** — System couldn't detect or log what changed in orders
+3. **Incomplete Logging** — Existing logs didn't capture all necessary details (references, keys, amounts, changes)
+4. **No Multi-Store Support** — Update webhook wasn't configured for Store 2
+5. **Limited Visibility** — OPS team had no way to see order updates without manual checking
+
+### Impact
+- Order updates in Shopify were not tracked in ERPNext
+- No audit trail for order modifications
+- OPS team couldn't see what changed in orders
+- Difficult to communicate order changes to stakeholders
+- No way to detect discrepancies between Shopify and ERPNext
+
+---
+
+## Solution Overview
+
+### Approach
+Implemented a comprehensive solution:
+1. **Webhook Event Registration** — Added `orders/updated` to webhook events for both stores
+2. **Update Handler Function** — Created `update_sales_order()` to handle order updates
+3. **Comprehensive Logging** — Logs all order references, primary keys, amount details, and change details
+4. **Change Detection** — Compares Shopify order with ERPNext Sales Order to detect changes
+5. **Multi-Store Support** — Works seamlessly with both Store 1 and Store 2
+6. **Complete Data Extraction** — Captures all necessary information for OPS team communication
+
+### Key Features
+- ✅ Automatic webhook registration for both stores
+- ✅ Complete order reference tracking (Shopify IDs ↔ ERPNext documents)
+- ✅ All primary keys captured (order, items, fulfillments, discounts)
+- ✅ Detailed amount breakdowns (totals, line items, taxes, shipping)
+- ✅ Change detection (amounts, items, status)
+- ✅ Comprehensive log entries in Ecommerce Integration Log
+- ✅ Support for new order creation if order doesn't exist
+
+---
+
+## Files Modified
+
+### Summary
+- **2 files modified**
+- **~600 lines added**
+- **2 lines modified**
+
+### File List
+1. `ecommerce_integrations/shopify/constants.py`
+2. `ecommerce_integrations/shopify/order.py`
+
+---
+
+## Detailed Code Changes
+
+### File 1: `ecommerce_integrations/shopify/constants.py`
+
+#### Change 1: Add `orders/updated` to WEBHOOK_EVENTS
+
+**Location:** Line 17
+
+**Purpose:** Register the order update webhook event
+
+**Before:**
+```python
+WEBHOOK_EVENTS = [
+ "orders/create",
+ "orders/paid",
+ "orders/fulfilled",
+ "orders/cancelled",
+ "orders/partially_fulfilled",
+]
+```
+
+**After:**
+```python
+WEBHOOK_EVENTS = [
+ "orders/create",
+ "orders/paid",
+ "orders/fulfilled",
+ "orders/cancelled",
+ "orders/partially_fulfilled",
+ "orders/updated", # ← NEW
+]
+```
+
+**Why:** Enables automatic webhook registration for order updates in both Store 1 and Store 2.
+
+---
+
+#### Change 2: Add Event Mapping to EVENT_MAPPER
+
+**Location:** Line 26
+
+**Purpose:** Map the webhook event to the handler function
+
+**Before:**
+```python
+EVENT_MAPPER = {
+ "orders/create": "ecommerce_integrations.shopify.order.sync_sales_order",
+ "orders/paid": "ecommerce_integrations.shopify.invoice.prepare_sales_invoice",
+ "orders/fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note",
+ "orders/cancelled": "ecommerce_integrations.shopify.order.cancel_order",
+ "orders/partially_fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note",
+}
+```
+
+**After:**
+```python
+EVENT_MAPPER = {
+ "orders/create": "ecommerce_integrations.shopify.order.sync_sales_order",
+ "orders/paid": "ecommerce_integrations.shopify.invoice.prepare_sales_invoice",
+ "orders/fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note",
+ "orders/cancelled": "ecommerce_integrations.shopify.order.cancel_order",
+ "orders/partially_fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note",
+ "orders/updated": "ecommerce_integrations.shopify.order.update_sales_order", # ← NEW
+}
+```
+
+**Why:** Routes the webhook event to the correct handler function when an order update is received.
+
+---
+
+### File 2: `ecommerce_integrations/shopify/order.py`
+
+#### Change 1: New Function — `update_sales_order()`
+
+**Location:** Lines 657-1236
+
+**Purpose:** Handle order updates from Shopify with comprehensive logging
+
+**Code Added:**
+```python
+def update_sales_order(payload, request_id=None, store_name=None):
+ """Handle order updates from Shopify with comprehensive logging.
+
+ Logs all order references, change details, amount details, and primary keys.
+ Supports both Store 1 and Store 2.
+ """
+ order = payload
+ frappe.set_user("Administrator")
+ frappe.flags.request_id = request_id
+
+ # Set store context for Store 2
+ if store_name:
+ frappe.local.shopify_store_name = store_name
+ log_store2("UPDATE-1", f"""
+========================================
+ORDER UPDATE WEBHOOK RECEIVED
+========================================
+Store: {store_name}
+Order ID: {order.get('id')}
+Order Number: {order.get('name')}
+request_id: {request_id}
+""", store_name)
+
+ # Initialize comprehensive log data structure
+ log_data = {
+ "order_references": {},
+ "change_details": {},
+ "amount_details": {},
+ "primary_keys": {},
+ "line_items_details": [],
+ "status": "processing",
+ "store_name": store_name or "Store 1"
+ }
+
+ try:
+ # ========== EXTRACT ALL PRIMARY KEYS ==========
+ order_id = cstr(order.get("id"))
+ order_number = order.get("name", "")
+ customer_id = order.get("customer", {}).get("id") if order.get("customer") else None
+
+ log_data["primary_keys"] = {
+ "shopify_order_id": order_id,
+ "shopify_order_number": order_number,
+ "shopify_customer_id": customer_id,
+ "shopify_order_name": order.get("name", ""),
+ "shopify_order_token": order.get("token", ""),
+ "shopify_checkout_id": order.get("checkout_id"),
+ "shopify_checkout_token": order.get("checkout_token"),
+ }
+
+ # Extract line item IDs
+ line_item_ids = []
+ for item in order.get("line_items", []):
+ line_item_ids.append({
+ "shopify_line_item_id": item.get("id"),
+ "shopify_product_id": item.get("product_id"),
+ "shopify_variant_id": item.get("variant_id"),
+ "shopify_sku": item.get("sku", ""),
+ })
+ log_data["primary_keys"]["line_items"] = line_item_ids
+
+ # Extract fulfillment IDs
+ fulfillment_ids = []
+ for fulfillment in order.get("fulfillments", []):
+ fulfillment_ids.append({
+ "shopify_fulfillment_id": fulfillment.get("id"),
+ "shopify_tracking_number": fulfillment.get("tracking_number"),
+ })
+ log_data["primary_keys"]["fulfillments"] = fulfillment_ids
+
+ # Extract discount code IDs
+ discount_codes = []
+ for discount in order.get("discount_codes", []):
+ discount_codes.append({
+ "shopify_discount_code": discount.get("code"),
+ "shopify_discount_type": discount.get("type"),
+ })
+ log_data["primary_keys"]["discount_codes"] = discount_codes
+
+ # ========== EXTRACT ALL ORDER REFERENCES ==========
+ sales_order_name = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id})
+ customer_name = None
+ if customer_id:
+ customer_name = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name")
+
+ # Get related documents
+ sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id})
+ delivery_notes = frappe.db.get_list("Delivery Note", filters={ORDER_ID_FIELD: order_id}, pluck="name")
+
+ log_data["order_references"] = {
+ "shopify_order_id": order_id,
+ "shopify_order_number": order_number,
+ "erpnext_sales_order": sales_order_name,
+ "erpnext_customer": customer_name or order.get("customer", {}).get("email", ""),
+ "erpnext_sales_invoice": sales_invoice,
+ "erpnext_delivery_notes": delivery_notes,
+ "shopify_customer_email": order.get("customer", {}).get("email", "") if order.get("customer") else "",
+ "shopify_customer_phone": order.get("customer", {}).get("phone", "") if order.get("customer") else "",
+ }
+
+ # ========== EXTRACT AMOUNT DETAILS ==========
+ log_data["amount_details"] = {
+ "shopify_subtotal_price": flt(order.get("subtotal_price", 0)),
+ "shopify_total_tax": flt(order.get("total_tax", 0)),
+ "shopify_total_discounts": flt(order.get("total_discounts", 0)),
+ "shopify_total_shipping_price": flt(order.get("total_shipping_price_set", {}).get("shop_money", {}).get("amount", 0)) if order.get("total_shipping_price_set") else 0,
+ "shopify_total_price": flt(order.get("total_price", 0)),
+ "shopify_total_price_usd": flt(order.get("total_price_usd", 0)),
+ "shopify_currency": order.get("currency", ""),
+ "shopify_current_total_price": flt(order.get("current_total_price", 0)),
+ "shopify_current_subtotal_price": flt(order.get("current_subtotal_price", 0)),
+ "shopify_current_total_tax": flt(order.get("current_total_tax", 0)),
+ "shopify_current_total_discounts": flt(order.get("current_total_discounts", 0)),
+ }
+
+ # Line item amounts
+ line_item_amounts = []
+ for item in order.get("line_items", []):
+ line_item_amounts.append({
+ "shopify_line_item_id": item.get("id"),
+ "quantity": cint(item.get("quantity", 0)),
+ "price": flt(item.get("price", 0)),
+ "total_discount": flt(_get_total_discount(item)),
+ "subtotal": flt(item.get("price", 0)) * cint(item.get("quantity", 0)),
+ "total_after_discount": (flt(item.get("price", 0)) * cint(item.get("quantity", 0))) - flt(_get_total_discount(item)),
+ })
+ log_data["amount_details"]["line_items"] = line_item_amounts
+
+ # Tax line amounts
+ tax_line_amounts = []
+ for tax_line in order.get("tax_lines", []):
+ tax_line_amounts.append({
+ "title": tax_line.get("title", ""),
+ "price": flt(tax_line.get("price", 0)),
+ "rate": flt(tax_line.get("rate", 0)),
+ })
+ log_data["amount_details"]["tax_lines"] = tax_line_amounts
+
+ # Shipping line amounts
+ shipping_line_amounts = []
+ for shipping_line in order.get("shipping_lines", []):
+ shipping_line_amounts.append({
+ "title": shipping_line.get("title", ""),
+ "price": flt(shipping_line.get("price", 0)),
+ "code": shipping_line.get("code", ""),
+ })
+ log_data["amount_details"]["shipping_lines"] = shipping_line_amounts
+
+ # ========== DETECT CHANGES ==========
+ if not sales_order_name:
+ # Order doesn't exist, create it
+ log_data["change_details"] = {
+ "action": "create_new_order",
+ "reason": "Order not found in ERPNext",
+ }
+ log_data["status"] = "creating"
+
+ create_shopify_log(
+ status="Info",
+ message=f"Order {order_number} not found, creating new order",
+ request_data=order,
+ response_data=log_data
+ )
+ sync_sales_order(payload, request_id, store_name=store_name)
+ return
+
+ # Order exists, compare and detect changes
+ sales_order = frappe.get_doc("Sales Order", sales_order_name)
+ changes = {}
+
+ # Compare amounts
+ old_total = flt(sales_order.grand_total)
+ new_total = flt(order.get("total_price", 0))
+ if old_total != new_total:
+ changes["grand_total"] = {
+ "old": old_total,
+ "new": new_total,
+ "difference": new_total - old_total
+ }
+
+ old_subtotal = flt(sales_order.total)
+ new_subtotal = flt(order.get("subtotal_price", 0))
+ if old_subtotal != new_subtotal:
+ changes["subtotal"] = {
+ "old": old_subtotal,
+ "new": new_subtotal,
+ "difference": new_subtotal - old_subtotal
+ }
+
+ # Compare order status
+ old_status = sales_order.get(ORDER_STATUS_FIELD) or ""
+ new_status = order.get("financial_status", "")
+ if old_status != new_status:
+ changes["financial_status"] = {
+ "old": old_status,
+ "new": new_status
+ }
+
+ old_fulfillment_status = order.get("fulfillment_status")
+ new_fulfillment_status = order.get("fulfillment_status", "")
+ if old_fulfillment_status != new_fulfillment_status:
+ changes["fulfillment_status"] = {
+ "old": old_fulfillment_status or "unfulfilled",
+ "new": new_fulfillment_status or "unfulfilled"
+ }
+
+ # Compare line items
+ old_items_count = len(sales_order.items)
+ new_items_count = len(order.get("line_items", []))
+ if old_items_count != new_items_count:
+ changes["line_items_count"] = {
+ "old": old_items_count,
+ "new": new_items_count
+ }
+
+ # Compare line items in detail
+ line_item_changes = []
+ shopify_items_map = {str(item.get("id")): item for item in order.get("line_items", [])}
+
+ # Get existing line item IDs from Sales Order
+ existing_shopify_ids = set()
+ for so_item in sales_order.items:
+ shopify_item_id = str(so_item.get("shopify_line_item_id", ""))
+ if shopify_item_id and shopify_item_id in shopify_items_map:
+ shopify_item = shopify_items_map[shopify_item_id]
+ item_changes = {}
+
+ # Compare quantity
+ old_qty = cint(so_item.qty)
+ new_qty = cint(shopify_item.get("quantity", 0))
+ if old_qty != new_qty:
+ item_changes["quantity"] = {"old": old_qty, "new": new_qty}
+
+ # Compare rate
+ old_rate = flt(so_item.rate)
+ new_rate = flt(_get_item_price(shopify_item, order.get("taxes_included", False)))
+ if abs(old_rate - new_rate) > 0.01:
+ item_changes["rate"] = {"old": old_rate, "new": new_rate}
+
+ if item_changes:
+ line_item_changes.append({
+ "item_code": so_item.item_code,
+ "shopify_line_item_id": shopify_item_id,
+ "changes": item_changes
+ })
+
+ existing_shopify_ids.add(shopify_item_id)
+
+ # Check for new items
+ for shopify_item in order.get("line_items", []):
+ if str(shopify_item.get("id")) not in existing_shopify_ids:
+ line_item_changes.append({
+ "shopify_line_item_id": str(shopify_item.get("id")),
+ "shopify_product_id": shopify_item.get("product_id"),
+ "shopify_variant_id": shopify_item.get("variant_id"),
+ "title": shopify_item.get("title"),
+ "action": "item_added"
+ })
+
+ # Build change details
+ log_data["change_details"] = {
+ "action": "order_updated",
+ "changes_detected": len(changes) > 0 or len(line_item_changes) > 0,
+ "amount_changes": {
+ "subtotal_changed": "subtotal" in changes,
+ "old_subtotal": old_subtotal,
+ "new_subtotal": new_subtotal,
+ "total_changed": "grand_total" in changes,
+ "old_total": old_total,
+ "new_total": new_total,
+ } if changes else {},
+ "item_changes": {
+ "items_added": len([c for c in line_item_changes if c.get("action") == "item_added"]),
+ "items_removed": 0, # Can be enhanced
+ "items_modified": len([c for c in line_item_changes if "changes" in c]),
+ "line_item_changes": line_item_changes
+ },
+ "status_changes": {
+ "financial_status_changed": "financial_status" in changes,
+ "old_financial_status": old_status,
+ "new_financial_status": new_status,
+ "fulfillment_status_changed": "fulfillment_status" in changes,
+ "old_fulfillment_status": old_fulfillment_status or "unfulfilled",
+ "new_fulfillment_status": new_fulfillment_status or "unfulfilled",
+ } if changes else {}
+ }
+
+ # Extract line items details
+ for item in order.get("line_items", []):
+ log_data["line_items_details"].append({
+ "shopify_line_item_id": item.get("id"),
+ "title": item.get("title"),
+ "quantity": cint(item.get("quantity", 0)),
+ "price": flt(item.get("price", 0)),
+ "sku": item.get("sku", ""),
+ "product_id": item.get("product_id"),
+ "variant_id": item.get("variant_id"),
+ })
+
+ log_data["status"] = "success"
+
+ # Check if order is cancelled
+ if order.get("cancelled_at"):
+ log_data["change_details"]["action"] = "order_cancelled"
+ log_data["change_details"]["cancelled_at"] = order.get("cancelled_at")
+ log_data["change_details"]["cancel_reason"] = order.get("cancel_reason")
+
+ create_shopify_log(
+ status="Info",
+ message=f"Order {order_number} was cancelled in Shopify",
+ request_data=order,
+ response_data=log_data
+ )
+ else:
+ create_shopify_log(
+ status="Success",
+ message=f"Order {order_number} updated successfully",
+ request_data=order,
+ response_data=log_data
+ )
+
+ except Exception as e:
+ log_data["status"] = "error"
+ log_data["error"] = str(e)
+ log_data["traceback"] = traceback.format_exc()
+
+ log_store2("UPDATE-ERROR", f"Error: {str(e)}\n{traceback.format_exc()}", store_name)
+
+ create_shopify_log(
+ status="Error",
+ message=f"Error processing order update: {str(e)}",
+ request_data=order,
+ response_data=log_data,
+ exception=e
+ )
+```
+
+**Key Features:**
+1. **Primary Keys Extraction** — Captures all Shopify IDs (order, items, fulfillments, discounts)
+2. **Order References** — Links Shopify orders to ERPNext documents (Sales Order, Customer, Invoice, Delivery Notes)
+3. **Amount Details** — Complete financial breakdown (totals, line items, taxes, shipping)
+4. **Change Detection** — Compares Shopify order with ERPNext Sales Order to detect changes
+5. **Line Items Details** — Complete item information for all line items
+6. **Multi-Store Support** — Handles both Store 1 and Store 2
+7. **Error Handling** — Comprehensive error logging with traceback
+8. **New Order Creation** — Creates new order if it doesn't exist in ERPNext
+
+**Why:** Provides complete visibility into order updates, enabling OPS team to track changes, communicate updates, and maintain audit trail.
+
+---
+
+## Function Reference
+
+### New Functions
+
+#### `update_sales_order(payload, request_id=None, store_name=None)`
+- **File:** `order.py`
+- **Line:** 657-1236
+- **Parameters:**
+ - `payload` (dict): Shopify order data from webhook
+ - `request_id` (str, optional): Unique request identifier
+ - `store_name` (str, optional): Store name ("Store 2" or None for Store 1)
+- **Returns:** None (creates log entry)
+- **Description:** Handles order updates from Shopify with comprehensive logging
+- **Dependencies:**
+ - `create_shopify_log()` from `utils.py`
+ - `sync_sales_order()` from `order.py`
+ - `log_store2()` for Store 2 debugging
+- **Side Effects:**
+ - Creates log entry in Ecommerce Integration Log
+ - May create new Sales Order if order doesn't exist
+
+---
+
+## Data Flow
+
+### Order Update Webhook Flow
+
+```
+1. Order Updated in Shopify
+ ↓
+2. Shopify Sends Webhook to ERPNext
+ ↓
+3. connection.py: store_request_data()
+ - Receives webhook
+ - Determines store (Store 1 or Store 2) from X-Shopify-Shop-Domain header
+ - Creates initial log (status: "Queued")
+ ↓
+4. connection.py: process_request()
+ - Enqueues job with store_name parameter
+ ↓
+5. order.py: update_sales_order()
+ - Extracts primary keys (order IDs, item IDs, fulfillment IDs)
+ - Extracts order references (Shopify ↔ ERPNext document links)
+ - Extracts amount details (totals, line items, taxes, shipping)
+ - Detects changes (compares Shopify with ERPNext)
+ - Extracts line items details
+ ↓
+6. Creates Final Log Entry
+ - Status: "Success", "Info", or "Error"
+ - Stores all data in response_data field
+ ↓
+7. Log Available in ERPNext
+ - View in Ecommerce Integration Log
+ - Use for notifications
+ - Use for OPS team communication
+```
+
+### Change Detection Flow
+
+```
+1. Get Shopify Order Data
+ ↓
+2. Find ERPNext Sales Order by shopify_order_id
+ ↓
+3. If Order Not Found:
+ - Set action: "create_new_order"
+ - Call sync_sales_order() to create new order
+ - Return
+ ↓
+4. If Order Found:
+ - Compare amounts (subtotal, total, tax, discounts)
+ - Compare line items (quantity, rate, added, removed)
+ - Compare status (financial, fulfillment)
+ ↓
+5. Build Change Details
+ - Store before/after values
+ - Calculate differences
+ - Identify what changed
+ ↓
+6. Create Log Entry
+ - All data stored in response_data
+ - Status based on outcome
+```
+
+### Multi-Store Flow
+
+```
+Store 1 Webhook:
+ ↓
+connection.py detects Store 1
+ ↓
+process_request() with store_name=None
+ ↓
+update_sales_order() with store_name=None
+ ↓
+Logs with store_name="Store 1"
+
+Store 2 Webhook:
+ ↓
+connection.py detects Store 2
+ ↓
+process_request() with store_name="Store 2"
+ ↓
+update_sales_order() with store_name="Store 2"
+ ↓
+Sets frappe.local.shopify_store_name
+ ↓
+Uses log_store2() for debugging
+ ↓
+Logs with store_name="Store 2"
+```
+
+---
+
+## Testing Scenarios
+
+### Test Case 1: Order Amount Updated
+**Input:**
+- Existing order in ERPNext
+- Order total changed from $100.00 to $120.00 in Shopify
+
+**Expected Output:**
+- Log entry created with status "Success"
+- `change_details.amount_changes.total_changed = true`
+- `change_details.amount_changes.old_total = 100.00`
+- `change_details.amount_changes.new_total = 120.00`
+- All order references populated
+- All primary keys captured
+
+**Test Command:**
+```python
+# Update order in Shopify, then check logs
+logs = frappe.get_all(
+ "Ecommerce Integration Log",
+ filters={"method": "ecommerce_integrations.shopify.order.update_sales_order"},
+ order_by="creation desc",
+ limit=1
+)
+```
+
+### Test Case 2: Order Item Added
+**Input:**
+- Existing order with 2 items
+- New item added in Shopify (now 3 items)
+
+**Expected Output:**
+- Log entry created
+- `change_details.item_changes.items_added = 1`
+- `change_details.item_changes.line_item_changes` contains new item
+- Line items details includes all 3 items
+
+### Test Case 3: Order Item Quantity Changed
+**Input:**
+- Existing order with item quantity = 2
+- Quantity changed to 3 in Shopify
+
+**Expected Output:**
+- Log entry created
+- `change_details.item_changes.items_modified = 1`
+- `change_details.item_changes.line_item_changes[0].changes.quantity.old = 2`
+- `change_details.item_changes.line_item_changes[0].changes.quantity.new = 3`
+
+### Test Case 4: Order Status Changed
+**Input:**
+- Existing order with financial_status = "pending"
+- Status changed to "paid" in Shopify
+
+**Expected Output:**
+- Log entry created
+- `change_details.status_changes.financial_status_changed = true`
+- `change_details.status_changes.old_financial_status = "pending"`
+- `change_details.status_changes.new_financial_status = "paid"`
+
+### Test Case 5: Order Not Found (New Order)
+**Input:**
+- Order updated in Shopify
+- Order doesn't exist in ERPNext
+
+**Expected Output:**
+- Log entry created with status "Info"
+- `change_details.action = "create_new_order"`
+- `change_details.reason = "Order not found in ERPNext"`
+- New Sales Order created via `sync_sales_order()`
+
+### Test Case 6: Order Cancelled
+**Input:**
+- Existing order
+- Order cancelled in Shopify
+
+**Expected Output:**
+- Log entry created with status "Info"
+- `change_details.action = "order_cancelled"`
+- `change_details.cancelled_at` populated
+- `change_details.cancel_reason` populated
+
+### Test Case 7: Multi-Store Update
+**Input:**
+- Order updated in Store 1
+- Order updated in Store 2
+
+**Expected Output:**
+- Two separate log entries created
+- Store 1 log has `store_name = "Store 1"`
+- Store 2 log has `store_name = "Store 2"`
+- Both logs contain complete data
+
+### Test Case 8: Error Handling
+**Input:**
+- Invalid order data or processing error
+
+**Expected Output:**
+- Log entry created with status "Error"
+- `response_data.status = "error"`
+- `response_data.error` contains error message
+- `response_data.traceback` contains full traceback
+
+---
+
+## Dependencies
+
+### Code Dependencies
+- `frappe` — Core Frappe framework
+- `ecommerce_integrations.shopify.constants` — Module constants (ORDER_ID_FIELD, etc.)
+- `ecommerce_integrations.shopify.utils` — Utility functions (create_shopify_log, log_store2)
+- `ecommerce_integrations.shopify.order` — Order sync functions (sync_sales_order)
+
+### System Dependencies
+- **Ecommerce Integration Log** doctype must exist
+- **Sales Order** doctype with `shopify_order_id` custom field
+- **Customer** doctype with `shopify_customer_id` custom field
+- Webhook endpoint must be accessible from Shopify
+
+### No Breaking Changes
+- All changes are backward compatible
+- Existing functionality preserved
+- Only adds new webhook handler
+- Doesn't modify existing order sync logic
+
+---
+
+## Deployment Checklist
+
+- [ ] Verify `orders/updated` is in `WEBHOOK_EVENTS` in `constants.py`
+- [ ] Verify event mapping exists in `EVENT_MAPPER` in `constants.py`
+- [ ] Verify `update_sales_order()` function exists in `order.py`
+- [ ] Test webhook registration for Store 1
+- [ ] Test webhook registration for Store 2 (if enabled)
+- [ ] Test order update webhook with sample order
+- [ ] Verify log entry is created in Ecommerce Integration Log
+- [ ] Verify all data is captured (references, keys, amounts, changes)
+- [ ] Test change detection (amount, item, status changes)
+- [ ] Test new order creation scenario
+- [ ] Test error handling
+- [ ] Verify multi-store support works correctly
+- [ ] Monitor logs for any errors after deployment
+
+---
+
+## Rollback Procedure
+
+If issues occur, restore from backup files:
+
+```bash
+cd apps/ecommerce_integrations/ecommerce_integrations/shopify
+cp constants.py.backup.YYYYMMDD_HHMM constants.py
+cp order.py.backup.YYYYMMDD_HHMM order.py
+bench restart
+```
+
+**Note:** After rollback, webhooks will need to be re-registered to remove `orders/updated` event.
+
+---
+
+## Support & Troubleshooting
+
+### Common Issues
+
+#### 1. "Webhook not receiving updates"
+**Symptoms:**
+- No log entries created
+- Webhook not triggered
+
+**Solutions:**
+1. Check webhook registration:
+ - Go to: `Shopify Setting`
+ - Click "Register Webhooks"
+ - Verify `orders/updated` is registered
+2. Check Shopify webhook settings:
+ - Go to Shopify Admin → Settings → Notifications
+ - Verify webhook URL is correct
+ - Verify webhook is active
+3. Check logs:
+ - Check `connection.py` logs for webhook receipt
+ - Check for errors in `Ecommerce Integration Log`
+
+#### 2. "Logs not showing all data"
+**Symptoms:**
+- Log entry created but `response_data` is empty or incomplete
+
+**Solutions:**
+1. Check function execution:
+ - Verify `update_sales_order()` is being called
+ - Check for errors in log entry
+2. Check data extraction:
+ - Verify order payload has expected fields
+ - Check Store 2 logs if applicable
+3. Check log creation:
+ - Verify `create_shopify_log()` is called
+ - Check `response_data` parameter is passed
+
+#### 3. "Change detection not working"
+**Symptoms:**
+- Changes made but not detected
+- `change_details` shows no changes
+
+**Solutions:**
+1. Check Sales Order exists:
+ - Verify Sales Order is found by Shopify Order ID
+ - Check custom field `shopify_order_id` is set
+2. Check comparison logic:
+ - Verify amounts are being compared correctly
+ - Check for data type mismatches
+3. Check log data:
+ - Review `response_data` in log entry
+ - Verify all fields are populated
+
+#### 4. "Store 2 not working"
+**Symptoms:**
+- Store 1 works but Store 2 doesn't
+
+**Solutions:**
+1. Check Store 2 configuration:
+ - Verify `enable_store_2` is checked
+ - Verify Store 2 credentials are set
+2. Check webhook registration:
+ - Verify webhooks are registered for Store 2
+ - Check webhook URL includes store identifier
+3. Check logs:
+ - Check Store 2 specific logs
+ - Verify `store_name` parameter is passed correctly
+
+### Logging
+
+All order updates are logged in `Ecommerce Integration Log`:
+- **Method:** `ecommerce_integrations.shopify.order.update_sales_order`
+- **Status:** "Success", "Info", or "Error"
+- **Request Data:** Full Shopify order payload
+- **Response Data:** Complete log data structure
+
+Check logs with:
+```bash
+bench --site [site] console
+```
+
+```python
+logs = frappe.get_all(
+ "Ecommerce Integration Log",
+ filters={"method": "ecommerce_integrations.shopify.order.update_sales_order"},
+ fields=["name", "creation", "status", "response_data"]
+)
+```
+
+---
+
+## Version History
+
+- **v1.0** (January 2025): Initial implementation
+ - Added `orders/updated` webhook event
+ - Created `update_sales_order()` function
+ - Implemented comprehensive logging
+ - Added change detection
+ - Multi-store support
+
+---
+
+## Contact
+
+For questions or issues, refer to:
+- **Author:** Priyanshi
+- **Branch:** `shopify-null-product-id-fallback-mapping`
+- **Files Modified:** `constants.py`, `order.py`
+
+---
+
+**End of Documentation**
diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py
index 4a4c7c86d..271cacc02 100644
--- a/ecommerce_integrations/shopify/connection.py
+++ b/ecommerce_integrations/shopify/connection.py
@@ -1,8 +1,15 @@
+"""
+connection.py - WITH COMPREHENSIVE STORE 2 LOGGING
+Every step is logged so we can trace exactly where it breaks.
+Logging only triggers for Store 2 to avoid cluttering logs.
+"""
+
import base64
import functools
import hashlib
import hmac
import json
+import traceback
import frappe
from frappe import _
@@ -10,120 +17,646 @@
from shopify.session import Session
from ecommerce_integrations.shopify.constants import (
- API_VERSION,
- EVENT_MAPPER,
- SETTING_DOCTYPE,
- WEBHOOK_EVENTS,
+ API_VERSION,
+ EVENT_MAPPER,
+ SETTING_DOCTYPE,
+ WEBHOOK_EVENTS,
)
from ecommerce_integrations.shopify.utils import create_shopify_log
-def temp_shopify_session(func):
- """Any function that needs to access shopify api needs this decorator. The decorator starts a temp session that's destroyed when function returns."""
-
- @functools.wraps(func)
- def wrapper(*args, **kwargs):
- # no auth in testing
- if frappe.flags.in_test:
- return func(*args, **kwargs)
+def log_store2(step, message, store_name=None):
+ """Helper function to log only for Store 2."""
+ if store_name and store_name != "Store 1":
+ frappe.log_error(
+ title=f"[STORE2 DEBUG] Step {step}",
+ message=f"Store: {store_name}\n\n{message}"
+ )
- setting = frappe.get_doc(SETTING_DOCTYPE)
- if setting.is_enabled():
- auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password"))
- with Session.temp(*auth_details):
- return func(*args, **kwargs)
-
- return wrapper
+def temp_shopify_session(func):
+ """Any function that needs to access shopify api needs this decorator.
+
+ Store-aware: Checks for store_name in kwargs or frappe.local.shopify_store_name
+ to determine which store's credentials to use.
+ """
+
+ @functools.wraps(func)
+ def wrapper(*args, **kwargs):
+ # no auth in testing
+ if frappe.flags.in_test:
+ return func(*args, **kwargs)
+
+ setting = frappe.get_doc(SETTING_DOCTYPE)
+ if setting.is_enabled():
+ # Determine which store's credentials to use
+ store_name = kwargs.get('store_name') or getattr(frappe.local, 'shopify_store_name', None)
+
+ # STORE 2 LOGGING
+ log_store2("DECORATOR-1", f"""
+temp_shopify_session called for function: {func.__name__}
+store_name from kwargs: {kwargs.get('store_name')}
+store_name from frappe.local: {getattr(frappe.local, 'shopify_store_name', None)}
+Final store_name: {store_name}
+enable_store_2: {setting.enable_store_2}
+""", store_name)
+
+ # Use Store 2 credentials if specified and enabled
+ if store_name and store_name != "Store 1" and setting.enable_store_2:
+ password_2 = setting.get_password("password_2")
+
+ log_store2("DECORATOR-2", f"""
+Using Store 2 credentials
+shopify_url_2: {setting.shopify_url_2}
+password_2 exists: {bool(password_2)}
+password_2 length: {len(password_2) if password_2 else 0}
+""", store_name)
+
+ if not password_2:
+ log_store2("DECORATOR-ERROR", "password_2 is None/empty!", store_name)
+ frappe.throw(_("Store 2 API password not configured"))
+
+ auth_details = (setting.shopify_url_2, API_VERSION, password_2)
+ else:
+ # Default to Store 1
+ auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password"))
+
+ try:
+ log_store2("DECORATOR-3", f"About to create Shopify session with URL: {auth_details[0]}", store_name)
+ with Session.temp(*auth_details):
+ log_store2("DECORATOR-4", f"Session created, calling {func.__name__}", store_name)
+ result = func(*args, **kwargs)
+ log_store2("DECORATOR-5", f"Function {func.__name__} completed successfully", store_name)
+ return result
+ except Exception as e:
+ log_store2("DECORATOR-ERROR", f"""
+Exception in temp_shopify_session!
+Function: {func.__name__}
+Error: {str(e)}
+Type: {type(e).__name__}
+Traceback:
+{traceback.format_exc()}
+""", store_name)
+ raise
+
+ return wrapper
def register_webhooks(shopify_url: str, password: str) -> list[Webhook]:
- """Register required webhooks with shopify and return registered webhooks."""
- new_webhooks = []
+ """Register required webhooks with shopify and return registered webhooks."""
+ new_webhooks = []
- # clear all stale webhooks matching current site url before registering new ones
- unregister_webhooks(shopify_url, password)
+ # clear all stale webhooks matching current site url before registering new ones
+ unregister_webhooks(shopify_url, password)
- with Session.temp(shopify_url, API_VERSION, password):
- for topic in WEBHOOK_EVENTS:
- webhook = Webhook.create({"topic": topic, "address": get_callback_url(), "format": "json"})
+ with Session.temp(shopify_url, API_VERSION, password):
+ for topic in WEBHOOK_EVENTS:
+ webhook = Webhook.create({"topic": topic, "address": get_callback_url(), "format": "json"})
- if webhook.is_valid():
- new_webhooks.append(webhook)
- else:
- create_shopify_log(
- status="Error",
- response_data=webhook.to_dict(),
- exception=webhook.errors.full_messages(),
- )
+ if webhook.is_valid():
+ new_webhooks.append(webhook)
+ else:
+ create_shopify_log(
+ status="Error",
+ response_data=webhook.to_dict(),
+ exception=webhook.errors.full_messages(),
+ )
- return new_webhooks
+ return new_webhooks
def unregister_webhooks(shopify_url: str, password: str) -> None:
- """Unregister all webhooks from shopify that correspond to current site url."""
- url = get_current_domain_name()
+ """Unregister all webhooks from shopify that correspond to current site url."""
+ url = get_current_domain_name()
- with Session.temp(shopify_url, API_VERSION, password):
- for webhook in Webhook.find():
- if url in webhook.address:
- webhook.destroy()
+ with Session.temp(shopify_url, API_VERSION, password):
+ for webhook in Webhook.find():
+ if url in webhook.address:
+ webhook.destroy()
def get_current_domain_name() -> str:
- """Get current site domain name. E.g. test.erpnext.com
-
- If developer_mode is enabled and localtunnel_url is set in site config then domain is set to localtunnel_url.
- """
- if frappe.conf.developer_mode and frappe.conf.localtunnel_url:
- return frappe.conf.localtunnel_url
- else:
- return frappe.request.host
+ """Get current site domain name."""
+ if frappe.conf.developer_mode and frappe.conf.localtunnel_url:
+ return frappe.conf.localtunnel_url
+ else:
+ return frappe.request.host
def get_callback_url() -> str:
- """Shopify calls this url when new events occur to subscribed webhooks.
-
- If developer_mode is enabled and localtunnel_url is set in site config then callback url is set to localtunnel_url.
- """
- url = get_current_domain_name()
-
- return f"https://{url}/api/method/ecommerce_integrations.shopify.connection.store_request_data"
+ """Shopify calls this url when new events occur to subscribed webhooks."""
+ url = get_current_domain_name()
+ return f"https://{url}/api/method/ecommerce_integrations.shopify.connection.store_request_data"
@frappe.whitelist(allow_guest=True)
def store_request_data() -> None:
- if frappe.request:
- hmac_header = frappe.get_request_header("X-Shopify-Hmac-Sha256")
-
- _validate_request(frappe.request, hmac_header)
-
- data = json.loads(frappe.request.data)
- event = frappe.request.headers.get("X-Shopify-Topic")
-
- process_request(data, event)
-
-
-def process_request(data, event):
- # create log
- log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data)
-
- # enqueue backround job
- frappe.enqueue(
- method=EVENT_MAPPER[event],
- queue="short",
- timeout=300,
- is_async=True,
- **{"payload": data, "request_id": log.name},
- )
-
-
-def _validate_request(req, hmac_header):
- settings = frappe.get_doc(SETTING_DOCTYPE)
- secret_key = settings.shared_secret
-
- sig = base64.b64encode(hmac.new(secret_key.encode("utf8"), req.data, hashlib.sha256).digest())
-
- if sig != bytes(hmac_header.encode()):
- create_shopify_log(status="Error", request_data=req.data)
- frappe.throw(_("Unverified Webhook Data"))
+ """
+ Central webhook endpoint for all Shopify stores.
+ Routes webhooks to the correct handler based on shop domain.
+ """
+
+ # =========================================================================
+ # STEP 1: Initial request validation
+ # =========================================================================
+ if not frappe.request:
+ frappe.log_error(
+ title="[STORE2 DEBUG] Step 1 - FAILED",
+ message="No frappe.request object!"
+ )
+ frappe.throw(_("Invalid request"))
+ return
+
+ # Get headers early for logging
+ shop_domain = frappe.get_request_header("X-Shopify-Shop-Domain")
+ event = frappe.get_request_header("X-Shopify-Topic")
+ hmac_header = frappe.get_request_header("X-Shopify-Hmac-Sha256")
+
+ # Check if this MIGHT be Store 2 (for early logging)
+ setting = frappe.get_doc(SETTING_DOCTYPE)
+ is_likely_store2 = (
+ setting.enable_store_2 and
+ setting.shopify_url_2 and
+ shop_domain and
+ shop_domain in setting.shopify_url_2
+ )
+
+ if is_likely_store2:
+ frappe.log_error(
+ title="[STORE2 DEBUG] Step 1 - Request Received",
+ message=f"""
+======================================
+STORE 2 WEBHOOK RECEIVED
+======================================
+shop_domain: {shop_domain}
+event: {event}
+hmac_header exists: {bool(hmac_header)}
+hmac_header length: {len(hmac_header) if hmac_header else 0}
+hmac_header (first 20): {hmac_header[:20] if hmac_header else 'NONE'}...
+request.data length: {len(frappe.request.data) if frappe.request.data else 0}
+request.data (first 200): {frappe.request.data[:200] if frappe.request.data else 'NONE'}
+======================================
+"""
+ )
+
+ # =========================================================================
+ # STEP 2: Validate shop_domain header
+ # =========================================================================
+ if not shop_domain:
+ if is_likely_store2:
+ frappe.log_error(
+ title="[STORE2 DEBUG] Step 2 - FAILED",
+ message="shop_domain header is missing!"
+ )
+ frappe.throw(_("Missing shop domain header"))
+ return
+
+ if is_likely_store2:
+ frappe.log_error(
+ title="[STORE2 DEBUG] Step 2 - shop_domain OK",
+ message=f"shop_domain: {shop_domain}"
+ )
+
+ # =========================================================================
+ # STEP 3: Determine which store
+ # =========================================================================
+ store_name = None
+ shared_secret = None
+
+ # Check Store 1
+ if setting.shopify_url and shop_domain in setting.shopify_url:
+ store_name = "Store 1"
+ shared_secret = setting.shared_secret
+
+ # Check Store 2
+ elif setting.enable_store_2 and setting.shopify_url_2 and shop_domain in setting.shopify_url_2:
+ store_name = setting.store_2_name or "Store 2"
+ shared_secret = setting.shared_secret_2
+
+ frappe.log_error(
+ title="[STORE2 DEBUG] Step 3 - Store Matched",
+ message=f"""
+Store identified as: {store_name}
+shop_domain: {shop_domain}
+shopify_url_2: {setting.shopify_url_2}
+shared_secret_2 exists: {bool(shared_secret)}
+shared_secret_2 type: {type(shared_secret)}
+shared_secret_2 length: {len(shared_secret) if shared_secret else 0}
+shared_secret_2 (first 10): {shared_secret[:10] if shared_secret else 'NONE'}...
+"""
+ )
+
+ # Unknown store
+ else:
+ frappe.log_error(
+ title="[STORE2 DEBUG] Step 3 - NO MATCH",
+ message=f"""
+shop_domain: {shop_domain}
+Store 1 URL: {setting.shopify_url}
+Store 2 URL: {setting.shopify_url_2}
+Store 2 Enabled: {setting.enable_store_2}
+"""
+ )
+ frappe.throw(_(f"Unknown Shopify store: {shop_domain}"))
+ return
+
+ # =========================================================================
+ # STEP 4: Validate shared_secret exists
+ # =========================================================================
+ if store_name != "Store 1":
+ frappe.log_error(
+ title="[STORE2 DEBUG] Step 4 - Validating shared_secret",
+ message=f"""
+shared_secret is None: {shared_secret is None}
+shared_secret is empty string: {shared_secret == ''}
+shared_secret is falsy: {not shared_secret}
+bool(shared_secret): {bool(shared_secret)}
+repr(shared_secret): {repr(shared_secret)[:50] if shared_secret else 'None'}
+"""
+ )
+
+ if not shared_secret:
+ log_store2("4-FAILED", "shared_secret is None or empty!", store_name)
+ frappe.throw(_(f"Shared secret not configured for {store_name}"))
+ return
+
+ log_store2("4-OK", "shared_secret exists and is not empty", store_name)
+
+ # =========================================================================
+ # STEP 5: Validate HMAC header exists
+ # =========================================================================
+ log_store2("5", f"""
+Checking HMAC header...
+hmac_header exists: {bool(hmac_header)}
+hmac_header is None: {hmac_header is None}
+hmac_header type: {type(hmac_header)}
+""", store_name)
+
+ if not hmac_header:
+ log_store2("5-FAILED", "hmac_header is None or empty!", store_name)
+ frappe.throw(_("Missing HMAC signature header"))
+ return
+
+ log_store2("5-OK", f"HMAC header present: {hmac_header[:20]}...", store_name)
+
+ # =========================================================================
+ # STEP 6: HMAC Validation
+ # =========================================================================
+ log_store2("6", f"""
+About to validate HMAC...
+shared_secret type: {type(shared_secret)}
+shared_secret length: {len(shared_secret)}
+hmac_header type: {type(hmac_header)}
+hmac_header length: {len(hmac_header)}
+request.data type: {type(frappe.request.data)}
+request.data length: {len(frappe.request.data)}
+""", store_name)
+
+ try:
+ log_store2("6a", "Calling shared_secret.encode('utf8')...", store_name)
+ secret_bytes = shared_secret.encode("utf8")
+ log_store2("6b", f"Success! secret_bytes length: {len(secret_bytes)}", store_name)
+
+ log_store2("6c", "Computing HMAC...", store_name)
+ computed_hmac = hmac.new(secret_bytes, frappe.request.data, hashlib.sha256)
+ log_store2("6d", "HMAC computed, getting digest...", store_name)
+
+ digest = computed_hmac.digest()
+ log_store2("6e", f"Digest obtained, length: {len(digest)}", store_name)
+
+ sig = base64.b64encode(digest)
+ log_store2("6f", f"Base64 encoded sig: {sig[:30]}...", store_name)
+
+ log_store2("6g", "Encoding hmac_header to bytes...", store_name)
+ expected_sig = bytes(hmac_header.encode())
+ log_store2("6h", f"Expected sig: {expected_sig[:30]}...", store_name)
+
+ log_store2("6i", f"""
+Comparing signatures...
+Computed: {sig}
+Expected: {expected_sig}
+Match: {sig == expected_sig}
+""", store_name)
+
+ if sig != expected_sig:
+ log_store2("6-FAILED", f"""
+HMAC MISMATCH!
+Computed: {sig}
+Expected: {expected_sig}
+
+This means the shared_secret in ERPNext doesn't match Shopify's signing key.
+Check: Shopify Admin → Settings → Notifications → Webhooks → "Your webhooks will be signed with: XXX"
+""", store_name)
+ create_shopify_log(status="Error", request_data=frappe.request.data)
+ frappe.throw(_("Unverified Webhook Data"))
+ return
+
+ log_store2("6-OK", "HMAC validation PASSED!", store_name)
+
+ except AttributeError as e:
+ log_store2("6-EXCEPTION", f"""
+AttributeError during HMAC validation!
+Error: {str(e)}
+This usually means shared_secret or hmac_header is None.
+
+shared_secret is None: {shared_secret is None}
+hmac_header is None: {hmac_header is None}
+
+Traceback:
+{traceback.format_exc()}
+""", store_name)
+ raise
+
+ except Exception as e:
+ log_store2("6-EXCEPTION", f"""
+Exception during HMAC validation!
+Error type: {type(e).__name__}
+Error: {str(e)}
+
+Traceback:
+{traceback.format_exc()}
+""", store_name)
+ raise
+
+ # =========================================================================
+ # STEP 7: Parse JSON data
+ # =========================================================================
+ log_store2("7", "Parsing JSON data...", store_name)
+
+ try:
+ data = json.loads(frappe.request.data)
+ log_store2("7-OK", f"""
+JSON parsed successfully!
+Order ID: {data.get('id', 'N/A')}
+Order Number: {data.get('name', 'N/A')}
+Customer: {data.get('customer', {}).get('email', 'N/A') if data.get('customer') else 'N/A'}
+Total Price: {data.get('total_price', 'N/A')}
+Line Items Count: {len(data.get('line_items', []))}
+""", store_name)
+ except json.JSONDecodeError as e:
+ log_store2("7-FAILED", f"""
+JSON parse error!
+Error: {str(e)}
+Raw data (first 500): {frappe.request.data[:500]}
+""", store_name)
+ frappe.throw(_("Invalid JSON in webhook payload"))
+ return
+
+ # =========================================================================
+ # STEP 8: Validate event type
+ # =========================================================================
+ log_store2("8", f"""
+Validating event type...
+Event: {event}
+Event in EVENT_MAPPER: {event in EVENT_MAPPER}
+Available events: {list(EVENT_MAPPER.keys())}
+""", store_name)
+
+ if not event:
+ log_store2("8-FAILED", "Event header is missing!", store_name)
+ frappe.throw(_("Missing webhook event type"))
+ return
+
+ if event not in EVENT_MAPPER:
+ log_store2("8-FAILED", f"Event '{event}' not in EVENT_MAPPER!", store_name)
+ frappe.throw(_(f"Unsupported webhook event: {event}"))
+ return
+
+ log_store2("8-OK", f"Event '{event}' is valid, maps to: {EVENT_MAPPER[event]}", store_name)
+
+ # =========================================================================
+ # STEP 9: Set store context
+ # =========================================================================
+ log_store2("9", f"Setting frappe.local.shopify_store_name = '{store_name}'", store_name)
+ frappe.local.shopify_store_name = store_name
+ log_store2("9-OK", f"Store context set. Verified: {frappe.local.shopify_store_name}", store_name)
+
+ # =========================================================================
+ # STEP 10: Call process_request
+ # =========================================================================
+ log_store2("10", f"""
+About to call process_request()
+Event: {event}
+Store: {store_name}
+Order ID: {data.get('id')}
+""", store_name)
+
+ try:
+ process_request(data, event, store_name)
+ log_store2("10-OK", "process_request() completed without exception", store_name)
+ except Exception as e:
+ log_store2("10-EXCEPTION", f"""
+Exception in process_request!
+Error: {str(e)}
+Type: {type(e).__name__}
+
+Traceback:
+{traceback.format_exc()}
+""", store_name)
+ raise
+
+
+def process_request(data, event, store_name=None):
+ """Process webhook request with store context."""
+
+ # =========================================================================
+ # STEP 11: Inside process_request
+ # =========================================================================
+ log_store2("11", f"""
+Inside process_request()
+Event: {event}
+Store: {store_name}
+Order ID: {data.get('id')}
+Method to call: {EVENT_MAPPER[event]}
+""", store_name)
+
+ # =========================================================================
+ # STEP 11.5: Early filter for noisy `orders/updated` webhooks
+ # =========================================================================
+ # NOTE: Per business requirements, this fingerprint ONLY tracks:
+ # - Shipping address
+ # - Billing address
+ # - Order notes
+ #
+ # Line items are handled via the dedicated `orders/edited` webhook, so they
+ # are *not* part of this fingerprint. This means:
+ # - Old/timeline/metadata-only updates on untouched orders are dropped here.
+ # - Only REAL address / note changes for existing Sales Orders will proceed.
+ if event == "orders/updated":
+ try:
+ from ecommerce_integrations.shopify.constants import ORDER_ID_FIELD
+
+ order_id = str(data.get("id") or "")
+ so_name = None
+ if order_id:
+ so_name = frappe.db.get_value("Sales Order", {ORDER_ID_FIELD: order_id}, "name")
+
+ # If we have a matching Sales Order, compare fingerprints
+ if so_name:
+ new_fingerprint = _build_order_fingerprint(data)
+
+ try:
+ old_fingerprint = frappe.db.get_value(
+ "Sales Order", so_name, "custom_shopify_fingerprint"
+ ) or ""
+ except Exception:
+ # If the field doesn't exist yet or any error occurs, skip fingerprint filter
+ log_store2(
+ "11.5-SKIP",
+ f"Fingerprint field missing or error while reading for SO {so_name}. "
+ f"Proceeding without early-exit filter.",
+ store_name,
+ )
+ old_fingerprint = ""
+
+ # If fingerprint unchanged, drop this webhook before logging / enqueue
+ if old_fingerprint and new_fingerprint == old_fingerprint:
+ log_store2(
+ "11.5-SKIP",
+ f"orders/updated fingerprint UNCHANGED for order_id={order_id}, so_name={so_name}. "
+ f"Skipping log + enqueue to avoid noise.",
+ store_name,
+ )
+ return
+
+ # Fingerprint changed or first time: update it so future metadata-only
+ # webhooks on the same order can be skipped.
+ try:
+ frappe.db.set_value(
+ "Sales Order",
+ so_name,
+ "custom_shopify_fingerprint",
+ new_fingerprint,
+ update_modified=False,
+ )
+ frappe.db.commit()
+ log_store2(
+ "11.5-SET",
+ f"Updated fingerprint for SO {so_name}. "
+ f"Old: {old_fingerprint or 'EMPTY'} | New: {new_fingerprint}",
+ store_name,
+ )
+ except Exception as e:
+ log_store2(
+ "11.5-SET-ERROR",
+ f"Failed to update fingerprint for SO {so_name}. Error: {str(e)}",
+ store_name,
+ )
+ except Exception as e:
+ # Fail open: if anything goes wrong in fingerprint logic, we still
+ # want the webhook to be processed normally.
+ log_store2(
+ "11.5-EXCEPTION",
+ f"Exception in orders/updated fingerprint filter: {str(e)}\n"
+ f"Traceback:\n{traceback.format_exc()}",
+ store_name,
+ )
+
+ # =========================================================================
+ # STEP 12: Create Shopify log
+ # =========================================================================
+ log_store2("12", "Creating Shopify log entry...", store_name)
+
+ try:
+ log = create_shopify_log(method=EVENT_MAPPER[event], request_data=data)
+ log_store2("12-OK", f"Log created: {log.name}", store_name)
+ except Exception as e:
+ log_store2("12-EXCEPTION", f"""
+Failed to create Shopify log!
+Error: {str(e)}
+
+Traceback:
+{traceback.format_exc()}
+""", store_name)
+ raise
+
+ # =========================================================================
+ # STEP 13: Enqueue background job
+ # =========================================================================
+ log_store2("13", f"""
+About to enqueue background job...
+Method: {EVENT_MAPPER[event]}
+Queue: short
+Timeout: 300
+Kwargs: payload (order data), request_id={log.name}, store_name={store_name}
+""", store_name)
+
+ try:
+ frappe.enqueue(
+ method=EVENT_MAPPER[event],
+ queue="short",
+ timeout=300,
+ is_async=True,
+ **{"payload": data, "request_id": log.name, "store_name": store_name},
+ )
+ log_store2("13-OK", f"""
+Job enqueued successfully!
+Method: {EVENT_MAPPER[event]}
+Log ID: {log.name}
+Store: {store_name}
+
+The webhook handler has completed.
+The background worker should now pick up the job.
+Check RQ Job doctype for the job status.
+""", store_name)
+ except Exception as e:
+ log_store2("13-EXCEPTION", f"""
+Failed to enqueue job!
+Error: {str(e)}
+
+Traceback:
+{traceback.format_exc()}
+""", store_name)
+ raise
+
+
+def _build_order_fingerprint(data):
+ """Build a fingerprint of ONLY the fields we care about for `orders/updated`.
+
+ Per the current requirement, this fingerprint includes:
+ - Order note
+ - Billing address (core fields)
+ - Shipping address (core fields)
+
+ Line items and other metadata are intentionally NOT included here. Line-item
+ changes are handled via the dedicated `orders/edited` webhook instead.
+ """
+ fingerprint_data = {
+ "note": data.get("note") or "",
+ "billing_address": _address_hash(data.get("billing_address")),
+ "shipping_address": _address_hash(data.get("shipping_address")),
+ }
+
+ raw = json.dumps(fingerprint_data, sort_keys=True)
+ return hashlib.md5(raw.encode()).hexdigest()
+
+
+def _address_hash(address):
+ """Return a stable string representing the address fields we care about."""
+ if not address:
+ return ""
+
+ return "|".join(
+ [
+ str(address.get("address1") or "").strip(),
+ str(address.get("address2") or "").strip(),
+ str(address.get("city") or "").strip(),
+ str(address.get("province") or "").strip(),
+ str(address.get("zip") or "").strip(),
+ str(address.get("country") or "").strip(),
+ str(address.get("phone") or "").strip(),
+ ]
+ )
+
+
+def _validate_request(req, hmac_header, shared_secret):
+ """Validate Shopify webhook using HMAC.
+
+ Note: This function is now bypassed - validation is done inline in store_request_data
+ with detailed logging. Keeping for backward compatibility.
+ """
+ sig = base64.b64encode(hmac.new(shared_secret.encode("utf8"), req.data, hashlib.sha256).digest())
+
+ if sig != bytes(hmac_header.encode()):
+ create_shopify_log(status="Error", request_data=req.data)
+ frappe.throw(_("Unverified Webhook Data"))
\ No newline at end of file
diff --git a/ecommerce_integrations/shopify/constants.py b/ecommerce_integrations/shopify/constants.py
index 47720e032..a3f25a72e 100644
--- a/ecommerce_integrations/shopify/constants.py
+++ b/ecommerce_integrations/shopify/constants.py
@@ -14,6 +14,8 @@
"orders/fulfilled",
"orders/cancelled",
"orders/partially_fulfilled",
+ "orders/edited", # Clean signal for line item edits
+ "orders/updated", # Kept only for address / notes changes (filtered via fingerprint)
]
EVENT_MAPPER = {
@@ -22,6 +24,8 @@
"orders/fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note",
"orders/cancelled": "ecommerce_integrations.shopify.order.cancel_order",
"orders/partially_fulfilled": "ecommerce_integrations.shopify.fulfillment.prepare_delivery_note",
+ "orders/edited": "ecommerce_integrations.shopify.order.handle_order_edited",
+ "orders/updated": "ecommerce_integrations.shopify.order.handle_order_updated",
}
SHOPIFY_VARIANTS_ATTR_LIST = ["option1", "option2", "option3"]
diff --git a/ecommerce_integrations/shopify/customer.py b/ecommerce_integrations/shopify/customer.py
index 3a0ee952f..ffa84e65c 100644
--- a/ecommerce_integrations/shopify/customer.py
+++ b/ecommerce_integrations/shopify/customer.py
@@ -28,8 +28,19 @@ def sync_customer(self, customer: dict[str, Any]) -> None:
customer_group = self.setting.customer_group
super().sync_customer(customer_name, customer_group)
- billing_address = customer.get("billing_address", {}) or customer.get("default_address")
+ # Handle billing address: only use shipping if billing is explicitly null/empty
+ # (Shopify indicates "same as shipping" by having billing_address as null)
+ billing_address = customer.get("billing_address")
shipping_address = customer.get("shipping_address", {})
+
+ # Check if billing is explicitly null/empty (Shopify "same as shipping" case)
+ if billing_address is None or (isinstance(billing_address, dict) and not any(billing_address.values())):
+ # Billing is same as shipping - use shipping address
+ if shipping_address:
+ billing_address = shipping_address
+ # Fallback to customer default address only if no shipping
+ elif not billing_address:
+ billing_address = customer.get("default_address", {})
if billing_address:
self.create_customer_address(
@@ -54,8 +65,19 @@ def create_customer_address(
super().create_customer_address(address_fields)
def update_existing_addresses(self, customer):
- billing_address = customer.get("billing_address", {}) or customer.get("default_address")
+ # Handle billing address: only use shipping if billing is explicitly null/empty
+ # (Shopify indicates "same as shipping" by having billing_address as null)
+ billing_address = customer.get("billing_address")
shipping_address = customer.get("shipping_address", {})
+
+ # Check if billing is explicitly null/empty (Shopify "same as shipping" case)
+ if billing_address is None or (isinstance(billing_address, dict) and not any(billing_address.values())):
+ # Billing is same as shipping - use shipping address
+ if shipping_address:
+ billing_address = shipping_address
+ # Fallback to customer default address only if no shipping
+ elif not billing_address:
+ billing_address = customer.get("default_address", {})
customer_name = cstr(customer.get("first_name")) + " " + cstr(customer.get("last_name"))
email = customer.get("email")
diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json
index 01722169b..a74234f40 100644
--- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json
+++ b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json
@@ -14,6 +14,15 @@
"shared_secret",
"section_break_4",
"webhooks",
+ "store_2_section",
+ "enable_store_2",
+ "store_2_name",
+ "column_break_store_2",
+ "shopify_url_2",
+ "password_2",
+ "shared_secret_2",
+ "webhooks_section_2",
+ "webhooks_2",
"customer_settings_section",
"default_customer",
"column_break_14",
@@ -113,6 +122,67 @@
"options": "Shopify Webhooks",
"read_only": 1
},
+ {
+ "collapsible": 1,
+ "fieldname": "store_2_section",
+ "fieldtype": "Section Break",
+ "label": "Store 2 - Additional Shopify Store"
+ },
+ {
+ "default": "0",
+ "fieldname": "enable_store_2",
+ "fieldtype": "Check",
+ "label": "Enable Store 2"
+ },
+ {
+ "default": "Store 2",
+ "depends_on": "enable_store_2",
+ "description": "User-friendly name for reporting (e.g., 'ZipCovers')",
+ "fieldname": "store_2_name",
+ "fieldtype": "Data",
+ "label": "Store 2 Name"
+ },
+ {
+ "fieldname": "column_break_store_2",
+ "fieldtype": "Column Break"
+ },
+ {
+ "depends_on": "enable_store_2",
+ "description": "e.g., zipcovers.myshopify.com",
+ "fieldname": "shopify_url_2",
+ "fieldtype": "Data",
+ "label": "Shop URL (Store 2)",
+ "mandatory_depends_on": "eval:doc.enable_store_2"
+ },
+ {
+ "depends_on": "enable_store_2",
+ "fieldname": "password_2",
+ "fieldtype": "Password",
+ "label": "Password / Access Token (Store 2)",
+ "mandatory_depends_on": "eval:doc.enable_store_2"
+ },
+ {
+ "depends_on": "enable_store_2",
+ "fieldname": "shared_secret_2",
+ "fieldtype": "Data",
+ "label": "Shared Secret / API Secret (Store 2)",
+ "mandatory_depends_on": "eval:doc.enable_store_2"
+ },
+ {
+ "collapsible": 1,
+ "depends_on": "enable_store_2",
+ "fieldname": "webhooks_section_2",
+ "fieldtype": "Section Break",
+ "label": "Webhooks (Store 2)"
+ },
+ {
+ "depends_on": "enable_store_2",
+ "fieldname": "webhooks_2",
+ "fieldtype": "Table",
+ "label": "Webhooks",
+ "options": "Shopify Webhook Store 2",
+ "read_only": 1
+ },
{
"fieldname": "customer_settings_section",
"fieldtype": "Section Break",
diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py
index dc974e70e..8a98ef027 100644
--- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py
+++ b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py
@@ -40,6 +40,11 @@ def validate(self):
if self.shopify_url:
self.shopify_url = self.shopify_url.replace("https://", "")
+
+ # Clean Store 2 URL if provided
+ if self.shopify_url_2:
+ self.shopify_url_2 = self.shopify_url_2.replace("https://", "")
+
self._handle_webhooks()
self._validate_warehouse_links()
self._initalize_default_values()
@@ -52,22 +57,68 @@ def on_update(self):
migrate_from_old_connector()
def _handle_webhooks(self):
+ """Handle webhook registration/unregistration for both stores."""
+
+ # Handle Store 1 webhooks
if self.is_enabled() and not self.webhooks:
new_webhooks = connection.register_webhooks(self.shopify_url, self.get_password("password"))
if not new_webhooks:
- msg = _("Failed to register webhooks with Shopify.") + "
"
+ msg = _("Failed to register webhooks with Shopify Store 1.") + "
"
msg += _("Please check credentials and retry.") + " "
msg += _("Disabling and re-enabling the integration might also help.")
frappe.throw(msg)
for webhook in new_webhooks:
self.append("webhooks", {"webhook_id": webhook.id, "method": webhook.topic})
+
+ frappe.logger().info(f"Registered {len(new_webhooks)} webhooks for Store 1")
elif not self.is_enabled():
connection.unregister_webhooks(self.shopify_url, self.get_password("password"))
-
self.webhooks = list() # remove all webhooks
+ frappe.logger().info("Unregistered Store 1 webhooks")
+
+ # Handle Store 2 webhooks
+ if self.enable_store_2 and self.shopify_url_2 and not self.webhooks_2:
+ try:
+ new_webhooks_2 = connection.register_webhooks(
+ self.shopify_url_2,
+ self.get_password("password_2")
+ )
+
+ if not new_webhooks_2:
+ msg = _("Failed to register webhooks with Shopify Store 2.") + "
"
+ msg += _("Please check Store 2 credentials and retry.")
+ frappe.throw(msg)
+
+ for webhook in new_webhooks_2:
+ self.append("webhooks_2", {"webhook_id": webhook.id, "method": webhook.topic})
+
+ frappe.logger().info(f"Registered {len(new_webhooks_2)} webhooks for Store 2 ({self.store_2_name})")
+
+ except Exception as e:
+ frappe.log_error(
+ title="Store 2 Webhook Registration Failed",
+ message=f"Error registering webhooks for Store 2: {str(e)}"
+ )
+ # Don't throw - allow Store 1 to continue working
+ frappe.msgprint(
+ _("Warning: Failed to register Store 2 webhooks. Store 1 will continue working. Error: {0}").format(str(e)),
+ alert=True
+ )
+
+ elif not self.enable_store_2 and self.shopify_url_2 and self.webhooks_2:
+ # Store 2 disabled but webhooks exist - unregister them
+ try:
+ connection.unregister_webhooks(self.shopify_url_2, self.get_password("password_2"))
+ self.webhooks_2 = list() # remove all Store 2 webhooks
+ frappe.logger().info("Unregistered Store 2 webhooks")
+ except Exception as e:
+ frappe.log_error(
+ title="Store 2 Webhook Unregistration Failed",
+ message=f"Error unregistering webhooks for Store 2: {str(e)}"
+ )
def _validate_warehouse_links(self):
for wh_map in self.shopify_warehouse_mapping:
@@ -171,6 +222,18 @@ def setup_custom_fields():
read_only=1,
print_hide=1,
),
+ # Fingerprint used to filter noisy `orders/updated` webhooks.
+ # Hidden technical field – do not show in UI.
+ dict(
+ fieldname="custom_shopify_fingerprint",
+ label="Shopify Fingerprint",
+ fieldtype="Small Text",
+ insert_after=ORDER_STATUS_FIELD,
+ read_only=1,
+ print_hide=1,
+ hidden=1,
+ no_copy=1,
+ ),
],
"Sales Order Item": [
dict(
diff --git a/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/__init__.py b/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.json b/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.json
new file mode 100644
index 000000000..f9eec3bce
--- /dev/null
+++ b/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.json
@@ -0,0 +1,42 @@
+{
+ "actions": [],
+ "creation": "2025-01-02 00:00:00.000000",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "webhook_id",
+ "method"
+ ],
+ "fields": [
+ {
+ "fieldname": "webhook_id",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Webhook ID",
+ "read_only": 1
+ },
+ {
+ "fieldname": "method",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Method",
+ "read_only": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2025-01-02 00:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Shopify",
+ "name": "Shopify Webhook Store 2",
+ "owner": "Administrator",
+ "permissions": [],
+ "quick_entry": 1,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 1
+}
+
diff --git a/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.py b/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.py
new file mode 100644
index 000000000..2a093fe61
--- /dev/null
+++ b/ecommerce_integrations/shopify/doctype/shopify_webhook_store_2/shopify_webhook_store_2.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2025, Frappe and contributors
+# For license information, please see LICENSE
+
+from frappe.model.document import Document
+
+
+class ShopifyWebhookStore2(Document):
+ pass
+
diff --git a/ecommerce_integrations/shopify/fulfillment.py b/ecommerce_integrations/shopify/fulfillment.py
index 5ffc0ebae..06dbed8be 100644
--- a/ecommerce_integrations/shopify/fulfillment.py
+++ b/ecommerce_integrations/shopify/fulfillment.py
@@ -14,12 +14,24 @@
from ecommerce_integrations.shopify.utils import create_shopify_log
-def prepare_delivery_note(payload, request_id=None):
+def prepare_delivery_note(payload, request_id=None, store_name=None):
+ """Prepare delivery note from Shopify fulfillment.
+
+ Args:
+ payload: Order data from Shopify
+ request_id: Shopify Log entry ID
+ store_name: Name of the store (for logging)
+ """
frappe.set_user("Administrator")
setting = frappe.get_doc(SETTING_DOCTYPE)
frappe.flags.request_id = request_id
order = payload
+
+ # Set store context for API calls in this background job
+ if store_name:
+ frappe.logger().info(f"Preparing delivery note for order from {store_name}")
+ frappe.local.shopify_store_name = store_name
try:
sales_order = get_sales_order(cstr(order["id"]))
diff --git a/ecommerce_integrations/shopify/invoice.py b/ecommerce_integrations/shopify/invoice.py
index f841cb416..2a983e488 100644
--- a/ecommerce_integrations/shopify/invoice.py
+++ b/ecommerce_integrations/shopify/invoice.py
@@ -10,7 +10,14 @@
from ecommerce_integrations.shopify.utils import create_shopify_log
-def prepare_sales_invoice(payload, request_id=None):
+def prepare_sales_invoice(payload, request_id=None, store_name=None):
+ """Prepare sales invoice from Shopify order.
+
+ Args:
+ payload: Order data from Shopify
+ request_id: Shopify Log entry ID
+ store_name: Name of the store (for logging)
+ """
from ecommerce_integrations.shopify.order import get_sales_order
order = payload
@@ -18,6 +25,11 @@ def prepare_sales_invoice(payload, request_id=None):
frappe.set_user("Administrator")
setting = frappe.get_doc(SETTING_DOCTYPE)
frappe.flags.request_id = request_id
+
+ # Set store context for API calls in this background job
+ if store_name:
+ frappe.logger().info(f"Preparing sales invoice for order from {store_name}")
+ frappe.local.shopify_store_name = store_name
try:
sales_order = get_sales_order(cstr(order["id"]))
diff --git a/ecommerce_integrations/shopify/order.py b/ecommerce_integrations/shopify/order.py
index 0570d035b..2a64ec8a2 100644
--- a/ecommerce_integrations/shopify/order.py
+++ b/ecommerce_integrations/shopify/order.py
@@ -1,4 +1,10 @@
+"""
+order.py - WITH COMPREHENSIVE STORE 2 LOGGING
+This handles the background job for orders/create webhook.
+"""
+
import json
+import traceback
from typing import Literal, Optional
import frappe
@@ -9,13 +15,13 @@
from ecommerce_integrations.shopify.connection import temp_shopify_session
from ecommerce_integrations.shopify.constants import (
- CUSTOMER_ID_FIELD,
- EVENT_MAPPER,
- ORDER_ID_FIELD,
- ORDER_ITEM_DISCOUNT_FIELD,
- ORDER_NUMBER_FIELD,
- ORDER_STATUS_FIELD,
- SETTING_DOCTYPE,
+ CUSTOMER_ID_FIELD,
+ EVENT_MAPPER,
+ ORDER_ID_FIELD,
+ ORDER_ITEM_DISCOUNT_FIELD,
+ ORDER_NUMBER_FIELD,
+ ORDER_STATUS_FIELD,
+ SETTING_DOCTYPE,
)
from ecommerce_integrations.shopify.customer import ShopifyCustomer
from ecommerce_integrations.shopify.product import create_items_if_not_exist, get_item_code
@@ -24,412 +30,1439 @@
from ecommerce_integrations.utils.taxation import get_dummy_tax_category
DEFAULT_TAX_FIELDS = {
- "sales_tax": "default_sales_tax_account",
- "shipping": "default_shipping_charges_account",
+ "sales_tax": "default_sales_tax_account",
+ "shipping": "default_shipping_charges_account",
}
-def sync_sales_order(payload, request_id=None):
- order = payload
- frappe.set_user("Administrator")
- frappe.flags.request_id = request_id
-
- 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")
- return
- try:
- shopify_customer = order.get("customer") if order.get("customer") is not None else {}
- shopify_customer["billing_address"] = order.get("billing_address", "")
- shopify_customer["shipping_address"] = order.get("shipping_address", "")
- customer_id = shopify_customer.get("id")
- if customer_id:
- customer = ShopifyCustomer(customer_id=customer_id)
- if not customer.is_synced():
- customer.sync_customer(customer=shopify_customer)
- else:
- customer.update_existing_addresses(shopify_customer)
-
- create_items_if_not_exist(order)
-
- setting = frappe.get_doc(SETTING_DOCTYPE)
- create_order(order, setting)
- except Exception as e:
- create_shopify_log(status="Error", exception=e, rollback=True)
- else:
- create_shopify_log(status="Success")
-
-
-def create_order(order, setting, company=None):
- # local import to avoid circular dependencies
- from ecommerce_integrations.shopify.fulfillment import create_delivery_note
- from ecommerce_integrations.shopify.invoice import create_sales_invoice
-
- so = create_sales_order(order, setting, company)
- if so:
- if order.get("financial_status") == "paid":
- create_sales_invoice(order, setting, so)
-
- if order.get("fulfillments"):
- create_delivery_note(order, setting, so)
-
-
-def create_sales_order(shopify_order, setting, company=None):
- customer = setting.default_customer
- if shopify_order.get("customer", {}):
- if customer_id := shopify_order.get("customer", {}).get("id"):
- customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name")
-
- so = frappe.db.get_value("Sales Order", {ORDER_ID_FIELD: shopify_order.get("id")}, "name")
-
- if not so:
- items = get_order_items(
- shopify_order.get("line_items"),
- setting,
- getdate(shopify_order.get("created_at")),
- taxes_inclusive=shopify_order.get("taxes_included"),
- )
-
- if not items:
- message = (
- "Following items exists in the shopify order but relevant records were"
- " not found in the shopify Product master"
- )
- product_not_exists = [] # TODO: fix missing items
- message += "\n" + ", ".join(product_not_exists)
-
- create_shopify_log(status="Error", exception=message, rollback=True)
-
- return ""
-
- taxes = get_order_taxes(shopify_order, setting, items)
- so = frappe.get_doc(
- {
- "doctype": "Sales Order",
- "naming_series": setting.sales_order_series or "SO-Shopify-",
- ORDER_ID_FIELD: str(shopify_order.get("id")),
- ORDER_NUMBER_FIELD: shopify_order.get("name"),
- "customer": customer,
- "transaction_date": getdate(shopify_order.get("created_at")) or nowdate(),
- "delivery_date": getdate(shopify_order.get("created_at")) or nowdate(),
- "company": setting.company,
- "selling_price_list": get_dummy_price_list(),
- "ignore_pricing_rule": 1,
- "items": items,
- "taxes": taxes,
- "tax_category": get_dummy_tax_category(),
- }
- )
-
- if company:
- so.update({"company": company, "status": "Draft"})
- so.flags.ignore_mandatory = True
- so.flags.shopiy_order_json = json.dumps(shopify_order)
- so.save(ignore_permissions=True)
- so.submit()
-
- if shopify_order.get("note"):
- so.add_comment(text=f"Order Note: {shopify_order.get('note')}")
-
- else:
- so = frappe.get_doc("Sales Order", so)
-
- return so
-
-
-def get_order_items(order_items, setting, delivery_date, taxes_inclusive):
- items = []
- all_product_exists = True
- product_not_exists = []
-
- for shopify_item in order_items:
- if not shopify_item.get("product_exists"):
- all_product_exists = False
- product_not_exists.append(
- {"title": shopify_item.get("title"), ORDER_ID_FIELD: shopify_item.get("id")}
- )
- continue
-
- if all_product_exists:
- item_code = get_item_code(shopify_item)
- items.append(
- {
- "item_code": item_code,
- "item_name": shopify_item.get("name"),
- "rate": _get_item_price(shopify_item, taxes_inclusive),
- "delivery_date": delivery_date,
- "qty": shopify_item.get("quantity"),
- "stock_uom": shopify_item.get("uom") or "Nos",
- "warehouse": setting.warehouse,
- ORDER_ITEM_DISCOUNT_FIELD: (
- _get_total_discount(shopify_item) / cint(shopify_item.get("quantity"))
- ),
- }
- )
- else:
- items = []
-
- return items
-
-
+def log_store2(step, message, store_name=None):
+ """Helper function to log only for Store 2."""
+ if store_name and store_name != "Store 1":
+ frappe.log_error(
+ title=f"[STORE2 ORDER] Step {step}",
+ message=f"Store: {store_name}\n\n{message}"
+ )
+
+
+def handle_order_edited(payload, request_id=None, store_name=None):
+ """Handle Shopify `orders/edited` webhook.
+
+ This topic is a CLEAN signal from Shopify that only fires when an order
+ is edited (line items added/removed/changed via the order edit flow).
+
+ We simply route it into the existing `update_sales_order` logic, which
+ already has robust line-item comparison and change tracking.
+ """
+ log_store2(
+ "BG-ORDER-EDITED",
+ f"orders/edited webhook received. request_id={request_id}, store_name={store_name}, "
+ f"order_id={payload.get('id')}, order_number={payload.get('name')}",
+ store_name,
+ )
+ return update_sales_order(payload, request_id=request_id, store_name=store_name)
+
+
+def handle_order_updated(payload, request_id=None, store_name=None):
+ """Handle Shopify `orders/updated` webhook.
+
+ This handler ONLY processes:
+ - Order notes
+ - Customer address changes (shipping, billing)
+
+ All other changes (line items, prices, etc.) are ignored here.
+ Line item changes are handled via the dedicated `orders/edited` webhook.
+ """
+ order = payload
+ frappe.set_user("Administrator")
+ frappe.flags.request_id = request_id
+
+ # Set store context for Store 2
+ if store_name:
+ frappe.local.shopify_store_name = store_name
+
+ try:
+ order_id = cstr(order.get("id"))
+ order_number = order.get("name", "")
+ shopify_customer = order.get("customer", {}) if order.get("customer") else {}
+ customer_id = shopify_customer.get("id")
+
+ # Check if Sales Order exists
+ sales_order_name = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id})
+
+ if not sales_order_name:
+ # Order doesn't exist - this shouldn't happen for orders/updated, but handle gracefully
+ create_shopify_log(
+ status="Info",
+ message=f"Order {order_number} not found in ERPNext for orders/updated webhook",
+ request_data=order,
+ )
+ return
+
+ sales_order = frappe.get_doc("Sales Order", sales_order_name)
+ changes = {}
+
+ # ONLY check notes
+ old_note = sales_order.get("note", "") or ""
+ new_note = order.get("note", "") or ""
+ if old_note.strip() != new_note.strip():
+ changes["note"] = {
+ "old": old_note,
+ "new": new_note
+ }
+
+ # ONLY check addresses (shipping and billing)
+ shipping_address = order.get("shipping_address", {})
+ if shipping_address and sales_order.customer:
+ shipping_addr_doc = _get_customer_address_by_type(sales_order.customer, "Shipping")
+
+ if shipping_addr_doc:
+ old_addr = {
+ "address_line1": shipping_addr_doc.get("address_line1", ""),
+ "address_line2": shipping_addr_doc.get("address_line2", ""),
+ "city": shipping_addr_doc.get("city", ""),
+ "state": shipping_addr_doc.get("state", ""),
+ "pincode": shipping_addr_doc.get("pincode", ""),
+ "country": shipping_addr_doc.get("country", ""),
+ "phone": shipping_addr_doc.get("phone", ""),
+ }
+ new_addr = {
+ "address_line1": shipping_address.get("address1", ""),
+ "address_line2": shipping_address.get("address2", ""),
+ "city": shipping_address.get("city", ""),
+ "state": shipping_address.get("province", ""),
+ "pincode": shipping_address.get("zip", ""),
+ "country": shipping_address.get("country", ""),
+ "phone": shipping_address.get("phone", ""),
+ }
+
+ old_normalized = _normalize_address_for_comparison(old_addr)
+ new_normalized = _normalize_address_for_comparison(new_addr)
+
+ if old_normalized != new_normalized:
+ changes["shipping_address"] = {
+ "old": old_addr,
+ "new": new_addr
+ }
+
+ # Compare billing address
+ billing_address = order.get("billing_address")
+ billing_is_same_as_shipping = False
+
+ # Check if billing is explicitly null/empty (Shopify "same as shipping" case)
+ if billing_address is None or (isinstance(billing_address, dict) and not any(billing_address.values())):
+ if shipping_address:
+ billing_address = shipping_address
+ billing_is_same_as_shipping = True
+ elif not billing_address:
+ billing_address = shopify_customer.get("default_address", {})
+
+ if billing_address and sales_order.customer:
+ billing_addr_doc = _get_customer_address_by_type(sales_order.customer, "Billing")
+
+ if billing_addr_doc:
+ old_addr = {
+ "address_line1": billing_addr_doc.get("address_line1", ""),
+ "address_line2": billing_addr_doc.get("address_line2", ""),
+ "city": billing_addr_doc.get("city", ""),
+ "state": billing_addr_doc.get("state", ""),
+ "pincode": billing_addr_doc.get("pincode", ""),
+ "country": billing_addr_doc.get("country", ""),
+ "phone": billing_addr_doc.get("phone", ""),
+ }
+ new_addr = {
+ "address_line1": billing_address.get("address1", ""),
+ "address_line2": billing_address.get("address2", ""),
+ "city": billing_address.get("city", ""),
+ "state": billing_address.get("province", ""),
+ "pincode": billing_address.get("zip", ""),
+ "country": billing_address.get("country", ""),
+ "phone": billing_address.get("phone", ""),
+ }
+
+ old_normalized = _normalize_address_for_comparison(old_addr)
+ new_normalized = _normalize_address_for_comparison(new_addr)
+
+ if old_normalized != new_normalized:
+ changes["billing_address"] = {
+ "old": old_addr,
+ "new": new_addr,
+ "is_same_as_shipping": billing_is_same_as_shipping
+ }
+
+ # Skip if no changes detected
+ if not changes:
+ return
+
+ # Update addresses if changed
+ if "shipping_address" in changes or "billing_address" in changes:
+ shopify_customer["billing_address"] = billing_address
+ shopify_customer["shipping_address"] = shipping_address
+
+ if customer_id:
+ customer = ShopifyCustomer(customer_id=customer_id)
+ if customer.is_synced():
+ customer.update_existing_addresses(shopify_customer)
+
+ # Update note if changed
+ # ERPNext's standard Sales Order does not have a direct "note" field;
+ # the original integration stored Shopify notes as comments.
+ if "note" in changes and new_note:
+ sales_order_doc = frappe.get_doc("Sales Order", sales_order_name)
+ sales_order_doc.add_comment(text=f"Order Note: {new_note}")
+
+ # Log the changes
+ create_shopify_log(
+ status="Success",
+ message=f"Order {order_number} updated: {', '.join(changes.keys())}",
+ request_data=order,
+ response_data={"change_details": changes}
+ )
+
+ frappe.db.commit()
+
+ except Exception as e:
+ frappe.log_error(
+ title=f"Failed to update order {order_number}",
+ message=f"Error: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ )
+ create_shopify_log(
+ status="Error",
+ message=f"Failed to update order {order_number}: {str(e)}",
+ request_data=order,
+ exception=e,
+ )
+ raise
+
+
+def sync_sales_order(payload, request_id=None, store_name=None):
+ """Sync Shopify order to ERPNext Sales Order.
+
+ This is called as a BACKGROUND JOB by the RQ worker.
+ """
+ order = payload
+
+ # =========================================================================
+ # STEP BG-1: Background job started
+ # =========================================================================
+ log_store2("BG-1", f"""
+========================================
+BACKGROUND JOB STARTED: sync_sales_order
+========================================
+request_id: {request_id}
+store_name: {store_name}
+Order ID: {order.get('id')}
+Order Number: {order.get('name')}
+frappe.local exists: {hasattr(frappe, 'local')}
+""", store_name)
+
+ frappe.set_user("Administrator")
+ frappe.flags.request_id = request_id
+
+ # =========================================================================
+ # STEP BG-2: Set store context (CRITICAL!)
+ # =========================================================================
+ log_store2("BG-2", f"""
+Setting store context in background worker...
+Before: frappe.local.shopify_store_name = {getattr(frappe.local, 'shopify_store_name', 'NOT SET')}
+""", store_name)
+
+ if store_name:
+ frappe.local.shopify_store_name = store_name
+ log_store2("BG-2-OK", f"""
+Store context set!
+After: frappe.local.shopify_store_name = {frappe.local.shopify_store_name}
+""", store_name)
+ else:
+ log_store2("BG-2-WARN", "store_name is None! Will default to Store 1 credentials!", store_name)
+
+ # =========================================================================
+ # STEP BG-3: Check if order already exists
+ # =========================================================================
+ log_store2("BG-3", f"Checking if Sales Order already exists for Shopify Order ID: {order['id']}", store_name)
+
+ existing_so = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: cstr(order["id"])})
+
+ if existing_so:
+ log_store2("BG-3-SKIP", f"""
+Sales Order already exists!
+Existing SO: {existing_so}
+Shopify Order ID: {order['id']}
+Skipping creation.
+""", store_name)
+ create_shopify_log(status="Invalid", message="Sales order already exists, not synced")
+ return
+
+ log_store2("BG-3-OK", "No existing Sales Order found, proceeding with creation.", store_name)
+
+ # =========================================================================
+ # STEP BG-4: Process customer
+ # =========================================================================
+ try:
+ log_store2("BG-4", "Processing customer...", store_name)
+
+ shopify_customer = order.get("customer") if order.get("customer") is not None else {}
+ shopify_customer["billing_address"] = order.get("billing_address", "")
+ shopify_customer["shipping_address"] = order.get("shipping_address", "")
+ customer_id = shopify_customer.get("id")
+
+ log_store2("BG-4a", f"""
+Customer data:
+customer_id: {customer_id}
+email: {shopify_customer.get('email')}
+has billing_address: {bool(order.get('billing_address'))}
+has shipping_address: {bool(order.get('shipping_address'))}
+""", store_name)
+
+ if customer_id:
+ customer = ShopifyCustomer(customer_id=customer_id)
+ if not customer.is_synced():
+ log_store2("BG-4b", f"Customer {customer_id} not synced, creating...", store_name)
+ customer.sync_customer(customer=shopify_customer)
+ log_store2("BG-4c", f"Customer {customer_id} created.", store_name)
+ else:
+ log_store2("BG-4b", f"Customer {customer_id} already exists, updating addresses...", store_name)
+ customer.update_existing_addresses(shopify_customer)
+ log_store2("BG-4c", f"Customer {customer_id} addresses updated.", store_name)
+ else:
+ log_store2("BG-4-WARN", "No customer_id in order, will use default customer.", store_name)
+
+ log_store2("BG-4-OK", "Customer processing complete.", store_name)
+
+ except Exception as e:
+ log_store2("BG-4-EXCEPTION", f"""
+Exception processing customer!
+Error: {str(e)}
+Type: {type(e).__name__}
+
+Traceback:
+{traceback.format_exc()}
+""", store_name)
+ create_shopify_log(status="Error", exception=e, rollback=True)
+ return
+
+ # =========================================================================
+ # STEP BG-5: Sync items/products
+ # =========================================================================
+ try:
+ log_store2("BG-5", f"""
+Syncing items from order...
+Line items count: {len(order.get('line_items', []))}
+Line items: {[item.get('title') for item in order.get('line_items', [])]}
+""", store_name)
+
+ create_items_if_not_exist(order)
+
+ log_store2("BG-5-OK", "Items synced successfully.", store_name)
+
+ except Exception as e:
+ log_store2("BG-5-EXCEPTION", f"""
+Exception syncing items!
+Error: {str(e)}
+Type: {type(e).__name__}
+
+Traceback:
+{traceback.format_exc()}
+""", store_name)
+ create_shopify_log(status="Error", exception=e, rollback=True)
+ return
+
+ # =========================================================================
+ # STEP BG-6: Create Sales Order
+ # =========================================================================
+ try:
+ log_store2("BG-6", "Creating Sales Order...", store_name)
+
+ setting = frappe.get_doc(SETTING_DOCTYPE)
+
+ log_store2("BG-6a", f"""
+Settings loaded:
+Company: {setting.company}
+Warehouse: {setting.warehouse}
+Sales Order Series: {setting.sales_order_series}
+Default Customer: {setting.default_customer}
+""", store_name)
+
+ create_order(order, setting, store_name=store_name)
+
+ log_store2("BG-6-OK", f"""
+========================================
+SALES ORDER CREATED SUCCESSFULLY!
+========================================
+Shopify Order ID: {order.get('id')}
+Shopify Order Number: {order.get('name')}
+Store: {store_name}
+""", store_name)
+
+ except Exception as e:
+ log_store2("BG-6-EXCEPTION", f"""
+Exception creating Sales Order!
+Error: {str(e)}
+Type: {type(e).__name__}
+
+Traceback:
+{traceback.format_exc()}
+""", store_name)
+ create_shopify_log(status="Error", exception=e, rollback=True)
+ return
+
+ # =========================================================================
+ # STEP BG-7: Success!
+ # =========================================================================
+ log_store2("BG-7", "Creating success log entry...", store_name)
+ create_shopify_log(status="Success")
+ log_store2("BG-7-OK", f"""
+========================================
+BACKGROUND JOB COMPLETED SUCCESSFULLY!
+========================================
+Shopify Order: {order.get('name')}
+Store: {store_name}
+""", store_name)
+
+
+def create_order(order, setting, company=None, store_name=None):
+ """Create order with related documents."""
+ # local import to avoid circular dependencies
+ from ecommerce_integrations.shopify.fulfillment import create_delivery_note
+ from ecommerce_integrations.shopify.invoice import create_sales_invoice
+
+ log_store2("CREATE-1", "Inside create_order()", store_name)
+
+ so = create_sales_order(order, setting, company, store_name=store_name)
+
+ if so:
+ log_store2("CREATE-2", f"Sales Order created: {so.name}", store_name)
+
+ if order.get("financial_status") == "paid":
+ log_store2("CREATE-3", "Order is paid, creating Sales Invoice...", store_name)
+ create_sales_invoice(order, setting, so)
+ log_store2("CREATE-3-OK", "Sales Invoice created.", store_name)
+
+ if order.get("fulfillments"):
+ log_store2("CREATE-4", "Order has fulfillments, creating Delivery Note...", store_name)
+ create_delivery_note(order, setting, so)
+ log_store2("CREATE-4-OK", "Delivery Note created.", store_name)
+ else:
+ log_store2("CREATE-WARN", "create_sales_order returned None!", store_name)
+
+
+def create_sales_order(shopify_order, setting, company=None, store_name=None):
+ """Create the actual Sales Order document."""
+
+ log_store2("SO-1", f"""
+Creating Sales Order...
+Shopify Order ID: {shopify_order.get('id')}
+Shopify Order Number: {shopify_order.get('name')}
+""", store_name)
+
+ # Determine customer
+ customer = setting.default_customer
+ if shopify_order.get("customer", {}):
+ if customer_id := shopify_order.get("customer", {}).get("id"):
+ customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") or customer
+
+ log_store2("SO-2", f"Customer determined: {customer}", store_name)
+
+ # Check if SO already exists
+ so = frappe.db.get_value("Sales Order", {ORDER_ID_FIELD: shopify_order.get("id")}, "name")
+
+ if not so:
+ log_store2("SO-3", "Getting order items...", store_name)
+
+ items = get_order_items(
+ shopify_order.get("line_items"),
+ setting,
+ getdate(shopify_order.get("created_at")),
+ taxes_inclusive=shopify_order.get("taxes_included"),
+ store_name=store_name,
+ )
+
+ log_store2("SO-3a", f"Items count: {len(items)}", store_name)
+
+ if not items:
+ log_store2("SO-3-FAIL", "No items returned! Cannot create Sales Order.", store_name)
+ message = (
+ "Following items exists in the shopify order but relevant records were"
+ " not found in the shopify Product master"
+ )
+ create_shopify_log(status="Error", exception=message, rollback=True)
+ return ""
+
+ log_store2("SO-4", "Getting order taxes...", store_name)
+ taxes = get_order_taxes(shopify_order, setting, items)
+ log_store2("SO-4a", f"Taxes count: {len(taxes)}", store_name)
+
+ log_store2("SO-5", "Creating Sales Order document...", store_name)
+
+ so = frappe.get_doc(
+ {
+ "doctype": "Sales Order",
+ "naming_series": setting.sales_order_series or "SO-Shopify-",
+ ORDER_ID_FIELD: str(shopify_order.get("id")),
+ ORDER_NUMBER_FIELD: shopify_order.get("name"),
+ "customer": customer,
+ "transaction_date": getdate(shopify_order.get("created_at")) or nowdate(),
+ "delivery_date": getdate(shopify_order.get("created_at")) or nowdate(),
+ "company": setting.company,
+ "selling_price_list": get_dummy_price_list(),
+ "ignore_pricing_rule": 1,
+ "items": items,
+ "taxes": taxes,
+ "tax_category": get_dummy_tax_category(),
+ }
+ )
+
+ if company:
+ so.update({"company": company, "status": "Draft"})
+
+ so.flags.ignore_mandatory = True
+ so.flags.shopiy_order_json = json.dumps(shopify_order)
+
+ log_store2("SO-6", "Saving Sales Order...", store_name)
+ so.save(ignore_permissions=True)
+ log_store2("SO-6a", f"Sales Order saved: {so.name}", store_name)
+
+ log_store2("SO-7", "Submitting Sales Order...", store_name)
+ so.submit()
+ log_store2("SO-7a", f"Sales Order submitted: {so.name}", store_name)
+
+ if shopify_order.get("note"):
+ so.add_comment(text=f"Order Note: {shopify_order.get('note')}")
+ log_store2("SO-8", "Added order note as comment.", store_name)
+
+ else:
+ log_store2("SO-EXISTS", f"Sales Order already exists: {so}", store_name)
+ so = frappe.get_doc("Sales Order", so)
+
+ return so
+
+
+def get_order_items(order_items, setting, delivery_date, taxes_inclusive, store_name=None):
+ """Get order items for Sales Order."""
+ items = []
+ all_product_exists = True
+ product_not_exists = []
+
+ log_store2("ITEMS-1", f"Processing {len(order_items)} line items...", store_name)
+
+ for idx, shopify_item in enumerate(order_items):
+ product_id = shopify_item.get("product_id")
+
+ log_store2(f"ITEMS-2-{idx}", f"""
+Processing item {idx + 1}:
+ title: {shopify_item.get('title')}
+ product_id: {product_id}
+ variant_id: {shopify_item.get('variant_id')}
+ sku: {shopify_item.get('sku')}
+ quantity: {shopify_item.get('quantity')}
+ price: {shopify_item.get('price')}
+ product_exists: {shopify_item.get('product_exists')}
+""", store_name)
+
+ # Handle items without product_id (tips, samples, fees)
+ if not product_id:
+ item_code = get_item_code(shopify_item)
+ log_store2(f"ITEMS-2-{idx}-NOID", f"No product_id, mapped to: {item_code}", store_name)
+ if item_code:
+ items.append(
+ {
+ "item_code": item_code,
+ "item_name": shopify_item.get("name") or shopify_item.get("title"),
+ "rate": _get_item_price(shopify_item, taxes_inclusive),
+ "delivery_date": delivery_date,
+ "qty": shopify_item.get("quantity"),
+ "stock_uom": "Nos",
+ "warehouse": setting.warehouse,
+ ORDER_ITEM_DISCOUNT_FIELD: (
+ _get_total_discount(shopify_item) / cint(shopify_item.get("quantity"))
+ ),
+ }
+ )
+ continue
+
+ # Original logic for items with product_id
+ if not shopify_item.get("product_exists"):
+ all_product_exists = False
+ product_not_exists.append(
+ {"title": shopify_item.get("title"), ORDER_ID_FIELD: shopify_item.get("id")}
+ )
+ log_store2(f"ITEMS-2-{idx}-NOTEXIST", f"Product does not exist in Shopify!", store_name)
+ continue
+
+ if all_product_exists:
+ item_code = get_item_code(shopify_item)
+ log_store2(f"ITEMS-2-{idx}-CODE", f"Item code: {item_code}", store_name)
+
+ if not item_code:
+ log_store2(f"ITEMS-2-{idx}-NOCODE", f"Could not get item_code!", store_name)
+ continue
+
+ items.append(
+ {
+ "item_code": item_code,
+ "item_name": shopify_item.get("name"),
+ "rate": _get_item_price(shopify_item, taxes_inclusive),
+ "delivery_date": delivery_date,
+ "qty": shopify_item.get("quantity"),
+ "stock_uom": shopify_item.get("uom") or "Nos",
+ "warehouse": setting.warehouse,
+ ORDER_ITEM_DISCOUNT_FIELD: (
+ _get_total_discount(shopify_item) / cint(shopify_item.get("quantity"))
+ ),
+ }
+ )
+ else:
+ items = []
+
+ log_store2("ITEMS-3", f"Returning {len(items)} items", store_name)
+ return items
+
+
+# Keep the rest of the functions unchanged but add store_name parameter where needed
def _get_item_price(line_item, taxes_inclusive: bool) -> float:
- price = flt(line_item.get("price"))
- qty = cint(line_item.get("quantity"))
+ price = flt(line_item.get("price"))
+ qty = cint(line_item.get("quantity"))
+ total_discount = _get_total_discount(line_item)
- # remove line item level discounts
- total_discount = _get_total_discount(line_item)
+ if not taxes_inclusive:
+ return price - (total_discount / qty)
- if not taxes_inclusive:
- return price - (total_discount / qty)
+ total_taxes = 0.0
+ for tax in line_item.get("tax_lines"):
+ total_taxes += flt(tax.get("price"))
- total_taxes = 0.0
- for tax in line_item.get("tax_lines"):
- total_taxes += flt(tax.get("price"))
-
- return price - (total_taxes + total_discount) / qty
+ return price - (total_taxes + total_discount) / qty
def _get_total_discount(line_item) -> float:
- discount_allocations = line_item.get("discount_allocations") or []
- return sum(flt(discount.get("amount")) for discount in discount_allocations)
+ discount_allocations = line_item.get("discount_allocations") or []
+ return sum(flt(discount.get("amount")) for discount in discount_allocations)
def get_order_taxes(shopify_order, setting, items):
- taxes = []
- line_items = shopify_order.get("line_items")
-
- for line_item in line_items:
- item_code = get_item_code(line_item)
- for tax in line_item.get("tax_lines"):
- taxes.append(
- {
- "charge_type": "Actual",
- "account_head": get_tax_account_head(tax, charge_type="sales_tax"),
- "description": (
- get_tax_account_description(tax)
- or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%"
- ),
- "tax_amount": tax.get("price"),
- "included_in_print_rate": 0,
- "cost_center": setting.cost_center,
- "item_wise_tax_detail": {item_code: [flt(tax.get("rate")) * 100, flt(tax.get("price"))]},
- "dont_recompute_tax": 1,
- }
- )
-
- update_taxes_with_shipping_lines(
- taxes,
- shopify_order.get("shipping_lines"),
- setting,
- items,
- taxes_inclusive=shopify_order.get("taxes_included"),
- )
-
- if cint(setting.consolidate_taxes):
- taxes = consolidate_order_taxes(taxes)
-
- for row in taxes:
- tax_detail = row.get("item_wise_tax_detail")
- if isinstance(tax_detail, dict):
- row["item_wise_tax_detail"] = json.dumps(tax_detail)
-
- return taxes
+ taxes = []
+ line_items = shopify_order.get("line_items")
+
+ for line_item in line_items:
+ item_code = get_item_code(line_item)
+ for tax in line_item.get("tax_lines"):
+ taxes.append(
+ {
+ "charge_type": "Actual",
+ "account_head": get_tax_account_head(tax, charge_type="sales_tax"),
+ "description": (
+ get_tax_account_description(tax)
+ or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%"
+ ),
+ "tax_amount": tax.get("price"),
+ "included_in_print_rate": 0,
+ "cost_center": setting.cost_center,
+ "item_wise_tax_detail": {item_code: [flt(tax.get("rate")) * 100, flt(tax.get("price"))]},
+ "dont_recompute_tax": 1,
+ }
+ )
+
+ update_taxes_with_shipping_lines(
+ taxes,
+ shopify_order.get("shipping_lines"),
+ setting,
+ items,
+ taxes_inclusive=shopify_order.get("taxes_included"),
+ )
+
+ if cint(setting.consolidate_taxes):
+ taxes = consolidate_order_taxes(taxes)
+
+ for row in taxes:
+ tax_detail = row.get("item_wise_tax_detail")
+ if isinstance(tax_detail, dict):
+ row["item_wise_tax_detail"] = json.dumps(tax_detail)
+
+ return taxes
def consolidate_order_taxes(taxes):
- tax_account_wise_data = {}
- for tax in taxes:
- account_head = tax["account_head"]
- tax_account_wise_data.setdefault(
- account_head,
- {
- "charge_type": "Actual",
- "account_head": account_head,
- "description": tax.get("description"),
- "cost_center": tax.get("cost_center"),
- "included_in_print_rate": 0,
- "dont_recompute_tax": 1,
- "tax_amount": 0,
- "item_wise_tax_detail": {},
- },
- )
- tax_account_wise_data[account_head]["tax_amount"] += flt(tax.get("tax_amount"))
- if tax.get("item_wise_tax_detail"):
- tax_account_wise_data[account_head]["item_wise_tax_detail"].update(tax["item_wise_tax_detail"])
-
- return tax_account_wise_data.values()
+ tax_account_wise_data = {}
+ for tax in taxes:
+ account_head = tax["account_head"]
+ tax_account_wise_data.setdefault(
+ account_head,
+ {
+ "charge_type": "Actual",
+ "account_head": account_head,
+ "description": tax.get("description"),
+ "cost_center": tax.get("cost_center"),
+ "included_in_print_rate": 0,
+ "dont_recompute_tax": 1,
+ "tax_amount": 0,
+ "item_wise_tax_detail": {},
+ },
+ )
+ tax_account_wise_data[account_head]["tax_amount"] += flt(tax.get("tax_amount"))
+ if tax.get("item_wise_tax_detail"):
+ tax_account_wise_data[account_head]["item_wise_tax_detail"].update(tax["item_wise_tax_detail"])
+
+ return tax_account_wise_data.values()
def get_tax_account_head(tax, charge_type: Literal["shipping", "sales_tax"] | None = None):
- tax_title = str(tax.get("title"))
+ tax_title = str(tax.get("title"))
- tax_account = frappe.db.get_value(
- "Shopify Tax Account",
- {"parent": SETTING_DOCTYPE, "shopify_tax": tax_title},
- "tax_account",
- )
+ tax_account = frappe.db.get_value(
+ "Shopify Tax Account",
+ {"parent": SETTING_DOCTYPE, "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])
+ if not tax_account and charge_type:
+ tax_account = frappe.db.get_single_value(SETTING_DOCTYPE, DEFAULT_TAX_FIELDS[charge_type])
- if not tax_account:
- frappe.throw(_("Tax Account not specified for Shopify Tax {0}").format(tax.get("title")))
+ if not tax_account:
+ frappe.throw(_("Tax Account not specified for Shopify Tax {0}").format(tax.get("title")))
- return tax_account
+ return tax_account
def get_tax_account_description(tax):
- tax_title = tax.get("title")
+ tax_title = tax.get("title")
- tax_description = frappe.db.get_value(
- "Shopify Tax Account",
- {"parent": SETTING_DOCTYPE, "shopify_tax": tax_title},
- "tax_description",
- )
+ tax_description = frappe.db.get_value(
+ "Shopify Tax Account",
+ {"parent": SETTING_DOCTYPE, "shopify_tax": tax_title},
+ "tax_description",
+ )
- return tax_description
+ return tax_description
def update_taxes_with_shipping_lines(taxes, shipping_lines, setting, items, taxes_inclusive=False):
- """Shipping lines represents the shipping details,
- each such shipping detail consists of a list of tax_lines"""
- shipping_as_item = cint(setting.add_shipping_as_item) and setting.shipping_item
- for shipping_charge in shipping_lines:
- if shipping_charge.get("price"):
- shipping_discounts = shipping_charge.get("discount_allocations") or []
- total_discount = sum(flt(discount.get("amount")) for discount in shipping_discounts)
-
- shipping_taxes = shipping_charge.get("tax_lines") or []
- total_tax = sum(flt(discount.get("price")) for discount in shipping_taxes)
-
- shipping_charge_amount = flt(shipping_charge["price"]) - flt(total_discount)
- if bool(taxes_inclusive):
- shipping_charge_amount -= total_tax
-
- if shipping_as_item:
- items.append(
- {
- "item_code": setting.shipping_item,
- "rate": shipping_charge_amount,
- "delivery_date": items[-1]["delivery_date"] if items else nowdate(),
- "qty": 1,
- "stock_uom": "Nos",
- "warehouse": setting.warehouse,
- }
- )
- else:
- taxes.append(
- {
- "charge_type": "Actual",
- "account_head": get_tax_account_head(shipping_charge, charge_type="shipping"),
- "description": get_tax_account_description(shipping_charge)
- or shipping_charge["title"],
- "tax_amount": shipping_charge_amount,
- "cost_center": setting.cost_center,
- }
- )
-
- for tax in shipping_charge.get("tax_lines"):
- taxes.append(
- {
- "charge_type": "Actual",
- "account_head": get_tax_account_head(tax, charge_type="sales_tax"),
- "description": (
- get_tax_account_description(tax)
- or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%"
- ),
- "tax_amount": tax["price"],
- "cost_center": setting.cost_center,
- "item_wise_tax_detail": {
- setting.shipping_item: [flt(tax.get("rate")) * 100, flt(tax.get("price"))]
- }
- if shipping_as_item
- else {},
- "dont_recompute_tax": 1,
- }
- )
+ shipping_as_item = cint(setting.add_shipping_as_item) and setting.shipping_item
+ for shipping_charge in shipping_lines:
+ if shipping_charge.get("price"):
+ shipping_discounts = shipping_charge.get("discount_allocations") or []
+ total_discount = sum(flt(discount.get("amount")) for discount in shipping_discounts)
+
+ shipping_taxes = shipping_charge.get("tax_lines") or []
+ total_tax = sum(flt(discount.get("price")) for discount in shipping_taxes)
+
+ shipping_charge_amount = flt(shipping_charge["price"]) - flt(total_discount)
+ if bool(taxes_inclusive):
+ shipping_charge_amount -= total_tax
+
+ if shipping_as_item:
+ items.append(
+ {
+ "item_code": setting.shipping_item,
+ "rate": shipping_charge_amount,
+ "delivery_date": items[-1]["delivery_date"] if items else nowdate(),
+ "qty": 1,
+ "stock_uom": "Nos",
+ "warehouse": setting.warehouse,
+ }
+ )
+ else:
+ taxes.append(
+ {
+ "charge_type": "Actual",
+ "account_head": get_tax_account_head(shipping_charge, charge_type="shipping"),
+ "description": get_tax_account_description(shipping_charge)
+ or shipping_charge["title"],
+ "tax_amount": shipping_charge_amount,
+ "cost_center": setting.cost_center,
+ }
+ )
+
+ for tax in shipping_charge.get("tax_lines"):
+ taxes.append(
+ {
+ "charge_type": "Actual",
+ "account_head": get_tax_account_head(tax, charge_type="sales_tax"),
+ "description": (
+ get_tax_account_description(tax)
+ or f"{tax.get('title')} - {tax.get('rate') * 100.0:.2f}%"
+ ),
+ "tax_amount": tax["price"],
+ "cost_center": setting.cost_center,
+ "item_wise_tax_detail": {
+ setting.shipping_item: [flt(tax.get("rate")) * 100, flt(tax.get("price"))]
+ }
+ if shipping_as_item
+ else {},
+ "dont_recompute_tax": 1,
+ }
+ )
def get_sales_order(order_id):
- """Get ERPNext sales order using shopify order id."""
- sales_order = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id})
- if sales_order:
- return frappe.get_doc("Sales Order", sales_order)
-
-
-def cancel_order(payload, request_id=None):
- """Called by order/cancelled event.
-
- When shopify order is cancelled there could be many different someone handles it.
-
- Updates document with custom field showing order status.
-
- IF sales invoice / delivery notes are not generated against an order, then cancel it.
+ """Get ERPNext sales order using shopify order id."""
+ sales_order = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id})
+ if sales_order:
+ return frappe.get_doc("Sales Order", sales_order)
+
+
+def cancel_order(payload, request_id=None, store_name=None):
+ """Called by order/cancelled event."""
+ frappe.set_user("Administrator")
+ frappe.flags.request_id = request_id
+
+ if store_name:
+ frappe.local.shopify_store_name = store_name
+ log_store2("CANCEL-1", f"Cancelling order {payload.get('id')}", store_name)
+
+ order = payload
+
+ try:
+ order_id = order["id"]
+ order_status = order["financial_status"]
+
+ sales_order = get_sales_order(order_id)
+
+ if not sales_order:
+ log_store2("CANCEL-FAIL", f"Sales Order not found for {order_id}", store_name)
+ create_shopify_log(status="Invalid", message="Sales Order does not exist")
+ return
+
+ sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id})
+ delivery_notes = frappe.db.get_list("Delivery Note", filters={ORDER_ID_FIELD: order_id})
+
+ if sales_invoice:
+ frappe.db.set_value("Sales Invoice", sales_invoice, ORDER_STATUS_FIELD, order_status)
+
+ for dn in delivery_notes:
+ frappe.db.set_value("Delivery Note", dn.name, ORDER_STATUS_FIELD, order_status)
+
+ if not sales_invoice and not delivery_notes and sales_order.docstatus == 1:
+ sales_order.cancel()
+ log_store2("CANCEL-OK", f"Sales Order {sales_order.name} cancelled", store_name)
+ else:
+ frappe.db.set_value("Sales Order", sales_order.name, ORDER_STATUS_FIELD, order_status)
+ log_store2("CANCEL-STATUS", f"Sales Order {sales_order.name} status updated", store_name)
+
+ except Exception as e:
+ log_store2("CANCEL-ERROR", f"Error: {str(e)}\n{traceback.format_exc()}", store_name)
+ create_shopify_log(status="Error", exception=e)
+ else:
+ create_shopify_log(status="Success")
+
+
+def _normalize_address_for_comparison(addr_dict):
+ """Normalize address fields for comparison (trim, lowercase, handle None)."""
+ return {
+ "address_line1": (addr_dict.get("address_line1") or "").strip().lower(),
+ "address_line2": (addr_dict.get("address_line2") or "").strip().lower(),
+ "city": (addr_dict.get("city") or "").strip().lower(),
+ "state": (addr_dict.get("state") or "").strip().lower(),
+ "pincode": (addr_dict.get("pincode") or "").strip().lower(),
+ "country": (addr_dict.get("country") or "").strip().lower(),
+ "phone": (addr_dict.get("phone") or "").strip().lower(),
+ }
+
+def _get_customer_address_by_type(customer_name, address_type):
+ if not customer_name:
+ return None
+
+ addresses = frappe.get_list(
+ "Address",
+ filters=[
+ ["Dynamic Link", "link_doctype", "=", "Customer"],
+ ["Dynamic Link", "link_name", "=", customer_name],
+ ["Dynamic Link", "parenttype", "=", "Address"],
+ ["Address", "address_type", "=", address_type],
+ ],
+ fields=[
+ "name",
+ "address_line1",
+ "address_line2",
+ "city",
+ "state",
+ "pincode",
+ "country",
+ "phone",
+ ],
+ limit=1,
+ )
+
+ return addresses[0] if addresses else None
+
+def update_sales_order(payload, request_id=None, store_name=None):
+ """Handle order updates from Shopify.
+
+ Tracks ONLY necessary business changes:
+ - Line item changes (quantity, price, new items) with full details
+ - Price changes (grand_total, subtotal)
+ - Contact details (email, phone)
+ - Address changes (shipping, billing)
+ - Customer name changes
+
+ Supports both Store 1 and Store 2.
"""
+ order = payload
frappe.set_user("Administrator")
frappe.flags.request_id = request_id
-
- order = payload
-
+
+ # Set store context for Store 2
+ if store_name:
+ frappe.local.shopify_store_name = store_name
+
try:
- order_id = order["id"]
- order_status = order["financial_status"]
-
- sales_order = get_sales_order(order_id)
-
- if not sales_order:
- create_shopify_log(status="Invalid", message="Sales Order does not exist")
+ order_id = cstr(order.get("id"))
+ order_number = order.get("name", "")
+ shopify_customer = order.get("customer", {}) if order.get("customer") else {}
+ customer_id = shopify_customer.get("id")
+
+ # Check if Sales Order exists
+ sales_order_name = frappe.db.get_value("Sales Order", filters={ORDER_ID_FIELD: order_id})
+
+ # Early exit: If order doesn't exist, create it and return
+ # (This prevents checking for metadata-only updates on non-existent orders)
+
+ if not sales_order_name:
+ # Order doesn't exist, create it
+ create_shopify_log(
+ status="Info",
+ message=f"Order {order_number} not found, creating new order",
+ request_data=order,
+ response_data={"action": "create_new_order"}
+ )
+ sync_sales_order(payload, request_id, store_name=store_name)
return
-
- sales_invoice = frappe.db.get_value("Sales Invoice", filters={ORDER_ID_FIELD: order_id})
- delivery_notes = frappe.db.get_list("Delivery Note", filters={ORDER_ID_FIELD: order_id})
-
- if sales_invoice:
- frappe.db.set_value("Sales Invoice", sales_invoice, ORDER_STATUS_FIELD, order_status)
-
- for dn in delivery_notes:
- frappe.db.set_value("Delivery Note", dn.name, ORDER_STATUS_FIELD, order_status)
-
- if not sales_invoice and not delivery_notes and sales_order.docstatus == 1:
- sales_order.cancel()
+
+ # Order exists, detect changes
+ sales_order = frappe.get_doc("Sales Order", sales_order_name)
+ changes = {}
+
+ # FIRST: Quick check for critical business changes (amounts, items)
+ # If these haven't changed, this is likely just a metadata/timeline update
+ # Skip expensive address/contact checks for metadata-only updates
+ has_critical_changes = False
+
+ # Compare grand total (with tolerance for floating point)
+ old_total = flt(sales_order.grand_total)
+ new_total = flt(order.get("total_price", 0))
+ if abs(old_total - new_total) > 0.01: # Only if difference > 1 cent
+ changes["grand_total"] = {
+ "old": old_total,
+ "new": new_total,
+ "difference": new_total - old_total
+ }
+ has_critical_changes = True
+
+ # Compare subtotal (with tolerance)
+ old_subtotal = flt(sales_order.total)
+ new_subtotal = flt(order.get("subtotal_price", 0))
+ if abs(old_subtotal - new_subtotal) > 0.01: # Only if difference > 1 cent
+ changes["subtotal"] = {
+ "old": old_subtotal,
+ "new": new_subtotal,
+ "difference": new_subtotal - old_subtotal
+ }
+ has_critical_changes = True
+
+ # Compare line items with improved matching and full details
+ line_item_changes = []
+ shopify_items_by_id = {str(item.get("id", "")): item for item in order.get("line_items", [])}
+ shopify_items_by_sku = {} # Group by SKU for fallback matching
+
+ for item in order.get("line_items", []):
+ sku = item.get("sku", "")
+ if sku:
+ if sku not in shopify_items_by_sku:
+ shopify_items_by_sku[sku] = []
+ shopify_items_by_sku[sku].append(item)
+
+ matched_shopify_ids = set()
+ matched_so_items = set() # Track which SO items we've matched
+
+ # Match existing Sales Order items with Shopify items
+ for idx, so_item in enumerate(sales_order.items):
+ item_code = so_item.item_code
+ shopify_item_id = str(so_item.get("shopify_line_item_id", ""))
+ matched_item = None
+ item_changes = {}
+
+ # Try matching by shopify_line_item_id first (most reliable)
+ if shopify_item_id and shopify_item_id in shopify_items_by_id:
+ matched_item = shopify_items_by_id[shopify_item_id]
+ matched_shopify_ids.add(shopify_item_id)
+ matched_so_items.add(idx)
+ else:
+ # Fallback: match by item_code/SKU + quantity + rate
+ so_sku = item_code
+ old_qty = cint(so_item.qty)
+ old_rate = flt(so_item.rate)
+
+ if so_sku in shopify_items_by_sku:
+ for shopify_item in shopify_items_by_sku[so_sku]:
+ shopify_item_id_str = str(shopify_item.get("id", ""))
+ if shopify_item_id_str in matched_shopify_ids:
+ continue
+
+ new_qty = cint(shopify_item.get("quantity", 0))
+ new_rate = flt(_get_item_price(shopify_item, order.get("taxes_included", False)))
+
+ # Match if quantity and rate are close (within tolerance)
+ if old_qty == new_qty and abs(old_rate - new_rate) <= 0.01:
+ matched_item = shopify_item
+ matched_shopify_ids.add(shopify_item_id_str)
+ matched_so_items.add(idx)
+ break
+
+ # If matched, compare details
+ if matched_item:
+ # Compare quantity
+ old_qty = cint(so_item.qty)
+ new_qty = cint(matched_item.get("quantity", 0))
+ if old_qty != new_qty:
+ item_changes["quantity"] = {"old": old_qty, "new": new_qty}
+
+ # Compare rate (with tolerance)
+ old_rate = flt(so_item.rate)
+ new_rate = flt(_get_item_price(matched_item, order.get("taxes_included", False)))
+ if abs(old_rate - new_rate) > 0.01:
+ item_changes["rate"] = {"old": old_rate, "new": new_rate}
+
+ # Compare item title/name (in case product name changed)
+ old_title = so_item.item_name or ""
+ new_title = matched_item.get("title", "") or matched_item.get("name", "")
+ if old_title != new_title:
+ item_changes["title"] = {"old": old_title, "new": new_title}
+
+ if item_changes:
+ line_item_changes.append({
+ "item_code": item_code,
+ "sku": matched_item.get("sku", ""),
+ "title": matched_item.get("title", ""),
+ "shopify_line_item_id": str(matched_item.get("id", "")),
+ "changes": item_changes
+ })
+
+ # Check for new items (items in Shopify that don't exist in Sales Order)
+ for shopify_item in order.get("line_items", []):
+ shopify_item_id_str = str(shopify_item.get("id", ""))
+ if shopify_item_id_str not in matched_shopify_ids:
+ # This is a genuinely new item
+ line_item_changes.append({
+ "action": "added",
+ "title": shopify_item.get("title", ""),
+ "sku": shopify_item.get("sku", ""),
+ "quantity": cint(shopify_item.get("quantity", 0)),
+ "rate": flt(_get_item_price(shopify_item, order.get("taxes_included", False))),
+ "shopify_line_item_id": shopify_item_id_str,
+ "product_id": shopify_item.get("product_id"),
+ "variant_id": shopify_item.get("variant_id"),
+ })
+
+ if line_item_changes:
+ changes["line_items"] = line_item_changes
+ has_critical_changes = True
+
+ # Track notes (but don't trigger logs for notes alone)
+ old_note = sales_order.get("note", "") or ""
+ new_note = order.get("note", "") or ""
+ if old_note.strip() != new_note.strip():
+ changes["note"] = {
+ "old": old_note,
+ "new": new_note
+ }
+ # Note: NOT setting has_critical_changes - notes won't trigger logs alone
+
+ # Track fulfillment status (but don't trigger logs for fulfillment status alone)
+ # Fulfillment status: unfulfilled, partial, fulfilled
+ old_fulfillment_status = ""
+ # Get from order fulfillments
+ if order.get("fulfillments"):
+ # Check if all items are fulfilled
+ total_quantity = sum(item.get("quantity", 0) for item in order.get("line_items", []))
+ fulfilled_quantity = sum(
+ sum(f_item.get("quantity", 0) for f_item in fulfillment.get("line_items", []))
+ for fulfillment in order.get("fulfillments", [])
+ )
+ if fulfilled_quantity == 0:
+ new_fulfillment_status = "unfulfilled"
+ elif fulfilled_quantity >= total_quantity:
+ new_fulfillment_status = "fulfilled"
+ else:
+ new_fulfillment_status = "partial"
+ else:
+ new_fulfillment_status = "unfulfilled"
+
+ # Track fulfillment status change
+ if new_fulfillment_status:
+ changes["fulfillment_status"] = {
+ "old": old_fulfillment_status or "unfulfilled",
+ "new": new_fulfillment_status
+ }
+ # Note: NOT setting has_critical_changes - fulfillment status won't trigger logs alone
+
+ # Track tags (but don't trigger logs for tags alone)
+ new_tags = order.get("tags", "") or "" # Tags are stored as comma-separated string in Shopify
+ if new_tags:
+ changes["tags"] = {
+ "new": new_tags
+ }
+ # Note: NOT setting has_critical_changes - tags won't trigger logs alone
+
+ # Track updated_at (but don't trigger logs for updated_at alone)
+ # updated_at is just a timestamp that changes on every update
+ updated_at = order.get("updated_at", "")
+ if updated_at:
+ changes["updated_at"] = {
+ "new": updated_at
+ }
+ # Note: NOT setting has_critical_changes - updated_at won't trigger logs alone
+
+ # Early exit: If no critical changes (amounts, items, notes, fulfillment), skip address/contact checks
+ # This filters out metadata-only updates (timeline events, emails, ParcelWILL, Katana, etc.)
+ # BUT: updated_at changes are NOT tracked - it's just a timestamp that changes on every update
+ if not has_critical_changes:
+ # Still check customer name change (important business change)
+ old_customer = sales_order.customer
+ if customer_id:
+ new_customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name")
+ if old_customer != new_customer and new_customer:
+ changes["customer"] = {
+ "old": old_customer,
+ "new": new_customer
+ }
+ has_critical_changes = True
+
+ # If still no critical changes, this is a metadata-only update - skip it entirely
+ # (ParcelWILL emails, Katana updates, timeline events, etc. don't change business data)
+ # Note: updated_at is NOT tracked - it's just a timestamp that changes on every update
+ if not has_critical_changes:
+ return
+
+ # Compare customer name (if we haven't already checked it above)
+ if "customer" not in changes:
+ old_customer = sales_order.customer
+ if customer_id:
+ new_customer = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name")
+ if old_customer != new_customer and new_customer:
+ changes["customer"] = {
+ "old": old_customer,
+ "new": new_customer
+ }
+
+ # Compare customer email
+ shopify_email = shopify_customer.get("email", "")
+ if shopify_email and sales_order.customer:
+ # Get customer email from ERPNext Contact (using Dynamic Link)
+ contact_filters = [
+ ["Dynamic Link", "link_doctype", "=", "Customer"],
+ ["Dynamic Link", "link_name", "=", sales_order.customer],
+ ["Dynamic Link", "parenttype", "=", "Contact"],
+ ]
+ contacts = frappe.get_all("Contact", filters=contact_filters, fields=["email_id"], limit=1)
+ old_email = contacts[0].get("email_id", "") if contacts else ""
+ if old_email != shopify_email:
+ changes["customer_email"] = {
+ "old": old_email,
+ "new": shopify_email
+ }
+
+ # Compare customer phone
+ shopify_phone = shopify_customer.get("phone", "") or shopify_customer.get("default_address", {}).get("phone", "")
+ if shopify_phone and sales_order.customer:
+ # Get customer phone from ERPNext Contact (using Dynamic Link)
+ contact_filters = [
+ ["Dynamic Link", "link_doctype", "=", "Customer"],
+ ["Dynamic Link", "link_name", "=", sales_order.customer],
+ ["Dynamic Link", "parenttype", "=", "Contact"],
+ ]
+ contacts = frappe.get_all("Contact", filters=contact_filters, fields=["phone", "mobile_no"], limit=1)
+ contact = contacts[0] if contacts else None
+ if contact:
+ old_phone = contact.get("phone", "") or contact.get("mobile_no", "")
+ if old_phone != shopify_phone:
+ changes["customer_phone"] = {
+ "old": old_phone,
+ "new": shopify_phone
+ }
+
+ # Compare shipping address
+ shipping_address = order.get("shipping_address", {})
+ if shipping_address and sales_order.customer:
+ # Get shipping address from Customer
+ shipping_addr_doc = _get_customer_address_by_type(
+ sales_order.customer, "Shipping"
+ )
+
+ if shipping_addr_doc:
+ old_addr = {
+ "address_line1": shipping_addr_doc.get("address_line1", ""),
+ "address_line2": shipping_addr_doc.get("address_line2", ""),
+ "city": shipping_addr_doc.get("city", ""),
+ "state": shipping_addr_doc.get("state", ""),
+ "pincode": shipping_addr_doc.get("pincode", ""),
+ "country": shipping_addr_doc.get("country", ""),
+ "phone": shipping_addr_doc.get("phone", ""),
+ }
+ new_addr = {
+ "address_line1": shipping_address.get("address1", ""),
+ "address_line2": shipping_address.get("address2", ""),
+ "city": shipping_address.get("city", ""),
+ "state": shipping_address.get("province", ""),
+ "pincode": shipping_address.get("zip", ""),
+ "country": shipping_address.get("country", ""),
+ "phone": shipping_address.get("phone", ""),
+ }
+
+ # Compare normalized addresses
+ old_normalized = _normalize_address_for_comparison(old_addr)
+ new_normalized = _normalize_address_for_comparison(new_addr)
+
+ if old_normalized != new_normalized:
+ changes["shipping_address"] = {
+ "old": old_addr,
+ "new": new_addr
+ }
+
+ # Compare billing address
+ # IMPORTANT: Only use shipping address if billing_address is explicitly null/empty
+ # (Shopify indicates "same as shipping" by having billing_address as null)
+ billing_address = order.get("billing_address")
+ billing_is_same_as_shipping = False
+
+ # Check if billing is explicitly null/empty (Shopify "same as shipping" case)
+ if billing_address is None or (isinstance(billing_address, dict) and not any(billing_address.values())):
+ # Billing is same as shipping - use shipping address
+ if shipping_address:
+ billing_address = shipping_address
+ billing_is_same_as_shipping = True
+ # Fallback to customer default address only if no shipping
+ elif not billing_address:
+ billing_address = shopify_customer.get("default_address", {})
+
+ if billing_address and sales_order.customer:
+ # Get billing address from Customer
+ billing_addr_doc = _get_customer_address_by_type(
+ sales_order.customer, "Billing"
+ )
+
+ if billing_addr_doc:
+ old_addr = {
+ "address_line1": billing_addr_doc.get("address_line1", ""),
+ "address_line2": billing_addr_doc.get("address_line2", ""),
+ "city": billing_addr_doc.get("city", ""),
+ "state": billing_addr_doc.get("state", ""),
+ "pincode": billing_addr_doc.get("pincode", ""),
+ "country": billing_addr_doc.get("country", ""),
+ "phone": billing_addr_doc.get("phone", ""),
+ }
+ new_addr = {
+ "address_line1": billing_address.get("address1", ""),
+ "address_line2": billing_address.get("address2", ""),
+ "city": billing_address.get("city", ""),
+ "state": billing_address.get("province", ""),
+ "pincode": billing_address.get("zip", ""),
+ "country": billing_address.get("country", ""),
+ "phone": billing_address.get("phone", ""),
+ }
+
+ # Compare normalized addresses
+ old_normalized = _normalize_address_for_comparison(old_addr)
+ new_normalized = _normalize_address_for_comparison(new_addr)
+
+ if old_normalized != new_normalized:
+ changes["billing_address"] = {
+ "old": old_addr,
+ "new": new_addr,
+ "is_same_as_shipping": billing_is_same_as_shipping
+ }
+
+ # Skip logging if no changes detected
+ if not changes:
+ return
+
+ # Process the update
+ if sales_order.docstatus == 2: # Cancelled
+ frappe.db.set_value("Sales Order", sales_order_name, ORDER_STATUS_FIELD, order.get("financial_status"))
+ create_shopify_log(
+ status="Invalid",
+ message=f"Cannot update cancelled Sales Order {sales_order_name}. Order {order_number} status updated.",
+ request_data=order,
+ response_data={"change_details": changes}
+ )
+ return
+
+ # Update customer if changed - handle billing address properly
+ shopify_customer = order.get("customer") if order.get("customer") is not None else {}
+ shipping_address = order.get("shipping_address", {})
+ billing_address = order.get("billing_address")
+
+ # IMPORTANT: Only use shipping address if billing_address is explicitly null/empty
+ # (Shopify indicates "same as shipping" by having billing_address as null)
+ if billing_address is None or (isinstance(billing_address, dict) and not any(billing_address.values())):
+ # Billing is same as shipping - use shipping address
+ if shipping_address:
+ billing_address = shipping_address
+ # Fallback to customer default address only if no shipping
+ elif not billing_address:
+ billing_address = shopify_customer.get("default_address", {})
+
+ shopify_customer["billing_address"] = billing_address
+ shopify_customer["shipping_address"] = shipping_address
+
+ if customer_id:
+ customer = ShopifyCustomer(customer_id=customer_id)
+ if not customer.is_synced():
+ customer.sync_customer(customer=shopify_customer)
+ else:
+ customer.update_existing_addresses(shopify_customer)
+
+ # Ensure items exist
+ create_items_if_not_exist(order)
+
+ # Get updated items and taxes
+ setting = frappe.get_doc(SETTING_DOCTYPE)
+ items = get_order_items(
+ order.get("line_items"),
+ setting,
+ getdate(order.get("created_at")) or getdate(sales_order.transaction_date),
+ taxes_inclusive=order.get("taxes_included"),
+ store_name=store_name,
+ )
+
+ if not items:
+ create_shopify_log(
+ status="Error",
+ message="Cannot update order: items not found in product master",
+ request_data=order,
+ response_data={"change_details": changes},
+ rollback=True
+ )
+ return
+
+ taxes = get_order_taxes(order, setting, items)
+
+ # Update customer name
+ customer_name = setting.default_customer
+ if shopify_customer.get("id"):
+ customer_name = frappe.db.get_value("Customer", {CUSTOMER_ID_FIELD: customer_id}, "name") or setting.default_customer
+
+ # Determine if we have real business changes (not just metadata)
+ # Track: prices, line items, contact details, addresses, customer name
+ # Note: tags, notes, fulfillment_status, updated_at are tracked but DON'T trigger logs alone
+ has_real_changes = any(
+ key in changes for key in [
+ "grand_total", "subtotal", "line_items",
+ "customer", "customer_email", "customer_phone",
+ "shipping_address", "billing_address"
+ ]
+ )
+
+ # Update the order
+ if sales_order.docstatus == 1: # Submitted
+ # For submitted orders, only update status and notes if there are REAL changes
+ # Don't log if only metadata changed (fulfillment status, tracking, updated_at, etc.)
+ if has_real_changes:
+ frappe.db.set_value("Sales Order", sales_order_name, ORDER_STATUS_FIELD, order.get("financial_status"))
+
+ if order.get("note"):
+ sales_order.add_comment(text=f"Order Note Updated: {order.get('note')}")
+
+ create_shopify_log(
+ status="Success",
+ message=f"Order {order_number} updated (status and notes only). Items/taxes require manual update.",
+ request_data=order,
+ response_data={"change_details": changes}
+ )
+ else:
+ # If no real changes, silently return (metadata-only update)
+ return
else:
- frappe.db.set_value("Sales Order", sales_order.name, ORDER_STATUS_FIELD, order_status)
+ # Draft order - can update fully
+ sales_order.update({
+ "customer": customer_name,
+ "transaction_date": getdate(order.get("created_at")) or sales_order.transaction_date,
+ "delivery_date": getdate(order.get("created_at")) or sales_order.delivery_date,
+ "items": items,
+ "taxes": taxes,
+ })
+
+ if order.get("name") != sales_order.get(ORDER_NUMBER_FIELD):
+ sales_order.set(ORDER_NUMBER_FIELD, order.get("name"))
+
+ sales_order.flags.ignore_mandatory = True
+ sales_order.flags.shopiy_order_json = json.dumps(order)
+ sales_order.save(ignore_permissions=True)
+
+ if order.get("note"):
+ sales_order.add_comment(text=f"Order Note: {order.get('note')}")
+
+ create_shopify_log(
+ status="Success",
+ message=f"Order {order_number} updated successfully with {len(changes)} change(s) detected",
+ request_data=order,
+ response_data={"change_details": changes}
+ )
+ # Trigger update detection on all linked documents (only if real changes detected)
+ if sales_order_name and has_real_changes:
+ try:
+ old_form_dict = dict(frappe.form_dict)
+ frappe.form_dict["doctype"] = "Sales Order"
+ frappe.form_dict["docname"] = sales_order_name
+
+ if frappe.db.exists("Server Script", "Get Shopify Order Updates"):
+ script_doc = frappe.get_doc("Server Script", "Get Shopify Order Updates")
+ script_doc.execute_method()
+
+ detection_result = frappe.response.get("message") or {}
+ detection_has_changes = detection_result.get("has_changes", False)
+
+ if detection_has_changes:
+ frappe.logger().info(
+ "Shopify update detection: Changes flagged on all linked docs for SO {}".format(
+ sales_order_name
+ )
+ )
+
+ frappe.form_dict = old_form_dict
+ except Exception as detection_error:
+ frappe.logger().error(
+ "Shopify update detection failed for SO {}: {}".format(
+ sales_order_name, str(detection_error)
+ )
+ )
+ frappe.form_dict = old_form_dict if "old_form_dict" in locals() else frappe.form_dict
+
except Exception as e:
- create_shopify_log(status="Error", exception=e)
- else:
- create_shopify_log(status="Success")
+ create_shopify_log(
+ status="Error",
+ exception=e,
+ message=f"Failed to update order {order.get('name', 'Unknown')}: {str(e)}",
+ request_data=order,
+ response_data={"error": str(e)},
+ rollback=True
+ )
@temp_shopify_session
def sync_old_orders():
- shopify_setting = frappe.get_cached_doc(SETTING_DOCTYPE)
- if not cint(shopify_setting.sync_old_orders):
- return
+ 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)
+ 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
- )
- sync_sales_order(order, request_id=log.name)
+ 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)
- shopify_setting = frappe.get_doc(SETTING_DOCTYPE)
- shopify_setting.sync_old_orders = 0
- shopify_setting.save()
+ shopify_setting = frappe.get_doc(SETTING_DOCTYPE)
+ shopify_setting.sync_old_orders = 0
+ shopify_setting.save()
def _fetch_old_orders(from_time, to_time):
- """Fetch all shopify orders in specified range and return an iterator on fetched orders."""
-
- from_time = get_datetime(from_time).astimezone().isoformat()
- to_time = get_datetime(to_time).astimezone().isoformat()
- orders_iterator = PaginatedIterator(
- Order.find(created_at_min=from_time, created_at_max=to_time, limit=250)
- )
-
- for orders in orders_iterator:
- for order in orders:
- # Using generator instead of fetching all at once is better for
- # avoiding rate limits and reducing resource usage.
- yield order.to_dict()
+ from_time = get_datetime(from_time).astimezone().isoformat()
+ to_time = get_datetime(to_time).astimezone().isoformat()
+ orders_iterator = PaginatedIterator(
+ Order.find(created_at_min=from_time, created_at_max=to_time, limit=250)
+ )
+
+ for orders in orders_iterator:
+ for order in orders:
+ yield order.to_dict()
\ No newline at end of file
diff --git a/ecommerce_integrations/shopify/product.py b/ecommerce_integrations/shopify/product.py
index 92c31f467..effa57cf8 100644
--- a/ecommerce_integrations/shopify/product.py
+++ b/ecommerce_integrations/shopify/product.py
@@ -1,570 +1,654 @@
-from typing import Optional
-
-import frappe
-from frappe import _, msgprint
-from frappe.utils import cint, cstr
-from frappe.utils.nestedset import get_root_of
-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.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
-
-
-class ShopifyProduct:
- def __init__(
- self,
- product_id: str,
- variant_id: str | None = None,
- sku: str | None = None,
- has_variants: int | None = 0,
- ):
- 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)
-
- if not self.setting.is_enabled():
- frappe.throw(_("Can not create Shopify product when integration is disabled."))
-
- def is_synced(self) -> bool:
- return ecommerce_item.is_synced(
- MODULE_NAME,
- integration_item_code=self.product_id,
- variant_id=self.variant_id,
- sku=self.sku,
- )
-
- def get_erpnext_item(self):
- return ecommerce_item.get_erpnext_item(
- MODULE_NAME,
- integration_item_code=self.product_id,
- variant_id=self.variant_id,
- sku=self.sku,
- 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)
-
- def _make_item(self, product_dict):
- _add_weight_details(product_dict)
-
- warehouse = self.setting.warehouse
-
- if _has_variants(product_dict):
- self.has_variants = 1
- attributes = self._create_attribute(product_dict)
- self._create_item(product_dict, warehouse, 1, attributes)
- self._create_item_variants(product_dict, warehouse, attributes)
-
- else:
- product_dict["variant_id"] = product_dict["variants"][0]["id"]
- self._create_item(product_dict, warehouse)
-
- def _create_attribute(self, product_dict):
- attribute = []
- for attr in product_dict.get("options"):
- if not frappe.db.get_value("Item Attribute", attr.get("name"), "name"):
- frappe.get_doc(
- {
- "doctype": "Item Attribute",
- "attribute_name": attr.get("name"),
- "item_attribute_values": [
- {"attribute_value": attr_value, "abbr": attr_value}
- for attr_value in attr.get("values")
- ],
- }
- ).insert()
- attribute.append({"attribute": attr.get("name")})
-
- else:
- # check for attribute values
- item_attr = frappe.get_doc("Item Attribute", attr.get("name"))
- if not item_attr.numeric_values:
- self._set_new_attribute_values(item_attr, attr.get("values"))
- item_attr.save()
- attribute.append({"attribute": attr.get("name")})
-
- else:
- attribute.append(
- {
- "attribute": attr.get("name"),
- "from_range": item_attr.get("from_range"),
- "to_range": item_attr.get("to_range"),
- "increment": item_attr.get("increment"),
- "numeric_values": item_attr.get("numeric_values"),
- }
- )
-
- return attribute
-
- def _set_new_attribute_values(self, item_attr, values):
- for attr_value in values:
- if not any(
- (d.abbr.lower() == attr_value.lower() or d.attribute_value.lower() == attr_value.lower())
- for d in item_attr.item_attribute_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):
- item_dict = {
- "variant_of": variant_of,
- "is_stock_item": 1,
- "item_code": cstr(product_dict.get("item_code")) or cstr(product_dict.get("id")),
- "item_name": product_dict.get("title", "").strip(),
- "description": product_dict.get("body_html") or product_dict.get("title"),
- "item_group": self._get_item_group(product_dict.get("product_type")),
- "has_variants": has_variant,
- "attributes": attributes or [],
- "stock_uom": product_dict.get("uom") or _("Nos"),
- "sku": product_dict.get("sku") or _get_sku(product_dict),
- "default_warehouse": warehouse,
- "image": _get_item_image(product_dict),
- "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),
- }
-
- 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"]
-
- if not _match_sku_and_link_item(
- item_dict, integration_item_code, variant_id, variant_of=variant_of, has_variant=has_variant
- ):
- ecommerce_item.create_ecommerce_item(
- MODULE_NAME,
- integration_item_code,
- item_dict,
- variant_id=variant_id,
- sku=sku,
- variant_of=variant_of,
- has_variants=has_variant,
- )
-
- def _create_item_variants(self, product_dict, warehouse, attributes):
- template_item = ecommerce_item.get_erpnext_item(
- MODULE_NAME, integration_item_code=product_dict.get("id"), has_variants=1
- )
-
- if template_item:
- for variant in product_dict.get("variants"):
- shopify_item_variant = {
- "id": product_dict.get("id"),
- "variant_id": variant.get("id"),
- "item_code": variant.get("id"),
- "title": product_dict.get("title", "").strip() + "-" + variant.get("title"),
- "product_type": product_dict.get("product_type"),
- "sku": variant.get("sku"),
- "uom": template_item.stock_uom or _("Nos"),
- "item_price": variant.get("price"),
- "weight_unit": variant.get("weight_unit"),
- "weight": variant.get("weight"),
- }
-
- for i, variant_attr in enumerate(SHOPIFY_VARIANTS_ATTR_LIST):
- if variant.get(variant_attr):
- attributes[i].update(
- {
- "attribute_value": self._get_attribute_value(
- variant.get(variant_attr), attributes[i]
- )
- }
- )
- self._create_item(shopify_item_variant, warehouse, 0, attributes, template_item.name)
-
- def _get_attribute_value(self, variant_attr_val, attribute):
- attribute_value = frappe.db.sql(
- """select attribute_value from `tabItem Attribute Value`
- where parent = %s and (abbr = %s or attribute_value = %s)""",
- (attribute["attribute"], variant_attr_val, variant_attr_val),
- as_list=1,
- )
- return attribute_value[0][0] if len(attribute_value) > 0 else cint(variant_attr_val)
-
- def _get_item_group(self, product_type=None):
- parent_item_group = get_root_of("Item Group")
-
- if not product_type:
- return parent_item_group
-
- if frappe.db.get_value("Item Group", product_type, "name"):
- return product_type
- item_group = frappe.get_doc(
- {
- "doctype": "Item Group",
- "item_group_name": product_type,
- "parent_item_group": parent_item_group,
- "is_group": "No",
- }
- ).insert()
- return item_group.name
-
- def _get_supplier(self, product_dict):
- if product_dict.get("vendor"):
- supplier = frappe.db.sql(
- f"""select name from tabSupplier
- where name = %s or {SUPPLIER_ID_FIELD} = %s """,
- (product_dict.get("vendor"), product_dict.get("vendor").lower()),
- as_list=1,
- )
-
- if supplier:
- return product_dict.get("vendor")
- supplier = frappe.get_doc(
- {
- "doctype": "Supplier",
- "supplier_name": product_dict.get("vendor"),
- SUPPLIER_ID_FIELD: product_dict.get("vendor").lower(),
- "supplier_group": self._get_supplier_group(),
- }
- ).insert()
- return supplier.name
- else:
- return ""
-
- def _get_supplier_group(self):
- supplier_group = frappe.db.get_value("Supplier Group", _("Shopify Supplier"))
- if not supplier_group:
- supplier_group = frappe.get_doc(
- {"doctype": "Supplier Group", "supplier_group_name": _("Shopify Supplier")}
- ).insert()
- return supplier_group.name
- return supplier_group
-
-
-def _add_weight_details(product_dict):
- variants = product_dict.get("variants")
- if variants:
- product_dict["weight"] = variants[0]["weight"]
- product_dict["weight_unit"] = variants[0]["weight_unit"]
-
-
-def _has_variants(product_dict) -> bool:
- options = product_dict.get("options")
- return bool(options and "Default Title" not in options[0]["values"])
-
-
-def _get_sku(product_dict):
- if product_dict.get("variants"):
- return product_dict.get("variants")[0].get("sku")
- return ""
-
-
-def _get_item_image(product_dict):
- if product_dict.get("image"):
- return product_dict.get("image").get("src")
- return None
-
-
-def _match_sku_and_link_item(item_dict, product_id, variant_id, variant_of=None, has_variant=False) -> bool:
- """Tries to match new item with existing item using Shopify SKU == item_code.
-
- Returns true if matched and linked.
- """
- sku = item_dict["sku"]
- if not sku or variant_of or has_variant:
- return False
-
- item_name = frappe.db.get_value("Item", {"item_code": sku})
- if item_name:
- try:
- ecommerce_item = frappe.get_doc(
- {
- "doctype": "Ecommerce Item",
- "integration": MODULE_NAME,
- "erpnext_item_code": item_name,
- "integration_item_code": product_id,
- "has_variants": 0,
- "variant_id": cstr(variant_id),
- "sku": sku,
- }
- )
-
- ecommerce_item.insert()
- return True
- except Exception:
- return False
-
-
-def create_items_if_not_exist(order):
- """Using shopify order, sync all items that are not already synced."""
- for item in order.get("line_items", []):
- product_id = item["product_id"]
- variant_id = item.get("variant_id")
- sku = item.get("sku")
- product = ShopifyProduct(product_id, variant_id=variant_id, sku=sku)
-
- if not product.is_synced():
- product.sync_product()
-
-
-def get_item_code(shopify_item):
- """Get item code using shopify_item dict.
-
- Item should contain both product_id and variant_id."""
-
- item = ecommerce_item.get_erpnext_item(
- integration=MODULE_NAME,
- integration_item_code=shopify_item.get("product_id"),
- variant_id=shopify_item.get("variant_id"),
- sku=shopify_item.get("sku"),
- )
- if 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`.
-
- New items are pushed to shopify and changes to existing items are
- updated depending on what is configured in "Shopify Setting" doctype.
- """
- template_item = item = doc # alias for readability
- # a new item recieved from ecommerce_integrations is being inserted
- if item.flags.from_integration:
- return
-
- setting = frappe.get_doc(SETTING_DOCTYPE)
-
- if not setting.is_enabled() or not setting.upload_erpnext_items:
- return
-
- if frappe.flags.in_import:
- return
-
- if item.has_variants:
- return
-
- if len(item.attributes) > 3:
- msgprint(_("Template items/Items with 4 or more attributes can not be uploaded to Shopify."))
- return
-
- if doc.variant_of and not setting.upload_variants_as_items:
- msgprint(_("Enable variant sync in setting to upload item to Shopify."))
- return
-
- if item.variant_of:
- template_item = frappe.get_doc("Item", item.variant_of)
-
- product_id = frappe.db.get_value(
- "Ecommerce Item",
- {"erpnext_item_code": template_item.name, "integration": MODULE_NAME},
- "integration_item_code",
- )
- 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()
-
- 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:
- 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"
- ),
- }
- )
- 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)
-
- write_upload_log(status=is_successful, product=product, item=item, action="Updated")
-
-
-def map_erpnext_variant_to_shopify_variant(shopify_product: Product, erpnext_item, variant_attributes):
- variant_product_id = frappe.db.get_value(
- "Ecommerce Item",
- {"erpnext_item_code": erpnext_item.name, "integration": MODULE_NAME},
- "integration_item_code",
- )
- if not variant_product_id:
- for variant in shopify_product.variants:
- if (
- variant.option1 == variant_attributes.get("option1")
- and variant.option2 == variant_attributes.get("option2")
- and variant.option3 == variant_attributes.get("option3")
- ):
- variant_product_id = str(variant.id)
- if not frappe.flags.in_test:
- frappe.get_doc(
- {
- "doctype": "Ecommerce Item",
- "erpnext_item_code": erpnext_item.name,
- "integration": MODULE_NAME,
- "integration_item_code": str(shopify_product.id),
- "variant_id": variant_product_id,
- "sku": str(variant.sku),
- "variant_of": erpnext_item.variant_of,
- }
- ).insert()
- break
- if not variant_product_id:
- msgprint(_("Shopify: Couldn't sync item variant."))
- return variant_product_id
-
-
-def map_erpnext_item_to_shopify(shopify_product: Product, erpnext_item):
- """Map erpnext fields to shopify, called both when updating and creating new products."""
-
- shopify_product.title = erpnext_item.item_name
- shopify_product.body_html = erpnext_item.description
- shopify_product.product_type = erpnext_item.item_group
-
- if erpnext_item.weight_uom in WEIGHT_TO_ERPNEXT_UOM_MAP.values():
- # reverse lookup for key
- uom = get_shopify_weight_uom(erpnext_weight_uom=erpnext_item.weight_uom)
- shopify_product.weight = erpnext_item.weight_per_unit
- shopify_product.weight_unit = uom
-
- if erpnext_item.disabled:
- shopify_product.status = "draft"
- shopify_product.published = False
- msgprint(_("Status of linked Shopify product is changed to Draft."))
-
-
-def get_shopify_weight_uom(erpnext_weight_uom: str) -> str:
- for shopify_uom, erpnext_uom in WEIGHT_TO_ERPNEXT_UOM_MAP.items():
- if erpnext_uom == erpnext_weight_uom:
- return shopify_uom
-
-
-def update_default_variant_properties(
- shopify_product: Product,
- is_stock_item: bool,
- sku: str | None = None,
- price: float | None = None,
-):
- """Shopify creates default variant upon saving the product.
-
- Some item properties are supposed to be updated on the default variant.
- Input: saved shopify_product, sku and price
- """
- default_variant: Variant = shopify_product.variants[0]
-
- # this will create Inventory item and qty will be updated by scheduled job.
- if is_stock_item:
- default_variant.inventory_management = "shopify"
-
- if price is not None:
- default_variant.price = price
- if sku is not None:
- default_variant.sku = sku
-
-
-def write_upload_log(status: bool, product: Product, item, action="Created") -> None:
- if not status:
- msg = _("Failed to upload item to Shopify") + "
"
- msg += _("Shopify reported errors:") + " " + ", ".join(product.errors.full_messages())
- msgprint(msg, title="Note", indicator="orange")
-
- create_shopify_log(
- status="Error",
- request_data=product.to_dict(),
- message=msg,
- method="upload_erpnext_item",
- )
- else:
- create_shopify_log(
- status="Success",
- request_data=product.to_dict(),
- message=f"{action} Item: {item.name}, shopify product: {product.id}",
- method="upload_erpnext_item",
- )
+from typing import Optional
+
+import frappe
+from frappe import _, msgprint
+from frappe.utils import cint, cstr
+from frappe.utils.nestedset import get_root_of
+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.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
+
+
+def get_shopify_fallback_item(title):
+ """Map Shopify line items without product_id to appropriate fallback items.
+
+ Args:
+ title (str): Line item title from Shopify
+
+ Returns:
+ str: ERPNext Item code
+ """
+ if not title:
+ return "SHOPIFY-MISC"
+
+ title_lower = title.lower()
+
+ # Map based on title keywords
+ if "tip" in title_lower:
+ return "SHOPIFY-TIP"
+ elif "sample" in title_lower:
+ return "SHOPIFY-SAMPLE"
+ elif "rush" in title_lower or "rush order" in title_lower or "rush fee" in title_lower:
+ return "SHOPIFY-RUSH-FEE"
+ elif "adjustment" in title_lower or "price adjustment" in title_lower:
+ return "SHOPIFY-ADJUSTMENT"
+ else:
+ return "SHOPIFY-MISC"
+
+
+class ShopifyProduct:
+ def __init__(
+ self,
+ product_id: str,
+ variant_id: str | None = None,
+ sku: str | None = None,
+ has_variants: int | None = 0,
+ ):
+ 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)
+
+ if not self.setting.is_enabled():
+ frappe.throw(_("Can not create Shopify product when integration is disabled."))
+
+ def is_synced(self) -> bool:
+ return ecommerce_item.is_synced(
+ MODULE_NAME,
+ integration_item_code=self.product_id,
+ variant_id=self.variant_id,
+ sku=self.sku,
+ )
+
+ def get_erpnext_item(self):
+ return ecommerce_item.get_erpnext_item(
+ MODULE_NAME,
+ integration_item_code=self.product_id,
+ variant_id=self.variant_id,
+ sku=self.sku,
+ 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)
+
+ def _make_item(self, product_dict):
+ _add_weight_details(product_dict)
+
+ warehouse = self.setting.warehouse
+
+ if _has_variants(product_dict):
+ self.has_variants = 1
+ attributes = self._create_attribute(product_dict)
+ self._create_item(product_dict, warehouse, 1, attributes)
+ self._create_item_variants(product_dict, warehouse, attributes)
+
+ else:
+ product_dict["variant_id"] = product_dict["variants"][0]["id"]
+ self._create_item(product_dict, warehouse)
+
+ def _create_attribute(self, product_dict):
+ attribute = []
+ for attr in product_dict.get("options"):
+ if not frappe.db.get_value("Item Attribute", attr.get("name"), "name"):
+ frappe.get_doc(
+ {
+ "doctype": "Item Attribute",
+ "attribute_name": attr.get("name"),
+ "item_attribute_values": [
+ {"attribute_value": attr_value, "abbr": attr_value}
+ for attr_value in attr.get("values")
+ ],
+ }
+ ).insert()
+ attribute.append({"attribute": attr.get("name")})
+
+ else:
+ # check for attribute values
+ item_attr = frappe.get_doc("Item Attribute", attr.get("name"))
+ if not item_attr.numeric_values:
+ self._set_new_attribute_values(item_attr, attr.get("values"))
+ item_attr.save()
+ attribute.append({"attribute": attr.get("name")})
+
+ else:
+ attribute.append(
+ {
+ "attribute": attr.get("name"),
+ "from_range": item_attr.get("from_range"),
+ "to_range": item_attr.get("to_range"),
+ "increment": item_attr.get("increment"),
+ "numeric_values": item_attr.get("numeric_values"),
+ }
+ )
+
+ return attribute
+
+ def _set_new_attribute_values(self, item_attr, values):
+ for attr_value in values:
+ if not any(
+ (d.abbr.lower() == attr_value.lower() or d.attribute_value.lower() == attr_value.lower())
+ for d in item_attr.item_attribute_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):
+ item_dict = {
+ "variant_of": variant_of,
+ "is_stock_item": 1,
+ "item_code": cstr(product_dict.get("item_code")) or cstr(product_dict.get("id")),
+ "item_name": product_dict.get("title", "").strip(),
+ "description": product_dict.get("body_html") or product_dict.get("title"),
+ "item_group": self._get_item_group(product_dict.get("product_type")),
+ "has_variants": has_variant,
+ "attributes": attributes or [],
+ "stock_uom": product_dict.get("uom") or _("Nos"),
+ "sku": product_dict.get("sku") or _get_sku(product_dict),
+ "default_warehouse": warehouse,
+ "image": _get_item_image(product_dict),
+ "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),
+ }
+
+ 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"]
+
+ if not _match_sku_and_link_item(
+ item_dict, integration_item_code, variant_id, variant_of=variant_of, has_variant=has_variant
+ ):
+ ecommerce_item.create_ecommerce_item(
+ MODULE_NAME,
+ integration_item_code,
+ item_dict,
+ variant_id=variant_id,
+ sku=sku,
+ variant_of=variant_of,
+ has_variants=has_variant,
+ )
+
+ def _create_item_variants(self, product_dict, warehouse, attributes):
+ template_item = ecommerce_item.get_erpnext_item(
+ MODULE_NAME, integration_item_code=product_dict.get("id"), has_variants=1
+ )
+
+ if template_item:
+ for variant in product_dict.get("variants"):
+ shopify_item_variant = {
+ "id": product_dict.get("id"),
+ "variant_id": variant.get("id"),
+ "item_code": variant.get("id"),
+ "title": product_dict.get("title", "").strip() + "-" + variant.get("title"),
+ "product_type": product_dict.get("product_type"),
+ "sku": variant.get("sku"),
+ "uom": template_item.stock_uom or _("Nos"),
+ "item_price": variant.get("price"),
+ "weight_unit": variant.get("weight_unit"),
+ "weight": variant.get("weight"),
+ }
+
+ for i, variant_attr in enumerate(SHOPIFY_VARIANTS_ATTR_LIST):
+ if variant.get(variant_attr):
+ attributes[i].update(
+ {
+ "attribute_value": self._get_attribute_value(
+ variant.get(variant_attr), attributes[i]
+ )
+ }
+ )
+ self._create_item(shopify_item_variant, warehouse, 0, attributes, template_item.name)
+
+ def _get_attribute_value(self, variant_attr_val, attribute):
+ attribute_value = frappe.db.sql(
+ """select attribute_value from `tabItem Attribute Value`
+ where parent = %s and (abbr = %s or attribute_value = %s)""",
+ (attribute["attribute"], variant_attr_val, variant_attr_val),
+ as_list=1,
+ )
+ return attribute_value[0][0] if len(attribute_value) > 0 else cint(variant_attr_val)
+
+ def _get_item_group(self, product_type=None):
+ parent_item_group = get_root_of("Item Group")
+
+ if not product_type:
+ return parent_item_group
+
+ if frappe.db.get_value("Item Group", product_type, "name"):
+ return product_type
+ item_group = frappe.get_doc(
+ {
+ "doctype": "Item Group",
+ "item_group_name": product_type,
+ "parent_item_group": parent_item_group,
+ "is_group": "No",
+ }
+ ).insert()
+ return item_group.name
+
+ def _get_supplier(self, product_dict):
+ if product_dict.get("vendor"):
+ supplier = frappe.db.sql(
+ f"""select name from tabSupplier
+ where name = %s or {SUPPLIER_ID_FIELD} = %s """,
+ (product_dict.get("vendor"), product_dict.get("vendor").lower()),
+ as_list=1,
+ )
+
+ if supplier:
+ return product_dict.get("vendor")
+ supplier = frappe.get_doc(
+ {
+ "doctype": "Supplier",
+ "supplier_name": product_dict.get("vendor"),
+ SUPPLIER_ID_FIELD: product_dict.get("vendor").lower(),
+ "supplier_group": self._get_supplier_group(),
+ }
+ ).insert()
+ return supplier.name
+ else:
+ return ""
+
+ def _get_supplier_group(self):
+ supplier_group = frappe.db.get_value("Supplier Group", _("Shopify Supplier"))
+ if not supplier_group:
+ supplier_group = frappe.get_doc(
+ {"doctype": "Supplier Group", "supplier_group_name": _("Shopify Supplier")}
+ ).insert()
+ return supplier_group.name
+ return supplier_group
+
+
+def _add_weight_details(product_dict):
+ variants = product_dict.get("variants")
+ if variants:
+ product_dict["weight"] = variants[0]["weight"]
+ product_dict["weight_unit"] = variants[0]["weight_unit"]
+
+
+def _has_variants(product_dict) -> bool:
+ options = product_dict.get("options")
+ return bool(options and "Default Title" not in options[0]["values"])
+
+
+def _get_sku(product_dict):
+ if product_dict.get("variants"):
+ return product_dict.get("variants")[0].get("sku")
+ return ""
+
+
+def _get_item_image(product_dict):
+ if product_dict.get("image"):
+ return product_dict.get("image").get("src")
+ return None
+
+
+def _match_sku_and_link_item(item_dict, product_id, variant_id, variant_of=None, has_variant=False) -> bool:
+ """Tries to match new item with existing item using Shopify SKU == item_code or product_id.
+
+ Returns true if matched and linked.
+ """
+ sku = item_dict["sku"]
+ if variant_of or has_variant:
+ return False
+
+ # Try matching by SKU first
+ if sku:
+ item_name = frappe.db.get_value("Item", {"item_code": sku})
+ if item_name:
+ try:
+ ecommerce_item = frappe.get_doc(
+ {
+ "doctype": "Ecommerce Item",
+ "integration": MODULE_NAME,
+ "erpnext_item_code": item_name,
+ "integration_item_code": product_id,
+ "has_variants": 0,
+ "variant_id": cstr(variant_id),
+ "sku": sku,
+ }
+ )
+ ecommerce_item.insert()
+ return True
+ except Exception:
+ pass
+
+ # Also try matching by product_id as item_code
+ item_name = frappe.db.get_value("Item", {"item_code": product_id})
+ if item_name:
+ try:
+ ecommerce_item = frappe.get_doc(
+ {
+ "doctype": "Ecommerce Item",
+ "integration": MODULE_NAME,
+ "erpnext_item_code": item_name,
+ "integration_item_code": product_id,
+ "has_variants": 0,
+ "variant_id": cstr(variant_id),
+ "sku": sku or "",
+ }
+ )
+ ecommerce_item.insert()
+ return True
+ except Exception:
+ return False
+
+ return False
+
+
+def create_items_if_not_exist(order):
+ """Using shopify order, sync all items that are not already synced."""
+ for item in order.get("line_items", []):
+ product_id = item.get("product_id")
+ variant_id = item.get("variant_id")
+ sku = item.get("sku")
+
+ # Skip items with null product_id - mapped to fallback items
+ if not product_id:
+ continue
+
+ try:
+ product = ShopifyProduct(product_id, variant_id=variant_id, sku=sku)
+ if not product.is_synced():
+ product.sync_product()
+ except frappe.DuplicateEntryError:
+ frappe.logger().info(f"Item {product_id} already exists, skipping")
+ continue
+ except Exception as e:
+ frappe.logger().error(f"Error syncing item {product_id}: {str(e)}")
+ if "IntegrityError" not in str(e):
+ raise
+ continue
+
+
+def get_item_code(shopify_item):
+ """Get item code using shopify_item dict.
+
+ Item should contain both product_id and variant_id."""
+
+ product_id = shopify_item.get("product_id")
+ variant_id = shopify_item.get("variant_id")
+ sku = shopify_item.get("sku")
+ title = shopify_item.get("title", "")
+
+ # Handle items without product_id (tips, samples, fees)
+ if not product_id:
+ fallback_item = get_shopify_fallback_item(title)
+
+ frappe.logger().info(
+ f"Line item '{title}' has no product_id - mapped to: {fallback_item}"
+ )
+
+ if frappe.db.exists("Item", fallback_item):
+ return fallback_item
+ else:
+ frappe.throw(
+ f"Fallback item '{fallback_item}' not found for '{title}'"
+ )
+
+ # Original logic continues
+ item = ecommerce_item.get_erpnext_item(
+ integration=MODULE_NAME,
+ integration_item_code=product_id,
+ variant_id=variant_id,
+ sku=sku,
+ )
+ if 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`.
+
+ New items are pushed to shopify and changes to existing items are
+ updated depending on what is configured in "Shopify Setting" doctype.
+ """
+ template_item = item = doc # alias for readability
+ # a new item recieved from ecommerce_integrations is being inserted
+ if item.flags.from_integration:
+ return
+
+ setting = frappe.get_doc(SETTING_DOCTYPE)
+
+ if not setting.is_enabled() or not setting.upload_erpnext_items:
+ return
+
+ if frappe.flags.in_import:
+ return
+
+ if item.has_variants:
+ return
+
+ if len(item.attributes) > 3:
+ msgprint(_("Template items/Items with 4 or more attributes can not be uploaded to Shopify."))
+ return
+
+ if doc.variant_of and not setting.upload_variants_as_items:
+ msgprint(_("Enable variant sync in setting to upload item to Shopify."))
+ return
+
+ if item.variant_of:
+ template_item = frappe.get_doc("Item", item.variant_of)
+
+ product_id = frappe.db.get_value(
+ "Ecommerce Item",
+ {"erpnext_item_code": template_item.name, "integration": MODULE_NAME},
+ "integration_item_code",
+ )
+ 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()
+
+ 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:
+ 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"
+ ),
+ }
+ )
+ 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)
+
+ write_upload_log(status=is_successful, product=product, item=item, action="Updated")
+
+
+def map_erpnext_variant_to_shopify_variant(shopify_product: Product, erpnext_item, variant_attributes):
+ variant_product_id = frappe.db.get_value(
+ "Ecommerce Item",
+ {"erpnext_item_code": erpnext_item.name, "integration": MODULE_NAME},
+ "integration_item_code",
+ )
+ if not variant_product_id:
+ for variant in shopify_product.variants:
+ if (
+ variant.option1 == variant_attributes.get("option1")
+ and variant.option2 == variant_attributes.get("option2")
+ and variant.option3 == variant_attributes.get("option3")
+ ):
+ variant_product_id = str(variant.id)
+ if not frappe.flags.in_test:
+ frappe.get_doc(
+ {
+ "doctype": "Ecommerce Item",
+ "erpnext_item_code": erpnext_item.name,
+ "integration": MODULE_NAME,
+ "integration_item_code": str(shopify_product.id),
+ "variant_id": variant_product_id,
+ "sku": str(variant.sku),
+ "variant_of": erpnext_item.variant_of,
+ }
+ ).insert()
+ break
+ if not variant_product_id:
+ msgprint(_("Shopify: Couldn't sync item variant."))
+ return variant_product_id
+
+
+def map_erpnext_item_to_shopify(shopify_product: Product, erpnext_item):
+ """Map erpnext fields to shopify, called both when updating and creating new products."""
+
+ shopify_product.title = erpnext_item.item_name
+ shopify_product.body_html = erpnext_item.description
+ shopify_product.product_type = erpnext_item.item_group
+
+ if erpnext_item.weight_uom in WEIGHT_TO_ERPNEXT_UOM_MAP.values():
+ # reverse lookup for key
+ uom = get_shopify_weight_uom(erpnext_weight_uom=erpnext_item.weight_uom)
+ shopify_product.weight = erpnext_item.weight_per_unit
+ shopify_product.weight_unit = uom
+
+ if erpnext_item.disabled:
+ shopify_product.status = "draft"
+ shopify_product.published = False
+ msgprint(_("Status of linked Shopify product is changed to Draft."))
+
+
+def get_shopify_weight_uom(erpnext_weight_uom: str) -> str:
+ for shopify_uom, erpnext_uom in WEIGHT_TO_ERPNEXT_UOM_MAP.items():
+ if erpnext_uom == erpnext_weight_uom:
+ return shopify_uom
+
+
+def update_default_variant_properties(
+ shopify_product: Product,
+ is_stock_item: bool,
+ sku: str | None = None,
+ price: float | None = None,
+):
+ """Shopify creates default variant upon saving the product.
+
+ Some item properties are supposed to be updated on the default variant.
+ Input: saved shopify_product, sku and price
+ """
+ default_variant: Variant = shopify_product.variants[0]
+
+ # this will create Inventory item and qty will be updated by scheduled job.
+ if is_stock_item:
+ default_variant.inventory_management = "shopify"
+
+ if price is not None:
+ default_variant.price = price
+ if sku is not None:
+ default_variant.sku = sku
+
+
+def write_upload_log(status: bool, product: Product, item, action="Created") -> None:
+ if not status:
+ msg = _("Failed to upload item to Shopify") + "
"
+ msg += _("Shopify reported errors:") + " " + ", ".join(product.errors.full_messages())
+ msgprint(msg, title="Note", indicator="orange")
+
+ create_shopify_log(
+ status="Error",
+ request_data=product.to_dict(),
+ message=msg,
+ method="upload_erpnext_item",
+ )
+ else:
+ create_shopify_log(
+ status="Success",
+ request_data=product.to_dict(),
+ message=f"{action} Item: {item.name}, shopify product: {product.id}",
+ method="upload_erpnext_item",
+ )