diff --git a/requirements.txt b/requirements.txt index 4f06ab0..74b6921 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ frappe erpnext -ShopifyAPI==8.2.0 +ShopifyAPI==8.4.1 diff --git a/shopify_integration/customers.py b/shopify_integration/customers.py index 604d144..8050c44 100644 --- a/shopify_integration/customers.py +++ b/shopify_integration/customers.py @@ -1,33 +1,37 @@ +from typing import TYPE_CHECKING + import frappe from frappe import _ from frappe.utils import cstr +if TYPE_CHECKING: + from erpnext.selling.doctype.customer.customer import Customer + from shopify import Address, Customer as ShopifyCustomer, Order -def validate_customer(shopify_order): - customer_id = shopify_order.get("customer", {}).get("id") - if customer_id and not frappe.db.get_value("Customer", {"shopify_customer_id": customer_id}, "name"): - create_customer(shopify_order.get("customer")) +def validate_customer(shop_name: str, shopify_order: "Order"): + customer = shopify_order.attributes.get("customer", frappe._dict()) + if customer.id and not frappe.db.get_value("Customer", {"shopify_customer_id": customer.id}, "name"): + create_customer(shop_name, customer) -def create_customer(shopify_customer): - from frappe.utils.nestedset import get_root_of - shopify_settings = frappe.get_single("Shopify Settings") +def create_customer(shop_name: str, shopify_customer: "ShopifyCustomer"): + from frappe.utils.nestedset import get_root_of - if shopify_customer.get("first_name"): - first_name = cstr(shopify_customer.get("first_name")) - last_name = cstr(shopify_customer.get("last_name")) + if shopify_customer.attributes.get("first_name"): + first_name = cstr(shopify_customer.first_name) + last_name = cstr(shopify_customer.last_name) cust_name = f"{first_name} {last_name}" else: - cust_name = shopify_customer.get("email") + cust_name = shopify_customer.email try: - customer = frappe.get_doc({ + customer: "Customer" = frappe.get_doc({ "doctype": "Customer", - "name": shopify_customer.get("id"), + "name": shopify_customer.id, "customer_name": cust_name, - "shopify_customer_id": shopify_customer.get("id"), - "customer_group": shopify_settings.customer_group, + "shopify_customer_id": shopify_customer.id, + "customer_group": frappe.db.get_value("Shopify Settings", shop_name, "customer_group"), "territory": get_root_of("Territory"), "customer_type": _("Individual") }) @@ -42,36 +46,37 @@ def create_customer(shopify_customer): raise e -def create_customer_address(customer, shopify_customer): - if not shopify_customer.get("addresses"): - return - - for i, address in enumerate(shopify_customer.get("addresses")): - address_title, address_type = get_address_title_and_type(customer.customer_name, i) - try: - frappe.get_doc({ - "doctype": "Address", - "shopify_address_id": address.get("id"), - "address_title": address_title, - "address_type": address_type, - "address_line1": address.get("address1") or "Address 1", - "address_line2": address.get("address2"), - "city": address.get("city") or "City", - "state": address.get("province"), - "pincode": address.get("zip"), - "country": address.get("country"), - "phone": address.get("phone"), - "email_id": shopify_customer.get("email"), - "links": [{ - "link_doctype": "Customer", - "link_name": customer.name - }] - }).insert(ignore_mandatory=True) - except Exception as e: - raise e - - -def get_address_title_and_type(customer_name, index): +def create_customer_address(customer: "Customer", shopify_customer: "ShopifyCustomer"): + addresses = shopify_customer.attributes.get("addresses") or [] + + if not addresses: + default_address = shopify_customer.attributes.get("default_address") + if default_address: + addresses.append(default_address) + + address: "Address" + for index, address in enumerate(addresses): + frappe.get_doc({ + "doctype": "Address", + "shopify_address_id": address.id, + "address_title": get_address_title(customer.customer_name, index), + "address_type": "Billing", + "address_line1": address.address1 or "Address 1", + "address_line2": address.address2, + "city": address.city or "City", + "state": address.province, + "pincode": address.zip, + "country": address.country, + "phone": address.phone, + "email_id": shopify_customer.email, + "links": [{ + "link_doctype": "Customer", + "link_name": customer.name + }] + }).insert(ignore_mandatory=True) + + +def get_address_title(customer_name: str, index: int): address_type = _("Billing") address_title = customer_name @@ -79,4 +84,4 @@ def get_address_title_and_type(customer_name, index): if frappe.db.exists("Address", address_name): address_title = f"{customer_name.strip()}-{index}" - return address_title, address_type + return address_title diff --git a/shopify_integration/fulfilments.py b/shopify_integration/fulfilments.py index d8dd12b..62dca03 100644 --- a/shopify_integration/fulfilments.py +++ b/shopify_integration/fulfilments.py @@ -1,58 +1,118 @@ +from typing import TYPE_CHECKING, List + import frappe from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note -from frappe.utils import cint, cstr, getdate +from frappe.utils import cint, getdate from shopify_integration.products import get_item_code from shopify_integration.shopify_integration.doctype.shopify_log.shopify_log import make_shopify_log from shopify_integration.utils import get_shopify_document +if TYPE_CHECKING: + from erpnext.selling.doctype.sales_order.sales_order import SalesOrder + from erpnext.stock.doctype.delivery_note.delivery_note import DeliveryNote + from erpnext.stock.doctype.delivery_note_item.delivery_note_item import DeliveryNoteItem + from shopify import Fulfillment, LineItem, Order + from shopify_integration.shopify_integration.doctype.shopify_settings.shopify_settings import ShopifySettings + + +def prepare_delivery_note(shop_name: str, order: "Order", log_id: str = str()): + """ + Webhook endpoint to process deliveries for Shopify orders. + + Args: + shop_name (str): The name of the Shopify configuration for the store. + order (Order): The Shopify order data. + log_id (str, optional): The ID of an existing Shopify Log. + Defaults to an empty string. + """ -def prepare_delivery_note(order, request_id=None): frappe.set_user("Administrator") - frappe.flags.request_id = request_id + create_shopify_delivery(shop_name=shop_name, shopify_order=order, log_id=log_id, rollback=True) - try: - sales_order = get_shopify_document("Sales Order", cstr(order.get("id"))) - if sales_order: - create_delivery_notes(order, sales_order) - make_shopify_log(status="Success", response_data=order) - except Exception as e: - make_shopify_log(status="Error", response_data=order, exception=e, rollback=True) +def create_shopify_delivery( + shop_name: str, + shopify_order: "Order", + sales_order: "SalesOrder" = None, + log_id: str = str(), + rollback: bool = False +): + """ + Create Delivery Note documents for each Shopify delivery. + + Args: + shop_name (str): The name of the Shopify configuration for the store. + shopify_order (Order): The Shopify order data. + sales_order (SalesOrder, optional): The reference Sales Order document for the + Shopify order. Defaults to None. + log_id (str, optional): The ID of an existing Shopify Log. Defaults to an empty string. + rollback (bool, optional): If an error occurs while processing the order, all + transactions will be rolled back, if this field is `True`. Defaults to False. -def create_shopify_delivery(order, so, request_id=None): - frappe.flags.request_id = request_id + Returns: + list: The list of created Delivery Note documents, if any, otherwise an empty list. + """ - if not order.get("fulfillments"): - return + if not shopify_order.attributes.get("fulfillments"): + return [] + if not sales_order: + sales_order = get_shopify_document(shop_name=shop_name, doctype="Sales Order", order=shopify_order) + if not sales_order or sales_order.docstatus != 1: + return [] + frappe.flags.log_id = log_id try: - delivery_notes = create_delivery_notes(order, so) + delivery_notes = create_delivery_notes(shop_name, shopify_order, sales_order) except Exception as e: - make_shopify_log(status="Error", response_data=order, exception=e) - return + make_shopify_log(status="Error", response_data=shopify_order.to_dict(), exception=e, rollback=rollback) + return [] else: + make_shopify_log(status="Success", response_data=shopify_order.to_dict()) return delivery_notes -def create_delivery_notes(shopify_order, so): - shopify_settings = frappe.get_doc("Shopify Settings") +def create_delivery_notes( + shop_name: str, + shopify_order: "Order", + sales_order: "SalesOrder" +) -> List["DeliveryNote"]: + """ + Helper function to create Delivery Note documents for a Shopify order. + + Args: + shop_name (str): The name of the Shopify configuration for the store. + shopify_order (Order): The Shopify order data. + sales_order (SalesOrder): The reference Sales Order document for the Shopify order. + + Returns: + list: The list of created Delivery Note documents, if any, otherwise an empty list. + """ + + shopify_settings: "ShopifySettings" = frappe.get_doc("Shopify Settings", shop_name) if not cint(shopify_settings.sync_delivery_note): - return + return [] delivery_notes = [] - for fulfillment in shopify_order.get("fulfillments"): - if so.docstatus == 1 and not frappe.db.get_value("Delivery Note", - {"shopify_fulfillment_id": fulfillment.get("id")}, "name"): - - dn = make_delivery_note(so.name) - dn.shopify_order_id = shopify_order.get("id") - dn.shopify_order_number = shopify_order.get("name") - dn.shopify_fulfillment_id = fulfillment.get("id") - dn.set_posting_time = 1 - dn.posting_date = getdate(fulfillment.get("created_at")) - dn.naming_series = shopify_settings.delivery_note_series or "DN-Shopify-" - dn.items = get_fulfillment_items(dn.items, fulfillment.get("line_items")) + fulfillment: "Fulfillment" + for fulfillment in shopify_order.attributes.get("fulfillments"): + existing_delivery = frappe.db.get_value("Delivery Note", + {"shopify_fulfillment_id": fulfillment.id}, "name") + + if not existing_delivery: + dn: "DeliveryNote" = make_delivery_note(sales_order.name) + dn.update({ + "shopify_settings": shopify_settings.name, + "shopify_order_id": shopify_order.id, + "shopify_order_number": shopify_order.attributes.get("order_number"), + "shopify_fulfillment_id": fulfillment.id, + "set_posting_time": True, + "posting_date": getdate(fulfillment.attributes.get("created_at")), + "naming_series": shopify_settings.delivery_note_series or "DN-Shopify-", + }) + + update_fulfillment_items(dn.items, fulfillment.attributes.get("line_items")) + dn.flags.ignore_mandatory = True dn.save() dn.submit() @@ -62,8 +122,12 @@ def create_delivery_notes(shopify_order, so): return delivery_notes -def get_fulfillment_items(dn_items, fulfillment_items): - # TODO: figure out a better way to add items without setting valuation rate to zero - return [dn_item.update({"qty": item.get("quantity"), "allow_zero_valuation_rate": 1}) - for item in fulfillment_items for dn_item in dn_items - if get_item_code(item) == dn_item.item_code] +def update_fulfillment_items( + dn_items: List["DeliveryNoteItem"], + fulfillment_items: List["LineItem"] +): + for dn_item in dn_items: + for item in fulfillment_items: + if get_item_code(item) == dn_item.item_code: + # TODO: figure out a better way to add items without setting valuation rate to zero + dn_item.update({"qty": item.attributes.get("quantity"), "allow_zero_valuation_rate": 1}) diff --git a/shopify_integration/hooks.py b/shopify_integration/hooks.py index 615f4fa..1fdfcba 100644 --- a/shopify_integration/hooks.py +++ b/shopify_integration/hooks.py @@ -97,7 +97,7 @@ scheduler_events = { "daily_long": [ - "shopify_integration.payouts.sync_payouts_from_shopify" + "shopify_integration.payouts.sync_all_payouts" ] } diff --git a/shopify_integration/invoices.py b/shopify_integration/invoices.py index ca87732..2a9d7ce 100644 --- a/shopify_integration/invoices.py +++ b/shopify_integration/invoices.py @@ -1,72 +1,151 @@ +from typing import TYPE_CHECKING + import frappe from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice -from frappe.utils import cint, cstr, flt, get_datetime, getdate +from frappe.utils import cint, flt, get_datetime, getdate from shopify_integration.shopify_integration.doctype.shopify_log.shopify_log import make_shopify_log from shopify_integration.utils import get_shopify_document, get_tax_account_head +if TYPE_CHECKING: + from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice + from erpnext.selling.doctype.sales_order.sales_order import SalesOrder + from shopify import Order + from shopify_integration.shopify_integration.doctype.shopify_settings.shopify_settings import ShopifySettings + + +def prepare_sales_invoice(shop_name: str, order: "Order", log_id: str = str()): + """ + Webhook endpoint to process invoices for Shopify orders. + + Args: + shop_name (str): The name of the Shopify configuration for the store. + order (Order): The Shopify order data. + log_id (str, optional): The ID of an existing Shopify Log. + Defaults to an empty string. + """ -def prepare_sales_invoice(order, request_id=None): from shopify_integration.orders import create_shopify_documents frappe.set_user("Administrator") - frappe.flags.request_id = request_id + frappe.flags.log_id = log_id try: - sales_order = get_shopify_document("Sales Order", cstr(order.get("id"))) + sales_order = get_shopify_document(shop_name=shop_name, doctype="Sales Order", order=order) if not sales_order: - create_shopify_documents(order, request_id) - sales_order = get_shopify_document("Sales Order", cstr(order.get("id"))) + create_shopify_documents(shop_name, order, log_id) + sales_order = get_shopify_document(shop_name=shop_name, doctype="Sales Order", order=order) if sales_order: - create_sales_invoice(order, sales_order) - make_shopify_log(status="Success", response_data=order) + sales_order: "SalesOrder" + create_sales_invoice(shop_name, order, sales_order) + make_shopify_log(status="Success", response_data=order.to_dict()) + else: + make_shopify_log(status="Skipped", response_data=order.to_dict()) except Exception as e: - make_shopify_log(status="Error", response_data=order, exception=e, rollback=True) + make_shopify_log(status="Error", response_data=order.to_dict(), exception=e, rollback=True) + +def create_shopify_invoice( + shop_name: str, + shopify_order: "Order", + sales_order: "SalesOrder", + log_id: str = str() +): + """ + Create a Sales Invoice document for a Shopify order. If the Shopify order is refunded + and a submitted Sales Invoice exists, make a sales return against the invoice. -def create_shopify_invoice(order, so, request_id=None): - frappe.flags.request_id = request_id + Args: + shop_name (str): The name of the Shopify configuration for the store. + shopify_order (Order): The Shopify order data. + sales_order (SalesOrder, optional): The reference Sales Order document for the + Shopify order. Defaults to None. + log_id (str, optional): The ID of an existing Shopify Log. Defaults to an empty string. - if not order.get("financial_status") in ["paid", "partially_refunded", "refunded"]: + Returns: + SalesInvoice: The created Sales Invoice document, if any, otherwise None. + """ + + if not shopify_order.attributes.get("financial_status") in ["paid", "partially_refunded", "refunded"]: return + frappe.flags.log_id = log_id try: - si = create_sales_invoice(order, so) - if si: - create_sales_return(order.get("id"), order.get("financial_status"), si) + sales_invoice = create_sales_invoice(shop_name, shopify_order, sales_order) + if sales_invoice and sales_invoice.docstatus == 1: + create_sales_return( + shop_name=shop_name, + shopify_order_id=shopify_order.id, + shopify_financial_status=shopify_order.attributes.get("financial_status"), + sales_invoice=sales_invoice + ) except Exception as e: - make_shopify_log(status="Error", response_data=order, exception=e) + make_shopify_log(status="Error", response_data=shopify_order.to_dict(), exception=e) else: - return si + make_shopify_log(status="Success", response_data=shopify_order.to_dict()) + return sales_invoice + + +def create_sales_invoice(shop_name: str, shopify_order: "Order", sales_order: "SalesOrder"): + """ + Helper function to create a Sales Invoice document for a Shopify order. + Args: + shop_name (str): The name of the Shopify configuration for the store. + shopify_order (Order): The Shopify order data. + sales_order (SalesOrder): The reference Sales Order document for the Shopify order. -def create_sales_invoice(shopify_order, sales_order): - shopify_settings = frappe.get_single("Shopify Settings") + Returns: + SalesInvoice: The created or existing Sales Invoice document, if any, otherwise None. + """ + + shopify_settings: "ShopifySettings" = frappe.get_doc("Shopify Settings", shop_name) if not cint(shopify_settings.sync_sales_invoice): return - si = get_shopify_document("Sales Invoice", shopify_order.get("id")) - if not si and sales_order.docstatus == 1 and not sales_order.per_billed: - si = make_sales_invoice(sales_order.name, ignore_permissions=True) - si.shopify_order_id = shopify_order.get("id") - si.shopify_order_number = shopify_order.get("name") - si.set_posting_time = 1 - si.posting_date = getdate(shopify_order.get("created_at")) - si.naming_series = shopify_settings.sales_invoice_series or "SI-Shopify-" - si.flags.ignore_mandatory = True - set_cost_center(si.items, shopify_settings.cost_center) - si.insert(ignore_mandatory=True) + existing_invoice = get_shopify_document(shop_name=shop_name, doctype="Sales Invoice", order=shopify_order) + if existing_invoice: + existing_invoice: "SalesInvoice" + frappe.db.set_value("Sales Invoice", existing_invoice.name, { + "shopify_settings": shopify_settings.name, + "shopify_order_id": shopify_order.id, + "shopify_order_number": shopify_order.attributes.get("order_number") + }) + return existing_invoice + + if sales_order.docstatus == 1 and not sales_order.per_billed: + sales_invoice: "SalesInvoice" = make_sales_invoice(sales_order.name, ignore_permissions=True) + sales_invoice.update({ + "shopify_settings": shopify_settings.name, + "shopify_order_id": shopify_order.id, + "shopify_order_number": shopify_order.attributes.get("order_number"), + "set_posting_time": True, + "posting_date": getdate(shopify_order.attributes.get("created_at")), + "naming_series": shopify_settings.sales_invoice_series or "SI-Shopify-" + }) + + for item in sales_invoice.items: + item.cost_center = shopify_settings.cost_center + + sales_invoice.flags.ignore_mandatory = True + sales_invoice.insert(ignore_mandatory=True) frappe.db.commit() - return si + return sales_invoice -def create_sales_return(shopify_order_id, shopify_financial_status, sales_invoice): +def create_sales_return( + shop_name: str, + shopify_order_id: int, + shopify_financial_status: str, + sales_invoice: "SalesInvoice" +): """ - Create a Sales Invoice return for the given Shopify order + Create a Sales Invoice return for the given Shopify order. Args: + shop_name (str): The name of the Shopify configuration for the store. shopify_order_id (int): The Shopify order ID. shopify_financial_status (str): The financial status of the Shopify order. Should be one of: refunded, partially_refunded. @@ -77,11 +156,12 @@ def create_sales_return(shopify_order_id, shopify_financial_status, sales_invoic If no refunds are found, returns None. """ - shopify_settings = frappe.get_single("Shopify Settings") + shopify_settings: "ShopifySettings" = frappe.get_doc("Shopify Settings", shop_name) refunds = shopify_settings.get_refunds(order_id=shopify_order_id) - refund_dates = [refund.processed_at or refund.created_at for refund in refunds - if refund.processed_at or refund.created_at] + refund_dates = [refund.processed_at or refund.created_at + for refund in refunds if refund.processed_at or refund.created_at] + if not refund_dates: return @@ -89,7 +169,7 @@ def create_sales_return(shopify_order_id, shopify_financial_status, sales_invoic if not refund_datetime: return - return_invoice = make_sales_return(sales_invoice.name) + return_invoice: "SalesInvoice" = make_sales_return(sales_invoice.name) return_invoice.set_posting_time = True return_invoice.posting_date = refund_datetime.date() return_invoice.posting_time = refund_datetime.time() @@ -120,7 +200,7 @@ def create_sales_return(shopify_order_id, shopify_financial_status, sales_invoic for adjustment in adjustments: return_invoice.append("taxes", { "charge_type": "Actual", - "account_head": get_tax_account_head("refund"), + "account_head": get_tax_account_head(shop_name, "refund"), "description": adjustment.reason, "tax_amount": flt(adjustment.amount) }) @@ -128,8 +208,3 @@ def create_sales_return(shopify_order_id, shopify_financial_status, sales_invoic return_invoice.save() return_invoice.submit() return return_invoice - - -def set_cost_center(items, cost_center): - for item in items: - item.cost_center = cost_center diff --git a/shopify_integration/orders.py b/shopify_integration/orders.py index dd6e5d7..641387b 100644 --- a/shopify_integration/orders.py +++ b/shopify_integration/orders.py @@ -1,11 +1,18 @@ +from typing import TYPE_CHECKING, List + import frappe -from frappe.utils import cstr, flt, nowdate +from frappe.utils import flt, nowdate from shopify_integration.shopify_integration.doctype.shopify_log.shopify_log import make_shopify_log from shopify_integration.utils import get_shopify_document, get_tax_account_head +if TYPE_CHECKING: + from erpnext.selling.doctype.sales_order.sales_order import SalesOrder + from shopify import LineItem, Order + from shopify_integration.shopify_integration.doctype.shopify_settings.shopify_settings import ShopifySettings + -def create_shopify_documents(order, request_id=None): +def create_shopify_documents(shop_name: str, order: "Order", log_id: str = str()): """ Create the following from a Shopify order: @@ -15,142 +22,169 @@ def create_shopify_documents(order, request_id=None): Args: - order (dict): The Shopify order data. - request_id (str, optional): The ID of the existing Shopify Log document - for this request. Defaults to None. + shop_name (str): The name of the Shopify configuration for the store. + order (Order): The Shopify order data. + log_id (str, optional): The ID of an existing Shopify Log. Defaults + to an empty string. """ from shopify_integration.fulfilments import create_shopify_delivery from shopify_integration.invoices import create_shopify_invoice frappe.set_user("Administrator") - frappe.flags.request_id = request_id - so = create_shopify_order(order, request_id) - if so: - create_shopify_invoice(order, so, request_id) - create_shopify_delivery(order, so, request_id) + frappe.flags.log_id = log_id + sales_order = create_shopify_order(shop_name, order, log_id) + if sales_order: + create_shopify_invoice(shop_name, order, sales_order, log_id) + create_shopify_delivery(shop_name, order, sales_order, log_id) -def create_shopify_order(order, request_id=None): +def create_shopify_order(shop_name: str, shopify_order: "Order", log_id: str = str()): + """ + Create a Sales Order document for a Shopify order. + + Args: + shop_name (str): The name of the Shopify configuration for the store. + shopify_order (Order): The Shopify order data. + log_id (str, optional): The ID of an existing Shopify Log. Defaults + to an empty string. + + Returns: + SalesOrder: The created Sales Order document, if any, otherwise None. + """ + from shopify_integration.customers import validate_customer from shopify_integration.products import validate_item - frappe.flags.request_id = request_id - - existing_so = frappe.db.get_value("Sales Order", - filters={ - "docstatus": ["<", 2], - "shopify_order_id": cstr(order.get("id")) - }) + frappe.flags.log_id = log_id + existing_so = get_shopify_document(shop_name=shop_name, doctype="Sales Order", order=shopify_order) if existing_so: - make_shopify_log(status="Skipped", response_data=order) - return frappe.get_doc("Sales Order", existing_so) + existing_so: "SalesOrder" + make_shopify_log(status="Skipped", response_data=shopify_order.to_dict()) + return existing_so try: - validate_customer(order) - validate_item(order) - so = create_sales_order(order) + validate_customer(shop_name, shopify_order) + validate_item(shop_name, shopify_order) + sales_order = create_sales_order(shop_name, shopify_order) except Exception as e: - make_shopify_log(status="Error", response_data=order, exception=e) + make_shopify_log(status="Error", response_data=shopify_order.to_dict(), exception=e) else: - make_shopify_log(status="Success", response_data=order) - return so - - -def create_sales_order(shopify_order, company=None): - shopify_settings = frappe.get_single("Shopify Settings") - - customer = frappe.db.get_value("Customer", {"shopify_customer_id": shopify_order.get("customer", {}).get("id")}, "name") - so = frappe.db.get_value("Sales Order", {"docstatus": ["<", 2], "shopify_order_id": shopify_order.get("id")}, "name") + make_shopify_log(status="Success", response_data=shopify_order.to_dict()) + return sales_order - if not so: - items = get_order_items(shopify_order.get("line_items"), shopify_settings) - so = frappe.get_doc({ - "doctype": "Sales Order", - "naming_series": shopify_settings.sales_order_series or "SO-Shopify-", - "shopify_order_id": shopify_order.get("id"), - "customer": customer or shopify_settings.default_customer, - "delivery_date": nowdate(), - "company": shopify_settings.company, - "selling_price_list": shopify_settings.price_list, - "ignore_pricing_rule": 1, - "items": items, - "taxes": get_order_taxes(shopify_order, shopify_settings), - "apply_discount_on": "Grand Total", - "discount_amount": flt(shopify_order.get("total_discounts")), - }) +def create_sales_order(shop_name: str, shopify_order: "Order"): + """ + Helper function to create a Sales Order document for a Shopify order. - if company: - so.update({ - "company": company, - "status": "Draft" - }) - so.flags.ignore_mandatory = True - so.save(ignore_permissions=True) - so.submit() + Args: + shop_name (str): The name of the Shopify configuration for the store. + shopify_order (Order): The Shopify order data. - else: - so = frappe.get_doc("Sales Order", so) + Returns: + SalesOrder: The created Sales Order document, if any, otherwise None. + """ + shopify_settings: "ShopifySettings" = frappe.get_doc("Shopify Settings", shop_name) + shopify_customer = shopify_order.attributes.get("customer", frappe._dict()) + customer = frappe.db.get_value("Customer", {"shopify_customer_id": shopify_customer.id}, "name") + + sales_order: "SalesOrder" = frappe.get_doc({ + "doctype": "Sales Order", + "naming_series": shopify_settings.sales_order_series or "SO-Shopify-", + "shopify_settings": shopify_settings.name, + "shopify_order_id": shopify_order.id, + "shopify_order_number": shopify_order.attributes.get("order_number"), + "customer": customer or shopify_settings.default_customer, + "transaction_date": shopify_order.attributes.get("created_at"), + "delivery_date": shopify_order.attributes.get("created_at"), + "company": shopify_settings.company, + "selling_price_list": shopify_settings.price_list, + "ignore_pricing_rule": 1, + "items": get_order_items(shopify_order.attributes.get("line_items"), shopify_settings.warehouse), + "taxes": get_order_taxes(shopify_order, shopify_settings), + "apply_discount_on": "Grand Total", + "discount_amount": flt(shopify_order.attributes.get("total_discounts")), + }) + + sales_order.flags.ignore_mandatory = True + sales_order.save(ignore_permissions=True) + sales_order.submit() frappe.db.commit() - return so + return sales_order -def get_order_items(order_items, shopify_settings): +def get_order_items(shopify_order_items: List["LineItem"], warehouse: str): from shopify_integration.products import get_item_code items = [] - for shopify_item in order_items: + for shopify_item in shopify_order_items: item_code = get_item_code(shopify_item) items.append({ "item_code": item_code, - "item_name": shopify_item.get("name"), - "rate": shopify_item.get("price"), + "item_name": shopify_item.attributes.get("name"), + "rate": shopify_item.attributes.get("price"), "delivery_date": nowdate(), - "qty": shopify_item.get("quantity"), - "stock_uom": shopify_item.get("uom") or "Nos", - "warehouse": shopify_settings.warehouse + "qty": shopify_item.attributes.get("quantity"), + "stock_uom": shopify_item.attributes.get("uom") or "Nos", + "conversion_factor": 1, + "warehouse": warehouse }) return items -def get_order_taxes(shopify_order, shopify_settings): +def get_order_taxes(shopify_order: "Order", shopify_settings: "ShopifySettings"): taxes = [] # add shipping charges - for shipping in shopify_order.get("shipping_lines"): - if shipping.get("price"): + for shipping in shopify_order.attributes.get("shipping_lines"): + if shipping.attributes.get("price"): taxes.append({ "charge_type": "Actual", - "account_head": get_tax_account_head("shipping"), - "description": shipping.get("title"), - "tax_amount": shipping.get("price"), + "account_head": get_tax_account_head(shopify_settings.name, "shipping"), + "description": shipping.attributes.get("title"), + "tax_amount": shipping.attributes.get("price"), "cost_center": shopify_settings.cost_center }) # add additional taxes and fees - for tax in shopify_order.get("tax_lines"): + for tax in shopify_order.attributes.get("tax_lines"): + tax_description = "{0} - {1}%".format( + tax.attributes.get("title"), + tax.attributes.get("rate") * 100.0 + ) + taxes.append({ "charge_type": "Actual", - "account_head": get_tax_account_head("tax"), - "description": "{0} - {1}%".format(tax.get("title"), tax.get("rate") * 100.0), - "tax_amount": tax.get("price"), + "account_head": get_tax_account_head(shopify_settings.name, "tax"), + "description": tax_description, + "tax_amount": tax.attributes.get("price"), "cost_center": shopify_settings.cost_center, - "included_in_print_rate": 1 if shopify_order.get("taxes_included") else 0, + "included_in_print_rate": shopify_order.attributes.get("taxes_included") }) return taxes -def cancel_shopify_order(order, request_id=None): +def cancel_shopify_order(shop_name: str, order: "Order", log_id: str = str()): + """ + Cancel all sales documents if a Shopify order is cancelled. + + Args: + shop_name (str): The name of the Shopify configuration for the store. + order (Order): The Shopify order data. + log_id (str, optional): The ID of an existing Shopify Log. + Defaults to an empty string. + """ + frappe.set_user("Administrator") - frappe.flags.request_id = request_id + frappe.flags.log_id = log_id doctypes = ["Delivery Note", "Sales Invoice", "Sales Order"] for doctype in doctypes: - doc = get_shopify_document(doctype, cstr(order.get("id"))) + doc = get_shopify_document(shop_name=shop_name, doctype=doctype, order=order) if not doc: continue @@ -161,7 +195,7 @@ def cancel_shopify_order(order, request_id=None): doc.flags.ignore_links = True doc.cancel() except Exception as e: - make_shopify_log(status="Error", response_data=order, + make_shopify_log(status="Error", response_data=order.to_dict(), exception=e, rollback=True) # update the financial status in all linked Shopify Payouts diff --git a/shopify_integration/patches.txt b/shopify_integration/patches.txt index e69de29..f010da7 100644 --- a/shopify_integration/patches.txt +++ b/shopify_integration/patches.txt @@ -0,0 +1 @@ +shopify_integration.patches.create_shopify_settings_documents \ No newline at end of file diff --git a/shopify_integration/patches/__init__.py b/shopify_integration/patches/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shopify_integration/patches/create_shopify_settings_documents.py b/shopify_integration/patches/create_shopify_settings_documents.py new file mode 100644 index 0000000..6c7d19d --- /dev/null +++ b/shopify_integration/patches/create_shopify_settings_documents.py @@ -0,0 +1,60 @@ +from urllib.parse import urlparse + +import frappe +from frappe.utils.nestedset import get_root_of + +from shopify_integration.setup import setup_custom_fields + + +def execute(): + # store data from original single document + shopify_settings = frappe.get_doc("Shopify Settings") + shopify_data = shopify_settings.as_dict(no_default_fields=True) + shopify_password = shopify_settings.get_password("password") + + frappe.reload_doc("shopify_integration", "doctype", "shopify_settings") + frappe.reload_doc("shopify_integration", "doctype", "shopify_payout") + setup_custom_fields() + + # get shop name + url = urlparse(shopify_data.get("shopify_url")) + subdomain = url.hostname.split(".")[0] + if subdomain: + shop_name = frappe.unscrub(subdomain.replace("-", " ")) + else: + shop_name = "Shopify" + + # create new Shopify Settings document + new_shop = frappe.new_doc("Shopify Settings") + new_shop.update(shopify_data) + new_shop.update({ + "shop_name": shop_name, + "password": shopify_password, + "item_group": get_root_of("Item Group") + }) + new_shop.insert(ignore_permissions=True) + + # update Shopify Payout and linked Shopify documents + for payout in frappe.get_all("Shopify Payout"): + frappe.db.set_value("Shopify Payout", payout.name, "shop_name", new_shop.name) + + payout_doc = frappe.get_doc("Shopify Payout", payout.name) + for transaction in payout_doc.transactions: + if transaction.sales_order: + frappe.db.set_value("Sales Order", transaction.sales_order, + "shopify_settings", new_shop.name) + if transaction.sales_invoice: + frappe.db.set_value("Sales Invoice", transaction.sales_invoice, + "shopify_settings", new_shop.name) + if transaction.delivery_note: + frappe.db.set_value("Delivery Note", transaction.delivery_note, + "shopify_settings", new_shop.name) + + # # ref: https://github.com/ParsimonyGit/shipstation_integration/ + # update the "Is Shopify Store" check in Shipstation stores + if "shipstation_integration" in frappe.get_installed_apps(): + mws_setup_marketplaces = frappe.get_all("Shipstation Store", + filters={"marketplace_name": "Shopify"}) + for marketplace in mws_setup_marketplaces: + frappe.db.set_value("Shipstation Store", marketplace.name, + "is_shopify_store", True) diff --git a/shopify_integration/payouts.py b/shopify_integration/payouts.py index 694c656..2766883 100644 --- a/shopify_integration/payouts.py +++ b/shopify_integration/payouts.py @@ -2,6 +2,8 @@ # Copyright (c) 2021, Parsimony, LLC and contributors # For license information, please see license.txt +from typing import TYPE_CHECKING, List + import frappe from frappe.utils import flt, get_datetime_str, get_first_day, getdate, now, today @@ -11,22 +13,24 @@ from shopify_integration.shopify_integration.doctype.shopify_log.shopify_log import make_shopify_log from shopify_integration.utils import get_shopify_document +if TYPE_CHECKING: + from erpnext.selling.doctype.sales_order.sales_order import SalesOrder + from shopify import Order, Payouts, Transactions + from shopify_integration.shopify_integration.doctype.shopify_settings.shopify_settings import ShopifySettings + from shopify_integration.shopify_integration.doctype.shopify_payout.shopify_payout import ShopifyPayout + -@frappe.whitelist() -def sync_payouts_from_shopify(): +def sync_all_payouts(): """ - Pull and sync payouts from Shopify Payments transactions. - Can be called manually, otherwise runs daily. + Daily hook to sync payouts from Shopify Payments transactions in all Shopify stores. """ - if not frappe.db.get_single_value("Shopify Settings", "enable_shopify"): - return False + for shop in frappe.get_all("Shopify Settings", filters={"enable_shopify": True}): + shop_doc: "ShopifySettings" = frappe.get_doc("Shopify Settings", shop.name) + shop_doc.sync_payouts() - frappe.enqueue(method=create_shopify_payouts, queue='long', is_async=True) - return True - -def create_shopify_payouts(): +def create_shopify_payouts(shop_name: str, start_date: str = str()): """ Pull the latest payouts from Shopify and do the following: @@ -34,47 +38,61 @@ def create_shopify_payouts(): if enabled in Shopify Settings - Create a Shopify Payout document with info on all transactions - Update any invoices with fees accrued for each payout transaction + + Args: + shop_name (str): The name of the Shopify configuration for the store. + start_date (str, optional): The date to start pulling payouts from. """ - payouts = get_payouts() + shopify_settings: "ShopifySettings" = frappe.get_doc("Shopify Settings", shop_name) + + payouts = get_payouts(shopify_settings, start_date) if not payouts: return - shopify_settings = frappe.get_single("Shopify Settings") - for payout in payouts: if frappe.db.exists("Shopify Payout", {"payout_id": payout.id}): continue payout_order_ids = [] try: - payout_transactions = shopify_settings.get_payout_transactions(payout_id=payout.id) + payout_transactions: List["Transactions"] = shopify_settings.get_payout_transactions( + payout_id=payout.id + ) except Exception as e: - make_shopify_log(status="Payout Transactions Error", response_data=payout.to_dict(), exception=e) + make_shopify_log( + status="Payout Transactions Error", + response_data=payout.to_dict(), + exception=e + ) else: payout_order_ids = [transaction.source_order_id for transaction in payout_transactions if transaction.source_order_id] - create_missing_orders(payout_order_ids) - payout_doc = create_shopify_payout(payout) + create_missing_orders(shopify_settings, payout_order_ids) + payout_doc: "ShopifyPayout" = create_shopify_payout(shopify_settings, payout) payout_doc.update_invoice_fees() shopify_settings.last_sync_datetime = now() shopify_settings.save() -def get_payouts(): +def get_payouts(shopify_settings: "ShopifySettings", start_date: str = str()): """ Request Shopify API for the latest payouts + Args: + shopify_settings (ShopifySettings): The Shopify configuration for the store. + start_date (str, optional): The date to start pulling payouts from. + Returns: list of shopify.Payout: The list of Shopify payouts, if any. """ - shopify_settings = frappe.get_single("Shopify Settings") - kwargs = {} - if shopify_settings.last_sync_datetime: + if start_date: + kwargs['date_min'] = start_date + elif shopify_settings.last_sync_datetime: kwargs['date_min'] = shopify_settings.last_sync_datetime else: # default to first day of current month for first sync @@ -89,59 +107,61 @@ def get_payouts(): return payouts -def create_missing_orders(shopify_order_ids): +def create_missing_orders(shopify_settings: "ShopifySettings", shopify_order_ids: List[str]): """ - Create missing Sales Orders, Sales Invoices and Delivery Notes, - if enabled in Shopify Settings. + Create missing Sales Orders, Sales Invoices and Delivery Notes, if enabled in Shopify Settings. Args: - shopify_order_ids (list of str): The Shopify order IDs to create documents against + shopify_settings (ShopifySettings): The Shopify configuration for the store. + shopify_order_ids (list of str): The Shopify order IDs to create documents against. """ - settings = frappe.get_single("Shopify Settings") - for shopify_order_id in shopify_order_ids: - sales_order = get_shopify_document("Sales Order", shopify_order_id) - sales_invoice = get_shopify_document("Sales Invoice", shopify_order_id) - delivery_note = get_shopify_document("Delivery Note", shopify_order_id) + sales_order = get_shopify_document(shop_name=shopify_settings.name, + doctype="Sales Order", order_id=shopify_order_id) + sales_invoice = get_shopify_document(shop_name=shopify_settings.name, + doctype="Sales Invoice", order_id=shopify_order_id) + delivery_note = get_shopify_document(shop_name=shopify_settings.name, + doctype="Delivery Note", order_id=shopify_order_id) if all([sales_order, sales_invoice, delivery_note]): continue - orders = settings.get_orders(shopify_order_id) + orders = shopify_settings.get_orders(shopify_order_id) if not orders: continue - order = orders[0] + order: "Order" = orders[0] # create an order, invoice and delivery, if missing if not sales_order: - sales_order = create_shopify_order(order.to_dict()) + sales_order = create_shopify_order(shopify_settings.name, order) if sales_order: + sales_order: "SalesOrder" if not sales_invoice: - create_shopify_invoice(order.to_dict(), sales_order) - if not delivery_note: - create_shopify_delivery(order.to_dict(), sales_order) + create_shopify_invoice(shopify_settings.name, order, sales_order) + if not delivery_note or sales_order.per_delivered < 100: + # multiple deliveries can be made against a single order + create_shopify_delivery(shopify_settings.name, order, sales_order) -def create_shopify_payout(payout): +def create_shopify_payout(shopify_settings: "ShopifySettings", payout: "Payouts"): """ Create a Shopify Payout document from Shopify's Payout information. Args: - payout (shopify.Payout): The Payout payload from Shopify + shopify_settings (ShopifySettings): The Shopify configuration for the store. + payout (Payouts): The Payout payload from Shopify. Returns: - ShopifyPayout: The created Shopify Payout document + ShopifyPayout: The created Shopify Payout document. """ - company = frappe.db.get_single_value("Shopify Settings", "company") - settings = frappe.get_single("Shopify Settings") - - payout_doc = frappe.new_doc("Shopify Payout") + payout_doc: "ShopifyPayout" = frappe.new_doc("Shopify Payout") payout_doc.update({ - "company": company, + "shop_name": shopify_settings.name, + "company": shopify_settings.company, "payout_id": payout.id, "payout_date": getdate(payout.date), "status": frappe.unscrub(payout.status), @@ -151,7 +171,7 @@ def create_shopify_payout(payout): }) try: - payout_transactions = settings.get_payout_transactions(payout_id=payout.id) + payout_transactions: List["Transactions"] = shopify_settings.get_payout_transactions(payout_id=payout.id) except Exception as e: payout_doc.save(ignore_permissions=True) make_shopify_log(status="Payout Transactions Error", response_data=payout.to_dict(), exception=e) @@ -161,21 +181,24 @@ def create_shopify_payout(payout): for transaction in payout_transactions: shopify_order_id = transaction.source_order_id - order_financial_status = None + order_financial_status = sales_order = sales_invoice = delivery_note = None if shopify_order_id: - orders = settings.get_orders(shopify_order_id) + orders = shopify_settings.get_orders(shopify_order_id) if not orders: continue order = orders[0] order_financial_status = frappe.unscrub(order.financial_status) + sales_order = get_shopify_document(shop_name=shopify_settings.name, + doctype="Sales Order", order_id=shopify_order_id) + sales_invoice = get_shopify_document(shop_name=shopify_settings.name, + doctype="Sales Invoice", order_id=shopify_order_id) + delivery_note = get_shopify_document(shop_name=shopify_settings.name, + doctype="Delivery Note", order_id=shopify_order_id) + total_amount = -flt(transaction.amount) if transaction.type == "payout" else flt(transaction.amount) net_amount = -flt(transaction.net) if transaction.type == "payout" else flt(transaction.net) - sales_order = get_shopify_document("Sales Order", shopify_order_id) - sales_invoice = get_shopify_document("Sales Invoice", shopify_order_id) - delivery_note = get_shopify_document("Delivery Note", shopify_order_id) - payout_doc.append("transactions", { "transaction_id": transaction.id, "transaction_type": frappe.unscrub(transaction.type), diff --git a/shopify_integration/products.py b/shopify_integration/products.py index f91803e..39ee2d4 100644 --- a/shopify_integration/products.py +++ b/shopify_integration/products.py @@ -1,3 +1,7 @@ +from typing import TYPE_CHECKING, Dict, List, Optional, Union + +from shopify import Product, Variant + import frappe from erpnext import get_default_company from frappe import _ @@ -5,6 +9,12 @@ from shopify_integration.shopify_integration.doctype.shopify_log.shopify_log import make_shopify_log +if TYPE_CHECKING: + from erpnext.stock.doctype.item.item import Item + from erpnext.stock.doctype.item_attribute.item_attribute import ItemAttribute + from shopify import LineItem, Option, Order + from shopify_integration.shopify_integration.doctype.shopify_settings.shopify_settings import ShopifySettings + SHOPIFY_VARIANTS_ATTR_LIST = ["option1", "option2", "option3"] # Weight units gathered from: @@ -17,22 +27,17 @@ } -@frappe.whitelist() -def sync_products_from_shopify(): - """ - Pull and sync products from Shopify, including variants +def sync_items_from_shopify(shop_name: str): """ + For a given Shopify store, sync all active products and create Item + documents for missing products. - if not frappe.db.get_single_value("Shopify Settings", "enable_shopify"): - return False - - frappe.enqueue(method=sync_items_from_shopify, queue="long", is_async=True) - return True - + Args: + shop_name (str): The name of the Shopify configuration for the store. + """ -def sync_items_from_shopify(): frappe.set_user("Administrator") - shopify_settings = frappe.get_single("Shopify Settings") + shopify_settings: "ShopifySettings" = frappe.get_doc("Shopify Settings", shop_name) try: shopify_items = shopify_settings.get_products(status="active") @@ -41,228 +46,385 @@ def sync_items_from_shopify(): return for shopify_item in shopify_items: - make_item(shopify_item.to_dict()) + shopify_item: Product + make_item(shopify_settings, shopify_item) + + +def validate_item(shop_name: str, shopify_order: "Order"): + """ + Ensure that a Shopify order's items exist before processing the order. + For every line item in the order, the order of priority for the reference field is: + - Product ID + - Variant ID + - Item Title -def validate_item(shopify_order): - for shopify_item in shopify_order.get("line_items"): - item_exists = True + Args: + shop_name (str): The name of the Shopify configuration for the store. + shopify_order (Order): The Shopify order data. + """ + + shopify_settings: "ShopifySettings" = frappe.get_doc("Shopify Settings", shop_name) + for shopify_item in shopify_order.attributes.get("line_items", []): + shopify_item: "LineItem" + shopify_products = [] - product_id = shopify_item.get("product_id") + # create the parent product item if it does not exist + product_id = shopify_item.attributes.get("product_id") if product_id and not frappe.db.exists("Item", {"shopify_product_id": product_id}): - item_exists = False + shopify_products: List[Product] = shopify_settings.get_products(product_id) + for product in shopify_products: + make_item(shopify_settings, product) - # Shopify somehow allows non-existent variants to be added to an order; - # for such cases, we force-create the item after creating the other variants - variant_id = shopify_item.get("variant_id") + # create the child variant item if it does not exist + variant_id = shopify_item.attributes.get("variant_id") if variant_id and not frappe.db.exists("Item", {"shopify_variant_id": variant_id}): - item_exists = False + shopify_variants: List[Variant] = shopify_settings.get_variants(variant_id) + for variant in shopify_variants: + make_item(shopify_settings, variant) - # Shopify somehow also allows non-existent products to be added to an order; + # Shopify somehow allows non-existent products to be added to an order; # for such cases, we create the item using the line item"s title - line_item_title = shopify_item.get("title", "").strip() - if line_item_title and not frappe.db.exists("Item", {"item_code": line_item_title}): - item_exists = False + if not (product_id or variant_id): + line_item_title = shopify_item.attributes.get("title", "").strip() + if line_item_title and not frappe.db.exists("Item", {"item_code": line_item_title}): + shopify_products: List[Product] = shopify_settings.get_products( + title=shopify_item.attributes.get("title") + ) + + if not shopify_products: + make_item_by_title(shopify_settings, line_item_title) + return + + for product in shopify_products: + make_item(shopify_settings, product) - if not item_exists: - make_item(shopify_item) +def get_item_code(shopify_item: "LineItem") -> Optional[str]: + item_code = frappe.db.get_value("Item", + {"shopify_variant_id": shopify_item.attributes.get("variant_id")}, + "item_code") -def get_item_code(shopify_item): - item_code = frappe.db.get_value("Item", {"shopify_variant_id": shopify_item.get("variant_id")}, "item_code") if not item_code: item_code = frappe.db.get_value("Item", - {"shopify_product_id": shopify_item.get("product_id")}, "item_code") + {"shopify_product_id": shopify_item.attributes.get("product_id")}, + "item_code") + if not item_code: - item_code = frappe.db.get_value("Item", {"item_name": shopify_item.get("title")}, "item_code") + item_code = frappe.db.get_value("Item", + {"item_name": shopify_item.attributes.get("title", "").strip()}, + "item_code") return item_code -def make_item(shopify_item): - warehouse = frappe.db.get_single_value("Shopify Settings", "warehouse") - add_item_weight(shopify_item) - - if has_variants(shopify_item): - attributes = create_attribute(shopify_item) - create_item(shopify_item, warehouse, has_variant=True, attributes=attributes) - create_item_variants(shopify_item, warehouse, attributes=attributes) - else: - variants = shopify_item.get("variants", []) - if len(variants) > 0: - shopify_item["variant_id"] = variants[0]["id"] - create_item(shopify_item, warehouse) - +def make_item( + shopify_settings: "ShopifySettings", + shopify_item: Union[Product, Variant] +): + attributes = [] + if isinstance(shopify_item, Product): + attributes = create_product_attributes(shopify_item) + + sync_item( + shopify_settings=shopify_settings, + shopify_item=shopify_item, + attributes=attributes + ) + sync_item_variants( + shopify_settings=shopify_settings, + shopify_item=shopify_item, + attributes=attributes + ) + + +def make_item_by_title(shopify_settings: "ShopifySettings", line_item_title: str): + item_data = { + "doctype": "Item", + "is_stock_item": 1, + "item_code": line_item_title, + "item_name": line_item_title, + "description": line_item_title, + "shopify_description": line_item_title, + "item_group": shopify_settings.item_group, + "stock_uom": _("Nos"), + "default_warehouse": shopify_settings.warehouse, + "integration_doctype": "Shopify Settings", + "integration_doc": shopify_settings.name, + "item_defaults": [{ + "company": get_default_company() + }] + } -def add_item_weight(shopify_item): - variants = shopify_item.get("variants", []) - if len(variants) > 0: - shopify_item["weight"] = variants[0]["weight"] - shopify_item["weight_unit"] = variants[0]["weight_unit"] + frappe.get_doc(item_data).insert(ignore_permissions=True) -def has_variants(shopify_item): - options = shopify_item.get("options", []) - if len(options) > 0 and "Default Title" not in options[0]["values"]: - return True - return False +def create_product_attributes(shopify_item: Product) -> List[Dict]: + if not has_variants(shopify_item): + return [] + item_attributes = [] -def create_attribute(shopify_item): - attribute = [] - # shopify item dict - for attr in shopify_item.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")}) + for attribute_option in shopify_item.attributes.get("options"): + attribute_option: "Option" + attribute_option_name = attribute_option.attributes.get("name") + attribute_option_values = attribute_option.attributes.get("values") or [] + if not frappe.db.exists("Item Attribute", attribute_option_name): + item_attr: "ItemAttribute" = frappe.new_doc("Item Attribute") + item_attr.attribute_name = attribute_option_name + update_item_attribute_values(item_attr, attribute_option_values) + item_attr.insert() + item_attributes.append({"attribute": attribute_option_name}) else: # check for attribute values - item_attr = frappe.get_doc("Item Attribute", attr.get("name")) + item_attr: "ItemAttribute" = frappe.get_doc("Item Attribute", attribute_option_name) if not item_attr.numeric_values: - set_new_attribute_values(item_attr, attr.get("values")) + update_item_attribute_values(item_attr, attribute_option_values) item_attr.save() - attribute.append({"attribute": attr.get("name")}) - + item_attributes.append({"attribute": attribute_option_name}) else: - attribute.append({ - "attribute": attr.get("name"), + item_attributes.append({ + "attribute": attribute_option_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 + return item_attributes + + +def has_variants(shopify_item: Product): + options = shopify_item.attributes.get("options", []) + if not options: + return False + if "Default Title" not in options[0].attributes.get("values"): + return True + return False + +def update_item_attribute_values(item_attr: "ItemAttribute", values: List[str]): + existing_attribute_values = [ + attr.attribute_value.lower() for attr in item_attr.item_attribute_values + ] -def set_new_attribute_values(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): + if attr_value.lower() not in existing_attribute_values: item_attr.append("item_attribute_values", { "attribute_value": attr_value, "abbr": attr_value }) -def create_item(shopify_item, warehouse, has_variant=False, attributes=None, variant_of=None): - item_title = shopify_item.get("title", "").strip() - item_description = shopify_item.get("body_html") or item_title - item_name = f"{variant_of} - {item_title}" if variant_of else item_title +def sync_item( + shopify_settings: "ShopifySettings", + shopify_item: Union[Product, Variant], + attributes: List[Dict] = None, + variant_of: str = str(), + update: bool = False +): + """ + Sync a Shopify product or variant and create a new Item document. If `update` is set + to `True`, then any existing items found are updated as well. + + Args: + shopify_settings (ShopifySettings): The Shopify configuration for the store. + shopify_item (Product | Variant): The Shopify `Product` or `Variant` data. + attributes (List[Dict], optional): The item attributes for the Shopify item. + Defaults to None. + variant_of (str, optional): If the item is a variant of an existing Item. + Defaults to an empty string. + update (bool, optional): `True` if existing items should be updated, otherwise `False`. + Defaults to False. + """ + + item_title = shopify_item.attributes.get("title", "").strip() + item_description = shopify_item.attributes.get("body_html") or item_title + item_has_variants = has_variants(shopify_item) if variant_of: variant_name = frappe.db.get_value("Item", variant_of, "item_name") item_name = f"{variant_name} - {item_title}" + + for attribute in attributes: + attribute.update({"variant_of": variant_of}) else: item_name = item_title - item_dict = { - "doctype": "Item", - "shopify_product_id": shopify_item.get("product_id"), - "shopify_variant_id": shopify_item.get("variant_id"), - "disabled_on_shopify": not shopify_item.get("product_exists", True), - "variant_of": variant_of, + product_id = variant_id = None + if isinstance(shopify_item, Product): + product_id = shopify_item.id + elif isinstance(shopify_item, Variant): + product_id = shopify_item.attributes.get("product_id") + variant_id = shopify_item.id + + item_data = { + "shopify_product_id": product_id, + "shopify_variant_id": variant_id, + "disabled_on_shopify": not shopify_item.attributes.get("product_exists", True), + # existing non-variant items default to `None`, if any other value is found, + # an error is thrown for "Variant Of", which is a "Set Only Once" field + "variant_of": variant_of or None, "is_stock_item": 1, - "item_code": cstr(shopify_item.get("item_code") or shopify_item.get("id") or item_title), + "item_code": cstr(shopify_item.id or item_title), "item_name": item_name, "description": item_description, "shopify_description": item_description, - "item_group": frappe.db.get_single_value("Shopify Settings", "default_item_group"), - "marketplace_item_group": get_item_group(shopify_item.get("product_type")), - "has_variants": has_variant, - "attributes": attributes or [], - "stock_uom": WEIGHT_UOM_MAP.get(shopify_item.get("uom")) or _("Nos"), - "stock_keeping_unit": shopify_item.get("sku") or get_sku(shopify_item), - "default_warehouse": warehouse, - "image": get_item_image(shopify_item), - "weight_uom": WEIGHT_UOM_MAP.get(shopify_item.get("weight_unit")), - "weight_per_unit": shopify_item.get("weight"), - "default_supplier": get_supplier(shopify_item), + "item_group": shopify_settings.item_group, + "marketplace_item_group": get_item_group(shopify_item.attributes.get("product_type")), + "has_variants": item_has_variants, + "stock_uom": WEIGHT_UOM_MAP.get(shopify_item.attributes.get("uom")) or _("Nos"), + "shopify_sku": shopify_item.attributes.get("sku"), + "default_warehouse": shopify_settings.warehouse, + "weight_uom": WEIGHT_UOM_MAP.get(shopify_item.attributes.get("weight_unit")), + "weight_per_unit": shopify_item.attributes.get("weight"), "integration_doctype": "Shopify Settings", - "integration_doc": "Shopify Settings", + "integration_doc": shopify_settings.name, "item_defaults": [{ "company": get_default_company() }] } - if not is_item_exists(item_dict, attributes, variant_of=variant_of): + if not is_item_exists(item_data, attributes, variant_of=variant_of): item_code = None - existing_item = get_existing_item(shopify_item) + existing_item_name = get_existing_item_name(shopify_item) + if not existing_item_name: + item_code = create_item(shopify_settings, shopify_item, item_data, attributes) + elif update: + item_code = update_item(shopify_settings, shopify_item, existing_item_name, item_data, attributes) - if existing_item: - existing_item_doc = frappe.get_doc("Item", existing_item) - existing_item_doc.update(item_dict) - existing_item_doc.save(ignore_permissions=True) - else: - new_item = frappe.get_doc(item_dict) + if item_code and not item_has_variants and shopify_settings.update_price_in_erpnext_price_list: + add_to_price_list(shopify_settings, shopify_item, item_code) - # this fails during the `validate_name_with_item_group` call in item.py - # if the item name matches with an existing item group; for such cases, - # appending the item group into the item name - if frappe.db.exists("Item Group", new_item.item_code): - new_item.item_code = f"{new_item.item_code} ({new_item.item_group})" + frappe.db.commit() - new_item.insert(ignore_permissions=True, ignore_mandatory=True) - item_code = new_item.name - if not item_code: - item_code = existing_item +def update_item( + shopify_settings: "ShopifySettings", + shopify_item: Union[Product, Variant], + item_name: str, + item_data: Dict, + attributes: List[Dict] +): + existing_item_doc: "Item" = frappe.get_doc("Item", item_name) + existing_item_doc.update(item_data) + + # update item attributes for existing items without transactions; + # if an item has transactions, its attributes cannot be changed + if not existing_item_doc.stock_ledger_created(): + existing_attributes = [attribute.attribute for attribute in existing_item_doc.attributes] + for attribute in attributes: + if attribute.get("attribute") not in existing_attributes: + existing_item_doc.append("attributes", attribute) + + # add default item supplier from Shopify + for default in existing_item_doc.item_defaults: + if not default.default_supplier: + default.default_supplier = get_supplier(shopify_settings, shopify_item) + + # fetch item image from Shopify + if not existing_item_doc.image: + existing_item_doc.image = get_item_image(shopify_settings, shopify_item) + + existing_item_doc.save(ignore_permissions=True) + return existing_item_doc.name + + +def create_item( + shopify_settings: "ShopifySettings", + shopify_item: Union[Product, Variant], + item_data: Dict, + attributes: List[Dict] +): + new_item: "Item" = frappe.new_doc("Item") + new_item.update(item_data) + new_item.update({ + "attributes": attributes or [], + "image": get_item_image(shopify_settings, shopify_item) + }) + + # this fails during the `validate_name_with_item_group` call in item.py + # if the item name matches with an existing item group; for such cases, + # appending the item group into the item name + if frappe.db.exists("Item Group", new_item.item_code): + new_item.item_code = f"{new_item.item_code} ({new_item.item_group})" + + new_item.insert(ignore_permissions=True, ignore_mandatory=True) + + # once the defaults have been generated, set the item supplier from Shopify + supplier = get_supplier(shopify_settings, shopify_item) + if supplier: + if new_item.item_defaults: + new_item.item_defaults[0].default_supplier = supplier + else: + new_item.append("item_defaults", {"default_supplier": supplier}) - if not has_variant: - add_to_price_list(shopify_item, item_code) + return new_item.name - frappe.db.commit() +def sync_item_variants( + shopify_settings: "ShopifySettings", + shopify_item: Union[Product, Variant], + attributes: List[Dict] +): + product_id = None + if isinstance(shopify_item, Product): + product_id = shopify_item.id + elif isinstance(shopify_item, Variant): + product_id = shopify_item.attributes.get("product_id") + + if not product_id: + return -def create_item_variants(shopify_item, warehouse, attributes): template_item = frappe.db.get_value("Item", - filters={"shopify_product_id": shopify_item.get("product_id")}, + filters={"shopify_product_id": product_id}, fieldname=["name", "stock_uom"], as_dict=True) if template_item: - for variant in shopify_item.get("variants", []): - shopify_item_variant = { - "id": variant.get("id"), - "item_code": variant.get("id"), - "title": variant.get("title"), - "product_type": shopify_item.get("product_type"), - "sku": variant.get("sku"), - "uom": template_item.stock_uom or _("Nos"), - "item_price": variant.get("price"), - "variant_id": variant.get("id"), - "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": get_attribute_value(variant.get(variant_attr), attributes[i])}) - create_item(shopify_item_variant, warehouse, 0, attributes, template_item.name) - - -def get_attribute_value(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) + for variant in shopify_item.attributes.get("variants", []): + variant: Variant + + for index, variant_attr in enumerate(SHOPIFY_VARIANTS_ATTR_LIST): + if variant.attributes.get(variant_attr): + attributes[index].update({ + "attribute_value": get_attribute_value( + variant.attributes.get(variant_attr), + attributes[index] + ) + }) + + sync_item( + shopify_settings=shopify_settings, + shopify_item=variant, + attributes=attributes, + variant_of=template_item.name + ) + + +def get_attribute_value(variant_attr_val: str, attribute: Dict): + 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(product_type=None): +def get_item_group(product_type: str = str()): from frappe.utils.nestedset import get_root_of - parent_item_group = get_root_of("Item Group") + parent_item_group = get_root_of("Item Group") if product_type: if not frappe.db.get_value("Item Group", product_type, "name"): item_group = frappe.get_doc({ @@ -276,26 +438,21 @@ def get_item_group(product_type=None): return parent_item_group -def get_sku(item): - if item.get("variants"): - return item.get("variants")[0].get("sku") - return "" - - -def add_to_price_list(item, item_code): - shopify_settings = frappe.db.get_value("Shopify Settings", None, ["price_list", "update_price_in_erpnext_price_list"], as_dict=1) - if not shopify_settings.update_price_in_erpnext_price_list: - return - +def add_to_price_list( + shopify_settings: "ShopifySettings", + shopify_item: Union[Product, Variant], + item_code: str +): item_price_name = frappe.db.get_value("Item Price", {"item_code": item_code, "price_list": shopify_settings.price_list}, "name") rate = 0 - variants = item.get("variants", []) - if item.get("item_price"): - rate = item.get("item_price") - elif variants and len(variants) > 0: - rate = variants[0].get("price") + if isinstance(shopify_item, Product): + variants = shopify_item.attributes.get("variants", []) + if variants: + rate = variants[0].attributes.get("price") or 0 + elif isinstance(shopify_item, Variant): + rate = shopify_item.attributes.get("price") or 0 if not item_price_name: frappe.get_doc({ @@ -310,28 +467,60 @@ def add_to_price_list(item, item_code): item_rate.save() -def get_item_image(shopify_item): - if shopify_item.get("image"): - return shopify_item.get("image").get("src") - return None - - -def get_supplier(shopify_item): - supplier = "" - if shopify_item.get("vendor"): - supplier = frappe.db.sql("""select name from tabSupplier - where name = %s or shopify_supplier_id = %s """, (shopify_item.get("vendor"), - shopify_item.get("vendor").lower()), as_list=1) +def get_item_image(shopify_settings: "ShopifySettings", shopify_item: Union[Product, Variant]): + image_url = None + products = [] + + if isinstance(shopify_item, Product): + products = [shopify_item] + elif isinstance(shopify_item, Variant): + products: List[Product] = shopify_settings.get_products( + shopify_item.attributes.get("product_id"), + fields="image" + ) + + for product in products: + if product.attributes.get("image"): + image_url = product.attributes.get("image").src + break + + return image_url + + +def get_supplier(shopify_settings: "ShopifySettings", shopify_item: Union[Product, Variant]): + supplier = vendor = str() + + # only Shopify products are assigned vendors + if isinstance(shopify_item, Product): + products = [shopify_item] + elif isinstance(shopify_item, Variant): + products: List[Product] = shopify_settings.get_products( + shopify_item.attributes.get("product_id"), + fields="vendor" + ) + + for product in products: + if product.attributes.get("vendor"): + vendor = product.attributes.get("vendor") + break + + if vendor: + suppliers = frappe.get_all("Supplier", + or_filters={ + "name": vendor, + "supplier_name": vendor, + "shopify_supplier_id": vendor.lower() + }) - if not supplier: + if not suppliers: supplier = frappe.get_doc({ "doctype": "Supplier", - "supplier_name": shopify_item.get("vendor"), - "shopify_supplier_id": shopify_item.get("vendor").lower(), + "supplier_name": vendor, + "shopify_supplier_id": vendor.lower(), "supplier_group": get_supplier_group() }).insert() return supplier.name - return shopify_item.get("vendor") + return suppliers[0].name return supplier @@ -346,25 +535,31 @@ def get_supplier_group(): return supplier_group -def get_existing_item(shopify_item): - existing_item = frappe.db.get_value("Item", {"shopify_product_id": shopify_item.get("product_id")}) - if existing_item: - return existing_item - - existing_item = frappe.db.get_value("Item", {"shopify_variant_id": shopify_item.get("variant_id")}) - return existing_item +def get_existing_item_name(shopify_item: Union[Product, Variant]): + item_name = None + if isinstance(shopify_item, Product): + item_name = frappe.db.get_value("Item", {"shopify_product_id": shopify_item.id}) + elif isinstance(shopify_item, Variant): + item_name = frappe.db.get_value("Item", {"shopify_variant_id": shopify_item.id}) + return item_name -def is_item_exists(shopify_item, attributes=None, variant_of=None): +def is_item_exists( + shopify_item: Dict, + attributes: List[Dict] = None, + variant_of: str = str() +): if variant_of: name = variant_of else: name = frappe.db.get_value("Item", {"item_name": shopify_item.get("item_name")}) + if not name: + return False - if not name: + if not frappe.db.exists("Item", name): return False - item = frappe.get_doc("Item", name) + item: "Item" = frappe.get_doc("Item", name) item.flags.ignore_mandatory = True if not variant_of and not item.shopify_product_id: @@ -404,7 +599,7 @@ def is_item_exists(shopify_item, attributes=None, variant_of=None): """.format(conditions=conditions), variant_of) if parent: - variant = frappe.get_doc("Item", parent[0]) + variant: "Item" = frappe.get_doc("Item", parent[0]) variant.flags.ignore_mandatory = True variant.shopify_product_id = shopify_item.get("shopify_product_id") diff --git a/shopify_integration/setup.py b/shopify_integration/setup.py index 311d05b..a3ba859 100644 --- a/shopify_integration/setup.py +++ b/shopify_integration/setup.py @@ -1,3 +1,4 @@ +import frappe from frappe import _ from frappe.custom.doctype.custom_field.custom_field import create_custom_fields @@ -5,13 +6,13 @@ def get_setup_stages(args=None): return [ { - 'status': _('Setting up Shopify'), - 'fail_msg': _('Failed to create Shopify masters'), - 'tasks': [ + "status": _("Setting up Shopify"), + "fail_msg": _("Failed to create Shopify masters"), + "tasks": [ { - 'fn': setup_custom_fields, - 'args': args, - 'fail_msg': _("Failed to create Shopify custom fields") + "fn": setup_custom_fields, + "args": args, + "fail_msg": _("Failed to create Shopify custom fields") } ] } @@ -21,54 +22,115 @@ def get_setup_stages(args=None): def setup_custom_fields(args=None): custom_fields = { "Customer": [ - dict(fieldname='shopify_customer_id', label='Shopify Customer ID', - fieldtype='Data', insert_after='series', read_only=1, print_hide=1) + dict(fieldname="shopify_customer_id", label="Shopify Customer ID", + fieldtype="Data", insert_after="series", read_only=1, + print_hide=1, translatable=0) ], "Supplier": [ - dict(fieldname='shopify_supplier_id', label='Shopify Supplier ID', - fieldtype='Data', insert_after='supplier_name', read_only=1, print_hide=1) + dict(fieldname="shopify_supplier_id", label="Shopify Supplier ID", + fieldtype="Data", insert_after="supplier_name", read_only=1, + print_hide=1, translatable=0) ], "Address": [ - dict(fieldname='shopify_address_id', label='Shopify Address ID', - fieldtype='Data', insert_after='fax', read_only=1, print_hide=1) + dict(fieldname="shopify_address_id", label="Shopify Address ID", + fieldtype="Data", insert_after="fax", read_only=1, + print_hide=1, translatable=0) ], "Item": [ # Shopify details - dict(fieldname='shopify_variant_id', label='Shopify Variant ID', - fieldtype='Data', insert_after='item_code', read_only=1, print_hide=1), - dict(fieldname='shopify_product_id', label='Shopify Product ID', - fieldtype='Data', insert_after='item_code', read_only=1, print_hide=1), - dict(fieldname='disabled_on_shopify', label='Disabled on Shopify', - fieldtype='Check', insert_after='disabled', read_only=1, print_hide=1), - dict(fieldname='marketplace_item_group', label='Marketplace Item Group', - fieldtype='Data', insert_after='item_group', read_only=1, print_hide=1), - dict(fieldname='shopify_description', label='Shopify Description', - fieldtype='Text Editor', insert_after='description', read_only=1, print_hide=1), + dict(fieldname="shopify_product_id", label="Shopify Product ID", + fieldtype="Data", insert_after="item_code", read_only=1, print_hide=1, + translatable=0), + dict(fieldname="shopify_variant_id", label="Shopify Variant ID", + fieldtype="Data", insert_after="item_code", read_only=1, print_hide=1, + translatable=0), + dict(fieldname="shopify_sku", label="Shopify SKU", fieldtype="Data", + insert_after="shopify_variant_id", read_only=1, print_hide=1, translatable=0), + dict(fieldname="disabled_on_shopify", label="Disabled on Shopify", + fieldtype="Check", insert_after="disabled", read_only=1, print_hide=1), + dict(fieldname="marketplace_item_group", label="Marketplace Item Group", + fieldtype="Data", insert_after="item_group", read_only=1, print_hide=1, + translatable=0), + dict(fieldname="shopify_description", label="Shopify Description", + fieldtype="Text Editor", insert_after="description", read_only=1, + print_hide=1, translatable=0), # Integration section - dict(fieldname='integration_details', label='Shopify', fieldtype='Section Break', - insert_after='description'), - dict(fieldname='integration_doctype', label='Integration DocType', - fieldtype='Link', options='DocType', insert_after='integration_details', + dict(fieldname="integration_details", label="Shopify", fieldtype="Section Break", + insert_after="description"), + dict(fieldname="integration_doctype", label="Integration DocType", + fieldtype="Link", options="DocType", insert_after="integration_details", hidden=1, print_hide=1), - dict(fieldname='cb_shopify', fieldtype='Column Break', insert_after='integration_doctype'), - dict(fieldname='integration_doc', label='Integration Doc', fieldtype='Data', - insert_after='cb_shopify', hidden=1, print_hide=1), + dict(fieldname="cb_shopify", fieldtype="Column Break", + insert_after="integration_doctype"), + dict(fieldname="integration_doc", label="Integration Doc", fieldtype="Data", + insert_after="cb_shopify", hidden=1, print_hide=1) ], "Sales Order": [ - dict(fieldname='shopify_order_id', label='Shopify Order ID', - fieldtype='Data', insert_after='title', read_only=1, print_hide=1) - ], - "Delivery Note": [ - dict(fieldname='shopify_order_id', label='Shopify Order ID', - fieldtype='Data', insert_after='title', read_only=1, print_hide=1), - dict(fieldname='shopify_fulfillment_id', label='Shopify Fulfillment ID', - fieldtype='Data', insert_after='title', read_only=1, print_hide=1) + dict(fieldname="sb_shopify", label="Shopify", fieldtype="Section Break", + insert_after="tax_id", collapsible=0), + dict(fieldname="shopify_settings", label="Shopify Settings", + fieldtype="Link", options="Shopify Settings", insert_after="sb_shopify", + read_only=1, print_hide=1), + dict(fieldname="shopify_order_number", label="Shopify Order Number", + fieldtype="Data", insert_after="shopify_settings", + read_only=1, print_hide=1, translatable=0), + dict(fieldname="cb_shopify", fieldtype="Column Break", + insert_after="shopify_order_number"), + dict(fieldname="shopify_order_id", label="Shopify Order ID", + fieldtype="Data", insert_after="cb_shopify", + read_only=1, print_hide=1, translatable=0) ], "Sales Invoice": [ - dict(fieldname='shopify_order_id', label='Shopify Order ID', - fieldtype='Data', insert_after='title', read_only=1, print_hide=1) + dict(fieldname="sb_shopify", label="Shopify", fieldtype="Section Break", + insert_after="cost_center", collapsible=0), + dict(fieldname="shopify_settings", label="Shopify Settings", + fieldtype="Link", options="Shopify Settings", insert_after="sb_shopify", + read_only=1, print_hide=1), + dict(fieldname="shopify_order_number", label="Shopify Order Number", + fieldtype="Data", insert_after="shopify_settings", + read_only=1, print_hide=1, translatable=0), + dict(fieldname="cb_shopify", fieldtype="Column Break", + insert_after="shopify_order_number"), + dict(fieldname="shopify_order_id", label="Shopify Order ID", + fieldtype="Data", insert_after="cb_shopify", + read_only=1, print_hide=1, translatable=0) + ], + "Delivery Note": [ + dict(fieldname="sb_shopify", label="Shopify", fieldtype="Section Break", + insert_after="return_against", collapsible=0), + dict(fieldname="shopify_settings", label="Shopify Settings", + fieldtype="Link", options="Shopify Settings", insert_after="sb_shopify", + read_only=1, print_hide=1), + dict(fieldname="shopify_order_number", label="Shopify Order Number", + fieldtype="Data", insert_after="shopify_settings", + read_only=1, print_hide=1, translatable=0), + dict(fieldname="cb_shopify", fieldtype="Column Break", + insert_after="shopify_order_number"), + dict(fieldname="shopify_order_id", label="Shopify Order ID", + fieldtype="Data", insert_after="cb_shopify", + read_only=1, print_hide=1, translatable=0), + dict(fieldname="shopify_fulfillment_id", label="Shopify Fulfillment ID", + fieldtype="Data", insert_after="shopify_order_id", + read_only=1, print_hide=1, translatable=0) ] } + # ref: https://github.com/ParsimonyGit/shipstation_integration/ + # check if the Shipstation app is installed on the current site; + # `frappe.db.table_exists` returns a false positive if any other + # site on the bench has the Shipstation app installed instead + if "shipstation_integration" in frappe.get_installed_apps(): + custom_fields.update({ + "Shipstation Store": [ + dict(fieldname="sb_shopify", label="Shopify", fieldtype="Section Break", + insert_after="amazon_marketplace", read_only=1), + dict(fieldname="is_shopify_store", label="Is Shopify Store", fieldtype="Check", + insert_after="sb_shopify", read_only=1, print_hide=1), + dict(fieldname="shopify_store", label="Shopify Store", fieldtype="Link", + options="Shopify Settings", insert_after="is_shopify_store", + depends_on="eval:doc.is_shopify_store", print_hide=1) + ] + }) + create_custom_fields(custom_fields) diff --git a/shopify_integration/shopify_integration/doctype/shopify_log/shopify_log.py b/shopify_integration/shopify_integration/doctype/shopify_log/shopify_log.py index 897f02b..370ab94 100644 --- a/shopify_integration/shopify_integration/doctype/shopify_log/shopify_log.py +++ b/shopify_integration/shopify_integration/doctype/shopify_log/shopify_log.py @@ -16,7 +16,7 @@ def make_shopify_log(status="Queued", response_data=None, exception=None, rollba # if name not provided by log calling method then fetch existing queued state log make_new = False - if not frappe.flags.request_id: + if not frappe.flags.log_id: make_new = True if rollback: @@ -25,7 +25,7 @@ def make_shopify_log(status="Queued", response_data=None, exception=None, rollba if make_new: log = frappe.new_doc("Shopify Log").insert(ignore_permissions=True) else: - log = frappe.get_doc("Shopify Log", frappe.flags.request_id) + log = frappe.get_doc("Shopify Log", frappe.flags.log_id) if not isinstance(response_data, str): response_data = json.dumps(response_data, sort_keys=True, indent=4) @@ -53,4 +53,4 @@ def get_message(exception): def resync(method, name, request_data): frappe.db.set_value("Shopify Log", name, "status", "Queued", update_modified=False) frappe.enqueue(method=method, queue='short', timeout=300, is_async=True, - **{"order": json.loads(request_data), "request_id": name}) + **{"order": json.loads(request_data), "log_id": name}) diff --git a/shopify_integration/shopify_integration/doctype/shopify_payout/shopify_payout.json b/shopify_integration/shopify_integration/doctype/shopify_payout/shopify_payout.json index 4346b2c..f6deae7 100644 --- a/shopify_integration/shopify_integration/doctype/shopify_payout/shopify_payout.json +++ b/shopify_integration/shopify_integration/doctype/shopify_payout/shopify_payout.json @@ -8,9 +8,10 @@ "field_order": [ "payout_id", "company", + "shop_name", "status", - "payout_date", "cb_payout", + "payout_date", "amount", "currency", "sb_summary", @@ -185,10 +186,17 @@ "fieldtype": "Table", "options": "Shopify Payout Transaction", "read_only": 1 + }, + { + "fieldname": "shop_name", + "fieldtype": "Link", + "label": "Shop Name", + "options": "Shopify Settings", + "read_only": 1 } ], "is_submittable": 1, - "modified": "2021-01-27 00:55:37.706988", + "modified": "2021-03-25 06:05:57.171458", "modified_by": "Administrator", "module": "Shopify Integration", "name": "Shopify Payout", diff --git a/shopify_integration/shopify_integration/doctype/shopify_payout/shopify_payout.py b/shopify_integration/shopify_integration/doctype/shopify_payout/shopify_payout.py index 572d270..263bd14 100644 --- a/shopify_integration/shopify_integration/doctype/shopify_payout/shopify_payout.py +++ b/shopify_integration/shopify_integration/doctype/shopify_payout/shopify_payout.py @@ -3,6 +3,7 @@ # For license information, please see license.txt from collections import defaultdict +from typing import TYPE_CHECKING import frappe from frappe.model.document import Document @@ -12,6 +13,9 @@ from shopify_integration.shopify_integration.doctype.shopify_log.shopify_log import make_shopify_log from shopify_integration.utils import get_accounting_entry, get_tax_account_head +if TYPE_CHECKING: + from shopify_integration.shopify_integration.doctype.shopify_settings.shopify_settings import ShopifySettings + class ShopifyPayout(Document): def on_submit(self): @@ -50,10 +54,10 @@ def update_invoice_fees(self): invoice.append("taxes", { "charge_type": "Actual", - "account_head": get_tax_account_head("fee"), + "account_head": get_tax_account_head(self.shop_name, "fee"), "description": transaction.transaction_type, "tax_amount": -flt(transaction.fee), - "cost_center": frappe.db.get_single_value("Shopify Settings", "cost_center") + "cost_center": frappe.db.get_value("Shopify Settings", self.shop_name, "cost_center") }) invoice.save() @@ -61,7 +65,7 @@ def update_invoice_fees(self): def update_cancelled_shopify_orders(self): doctypes = ["Delivery Note", "Sales Invoice", "Sales Order"] - settings = frappe.get_single("Shopify Settings") + settings: "ShopifySettings" = frappe.get_doc("Shopify Settings", self.shop_name) for transaction in self.transactions: if not transaction.source_order_id: @@ -121,7 +125,7 @@ def create_sales_returns(self): if not is_invoice_returned: si_doc = frappe.get_doc("Sales Invoice", transaction.sales_invoice) - create_sales_return(transaction.source_order_id, financial_status, si_doc) + create_sales_return(self.shop_name, transaction.source_order_id, financial_status, si_doc) def create_payout_journal_entry(self): entries = [] @@ -129,7 +133,7 @@ def create_payout_journal_entry(self): # make payout cash entry for transaction in self.transactions: if transaction.total_amount and transaction.transaction_type.lower() == "payout": - account = get_tax_account_head("payout") + account = get_tax_account_head(self.shop_name, "payout") amount = flt(transaction.net_amount) entry = get_accounting_entry(account=account, amount=amount) entries.append(entry) diff --git a/shopify_integration/shopify_integration/doctype/shopify_settings/shopify_settings.js b/shopify_integration/shopify_integration/doctype/shopify_settings/shopify_settings.js index dbbbddc..4c90318 100644 --- a/shopify_integration/shopify_integration/doctype/shopify_settings/shopify_settings.js +++ b/shopify_integration/shopify_integration/doctype/shopify_settings/shopify_settings.js @@ -48,33 +48,54 @@ frappe.ui.form.on("Shopify Settings", { `); } - frm.add_custom_button(__("Products"), function () { - frappe.call({ - method: "shopify_integration.products.sync_products_from_shopify", - freeze: true, - callback: function (r) { - if (r.message) { - frappe.msgprint(__("Product sync has been queued. This may take a few minutes.")); - } else { - frappe.msgprint(__("Something went wrong while trying to sync products. Please check the latest Shopify logs.")) + if (frm.doc.enable_shopify) { + frm.add_custom_button(__("Products"), function () { + frm.call({ + doc: frm.doc, + method: "sync_products", + freeze: true, + callback: function (r) { + if (!r.exc) { + frappe.msgprint(__("Product sync has been queued. This may take a few minutes.")); + } else { + frappe.msgprint(__("Something went wrong while trying to sync products. Please check the latest Shopify logs.")) + } } - } - }) - }, __("Sync")) - - frm.add_custom_button(__("Payouts"), function () { - frappe.call({ - method: "shopify_integration.payouts.sync_payouts_from_shopify", - freeze: true, - callback: function (r) { - if (r.message) { - frappe.msgprint(__("Payout sync has been queued. This may take a few minutes.")); - } else { - frappe.msgprint(__("Something went wrong while trying to sync payouts. Please check the latest Shopify logs.")) - } - } - }) - }, __("Sync")) + }) + }, __("Sync")); + + frm.add_custom_button(__("Payouts"), function () { + frappe.prompt( + [ + { + "fieldname": "start_date", + "fieldtype": "Datetime", + "label": __("Payout Start Date"), + "description": __("Defaults to the 'Last Sync Datetime' field"), + "default": frm.doc.last_sync_datetime, + "reqd": 1, + } + ], + (values) => { + let start_date = values.start_date; + frm.call({ + doc: frm.doc, + method: "sync_payouts", + args: { "start_date": start_date }, + freeze: true, + callback: function (r) { + if (!r.exc) { + frappe.msgprint(__("Payout sync has been queued. This may take a few minutes.")); + } else { + frappe.msgprint(__("Something went wrong while trying to sync payouts. Please check the latest Shopify logs.")) + } + } + }) + }, + __("Select Start Date") + ); + }, __("Sync")); + } }, app_type: function (frm) { diff --git a/shopify_integration/shopify_integration/doctype/shopify_settings/shopify_settings.json b/shopify_integration/shopify_integration/doctype/shopify_settings/shopify_settings.json index e6c8416..479cb20 100644 --- a/shopify_integration/shopify_integration/doctype/shopify_settings/shopify_settings.json +++ b/shopify_integration/shopify_integration/doctype/shopify_settings/shopify_settings.json @@ -1,13 +1,15 @@ { - "creation": "2015-05-18 05:21:07.270859", + "autoname": "field:shop_name", + "creation": "2021-03-24 07:03:58.991830", "doctype": "DocType", "document_type": "System", "engine": "InnoDB", "field_order": [ - "status_html", "enable_shopify", + "sb_shop", + "shop_name", "shopify_url", - "column_break_4", + "cb_shop", "app_type", "last_sync_datetime", "sb_auth", @@ -20,7 +22,6 @@ "webhooks", "sb_company", "company", - "default_item_group", "cb_company", "cost_center", "sb_customer", @@ -29,9 +30,10 @@ "customer_group", "sb_transactions", "price_list", - "update_price_in_erpnext_price_list", + "item_group", "cb_transactions", "warehouse", + "update_price_in_erpnext_price_list", "sb_naming_series", "sales_order_series", "sync_delivery_note", @@ -47,29 +49,21 @@ "payment_fee_account" ], "fields": [ - { - "fieldname": "status_html", - "fieldtype": "HTML", - "label": "status html", - "read_only": 1 - }, { "default": "0", "fieldname": "enable_shopify", "fieldtype": "Check", - "label": "Enable Shopify" + "in_list_view": 1, + "label": "Enabled" }, { - "description": "eg: frappe.myshopify.com", + "description": "For example, https://frappe.myshopify.com", "fieldname": "shopify_url", "fieldtype": "Data", "in_list_view": 1, "label": "Shop URL", - "reqd": 1 - }, - { - "fieldname": "column_break_4", - "fieldtype": "Column Break" + "reqd": 1, + "unique": 1 }, { "default": "Private", @@ -84,6 +78,7 @@ "fieldname": "last_sync_datetime", "fieldtype": "Datetime", "label": "Last Sync Datetime", + "no_copy": 1, "read_only": 1 }, { @@ -130,6 +125,7 @@ "fieldname": "webhooks", "fieldtype": "Table", "label": "Webhooks", + "no_copy": 1, "options": "Shopify Webhook Detail", "read_only": 1 }, @@ -284,15 +280,29 @@ "options": "Account" }, { - "default": "All Item Groups", - "fieldname": "default_item_group", + "fieldname": "shop_name", + "fieldtype": "Data", + "label": "Shop Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "sb_shop", + "fieldtype": "Section Break" + }, + { + "fieldname": "cb_shop", + "fieldtype": "Column Break" + }, + { + "fieldname": "item_group", "fieldtype": "Link", - "label": "Default Item Group", - "options": "Item Group" + "label": "Item Group", + "options": "Item Group", + "reqd": 1 } ], - "issingle": 1, - "modified": "2021-03-15 03:28:40.516690", + "modified": "2021-04-26 04:08:26.693213", "modified_by": "Administrator", "module": "Shopify Integration", "name": "Shopify Settings", @@ -310,5 +320,6 @@ } ], "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/shopify_integration/shopify_integration/doctype/shopify_settings/shopify_settings.py b/shopify_integration/shopify_integration/doctype/shopify_settings/shopify_settings.py index 0212be8..1515159 100644 --- a/shopify_integration/shopify_integration/doctype/shopify_settings/shopify_settings.py +++ b/shopify_integration/shopify_integration/doctype/shopify_settings/shopify_settings.py @@ -3,12 +3,13 @@ # For license information, please see license.txt from shopify.collection import PaginatedCollection, PaginatedIterator -from shopify.resources import Order, Payouts, Product, Refund, Transactions, Webhook +from shopify.resources import Order, Payouts, Product, Refund, Transactions, Variant, Webhook from shopify.session import Session as ShopifySession import frappe from frappe import _ from frappe.model.document import Document +from frappe.utils import get_datetime_str, get_first_day, today from shopify_integration.shopify_integration.doctype.shopify_log.shopify_log import make_shopify_log @@ -38,14 +39,17 @@ def get_shopify_session(self, temp=False): return ShopifySession(*args) def get_resources(self, resource, *args, **kwargs): - # TODO: figure out a way to nicely handle limits; - # currently, shopify's PaginatedIterator ignores limits during retrieval - with self.get_shopify_session(temp=True): resources = resource.find(*args, **kwargs) + # if a limited number of documents are requested, don't keep looping; + # this is a side-effect from the way the library works, since it + # doesn't process the "limit" keyword + if "limit" in kwargs: + return resources if isinstance(resources, PaginatedCollection) else [resources] + if isinstance(resources, PaginatedCollection): - # Shopify's API limits responses to 50 per page; + # Shopify's API limits responses to 50 per page by default; # we keep calling to retrieve all the resource documents paged_resources = PaginatedIterator(resources) return [resource for page in paged_resources for resource in page] @@ -69,9 +73,27 @@ def get_products(self, *args, **kwargs): def get_refunds(self, *args, **kwargs): return self.get_resources(Refund, *args, **kwargs) + def get_variants(self, *args, **kwargs): + return self.get_resources(Variant, *args, **kwargs) + def get_webhooks(self, *args, **kwargs): return self.get_resources(Webhook, *args, **kwargs) + def sync_products(self): + "Pull and sync products from Shopify, including variants" + from shopify_integration.products import sync_items_from_shopify + frappe.enqueue(method=sync_items_from_shopify, queue="long", is_async=True, **{"shop_name": self.name}) + + def sync_payouts(self, start_date: str = str()): + "Pull and sync payouts from Shopify Payments transactions" + from shopify_integration.payouts import create_shopify_payouts + if not start_date: + start_date = get_datetime_str(get_first_day(today())) + frappe.enqueue(method=create_shopify_payouts, queue='long', is_async=True, **{ + "shop_name": self.name, + "start_date": start_date + }) + def validate_access_credentials(self): if not self.shopify_url: frappe.throw(_("Missing value for Shop URL")) @@ -86,9 +108,9 @@ def update_webhooks(self): self.unregister_webhooks() def register_webhooks(self): - from shopify_integration.webhooks import get_webhook_url, SHOPIFY_WEBHOOK_TOPICS + from shopify_integration.webhooks import get_webhook_url, SHOPIFY_WEBHOOK_TOPIC_MAPPER - for topic in SHOPIFY_WEBHOOK_TOPICS: + for topic in SHOPIFY_WEBHOOK_TOPIC_MAPPER: with self.get_shopify_session(temp=True): webhook = Webhook.create({ "topic": topic, diff --git a/shopify_integration/shopify_integration/doctype/shopify_settings/test_data/custom_field.json b/shopify_integration/shopify_integration/doctype/shopify_settings/test_data/custom_field.json index 2056f14..3c8fba5 100644 --- a/shopify_integration/shopify_integration/doctype/shopify_settings/test_data/custom_field.json +++ b/shopify_integration/shopify_integration/doctype/shopify_settings/test_data/custom_field.json @@ -209,41 +209,6 @@ "unique": 0, "width": null }, - { - "allow_on_submit": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "default": null, - "depends_on": null, - "description": null, - "docstatus": 0, - "doctype": "Custom Field", - "dt": "Item", - "fieldname": "stock_keeping_unit", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "insert_after": "stock_uom", - "label": "Stock Keeping Unit", - "modified": "2015-11-10 09:29:10.854943", - "name": "Item-stock_keeping_unit", - "no_copy": 1, - "options": null, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 1, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "unique": 0, - "width": null - }, { "allow_on_submit": 0, "collapsible": 0, diff --git a/shopify_integration/shopify_integration/doctype/shopify_settings/test_shopify_settings.py b/shopify_integration/shopify_integration/doctype/shopify_settings/test_shopify_settings.py index be44cbc..47ad310 100644 --- a/shopify_integration/shopify_integration/doctype/shopify_settings/test_shopify_settings.py +++ b/shopify_integration/shopify_integration/doctype/shopify_settings/test_shopify_settings.py @@ -32,20 +32,22 @@ def setUp(self): setup_shopify() def test_order(self): + shopify_settings = frappe.get_doc("Shopify Settings", "Test Shopify") + # create customer with open(os.path.join(os.path.dirname(__file__), "test_data", "shopify_customer.json")) as shopify_customer: shopify_customer = json.loads(shopify_customer.read()) - create_customer(shopify_customer.get("customer")) + create_customer(shopify_settings.name, shopify_customer.get("customer")) # create item with open(os.path.join(os.path.dirname(__file__), "test_data", "shopify_item.json")) as shopify_item: shopify_item = json.loads(shopify_item.read()) - make_item(shopify_item.get("product")) + make_item(shopify_settings, shopify_item.get("product")) # create order with open(os.path.join(os.path.dirname(__file__), "test_data", "shopify_order.json")) as shopify_order: shopify_order = json.loads(shopify_order.read()) - create_shopify_documents(shopify_order.get("order")) + create_shopify_documents(shopify_settings.name, shopify_order.get("order")) # verify sales order IDs shopify_order_id = cstr(shopify_order.get("order", {}).get("id")) @@ -69,9 +71,10 @@ def test_order(self): def setup_shopify(): - shopify_settings = frappe.get_single("Shopify Settings") + shopify_settings = frappe.new_doc("Shopify Settings") shopify_settings.update({ "app_type": "Private", + "shop_name": "Test Shopify", "shopify_url": "test.myshopify.com", "api_key": secrets.token_urlsafe(nbytes=16), "password": secrets.token_urlsafe(nbytes=16), diff --git a/shopify_integration/utils.py b/shopify_integration/utils.py index df55cb7..0f757a6 100644 --- a/shopify_integration/utils.py +++ b/shopify_integration/utils.py @@ -1,5 +1,11 @@ +from typing import TYPE_CHECKING + import frappe from frappe import _ +from frappe.utils import cstr + +if TYPE_CHECKING: + from shopify import Order def get_accounting_entry( @@ -46,7 +52,7 @@ def get_debit_or_credit(amount, account): return debit_field if amount < 0 else credit_field -def get_tax_account_head(tax_type): +def get_tax_account_head(shop_name: str, tax_type: str): tax_map = { "payout": "cash_bank_account", "refund": "cash_bank_account", @@ -60,28 +66,58 @@ def get_tax_account_head(tax_type): if not tax_field: frappe.throw(_("Account not specified for '{0}'".format(frappe.unscrub(tax_type)))) - tax_account = frappe.db.get_single_value("Shopify Settings", tax_field) + tax_account = frappe.db.get_value("Shopify Settings", shop_name, tax_field) if not tax_account: frappe.throw(_("Account not specified for '{0}'".format(frappe.unscrub(tax_field)))) return tax_account -def get_shopify_document(doctype, shopify_order_id): +def get_shopify_document( + shop_name: str, + doctype: str, + order: "Order" = None, + order_id: str = str() +): """ - Get a valid linked document for a Shopify order ID. + Check if a Shopify order exists, including references from other apps. Args: - doctype (str): The doctype to retrieve - shopify_order_id (str): The Shopify order ID + shop_name (str): The name of the Shopify configuration for the store. + doctype (str): The doctype records to check against. + order (Order, optional): The Shopify order data. + order_id (str, optional): The Shopify order ID. Returns: - Document: The document for the Shopify order. Defaults to an - empty object if no document is found. + list(BaseDocument) | BaseDocument: The document object if a Shipstation + order exists for the Shopify order, otherwise an empty list. If + Delivery Notes need to be checked, then all found delivery documents + are returned. """ - name = frappe.db.get_value(doctype, - {"docstatus": ["<", 2], "shopify_order_id": shopify_order_id}, "name") - if name: - return frappe.get_doc(doctype, name) - return frappe._dict() + shopify_docs = [] + + if order: + shopify_order_id = cstr(order.id) + shopify_order_number = cstr(order.order_number) + elif order_id: + shopify_order_id = order_id + shopify_order_number = None + + existing_docs = frappe.db.get_all(doctype, + filters={ + "docstatus": ["<", 2], + "shopify_settings": shop_name, + }, + or_filters={ + "shopify_order_id": shopify_order_id, + "shopify_order_number": shopify_order_number + }) + + if existing_docs: + # multiple deliveries can be made against a single order + if doctype == "Delivery Note": + shopify_docs = [frappe.get_doc(doctype, doc.name) for doc in existing_docs] + shopify_docs = frappe.get_doc(doctype, existing_docs[0].name) + + return shopify_docs diff --git a/shopify_integration/webhooks.py b/shopify_integration/webhooks.py index 304fc30..3d2e583 100644 --- a/shopify_integration/webhooks.py +++ b/shopify_integration/webhooks.py @@ -1,45 +1,95 @@ +import base64 +import hashlib +import hmac import json import frappe -from erpnext.erpnext_integrations.utils import validate_webhooks_request +from frappe import _ -SHOPIFY_WEBHOOK_TOPICS = [ - "orders/create", - "orders/paid", - "orders/fulfilled", - "orders/cancelled" -] +SHOPIFY_WEBHOOK_TOPIC_MAPPER = { + "orders/create": "shopify_integration.orders.create_shopify_documents", + "orders/paid": "shopify_integration.invoices.prepare_sales_invoice", + "orders/fulfilled": "shopify_integration.fulfilments.prepare_delivery_note", + "orders/cancelled": "shopify_integration.orders.cancel_shopify_order" +} @frappe.whitelist(allow_guest=True) -@validate_webhooks_request("Shopify Settings", 'X-Shopify-Hmac-Sha256', secret_key='shared_secret') -def store_request_data(data=None, event=None): - if frappe.request: +def store_request_data(): + if not frappe.request: + return + + event = frappe.request.headers.get("X-Shopify-Topic") + if not SHOPIFY_WEBHOOK_TOPIC_MAPPER.get(event): + return + + shop_name = get_shop_for_webhook() + if shop_name: + validate_webhooks_request( + doctype="Shopify Settings", + name=shop_name, + hmac_key="X-Shopify-Hmac-Sha256", + secret_key="shared_secret", + ) + data = json.loads(frappe.request.data) - event = frappe.request.headers.get('X-Shopify-Topic') + dump_request_data(shop_name, data, event) + + +def validate_webhooks_request(doctype, name, hmac_key, secret_key="secret"): + if not frappe.request or frappe.flags.in_test: + return - dump_request_data(data, event) + shop = frappe.get_doc(doctype, name) + if shop and shop.get(secret_key): + digest = hmac.new( + key=shop.get(secret_key).encode("utf8"), + msg=frappe.request.data, + digestmod=hashlib.sha256, + ).digest() + computed_hmac = base64.b64encode(digest) + if ( + frappe.request.data + and frappe.get_request_header(hmac_key) + and computed_hmac != bytes(frappe.get_request_header(hmac_key).encode()) + ): + frappe.throw(_("Unverified Shopify Webhook Data")) -def dump_request_data(data, event="orders/create"): - event_mapper = { - "orders/create": "shopify_integration.orders.create_shopify_documents", - "orders/paid": "shopify_integration.invoices.prepare_sales_invoice", - "orders/fulfilled": "shopify_integration.fulfilments.prepare_delivery_note", - "orders/cancelled": "shopify_integration.orders.cancel_shopify_order", - } +def dump_request_data(shop_name, data, event="orders/create"): + frappe.set_user("Administrator") log = frappe.get_doc({ "doctype": "Shopify Log", "request_data": json.dumps(data, indent=1), - "method": event_mapper.get(event) + "method": SHOPIFY_WEBHOOK_TOPIC_MAPPER.get(event), }).insert(ignore_permissions=True) frappe.db.commit() - frappe.enqueue(method=event_mapper[event], queue='short', timeout=300, is_async=True, - **{"order": data, "request_id": log.name}) + frappe.enqueue( + method=SHOPIFY_WEBHOOK_TOPIC_MAPPER.get(event), + queue="short", + timeout=300, + is_async=True, + **{"shop": shop_name, "order": data, "log_id": log.name} + ) + + +def get_shop_for_webhook(): + shop_domain = frappe.request.headers.get("X-Shopify-Shop-Domain") + + active_shops = frappe.get_all("Shopify Settings", + filters={"enable_shopify": True}, + fields=["name", "shopify_url"] + ) + + for active_shop in active_shops: + # sometimes URLs can include HTTP schemes, so only check if + # the domain is in the Shopify URL + if shop_domain in active_shop.shopify_url: + return active_shop.name def get_webhook_url(): # Shopify only supports HTTPS requests - return f'https://{frappe.request.host}/api/method/shopify_integration.webhooks.store_request_data' + return f"https://{frappe.request.host}/api/method/shopify_integration.webhooks.store_request_data"