Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
400 changes: 400 additions & 0 deletions commera/api/admin/discounts.py

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions commera/api/admin/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
"custom_ecommerce_payment_mode",
"custom_ecommerce_status",
"custom_delivery_option",
"custom_discount_allocations",
"custom_cart_attributes",
"shipping_address",
"address_display",
"modified",
Expand Down Expand Up @@ -720,6 +722,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",
)
Expand Down
54 changes: 53 additions & 1 deletion commera/api/payments.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,15 @@
reprice_selected_option,
)
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.checkout_hooks import (
apply_plugin_fees,
filter_payment_methods,
get_cart_refusal,
get_discount_code_refusal,
)
from commera.commera_ecommerce.doctype.commera_cart.commera_cart import get_session_cart
from commera.core import _get_cart_quotation, create_party, get_customer_contact, new_cart_quotation
from commera.discounts.selection import get_discount_codes
from commera.guest import (
get_guest_cart_name,
is_guest,
Expand Down Expand Up @@ -145,6 +152,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)
Expand Down Expand Up @@ -230,6 +238,42 @@ def validate_cart(quotation):
frappe.throw(refusal)


def drop_refused_codes(quotation):
"""Take off the codes an app's validate_discount_code hook refuses now, such as one whose limit another
order just used up, and tell the shopper why and that the total changed, instead of refusing the cart."""
reasons = []
codes = get_discount_codes(quotation)
kept_codes = []
for code in codes:
if reason := get_discount_code_refusal(quotation, code):
reasons.append(reason)
else:
kept_codes.append(code)

coupon = quotation.coupon_code and frappe.db.get_value(
"Coupon Code", quotation.coupon_code, "coupon_code"
)
if coupon and (reason := get_discount_code_refusal(quotation, coupon)):
reasons.append(reason)
_remove_coupon_code(quotation)
if not reasons:
return

quotation.custom_discount_codes = "\n".join(kept_codes) or None
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(
"<br>".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):
Expand Down Expand Up @@ -505,6 +549,8 @@ def get_quotation_for_cart(cart: dict, unsaved_quotation_doc):
"warehouse": ecommerce_warehouse,
},
)
if server_cart := get_session_cart():
server_cart.update_quotation(unsaved_quotation_doc)
save_cart_quotation(unsaved_quotation_doc)
# The stored option was priced for the old cart, and payment would re-quote it for the new one.
clear_delivery_option(unsaved_quotation_doc)
Expand All @@ -524,6 +570,9 @@ def reapply_coupon_code(quotation, coupon_code: str | None):
# An expired coupon is dropped quietly; the shopper only edited their cart.
frappe.clear_last_message()
return
# An app's refusal drops it as quietly as an expiry; checkout explains it if the shopper gets there.
if get_discount_code_refusal(quotation, frappe.db.get_value("Coupon Code", coupon_code, "coupon_code")):
return
quotation.coupon_code = coupon_code


Expand Down Expand Up @@ -788,6 +837,7 @@ def place_cod_order(quotation_name: str):
validate_cart(quotation)
if COD_PAYMENT_MODE not in get_checkout_payment_methods(quotation, strict=True):
refuse_payment(_("Cash on delivery is not available."), quotation.name)
quotation.flags.commera_payment_kind = "cod"
with system_user_session():
set_cod_charges(quotation)
quotation.flags.ignore_permissions = True
Expand Down Expand Up @@ -820,6 +870,8 @@ def apply_coupon_code(applied_code: str):
frappe.throw(_("Please enter a valid coupon code"))
validate_coupon_code(coupon_name)
validate_cart_is_not_in_checkout(quotation.name)
if refusal := get_discount_code_refusal(quotation, applied_code):
frappe.throw(refusal)
quotation.coupon_code = coupon_name
save_cart_quotation(quotation)
return {
Expand Down
78 changes: 78 additions & 0 deletions commera/api/server_cart.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import frappe
from frappe import _
from frappe.rate_limiter import rate_limit
from frappe.utils.data import cint, cstr

from commera.commera_ecommerce.doctype.commera_cart.commera_cart import (
MAX_CART_LINES,
CommeraCart,
get_session_cart,
)
from commera.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
def get_server_cart() -> dict | None:
cart = get_session_cart()
return get_cart_state(cart) if cart else None


@frappe.whitelist(allow_guest=True, methods=["POST"]) # nosemgrep: guest-whitelisted-method
@rate_limit(limit=600, seconds=60 * 60)
def sync_server_cart(items: list | str, version: str | None = None) -> dict:
"""Copy the browser cart's quantities to the server cart. When the server cart changed after the
browser last saw it, nothing is written and the server cart comes back for the browser to show."""
quantities = get_browser_quantities(items)
cart = get_session_cart(create=bool(quantities))
if not cart:
return {"version": None}
if version and not cart.is_new() and version != cart.version:
return {"conflict": True, **get_cart_state(cart)}

if cart.set_quantities(quantities) or cart.is_new():
cart.save(ignore_permissions=True)
return {"version": cart.version}


@frappe.whitelist(allow_guest=True, methods=["GET", "POST"]) # nosemgrep: guest-whitelisted-method
def get_priced_server_cart() -> dict | None:
return cart_sdk.get_priced_cart()


@frappe.whitelist(allow_guest=True, methods=["POST"]) # nosemgrep: guest-whitelisted-method
@rate_limit(limit=30, seconds=60 * 60)
def apply_discount_code(code: str) -> dict:
return cart_sdk.add_discount_code(cstr(code).strip())


@frappe.whitelist(allow_guest=True, methods=["POST"]) # nosemgrep: guest-whitelisted-method
def remove_discount_code(code: str) -> dict:
return cart_sdk.remove_discount_code(cstr(code).strip())


def get_browser_quantities(items: list | str) -> dict[str, int]:
quantities = {}
for entry in frappe.parse_json(items) or []:
item_code = cstr(entry.get("item_code")).strip()
qty = cint(entry.get("qty"))
if not item_code or qty < 1:
frappe.throw(_("Each cart line needs an item code and a quantity of at least 1."))
quantities[item_code] = quantities.get(item_code, 0) + qty
if len(quantities) > MAX_CART_LINES:
frappe.throw(_("A cart holds at most {0} lines.").format(MAX_CART_LINES))
return quantities


def get_cart_state(cart: CommeraCart) -> dict:
"""The server cart as storefront cart lines, so the browser can replace its own copy."""
quantities = {}
for row in cart.lines:
quantities[row.item_code] = quantities.get(row.item_code, 0) + cint(row.qty)
return {
"version": cart.version,
"lines": get_cart_lines(quantities, []),
"discount_codes": get_discount_codes(cart),
}
7 changes: 7 additions & 0 deletions commera/api/shipping.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from commera.checkout_hooks import PLUGIN_FEE_FIELD, apply_delivery_option_hooks, apply_plugin_fees
from commera.core import _get_cart_quotation
from commera.discounts.pricing import refresh_code_discounts
from commera.discounts.shipping import set_shipping_discount
from commera.utils import (
COD_CHARGE_DESCRIPTION,
get_charge_account_head,
Expand Down Expand Up @@ -214,6 +216,7 @@ def apply_delivery_option(quotation, option: dict):
quotation.custom_delivery_charge = flt(option["amount"])
quotation.custom_shipping_provider = option.get("provider")
quotation.custom_shipping_service_code = option.get("service_code")
set_shipping_discount(quotation, option)
set_delivery_charge_row(quotation, flt(option["amount"]), option["title"])
apply_plugin_fees(quotation)

Expand All @@ -223,6 +226,7 @@ def clear_delivery_option(quotation):
quotation.custom_delivery_charge = 0
quotation.custom_shipping_provider = None
quotation.custom_shipping_service_code = None
set_shipping_discount(quotation, None)
remove_delivery_charge_row(quotation)
quotation.calculate_taxes_and_totals()

Expand Down Expand Up @@ -321,6 +325,9 @@ def get_checkout_summary(quotation) -> dict:
clear_pickup_charges(preview)
summary = get_charge_summary(preview)

# A prepaid-only discount drops off, and a cash-on-delivery one comes on, before the fee is judged.
preview.flags.commera_payment_kind = "cod"
refresh_code_discounts(preview)
cod_charge = get_cod_charge(preview)
if cod_charge:
account_head = frappe.get_cached_value("Commera Settings", "Commera Settings", "charge_account_head")
Expand Down
58 changes: 56 additions & 2 deletions commera/checkout_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
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_handler_app, get_handlers
from commera.utils import get_charge_account_head

PLUGIN_FEE_FIELD = "commera_plugin_fee"
Expand All @@ -22,6 +23,24 @@ def get_cart_refusal(quotation) -> str | None:
return None


def get_discount_code_refusal(quotation, code: str) -> str | None:
"""Why an app will not let this code apply to the cart, in the shopper's words; None when every app
allows it. Checked for discount codes and the store coupon alike."""
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


def apply_plugin_fees(quotation):
"""Set quotation.flags.strict_plugin_fees at checkout: a failing fee hook then blocks it, while on a cart
edit the fee is only left out, so a broken app never stops a shopper changing their cart."""
Expand All @@ -42,6 +61,41 @@ def apply_plugin_fees(quotation):
quotation.calculate_taxes_and_totals()


def get_plugin_discounts(quotation) -> list[dict]:
"""What the apps' cart_discounts hooks take off: `line` (a row idx) marks a product discount, none an
order one. Like cart fees, a failing handler blocks checkout but is only left out of a cart edit."""
discounts = []
for handler in get_handlers("commera_hooks", "cart_discounts"):
with handle_hook_error(
"cart_discounts", handler, quotation, bool(quotation.flags.strict_plugin_fees)
):
returned = frappe.get_attr(handler)(quotation) or []
if not isinstance(returned, list):
raise TypeError(f'commera_hooks["cart_discounts"] must return a list, got {returned!r}')
app = get_handler_app(handler)
discounts += [get_plugin_discount(quotation, app, discount) for discount in returned]
return [discount for discount in discounts if discount["amount"] > 0]


def get_plugin_discount(quotation, app: str, discount: dict) -> dict:
title = cstr(discount.get("title")).strip()
amount = discount.get("amount")
if not title or not is_finite_number(amount) or amount < 0:
raise ValueError(f"A cart discount needs a title and an amount of zero or more, got {discount!r}")
line = discount.get("line")
if line is not None and line not in {item.idx for item in quotation.items}:
raise ValueError(f"A cart discount names line {line!r}, which the cart does not have")
return {
"discount": None,
"code": None,
"app": app,
"title": title,
"discount_class": "Product" if line is not None else "Order",
"amount": flt(amount, quotation.precision("discount_amount")),
"line": line,
}


def get_plugin_fee_rows(quotation, handlers: list[str], strict: bool) -> list[dict]:
precision = quotation.precision("tax_amount", "taxes")
fee_rows = []
Expand Down Expand Up @@ -86,7 +140,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]:
Expand Down
Loading
Loading