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 05 · From payment to fulfilment

Stock, fulfilment and the member's order screens

How stock is recorded per warehouse and kept in step with the system that ships the goods, how fulfilments and shipments follow an order out of the door, which status a customer sees, and how to build the member's order screens.

Who this is for. Developers connecting a store to its stock and dispatch systems, and building the order pages of the member area. After this page you can keep stock figures right, show availability, track an order's journey, and override the two order screens.

Stock is a ledger per warehouse

Stock is recorded in four records:

RecordWhat it holds
WarehouseA place stock is kept, with a unique code and an address.
Inventory transactionOne movement of stock: the SKU, the warehouse, the quantities moved and the reason. It can belong to a batch, and can carry an expiry, for a reservation held before payment. A transaction is final once written.
StockOne product's or variant's quantities in one warehouse: available, reserved, in transit and on hand. It is the running total of that product's transactions in that warehouse, kept up to date by the platform as transactions are written.
BatchA lot of goods, with a name, an expiry date and a received date, for lot and expiry tracking.

The four quantities let the store say where every unit is: available to sell, reserved for an order that has not left yet, in transit to a warehouse or a customer, and on hand on the shelf. A product and each variant also carry their available quantity summed across the account's warehouses, which templates read with getAvailableQuantity().

Keeping stock in step

Stock changes when a transaction is written. There are two ways to write one:

  • The product editor. Staff enter the four quantities per warehouse as they are now; on save, the platform writes one transaction per warehouse holding the difference. A form opened before someone else changed the stock is refused, so two people never overwrite each other's counts.
  • The Inventory API. POST /cdna-api/inventory/transaction records one movement for a SKU in a warehouse: the quantity moved in each bucket and a reason (Record a transaction).

The recommended set-up gives the numbers one owner: the system that ships the goods, such as a warehouse or ERP system, writes a transaction through the API for each movement it makes. Goods received add to on hand and available; an order picked moves units from available to reserved; a parcel handed to the courier moves them out; a return put back on the shelf adds them again. When to reserve and when to take units out of stock is that system's rule; tell it about each paid order with a job on the order (Entity events). The store then reads stock and never has to calculate it.

A transaction that makes a product available again fires the back-in-stock event, which a job turns into emails to the people who asked (Back-in-stock requests).

Selling limited stock

A store that sells limited stock shows availability on the product page and offers the add button only while something is available:

Template code templates/products/product-descriptions/show.html
{$product = $productDescription->getProduct()}
{if $product->getAvailableQuantity() > 0}
  <p class="stock">In stock</p>
  <button type="submit">Add to cart</button>
{else}
  <p class="stock">Out of stock</p>
  {include 'products/includes/back-in-stock-form.html'}
{/if}
{% set product = productDescription.getProduct() %}
{% if product.getAvailableQuantity() > 0 %}
  <p class="stock">In stock</p>
  <button type="submit">Add to cart</button>
{% else %}
  <p class="stock">Out of stock</p>
  {% include 'products/includes/back-in-stock-form.html' %}
{% endif %}

The include is the store's own back-in-stock form. For a product with variants, read each variant's own getAvailableQuantity() and offer only the options that have stock (The product page and its variants). Where the last units of something can sell twice at the same moment, as in a flash sale, the system that ships the goods has the final word: it confirms each paid order against the shelf, and an item it cannot supply is refunded and the customer told (Refunds).

Fulfilments and shipments

A fulfilment is a set of an order's lines that leave together. It is created at checkout when shipping is chosen, one for all available items from the built-in delivery and pickup options (Creating shipping and showing the rates), and moves to the order at the conversion. It holds:

  • its items: each order line it covers, with a quantity (getFulfillmentItems());
  • a delivery address for a delivery, or a warehouse for a pickup (getDeliveryAddress(), getWarehouse());
  • the shipping rate the shopper chose (getSelectedShippingRate());
  • its status, and its shipments.

Staff move a fulfilment through its statuses in the admin, on the order's fulfilments:

ValueLabel
pendingPending
readyReady
in_transitIn Transit
deliveredDelivered
cancelledCancelled

A shipment is one parcel on its way. It belongs to a fulfilment and carries the courier's trackingCode and trackingUrl, an externalId and the amount charged. A fulfilment whose items leave in more than one parcel has a shipment for each. An order can also have more than one fulfilment, each with its own status: that is a partial fulfilment, where some lines ship before others.

The status a customer sees

The order's fulfilment status sums up its fulfilments, and is worked out again every time the order is saved. A cancelled fulfilment is left out of the count. getFulfillmentStatus() returns the value and getFulfillmentStatusDisplay() the label:

ValueLabelWhen
noneNot FulfilledNo fulfilments, or all of them cancelled
pendingPending FulfillmentEvery fulfilment is pending
partialPartially FulfilledSome fulfilments are pending and others have moved on
fulfilledFulfilledNone is pending: each is ready, in transit or delivered, and not all are delivered
completedFulfillment CompleteEvery fulfilment is delivered

Show the customer the order's fulfilment status as the summary, and beside the items each fulfilment covers, its own status and tracking links.

The member's order screens

The member area has two order screens, and each shows only the signed-in member's own orders:

ScreenTemplateReceives
The member's orders, a page at a timeorders/orders/index.htmlorders
One orderorders/orders/show.htmlorder

The framework ships starter versions of both, which extend users/frontend/includes/account.html. Override them by creating the same paths in the site's templates/ folder, and print every amount with the money getters and format(), which format in the order's own currency. Link a row to its order with $_url->getPath('MyAccount', 'orderDetails', ['id' => $order->getId()]), which builds the member area's path for that order.

Template code templates/orders/orders/index.html
<table>
  {foreach $orders as $order}
    {$ordered = $order->getOrdered()}
    <tr>
      <td><a href="{$_url->getPath('MyAccount', 'orderDetails', ['id' => $order->getId()])}">{$order->getNumber()|escape}</a></td>
      <td>{if $ordered}{$ordered->format('j M Y')}{/if}</td>
      <td>{$order->getFinancialStatusDisplay()}</td>
      <td>{$order->getFulfillmentStatusDisplay()}</td>
      <td>{$order->getTotalAsMoney()->format()}</td>
    </tr>
  {foreachelse}
    <tr><td colspan="5">You have no orders yet.</td></tr>
  {/foreach}
</table>
<table>
  {% for order in orders %}
    <tr>
      <td><a href="{{ _url.getPath('MyAccount', 'orderDetails', {'id': order.getId()}) }}">{{ order.getNumber()|e }}</a></td>
      <td>{{ order.getOrdered() ? order.getOrdered()|date('j M Y') }}</td>
      <td>{{ order.getFinancialStatusDisplay() }}</td>
      <td>{{ order.getFulfillmentStatusDisplay() }}</td>
      <td>{{ order.getTotalAsMoney().format() }}</td>
    </tr>
  {% else %}
    <tr><td colspan="5">You have no orders yet.</td></tr>
  {% endfor %}
</table>

On the order screen, each line's stored name is in the client's default language. On a multilingual site, take the name from the line's product description in the visitor's language, keeping the stored name as the fallback, as Reading the right description does for product cards. Fulfilments follow the lines, with a tracking link for each shipment that has one:

Template code templates/orders/orders/show.html
{$language = $_query->findOneBy('Common\Entities\Language', ['code' => $_request->getLanguage()])}
<h1>Order {$order->getNumber()|escape}</h1>
<p>{$order->getFinancialStatusDisplay()} · {$order->getFulfillmentStatusDisplay()}</p>
<table>
  {foreach $order->getItems() as $item}
    {$name = $item->getName()}
    {$product = $item->getProduct()}
    {if $language && $product}
      {$description = $product->getDescriptionForLanguage($language)}
      {if $description}{$name = $description->getName()}{/if}
    {/if}
    <tr><td>{$name|escape} × {$item->getQuantity()}</td><td>{$item->getTotalAsMoney()->format()}</td></tr>
  {/foreach}
  <tr><td>Shipping</td><td>{$order->getShippingTotalAsMoney()->format()}</td></tr>
  <tr><td>Tax</td><td>{$order->getTaxTotalAsMoney()->format()}</td></tr>
  <tr><td>Total</td><td>{$order->getTotalAsMoney()->format()}</td></tr>
</table>
{foreach $order->getFulfillments() as $fulfillment}
  <h2>{$fulfillment->getStatusDisplay()}</h2>
  {foreach $fulfillment->getShipments() as $shipment}
    {if $shipment->getTrackingUrl()}
      <p><a href="{$shipment->getTrackingUrl()|escape}">Track parcel {$shipment->getTrackingCode()|escape}</a></p>
    {/if}
  {/foreach}
{/foreach}
{% set language = _query.findOneBy('Common\\Entities\\Language', {'code': _request.getLanguage()}) %}
<h1>Order {{ order.getNumber()|e }}</h1>
<p>{{ order.getFinancialStatusDisplay() }} · {{ order.getFulfillmentStatusDisplay() }}</p>
<table>
  {% for item in order.getItems() %}
    {% set name = item.getName() %}
    {% set product = item.getProduct() %}
    {% set description = language and product ? product.getDescriptionForLanguage(language) : null %}
    {% if description %}{% set name = description.getName() %}{% endif %}
    <tr><td>{{ name|e }} × {{ item.getQuantity() }}</td><td>{{ item.getTotalAsMoney().format() }}</td></tr>
  {% endfor %}
  <tr><td>Shipping</td><td>{{ order.getShippingTotalAsMoney().format() }}</td></tr>
  <tr><td>Tax</td><td>{{ order.getTaxTotalAsMoney().format() }}</td></tr>
  <tr><td>Total</td><td>{{ order.getTotalAsMoney().format() }}</td></tr>
</table>
{% for fulfillment in order.getFulfillments() %}
  <h2>{{ fulfillment.getStatusDisplay() }}</h2>
  {% for shipment in fulfillment.getShipments() %}
    {% if shipment.getTrackingUrl() %}
      <p><a href="{{ shipment.getTrackingUrl()|e }}">Track parcel {{ shipment.getTrackingCode()|e }}</a></p>
    {% endif %}
  {% endfor %}
{% endfor %}

The order's refunds and the total refunded so far read the same way (The adjustment is meant to go negative). How the member area routes these screens and checks the signed-in member is Guide 7's subject.

Go deeper

Was this page helpful?

Your answer goes to the docs team.