BWH Shipping

BWH Shipping

Open in ChatGPT
Ask ChatGPT about this page
Open in Claude
Ask Claude about this page

Use it in your app

This page is for the app that uses BWH Shipping, for example your storefront. It covers the four things you wire up: services, checkout quotes, shipments, and status changes.

1. Set up the Shipping Services

A Shipping Service is a delivery option a shopper can pick, such as "Express, 1–2 days". Create one per option, in Shipping Service → New:

Field Purpose
Title What the shopper sees. It is also the service's name, so it must be unique.
Provider The Shipping Provider Profile that carries it, for example Shiprocket
Service Code The carrier's id for this service. Each carrier page says what it means.
Markup %, Handling Fee Added on top of the live carrier rate
Shipping Rule Optional. When one of its bands covers the cart, the band sets the price instead of the live rate.
Backup Charge Used when there is no band and no live rate, so the option still shows

An enabled service needs a Service Code and an enabled provider.

2. Quote at checkout

quote_services prices every enabled Shipping Service for a cart. It isn't a public API, so wrap it in a whitelisted method of your own:

# your_app/api.py
import frappe

from bwh_shipping.bwh_shipping.pricing import quote_services
from bwh_shipping.bwh_shipping.utils import get_address_payload
from bwh_shipping.units import normalise_parcels


@frappe.whitelist(methods=["POST"])
def get_delivery_options(address: str) -> list[dict]:
	cart = get_current_cart()  # your code: the shopper's cart
	return quote_services(
		origin=None,  # each carrier ships from its own Pickup Address
		destination=get_address_payload(address),
		parcels=normalise_parcels([{"length": 30, "width": 20, "height": 10, "weight": cart.weight}]),
		cart={
			"declared_value": cart.grand_total,
			"weight": cart.weight,
			"base_net_total": cart.base_net_total,
			"conversion_rate": cart.conversion_rate,
			"currency": cart.currency,
		},
	)

Each row it returns looks like this:

{
	"title": "Express",
	"description": "1–2 days",
	"provider": "Shiprocket",
	"service_code": "10",
	"amount": 99.0,
	"is_free": False,
	"is_live_rate": True,
}
  • Carrier errors never break checkout. A carrier that fails is logged as <provider> checkout rates failed in the Error Log, and its services fall back to their Shipping Rule or Backup Charge.
  • A service with no price is dropped, rather than shown as free.
  • Store the chosen row on the server when the shopper picks it. When the order is placed, bill get_charge_amount(title, cart, quoted_amount=stored_amount) from the same module. Never trust an amount the browser sends back. Without a quoted_amount, a live-rate service is billed its Backup Charge, or refused if it has none.

3. Create and book a shipment

From the desk: open a submitted Delivery Note and click Create → Shipment. That creates a draft Shipping Request, which staff book with Book Shipment.

The Delivery Note route picks:

  • the carrier and service from the Sales Order's custom fields custom_shipping_provider, custom_shipping_service_code and custom_delivery_option (the Shipping Service title), when you've added them. Otherwise it uses the most recently created enabled provider, with no service, so staff pick one before Book Shipment.
  • the origin from the carrier's Pickup Address, or the company address.
  • one 30 × 20 × 10 cm parcel, weighted from the items' total weight. Items with no weight count as 0.5 kg per unit.

From code:

shipping_request = frappe.get_doc(
	{
		"doctype": "Shipping Request",
		"provider": "Shiprocket",
		"service_code": "10",
		"ref_doctype": "Sales Order",
		"ref_docname": sales_order.name,
		"origin_address": warehouse_address,
		"destination_address": sales_order.shipping_address_name,
		"customer_name": sales_order.customer_name,
		"declared_value": sales_order.grand_total,
		"currency": sales_order.currency,
		"parcels": [{"length": 30, "width": 20, "height": 10, "weight": 1.2, "count": 1}],
	}
).insert(ignore_permissions=True)

shipping_request.book()

book() buys the label and sets the status to Ready To Ship. The request then holds the awb, label_url, carrier and cost_amount.

4. React to status changes

Statuses arrive from carrier webhooks, or when someone clicks Sync Status (shipping_request.sync_status()). BWH Shipping doesn't poll carriers on a schedule. If you need that, for a carrier without webhooks, run your own scheduled job that calls sync_status() on shipments that are still in flight.

To act on a change, for example to email the customer when a parcel is out for delivery, hook on_update:

# your_app/hooks.py
doc_events = {
	"Shipping Request": {"on_update": "your_app.shipping.on_shipping_request_update"},
}

# your_app/shipping.py
import frappe


def on_shipping_request_update(doc, method=None):
	if doc.has_value_changed("status") and doc.status == "Out For Delivery":
		frappe.enqueue(send_out_for_delivery_email, shipping_request=doc.name, enqueue_after_commit=True)

Check has_value_changed, because the hook fires on every save. Enqueue slow work. During a webhook the hook runs inside the carrier's request, as Administrator.

Next steps

Last updated 3 weeks ago
Was this helpful?
Thanks!