Skip to content

Powered by Grav CMS + Helios Theme

Shipping rates

This page lists each component of a shipping charge and the order in which it is calculated. Shipping covers zones, methods and whether a method is offered at all. It is intended for store administrators setting delivery prices.

The six rate types, in application order

A method's price is calculated in the order below. Each component is added to the running total, then the minimum and maximum limits are applied.

# Rate type Field Meaning
1 Base rate cost_minor A flat amount per parcel. A matched weight band replaces it, and a free method is zero whatever else is set
2 Per-item rate per_item_minor Multiplied by the total quantity in the parcel
3 Weight rate per_kg_minor Multiplied by the parcel's weight in kilograms, alongside the weight bands rather than instead of them
4 Percentage rate percent_rate A percentage of the parcel's item price, written as a decimal string
5 Category rate per_class_json, class_costs_mode The per-class figure, once per distinct shipping class the parcel holds. Added on top of everything above, or in place of it. See Adding to the rate, or replacing it
6 Clamps min_total_cost_minor, max_total_cost_minor The floor, then the ceiling

The clamps are applied after tabulation, minimum first and then maximum. Both are inclusive at the boundary, so a figure exactly equal to the minimum is not raised and one exactly equal to the maximum is not lowered.

Every intermediate is an amount in minor units. The per-kilogram rate and the percentage go through exact rational arithmetic and are rounded once, half up, at the end. No shipping cost is ever computed from a float. See Money conventions.

Watch the two similar names. min_total_cost_minor is the clamp. min_total_minor is the free-shipping threshold and has nothing to do with it.

Worked example

A "Courier" method with a base of 5.00, a per-item rate of 0.75, a per-kilogram rate of 2.40, a percentage rate of 1.5, a fragile class figure of 2.50, a minimum of 8.00 and a maximum of 30.00. A parcel of three items, two of them fragile, weighing 1,437 g, worth 60.00:

Step Working Running total
Base 5.00 5.00
Per item 0.75 × 3 7.25
Per kilogram 2.40 × 1.437 = 3.4488 → 3.45 10.70
Percentage 1.5% of 60.00 11.60
Category fragile once, not twice 14.10
Minimum 14.10 is already above 8.00 14.10
Maximum 14.10 is below 30.00 14.10

Empty the basket down to one non-fragile item worth 4.00 weighing 100 g and the same method tabulates to 6.05, which the minimum raises to 8.00. Fill it with forty heavy items and the maximum caps it at 30.00.

Adding to the rate, or replacing it

By default a per-class figure is a surcharge: steps 1 to 4, plus the figure, once per distinct class in the box. That prices "anything fragile costs another 2.50 to send". It cannot price "anything fragile costs 12.00 to send, whatever else is in the box". class_costs_mode on the method decides which you get.

Mode What a parcel holding a classed line costs
add Steps 1 to 4, plus the figure for each distinct class present. The default
replace The figures for the classes present, and nothing from steps 1 to 4. Two classed lines in one parcel add their two figures together

The clamps apply either way. A parcel holding no class the method has a figure for is priced by steps 1 to 4 in both modes.

Take the Courier method above and set it to replace. The same parcel of three items, two of them fragile, now costs the fragile figure of 2.50, which the minimum raises to 8.00. Give fragile a figure of 12.00 instead and the parcel costs 12.00, whatever it weighs.

The mode is stored on the method. The upgrade adds the column with add in it, so nothing changes until you choose. In the admin the control sits under the per-class figures, and appears once a method has at least one of them.

Cost expressions

A method can carry a formula instead of the table above. When cost_expression is set it replaces steps 1 to 5 entirely, class_costs_mode included. The two clamps still apply.

TXT
1000 + ( 200 * [qty] ) + [fee percent="10" min_fee="400" max_fee="2000"]

Everything in an expression is in minor units

[cost] is minor units, so every literal beside it is too, and so is the result. WooCommerce's familiar 10 + ( 2 * [qty] ) is written 1000 + ( 200 * [qty] ) here.

A negative result is floored at zero.

The closed token list

Nothing outside this table parses. There are no function names, no variables you can invent, no property access, no comparison and no way to call anything.

Token Type Is
12, 12.5, .5 decimal literal A plain decimal. No signs, no exponents
+ - * / operator Ordinary precedence. - also works as unary minus
( ) grouping Must balance
[qty] integer The total quantity in the parcel
[item_count] integer How many lines the parcel holds
[cost] integer, minor units The parcel's item price
[weight] integer, grams The parcel's weight
[fee percent="N" min_fee="N" max_fee="N"] function N percent of [cost], raised to min_fee if it fell below, lowered to max_fee if it rose above. All three attributes are optional, and min_fee and max_fee are minor units

Every token is typed and coerced when it is read. No type is ever inferred from what a value looks like.

What cannot get in

KahunaCart tokenises the expression, parses it with a recursive-descent parser over the grammar above, and walks the resulting tree. There is no eval, no create_function, no preg_replace with /e, no Twig, no Symfony ExpressionLanguage and no path from any character you type to a PHP callable. The only things that cross from the text into evaluation are the token kinds in the table and a [fee]'s three numeric attributes.

An expression that is not in the list is refused where you type it, with the offending token named, by POST /kahunacart/shipping/expressions/validate as you write and again by the save. It can never reach a checkout:

Typed Refused with
system("ls") 'system' is not allowed in a cost expression. There are no function or variable names here…
`id` | '' is not allowed in a cost expression (position 0).`
{{7*7}} '{' is not allowed in a cost expression (position 0).
[fee percent="1" cb="phpinfo"] 'cb' is not part of [fee]. It takes percent, min_fee and max_fee…
1;DROP TABLE ';' is not allowed in a cost expression (position 1).
$_SERVER '$' is not allowed in a cost expression (position 0).
[qtу] (Cyrillic у) '[qtу]' is not a cost-expression token. The tokens are [qty], [cost], [weight], [item_count]…
(1+2 '(' at position 0 was never closed.

The one thing evaluation forgives is division by zero. It evaluates to zero and is noted in the adjustment's audit trail rather than stopping the request, so [cost] / [qty] does not take checkout down on an empty parcel.

Switch the whole feature off with shipping.expressions.enabled, which defaults to true. Set it to false and the store refuses to save an expression and ignores any already stored.

The three-valued shipping-class policy

One control on a method, three values.

Policy The method is
allow Offered as it always was. The list is not consulted at all. This is the default
disallow Not offered when the parcel holds a line of a named class. "This courier never takes aerosols"
require Not offered unless the parcel holds a line of a named class. "This pallet service is only for furniture"

Set it with class_policy and class_policy_classes_json on the method, or through PUT /kahunacart/shipping/methods/{id}/rates.

A method removed by policy comes back present, disabled and naming the class, through the same machinery a weight band uses. The two reason codes are class_disallowed and class_required, both in the closed refusal vocabulary.

A disallow or require policy naming no classes refuses nothing.

Classes come from each line's snapshot, never the live product row, so reclassifying a product does not change what a completed order was charged.

Shipping profiles and packages

A fridge and a phone case do not travel together, and a download does not travel at all. A shipping profile on a product says which consignment it belongs to. An order is packed into one package per profile its lines mention, and each package resolves its own method and gets its own shipping line.

Two profiles ship with the plugin:

Code Ships Is
default yes What every physical product falls back to
digital no What every download falls back to. A package of this profile has no method, no cost and no adjustment

A product with no profile of its own falls back to digital when it is a download and default otherwise, so an existing store packs every order into exactly one parcel and is charged exactly what it was charged before.

The rules:

  • Packages come out in profile position order, ties broken on the profile id. The same cart packs the same way every time it is recalculated.
  • The order's shipping total is the sum of its packages.
  • A package whose profile does not ship produces no adjustment and no method choice, and is still written down, so a packing slip can say "parcel 2: nothing to send".
  • A line's profile comes from its snapshot, never the live product row.
  • No weight or dimension packing. Working out that four mugs fit in one box and the fifth needs a second is a carrier's job with a carrier's data.

The parcels are stored and replaced wholesale on every recalculation, the way adjustments are, and read through GET /kahunacart/orders/{id}/packages. shipping.packages.enabled defaults to true. Set it to false and every physical line is packed into one parcel and nothing is stored.

Worked example

A cart holding one downloadable guide and one mug produces two packages: parcel 1 (default) with the mug and a shipping line, parcel 2 (digital) with the guide and no shipping line at all. The order's shipping total is parcel 1's postage, and the order total is the goods plus that.

The carrier rate contract

KahunaCart ships no carrier. There is no DHL, UPS or Royal Mail driver in core. The carriers are the KahunaCart Shipping add-on. Core ships the contract they plug into: the interface, the registry, the cache, the timeout, the documented failure mode and a working reference driver in the test suite.

The platform decides which methods are offered, and the driver decides what one costs. You create an ordinary shipping method and name a carrier and a service on it. The driver is asked for a price for that service and answers either a number or a reason. A driver never invents a method, never reaches into the offer list and never sees a cart.

Bind a method to a carrier

Field Is
carrier_code The driver that prices this method. Empty on every method in a store with no carrier add-on, and empty means "my own rates price this"
carrier_service_code Which of that carrier's services. Empty takes the first rate the driver returned

The quoted amount is still clamped by min_total_cost_minor and max_total_cost_minor. Those two numbers stand between a bad API response and a charged order.

When a carrier does not answer

A carrier never blocks checkout and never takes the storefront down. Every way a driver can fail leads to the same outcome: the method is present, disabled and carrying a sentence, and your nominated offline fallback method carries the order.

Situation The customer sees Reason code
The carrier quoted the service The method, at the quoted price, clamped -
The carrier excluded the service The method, disabled, with the driver's own reason key carrier_excluded
The carrier quoted nothing about it The method, disabled calculator_declined
The carrier timed out or stopped with an error The method, disabled carrier_unavailable
No add-on provides that carrier The method, disabled carrier_unavailable
shipping.carrier_rates.enabled is false The method, disabled carrier_unavailable

shipping.carrier_rates.fallback_method_id names the offline method that carries the checkout when every carrier-priced method is unavailable. It defaults to null. Nominate it yourself: "the cheapest remaining method" would ship pallets by letter post the day a courier's API goes down.

A method the store has already refused, for being too heavy, under a free-shipping minimum or the wrong class, is not quoted for at all.

The timeout, and what "timeout" means in PHP

shipping.carrier_rates.timeout is a per-driver, wall-clock budget in seconds, and it defaults to 5. Portable PHP cannot interrupt a driver that has gone to sleep inside a socket read, so the budget is enforced as two things:

  1. a deadline handed to the driver before it is called, which a well-written driver puts into its HTTP client's timeout;
  2. a verdict on the answer. A driver that came back late has its answer discarded and its methods refused, and the late answer is not cached.

A driver that ignores its deadline costs that request the time it took, once, and costs the store nothing else.

The cache

A checkout page that recalculates three times must not call a courier three times. A quote is cached on a hash of the destination, the parcel contents, the weight, the declared value and the currency. It carries:

  • a TTL, shipping.carrier_rates.cache_ttl, default 900 seconds, and never forever. A courier's prices change.
  • a tag, so the whole set drops at once. Saving a shipping method's rates clears it.
  • a rebuild action, POST /kahunacart/shipping/carriers/cache/clear, for the morning your account manager changes a rate card.

A carrier that answered with nothing is cached as nothing, so a driver refusing a destination is not asked again on every keystroke. Set cache_ttl to 0 to ask the carrier every time.

Writing a carrier driver

Implement Shipping\Carrier\CarrierRateProviderInterface and register it on onKahunaCartRegisterCarrierRateProviders. The worked example is in the provider developer guide.

Shipping in another currency

A method's fields are its rates in the base currency. For an order in another currency the method is worked out again from that currency's own amounts, with the same six steps in the same order, and with typed pricing nothing in the base currency is read, converted or reused. A base 5.00 is not 5.00 euro and not 500 yen. A currency set to convert works the amounts you haven't typed out from the base ones first, to that currency's own decimal places, and then runs the same six steps.

A method is priced in a currency when every rate it uses has an amount in that currency. A method that is not priced in the order's currency is not offered there, and it says currency_unpriced. What a missing amount does, field by field:

Field When the base has it and the currency has none
cost (base rate) The method is not offered, unless the base rate is 0 (a zero is zero in every currency) or weight bands or a cost expression decide the price.
per_item, per_kg The method is not offered.
per_class The method is not offered when any class the base prices has no figure in the currency. A base figure of 0 needs none.
weight_rules The method is not offered. The bands decide whether the parcel can go at all, so each currency has its own list.
cost_expression The method is not offered. A currency uses its own expression and never the base one, because every number in an expression is in minor units of one currency.
free_minimum The minimum cannot be reached, so a minimum or minimum_and_coupon method is not offered and a minimum_or_coupon method is offered with the coupon only.
min_total_cost, max_total_cost The floor or the ceiling does not apply in that currency. They bound a price and are not part of one.

percent_rate is a percentage and is the same in every currency. A free method and a pickup method with nothing set are offered everywhere. A method a carrier prices needs no amounts of its own, because the carrier's rate sets the price. A carrier rate quoted in a currency other than the order's is dropped, never used as if it were in the order's currency.

The base is the switch. An amount in another currency counts only where the base has that field set, so clearing a base rate leaves a euro row that does nothing, and the admin and the API refuse a euro rate beside an empty base one.

The amounts are whole minor units of their own currency, so 1850 is 18.50 in euro and 1,850 in yen. The API takes decimals and reads each with its own currency's decimal places, so 15.5 is refused for yen instead of being rounded. The seven numeric fields (cost, free_minimum, per_item, per_kg, min_total_cost, max_total_cost, and the old name min_total) are rows in kahunacart_currency_amounts (owner shipping_method). The other three are kept on the method in currency_rates_json:

JSON
{ "EUR": {
    "per_class": { "fragile": 300 },
    "weight_rules": [ { "min_grams": 0, "max_grams": 500, "amount": "4.50" } ],
    "cost_expression": "400 + [qty] * 50" } }

Per-class figures are in minor units, as per_class_json has them. Weight bands carry their amount as a decimal string, as the base bands do. A cost expression for a currency is written in minor units of that currency, so the same shipping in euro and in yen is two different expressions.

Set the rates for one currency through PUT /kahunacart/shipping/methods/{id}/rates with currency_amounts, the same object as on the zone. It writes the base fields and the currency's amounts together or not at all.

API reference

Rates, profiles and carriers all need kahunacart.settings. An order's parcels need kahunacart.orders.view.

TXT
GET    /kahunacart/shipping/methods/{id}/rates
PUT    /kahunacart/shipping/methods/{id}/rates
POST   /kahunacart/shipping/expressions/validate
GET    /kahunacart/shipping/profiles
POST   /kahunacart/shipping/profiles
PUT    /kahunacart/shipping/profiles/{id}
DELETE /kahunacart/shipping/profiles/{id}
GET    /kahunacart/shipping/carriers
POST   /kahunacart/shipping/carriers/cache/clear
GET    /kahunacart/orders/{id}/packages

Each has an MCP tool: get_shipping_rate_rules, set_shipping_rate_rules, validate_shipping_expression, list_shipping_profiles, create_shipping_profile, update_shipping_profile, delete_shipping_profile, list_carrier_drivers, clear_carrier_rate_cache and list_shipping_packages.

PUT .../rates writes only the fields it is handed, so a client that has never heard of percent_rate cannot clear one. class_costs_mode is one of the fields it takes, and a mode that is not add or replace is refused by name with a 422. Saving a whole zone rewrites every method it is given, so a client editing a zone sends the mode with each method or the method goes back to add. Deleting the default or digital profile is refused, and so is deleting any profile a product still points at.

See Shipping in the REST API for the full request and response bodies.

Configuration

Key Default Effect
shipping.expressions.enabled true Whether a method may carry a cost expression. false refuses to save one and ignores any already stored
shipping.packages.enabled true Whether an order is split into one parcel per shipping profile. false packs every physical line into one
shipping.carrier_rates.enabled false Whether carrier-priced methods are quoted for. false refuses every one of them with carrier_unavailable
shipping.carrier_rates.timeout 5 The per-driver wall-clock budget, in seconds
shipping.carrier_rates.cache_ttl 900 How long a carrier quote is cached, in seconds. 0 asks the carrier every time
shipping.carrier_rates.fallback_method_id null The offline method that carries the checkout when every carrier-priced method is unavailable

The zone editor keeps foreign weight bands whose boundaries differ from the base bands. Editing the zone preserves them. Matching bands have their own amount box beside the base band.

A carrier driver can implement CarrierQuoteCacheKeyInterface and return a local string from quoteCacheKey(ShippingContext $context): string. Core adds it to the quote-cache key. Include an account identifier or currency when those settings affect rates. Do not call the carrier or include credentials. Without the contract, or with an empty string, the existing key is used.