Skip to content

Building a Cart Transform that gets quantities right

A fee that is right at quantity one and wrong at quantity eleven is the signature bug of this work. Here is the order of operations that avoids it.

From the workAugust 5, 20268 min read

A Cart Transform Function is roughly thirty lines of real logic. The hard part is not the code. It is that a cart line is a merchandise reference plus a quantity, that three different numbers in the input and output are all denominated per unit, and that whoever signs the work off almost always adds one of each product.

So the fee looks perfect in the development store, ships, and charges the eleventh unit wrong. That is the signature failure of this target, and everything below is arranged to prevent it rather than to catch it afterwards.

The order we work in for a fee or a bundle: the rule in prose, then the operation, then the exclusions, then the configuration, then the tests. Skipping to the input query is how ambiguity survives into production.

Write the rule as a sentence a merchant would sign

Before any GraphQL, write the rule in plain language, and make it answer five questions it will otherwise answer by accident:

  • What does it apply to? Not "products" — which products, identified how. A collection, a tag, an explicit variant list.
  • Is the amount per unit or per line? Say the word "unit" or the word "line" out loud. "Per order" is a third answer and a different implementation.
  • What is excluded? Gift cards are the usual first answer. Ask for the second and third.
  • What does the buyer see? A separate line with its own title, or a changed price on the existing one. Different operations.
  • What happens when it meets a discount? An order-level percentage discount interacts with an added fee line, and the merchant has an opinion about it even if they have not said so.

A good rule reads like this: "Twelve dollars per unit, on every unit of a product tagged import-duty, excluding gift cards and excluding anything tagged fee-exempt, shown as its own line titled Import fee." That sentence contains an input query, an operation, and four tests. A vaguer sentence contains a bug.

Pick the operation before you pick the fields

Cart Transform gives you three operations and they are not interchangeable.

lineExpand replaces one cart line with a set of expanded items. This is what you want for a fee: expand the original line into itself plus a fee item, each with its own price. It is also how a bundle is exploded into components — one bundle line becomes the parts, and each part carries its own price adjustment.

linesMerge is the reverse: several lines collapse into one parent variant with a new title and price, either a fixedPricePerUnit or a percentageDecrease. This is "three of these become a kit at fifteen percent off".

lineUpdate changes a line where it stands. It is the smallest of the three and the most restricted: the operation is rejected outright unless the shop is on a Plus plan or a development store. Plan for that before you design around it.

Two things worth knowing early. A subscription line is a case to test explicitly rather than assume, because selling plans interact with all three operations. And collisions are not rejected — every installed app's operations are combined into one list ordered by activation time, duplicate expansions after the first are discarded, and an expansion outranks a merge or an update. So your code should decide one thing per line, and you should know what else is installed, because another app's transform can quietly win a line you thought was yours.

Per-unit versus per-line, concretely

The whole trap fits in three field names. The input hands you, for each cart line, a quantity and a cost.amountPerQuantity.amount. That second one is the price of one unit, not the line total. The output, in an expanded item, takes a price adjustment of fixedPricePerUnit — again, the price of one unit.

So the correct implementation of "twelve dollars per unit" is to emit a fee component with quantity equal to the line quantity and a fixed price per unit of twelve. The fee scales because Shopify multiplies, not because you did.

The three ways to get it wrong are all things a reasonable person writes on a Tuesday. Multiply twelve by the quantity and also set the quantity: one hundred and thirty-two per unit. Set the fee quantity to one and leave the price at twelve: eleven units carry a single twelve dollar fee. Multiply and set the quantity to one: this line is right, and every rule that reruns on the next cart edit is not.

The original item also has to be re-priced deliberately inside the expansion. You are describing all of its components, the product included, so its price is stated rather than inherited. Passing cost.amountPerQuantity.amount straight through is correct; forgetting to is how a fee silently becomes a discount.

Scroll the figure sideways to see all of it

Exclusions are part of the rule, not a filter you add later

Exclusions have to be in the input query, because the input query is the only data you will ever get. Three mechanisms cover almost everything.

Tags. The input schema exposes hasAnyTag(tags: [...]) on products and on the customer, evaluated by Shopify before your code runs. Cheapest exclusion to operate: a merchant adds a tag in the admin and the behaviour changes, with no deploy and no support ticket.

Collections. inAnyCollection(ids: $collectionIds) takes collection IDs as query variables. One documented gotcha earns its own test: if the collection set is empty, the field returns false, not true. A rule written as "apply to everything unless a collection list is set" will apply to nothing the day the list is cleared.

An explicit list in configuration. For gift cards and the handful of variants that are always exceptions, do not rely on inference. Decide how a gift card is identified in this catalogue — a tag, a collection, a listed variant ID — and encode it. A cart line does not announce that it is a special case.

One more thing to handle rather than assume: the merchandise on a cart line is a union. It can be a ProductVariant, or a CustomProduct, which has no product and therefore no tags and no collections. Branch on __typename and decide, because the default answer is an unhandled case in a checkout.

Configuration in metafields, so numbers are not a deploy

The amount, the tag names, the excluded variant IDs and the fee line title all belong in a metafield read by the input query, not in the compiled binary. The reserved $app namespace is the right home: metafield(namespace: "$app", key: "function-configuration"), holding JSON.

query Input {
  cart {
    lines {
      id
      quantity
      cost { amountPerQuantity { amount } }
      merchandise {
        __typename
        ... on ProductVariant {
          id
          product { hasAnyTag(tags: ["import-duty", "fee-exempt"]) }
        }
      }
    }
  }
  cartTransform {
    metafield(namespace: "$app", key: "function-configuration") { jsonValue }
  }
}

Two rules keep this honest. First, parse the configuration once, at the top, into a typed shape, and fail loudly on a malformed value rather than silently defaulting to zero — a fee that quietly becomes free is worse than an error. Second, keep money as a decimal string until the last moment. The input gives you amounts as strings for a reason, and a fee computed through a float is a fee that will one day be off by a cent on an invoice somebody reads.

Presentment currency is the other half. A value stored in the shop's currency has to be multiplied by the presentment currency rate before a buyer in another market sees it. A fee correct in one currency and wrong in three is the international version of the quantity bug.

Scroll the figure sideways to see all of it

The tests worth writing

A Cart Transform is a pure function from an input object to a list of operations, which makes it one of the most testable things in commerce. Fixtures are plain JSON. There is no store to spin up.

The set that earns its keep:

  1. Quantity one. The baseline. It will pass.
  2. Quantity eleven. The test that would have caught the bug. Assert the amount, not that "a fee exists".
  3. An excluded line alone. A gift card by itself should produce no operations, not an operation worth zero.
  4. A mixed cart. Eligible and excluded lines together, the excluded one above quantity one, so a leaking filter shows up as a wrong number.
  5. A tagged exemption on an otherwise eligible product. Where the two tag rules meet is where precedence bugs live.
  6. Empty and malformed configuration. Missing metafield, empty JSON, a zero amount, an amount given as a number instead of a string.
  7. Custom merchandise. A line whose merchandise is not a product variant.
  8. Idempotence over a cart edit. Run against a cart that already reflects a previous result and confirm the fee is not applied twice.

The eighth is the one people skip and the one that produces the strangest support tickets. Functions rerun on every cart change, so running twice has to equal running once.

Deploying it

Deploy through your app, verify against real carts in a development store — including one at a quantity that is not one — then watch the first live orders it touches. Not the first day: the first orders. A Cart Transform is invisible when it works, and the gap between "shipped" and "someone noticed" is where the money goes.

The import fee Function in our portfolio is this exact shape: metafield-driven configuration, gift-card and product exclusions, quantity-safe arithmetic. If you are deciding whether the thing you need is a Cart Transform at all, the target and plan decision comes first, and our Shopify Functions development page covers how the whole sequence runs.

Blog