diff --git a/commera/api/admin/analytics.py b/commera/api/admin/analytics.py index 8f1738b8..6cbe9d48 100644 --- a/commera/api/admin/analytics.py +++ b/commera/api/admin/analytics.py @@ -32,6 +32,7 @@ in_window, is_provider_configured, ) +from commera.discounts.allocations import get_allocations ANALYTICS_SETTINGS_DOCTYPE = "Analytics Settings" @@ -49,6 +50,8 @@ SCRIPT_FIELDS = ("title", "enabled", "script") +TOP_DISCOUNTS_LIMIT = 5 + def get_analytics_settings_doc(): frappe.only_for("System Manager") @@ -247,6 +250,8 @@ def get_revenue_report(months: int = REPORT_MONTHS_DEFAULT): sales_order.transaction_date, sales_order.base_grand_total, sales_order.base_discount_amount, + sales_order.conversion_rate, + sales_order.custom_discount_allocations, ) .where(is_webshop_order(sales_order)) .where(sales_order.transaction_date >= start) @@ -264,7 +269,7 @@ def get_revenue_report(months: int = REPORT_MONTHS_DEFAULT): ) bucket["revenue"] += flt(row.base_grand_total) bucket["orders"] += 1 - bucket["discounts"] += flt(row.base_discount_amount) + bucket["discounts"] += get_base_discount_given(row) bucket["refunds"] += flt(refunds_by_order.get(row.name, 0)) series = [] @@ -303,9 +308,51 @@ def get_revenue_report(months: int = REPORT_MONTHS_DEFAULT): }, "refunds": {"value": total_refunds, "previous": previous["refunds"]}, }, + "discounts": get_discount_summary(order_rows), + } + + +def get_discount_summary(order_rows) -> dict: + given, orders, by_label = 0.0, 0, {} + for row in order_rows: + allocations = get_allocations(row) + if not allocations: + continue + orders += 1 + labels_on_order = set() + for allocation in allocations: + amount = get_base_allocation_amount(row, allocation) + label = allocation.get("code") or allocation.get("title") + given += amount + entry = by_label.setdefault(label, {"label": label, "given": 0.0, "orders": 0}) + entry["given"] += amount + if label not in labels_on_order: + entry["orders"] += 1 + labels_on_order.add(label) + top = sorted(by_label.values(), key=lambda entry: entry["given"], reverse=True)[:TOP_DISCOUNTS_LIMIT] + return { + "given": flt(given, 2), + "orders": orders, + "top": [{**entry, "given": flt(entry["given"], 2)} for entry in top], } +def get_base_discount_given(row) -> float: + # Codes, plus what ERPNext gave on its own (a Pricing Rule, staff's Additional Discount) beyond the order code. + allocations = get_allocations(row) + codes = sum(get_base_allocation_amount(row, allocation) for allocation in allocations) + order_codes = sum( + get_base_allocation_amount(row, allocation) + for allocation in allocations + if allocation.get("discount_class") == "Order" + ) + return codes + max(flt(row.base_discount_amount) - order_codes, 0) + + +def get_base_allocation_amount(row, allocation: dict) -> float: + return flt(allocation.get("amount")) * (flt(row.conversion_rate) or 1) + + def get_item_sales(from_date, to_date, item_codes): """units/revenue/last-sold per sellable size, one aggregate query regardless of scope. from_date=None scans the size's whole history ("last sold, ever"); drafts count - see is_webshop_order.""" diff --git a/commera/api/admin/customers.py b/commera/api/admin/customers.py index a857ff96..f77b504b 100644 --- a/commera/api/admin/customers.py +++ b/commera/api/admin/customers.py @@ -5,6 +5,7 @@ import frappe from frappe import _ +from frappe.query_builder import Order from frappe.query_builder.functions import Count, Sum from frappe.utils.data import cint, cstr, date_diff, flt, formatdate @@ -189,6 +190,7 @@ def get_customer(customer: str): payment_modes = Counter( row.custom_ecommerce_payment_mode for row in lifetime_orders if row.custom_ecommerce_payment_mode ) + discount_uses = read_customer_discount_uses(customer) return { "id": doc.name, @@ -210,6 +212,8 @@ def get_customer(customer: str): "top_products": get_top_products(items), "spend_by_month": get_spend_by_month(lifetime_orders), "note": cstr(doc.customer_details), + "discount_uses": discount_uses, + "discount_saved": sum(flt(use.amount) for use in discount_uses), "recent_orders": [ {key: value for key, value in order.items() if key != "base_total"} for order in read_customer_orders(customer) @@ -333,6 +337,25 @@ def get_acquisition(first_order: str | None) -> dict | None: return {"source": event[0].utm_source, "campaign": event[0].utm_campaign or None} +def read_customer_discount_uses(customer: str) -> list: + use = frappe.qb.DocType("Commera Discount Use") + discount = frappe.qb.DocType("Commera Discount") + return ( + frappe.qb.from_(use) + .left_join(discount) + .on(discount.name == use.discount) + .select( + use.sales_order, + use.code, + use.base_amount.as_("amount"), + use.creation.as_("used_on"), + discount.title, + ) + .where(use.customer == customer) + .orderby(use.creation, order=Order.desc) + ).run(as_dict=True) + + def read_customer_orders(customer: str) -> list: """This one customer's own order history, in the same shape orders.get_orders uses for its rows. Counts drafts, for the reason read_customer_order_stats gives.""" diff --git a/commera/api/admin/discounts.py b/commera/api/admin/discounts.py new file mode 100644 index 00000000..90807f47 --- /dev/null +++ b/commera/api/admin/discounts.py @@ -0,0 +1,425 @@ +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, enqueue_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",), + "customers": ("customer", "customer_name"), +} +LIST_FIELDS = ( + "name", + "title", + "currency", + "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", + "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", +) + + +@frappe.whitelist() +def get_discounts( + tab: str = "all", search: str | None = None, start: int = 0, page_length: int = PAGE_LENGTH +): + 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) + + names = [row.name for row in rows] + codes = get_code_summaries(names) + tiers = get_tiers(names) + return { + "discounts": [ + {**row, **codes.get(row.name, {}), "tiers": tiers.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 in ("active", "used_up"): + started = discount.starts_on.isnull() | (discount.starts_on <= day) + running = discount.ends_on.isnull() | (discount.ends_on >= day) + used_up = ((discount.usage_limit > 0) & (discount.times_used >= discount.usage_limit)) | ( + (discount.budget > 0) & (discount.discount_given >= discount.budget) + ) + live = (discount.enabled == 1) & started & running + return live & used_up if tab == "used_up" else live & used_up.negate() + 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_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 {} + 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: + 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]]) + is_new = doc.is_new() + doc.flags.code = values.get("code") + doc.save() + + if not is_new and 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): + # A used code is kept, not renamed, 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.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) + created = enqueue_codes(name, count, prefix or "") + return {"created": created, "queued": cint(count) - created} + + +@frappe.whitelist() +def download_codes(name: str): + 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, + customer: str | None = None, +) -> dict: + # With a customer, first_order is ignored: their group and history decide. + frappe.has_permission(DOCTYPE, ptype="read", throw=True) + if customer: + frappe.has_permission("Customer", doc=customer, 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": None if customer else bool(cint(first_order)), + } + quotation = get_preview_quotation( + lines, flags=flags, fields={"party_name": customer} if customer else None + ) + 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 + ], + "currency": quotation.currency, + "subtotal": subtotal, + "discount": subtotal - flt(quotation.net_total), + "total": flt(quotation.net_total), + "reasons": [reason] if (reason := get_unmet_reason(draft, get_cart_context(quotation))) else [], + } + + +@frappe.whitelist() +def get_editor_options() -> dict: + 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": as_picker_options( + frappe.get_all("Customer Group", filters={"is_group": 0}, pluck="name", order_by="name") + ), + "currencies": [ + {"label": currency.name, "value": currency.name, "symbol": currency.symbol} + for currency in frappe.get_all( + "Currency", filters={"enabled": 1}, fields=["name", "symbol"], order_by="name" + ) + ], + "campaigns": frappe.get_all("UTM 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": as_picker_options(get_delivery_option_titles()), + } + + +def as_picker_options(names: list[str]) -> list[dict]: + return [{"label": name, "value": name} for name in names] + + +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, sellable: int = 0) -> list[dict]: + # sellable returns variants (each size), not the template products. + search = f"%{cstr(search)}%" + if target_type == "Item": + frappe.has_permission("Item", ptype="read", throw=True) + rows = frappe.get_all( + "Item", + 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", + 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 == "Customer": + frappe.has_permission("Customer", ptype="read", throw=True) + rows = frappe.get_list( + "Customer", + filters={"disabled": 0}, + or_filters={"name": ("like", search), "customer_name": ("like", search)}, + fields=["name", "customer_name", "customer_group"], + order_by="customer_name", + limit=20, + ) + return [ + {"label": row.customer_name, "value": row.name, "description": row.customer_group} for row in rows + ] + 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/api/admin/orders.py b/commera/api/admin/orders.py index c135bf14..ad376db9 100644 --- a/commera/api/admin/orders.py +++ b/commera/api/admin/orders.py @@ -11,6 +11,7 @@ from commera.api.admin.inventory import get_inventory from commera.api.admin.plugins import query_deliveries from commera.api.shipping import get_order_charge_lines +from commera.discounts.allocations import get_allocations, get_discount_rows, get_order_discount_rows from commera.utils import get_address_lines, get_item_images PAGE_LENGTH = 20 @@ -34,6 +35,7 @@ "currency", "total", "net_total", + "discount_amount", "total_taxes_and_charges", "grand_total", "rounded_total", @@ -44,6 +46,8 @@ "custom_ecommerce_payment_mode", "custom_ecommerce_status", "custom_delivery_option", + "custom_discount_allocations", + "custom_cart_attributes", "shipping_address", "address_display", "modified", @@ -617,6 +621,7 @@ def get_order(sales_order: str): state = describe_state(order, lifecycle) charges = get_order_charges(order) payment_state = describe_payment(order) + allocations = get_allocations(order) return { "name": order.name, @@ -632,6 +637,8 @@ def get_order(sales_order: str): "total": flt(order.total), "net_total": flt(order.net_total), "shipping": charges["shipping"], + "order_discounts": get_order_discount_rows(order), + "shipping_discounts": get_discount_rows(allocations, ("Shipping",)), # The delivery option the shopper actually chose and paid for. Empty on an order placed # before the shipping connector was installed, or one that took the flat Shipping Rule. "delivery_option": order.custom_delivery_option, @@ -656,6 +663,7 @@ def get_order(sales_order: str): "rate": flt(row.rate), "amount": flt(row.amount), "image": row.image, + "discounts": get_discount_rows(allocations, line=row.idx), } for row in order.lines ], @@ -712,6 +720,7 @@ def read_order_lines(order_names: list[str]) -> dict[str, list]: fields=[ "name", "parent", + "idx", "item_code", "item_name", "qty", @@ -720,6 +729,8 @@ def read_order_lines(order_names: list[str]) -> dict[str, list]: "rate", "amount", "image", + "custom_code_discount", + "custom_line_attributes", ], order_by="idx asc", ) diff --git a/commera/api/orders.py b/commera/api/orders.py index f2f99d98..5c4096ce 100644 --- a/commera/api/orders.py +++ b/commera/api/orders.py @@ -4,6 +4,7 @@ from frappe.utils import flt from commera.api.payments import system_user_session +from commera.api.shipping import get_charge_amount from commera.plugin_events import fire_order_refunded from commera.utils import update_sales_order_ecommerce_status, validate_document_access @@ -248,12 +249,13 @@ def get_refund_status(order_id: str | int) -> dict: ) total_refunded = sum(flt(pe.paid_amount) for pe in refund_payment_entries) + payable_total = get_charge_amount(order) - if total_refunded >= order.rounded_total: + if total_refunded >= payable_total: return get_refund_refusal(order, _("This order has already been refunded in full."), total_refunded) only_charges = total_refunded >= order.net_total - refundable_amount = order.rounded_total - total_refunded + refundable_amount = payable_total - total_refunded return { "can_refund": True, diff --git a/commera/api/payments.py b/commera/api/payments.py index 8377424d..2d943a45 100644 --- a/commera/api/payments.py +++ b/commera/api/payments.py @@ -4,7 +4,6 @@ from bwh_payments.bwh_payments.utils import get_available_payment_modes, resolve_payment_mode from erpnext.accounts.doctype.journal_entry.journal_entry import get_default_bank_cash_account from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry -from erpnext.accounts.doctype.pricing_rule.utils import validate_coupon_code from frappe import _ from frappe.rate_limiter import rate_limit from frappe.utils import getdate, validate_email_address @@ -21,10 +20,29 @@ get_checkout_summary, get_cod_charge, reprice_selected_option, + reprice_shipping_discount, ) 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.core import _get_cart_quotation, create_party, get_customer_contact, new_cart_quotation +from commera.checkout_hooks import ( + apply_plugin_fees, + filter_payment_methods, + get_cart_refusal, +) +from commera.commera_ecommerce.doctype.commera_cart.commera_cart import ( + clear_ordered_cart, + get_quantities_by_item_code, + get_session_cart, +) +from commera.core import ( + COD_PAYMENT_MODE, + _get_cart_quotation, + create_party, + get_customer_contact, + is_cod, + new_cart_quotation, + system_user_session, +) +from commera.discounts.selection import get_discount_codes, get_unusable_code_reason from commera.guest import ( get_guest_cart_name, is_guest, @@ -34,13 +52,20 @@ ) from commera.order_access import get_order_link, set_order_access_key from commera.plugin_events import fire_event -from commera.utils import get_charge_account_head, get_pickup_addresses, get_pickup_warehouses +from commera.utils import ( + get_charge_account_head, + get_pickup_addresses, + get_pickup_warehouses, +) class CheckoutPriceChangedError(frappe.ValidationError): pass +COD_TOTAL_TTL_SECONDS = 24 * 60 * 60 + + # ERPNext moved its transaction mappers to a sibling `mapper` module; both layouts are in the wild. try: from erpnext.selling.doctype.quotation.mapper import _make_sales_order @@ -52,12 +77,6 @@ class CheckoutPriceChangedError(frappe.ValidationError): except ImportError: from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice -COD_PAYMENT_MODE = "COD" - - -def is_cod(payment_mode: str | None) -> bool: - return (payment_mode or "").strip().casefold() == COD_PAYMENT_MODE.casefold() - def get_open_gateway_payment_request(quotation_name: str) -> str | None: for status in ("Paid", "Pending"): @@ -145,6 +164,7 @@ def open_checkout( validate_delivery_option(quotation, delivery_option) validate_cart_is_not_in_checkout(quotation.name) quotation.flags.strict_plugin_fees = True + drop_refused_codes(quotation) update_delivery_charges(quotation) validate_cart(quotation) validate_expected_total(quotation, payment_mode, expected_total) @@ -153,6 +173,11 @@ def open_checkout( if is_cod(payment_mode): if COD_PAYMENT_MODE not in payment_methods: refuse_payment(_("Cash on delivery is not available."), quotation.name) + frappe.cache.set_value( + get_cod_total_key(quotation.name), + get_payable_total(quotation, payment_mode), + expires_in_sec=COD_TOTAL_TTL_SECONDS, + ) return {"order_url": get_confirmation_url(quotation.name, payment_mode=COD_PAYMENT_MODE)} gateway = resolve_payment_mode(payment_mode) @@ -230,6 +255,34 @@ def validate_cart(quotation): frappe.throw(refusal) +def drop_refused_codes(quotation): + reasons = [] + codes = get_discount_codes(quotation) + kept_codes = [] + for code in codes: + if reason := get_unusable_code_reason(quotation, code): + reasons.append(reason) + else: + kept_codes.append(code) + if not reasons: + return + + quotation.custom_discount_codes = "\n".join(kept_codes) or None + reprice_shipping_discount(quotation) + save_cart_quotation(quotation) + if server_cart := get_session_cart(): + for code in set(codes) - set(kept_codes): + server_cart.remove_discount_code(code) + server_cart.save(ignore_permissions=True) + # nosemgrep: frappe-manual-commit # kept past the throw below, or every retry would refuse again + frappe.db.commit() + frappe.throw( + " ".join([*reasons, _("We removed the code and updated your total.")]), + title=_("Discount Removed"), + exc=CheckoutPriceChangedError, + ) + + def validate_delivery_option(quotation, delivery_option: str | None): # Payment reprices the stored option, so it must be the one the shopper is looking at. if cstr(quotation.custom_delivery_option) == cstr(delivery_option): @@ -261,6 +314,28 @@ def validate_expected_total(quotation, payment_mode: str, expected_total: float ) +def get_cod_total_key(quotation_name: str) -> str: + return f"commera:cod_total:{quotation_name}" + + +def validate_cod_total(quotation): + # Placement re-prices for cash on delivery; a discount that ran out since checkout must not raise it quietly. + confirmed_total = frappe.cache.get_value(get_cod_total_key(quotation.name)) + if confirmed_total is None: + return + total = get_charge_amount(quotation) + precision = quotation.precision("grand_total") + if flt(confirmed_total, precision) == flt(total, precision): + return + frappe.throw( + _("Your order total has changed to {0}. Please review it before placing your order.").format( + fmt_money(total, currency=quotation.currency) + ), + title=_("Order Total Changed"), + exc=CheckoutPriceChangedError, + ) + + def get_payable_total(quotation, payment_mode: str) -> float: if is_cod(payment_mode): return get_checkout_summary(quotation)["cash_on_delivery"]["total"] @@ -285,26 +360,6 @@ def gateway_mode_of_payment(gateway: str) -> str: return mode_of_payment -@contextmanager -def system_user_session(user: str = "Administrator"): - """Place the accounting documents as `user`, then hand the session back. - - ERPNext's get_party_account checks frappe.has_permission directly, so no ignore_permissions reaches it. - """ - # frappe.set_user() mutates frappe.local.session in place, stamping "sid" with the username and wiping - # local.session.data, so restore the snapshot verbatim rather than calling it again for the shopper. - live_session_snapshot = frappe.local.session.copy() - try: - # Audited: the docstring above and the snapshot restore below are why this is safe. - frappe.set_user(user) # nosemgrep: frappe-semgrep-rules.rules.security.frappe-setuser - yield - finally: - frappe.local.session.update(live_session_snapshot) - frappe.local.cache = {} - frappe.local.role_permissions = {} - frappe.local.user_perms = None - - @contextmanager def cart_write_lock(quotation): if quotation.is_new(): @@ -367,6 +422,7 @@ def place_order(quotation, payment_mode: str, gateway_amount=None, gateway_refer # Outside the switch: log_purchase stamps frappe.session.user, so Administrator would own every purchase. stamp_order_owner(sales_order, shopper) + clear_ordered_cart(quotation, shopper) log_purchase(sales_order) fire_event("order_placed", "Sales Order", sales_order.name) if paid: @@ -432,7 +488,13 @@ def generate_quotation_for_cart(cart: dict): def get_checkout_cart(cart: dict) -> dict: + """The server cart's ticked lines; the browser's only without a server cart, or when a guest's is empty.""" cart = frappe.parse_json(cart) or {} + if (server_cart := get_session_cart()) and (server_cart.lines or not is_guest()): + cart["items"] = [ + {"variant": {"item_code": item_code}, "qty": qty} + for item_code, qty in get_quantities_by_item_code(server_cart.get_selected_lines()).items() + ] if len(cart.get("items", [])) < 1: frappe.throw(_("Can't checkout with empty cart")) validate_stock_available(cart["items"]) @@ -505,28 +567,15 @@ 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) - coupon_code = unsaved_quotation_doc.coupon_code - _remove_coupon_code(unsaved_quotation_doc) - reapply_coupon_code(unsaved_quotation_doc, coupon_code) set_charges(unsaved_quotation_doc) return save_cart_quotation(unsaved_quotation_doc) -def reapply_coupon_code(quotation, coupon_code: str | None): - if not coupon_code: - return - try: - validate_coupon_code(coupon_code) - except frappe.ValidationError: - # An expired coupon is dropped quietly; the shopper only edited their cart. - frappe.clear_last_message() - return - quotation.coupon_code = coupon_code - - def set_charges(quotation): shipping_rule = frappe.get_cached_value("Commera Settings", "Commera Settings", "shipping_rule") if shipping_rule: @@ -537,10 +586,9 @@ def set_charges(quotation): def set_cod_charges(quotation): - cod_charge = get_cod_charge(quotation) - if not cod_charge: - return - add_cod_charge(quotation, cod_charge, get_charge_account_head()) + if cod_charge := get_cod_charge(quotation): + add_cod_charge(quotation, cod_charge, get_charge_account_head()) + # Saved even without a fee: submit keeps the cart's discounts, so this save prices them for cash on delivery. quotation.flags.ignore_permissions = True quotation.save() @@ -788,10 +836,14 @@ 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" + reprice_shipping_discount(quotation) with system_user_session(): set_cod_charges(quotation) + validate_cod_total(quotation) quotation.flags.ignore_permissions = True quotation.submit() + frappe.cache.delete_value(get_cod_total_key(quotation.name)) sales_order = _make_sales_order(quotation_name, ignore_permissions=True) sales_order.custom_ecommerce_payment_mode = COD_PAYMENT_MODE @@ -803,54 +855,12 @@ def place_cod_order(quotation_name: str): # COD orders count as purchases even while the Sales Order stays draft. stamp_order_owner(sales_order, shopper) + clear_ordered_cart(quotation, shopper) log_purchase(sales_order) fire_event("order_placed", "Sales Order", sales_order.name) return sales_order -# Guest checkout: refuses any caller without a guest cart cookie, and is rate limited. -@frappe.whitelist(allow_guest=True, methods=["POST"]) # nosemgrep: guest-whitelisted-method -@rate_limit(limit=30, seconds=60 * 60) -def apply_coupon_code(applied_code: str): - if not applied_code: - frappe.throw(_("Please enter a coupon code")) - quotation = _get_cart_quotation() - coupon_name = frappe.db.get_value("Coupon Code", {"coupon_code": applied_code}, "name") - if not coupon_name: - frappe.throw(_("Please enter a valid coupon code")) - validate_coupon_code(coupon_name) - validate_cart_is_not_in_checkout(quotation.name) - quotation.coupon_code = coupon_name - save_cart_quotation(quotation) - return { - "message": _("Coupon code applied successfully"), - "checkout_summary": get_checkout_summary(quotation), - } - - -# Guest checkout: refuses any caller without a guest cart cookie, and is rate limited. -@frappe.whitelist(allow_guest=True, methods=["POST"]) # nosemgrep: guest-whitelisted-method -@rate_limit(limit=100, seconds=60 * 60) -def remove_coupon_code(): - quotation = _get_cart_quotation() - validate_cart_is_not_in_checkout(quotation.name) - _remove_coupon_code(quotation) - - -def _remove_coupon_code(quotation): - quotation.coupon_code = "" - quotation.items = [item for item in quotation.items if not item.get("is_free_item")] - for item in quotation.items: - item.discount_percentage = 0 - item.discount_amount = 0 - item.distributed_discount_amount = 0 - item.rate = item.price_list_rate - quotation.calculate_taxes_and_totals() - save_cart_quotation(quotation) - quotation.discount_amount = 0 - save_cart_quotation(quotation) - - def add_billing_address(party_name, address): return add_party_address(party_name, address.get("billing_address", {}), "Billing") diff --git a/commera/api/server_cart.py b/commera/api/server_cart.py new file mode 100644 index 00000000..70b319b2 --- /dev/null +++ b/commera/api/server_cart.py @@ -0,0 +1,100 @@ +import frappe +from frappe import _ +from frappe.rate_limiter import rate_limit +from frappe.utils.data import cint, cstr + +from commera.api.shipping import get_checkout_summary +from commera.commera_ecommerce.doctype.commera_cart.commera_cart import ( + MAX_CART_LINES, + CommeraCart, + get_quantities_by_item_code, + get_session_cart, +) +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 + + +# 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 +@rate_limit(limit=1200, seconds=60 * 60) +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: + # A stale browser version writes nothing; the server cart comes back instead. + quantities = get_browser_quantities(items) + selection = get_browser_selection(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)} + + changed = cart.set_quantities(quantities) + if cart.set_selection(selection) or changed 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 +@rate_limit(limit=600, seconds=60 * 60) +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()), "checkout_summary": get_open_checkout_summary()} + + +@frappe.whitelist(allow_guest=True, methods=["POST"]) # nosemgrep: guest-whitelisted-method +@rate_limit(limit=100, seconds=60 * 60) +def remove_discount_code(code: str) -> dict: + return { + **cart_sdk.remove_discount_code(cstr(code).strip()), + "checkout_summary": get_open_checkout_summary(), + } + + +def get_open_checkout_summary() -> dict | None: + quotation = cart_sdk.get_open_cart_quotation() + return get_checkout_summary(quotation) if quotation else None + + +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_browser_selection(items: list | str) -> dict[str, bool]: + selection = {} + for entry in frappe.parse_json(items) or []: + item_code = cstr(entry.get("item_code")).strip() + selection[item_code] = selection.get(item_code, False) or bool(cint(entry.get("selected", 1))) + return selection + + +def get_cart_state(cart: CommeraCart) -> dict: + selected_item_codes = {row.item_code for row in cart.get_selected_lines()} + lines = get_cart_lines(get_quantities_by_item_code(cart.lines), []) + for line in lines: + line["selected"] = line["variant"]["item_code"] in selected_item_codes + return { + "version": cart.version, + "lines": lines, + "discount_codes": get_discount_codes(cart), + } diff --git a/commera/api/shipping.py b/commera/api/shipping.py index b2676882..3acbbe24 100644 --- a/commera/api/shipping.py +++ b/commera/api/shipping.py @@ -5,10 +5,14 @@ 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.allocations import get_allocations, get_discount_rows, get_price_discount_rows +from commera.discounts.pricing import refresh_code_discounts +from commera.discounts.shipping import apply_shipping_discounts, set_shipping_discount from commera.utils import ( COD_CHARGE_DESCRIPTION, get_charge_account_head, get_cod_configuration, + get_free_delivery, validate_document_access, ) @@ -228,15 +232,30 @@ 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) +def reprice_shipping_discount(quotation): + # The option was priced on a prepaid cart; re-price it for the payment mode. + if not quotation.custom_delivery_option: + return + option = { + "title": quotation.custom_delivery_option, + "amount": flt(quotation.custom_delivery_charge) + flt(quotation.custom_shipping_discount), + "provider": quotation.custom_shipping_provider, + "service_code": quotation.custom_shipping_service_code, + } + apply_delivery_option(quotation, apply_shipping_discounts(quotation, [option])[0]) + + def clear_delivery_option(quotation): quotation.custom_delivery_option = None 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() @@ -324,8 +343,7 @@ def get_charge_lines(taxes, shipping_rule: str | None) -> dict: def get_charge_amount(quotation) -> float: - # rounded_total is 0 when rounding is disabled on the document; grand_total is the billed figure then. - return flt(quotation.rounded_total) or flt(quotation.grand_total) + return flt(quotation.grand_total if quotation.is_rounded_total_disabled() else quotation.rounded_total) def get_checkout_summary(quotation) -> dict: @@ -335,6 +353,10 @@ 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" + reprice_shipping_discount(preview) + 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") @@ -348,11 +370,14 @@ def get_charge_summary(quotation) -> dict: charges = charge_lines["shipping"] + charge_lines["cod_charge"] charges += sum(line["amount"] for line in charge_lines["taxes"] + charge_lines["plugin_fees"]) discount_amount = flt(quotation.discount_amount) + # Derived rather than read: with a Grand Total discount the stored net_total is already partly discounted. + subtotal = flt(flt(quotation.grand_total) + discount_amount - charges, quotation.precision("grand_total")) + line_discount = sum(flt(item.get("custom_code_discount")) * flt(item.qty) for item in quotation.items) return { - # Derived rather than read: with a Grand Total discount the stored net_total is already partly discounted. - "subtotal": flt( - flt(quotation.grand_total) + discount_amount - charges, quotation.precision("grand_total") - ), + "subtotal": subtotal, + "items_total": flt(subtotal + line_discount, quotation.precision("grand_total")), + "discounts": get_price_discount_rows(quotation), + "shipping_discounts": get_discount_rows(get_allocations(quotation), ("Shipping",)), "shipping": charge_lines["shipping"], "cod_charge": charge_lines["cod_charge"], "plugin_fees": charge_lines["plugin_fees"], @@ -360,6 +385,7 @@ def get_charge_summary(quotation) -> dict: "discount_amount": discount_amount, "rounding_adjustment": flt(quotation.rounding_adjustment), "total": get_charge_amount(quotation), + **get_free_delivery(quotation), } @@ -457,8 +483,8 @@ def reprice_selected_option(quotation) -> bool: amount = get_charge_amount( quotation.custom_delivery_option, get_cart_context(quotation), - # A Currency field is never None, so the stored price is always billed and a stored 0 stays free. - quoted_amount=quotation.custom_delivery_charge, + # Before its shipping discount, which the hooks below take off again. + quoted_amount=flt(quotation.custom_delivery_charge) + flt(quotation.custom_shipping_discount), shipping_rule=get_store_shipping_rule(), ) options = [ diff --git a/commera/api/utils.py b/commera/api/utils.py index fba8b47a..9aa3483a 100644 --- a/commera/api/utils.py +++ b/commera/api/utils.py @@ -8,7 +8,11 @@ from frappe.utils import cstr from commera.search import query as search_query -from commera.utils import get_available_stocks, get_product_list, validate_document_access +from commera.utils import ( + get_available_stocks, + get_product_list, + validate_document_access, +) def auth_required(func): diff --git a/commera/checkout_hooks.py b/commera/checkout_hooks.py index 54b9ca33..eb09c3c3 100644 --- a/commera/checkout_hooks.py +++ b/commera/checkout_hooks.py @@ -1,11 +1,11 @@ import math -from contextlib import contextmanager import frappe from frappe import _ from frappe.utils.data import cstr, flt -from commera.plugin_events import get_handlers +from commera.discounts.shipping import apply_shipping_discounts +from commera.plugin_events import get_handlers, handle_hook_error from commera.utils import get_charge_account_head PLUGIN_FEE_FIELD = "commera_plugin_fee" @@ -86,7 +86,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]: @@ -133,21 +133,3 @@ def filter_payment_methods(quotation, methods: list[str], strict: bool) -> list[ ) methods = [method for method in methods if method in kept_methods] return methods - - -@contextmanager -def handle_hook_error(key: str, handler: str, quotation, strict: bool): - message_count = len(frappe.local.message_log) - try: - yield - except Exception: - frappe.log_error( - title=f'commera_hooks["{key}"] hook failed: {handler}'[:140], - reference_doctype="Quotation", - reference_name=quotation.name, - defer_insert=True, - ) - # Whatever the app msgprinted is meant for its developer, never for the shopper. - del frappe.local.message_log[message_count:] - if strict: - frappe.throw(_("Something went wrong, please try again.")) diff --git a/commera/commera_ecommerce/custom/brand.json b/commera/commera_ecommerce/custom/brand.json deleted file mode 100644 index 450596af..00000000 --- a/commera/commera_ecommerce/custom/brand.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "custom_fields": [], - "custom_perms": [], - "doctype": "Brand", - "links": [], - "property_setters": [ - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-05-22 12:31:47.165795", - "default_value": null, - "doc_type": "Brand", - "docstatus": 0, - "doctype_or_field": "DocType", - "field_name": null, - "idx": 0, - "is_system_generated": 0, - "modified": "2025-05-22 12:31:47.165795", - "modified_by": "Administrator", - "module": null, - "name": "Brand-main-field_order", - "owner": "Administrator", - "property": "field_order", - "property_type": "Data", - "row_name": null, - "value": "[\"brand\", \"image\", \"description\", \"display_on_website\", \"defaults\", \"brand_defaults\"]" - } - ], - "sync_on_migrate": 1 -} diff --git a/commera/commera_ecommerce/custom/delivery_note.json b/commera/commera_ecommerce/custom/delivery_note.json new file mode 100644 index 00000000..f71d7a3c --- /dev/null +++ b/commera/commera_ecommerce/custom/delivery_note.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-21 18:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Delivery Note", + "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": "discount_amount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Codes Order Discount", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 18:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Delivery Note-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 + }, + { + "_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-21 18:00:00.000000", + "default": null, + "depends_on": null, + "description": "JSON list of the discounts on this document and their amounts.", + "docstatus": 0, + "dt": "Delivery Note", + "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_code_order_discount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Allocations", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 18:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Delivery Note-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": [], + "doctype": "Delivery Note", + "links": [], + "property_setters": [], + "sync_on_migrate": 1 +} diff --git a/commera/commera_ecommerce/custom/delivery_note_item.json b/commera/commera_ecommerce/custom/delivery_note_item.json index 0032d367..643a6f2b 100644 --- a/commera/commera_ecommerce/custom/delivery_note_item.json +++ b/commera/commera_ecommerce/custom/delivery_note_item.json @@ -127,81 +127,139 @@ "translatable": 0, "unique": 0, "width": null - } - ], - "custom_perms": [], - "doctype": "Delivery Note Item", - "links": [], - "property_setters": [ - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-02-10 17:20:48.134321", - "default_value": null, - "doc_type": "Delivery Note Item", - "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "barcode", - "idx": 0, - "is_system_generated": 1, - "modified": "2025-05-17 10:48:39.259361", - "modified_by": "Administrator", - "module": null, - "name": "Delivery Note Item-barcode-hidden", - "owner": "Administrator", - "property": "hidden", - "property_type": "Check", - "row_name": null, - "value": "0" }, { "_assign": null, "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-05-17 12:49:31.506293", - "default_value": null, - "doc_type": "Delivery Note Item", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 18:00:00.000000", + "default": null, + "depends_on": null, + "description": null, "docstatus": 0, - "doctype_or_field": "DocType", - "field_name": null, + "dt": "Delivery Note 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, - "modified": "2025-05-17 12:49:31.506293", + "is_virtual": 0, + "label": "Discount Code Amount Per Unit", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 18:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Delivery Note Item-main-field_order", + "name": "Delivery Note Item-custom_code_discount", + "no_copy": 0, + "non_negative": 0, + "options": "currency", "owner": "Administrator", - "property": "field_order", - "property_type": "Data", - "row_name": null, - "value": "[\"barcode\", \"has_item_scanned\", \"item_code\", \"item_name\", \"col_break1\", \"customer_item_code\", \"section_break_6\", \"description\", \"brand\", \"item_group\", \"custom_reason_for_return\", \"return_remarks\", \"image_section\", \"image\", \"image_view\", \"quantity_and_rate\", \"qty\", \"stock_uom\", \"col_break2\", \"uom\", \"conversion_factor\", \"stock_qty_sec_break\", \"stock_qty\", \"stock_qty_col_break\", \"returned_qty\", \"section_break_17\", \"price_list_rate\", \"base_price_list_rate\", \"discount_and_margin\", \"margin_type\", \"margin_rate_or_amount\", \"rate_with_margin\", \"column_break_19\", \"discount_percentage\", \"discount_amount\", \"base_rate_with_margin\", \"section_break_1\", \"rate\", \"amount\", \"col_break3\", \"base_rate\", \"base_amount\", \"pricing_rules\", \"stock_uom_rate\", \"is_free_item\", \"grant_commission\", \"section_break_25\", \"net_rate\", \"net_amount\", \"item_tax_template\", \"column_break_28\", \"base_net_rate\", \"base_net_amount\", \"billed_amt\", \"incoming_rate\", \"item_weight_details\", \"weight_per_unit\", \"total_weight\", \"column_break_21\", \"weight_uom\", \"warehouse_and_reference\", \"warehouse\", \"target_warehouse\", \"quality_inspection\", \"col_break4\", \"allow_zero_valuation_rate\", \"against_sales_order\", \"so_detail\", \"against_sales_invoice\", \"si_detail\", \"dn_detail\", \"pick_list_item\", \"section_break_40\", \"pick_serial_and_batch\", \"serial_and_batch_bundle\", \"use_serial_batch_fields\", \"column_break_eaoe\", \"section_break_qyjv\", \"serial_no\", \"column_break_rxvc\", \"batch_no\", \"available_qty_section\", \"actual_qty\", \"actual_batch_qty\", \"column_break_atna\", \"company_total_stock\", \"section_break_kejd\", \"installed_qty\", \"packed_qty\", \"column_break_fguf\", \"received_qty\", \"accounting_details_section\", \"expense_account\", \"column_break_71\", \"item_tax_rate\", \"internal_transfer_section\", \"material_request\", \"purchase_order\", \"column_break_82\", \"purchase_order_item\", \"material_request_item\", \"accounting_dimensions_section\", \"cost_center\", \"dimension_col_break\", \"project\", \"section_break_72\", \"page_break\"]" + "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, - "creation": "2025-02-10 17:20:48.370044", - "default_value": null, - "doc_type": "Delivery Note Item", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 18:00:00.000000", + "default": null, + "depends_on": null, + "description": null, "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "target_warehouse", + "dt": "Delivery Note Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discount", + "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, - "modified": "2025-05-17 10:48:39.289925", + "is_virtual": 0, + "label": "Discount", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 18:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Delivery Note Item-target_warehouse-hidden", + "name": "Delivery Note Item-custom_discount", + "no_copy": 0, + "non_negative": 0, + "options": "Commera Discount", "owner": "Administrator", - "property": "hidden", - "property_type": "Check", - "row_name": null, - "value": "1" + "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": "Delivery Note Item", + "links": [], + "property_setters": [], "sync_on_migrate": 1 } diff --git a/commera/commera_ecommerce/custom/item_group.json b/commera/commera_ecommerce/custom/item_group.json index e5a0bd08..3ac29ea2 100644 --- a/commera/commera_ecommerce/custom/item_group.json +++ b/commera/commera_ecommerce/custom/item_group.json @@ -1,69 +1,5 @@ { "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": "2025-02-10 17:19:17.652498", - "default": null, - "depends_on": null, - "description": null, - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "column_break_16", - "fieldtype": "Column Break", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 16, - "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": "include_descendants", - "is_system_generated": 1, - "is_virtual": 0, - "label": null, - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.652498", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-column_break_16", - "no_copy": 0, - "non_negative": 0, - "options": null, - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "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, @@ -105,7 +41,7 @@ "mandatory_depends_on": null, "modified": "2025-03-09 10:20:10.750158", "modified_by": "Administrator", - "module": "Printechs Fashion", + "module": "Commera Ecommerce", "name": "Item Group-custom_displayname", "no_copy": 0, "non_negative": 0, @@ -127,826 +63,11 @@ "translatable": 1, "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": "2025-02-10 17:19:17.427009", - "default": null, - "depends_on": null, - "description": null, - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "custom_website_settings", - "fieldtype": "Section Break", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 10, - "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": "taxes", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Website Settings", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.427009", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-custom_website_settings", - "no_copy": 0, - "non_negative": 0, - "options": null, - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "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": "2025-02-10 17:19:17.572924", - "default": null, - "depends_on": "show_in_website", - "description": "HTML / Banner that will show on the top of product list.", - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "description", - "fieldtype": "Text Editor", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 14, - "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": "website_title", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Description", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.572924", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-description", - "no_copy": 0, - "non_negative": 0, - "options": null, - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "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": "2025-02-10 17:19:17.940989", - "default": null, - "depends_on": null, - "description": null, - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "filter_attributes", - "fieldtype": "Table", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 18, - "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": "filter_fields", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Attributes", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.940989", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-filter_attributes", - "no_copy": 0, - "non_negative": 0, - "options": "Website Attribute", - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "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": "2025-02-10 17:19:17.880743", - "default": null, - "depends_on": null, - "description": null, - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "filter_fields", - "fieldtype": "Table", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 17, - "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": "website_filters_section", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Item Fields", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.880743", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-filter_fields", - "no_copy": 0, - "non_negative": 0, - "options": "Website Filter Field", - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "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": "2025-02-10 17:19:17.615849", - "default": "0", - "depends_on": "show_in_website", - "description": "Include Website Items belonging to child Item Groups", - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "include_descendants", - "fieldtype": "Check", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 14, - "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": "website_title", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Include Descendants", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.615849", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-include_descendants", - "no_copy": 0, - "non_negative": 0, - "options": null, - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "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": "2025-02-10 17:19:17.498013", - "default": null, - "depends_on": "show_in_website", - "description": null, - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "route", - "fieldtype": "Data", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 12, - "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": "show_in_website", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Route", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.498013", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-route", - "no_copy": 1, - "non_negative": 0, - "options": null, - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "show_dashboard": 0, - "sort_options": 0, - "translatable": 0, - "unique": 1, - "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": "2025-02-10 17:19:17.460389", - "default": "0", - "depends_on": null, - "description": "Make Item Group visible in website", - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "show_in_website", - "fieldtype": "Check", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 11, - "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_website_settings", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Show in Website", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.460389", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-show_in_website", - "no_copy": 0, - "non_negative": 0, - "options": null, - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "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": "2025-02-10 17:19:17.734125", - "default": null, - "depends_on": "show_in_website", - "description": "Show this slideshow at the top of the page", - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "slideshow", - "fieldtype": "Link", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 18, - "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": "weightage", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Slideshow", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.734125", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-slideshow", - "no_copy": 0, - "non_negative": 0, - "options": "Website Slideshow", - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "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": 1, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2025-02-10 17:19:17.839637", - "default": null, - "depends_on": "show_in_website", - "description": null, - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "website_filters_section", - "fieldtype": "Section Break", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 16, - "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": "website_specifications", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Website Filters", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.839637", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-website_filters_section", - "no_copy": 0, - "non_negative": 0, - "options": null, - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "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": "2025-02-10 17:19:17.787109", - "default": null, - "depends_on": "show_in_website", - "description": null, - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "website_specifications", - "fieldtype": "Table", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 15, - "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": "description", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Website Specifications", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.787109", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-website_specifications", - "no_copy": 0, - "non_negative": 0, - "options": "Item Website Specification", - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "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": "2025-02-10 17:19:17.539576", - "default": null, - "depends_on": "show_in_website", - "description": null, - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "website_title", - "fieldtype": "Data", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 13, - "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": "route", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Title", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.539576", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-website_title", - "no_copy": 0, - "non_negative": 0, - "options": null, - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "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": "2025-02-10 17:19:17.690734", - "default": null, - "depends_on": "show_in_website", - "description": null, - "docstatus": 0, - "dt": "Item Group", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "weightage", - "fieldtype": "Int", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 17, - "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": "column_break_16", - "is_system_generated": 1, - "is_virtual": 0, - "label": "Weightage", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-02-10 17:19:17.690734", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-weightage", - "no_copy": 0, - "non_negative": 0, - "options": null, - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "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": "Item Group", "links": [], - "property_setters": [ - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-02-10 17:19:17.162640", - "default_value": null, - "doc_type": "Item Group", - "docstatus": 0, - "doctype_or_field": "DocType", - "field_name": "allow_guest_to_view", - "idx": 0, - "is_system_generated": 1, - "modified": "2025-06-06 14:02:34.285335", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-allow_guest_to_view-allow_guest_to_view", - "owner": "Administrator", - "property": "allow_guest_to_view", - "property_type": "Check", - "row_name": null, - "value": "1" - }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-06-18 07:57:05.185493", - "default_value": null, - "doc_type": "Item Group", - "docstatus": 0, - "doctype_or_field": "DocType", - "field_name": null, - "idx": 0, - "is_system_generated": 0, - "modified": "2025-06-18 07:57:05.185493", - "modified_by": "Administrator", - "module": null, - "name": "Item Group-main-field_order", - "owner": "Administrator", - "property": "field_order", - "property_type": "Data", - "row_name": null, - "value": "[\"gs\", \"item_group_name\", \"custom_displayname\", \"parent_item_group\", \"is_group\", \"image\", \"column_break_5\", \"display_on_website\", \"defaults\", \"item_group_defaults\", \"sec_break_taxes\", \"taxes\", \"custom_website_settings\", \"show_in_website\", \"route\", \"website_title\", \"description\", \"website_specifications\", \"website_filters_section\", \"filter_fields\", \"filter_attributes\", \"include_descendants\", \"column_break_16\", \"weightage\", \"slideshow\", \"lft\", \"old_parent\", \"rgt\"]" - } - ], + "property_setters": [], "sync_on_migrate": 1 } diff --git a/commera/commera_ecommerce/custom/quotation.json b/commera/commera_ecommerce/custom/quotation.json index 74fad6fc..71414206 100644 --- a/commera/commera_ecommerce/custom/quotation.json +++ b/commera/commera_ecommerce/custom/quotation.json @@ -32,7 +32,7 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "valid_till", + "insert_after": "customer_name", "is_system_generated": 0, "is_virtual": 0, "label": "Is Store Pickup?", @@ -96,7 +96,7 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "amended_from", + "insert_after": "custom_fulfilment_section", "is_system_generated": 0, "is_virtual": 0, "label": "Store", @@ -128,6 +128,838 @@ "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": 1, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_tracking_section", + "fieldtype": "Section Break", + "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_cart_attributes", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Tracking", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_tracking_section", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "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-07-21 12:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_analytics_session_id", + "fieldtype": "Data", + "hidden": 1, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 15, + "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_tracking_section", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Analytics Session ID", + "length": 64, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-07-21 12:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_analytics_session_id", + "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-07-21 12:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_utm_source", + "fieldtype": "Data", + "hidden": 1, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 16, + "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_analytics_session_id", + "is_system_generated": 0, + "is_virtual": 0, + "label": "UTM Source", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-07-21 12:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_utm_source", + "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-07-21 12:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_utm_medium", + "fieldtype": "Data", + "hidden": 1, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 17, + "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_utm_source", + "is_system_generated": 0, + "is_virtual": 0, + "label": "UTM Medium", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-07-21 12:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_utm_medium", + "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-07-21 12:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_utm_campaign", + "fieldtype": "Data", + "hidden": 1, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 18, + "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_utm_medium", + "is_system_generated": 0, + "is_virtual": 0, + "label": "UTM Campaign", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-07-21 12:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_utm_campaign", + "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-08-20 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "The Shipping Service the customer chose at checkout. Data rather than a Link, so the storefront still works with bwh_shipping uninstalled.", + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_delivery_option", + "fieldtype": "Data", + "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_store", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Delivery Option", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-08-20 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_delivery_option", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "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-08-20 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "What the customer was quoted and billed for delivery, stored server-side at selection time. The browser never supplies a price.", + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_delivery_charge", + "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_delivery_option", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Delivery Charge", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-08-20 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_delivery_charge", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "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-08-20 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "bwh_shipping Shipping Provider Profile that quoted this option.", + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_shipping_provider", + "fieldtype": "Data", + "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_fulfilment_column_break", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Shipping Provider", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-08-20 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_shipping_provider", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "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-08-20 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "The provider's carrier service code, so fulfilment books exactly what the customer paid for.", + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_shipping_service_code", + "fieldtype": "Data", + "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_provider", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Shipping Service Code", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-08-20 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_shipping_service_code", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "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-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_tracking_column_break", + "fieldtype": "Column Break", + "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_utm_campaign", + "is_system_generated": 0, + "is_virtual": 0, + "label": null, + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_tracking_column_break", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "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-09-29 00:00:00.000000", + "default": null, + "depends_on": null, + "description": "Hash of the guest shopper's cart cookie; set only on carts started without signing in.", + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_guest_cart_key", + "fieldtype": "Data", + "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_tracking_column_break", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Guest Cart Key", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-09-29 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_guest_cart_key", + "no_copy": 1, + "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": 1, + "reqd": 0, + "search_index": 1, + "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-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Quotation", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discounts_section", + "fieldtype": "Section Break", + "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_service_code", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discounts", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Quotation-custom_discounts_section", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "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": "Discount codes the shopper applied, 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": "custom_discounts_section", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Codes", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 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, @@ -139,7 +971,7 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2026-07-21 12:00:00.000000", + "creation": "2026-10-21 00:00:00.000000", "default": null, "depends_on": null, "description": null, @@ -147,30 +979,30 @@ "dt": "Quotation", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_analytics_session_id", - "fieldtype": "Data", - "hidden": 1, + "fieldname": "custom_discounts_column_break", + "fieldtype": "Column Break", + "hidden": 0, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, - "idx": 15, + "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": "amended_from", + "insert_after": "custom_discount_codes", "is_system_generated": 0, "is_virtual": 0, - "label": "Analytics Session ID", - "length": 64, + "label": null, + "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2026-07-21 12:00:00.000000", + "modified": "2026-10-21 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Quotation-custom_analytics_session_id", + "name": "Quotation-custom_discounts_column_break", "no_copy": 0, "non_negative": 0, "options": null, @@ -178,6 +1010,70 @@ "permlevel": 0, "placeholder": null, "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "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_discounts_column_break", + "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, @@ -203,7 +1099,7 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2026-07-21 12:00:00.000000", + "creation": "2026-10-11 00:00:00.000000", "default": null, "depends_on": null, "description": null, @@ -211,33 +1107,33 @@ "dt": "Quotation", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_utm_source", - "fieldtype": "Data", - "hidden": 1, + "fieldname": "custom_shipping_discount", + "fieldtype": "Currency", + "hidden": 0, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, - "idx": 16, + "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_analytics_session_id", + "insert_after": "custom_code_order_discount", "is_system_generated": 0, "is_virtual": 0, - "label": "UTM Source", + "label": "Shipping Discount", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2026-07-21 12:00:00.000000", + "modified": "2026-10-11 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Quotation-custom_utm_source", + "name": "Quotation-custom_shipping_discount", "no_copy": 0, "non_negative": 0, - "options": null, + "options": "currency", "owner": "Administrator", "permlevel": 0, "placeholder": null, @@ -267,7 +1163,7 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2026-07-21 12:00:00.000000", + "creation": "2026-10-11 00:00:00.000000", "default": null, "depends_on": null, "description": null, @@ -275,33 +1171,33 @@ "dt": "Quotation", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_utm_medium", - "fieldtype": "Data", - "hidden": 1, + "fieldname": "custom_shipping_discount_id", + "fieldtype": "Link", + "hidden": 0, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, - "idx": 17, + "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_utm_source", + "insert_after": "custom_shipping_discount", "is_system_generated": 0, "is_virtual": 0, - "label": "UTM Medium", + "label": "Shipping Discount Rule", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2026-07-21 12:00:00.000000", + "modified": "2026-10-11 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Quotation-custom_utm_medium", + "name": "Quotation-custom_shipping_discount_id", "no_copy": 0, "non_negative": 0, - "options": null, + "options": "Commera Discount", "owner": "Administrator", "permlevel": 0, "placeholder": null, @@ -331,38 +1227,38 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2026-07-21 12:00:00.000000", + "creation": "2026-10-11 00:00:00.000000", "default": null, "depends_on": null, - "description": 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_utm_campaign", - "fieldtype": "Data", + "fieldname": "custom_discount_allocations", + "fieldtype": "Long Text", "hidden": 1, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, - "idx": 18, + "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_utm_medium", + "insert_after": "custom_shipping_discount_id", "is_system_generated": 0, "is_virtual": 0, - "label": "UTM Campaign", + "label": "Discount Allocations", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2026-07-21 12:00:00.000000", + "modified": "2026-10-11 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Quotation-custom_utm_campaign", + "name": "Quotation-custom_discount_allocations", "no_copy": 0, "non_negative": 0, "options": null, @@ -395,16 +1291,16 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2026-08-20 00:00:00.000000", + "creation": "2026-10-11 00:00:00.000000", "default": null, "depends_on": null, - "description": "The Shipping Service the customer chose at checkout. Data rather than a Link, so the storefront still works with bwh_shipping uninstalled.", + "description": "JSON object of the shopper's cart-level attributes.", "docstatus": 0, "dt": "Quotation", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_delivery_option", - "fieldtype": "Data", + "fieldname": "custom_cart_attributes", + "fieldtype": "Long Text", "hidden": 0, "hide_border": 0, "hide_days": 0, @@ -416,17 +1312,17 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_is_store_pickup", + "insert_after": "custom_cart_details_section", "is_system_generated": 0, "is_virtual": 0, - "label": "Delivery Option", + "label": "Cart Attributes", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2026-08-20 00:00:00.000000", + "modified": "2026-10-11 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Quotation-custom_delivery_option", + "name": "Quotation-custom_cart_attributes", "no_copy": 0, "non_negative": 0, "options": null, @@ -434,7 +1330,7 @@ "permlevel": 0, "placeholder": null, "precision": "", - "print_hide": 0, + "print_hide": 1, "print_hide_if_no_value": 0, "print_width": null, "read_only": 1, @@ -459,16 +1355,16 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2026-08-20 00:00:00.000000", + "creation": "2026-10-21 00:00:00.000000", "default": null, - "depends_on": null, - "description": "What the customer was quoted and billed for delivery, stored server-side at selection time. The browser never supplies a price.", + "depends_on": "eval:doc.order_type==\"Shopping Cart\"", + "description": null, "docstatus": 0, "dt": "Quotation", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_delivery_charge", - "fieldtype": "Currency", + "fieldname": "custom_ecommerce_tab", + "fieldtype": "Tab Break", "hidden": 0, "hide_border": 0, "hide_days": 0, @@ -480,20 +1376,20 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_delivery_option", + "insert_after": "supplier_quotation", "is_system_generated": 0, "is_virtual": 0, - "label": "Delivery Charge", + "label": "Ecommerce", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2026-08-20 00:00:00.000000", + "modified": "2026-10-21 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Quotation-custom_delivery_charge", + "name": "Quotation-custom_ecommerce_tab", "no_copy": 0, "non_negative": 0, - "options": "currency", + "options": null, "owner": "Administrator", "permlevel": 0, "placeholder": null, @@ -501,7 +1397,7 @@ "print_hide": 0, "print_hide_if_no_value": 0, "print_width": null, - "read_only": 1, + "read_only": 0, "read_only_depends_on": null, "report_hide": 0, "reqd": 0, @@ -523,16 +1419,16 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2026-08-20 00:00:00.000000", + "creation": "2026-10-21 00:00:00.000000", "default": null, "depends_on": null, - "description": "bwh_shipping Shipping Provider Profile that quoted this option.", + "description": null, "docstatus": 0, "dt": "Quotation", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_shipping_provider", - "fieldtype": "Data", + "fieldname": "custom_fulfilment_section", + "fieldtype": "Section Break", "hidden": 0, "hide_border": 0, "hide_days": 0, @@ -544,17 +1440,17 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_delivery_charge", + "insert_after": "custom_ecommerce_tab", "is_system_generated": 0, "is_virtual": 0, - "label": "Shipping Provider", + "label": "Fulfilment", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2026-08-20 00:00:00.000000", + "modified": "2026-10-21 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Quotation-custom_shipping_provider", + "name": "Quotation-custom_fulfilment_section", "no_copy": 0, "non_negative": 0, "options": null, @@ -565,7 +1461,7 @@ "print_hide": 0, "print_hide_if_no_value": 0, "print_width": null, - "read_only": 1, + "read_only": 0, "read_only_depends_on": null, "report_hide": 0, "reqd": 0, @@ -587,16 +1483,16 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2026-08-20 00:00:00.000000", + "creation": "2026-10-21 00:00:00.000000", "default": null, "depends_on": null, - "description": "The provider's carrier service code, so fulfilment books exactly what the customer paid for.", + "description": null, "docstatus": 0, "dt": "Quotation", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_shipping_service_code", - "fieldtype": "Data", + "fieldname": "custom_fulfilment_column_break", + "fieldtype": "Column Break", "hidden": 0, "hide_border": 0, "hide_days": 0, @@ -608,17 +1504,17 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_shipping_provider", + "insert_after": "custom_delivery_charge", "is_system_generated": 0, "is_virtual": 0, - "label": "Shipping Service Code", + "label": null, "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2026-08-20 00:00:00.000000", + "modified": "2026-10-21 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Quotation-custom_shipping_service_code", + "name": "Quotation-custom_fulfilment_column_break", "no_copy": 0, "non_negative": 0, "options": null, @@ -629,7 +1525,7 @@ "print_hide": 0, "print_hide_if_no_value": 0, "print_width": null, - "read_only": 1, + "read_only": 0, "read_only_depends_on": null, "report_hide": 0, "reqd": 0, @@ -648,20 +1544,20 @@ "allow_in_quick_entry": 0, "allow_on_submit": 0, "bold": 0, - "collapsible": 0, + "collapsible": 1, "collapsible_depends_on": null, "columns": 0, - "creation": "2026-09-29 00:00:00.000000", + "creation": "2026-10-21 00:00:00.000000", "default": null, "depends_on": null, - "description": "Hash of the guest shopper's cart cookie; set only on carts started without signing in.", + "description": null, "docstatus": 0, "dt": "Quotation", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_guest_cart_key", - "fieldtype": "Data", - "hidden": 1, + "fieldname": "custom_cart_details_section", + "fieldtype": "Section Break", + "hidden": 0, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, @@ -672,32 +1568,32 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_shipping_service_code", + "insert_after": "custom_discount_allocations", "is_system_generated": 0, "is_virtual": 0, - "label": "Guest Cart Key", + "label": "Cart Details", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2026-09-29 00:00:00.000000", + "modified": "2026-10-21 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Quotation-custom_guest_cart_key", - "no_copy": 1, + "name": "Quotation-custom_cart_details_section", + "no_copy": 0, "non_negative": 0, "options": null, "owner": "Administrator", "permlevel": 0, "placeholder": null, "precision": "", - "print_hide": 1, + "print_hide": 0, "print_hide_if_no_value": 0, "print_width": null, - "read_only": 1, + "read_only": 0, "read_only_depends_on": null, - "report_hide": 1, + "report_hide": 0, "reqd": 0, - "search_index": 1, + "search_index": 0, "show_dashboard": 0, "sort_options": 0, "translatable": 0, @@ -755,29 +1651,6 @@ "row_name": null, "value": "1" }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-03-29 20:37:38.000785", - "default_value": null, - "doc_type": "Quotation", - "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "disable_rounded_total", - "idx": 0, - "is_system_generated": 0, - "modified": "2025-04-23 13:01:06.684130", - "modified_by": "Administrator", - "module": null, - "name": "Quotation-disable_rounded_total-default", - "owner": "Administrator", - "property": "default", - "property_type": "Text", - "row_name": null, - "value": "0" - }, { "_assign": null, "_comments": null, @@ -824,29 +1697,6 @@ "row_name": null, "value": "0" }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-04-24 14:57:35.884281", - "default_value": null, - "doc_type": "Quotation", - "docstatus": 0, - "doctype_or_field": "DocType", - "field_name": null, - "idx": 0, - "is_system_generated": 0, - "modified": "2025-04-24 14:57:35.884281", - "modified_by": "Administrator", - "module": null, - "name": "Quotation-main-field_order", - "owner": "Administrator", - "property": "field_order", - "property_type": "Data", - "row_name": null, - "value": "[\"customer_section\", \"title\", \"naming_series\", \"quotation_to\", \"party_name\", \"customer_name\", \"column_break_7\", \"transaction_date\", \"valid_till\", \"custom_is_store_pickup\", \"column_break1\", \"order_type\", \"company\", \"amended_from\", \"store\", \"currency_and_price_list\", \"currency\", \"conversion_rate\", \"column_break2\", \"selling_price_list\", \"price_list_currency\", \"plc_conversion_rate\", \"ignore_pricing_rule\", \"items_section\", \"scan_barcode\", \"items\", \"sec_break23\", \"total_qty\", \"total_net_weight\", \"column_break_28\", \"base_total\", \"base_net_total\", \"column_break_31\", \"total\", \"net_total\", \"taxes_section\", \"tax_category\", \"taxes_and_charges\", \"column_break_36\", \"shipping_rule\", \"column_break_34\", \"incoterm\", \"named_place\", \"section_break_36\", \"taxes\", \"section_break_39\", \"base_total_taxes_and_charges\", \"column_break_42\", \"total_taxes_and_charges\", \"totals\", \"base_grand_total\", \"base_rounding_adjustment\", \"base_rounded_total\", \"base_in_words\", \"column_break3\", \"grand_total\", \"rounding_adjustment\", \"rounded_total\", \"disable_rounded_total\", \"in_words\", \"section_break_44\", \"apply_discount_on\", \"base_discount_amount\", \"coupon_code\", \"column_break_46\", \"additional_discount_percentage\", \"discount_amount\", \"referral_sales_partner\", \"sec_tax_breakup\", \"other_charges_calculation\", \"bundle_items_section\", \"packed_items\", \"pricing_rule_details\", \"pricing_rules\", \"address_and_contact_tab\", \"billing_address_section\", \"customer_address\", \"address_display\", \"col_break98\", \"contact_person\", \"contact_display\", \"contact_mobile\", \"contact_email\", \"shipping_address_section\", \"shipping_address_name\", \"column_break_81\", \"shipping_address\", \"company_address_section\", \"company_address\", \"company_address_display\", \"column_break_87\", \"company_contact_person\", \"terms_tab\", \"payment_schedule_section\", \"payment_terms_template\", \"payment_schedule\", \"terms_section_break\", \"tc_name\", \"terms\", \"more_info_tab\", \"subscription_section\", \"auto_repeat\", \"update_auto_repeat_reference\", \"print_settings\", \"letter_head\", \"group_same_items\", \"column_break_73\", \"select_print_heading\", \"language\", \"lost_reasons_section\", \"lost_reasons\", \"competitors\", \"column_break_117\", \"order_lost_reason\", \"additional_info_section\", \"status\", \"customer_group\", \"territory\", \"column_break_108\", \"campaign\", \"source\", \"column_break4\", \"opportunity\", \"supplier_quotation\", \"enq_det\", \"connections_tab\"]" - }, { "_assign": null, "_comments": null, @@ -892,29 +1742,6 @@ "property_type": "Check", "row_name": null, "value": "0" - }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-02-10 17:20:48.284205", - "default_value": null, - "doc_type": "Quotation", - "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "scan_barcode", - "idx": 0, - "is_system_generated": 1, - "modified": "2025-04-23 13:01:06.781942", - "modified_by": "Administrator", - "module": null, - "name": "Quotation-scan_barcode-hidden", - "owner": "Administrator", - "property": "hidden", - "property_type": "Check", - "row_name": null, - "value": "0" } ], "sync_on_migrate": 1 diff --git a/commera/commera_ecommerce/custom/quotation_item.json b/commera/commera_ecommerce/custom/quotation_item.json new file mode 100644 index 00000000..f58447df --- /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", + "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", + "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", + "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", + "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/recommended_items.json b/commera/commera_ecommerce/custom/recommended_items.json deleted file mode 100644 index 2cc5069f..00000000 --- a/commera/commera_ecommerce/custom/recommended_items.json +++ /dev/null @@ -1,97 +0,0 @@ -{ - "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": "2025-03-12 12:12:20.061519", - "default": null, - "depends_on": null, - "description": null, - "docstatus": 0, - "dt": "Recommended Items", - "fetch_from": "website_item.brand", - "fetch_if_empty": 0, - "fieldname": "custom_website_item_brand", - "fieldtype": "Data", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 9, - "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": "website_item_thumbnail", - "is_system_generated": 0, - "is_virtual": 0, - "label": "Website Item Brand", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-03-12 12:12:20.061519", - "modified_by": "12rahulagrawaliitjee@gmail.com", - "module": null, - "name": "Recommended Items-custom_website_item_brand", - "no_copy": 0, - "non_negative": 0, - "options": null, - "owner": "12rahulagrawaliitjee@gmail.com", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "show_dashboard": 0, - "sort_options": 0, - "translatable": 1, - "unique": 0, - "width": null - } - ], - "custom_perms": [], - "doctype": "Recommended Items", - "links": [], - "property_setters": [ - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-03-12 12:12:19.969154", - "default_value": null, - "doc_type": "Recommended Items", - "docstatus": 0, - "doctype_or_field": "DocType", - "field_name": null, - "idx": 0, - "is_system_generated": 0, - "modified": "2025-03-12 12:12:19.969154", - "modified_by": "12rahulagrawaliitjee@gmail.com", - "module": null, - "name": "Recommended Items-main-field_order", - "owner": "12rahulagrawaliitjee@gmail.com", - "property": "field_order", - "property_type": "Data", - "row_name": null, - "value": "[\"website_item\", \"website_item_name\", \"column_break_2\", \"item_code\", \"more_information_section\", \"route\", \"column_break_6\", \"website_item_image\", \"website_item_thumbnail\", null]" - } - ], - "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..e6a86384 --- /dev/null +++ b/commera/commera_ecommerce/custom/sales_invoice.json @@ -0,0 +1,649 @@ +{ + "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-21 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_discounts_section", + "fieldtype": "Section Break", + "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_ecommerce_tab", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discounts", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice-custom_discounts_section", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "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": "Discount codes the shopper applied, 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": "custom_discounts_section", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Codes", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 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-21 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_discounts_column_break", + "fieldtype": "Column Break", + "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": null, + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice-custom_discounts_column_break", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "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_discounts_column_break", + "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 + }, + { + "_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 + }, + { + "_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 object of the shopper's cart-level attributes.", + "docstatus": 0, + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_cart_attributes", + "fieldtype": "Long 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_cart_details_section", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Cart Attributes", + "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_cart_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 + }, + { + "_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-21 00:00:00.000000", + "default": null, + "depends_on": "eval:doc.custom_discount_codes || doc.custom_discount_allocations || doc.custom_cart_attributes", + "description": null, + "docstatus": 0, + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_ecommerce_tab", + "fieldtype": "Tab Break", + "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": "is_discounted", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Ecommerce", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice-custom_ecommerce_tab", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "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": 1, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 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_cart_details_section", + "fieldtype": "Section Break", + "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_allocations", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Cart Details", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Invoice-custom_cart_details_section", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "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..c8df6575 --- /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", + "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", + "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", + "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", + "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..3ab283a3 100644 --- a/commera/commera_ecommerce/custom/sales_order.json +++ b/commera/commera_ecommerce/custom/sales_order.json @@ -6,46 +6,46 @@ "_liked_by": null, "_user_tags": null, "allow_in_quick_entry": 0, - "allow_on_submit": 1, + "allow_on_submit": 0, "bold": 0, "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2025-05-27 07:43:30.151623", - "default": null, + "creation": "2025-06-03 08:56:47.997238", + "default": "COD", "depends_on": null, "description": null, "docstatus": 0, "dt": "Sales Order", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_animo_api_response", - "fieldtype": "Long Text", + "fieldname": "custom_ecommerce_payment_mode", + "fieldtype": "Link", "hidden": 0, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, - "idx": 155, + "idx": 14, "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_animo_api_status", + "insert_after": "custom_payment_section", "is_system_generated": 0, "is_virtual": 0, - "label": "Animo Api Response", + "label": "Ecommerce Payment Mode", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2025-05-17 01:16:48.419503", + "modified": "2025-06-03 08:56:47.997238", "modified_by": "Administrator", - "module": "Printechs Fashion", - "name": "Sales Order-custom_animo_api_response", + "module": null, + "name": "Sales Order-custom_ecommerce_payment_mode", "no_copy": 0, "non_negative": 0, - "options": null, + "options": "Mode of Payment", "owner": "Administrator", "permlevel": 0, "placeholder": null, @@ -75,41 +75,41 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2025-05-27 07:43:29.979343", - "default": null, + "creation": "2025-06-03 08:56:47.744936", + "default": "Waiting for Approval", "depends_on": null, "description": null, "docstatus": 0, "dt": "Sales Order", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_animo_api_status", + "fieldname": "custom_ecommerce_status", "fieldtype": "Select", "hidden": 0, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, - "idx": 154, + "idx": 13, "ignore_user_permissions": 0, "ignore_xss_filter": 0, "in_global_search": 0, - "in_list_view": 0, + "in_list_view": 1, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "is_internal_customer", + "insert_after": "customer_name", "is_system_generated": 0, "is_virtual": 0, - "label": "Animo Api Status", + "label": "Ecommerce Status", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2025-05-17 01:10:26.062999", + "modified": "2025-06-03 08:56:47.744936", "modified_by": "Administrator", - "module": "Printechs Fashion", - "name": "Sales Order-custom_animo_api_status", + "module": null, + "name": "Sales Order-custom_ecommerce_status", "no_copy": 0, "non_negative": 0, - "options": "Success\nFailed\nError", + "options": "Waiting for Approval\nOrder Received\nPreparing for Shipment\nShipped\nDelivered\nPartially Returned\nCancelled\nReturned", "owner": "Administrator", "permlevel": 0, "placeholder": null, @@ -117,14 +117,14 @@ "print_hide": 0, "print_hide_if_no_value": 0, "print_width": null, - "read_only": 0, + "read_only": 1, "read_only_depends_on": null, "report_hide": 0, "reqd": 0, "search_index": 0, "show_dashboard": 0, "sort_options": 0, - "translatable": 0, + "translatable": 1, "unique": 0, "width": null }, @@ -139,21 +139,21 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2025-06-03 08:56:47.997238", - "default": "COD", + "creation": "2025-04-11 08:42:07.361360", + "default": null, "depends_on": null, "description": null, "docstatus": 0, "dt": "Sales Order", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_ecommerce_payment_mode", - "fieldtype": "Link", + "fieldname": "custom_is_store_pickup", + "fieldtype": "Check", "hidden": 0, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, - "idx": 14, + "idx": 12, "ignore_user_permissions": 0, "ignore_xss_filter": 0, "in_global_search": 0, @@ -163,17 +163,17 @@ "insert_after": "custom_ecommerce_status", "is_system_generated": 0, "is_virtual": 0, - "label": "Ecommerce Payment Mode", + "label": "Is Store Pickup?", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2025-06-03 08:56:47.997238", + "modified": "2025-04-11 08:42:07.361360", "modified_by": "Administrator", "module": null, - "name": "Sales Order-custom_ecommerce_payment_mode", + "name": "Sales Order-custom_is_store_pickup", "no_copy": 0, "non_negative": 0, - "options": "Mode of Payment", + "options": null, "owner": "Administrator", "permlevel": 0, "placeholder": null, @@ -192,70 +192,6 @@ "unique": 0, "width": null }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2025-06-03 08:56:47.744936", - "default": "Waiting for Approval", - "depends_on": null, - "description": null, - "docstatus": 0, - "dt": "Sales Order", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "custom_ecommerce_status", - "fieldtype": "Select", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 13, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "custom_is_store_pickup", - "is_system_generated": 0, - "is_virtual": 0, - "label": "Ecommerce Status", - "length": 0, - "link_filters": null, - "mandatory_depends_on": null, - "modified": "2025-06-03 08:56:47.744936", - "modified_by": "Administrator", - "module": null, - "name": "Sales Order-custom_ecommerce_status", - "no_copy": 0, - "non_negative": 0, - "options": "Waiting for Approval\nOrder Received\nPreparing for Shipment\nShipped\nDelivered\nPartially Returned\nCancelled\nReturned", - "owner": "Administrator", - "permlevel": 0, - "placeholder": null, - "precision": "", - "print_hide": 0, - "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": 1, - "unique": 0, - "width": null - }, { "_assign": null, "_comments": null, @@ -267,7 +203,7 @@ "collapsible": 0, "collapsible_depends_on": null, "columns": 0, - "creation": "2025-04-11 08:42:07.361360", + "creation": "2025-04-24 14:58:33.762027", "default": null, "depends_on": null, "description": null, @@ -275,33 +211,33 @@ "dt": "Sales Order", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_is_store_pickup", - "fieldtype": "Check", + "fieldname": "custom_store", + "fieldtype": "Link", "hidden": 0, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, - "idx": 12, + "idx": 19, "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": "delivery_date", + "insert_after": "custom_fulfilment_section", "is_system_generated": 0, "is_virtual": 0, - "label": "Is Store Pickup?", + "label": "Store", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2025-04-11 08:42:07.361360", + "modified": "2025-04-24 14:58:33.762027", "modified_by": "Administrator", "module": null, - "name": "Sales Order-custom_is_store_pickup", + "name": "Sales Order-custom_store", "no_copy": 0, "non_negative": 0, - "options": null, + "options": "Warehouse", "owner": "Administrator", "permlevel": 0, "placeholder": null, @@ -328,10 +264,10 @@ "allow_in_quick_entry": 0, "allow_on_submit": 0, "bold": 0, - "collapsible": 0, + "collapsible": 1, "collapsible_depends_on": null, "columns": 0, - "creation": "2025-04-24 14:58:33.762027", + "creation": "2026-10-21 00:00:00.000000", "default": null, "depends_on": null, "description": null, @@ -339,33 +275,33 @@ "dt": "Sales Order", "fetch_from": null, "fetch_if_empty": 0, - "fieldname": "custom_store", - "fieldtype": "Link", + "fieldname": "custom_tracking_section", + "fieldtype": "Section Break", "hidden": 0, "hide_border": 0, "hide_days": 0, "hide_seconds": 0, - "idx": 19, + "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": "company", + "insert_after": "custom_cart_attributes", "is_system_generated": 0, "is_virtual": 0, - "label": "Store", + "label": "Tracking", "length": 0, "link_filters": null, "mandatory_depends_on": null, - "modified": "2025-04-24 14:58:33.762027", + "modified": "2026-10-21 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Sales Order-custom_store", + "name": "Sales Order-custom_tracking_section", "no_copy": 0, "non_negative": 0, - "options": "Warehouse", + "options": null, "owner": "Administrator", "permlevel": 0, "placeholder": null, @@ -416,7 +352,7 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "amended_from", + "insert_after": "custom_tracking_section", "is_system_generated": 0, "is_virtual": 0, "label": "Analytics Session ID", @@ -672,7 +608,7 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_is_store_pickup", + "insert_after": "custom_store", "is_system_generated": 0, "is_virtual": 0, "label": "Delivery Option", @@ -800,7 +736,7 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_delivery_charge", + "insert_after": "custom_fulfilment_column_break", "is_system_generated": 0, "is_virtual": 0, "label": "Shipping Provider", @@ -896,6 +832,70 @@ "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-21 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_tracking_column_break", + "fieldtype": "Column Break", + "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_utm_campaign", + "is_system_generated": 0, + "is_virtual": 0, + "label": null, + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order-custom_tracking_column_break", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "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, @@ -928,7 +928,7 @@ "in_list_view": 0, "in_preview": 0, "in_standard_filter": 0, - "insert_after": "custom_shipping_service_code", + "insert_after": "custom_tracking_column_break", "is_system_generated": 0, "is_virtual": 0, "label": "Order Access Key", @@ -1023,234 +1023,905 @@ "translatable": 0, "unique": 0, "width": null - } - ], - "custom_perms": [], - "doctype": "Sales Order", - "links": [ - { - "creation": "2025-06-09 10:26:21.148448", - "custom": 1, - "docstatus": 0, - "group": null, - "hidden": 0, - "idx": 1, - "is_child_table": 0, - "link_doctype": "Gateway Payment Request", - "link_fieldname": "ref_docname", - "modified": "2025-06-09 10:26:21.148448", - "modified_by": "Administrator", - "name": "vh35ou9602", - "owner": "Administrator", - "parent": "Sales Order", - "parent_doctype": null, - "parentfield": "links", - "parenttype": "Customize Form", - "table_fieldname": null - } - ], - "property_setters": [ + }, { "_assign": null, "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-03-29 20:37:38.015527", - "default_value": null, - "doc_type": "Sales Order", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "base_rounded_total", + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_discounts_section", + "fieldtype": "Section Break", + "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_ecommerce_payment_mode", "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.390638", - "modified_by": "Administrator", - "module": null, - "name": "Sales Order-base_rounded_total-hidden", + "is_virtual": 0, + "label": "Discounts", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order-custom_discounts_section", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "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": "Discount codes the shopper applied, 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": "custom_discounts_section", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Discount Codes", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 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-21 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_discounts_column_break", + "fieldtype": "Column Break", + "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": null, + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order-custom_discounts_column_break", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "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_discounts_column_break", + "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 + }, + { + "_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", - "property": "hidden", - "property_type": "Check", - "row_name": null, - "value": "0" + "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, - "creation": "2025-03-29 20:37:38.031423", - "default_value": null, - "doc_type": "Sales Order", + "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, - "doctype_or_field": "DocField", - "field_name": "base_rounded_total", + "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, - "modified": "2025-06-06 14:02:34.398769", + "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-base_rounded_total-print_hide", + "name": "Sales Order-custom_discount_allocations", + "no_copy": 0, + "non_negative": 0, + "options": null, "owner": "Administrator", - "property": "print_hide", - "property_type": "Check", - "row_name": null, - "value": "1" + "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, - "creation": "2025-03-29 20:37:38.077738", - "default_value": null, - "doc_type": "Sales Order", + "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 object of the shopper's cart-level attributes.", "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "disable_rounded_total", + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_cart_attributes", + "fieldtype": "Long 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_cart_details_section", "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.407508", + "is_virtual": 0, + "label": "Cart Attributes", + "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-disable_rounded_total-default", + "name": "Sales Order-custom_cart_attributes", + "no_copy": 0, + "non_negative": 0, + "options": null, "owner": "Administrator", - "property": "default", - "property_type": "Text", - "row_name": null, - "value": "0" + "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-21 00:00:00.000000", + "default": null, + "depends_on": "eval:doc.order_type==\"Shopping Cart\"", + "description": null, + "docstatus": 0, + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_ecommerce_tab", + "fieldtype": "Tab Break", + "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": "party_account_currency", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Ecommerce", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order-custom_ecommerce_tab", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "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-21 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_fulfilment_section", + "fieldtype": "Section Break", + "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_ecommerce_tab", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Fulfilment", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": null, + "name": "Sales Order-custom_fulfilment_section", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "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, - "creation": "2025-02-10 17:19:12.707806", - "default_value": null, - "doc_type": "Sales Order", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "due_date", + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_fulfilment_column_break", + "fieldtype": "Column Break", + "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_delivery_charge", "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.416249", + "is_virtual": 0, + "label": null, + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Sales Order-due_date-print_hide", + "name": "Sales Order-custom_fulfilment_column_break", + "no_copy": 0, + "non_negative": 0, + "options": null, "owner": "Administrator", - "property": "print_hide", - "property_type": "Check", - "row_name": null, - "value": "0" + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "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, - "creation": "2025-03-29 20:37:38.498950", - "default_value": null, - "doc_type": "Sales Order", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "in_words", + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_payment_section", + "fieldtype": "Section Break", + "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_service_code", "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.425193", + "is_virtual": 0, + "label": "Payment", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Sales Order-in_words-hidden", + "name": "Sales Order-custom_payment_section", + "no_copy": 0, + "non_negative": 0, + "options": null, "owner": "Administrator", - "property": "hidden", - "property_type": "Check", - "row_name": null, - "value": "0" + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "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, - "creation": "2025-03-29 20:37:38.511706", - "default_value": null, - "doc_type": "Sales Order", + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 1, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2026-10-21 00:00:00.000000", + "default": null, + "depends_on": null, + "description": null, "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "in_words", + "dt": "Sales Order", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_cart_details_section", + "fieldtype": "Section Break", + "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_allocations", "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.437044", + "is_virtual": 0, + "label": "Cart Details", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-10-21 00:00:00.000000", "modified_by": "Administrator", "module": null, - "name": "Sales Order-in_words-print_hide", + "name": "Sales Order-custom_cart_details_section", + "no_copy": 0, + "non_negative": 0, + "options": null, "owner": "Administrator", - "property": "print_hide", - "property_type": "Check", - "row_name": null, - "value": "0" - }, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "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", + "links": [ { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-06-03 08:57:44.591648", - "default_value": null, - "doc_type": "Sales Order", + "creation": "2025-06-09 10:26:21.148448", + "custom": 1, "docstatus": 0, - "doctype_or_field": "DocType", - "field_name": null, - "idx": 0, - "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.448344", + "group": null, + "hidden": 0, + "idx": 1, + "is_child_table": 0, + "link_doctype": "Gateway Payment Request", + "link_fieldname": "ref_docname", + "modified": "2025-06-09 10:26:21.148448", "modified_by": "Administrator", - "module": null, - "name": "Sales Order-main-field_order", + "name": "vh35ou9602", "owner": "Administrator", - "property": "field_order", - "property_type": "Data", - "row_name": null, - "value": "[\"customer_section\", \"column_break0\", \"title\", \"naming_series\", \"customer\", \"customer_name\", \"tax_id\", \"order_type\", \"column_break_7\", \"transaction_date\", \"delivery_date\", \"custom_is_store_pickup\", \"custom_ecommerce_status\", \"custom_ecommerce_payment_mode\", \"column_break1\", \"po_no\", \"po_date\", \"company\", \"custom_store\", \"skip_delivery_note\", \"amended_from\", \"accounting_dimensions_section\", \"cost_center\", \"dimension_col_break\", \"project\", \"currency_and_price_list\", \"currency\", \"conversion_rate\", \"column_break2\", \"selling_price_list\", \"price_list_currency\", \"plc_conversion_rate\", \"ignore_pricing_rule\", \"sec_warehouse\", \"scan_barcode\", \"column_break_28\", \"set_warehouse\", \"reserve_stock\", \"items_section\", \"items\", \"section_break_31\", \"total_qty\", \"total_net_weight\", \"column_break_33\", \"base_total\", \"base_net_total\", \"column_break_33a\", \"total\", \"net_total\", \"taxes_section\", \"tax_category\", \"taxes_and_charges\", \"column_break_38\", \"shipping_rule\", \"column_break_49\", \"incoterm\", \"named_place\", \"section_break_40\", \"taxes\", \"section_break_43\", \"base_total_taxes_and_charges\", \"column_break_46\", \"total_taxes_and_charges\", \"totals\", \"base_grand_total\", \"base_rounding_adjustment\", \"base_rounded_total\", \"base_in_words\", \"column_break3\", \"grand_total\", \"rounding_adjustment\", \"rounded_total\", \"in_words\", \"advance_paid\", \"disable_rounded_total\", \"section_break_48\", \"apply_discount_on\", \"base_discount_amount\", \"coupon_code\", \"column_break_50\", \"additional_discount_percentage\", \"discount_amount\", \"sec_tax_breakup\", \"other_charges_calculation\", \"packing_list\", \"packed_items\", \"pricing_rule_details\", \"pricing_rules\", \"contact_info\", \"billing_address_column\", \"customer_address\", \"address_display\", \"customer_group\", \"territory\", \"column_break_84\", \"contact_person\", \"contact_display\", \"contact_phone\", \"contact_mobile\", \"contact_email\", \"shipping_address_column\", \"shipping_address_name\", \"shipping_address\", \"column_break_93\", \"dispatch_address_name\", \"dispatch_address\", \"col_break46\", \"company_address\", \"company_address_display\", \"column_break_92\", \"company_contact_person\", \"payment_schedule_section\", \"payment_terms_section\", \"payment_terms_template\", \"payment_schedule\", \"terms_section_break\", \"tc_name\", \"terms\", \"more_info\", \"section_break_78\", \"status\", \"delivery_status\", \"per_delivered\", \"column_break_81\", \"per_billed\", \"per_picked\", \"billing_status\", \"sales_team_section_break\", \"sales_partner\", \"column_break7\", \"amount_eligible_for_commission\", \"commission_rate\", \"total_commission\", \"section_break1\", \"sales_team\", \"loyalty_points_redemption\", \"loyalty_points\", \"column_break_116\", \"loyalty_amount\", \"subscription_section\", \"from_date\", \"to_date\", \"column_break_108\", \"auto_repeat\", \"update_auto_repeat_reference\", \"printing_details\", \"letter_head\", \"group_same_items\", \"column_break4\", \"select_print_heading\", \"language\", \"additional_info_section\", \"is_internal_customer\", \"custom_animo_api_status\", \"custom_animo_api_response\", \"represents_company\", \"column_break_152\", \"source\", \"inter_company_order_reference\", \"campaign\", \"party_account_currency\", \"connections_tab\"]" - }, + "parent": "Sales Order", + "parent_doctype": null, + "parentfield": "links", + "parenttype": "Customize Form", + "table_fieldname": null + } + ], + "property_setters": [ { "_assign": null, "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-06-09 10:26:21.153761", + "creation": "2025-03-29 20:37:38.015527", "default_value": null, "doc_type": "Sales Order", "docstatus": 0, - "doctype_or_field": "DocType", - "field_name": null, + "doctype_or_field": "DocField", + "field_name": "base_rounded_total", "idx": 0, "is_system_generated": 0, - "modified": "2025-06-09 10:26:21.153761", + "modified": "2025-06-06 14:02:34.390638", "modified_by": "Administrator", "module": null, - "name": "Sales Order-main-links_order", + "name": "Sales Order-base_rounded_total-hidden", "owner": "Administrator", - "property": "links_order", - "property_type": "Small Text", + "property": "hidden", + "property_type": "Check", "row_name": null, - "value": "[\"vh3cpo4afa\", \"vh35ou9602\"]" + "value": "0" }, { "_assign": null, "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-02-10 17:19:12.712546", + "creation": "2025-03-29 20:37:38.031423", "default_value": null, "doc_type": "Sales Order", "docstatus": 0, "doctype_or_field": "DocField", - "field_name": "payment_schedule", + "field_name": "base_rounded_total", "idx": 0, "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.464788", + "modified": "2025-06-06 14:02:34.398769", "modified_by": "Administrator", "module": null, - "name": "Sales Order-payment_schedule-print_hide", + "name": "Sales Order-base_rounded_total-print_hide", "owner": "Administrator", "property": "print_hide", "property_type": "Check", @@ -1262,18 +1933,18 @@ "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-03-29 20:37:38.046560", + "creation": "2025-03-29 20:37:38.498950", "default_value": null, "doc_type": "Sales Order", "docstatus": 0, "doctype_or_field": "DocField", - "field_name": "rounded_total", + "field_name": "in_words", "idx": 0, "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.474369", + "modified": "2025-06-06 14:02:34.425193", "modified_by": "Administrator", "module": null, - "name": "Sales Order-rounded_total-hidden", + "name": "Sales Order-in_words-hidden", "owner": "Administrator", "property": "hidden", "property_type": "Check", @@ -1285,18 +1956,18 @@ "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-03-29 20:37:38.062243", + "creation": "2025-03-29 20:37:38.511706", "default_value": null, "doc_type": "Sales Order", "docstatus": 0, "doctype_or_field": "DocField", - "field_name": "rounded_total", + "field_name": "in_words", "idx": 0, "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.483896", + "modified": "2025-06-06 14:02:34.437044", "modified_by": "Administrator", "module": null, - "name": "Sales Order-rounded_total-print_hide", + "name": "Sales Order-in_words-print_hide", "owner": "Administrator", "property": "print_hide", "property_type": "Check", @@ -1308,41 +1979,18 @@ "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-02-10 17:20:48.306479", - "default_value": null, - "doc_type": "Sales Order", - "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "scan_barcode", - "idx": 0, - "is_system_generated": 1, - "modified": "2025-06-06 14:02:34.494206", - "modified_by": "Administrator", - "module": null, - "name": "Sales Order-scan_barcode-hidden", - "owner": "Administrator", - "property": "hidden", - "property_type": "Check", - "row_name": null, - "value": "0" - }, - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-02-10 17:20:43.217392", + "creation": "2025-03-29 20:37:38.046560", "default_value": null, "doc_type": "Sales Order", "docstatus": 0, "doctype_or_field": "DocField", - "field_name": "tax_id", + "field_name": "rounded_total", "idx": 0, "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.504141", + "modified": "2025-06-06 14:02:34.474369", "modified_by": "Administrator", "module": null, - "name": "Sales Order-tax_id-hidden", + "name": "Sales Order-rounded_total-hidden", "owner": "Administrator", "property": "hidden", "property_type": "Check", @@ -1354,18 +2002,18 @@ "_comments": null, "_liked_by": null, "_user_tags": null, - "creation": "2025-02-10 17:20:43.228483", + "creation": "2025-03-29 20:37:38.062243", "default_value": null, "doc_type": "Sales Order", "docstatus": 0, "doctype_or_field": "DocField", - "field_name": "tax_id", + "field_name": "rounded_total", "idx": 0, "is_system_generated": 0, - "modified": "2025-06-06 14:02:34.514262", + "modified": "2025-06-06 14:02:34.483896", "modified_by": "Administrator", "module": null, - "name": "Sales Order-tax_id-print_hide", + "name": "Sales Order-rounded_total-print_hide", "owner": "Administrator", "property": "print_hide", "property_type": "Check", 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..4d485304 --- /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", + "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", + "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", + "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", + "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/custom/warehouse.json b/commera/commera_ecommerce/custom/warehouse.json index 916a33eb..e1f9555d 100644 --- a/commera/commera_ecommerce/custom/warehouse.json +++ b/commera/commera_ecommerce/custom/warehouse.json @@ -68,30 +68,6 @@ "custom_perms": [], "doctype": "Warehouse", "links": [], - "property_setters": [ - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2025-04-11 08:39:41.822511", - "default_value": null, - "doc_type": "Warehouse", - "docstatus": 0, - "doctype_or_field": "DocType", - "field_name": null, - "idx": 0, - "is_system_generated": 0, - "modified": "2025-04-11 08:39:41.822511", - "modified_by": "Administrator", - "module": null, - "name": "Warehouse-main-field_order", - "owner": "Administrator", - "property": "field_order", - "property_type": "Data", - "row_name": null, - "value": "[\"warehouse_detail\", \"disabled\", \"warehouse_name\", \"column_break_3\", \"is_group\", \"parent_warehouse\", \"is_rejected_warehouse\", \"column_break_4\", \"custom_store_pickup\", \"account\", \"company\", \"address_and_contact\", \"address_html\", \"column_break_10\", \"contact_html\", \"warehouse_contact_info\", \"email_id\", \"phone_no\", \"mobile_no\", \"column_break0\", \"address_line_1\", \"address_line_2\", \"city\", \"state\", \"pin\", \"transit_section\", \"warehouse_type\", \"column_break_qajx\", \"default_in_transit_warehouse\", \"tree_details\", \"lft\", \"rgt\", \"old_parent\"]" - } - ], + "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..95199467 --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_cart/commera_cart.json @@ -0,0 +1,78 @@ +{ + "actions": [], + "autoname": "hash", + "creation": "2026-10-10 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "user", + "cart_key", + "attributes", + "plugin_fields", + "discount_codes", + "lines" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "label": "User", + "options": "User", + "read_only": 1, + "unique": 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, + "unique": 1 + }, + { + "description": "JSON object of cart-level attributes, copied to the order.", + "fieldname": "attributes", + "fieldtype": "Small Text", + "label": "Attributes" + }, + { + "description": "JSON object of plugin Quotation fields set before checkout; copied to the Quotation at checkout.", + "fieldname": "plugin_fields", + "fieldtype": "Long Text", + "label": "Plugin Fields", + "read_only": 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-21 18: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..16b3a694 --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_cart/commera_cart.py @@ -0,0 +1,438 @@ +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import create_batch +from frappe.utils.data import add_days, cint, cstr, flt, get_datetime, now_datetime + +from commera.api.cart import validate_stock_available +from commera.discounts.allocations import get_allocations, get_price_discount_rows +from commera.discounts.cart_context import get_cart_context +from commera.discounts.codes import get_code_discount, normalize_code +from commera.discounts.doctype.commera_discount.commera_discount import SHIPPING, DiscountCodeError +from commera.discounts.preview import get_preview_quotation +from commera.discounts.pricing import get_saving, select_discounts +from commera.discounts.selection import ( + Selection, + get_discount, + get_discount_codes, + get_unmet_reason, + get_unusable_code_reason, + validate_new_discount_code, +) +from commera.guest import clear_guest_cart_cookie, get_guest_cart_key, is_guest, set_guest_cart_cookie +from commera.plugin_events import fire_event, get_handlers +from commera.utils import get_available_stocks, get_free_delivery + +MAX_CART_LINES = 50 +# Discount pricing slows down with every unit, so even an item that never runs out has a limit. +MAX_LINE_QUANTITY = 100 +ABANDONED_GUEST_CART_DAYS = 30 + + +def get_max_line_quantity() -> int: + return cint(frappe.get_cached_doc("Commera Settings").max_cart_line_quantity) or MAX_LINE_QUANTITY + + +class CommeraCart(Document): + def validate(self): + if len(self.lines) > MAX_CART_LINES: + frappe.throw(_("A cart holds at most {0} lines.").format(MAX_CART_LINES)) + max_line_quantity = get_max_line_quantity() + for line in self.lines: + if cint(line.qty) < 1: + frappe.throw(_("Row {0}: the quantity must be at least 1.").format(line.idx)) + if cint(line.qty) > max_line_quantity: + frappe.throw( + _("Row {0}: a cart line holds at most {1} units.").format(line.idx, max_line_quantity) + ) + attributes = parse_attributes(line.attributes) + line.attributes = frappe.as_json(attributes, indent=None) if attributes else None + self.validate_stock() + + def validate_stock(self): + # Only what grew: a line stock fell under since it was added must not block every other cart edit. + previous = self.get_doc_before_save() + previous_quantities = get_quantities_by_item_code(previous.lines) if previous else {} + grown = { + item_code: qty + for item_code, qty in get_quantities_by_item_code(self.lines).items() + if qty > previous_quantities.get(item_code, 0) + } + if grown: + validate_stock_available( + [{"variant": {"item_code": item_code}, "qty": qty} for item_code, qty in grown.items()] + ) + + def cap_at_stock(self): + quantities = get_quantities_by_item_code(self.lines) + warehouse = frappe.get_cached_value("Commera Settings", "Commera Settings", "ecommerce_warehouse") + stock_by_item_code = get_available_stocks(list(quantities), warehouse) + for item_code, qty in quantities.items(): + stock = stock_by_item_code.get(item_code, {}) + available = max(cint(stock.get("stock_qty")), 0) + if not stock.get("unlimited") and qty > available: + rows = [row for row in self.lines if row.item_code == item_code] + change_quantity_from_the_end(self, rows, available - qty) + + def after_insert(self): + # Most guest carts never reach checkout, so the event is only logged when an app listens for it. + if get_handlers("commera_events", "cart_created"): + fire_event("cart_created", self.doctype, self.name, data={"user": self.user}) + + @property + def version(self) -> str: + return str(get_datetime(self.modified)) + + def add_lines(self, lines: list[dict]): + for line in lines: + attributes = parse_attributes(line.get("attributes")) + qty = cint(line.get("qty") or 1) + selected = cint(line.get("selected", 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 + existing_line.selected = cint(existing_line.selected) or selected + else: + self.append( + "lines", + { + "item_code": line["item_code"], + "qty": qty, + "attributes": attributes, + "selected": selected, + }, + ) + + def update_lines(self, lines: list[dict]): + 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: + 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 set_selection(self, selection: dict[str, bool]) -> bool: + changed = False + for row in self.lines: + selected = cint(selection.get(row.item_code, 1)) + changed = changed or cint(row.selected) != selected + # Set even when unchanged: a new line's None would take the field's default of 1 on insert. + row.selected = selected + return changed + + def get_selected_lines(self) -> list: + return [row for row in self.lines if cint(row.selected)] + + def add_discount_code(self, code: str): + codes = [applied_code for applied_code in get_discount_codes(self) if get_code_discount(applied_code)] + preview = self.get_quotation_preview() + # A code that does not combine still joins the cart: pricing keeps whichever saves the shopper more. + code = validate_new_discount_code(code, codes, active_codes=[]) + if refusal := get_unusable_code_reason(preview, code): + frappe.throw(refusal, DiscountCodeError) + self.discount_codes = "\n".join([*codes, code]) + + def remove_discount_code(self, code: str): + 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]: + lines_by_item_code = {} + for row in self.get_selected_lines(): + lines_by_item_code.setdefault(row.item_code, []).append(row) + return lines_by_item_code + + def get_priced_cart(self) -> dict: + # Unticked lines are priced on their own, without discounts. + quotation = self.get_quotation_preview() + selected_lines = self.get_selected_lines() + items_by_row = dict(zip([row.name for row in selected_lines], quotation.items, strict=True)) + if other_lines := [row for row in self.lines if not cint(row.selected)]: + plain_quotation = get_preview_quotation( + get_preview_lines(other_lines), user=self.user, flags={"commera_discounts": []} + ) + items_by_row.update(zip([row.name for row in other_lines], plain_quotation.items, strict=True)) + return { + "name": self.name, + "version": self.version, + "attributes": parse_attributes(self.attributes), + "currency": quotation.currency, + "discount_codes": get_code_states(quotation), + "automatic_discounts": get_automatic_discounts(quotation), + "discounts": get_price_discount_rows(quotation), + "lines": [get_priced_line(row, items_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), + **get_free_delivery(quotation), + } + + def get_quotation_preview(self): + fields = {**frappe.parse_json(self.plugin_fields or "{}"), "custom_cart_attributes": self.attributes} + return get_preview_quotation( + get_preview_lines(self.get_selected_lines()), self.discount_codes, self.user, fields=fields + ) + + def update_quotation(self, quotation): + # Split by attributes only while the qty still matches the ticked lines. + 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 + quotation.custom_cart_attributes = self.attributes + quotation.update(frappe.parse_json(self.plugin_fields or "{}")) + + def set_attributes(self, values: dict): + attributes = { + **parse_attributes(self.attributes), + **{cstr(key): value for key, value in values.items()}, + } + attributes = {key: cstr(value) for key, value in attributes.items() if value is not None} + self.attributes = frappe.as_json(attributes, indent=None) if attributes else None + + def set_plugin_fields(self, values: dict): + self.plugin_fields = frappe.as_json( + {**frappe.parse_json(self.plugin_fields or "{}"), **values}, indent=None + ) + + +def get_preview_lines(rows: list) -> list[dict]: + return [{"item_code": row.item_code, "qty": row.qty} for row in rows] + + +def get_quantities_by_item_code(rows: list) -> dict[str, int]: + quantities = {} + for row in rows: + quantities[row.item_code] = quantities.get(row.item_code, 0) + cint(row.qty) + return quantities + + +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_code_states(quotation) -> list[dict]: + applied = {(row["discount"], row.get("code")) for row in get_allocations(quotation)} + cart = get_cart_context(quotation) + selections = select_discounts(cart) + states = [] + for code in get_discount_codes(quotation): + discount_name = get_code_discount(code) + discount = get_discount(discount_name) if discount_name else None + is_applied = (discount_name, code) in applied + states.append( + { + "code": code, + "title": discount.title if discount else code, + "discount_class": discount.discount_class if discount else None, + "applied": is_applied, + "reason": None if is_applied else get_code_reason(cart, code, selections), + } + ) + return states + + +def get_code_reason(cart, code: str, selections: list) -> str: + if reason := get_unusable_code_reason(cart.doc, code): + return reason + discount = get_discount(get_code_discount(code)) + if reason := get_unmet_reason(discount, cart): + return reason + if not discount.gives_anything(cart): + if discount.discount_class == SHIPPING: + return _("{0} does not cover the delivery option you chose.").format(discount.title) + return _("Nothing in your cart qualifies for {0}.").format(discount.title) + if discount.discount_class == SHIPPING and any(selection.code == code for selection in selections): + return _("{0} comes off the delivery charge once you choose a delivery option.").format( + discount.title + ) + blocking = next((other for other in selections if not discount.can_combine_with(other.discount)), None) + if blocking: + label = blocking.code or blocking.discount.title + own = [Selection(discount, code), *(s for s in selections if discount.can_combine_with(s.discount))] + if flt(get_saving(cart, own), 2) < flt(get_saving(cart, selections), 2): + return _("{0} doesn't combine with {1}, which saves you more.").format(discount.title, label) + return _("{0} doesn't combine with {1}.").format(discount.title, label) + return _("{0} cannot be combined with your other discounts.").format(discount.title) + + +def get_automatic_discounts(quotation) -> list[dict]: + return [ + {"title": row["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, + "item_code": row.item_code, + "title": item.item_name, + "qty": cint(row.qty), + "selected": bool(cint(row.selected)), + "attributes": parse_attributes(row.attributes), + "price": flt(item.price_list_rate), + "rate": flt(item.rate), + "code_discount": flt(item.custom_code_discount), + "discount": item.custom_discount, + "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: + 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 + + +def clear_ordered_cart(quotation, shopper: str | None): + # modified is left alone so the next browser sync is not a version conflict. + cart = frappe.qb.DocType("Commera Cart") + if quotation.custom_guest_cart_key: + condition = cart.cart_key == quotation.custom_guest_cart_key + elif shopper: + condition = cart.user == shopper + else: + return + cart_name = frappe.qb.from_(cart).select(cart.name).where(condition).run(pluck=True) + if not cart_name: + return + frappe.qb.update(cart).set(cart.discount_codes, None).set(cart.attributes, None).where( + cart.name == cart_name[0] + ).run() + # The webhook can place the order while the shopper never comes back to sync the browser cart. + line = frappe.qb.DocType("Commera Cart Line") + frappe.qb.from_(line).delete().where( + (line.parent == cart_name[0]) & (line.parenttype == "Commera Cart") & (line.selected == 1) + ).run() + + +def delete_abandoned_guest_carts(): + cutoff = add_days(now_datetime(), -ABANDONED_GUEST_CART_DAYS) + names = frappe.get_all( + "Commera Cart", filters={"user": ["is", "not set"], "modified": ["<", cutoff]}, pluck="name" + ) + for batch in create_batch(names, 500): + frappe.db.delete("Commera Cart Line", {"parent": ["in", batch], "parenttype": "Commera Cart"}) + frappe.db.delete("Commera Cart", {"name": ["in", batch]}) + frappe.db.commit() # nosemgrep: frappe-manual-commit # a daily job; each batch stands alone + + +def keep_guest_cart(login_manager=None): + # A failed merge is logged, never a reason to refuse sign-in. + cart_key = get_guest_cart_key() + guest_cart_name = cart_key and frappe.db.get_value("Commera Cart", {"cart_key": cart_key}, "name") + if not guest_cart_name or is_guest(): + return + frappe.db.savepoint("keep_guest_cart") + try: + guest_cart = frappe.get_doc("Commera Cart", guest_cart_name, for_update=True) + account_cart = get_session_cart() + if account_cart: + merge_carts(guest_cart, account_cart) + else: + guest_cart.update({"user": frappe.session.user, "cart_key": None}) + guest_cart.save(ignore_permissions=True) + except Exception: + frappe.db.rollback(save_point="keep_guest_cart") + frappe.log_error(title=f"Could not keep the guest cart {guest_cart_name} on sign-in") + clear_guest_cart_cookie() + + +def merge_carts(guest_cart: CommeraCart, account_cart: CommeraCart): + account_cart.add_lines( + [ + { + "item_code": row.item_code, + "qty": row.qty, + "attributes": parse_attributes(row.attributes), + "selected": row.selected, + } + for row in guest_cart.lines + ] + ) + account_cart.cap_at_stock() + account_cart.set_attributes(parse_attributes(guest_cart.attributes)) + for code in get_discount_codes(guest_cart): + try: + account_cart.add_discount_code(code) + except frappe.ValidationError: + # A code the account cart refuses, such as one already used, is left behind. + frappe.clear_last_message() + account_cart.save(ignore_permissions=True) + frappe.delete_doc("Commera Cart", guest_cart.name, ignore_permissions=True, force=True) 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..c5840d1c --- /dev/null +++ b/commera/commera_ecommerce/doctype/commera_cart_line/commera_cart_line.json @@ -0,0 +1,51 @@ +{ + "actions": [], + "creation": "2026-10-10 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": ["item_code", "qty", "attributes", "selected"], + "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" + }, + { + "default": "1", + "fieldname": "selected", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Include in Checkout" + } + ], + "istable": 1, + "links": [], + "modified": "2026-10-21 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_settings/commera_settings.json b/commera/commera_ecommerce/doctype/commera_settings/commera_settings.json index 85851ea2..e8f86792 100644 --- a/commera/commera_ecommerce/doctype/commera_settings/commera_settings.json +++ b/commera/commera_ecommerce/doctype/commera_settings/commera_settings.json @@ -34,6 +34,7 @@ "print_format", "allow_guest_checkout", "guest_order_link_days", + "max_cart_line_quantity", "payment_mode_section", "column_break_iirq", "column_break_oopm", @@ -861,6 +862,14 @@ "label": "Guest Order Link Full Details (Days)", "non_negative": 1 }, + { + "default": "100", + "description": "The most units of one item a shopper can put in the cart, even when the item never runs out of stock. Blank or 0 means 100.", + "fieldname": "max_cart_line_quantity", + "fieldtype": "Int", + "label": "Most Units per Cart Line", + "non_negative": 1 + }, { "fieldname": "storefront_apps_tab", "fieldtype": "Tab Break", @@ -879,7 +888,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-10-08 10:00:00.000000", + "modified": "2026-10-21 10:00:00.000000", "modified_by": "Administrator", "module": "Commera Ecommerce", "name": "Commera Settings", diff --git a/commera/core.py b/commera/core.py index b114de3b..6a269667 100644 --- a/commera/core.py +++ b/commera/core.py @@ -1,4 +1,5 @@ import os +from contextlib import contextmanager import frappe import frappe.defaults @@ -10,6 +11,32 @@ from commera.guest import get_guest_cart_name, is_guest, validate_guest_checkout_enabled +COD_PAYMENT_MODE = "COD" + + +def is_cod(payment_mode: str | None) -> bool: + return (payment_mode or "").strip().casefold() == COD_PAYMENT_MODE.casefold() + + +@contextmanager +def system_user_session(user: str = "Administrator"): + """Place the accounting documents as `user`, then hand the session back. + + ERPNext's get_party_account checks frappe.has_permission directly, so no ignore_permissions reaches it. + """ + # frappe.set_user() mutates frappe.local.session in place, stamping "sid" with the username and wiping + # local.session.data, so restore the snapshot verbatim rather than calling it again for the shopper. + live_session_snapshot = frappe.local.session.copy() + try: + # Audited: the docstring above and the snapshot restore below are why this is safe. + frappe.set_user(user) # nosemgrep: frappe-semgrep-rules.rules.security.frappe-setuser + yield + finally: + frappe.local.session.update(live_session_snapshot) + frappe.local.cache = {} + frappe.local.role_permissions = {} + frappe.local.user_perms = None + def generate_otp(): """Generates a cryptographically secure random OTP""" @@ -113,8 +140,6 @@ def get_party(user=None): if link: party_doc = frappe.get_cached_doc(link.link_doctype, link.link_name) if not frappe.db.exists("Portal User", {"parent": party_doc.name, "user": user}): - from commera.api.payments import system_user_session - party_doc.append("portal_users", {"user": user}) party_doc.flags.ignore_mandatory = True # Customer.on_update rewrites the primary Contact, which the shopper cannot write. 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..78ef8286 --- /dev/null +++ b/commera/discounts/allocations.py @@ -0,0 +1,49 @@ +import json + +import frappe +from frappe.utils.data import flt + +ALLOCATIONS_FIELD = "custom_discount_allocations" + + +def get_allocations(doc) -> list[dict]: + return frappe.parse_json(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, line: int | None = None) -> dict: + return { + "discount": selection.discount.name, + "code": selection.code, + "title": selection.discount.title, + "discount_class": discount_class, + "amount": flt(amount, 2), + "line": line, + } + + +def get_discount_rows(allocations: list[dict], classes: tuple = (), line: int | None = None) -> list[dict]: + amounts = {} + for allocation in allocations: + if line is not None and allocation.get("line") != line: + continue + if classes and allocation.get("discount_class") not in classes: + continue + label = allocation.get("code") or allocation.get("title") + amounts[label] = amounts.get(label, 0) + flt(allocation.get("amount")) + return [{"label": label, "amount": flt(amount, 2)} for label, amount in amounts.items()] + + +def get_order_discount_rows(doc) -> list[dict]: + rows = get_discount_rows(get_allocations(doc), ("Order",)) + # A discount ERPNext gave on its own, such as a Pricing Rule, has no allocation to name it. + other = flt(flt(doc.get("discount_amount")) - sum(row["amount"] for row in rows), 2) + return [*rows, {"label": None, "amount": other}] if other > 0 else rows + + +def get_price_discount_rows(doc) -> list[dict]: + # Includes line and order discounts so a list-price summary adds up. + return get_discount_rows(get_allocations(doc), ("Product",)) + get_order_discount_rows(doc) diff --git a/commera/discounts/cart_context.py b/commera/discounts/cart_context.py new file mode 100644 index 00000000..31afbb29 --- /dev/null +++ b/commera/discounts/cart_context.py @@ -0,0 +1,91 @@ +from dataclasses import dataclass, field + +import frappe +from erpnext.setup.utils import get_exchange_rate +from frappe.utils.data import cint, cstr, flt + +from commera.api.cart import get_cart_price +from commera.core import is_cod + + +@dataclass +class CartContext: + 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) + exchange_rates: dict = field(default_factory=dict) + offered_option: dict | None = None + + @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) + + @property + def delivery_option(self) -> dict | None: + # Priced before any shipping discount. + if self.offered_option: + return self.offered_option + if not self.doc.get("custom_delivery_option"): + return None + return { + "title": self.doc.custom_delivery_option, + "amount": flt(self.doc.get("custom_delivery_charge")) + + flt(self.doc.get("custom_shipping_discount")), + } + + def get_exchange_rate(self, currency: str | None) -> float: + currency = currency or frappe.get_cached_value("Company", self.doc.company, "default_currency") + if currency == self.currency: + return 1.0 + if currency not in self.exchange_rates: + self.exchange_rates[currency] = flt( + get_exchange_rate(currency, self.currency, self.doc.get("transaction_date"), "for_selling") + ) + return self.exchange_rates[currency] + + def convert(self, amount: float, currency: str | None) -> float: + return flt(amount) * self.get_exchange_rate(currency) if flt(amount) else 0.0 + + 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.""" + 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..55b4bdbf --- /dev/null +++ b/commera/discounts/codes.py @@ -0,0 +1,92 @@ +import secrets + +import frappe +from frappe import _ +from frappe.utils.data import cint, cstr + +from commera.plugin_events import get_handlers, handle_hook_error + +MAX_GENERATED_CODES = 5000 +MAX_CODES_IN_REQUEST = 200 +# Leaves room for the 8 random characters within a code's 64. +MAX_PREFIX_LENGTH = 56 +# 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: + return ( + frappe.get_doc( + { + "doctype": "Commera Discount Code", + "code": code, + "discount": discount, + "is_generated": is_generated, + } + ) + .insert(ignore_permissions=True) + .name + ) + + +def validate_code_count(count: int, prefix: str = ""): + if not 0 < cint(count) <= MAX_GENERATED_CODES: + frappe.throw(_("Generate between 1 and {0} codes at a time.").format(MAX_GENERATED_CODES)) + if len(normalize_code(prefix)) > MAX_PREFIX_LENGTH: + frappe.throw(_("A code prefix holds at most {0} characters.").format(MAX_PREFIX_LENGTH)) + + +def generate_codes(discount: str, count: int, prefix: str = "") -> list[str]: + validate_code_count(count, prefix) + count, prefix = cint(count), normalize_code(prefix) + codes = [] + while len(codes) < count: + batch = { + prefix + "".join(secrets.choice(CODE_ALPHABET) for _index in range(8)) + for _row in range(count - len(codes)) + } + taken = set( + frappe.get_all("Commera Discount Code", filters={"name": ["in", list(batch)]}, pluck="name") + ) + codes += [add_code(discount, code, is_generated=True) for code in batch - taken] + return codes + + +def enqueue_codes(discount: str, count: int, prefix: str = "") -> int: + """Returns how many were made now; a large batch is made by a background job instead.""" + validate_code_count(count, prefix) + if cint(count) <= MAX_CODES_IN_REQUEST: + return len(generate_codes(discount, count, prefix)) + frappe.enqueue( + generate_codes, + queue="long", + enqueue_after_commit=True, + discount=discount, + count=cint(count), + prefix=prefix, + ) + return 0 + + +def get_discount_code_refusal(quotation, code: str) -> str | None: + for handler in get_handlers("commera_hooks", "validate_discount_code"): + reason = None + with handle_hook_error( + "validate_discount_code", handler, quotation, bool(quotation.flags.strict_plugin_fees) + ): + reason = frappe.get_attr(handler)(quotation, code) + if reason and not isinstance(reason, str): + raise TypeError( + f'commera_hooks["validate_discount_code"] must return a str or None, got {reason!r}' + ) + if reason: + return reason + return None diff --git a/commera/discounts/doctype/__init__.py b/commera/discounts/doctype/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/discounts/doctype/commera_discount/__init__.py b/commera/discounts/doctype/commera_discount/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/discounts/doctype/commera_discount/commera_discount.json b/commera/discounts/doctype/commera_discount/commera_discount.json new file mode 100644 index 00000000..07b9084a --- /dev/null +++ b/commera/discounts/doctype/commera_discount/commera_discount.json @@ -0,0 +1,470 @@ +{ + "actions": [], + "creation": "2026-10-11 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "title", + "method", + "discount_type", + "column_break_main", + "enabled", + "starts_on", + "ends_on", + "section_value", + "value_type", + "value", + "maximum_discount", + "fixed_applies_to", + "column_break_value", + "currency", + "tiered", + "tiers", + "section_targets", + "applies_to", + "exclude_sale_items", + "targets", + "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", + "customers", + "section_combinations", + "combines_with_product", + "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": [ + { + "fieldname": "title", + "fieldtype": "Data", + "label": "Title", + "reqd": 1, + "in_list_view": 1, + "description": "Shoppers see this for an automatic discount. A code discount shows its code." + }, + { + "fieldname": "method", + "fieldtype": "Select", + "label": "Method", + "options": "Code\nAutomatic", + "default": "Code", + "reqd": 1, + "in_list_view": 1, + "in_standard_filter": 1 + }, + { + "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, + "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", + "default": "Percentage" + }, + { + "fieldname": "value", + "fieldtype": "Float", + "label": "Value", + "non_negative": 1 + }, + { + "fieldname": "maximum_discount", + "fieldtype": "Currency", + "label": "Maximum Discount", + "options": "currency", + "non_negative": 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" + }, + { + "fieldname": "currency", + "fieldtype": "Link", + "label": "Currency", + "options": "Currency", + "description": "Fixed amounts, caps and budgets apply only to carts in this currency." + }, + { + "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": "tiers", + "fieldtype": "Table", + "label": "Tiers", + "options": "Commera Discount Tier", + "depends_on": "eval:doc.tiered" + }, + { + "fieldname": "section_targets", + "fieldtype": "Section Break", + "label": "Products", + "depends_on": "eval:['Amount Off Products','Buy X Get Y'].includes(doc.discount_type)" + }, + { + "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 + }, + { + "fieldname": "uses_per_order", + "fieldtype": "Int", + "label": "Maximum Uses Per Order", + "non_negative": 1, + "description": "0 means as many times as the cart allows." + }, + { + "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": "Delivery", + "depends_on": "eval:doc.discount_type == 'Free Shipping'" + }, + { + "fieldname": "delivery_options", + "fieldtype": "Small Text", + "label": "Delivery Options", + "description": "Delivery option titles, one per line. Empty means every option." + }, + { + "fieldname": "column_break_shipping", + "fieldtype": "Column Break" + }, + { + "fieldname": "shipping_percentage", + "fieldtype": "Float", + "label": "Off The Delivery Charge (%)", + "default": "100", + "non_negative": 1 + }, + { + "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", + "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\nSpecific Customers", + "default": "Everyone" + }, + { + "fieldname": "customer_groups", + "fieldtype": "Table MultiSelect", + "label": "Customer Groups", + "options": "Commera Discount Customer Group", + "depends_on": "eval:doc.eligibility == 'Customer Groups'" + }, + { + "fieldname": "customers", + "fieldtype": "Table MultiSelect", + "label": "Specific Customers", + "options": "Commera Discount Customer", + "depends_on": "eval:doc.eligibility == 'Specific Customers'" + }, + { + "fieldname": "section_combinations", + "fieldtype": "Section Break", + "label": "Combinations", + "description": "Two discounts stack only when each allows the other's class." + }, + { + "fieldname": "combines_with_product", + "fieldtype": "Check", + "label": "Combines With Product Discounts" + }, + { + "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": "UTM 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-21 00:00:00.000000", + "modified_by": "Administrator", + "module": "Discounts", + "name": "Commera Discount", + "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": "format:DISC-{#####}", + "title_field": "title", + "show_title_field_in_link": 1, + "search_fields": "discount_type,method" +} diff --git a/commera/discounts/doctype/commera_discount/commera_discount.py b/commera/discounts/doctype/commera_discount/commera_discount.py new file mode 100644 index 00000000..6707ffc6 --- /dev/null +++ b/commera/discounts/doctype/commera_discount/commera_discount.py @@ -0,0 +1,341 @@ +import frappe +from erpnext.setup.utils import get_exchange_rate +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 + +from commera.discounts.codes import add_code, normalize_code + +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" +MAX_CUSTOMERS = 100 + + +class DiscountCodeError(frappe.ValidationError): + pass + + +class CommeraDiscount(Document): + """flags.code: the code a new Code discount is made with; its title defaults to it.""" + + def validate(self): + self.validate_code() + 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() + if self.enabled and self.eligibility == "Customer Groups" and not self.customer_groups: + frappe.throw(_("Choose at least one customer group, or let everyone use this discount.")) + if self.enabled and self.eligibility == "Specific Customers" and not self.customers: + frappe.throw(_("Choose at least one customer, or let everyone use this discount.")) + if len(self.customers) > MAX_CUSTOMERS: + frappe.throw(_("A discount can be limited to at most {0} customers.").format(MAX_CUSTOMERS)) + + def validate_code(self): + code = normalize_code(self.flags.code) + if self.method != "Code": + if code: + frappe.throw(_("Only a code discount takes codes.")) + return + if self.is_new() and "code" in self.flags and not code: + frappe.throw(_("A code discount needs a code.")) + self.title = self.title or code or None + + def after_insert(self): + if self.method == "Code" and normalize_code(self.flags.code): + add_code(self.name, self.flags.code) + + def on_trash(self): + if frappe.db.exists("Commera Discount Use", {"discount": self.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": self.name}, pluck="name"): + frappe.delete_doc("Commera Discount Code", code, ignore_permissions=True) + + 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: + """In the company currency, which Discount given is kept in; the budget is in the discount's.""" + if not flt(self.budget): + return None + return max(flt(self.budget) * self.get_company_exchange_rate() - flt(self.discount_given), 0) + + def get_company_exchange_rate(self) -> float: + company = frappe.get_cached_doc("Commera Settings").get( + "company" + ) or frappe.defaults.get_global_default("company") + company_currency = frappe.get_cached_value("Company", company, "default_currency") + if not self.currency or self.currency == company_currency: + return 1.0 + return flt(get_exchange_rate(self.currency, company_currency, today(), "for_selling")) + + @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 reason := self.get_unusable_reason(): + frappe.throw(reason, DiscountCodeError) + + def get_unusable_reason(self) -> str | None: + messages = { + "Disabled": _("{0} is not active."), + "Scheduled": _("{0} is not active yet."), + "Expired": _("{0} has expired."), + "Used Up": _("{0} has been used up."), + } + return messages[self.status].format(self.title) if self.status in messages else None + + 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: + 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, cart) -> bool: + return cart.get_exchange_rate(self.currency) > 0 + + def get_money(self, amount: float, cart) -> float: + return cart.convert(amount, self.currency) + + def get_budget_left(self, cart) -> float | None: + budget_left = self.budget_left + return None if budget_left is None else cart.convert(budget_left, None) + + def get_unmet_condition(self, cart) -> str | None: + if reason := self.get_unmet_minimum(cart): + return reason + 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): + return _("{0} is not available for your account.").format(self.title) + if self.eligibility == "Specific Customers" and not self.is_for_customer(cart): + return _("{0} is not available for your account.").format(self.title) + return None + + def get_unmet_minimum(self, cart) -> str | None: + minimum_subtotal = self.get_money(self.minimum_subtotal, cart) + if self.minimum_requirement == "Subtotal" and cart.subtotal < minimum_subtotal: + return _("Add {0} more to use {1}.").format( + frappe.format_value( + minimum_subtotal - cart.subtotal, {"fieldtype": "Currency"}, currency=cart.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)) + return None + + def gives_anything(self, cart) -> bool: + # A zero discount must not take a place a combinable one could use. + if self.discount_class == PRODUCT: + return any(self.get_line_discounts(cart).values()) + if self.discount_class == ORDER: + return self.get_order_discount(cart) > 0 + # Before a delivery option is chosen there is nothing to judge a shipping discount on. + if not cart.delivery_option: + return True + return self.get_delivery_discount(cart.delivery_option, cart) > 0 + + def is_for_customer_group(self, cart) -> bool: + if not cart.customer_group or cart.doc.get("custom_guest_cart_key"): + return False + return any( + cart.customer_group == row.customer_group + or cart.customer_group in get_descendants_of("Customer Group", row.customer_group) + for row in self.customer_groups + ) + + def is_for_customer(self, cart) -> bool: + # A guest who types a listed customer's email is booked to that customer, but is not them. + if not cart.customer or cart.doc.get("custom_guest_cart_key"): + return False + return any(row.customer == cart.customer for row in self.customers) + + 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: + 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_line_discounts(self, cart) -> dict[int, float]: + # Keyed by idx: an unsaved preview's rows have no name. + 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, self.get_money(self.maximum_discount, cart)) + value = self.get_money(self.value, cart) + if self.fixed_applies_to == "Each Item": + return {item.idx: min(value, flt(item.rate)) * flt(item.qty) for item in lines} + return spread_amount(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.""" + # One bucket per line, cheapest first: quantities are taken arithmetically, never unit by unit. + buy_lines = {item.idx for item in cart.items if self.is_for_line(item, cart, "Buys")} + get_lines = {item.idx for item in cart.items if self.is_for_line(item, cart, "Gets")} + buckets = sorted( + [flt(item.rate), item.idx, flt(item.qty)] + for item in cart.items + if flt(item.qty) > 0 and (item.idx in buy_lines or item.idx in get_lines) + ) + discounts = {} + applications = 0 + while not self.uses_per_order or applications < cint(self.uses_per_order): + rewarded = take_quantity(buckets, cint(self.get_quantity), get_lines) + if not rewarded or not self.take_buy_units(buckets, buy_lines, cart): + break + for rate, idx, qty in rewarded: + discounts[idx] = discounts.get(idx, 0) + self.get_reward(rate, cart) * qty + applications += 1 + return discounts + + def take_buy_units(self, buckets: list, buy_lines: set[int], cart) -> bool: + buys = [bucket for bucket in buckets if bucket[1] in buy_lines] + if self.buy_requirement == "Quantity": + # The dearest bought units earn the reward, leaving the cheap ones to be rewarded. + return bool(take_quantity(buckets, flt(self.buy_value), buy_lines, dearest_first=True)) + # An amount requirement is met once by everything bought; it does not repeat per order. + if sum(rate * qty for rate, _idx, qty in buys) < self.get_money(self.buy_value, cart): + return False + buckets.clear() + return True + + def get_reward(self, rate: float, cart) -> float: + if self.get_value_type == "Free": + return rate + if self.get_value_type == PERCENTAGE: + return rate * flt(self.get_value) / 100 + return min(self.get_money(self.get_value, cart), 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, cart) if self.tiered else flt(self.value) + if value is None: + return 0.0 + if self.value_type != PERCENTAGE: + return min(self.get_money(value, cart), subtotal) + discount = subtotal * value / 100 + maximum_discount = self.get_money(self.maximum_discount, cart) + return min(discount, maximum_discount) if maximum_discount else discount + + def get_tier_value(self, subtotal: float, cart) -> float | None: + reached = [tier for tier in self.tiers if subtotal >= self.get_money(tier.minimum_subtotal, cart)] + return flt(max(reached, key=lambda tier: flt(tier.minimum_subtotal)).value) if reached else None + + def get_delivery_discount(self, option: dict, cart) -> 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"]) + maximum_shipping_rate = self.get_money(self.maximum_shipping_rate, cart) + if maximum_shipping_rate and amount > maximum_shipping_rate: + return 0.0 + return amount * flt(self.shipping_percentage) / 100 + + +def take_quantity(buckets: list, quantity: float, lines: set[int], dearest_first: bool = False) -> list: + """Takes `quantity` units from the wanted lines' buckets, or nothing when there are not enough.""" + wanted = [bucket for bucket in buckets if bucket[1] in lines] + if dearest_first: + wanted.reverse() + if not quantity or sum(bucket[2] for bucket in wanted) < quantity: + return [] + taken = [] + for bucket in wanted: + qty = min(bucket[2], quantity) + taken.append((bucket[0], bucket[1], qty)) + bucket[2] -= qty + quantity -= qty + if quantity <= 0: + break + buckets[:] = [bucket for bucket in buckets if bucket[2] > 0] + return taken + + +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 spread_amount(amount: float, amounts: dict[int, float]) -> dict[int, float]: + 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]: + return {item_group, *get_descendants_of("Item Group", item_group)} diff --git a/commera/discounts/doctype/commera_discount_code/__init__.py b/commera/discounts/doctype/commera_discount_code/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/discounts/doctype/commera_discount_code/commera_discount_code.json b/commera/discounts/doctype/commera_discount_code/commera_discount_code.json new file mode 100644 index 00000000..6fad82b0 --- /dev/null +++ b/commera/discounts/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": "Discounts", + "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/discounts/doctype/commera_discount_code/commera_discount_code.py b/commera/discounts/doctype/commera_discount_code/commera_discount_code.py new file mode 100644 index 00000000..660d253d --- /dev/null +++ b/commera/discounts/doctype/commera_discount_code/commera_discount_code.py @@ -0,0 +1,23 @@ +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils.data import cint + +from commera.discounts.codes import normalize_code + +MAX_CODE_LENGTH = 64 + + +class CommeraDiscountCode(Document): + def before_insert(self): + self.code = normalize_code(self.code) + if not self.code or len(self.code) > MAX_CODE_LENGTH or any(char.isspace() for char in self.code): + frappe.throw(_("A code needs 1 to {0} characters and no spaces.").format(MAX_CODE_LENGTH)) + if frappe.db.exists("Commera Discount Code", self.code): + frappe.throw(_("The code {0} is already in use.").format(self.code), frappe.DuplicateEntryError) + + def validate(self): + if frappe.get_cached_value("Commera Discount", self.discount, "method") != "Code": + frappe.throw(_("Only a code discount takes codes.")) + if not self.is_new() and self.has_value_changed("discount") and cint(self.times_used): + frappe.throw(_("{0} has been used, so it stays with its discount.").format(self.name)) diff --git a/commera/discounts/doctype/commera_discount_customer/__init__.py b/commera/discounts/doctype/commera_discount_customer/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/discounts/doctype/commera_discount_customer/commera_discount_customer.json b/commera/discounts/doctype/commera_discount_customer/commera_discount_customer.json new file mode 100644 index 00000000..de16da87 --- /dev/null +++ b/commera/discounts/doctype/commera_discount_customer/commera_discount_customer.json @@ -0,0 +1,40 @@ +{ + "actions": [], + "creation": "2026-10-21 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "customer", + "customer_name" + ], + "fields": [ + { + "fieldname": "customer", + "fieldtype": "Link", + "label": "Customer", + "options": "Customer", + "reqd": 1, + "in_list_view": 1 + }, + { + "fetch_from": "customer.customer_name", + "fieldname": "customer_name", + "fieldtype": "Data", + "label": "Customer Name", + "read_only": 1, + "in_list_view": 1 + } + ], + "links": [], + "modified": "2026-10-21 00:00:00.000000", + "modified_by": "Administrator", + "module": "Discounts", + "name": "Commera Discount Customer", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "istable": 1, + "editable_grid": 1 +} diff --git a/commera/discounts/doctype/commera_discount_customer/commera_discount_customer.py b/commera/discounts/doctype/commera_discount_customer/commera_discount_customer.py new file mode 100644 index 00000000..c0004fc0 --- /dev/null +++ b/commera/discounts/doctype/commera_discount_customer/commera_discount_customer.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class CommeraDiscountCustomer(Document): + pass diff --git a/commera/discounts/doctype/commera_discount_customer_group/__init__.py b/commera/discounts/doctype/commera_discount_customer_group/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/discounts/doctype/commera_discount_customer_group/commera_discount_customer_group.json b/commera/discounts/doctype/commera_discount_customer_group/commera_discount_customer_group.json new file mode 100644 index 00000000..5893fbd3 --- /dev/null +++ b/commera/discounts/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": "Discounts", + "name": "Commera Discount Customer Group", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "istable": 1, + "editable_grid": 1 +} diff --git a/commera/discounts/doctype/commera_discount_customer_group/commera_discount_customer_group.py b/commera/discounts/doctype/commera_discount_customer_group/commera_discount_customer_group.py new file mode 100644 index 00000000..e5c7f36c --- /dev/null +++ b/commera/discounts/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/discounts/doctype/commera_discount_target/__init__.py b/commera/discounts/doctype/commera_discount_target/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/discounts/doctype/commera_discount_target/commera_discount_target.json b/commera/discounts/doctype/commera_discount_target/commera_discount_target.json new file mode 100644 index 00000000..35943d57 --- /dev/null +++ b/commera/discounts/doctype/commera_discount_target/commera_discount_target.json @@ -0,0 +1,50 @@ +{ + "actions": [], + "creation": "2026-10-11 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "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", + "label": "Target Type", + "options": "Item\nItem Group\nBrand", + "reqd": 1, + "in_list_view": 1 + }, + { + "fieldname": "target", + "fieldtype": "Dynamic Link", + "label": "Target", + "options": "target_type", + "reqd": 1, + "in_list_view": 1 + } + ], + "links": [], + "modified": "2026-10-11 00:00:00.000000", + "modified_by": "Administrator", + "module": "Discounts", + "name": "Commera Discount Target", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "istable": 1, + "editable_grid": 1 +} diff --git a/commera/discounts/doctype/commera_discount_target/commera_discount_target.py b/commera/discounts/doctype/commera_discount_target/commera_discount_target.py new file mode 100644 index 00000000..dbe483d0 --- /dev/null +++ b/commera/discounts/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/discounts/doctype/commera_discount_tier/__init__.py b/commera/discounts/doctype/commera_discount_tier/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/discounts/doctype/commera_discount_tier/commera_discount_tier.json b/commera/discounts/doctype/commera_discount_tier/commera_discount_tier.json new file mode 100644 index 00000000..a361a198 --- /dev/null +++ b/commera/discounts/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": "Discounts", + "name": "Commera Discount Tier", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "istable": 1, + "editable_grid": 1 +} diff --git a/commera/discounts/doctype/commera_discount_tier/commera_discount_tier.py b/commera/discounts/doctype/commera_discount_tier/commera_discount_tier.py new file mode 100644 index 00000000..38443ef7 --- /dev/null +++ b/commera/discounts/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/discounts/doctype/commera_discount_use/__init__.py b/commera/discounts/doctype/commera_discount_use/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/commera/discounts/doctype/commera_discount_use/commera_discount_use.json b/commera/discounts/doctype/commera_discount_use/commera_discount_use.json new file mode 100644 index 00000000..2cee47d7 --- /dev/null +++ b/commera/discounts/doctype/commera_discount_use/commera_discount_use.json @@ -0,0 +1,112 @@ +{ + "actions": [], + "creation": "2026-10-11 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "discount", + "code", + "sales_order", + "amount", + "order_total", + "base_amount", + "base_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, + "search_index": 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" + }, + { + "description": "In the company currency, at the order's exchange rate when it was placed.", + "fieldname": "base_amount", + "fieldtype": "Currency", + "label": "Discount Amount (Company Currency)" + }, + { + "fieldname": "base_order_total", + "fieldtype": "Currency", + "label": "Order Total (Company Currency)" + }, + { + "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-21 18:00:00.000000", + "modified_by": "Administrator", + "module": "Discounts", + "name": "Commera Discount Use", + "owner": "Administrator", + "permissions": [ + { + "read": 1, + "role": "System Manager", + "export": 1, + "report": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "in_create": 1, + "read_only": 1 +} diff --git a/commera/discounts/doctype/commera_discount_use/commera_discount_use.py b/commera/discounts/doctype/commera_discount_use/commera_discount_use.py new file mode 100644 index 00000000..85388ad5 --- /dev/null +++ b/commera/discounts/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/discounts/preview.py b/commera/discounts/preview.py new file mode 100644 index 00000000..4c0d13b4 --- /dev/null +++ b/commera/discounts/preview.py @@ -0,0 +1,39 @@ +import frappe + +from commera.core import system_user_session +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, + fields: dict | None = None, +): + # Elevated, like checkout: ERPNext reads Items the shopper cannot. + 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, + "party_name": user and frappe.db.get_value("Portal User", {"user": user}, "parent"), + "contact_email": user, + } + ) + quotation.update(fields or {}) + 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/pricing.py b/commera/discounts/pricing.py new file mode 100644 index 00000000..2773a97b --- /dev/null +++ b/commera/discounts/pricing.py @@ -0,0 +1,504 @@ +import math + +import frappe +from frappe import _ +from frappe.utils.data import flt + +from commera.discounts.allocations import ALLOCATIONS_FIELD, 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.doctype.commera_discount.commera_discount import ORDER, PRODUCT, SHIPPING +from commera.discounts.selection import ( + Selection, + get_candidates, + get_discount, + get_discount_codes, + get_unmet_reason, +) + +try: + from erpnext.accounts.services.payment_schedule import PaymentScheduleService +except ImportError: + PaymentScheduleService = None + +MAX_PRICED_COMBINATIONS = 20 +# At most 3^(12/3) = 81 combinations, however many automatic discounts are live. +MAX_SEARCHED_SELECTIONS = 12 + + +def is_storefront_cart(doc) -> bool: + return doc.doctype == "Quotation" and doc.order_type == "Shopping Cart" + + +def is_repriced(doc) -> bool: + """Only a cart being shopped picks and prices discounts; once submitted, its amounts are the order's.""" + return doc.flags.commera_discounts is not None or (is_storefront_cart(doc) and doc.docstatus == 0) + + +def remove_code_discounts(doc, method=None): + # Clamped at zero: other code may already have reset these fields. + if doc.get("is_return"): + return + keep_lines = not is_repriced(doc) + 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 + if not keep_lines: + item.custom_code_discount = 0 + item.custom_discount = None + + if flt(doc.get("custom_code_order_discount")): + if keep_lines: + validate_order_discount_edit(doc) + doc.discount_amount = max(flt(doc.discount_amount) - flt(doc.custom_code_order_discount), 0) + doc.custom_code_order_discount = 0 + + +def validate_order_discount_edit(doc): + # Shown with the code discount inside; a lower figure loses staff's edit. + previous = doc.get_doc_before_save() + code_discount = flt(doc.custom_code_order_discount) + if not previous or flt(previous.discount_amount) == flt(doc.discount_amount): + return + if flt(doc.discount_amount) < code_discount: + frappe.throw( + _( + "Additional Discount Amount includes {0} from discount codes, so it cannot be less than that. " + "To give more off, enter {0} plus the extra amount." + ).format(frappe.format_value(code_discount, {"fieldtype": "Currency"}, currency=doc.currency)) + ) + + +def drop_copied_discounts(sales_order, method=None): + # A Desk duplicate copies the allocations; only the first order from a cart, or its amendment, keeps them. + if is_repriced(sales_order) or sales_order.amended_from or not get_allocations(sales_order): + return + quotation = next( + (item.prevdoc_docname for item in sales_order.items if item.get("prevdoc_docname")), None + ) + if ( + quotation + and frappe.db.get_value("Quotation", quotation, "docstatus") == 1 + and not frappe.db.exists("Sales Order Item", {"prevdoc_docname": quotation}) + ): + return + sales_order.flags.commera_discounts = [] + remove_code_discounts(sales_order) + sales_order.flags.commera_discounts = None + set_allocations(sales_order, []) + + +def apply_code_discounts(doc, method=None): + if not is_repriced(doc): + apply_recorded_discounts(doc) + return + selections = select_discounts(get_cart_context(doc)) + 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) + if allocations: + set_amounts_from_grand_total(doc) + + +def apply_line_discounts(doc, selections: list) -> list[dict]: + cart = get_cart_context(doc) + best_by_line = {} + + def offer(idx: int, amount: float, allocation: dict): + if amount > best_by_line.get(idx, (0, None))[0]: + best_by_line[idx] = (amount, allocation) + + for selection in selections: + line_discounts = cap_by_budget( + selection.discount.get_budget_left(cart), selection.discount.get_line_discounts(cart) + ) + for idx, amount in line_discounts.items(): + offer(idx, amount, get_allocation(selection, PRODUCT, 0, idx)) + + allocations = [] + for item in doc.items: + if item.idx not in best_by_line: + continue + amount, allocation = best_by_line[item.idx] + if flt(item.qty) <= 0: + continue + # Rounded down: a unit rate cannot carry a part of a paisa, and rounding up would overshoot a budget. + unit_discount = round_down( + min(amount / flt(item.qty), flt(item.rate)), item.precision("discount_amount") + ) + add_line_discount(item, unit_discount) + item.custom_discount = allocation["discount"] + allocations.append({**allocation, "amount": flt(unit_discount * flt(item.qty), 2)}) + return allocations + + +def add_line_discount(item, unit_discount: float): + # 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 + + +def apply_order_discount(doc, selections: list) -> list[dict]: + cart = get_cart_context(doc) + offers = [] + for selection in selections: + amount = flt(selection.discount.get_order_discount(cart)) + budget_left = selection.discount.get_budget_left(cart) + if budget_left is not None and amount > budget_left: + amount = round_down(budget_left, doc.precision("discount_amount")) + offers.append((amount, get_allocation(selection, ORDER, 0))) + + allocations = [] + subtotal = flt(doc.total) + for amount, allocation in offers: + amount = flt( + min(amount, subtotal - sum(row["amount"] for row in allocations)), + doc.precision("discount_amount"), + ) + if amount > 0: + allocations.append({**allocation, "amount": amount}) + + total = sum(row["amount"] for row in allocations) + if total: + # Inclusive tax: discount Grand Total so ERPNext works the tax out inside it. + if not flt(doc.discount_amount): + doc.apply_discount_on = "Grand Total" if has_inclusive_tax(doc) else "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_saving(cart, selections: list) -> float: + doc = frappe.copy_doc(cart.doc) + doc.flags.commera_discounts = [] + remove_code_discounts(doc) + doc.calculate_taxes_and_totals() + 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]) + option = cart.delivery_option + shipping = [ + s.discount.get_delivery_discount(option, cart) + for s in selections + if option and s.discount.discount_class == SHIPPING + ] + return sum(flt(row["amount"]) for row in allocations) + max(shipping, default=0) + + +def select_discounts(cart) -> list[Selection]: + # On a tie, the code applied first wins. + applicable = [] + for selection in get_candidates(cart.doc): + discount = selection.discount + if discount.status != "Active" or not discount.is_for_currency(cart): + continue + if any(other.discount.name == discount.name for other in applicable): + continue + if get_unmet_reason(discount, cart) or not discount.gives_anything(cart): + continue + applicable.append(selection) + + combinations = get_combinations(get_searched_selections(applicable, cart))[:MAX_PRICED_COMBINATIONS] + if len(combinations) == 1: + return combinations[0] + return max(combinations, key=lambda combination: flt(get_saving(cart, combination), 2)) + + +def get_searched_selections(selections: list[Selection], cart) -> list[Selection]: + # Every code the shopper typed is searched; past the cap, only the automatic discounts that save most alone. + if len(selections) <= MAX_SEARCHED_SELECTIONS: + return selections + codes = [selection for selection in selections if selection.code] + automatic = [selection for selection in selections if not selection.code] + automatic.sort(key=lambda selection: get_solo_saving(selection.discount, cart), reverse=True) + kept = {id(selection) for selection in codes + automatic[: MAX_SEARCHED_SELECTIONS - len(codes)]} + return [selection for selection in selections if id(selection) in kept] + + +def get_solo_saving(discount, cart) -> float: + if discount.discount_class == PRODUCT: + return sum(discount.get_line_discounts(cart).values()) + if discount.discount_class == ORDER: + return flt(discount.get_order_discount(cart)) + option = cart.delivery_option + return discount.get_delivery_discount(option, cart) if option else 0.0 + + +def get_combinations(selections: list[Selection]) -> list[list[Selection]]: + """Every largest set of discounts that all combine with each other, in the order the selections came.""" + order = {id(selection): index for index, selection in enumerate(selections)} + + def combines(selection, other) -> bool: + return selection is not other and selection.discount.can_combine_with(other.discount) + + def extend(combination: list, candidates: list, skipped: list): + if not candidates and not skipped: + yield combination + return + # Bron-Kerbosch with a pivot: a set the pivot could join is found from the pivot's own branch. + pivot = max(candidates + skipped, key=lambda other: sum(combines(other, c) for c in candidates)) + for selection in [c for c in candidates if not combines(c, pivot)]: + yield from extend( + [*combination, selection], + [other for other in candidates if combines(other, selection)], + [other for other in skipped if combines(other, selection)], + ) + candidates.remove(selection) + skipped.append(selection) + + combinations = [ + sorted(combination, key=lambda selection: order[id(selection)]) + for combination in extend([], list(selections), []) + ] + return sorted(combinations, key=lambda combination: [order[id(selection)] for selection in combination]) + + +def has_inclusive_tax(doc) -> bool: + return any(tax.included_in_print_rate for tax in doc.get("taxes") or []) + + +def apply_recorded_discounts(doc): + recorded = get_allocations(doc) + if not recorded: + return + if doc.doctype == "Sales Order" and doc.docstatus == 0 and not doc.is_new(): + recorded = drop_unmet_order_discounts(doc, recorded) + + allocations = [] + for item in doc.items: + if not flt(item.get("custom_code_discount")): + continue + if not doc.get("is_return"): + add_line_discount(item, flt(item.custom_code_discount)) + row = next( + ( + row + for row in recorded + if row["discount_class"] == PRODUCT and row.get("discount") == item.custom_discount + ), + None, + ) + if row: + amount = flt(flt(item.custom_code_discount) * flt(item.qty), 2) + allocations.append({**row, "amount": amount, "line": item.idx}) + doc.calculate_taxes_and_totals() + + order_rows = [row for row in recorded if row["discount_class"] == ORDER] + source = get_discount_source(doc) if order_rows else None + shares = get_order_discount_shares(doc, order_rows, source) + if order_rows: + doc.discount_amount = get_other_order_discount(doc, source) + sum(shares) + doc.additional_discount_percentage = 0 + doc.custom_code_order_discount = sum(shares) + doc.calculate_taxes_and_totals() + allocations += [{**row, "amount": share} for row, share in zip(order_rows, shares, strict=True)] + allocations += [row for row in recorded if row["discount_class"] == SHIPPING] + set_allocations(doc, allocations) + set_amounts_from_grand_total(doc) + set_discount_links(doc, allocations) + + +def drop_unmet_order_discounts(doc, recorded: list[dict]) -> list[dict]: + # Judged before the line discounts go back on, as the cart was. + cart = get_cart_context(doc) + kept = [] + for row in recorded: + reason = row["discount_class"] == ORDER and get_discount(row["discount"]).get_unmet_minimum(cart) + if reason: + frappe.msgprint( + _("{0} no longer applies to this order. {1}").format(row.get("code") or row["title"], reason) + ) + else: + kept.append(row) + return kept + + +def set_discount_links(doc, allocations: list[dict]): + discounts = { + discount.name: discount + for discount in frappe.get_all( + "Commera Discount", + filters={"name": ["in", list({row["discount"] for row in allocations})]}, + fields=["name", "campaign", "discount_account"], + ) + } + if not doc.get("utm_campaign"): + doc.utm_campaign = next( + ( + discounts[row["discount"]].campaign + for row in allocations + if discounts.get(row["discount"], {}).get("campaign") + ), + None, + ) + if doc.doctype != "Sales Invoice": + return + for item in doc.items: + if account := discounts.get(item.get("custom_discount"), {}).get("discount_account"): + item.discount_account = account + order_accounts = { + discounts[row["discount"]].discount_account + for row in allocations + if row["discount_class"] == ORDER and discounts.get(row["discount"], {}).get("discount_account") + } + if len(order_accounts) == 1 and not doc.get("additional_discount_account"): + doc.additional_discount_account = order_accounts.pop() + + +def set_amounts_from_grand_total(doc): + """ERPNext writes these during validate, before this hook takes the discount off the total.""" + doc.set_total_in_words() + if not doc.meta.has_field("payment_schedule") or doc.get("is_return"): + return + if PaymentScheduleService: + PaymentScheduleService(doc).set_payment_schedule() + else: + doc.set_payment_schedule() + + +def get_other_order_discount(doc, source) -> float: + # A new credit note carries our whole share; ERPNext's is net of earlier returns. + code_discount = flt(doc.custom_code_order_discount) + if not (doc.get("is_return") and code_discount > 0): + return flt(doc.discount_amount) - code_discount + reversed_by_siblings = ( + sum(abs(flt(sibling.custom_code_order_discount)) for sibling in source[1]) if source else 0 + ) + return flt(doc.discount_amount) + code_discount - reversed_by_siblings + + +def get_order_discount_shares(doc, order_rows: list[dict], source) -> list[float]: + # The document completing the source takes the remainder, so parts sum exactly. + if not source: + return [flt(row["amount"]) for row in order_rows] + + source_doc, siblings, doc_total = source + source_total = abs(flt(source_doc.total)) + covered = abs(doc_total) + sum(abs(flt(sibling.total)) for sibling in siblings) + is_complete = covered >= source_total - 0.005 + sign = -1 if doc.get("is_return") else 1 + precision = doc.precision("discount_amount") + + shares = [] + source_rows = get_allocations(source_doc) + for row in order_rows: + key = get_allocation_key(row) + source_amount = sum(abs(flt(r["amount"])) for r in source_rows if get_allocation_key(r) == key) + used = sum( + abs(flt(r["amount"])) + for sibling in siblings + for r in get_allocations(sibling) + if get_allocation_key(r) == key + ) + left = max(source_amount - used, 0) + share = left + if not is_complete and source_total: + share = min(left, source_amount * abs(doc_total) / source_total) + shares.append(sign * flt(share, precision)) + return shares + + +def get_allocation_key(row: dict) -> tuple: + return (row["discount_class"], row.get("discount"), row.get("code")) + + +# The item field naming the order a delivery or invoice line comes from. +ORDER_LINK_FIELDS = {"Sales Invoice": "sales_order", "Delivery Note": "against_sales_order"} + + +def get_discount_source(doc): + """The document this one's order discount is a share of, the earlier shares of it, and this one's part.""" + fields = ["name", "total", "custom_code_order_discount", ALLOCATIONS_FIELD] + if doc.get("is_return"): + if not doc.return_against: + return None + filters = {"return_against": doc.return_against, "is_return": 1, "name": ["!=", doc.name or ""]} + source_doctype, source_name = doc.doctype, doc.return_against + siblings = frappe.get_all(doc.doctype, filters={**filters, "docstatus": 1}, fields=fields) + doc_total = flt(doc.total) + elif link_field := ORDER_LINK_FIELDS.get(doc.doctype): + source_doctype, source_name = "Sales Order", get_discounted_order(doc, link_field) + if not source_name: + return None + siblings = get_order_siblings(doc, link_field, source_name, fields) + doc_total = sum(flt(item.amount) for item in doc.items if item.get(link_field) == source_name) + else: + source_name = next((item.prevdoc_docname for item in doc.items if item.get("prevdoc_docname")), None) + if not source_name: + return None + source_doctype, siblings, doc_total = "Quotation", [], flt(doc.total) + + source_doc = frappe.db.get_value(source_doctype, source_name, fields, as_dict=True) + return (source_doc, siblings, doc_total) if source_doc else None + + +def get_discounted_order(doc, link_field: str) -> str | None: + orders = sorted({item.get(link_field) for item in doc.items if item.get(link_field)}) + if len(orders) <= 1: + return orders[0] if orders else None + discounted = frappe.get_all( + "Sales Order", filters={"name": ["in", orders], "custom_code_order_discount": [">", 0]}, pluck="name" + ) + if len(discounted) > 1: + frappe.throw( + _("Orders {0} each have a discount code order discount. Make a separate {1} for each.").format( + ", ".join(sorted(discounted)), _(doc.doctype) + ) + ) + return discounted[0] if discounted else orders[0] + + +def get_order_siblings(doc, link_field: str, sales_order: str, fields: list[str]) -> list: + # Only their lines from this order count: a combined document also carries other orders' lines. + item_doctype = f"{doc.doctype} Item" + lines = frappe.get_all( + item_doctype, + filters={link_field: sales_order, "docstatus": 1, "parenttype": doc.doctype}, + fields=["parent", "amount"], + ) + totals = {} + for line in lines: + if line.parent != doc.name: + totals[line.parent] = totals.get(line.parent, 0) + flt(line.amount) + if not totals: + return [] + siblings = frappe.get_all( + doc.doctype, filters={"name": ["in", list(totals)], "is_return": 0}, fields=fields + ) + for sibling in siblings: + sibling.total = totals[sibling.name] + return siblings + + +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 [get_allocation(Selection(get_discount(discount), code), SHIPPING, doc.custom_shipping_discount)] + + +def round_down(amount: float, precision: int) -> float: + # The epsilon keeps a float like 8.999999999 from dropping a whole paisa. + return math.floor(flt(amount) * 10**precision + 1e-6) / 10**precision + + +def cap_by_budget(budget_left: float | None, line_discounts: dict[int, float]) -> dict[int, float]: + 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): + 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..6bab6b9a --- /dev/null +++ b/commera/discounts/selection.py @@ -0,0 +1,110 @@ +from dataclasses import dataclass + +import frappe +from frappe import _ +from frappe.utils.data import cstr, flt + +from commera.discounts.cart_context import get_cart_context +from commera.discounts.codes import get_code_discount, get_discount_code_refusal, normalize_code +from commera.discounts.doctype.commera_discount.commera_discount import ( + CommeraDiscount, + DiscountCodeError, +) +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]: + 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], active_codes: list[str] | None = None) -> str: + # Only active_codes can refuse it for not combining. + 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_name = get_code_discount(applied_code) + if applied_name == discount_name: + frappe.throw( + _("{0} gives the same discount as {1}, which is already applied.").format(code, applied_code), + DiscountCodeError, + ) + if active_codes is not None and applied_code not in active_codes: + continue + if applied_name and not discount.can_combine_with(get_discount(applied_name)): + frappe.throw(_("{0} cannot be combined with {1}.").format(code, applied_code), DiscountCodeError) + return code + + +def get_unusable_code_reason(doc, code: str) -> str | None: + discount_name = get_code_discount(code) + if not discount_name: + return _("{0} is no longer a valid discount code.").format(code) + if is_single_use_spent(code): + return _("{0} has already been used.").format(code) + discount = get_discount(discount_name) + if reason := discount.get_unusable_reason(): + return reason + if discount.once_per_customer and has_used_before(discount.name, get_cart_context(doc)): + return _("You have already used {0}.").format(discount.title) + return get_discount_code_refusal(doc, 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 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) and not get_discount_code_refusal(doc, 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 + 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): + 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..8e0c0dba --- /dev/null +++ b/commera/discounts/shipping.py @@ -0,0 +1,36 @@ +from frappe.utils.data import flt + +from commera.discounts.cart_context import get_cart_context +from commera.discounts.doctype.commera_discount.commera_discount import SHIPPING +from commera.discounts.pricing import select_discounts + + +def apply_shipping_discounts(quotation, options: list[dict]) -> list[dict]: + # Runs after the plugin hooks, so shown price equals charged price. + return [get_discounted_option(option, quotation) for option in options] + + +def get_discounted_option(option: dict, quotation) -> dict: + cart = get_cart_context(quotation) + cart.offered_option = option + selections = [s for s in select_discounts(cart) if s.discount.discount_class == SHIPPING] + if not selections: + return option + best_amount, best_discount = max( + (s.discount.get_delivery_discount(option, cart), 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): + 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..e30f8fe0 --- /dev/null +++ b/commera/discounts/usage.py @@ -0,0 +1,179 @@ +import frappe +from frappe import _ +from frappe.query_builder.functions import Count, Sum +from frappe.utils.data import cint, flt + +from commera.discounts.allocations import get_allocations +from commera.discounts.cart_context import get_cart_context +from commera.plugin_events import fire_event + + +class DiscountLimitError(frappe.ValidationError): + pass + + +def has_used_before( + discount: str, cart, excluded_orders: list[str] | None = None, for_update: bool = False +) -> bool: + use = frappe.qb.DocType("Commera Discount Use") + identity = None + for field, value in (("customer", cart.customer), ("email", cart.email), ("phone", cart.phone)): + if value: + identity = (use[field] == value) if identity is None else identity | (use[field] == value) + if identity is None: + return False + query = frappe.qb.from_(use).select(use.name).where((use.discount == discount) & identity).limit(1) + if excluded_orders: + query = query.where(use.sales_order.notin(excluded_orders)) + if for_update: + query = query.for_update() + return bool(query.run()) + + +def is_first_order(cart) -> bool: + # 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 get_earlier_orders(sales_order) -> list[str]: + orders = [] + name = sales_order.amended_from + while name: + orders.append(name) + name = frappe.db.get_value("Sales Order", name, "amended_from") + return orders + + +def record_discount_uses(sales_order, method=None): + # A use counts from placement; cancel, amend, bill, return or delete never changes it. + amounts = {} + for allocation in get_allocations(sales_order): + key = (allocation["discount"], allocation.get("code")) + amounts[key] = amounts.get(key, 0) + flt(allocation["amount"]) + if not amounts: + return + + # An amended order is the same placement as the order it replaces, so it keeps that order's uses. + earlier_orders = get_earlier_orders(sales_order) if sales_order.amended_from else [] + conversion_rate = flt(sales_order.conversion_rate) or 1 + base_amounts = {} + for (discount, _code), amount in amounts.items(): + base_amounts[discount] = base_amounts.get(discount, 0) + flt(amount * conversion_rate, 2) + validate_discount_limits(sales_order, amounts, base_amounts, earlier_orders) + if earlier_orders and frappe.db.exists("Commera Discount Use", {"sales_order": ["in", earlier_orders]}): + return + + 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), + "base_amount": flt(amount * conversion_rate, 2), + "base_order_total": flt(sales_order.base_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) + add_usage(base_amounts, {code for _discount, code in amounts if code}, flt(sales_order.base_grand_total)) + for (discount, code), amount in amounts.items(): + fire_event( + "discount_used", + "Sales Order", + sales_order.name, + data={"discount": discount, "code": code, "amount": amount}, + key=f"{discount}-{code or ''}", + ) + + +def validate_discount_limits(sales_order, amounts: dict, base_amounts: dict, earlier_orders: list[str]): + # A locking read waits for a concurrent placement and sees its commit; a plain read would not (REPEATABLE READ). + cart = get_cart_context(sales_order) + labels = {discount: code for discount, code in amounts} + for discount_name in sorted(base_amounts): + counters = frappe.db.get_value( + "Commera Discount", discount_name, ["times_used", "discount_given"], as_dict=True, for_update=True + ) + discount = frappe.get_cached_doc("Commera Discount", discount_name) + label = labels[discount_name] or discount.title + earlier_uses, earlier_amount = get_earlier_usage({"discount": discount_name}, earlier_orders) + if discount.usage_limit and cint(counters.times_used) - earlier_uses >= cint(discount.usage_limit): + frappe.throw(_("{0} has been used up by other orders.").format(label), DiscountLimitError) + if flt(discount.budget): + budget = flt(discount.budget) * discount.get_company_exchange_rate() + given = flt(counters.discount_given) - earlier_amount + base_amounts[discount_name] + if flt(given, 2) > flt(budget, 2): + frappe.throw(_("{0} has run out of budget.").format(label), DiscountLimitError) + if discount.once_per_customer and has_used_before( + discount_name, cart, earlier_orders, for_update=True + ): + frappe.throw( + _("This customer has already used {0} on another order.").format(label), DiscountLimitError + ) + + for code in sorted(code for _discount, code in amounts if code): + is_generated, times_used = frappe.db.get_value( + "Commera Discount Code", code, ["is_generated", "times_used"], for_update=True + ) + if is_generated and cint(times_used) - get_earlier_usage({"code": code}, earlier_orders)[0] > 0: + frappe.throw( + _("{0} is a single-use code and another order has used it.").format(code), DiscountLimitError + ) + + +def get_earlier_usage(filters: dict, earlier_orders: list[str]) -> tuple[int, float]: + if not earlier_orders: + return 0, 0.0 + use = frappe.qb.DocType("Commera Discount Use") + query = ( + frappe.qb.from_(use) + .select(Count(use.name), Sum(use.base_amount)) + .where(use.sales_order.isin(earlier_orders)) + ) + for field, value in filters.items(): + query = query.where(use[field] == value) + count, amount = query.run()[0] + return cint(count), flt(amount) + + +def add_usage(base_amounts: dict[str, float], codes: set[str], base_order_total: float): + # Increments, not recounts: the update reads the latest committed counter, whatever this snapshot holds. + discount = frappe.qb.DocType("Commera Discount") + for name, base_amount in base_amounts.items(): + ( + frappe.qb.update(discount) + .set(discount.times_used, discount.times_used + 1) + .set(discount.discount_given, discount.discount_given + base_amount) + .set(discount.sales, discount.sales + base_order_total) + .where(discount.name == name) + ).run() + frappe.clear_document_cache("Commera Discount", name) + code_table = frappe.qb.DocType("Commera Discount Code") + for code in codes: + ( + frappe.qb.update(code_table) + .set(code_table.times_used, code_table.times_used + 1) + .where(code_table.name == code) + ).run() + frappe.clear_document_cache("Commera Discount Code", code) diff --git a/commera/fixtures/email_template.json b/commera/fixtures/email_template.json index b0841c8f..8f8126ea 100644 --- a/commera/fixtures/email_template.json +++ b/commera/fixtures/email_template.json @@ -15,17 +15,17 @@ "modified": "2025-05-21 13:30:13.212018", "name": "Order Cancellation", "response": "
<p>Hello {{ customer_name or customer }},</p>
<p>We’ve received your request to cancel order <strong>{{ doc.name }}</strong>. The order has now been successfully cancelled.</p>
<p>If this was a mistake or if you need help placing a new order, feel free to reach out to our support team.</p>
<p>Thank you for visiting us.</p>
<p>Commera</p>
Dear {{ customer_name }},
\nWe’re writing to confirm that your Sales Order placed on {{ transaction_date }} has been successfully cancelled.
\n \nHere are the details of the cancelled order:
\n \n \n| Item | \nQuantity | \nPrice | \n
|---|---|---|
| {{ item.item_name }} | \n{{ item.qty }} | \n{{ frappe.utils.fmt_money(item.amount, currency=currency) }} | \n
Taxes & Additional Charges:
\n| Description | \nAmount | \n
|---|---|
| {{ tax.description or tax.account_head }} | \n{{ frappe.utils.fmt_money(tax.tax_amount, currency=currency) }} | \n
\n Total Cancelled Amount: \n {{ frappe.utils.fmt_money(rounded_total, currency=currency) }}\n
\nIf a payment was made, your refund will be processed as per our refund policy.
\nFor any questions or assistance, feel free to contact our support team.
\n \nWarm regards,
\n{{ company }} Team
\n \n \n© {{ frappe.utils.getdate().year }} {{ company }}. All rights reserved.
\nDear {{ customer_name }},
\nWe’re writing to confirm that your Sales Order placed on {{ transaction_date }} has been successfully cancelled.
\n \nHere are the details of the cancelled order:
\n \n \n| Item | \nQuantity | \nPrice | \n
|---|---|---|
| {{ item.item_name }} | \n{{ item.qty }} | \n{{ frappe.utils.fmt_money(item.amount, currency=currency) }} | \n
Taxes & Additional Charges:
\n| Description | \nAmount | \n
|---|---|
| {{ tax.description or tax.account_head }} | \n{{ frappe.utils.fmt_money(tax.tax_amount, currency=currency) }} | \n
\n Total Cancelled Amount: \n {{ frappe.utils.fmt_money(payable_total, currency=currency) }}\n
\nIf a payment was made, your refund will be processed as per our refund policy.
\nFor any questions or assistance, feel free to contact our support team.
\n \nWarm regards,
\n{{ company }} Team
\n \n \n© {{ frappe.utils.getdate().year }} {{ company }}. All rights reserved.
\nDear {{ customer_name }},
\nThank you for your order with {{ company }}! We're pleased to confirm your Sales Order placed on {{ transaction_date }}.
\n \nBelow is a summary of your order:
\n \n \n| Item | \nQuantity | \nPrice | \n
|---|---|---|
| {{ item.item_name }} | \n{{ item.qty }} | \n{{ frappe.utils.fmt_money(item.amount, currency=currency) }} | \n
Taxes & Additional Charges:
\n| Description | \nAmount | \n
|---|---|
| {{ tax.description or tax.account_head }} | \n{{ frappe.utils.fmt_money(tax.tax_amount, currency=currency) }} | \n
\n Total Amount: \n {{ frappe.utils.fmt_money(rounded_total, currency=currency) }}\n
\nIf you have any questions or need further assistance, please don't hesitate to contact our customer support team.
\nWarm regards,
\n{{ company }} Team
\n \n \n© {{ frappe.utils.getdate().year }} {{ company }}. All rights reserved.
\nDear {{ customer_name }},
\nThank you for your order with {{ company }}! We're pleased to confirm your Sales Order placed on {{ transaction_date }}.
\n \nBelow is a summary of your order:
\n \n \n| Item | \nQuantity | \nPrice | \n
|---|---|---|
| {{ item.item_name }} | \n{{ item.qty }} | \n{{ frappe.utils.fmt_money(item.amount + (item.custom_code_discount or 0) * item.qty, currency=currency) }} | \n
| Discount{% if discount.label %} ({{ discount.label | e }}){% endif %} | \n−{{ frappe.utils.fmt_money(discount.amount, currency=currency) }} | \n
Taxes & Additional Charges:
\n| Description | \nAmount | \n
|---|---|
| {{ tax.description or tax.account_head }} | \n{{ frappe.utils.fmt_money(tax.tax_amount, currency=currency) }} | \n
\n Total Amount: \n {{ frappe.utils.fmt_money(payable_total, currency=currency) }}\n
\nIf you have any questions or need further assistance, please don't hesitate to contact our customer support team.
\nWarm regards,
\n{{ company }} Team
\n \n \n© {{ frappe.utils.getdate().year }} {{ company }}. All rights reserved.
\n{{get_currency_symbol()}}
-{{get_currency_symbol()}} +
{{get_currency_symbol()}}
{{get_currency_symbol()}}
+ {{ _('Discount') }} +
+−{{get_currency_symbol()}}
+{{ _("non-refundable delivery charge") }}
({{ _('Additional cash on delivery charge of')}} {{get_currency_symbol()}}{{"%.2f"|format(cod_charge)}} {{_('will apply')}}) diff --git a/commera/tests/test_admin_discounts.py b/commera/tests/test_admin_discounts.py new file mode 100644 index 00000000..f7025b14 --- /dev/null +++ b/commera/tests/test_admin_discounts.py @@ -0,0 +1,150 @@ +from unittest.mock import patch + +import frappe +from frappe.utils.data import add_days, today + +from commera.api.admin import discounts as admin_discounts +from commera.discounts.codes import MAX_CODES_IN_REQUEST, generate_codes +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_specific_customers_are_saved_and_read_back_with_their_names(self): + saved = self.save(eligibility="Specific Customers", customers=[{"customer": self.customer.name}]) + + self.assertEqual( + saved["customers"], + [{"customer": self.customer.name, "customer_name": self.customer.customer_name}], + ) + + 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_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() + + 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_a_code_discount_needs_a_code(self): + with self.assertRaisesRegex(frappe.ValidationError, "needs a code"): + self.save(code=None) + + def test_deleting_a_discount_deletes_its_codes(self): + saved = self.save(code="zz-gone") + + admin_discounts.delete_discount(saved["name"]) + + self.assertFalse(frappe.db.exists("Commera Discount", saved["name"])) + self.assertFalse(frappe.db.exists("Commera Discount Code", "ZZ-GONE")) + + def test_a_large_batch_of_codes_is_made_in_the_background(self): + saved = self.save() + jobs = [] + with patch.object(frappe, "enqueue", side_effect=lambda method, **kwargs: jobs.append(kwargs)): + response = admin_discounts.create_codes(saved["name"], MAX_CODES_IN_REQUEST + 1, "zz-") + + self.assertEqual(response, {"created": 0, "queued": MAX_CODES_IN_REQUEST + 1}) + self.assertEqual(jobs[0]["count"], MAX_CODES_IN_REQUEST + 1) + self.assertEqual(admin_discounts.get_discount(saved["name"])["generated_codes"], 0) + + generate_codes(**{key: jobs[0][key] for key in ("discount", "count", "prefix")}) + self.assertEqual( + admin_discounts.get_discount(saved["name"])["generated_codes"], MAX_CODES_IN_REQUEST + 1 + ) + + def test_the_list_carries_each_discounts_currency(self): + saved = self.save(currency=self.currency) + + row = next( + row for row in admin_discounts.get_discounts()["discounts"] if row["name"] == saved["name"] + ) + + self.assertEqual(row["currency"], self.currency) + + 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]) diff --git a/commera/tests/test_analytics.py b/commera/tests/test_analytics.py index 3a61d3fd..6ec2817b 100644 --- a/commera/tests/test_analytics.py +++ b/commera/tests/test_analytics.py @@ -8,6 +8,7 @@ from frappe.utils import add_days, now_datetime from commera.analytics import events +from commera.api.admin.analytics import get_base_discount_given from commera.api.analytics import capture from commera.api.analytics_dashboard import get_traffic_sources from commera.commera_ecommerce.doctype.storefront_analytics_event.storefront_analytics_event import ( @@ -29,6 +30,27 @@ ) +class TestRevenueDiscounts(UnitTestCase): + def test_codes_and_the_discounts_erpnext_gave_on_its_own_both_count(self): + allocations = [ + {"discount_class": "Product", "amount": 9}, + {"discount_class": "Order", "amount": 10}, + ] + row = frappe._dict( + conversion_rate=2, + base_discount_amount=30, + custom_discount_allocations=json.dumps(allocations), + ) + + # 18 + 20 from codes in the company currency, plus the 10 a Pricing Rule added to the order discount. + self.assertEqual(get_base_discount_given(row), 48) + + def test_an_order_without_codes_counts_its_additional_discount(self): + row = frappe._dict(conversion_rate=1, base_discount_amount=12, custom_discount_allocations=None) + + self.assertEqual(get_base_discount_given(row), 12) + + class TestGetDevice(UnitTestCase): def test_ipad_is_a_tablet(self): self.assertEqual(events.get_device(IPAD_USER_AGENT), "Tablet") diff --git a/commera/tests/test_cart_checkout.py b/commera/tests/test_cart_checkout.py index 4185284a..7eb6b747 100644 --- a/commera/tests/test_cart_checkout.py +++ b/commera/tests/test_cart_checkout.py @@ -13,15 +13,16 @@ from commera.api.payments import ( COD_PAYMENT_MODE, CheckoutPriceChangedError, - apply_coupon_code, confirm_payment, generate_quotation_for_cart, + get_cod_total_key, initiate_checkout_with_mode, save_cart_quotation, update_delivery_charges, update_quotation_address, validate_cart_is_not_in_checkout, ) +from commera.api.server_cart import sync_server_cart from commera.api.shipping import ( DELIVERY_CHARGE_DESCRIPTION, apply_delivery_option, @@ -30,7 +31,10 @@ get_order_charge_lines, set_delivery_option, ) +from commera.commera_ecommerce.doctype.commera_cart.commera_cart import get_session_cart from commera.core import _get_cart_quotation +from commera.discounts.codes import add_code +from commera.sdk import cart as cart_sdk from commera.tests.test_admin_orders import ensure_fiscal_year from commera.utils import get_available_stocks, get_pickup_addresses from commera.www.cart.checkout import get_context as get_checkout_context @@ -354,72 +358,6 @@ def test_a_grand_total_coupon_shows_as_a_discount_the_lines_add_up_to(self): self.assertEqual(summary["total"], round(270 + 48.6 + summary["shipping"] - 50)) self.assert_summary_adds_up(summary) - def create_coupon(self, discount_percentage: float) -> str: - pricing_rule = frappe.get_doc( - { - "doctype": "Pricing Rule", - "title": f"ZZ Coupon {frappe.generate_hash(length=8)}", - "apply_on": "Transaction", - "rate_or_discount": "Discount Percentage", - "discount_percentage": discount_percentage, - "selling": 1, - "coupon_code_based": 1, - "currency": frappe.get_cached_value("Price List", self.sale_price_list, "currency"), - } - ).insert(ignore_permissions=True) - coupon_code = f"ZZ{frappe.generate_hash(length=8).upper()}" - frappe.get_doc( - { - "doctype": "Coupon Code", - "coupon_name": coupon_code, - "coupon_code": coupon_code, - "coupon_type": "Promotional", - "pricing_rule": pricing_rule.name, - } - ).insert(ignore_permissions=True) - return coupon_code - - def test_applying_a_coupon_returns_the_discounted_checkout_summary(self): - coupon_code = self.create_coupon(discount_percentage=10) - frappe.set_user(self.shopper) - generate_quotation_for_cart({"items": [self.cart_line(self.discounted_item, 3)]}) - self.assertEqual(get_checkout_summary(_get_cart_quotation())["discount_amount"], 0) - - summary = apply_coupon_code(coupon_code)["checkout_summary"] - - self.assertEqual(summary["subtotal"], 270) - self.assertAlmostEqual(summary["discount_amount"], (270 + summary["shipping"]) * 0.1, places=2) - self.assert_summary_adds_up(summary) - - def test_a_coupon_stays_on_the_cart_when_it_is_rebuilt(self): - coupon_code = self.create_coupon(discount_percentage=10) - frappe.set_user(self.shopper) - generate_quotation_for_cart({"items": [self.cart_line(self.discounted_item, 3)]}) - apply_coupon_code(coupon_code) - - quotation = generate_quotation_for_cart({"items": [self.cart_line(self.discounted_item, 2)]}) - - summary = get_checkout_summary(quotation) - self.assertEqual( - frappe.db.get_value("Coupon Code", quotation.coupon_code, "coupon_code"), coupon_code - ) - self.assertEqual(summary["subtotal"], 180) - self.assertAlmostEqual(summary["discount_amount"], (180 + summary["shipping"]) * 0.1, places=2) - - def test_an_expired_coupon_is_dropped_quietly_when_the_cart_is_rebuilt(self): - coupon_code = self.create_coupon(discount_percentage=10) - frappe.set_user(self.shopper) - generate_quotation_for_cart({"items": [self.cart_line(self.discounted_item, 3)]}) - apply_coupon_code(coupon_code) - frappe.db.set_value("Coupon Code", {"coupon_code": coupon_code}, "valid_upto", add_days(today(), -1)) - frappe.clear_messages() - - quotation = generate_quotation_for_cart({"items": [self.cart_line(self.discounted_item, 2)]}) - - self.assertFalse(quotation.coupon_code) - self.assertEqual(get_checkout_summary(quotation)["discount_amount"], 0) - self.assertNotIn("expired", frappe.as_json(frappe.get_message_log())) - def test_the_cod_checkout_total_is_what_the_order_charges(self): ensure_fiscal_year() frappe.set_user(self.shopper) @@ -443,6 +381,60 @@ def test_the_cod_checkout_total_is_what_the_order_charges(self): [{"description": TAX_DESCRIPTION, "amount": 48.6}], ) + def test_cash_on_delivery_refuses_a_total_that_rose_since_checkout(self): + ensure_fiscal_year() + discount = frappe.get_doc( + { + "doctype": "Commera Discount", + "title": f"ZZ {frappe.generate_hash(length=6)}", + "method": "Automatic", + "discount_type": "Amount Off Order", + "value_type": "Fixed Amount", + "value": 5, + } + ).insert(ignore_permissions=True) + self.addCleanup(frappe.db.set_value, "Commera Discount", discount.name, "enabled", 0) + frappe.set_user(self.shopper) + generate_quotation_for_cart({"items": [self.cart_line(self.discounted_item, 1)]}) + update_quotation_address(self.address_payload()) + initiate_checkout_with_mode(COD_PAYMENT_MODE) + self.addCleanup(frappe.cache.delete_value, get_cod_total_key(_get_cart_quotation().name)) + + frappe.db.set_value("Commera Discount", discount.name, "enabled", 0) + frappe.clear_document_cache("Commera Discount", discount.name) + + with self.assertRaisesRegex(CheckoutPriceChangedError, "changed to"): + confirm_payment(_get_cart_quotation().name, payment_mode=COD_PAYMENT_MODE) + + def test_a_placed_order_clears_the_codes_and_attributes_from_the_cart(self): + ensure_fiscal_year() + frappe.set_user(self.shopper) + cart_sdk.set_cart_attributes({"gift_message": "Happy birthday"}) + sync_server_cart([{"item_code": self.discounted_item, "qty": 1}]) + cart = get_session_cart() + discount = frappe.get_doc( + { + "doctype": "Commera Discount", + "title": f"ZZ {frappe.generate_hash(length=6)}", + "discount_type": "Amount Off Order", + "applies_to": "All Products", + "value_type": "Fixed Amount", + "value": 1, + } + ).insert(ignore_permissions=True) + code = add_code(discount.name, f"zz{frappe.generate_hash(length=8)}") + frappe.db.set_value("Commera Cart", cart.name, "discount_codes", code, update_modified=False) + generate_quotation_for_cart({"items": [self.cart_line(self.discounted_item, 1)]}) + update_quotation_address(self.address_payload()) + + initiate_checkout_with_mode(COD_PAYMENT_MODE) + confirm_payment(_get_cart_quotation().name, payment_mode=COD_PAYMENT_MODE) + + self.assertEqual( + frappe.db.get_value("Commera Cart", cart.name, ["discount_codes", "attributes", "modified"]), + (None, None, cart.modified), + ) + def test_a_pickup_summary_drops_the_charges_without_saving_that(self): self.set_store_pickup(1) warehouse = self.create_pickup_warehouse() diff --git a/commera/tests/test_cart_permalink.py b/commera/tests/test_cart_permalink.py index 862e2491..1b9f75c1 100644 --- a/commera/tests/test_cart_permalink.py +++ b/commera/tests/test_cart_permalink.py @@ -160,6 +160,29 @@ def test_signed_in_shopper_resumes_checkout_from_the_cart_keeping_campaign_param }, ) + def test_return_to_sends_the_shopper_to_the_cart_or_a_page_on_this_site(self): + to_cart = self.render(f"{self.medium}:1", return_to="cart", utm_source="ig") + to_page = self.render(f"{self.medium}:1", return_to="/en/products/shirt?colour=red", utm_source="ig") + + self.assertEqual(to_cart.next_url, "/en/cart?utm_source=ig") + self.assertEqual(to_page.next_url, "/en/products/shirt?colour=red&utm_source=ig") + + def test_return_to_never_leaves_the_site(self): + default = self.render(f"{self.medium}:1").next_url + for target in ( + "https://evil.example", + "//evil.example", + "/\\evil.example", + "javascript:alert(1)", + "/\t/evil.example", + "/\n/evil.example", + ): + self.assertEqual(self.render(f"{self.medium}:1", return_to=target).next_url, default) + + def test_add_mode_keeps_the_shoppers_cart(self): + self.assertTrue(self.render(f"{self.medium}:1", mode="add").is_merge) + self.assertFalse(self.render(f"{self.medium}:1").is_merge) + def test_guest_goes_to_checkout_only_when_guest_checkout_is_on(self): frappe.set_user("Guest") for allow_guest_checkout, expected_path in ((1, "/en/cart/checkout"), (0, "/en/cart")): diff --git a/commera/tests/test_delivery_option_charges.py b/commera/tests/test_delivery_option_charges.py index a2c57e25..f937e9fe 100644 --- a/commera/tests/test_delivery_option_charges.py +++ b/commera/tests/test_delivery_option_charges.py @@ -216,6 +216,32 @@ def test_a_free_option_stays_free_when_it_can_no_longer_be_quoted(self): self.assertEqual(self.get_delivery_rows(quotation), []) self.assertEqual(flt(quotation.custom_delivery_charge), 0) + def test_a_discounted_option_is_discounted_once_when_it_can_no_longer_be_quoted(self): + from bwh_shipping.bwh_shipping.pricing import get_enabled_services + + self.make_tiered_rule() + discount = frappe.get_doc( + { + "doctype": "Commera Discount", + "title": f"_Test Half Shipping {frappe.generate_hash(length=6)}", + "method": "Automatic", + "discount_type": "Free Shipping", + "shipping_percentage": 50, + } + ).insert(ignore_permissions=True) + self.addCleanup(frappe.db.set_value, "Commera Discount", discount.name, "enabled", 0) + quotation = self.make_quotation(rate=80) + self.choose(quotation, self.express) + quotation.save(ignore_permissions=True) + self.assertEqual(flt(quotation.custom_delivery_charge), 50) + + frappe.db.set_value("Shipping Service", self.express, "enabled", 0) + get_enabled_services.clear_cache() + + self.assertTrue(reprice_selected_option(quotation)) + self.assertEqual(flt(quotation.custom_delivery_charge), 50) + self.assertEqual(flt(quotation.custom_shipping_discount), 50) + def test_linked_option_posts_fee_against_rule_account(self): charge_account_head = frappe.db.get_single_value("Commera Settings", "charge_account_head") rule_account = get_expense_account(exclude=charge_account_head) diff --git a/commera/tests/test_discount_codes.py b/commera/tests/test_discount_codes.py new file mode 100644 index 00000000..b44f6bc9 --- /dev/null +++ b/commera/tests/test_discount_codes.py @@ -0,0 +1,1295 @@ +import threading +import time +from types import SimpleNamespace +from unittest.mock import patch + +import frappe +from erpnext.controllers.sales_and_purchase_return import make_return_doc +from frappe.tests import IntegrationTestCase +from frappe.utils.data import add_days, money_in_words, today + +# ERPNext moved its transaction mappers to a sibling `mapper` module; both layouts are in the wild. +try: + from erpnext.selling.doctype.sales_order.mapper import make_delivery_note + from erpnext.stock.doctype.delivery_note.mapper import ( + make_sales_invoice as make_sales_invoice_from_delivery_note, + ) +except ImportError: + from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note + from erpnext.stock.doctype.delivery_note.delivery_note import ( + make_sales_invoice as make_sales_invoice_from_delivery_note, + ) + +from commera.api.payments import ( + CheckoutPriceChangedError, + _make_sales_order, + drop_refused_codes, + make_sales_invoice, +) +from commera.api.shipping import apply_delivery_option, get_checkout_summary +from commera.core import new_cart_quotation +from commera.discounts.allocations import get_allocations +from commera.discounts.codes import add_code, generate_codes, get_code_discount +from commera.discounts.doctype.commera_discount.commera_discount import DiscountCodeError +from commera.discounts.pricing import ( + MAX_SEARCHED_SELECTIONS, + get_combinations, + get_saving, + refresh_code_discounts, +) +from commera.discounts.selection import MAX_DISCOUNT_CODES, validate_new_discount_code +from commera.discounts.shipping import apply_shipping_discounts +from commera.discounts.usage import ( + DiscountLimitError, + add_usage, + record_discount_uses, + validate_discount_limits, +) + +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) + self.customer = self.create_customer() + + def set_charge_account_head(self): + company = frappe.get_cached_doc("Commera Settings").get( + "company" + ) or frappe.defaults.get_global_default("company") + charge_account_head = frappe.db.get_value( + "Account", {"company": company, "root_type": "Income", "is_group": 0}, "name" + ) + frappe.db.set_single_value("Commera Settings", "charge_account_head", charge_account_head) + frappe.clear_document_cache("Commera Settings", "Commera Settings") + self.addCleanup(frappe.clear_document_cache, "Commera Settings", "Commera Settings") + + 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) + 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": price_list, + "price_list_rate": rate, + } + ).insert(ignore_permissions=True) + + 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) + + def create_discount( + self, percentage: float = 0, amount: float = 0, item_codes: tuple[str, ...] = (), **fields + ) -> str: + discount = frappe.get_doc( + { + "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_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", taxes=() + ): + 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}) + for tax in taxes: + quotation.append("taxes", tax) + quotation.custom_discount_codes = "\n".join(codes) + 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): + 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, shirt.custom_code_discount), (81, 9)) + self.assertEqual(shirt.custom_discount, get_code_discount(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, 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) + 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, 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_spend_tiers_entered_from_highest_to_lowest_pick_the_highest_reached(self): + code = self.create_code( + percentage=1, + tiered=1, + tiers=[{"minimum_subtotal": 200, "value": 20}, {"minimum_subtotal": 100, "value": 10}], + ) + frappe.clear_document_cache("Commera Discount", get_code_discount(code)) + + highest = self.create_quotation({self.shirt: 2, self.cap: 1}, (code,)) + lowest = self.create_quotation({self.shirt: 1, self.cap: 1}, (code,)) + + self.assertEqual(highest.custom_code_order_discount, 44) + self.assertEqual(lowest.custom_code_order_discount, 13) + + 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_split_over_units_never_overshoots(self): + code = self.create_code(percentage=50, item_codes=(self.shirt,), budget=100.01) + + quotation = self.create_quotation({self.shirt: 3}, (code,)) + + shirt = self.get_line(quotation, self.shirt) + self.assertEqual(shirt.custom_code_discount, 33.33) + self.assertLessEqual(sum(row["amount"] for row in get_allocations(quotation)), 100.01) + + def test_buy_x_get_y_on_thousands_of_units_is_quick(self): + code = self.create_code( + discount_type="Buy X Get Y", + buy_value=1, + get_quantity=1, + targets=[ + {"role": "Buys", "target_type": "Item", "target": self.shirt}, + {"role": "Gets", "target_type": "Item", "target": self.shirt}, + ], + ) + + started = time.monotonic() + quotation = self.create_quotation({self.shirt: 5000}, (code,)) + + self.assertLess(time.monotonic() - started, 5) + self.assertEqual(self.get_line(quotation, self.shirt).amount, 2500 * SHIRT_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) + + with self.assertRaises(DiscountCodeError): + validate_new_discount_code(strict_order, [product]) + self.assertEqual(validate_new_discount_code(open_order, [product]), open_order) + + def test_many_automatic_discounts_are_searched_within_a_bound(self): + frappe.db.set_value("Commera Discount", {"method": "Automatic"}, "enabled", 0) + self.addCleanup(frappe.db.set_value, "Commera Discount", {"method": "Automatic"}, "enabled", 0) + for amount in range(1, 31): + self.create_discount(amount=amount, method="Automatic") + + with patch("commera.discounts.pricing.get_saving", wraps=get_saving) as saving: + quotation = self.create_quotation({self.shirt: 1}) + + self.assertEqual(quotation.custom_code_order_discount, 30) + self.assertLessEqual(saving.call_count, 2 * MAX_SEARCHED_SELECTIONS) + + def test_combinations_of_many_combinable_discounts_are_found_without_a_blow_up(self): + discount = SimpleNamespace(can_combine_with=lambda other: True) + selections = [SimpleNamespace(discount=discount) for _index in range(40)] + + started = time.monotonic() + combinations = get_combinations(selections) + + self.assertLess(time.monotonic() - started, 1) + self.assertEqual(combinations, [selections]) + + def test_of_two_codes_that_do_not_combine_the_bigger_saving_applies(self): + order = self.create_code(amount=10) + product = self.create_code(percentage=15, item_codes=(self.shirt,)) + for codes in ((order, product), (product, order)): + with self.subTest(codes): + quotation = self.create_quotation({self.shirt: 1, self.cap: 1}, codes) + + self.assertEqual(quotation.net_total, 116.5) + self.assertEqual([row["code"] for row in get_allocations(quotation)], [product]) + + def test_a_code_and_an_automatic_discount_keep_the_bigger_saving(self): + code = self.create_code(amount=10) + for percentage, net_total, wins in ((5, 120, "code"), (20, 104, "automatic")): + with self.subTest(percentage): + automatic = self.create_discount(percentage=percentage, method="Automatic") + self.addCleanup(frappe.db.set_value, "Commera Discount", automatic, "enabled", 0) + quotation = self.create_quotation({self.shirt: 1, self.cap: 1}, (code,)) + frappe.db.set_value("Commera Discount", automatic, "enabled", 0) + + self.assertEqual(quotation.net_total, net_total) + self.assertEqual( + [row["discount"] for row in get_allocations(quotation)], + [get_code_discount(code) if wins == "code" else automatic], + ) + + def test_on_an_equal_saving_the_code_applied_first_stays(self): + first = self.create_code(amount=10) + second = self.create_code(percentage=100, item_codes=(self.cap,), maximum_discount=10) + for codes in ((first, second), (second, first)): + with self.subTest(codes): + quotation = self.create_quotation({self.shirt: 1, self.cap: 1}, codes) + + self.assertEqual(quotation.net_total, 120) + self.assertEqual([row["code"] for row in get_allocations(quotation)], [codes[0]]) + + def test_one_discount_applies_once_whatever_codes_it_has(self): + code = self.create_code(amount=10) + second_code = add_code(get_code_discount(code), f"zz{frappe.generate_hash(length=8)}") + + with self.assertRaisesRegex(DiscountCodeError, "same discount"): + validate_new_discount_code(second_code, [code]) + quotation = self.create_quotation({self.shirt: 1}, (code, second_code)) + + self.assertEqual(quotation.custom_code_order_discount, 10) + self.assertEqual(quotation.net_total, SHIRT_RATE - 10) + + 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, starts_on=add_days(today(), -9), ends_on=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) + + 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_discount_that_gives_nothing_does_not_block_another(self): + self.create_discount(percentage=50, item_codes=(self.cap,), method="Automatic") + code = self.create_code(percentage=10) + + quotation = self.create_quotation({self.shirt: 1}, (code,)) + + self.assertEqual(quotation.custom_code_order_discount, 9) + + 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_and_guests(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_customer = self.create_customer(group.name) + member = self.create_quotation({self.shirt: 1}, (code,), customer=member_customer) + other = self.create_quotation({self.shirt: 1}, (code,)) + guest = self.create_quotation({self.shirt: 1}, (code,), customer=member_customer) + guest.custom_guest_cart_key = frappe.generate_hash(length=16) + refresh_code_discounts(guest) + + self.assertEqual(member.custom_code_order_discount, 9.0) + self.assertEqual(member.net_total, 81.0) + self.assertFalse(other.custom_code_order_discount) + self.assertFalse(guest.custom_code_order_discount) + self.assertEqual(guest.net_total, 90.0) + + def test_a_specific_customers_discount_skips_other_customers_and_guests(self): + frappe.db.set_value("Commera Discount", {"method": "Automatic"}, "enabled", 0) + code = self.create_code( + percentage=10, eligibility="Specific Customers", customers=[{"customer": self.customer.name}] + ) + + listed = self.create_quotation({self.shirt: 1}, (code,)) + unlisted = self.create_quotation({self.shirt: 1}, (code,), customer=self.create_customer()) + guest = self.create_quotation({self.shirt: 1}, (code,)) + guest.custom_guest_cart_key = frappe.generate_hash(length=16) + refresh_code_discounts(guest) + + self.assertEqual(listed.custom_code_order_discount, 9.0) + self.assertEqual(listed.net_total, 81.0) + self.assertFalse(unlisted.custom_code_order_discount) + self.assertEqual(unlisted.net_total, 90.0) + self.assertFalse(guest.custom_code_order_discount) + self.assertEqual(guest.net_total, 90.0) + + def test_an_active_specific_customers_discount_needs_a_customer(self): + with self.assertRaisesRegex(frappe.ValidationError, "at least one customer"): + self.create_discount(percentage=10, eligibility="Specific Customers", enabled=1) + self.assertTrue(self.create_discount(percentage=10, eligibility="Specific Customers", enabled=0)) + + with self.assertRaisesRegex(frappe.ValidationError, "at most 100 customers"): + self.create_discount( + percentage=10, + eligibility="Specific Customers", + customers=[{"customer": self.customer.name}] * 101, + ) + + 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) + + 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) + + def test_a_shipping_code_that_excludes_order_discounts_never_stacks_with_one(self): + self.set_charge_account_head() + frappe.db.set_value("Commera Discount", {"method": "Automatic"}, "enabled", 0) + self.create_discount( + percentage=10, method="Automatic", combines_with_product=1, combines_with_shipping=1 + ) + code = self.create_code( + discount_type="Free Shipping", payment_condition="Prepaid", shipping_percentage=100 + ) + quotation = self.create_quotation({self.shirt: 1}, (code,)) + option = {"title": "Standard", "amount": 60} + apply_delivery_option(quotation, apply_shipping_discounts(quotation, [option])[0]) + quotation.save(ignore_permissions=True) + + summary = get_checkout_summary(quotation) + + self.assertEqual((summary["shipping"], summary["discount_amount"]), (0, 0)) + cash_on_delivery = summary["cash_on_delivery"] + self.assertEqual((cash_on_delivery["shipping"], cash_on_delivery["discount_amount"]), (60, 9)) + + def test_checkout_removes_a_used_up_code_and_says_why(self): + frappe.db.set_value("Commera Discount", {"method": "Automatic"}, "enabled", 0) + code = self.create_code(amount=10, usage_limit=1) + quotation = self.create_quotation({self.shirt: 1}, (code,)) + self.place_order(self.create_quotation({self.shirt: 1}, (code,))) + + # The removal is committed before the message; a test must not commit its fixtures. + with ( + patch.object(frappe.db, "commit"), + self.assertRaisesRegex(CheckoutPriceChangedError, r"has been used up\. We removed"), + ): + drop_refused_codes(quotation) + + quotation.reload() + self.assertFalse(quotation.custom_discount_codes) + self.assertEqual(quotation.net_total, SHIRT_RATE) + + def test_free_shipping_follows_how_the_order_is_paid(self): + self.set_charge_account_head() + for payment_condition, shipping, cod_shipping in (("Prepaid", 0, 60), ("Cash On Delivery", 60, 0)): + with self.subTest(payment_condition): + frappe.db.set_value("Commera Discount", {"method": "Automatic"}, "enabled", 0) + self.create_discount( + discount_type="Free Shipping", + method="Automatic", + payment_condition=payment_condition, + shipping_percentage=100, + ) + quotation = self.create_quotation({self.shirt: 1}) + option = {"title": "Standard", "amount": 60} + apply_delivery_option(quotation, apply_shipping_discounts(quotation, [option])[0]) + + summary = get_checkout_summary(quotation) + + self.assertEqual(summary["shipping"], shipping) + self.assertEqual(summary["cash_on_delivery"]["shipping"], cod_shipping) + + +class TestDiscountCodeRules(DiscountCodeTestCase): + def test_a_code_made_in_desk_is_kept_upper_case(self): + discount = self.create_discount(amount=10) + code = frappe.get_doc({"doctype": "Commera Discount Code", "code": " save10 ", "discount": discount}) + + self.assertEqual(code.insert(ignore_permissions=True).name, "SAVE10") + + def test_a_code_with_a_space_or_too_long_is_refused(self): + discount = self.create_discount(amount=10) + for code in ("SAVE 10", "X" * 65, " "): + with self.subTest(code), self.assertRaisesRegex(frappe.ValidationError, "1 to 64"): + frappe.get_doc( + {"doctype": "Commera Discount Code", "code": code, "discount": discount} + ).insert(ignore_permissions=True) + + def test_an_automatic_discount_takes_no_code(self): + discount = self.create_discount(amount=10, method="Automatic", enabled=0) + + with self.assertRaisesRegex(frappe.ValidationError, "Only a code discount"): + add_code(discount, f"zz{frappe.generate_hash(length=8)}") + + def test_a_used_code_stays_with_its_discount(self): + code = self.create_code(amount=10) + self.place_order(self.create_quotation({self.shirt: 1}, (code,))) + code_doc = frappe.get_doc("Commera Discount Code", code) + code_doc.discount = self.create_discount(amount=20) + + with self.assertRaisesRegex(frappe.ValidationError, "stays with its discount"): + code_doc.save(ignore_permissions=True) + + +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)) + + 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((discount.times_used, discount.discount_given), (1, 9)) + self.assertEqual(frappe.db.get_value("Commera Discount Code", product, "times_used"), 1) + + def test_a_placed_order_keeps_its_discount_use_whatever_happens_to_it_later(self): + discount = self.create_discount(amount=10, usage_limit=5) + code = generate_codes(discount, 1)[0] + sales_order = self.place_order(self.create_quotation({self.shirt: 1, self.cap: 1}, (code,))) + sales_order.submit() + placed = self.get_usage(discount, code) + + sales_invoice = self.create_invoice(sales_order, self.shirt) + self.create_return(sales_invoice, self.shirt) + self.assertEqual(self.get_usage(discount, code), placed) + self.assertEqual(placed, (1, 10, 1, [(sales_order.name, 10)])) + + def test_a_draft_order_edit_scales_the_order_discount_and_never_raises_it(self): + code = self.create_code(amount=26) + sales_order = self.place_order(self.create_quotation({self.shirt: 1, self.cap: 1}, (code,))) + + sales_order.items = [self.get_line(sales_order, self.shirt)] + sales_order.save(ignore_permissions=True) + self.assertEqual((sales_order.custom_code_order_discount, sales_order.net_total), (18, 72)) + + sales_order.append("items", {"item_code": self.cap, "qty": 2, "warehouse": self.warehouse}) + sales_order.save(ignore_permissions=True) + self.assertEqual((sales_order.custom_code_order_discount, sales_order.net_total), (26, 144)) + + def test_a_draft_order_edit_drops_an_order_discount_whose_minimum_is_no_longer_met(self): + for requirement in ({"minimum_subtotal": 100}, {"minimum_quantity": 2}): + with self.subTest(requirement): + code = self.create_code( + amount=30, + minimum_requirement="Subtotal" if "minimum_subtotal" in requirement else "Quantity", + **requirement, + ) + sales_order = self.place_order(self.create_quotation({self.shirt: 1, self.cap: 1}, (code,))) + self.assertEqual(sales_order.net_total, 100) + + sales_order.items = [self.get_line(sales_order, self.shirt)] + sales_order.save(ignore_permissions=True) + + self.assertEqual((sales_order.custom_code_order_discount, sales_order.net_total), (0, 90)) + self.assertEqual(get_allocations(sales_order), []) + self.assertIn("no longer applies", str(frappe.message_log[-1])) + + def test_a_cancelled_order_keeps_its_discount_use(self): + discount = self.create_discount(amount=10, usage_limit=5) + code = generate_codes(discount, 1)[0] + sales_order = self.place_order(self.create_quotation({self.shirt: 1}, (code,))) + sales_order.submit() + sales_order.cancel() + + self.assertEqual(self.get_usage(discount, code), (1, 10, 1, [(sales_order.name, 10)])) + reorder = self.place_order(self.create_quotation({self.shirt: 1}, (code,))) + self.assertEqual(reorder.custom_code_order_discount, 0) + + amended = self.amend(sales_order) + self.assertEqual(amended.custom_code_order_discount, 10) + self.assertEqual(self.get_usage(discount, code), (1, 10, 1, [(sales_order.name, 10)])) + + def test_a_deleted_order_keeps_its_discount_use(self): + discount = self.create_discount(amount=10, usage_limit=5) + code = generate_codes(discount, 1)[0] + sales_order = self.place_order(self.create_quotation({self.shirt: 1}, (code,))) + + frappe.delete_doc("Sales Order", sales_order.name, ignore_permissions=True) + + self.assertEqual(self.get_usage(discount, code), (1, 10, 1, [(sales_order.name, 10)])) + + def test_a_limit_used_up_between_checkout_and_placement_refuses_the_order(self): + limits = { + "single use": {"usage_limit": 0}, + "usage limit": {"usage_limit": 1}, + "once per customer": {"once_per_customer": 1}, + "budget": {"budget": 15}, + } + for limit, fields in limits.items(): + with self.subTest(limit): + discount = self.create_discount(amount=10, **fields) + code = ( + generate_codes(discount, 1)[0] + if limit == "single use" + else add_code(discount, f"zz{frappe.generate_hash(length=8)}") + ) + # Both carts were priced while the code was still unused, as two open checkouts are. + first = self.create_quotation({self.shirt: 1}, (code,)) + second = self.create_quotation({self.shirt: 1}, (code,)) + self.assertEqual(second.custom_code_order_discount, 10) + + self.place_order(first) + with self.assertRaises(DiscountLimitError): + self.place_order(second) + self.assertEqual(self.get_usage(discount, code)[0], 1) + + def test_a_duplicated_order_gets_no_discount_and_uses_nothing(self): + discount = self.create_discount(amount=10) + code = generate_codes(discount, 1)[0] + sales_order = self.place_order(self.create_quotation({self.shirt: 1}, (code,))) + + duplicate = frappe.copy_doc(sales_order) + duplicate.insert(ignore_permissions=True) + + self.assertEqual((duplicate.custom_code_order_discount, duplicate.net_total), (0, SHIRT_RATE)) + self.assertEqual(get_allocations(duplicate), []) + self.assertEqual(self.get_usage(discount, code), (1, 10, 1, [(sales_order.name, 10)])) + + def get_usage(self, discount: str, code: str) -> tuple: + times_used, discount_given = frappe.db.get_value( + "Commera Discount", discount, ["times_used", "discount_given"] + ) + uses = frappe.get_all( + "Commera Discount Use", filters={"discount": discount}, fields=["sales_order", "amount"] + ) + code_times_used = frappe.db.get_value("Commera Discount Code", code, "times_used") + return times_used, discount_given, code_times_used, [(use.sales_order, use.amount) for use in uses] + + 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,)) + sales_order = self.place_order(self.create_quotation({self.shirt: 1}, (code,))) + 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_a_discount_changed_after_checkout_does_not_reprice_the_order(self): + code = self.create_code(percentage=10) + quotation = self.create_quotation({self.shirt: 1}, (code,)) + frappe.db.set_value("Commera Discount", get_code_discount(code), "value", 50) + frappe.clear_document_cache("Commera Discount", get_code_discount(code)) + + sales_order = self.place_order(quotation) + self.assertEqual(quotation.net_total, 81) + self.assertEqual(sales_order.net_total, 81) + + sales_order.save() + self.assertEqual(sales_order.net_total, 81) + + def test_staff_add_goodwill_on_top_of_the_order_code(self): + code = self.create_code(amount=10) + sales_order = self.place_order(self.create_quotation({self.shirt: 2}, (code,))) + self.assertEqual(sales_order.discount_amount, 10) + + sales_order.discount_amount = 5 + with self.assertRaisesRegex(frappe.ValidationError, "includes .*10.* from discount codes"): + sales_order.save() + + sales_order.reload() + sales_order.discount_amount = 15 + sales_order.save() + self.assertEqual((sales_order.discount_amount, sales_order.custom_code_order_discount), (15, 10)) + self.assertEqual(sales_order.net_total, 2 * SHIRT_RATE - 15) + + def test_partial_invoices_add_up_to_the_order_discount(self): + product = self.create_code(amount=9, item_codes=(self.shirt,), combines_with_order=1) + order = self.create_code(amount=10, combines_with_product=1) + sales_order = self.place_order(self.create_quotation({self.shirt: 2, self.cap: 1}, (product, order))) + sales_order.submit() + + invoices = [ + self.create_invoice(sales_order, self.cap), + self.create_invoice(sales_order, self.shirt, qty=1), + self.create_invoice(sales_order, self.shirt, qty=1), + ] + + self.assertEqual([invoice.custom_code_order_discount for invoice in invoices], [1.98, 4.01, 4.01]) + self.assertEqual(self.get_line(invoices[1], self.shirt).rate, 81) + self.assertEqual(sum(invoice.net_total for invoice in invoices), sales_order.net_total) + + def test_an_invoice_with_another_orders_lines_takes_only_its_orders_share(self): + code = self.create_code(amount=26) + discounted = self.place_order(self.create_quotation({self.shirt: 1, self.cap: 1}, (code,))) + discounted.submit() + plain = self.place_order(self.create_quotation({self.cap: 1})) + plain.submit() + + sales_invoice = make_sales_invoice(discounted.name) + sales_invoice.items = [self.get_line(sales_invoice, self.shirt)] + other_line = make_sales_invoice(plain.name).items[0] + sales_invoice.append("items", other_line.as_dict(no_default_fields=True)) + sales_invoice.insert(ignore_permissions=True) + + self.assertEqual(sales_invoice.custom_code_order_discount, 18) + + def test_two_discounted_orders_cannot_share_an_invoice(self): + code = self.create_code(amount=10) + first = self.place_order(self.create_quotation({self.shirt: 1}, (code,))) + second = self.place_order(self.create_quotation({self.shirt: 1}, (code,))) + first.submit() + second.submit() + + sales_invoice = make_sales_invoice(first.name) + sales_invoice.append( + "items", make_sales_invoice(second.name).items[0].as_dict(no_default_fields=True) + ) + + with self.assertRaisesRegex(frappe.ValidationError, "separate"): + sales_invoice.insert(ignore_permissions=True) + + def test_a_delivery_note_and_its_invoice_carry_the_delivered_share(self): + code = self.create_code(amount=26) + sales_order = self.place_order(self.create_quotation({self.shirt: 1, self.cap: 1}, (code,))) + sales_order.submit() + + delivery_note = make_delivery_note(sales_order.name) + delivery_note.items = [self.get_line(delivery_note, self.shirt)] + delivery_note.items[0].allow_zero_valuation_rate = 1 + delivery_note.insert(ignore_permissions=True) + self.assertEqual(delivery_note.custom_code_order_discount, 18) + self.assertEqual(delivery_note.discount_amount, 18) + allow_negative_stock = frappe.db.get_single_value("Stock Settings", "allow_negative_stock") + self.addCleanup( + frappe.db.set_single_value, "Stock Settings", "allow_negative_stock", allow_negative_stock + ) + frappe.db.set_single_value("Stock Settings", "allow_negative_stock", 1) + delivery_note.submit() + + sales_invoice = make_sales_invoice_from_delivery_note(delivery_note.name) + sales_invoice.insert(ignore_permissions=True) + self.assertEqual((sales_invoice.custom_code_order_discount, sales_invoice.discount_amount), (18, 18)) + + def test_a_credit_note_keeps_the_invoice_rates_and_its_share_of_the_order_discount(self): + product = self.create_code(amount=9, item_codes=(self.shirt,), combines_with_order=1) + order = self.create_code(amount=10, combines_with_product=1) + sales_order = self.place_order(self.create_quotation({self.shirt: 2, self.cap: 1}, (product, order))) + sales_order.submit() + sales_invoice = self.create_invoice(sales_order) + + cap_return = self.create_return(sales_invoice, self.cap) + shirt_return = self.create_return(sales_invoice, self.shirt) + + self.assertEqual(self.get_line(shirt_return, self.shirt).rate, 81) + self.assertEqual(cap_return.custom_code_order_discount, -1.98) + self.assertEqual(shirt_return.custom_code_order_discount, -8.02) + self.assertEqual(cap_return.net_total + shirt_return.net_total, -sales_invoice.net_total) + + def test_a_free_unit_is_refunded_at_what_was_paid(self): + code = self.create_code(percentage=100, item_codes=(self.cap,)) + sales_order = self.place_order(self.create_quotation({self.shirt: 1, self.cap: 1}, (code,))) + sales_order.submit() + sales_invoice = self.create_invoice(sales_order) + + cap_return = self.create_return(sales_invoice, self.cap) + + self.assertEqual(cap_return.net_total, 0) + + def test_an_amended_order_cannot_reuse_a_code_another_order_used_up(self): + for limit in ("single use", "once per customer", "usage limit"): + with self.subTest(limit): + discount = self.create_discount( + amount=10, once_per_customer=int(limit == "once per customer"), usage_limit=1 + ) + code = ( + generate_codes(discount, 1)[0] + if limit == "single use" + else add_code(discount, f"zz{frappe.generate_hash(length=8)}") + ) + cancelled = self.place_order(self.create_quotation({self.shirt: 1}, (code,))) + cancelled.submit() + cancelled.cancel() + other_order = self.place_order(self.create_quotation({self.shirt: 1})) + frappe.get_doc( + { + "doctype": "Commera Discount Use", + "discount": discount, + "code": code, + "sales_order": other_order.name, + "amount": 10, + "customer": other_order.customer, + } + ).insert(ignore_permissions=True) + add_usage({discount: 10}, {code}, 0) + + with self.assertRaisesRegex(DiscountLimitError, "another order|used up"): + self.amend(cancelled) + + def amend(self, sales_order): + amended = frappe.copy_doc(sales_order) + amended.amended_from = sales_order.name + amended.docstatus = 0 + return amended.insert(ignore_permissions=True) + + def test_in_words_and_the_payment_schedule_follow_the_discounted_total(self): + self.customer.db_set("payment_terms", self.create_payment_terms()) + code = self.create_code(amount=10) + quotation = self.create_quotation({self.shirt: 2}, (code,)) + sales_order = self.place_order(quotation) + sales_order.submit() + sales_invoice = self.create_invoice(sales_order) + + for doc in (quotation, sales_order, sales_invoice): + self.assertEqual(doc.grand_total, 170) + self.assertEqual(doc.in_words, money_in_words(170, doc.currency)) + for doc in (quotation, sales_order, sales_invoice): + self.assertEqual([row.payment_amount for row in doc.payment_schedule], [85, 85]) + + def create_payment_terms(self) -> str: + terms = [] + for credit_days in (0, 30): + term = frappe.get_doc( + { + "doctype": "Payment Term", + "payment_term_name": f"ZZ {frappe.generate_hash(length=8)}", + "invoice_portion": 50, + "due_date_based_on": "Day(s) after invoice date", + "credit_days": credit_days, + } + ).insert(ignore_permissions=True) + terms.append({"payment_term": term.name, "invoice_portion": 50, "credit_days": credit_days}) + return ( + frappe.get_doc( + { + "doctype": "Payment Terms Template", + "template_name": f"ZZ Half Now {frappe.generate_hash(length=8)}", + "terms": terms, + } + ) + .insert(ignore_permissions=True) + .name + ) + + def test_the_campaign_tags_the_order_and_the_account_books_the_discount(self): + frappe.db.set_single_value("Selling Settings", "enable_discount_accounting", 1) + campaign = frappe.get_doc( + {"doctype": "UTM Campaign", "__newname": f"ZZ Campaign {frappe.generate_hash(length=6)}"} + ).insert(ignore_permissions=True) + sales_order_company = frappe.get_cached_doc("Commera Settings").get("company") + account = frappe.get_all( + "Account", + filters={"is_group": 0, "root_type": "Expense", "company": sales_order_company or ["is", "set"]}, + pluck="name", + limit=1, + )[0] + code = self.create_code( + percentage=10, item_codes=(self.shirt,), campaign=campaign.name, discount_account=account + ) + sales_order = self.place_order(self.create_quotation({self.shirt: 1, self.cap: 1}, (code,))) + sales_order.submit() + + sales_invoice = self.create_invoice(sales_order) + + self.assertEqual( + (sales_order.utm_campaign, sales_invoice.utm_campaign), (campaign.name, campaign.name) + ) + self.assertEqual(self.get_line(sales_invoice, self.shirt).discount_account, account) + debit = frappe.get_all( + "GL Entry", filters={"voucher_no": sales_invoice.name, "account": account}, pluck="debit" + ) + self.assertEqual(sum(debit), 9) + + def create_invoice(self, sales_order, item_code: str | None = None, qty: int | None = None): + sales_invoice = make_sales_invoice(sales_order.name) + if item_code: + sales_invoice.items = [item for item in sales_invoice.items if item.item_code == item_code] + if qty: + sales_invoice.items[0].qty = qty + sales_invoice.insert(ignore_permissions=True) + sales_invoice.submit() + return sales_invoice + + def create_return(self, sales_invoice, item_code: str): + credit_note = make_return_doc("Sales Invoice", sales_invoice.name) + credit_note.items = [item for item in credit_note.items if item.item_code == item_code] + credit_note.insert(ignore_permissions=True) + credit_note.submit() + return credit_note + + +class TestTaxInclusiveDiscounts(DiscountCodeTestCase): + def setUp(self): + super().setUp() + self.leggings = self.create_item(2999) + self.shorts = self.create_item(3399) + + def test_a_discount_takes_exactly_its_amount_off_a_tax_inclusive_price(self): + cases = { + "200 off the order": (self.create_code(amount=200), 6198), + "10% off the order": (self.create_code(percentage=10), 5758.2), + "10% off products": ( + self.create_code(percentage=10, item_codes=(self.leggings, self.shorts)), + 5758.2, + ), + "150 off each product": ( + self.create_code(amount=150, item_codes=(self.leggings, self.shorts)), + 6098, + ), + "buy leggings get shorts free": ( + self.create_code( + discount_type="Buy X Get Y", + buy_value=1, + get_quantity=1, + uses_per_order=1, + targets=[ + {"role": "Buys", "target_type": "Item", "target": self.leggings}, + {"role": "Gets", "target_type": "Item", "target": self.shorts}, + ], + ), + 2999, + ), + } + for case, (code, grand_total) in cases.items(): + with self.subTest(case): + quotation = self.create_quotation( + {self.leggings: 1, self.shorts: 1}, (code,), taxes=[self.get_inclusive_tax()] + ) + sales_order = self.place_order(quotation) + sales_order.submit() + sales_invoice = self.create_invoice(sales_order) + + for doc in (quotation, sales_order, sales_invoice): + self.assertEqual(doc.grand_total, grand_total) + self.assertAlmostEqual(doc.net_total, grand_total / 1.18, delta=0.01) + self.assertAlmostEqual(doc.total_taxes_and_charges, grand_total * 18 / 118, delta=0.01) + + def get_inclusive_tax(self) -> dict: + company = frappe.get_cached_doc("Commera Settings").get( + "company" + ) or frappe.defaults.get_global_default("company") + return { + "charge_type": "On Net Total", + "description": "GST 18% included", + "account_head": frappe.db.get_value( + "Account", + {"company": company, "root_type": "Liability", "account_type": "", "is_group": 0}, + "name", + ), + "rate": 18, + "included_in_print_rate": 1, + } + + def create_invoice(self, sales_order): + sales_invoice = make_sales_invoice(sales_order.name) + sales_invoice.insert(ignore_permissions=True) + sales_invoice.submit() + return sales_invoice + + +class TestDiscountCurrency(DiscountCodeTestCase): + def setUp(self): + super().setUp() + self.foreign = "EUR" if self.currency != "EUR" else "USD" + self.set_exchange_rate(self.foreign, 80) + + def set_exchange_rate(self, currency: str, rate: float): + filters = {"from_currency": currency, "to_currency": self.currency, "date": today(), "for_selling": 1} + if name := frappe.db.exists("Currency Exchange", filters): + frappe.db.set_value("Currency Exchange", name, "exchange_rate", rate) + else: + frappe.get_doc({"doctype": "Currency Exchange", **filters, "exchange_rate": rate}).insert( + ignore_permissions=True + ) + + def test_a_fixed_amount_in_another_currency_is_converted_to_the_cart_currency(self): + cases = { + "1 off the order": ( + self.create_code(amount=1, currency=self.foreign), + {self.shirt: 1, self.cap: 1}, + 50, + ), + "0.25 off each item": ( + self.create_code(amount=0.25, item_codes=(self.shirt,), currency=self.foreign), + {self.shirt: 2}, + 140, + ), + "blank currency is the company's": ( + self.create_code(amount=10, currency=None), + {self.shirt: 1}, + 80, + ), + } + for case, (code, lines, net_total) in cases.items(): + with self.subTest(case): + self.assertEqual(self.create_quotation(lines, (code,)).net_total, net_total) + + def test_uses_in_two_currencies_add_up_in_the_company_currency(self): + discount = self.create_discount(amount=10, currency=None) + code = add_code(discount, f"zz{frappe.generate_hash(length=8)}") + sales_order = self.place_order(self.create_quotation({self.shirt: 1}, (code,))) + foreign_order = self.place_order(self.create_quotation({self.shirt: 1}, (code,))) + foreign_order.currency, foreign_order.conversion_rate = self.foreign, 80 + foreign_order.base_grand_total = foreign_order.grand_total * 80 + frappe.db.delete("Commera Discount Use", {"sales_order": foreign_order.name}) + frappe.db.set_value( + "Commera Discount", + discount, + {"times_used": 1, "discount_given": 10, "sales": sales_order.base_grand_total}, + ) + frappe.db.set_value("Commera Discount Code", code, "times_used", 1) + record_discount_uses(foreign_order) + + self.assertEqual( + frappe.db.get_value("Commera Discount", discount, ["times_used", "discount_given", "sales"]), + (2, 810, sales_order.grand_total + foreign_order.grand_total * 80), + ) + + def test_a_budget_in_another_currency_is_used_up_by_orders_in_the_company_currency(self): + code = self.create_code(amount=1, currency=self.foreign, budget=1.5) + discounts = [] + for _index in range(3): + sales_order = self.place_order(self.create_quotation({self.shirt: 1, self.cap: 1}, (code,))) + discounts.append(sales_order.custom_code_order_discount) + + self.assertEqual(discounts, [80, 40, 0]) + self.assertEqual( + frappe.db.get_value("Commera Discount", get_code_discount(code), "discount_given"), 120 + ) + + def test_money_thresholds_in_another_currency_are_converted(self): + minimum = self.create_code( + percentage=10, currency=self.foreign, minimum_requirement="Subtotal", minimum_subtotal=2 + ) + self.assertEqual(self.create_quotation({self.shirt: 1, self.cap: 1}, (minimum,)).net_total, 130) + self.assertEqual(self.create_quotation({self.shirt: 2}, (minimum,)).net_total, 162) + + tiered = self.create_code( + percentage=1, + currency=self.foreign, + tiered=1, + tiers=[{"minimum_subtotal": 1, "value": 5}, {"minimum_subtotal": 2, "value": 10}], + ) + self.assertEqual(self.create_quotation({self.shirt: 1, self.cap: 1}, (tiered,)).net_total, 123.5) + + shipping = self.create_code( + discount_type="Free Shipping", + currency=self.foreign, + shipping_percentage=100, + maximum_shipping_rate=1, + ) + quotation = self.create_quotation({self.shirt: 1}, (shipping,)) + options = [{"title": "Standard", "amount": 60}, {"title": "Express", "amount": 100}] + self.assertEqual( + [option["amount"] for option in apply_shipping_discounts(quotation, options)], [0, 100] + ) + + +def run_in_another_request(function): + site, sites_path = frappe.local.site, frappe.local.sites_path + errors = [] + + def target(): + frappe.init(site, sites_path=sites_path) + frappe.connect() + try: + function() + frappe.db.commit() + except Exception as error: + errors.append(error) + finally: + frappe.destroy() + + thread = threading.Thread(target=target) + thread.start() + thread.join() + if errors: + raise errors[0] + + +class TestDiscountUsageAcrossRequests(IntegrationTestCase): + """Commits on purpose: another connection only sees committed rows. Everything made here is deleted.""" + + def setUp(self): + self.discount = ( + frappe.get_doc( + { + "doctype": "Commera Discount", + "title": f"ZZ Race {frappe.generate_hash(length=6)}", + "discount_type": "Amount Off Order", + "value_type": "Fixed Amount", + "value": 10, + "usage_limit": 1, + } + ) + .insert(ignore_permissions=True) + .name + ) + self.code = add_code(self.discount, f"zz{frappe.generate_hash(length=8)}") + frappe.db.commit() + self.addCleanup(self.delete_committed) + + def delete_committed(self): + frappe.db.rollback() + frappe.delete_doc("Commera Discount Code", self.code, ignore_permissions=True, force=True) + frappe.delete_doc("Commera Discount", self.discount, ignore_permissions=True, force=True) + frappe.db.commit() + + def test_a_placement_sees_a_use_another_request_committed_after_it_started(self): + self.assertEqual(frappe.db.get_value("Commera Discount", self.discount, "times_used"), 0) + + run_in_another_request(lambda: add_usage({self.discount: 10}, {self.code}, 100)) + + # This request's snapshot still says unused; only a locking read sees the other order. + self.assertEqual(frappe.db.get_value("Commera Discount", self.discount, "times_used"), 0) + with self.assertRaises(DiscountLimitError): + validate_discount_limits( + frappe.new_doc("Sales Order"), {(self.discount, self.code): 10}, {self.discount: 10}, [] + ) + + def test_two_placements_committed_together_both_count(self): + self.assertEqual(frappe.db.get_value("Commera Discount", self.discount, "times_used"), 0) + + run_in_another_request(lambda: add_usage({self.discount: 10}, {self.code}, 100)) + add_usage({self.discount: 10}, {self.code}, 100) + + self.assertEqual( + frappe.db.get_value( + "Commera Discount", self.discount, ["times_used", "discount_given", "sales"], for_update=True + ), + (2, 20, 200), + ) + self.assertEqual( + frappe.db.get_value("Commera Discount Code", self.code, "times_used", for_update=True), 2 + ) diff --git a/commera/tests/test_order_refunds.py b/commera/tests/test_order_refunds.py index 0520a4a5..243d13fb 100644 --- a/commera/tests/test_order_refunds.py +++ b/commera/tests/test_order_refunds.py @@ -16,6 +16,7 @@ ) from commera.api.payments import create_sales_invoice from commera.tests.test_admin_orders import COMPANY, make_test_sales_order +from commera.utils import get_rounded_or_grand_total try: from erpnext.selling.doctype.sales_order.mapper import make_delivery_note @@ -55,9 +56,9 @@ def ensure_mode_of_payment(cls): mode_of_payment.save(ignore_permissions=True) frappe.db.commit() - def make_paid_order(self): + def make_paid_order(self, sales_order=None): """An order billed and captured the way checkout does it, gateway session and all.""" - sales_order = make_test_sales_order() + sales_order = sales_order or make_test_sales_order() sales_order.db_set("custom_ecommerce_payment_mode", GATEWAY, update_modified=False) self.gateway_reference = f"zz_session_{frappe.generate_hash(length=10)}" create_sales_invoice(sales_order, GATEWAY, flt(sales_order.grand_total), self.gateway_reference) @@ -89,6 +90,26 @@ def test_a_prepaid_order_is_refundable(self): self.assertAlmostEqual(status["refundable_amount"], flt(sales_order.grand_total)) self.assertEqual(status["currency"], sales_order.currency) + def test_an_order_with_rounding_off_shows_and_refunds_its_grand_total(self): + sales_order = make_test_sales_order(submit=False) + sales_order.disable_rounded_total = 1 + sales_order.save() + sales_order.submit() + sales_order = self.make_paid_order(sales_order) + order_row = frappe.db.get_value( + "Sales Order", + sales_order.name, + ["grand_total", "rounded_total", "disable_rounded_total"], + as_dict=True, + ) + + status = get_sales_order_refund_status(sales_order.name) + + self.assertEqual(flt(sales_order.rounded_total), 0) + self.assertTrue(status["can_refund"]) + self.assertAlmostEqual(status["refundable_amount"], flt(sales_order.grand_total)) + self.assertAlmostEqual(get_rounded_or_grand_total(order_row), flt(sales_order.grand_total)) + def test_the_capture_is_found_through_the_sales_invoice(self): sales_order = self.make_paid_order() diff --git a/commera/tests/test_payment_hooks.py b/commera/tests/test_payment_hooks.py index ee49e7dc..23b51845 100644 --- a/commera/tests/test_payment_hooks.py +++ b/commera/tests/test_payment_hooks.py @@ -912,6 +912,7 @@ def test_an_installed_app_hears_placed_then_paid_through_the_real_queue(self): "payment_mode": GATEWAY, "docstatus": 1, "shipping_address": None, + "discounts": [], }, ) self.assertEqual(placed.created_at, frappe.db.get_value("Commera Event", placed.id, "creation")) diff --git a/commera/tests/test_plugin_discounts.py b/commera/tests/test_plugin_discounts.py new file mode 100644 index 00000000..2b5baf1e --- /dev/null +++ b/commera/tests/test_plugin_discounts.py @@ -0,0 +1,117 @@ +from unittest.mock import patch + +import frappe + +from commera.api.payments import ( + CheckoutPriceChangedError, + drop_refused_codes, + generate_quotation_for_cart, +) +from commera.discounts.doctype.commera_discount.commera_discount import DiscountCodeError +from commera.sdk import cart as cart_sdk +from commera.tests import test_cart_checkout +from commera.tests.test_discount_codes import CAP_RATE, DiscountCodeTestCase +from commera.tests.test_payment_hooks import patch_app_hooks + + +def note_the_cart(event): + pass + + +def refuse_zz_codes(quotation, code): + return "This code's limit was reached." if code.upper().startswith("ZZ") else None + + +class TestDiscountEvents(DiscountCodeTestCase): + def test_a_placed_order_fires_discount_used(self): + code = self.create_code(percentage=10) + + sales_order = self.place_order(self.create_quotation({self.shirt: 1}, (code,))) + + data = frappe.parse_json( + frappe.db.get_value( + "Commera Event", {"event": "discount_used", "reference_name": sales_order.name}, "data" + ) + ) + self.assertEqual((data["code"], data["amount"]), (code, 9)) + self.assertEqual(data["discounts"][0]["code"], code) + + def test_a_new_cart_fires_cart_created_only_when_an_app_listens(self): + frappe.set_user("Administrator") + cart_sdk.add_lines([{"item_code": self.cap, "qty": 1}]) + quiet_cart = frappe.get_all("Commera Cart", filters={"user": "Administrator"}, pluck="name")[0] + frappe.delete_doc("Commera Cart", quiet_cart, force=True) + patch_app_hooks(self, {"commera_events": {"cart_created": [f"{__name__}.note_the_cart"]}}) + + cart_sdk.add_lines([{"item_code": self.cap, "qty": 1}]) + + new_cart = frappe.get_all("Commera Cart", filters={"user": "Administrator"}, pluck="name")[0] + self.assertFalse( + frappe.db.exists("Commera Event", {"event": "cart_created", "reference_name": quiet_cart}) + ) + self.assertTrue( + frappe.db.exists("Commera Event", {"event": "cart_created", "reference_name": new_cart}) + ) + + +class TestCartAttributes(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_cart_attributes_merge_and_reach_the_order(self): + cart_sdk.add_lines([{"item_code": self.cap, "qty": 1}]) + cart_sdk.set_cart_attributes({"source": "buy-link", "gift": "yes"}) + + priced = cart_sdk.set_cart_attributes({"gift": None}) + quotation = generate_quotation_for_cart( + {"items": [{"item": {}, "variant": {"item_code": self.cap}, "qty": 1}]} + ) + + self.assertEqual(priced["attributes"], {"source": "buy-link"}) + self.assertEqual(frappe.parse_json(quotation.custom_cart_attributes), {"source": "buy-link"}) + self.assertEqual(self.get_line(quotation, self.cap).rate, CAP_RATE) + + +class TestValidateDiscountCodeHook(DiscountCodeTestCase): + def setUp(self): + super().setUp() + patch_app_hooks(self, {"commera_hooks": {"validate_discount_code": [f"{__name__}.refuse_zz_codes"]}}) + + def test_a_refused_code_is_skipped_on_the_cart(self): + code = self.create_code(percentage=10) + + quotation = self.create_quotation({self.shirt: 1}, (code,)) + + self.assertFalse(quotation.custom_code_order_discount) + + def test_a_refused_code_cannot_be_added_and_says_why(self): + code = self.create_code(percentage=10) + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 1}]) + + with self.assertRaisesRegex(DiscountCodeError, "limit was reached"): + cart_sdk.add_discount_code(code) + + def test_checkout_removes_a_refused_code_and_tells_the_shopper(self): + code = self.create_code(percentage=10) + patch_app_hooks(self, {"commera_hooks": {}}) + quotation = self.create_quotation({self.shirt: 1}, (code,)) + self.assertEqual(quotation.custom_code_order_discount, 9) + patch_app_hooks(self, {"commera_hooks": {"validate_discount_code": [f"{__name__}.refuse_zz_codes"]}}) + + # The removal is committed before the message; a test must not commit its fixtures. + with ( + patch.object(frappe.db, "commit"), + self.assertRaisesRegex(CheckoutPriceChangedError, "limit was reached"), + ): + drop_refused_codes(quotation) + + quotation.reload() + self.assertFalse(quotation.custom_discount_codes) + self.assertFalse(quotation.custom_code_order_discount) diff --git a/commera/tests/test_quantity_breaks.py b/commera/tests/test_quantity_breaks.py new file mode 100644 index 00000000..2b20258a --- /dev/null +++ b/commera/tests/test_quantity_breaks.py @@ -0,0 +1,128 @@ +import frappe +from frappe.utils.data import add_days, today + +from commera.api.payments import _make_sales_order, generate_quotation_for_cart, make_sales_invoice +from commera.discounts.codes import get_code_discount +from commera.discounts.preview import get_preview_quotation +from commera.sdk import cart as cart_sdk +from commera.tests import test_server_cart +from commera.tests.test_discount_codes import DiscountCodeTestCase + +LIST_RATE = 500.0 +BREAK_RATES = {5: 500.0, 10: 450.0, 50: 400.0} + + +class TestQuantityBreaks(DiscountCodeTestCase): + def setUp(self): + super().setUp() + sale_price_list = frappe.db.get_single_value("Commera Settings", "sale_price_list") + self.addCleanup(test_server_cart.TestServerCart.set_sale_price_list, self, sale_price_list) + test_server_cart.TestServerCart.set_sale_price_list(self, self.price_list) + + def create_bulk_item(self, rate_or_discount: str) -> str: + item_code = self.create_item(LIST_RATE) + frappe.db.set_value("Bin", {"item_code": item_code, "warehouse": self.warehouse}, "actual_qty", 100) + for min_qty, max_qty, rate, percentage in ((10, 49, 450, 10), (50, 0, 400, 20)): + frappe.get_doc( + { + "doctype": "Pricing Rule", + "title": f"ZZ Break {frappe.generate_hash(length=8)}", + "apply_on": "Item Code", + "items": [{"item_code": item_code}], + "selling": 1, + "currency": self.currency, + "min_qty": min_qty, + "max_qty": max_qty, + "rate_or_discount": rate_or_discount, + "rate": rate, + "discount_percentage": percentage, + } + ).insert(ignore_permissions=True) + return item_code + + def use_new_shopper(self): + frappe.set_user(test_server_cart.TestServerCart.create_shopper(self)) + + 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_each_quantity_break_prices_the_cart_preview(self): + for rate_or_discount in ("Rate", "Discount Percentage"): + item_code = self.create_bulk_item(rate_or_discount) + for qty, rate in BREAK_RATES.items(): + with self.subTest(rate_or_discount=rate_or_discount, qty=qty): + quotation = get_preview_quotation([{"item_code": item_code, "qty": qty}]) + + self.assertEqual(quotation.items[0].rate, rate) + self.assertEqual(quotation.net_total, rate * qty) + + def test_the_priced_cart_shows_the_break_price_once_the_quantity_crosses_it(self): + item_code = self.create_bulk_item("Discount Percentage") + self.use_new_shopper() + + below = cart_sdk.add_lines([{"item_code": item_code, "qty": 5}]) + crossed = cart_sdk.add_lines([{"item_code": item_code, "qty": 5}]) + bulk = cart_sdk.add_lines([{"item_code": item_code, "qty": 40}]) + + self.assertEqual( + [(cart["lines"][0]["price"], cart["lines"][0]["rate"]) for cart in (below, crossed, bulk)], + [(500, 500), (500, 450), (500, 400)], + ) + self.assertEqual([cart["total"] for cart in (below, crossed, bulk)], [2500, 4500, 20000]) + + def test_checkout_the_order_and_the_invoice_keep_the_break_price(self): + for rate_or_discount in ("Rate", "Discount Percentage"): + item_code = self.create_bulk_item(rate_or_discount) + with self.subTest(rate_or_discount=rate_or_discount): + self.use_new_shopper() + cart_sdk.add_lines([{"item_code": item_code, "qty": 10}]) + quotation = generate_quotation_for_cart({"items": [self.browser_line(item_code, 10)]}) + frappe.set_user("Administrator") + sales_order, sales_invoice = self.place_and_bill(quotation) + + for doc in (quotation, sales_order, sales_invoice): + self.assertEqual((doc.items[0].rate, doc.net_total), (450, 4500), doc.doctype) + + def test_codes_come_off_the_break_price(self): + for rate_or_discount in ("Rate", "Discount Percentage"): + item_code = self.create_bulk_item(rate_or_discount) + product = self.create_code(percentage=10, item_codes=(item_code,), combines_with_order=1) + order = self.create_code(amount=100, combines_with_product=1) + with self.subTest(rate_or_discount=rate_or_discount): + self.use_new_shopper() + cart_sdk.add_lines([{"item_code": item_code, "qty": 10}]) + cart_sdk.add_discount_code(product) + priced = cart_sdk.add_discount_code(order) + quotation = generate_quotation_for_cart({"items": [self.browser_line(item_code, 10)]}) + frappe.set_user("Administrator") + quotation.reload() + quotation.save(ignore_permissions=True) + sales_order, sales_invoice = self.place_and_bill(quotation) + + line = priced["lines"][0] + self.assertEqual((line["rate"], line["code_discount"]), (405, 45)) + self.assertEqual((priced["order_discount"], priced["total"]), (100, 3950)) + for doc in (quotation, sales_order, sales_invoice): + self.assertEqual( + ( + doc.items[0].rate, + doc.items[0].custom_code_discount, + doc.custom_code_order_discount, + doc.net_total, + ), + (405, 45, 100, 3950), + doc.doctype, + ) + self.assertEqual( + frappe.db.get_value("Commera Discount", get_code_discount(product), "discount_given"), 450 + ) + + def place_and_bill(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) + sales_order.submit() + sales_invoice = make_sales_invoice(sales_order.name) + sales_invoice.insert(ignore_permissions=True) + return sales_order, sales_invoice diff --git a/commera/tests/test_rate_limits.py b/commera/tests/test_rate_limits.py new file mode 100644 index 00000000..714da75f --- /dev/null +++ b/commera/tests/test_rate_limits.py @@ -0,0 +1,30 @@ +import frappe +from frappe.tests import IntegrationTestCase +from werkzeug.test import EnvironBuilder +from werkzeug.wrappers import Request + +from commera.api.order_link import send_order_link + + +class TestRateLimits(IntegrationTestCase): + def setUp(self): + frappe.local.request = Request(EnvironBuilder(path="/api/v2/method/commera").get_environ()) + self.addCleanup(delattr, frappe.local, "request") + frappe.local.request_ip = f"10.{frappe.generate_hash(length=6)}" + self.addCleanup(delattr, frappe.local, "request_ip") + self.addCleanup(frappe.cache.delete_keys, f"rl:*{frappe.local.request_ip}*") + frappe.set_user("Guest") + self.addCleanup(frappe.set_user, "Administrator") + + def call(self, method, **kwargs): + frappe.form_dict.clear() + frappe.form_dict.update(kwargs) + return method(**kwargs) + + def test_a_limited_method_refuses_past_its_limit(self): + order_lookup = {"order_number": "SAL-ORD-ZZ-NONE", "email": "zz@example.com"} + for _attempt in range(10): + self.call(send_order_link, **order_lookup) + + with self.assertRaises(frappe.RateLimitExceededError): + self.call(send_order_link, **order_lookup) diff --git a/commera/tests/test_sdk.py b/commera/tests/test_sdk.py index 84b00a3b..8a445fcb 100644 --- a/commera/tests/test_sdk.py +++ b/commera/tests/test_sdk.py @@ -29,8 +29,36 @@ # 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_attributes", + "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 +69,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..667e13d9 --- /dev/null +++ b/commera/tests/test_sdk_discounts.py @@ -0,0 +1,80 @@ +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]) + + def test_an_automatic_discount_takes_no_code(self): + with self.assertRaisesRegex(frappe.ValidationError, "Only a code discount"): + discounts.create_discount( + {"method": "Automatic", "discount_type": "Amount Off Order", "value": 10}, code="zz-auto" + ) + + def test_the_list_reads_tables_and_codes_like_a_single_read(self): + created = discounts.create_discount( + { + "discount_type": "Amount Off Order", + "value_type": "Percentage", + "tiered": 1, + "tiers": [{"minimum_subtotal": 100, "value": 5}, {"minimum_subtotal": 200, "value": 10}], + }, + code=f"zz{frappe.generate_hash(length=6)}", + ) + discounts.add_code(created["name"], f"zz{frappe.generate_hash(length=6)}") + + listed = next(row for row in discounts.list_discounts() if row["name"] == created["name"]) + + single = discounts.get_discount(created["name"]) + self.assertEqual(listed["codes"], single["codes"]) + self.assertEqual(len(listed["codes"]), 2) + self.assertEqual( + [(tier["minimum_subtotal"], tier["value"]) for tier in listed["tiers"]], [(100, 5), (200, 10)] + ) + self.assertEqual(listed["status"], single["status"]) diff --git a/commera/tests/test_server_cart.py b/commera/tests/test_server_cart.py new file mode 100644 index 00000000..41e9b6b5 --- /dev/null +++ b/commera/tests/test_server_cart.py @@ -0,0 +1,611 @@ +from unittest.mock import patch + +import frappe +from frappe.auth import CookieManager +from frappe.utils.data import add_days, now_datetime +from werkzeug.test import EnvironBuilder +from werkzeug.wrappers import Request + +from commera.api.payments import generate_quotation_for_cart, save_cart_quotation +from commera.api.server_cart import ( + apply_discount_code, + get_priced_server_cart, + get_server_cart, + remove_discount_code, + sync_server_cart, +) +from commera.api.shipping import apply_delivery_option, get_checkout_summary +from commera.commera_ecommerce.doctype.commera_cart.commera_cart import ( + ABANDONED_GUEST_CART_DAYS, + MAX_LINE_QUANTITY, + clear_ordered_cart, + delete_abandoned_guest_carts, + get_session_cart, + keep_guest_cart, +) +from commera.core import _get_cart_quotation +from commera.discounts.codes import generate_codes, get_code_discount +from commera.discounts.shipping import apply_shipping_discounts +from commera.guest import GUEST_CART_COOKIE +from commera.sdk import cart as cart_sdk +from commera.tests.test_delivery_option_charges import make_store_rule, set_store_rule +from commera.tests.test_discount_codes import CAP_RATE, SHIRT_RATE, DiscountCodeTestCase +from commera.www.cart.checkout import cart_needs_refresh, refresh_checkout_cart, set_cart_context + + +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_a_cart_link_replaces_a_cart_changed_on_another_device(self): + stale_version = sync_server_cart([{"item_code": self.shirt, "qty": 1}])["version"] + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 2}]) + link_lines = [{"item_code": self.cap, "qty": 4}] + self.assertTrue(sync_server_cart(link_lines, stale_version)["conflict"]) + + response = sync_server_cart(link_lines, get_server_cart()["version"]) + + self.assertNotIn("conflict", response) + self.assertEqual([(row.item_code, row.qty) for row in get_session_cart().lines], [(self.cap, 4)]) + + def test_sync_refuses_more_than_the_stock(self): + self.assertTrue(sync_server_cart([{"item_code": self.shirt, "qty": 10}])["version"]) + + with self.assertRaisesRegex(frappe.ValidationError, "out of stock"): + sync_server_cart([{"item_code": self.shirt, "qty": 11}]) + + def test_an_app_cannot_add_more_than_the_stock(self): + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 10}]) + + with self.assertRaisesRegex(frappe.ValidationError, "out of stock"): + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 1}]) + with self.assertRaisesRegex(frappe.ValidationError, "out of stock"): + cart_sdk.update_lines([{"line_id": get_session_cart().lines[0].name, "qty": 11}]) + + def test_a_line_that_ran_out_does_not_block_other_edits(self): + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 5}]) + self.set_stock(self.shirt, 1) + + priced = cart_sdk.add_lines([{"item_code": self.cap, "qty": 1}]) + + self.assertEqual( + {(line["item_code"], line["qty"]) for line in priced["lines"]}, {(self.shirt, 5), (self.cap, 1)} + ) + + def test_sign_in_caps_the_merged_cart_at_the_stock(self): + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 6}]) + self.use_guest_cart() + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 6}]) + + frappe.set_user(self.shopper) + keep_guest_cart() + + self.assertEqual([(row.item_code, row.qty) for row in get_session_cart().lines], [(self.shirt, 10)]) + + def test_a_placed_order_clears_the_ordered_lines_from_the_server_cart(self): + cart_sdk.add_lines( + [{"item_code": self.shirt, "qty": 1}, {"item_code": self.cap, "qty": 1, "selected": 0}] + ) + version = get_session_cart().version + + clear_ordered_cart(frappe._dict(custom_guest_cart_key=None), self.shopper) + + cart = get_session_cart() + self.assertEqual([row.item_code for row in cart.lines], [self.cap]) + self.assertEqual(cart.version, version) + + def test_abandoned_guest_carts_are_deleted(self): + self.use_guest_cart() + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 1}]) + guest_cart = get_session_cart().name + frappe.set_user(self.shopper) + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 1}]) + account_cart = get_session_cart().name + old = add_days(now_datetime(), -ABANDONED_GUEST_CART_DAYS - 1) + frappe.db.set_value( + "Commera Cart", + {"name": ["in", [guest_cart, account_cart]]}, + "modified", + old, + update_modified=False, + ) + + with patch.object(frappe.db, "commit"): + delete_abandoned_guest_carts() + + self.assertFalse(frappe.db.exists("Commera Cart", guest_cart)) + self.assertFalse(frappe.db.exists("Commera Cart Line", {"parent": guest_cart})) + self.assertTrue(frappe.db.exists("Commera Cart", account_cart)) + + def test_a_cart_line_holds_a_limited_quantity(self): + self.set_stock(self.shirt, 1000) + cart_sdk.add_lines([{"item_code": self.shirt, "qty": MAX_LINE_QUANTITY}]) + + with self.assertRaisesRegex(frappe.ValidationError, "at most"): + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 1}]) + + def test_the_store_sets_how_many_units_a_cart_line_holds(self): + previous = frappe.db.get_single_value("Commera Settings", "max_cart_line_quantity") + self.addCleanup(self.set_max_line_quantity, previous) + self.set_max_line_quantity(250) + self.set_stock(self.shirt, 1000) + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 250}]) + with self.assertRaisesRegex(frappe.ValidationError, "at most 250"): + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 1}]) + + self.set_max_line_quantity(0) + with self.assertRaisesRegex(frappe.ValidationError, f"at most {MAX_LINE_QUANTITY} "): + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 1}]) + + def set_stock(self, item_code: str, qty: float): + frappe.db.set_value("Bin", {"item_code": item_code, "warehouse": self.warehouse}, "actual_qty", qty) + + def set_max_line_quantity(self, quantity: int): + frappe.db.set_single_value("Commera Settings", "max_cart_line_quantity", quantity) + frappe.clear_document_cache("Commera Settings", "Commera Settings") + + def test_sign_in_makes_the_guest_cart_the_account_cart(self): + code = self.create_code(amount=10) + self.use_guest_cart() + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 1}]) + cart_sdk.add_discount_code(code) + + frappe.set_user(self.shopper) + keep_guest_cart() + + cart = get_session_cart() + self.assertEqual([(row.item_code, row.qty) for row in cart.lines], [(self.shirt, 1)]) + self.assertEqual(cart.discount_codes, code) + self.assertIsNone(cart.cart_key) + self.assertIn(GUEST_CART_COOKIE, frappe.local.cookie_manager.to_delete) + + def test_sign_in_merges_the_guest_cart_into_the_account_cart(self): + code = self.create_code(amount=10) + cart_sdk.add_lines([{"item_code": self.cap, "qty": 1}]) + account_cart = get_session_cart().name + self.use_guest_cart() + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 2}]) + cart_sdk.add_discount_code(code) + guest_cart = get_session_cart().name + + frappe.set_user(self.shopper) + keep_guest_cart() + + cart = get_session_cart() + self.assertEqual(cart.name, account_cart) + self.assertEqual({(row.item_code, row.qty) for row in cart.lines}, {(self.cap, 1), (self.shirt, 2)}) + self.assertEqual(cart.discount_codes, code) + self.assertFalse(frappe.db.exists("Commera Cart", guest_cart)) + + def use_guest_cart(self): + token = frappe.generate_hash(length=32) + frappe.local.request = Request( + EnvironBuilder(path="/", headers={"Cookie": f"{GUEST_CART_COOKIE}={token}"}).get_environ() + ) + self.addCleanup(delattr, frappe.local, "request") + frappe.local.cookie_manager = CookieManager() + frappe.set_user("Guest") + + 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"]), (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) + + 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(self): + 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_a_code_applied_at_checkout_reaches_the_checkout(self): + code = self.create_code(amount=10) + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 1}]) + generate_quotation_for_cart({"items": [self.browser_line(self.shirt, 1)]}) + + response = apply_discount_code(code.lower()) + + quotation = _get_cart_quotation() + self.assertEqual(quotation.custom_discount_codes, code) + self.assertEqual(quotation.net_total, SHIRT_RATE - 10) + self.assertEqual(response["checkout_summary"]["discount_amount"], 10) + + def test_a_code_removed_at_checkout_leaves_the_checkout(self): + code = self.create_code(amount=10) + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 1}]) + cart_sdk.add_discount_code(code) + generate_quotation_for_cart({"items": [self.browser_line(self.shirt, 1)]}) + + response = remove_discount_code(code) + + quotation = _get_cart_quotation() + self.assertFalse(quotation.custom_discount_codes) + self.assertEqual(quotation.net_total, SHIRT_RATE) + self.assertEqual(response["checkout_summary"]["discount_amount"], 0) + + def test_a_code_change_at_checkout_reprices_the_delivery_discount(self): + self.set_charge_account_head() + frappe.set_user("Administrator") + frappe.db.set_value("Commera Discount", {"method": "Automatic"}, "enabled", 0) + free_shipping = self.create_code(discount_type="Free Shipping", shipping_percentage=100) + frappe.set_user(self.shopper) + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 1}]) + cart_sdk.add_discount_code(free_shipping) + generate_quotation_for_cart({"items": [self.browser_line(self.shirt, 1)]}) + quotation = _get_cart_quotation() + option = {"title": "Standard", "amount": 60} + apply_delivery_option(quotation, apply_shipping_discounts(quotation, [option])[0]) + save_cart_quotation(quotation) + self.assertEqual(_get_cart_quotation().custom_delivery_charge, 0) + + remove_discount_code(free_shipping) + + quotation = _get_cart_quotation() + self.assertEqual((quotation.custom_delivery_charge, quotation.custom_shipping_discount), (60, 0)) + self.assertFalse(quotation.custom_shipping_discount_id) + + def test_free_delivery_is_judged_after_discounts_in_cart_and_checkout(self): + self.addCleanup(set_store_rule, frappe.db.get_single_value("Commera Settings", "shipping_rule")) + frappe.set_user("Administrator") + make_store_rule( + [ + {"from_value": 0, "to_value": 100, "shipping_amount": 50}, + {"from_value": 100, "to_value": 0, "shipping_amount": 0, "free_shipping": 1}, + ] + ) + code = self.create_code(amount=40) + frappe.set_user(self.shopper) + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 1}, {"item_code": self.cap, "qty": 1}]) + self.assertEqual(cart_sdk.get_priced_cart()["free_delivery_shortfall"], 0) + + priced = cart_sdk.add_discount_code(code) + generate_quotation_for_cart( + {"items": [self.browser_line(self.shirt, 1), self.browser_line(self.cap, 1)]} + ) + summary = get_checkout_summary(_get_cart_quotation()) + + self.assertEqual((priced["free_delivery_above"], priced["free_delivery_shortfall"]), (100, 10)) + self.assertEqual((summary["free_delivery_above"], summary["free_delivery_shortfall"]), (100, 10)) + self.assertEqual(summary["shipping"], 50) + + def test_a_band_charging_nothing_counts_as_free_delivery(self): + self.addCleanup(set_store_rule, frappe.db.get_single_value("Commera Settings", "shipping_rule")) + frappe.set_user("Administrator") + make_store_rule( + [ + {"from_value": 0, "to_value": 999, "shipping_amount": 99}, + {"from_value": 999, "to_value": 0, "shipping_amount": 0}, + ] + ) + frappe.set_user(self.shopper) + + priced = cart_sdk.add_lines([{"item_code": self.shirt, "qty": 1}]) + + self.assertEqual( + (priced["free_delivery_above"], priced["free_delivery_shortfall"]), (999, 999 - SHIRT_RATE) + ) + + 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_a_code_with_nothing_to_discount_says_so(self): + code = self.create_code(percentage=10, item_codes=(self.shirt,)) + cart_sdk.add_lines([{"item_code": self.cap, "qty": 1}]) + + state = cart_sdk.add_discount_code(code)["discount_codes"][0] + + self.assertIn("Nothing in your cart qualifies", state["reason"]) + + def test_a_deleted_or_renamed_code_drops_off_with_a_message(self): + deleted = self.create_code(amount=10) + renamed = self.create_code(amount=5, combines_with_order=1) + frappe.db.set_value("Commera Discount", get_code_discount(deleted), "combines_with_order", 1) + kept = self.create_code(amount=3, combines_with_order=1) + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 1}]) + cart_sdk.add_discount_code(deleted) + cart_sdk.add_discount_code(renamed) + frappe.set_user("Administrator") + frappe.delete_doc("Commera Discount Code", deleted, force=True) + frappe.rename_doc("Commera Discount Code", renamed, f"{renamed}NEW", force=True) + frappe.set_user(self.shopper) + + priced = cart_sdk.get_priced_cart() + + self.assertEqual( + [(state["code"], state["applied"]) for state in priced["discount_codes"]], + [(deleted, False), (renamed, False)], + ) + self.assertIn("no longer a valid", priced["discount_codes"][0]["reason"]) + self.assertEqual(priced["total"], SHIRT_RATE) + + priced = cart_sdk.add_discount_code(kept) + + self.assertEqual([state["code"] for state in priced["discount_codes"]], [kept]) + self.assertEqual(priced["total"], SHIRT_RATE - 3) + + def test_a_code_that_saves_less_stays_on_the_cart_and_says_why(self): + smaller = self.create_code(amount=5) + bigger = self.create_code(amount=8) + cart_sdk.add_lines([{"item_code": self.cap, "qty": 1}]) + cart_sdk.add_discount_code(smaller) + + priced = cart_sdk.add_discount_code(bigger) + + states = {state["code"]: state for state in priced["discount_codes"]} + self.assertTrue(states[bigger]["applied"]) + self.assertFalse(states[smaller]["applied"]) + self.assertIn(f"doesn't combine with {bigger}, which saves you more", states[smaller]["reason"]) + self.assertEqual(priced["total"], CAP_RATE - 8) + + def test_a_code_that_saves_the_same_says_only_that_it_does_not_combine(self): + first = self.create_code(amount=5) + second = self.create_code(amount=5) + cart_sdk.add_lines([{"item_code": self.cap, "qty": 1}]) + cart_sdk.add_discount_code(first) + + priced = cart_sdk.add_discount_code(second) + + states = {state["code"]: state for state in priced["discount_codes"]} + self.assertTrue(states[first]["applied"]) + self.assertTrue(states[second]["reason"].endswith(f"doesn't combine with {first}.")) + self.assertEqual(priced["total"], CAP_RATE - 5) + + def test_a_code_that_gives_nothing_does_not_block_another(self): + waiting = self.create_code(percentage=10, minimum_requirement="Subtotal", minimum_subtotal=500) + other = self.create_code(amount=5) + cart_sdk.add_lines([{"item_code": self.cap, "qty": 1}]) + cart_sdk.add_discount_code(waiting) + + priced = cart_sdk.add_discount_code(other) + + states = {state["code"]: state for state in priced["discount_codes"]} + self.assertTrue(states[other]["applied"]) + self.assertIn("more to use", states[waiting]["reason"]) + self.assertEqual(priced["total"], CAP_RATE - 5) + + def test_a_code_that_cannot_apply_gives_its_own_reason(self): + combines = {"combines_with_order": 1, "combines_with_shipping": 1} + single_use = generate_codes(self.create_discount(amount=5, **combines), 1)[0] + limited = self.create_code(amount=5, usage_limit=1, **combines) + shipping = self.create_code(discount_type="Free Shipping", shipping_percentage=100, **combines) + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 1}]) + for code in (single_use, limited, shipping): + cart_sdk.add_discount_code(code) + frappe.set_user("Administrator") + self.place_order(self.create_quotation({self.shirt: 1}, (single_use, limited))) + frappe.set_user(self.shopper) + + reasons = {state["code"]: state["reason"] for state in cart_sdk.get_priced_cart()["discount_codes"]} + + self.assertIn("has already been used", reasons[single_use]) + self.assertIn("has been used up", reasons[limited]) + self.assertIn("once you choose a delivery option", reasons[shipping]) + + 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) + + def test_the_checkout_ticks_are_kept_on_the_server_cart(self): + sync_server_cart([{"item_code": self.shirt, "qty": 1}, {"item_code": self.cap, "qty": 2}]) + version = get_server_cart()["version"] + + response = sync_server_cart( + [{"item_code": self.shirt, "qty": 1, "selected": False}, {"item_code": self.cap, "qty": 2}], + version, + ) + + self.assertNotEqual(response["version"], version) + self.assertEqual( + {line["item_code"]: line["selected"] for line in cart_sdk.get_priced_cart()["lines"]}, + {self.shirt: False, self.cap: True}, + ) + + def test_the_priced_cart_discounts_only_the_ticked_lines(self): + code = self.create_code(percentage=10) + sync_server_cart( + [{"item_code": self.shirt, "qty": 2}, {"item_code": self.cap, "qty": 1, "selected": False}] + ) + + priced = cart_sdk.add_discount_code(code) + + cap = next(line for line in priced["lines"] if line["item_code"] == self.cap) + self.assertEqual((cap["rate"], cap["code_discount"], cap["amount"]), (CAP_RATE, 0, CAP_RATE)) + self.assertEqual(priced["subtotal"], 2 * SHIRT_RATE) + self.assertEqual(priced["total"], 2 * SHIRT_RATE * 0.9) + self.assertEqual([row["amount"] for row in priced["discounts"]], [2 * SHIRT_RATE * 0.1]) + + def test_checkout_takes_only_the_ticked_lines(self): + sync_server_cart( + [{"item_code": self.shirt, "qty": 1}, {"item_code": self.cap, "qty": 1, "selected": False}] + ) + + quotation = generate_quotation_for_cart( + {"items": [self.browser_line(self.shirt, 1), self.browser_line(self.cap, 1)]} + ) + + self.assertEqual([(row.item_code, row.qty) for row in quotation.items], [(self.shirt, 1)]) + + def test_opening_checkout_asks_the_page_to_rebuild_a_changed_cart_and_writes_nothing(self): + sync_server_cart([{"item_code": self.shirt, "qty": 1}]) + generate_quotation_for_cart({"items": [self.browser_line(self.shirt, 1)]}) + cart_sdk.add_lines([{"item_code": self.cap, "qty": 2}]) + context = frappe._dict() + + set_cart_context(context, frappe.get_cached_doc("Commera Settings")) + + self.assertTrue(context.cart_needs_refresh) + self.assertEqual([(row.item_code, row.qty) for row in _get_cart_quotation().items], [(self.shirt, 1)]) + self.assertFalse(frappe.local.flags.commit) + + def test_the_checkout_page_can_rebuild_the_cart_with_a_post(self): + sync_server_cart([{"item_code": self.shirt, "qty": 1}]) + generate_quotation_for_cart({"items": [self.browser_line(self.shirt, 1)]}) + self.assertEqual(refresh_checkout_cart(), {"refreshed": False, "redirect": None}) + cart_sdk.add_lines([{"item_code": self.cap, "qty": 2}]) + self.assertTrue(cart_needs_refresh(_get_cart_quotation())) + + self.assertEqual(refresh_checkout_cart(), {"refreshed": True, "redirect": None}) + + self.assertEqual( + {(row.item_code, row.qty) for row in _get_cart_quotation().items}, + {(self.shirt, 1), (self.cap, 2)}, + ) + sync_server_cart([]) + self.assertEqual(refresh_checkout_cart()["redirect"], f"/{frappe.local.lang}/cart") + + def test_an_emptied_server_cart_checks_out_nothing(self): + sync_server_cart([{"item_code": self.shirt, "qty": 1}]) + generate_quotation_for_cart({"items": [self.browser_line(self.shirt, 1)]}) + sync_server_cart([]) + + with self.assertRaises(frappe.ValidationError): + generate_quotation_for_cart({"items": [self.browser_line(self.shirt, 1)]}) + self.assertEqual(refresh_checkout_cart()["redirect"], f"/{frappe.local.lang}/cart") + + def test_opening_checkout_leaves_a_cart_in_payment_alone(self): + sync_server_cart([{"item_code": self.shirt, "qty": 1}]) + quotation = generate_quotation_for_cart({"items": [self.browser_line(self.shirt, 1)]}) + self.create_pending_payment_request(quotation.name) + cart_sdk.add_lines([{"item_code": self.cap, "qty": 2}]) + + with patch( + "bwh_payments.bwh_payments.doctype.gateway_payment_request.gateway_payment_request" + ".GatewayPaymentRequest.release_if_unpaid", + return_value=False, + ): + self.assertEqual(refresh_checkout_cart(), {"refreshed": False, "redirect": None}) + + self.assertEqual([(row.item_code, row.qty) for row in _get_cart_quotation().items], [(self.shirt, 1)]) + + def create_pending_payment_request(self, quotation_name: str): + payment_request = frappe.new_doc("Gateway Payment Request") + payment_request.update( + { + "name": frappe.generate_hash(length=10), + "ref_doctype": "Quotation", + "ref_docname": quotation_name, + "status": "Pending", + } + ) + # db_insert, not insert(): insert() opens a real gateway session. + payment_request.db_insert() + + def test_sign_in_keeps_the_guest_cart_ticks(self): + cart_sdk.add_lines([{"item_code": self.cap, "qty": 1}]) + self.use_guest_cart() + cart_sdk.add_lines([{"item_code": self.shirt, "qty": 2, "selected": False}]) + + frappe.set_user(self.shopper) + keep_guest_cart() + + self.assertEqual( + {(row.item_code, row.selected) for row in get_session_cart().lines}, + {(self.cap, 1), (self.shirt, 0)}, + ) diff --git a/commera/themes/summer_theme/components/macros/discount_codes.html b/commera/themes/summer_theme/components/macros/discount_codes.html new file mode 100644 index 00000000..40bfd7fd --- /dev/null +++ b/commera/themes/summer_theme/components/macros/discount_codes.html @@ -0,0 +1,18 @@ +{% macro discount_code_chips(codes, saving, remove, classes="") -%} +
{{ _("Order Total") }}: - {{ get_currency_symbol() }}{{ "%.2f" | format(order.rounded_total or 0) }} + {{ get_currency_symbol() }}{{ "%.2f" | format(order.payable_total) }}
{% endif %}| {{ _("Subtotal") }} ( {{ _("items") }}) | +{{ _("Subtotal") }} ( {{ _("items") }}) | {{ currency_symbol }} |
| {{ _("Discount") }} | +-{{ currency_symbol }} | +|
| {{ _("Delivery Charge") }} |
@@ -140,16 +152,19 @@ {{ _("Delivery") }} |
+ {{ _("Tick an item to check out") }} +
@@ -164,13 +179,14 @@Shoppers type this at checkout. Matching ignores case.
+Unique codes
++ + {{ discount.generated_codes_used }} of {{ discount.generated_codes }} used · + Download as CSV + + Single-use codes for influencers, emails or receipts, sharing these rules. + Save the discount first, then generate single-use codes that share these rules. +
+{{ summary }}
+{{ description }}
++ {{ discount.method === 'Code' ? 'Code' : 'Automatic' }} · {{ discountTypeLabel(discount) }} +
+Priced the way checkout prices it, with this draft.
+ ++ {{ refusal }} +
+ ++ {{ reason }} +
+ +{{ use.code || use.title || 'Discount' }}
++ {{ use.sales_order }} · {{ longDate(use.used_on) }} +
++ {{ discountMoney(use.amount) }} +
+ +{{ discountHeading(item) }}
+{{ summaryLines(item).slice(0, 2).join(' · ') }}
+{{ item.size }} · {{ item.item_code }}
++ + {{ discount.label }} · {{ discountMoney(discount.amount) }} +