Guide 08 · Developer docs

Commerce

A Core dna store is a set of records and an API that moves a purchase from cart to order: products described in every language and priced in every currency the store sells in, a cart, a checkout that becomes an order, invoices, payments, tax and shipping. The site builds the storefront on top: the cart and checkout pages, their scripts, and the customer emails.

L2–L3
Unit 06 · Tax and shipping configuration

Shipping methods and their templates

Every shipping method is a template that creates the rates a shopper chooses from. The parts of shipping, what a method's template receives, a complete two-country set-up with weight-banded prices in both template languages, and currencies and languages.

Who this is for. Developers setting up a store's shipping and writing its rate logic. After this page you can configure shipping for the places a store serves and price it by weight, destination and currency, in the site's template language, without framework code.

The parts of shipping

PartWhat it is
Shipping categoryWhat a product is for shipping: parcel, oversize, chilled. A product has one; a variant uses its product's. Products without one use the Default shipping category (Shipping.defaultShippingCategory, required).
GroupEvery method belongs to a group, which has a name and an identifier ("Unique identifier to define a group even if it has the same name with any other group").
Shipping methodOf type Template ("Specify a custom template to create rates manually"): a Name, the Active switch, its group, the shipping categories it carries, the warehouses it serves for pickup, and its Template code.
RateOne option the shopper chooses from: a name, a total, an optional service level and courier name, and an expiry, after the Lifetime (Shipping.rates.lifetime, 3,600 seconds).

The categories and warehouses decide which fulfilments a method may carry; the template decides where it ships and what it charges.

What a method's template receives

A method's Template code runs for each fulfilment when shipping is created on a checkout, in the site's template language, Smarty or Twig. What it prints is not shown anywhere: rates exist because the code calls create on the rates helper, once for each option it offers. The code receives:

VariableWhat it is
fulfillmentThe items being shipped. getFulfillmentItems() gives each item, with getItem() (the line, whose getProduct() is the product or variant added) and getQuantity(); getDeliveryAddress() is the delivery address, empty for a pickup; getWarehouse() is the pickup warehouse; getOrder() is the checkout.
ratesThe rates helper, with one method: create(total, name, serviceLevel, courierName); the last two are optional.
site, userThe current site and user.
siteDataThe site-wide key-value store: get() and set().
queryThe query helper, as $_query is in page templates: findBy(), findOneBy() and the rest.

The admin's own examples are $rates->create('100', 'Standard') and $rates->create('200', 'Express', 'exp', 'Bob Smith') in Smarty, and rates.create('100', 'Standard') in Twig. For each rate:

  • total is a string holding a number of zero or more, in the checkout's currency, entered the same way as product prices: with tax or without, as Prices that include tax sets. A value that is not a number, or is negative, creates no rate.
  • name is what the shopper sees. serviceLevel and courierName are stored on the rate and returned with it.
  • Code that stops with an error creates no rates for that fulfilment, so guard every value that can be empty, above all the delivery address, which a pickup does not have.

Weight is each product's and variant's getWeight(): a decimal string, or null when no weight was entered. It carries no unit, so the store picks one (kilograms below) and enters every weight in it.

A complete two-country set-up

An Australian store ships parcels in Australia and to New Zealand, prices in Australian dollars, and keeps weights in kilograms. Its shipping takes five steps:

  1. Create a shipping category Parcel and make it the Default shipping category, so every product without a category of its own is carried too.
  2. Create a group Delivery, identifier delivery.
  3. Enter a weight in kilograms on every product and variant.
  4. Create a shipping method of type Template: name Post, group Delivery, category Parcel, Active on.
  5. Give it the template below. It reads the delivery country and creates rates only for the two countries the store serves, priced by the parcel's weight.
Template code Shipping method "Post": Template code
{$address = $fulfillment->getDeliveryAddress()}
{$country = null}
{if $address}{$country = $address->getCountry()}{/if}
{$code = ''}
{if $country}{$code = $country->getAlpha2Code()}{/if}

{$weight = 0}
{foreach $fulfillment->getFulfillmentItems() as $fulfillmentItem}
  {$product = $fulfillmentItem->getItem()->getProduct()}
  {if $product && !is_null($product->getWeight())}
    {$weight = $weight + $product->getWeight() * $fulfillmentItem->getQuantity()}
  {/if}
{/foreach}

{if $code == 'AU'}
  {if $weight <= 1}
    {$rates->create('9.95', 'Standard post', 'standard')}
  {elseif $weight <= 5}
    {$rates->create('14.95', 'Standard post', 'standard')}
  {elseif $weight <= 20}
    {$rates->create('24.95', 'Standard post', 'standard')}
  {/if}
  {if $weight <= 5}
    {$rates->create('19.95', 'Express post', 'express')}
  {/if}
{elseif $code == 'NZ'}
  {if $weight <= 2}
    {$rates->create('25.00', 'International post', 'international')}
  {elseif $weight <= 10}
    {$rates->create('45.00', 'International post', 'international')}
  {/if}
{/if}
{% set address = fulfillment.getDeliveryAddress() %}
{% set country = address ? address.getCountry() : null %}
{% set code = country ? country.getAlpha2Code() : '' %}

{% set weight = 0 %}
{% for fulfillmentItem in fulfillment.getFulfillmentItems() %}
  {% set product = fulfillmentItem.getItem().getProduct() %}
  {% if product and product.getWeight() is not null %}
    {% set weight = weight + product.getWeight() * fulfillmentItem.getQuantity() %}
  {% endif %}
{% endfor %}

{% if code == 'AU' %}
  {% if weight <= 1 %}
    {{ rates.create('9.95', 'Standard post', 'standard') }}
  {% elseif weight <= 5 %}
    {{ rates.create('14.95', 'Standard post', 'standard') }}
  {% elseif weight <= 20 %}
    {{ rates.create('24.95', 'Standard post', 'standard') }}
  {% endif %}
  {% if weight <= 5 %}
    {{ rates.create('19.95', 'Express post', 'express') }}
  {% endif %}
{% elseif code == 'NZ' %}
  {% if weight <= 2 %}
    {{ rates.create('25.00', 'International post', 'international') }}
  {% elseif weight <= 10 %}
    {{ rates.create('45.00', 'International post', 'international') }}
  {% endif %}
{% endif %}

The country is read from the address's country record, which a checkout address has when its countryName matches the country list (Writing the email and addresses), and compared by its two-letter code. An address with no country record, or in another country, gets no rate from this method.

With this configuration, creating shipping on a checkout gives these rates:

Delivery addressParcel weightRates offered
Melbourne, Australia0.4 kgStandard post 9.95, Express post 19.95
Melbourne, Australia3 kgStandard post 14.95, Express post 19.95
Melbourne, Australia12 kgStandard post 24.95
Auckland, New Zealand1.5 kgInternational post 25.00
Auckland, New Zealand12 kgNone
London, United KingdomanyNone

Every total is in the checkout's currency, here AUD. Where no rate is offered, the checkout's page tells the shopper why (When no method covers the address). If a method that should offer a rate does not, work through Making a shipping method appear at checkout.

Currencies and languages

A rate is created in the checkout's currency, so on a multi-currency store each total has to be the price in that currency. Read the checkout's currency code from the fulfilment and keep one price per currency:

Template code
{$standard = ['AUD' => '9.95', 'NZD' => '10.95']}
{$currencyCode = $fulfillment->getOrder()->getCurrencyCode()}
{if isset($standard[$currencyCode])}
  {$rates->create($standard[$currencyCode], 'Standard post', 'standard')}
{/if}
{% set standard = {'AUD': '9.95', 'NZD': '10.95'} %}
{% set currencyCode = fulfillment.getOrder().getCurrencyCode() %}
{% if standard[currencyCode] is defined %}
  {{ rates.create(standard[currencyCode], 'Standard post', 'standard') }}
{% endif %}

A currency with no price in the list gets no rate, which keeps a method from charging an amount meant for another currency. How a cart's currency is chosen is on Prices and currencies.

A rate's name is stored as the template wrote it. On a multilingual store, give every rate a serviceLevel (standard, express, international) and let the checkout page print the label for that service level in the page's language, falling back to the rate's name.

One method or several

A method's categories and warehouses apply to every rate it creates, so add a method wherever those differ. A store with bulky goods adds a category Oversize on those products and a second method, "Courier", that carries Parcel and Oversize; a fulfilment holding an oversize item then gets its rates from Courier alone. A store that offers collection adds a method with its pickup warehouses whose template creates a free rate when the fulfilment has a warehouse: {if $fulfillment->getWarehouse()}{$rates->create('0', 'Collect in store', 'pickup')}{/if} in Smarty, {% if fulfillment.getWarehouse() %}{{ rates.create('0', 'Collect in store', 'pickup') }}{% endif %} in Twig.

The rates at checkout

Creating shipping on a checkout runs every active method that may carry the fulfilment and gathers the rates they create into one list, which the shopper chooses from (Creating shipping and showing the rates). A rate lasts for the Lifetime, an hour by default, and a checkout whose selected rate has expired creates shipping again (A rate lasts an hour). Tax on the shipping is the tax provider's, from its Shipping Tax Code (Tax categories and provider codes).

Go deeper

Was this page helpful?

Your answer goes to the docs team.