Hooks run inside the shopper's request, before Commera commits. Today every hook runs at checkout. They can refuse a cart, add a fee, hide or reprice a delivery option, or hide a payment method.
Register them in one commera_hooks dict in hooks.py. Each key is a hook name, and each value is a list of handlers.
commera_hooks = {
"validate_cart": ["my_app.cart.get_cart_refusal"],
"cart_fees": ["my_app.cart.get_cart_fees"],
}
bench migrate prints a warning and writes an Error Log for a key that is not a hook name, for example cart_fee.
| Key | Arguments | Returns | Effect |
|---|---|---|---|
validate_cart |
the cart Quotation |
str or None |
A string refuses checkout and shows the string to the shopper |
cart_fees |
the cart Quotation |
list[dict] |
Adds one charge row to the cart for each fee |
delivery_options |
the cart Quotation, the delivery options |
list[dict] |
Hides, reprices or renames delivery options |
payment_methods |
the cart Quotation, the payment method names |
list[str] |
Hides payment methods |
Rules for every hook
- Be fast. Commera calls these hooks on cart changes, and the shopper waits. Do not call a remote API.
- Do not change data. Do not change or save the Quotation. The hooks can run many times for one cart, and the transaction can roll back. Do side effects in an event handler.
- Translate messages. The shopper sees the string that you return. Wrap it in
frappe._(). - Do not raise to refuse. An exception is a crash, not a refusal. See When a hook raises.
When more than one plugin has a hook, Commera calls them in the order of frappe.get_hooks. For validate_cart, the first string wins. For the other two list hooks, each handler gets the list that the handler before it returned.
validate_cart
commera_hooks = {"validate_cart": ["my_app.cart.get_cart_refusal"]}
import frappe
from frappe import _
def get_cart_refusal(quotation) -> str | None:
item_codes = [row.item_code for row in quotation.items]
missing = frappe.get_all(
"Item", filters={"name": ["in", item_codes], "my_app_sku": ["is", "not set"]}, pluck="name"
)
if missing:
return _("{0} can't be ordered right now. Please remove it from your cart to check out.").format(
missing[0]
)
return None
Commera calls the hook when the shopper starts checkout, for every payment mode, after it applies the delivery charge and the plugin fees. It calls it again when the shopper confirms a cash on delivery order. When the hook returns a string, no payment session opens and no order is made. Commera writes no Error Log for a refusal.
Return None to allow the cart. An empty value such as "" or False also allows it. Any other value that is not a string counts as a crash.
cart_fees
commera_hooks = {"cart_fees": ["my_app.cart.get_cart_fees"]}
from frappe import _
def get_cart_fees(quotation) -> list[dict]:
if not quotation.get("my_app_gift_wrap"):
return []
return [{"description": _("Gift wrap"), "amount": 50}]
| Key | Required | Value |
|---|---|---|
description |
Yes | The label the shopper sees |
amount |
Yes | An int or a float above zero, in the cart currency |
account_head |
No | The account for the fee. The default is Commera Settings > Charge Account Head |
Commera calls the hook each time it prices the cart: a cart change, an address change, a delivery option change, and the start of checkout. Each time, it removes every plugin fee row and asks every plugin again, so a fee never adds up across calls.
Each fee becomes a row in the cart's taxes table with Type Actual, after all other rows, so no tax is calculated on it. The row has commera_plugin_fee set to 1, and it goes into the Sales Order with the rest of the cart.
- An amount of
0adds no row. - A negative amount, a missing description or an amount that is not a number counts as a crash. A fee cannot be a discount.
- If you give no
account_headand Charge Account Head is empty, checkout fails. - Use an account that no rate-based tax row uses.
delivery_options
commera_hooks = {"delivery_options": ["my_app.delivery.get_delivery_options"]}
def get_delivery_options(quotation, options: list[dict]) -> list[dict]:
if not any(row.get("my_app_fragile") for row in quotation.items):
return options
return [option for option in options if option["title"] != "Economy"]
Each option is a copy with the keys title, amount, description, provider, service_code, is_free and is_live_rate. Return the options that the shopper can choose. Commera reads only these keys:
| Key | Value |
|---|---|
title |
Required. The title of an option that Commera gave you. You cannot change it |
amount |
Optional. A new price of 0 or more |
label |
Optional. The name that the shopper sees |
description |
Optional. A new description |
You cannot add an option or change the order. An added or renamed option, a duplicate title or a negative amount counts as a crash.
The hook runs only when bwh_shipping is installed, the cart has a shipping address and the order is not a store pickup. Commera calls it when it lists the options, when the shopper selects one, and at the start of checkout. If the hook hides the selected option at checkout, the shopper sees "Your delivery option is no longer available. Please choose another one."
payment_methods
commera_hooks = {"payment_methods": ["my_app.payments.get_payment_methods"]}
from frappe.utils.data import flt
def get_payment_methods(quotation, methods: list[str]) -> list[str]:
if flt(quotation.grand_total) <= 5000:
return methods
return [method for method in methods if method != "COD"]
methods holds the names of the enabled payment gateways, and COD when cash on delivery is on. You can only remove methods. Commera calls the hook on the checkout page of a signed-in shopper, at the start of checkout, and when the shopper confirms a COD order. A guest's checkout page shows every method, but the start of checkout still refuses a hidden one.
When a hook raises
Commera writes an Error Log with the title commera_hooks["<hook>"] hook failed: <handler> and removes any message that your hook printed. Then:
- On a cart edit, the options list or the checkout page: Commera leaves out that handler's fees, options change or methods change. The cart still works.
- At the start of checkout or a COD confirmation: the shopper sees "Something went wrong, please try again." and cannot check out.
The Error Log is written with a deferred insert, so it can appear a few minutes later.
Refuse a cancel
There is no Commera hook to refuse a cancel. Use a Sales Order before_cancel doc event, and return early for orders that are not store orders:
doc_events = {"Sales Order": {"before_cancel": "my_app.orders.before_order_cancel"}}
import frappe
from frappe import _
from commera.sdk import STORE_ORDER_TYPE
def before_order_cancel(doc, method=None):
if doc.order_type != STORE_ORDER_TYPE:
return
if frappe.db.exists("My Job", {"sales_order": doc.name, "status": "In Production"}):
frappe.throw(_("This order is already in production, so it can't be cancelled."))
The refusal applies to the shopper, the dashboard and Desk. No order_cancelled event fires.