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.
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
positionorder, 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:
- a deadline handed to the driver before it is called, which a well-written driver puts into its HTTP client's timeout;
- 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, default900seconds, 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:
{ "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.
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.
Related
- Shipping - zones, methods, weight bands, the free-shipping modes and why a method is not offered
- Click and collect - collection points, and the rule that a collected order is taxed at the counter
- Money conventions - minor units, and why nothing here is a float
- Provider developer guide -
onKahunaCartRegisterCarrierRateProvidersand a worked driver - Configuration - the six keys in their table
- Currencies - selling in more than one currency