From 8ed05b1672510833bc19306bebda337526e6168f Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Sun, 11 Oct 2026 01:09:23 +0000 Subject: [PATCH 001/109] feat(cart): add a server-side cart and stackable discount codes The cart lived only in the browser and the Quotation was rebuilt from it, so a cart could not be built on the server and lines could not carry discounts or attributes. ERPNext also allows one coupon per document, and its rule matcher fails when coupon rules overlap. Commera Cart now holds the shopper's lines, line attributes and codes. The browser cart loads it on page load and syncs changes back, guarded by a version. Commera Discount codes stack like Shopify's product and order classes and are applied on top of ERPNext pricing on every save of a Quotation, Sales Order and Sales Invoice, into the standard discount fields. --- commera/api/payments.py | 3 + commera/api/server_cart.py | 78 ++++++ .../commera_ecommerce/custom/quotation.json | 128 +++++++++ .../custom/quotation_item.json | 201 ++++++++++++++ .../custom/sales_invoice.json | 137 ++++++++++ .../custom/sales_invoice_item.json | 201 ++++++++++++++ .../commera_ecommerce/custom/sales_order.json | 128 +++++++++ .../custom/sales_order_item.json | 201 ++++++++++++++ .../doctype/commera_cart/__init__.py | 0 .../doctype/commera_cart/commera_cart.json | 58 ++++ .../doctype/commera_cart/commera_cart.py | 220 +++++++++++++++ .../doctype/commera_cart_line/__init__.py | 0 .../commera_cart_line/commera_cart_line.json | 44 +++ .../commera_cart_line/commera_cart_line.py | 5 + .../doctype/commera_discount/__init__.py | 0 .../commera_discount/commera_discount.json | 169 ++++++++++++ .../commera_discount/commera_discount.py | 88 ++++++ .../commera_discount_target/__init__.py | 0 .../commera_discount_target.json | 37 +++ .../commera_discount_target.py | 5 + commera/discount_codes.py | 120 +++++++++ commera/hooks.py | 14 +- commera/sdk/cart.py | 66 ++++- commera/sdk/types.py | 37 +++ .../templates/includes/cart-permalink.html | 1 + commera/templates/includes/cart-state.html | 50 ++++ commera/tests/test_discount_codes.py | 254 ++++++++++++++++++ commera/tests/test_server_cart.py | 128 +++++++++ 28 files changed, 2370 insertions(+), 3 deletions(-) create mode 100644 commera/api/server_cart.py create mode 100644 commera/commera_ecommerce/custom/quotation_item.json create mode 100644 commera/commera_ecommerce/custom/sales_invoice.json create mode 100644 commera/commera_ecommerce/custom/sales_invoice_item.json create mode 100644 commera/commera_ecommerce/custom/sales_order_item.json create mode 100644 commera/commera_ecommerce/doctype/commera_cart/__init__.py create mode 100644 commera/commera_ecommerce/doctype/commera_cart/commera_cart.json create mode 100644 commera/commera_ecommerce/doctype/commera_cart/commera_cart.py create mode 100644 commera/commera_ecommerce/doctype/commera_cart_line/__init__.py create mode 100644 commera/commera_ecommerce/doctype/commera_cart_line/commera_cart_line.json create mode 100644 commera/commera_ecommerce/doctype/commera_cart_line/commera_cart_line.py create mode 100644 commera/commera_ecommerce/doctype/commera_discount/__init__.py create mode 100644 commera/commera_ecommerce/doctype/commera_discount/commera_discount.json create mode 100644 commera/commera_ecommerce/doctype/commera_discount/commera_discount.py create mode 100644 commera/commera_ecommerce/doctype/commera_discount_target/__init__.py create mode 100644 commera/commera_ecommerce/doctype/commera_discount_target/commera_discount_target.json create mode 100644 commera/commera_ecommerce/doctype/commera_discount_target/commera_discount_target.py create mode 100644 commera/discount_codes.py create mode 100644 commera/tests/test_discount_codes.py create mode 100644 commera/tests/test_server_cart.py diff --git a/commera/api/payments.py b/commera/api/payments.py index 8377424d..0f5ab481 100644 --- a/commera/api/payments.py +++ b/commera/api/payments.py @@ -24,6 +24,7 @@ ) from commera.api.signup import get_placeholder_first_name, validate_single_email, validate_user_names from commera.checkout_hooks import apply_plugin_fees, filter_payment_methods, get_cart_refusal +from commera.commera_ecommerce.doctype.commera_cart.commera_cart import get_session_cart from commera.core import _get_cart_quotation, create_party, get_customer_contact, new_cart_quotation from commera.guest import ( get_guest_cart_name, @@ -505,6 +506,8 @@ def get_quotation_for_cart(cart: dict, unsaved_quotation_doc): "warehouse": ecommerce_warehouse, }, ) + if server_cart := get_session_cart(): + server_cart.update_quotation(unsaved_quotation_doc) save_cart_quotation(unsaved_quotation_doc) # The stored option was priced for the old cart, and payment would re-quote it for the new one. clear_delivery_option(unsaved_quotation_doc) diff --git a/commera/api/server_cart.py b/commera/api/server_cart.py new file mode 100644 index 00000000..a4d68c31 --- /dev/null +++ b/commera/api/server_cart.py @@ -0,0 +1,78 @@ +import frappe +from frappe import _ +from frappe.rate_limiter import rate_limit +from frappe.utils.data import cint, cstr + +from commera.commera_ecommerce.doctype.commera_cart.commera_cart import ( + MAX_CART_LINES, + CommeraCart, + get_session_cart, +) +from commera.discount_codes import get_discount_codes +from commera.sdk import cart as cart_sdk +from commera.www.cart.permalink import get_cart_lines + + +# Guests hold carts before signing in; the cart cookie scopes each call to the caller's own cart. +@frappe.whitelist(allow_guest=True, methods=["GET", "POST"]) # nosemgrep: guest-whitelisted-method +def get_server_cart() -> dict | None: + cart = get_session_cart() + return get_cart_state(cart) if cart else None + + +@frappe.whitelist(allow_guest=True, methods=["POST"]) # nosemgrep: guest-whitelisted-method +@rate_limit(limit=600, seconds=60 * 60) +def sync_server_cart(items: list | str, version: str | None = None) -> dict: + """Copy the browser cart's quantities to the server cart. When the server cart changed after the + browser last saw it, nothing is written and the server cart comes back for the browser to show.""" + quantities = get_browser_quantities(items) + cart = get_session_cart(create=bool(quantities)) + if not cart: + return {"version": None} + if version and not cart.is_new() and version != cart.version: + return {"conflict": True, **get_cart_state(cart)} + + if cart.set_quantities(quantities) or cart.is_new(): + cart.save(ignore_permissions=True) + return {"version": cart.version} + + +@frappe.whitelist(allow_guest=True, methods=["GET", "POST"]) # nosemgrep: guest-whitelisted-method +def get_priced_server_cart() -> dict | None: + return cart_sdk.get_priced_cart() + + +@frappe.whitelist(allow_guest=True, methods=["POST"]) # nosemgrep: guest-whitelisted-method +@rate_limit(limit=30, seconds=60 * 60) +def apply_discount_code(code: str) -> dict: + return cart_sdk.add_discount_code(cstr(code).strip()) + + +@frappe.whitelist(allow_guest=True, methods=["POST"]) # nosemgrep: guest-whitelisted-method +def remove_discount_code(code: str) -> dict: + return cart_sdk.remove_discount_code(cstr(code).strip()) + + +def get_browser_quantities(items: list | str) -> dict[str, int]: + quantities = {} + for entry in frappe.parse_json(items) or []: + item_code = cstr(entry.get("item_code")).strip() + qty = cint(entry.get("qty")) + if not item_code or qty < 1: + frappe.throw(_("Each cart line needs an item code and a quantity of at least 1.")) + quantities[item_code] = quantities.get(item_code, 0) + qty + if len(quantities) > MAX_CART_LINES: + frappe.throw(_("A cart holds at most {0} lines.").format(MAX_CART_LINES)) + return quantities + + +def get_cart_state(cart: CommeraCart) -> dict: + """The server cart as storefront cart lines, so the browser can replace its own copy.""" + quantities = {} + for row in cart.lines: + quantities[row.item_code] = quantities.get(row.item_code, 0) + cint(row.qty) + return { + "version": cart.version, + "lines": get_cart_lines(quantities, []), + "discount_codes": get_discount_codes(cart), + } diff --git a/commera/commera_ecommerce/custom/quotation.json b/commera/commera_ecommerce/custom/quotation.json index 74fad6fc..2a20da50 100644 --- a/commera/commera_ecommerce/custom/quotation.json +++ b/commera/commera_ecommerce/custom/quotation.json @@ -703,6 +703,134 @@ "translatable": 0, "unique": 0, "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "Commera Discount codes, one per line.", + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount_codes", + "fieldtype": "Small Text", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "coupon_code", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Codes", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_discount_codes", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_code_order_discount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_discount_codes", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Codes Order Discount", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_code_order_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null } ], "custom_perms": [], diff --git a/commera/commera_ecommerce/custom/quotation_item.json b/commera/commera_ecommerce/custom/quotation_item.json new file mode 100644 index 00000000..3fec2d22 --- /dev/null +++ b/commera/commera_ecommerce/custom/quotation_item.json @@ -0,0 +1,201 @@ +{ + "custom_fields": [ + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_code_discount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "discount_amount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Code Amount Per Unit", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation Item-custom_code_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount_code", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_code_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Code", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation Item-custom_discount_code", + "no_copy": 0, + "non_negative": 0, + "options": "Commera Discount", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "JSON object of the cart line's attributes.", + "docstatus": 0, + "dt": "Quotation Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_line_attributes", + "fieldtype": "Small Text", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_discount_code", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Line Attributes", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation Item-custom_line_attributes", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + } + ], + "custom_perms": [], + "doctype": "Quotation Item", + "links": [], + "property_setters": [], + "sync_on_migrate": 1 +} diff --git a/commera/commera_ecommerce/custom/sales_invoice.json b/commera/commera_ecommerce/custom/sales_invoice.json new file mode 100644 index 00000000..b73c4499 --- /dev/null +++ b/commera/commera_ecommerce/custom/sales_invoice.json @@ -0,0 +1,137 @@ +{ + "custom_fields": [ + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "Commera Discount codes, one per line.", + "docstatus": 0, + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount_codes", + "fieldtype": "Small Text", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "coupon_code", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Codes", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice-custom_discount_codes", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_code_order_discount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_discount_codes", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Codes Order Discount", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice-custom_code_order_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + } + ], + "custom_perms": [], + "doctype": "Sales Invoice", + "links": [], + "property_setters": [], + "sync_on_migrate": 1 +} diff --git a/commera/commera_ecommerce/custom/sales_invoice_item.json b/commera/commera_ecommerce/custom/sales_invoice_item.json new file mode 100644 index 00000000..42fa065d --- /dev/null +++ b/commera/commera_ecommerce/custom/sales_invoice_item.json @@ -0,0 +1,201 @@ +{ + "custom_fields": [ + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Invoice Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_code_discount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "discount_amount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Code Amount Per Unit", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice Item-custom_code_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Invoice Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount_code", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_code_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Code", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice Item-custom_discount_code", + "no_copy": 0, + "non_negative": 0, + "options": "Commera Discount", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "JSON object of the cart line's attributes.", + "docstatus": 0, + "dt": "Sales Invoice Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_line_attributes", + "fieldtype": "Small Text", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_discount_code", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Line Attributes", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice Item-custom_line_attributes", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + } + ], + "custom_perms": [], + "doctype": "Sales Invoice Item", + "links": [], + "property_setters": [], + "sync_on_migrate": 1 +} diff --git a/commera/commera_ecommerce/custom/sales_order.json b/commera/commera_ecommerce/custom/sales_order.json index 3ad79a7e..4dc34ee8 100644 --- a/commera/commera_ecommerce/custom/sales_order.json +++ b/commera/commera_ecommerce/custom/sales_order.json @@ -1023,6 +1023,134 @@ "translatable": 0, "unique": 0, "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "Commera Discount codes, one per line.", + "docstatus": 0, + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount_codes", + "fieldtype": "Small Text", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "coupon_code", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Codes", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order-custom_discount_codes", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_code_order_discount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_discount_codes", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Codes Order Discount", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order-custom_code_order_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null } ], "custom_perms": [], diff --git a/commera/commera_ecommerce/custom/sales_order_item.json b/commera/commera_ecommerce/custom/sales_order_item.json new file mode 100644 index 00000000..e07d6b5e --- /dev/null +++ b/commera/commera_ecommerce/custom/sales_order_item.json @@ -0,0 +1,201 @@ +{ + "custom_fields": [ + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Order Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_code_discount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "discount_amount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Code Amount Per Unit", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order Item-custom_code_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Order Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount_code", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_code_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Code", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order Item-custom_discount_code", + "no_copy": 0, + "non_negative": 0, + "options": "Commera Discount", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-10 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "JSON object of the cart line's attributes.", + "docstatus": 0, + "dt": "Sales Order Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_line_attributes", + "fieldtype": "Small Text", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_discount_code", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Line Attributes", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order Item-custom_line_attributes", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + } + ], + "custom_perms": [], + "doctype": "Sales Order Item", + "links": [], + "property_setters": [], + "sync_on_migrate": 1 +} diff --git a/commera/commera_ecommerce/doctype/commera_cart/__init__.py b/commera/commera_ecommerce/doctype/commera_cart/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/commera_ecommerce/doctype/commera_cart/commera_cart.json b/commera/commera_ecommerce/doctype/commera_cart/commera_cart.json new file mode 100644 index 00000000..81adc8af --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_cart/commera_cart.json @@ -0,0 +1,58 @@ +{ + "actions": [], + "autoname": "hash", + "creation": "2026-10-10 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": ["user", "cart_key", "discount_codes", "lines"], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "label": "User", + "options": "User", + "read_only": 1, + "search_index": 1 + }, + { + "description": "Hash of the guest cart cookie. Empty for a signed-in shopper.", + "fieldname": "cart_key", + "fieldtype": "Data", + "hidden": 1, + "label": "Guest Cart Key", + "read_only": 1, + "search_index": 1 + }, + { + "description": "Commera Discount codes, one per line.", + "fieldname": "discount_codes", + "fieldtype": "Small Text", + "label": "Discount Codes" + }, + { + "fieldname": "lines", + "fieldtype": "Table", + "label": "Lines", + "options": "Commera Cart Line" + } + ], + "links": [], + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": "Commera Ecommerce", + "name": "Commera Cart", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "read": 1, + "role": "System Manager", + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/commera/commera_ecommerce/doctype/commera_cart/commera_cart.py b/commera/commera_ecommerce/doctype/commera_cart/commera_cart.py new file mode 100644 index 00000000..360f7ae4 --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_cart/commera_cart.py @@ -0,0 +1,220 @@ +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils.data import cint, cstr, flt, get_datetime + +from commera.commera_ecommerce.doctype.commera_discount.commera_discount import get_code_name +from commera.discount_codes import ( + apply_code_discounts, + get_discount, + get_discount_codes, + validate_new_discount_code, +) +from commera.guest import get_guest_cart_key, is_guest, set_guest_cart_cookie + +MAX_CART_LINES = 50 + + +class CommeraCart(Document): + """The shopper's cart on the server: lines with attributes, and discount codes. ERPNext prices it.""" + + def validate(self): + if len(self.lines) > MAX_CART_LINES: + frappe.throw(_("A cart holds at most {0} lines.").format(MAX_CART_LINES)) + for line in self.lines: + if cint(line.qty) < 1: + frappe.throw(_("Row {0}: the quantity must be at least 1.").format(line.idx)) + attributes = parse_attributes(line.attributes) + line.attributes = frappe.as_json(attributes, indent=None) if attributes else None + + @property + def version(self) -> str: + return str(get_datetime(self.modified)) + + def add_lines(self, lines: list[dict]): + """Add lines; a line with the same item and attributes as an existing one adds to its quantity.""" + for line in lines: + attributes = parse_attributes(line.get("attributes")) + qty = cint(line.get("qty") or 1) + existing_line = next( + ( + row + for row in self.lines + if row.item_code == line["item_code"] and parse_attributes(row.attributes) == attributes + ), + None, + ) + if existing_line: + existing_line.qty = cint(existing_line.qty) + qty + else: + self.append("lines", {"item_code": line["item_code"], "qty": qty, "attributes": attributes}) + + def update_lines(self, lines: list[dict]): + """Set the quantity or attributes of lines by `line_id`. A quantity of 0 removes the line.""" + for line in lines: + row = self.get_line(line["line_id"]) + if "attributes" in line: + row.attributes = parse_attributes(line["attributes"]) + if "qty" in line: + row.qty = cint(line["qty"]) + self.lines = [row for row in self.lines if cint(row.qty) > 0] + + def remove_lines(self, line_ids: list[str]): + for line_id in line_ids: + self.get_line(line_id) + self.lines = [row for row in self.lines if row.name not in line_ids] + + def get_line(self, line_id: str): + row = next((row for row in self.lines if row.name == line_id), None) + if not row: + frappe.throw(_("Cart line {0} was not found.").format(line_id), frappe.DoesNotExistError) + return row + + def set_quantities(self, quantities: dict[str, int]) -> bool: + """Match the browser cart's quantity per item, keeping each line's attributes. Return True on a change.""" + changed = False + for item_code in {row.item_code for row in self.lines} - set(quantities): + self.lines = [row for row in self.lines if row.item_code != item_code] + changed = True + + for item_code, qty in quantities.items(): + rows = [row for row in self.lines if row.item_code == item_code] + delta = cint(qty) - sum(cint(row.qty) for row in rows) + if not delta: + continue + changed = True + if not rows: + self.append("lines", {"item_code": item_code, "qty": cint(qty)}) + continue + change_quantity_from_the_end(self, rows, delta) + return changed + + def add_discount_code(self, code: str): + codes = get_discount_codes(self) + codes.append(validate_new_discount_code(code, codes)) + self.discount_codes = "\n".join(codes) + + def remove_discount_code(self, code: str): + code = get_code_name(code) + self.discount_codes = "\n".join(name for name in get_discount_codes(self) if name != code) + + def get_lines_by_item_code(self) -> dict[str, list]: + lines_by_item_code = {} + for row in self.lines: + lines_by_item_code.setdefault(row.item_code, []).append(row) + return lines_by_item_code + + def get_priced_cart(self) -> dict: + """The cart priced as checkout would price it: ERPNext price lists and rules, then the codes.""" + quotation = self.get_quotation_preview() + lines_by_row = dict(zip([row.name for row in self.lines], quotation.items, strict=True)) + return { + "name": self.name, + "version": self.version, + "currency": quotation.currency, + "discount_codes": [ + {"code": code, "class": get_discount(code).discount_class} + for code in get_discount_codes(self) + ], + "lines": [get_priced_line(row, lines_by_row[row.name]) for row in self.lines], + "subtotal": flt(quotation.total), + "order_discount": flt(quotation.custom_code_order_discount), + "total": flt(quotation.net_total), + } + + def get_quotation_preview(self): + """An unsaved Quotation. Priced elevated, like the checkout cart: ERPNext reads Items the shopper cannot.""" + from commera.api.payments import system_user_session + + settings = frappe.get_cached_doc("Commera Settings") + quotation = frappe.new_doc("Quotation") + quotation.update( + { + "quotation_to": "Customer", + "company": settings.get("company") or frappe.defaults.get_global_default("company"), + "order_type": "Shopping Cart", + "selling_price_list": settings.sale_price_list, + "custom_discount_codes": self.discount_codes, + } + ) + for row in self.lines: + quotation.append( + "items", + {"item_code": row.item_code, "qty": row.qty, "warehouse": settings.ecommerce_warehouse}, + ) + with system_user_session(): + quotation.run_method("set_missing_values") + quotation.calculate_taxes_and_totals() + apply_code_discounts(quotation) + return quotation + + def update_quotation(self, quotation): + """Copy the attributes and codes onto a checkout Quotation whose lines came from the browser. When + the browser's quantity matches, an item held on several lines stays split by attributes.""" + lines_by_item_code = self.get_lines_by_item_code() + items = [] + for item in quotation.items: + lines = lines_by_item_code.get(item.item_code, []) + row = {"item_code": item.item_code, "warehouse": item.warehouse} + if len(lines) > 1 and sum(cint(line.qty) for line in lines) == cint(item.qty): + items.extend( + {**row, "qty": line.qty, "custom_line_attributes": line.attributes} for line in lines + ) + else: + items.append( + {**row, "qty": item.qty, "custom_line_attributes": lines[0].attributes if lines else None} + ) + quotation.set("items", items) + quotation.custom_discount_codes = self.discount_codes + + +def change_quantity_from_the_end(cart, rows: list, delta: int): + if delta > 0: + rows[-1].qty = cint(rows[-1].qty) + delta + return + for row in reversed(rows): + taken = min(cint(row.qty), -delta) + row.qty = cint(row.qty) - taken + delta += taken + cart.lines = [row for row in cart.lines if cint(row.qty) > 0] + + +def get_priced_line(row, item) -> dict: + return { + "line_id": row.name, + "item_code": row.item_code, + "title": item.item_name, + "qty": cint(row.qty), + "attributes": parse_attributes(row.attributes), + "price": flt(item.price_list_rate), + "rate": flt(item.rate), + "code_discount": flt(item.custom_code_discount), + "discount_code": item.custom_discount_code, + "amount": flt(item.amount), + } + + +def parse_attributes(attributes) -> dict: + attributes = frappe.parse_json(attributes) if attributes else {} + if not isinstance(attributes, dict): + frappe.throw(_("Line attributes must be a JSON object.")) + return {cstr(key): cstr(value) for key, value in attributes.items()} + + +def get_session_cart(create: bool = False) -> CommeraCart | None: + """The session shopper's cart, locked for this request. With `create`, start one when they have none.""" + filters = {"user": frappe.session.user} if not is_guest() else {"cart_key": get_guest_cart_key()} + if is_guest() and not filters["cart_key"]: + filters = None + name = frappe.db.get_value("Commera Cart", filters, "name", order_by="modified desc") if filters else None + if name: + return frappe.get_doc("Commera Cart", name, for_update=True) + if not create: + return None + + cart = frappe.new_doc("Commera Cart") + if is_guest(): + cart.cart_key = set_guest_cart_cookie() + else: + cart.user = frappe.session.user + return cart diff --git a/commera/commera_ecommerce/doctype/commera_cart_line/__init__.py b/commera/commera_ecommerce/doctype/commera_cart_line/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/commera_ecommerce/doctype/commera_cart_line/commera_cart_line.json b/commera/commera_ecommerce/doctype/commera_cart_line/commera_cart_line.json new file mode 100644 index 00000000..649ef894 --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_cart_line/commera_cart_line.json @@ -0,0 +1,44 @@ +{ + "actions": [], + "creation": "2026-10-10 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": ["item_code", "qty", "attributes"], + "fields": [ + { + "fieldname": "item_code", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Item Code", + "options": "Item", + "reqd": 1 + }, + { + "fieldname": "qty", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Quantity", + "non_negative": 1, + "reqd": 1 + }, + { + "description": "JSON object of line properties, for example a gift note.", + "fieldname": "attributes", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Attributes" + } + ], + "istable": 1, + "links": [], + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": "Commera Ecommerce", + "name": "Commera Cart Line", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/commera/commera_ecommerce/doctype/commera_cart_line/commera_cart_line.py b/commera/commera_ecommerce/doctype/commera_cart_line/commera_cart_line.py new file mode 100644 index 00000000..cb48b7ec --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_cart_line/commera_cart_line.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class CommeraCartLine(Document): + pass diff --git a/commera/commera_ecommerce/doctype/commera_discount/__init__.py b/commera/commera_ecommerce/doctype/commera_discount/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/commera_ecommerce/doctype/commera_discount/commera_discount.json b/commera/commera_ecommerce/doctype/commera_discount/commera_discount.json new file mode 100644 index 00000000..7a769cb8 --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_discount/commera_discount.json @@ -0,0 +1,169 @@ +{ + "actions": [], + "creation": "2026-10-10 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "code", + "enabled", + "discount_class", + "value_type", + "value", + "currency", + "column_break_value", + "valid_from", + "valid_upto", + "usage_limit", + "times_used", + "section_conditions", + "targets", + "minimum_quantity", + "minimum_subtotal", + "section_combinations", + "combines_with_product", + "combines_with_order" + ], + "fields": [ + { + "description": "What the shopper types. Stored in capitals; matching ignores case.", + "fieldname": "code", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Code", + "reqd": 1, + "unique": 1 + }, + { + "default": "1", + "fieldname": "enabled", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Enabled" + }, + { + "description": "Product: comes off matching lines. Order: comes off the subtotal after product discounts.", + "fieldname": "discount_class", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Discount Class", + "options": "Product\nOrder", + "reqd": 1 + }, + { + "fieldname": "value_type", + "fieldtype": "Select", + "label": "Value Type", + "options": "Percentage\nFixed Amount", + "reqd": 1 + }, + { + "description": "A percentage, or an amount per unit (Product) or per order (Order).", + "fieldname": "value", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Value", + "non_negative": 1, + "reqd": 1 + }, + { + "depends_on": "eval:doc.value_type == 'Fixed Amount'", + "description": "A fixed amount applies only to carts in this currency.", + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "mandatory_depends_on": "eval:doc.value_type == 'Fixed Amount'", + "options": "Currency" + }, + { + "fieldname": "column_break_value", + "fieldtype": "Column Break" + }, + { + "fieldname": "valid_from", + "fieldtype": "Date", + "label": "Valid From" + }, + { + "fieldname": "valid_upto", + "fieldtype": "Date", + "label": "Valid Upto" + }, + { + "description": "0 means no limit.", + "fieldname": "usage_limit", + "fieldtype": "Int", + "label": "Usage Limit", + "non_negative": 1 + }, + { + "default": "0", + "fieldname": "times_used", + "fieldtype": "Int", + "label": "Times Used", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "section_conditions", + "fieldtype": "Section Break", + "label": "Conditions" + }, + { + "depends_on": "eval:doc.discount_class == 'Product'", + "description": "Items, item groups (with their sub-groups) or brands. Empty means every item.", + "fieldname": "targets", + "fieldtype": "Table", + "label": "Applies To", + "options": "Commera Discount Target" + }, + { + "depends_on": "eval:doc.discount_class == 'Product'", + "fieldname": "minimum_quantity", + "fieldtype": "Int", + "label": "Minimum Quantity Per Line", + "non_negative": 1 + }, + { + "depends_on": "eval:doc.discount_class == 'Order'", + "fieldname": "minimum_subtotal", + "fieldtype": "Currency", + "label": "Minimum Subtotal", + "non_negative": 1 + }, + { + "fieldname": "section_combinations", + "fieldtype": "Section Break", + "label": "Combinations" + }, + { + "default": "0", + "fieldname": "combines_with_product", + "fieldtype": "Check", + "label": "Combines With Product Discounts" + }, + { + "default": "0", + "fieldname": "combines_with_order", + "fieldtype": "Check", + "label": "Combines With Order Discounts" + } + ], + "links": [], + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": "Commera Ecommerce", + "name": "Commera Discount", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "read": 1, + "role": "System Manager", + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/commera/commera_ecommerce/doctype/commera_discount/commera_discount.py b/commera/commera_ecommerce/doctype/commera_discount/commera_discount.py new file mode 100644 index 00000000..491b2070 --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_discount/commera_discount.py @@ -0,0 +1,88 @@ +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils.data import cint, flt, getdate, today +from frappe.utils.nestedset import get_descendants_of + +PRODUCT = "Product" +ORDER = "Order" + + +class DiscountCodeError(frappe.ValidationError): + pass + + +class CommeraDiscount(Document): + """A discount code. Commera applies it on top of ERPNext's price, into the standard discount fields.""" + + def autoname(self): + self.code = get_code_name(self.code) + self.name = self.code + + def validate(self): + if self.value_type == "Percentage" and flt(self.value) > 100: + frappe.throw(_("A percentage discount cannot be more than 100.")) + if self.valid_from and self.valid_upto and getdate(self.valid_from) > getdate(self.valid_upto): + frappe.throw(_("Valid From must be on or before Valid Upto.")) + + def validate_usable(self): + if not self.enabled: + frappe.throw(_("{0} is not active.").format(self.code), DiscountCodeError) + if self.valid_from and getdate(self.valid_from) > getdate(today()): + frappe.throw(_("{0} is not active yet.").format(self.code), DiscountCodeError) + if self.valid_upto and getdate(self.valid_upto) < getdate(today()): + frappe.throw(_("{0} has expired.").format(self.code), DiscountCodeError) + if self.usage_limit and cint(self.times_used) >= cint(self.usage_limit): + frappe.throw(_("{0} has been used up.").format(self.code), DiscountCodeError) + + def can_combine_with(self, other: "CommeraDiscount") -> bool: + return self.is_combinable_with(other.discount_class) and other.is_combinable_with(self.discount_class) + + def is_combinable_with(self, discount_class: str) -> bool: + return bool(self.combines_with_product if discount_class == PRODUCT else self.combines_with_order) + + def is_for_currency(self, currency: str) -> bool: + return self.value_type == "Percentage" or self.currency == currency + + def is_for_line(self, item) -> bool: + if self.minimum_quantity and flt(item.qty) < cint(self.minimum_quantity): + return False + if not self.targets: + return True + + variant_of = frappe.get_cached_value("Item", item.item_code, "variant_of") + for target in self.targets: + if target.target_type == "Item" and target.target in (item.item_code, variant_of): + return True + if target.target_type == "Brand" and target.target == item.brand: + return True + if target.target_type == "Item Group" and item.item_group in get_item_group_tree(target.target): + return True + return False + + def get_unit_discount(self, rate: float) -> float: + if self.value_type == "Percentage": + return min(rate * flt(self.value) / 100, rate) + return min(flt(self.value), rate) + + def get_order_discount(self, subtotal: float) -> float: + if self.minimum_subtotal and subtotal < flt(self.minimum_subtotal): + return 0.0 + if self.value_type == "Percentage": + return subtotal * flt(self.value) / 100 + return flt(self.value) + + def count_use(self, change: int): + """Add `change` to the use count, under a row lock so two orders cannot both take the last use.""" + times_used = cint(frappe.db.get_value(self.doctype, self.name, "times_used", for_update=True)) + if change > 0 and self.usage_limit and times_used >= cint(self.usage_limit): + frappe.throw(_("{0} has been used up.").format(self.code), DiscountCodeError) + frappe.db.set_value(self.doctype, self.name, "times_used", max(times_used + change, 0)) + + +def get_code_name(code: str) -> str: + return (code or "").strip().upper() + + +def get_item_group_tree(item_group: str) -> set[str]: + return {item_group, *get_descendants_of("Item Group", item_group)} diff --git a/commera/commera_ecommerce/doctype/commera_discount_target/__init__.py b/commera/commera_ecommerce/doctype/commera_discount_target/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/commera_ecommerce/doctype/commera_discount_target/commera_discount_target.json b/commera/commera_ecommerce/doctype/commera_discount_target/commera_discount_target.json new file mode 100644 index 00000000..731aaa8b --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_discount_target/commera_discount_target.json @@ -0,0 +1,37 @@ +{ + "actions": [], + "creation": "2026-10-10 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": ["target_type", "target"], + "fields": [ + { + "fieldname": "target_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Target Type", + "options": "Item\nItem Group\nBrand", + "reqd": 1 + }, + { + "fieldname": "target", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Target", + "options": "target_type", + "reqd": 1 + } + ], + "istable": 1, + "links": [], + "modified": "2026-10-10 00:00:00.000000", + "modified_by": "Administrator", + "module": "Commera Ecommerce", + "name": "Commera Discount Target", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/commera/commera_ecommerce/doctype/commera_discount_target/commera_discount_target.py b/commera/commera_ecommerce/doctype/commera_discount_target/commera_discount_target.py new file mode 100644 index 00000000..dbe483d0 --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_discount_target/commera_discount_target.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class CommeraDiscountTarget(Document): + pass diff --git a/commera/discount_codes.py b/commera/discount_codes.py new file mode 100644 index 00000000..79faaa69 --- /dev/null +++ b/commera/discount_codes.py @@ -0,0 +1,120 @@ +import frappe +from frappe import _ +from frappe.utils.data import cstr, flt + +from commera.commera_ecommerce.doctype.commera_discount.commera_discount import ( + ORDER, + PRODUCT, + CommeraDiscount, + DiscountCodeError, + get_code_name, +) + +MAX_DISCOUNT_CODES = 5 + + +def get_discount_codes(doc) -> list[str]: + """The Commera Discount codes a cart, Quotation or Sales Order holds, in the order they were applied.""" + fieldname = "discount_codes" if doc.doctype == "Commera Cart" else "custom_discount_codes" + return [code for code in cstr(doc.get(fieldname)).splitlines() if code.strip()] + + +def get_discount(code: str) -> CommeraDiscount: + return frappe.get_cached_doc("Commera Discount", code) + + +def validate_new_discount_code(code: str, codes: list[str]) -> str: + """Check that a shopper's code can join the codes already applied, and return its stored name.""" + code = get_code_name(code) + if not frappe.db.exists("Commera Discount", code): + frappe.throw(_("{0} is not a valid discount code.").format(code), DiscountCodeError) + if code in codes: + frappe.throw(_("{0} is already applied.").format(code), DiscountCodeError) + if len(codes) >= MAX_DISCOUNT_CODES: + frappe.throw( + _("A cart holds at most {0} discount codes.").format(MAX_DISCOUNT_CODES), DiscountCodeError + ) + + discount = get_discount(code) + discount.validate_usable() + for applied_code in codes: + if not discount.can_combine_with(get_discount(applied_code)): + frappe.throw(_("{0} cannot be combined with {1}.").format(code, applied_code), DiscountCodeError) + return code + + +def remove_code_discounts(doc, method=None): + """Take the code discounts off before ERPNext prices the document again, so they never apply twice. + Clamped at zero: other code may already have reset these fields.""" + for item in doc.items: + if flt(item.get("custom_code_discount")): + item.discount_amount = max(flt(item.discount_amount) - flt(item.custom_code_discount), 0) + item.rate = (flt(item.rate_with_margin) or flt(item.price_list_rate)) - item.discount_amount + item.custom_code_discount = 0 + item.custom_discount_code = None + + if flt(doc.get("custom_code_order_discount")): + doc.discount_amount = max(flt(doc.discount_amount) - flt(doc.custom_code_order_discount), 0) + doc.custom_code_order_discount = 0 + + +def apply_code_discounts(doc, method=None): + """Add the discount codes on top of ERPNext's price: product codes per line, then order codes on the + discounted subtotal. The best product code wins on each line.""" + discounts = [get_discount(code) for code in get_discount_codes(doc)] + discounts = [discount for discount in discounts if discount.is_for_currency(doc.currency)] + if not discounts: + return + + product_discounts = [discount for discount in discounts if discount.discount_class == PRODUCT] + for item in doc.items: + apply_line_discount(item, product_discounts) + doc.calculate_taxes_and_totals() + + apply_order_discount(doc, [discount for discount in discounts if discount.discount_class == ORDER]) + doc.calculate_taxes_and_totals() + + +def apply_line_discount(item, product_discounts: list[CommeraDiscount]): + rate = flt(item.rate) + best_code, best_discount = None, 0.0 + for discount in product_discounts: + unit_discount = discount.get_unit_discount(rate) if discount.is_for_line(item) else 0 + if unit_discount > best_discount: + best_code, best_discount = discount.name, unit_discount + if not best_code: + return + + best_discount = flt(best_discount, item.precision("discount_amount")) + # ERPNext rebuilds a percentage discount from the price list, which would drop the code's share. + item.discount_percentage = 0 + item.discount_amount = flt(item.discount_amount) + best_discount + item.rate = rate - best_discount + item.custom_code_discount = best_discount + item.custom_discount_code = best_code + + +def apply_order_discount(doc, order_discounts: list[CommeraDiscount]): + subtotal = flt(doc.total) + amount = sum(discount.get_order_discount(subtotal) for discount in order_discounts) + amount = flt(min(amount, subtotal), doc.precision("discount_amount")) + if not amount: + return + + # Like Shopify, an order code comes off the subtotal, before tax and shipping. + if not flt(doc.discount_amount): + doc.apply_discount_on = "Net Total" + # ERPNext's own percentage discount is kept as an amount, so the code's share can be added to it. + doc.discount_amount = flt(doc.discount_amount) + amount + doc.additional_discount_percentage = 0 + doc.custom_code_order_discount = amount + + +def count_code_uses(doc, method=None): + for code in get_discount_codes(doc): + get_discount(code).count_use(1) + + +def uncount_code_uses(doc, method=None): + for code in get_discount_codes(doc): + get_discount(code).count_use(-1) diff --git a/commera/hooks.py b/commera/hooks.py index e1ed6b39..7b5b1a3f 100644 --- a/commera/hooks.py +++ b/commera/hooks.py @@ -135,14 +135,22 @@ "Gateway Payment Request": { "on_update": "commera.api.payment_hooks.on_payment_request_update", }, + "Quotation": { + "before_validate": "commera.discount_codes.remove_code_discounts", + "validate": "commera.discount_codes.apply_code_discounts", + }, "Sales Order": { + "before_validate": "commera.discount_codes.remove_code_discounts", + "validate": "commera.discount_codes.apply_code_discounts", "after_insert": "commera.jobs.send_order_success_acknowledgement", "on_submit": [ + "commera.discount_codes.count_code_uses", "commera.jobs.send_order_success_acknowledgement", "commera.utils.update_so_status_from_related_doc", "commera.plugin_events.on_sales_order_stock_reservation", ], "on_cancel": [ + "commera.discount_codes.uncount_code_uses", "commera.jobs.send_order_cancel_acknowledgement", "commera.utils.update_so_status_from_related_doc", "commera.plugin_events.on_sales_order_cancel", @@ -173,7 +181,11 @@ "after_rename": "commera.search.sync.after_rename", "on_trash": "commera.search.sync.on_trash", }, - "Sales Invoice": {"on_submit": "commera.utils.update_so_status_from_related_doc"}, + "Sales Invoice": { + "before_validate": "commera.discount_codes.remove_code_discounts", + "validate": "commera.discount_codes.apply_code_discounts", + "on_submit": "commera.utils.update_so_status_from_related_doc", + }, "Payment Entry": {"on_submit": "commera.plugin_events.on_payment_entry_submit"}, "Delivery Note": { "after_insert": "commera.utils.update_so_status_from_related_doc", diff --git a/commera/sdk/cart.py b/commera/sdk/cart.py index aeb0cf88..f57e5f13 100644 --- a/commera/sdk/cart.py +++ b/commera/sdk/cart.py @@ -5,11 +5,21 @@ from commera.api.payments import cart_write_lock, save_cart_quotation, validate_cart_is_not_in_checkout from commera.api.shipping import get_checkout_summary from commera.checkout_hooks import apply_plugin_fees +from commera.commera_ecommerce.doctype.commera_cart.commera_cart import CommeraCart, get_session_cart from commera.core import _get_cart_quotation from commera.plugin_events import get_plugin_fieldnames, validate_plugin_fieldnames -from commera.sdk.types import Cart, CheckoutSummary +from commera.sdk.types import Cart, CheckoutSummary, NewCartLine, PricedCart -__all__ = ["get_cart", "set_cart_fields"] +__all__ = [ + "add_discount_code", + "add_lines", + "get_cart", + "get_priced_cart", + "remove_discount_code", + "remove_lines", + "set_cart_fields", + "update_lines", +] def get_cart() -> Cart | None: @@ -54,3 +64,55 @@ def set_cart_fields(values: dict) -> CheckoutSummary: apply_plugin_fees(quotation) save_cart_quotation(quotation) return get_checkout_summary(quotation) + + +def get_priced_cart() -> PricedCart | None: + """The session shopper's server cart with lines, attributes and code discounts, or None.""" + cart = get_session_cart() + return cart.get_priced_cart() if cart else None + + +def add_lines(lines: list[NewCartLine]) -> PricedCart: + """Add lines to the session shopper's server cart, starting one when they have none. The storefront + shows the new lines on the shopper's next page load.""" + cart = get_session_cart(create=True) + cart.add_lines(lines) + return save_server_cart(cart) + + +def update_lines(lines: list[dict]) -> PricedCart: + """Set `qty` or `attributes` per `line_id`. A quantity of 0 removes the line.""" + cart = get_existing_cart() + cart.update_lines(lines) + return save_server_cart(cart) + + +def remove_lines(line_ids: list[str]) -> PricedCart: + cart = get_existing_cart() + cart.remove_lines(line_ids) + return save_server_cart(cart) + + +def add_discount_code(code: str) -> PricedCart: + """Apply a Commera Discount code, refused when it is invalid or does not combine with the codes applied.""" + cart = get_existing_cart() + cart.add_discount_code(code) + return save_server_cart(cart) + + +def remove_discount_code(code: str) -> PricedCart: + cart = get_existing_cart() + cart.remove_discount_code(code) + return save_server_cart(cart) + + +def get_existing_cart() -> CommeraCart: + cart = get_session_cart() + if not cart: + frappe.throw(_("Add something to your cart first."), frappe.DoesNotExistError) + return cart + + +def save_server_cart(cart: CommeraCart) -> PricedCart: + cart.save(ignore_permissions=True) + return cart.get_priced_cart() diff --git a/commera/sdk/types.py b/commera/sdk/types.py index f5936e1e..e8a8b883 100644 --- a/commera/sdk/types.py +++ b/commera/sdk/types.py @@ -9,8 +9,11 @@ "Charge", "ChargeSummary", "CheckoutSummary", + "NewCartLine", "Order", "OrderLine", + "PricedCart", + "PricedCartLine", "Stage", ] @@ -99,6 +102,40 @@ class Cart(TypedDict): plugin_fields: dict[str, Any] +class NewCartLine(TypedDict, total=False): + item_code: str + qty: int + attributes: dict[str, str] + + +class PricedCartLine(TypedDict): + line_id: str + item_code: str + title: str + qty: int + attributes: dict[str, str] + price: float + rate: float + # Per unit, from the best product discount code on the line. + code_discount: float + discount_code: str | None + amount: float + + +class PricedCart(TypedDict): + """The server cart priced like checkout, before shipping and tax.""" + + name: str + version: str + currency: str + # {"code", "class"}, where class is "Product" or "Order". + discount_codes: list[dict] + lines: list[PricedCartLine] + subtotal: float + order_discount: float + total: float + + class ChargeSummary(TypedDict): subtotal: float shipping: float diff --git a/commera/templates/includes/cart-permalink.html b/commera/templates/includes/cart-permalink.html index 957c70c4..79a2774e 100644 --- a/commera/templates/includes/cart-permalink.html +++ b/commera/templates/includes/cart-permalink.html @@ -9,6 +9,7 @@ } // $persist writes on Alpine's next flush; navigating first can leave the old cart in storage. await this.$nextTick(); + await this.$store.cart.sync(); if (!config.notices.length) window.location.replace(config.next_url); }, })); diff --git a/commera/templates/includes/cart-state.html b/commera/templates/includes/cart-state.html index 13b38809..72465a0d 100644 --- a/commera/templates/includes/cart-state.html +++ b/commera/templates/includes/cart-state.html @@ -2,6 +2,56 @@ document.addEventListener('alpine:init', () => { Alpine.store('cart', { items: Alpine.$persist([]), + // The server cart's version this browser last saw; the server cart wins when they differ. + version: Alpine.$persist(null).as('commera_cart_version'), + async init() { + await this.load_server_cart(); + let is_first_run = true; + Alpine.effect(() => { + this.items.forEach(item => [item.variant?.item_code, item.qty]); + if (is_first_run) { + is_first_run = false; + return; + } + clearTimeout(this.sync_timer); + this.sync_timer = setTimeout(() => this.sync(), 400); + }); + }, + async load_server_cart() { + try { + const server_cart = (await frappe_call("/api/v2/method/commera.api.server_cart.get_server_cart", {})).data; + if (!server_cart) { + if (this.items.length) await this.sync(); + return; + } + if (server_cart.version === this.version) return; + if (this.version || server_cart.lines.length) this.apply_server_cart(server_cart); + else await this.sync(); + } catch (error) {} + }, + async sync() { + clearTimeout(this.sync_timer); + const items = this.items.map(item => ({ item_code: item.variant.item_code, qty: item.qty })); + try { + const response = (await frappe_call("/api/v2/method/commera.api.server_cart.sync_server_cart", { items, version: this.version })).data; + if (response.conflict) this.apply_server_cart(response); + else this.version = response.version; + } catch (error) {} + }, + apply_server_cart(server_cart) { + this.items = server_cart.lines.map(line => ({ + item: line.item, + variant: line.variant, + qty: line.qty, + price: line.price, + default_price: line.default_price, + brand: line.brand, + stock_qty: line.variant.stock_detail?.stock_qty ?? 0, + selected: true, + sizes: line.sizes, + })); + this.version = server_cart.version; + }, add(item, variant, price = 0,default_price=0, brand = '',sizes=[],qty=1) { let already_added = false; variant = JSON.parse(variant) diff --git a/commera/tests/test_discount_codes.py b/commera/tests/test_discount_codes.py new file mode 100644 index 00000000..9449d603 --- /dev/null +++ b/commera/tests/test_discount_codes.py @@ -0,0 +1,254 @@ +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils.data import add_days, today + +from commera.api.payments import _make_sales_order, make_sales_invoice +from commera.core import new_cart_quotation +from commera.discount_codes import ( + MAX_DISCOUNT_CODES, + DiscountCodeError, + validate_new_discount_code, +) + +SHIRT_RATE = 90.0 +CAP_RATE = 40.0 + + +class DiscountCodeTestCase(IntegrationTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + settings = frappe.get_cached_doc("Commera Settings") + cls.warehouse = settings.ecommerce_warehouse + company = settings.get("company") or frappe.defaults.get_global_default("company") + cls.currency = frappe.get_cached_value("Company", company, "default_currency") + # In the company currency, so rates are not converted and the expected numbers stay readable. + cls.price_list = "ZZ Discount Code Prices" + if not frappe.db.exists("Price List", cls.price_list): + frappe.get_doc( + { + "doctype": "Price List", + "price_list_name": cls.price_list, + "currency": cls.currency, + "selling": 1, + } + ).insert(ignore_permissions=True) + + def setUp(self): + self.addCleanup(frappe.set_user, "Administrator") + self.shirt = self.create_item(SHIRT_RATE) + self.cap = self.create_item(CAP_RATE) + + def create_item(self, rate: float) -> str: + item_code = f"ZZ-CODE-{frappe.generate_hash(length=8)}" + frappe.get_doc( + { + "doctype": "Item", + "item_code": item_code, + "item_name": item_code, + "item_group": frappe.get_all("Item Group", {"is_group": 0}, pluck="name", limit=1)[0], + "stock_uom": "Nos", + "is_stock_item": 1, + } + ).insert(ignore_permissions=True) + frappe.get_doc( + { + "doctype": "Item Price", + "item_code": item_code, + "price_list": self.price_list, + "price_list_rate": rate, + } + ).insert(ignore_permissions=True) + frappe.get_doc( + {"doctype": "Bin", "item_code": item_code, "warehouse": self.warehouse, "actual_qty": 10} + ).insert(ignore_permissions=True) + return item_code + + def create_code( + self, + percentage: float = 0, + amount: float = 0, + item_codes: tuple[str, ...] = (), + **discount_fields, + ) -> str: + """A Product code on `item_codes` when given, else an Order code.""" + return ( + frappe.get_doc( + { + "doctype": "Commera Discount", + "code": f"zz{frappe.generate_hash(length=8)}", + "discount_class": "Product" if item_codes else "Order", + "value_type": "Percentage" if percentage else "Fixed Amount", + "value": percentage or amount, + "currency": self.currency, + "targets": [{"target_type": "Item", "target": item_code} for item_code in item_codes], + **discount_fields, + } + ) + .insert(ignore_permissions=True) + .name + ) + + def create_automatic_rule(self, item_code: str, percentage: float): + frappe.get_doc( + { + "doctype": "Pricing Rule", + "title": f"ZZ Auto {frappe.generate_hash(length=8)}", + "apply_on": "Item Code", + "items": [{"item_code": item_code}], + "rate_or_discount": "Discount Percentage", + "discount_percentage": percentage, + "selling": 1, + "currency": self.currency, + } + ).insert(ignore_permissions=True) + + def create_quotation(self, lines: dict[str, int], codes: tuple[str, ...] = ()): + customer = frappe.get_doc( + {"doctype": "Customer", "customer_name": f"ZZ Code Shopper {frappe.generate_hash(length=6)}"} + ).insert(ignore_permissions=True) + quotation = new_cart_quotation(customer, "zz-code-shopper@example.com") + quotation.selling_price_list = self.price_list + for item_code, qty in lines.items(): + quotation.append("items", {"item_code": item_code, "qty": qty, "warehouse": self.warehouse}) + quotation.custom_discount_codes = "\n".join(codes) + quotation.insert(ignore_permissions=True) + return quotation + + def get_line(self, doc, item_code: str): + return next(row for row in doc.items if row.item_code == item_code) + + +class TestDiscountCodes(DiscountCodeTestCase): + def test_a_product_code_discounts_only_its_items(self): + code = self.create_code(percentage=10, item_codes=(self.shirt,)) + + quotation = self.create_quotation({self.shirt: 2, self.cap: 1}, (code,)) + + shirt = self.get_line(quotation, self.shirt) + self.assertEqual(shirt.rate, 81) + self.assertEqual(shirt.custom_code_discount, 9) + self.assertEqual(shirt.custom_discount_code, code) + self.assertEqual(self.get_line(quotation, self.cap).rate, CAP_RATE) + self.assertEqual(quotation.net_total, 2 * 81 + CAP_RATE) + + def test_the_best_product_code_wins_on_a_line(self): + small = self.create_code(percentage=10, item_codes=(self.shirt,), combines_with_product=1) + large = self.create_code(amount=20, item_codes=(self.shirt,), combines_with_product=1) + + quotation = self.create_quotation({self.shirt: 1}, (small, large)) + + shirt = self.get_line(quotation, self.shirt) + self.assertEqual(shirt.rate, 70) + self.assertEqual(shirt.custom_discount_code, large) + + def test_an_order_code_comes_off_the_subtotal_after_product_codes(self): + product = self.create_code(amount=10, item_codes=(self.shirt,), combines_with_order=1) + order = self.create_code(percentage=10, combines_with_product=1) + + quotation = self.create_quotation({self.shirt: 1, self.cap: 1}, (product, order)) + + self.assertEqual(quotation.total, 80 + CAP_RATE) + self.assertEqual(quotation.custom_code_order_discount, 12) + self.assertEqual(quotation.net_total, 108) + + def test_saving_again_does_not_apply_a_code_twice(self): + code = self.create_code(percentage=10, item_codes=(self.shirt,)) + quotation = self.create_quotation({self.shirt: 1}, (code,)) + + quotation.save(ignore_permissions=True) + quotation.reload() + quotation.save(ignore_permissions=True) + + self.assertEqual(self.get_line(quotation, self.shirt).rate, 81) + + def test_a_code_stacks_on_an_automatic_pricing_rule_across_saves(self): + """ERPNext rebuilds an automatic rule's discount on every save; the code must survive that.""" + self.create_automatic_rule(self.shirt, percentage=10) + code = self.create_code(amount=5, item_codes=(self.shirt,)) + + quotation = self.create_quotation({self.shirt: 1}, (code,)) + quotation.reload() + quotation.save(ignore_permissions=True) + + shirt = self.get_line(quotation, self.shirt) + self.assertEqual(shirt.rate, 90 - 9 - 5) + self.assertEqual(shirt.discount_amount, 14) + + def test_codes_combine_only_when_both_allow_it(self): + product = self.create_code(percentage=10, item_codes=(self.shirt,), combines_with_order=1) + strict_order = self.create_code(percentage=5) + open_order = self.create_code(percentage=5, combines_with_product=1) + applied = [product] + + with self.assertRaises(DiscountCodeError): + validate_new_discount_code(strict_order, applied) + self.assertEqual(validate_new_discount_code(open_order, applied), open_order) + + def test_a_cart_holds_a_limited_number_of_codes(self): + codes = [ + self.create_code(percentage=1, combines_with_order=1) for _index in range(MAX_DISCOUNT_CODES) + ] + + with self.assertRaises(DiscountCodeError): + validate_new_discount_code(self.create_code(percentage=1, combines_with_order=1), codes) + + def test_an_expired_code_is_refused(self): + code = self.create_code(percentage=10, valid_upto=add_days(today(), -1)) + + with self.assertRaises(DiscountCodeError): + validate_new_discount_code(code, []) + + def test_codes_are_matched_without_case(self): + code = self.create_code(percentage=10) + + self.assertEqual(validate_new_discount_code(f" {code.lower()} ", []), code) + + +class TestDiscountCodesOnOrders(DiscountCodeTestCase): + def test_the_sales_order_keeps_code_discounts_and_counts_uses(self): + product = self.create_code(percentage=10, item_codes=(self.shirt,), combines_with_order=1) + order = self.create_code(amount=5, combines_with_product=1) + quotation = self.create_quotation({self.shirt: 1, self.cap: 1}, (product, order)) + quotation.submit() + + sales_order = _make_sales_order(quotation.name, ignore_permissions=True) + sales_order.delivery_date = add_days(today(), 2) + sales_order.insert(ignore_permissions=True) + sales_order.submit() + + self.assertEqual(self.get_line(sales_order, self.shirt).rate, 81) + self.assertEqual(sales_order.custom_code_order_discount, 5) + self.assertEqual(sales_order.net_total, quotation.net_total) + self.assertEqual(frappe.db.get_value("Commera Discount", product, "times_used"), 1) + + sales_order.cancel() + self.assertEqual(frappe.db.get_value("Commera Discount", product, "times_used"), 0) + + def test_the_invoice_keeps_code_discounts_over_an_automatic_rule(self): + """ERPNext prices the invoice again; with an automatic rule on the line that drops an unhooked code.""" + self.create_automatic_rule(self.shirt, percentage=10) + code = self.create_code(amount=5, item_codes=(self.shirt,)) + quotation = self.create_quotation({self.shirt: 1}, (code,)) + quotation.submit() + sales_order = _make_sales_order(quotation.name, ignore_permissions=True) + sales_order.delivery_date = add_days(today(), 2) + sales_order.insert(ignore_permissions=True) + sales_order.submit() + + sales_invoice = make_sales_invoice(sales_order.name) + sales_invoice.insert(ignore_permissions=True) + + self.assertEqual(self.get_line(sales_invoice, self.shirt).rate, 90 - 9 - 5) + self.assertEqual(sales_invoice.net_total, sales_order.net_total) + + def test_an_order_is_refused_when_its_code_is_used_up(self): + code = self.create_code(percentage=10, usage_limit=1, times_used=1) + quotation = self.create_quotation({self.shirt: 1}, (code,)) + quotation.submit() + sales_order = _make_sales_order(quotation.name, ignore_permissions=True) + sales_order.delivery_date = add_days(today(), 2) + sales_order.insert(ignore_permissions=True) + + with self.assertRaises(DiscountCodeError): + sales_order.submit() diff --git a/commera/tests/test_server_cart.py b/commera/tests/test_server_cart.py new file mode 100644 index 00000000..357cbc75 --- /dev/null +++ b/commera/tests/test_server_cart.py @@ -0,0 +1,128 @@ +import frappe + +from commera.api.payments import generate_quotation_for_cart +from commera.api.server_cart import sync_server_cart +from commera.commera_ecommerce.doctype.commera_cart.commera_cart import get_session_cart +from commera.sdk import cart as cart_sdk +from commera.tests.test_discount_codes import CAP_RATE, SHIRT_RATE, DiscountCodeTestCase + + +class TestServerCart(DiscountCodeTestCase): + def setUp(self): + super().setUp() + sale_price_list = frappe.db.get_single_value("Commera Settings", "sale_price_list") + self.addCleanup(self.set_sale_price_list, sale_price_list) + self.set_sale_price_list(self.price_list) + self.shopper = self.create_shopper() + frappe.set_user(self.shopper) + + def set_sale_price_list(self, price_list: str): + frappe.db.set_single_value("Commera Settings", "sale_price_list", price_list) + frappe.clear_document_cache("Commera Settings", "Commera Settings") + + def create_shopper(self) -> str: + email = f"zz-cart-{frappe.generate_hash(length=8)}@example.com" + user = frappe.get_doc( + { + "doctype": "User", + "email": email, + "first_name": "ZZ Cart", + "send_welcome_email": 0, + "user_type": "Website User", + } + ) + user.append("roles", {"role": "Customer"}) + user.insert(ignore_permissions=True) + return email + + def browser_line(self, item_code: str, qty: int) -> dict: + return {"item": {"display_name": item_code}, "variant": {"item_code": item_code}, "qty": qty} + + def test_lines_merge_only_when_item_and_attributes_match(self): + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 1, "attributes": {"engraving": "AB"}}]) + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 2, "attributes": {"engraving": "AB"}}]) + + priced = cart_sdk.add_lines([{"item_code": self.shirt, "qty": 1}]) + + self.assertEqual( + [(line["qty"], line["attributes"]) for line in priced["lines"]], + [(3, {"engraving": "AB"}), (1, {})], + ) + + def test_browser_quantities_keep_line_attributes(self): + cart_sdk.add_lines( + [ + {"item_code": self.shirt, "qty": 1, "attributes": {"gift": "yes"}}, + {"item_code": self.cap, "qty": 1}, + ] + ) + cart = get_session_cart() + + self.assertTrue(cart.set_quantities({self.shirt: 3})) + + self.assertEqual([(row.item_code, row.qty) for row in cart.lines], [(self.shirt, 3)]) + self.assertEqual(frappe.parse_json(cart.lines[0].attributes), {"gift": "yes"}) + + def test_a_stale_browser_does_not_overwrite_the_server_cart(self): + version = sync_server_cart([{"item_code": self.shirt, "qty": 1}])["version"] + server_version = cart_sdk.add_lines([{"item_code": self.cap, "qty": 1}])["version"] + + response = sync_server_cart([{"item_code": self.shirt, "qty": 5}], version) + + self.assertTrue(response["conflict"]) + self.assertEqual(response["version"], server_version) + self.assertEqual( + {(row.item_code, row.qty) for row in get_session_cart().lines}, {(self.shirt, 1), (self.cap, 1)} + ) + + def test_the_priced_cart_shows_line_and_order_discounts(self): + product = self.create_code(percentage=10, item_codes=(self.shirt,), combines_with_order=1) + order = self.create_code(amount=10, combines_with_product=1) + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 2}, {"item_code": self.cap, "qty": 1}]) + cart_sdk.add_discount_code(product.lower()) + + priced = cart_sdk.add_discount_code(order) + + shirt = next(line for line in priced["lines"] if line["item_code"] == self.shirt) + self.assertEqual((shirt["rate"], shirt["code_discount"], shirt["discount_code"]), (81, 9, product)) + self.assertEqual(priced["subtotal"], 2 * 81 + CAP_RATE) + self.assertEqual(priced["order_discount"], 10) + self.assertEqual(priced["total"], 2 * 81 + CAP_RATE - 10) + + def test_checkout_takes_attributes_and_codes_from_the_server_cart(self): + code = self.create_code(amount=10, item_codes=(self.shirt,)) + cart_sdk.add_lines( + [ + {"item_code": self.shirt, "qty": 1, "attributes": {"engraving": "AB"}}, + {"item_code": self.shirt, "qty": 1, "attributes": {"engraving": "CD"}}, + ] + ) + cart_sdk.add_discount_code(code) + + quotation = generate_quotation_for_cart({"items": [self.browser_line(self.shirt, 2)]}) + + self.assertEqual( + [(row.qty, frappe.parse_json(row.custom_line_attributes)) for row in quotation.items], + [(1, {"engraving": "AB"}), (1, {"engraving": "CD"})], + ) + self.assertEqual({row.rate for row in quotation.items}, {SHIRT_RATE - 10}) + self.assertEqual(quotation.custom_discount_codes, code) + + def test_checkout_keeps_an_order_code_through_the_coupon_reset(self): + """Checkout zeroes the document discount after a save; the order code must still apply.""" + code = self.create_code(amount=10) + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 1}]) + cart_sdk.add_discount_code(code) + + quotation = generate_quotation_for_cart({"items": [self.browser_line(self.shirt, 1)]}) + + self.assertEqual(quotation.custom_code_order_discount, 10) + self.assertEqual(quotation.net_total, SHIRT_RATE - 10) + + def test_checkout_without_a_server_cart_is_unchanged(self): + quotation = generate_quotation_for_cart({"items": [self.browser_line(self.cap, 2)]}) + + self.assertEqual( + [(row.item_code, row.qty, row.rate) for row in quotation.items], [(self.cap, 2, CAP_RATE)] + ) + self.assertFalse(quotation.custom_discount_codes) From 938ed75592b3b4cdda8c638882b4c8bb2fa9cdcd Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Sun, 11 Oct 2026 01:18:17 +0000 Subject: [PATCH 002/109] refactor(discounts): split discounts from codes and add Shopify-style rules One code per discount could not give automatic discounts or unique codes, and the rules covered only a percentage or amount on items or the order. A Commera Discount now holds the rules and Commera Discount Code holds the strings shoppers type, including generated single-use codes. Discounts can be automatic, buy X get Y or free shipping, with a percentage cap, spend tiers, skipping items already on sale, a minimum purchase, a payment method, first-order or customer-group eligibility, total and once-per-customer limits, and a budget. Each placed order logs a Commera Discount Use, which drives the counts, the budget and once-per-customer. Shipping discounts change the delivery options after the plugin hooks, and the cash on delivery summary drops prepaid-only discounts. --- commera/api/payments.py | 1 + commera/api/server_cart.py | 2 +- commera/api/shipping.py | 7 + commera/checkout_hooks.py | 3 +- .../commera_ecommerce/custom/quotation.json | 194 +++++++- .../custom/quotation_item.json | 2 +- .../custom/sales_invoice.json | 194 +++++++- .../custom/sales_invoice_item.json | 2 +- .../commera_ecommerce/custom/sales_order.json | 194 +++++++- .../custom/sales_order_item.json | 2 +- .../doctype/commera_cart/commera_cart.py | 50 +- .../commera_discount/commera_discount.json | 433 +++++++++++++++--- .../commera_discount/commera_discount.py | 246 ++++++++-- .../doctype/commera_discount_code/__init__.py | 0 .../commera_discount_code.json | 70 +++ .../commera_discount_code.py | 5 + .../__init__.py | 0 .../commera_discount_customer_group.json | 31 ++ .../commera_discount_customer_group.py | 5 + .../commera_discount_target.json | 33 +- .../doctype/commera_discount_tier/__init__.py | 0 .../commera_discount_tier.json | 40 ++ .../commera_discount_tier.py | 5 + .../doctype/commera_discount_use/__init__.py | 0 .../commera_discount_use.json | 101 ++++ .../commera_discount_use.py | 5 + commera/discount_codes.py | 120 ----- commera/discounts/__init__.py | 0 commera/discounts/allocations.py | 23 + commera/discounts/cart_context.py | 66 +++ commera/discounts/codes.py | 44 ++ commera/discounts/pricing.py | 139 ++++++ commera/discounts/selection.py | 104 +++++ commera/discounts/shipping.py | 38 ++ commera/discounts/usage.py | 93 ++++ commera/hooks.py | 21 +- commera/tests/test_discount_codes.py | 315 +++++++++---- commera/tests/test_server_cart.py | 14 +- 38 files changed, 2252 insertions(+), 350 deletions(-) create mode 100644 commera/commera_ecommerce/doctype/commera_discount_code/__init__.py create mode 100644 commera/commera_ecommerce/doctype/commera_discount_code/commera_discount_code.json create mode 100644 commera/commera_ecommerce/doctype/commera_discount_code/commera_discount_code.py create mode 100644 commera/commera_ecommerce/doctype/commera_discount_customer_group/__init__.py create mode 100644 commera/commera_ecommerce/doctype/commera_discount_customer_group/commera_discount_customer_group.json create mode 100644 commera/commera_ecommerce/doctype/commera_discount_customer_group/commera_discount_customer_group.py create mode 100644 commera/commera_ecommerce/doctype/commera_discount_tier/__init__.py create mode 100644 commera/commera_ecommerce/doctype/commera_discount_tier/commera_discount_tier.json create mode 100644 commera/commera_ecommerce/doctype/commera_discount_tier/commera_discount_tier.py create mode 100644 commera/commera_ecommerce/doctype/commera_discount_use/__init__.py create mode 100644 commera/commera_ecommerce/doctype/commera_discount_use/commera_discount_use.json create mode 100644 commera/commera_ecommerce/doctype/commera_discount_use/commera_discount_use.py delete mode 100644 commera/discount_codes.py create mode 100644 commera/discounts/__init__.py create mode 100644 commera/discounts/allocations.py create mode 100644 commera/discounts/cart_context.py create mode 100644 commera/discounts/codes.py create mode 100644 commera/discounts/pricing.py create mode 100644 commera/discounts/selection.py create mode 100644 commera/discounts/shipping.py create mode 100644 commera/discounts/usage.py diff --git a/commera/api/payments.py b/commera/api/payments.py index 0f5ab481..24f2c2c0 100644 --- a/commera/api/payments.py +++ b/commera/api/payments.py @@ -791,6 +791,7 @@ def place_cod_order(quotation_name: str): validate_cart(quotation) if COD_PAYMENT_MODE not in get_checkout_payment_methods(quotation, strict=True): refuse_payment(_("Cash on delivery is not available."), quotation.name) + quotation.flags.commera_payment_kind = "cod" with system_user_session(): set_cod_charges(quotation) quotation.flags.ignore_permissions = True diff --git a/commera/api/server_cart.py b/commera/api/server_cart.py index a4d68c31..f47f3ab1 100644 --- a/commera/api/server_cart.py +++ b/commera/api/server_cart.py @@ -8,7 +8,7 @@ CommeraCart, get_session_cart, ) -from commera.discount_codes import get_discount_codes +from commera.discounts.selection import get_discount_codes from commera.sdk import cart as cart_sdk from commera.www.cart.permalink import get_cart_lines diff --git a/commera/api/shipping.py b/commera/api/shipping.py index b2676882..d9ba5077 100644 --- a/commera/api/shipping.py +++ b/commera/api/shipping.py @@ -5,6 +5,8 @@ from commera.checkout_hooks import PLUGIN_FEE_FIELD, apply_delivery_option_hooks, apply_plugin_fees from commera.core import _get_cart_quotation +from commera.discounts.pricing import refresh_code_discounts +from commera.discounts.shipping import set_shipping_discount from commera.utils import ( COD_CHARGE_DESCRIPTION, get_charge_account_head, @@ -228,6 +230,7 @@ def apply_delivery_option(quotation, option: dict): quotation.custom_delivery_charge = flt(option["amount"]) quotation.custom_shipping_provider = option.get("provider") quotation.custom_shipping_service_code = option.get("service_code") + set_shipping_discount(quotation, option) set_delivery_charge_row(quotation, flt(option["amount"]), option["title"]) apply_plugin_fees(quotation) @@ -237,6 +240,7 @@ def clear_delivery_option(quotation): quotation.custom_delivery_charge = 0 quotation.custom_shipping_provider = None quotation.custom_shipping_service_code = None + set_shipping_discount(quotation, None) remove_delivery_charge_row(quotation) quotation.calculate_taxes_and_totals() @@ -335,6 +339,9 @@ def get_checkout_summary(quotation) -> dict: clear_pickup_charges(preview) summary = get_charge_summary(preview) + # A prepaid-only discount drops off, and a cash-on-delivery one comes on, before the fee is judged. + preview.flags.commera_payment_kind = "cod" + refresh_code_discounts(preview) cod_charge = get_cod_charge(preview) if cod_charge: account_head = frappe.get_cached_value("Commera Settings", "Commera Settings", "charge_account_head") diff --git a/commera/checkout_hooks.py b/commera/checkout_hooks.py index 54b9ca33..ee64d795 100644 --- a/commera/checkout_hooks.py +++ b/commera/checkout_hooks.py @@ -5,6 +5,7 @@ from frappe import _ from frappe.utils.data import cstr, flt +from commera.discounts.shipping import apply_shipping_discounts from commera.plugin_events import get_handlers from commera.utils import get_charge_account_head @@ -86,7 +87,7 @@ def apply_delivery_option_hooks(quotation, options: list[dict], strict: bool) -> with handle_hook_error("delivery_options", handler, quotation, strict): hooked_options = frappe.get_attr(handler)(quotation, [dict(option) for option in options]) options = get_hooked_delivery_options(options, hooked_options, precision) - return options + return apply_shipping_discounts(quotation, options) def get_hooked_delivery_options(options: list[dict], hooked_options, precision: int) -> list[dict]: diff --git a/commera/commera_ecommerce/custom/quotation.json b/commera/commera_ecommerce/custom/quotation.json index 2a20da50..8d28c295 100644 --- a/commera/commera_ecommerce/custom/quotation.json +++ b/commera/commera_ecommerce/custom/quotation.json @@ -718,7 +718,7 @@ "creation": "2026-10-10 00:00:00.000000", "default": null, "depends_on": null, - "description": "Commera Discount codes, one per line.", + "description": "Discount codes the shopper applied, one per line.", "docstatus": 0, "dt": "Quotation", "fetch_from": null, @@ -831,6 +831,198 @@ "translatable": 0, "unique": 0, "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-11 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_shipping_discount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_code_order_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Shipping Discount", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_shipping_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-11 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_shipping_discount_id", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_shipping_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Shipping Discount Rule", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_shipping_discount_id", + "no_copy": 0, + "non_negative": 0, + "options": "Commera Discount", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-11 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "JSON list of the discounts on this document and their amounts.", + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount_allocations", + "fieldtype": "Long Text", + "hidden": 1, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_shipping_discount_id", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Allocations", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_discount_allocations", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null } ], "custom_perms": [], diff --git a/commera/commera_ecommerce/custom/quotation_item.json b/commera/commera_ecommerce/custom/quotation_item.json index 3fec2d22..29d3ee4d 100644 --- a/commera/commera_ecommerce/custom/quotation_item.json +++ b/commera/commera_ecommerce/custom/quotation_item.json @@ -99,7 +99,7 @@ "insert_after": "custom_code_discount", "is_system_generated": 0, "is_virtual": 0, - "label": "Discount Code", + "label": "Discount", "length": 0, "link_filters": null, "mandatory_depends_on": null, diff --git a/commera/commera_ecommerce/custom/sales_invoice.json b/commera/commera_ecommerce/custom/sales_invoice.json index b73c4499..0db47bc8 100644 --- a/commera/commera_ecommerce/custom/sales_invoice.json +++ b/commera/commera_ecommerce/custom/sales_invoice.json @@ -14,7 +14,7 @@ "creation": "2026-10-10 00:00:00.000000", "default": null, "depends_on": null, - "description": "Commera Discount codes, one per line.", + "description": "Discount codes the shopper applied, one per line.", "docstatus": 0, "dt": "Sales Invoice", "fetch_from": null, @@ -127,6 +127,198 @@ "translatable": 0, "unique": 0, "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-11 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_shipping_discount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_code_order_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Shipping Discount", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice-custom_shipping_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-11 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_shipping_discount_id", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_shipping_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Shipping Discount Rule", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice-custom_shipping_discount_id", + "no_copy": 0, + "non_negative": 0, + "options": "Commera Discount", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-11 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "JSON list of the discounts on this document and their amounts.", + "docstatus": 0, + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount_allocations", + "fieldtype": "Long Text", + "hidden": 1, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_shipping_discount_id", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Allocations", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice-custom_discount_allocations", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null } ], "custom_perms": [], diff --git a/commera/commera_ecommerce/custom/sales_invoice_item.json b/commera/commera_ecommerce/custom/sales_invoice_item.json index 42fa065d..e445b9fb 100644 --- a/commera/commera_ecommerce/custom/sales_invoice_item.json +++ b/commera/commera_ecommerce/custom/sales_invoice_item.json @@ -99,7 +99,7 @@ "insert_after": "custom_code_discount", "is_system_generated": 0, "is_virtual": 0, - "label": "Discount Code", + "label": "Discount", "length": 0, "link_filters": null, "mandatory_depends_on": null, diff --git a/commera/commera_ecommerce/custom/sales_order.json b/commera/commera_ecommerce/custom/sales_order.json index 4dc34ee8..4f7d3954 100644 --- a/commera/commera_ecommerce/custom/sales_order.json +++ b/commera/commera_ecommerce/custom/sales_order.json @@ -1038,7 +1038,7 @@ "creation": "2026-10-10 00:00:00.000000", "default": null, "depends_on": null, - "description": "Commera Discount codes, one per line.", + "description": "Discount codes the shopper applied, one per line.", "docstatus": 0, "dt": "Sales Order", "fetch_from": null, @@ -1151,6 +1151,198 @@ "translatable": 0, "unique": 0, "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-11 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_shipping_discount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_code_order_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Shipping Discount", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order-custom_shipping_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-11 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_shipping_discount_id", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_shipping_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Shipping Discount Rule", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order-custom_shipping_discount_id", + "no_copy": 0, + "non_negative": 0, + "options": "Commera Discount", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-11 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "JSON list of the discounts on this document and their amounts.", + "docstatus": 0, + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount_allocations", + "fieldtype": "Long Text", + "hidden": 1, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_shipping_discount_id", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Allocations", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order-custom_discount_allocations", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null } ], "custom_perms": [], diff --git a/commera/commera_ecommerce/custom/sales_order_item.json b/commera/commera_ecommerce/custom/sales_order_item.json index e07d6b5e..e2bba07b 100644 --- a/commera/commera_ecommerce/custom/sales_order_item.json +++ b/commera/commera_ecommerce/custom/sales_order_item.json @@ -99,7 +99,7 @@ "insert_after": "custom_code_discount", "is_system_generated": 0, "is_virtual": 0, - "label": "Discount Code", + "label": "Discount", "length": 0, "link_filters": null, "mandatory_depends_on": null, diff --git a/commera/commera_ecommerce/doctype/commera_cart/commera_cart.py b/commera/commera_ecommerce/doctype/commera_cart/commera_cart.py index 360f7ae4..877798dc 100644 --- a/commera/commera_ecommerce/doctype/commera_cart/commera_cart.py +++ b/commera/commera_ecommerce/doctype/commera_cart/commera_cart.py @@ -3,11 +3,14 @@ from frappe.model.document import Document from frappe.utils.data import cint, cstr, flt, get_datetime -from commera.commera_ecommerce.doctype.commera_discount.commera_discount import get_code_name -from commera.discount_codes import ( - apply_code_discounts, +from commera.discounts.allocations import get_allocations +from commera.discounts.cart_context import get_cart_context +from commera.discounts.codes import get_code_discount, normalize_code +from commera.discounts.pricing import apply_code_discounts +from commera.discounts.selection import ( get_discount, get_discount_codes, + get_unmet_reason, validate_new_discount_code, ) from commera.guest import get_guest_cart_key, is_guest, set_guest_cart_cookie @@ -95,7 +98,7 @@ def add_discount_code(self, code: str): self.discount_codes = "\n".join(codes) def remove_discount_code(self, code: str): - code = get_code_name(code) + code = normalize_code(code) self.discount_codes = "\n".join(name for name in get_discount_codes(self) if name != code) def get_lines_by_item_code(self) -> dict[str, list]: @@ -112,10 +115,8 @@ def get_priced_cart(self) -> dict: "name": self.name, "version": self.version, "currency": quotation.currency, - "discount_codes": [ - {"code": code, "class": get_discount(code).discount_class} - for code in get_discount_codes(self) - ], + "discount_codes": get_code_states(quotation), + "automatic_discounts": get_automatic_discounts(quotation), "lines": [get_priced_line(row, lines_by_row[row.name]) for row in self.lines], "subtotal": flt(quotation.total), "order_discount": flt(quotation.custom_code_order_discount), @@ -135,6 +136,9 @@ def get_quotation_preview(self): "order_type": "Shopping Cart", "selling_price_list": settings.sale_price_list, "custom_discount_codes": self.discount_codes, + # Who is shopping, so first-order, group and once-per-customer rules can be judged. + "party_name": self.user and frappe.db.get_value("Portal User", {"user": self.user}, "parent"), + "contact_email": self.user, } ) for row in self.lines: @@ -179,6 +183,34 @@ def change_quantity_from_the_end(cart, rows: list, delta: int): cart.lines = [row for row in cart.lines if cint(row.qty) > 0] +def get_code_states(quotation) -> list[dict]: + """Each code with whether it applies now and, when not, why in the shopper's words.""" + applied = {(row["discount"], row.get("code")) for row in get_allocations(quotation)} + cart = get_cart_context(quotation) + states = [] + for code in get_discount_codes(quotation): + discount = get_discount(get_code_discount(code)) + is_applied = (discount.name, code) in applied + states.append( + { + "code": code, + "title": discount.title, + "discount_class": discount.discount_class, + "applied": is_applied, + "reason": None if is_applied else get_unmet_reason(discount, cart) or discount.status, + } + ) + return states + + +def get_automatic_discounts(quotation) -> list[dict]: + return [ + {"title": get_discount(row["discount"]).title, "amount": row["amount"]} + for row in get_allocations(quotation) + if not row.get("code") + ] + + def get_priced_line(row, item) -> dict: return { "line_id": row.name, @@ -189,7 +221,7 @@ def get_priced_line(row, item) -> dict: "price": flt(item.price_list_rate), "rate": flt(item.rate), "code_discount": flt(item.custom_code_discount), - "discount_code": item.custom_discount_code, + "discount": item.custom_discount_code, "amount": flt(item.amount), } diff --git a/commera/commera_ecommerce/doctype/commera_discount/commera_discount.json b/commera/commera_ecommerce/doctype/commera_discount/commera_discount.json index 7a769cb8..bcbeca5f 100644 --- a/commera/commera_ecommerce/doctype/commera_discount/commera_discount.json +++ b/commera/commera_ecommerce/doctype/commera_discount/commera_discount.json @@ -1,155 +1,442 @@ { "actions": [], - "creation": "2026-10-10 00:00:00.000000", + "creation": "2026-10-11 00:00:00.000000", "doctype": "DocType", "engine": "InnoDB", "field_order": [ - "code", + "title", + "method", + "discount_type", + "column_break_main", "enabled", - "discount_class", + "starts_on", + "ends_on", + "section_value", "value_type", "value", - "currency", + "maximum_discount", + "fixed_applies_to", "column_break_value", - "valid_from", - "valid_upto", - "usage_limit", - "times_used", - "section_conditions", + "currency", + "tiered", + "tiers", + "section_targets", + "applies_to", + "exclude_sale_items", "targets", - "minimum_quantity", + "section_buy_x_get_y", + "buy_requirement", + "buy_value", + "uses_per_order", + "column_break_get", + "get_quantity", + "get_value_type", + "get_value", + "section_shipping", + "delivery_options", + "column_break_shipping", + "shipping_percentage", + "maximum_shipping_rate", + "section_conditions", + "minimum_requirement", "minimum_subtotal", + "minimum_quantity", + "payment_condition", + "column_break_conditions", + "eligibility", + "customer_groups", "section_combinations", "combines_with_product", - "combines_with_order" + "combines_with_order", + "combines_with_shipping", + "section_limits", + "usage_limit", + "once_per_customer", + "budget", + "column_break_limits", + "times_used", + "discount_given", + "sales", + "section_reporting", + "campaign", + "column_break_reporting", + "discount_account" ], "fields": [ { - "description": "What the shopper types. Stored in capitals; matching ignores case.", - "fieldname": "code", + "fieldname": "title", "fieldtype": "Data", - "in_list_view": 1, - "label": "Code", + "label": "Title", "reqd": 1, - "unique": 1 + "in_list_view": 1, + "description": "Shoppers see this for an automatic discount. A code discount shows its code." }, { - "default": "1", - "fieldname": "enabled", - "fieldtype": "Check", + "fieldname": "method", + "fieldtype": "Select", + "label": "Method", + "options": "Code\nAutomatic", + "default": "Code", + "reqd": 1, "in_list_view": 1, - "label": "Enabled" + "in_standard_filter": 1 }, { - "description": "Product: comes off matching lines. Order: comes off the subtotal after product discounts.", - "fieldname": "discount_class", + "fieldname": "discount_type", "fieldtype": "Select", + "label": "Discount Type", + "options": "Amount Off Products\nAmount Off Order\nBuy X Get Y\nFree Shipping", + "reqd": 1, "in_list_view": 1, - "label": "Discount Class", - "options": "Product\nOrder", - "reqd": 1 + "in_standard_filter": 1 + }, + { + "fieldname": "column_break_main", + "fieldtype": "Column Break" + }, + { + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled", + "default": "1" + }, + { + "fieldname": "starts_on", + "fieldtype": "Date", + "label": "Starts On", + "default": "Today" + }, + { + "fieldname": "ends_on", + "fieldtype": "Date", + "label": "Ends On" + }, + { + "fieldname": "section_value", + "fieldtype": "Section Break", + "label": "Value", + "depends_on": "eval:['Amount Off Products','Amount Off Order'].includes(doc.discount_type)" }, { "fieldname": "value_type", "fieldtype": "Select", "label": "Value Type", "options": "Percentage\nFixed Amount", - "reqd": 1 + "default": "Percentage" }, { - "description": "A percentage, or an amount per unit (Product) or per order (Order).", "fieldname": "value", "fieldtype": "Float", - "in_list_view": 1, "label": "Value", + "non_negative": 1 + }, + { + "fieldname": "maximum_discount", + "fieldtype": "Currency", + "label": "Maximum Discount", + "options": "currency", "non_negative": 1, - "reqd": 1 + "depends_on": "eval:doc.value_type == 'Percentage'", + "description": "Caps a percentage discount for the whole order. 0 means no cap." + }, + { + "fieldname": "fixed_applies_to", + "fieldtype": "Select", + "label": "Fixed Amount Comes Off", + "options": "Each Item\nOnce Per Order", + "default": "Each Item", + "depends_on": "eval:doc.discount_type == 'Amount Off Products' && doc.value_type == 'Fixed Amount'" + }, + { + "fieldname": "column_break_value", + "fieldtype": "Column Break" }, { - "depends_on": "eval:doc.value_type == 'Fixed Amount'", - "description": "A fixed amount applies only to carts in this currency.", "fieldname": "currency", "fieldtype": "Link", "label": "Currency", - "mandatory_depends_on": "eval:doc.value_type == 'Fixed Amount'", - "options": "Currency" + "options": "Currency", + "description": "Fixed amounts, caps and budgets apply only to carts in this currency." }, { - "fieldname": "column_break_value", - "fieldtype": "Column Break" + "fieldname": "tiered", + "fieldtype": "Check", + "label": "Spend Tiers", + "depends_on": "eval:doc.discount_type == 'Amount Off Order'", + "description": "The best tier the subtotal reaches replaces the single value." }, { - "fieldname": "valid_from", - "fieldtype": "Date", - "label": "Valid From" + "fieldname": "tiers", + "fieldtype": "Table", + "label": "Tiers", + "options": "Commera Discount Tier", + "depends_on": "eval:doc.tiered" }, { - "fieldname": "valid_upto", - "fieldtype": "Date", - "label": "Valid Upto" + "fieldname": "section_targets", + "fieldtype": "Section Break", + "label": "Products", + "depends_on": "eval:['Amount Off Products','Buy X Get Y'].includes(doc.discount_type)" }, { - "description": "0 means no limit.", - "fieldname": "usage_limit", - "fieldtype": "Int", - "label": "Usage Limit", + "fieldname": "applies_to", + "fieldtype": "Select", + "label": "Applies To", + "options": "All Products\nSpecific Products\nSpecific Collections\nSpecific Brands", + "default": "All Products", + "depends_on": "eval:doc.discount_type == 'Amount Off Products'" + }, + { + "fieldname": "exclude_sale_items", + "fieldtype": "Check", + "label": "Skip Items Already On Sale", + "description": "An item whose sale price is below its regular price gets no extra discount and does not count towards an order discount." + }, + { + "fieldname": "targets", + "fieldtype": "Table", + "label": "Targets", + "options": "Commera Discount Target", + "description": "Applies To and Excluded rows for product discounts; Buys and Gets rows for Buy X Get Y." + }, + { + "fieldname": "section_buy_x_get_y", + "fieldtype": "Section Break", + "label": "Buy X Get Y", + "depends_on": "eval:doc.discount_type == 'Buy X Get Y'" + }, + { + "fieldname": "buy_requirement", + "fieldtype": "Select", + "label": "Customer Buys", + "options": "Quantity\nAmount", + "default": "Quantity" + }, + { + "fieldname": "buy_value", + "fieldtype": "Float", + "label": "Buy Quantity Or Amount", "non_negative": 1 }, { - "default": "0", - "fieldname": "times_used", + "fieldname": "uses_per_order", "fieldtype": "Int", - "label": "Times Used", - "no_copy": 1, - "read_only": 1 + "label": "Maximum Uses Per Order", + "non_negative": 1, + "description": "0 means as many times as the cart allows." }, { - "fieldname": "section_conditions", + "fieldname": "column_break_get", + "fieldtype": "Column Break" + }, + { + "fieldname": "get_quantity", + "fieldtype": "Int", + "label": "Customer Gets Quantity", + "non_negative": 1, + "default": "1" + }, + { + "fieldname": "get_value_type", + "fieldtype": "Select", + "label": "At", + "options": "Free\nPercentage\nFixed Amount", + "default": "Free" + }, + { + "fieldname": "get_value", + "fieldtype": "Float", + "label": "Get Value", + "non_negative": 1, + "depends_on": "eval:doc.get_value_type != 'Free'" + }, + { + "fieldname": "section_shipping", "fieldtype": "Section Break", - "label": "Conditions" + "label": "Delivery", + "depends_on": "eval:doc.discount_type == 'Free Shipping'" }, { - "depends_on": "eval:doc.discount_class == 'Product'", - "description": "Items, item groups (with their sub-groups) or brands. Empty means every item.", - "fieldname": "targets", - "fieldtype": "Table", - "label": "Applies To", - "options": "Commera Discount Target" + "fieldname": "delivery_options", + "fieldtype": "Small Text", + "label": "Delivery Options", + "description": "Delivery option titles, one per line. Empty means every option." }, { - "depends_on": "eval:doc.discount_class == 'Product'", - "fieldname": "minimum_quantity", - "fieldtype": "Int", - "label": "Minimum Quantity Per Line", + "fieldname": "column_break_shipping", + "fieldtype": "Column Break" + }, + { + "fieldname": "shipping_percentage", + "fieldtype": "Float", + "label": "Off The Delivery Charge (%)", + "default": "100", "non_negative": 1 }, { - "depends_on": "eval:doc.discount_class == 'Order'", + "fieldname": "maximum_shipping_rate", + "fieldtype": "Currency", + "label": "Skip Rates Above", + "options": "currency", + "non_negative": 1, + "description": "0 means no limit." + }, + { + "fieldname": "section_conditions", + "fieldtype": "Section Break", + "label": "Conditions" + }, + { + "fieldname": "minimum_requirement", + "fieldtype": "Select", + "label": "Minimum Purchase", + "options": "None\nSubtotal\nQuantity", + "default": "None" + }, + { "fieldname": "minimum_subtotal", "fieldtype": "Currency", "label": "Minimum Subtotal", - "non_negative": 1 + "options": "currency", + "non_negative": 1, + "depends_on": "eval:doc.minimum_requirement == 'Subtotal'" + }, + { + "fieldname": "minimum_quantity", + "fieldtype": "Int", + "label": "Minimum Items", + "non_negative": 1, + "depends_on": "eval:doc.minimum_requirement == 'Quantity'" + }, + { + "fieldname": "payment_condition", + "fieldtype": "Select", + "label": "Payment", + "options": "Any\nPrepaid\nCash On Delivery", + "default": "Any" + }, + { + "fieldname": "column_break_conditions", + "fieldtype": "Column Break" + }, + { + "fieldname": "eligibility", + "fieldtype": "Select", + "label": "Customers", + "options": "Everyone\nFirst Order\nCustomer Groups", + "default": "Everyone" + }, + { + "fieldname": "customer_groups", + "fieldtype": "Table MultiSelect", + "label": "Customer Groups", + "options": "Commera Discount Customer Group", + "depends_on": "eval:doc.eligibility == 'Customer Groups'" }, { "fieldname": "section_combinations", "fieldtype": "Section Break", - "label": "Combinations" + "label": "Combinations", + "description": "Two discounts stack only when each allows the other's class." }, { - "default": "0", "fieldname": "combines_with_product", "fieldtype": "Check", "label": "Combines With Product Discounts" }, { - "default": "0", "fieldname": "combines_with_order", "fieldtype": "Check", "label": "Combines With Order Discounts" + }, + { + "fieldname": "combines_with_shipping", + "fieldtype": "Check", + "label": "Combines With Shipping Discounts", + "default": "1" + }, + { + "fieldname": "section_limits", + "fieldtype": "Section Break", + "label": "Limits" + }, + { + "fieldname": "usage_limit", + "fieldtype": "Int", + "label": "Total Uses", + "non_negative": 1, + "description": "0 means no limit." + }, + { + "fieldname": "once_per_customer", + "fieldtype": "Check", + "label": "Once Per Customer", + "description": "Matched by customer, email or phone." + }, + { + "fieldname": "budget", + "fieldtype": "Currency", + "label": "Budget", + "options": "currency", + "non_negative": 1, + "description": "Stops once this much discount is given. 0 means no budget." + }, + { + "fieldname": "column_break_limits", + "fieldtype": "Column Break" + }, + { + "fieldname": "times_used", + "fieldtype": "Int", + "label": "Times Used", + "read_only": 1, + "no_copy": 1, + "default": "0" + }, + { + "fieldname": "discount_given", + "fieldtype": "Currency", + "label": "Discount Given", + "options": "currency", + "read_only": 1, + "no_copy": 1, + "default": "0" + }, + { + "fieldname": "sales", + "fieldtype": "Currency", + "label": "Sales With The Discount", + "options": "currency", + "read_only": 1, + "no_copy": 1, + "default": "0" + }, + { + "fieldname": "section_reporting", + "fieldtype": "Section Break", + "label": "Reporting And Accounting", + "collapsible": 1 + }, + { + "fieldname": "campaign", + "fieldtype": "Link", + "label": "Campaign", + "options": "Campaign" + }, + { + "fieldname": "column_break_reporting", + "fieldtype": "Column Break" + }, + { + "fieldname": "discount_account", + "fieldtype": "Link", + "label": "Discount Account", + "options": "Account", + "description": "Posted here when Selling Settings > Enable Discount Accounting is on." } ], "links": [], - "modified": "2026-10-10 00:00:00.000000", + "modified": "2026-10-11 00:00:00.000000", "modified_by": "Administrator", "module": "Commera Ecommerce", "name": "Commera Discount", @@ -160,10 +447,16 @@ "delete": 1, "read": 1, "role": "System Manager", - "write": 1 + "write": 1, + "export": 1, + "report": 1 } ], "sort_field": "modified", "sort_order": "DESC", - "states": [] + "states": [], + "autoname": "format:DISC-{#####}", + "title_field": "title", + "show_title_field_in_link": 1, + "search_fields": "discount_type,method" } diff --git a/commera/commera_ecommerce/doctype/commera_discount/commera_discount.py b/commera/commera_ecommerce/doctype/commera_discount/commera_discount.py index 491b2070..1d7ebc73 100644 --- a/commera/commera_ecommerce/doctype/commera_discount/commera_discount.py +++ b/commera/commera_ecommerce/doctype/commera_discount/commera_discount.py @@ -6,6 +6,14 @@ PRODUCT = "Product" ORDER = "Order" +SHIPPING = "Shipping" +CLASS_BY_TYPE = { + "Amount Off Products": PRODUCT, + "Buy X Get Y": PRODUCT, + "Amount Off Order": ORDER, + "Free Shipping": SHIPPING, +} +PERCENTAGE = "Percentage" class DiscountCodeError(frappe.ValidationError): @@ -13,45 +21,116 @@ class DiscountCodeError(frappe.ValidationError): class CommeraDiscount(Document): - """A discount code. Commera applies it on top of ERPNext's price, into the standard discount fields.""" - - def autoname(self): - self.code = get_code_name(self.code) - self.name = self.code + """A discount's rules. Commera applies it on top of ERPNext's price, into the standard discount fields.""" def validate(self): - if self.value_type == "Percentage" and flt(self.value) > 100: - frappe.throw(_("A percentage discount cannot be more than 100.")) - if self.valid_from and self.valid_upto and getdate(self.valid_from) > getdate(self.valid_upto): - frappe.throw(_("Valid From must be on or before Valid Upto.")) + self.validate_values() + if self.starts_on and self.ends_on and getdate(self.starts_on) > getdate(self.ends_on): + frappe.throw(_("Starts On must be on or before Ends On.")) + self.tiers = sorted(self.tiers, key=lambda tier: flt(tier.minimum_subtotal)) + self.validate_targets() + + def validate_values(self): + percentages = [self.shipping_percentage] + if self.value_type == PERCENTAGE: + percentages += [self.value, *(tier.value for tier in self.tiers)] + if self.get_value_type == PERCENTAGE: + percentages.append(self.get_value) + if any(flt(percentage) > 100 for percentage in percentages): + frappe.throw(_("A percentage cannot be more than 100.")) + + def validate_targets(self): + roles = {target.role for target in self.targets} + if self.discount_type == "Buy X Get Y": + if not {"Buys", "Gets"} <= roles or not flt(self.buy_value) or not cint(self.get_quantity): + frappe.throw(_("Buy X Get Y needs what the customer buys, how much, and what they get.")) + elif roles & {"Buys", "Gets"}: + frappe.throw(_("Buys and Gets rows are only for Buy X Get Y.")) + + @property + def discount_class(self) -> str: + return CLASS_BY_TYPE[self.discount_type] + + @property + def budget_left(self) -> float | None: + return max(flt(self.budget) - flt(self.discount_given), 0) if flt(self.budget) else None + + @property + def status(self) -> str: + if not self.enabled: + return "Disabled" + if self.starts_on and getdate(self.starts_on) > getdate(today()): + return "Scheduled" + if self.ends_on and getdate(self.ends_on) < getdate(today()): + return "Expired" + if (self.usage_limit and cint(self.times_used) >= cint(self.usage_limit)) or self.budget_left == 0: + return "Used Up" + return "Active" def validate_usable(self): - if not self.enabled: - frappe.throw(_("{0} is not active.").format(self.code), DiscountCodeError) - if self.valid_from and getdate(self.valid_from) > getdate(today()): - frappe.throw(_("{0} is not active yet.").format(self.code), DiscountCodeError) - if self.valid_upto and getdate(self.valid_upto) < getdate(today()): - frappe.throw(_("{0} has expired.").format(self.code), DiscountCodeError) - if self.usage_limit and cint(self.times_used) >= cint(self.usage_limit): - frappe.throw(_("{0} has been used up.").format(self.code), DiscountCodeError) + messages = { + "Disabled": _("{0} is not active."), + "Scheduled": _("{0} is not active yet."), + "Expired": _("{0} has expired."), + "Used Up": _("{0} has been used up."), + } + if self.status in messages: + frappe.throw(messages[self.status].format(self.title), DiscountCodeError) def can_combine_with(self, other: "CommeraDiscount") -> bool: return self.is_combinable_with(other.discount_class) and other.is_combinable_with(self.discount_class) def is_combinable_with(self, discount_class: str) -> bool: - return bool(self.combines_with_product if discount_class == PRODUCT else self.combines_with_order) + fieldname = { + PRODUCT: "combines_with_product", + ORDER: "combines_with_order", + SHIPPING: "combines_with_shipping", + } + return bool(self.get(fieldname[discount_class])) def is_for_currency(self, currency: str) -> bool: - return self.value_type == "Percentage" or self.currency == currency - - def is_for_line(self, item) -> bool: - if self.minimum_quantity and flt(item.qty) < cint(self.minimum_quantity): + return not self.currency or self.currency == currency + + def get_unmet_condition(self, cart) -> str | None: + """Why this discount does not apply to the cart now, in the shopper's words; None when it does.""" + if self.minimum_requirement == "Subtotal" and cart.subtotal < flt(self.minimum_subtotal): + return _("Add {0} more to use {1}.").format( + frappe.format_value(flt(self.minimum_subtotal) - cart.subtotal, {"fieldtype": "Currency"}), + self.title, + ) + if self.minimum_requirement == "Quantity" and cart.quantity < cint(self.minimum_quantity): + return _("{0} needs at least {1} items.").format(self.title, cint(self.minimum_quantity)) + if self.payment_condition == "Prepaid" and cart.payment_kind == "cod": + return _("{0} is for prepaid orders only.").format(self.title) + if self.payment_condition == "Cash On Delivery" and cart.payment_kind != "cod": + return _("{0} is for cash on delivery orders only.").format(self.title) + if self.eligibility == "Customer Groups" and not self.is_for_customer_group(cart.customer_group): + return _("{0} is not available for your account.").format(self.title) + return None + + def is_for_customer_group(self, customer_group: str | None) -> bool: + if not customer_group: return False - if not self.targets: + return any( + customer_group == row.customer_group + or customer_group in get_descendants_of("Customer Group", row.customer_group) + for row in self.customer_groups + ) + + def is_for_line(self, item, cart, role: str = "Applies To") -> bool: + if self.exclude_sale_items and cart.is_on_sale(item.item_code): + return False + if self.has_target_match(item, "Excluded"): + return False + if role == "Applies To" and self.applies_to == "All Products": return True + return self.has_target_match(item, role) + def has_target_match(self, item, role: str) -> bool: variant_of = frappe.get_cached_value("Item", item.item_code, "variant_of") for target in self.targets: + if target.role != role: + continue if target.target_type == "Item" and target.target in (item.item_code, variant_of): return True if target.target_type == "Brand" and target.target == item.brand: @@ -60,28 +139,113 @@ def is_for_line(self, item) -> bool: return True return False - def get_unit_discount(self, rate: float) -> float: - if self.value_type == "Percentage": - return min(rate * flt(self.value) / 100, rate) - return min(flt(self.value), rate) - - def get_order_discount(self, subtotal: float) -> float: - if self.minimum_subtotal and subtotal < flt(self.minimum_subtotal): + def get_line_discounts(self, cart) -> dict[int, float]: + """The whole-line discount this product discount gives each eligible line, keyed by row idx: an + unsaved preview's rows have no name yet.""" + if self.discount_type == "Buy X Get Y": + return self.get_buy_x_get_y_discounts(cart) + + lines = [item for item in cart.items if self.is_for_line(item, cart)] + if self.value_type == PERCENTAGE: + discounts = {item.idx: flt(item.amount) * flt(self.value) / 100 for item in lines} + return cap_discounts(discounts, flt(self.maximum_discount)) + if self.fixed_applies_to == "Each Item": + return {item.idx: min(flt(self.value), flt(item.rate)) * flt(item.qty) for item in lines} + return spread_amount(flt(self.value), {item.idx: flt(item.amount) for item in lines}) + + def get_buy_x_get_y_discounts(self, cart) -> dict[int, float]: + """The cheapest units the customer gets are discounted; the units that earn them cannot also be them.""" + units = sorted( + (flt(item.rate), item.idx, item) + for item in cart.items + for _unit in range(cint(item.qty)) + if self.is_for_line(item, cart, "Buys") or self.is_for_line(item, cart, "Gets") + ) + discounts = {} + applications = 0 + while not self.uses_per_order or applications < cint(self.uses_per_order): + rewarded = take_units( + units, cint(self.get_quantity), lambda item: self.is_for_line(item, cart, "Gets") + ) + if not rewarded or not self.take_buy_units(units, cart): + break + for rate, idx, _item in rewarded: + discounts[idx] = discounts.get(idx, 0) + self.get_reward(rate) + applications += 1 + return discounts + + def take_buy_units(self, units: list, cart) -> bool: + buys = [unit for unit in units if self.is_for_line(unit[2], cart, "Buys")] + if self.buy_requirement == "Quantity": + if len(buys) < cint(self.buy_value): + return False + for unit in buys[-cint(self.buy_value) :]: + units.remove(unit) + return True + # An amount requirement is met once by everything bought; it does not repeat per order. + if sum(unit[0] for unit in buys) < flt(self.buy_value): + return False + units.clear() + return True + + def get_reward(self, rate: float) -> float: + if self.get_value_type == "Free": + return rate + if self.get_value_type == PERCENTAGE: + return rate * flt(self.get_value) / 100 + return min(flt(self.get_value), rate) + + def get_order_discount(self, cart) -> float: + subtotal = sum( + flt(item.amount) + for item in cart.items + if not (self.exclude_sale_items and cart.is_on_sale(item.item_code)) + ) + value = self.get_tier_value(subtotal) if self.tiered else flt(self.value) + if value is None: + return 0.0 + if self.value_type != PERCENTAGE: + return min(value, subtotal) + discount = subtotal * value / 100 + return min(discount, flt(self.maximum_discount)) if flt(self.maximum_discount) else discount + + def get_tier_value(self, subtotal: float) -> float | None: + reached = [tier for tier in self.tiers if subtotal >= flt(tier.minimum_subtotal)] + return flt(reached[-1].value) if reached else None + + def get_delivery_discount(self, option: dict) -> float: + titles = {line.strip() for line in (self.delivery_options or "").splitlines() if line.strip()} + if titles and option["title"] not in titles: + return 0.0 + amount = flt(option["amount"]) + if flt(self.maximum_shipping_rate) and amount > flt(self.maximum_shipping_rate): return 0.0 - if self.value_type == "Percentage": - return subtotal * flt(self.value) / 100 - return flt(self.value) + return amount * flt(self.shipping_percentage) / 100 + + +def take_units(units: list, quantity: int, is_wanted) -> list: + wanted = [unit for unit in units if is_wanted(unit[2])][:quantity] + if len(wanted) < quantity: + return [] + for unit in wanted: + units.remove(unit) + return wanted + - def count_use(self, change: int): - """Add `change` to the use count, under a row lock so two orders cannot both take the last use.""" - times_used = cint(frappe.db.get_value(self.doctype, self.name, "times_used", for_update=True)) - if change > 0 and self.usage_limit and times_used >= cint(self.usage_limit): - frappe.throw(_("{0} has been used up.").format(self.code), DiscountCodeError) - frappe.db.set_value(self.doctype, self.name, "times_used", max(times_used + change, 0)) +def cap_discounts(discounts: dict[int, float], cap: float) -> dict[int, float]: + total = sum(discounts.values()) + if not cap or total <= cap: + return discounts + return {name: amount * cap / total for name, amount in discounts.items()} -def get_code_name(code: str) -> str: - return (code or "").strip().upper() +def spread_amount(amount: float, amounts: dict[int, float]) -> dict[int, float]: + """Share one amount over lines in proportion to their value, never more than the lines are worth.""" + total = sum(amounts.values()) + if not total: + return {} + amount = min(amount, total) + return {name: amount * value / total for name, value in amounts.items()} def get_item_group_tree(item_group: str) -> set[str]: diff --git a/commera/commera_ecommerce/doctype/commera_discount_code/__init__.py b/commera/commera_ecommerce/doctype/commera_discount_code/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/commera_ecommerce/doctype/commera_discount_code/commera_discount_code.json b/commera/commera_ecommerce/doctype/commera_discount_code/commera_discount_code.json new file mode 100644 index 00000000..de157b5d --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_discount_code/commera_discount_code.json @@ -0,0 +1,70 @@ +{ + "actions": [], + "creation": "2026-10-11 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "code", + "discount", + "is_generated", + "times_used" + ], + "fields": [ + { + "fieldname": "code", + "fieldtype": "Data", + "label": "Code", + "reqd": 1, + "unique": 1, + "in_list_view": 1, + "description": "What the shopper types. Stored in capitals; matching ignores case." + }, + { + "fieldname": "discount", + "fieldtype": "Link", + "label": "Discount", + "options": "Commera Discount", + "reqd": 1, + "in_list_view": 1, + "in_standard_filter": 1, + "search_index": 1 + }, + { + "fieldname": "is_generated", + "fieldtype": "Check", + "label": "Generated", + "read_only": 1, + "description": "A generated code can be used once." + }, + { + "fieldname": "times_used", + "fieldtype": "Int", + "label": "Times Used", + "read_only": 1, + "no_copy": 1, + "default": "0", + "in_list_view": 1 + } + ], + "links": [], + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": "Commera Ecommerce", + "name": "Commera Discount Code", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "read": 1, + "role": "System Manager", + "write": 1, + "export": 1, + "report": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "autoname": "field:code" +} diff --git a/commera/commera_ecommerce/doctype/commera_discount_code/commera_discount_code.py b/commera/commera_ecommerce/doctype/commera_discount_code/commera_discount_code.py new file mode 100644 index 00000000..afd26e10 --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_discount_code/commera_discount_code.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class CommeraDiscountCode(Document): + pass diff --git a/commera/commera_ecommerce/doctype/commera_discount_customer_group/__init__.py b/commera/commera_ecommerce/doctype/commera_discount_customer_group/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/commera_ecommerce/doctype/commera_discount_customer_group/commera_discount_customer_group.json b/commera/commera_ecommerce/doctype/commera_discount_customer_group/commera_discount_customer_group.json new file mode 100644 index 00000000..fbd01ba6 --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_discount_customer_group/commera_discount_customer_group.json @@ -0,0 +1,31 @@ +{ + "actions": [], + "creation": "2026-10-11 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "customer_group" + ], + "fields": [ + { + "fieldname": "customer_group", + "fieldtype": "Link", + "label": "Customer Group", + "options": "Customer Group", + "reqd": 1, + "in_list_view": 1 + } + ], + "links": [], + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": "Commera Ecommerce", + "name": "Commera Discount Customer Group", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "istable": 1, + "editable_grid": 1 +} diff --git a/commera/commera_ecommerce/doctype/commera_discount_customer_group/commera_discount_customer_group.py b/commera/commera_ecommerce/doctype/commera_discount_customer_group/commera_discount_customer_group.py new file mode 100644 index 00000000..e5c7f36c --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_discount_customer_group/commera_discount_customer_group.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class CommeraDiscountCustomerGroup(Document): + pass diff --git a/commera/commera_ecommerce/doctype/commera_discount_target/commera_discount_target.json b/commera/commera_ecommerce/doctype/commera_discount_target/commera_discount_target.json index 731aaa8b..19e419fe 100644 --- a/commera/commera_ecommerce/doctype/commera_discount_target/commera_discount_target.json +++ b/commera/commera_ecommerce/doctype/commera_discount_target/commera_discount_target.json @@ -1,31 +1,42 @@ { "actions": [], - "creation": "2026-10-10 00:00:00.000000", + "creation": "2026-10-11 00:00:00.000000", "doctype": "DocType", - "editable_grid": 1, "engine": "InnoDB", - "field_order": ["target_type", "target"], + "field_order": [ + "role", + "target_type", + "target" + ], "fields": [ + { + "fieldname": "role", + "fieldtype": "Select", + "label": "Role", + "options": "Applies To\nExcluded\nBuys\nGets", + "default": "Applies To", + "reqd": 1, + "in_list_view": 1 + }, { "fieldname": "target_type", "fieldtype": "Select", - "in_list_view": 1, "label": "Target Type", "options": "Item\nItem Group\nBrand", - "reqd": 1 + "reqd": 1, + "in_list_view": 1 }, { "fieldname": "target", "fieldtype": "Dynamic Link", - "in_list_view": 1, "label": "Target", "options": "target_type", - "reqd": 1 + "reqd": 1, + "in_list_view": 1 } ], - "istable": 1, "links": [], - "modified": "2026-10-10 00:00:00.000000", + "modified": "2026-10-11 00:00:00.000000", "modified_by": "Administrator", "module": "Commera Ecommerce", "name": "Commera Discount Target", @@ -33,5 +44,7 @@ "permissions": [], "sort_field": "modified", "sort_order": "DESC", - "states": [] + "states": [], + "istable": 1, + "editable_grid": 1 } diff --git a/commera/commera_ecommerce/doctype/commera_discount_tier/__init__.py b/commera/commera_ecommerce/doctype/commera_discount_tier/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/commera_ecommerce/doctype/commera_discount_tier/commera_discount_tier.json b/commera/commera_ecommerce/doctype/commera_discount_tier/commera_discount_tier.json new file mode 100644 index 00000000..10d855da --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_discount_tier/commera_discount_tier.json @@ -0,0 +1,40 @@ +{ + "actions": [], + "creation": "2026-10-11 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "minimum_subtotal", + "value" + ], + "fields": [ + { + "fieldname": "minimum_subtotal", + "fieldtype": "Currency", + "label": "Subtotal From", + "reqd": 1, + "in_list_view": 1, + "non_negative": 1 + }, + { + "fieldname": "value", + "fieldtype": "Float", + "label": "Value", + "reqd": 1, + "in_list_view": 1, + "non_negative": 1 + } + ], + "links": [], + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": "Commera Ecommerce", + "name": "Commera Discount Tier", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "istable": 1, + "editable_grid": 1 +} diff --git a/commera/commera_ecommerce/doctype/commera_discount_tier/commera_discount_tier.py b/commera/commera_ecommerce/doctype/commera_discount_tier/commera_discount_tier.py new file mode 100644 index 00000000..38443ef7 --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_discount_tier/commera_discount_tier.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class CommeraDiscountTier(Document): + pass diff --git a/commera/commera_ecommerce/doctype/commera_discount_use/__init__.py b/commera/commera_ecommerce/doctype/commera_discount_use/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/commera_ecommerce/doctype/commera_discount_use/commera_discount_use.json b/commera/commera_ecommerce/doctype/commera_discount_use/commera_discount_use.json new file mode 100644 index 00000000..f1ff949a --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_discount_use/commera_discount_use.json @@ -0,0 +1,101 @@ +{ + "actions": [], + "creation": "2026-10-11 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "discount", + "code", + "sales_order", + "amount", + "order_total", + "column_break_use", + "customer", + "email", + "phone" + ], + "fields": [ + { + "fieldname": "discount", + "fieldtype": "Link", + "label": "Discount", + "options": "Commera Discount", + "reqd": 1, + "in_list_view": 1, + "search_index": 1 + }, + { + "fieldname": "code", + "fieldtype": "Link", + "label": "Code", + "options": "Commera Discount Code", + "in_list_view": 1 + }, + { + "fieldname": "sales_order", + "fieldtype": "Link", + "label": "Sales Order", + "options": "Sales Order", + "reqd": 1, + "in_list_view": 1, + "search_index": 1 + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "label": "Discount Amount", + "in_list_view": 1 + }, + { + "fieldname": "order_total", + "fieldtype": "Currency", + "label": "Order Total" + }, + { + "fieldname": "column_break_use", + "fieldtype": "Column Break" + }, + { + "fieldname": "customer", + "fieldtype": "Link", + "label": "Customer", + "options": "Customer", + "search_index": 1 + }, + { + "fieldname": "email", + "fieldtype": "Data", + "label": "Email", + "options": "Email", + "search_index": 1 + }, + { + "fieldname": "phone", + "fieldtype": "Data", + "label": "Phone", + "search_index": 1 + } + ], + "links": [], + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": "Commera Ecommerce", + "name": "Commera Discount Use", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "read": 1, + "role": "System Manager", + "write": 1, + "export": 1, + "report": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "in_create": 1, + "read_only": 1 +} diff --git a/commera/commera_ecommerce/doctype/commera_discount_use/commera_discount_use.py b/commera/commera_ecommerce/doctype/commera_discount_use/commera_discount_use.py new file mode 100644 index 00000000..85388ad5 --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_discount_use/commera_discount_use.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class CommeraDiscountUse(Document): + pass diff --git a/commera/discount_codes.py b/commera/discount_codes.py deleted file mode 100644 index 79faaa69..00000000 --- a/commera/discount_codes.py +++ /dev/null @@ -1,120 +0,0 @@ -import frappe -from frappe import _ -from frappe.utils.data import cstr, flt - -from commera.commera_ecommerce.doctype.commera_discount.commera_discount import ( - ORDER, - PRODUCT, - CommeraDiscount, - DiscountCodeError, - get_code_name, -) - -MAX_DISCOUNT_CODES = 5 - - -def get_discount_codes(doc) -> list[str]: - """The Commera Discount codes a cart, Quotation or Sales Order holds, in the order they were applied.""" - fieldname = "discount_codes" if doc.doctype == "Commera Cart" else "custom_discount_codes" - return [code for code in cstr(doc.get(fieldname)).splitlines() if code.strip()] - - -def get_discount(code: str) -> CommeraDiscount: - return frappe.get_cached_doc("Commera Discount", code) - - -def validate_new_discount_code(code: str, codes: list[str]) -> str: - """Check that a shopper's code can join the codes already applied, and return its stored name.""" - code = get_code_name(code) - if not frappe.db.exists("Commera Discount", code): - frappe.throw(_("{0} is not a valid discount code.").format(code), DiscountCodeError) - if code in codes: - frappe.throw(_("{0} is already applied.").format(code), DiscountCodeError) - if len(codes) >= MAX_DISCOUNT_CODES: - frappe.throw( - _("A cart holds at most {0} discount codes.").format(MAX_DISCOUNT_CODES), DiscountCodeError - ) - - discount = get_discount(code) - discount.validate_usable() - for applied_code in codes: - if not discount.can_combine_with(get_discount(applied_code)): - frappe.throw(_("{0} cannot be combined with {1}.").format(code, applied_code), DiscountCodeError) - return code - - -def remove_code_discounts(doc, method=None): - """Take the code discounts off before ERPNext prices the document again, so they never apply twice. - Clamped at zero: other code may already have reset these fields.""" - for item in doc.items: - if flt(item.get("custom_code_discount")): - item.discount_amount = max(flt(item.discount_amount) - flt(item.custom_code_discount), 0) - item.rate = (flt(item.rate_with_margin) or flt(item.price_list_rate)) - item.discount_amount - item.custom_code_discount = 0 - item.custom_discount_code = None - - if flt(doc.get("custom_code_order_discount")): - doc.discount_amount = max(flt(doc.discount_amount) - flt(doc.custom_code_order_discount), 0) - doc.custom_code_order_discount = 0 - - -def apply_code_discounts(doc, method=None): - """Add the discount codes on top of ERPNext's price: product codes per line, then order codes on the - discounted subtotal. The best product code wins on each line.""" - discounts = [get_discount(code) for code in get_discount_codes(doc)] - discounts = [discount for discount in discounts if discount.is_for_currency(doc.currency)] - if not discounts: - return - - product_discounts = [discount for discount in discounts if discount.discount_class == PRODUCT] - for item in doc.items: - apply_line_discount(item, product_discounts) - doc.calculate_taxes_and_totals() - - apply_order_discount(doc, [discount for discount in discounts if discount.discount_class == ORDER]) - doc.calculate_taxes_and_totals() - - -def apply_line_discount(item, product_discounts: list[CommeraDiscount]): - rate = flt(item.rate) - best_code, best_discount = None, 0.0 - for discount in product_discounts: - unit_discount = discount.get_unit_discount(rate) if discount.is_for_line(item) else 0 - if unit_discount > best_discount: - best_code, best_discount = discount.name, unit_discount - if not best_code: - return - - best_discount = flt(best_discount, item.precision("discount_amount")) - # ERPNext rebuilds a percentage discount from the price list, which would drop the code's share. - item.discount_percentage = 0 - item.discount_amount = flt(item.discount_amount) + best_discount - item.rate = rate - best_discount - item.custom_code_discount = best_discount - item.custom_discount_code = best_code - - -def apply_order_discount(doc, order_discounts: list[CommeraDiscount]): - subtotal = flt(doc.total) - amount = sum(discount.get_order_discount(subtotal) for discount in order_discounts) - amount = flt(min(amount, subtotal), doc.precision("discount_amount")) - if not amount: - return - - # Like Shopify, an order code comes off the subtotal, before tax and shipping. - if not flt(doc.discount_amount): - doc.apply_discount_on = "Net Total" - # ERPNext's own percentage discount is kept as an amount, so the code's share can be added to it. - doc.discount_amount = flt(doc.discount_amount) + amount - doc.additional_discount_percentage = 0 - doc.custom_code_order_discount = amount - - -def count_code_uses(doc, method=None): - for code in get_discount_codes(doc): - get_discount(code).count_use(1) - - -def uncount_code_uses(doc, method=None): - for code in get_discount_codes(doc): - get_discount(code).count_use(-1) diff --git a/commera/discounts/__init__.py b/commera/discounts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/discounts/allocations.py b/commera/discounts/allocations.py new file mode 100644 index 00000000..68167d8a --- /dev/null +++ b/commera/discounts/allocations.py @@ -0,0 +1,23 @@ +import json + +from frappe.utils.data import flt + +ALLOCATIONS_FIELD = "custom_discount_allocations" + + +def get_allocations(doc) -> list[dict]: + """Each discount on a document: {discount, code, discount_class, amount}. Written only by pricing.""" + return json.loads(doc.get(ALLOCATIONS_FIELD) or "[]") + + +def set_allocations(doc, allocations: list[dict]): + doc.set(ALLOCATIONS_FIELD, json.dumps(allocations) if allocations else None) + + +def get_allocation(selection, discount_class: str, amount: float) -> dict: + return { + "discount": selection.discount.name, + "code": selection.code, + "discount_class": discount_class, + "amount": flt(amount, 2), + } diff --git a/commera/discounts/cart_context.py b/commera/discounts/cart_context.py new file mode 100644 index 00000000..d65246b5 --- /dev/null +++ b/commera/discounts/cart_context.py @@ -0,0 +1,66 @@ +from dataclasses import dataclass, field + +import frappe +from frappe.utils.data import cint, cstr, flt + +from commera.api.cart import get_cart_price + + +@dataclass +class CartContext: + """What a discount's conditions read about a cart, Sales Order or Sales Invoice.""" + + doc: object + payment_kind: str + customer: str | None + customer_group: str | None + email: str | None + phone: str | None + sale_by_item_code: dict = field(default_factory=dict) + + @property + def items(self) -> list: + return self.doc.items + + @property + def currency(self) -> str: + return self.doc.currency + + @property + def subtotal(self) -> float: + return sum(flt(item.amount) for item in self.items) + + @property + def quantity(self) -> int: + return sum(cint(item.qty) for item in self.items) + + def is_on_sale(self, item_code: str) -> bool: + if item_code not in self.sale_by_item_code: + settings = frappe.get_cached_doc("Commera Settings") + regular_price, price = get_cart_price( + item_code, settings.get_default_price_list(), settings.get_sale_price_list() + ) + self.sale_by_item_code[item_code] = flt(price) < flt(regular_price) + return self.sale_by_item_code[item_code] + + +def get_cart_context(doc) -> CartContext: + customer = doc.get("customer") or (doc.party_name if doc.get("quotation_to") == "Customer" else None) + return CartContext( + doc=doc, + payment_kind=get_payment_kind(doc), + customer=customer, + customer_group=frappe.get_cached_value("Customer", customer, "customer_group") if customer else None, + email=cstr(doc.get("contact_email")).strip().lower() or None, + phone=doc.get("contact_mobile") or doc.get("contact_phone") or None, + ) + + +def get_payment_kind(doc) -> str: + """A cart is priced as prepaid until checkout says otherwise; an order knows how it is paid.""" + # Imported here: commera.api.payments imports the cart, which imports this module. + from commera.api.payments import is_cod + + if doc.flags.commera_payment_kind: + return doc.flags.commera_payment_kind + return "cod" if is_cod(doc.get("custom_ecommerce_payment_mode")) else "prepaid" diff --git a/commera/discounts/codes.py b/commera/discounts/codes.py new file mode 100644 index 00000000..b2483ae6 --- /dev/null +++ b/commera/discounts/codes.py @@ -0,0 +1,44 @@ +import secrets + +import frappe +from frappe import _ +from frappe.utils.data import cint, cstr + +MAX_GENERATED_CODES = 5000 +# No 0/O or 1/I: a code read off a receipt must not be mistyped. +CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + + +def normalize_code(code: str | None) -> str: + return cstr(code).strip().upper() + + +def get_code_discount(code: str) -> str | None: + return frappe.get_cached_value("Commera Discount Code", normalize_code(code), "discount") + + +def add_code(discount: str, code: str, is_generated: bool = False) -> str: + code = normalize_code(code) + if not code or len(code) > 64 or any(character.isspace() for character in code): + frappe.throw(_("A code needs 1 to 64 characters and no spaces.")) + if frappe.db.exists("Commera Discount Code", code): + frappe.throw(_("The code {0} is already in use.").format(code), frappe.DuplicateEntryError) + frappe.get_doc( + {"doctype": "Commera Discount Code", "code": code, "discount": discount, "is_generated": is_generated} + ).insert(ignore_permissions=True) + return code + + +def generate_codes(discount: str, count: int, prefix: str = "") -> list[str]: + """Unique single-use codes that share one discount's rules, such as one per influencer or receipt.""" + count = cint(count) + if not 0 < count <= MAX_GENERATED_CODES: + frappe.throw(_("Generate between 1 and {0} codes at a time.").format(MAX_GENERATED_CODES)) + + prefix = normalize_code(prefix) + codes = [] + while len(codes) < count: + code = prefix + "".join(secrets.choice(CODE_ALPHABET) for _index in range(8)) + if not frappe.db.exists("Commera Discount Code", code): + codes.append(add_code(discount, code, is_generated=True)) + return codes diff --git a/commera/discounts/pricing.py b/commera/discounts/pricing.py new file mode 100644 index 00000000..63f977b2 --- /dev/null +++ b/commera/discounts/pricing.py @@ -0,0 +1,139 @@ +from frappe.utils.data import flt + +from commera.commera_ecommerce.doctype.commera_discount.commera_discount import ORDER, PRODUCT, SHIPPING +from commera.discounts.allocations import get_allocation, get_allocations, set_allocations +from commera.discounts.cart_context import get_cart_context +from commera.discounts.codes import get_code_discount +from commera.discounts.selection import Selection, get_discount, get_discount_codes, select_discounts + + +def is_storefront_cart(doc) -> bool: + return doc.doctype == "Quotation" and doc.order_type == "Shopping Cart" + + +def remove_code_discounts(doc, method=None): + """Take the code discounts off before ERPNext prices the document again, so they never apply twice. + Clamped at zero: other code may already have reset these fields.""" + for item in doc.items: + if flt(item.get("custom_code_discount")): + item.discount_amount = max(flt(item.discount_amount) - flt(item.custom_code_discount), 0) + item.rate = (flt(item.rate_with_margin) or flt(item.price_list_rate)) - item.discount_amount + item.custom_code_discount = 0 + item.custom_discount_code = None + + if flt(doc.get("custom_code_order_discount")): + doc.discount_amount = max(flt(doc.discount_amount) - flt(doc.custom_code_order_discount), 0) + doc.custom_code_order_discount = 0 + + +def apply_code_discounts(doc, method=None): + """Add the discounts on top of ERPNext's price: product discounts per line (the best one wins on a + line), then order discounts on the discounted subtotal. A cart chooses its discounts again on every + save; an order or invoice keeps the ones its cart had.""" + selections = get_selections(doc) + allocations = [] + if selections: + allocations += apply_line_discounts( + doc, [s for s in selections if s.discount.discount_class == PRODUCT] + ) + doc.calculate_taxes_and_totals() + allocations += apply_order_discount( + doc, [s for s in selections if s.discount.discount_class == ORDER] + ) + doc.calculate_taxes_and_totals() + allocations += get_shipping_allocations(doc) + set_allocations(doc, allocations) + + +def get_selections(doc) -> list[Selection]: + cart = get_cart_context(doc) + if is_storefront_cart(doc) or doc.flags.commera_discounts is not None: + return select_discounts(cart) + recorded = { + (row["discount"], row.get("code")) + for row in get_allocations(doc) + if row["discount_class"] != SHIPPING + } + return [Selection(get_discount(discount), code) for discount, code in recorded] + + +def apply_line_discounts(doc, selections: list) -> list[dict]: + cart = get_cart_context(doc) + best_by_row = {} + for selection in selections: + line_discounts = cap_by_budget(selection.discount, selection.discount.get_line_discounts(cart)) + for idx, amount in line_discounts.items(): + if amount > best_by_row.get(idx, (0, None))[0]: + best_by_row[idx] = (amount, selection) + + allocations = [] + for item in doc.items: + if item.idx not in best_by_row: + continue + amount, selection = best_by_row[item.idx] + unit_discount = flt(min(amount / flt(item.qty), flt(item.rate)), item.precision("discount_amount")) + # ERPNext rebuilds a percentage discount from the price list, which would drop this share. + item.discount_percentage = 0 + item.discount_amount = flt(item.discount_amount) + unit_discount + item.rate = flt(item.rate) - unit_discount + item.custom_code_discount = unit_discount + item.custom_discount_code = selection.discount.name + allocations.append(get_allocation(selection, PRODUCT, unit_discount * flt(item.qty))) + return allocations + + +def apply_order_discount(doc, selections: list) -> list[dict]: + cart = get_cart_context(doc) + allocations = [] + subtotal = flt(doc.total) + for selection in selections: + amount = flt(selection.discount.get_order_discount(cart)) + budget_left = selection.discount.budget_left + amount = min(amount, budget_left) if budget_left is not None else amount + amount = flt( + min(amount, subtotal - sum(row["amount"] for row in allocations)), + doc.precision("discount_amount"), + ) + if amount > 0: + allocations.append(get_allocation(selection, ORDER, amount)) + + total = sum(row["amount"] for row in allocations) + if total: + # Like Shopify, an order discount comes off the subtotal, before tax and shipping. + if not flt(doc.discount_amount): + doc.apply_discount_on = "Net Total" + # ERPNext's own percentage discount is kept as an amount, so this share can be added to it. + doc.discount_amount = flt(doc.discount_amount) + total + doc.additional_discount_percentage = 0 + doc.custom_code_order_discount = total + return allocations + + +def get_shipping_allocations(doc) -> list[dict]: + discount = doc.get("custom_shipping_discount_id") + if not discount or not flt(doc.get("custom_shipping_discount")): + return [] + code = next((code for code in get_discount_codes(doc) if get_code_discount(code) == discount), None) + return [ + { + "discount": discount, + "code": code, + "discount_class": SHIPPING, + "amount": flt(doc.custom_shipping_discount), + } + ] + + +def cap_by_budget(discount, line_discounts: dict[int, float]) -> dict[int, float]: + budget_left = discount.budget_left + total = sum(line_discounts.values()) + if budget_left is None or total <= budget_left: + return line_discounts + return {idx: amount * budget_left / total for idx, amount in line_discounts.items()} if total else {} + + +def refresh_code_discounts(doc): + """Price an unsaved document again, such as the checkout's cash on delivery preview.""" + remove_code_discounts(doc) + doc.calculate_taxes_and_totals() + apply_code_discounts(doc) diff --git a/commera/discounts/selection.py b/commera/discounts/selection.py new file mode 100644 index 00000000..29f78649 --- /dev/null +++ b/commera/discounts/selection.py @@ -0,0 +1,104 @@ +from dataclasses import dataclass + +import frappe +from frappe import _ +from frappe.utils.data import cstr + +from commera.commera_ecommerce.doctype.commera_discount.commera_discount import ( + CommeraDiscount, + DiscountCodeError, +) +from commera.discounts.codes import get_code_discount, normalize_code +from commera.discounts.usage import has_used_before, is_first_order + +MAX_DISCOUNT_CODES = 5 + + +@dataclass +class Selection: + discount: CommeraDiscount + code: str | None = None + + +def get_discount_codes(doc) -> list[str]: + """The codes a cart, Quotation or Sales Order holds, in the order they were applied.""" + fieldname = "discount_codes" if doc.doctype == "Commera Cart" else "custom_discount_codes" + return [code for code in cstr(doc.get(fieldname)).splitlines() if code.strip()] + + +def get_discount(name: str) -> CommeraDiscount: + return frappe.get_cached_doc("Commera Discount", name) + + +def validate_new_discount_code(code: str, codes: list[str]) -> str: + """Check that a shopper's code can join the codes already applied, and return it as stored.""" + code = normalize_code(code) + discount_name = get_code_discount(code) + if not discount_name: + frappe.throw(_("{0} is not a valid discount code.").format(code), DiscountCodeError) + if code in codes: + frappe.throw(_("{0} is already applied.").format(code), DiscountCodeError) + if len(codes) >= MAX_DISCOUNT_CODES: + frappe.throw( + _("A cart holds at most {0} discount codes.").format(MAX_DISCOUNT_CODES), DiscountCodeError + ) + + if is_single_use_spent(code): + frappe.throw(_("{0} has already been used.").format(code), DiscountCodeError) + discount = get_discount(discount_name) + discount.validate_usable() + for applied_code in codes: + applied = get_discount(get_code_discount(applied_code)) + if not discount.can_combine_with(applied): + frappe.throw(_("{0} cannot be combined with {1}.").format(code, applied_code), DiscountCodeError) + return code + + +def is_single_use_spent(code: str) -> bool: + is_generated, times_used = frappe.get_cached_value( + "Commera Discount Code", code, ["is_generated", "times_used"] + ) + return bool(is_generated and times_used) + + +def select_discounts(cart) -> list[Selection]: + """The shopper's codes first, then automatic discounts, each kept while it applies and combines.""" + selected = [] + for selection in get_candidates(cart.doc): + discount = selection.discount + if discount.status != "Active" or not discount.is_for_currency(cart.currency): + continue + if get_unmet_reason(discount, cart): + continue + if all(discount.can_combine_with(other.discount) for other in selected): + selected.append(selection) + return selected + + +def get_candidates(doc) -> list[Selection]: + if doc.flags.commera_discounts is not None: + return [Selection(discount) for discount in doc.flags.commera_discounts] + + candidates = [] + for code in get_discount_codes(doc): + discount_name = get_code_discount(code) + if discount_name and not is_single_use_spent(code): + candidates.append(Selection(get_discount(discount_name), code)) + automatic = frappe.get_all( + "Commera Discount", filters={"enabled": 1, "method": "Automatic"}, pluck="name", order_by="creation" + ) + return candidates + [Selection(get_discount(name)) for name in automatic] + + +def get_unmet_reason(discount: CommeraDiscount, cart) -> str | None: + if reason := discount.get_unmet_condition(cart): + return reason + if ( + discount.eligibility == "First Order" + and not cart.doc.flags.commera_first_order + and not is_first_order(cart) + ): + return _("{0} is for a first order only.").format(discount.title) + if discount.once_per_customer and has_used_before(discount.name, cart): + return _("You have already used {0}.").format(discount.title) + return None diff --git a/commera/discounts/shipping.py b/commera/discounts/shipping.py new file mode 100644 index 00000000..2146ceab --- /dev/null +++ b/commera/discounts/shipping.py @@ -0,0 +1,38 @@ +from frappe.utils.data import flt + +from commera.commera_ecommerce.doctype.commera_discount.commera_discount import SHIPPING +from commera.discounts.cart_context import get_cart_context +from commera.discounts.selection import select_discounts + + +def apply_shipping_discounts(quotation, options: list[dict]) -> list[dict]: + """Discount each delivery option the cart's shipping discounts cover. Runs after the plugin hooks, so + the price shown and the price charged at payment are the same.""" + selections = [ + s for s in select_discounts(get_cart_context(quotation)) if s.discount.discount_class == SHIPPING + ] + if not selections: + return options + return [get_discounted_option(option, selections) for option in options] + + +def get_discounted_option(option: dict, selections: list) -> dict: + best_amount, best_discount = max( + (s.discount.get_delivery_discount(option), s.discount.name) for s in selections + ) + if not best_amount: + return option + option = dict(option) + option["original_amount"] = option["amount"] + option["amount"] = flt(option["amount"] - best_amount, 2) + option["is_free"] = not option["amount"] + option["shipping_discount"] = best_discount + return option + + +def set_shipping_discount(quotation, option: dict | None): + """Record the chosen option's shipping discount; pricing reports it with the other discounts.""" + option = option or {} + original_amount = flt(option.get("original_amount")) + quotation.custom_shipping_discount = original_amount - flt(option.get("amount")) if original_amount else 0 + quotation.custom_shipping_discount_id = option.get("shipping_discount") diff --git a/commera/discounts/usage.py b/commera/discounts/usage.py new file mode 100644 index 00000000..7551ad80 --- /dev/null +++ b/commera/discounts/usage.py @@ -0,0 +1,93 @@ +import frappe +from frappe.query_builder.functions import Count, Sum +from frappe.utils.data import flt + +from commera.discounts.allocations import get_allocations + + +def has_used_before(discount: str, cart) -> bool: + identities = {"customer": cart.customer, "email": cart.email, "phone": cart.phone} + or_filters = {field: value for field, value in identities.items() if value} + if not or_filters: + return False + return bool( + frappe.get_all("Commera Discount Use", filters={"discount": discount}, or_filters=or_filters, limit=1) + ) + + +def is_first_order(cart) -> bool: + """No earlier store order for this customer or email. Draft orders count: cash on delivery stays draft.""" + sales_order = frappe.qb.DocType("Sales Order") + identity = None + if cart.customer: + identity = sales_order.customer == cart.customer + if cart.email: + by_email = sales_order.contact_email == cart.email + identity = by_email if identity is None else identity | by_email + if identity is None: + return True + query = ( + frappe.qb.from_(sales_order) + .select(sales_order.name) + .where(identity & (sales_order.docstatus < 2) & (sales_order.order_type == "Shopping Cart")) + .limit(1) + ) + if cart.doc.doctype == "Sales Order" and cart.doc.name: + query = query.where(sales_order.name != cart.doc.name) + return not query.run() + + +def record_discount_uses(sales_order, method=None): + """Log each discount a placed order used. Counts, budgets and once-per-customer read this log.""" + amounts = {} + for allocation in get_allocations(sales_order): + key = (allocation["discount"], allocation.get("code")) + amounts[key] = amounts.get(key, 0) + flt(allocation["amount"]) + + for (discount, code), amount in amounts.items(): + frappe.get_doc( + { + "doctype": "Commera Discount Use", + "discount": discount, + "code": code, + "sales_order": sales_order.name, + "amount": amount, + "order_total": flt(sales_order.grand_total), + "customer": sales_order.customer, + "email": (sales_order.contact_email or "").lower() or None, + "phone": sales_order.contact_mobile or sales_order.contact_phone or None, + } + ).insert(ignore_permissions=True) + refresh_usage({discount for discount, _code in amounts}, {code for _discount, code in amounts if code}) + + +def remove_discount_uses(sales_order, method=None): + uses = frappe.get_all( + "Commera Discount Use", filters={"sales_order": sales_order.name}, fields=["name", "discount", "code"] + ) + for use in uses: + frappe.delete_doc("Commera Discount Use", use.name, ignore_permissions=True, force=True) + refresh_usage({use.discount for use in uses}, {use.code for use in uses if use.code}) + + +def refresh_usage(discounts: set[str], codes: set[str]): + """Recount from the log, under row locks, so two orders placed at once cannot lose a count.""" + use = frappe.qb.DocType("Commera Discount Use") + for discount in discounts: + frappe.db.get_value("Commera Discount", discount, "name", for_update=True) + times_used, discount_given, sales = ( + frappe.qb.from_(use) + .select(Count(use.name), Sum(use.amount), Sum(use.order_total)) + .where(use.discount == discount) + ).run()[0] + frappe.db.set_value( + "Commera Discount", + discount, + {"times_used": times_used, "discount_given": flt(discount_given), "sales": flt(sales)}, + update_modified=False, + ) + frappe.clear_document_cache("Commera Discount", discount) + for code in codes: + times_used = frappe.db.count("Commera Discount Use", {"code": code}) + frappe.db.set_value("Commera Discount Code", code, "times_used", times_used, update_modified=False) + frappe.clear_document_cache("Commera Discount Code", code) diff --git a/commera/hooks.py b/commera/hooks.py index 7b5b1a3f..680c9923 100644 --- a/commera/hooks.py +++ b/commera/hooks.py @@ -136,21 +136,24 @@ "on_update": "commera.api.payment_hooks.on_payment_request_update", }, "Quotation": { - "before_validate": "commera.discount_codes.remove_code_discounts", - "validate": "commera.discount_codes.apply_code_discounts", + "before_validate": "commera.discounts.pricing.remove_code_discounts", + "validate": "commera.discounts.pricing.apply_code_discounts", }, "Sales Order": { - "before_validate": "commera.discount_codes.remove_code_discounts", - "validate": "commera.discount_codes.apply_code_discounts", - "after_insert": "commera.jobs.send_order_success_acknowledgement", + "before_validate": "commera.discounts.pricing.remove_code_discounts", + "validate": "commera.discounts.pricing.apply_code_discounts", + "after_insert": [ + "commera.discounts.usage.record_discount_uses", + "commera.jobs.send_order_success_acknowledgement", + ], + "on_trash": "commera.discounts.usage.remove_discount_uses", "on_submit": [ - "commera.discount_codes.count_code_uses", "commera.jobs.send_order_success_acknowledgement", "commera.utils.update_so_status_from_related_doc", "commera.plugin_events.on_sales_order_stock_reservation", ], "on_cancel": [ - "commera.discount_codes.uncount_code_uses", + "commera.discounts.usage.remove_discount_uses", "commera.jobs.send_order_cancel_acknowledgement", "commera.utils.update_so_status_from_related_doc", "commera.plugin_events.on_sales_order_cancel", @@ -182,8 +185,8 @@ "on_trash": "commera.search.sync.on_trash", }, "Sales Invoice": { - "before_validate": "commera.discount_codes.remove_code_discounts", - "validate": "commera.discount_codes.apply_code_discounts", + "before_validate": "commera.discounts.pricing.remove_code_discounts", + "validate": "commera.discounts.pricing.apply_code_discounts", "on_submit": "commera.utils.update_so_status_from_related_doc", }, "Payment Entry": {"on_submit": "commera.plugin_events.on_payment_entry_submit"}, diff --git a/commera/tests/test_discount_codes.py b/commera/tests/test_discount_codes.py index 9449d603..62d5bff1 100644 --- a/commera/tests/test_discount_codes.py +++ b/commera/tests/test_discount_codes.py @@ -3,12 +3,12 @@ from frappe.utils.data import add_days, today from commera.api.payments import _make_sales_order, make_sales_invoice +from commera.commera_ecommerce.doctype.commera_discount.commera_discount import DiscountCodeError from commera.core import new_cart_quotation -from commera.discount_codes import ( - MAX_DISCOUNT_CODES, - DiscountCodeError, - validate_new_discount_code, -) +from commera.discounts.codes import add_code, generate_codes, get_code_discount +from commera.discounts.pricing import refresh_code_discounts +from commera.discounts.selection import MAX_DISCOUNT_CODES, validate_new_discount_code +from commera.discounts.shipping import apply_shipping_discounts SHIRT_RATE = 90.0 CAP_RATE = 40.0 @@ -38,6 +38,7 @@ def setUp(self): self.addCleanup(frappe.set_user, "Administrator") self.shirt = self.create_item(SHIRT_RATE) self.cap = self.create_item(CAP_RATE) + self.customer = self.create_customer() def create_item(self, rate: float) -> str: item_code = f"ZZ-CODE-{frappe.generate_hash(length=8)}" @@ -51,63 +52,60 @@ def create_item(self, rate: float) -> str: "is_stock_item": 1, } ).insert(ignore_permissions=True) + self.set_price(item_code, self.price_list, rate) + frappe.get_doc( + {"doctype": "Bin", "item_code": item_code, "warehouse": self.warehouse, "actual_qty": 10} + ).insert(ignore_permissions=True) + return item_code + + def set_price(self, item_code: str, price_list: str, rate: float): frappe.get_doc( { "doctype": "Item Price", "item_code": item_code, - "price_list": self.price_list, + "price_list": price_list, "price_list_rate": rate, } ).insert(ignore_permissions=True) - frappe.get_doc( - {"doctype": "Bin", "item_code": item_code, "warehouse": self.warehouse, "actual_qty": 10} + + def create_customer(self, customer_group: str | None = None): + return frappe.get_doc( + { + "doctype": "Customer", + "customer_name": f"ZZ Code Shopper {frappe.generate_hash(length=6)}", + **({"customer_group": customer_group} if customer_group else {}), + } ).insert(ignore_permissions=True) - return item_code - def create_code( - self, - percentage: float = 0, - amount: float = 0, - item_codes: tuple[str, ...] = (), - **discount_fields, + def create_discount( + self, percentage: float = 0, amount: float = 0, item_codes: tuple[str, ...] = (), **fields ) -> str: - """A Product code on `item_codes` when given, else an Order code.""" - return ( - frappe.get_doc( - { - "doctype": "Commera Discount", - "code": f"zz{frappe.generate_hash(length=8)}", - "discount_class": "Product" if item_codes else "Order", - "value_type": "Percentage" if percentage else "Fixed Amount", - "value": percentage or amount, - "currency": self.currency, - "targets": [{"target_type": "Item", "target": item_code} for item_code in item_codes], - **discount_fields, - } - ) - .insert(ignore_permissions=True) - .name - ) - - def create_automatic_rule(self, item_code: str, percentage: float): - frappe.get_doc( + """Amount off products on `item_codes` when given, else amount off the order.""" + discount = frappe.get_doc( { - "doctype": "Pricing Rule", - "title": f"ZZ Auto {frappe.generate_hash(length=8)}", - "apply_on": "Item Code", - "items": [{"item_code": item_code}], - "rate_or_discount": "Discount Percentage", - "discount_percentage": percentage, - "selling": 1, + "doctype": "Commera Discount", + "title": f"ZZ {frappe.generate_hash(length=6)}", + "discount_type": "Amount Off Products" if item_codes else "Amount Off Order", + "applies_to": "Specific Products" if item_codes else "All Products", + "value_type": "Percentage" if percentage else "Fixed Amount", + "value": percentage or amount, "currency": self.currency, + "targets": [ + {"role": "Applies To", "target_type": "Item", "target": code} for code in item_codes + ], + **fields, } ).insert(ignore_permissions=True) + return discount.name - def create_quotation(self, lines: dict[str, int], codes: tuple[str, ...] = ()): - customer = frappe.get_doc( - {"doctype": "Customer", "customer_name": f"ZZ Code Shopper {frappe.generate_hash(length=6)}"} - ).insert(ignore_permissions=True) - quotation = new_cart_quotation(customer, "zz-code-shopper@example.com") + def create_code(self, percentage: float = 0, amount: float = 0, item_codes=(), **fields) -> str: + discount = self.create_discount(percentage, amount, item_codes, **fields) + return add_code(discount, f"zz{frappe.generate_hash(length=8)}") + + def create_quotation(self, lines: dict[str, int], codes=(), customer=None, order_type="Shopping Cart"): + customer = customer or self.customer + quotation = new_cart_quotation(customer, f"{customer.name.lower().replace(' ', '-')}@example.com") + quotation.order_type = order_type quotation.selling_price_list = self.price_list for item_code, qty in lines.items(): quotation.append("items", {"item_code": item_code, "qty": qty, "warehouse": self.warehouse}) @@ -115,9 +113,30 @@ def create_quotation(self, lines: dict[str, int], codes: tuple[str, ...] = ()): quotation.insert(ignore_permissions=True) return quotation + def place_order(self, quotation): + quotation.submit() + sales_order = _make_sales_order(quotation.name, ignore_permissions=True) + sales_order.delivery_date = add_days(today(), 2) + sales_order.insert(ignore_permissions=True) + return sales_order + def get_line(self, doc, item_code: str): return next(row for row in doc.items if row.item_code == item_code) + def create_automatic_rule(self, item_code: str, percentage: float): + frappe.get_doc( + { + "doctype": "Pricing Rule", + "title": f"ZZ Auto {frappe.generate_hash(length=8)}", + "apply_on": "Item Code", + "items": [{"item_code": item_code}], + "rate_or_discount": "Discount Percentage", + "discount_percentage": percentage, + "selling": 1, + "currency": self.currency, + } + ).insert(ignore_permissions=True) + class TestDiscountCodes(DiscountCodeTestCase): def test_a_product_code_discounts_only_its_items(self): @@ -126,9 +145,8 @@ def test_a_product_code_discounts_only_its_items(self): quotation = self.create_quotation({self.shirt: 2, self.cap: 1}, (code,)) shirt = self.get_line(quotation, self.shirt) - self.assertEqual(shirt.rate, 81) - self.assertEqual(shirt.custom_code_discount, 9) - self.assertEqual(shirt.custom_discount_code, code) + self.assertEqual((shirt.rate, shirt.custom_code_discount), (81, 9)) + self.assertEqual(shirt.custom_discount_code, get_code_discount(code)) self.assertEqual(self.get_line(quotation, self.cap).rate, CAP_RATE) self.assertEqual(quotation.net_total, 2 * 81 + CAP_RATE) @@ -140,7 +158,7 @@ def test_the_best_product_code_wins_on_a_line(self): shirt = self.get_line(quotation, self.shirt) self.assertEqual(shirt.rate, 70) - self.assertEqual(shirt.custom_discount_code, large) + self.assertEqual(shirt.custom_discount_code, get_code_discount(large)) def test_an_order_code_comes_off_the_subtotal_after_product_codes(self): product = self.create_code(amount=10, item_codes=(self.shirt,), combines_with_order=1) @@ -172,18 +190,88 @@ def test_a_code_stacks_on_an_automatic_pricing_rule_across_saves(self): quotation.save(ignore_permissions=True) shirt = self.get_line(quotation, self.shirt) - self.assertEqual(shirt.rate, 90 - 9 - 5) - self.assertEqual(shirt.discount_amount, 14) + self.assertEqual((shirt.rate, shirt.discount_amount), (90 - 9 - 5, 14)) + + def test_a_percentage_is_capped_for_the_whole_order(self): + code = self.create_code(percentage=10, maximum_discount=5) + + quotation = self.create_quotation({self.shirt: 1, self.cap: 1}, (code,)) + + self.assertEqual(quotation.custom_code_order_discount, 5) + def test_the_best_spend_tier_reached_applies(self): + code = self.create_code( + percentage=1, + tiered=1, + tiers=[{"minimum_subtotal": 100, "value": 10}, {"minimum_subtotal": 200, "value": 20}], + ) + + reached = self.create_quotation({self.shirt: 1, self.cap: 1}, (code,)) + missed = self.create_quotation({self.cap: 2}, (code,)) + + self.assertEqual(reached.custom_code_order_discount, 13) + self.assertFalse(missed.custom_code_order_discount) + + def test_items_already_on_sale_get_no_extra_discount(self): + settings = frappe.get_cached_doc("Commera Settings") + self.set_price(self.shirt, settings.get_default_price_list(), 200) + self.set_price(self.shirt, settings.get_sale_price_list(), 100) + code = self.create_code(percentage=10, item_codes=(self.shirt, self.cap), exclude_sale_items=1) + + quotation = self.create_quotation({self.shirt: 1, self.cap: 1}, (code,)) + + self.assertEqual(self.get_line(quotation, self.shirt).rate, SHIRT_RATE) + self.assertEqual(self.get_line(quotation, self.cap).rate, 36) + + def test_buy_x_get_y_discounts_the_cheapest_rewarded_units(self): + code = self.create_code( + discount_type="Buy X Get Y", + buy_value=2, + get_quantity=1, + uses_per_order=1, + targets=[ + {"role": "Buys", "target_type": "Item", "target": self.shirt}, + {"role": "Gets", "target_type": "Item", "target": self.cap}, + ], + ) + + quotation = self.create_quotation({self.shirt: 2, self.cap: 3}, (code,)) + + self.assertAlmostEqual(self.get_line(quotation, self.cap).amount, 2 * CAP_RATE, places=1) + self.assertEqual(self.get_line(quotation, self.shirt).rate, SHIRT_RATE) + + def test_buy_x_get_y_needs_the_bought_units(self): + code = self.create_code( + discount_type="Buy X Get Y", + buy_value=2, + get_quantity=1, + targets=[ + {"role": "Buys", "target_type": "Item", "target": self.shirt}, + {"role": "Gets", "target_type": "Item", "target": self.cap}, + ], + ) + + quotation = self.create_quotation({self.shirt: 1, self.cap: 1}, (code,)) + + self.assertEqual(self.get_line(quotation, self.cap).rate, CAP_RATE) + + def test_a_budget_caps_the_discount(self): + code = self.create_code(percentage=50, item_codes=(self.shirt,), budget=20) + + quotation = self.create_quotation({self.shirt: 1}, (code,)) + + self.assertEqual(self.get_line(quotation, self.shirt).rate, SHIRT_RATE - 20) + + +class TestDiscountSelection(DiscountCodeTestCase): def test_codes_combine_only_when_both_allow_it(self): product = self.create_code(percentage=10, item_codes=(self.shirt,), combines_with_order=1) strict_order = self.create_code(percentage=5) open_order = self.create_code(percentage=5, combines_with_product=1) - applied = [product] with self.assertRaises(DiscountCodeError): - validate_new_discount_code(strict_order, applied) - self.assertEqual(validate_new_discount_code(open_order, applied), open_order) + validate_new_discount_code(strict_order, [product]) + self.assertEqual(validate_new_discount_code(open_order, [product]), open_order) def test_a_cart_holds_a_limited_number_of_codes(self): codes = [ @@ -194,7 +282,7 @@ def test_a_cart_holds_a_limited_number_of_codes(self): validate_new_discount_code(self.create_code(percentage=1, combines_with_order=1), codes) def test_an_expired_code_is_refused(self): - code = self.create_code(percentage=10, valid_upto=add_days(today(), -1)) + code = self.create_code(percentage=10, starts_on=add_days(today(), -9), ends_on=add_days(today(), -1)) with self.assertRaises(DiscountCodeError): validate_new_discount_code(code, []) @@ -204,36 +292,110 @@ def test_codes_are_matched_without_case(self): self.assertEqual(validate_new_discount_code(f" {code.lower()} ", []), code) + def test_an_automatic_discount_applies_to_store_carts_only(self): + self.create_discount(percentage=10, method="Automatic") + + cart = self.create_quotation({self.shirt: 1}) + desk_quotation = self.create_quotation({self.shirt: 1}, order_type="Sales") + + self.assertEqual(cart.custom_code_order_discount, 9) + self.assertFalse(desk_quotation.custom_code_order_discount) + + def test_a_prepaid_discount_drops_off_for_cash_on_delivery(self): + code = self.create_code(percentage=10, payment_condition="Prepaid") + quotation = self.create_quotation({self.shirt: 1}, (code,)) + self.assertEqual(quotation.custom_code_order_discount, 9) + + quotation.flags.commera_payment_kind = "cod" + refresh_code_discounts(quotation) + + self.assertFalse(quotation.custom_code_order_discount) + + def test_a_customer_group_discount_skips_other_groups(self): + group = frappe.get_doc( + {"doctype": "Customer Group", "customer_group_name": f"ZZ VIP {frappe.generate_hash(length=6)}"} + ).insert(ignore_permissions=True) + code = self.create_code( + percentage=10, eligibility="Customer Groups", customer_groups=[{"customer_group": group.name}] + ) + + member = self.create_quotation({self.shirt: 1}, (code,), customer=self.create_customer(group.name)) + other = self.create_quotation({self.shirt: 1}, (code,)) + + self.assertEqual(member.custom_code_order_discount, 9) + self.assertFalse(other.custom_code_order_discount) + + def test_a_first_order_discount_skips_a_returning_customer(self): + code = self.create_code(percentage=10, eligibility="First Order") + first = self.create_quotation({self.shirt: 1}, (code,)) + self.assertEqual(first.custom_code_order_discount, 9) + self.place_order(first) + + second = self.create_quotation({self.shirt: 1}, (code,)) + + self.assertFalse(second.custom_code_order_discount) -class TestDiscountCodesOnOrders(DiscountCodeTestCase): - def test_the_sales_order_keeps_code_discounts_and_counts_uses(self): + def test_a_once_per_customer_code_is_not_applied_twice(self): + code = self.create_code(percentage=10, once_per_customer=1) + self.place_order(self.create_quotation({self.shirt: 1}, (code,))) + + second = self.create_quotation({self.shirt: 1}, (code,)) + + self.assertFalse(second.custom_code_order_discount) + + def test_a_generated_code_is_used_once(self): + discount = self.create_discount(percentage=10) + first_code, second_code = generate_codes(discount, 2, prefix="zz-") + self.place_order(self.create_quotation({self.shirt: 1}, (first_code,))) + + with self.assertRaises(DiscountCodeError): + validate_new_discount_code(first_code, []) + self.assertEqual(validate_new_discount_code(second_code, []), second_code) + self.assertTrue(first_code.startswith("ZZ-")) + + def test_a_shipping_discount_frees_cheap_delivery_options(self): + self.create_discount( + discount_type="Free Shipping", + method="Automatic", + minimum_requirement="Subtotal", + minimum_subtotal=50, + maximum_shipping_rate=100, + combines_with_product=1, + combines_with_order=1, + ) + quotation = self.create_quotation({self.shirt: 1}) + options = [{"title": "Standard", "amount": 60}, {"title": "Express", "amount": 150}] + + standard, express = apply_shipping_discounts(quotation, options) + + self.assertEqual((standard["amount"], standard["original_amount"]), (0, 60)) + self.assertEqual(express["amount"], 150) + + +class TestDiscountsOnOrders(DiscountCodeTestCase): + def test_the_sales_order_keeps_discounts_and_logs_their_use(self): product = self.create_code(percentage=10, item_codes=(self.shirt,), combines_with_order=1) order = self.create_code(amount=5, combines_with_product=1) quotation = self.create_quotation({self.shirt: 1, self.cap: 1}, (product, order)) - quotation.submit() - sales_order = _make_sales_order(quotation.name, ignore_permissions=True) - sales_order.delivery_date = add_days(today(), 2) - sales_order.insert(ignore_permissions=True) + sales_order = self.place_order(quotation) sales_order.submit() + discount = frappe.get_doc("Commera Discount", get_code_discount(product)) self.assertEqual(self.get_line(sales_order, self.shirt).rate, 81) self.assertEqual(sales_order.custom_code_order_discount, 5) self.assertEqual(sales_order.net_total, quotation.net_total) - self.assertEqual(frappe.db.get_value("Commera Discount", product, "times_used"), 1) + self.assertEqual((discount.times_used, discount.discount_given), (1, 9)) + self.assertEqual(frappe.db.get_value("Commera Discount Code", product, "times_used"), 1) sales_order.cancel() - self.assertEqual(frappe.db.get_value("Commera Discount", product, "times_used"), 0) + self.assertEqual(frappe.db.get_value("Commera Discount", discount.name, "times_used"), 0) - def test_the_invoice_keeps_code_discounts_over_an_automatic_rule(self): + def test_the_invoice_keeps_discounts_over_an_automatic_rule(self): """ERPNext prices the invoice again; with an automatic rule on the line that drops an unhooked code.""" self.create_automatic_rule(self.shirt, percentage=10) code = self.create_code(amount=5, item_codes=(self.shirt,)) - quotation = self.create_quotation({self.shirt: 1}, (code,)) - quotation.submit() - sales_order = _make_sales_order(quotation.name, ignore_permissions=True) - sales_order.delivery_date = add_days(today(), 2) - sales_order.insert(ignore_permissions=True) + sales_order = self.place_order(self.create_quotation({self.shirt: 1}, (code,))) sales_order.submit() sales_invoice = make_sales_invoice(sales_order.name) @@ -241,14 +403,3 @@ def test_the_invoice_keeps_code_discounts_over_an_automatic_rule(self): self.assertEqual(self.get_line(sales_invoice, self.shirt).rate, 90 - 9 - 5) self.assertEqual(sales_invoice.net_total, sales_order.net_total) - - def test_an_order_is_refused_when_its_code_is_used_up(self): - code = self.create_code(percentage=10, usage_limit=1, times_used=1) - quotation = self.create_quotation({self.shirt: 1}, (code,)) - quotation.submit() - sales_order = _make_sales_order(quotation.name, ignore_permissions=True) - sales_order.delivery_date = add_days(today(), 2) - sales_order.insert(ignore_permissions=True) - - with self.assertRaises(DiscountCodeError): - sales_order.submit() diff --git a/commera/tests/test_server_cart.py b/commera/tests/test_server_cart.py index 357cbc75..cd17e99d 100644 --- a/commera/tests/test_server_cart.py +++ b/commera/tests/test_server_cart.py @@ -3,6 +3,7 @@ from commera.api.payments import generate_quotation_for_cart from commera.api.server_cart import sync_server_cart from commera.commera_ecommerce.doctype.commera_cart.commera_cart import get_session_cart +from commera.discounts.codes import get_code_discount from commera.sdk import cart as cart_sdk from commera.tests.test_discount_codes import CAP_RATE, SHIRT_RATE, DiscountCodeTestCase @@ -84,7 +85,9 @@ def test_the_priced_cart_shows_line_and_order_discounts(self): priced = cart_sdk.add_discount_code(order) shirt = next(line for line in priced["lines"] if line["item_code"] == self.shirt) - self.assertEqual((shirt["rate"], shirt["code_discount"], shirt["discount_code"]), (81, 9, product)) + self.assertEqual((shirt["rate"], shirt["code_discount"]), (81, 9)) + self.assertEqual(shirt["discount"], get_code_discount(product)) + self.assertEqual([state["applied"] for state in priced["discount_codes"]], [True, True]) self.assertEqual(priced["subtotal"], 2 * 81 + CAP_RATE) self.assertEqual(priced["order_discount"], 10) self.assertEqual(priced["total"], 2 * 81 + CAP_RATE - 10) @@ -119,6 +122,15 @@ def test_checkout_keeps_an_order_code_through_the_coupon_reset(self): self.assertEqual(quotation.custom_code_order_discount, 10) self.assertEqual(quotation.net_total, SHIRT_RATE - 10) + def test_a_code_that_does_not_apply_yet_says_why(self): + code = self.create_code(percentage=10, minimum_requirement="Subtotal", minimum_subtotal=500) + cart_sdk.add_lines([{"item_code": self.cap, "qty": 1}]) + + state = cart_sdk.add_discount_code(code)["discount_codes"][0] + + self.assertFalse(state["applied"]) + self.assertIn("more to use", state["reason"]) + def test_checkout_without_a_server_cart_is_unchanged(self): quotation = generate_quotation_for_cart({"items": [self.browser_line(self.cap, 2)]}) From d8164878d9774fc39fa9994362297d6ce164dea1 Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Sun, 11 Oct 2026 01:20:42 +0000 Subject: [PATCH 003/109] feat(admin): add the discounts admin API The dashboard needs to list, edit and test discounts. The new endpoints in commera.api.admin.discounts list discounts by status and code, save one with its typed code, generate and download unique codes, refuse to delete a discount orders used, price a test cart with an unsaved draft, and fill the editor's pickers. The cart and the test cart share one preview Quotation. --- commera/api/admin/discounts.py | 362 ++++++++++++++++++ .../doctype/commera_cart/commera_cart.py | 31 +- commera/discounts/preview.py | 36 ++ commera/discounts/selection.py | 8 +- commera/tests/test_admin_discounts.py | 91 +++++ 5 files changed, 496 insertions(+), 32 deletions(-) create mode 100644 commera/api/admin/discounts.py create mode 100644 commera/discounts/preview.py create mode 100644 commera/tests/test_admin_discounts.py diff --git a/commera/api/admin/discounts.py b/commera/api/admin/discounts.py new file mode 100644 index 00000000..12389a9a --- /dev/null +++ b/commera/api/admin/discounts.py @@ -0,0 +1,362 @@ +"""The Discounts screens: discount rules, their codes, a test cart and the pickers the editor needs.""" + +import frappe +from frappe import _ +from frappe.query_builder.functions import Count +from frappe.utils.data import cint, cstr, flt, getdate, today + +from commera.discounts.cart_context import get_cart_context +from commera.discounts.codes import add_code, generate_codes, normalize_code +from commera.discounts.preview import get_preview_quotation +from commera.discounts.selection import get_unmet_reason + +DOCTYPE = "Commera Discount" +PAGE_LENGTH = 20 +MAX_PAGE_LENGTH = 100 +MAX_TEST_LINES = 20 + +EDITABLE_FIELDS = ( + "title", + "method", + "discount_type", + "enabled", + "starts_on", + "ends_on", + "value_type", + "value", + "maximum_discount", + "fixed_applies_to", + "currency", + "tiered", + "applies_to", + "exclude_sale_items", + "buy_requirement", + "buy_value", + "uses_per_order", + "get_quantity", + "get_value_type", + "get_value", + "delivery_options", + "shipping_percentage", + "maximum_shipping_rate", + "minimum_requirement", + "minimum_subtotal", + "minimum_quantity", + "payment_condition", + "eligibility", + "combines_with_product", + "combines_with_order", + "combines_with_shipping", + "usage_limit", + "once_per_customer", + "budget", + "campaign", + "discount_account", +) +TABLES = { + "tiers": ("minimum_subtotal", "value"), + "targets": ("role", "target_type", "target"), + "customer_groups": ("customer_group",), +} +LIST_FIELDS = ( + "name", + "title", + "method", + "discount_type", + "enabled", + "starts_on", + "ends_on", + "value_type", + "value", + "maximum_discount", + "tiered", + "payment_condition", + "eligibility", + "once_per_customer", + "usage_limit", + "budget", + "times_used", + "discount_given", +) + + +@frappe.whitelist() +def get_discounts( + tab: str = "all", search: str | None = None, start: int = 0, page_length: int = PAGE_LENGTH +): + """The Discounts list. A tab narrows by the dates and switch a status is read from.""" + frappe.has_permission(DOCTYPE, ptype="read", throw=True) + + discount = frappe.qb.DocType(DOCTYPE) + condition = get_tab_condition(discount, tab) + if search: + matching = frappe.get_all( + "Commera Discount Code", filters={"code": ("like", f"%{cstr(search)}%")}, pluck="discount" + ) + by_text = discount.title.like(f"%{cstr(search)}%") + by_code = discount.name.isin(matching) if matching else None + search_condition = by_text | by_code if by_code is not None else by_text + condition = search_condition if condition is None else condition & search_condition + + query = frappe.qb.from_(discount).select(*(discount[field] for field in LIST_FIELDS)) + count = frappe.qb.from_(discount).select(Count(discount.name)) + if condition is not None: + query, count = query.where(condition), count.where(condition) + rows = ( + query.orderby(discount.creation, order=frappe.qb.desc) + .limit(min(cint(page_length) or PAGE_LENGTH, MAX_PAGE_LENGTH)) + .offset(cint(start)) + ).run(as_dict=True) + + codes = get_code_summaries([row.name for row in rows]) + return { + "discounts": [{**row, **codes.get(row.name, {}), "status": get_status(row)} for row in rows], + "total": count.run()[0][0], + } + + +def get_tab_condition(discount, tab: str): + day = getdate(today()) + if tab == "active": + started = discount.starts_on.isnull() | (discount.starts_on <= day) + running = discount.ends_on.isnull() | (discount.ends_on >= day) + return (discount.enabled == 1) & started & running + if tab == "scheduled": + return (discount.enabled == 1) & (discount.starts_on > day) + if tab == "expired": + return discount.ends_on < day + if tab == "disabled": + return discount.enabled == 0 + return None + + +def get_status(row) -> str: + return frappe.get_doc({"doctype": DOCTYPE, **row}).status + + +def get_code_summaries(discounts: list[str]) -> dict[str, dict]: + if not discounts: + return {} + rows = frappe.get_all( + "Commera Discount Code", + filters={"discount": ("in", discounts)}, + fields=["discount", "code", "is_generated", "times_used"], + order_by="creation asc", + ) + summaries = {} + for row in rows: + summary = summaries.setdefault( + row.discount, {"code": None, "generated_codes": 0, "generated_codes_used": 0} + ) + if row.is_generated: + summary["generated_codes"] += 1 + summary["generated_codes_used"] += 1 if row.times_used else 0 + elif not summary["code"]: + summary["code"] = row.code + return summaries + + +@frappe.whitelist() +def get_discount(name: str) -> dict: + frappe.has_permission(DOCTYPE, ptype="read", throw=True) + discount = frappe.get_doc(DOCTYPE, name) + record = {field: discount.get(field) for field in EDITABLE_FIELDS} + for table, fields in TABLES.items(): + record[table] = [{field: row.get(field) for field in fields} for row in discount.get(table)] + return { + "name": discount.name, + **record, + **get_code_summaries([discount.name]).get( + discount.name, {"code": None, "generated_codes": 0, "generated_codes_used": 0} + ), + "status": discount.status, + "discount_class": discount.discount_class, + "performance": { + "orders": cint(discount.times_used), + "discount_given": flt(discount.discount_given), + "sales": flt(discount.sales), + "budget_left": discount.budget_left, + }, + } + + +@frappe.whitelist(methods=["POST"]) +def save_discount(discount: dict | str) -> dict: + """Create or update a discount from the editor. A code discount's single code is kept in step.""" + values = frappe.parse_json(discount) + name = values.get("name") + frappe.has_permission(DOCTYPE, ptype="write" if name else "create", throw=True) + + doc = frappe.get_doc(DOCTYPE, name) if name else frappe.new_doc(DOCTYPE) + doc.update({field: values[field] for field in EDITABLE_FIELDS if field in values}) + for table, fields in TABLES.items(): + if table in values: + doc.set(table, [{field: row.get(field) for field in fields} for row in values[table]]) + if doc.method == "Code" and not doc.title: + doc.title = normalize_code(values.get("code")) + doc.save() + + if doc.method == "Code" and values.get("code"): + set_single_code(doc.name, values["code"]) + return get_discount(doc.name) + + +def set_single_code(discount: str, code: str): + """Point the discount's typed code at `code`. An old code that was used is kept, so its orders still link.""" + code = normalize_code(code) + current = frappe.get_all( + "Commera Discount Code", + filters={"discount": discount, "is_generated": 0}, + fields=["name", "times_used"], + ) + if any(row.name == code for row in current): + return + add_code(discount, code) + for row in current: + if not row.times_used: + frappe.delete_doc("Commera Discount Code", row.name, ignore_permissions=True) + + +@frappe.whitelist(methods=["POST"]) +def delete_discount(name: str): + frappe.has_permission(DOCTYPE, ptype="delete", throw=True) + if frappe.db.exists("Commera Discount Use", {"discount": name}): + frappe.throw(_("Orders used this discount, so it is kept for their records. Switch it off instead.")) + for code in frappe.get_all("Commera Discount Code", filters={"discount": name}, pluck="name"): + frappe.delete_doc("Commera Discount Code", code, ignore_permissions=True) + frappe.delete_doc(DOCTYPE, name) + + +@frappe.whitelist(methods=["POST"]) +def create_codes(name: str, count: int, prefix: str | None = None) -> dict: + frappe.has_permission(DOCTYPE, ptype="write", throw=True) + codes = generate_codes(name, count, prefix or "") + return {"created": len(codes)} + + +@frappe.whitelist() +def download_codes(name: str): + """The discount's generated codes as CSV, with whether each was used.""" + frappe.has_permission(DOCTYPE, ptype="read", throw=True) + rows = frappe.get_all( + "Commera Discount Code", + filters={"discount": name, "is_generated": 1}, + fields=["code", "times_used"], + order_by="creation asc", + ) + frappe.response["filename"] = f"{name}-codes.csv" + frappe.response["filecontent"] = "code,used\n" + "".join( + f"{row.code},{cint(row.times_used)}\n" for row in rows + ) + frappe.response["type"] = "download" + + +@frappe.whitelist(methods=["POST"]) +def test_discount( + discount: dict | str, lines: list | str, payment_kind: str = "prepaid", first_order: int = 0 +) -> dict: + """Price a cart with an unsaved draft of the discount, the way checkout would price it.""" + frappe.has_permission(DOCTYPE, ptype="read", throw=True) + values = frappe.parse_json(discount) + lines = [ + {"item_code": cstr(line["item_code"]), "qty": max(cint(line.get("qty")), 1)} + for line in (frappe.parse_json(lines) or [])[:MAX_TEST_LINES] + if line.get("item_code") + ] + if not lines: + return {"lines": [], "subtotal": 0, "discount": 0, "total": 0, "reasons": []} + + draft = frappe.get_doc({"doctype": DOCTYPE, **{field: values.get(field) for field in EDITABLE_FIELDS}}) + for table, fields in TABLES.items(): + draft.set(table, [{field: row.get(field) for field in fields} for row in values.get(table) or []]) + # The test shows the rules' effect today, whatever the switch and dates say. + draft.update( + {"enabled": 1, "starts_on": None, "ends_on": None, "title": draft.title or _("This discount")} + ) + flags = { + "commera_discounts": [draft], + "commera_payment_kind": "cod" if payment_kind == "cod" else "prepaid", + "commera_first_order": bool(cint(first_order)), + } + quotation = get_preview_quotation(lines, flags=flags) + subtotal = sum(flt(item.price_list_rate) * flt(item.qty) for item in quotation.items) + return { + "lines": [ + { + "item_code": item.item_code, + "title": item.item_name, + "qty": flt(item.qty), + "price": flt(item.price_list_rate), + "discount": flt(item.custom_code_discount) * flt(item.qty), + "amount": flt(item.amount), + } + for item in quotation.items + ], + "subtotal": subtotal, + "discount": subtotal - flt(quotation.net_total), + "total": flt(quotation.net_total), + "reasons": list(filter(None, [get_unmet_reason(draft, get_cart_context(quotation))])), + } + + +@frappe.whitelist() +def get_editor_options() -> dict: + """The choices the editor's pickers offer that do not need a search.""" + frappe.has_permission(DOCTYPE, ptype="read", throw=True) + company = frappe.defaults.get_global_default("company") + return { + "currency": frappe.get_cached_value("Company", company, "default_currency") if company else None, + "customer_groups": frappe.get_all( + "Customer Group", filters={"is_group": 0}, pluck="name", order_by="name" + ), + "campaigns": frappe.get_all("Campaign", pluck="name", order_by="name"), + "discount_accounts": frappe.get_all( + "Account", + filters={"company": company, "is_group": 0, "disabled": 0}, + pluck="name", + order_by="name", + limit=500, + ), + "delivery_options": get_delivery_option_titles(), + } + + +def get_delivery_option_titles() -> list[str]: + if not frappe.db.exists("DocType", "Shipping Service"): + return [] + return frappe.get_all("Shipping Service", filters={"enabled": 1}, pluck="title", order_by="title") + + +@frappe.whitelist() +def search_targets(target_type: str, search: str | None = None) -> list[dict]: + """Products, collections or brands for a discount's target pickers.""" + search = f"%{cstr(search)}%" + if target_type == "Item": + frappe.has_permission("Item", ptype="read", throw=True) + rows = frappe.get_all( + "Item", + filters={"disabled": 0, "variant_of": ("is", "not set")}, + or_filters={"name": ("like", search), "item_name": ("like", search)}, + fields=["name", "item_name"], + order_by="item_name", + limit=20, + ) + return [{"label": row.item_name, "value": row.name} for row in rows] + if target_type == "Item Group": + frappe.has_permission("Item Group", ptype="read", throw=True) + item_group = frappe.qb.DocType("Item Group") + names = ( + frappe.qb.from_(item_group) + .select(item_group.name) + .where((item_group.rgt == item_group.lft + 1) & item_group.name.like(search)) + .orderby(item_group.name) + .limit(20) + ).run(pluck=True) + return [{"label": name, "value": name} for name in names] + if target_type == "Brand": + frappe.has_permission("Brand", ptype="read", throw=True) + names = frappe.get_all( + "Brand", filters={"name": ("like", search)}, pluck="name", order_by="name", limit=20 + ) + return [{"label": name, "value": name} for name in names] + frappe.throw(_("Unknown target type {0}.").format(target_type)) diff --git a/commera/commera_ecommerce/doctype/commera_cart/commera_cart.py b/commera/commera_ecommerce/doctype/commera_cart/commera_cart.py index 877798dc..6eaead44 100644 --- a/commera/commera_ecommerce/doctype/commera_cart/commera_cart.py +++ b/commera/commera_ecommerce/doctype/commera_cart/commera_cart.py @@ -6,7 +6,7 @@ from commera.discounts.allocations import get_allocations from commera.discounts.cart_context import get_cart_context from commera.discounts.codes import get_code_discount, normalize_code -from commera.discounts.pricing import apply_code_discounts +from commera.discounts.preview import get_preview_quotation from commera.discounts.selection import ( get_discount, get_discount_codes, @@ -124,33 +124,8 @@ def get_priced_cart(self) -> dict: } def get_quotation_preview(self): - """An unsaved Quotation. Priced elevated, like the checkout cart: ERPNext reads Items the shopper cannot.""" - from commera.api.payments import system_user_session - - settings = frappe.get_cached_doc("Commera Settings") - quotation = frappe.new_doc("Quotation") - quotation.update( - { - "quotation_to": "Customer", - "company": settings.get("company") or frappe.defaults.get_global_default("company"), - "order_type": "Shopping Cart", - "selling_price_list": settings.sale_price_list, - "custom_discount_codes": self.discount_codes, - # Who is shopping, so first-order, group and once-per-customer rules can be judged. - "party_name": self.user and frappe.db.get_value("Portal User", {"user": self.user}, "parent"), - "contact_email": self.user, - } - ) - for row in self.lines: - quotation.append( - "items", - {"item_code": row.item_code, "qty": row.qty, "warehouse": settings.ecommerce_warehouse}, - ) - with system_user_session(): - quotation.run_method("set_missing_values") - quotation.calculate_taxes_and_totals() - apply_code_discounts(quotation) - return quotation + lines = [{"item_code": row.item_code, "qty": row.qty} for row in self.lines] + return get_preview_quotation(lines, self.discount_codes, self.user) def update_quotation(self, quotation): """Copy the attributes and codes onto a checkout Quotation whose lines came from the browser. When diff --git a/commera/discounts/preview.py b/commera/discounts/preview.py new file mode 100644 index 00000000..194fcfdb --- /dev/null +++ b/commera/discounts/preview.py @@ -0,0 +1,36 @@ +import frappe + +from commera.discounts.pricing import apply_code_discounts + + +def get_preview_quotation(lines: list[dict], codes: str | None = None, user: str | None = None, flags=None): + """An unsaved store cart priced as checkout would price it: ERPNext price lists and rules, then the + discounts. Priced elevated, like the checkout cart, because ERPNext reads Items the shopper cannot.""" + # Imported here: commera.api.payments imports the cart, which imports this module. + from commera.api.payments import system_user_session + + settings = frappe.get_cached_doc("Commera Settings") + quotation = frappe.new_doc("Quotation") + quotation.update( + { + "quotation_to": "Customer", + "company": settings.get("company") or frappe.defaults.get_global_default("company"), + "order_type": "Shopping Cart", + "selling_price_list": settings.sale_price_list, + "custom_discount_codes": codes, + # Who is shopping, so first-order, group and once-per-customer rules can be judged. + "party_name": user and frappe.db.get_value("Portal User", {"user": user}, "parent"), + "contact_email": user, + } + ) + quotation.flags.update(flags or {}) + for line in lines: + quotation.append( + "items", + {"item_code": line["item_code"], "qty": line["qty"], "warehouse": settings.ecommerce_warehouse}, + ) + with system_user_session(): + quotation.run_method("set_missing_values") + quotation.calculate_taxes_and_totals() + apply_code_discounts(quotation) + return quotation diff --git a/commera/discounts/selection.py b/commera/discounts/selection.py index 29f78649..90e3caec 100644 --- a/commera/discounts/selection.py +++ b/commera/discounts/selection.py @@ -93,10 +93,10 @@ def get_candidates(doc) -> list[Selection]: def get_unmet_reason(discount: CommeraDiscount, cart) -> str | None: if reason := discount.get_unmet_condition(cart): return reason - if ( - discount.eligibility == "First Order" - and not cart.doc.flags.commera_first_order - and not is_first_order(cart) + # A merchant's test cart says whether it stands for a first order; a real cart is looked up. + is_first = cart.doc.flags.commera_first_order + if discount.eligibility == "First Order" and not ( + is_first if is_first is not None else is_first_order(cart) ): return _("{0} is for a first order only.").format(discount.title) if discount.once_per_customer and has_used_before(discount.name, cart): diff --git a/commera/tests/test_admin_discounts.py b/commera/tests/test_admin_discounts.py new file mode 100644 index 00000000..0834177b --- /dev/null +++ b/commera/tests/test_admin_discounts.py @@ -0,0 +1,91 @@ +import frappe +from frappe.utils.data import add_days, today + +from commera.api.admin import discounts as admin_discounts +from commera.tests.test_discount_codes import SHIRT_RATE, DiscountCodeTestCase + + +class TestAdminDiscounts(DiscountCodeTestCase): + def setUp(self): + super().setUp() + sale_price_list = frappe.db.get_single_value("Commera Settings", "sale_price_list") + self.addCleanup(self.set_sale_price_list, sale_price_list) + self.set_sale_price_list(self.price_list) + + def set_sale_price_list(self, price_list: str): + frappe.db.set_single_value("Commera Settings", "sale_price_list", price_list) + frappe.clear_document_cache("Commera Settings", "Commera Settings") + + def save(self, **values) -> dict: + draft = { + "method": "Code", + "discount_type": "Amount Off Order", + "value_type": "Percentage", + "value": 10, + "code": f"zz{frappe.generate_hash(length=6)}", + **values, + } + return admin_discounts.save_discount(draft) + + def test_a_code_discount_is_created_with_its_code_as_title(self): + saved = self.save(code="zz-welcome") + + self.assertEqual( + (saved["code"], saved["title"], saved["status"]), ("ZZ-WELCOME", "ZZ-WELCOME", "Active") + ) + self.assertEqual(saved["discount_class"], "Order") + + def test_changing_an_unused_code_replaces_it(self): + saved = self.save(code="zz-first") + + updated = admin_discounts.save_discount({**saved, "code": "zz-second"}) + + self.assertEqual(updated["code"], "ZZ-SECOND") + self.assertFalse(frappe.db.exists("Commera Discount Code", "ZZ-FIRST")) + + def test_the_list_filters_by_tab_and_finds_by_code(self): + scheduled = self.save(starts_on=add_days(today(), 5)) + active = self.save(code="zz-findme") + + scheduled_names = [row["name"] for row in admin_discounts.get_discounts("scheduled")["discounts"]] + found = admin_discounts.get_discounts(search="ZZ-FINDME")["discounts"] + + self.assertIn(scheduled["name"], scheduled_names) + self.assertNotIn(active["name"], scheduled_names) + self.assertEqual([row["name"] for row in found], [active["name"]]) + + def test_generated_codes_download_as_csv(self): + saved = self.save() + + admin_discounts.create_codes(saved["name"], 3, "zz-") + admin_discounts.download_codes(saved["name"]) + + self.assertEqual(admin_discounts.get_discount(saved["name"])["generated_codes"], 3) + self.assertEqual(frappe.response["filecontent"].count("\n"), 4) + + def test_a_used_discount_cannot_be_deleted(self): + saved = self.save() + self.place_order(self.create_quotation({self.shirt: 1}, (saved["code"],))) + + with self.assertRaises(frappe.ValidationError): + admin_discounts.delete_discount(saved["name"]) + + def test_the_test_cart_prices_an_unsaved_draft(self): + draft = { + "discount_type": "Amount Off Order", + "value_type": "Percentage", + "value": 10, + "payment_condition": "Prepaid", + } + + prepaid = admin_discounts.test_discount(draft, [{"item_code": self.shirt, "qty": 2}]) + cod = admin_discounts.test_discount(draft, [{"item_code": self.shirt, "qty": 2}], payment_kind="cod") + + self.assertEqual((prepaid["subtotal"], prepaid["discount"]), (2 * SHIRT_RATE, 18)) + self.assertEqual(cod["discount"], 0) + self.assertEqual(len(cod["reasons"]), 1) + + def test_target_search_finds_products(self): + matches = admin_discounts.search_targets("Item", self.shirt) + + self.assertEqual([match["value"] for match in matches], [self.shirt]) From d9f47adcbe6f117ec561ead6a4a1a949d8a2cacd Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Sun, 11 Oct 2026 01:28:34 +0000 Subject: [PATCH 004/109] feat(dashboard): add the Discounts screens Merchants could only manage discounts in Desk. Discounts in the sidebar now lists them by status with uses and discount given. The editor opens on what every discount needs, with conditions, limits and reporting in their own tabs and optional sections collapsed to a one-line summary. Its side panel summarises the rules in words, shows orders, discount given, sales and budget left as number cards, and prices a test cart with the unsaved draft. The list endpoint now returns the fields its summary reads. --- commera/api/admin/discounts.py | 48 ++- commera/tests/test_admin_discounts.py | 12 + dashboard/components.d.ts | 10 + .../components/discounts/AppliesToSection.vue | 42 +++ .../components/discounts/BuyXGetYSection.vue | 56 +++ .../src/components/discounts/CodeSection.vue | 89 +++++ .../components/discounts/DiscountSection.vue | 43 +++ .../components/discounts/DiscountSummary.vue | 38 ++ .../components/discounts/DiscountTestCart.vue | 79 ++++ .../discounts/DiscountTypeDialog.vue | 36 ++ .../components/discounts/ShippingSection.vue | 34 ++ .../src/components/discounts/TargetPicker.vue | 46 +++ .../src/components/discounts/ValueSection.vue | 80 ++++ dashboard/src/data/discounts.js | 145 +++++++ dashboard/src/ia/nav.js | 3 +- dashboard/src/pages/DiscountDetail.vue | 354 ++++++++++++++++++ dashboard/src/pages/Discounts.vue | 124 ++++++ dashboard/src/router.js | 3 + 18 files changed, 1236 insertions(+), 6 deletions(-) create mode 100644 dashboard/src/components/discounts/AppliesToSection.vue create mode 100644 dashboard/src/components/discounts/BuyXGetYSection.vue create mode 100644 dashboard/src/components/discounts/CodeSection.vue create mode 100644 dashboard/src/components/discounts/DiscountSection.vue create mode 100644 dashboard/src/components/discounts/DiscountSummary.vue create mode 100644 dashboard/src/components/discounts/DiscountTestCart.vue create mode 100644 dashboard/src/components/discounts/DiscountTypeDialog.vue create mode 100644 dashboard/src/components/discounts/ShippingSection.vue create mode 100644 dashboard/src/components/discounts/TargetPicker.vue create mode 100644 dashboard/src/components/discounts/ValueSection.vue create mode 100644 dashboard/src/data/discounts.js create mode 100644 dashboard/src/pages/DiscountDetail.vue create mode 100644 dashboard/src/pages/Discounts.vue diff --git a/commera/api/admin/discounts.py b/commera/api/admin/discounts.py index 12389a9a..d2b7a845 100644 --- a/commera/api/admin/discounts.py +++ b/commera/api/admin/discounts.py @@ -77,6 +77,20 @@ "budget", "times_used", "discount_given", + # What the list's one-line summary reads. + "exclude_sale_items", + "minimum_requirement", + "minimum_subtotal", + "minimum_quantity", + "buy_requirement", + "buy_value", + "get_quantity", + "get_value_type", + "get_value", + "shipping_percentage", + "combines_with_product", + "combines_with_order", + "combines_with_shipping", ) @@ -108,9 +122,14 @@ def get_discounts( .offset(cint(start)) ).run(as_dict=True) - codes = get_code_summaries([row.name for row in rows]) + names = [row.name for row in rows] + codes = get_code_summaries(names) + tiers = get_tiers(names) return { - "discounts": [{**row, **codes.get(row.name, {}), "status": get_status(row)} for row in rows], + "discounts": [ + {**row, **codes.get(row.name, {}), "tiers": tiers.get(row.name, []), "status": get_status(row)} + for row in rows + ], "total": count.run()[0][0], } @@ -134,6 +153,22 @@ def get_status(row) -> str: return frappe.get_doc({"doctype": DOCTYPE, **row}).status +def get_tiers(discounts: list[str]) -> dict[str, list[dict]]: + tiers = {} + if not discounts: + return tiers + for row in frappe.get_all( + "Commera Discount Tier", + filters={"parent": ("in", discounts), "parenttype": DOCTYPE}, + fields=["parent", "minimum_subtotal", "value"], + order_by="idx asc", + ): + tiers.setdefault(row.parent, []).append( + {"minimum_subtotal": row.minimum_subtotal, "value": row.value} + ) + return tiers + + def get_code_summaries(discounts: list[str]) -> dict[str, dict]: if not discounts: return {} @@ -328,14 +363,17 @@ def get_delivery_option_titles() -> list[str]: @frappe.whitelist() -def search_targets(target_type: str, search: str | None = None) -> list[dict]: - """Products, collections or brands for a discount's target pickers.""" +def search_targets(target_type: str, search: str | None = None, sellable: int = 0) -> list[dict]: + """Products, collections or brands for a discount's target pickers. `sellable` lists the items a + cart can hold, such as each size, rather than the products they belong to.""" search = f"%{cstr(search)}%" if target_type == "Item": frappe.has_permission("Item", ptype="read", throw=True) rows = frappe.get_all( "Item", - filters={"disabled": 0, "variant_of": ("is", "not set")}, + filters={"disabled": 0, "has_variants": 0} + if cint(sellable) + else {"disabled": 0, "variant_of": ("is", "not set")}, or_filters={"name": ("like", search), "item_name": ("like", search)}, fields=["name", "item_name"], order_by="item_name", diff --git a/commera/tests/test_admin_discounts.py b/commera/tests/test_admin_discounts.py index 0834177b..907ab46a 100644 --- a/commera/tests/test_admin_discounts.py +++ b/commera/tests/test_admin_discounts.py @@ -54,6 +54,18 @@ def test_the_list_filters_by_tab_and_finds_by_code(self): self.assertNotIn(active["name"], scheduled_names) self.assertEqual([row["name"] for row in found], [active["name"]]) + def test_the_list_carries_what_its_summary_reads(self): + saved = self.save( + discount_type="Amount Off Order", tiered=1, tiers=[{"minimum_subtotal": 100, "value": 5}] + ) + + row = next( + row for row in admin_discounts.get_discounts()["discounts"] if row["name"] == saved["name"] + ) + + self.assertEqual(row["tiers"], [{"minimum_subtotal": 100, "value": 5}]) + self.assertEqual(row["combines_with_shipping"], 1) + def test_generated_codes_download_as_csv(self): saved = self.save() diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts index f24fa22f..b02292bd 100644 --- a/dashboard/components.d.ts +++ b/dashboard/components.d.ts @@ -15,6 +15,7 @@ declare module 'vue' { AdvancedSettings: typeof import('./src/components/settings/AdvancedSettings.vue')['default'] AppearancePicker: typeof import('./src/components/settings/AppearancePicker.vue')['default'] AppIcon: typeof import('./src/components/AppIcon.vue')['default'] + AppliesToSection: typeof import('./src/components/discounts/AppliesToSection.vue')['default'] AppPageHeader: typeof import('./src/components/AppPageHeader.vue')['default'] AppSettingsDialog: typeof import('./src/components/settings/AppSettingsDialog.vue')['default'] AppShell: typeof import('./src/components/AppShell.vue')['default'] @@ -22,14 +23,20 @@ declare module 'vue' { AttributeMultiSelect: typeof import('./src/components/AttributeMultiSelect.vue')['default'] AttributeValuesDialog: typeof import('./src/components/AttributeValuesDialog.vue')['default'] BulkBar: typeof import('./src/components/BulkBar.vue')['default'] + BuyXGetYSection: typeof import('./src/components/discounts/BuyXGetYSection.vue')['default'] CascadePublishDialog: typeof import('./src/components/storefront/CascadePublishDialog.vue')['default'] CashOnDeliverySettings: typeof import('./src/components/settings/CashOnDeliverySettings.vue')['default'] CheckoutSettings: typeof import('./src/components/settings/CheckoutSettings.vue')['default'] ChromePreview: typeof import('./src/components/storefront/ChromePreview.vue')['default'] CoachTip: typeof import('./src/components/import/CoachTip.vue')['default'] + CodeSection: typeof import('./src/components/discounts/CodeSection.vue')['default'] DeliveryOptionConfig: typeof import('./src/components/settings/DeliveryOptionConfig.vue')['default'] DeliveryOptionRow: typeof import('./src/components/settings/DeliveryOptionRow.vue')['default'] DeliveryOptionsPanel: typeof import('./src/components/settings/DeliveryOptionsPanel.vue')['default'] + DiscountSection: typeof import('./src/components/discounts/DiscountSection.vue')['default'] + DiscountSummary: typeof import('./src/components/discounts/DiscountSummary.vue')['default'] + DiscountTestCart: typeof import('./src/components/discounts/DiscountTestCart.vue')['default'] + DiscountTypeDialog: typeof import('./src/components/discounts/DiscountTypeDialog.vue')['default'] EditableValue: typeof import('./src/components/EditableValue.vue')['default'] EditOptionsDialog: typeof import('./src/components/EditOptionsDialog.vue')['default'] EmailSettings: typeof import('./src/components/settings/EmailSettings.vue')['default'] @@ -100,14 +107,17 @@ declare module 'vue' { ShippingRuleConfig: typeof import('./src/components/settings/ShippingRuleConfig.vue')['default'] ShippingRuleRow: typeof import('./src/components/settings/ShippingRuleRow.vue')['default'] ShippingRulesPanel: typeof import('./src/components/settings/ShippingRulesPanel.vue')['default'] + ShippingSection: typeof import('./src/components/discounts/ShippingSection.vue')['default'] ShippingSections: typeof import('./src/components/settings/ShippingSections.vue')['default'] SourceStep: typeof import('./src/components/import/steps/SourceStep.vue')['default'] StatusBadge: typeof import('./src/components/StatusBadge.vue')['default'] SwatchDot: typeof import('./src/components/SwatchDot.vue')['default'] + TargetPicker: typeof import('./src/components/discounts/TargetPicker.vue')['default'] ThemeEditor: typeof import('./src/components/storefront/ThemeEditor.vue')['default'] ThemeField: typeof import('./src/components/storefront/ThemeField.vue')['default'] Thumb: typeof import('./src/components/Thumb.vue')['default'] UploadStep: typeof import('./src/components/import/steps/UploadStep.vue')['default'] + ValueSection: typeof import('./src/components/discounts/ValueSection.vue')['default'] VariantDialog: typeof import('./src/components/VariantDialog.vue')['default'] VariantEditor: typeof import('./src/components/VariantEditor.vue')['default'] VariantMedia: typeof import('./src/components/VariantMedia.vue')['default'] diff --git a/dashboard/src/components/discounts/AppliesToSection.vue b/dashboard/src/components/discounts/AppliesToSection.vue new file mode 100644 index 00000000..64301cf0 --- /dev/null +++ b/dashboard/src/components/discounts/AppliesToSection.vue @@ -0,0 +1,42 @@ + + + diff --git a/dashboard/src/components/discounts/BuyXGetYSection.vue b/dashboard/src/components/discounts/BuyXGetYSection.vue new file mode 100644 index 00000000..66ea36cf --- /dev/null +++ b/dashboard/src/components/discounts/BuyXGetYSection.vue @@ -0,0 +1,56 @@ + + + diff --git a/dashboard/src/components/discounts/CodeSection.vue b/dashboard/src/components/discounts/CodeSection.vue new file mode 100644 index 00000000..2cd16e7e --- /dev/null +++ b/dashboard/src/components/discounts/CodeSection.vue @@ -0,0 +1,89 @@ + + + diff --git a/dashboard/src/components/discounts/DiscountSection.vue b/dashboard/src/components/discounts/DiscountSection.vue new file mode 100644 index 00000000..437c38d3 --- /dev/null +++ b/dashboard/src/components/discounts/DiscountSection.vue @@ -0,0 +1,43 @@ + + + diff --git a/dashboard/src/components/discounts/DiscountSummary.vue b/dashboard/src/components/discounts/DiscountSummary.vue new file mode 100644 index 00000000..d08d85c5 --- /dev/null +++ b/dashboard/src/components/discounts/DiscountSummary.vue @@ -0,0 +1,38 @@ + + + diff --git a/dashboard/src/components/discounts/DiscountTestCart.vue b/dashboard/src/components/discounts/DiscountTestCart.vue new file mode 100644 index 00000000..db78a502 --- /dev/null +++ b/dashboard/src/components/discounts/DiscountTestCart.vue @@ -0,0 +1,79 @@ + + + diff --git a/dashboard/src/components/discounts/DiscountTypeDialog.vue b/dashboard/src/components/discounts/DiscountTypeDialog.vue new file mode 100644 index 00000000..637d2e15 --- /dev/null +++ b/dashboard/src/components/discounts/DiscountTypeDialog.vue @@ -0,0 +1,36 @@ + + + diff --git a/dashboard/src/components/discounts/ShippingSection.vue b/dashboard/src/components/discounts/ShippingSection.vue new file mode 100644 index 00000000..f0f1ec36 --- /dev/null +++ b/dashboard/src/components/discounts/ShippingSection.vue @@ -0,0 +1,34 @@ + + + diff --git a/dashboard/src/components/discounts/TargetPicker.vue b/dashboard/src/components/discounts/TargetPicker.vue new file mode 100644 index 00000000..56676260 --- /dev/null +++ b/dashboard/src/components/discounts/TargetPicker.vue @@ -0,0 +1,46 @@ + + + diff --git a/dashboard/src/components/discounts/ValueSection.vue b/dashboard/src/components/discounts/ValueSection.vue new file mode 100644 index 00000000..ae554530 --- /dev/null +++ b/dashboard/src/components/discounts/ValueSection.vue @@ -0,0 +1,80 @@ + + + diff --git a/dashboard/src/data/discounts.js b/dashboard/src/data/discounts.js new file mode 100644 index 00000000..fb63bfb8 --- /dev/null +++ b/dashboard/src/data/discounts.js @@ -0,0 +1,145 @@ +import { createAdminCaller } from './adminCaller' +import { money } from './format' + +// The route slug is what a new-discount URL carries; the label is what the server stores. +export const DISCOUNT_TYPES = [ + { + slug: 'products', + value: 'Amount Off Products', + label: 'Amount off products', + icon: 'lucide-tag', + description: 'A percentage or amount off chosen products, collections or brands.', + }, + { + slug: 'order', + value: 'Amount Off Order', + label: 'Amount off order', + icon: 'lucide-shopping-cart', + description: 'Off the order subtotal, with optional spend tiers and a cap.', + }, + { + slug: 'buy-x-get-y', + value: 'Buy X Get Y', + label: 'Buy X get Y', + icon: 'lucide-gift', + description: 'Buy a quantity or amount of some products, get others free or cheaper.', + }, + { + slug: 'shipping', + value: 'Free Shipping', + label: 'Free shipping', + icon: 'lucide-truck', + description: 'Free or cheaper delivery above a spend, on chosen delivery options.', + }, +] + +export const discountTypeByValue = Object.fromEntries(DISCOUNT_TYPES.map((type) => [type.value, type])) +export const discountTypeBySlug = Object.fromEntries(DISCOUNT_TYPES.map((type) => [type.slug, type])) + +export const STATUS_THEMES = { Active: 'green', Scheduled: 'blue', Expired: 'gray', 'Used Up': 'amber', Disabled: 'gray' } + +export function blankDiscount(discountType, currency) { + return { + name: null, + title: '', + code: '', + method: 'Code', + discount_type: discountType, + enabled: 1, + starts_on: new Date().toISOString().slice(0, 10), + ends_on: null, + value_type: 'Percentage', + value: 10, + maximum_discount: 0, + fixed_applies_to: 'Each Item', + currency, + tiered: 0, + tiers: [], + applies_to: 'All Products', + exclude_sale_items: 0, + targets: [], + buy_requirement: 'Quantity', + buy_value: 2, + uses_per_order: 0, + get_quantity: 1, + get_value_type: 'Free', + get_value: 0, + delivery_options: '', + shipping_percentage: 100, + maximum_shipping_rate: 0, + minimum_requirement: discountType === 'Free Shipping' ? 'Subtotal' : 'None', + minimum_subtotal: 0, + minimum_quantity: 0, + payment_condition: 'Any', + eligibility: 'Everyone', + customer_groups: [], + // A free-shipping offer is meant to sit beside the store's other discounts. + combines_with_product: discountType === 'Free Shipping' ? 1 : 0, + combines_with_order: discountType === 'Free Shipping' ? 1 : 0, + combines_with_shipping: 1, + usage_limit: 0, + once_per_customer: 0, + budget: 0, + campaign: null, + discount_account: null, + generated_codes: 0, + generated_codes_used: 0, + performance: null, + } +} + +function valueLine(discount) { + const off = (value) => (discount.value_type === 'Percentage' ? `${value}%` : money(value)) + if (discount.discount_type === 'Buy X Get Y') { + const reward = { Free: 'free', Percentage: `${discount.get_value}% off` }[discount.get_value_type] ?? `${money(discount.get_value)} off each` + const buy = discount.buy_requirement === 'Quantity' ? discount.buy_value : money(discount.buy_value) + return `Buy ${buy}, get ${discount.get_quantity} ${reward}` + } + if (discount.discount_type === 'Free Shipping') { + return discount.shipping_percentage >= 100 ? 'Free delivery' : `${discount.shipping_percentage}% off delivery` + } + if (discount.tiered && discount.tiers?.length) { + return discount.tiers.map((tier) => `${money(tier.minimum_subtotal)}+ gets ${off(tier.value)}`).join(' · ') + } + const where = discount.discount_type === 'Amount Off Order' ? 'the order' : 'products' + const cap = discount.value_type === 'Percentage' && discount.maximum_discount ? `, up to ${money(discount.maximum_discount)}` : '' + return `${off(discount.value)} off ${where}${cap}` +} + +// One sentence per rule, in the order a shopper meets them. The list shows the first few, +// the editor's summary shows them all. +export function summaryLines(discount) { + const combines = [ + discount.combines_with_product && 'product', + discount.combines_with_order && 'order', + discount.combines_with_shipping && 'shipping', + ].filter(Boolean) + return [ + valueLine(discount), + discount.minimum_requirement === 'Subtotal' && `Minimum subtotal ${money(discount.minimum_subtotal)}`, + discount.minimum_requirement === 'Quantity' && `Minimum ${discount.minimum_quantity} items`, + discount.exclude_sale_items && 'Not on items already on sale', + { 'First Order': 'First order only', 'Customer Groups': 'Chosen customer groups' }[discount.eligibility], + { Prepaid: 'Prepaid orders only', 'Cash On Delivery': 'Cash on delivery only' }[discount.payment_condition], + discount.once_per_customer && 'Once per customer', + discount.usage_limit && `${discount.usage_limit} uses in total`, + discount.budget && `Stops after ${money(discount.budget)} of discounts`, + combines.length ? `Combines with ${combines.join(', ')} discounts` : 'Does not combine with other discounts', + ].filter(Boolean) +} + +const { attempt, call } = createAdminCaller('discounts.') + +export const getDiscount = (name) => attempt('get_discount', { name }) +export const saveDiscount = (discount) => call('save_discount', { discount }) +export const deleteDiscount = (name) => attempt('delete_discount', { name }) +export const createCodes = (name, count, prefix) => call('create_codes', { name, count, prefix }) +export const testDiscount = (discount, lines, paymentKind, firstOrder) => + call('test_discount', { discount, lines, payment_kind: paymentKind, first_order: firstOrder ? 1 : 0 }) +export const getEditorOptions = () => call('get_editor_options') +export const searchTargets = (targetType, search, sellable = false) => + call('search_targets', { target_type: targetType, search, sellable: sellable ? 1 : 0 }) + +export function codesDownloadUrl(name) { + return `/api/v2/method/commera.api.admin.discounts.download_codes?name=${encodeURIComponent(name)}` +} diff --git a/dashboard/src/ia/nav.js b/dashboard/src/ia/nav.js index b3f037fa..cb0d37bb 100644 --- a/dashboard/src/ia/nav.js +++ b/dashboard/src/ia/nav.js @@ -19,6 +19,7 @@ const ITEM = { }, orders: { label: 'Orders', icon: 'lucide-shopping-bag', to: '/orders' }, customers: { label: 'Customers', icon: 'lucide-users', to: '/customers' }, + discounts: { label: 'Discounts', icon: 'lucide-badge-percent', to: '/discounts' }, // A parent with children is a disclosure, not a destination: /analytics // itself holds nothing, so opening it lands on the first report. analytics: { label: 'Analytics', icon: 'lucide-chart-line', to: '/analytics', children: REPORTS }, @@ -37,7 +38,7 @@ const ITEM = { const coreSections = [ { id: 'primary', - items: [ITEM.overview, ITEM.search, ITEM.orders, ITEM.customers, ITEM.analytics], + items: [ITEM.overview, ITEM.search, ITEM.orders, ITEM.customers, ITEM.discounts, ITEM.analytics], }, { id: 'catalog', label: 'Catalog', items: [ITEM.products, ITEM.collections, ITEM.attributes] }, { id: 'storefront', label: 'Storefront', items: [ITEM.theme, ITEM.navigation, ITEM.pages, ITEM.reviews] }, diff --git a/dashboard/src/pages/DiscountDetail.vue b/dashboard/src/pages/DiscountDetail.vue new file mode 100644 index 00000000..501562da --- /dev/null +++ b/dashboard/src/pages/DiscountDetail.vue @@ -0,0 +1,354 @@ + + + diff --git a/dashboard/src/pages/Discounts.vue b/dashboard/src/pages/Discounts.vue new file mode 100644 index 00000000..2d11fda7 --- /dev/null +++ b/dashboard/src/pages/Discounts.vue @@ -0,0 +1,124 @@ + + + diff --git a/dashboard/src/router.js b/dashboard/src/router.js index 71c9d315..b53f2584 100644 --- a/dashboard/src/router.js +++ b/dashboard/src/router.js @@ -17,6 +17,9 @@ const routes = [ { path: '/inventory', name: 'Inventory', component: () => import('./pages/Inventory.vue') }, { path: '/inventory/adjustments', name: 'Adjustments', component: () => import('./pages/Adjustments.vue') }, { path: '/pricing', name: 'Pricing', component: () => import('./pages/Pricing.vue') }, + { path: '/discounts', name: 'Discounts', component: () => import('./pages/Discounts.vue') }, + { path: '/discounts/new/:type', name: 'DiscountNew', component: () => import('./pages/DiscountDetail.vue') }, + { path: '/discounts/:id', name: 'DiscountDetail', component: () => import('./pages/DiscountDetail.vue') }, { path: '/customers', name: 'Customers', component: () => import('./pages/Customers.vue') }, { path: '/customers/:id', name: 'CustomerDetail', component: () => import('./pages/CustomerDetail.vue') }, { path: '/reviews', name: 'Reviews', component: () => import('./pages/Reviews.vue') }, From 9dac293311b6ae9b864ccc93379be5a77d5079c6 Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Sun, 11 Oct 2026 01:29:56 +0000 Subject: [PATCH 005/109] feat(sdk): add commera.sdk.discounts Team code and plugins had no supported way to manage discounts or show a discount's amount before checkout. commera.sdk.discounts can create, read, list and update discounts, add or generate codes, preview a cart with codes and automatic discounts, and read the discounts an order used. Writes need the caller's own permission on Commera Discount. The new cart and discount names join the frozen public SDK list; nothing was removed, so API_VERSION stays. --- commera/sdk/__init__.py | 2 +- commera/sdk/discounts.py | 113 ++++++++++++++++++++++++++++ commera/sdk/types.py | 41 ++++++++++ commera/tests/test_sdk.py | 37 ++++++++- commera/tests/test_sdk_discounts.py | 52 +++++++++++++ 5 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 commera/sdk/discounts.py create mode 100644 commera/tests/test_sdk_discounts.py diff --git a/commera/sdk/__init__.py b/commera/sdk/__init__.py index 9858cc9c..9d50e60d 100644 --- a/commera/sdk/__init__.py +++ b/commera/sdk/__init__.py @@ -6,7 +6,7 @@ API_VERSION = 1 STORE_ORDER_TYPE = "Shopping Cart" -MODULES = ("cart", "catalog", "orders") +MODULES = ("cart", "catalog", "discounts", "orders") __all__ = ["API_VERSION", "STORE_ORDER_TYPE", "as_plugin_user", *MODULES] diff --git a/commera/sdk/discounts.py b/commera/sdk/discounts.py new file mode 100644 index 00000000..beaf15da --- /dev/null +++ b/commera/sdk/discounts.py @@ -0,0 +1,113 @@ +import frappe +from frappe import _ + +from commera.discounts.allocations import get_allocations +from commera.discounts.codes import add_code as add_discount_code +from commera.discounts.codes import generate_codes as generate_discount_codes +from commera.discounts.codes import normalize_code +from commera.discounts.preview import get_preview_quotation +from commera.sdk.types import Discount, DiscountAllocation, DiscountInput + +__all__ = [ + "add_code", + "create_discount", + "generate_codes", + "get_discount", + "get_order_discounts", + "list_discounts", + "preview_cart", + "update_discount", +] + +DOCTYPE = "Commera Discount" + + +def get_discount(name: str) -> Discount: + """A discount's rules, codes and results. Needs read permission on Commera Discount.""" + doc = frappe.get_doc(DOCTYPE, name) + doc.check_permission("read") + return as_discount(doc) + + +def list_discounts(status: str | None = None, limit: int = 100) -> list[Discount]: + """The newest `limit` discounts, keeping those with `status` when given: Active, Scheduled, Expired, + Used Up or Disabled. Status is read from dates, switches and use, so it filters after the read.""" + names = frappe.get_list(DOCTYPE, pluck="name", order_by="creation desc", limit=limit) + discounts = [as_discount(frappe.get_doc(DOCTYPE, name)) for name in names] + return [discount for discount in discounts if not status or discount["status"] == status] + + +def create_discount(values: DiscountInput, code: str | None = None) -> Discount: + """Create a discount. A code discount needs `code`, the string shoppers type; an automatic one does not.""" + doc = frappe.get_doc({"doctype": DOCTYPE, "method": "Code", **values}) + if doc.method == "Code": + if not code: + frappe.throw(_("A code discount needs a code.")) + doc.title = doc.title or normalize_code(code) + doc.insert() + if code: + add_discount_code(doc.name, code) + return as_discount(doc) + + +def update_discount(name: str, values: dict) -> Discount: + """Change a discount's rules. A table given in `values` replaces the stored rows.""" + doc = frappe.get_doc(DOCTYPE, name) + doc.update(values) + doc.save() + return as_discount(doc) + + +def add_code(discount: str, code: str) -> str: + """Add another code shoppers can type for the discount. Returns it as stored, in capitals.""" + frappe.has_permission(DOCTYPE, "write", discount, throw=True) + return add_discount_code(discount, code) + + +def generate_codes(discount: str, count: int, prefix: str = "") -> list[str]: + """Unique single-use codes that share the discount's rules, up to 5,000 at a time.""" + frappe.has_permission(DOCTYPE, "write", discount, throw=True) + return generate_discount_codes(discount, count, prefix) + + +def preview_cart(lines: list[dict], codes: list[str] | None = None, payment_kind: str = "prepaid") -> dict: + """Price `[{item_code, qty}]` as checkout would for the session shopper, with these codes and every + automatic discount. Use it to show a discount's amount before checkout, for example on a landing page.""" + flags = {"commera_payment_kind": "cod" if payment_kind == "cod" else "prepaid"} + user = None if frappe.session.user == "Guest" else frappe.session.user + quotation = get_preview_quotation(lines, "\n".join(codes or []), user, flags) + return { + "lines": [ + { + "item_code": item.item_code, + "qty": item.qty, + "price": item.price_list_rate, + "rate": item.rate, + "amount": item.amount, + "discount": item.custom_discount_code, + } + for item in quotation.items + ], + "discounts": get_allocations(quotation), + "subtotal": quotation.total, + "total": quotation.net_total, + } + + +def get_order_discounts(sales_order: str) -> list[DiscountAllocation]: + """The discounts an order used and how much each gave.""" + doc = frappe.get_doc("Sales Order", sales_order) + doc.check_permission("read") + return get_allocations(doc) + + +def as_discount(doc) -> Discount: + return { + **doc.as_dict(no_default_fields=True, no_child_table_fields=True), + "name": doc.name, + "status": doc.status, + "discount_class": doc.discount_class, + "codes": frappe.get_all( + "Commera Discount Code", filters={"discount": doc.name}, pluck="name", order_by="creation asc" + ), + } diff --git a/commera/sdk/types.py b/commera/sdk/types.py index e8a8b883..9c737539 100644 --- a/commera/sdk/types.py +++ b/commera/sdk/types.py @@ -9,6 +9,9 @@ "Charge", "ChargeSummary", "CheckoutSummary", + "Discount", + "DiscountAllocation", + "DiscountInput", "NewCartLine", "Order", "OrderLine", @@ -102,6 +105,44 @@ class Cart(TypedDict): plugin_fields: dict[str, Any] +class DiscountInput(TypedDict, total=False): + """Commera Discount fields. Only `discount_type` is required; see the doctype for every rule.""" + + title: str + # "Code" or "Automatic". + method: str + # "Amount Off Products", "Amount Off Order", "Buy X Get Y" or "Free Shipping". + discount_type: str + # "Percentage" or "Fixed Amount". + value_type: str + value: float + maximum_discount: float + # {"role": "Applies To" | "Excluded" | "Buys" | "Gets", "target_type": "Item" | "Item Group" | "Brand", "target"} + targets: list[dict] + tiers: list[dict] + starts_on: date + ends_on: date | None + + +class Discount(DiscountInput, total=False): + name: str + # Active, Scheduled, Expired, Used Up or Disabled. + status: str + # Product, Order or Shipping. + discount_class: str + codes: list[str] + times_used: int + discount_given: float + sales: float + + +class DiscountAllocation(TypedDict): + discount: str + code: str | None + discount_class: str + amount: float + + class NewCartLine(TypedDict, total=False): item_code: str qty: int diff --git a/commera/tests/test_sdk.py b/commera/tests/test_sdk.py index 84b00a3b..779e85af 100644 --- a/commera/tests/test_sdk.py +++ b/commera/tests/test_sdk.py @@ -29,8 +29,35 @@ # Removing or renaming a name here breaks apps built on the SDK, so it needs an API_VERSION bump. PUBLIC_NAMES = { - "commera.sdk": ["API_VERSION", "STORE_ORDER_TYPE", "as_plugin_user", "cart", "catalog", "orders"], - "commera.sdk.cart": ["get_cart", "set_cart_fields"], + "commera.sdk": [ + "API_VERSION", + "STORE_ORDER_TYPE", + "as_plugin_user", + "cart", + "catalog", + "discounts", + "orders", + ], + "commera.sdk.cart": [ + "add_discount_code", + "add_lines", + "get_cart", + "get_priced_cart", + "remove_discount_code", + "remove_lines", + "set_cart_fields", + "update_lines", + ], + "commera.sdk.discounts": [ + "add_code", + "create_discount", + "generate_codes", + "get_discount", + "get_order_discounts", + "list_discounts", + "preview_cart", + "update_discount", + ], "commera.sdk.catalog": ["get_items"], "commera.sdk.orders": ["ShippingNotInstalled", "get_order", "get_orders", "record_shipment"], "commera.sdk.types": [ @@ -41,8 +68,14 @@ "Charge", "ChargeSummary", "CheckoutSummary", + "Discount", + "DiscountAllocation", + "DiscountInput", + "NewCartLine", "Order", "OrderLine", + "PricedCart", + "PricedCartLine", "Stage", ], } diff --git a/commera/tests/test_sdk_discounts.py b/commera/tests/test_sdk_discounts.py new file mode 100644 index 00000000..15c7a672 --- /dev/null +++ b/commera/tests/test_sdk_discounts.py @@ -0,0 +1,52 @@ +import frappe + +from commera.sdk import discounts +from commera.tests.test_discount_codes import SHIRT_RATE, DiscountCodeTestCase + + +class TestSdkDiscounts(DiscountCodeTestCase): + def setUp(self): + super().setUp() + sale_price_list = frappe.db.get_single_value("Commera Settings", "sale_price_list") + self.addCleanup(self.set_sale_price_list, sale_price_list) + self.set_sale_price_list(self.price_list) + + def set_sale_price_list(self, price_list: str): + frappe.db.set_single_value("Commera Settings", "sale_price_list", price_list) + frappe.clear_document_cache("Commera Settings", "Commera Settings") + + def test_a_code_discount_is_created_with_its_code(self): + created = discounts.create_discount( + {"discount_type": "Amount Off Order", "value_type": "Percentage", "value": 10}, code="zz-sdk" + ) + + self.assertEqual( + (created["title"], created["codes"], created["status"]), ("ZZ-SDK", ["ZZ-SDK"], "Active") + ) + self.assertEqual(discounts.get_discount(created["name"])["value"], 10) + + def test_a_code_discount_without_a_code_is_refused(self): + with self.assertRaises(frappe.ValidationError): + discounts.create_discount({"discount_type": "Amount Off Order", "value": 10}) + + def test_a_shopper_cannot_create_discounts(self): + frappe.set_user("Guest") + + with self.assertRaises(frappe.PermissionError): + discounts.create_discount({"discount_type": "Amount Off Order", "value": 10}, code="zz-guest") + + def test_preview_cart_shows_the_discount_before_checkout(self): + created = discounts.create_discount( + {"discount_type": "Amount Off Order", "value_type": "Percentage", "value": 10}, code="zz-preview" + ) + + preview = discounts.preview_cart([{"item_code": self.shirt, "qty": 2}], codes=created["codes"]) + + self.assertEqual((preview["subtotal"], preview["total"]), (2 * SHIRT_RATE, 2 * SHIRT_RATE * 0.9)) + self.assertEqual([row["discount"] for row in preview["discounts"]], [created["name"]]) + + def test_an_order_reports_its_discounts(self): + code = self.create_code(percentage=10) + sales_order = self.place_order(self.create_quotation({self.shirt: 1}, (code,))) + + self.assertEqual([row["amount"] for row in discounts.get_order_discounts(sales_order.name)], [9]) From d42b6a200e721b28cc0891b06a84e2f6f42e69b3 Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Sun, 11 Oct 2026 01:30:42 +0000 Subject: [PATCH 006/109] docs(discounts): document discounts and the server cart Explains why discounts are not ERPNext coupons, the discount step on each save, the cart sync, the dashboard screens, the SDK and the records, with diagrams and screenshots. --- docs/implementation/discounts.md | 135 ++++++++++++++++++ .../media/discounts-buy-x-get-y.png | Bin 0 -> 111143 bytes .../media/discounts-conditions.png | Bin 0 -> 88327 bytes .../implementation/media/discounts-create.png | Bin 0 -> 129285 bytes docs/implementation/media/discounts-dark.png | Bin 0 -> 121991 bytes docs/implementation/media/discounts-list.png | Bin 0 -> 107480 bytes .../implementation/media/discounts-mobile.png | Bin 0 -> 117107 bytes .../media/discounts-order-code.png | Bin 0 -> 112593 bytes .../media/discounts-spend-tiers.png | Bin 0 -> 100793 bytes .../media/discounts-unique-codes.png | Bin 0 -> 121501 bytes 10 files changed, 135 insertions(+) create mode 100644 docs/implementation/discounts.md create mode 100644 docs/implementation/media/discounts-buy-x-get-y.png create mode 100644 docs/implementation/media/discounts-conditions.png create mode 100644 docs/implementation/media/discounts-create.png create mode 100644 docs/implementation/media/discounts-dark.png create mode 100644 docs/implementation/media/discounts-list.png create mode 100644 docs/implementation/media/discounts-mobile.png create mode 100644 docs/implementation/media/discounts-order-code.png create mode 100644 docs/implementation/media/discounts-spend-tiers.png create mode 100644 docs/implementation/media/discounts-unique-codes.png diff --git a/docs/implementation/discounts.md b/docs/implementation/discounts.md new file mode 100644 index 00000000..d3e92a2f --- /dev/null +++ b/docs/implementation/discounts.md @@ -0,0 +1,135 @@ +# Discounts and the server cart + +Commera keeps the cart on the server and applies Shopify-style discounts on top of ERPNext pricing. ERPNext stays the price authority, and the Quotation, Sales Order, Sales Invoice and GL flow does not change. + +## Why not ERPNext coupons + +ERPNext allows one `coupon_code` on a document. Its Pricing Rule matcher also reads coupon rules on every save, before it checks the coupon: + +- Two coupon rules on the same item, with no priority, make every cart with that item fail with `MultiplePricingRuleConflict`. +- A coupon rule with a priority wins the match, is then skipped because no coupon is applied, and removes the automatic rule on that item. + +Commera Discount records are not Pricing Rules, so the ERPNext matcher does not see them. Automatic Pricing Rules still apply first, and discounts stack on top. + +## How it fits together + +```mermaid +flowchart TB + browser["Storefront browser cart"] <-->|"load and sync, by version"| cart["Commera Cart: lines, attributes, codes"] + sdk["Plugins and server code: commera.sdk"] -->|"add_lines, add_discount_code"| cart + admin["Dashboard: Discounts screens"] -->|"commera.api.admin.discounts"| discount["Commera Discount + Commera Discount Code"] + cart -->|"checkout: attributes and codes"| quotation["Quotation"] + discount -->|"applied on every save"| quotation + quotation --> order["Sales Order"] --> invoice["Sales Invoice"] + order -->|"after insert"| use["Commera Discount Use"] + use -->|"counts, budget, once per customer"| discount +``` + +## Discount types and rules + +| Area | Options | +| --- | --- | +| Method | Code (typed by the shopper, or many generated single-use codes) or Automatic | +| Type | Amount off products, Amount off order, Buy X get Y, Free shipping | +| Value | Percentage with an optional maximum ("10% off, up to ₹200"), fixed amount on each item or once per order, spend tiers for order discounts | +| Products | All, specific products, collections (with sub-collections) or brands; excluded products; skip items already on sale | +| Conditions | Minimum subtotal or item count; prepaid or cash on delivery only; everyone, first order only, or customer groups | +| Combinations | Combines with product, order and shipping discounts. Two discounts stack only when each allows the other | +| Limits | Total uses, once per customer (by customer, email or phone), budget (total discount given), start and end dates | +| Reporting | ERPNext Campaign and discount account | + +An item is on sale when its sale price list rate is below its default price list rate. + +## The discount step on each save + +Quotation, Sales Order and Sales Invoice run the same two hooks from `commera/discounts/pricing.py`: + +1. `before_validate` takes off the amounts the last save added. The result is clamped at zero, so other code that resets these fields cannot cause a double removal. +2. ERPNext prices the document: price lists, automatic Pricing Rules, taxes. +3. `validate` adds the discounts again: + 1. A storefront cart chooses its discounts: the shopper's codes first, then automatic discounts, each kept while its conditions hold and it combines with those already chosen. A Sales Order or Sales Invoice keeps the discounts its cart had. + 2. Product discounts: the best one on each line goes into the line `discount_amount`. + 3. Order discounts: they come off the subtotal after product discounts, into the document `discount_amount` with `apply_discount_on = Net Total`. + 4. The step writes `custom_discount_allocations`: one row per discount with its amount. + +Shipping discounts change the delivery options after the plugin `delivery_options` hooks, so the price shown and the price charged are the same. The checkout's cash on delivery summary prices the cart again without prepaid-only discounts. + +Each Sales Order logs one Commera Discount Use per discount when it is created. Cash on delivery orders stay draft, so the log does not wait for submit. Cancelling or deleting the order removes its uses. + +## Server cart sync + +```mermaid +sequenceDiagram + participant B as Browser cart + participant S as Server cart + participant P as Plugin (SDK) + B->>S: get_server_cart + S-->>B: lines + version v2 (browser replaces its lines when the versions differ) + B->>S: sync_server_cart(items, v2), 400 ms after a change + S-->>B: version v3 + P->>S: add_lines, saved as v4 + B->>S: sync_server_cart(items, v3) + S-->>B: conflict + server lines, v4 (nothing written) +``` + +At checkout, quantities come from the browser, so a shopper can check out only the selected lines. The server cart adds each line's attributes and the codes. + +## Dashboard + +Discounts sits in the sidebar under Customers. The editor opens on Setup. Conditions, Limits and dates, and Reporting are separate tabs, and their optional sections stay collapsed with a one-line summary. The side panel shows the rules in words, the results as number cards, and a test cart priced on the server with the unsaved draft. + +| Screen | Screenshot | +| --- | --- | +| List | ![Discounts list](media/discounts-list.png) | +| Create | ![Pick a discount type](media/discounts-create.png) | +| Order code with a cap and a test cart | ![Order code](media/discounts-order-code.png) | +| Conditions, collapsed | ![Conditions tab](media/discounts-conditions.png) | +| Collection discount with unique codes and a budget | ![Unique codes](media/discounts-unique-codes.png) | +| Automatic spend tiers | ![Spend tiers](media/discounts-spend-tiers.png) | +| Buy X get Y | ![Buy X get Y](media/discounts-buy-x-get-y.png) | +| Phone | ![Phone layout](media/discounts-mobile.png) | +| Dark | ![Dark theme](media/discounts-dark.png) | + +## SDK + +`commera.sdk.discounts` writes with the caller's own permission on Commera Discount. Use `commera.sdk.as_plugin_user` when there is no user session, for example in a webhook. + +```python +from commera.sdk import cart, discounts + +created = discounts.create_discount( + {"discount_type": "Amount Off Order", "value_type": "Percentage", "value": 10, "maximum_discount": 200}, + code="WELCOME10", +) +discounts.generate_codes(created["name"], 500, prefix="RIYA-") + +# Show the amount before checkout, for example on a landing page. +preview = discounts.preview_cart([{"item_code": "SHIRT-M", "qty": 2}], codes=["WELCOME10"]) + +# Build the shopper's cart on the server; the storefront shows it on the next page load. +cart.add_lines([{"item_code": "RING-7", "qty": 1, "attributes": {"engraving": "A&B"}}]) +cart.add_discount_code("WELCOME10") +``` + +| Module | Functions | +| --- | --- | +| `commera.sdk.discounts` | `create_discount`, `update_discount`, `get_discount`, `list_discounts`, `add_code`, `generate_codes`, `preview_cart`, `get_order_discounts` | +| `commera.sdk.cart` | `get_priced_cart`, `add_lines`, `update_lines`, `remove_lines`, `add_discount_code`, `remove_discount_code` (and the existing `get_cart`, `set_cart_fields`) | + +## Records + +| Doctype | Holds | +| --- | --- | +| Commera Discount | The rules, with child tables Commera Discount Target, Commera Discount Tier and Commera Discount Customer Group | +| Commera Discount Code | A code shoppers type, its discount, whether it was generated (single use) and its use count | +| Commera Discount Use | One row per discount per Sales Order: amount, order total, customer, email, phone | +| Commera Cart | The shopper's server cart, with Commera Cart Line rows | + +Custom fields on Quotation, Sales Order and Sales Invoice (and their items) record the codes, each line's code amount and discount, the order and shipping discount, and the allocations. + +## Not done yet + +- The storefront checkout coupon box still applies one ERPNext coupon. A box for several Commera codes needs its own design. +- Usage limits are checked when a code is applied and when the cart is priced. Two orders placed at the same moment can both take the last use. +- The browser cart does not show line attributes, and a guest cart does not merge into the account cart at sign-in. +- No job removes old guest carts yet. diff --git a/docs/implementation/media/discounts-buy-x-get-y.png b/docs/implementation/media/discounts-buy-x-get-y.png new file mode 100644 index 0000000000000000000000000000000000000000..20d99b6515941b6abb20653542a14f0d973314e7 GIT binary patch literal 111143 zcmcG0^;cV66D}<+QmmBXR-{O=;_mKH+=~<3El6A3-6gn7afjk=!QCZLECjck_xs*& zt@{Vudw$58m6fyiK67Tzo_Xe(gsCV=W4t4Jhk$^9AuA*C4FTb84g$g(1(etDZ#LQU z!3YTN5o9Gq)jc!+uA%BD>>(nbBa_-5He*rnCa8w8i>gxht9->)=1^r+P*fY4Wvl-< zdJKv$Ecz*(XKa%C3qLO}UfMH9<@L88rRaz)gTc06uU>2s->iPue|P`z{6Y@AA)5q7 z*`1yFW*?Y`N>IL@Vopj*f*c>?z!w0&ehj!${#QakASHQ?`LBF<{@(FlQ6wbf2jagn zpTHZEf5oXSip_t;ADCFLkpGokJyCf66?1yU|GfHFCM65_R}$gBJekYxu z&@XT~n8@OD`=+H;{1RW(?Durv>HEOO#s;35iHL}hU74AgsY^-|@N(Yh^p&+y!3mRi zlVk9OfnjE0f%d;cvJlp2E6iYk^@-TZ#$?%lI&?j8QBw!f(C>QX=tOpPy=?bK0GP#f zkzOI!8~wYwO>^;pU?>zi7(qTjmti?p@%(ch&yhqYG8?51o%p#6IL()xOK zJ;MFwzl*nGq5dE{I)g!pCTFFJF^PkVJ4Xh_INBf2a5a?~%|U`sP}2S1XJ)}omlN_bm*O^MA@(4oZf#P0zl*IosIq17n2S zkDacy$)au2fBAQ6#i?_4Y3pL71`sZ|+NeuPUi0ZE{TJ(;Fyf?B6vQWAoUAAMve z>tTJqfcnT6u@RGPT)tu_W;GwNL=*!eITdcssIqTT{X)(-Hx!+*7kExX-sFwkep*Vw zG_%W%1b-e88~AH1*Q_KG^oFr?Jv#4=BzZ#yc8WjZ;u>`N@G>&m&X%aUc87h3-jkdB%P@mei^U=QGx$4{TpAhK0 zLZ)X>cPp(zvKCxd2im2kKcRqi3Z)ki>LaV70DZfm#WO>ojUn@B;ix_RhkuXu7 z^hry+NH2p%GB0zxYu@T_DAOzfIPIpx<4Hs<5v!_xbve>300+LJ(O@5ZHWDE1IG#CV zU$`DX0=iLEiY1b;na<){O5g>o(%!P28S&#l0Wioe;viQux6@k_ArOnE+x~;)ykE^R zC5U~@U+^F0x$^uPJ$}R^quk*NWOBFPX8BGjgQeGg+r0OS93PAIlU@FnW%Lk({m{$R z)fFkLNnk(#BB_#`9G}y+gs0~%+*T*E1?68(=F0KCG>NPNDc_@fR6evmZre(E=@=Gv z`Q+M9X2RdM;_>vV>S8vnd*Rm3XH8v2MMO78^3~wZ-!8JVhjS;0FIe3?D8_*#_eG0; zNzrbEi?fa!MI^%{UmXt~IUU(B#)C-|3!W1c2gnlI_*7@;xdfr&PZMynVFU%C)@3TL+qoo5zLG3A&aI z7H<(#9bl&0jDBk&NUl6v4>b0%;3iE<#=fY7-u8L+EOcv#Mk!VH>*)e=);^dA`afMs zWpJ5=ed77cRSit`sh5^@v= zN4)}*9d9y{ohNd~Ps`}p0%A=~&Eth?4>z~UG!C(Wl@;K0o+JllSY%{02cEM zd$XA~&Eb~D0mwncHaC&Cau@;Yl#V zua%29;g@FnAXo6DbQScC^8GRPc@^2+-Qbz#8>FhcXeD_c@TR1r=~?<8+?BGClqw3p zmGD2DKnIlFg!#Xb%JKrwmql(2^FB>>{*QKExk>$D!pLt2E#!|S0+HWV-SXBnPmh;V zV1L{XYZ$}el%+~tvyB4GQp7*KK;}s}vI*uC7u=;>gDadOP9g(1{m&jFjCdeN*M^?0 z6SN2&nTnHC15Y%t^43&y9UhGBul+YOmmfD=yZoJc(LHGO05}orr)ph7srtCmQ%p|Y zc`uin0K^uRc+WLB>Mbo zC8E+J_{A?p1qJox>w>r@?vrcq*~MWkbglFIB7>b&AY0hRbHN7skzd_aGaMuJ!jjTzp19AaVvS$hjHb*pjbC~WgV z$$@p6qIpeQRd;6vE33bD>AEF#3Z$8bH1Bne(|_7y7F7guSL4ZgQJlS+S^QD?J2;t+ z;Vy3#a*q3Kl)0tnd56hne@i8m!Qb6R&VFVBYbZmL@m>P51~|6U@XWO^P1c?ZdNxrD z!iLB1+hMzmtZIW|(xT=cRK0JD*)=6B++094-@7A4D*~g~iFVz)QkNv>oknXPA1x_f3&rBGOB>y%d7dDO0kK-#H>murWvVtz{{^Y8vD3d)K z%bwM%_WrgXgy!+Lo%hS`AUgXbVP`E{1kMez^ix)5;)%uhG{?yaSM#pBv%9F`ary|! zUWS&Qq;X?TT}=-rPf0sFLs53Am-!Z{&*lRvUX)}BWAwwD;szsq0nd|8d_|Vq;kaUm z#@_3*VBu5=ooSl|seRUmakm3H?SNDkHnQD^r&R|F9@+A^0+%!3tx*B@RtMup4vQ(m z>+$X>)OX9%hmN#k2C*LdoBgiE5c$sJo6or%jw&23ny_FDe4T(!UeE7UFiN9Dx2X+Z zi=lxT9CxMf^F|GHf>7Ps#U5_Y>WYQ%2Yl`dlyNGPd-LIMe(#k+0%y=pAix;ASR~;a zWq(#D6B$Fd&6z*Tp!L}z>q8UD$rTLQhGhG3inqTY z`5H7_Y8)>TI1iM&2P7{Sce-;XJXmrC>hJ1#o(bSF`XWcv_E@$)goH>;5O8Tp->EiO zm9M;o8|swFugJwwF?n)idHHQ)LK3_T-NV1OBL3jjuy^_--*yk{zE@JU(Q#3GUPuLi zG`&(ks8vVB{nb@e@AKH*`^|fLTBJItynzdgpWmlb;LQ^A_T9Al^6*MZUJsYx+pd61 zx7)Hl_1)*MM=7uYZsZyB(hOiafjnh#mMy@;t`0~inZ(nNA}Hrn4KQ_zD!kU0Oa zXe1!;>Yeg){*{9;XC2buPNa*QVO{;)2J~#zQefKF0#mI^%R=vF!J5CE;D2wR;srCkR_jzxT?gU z0_kQ9CUG&i`(^85BaRf8m?EFv>eUb?|IFZ7Vq=}NE57Wxs0EBsMZWeEUWjlJ2K&#> z#_mc9>uF~iuPcoS0D34YyPWP#kU9?59gE{0BWx_kP$qM*<<9kk<`7*oEo@ORvb%8$ zX(d8J0s~3LHgSZ6W2))0zb|8M6D2e{3O|iC2xN(N8fvTz)e><0T!$)$RteEHXpJ8Y z?CDOanDmFo&J64LWQn2$h<5>+!6?Fa3a`gKeJs%vjA}iIIehOw6^+<9#1)Yk2c