Skip to content
Tyler Marshall

Preserving tiered set prices with Cart Transform

A merchant needs configurable greenhouse kits to use prices managed as catalog tiers while preserving each selected module as an order component that carries inventory.

Known limits

Carrier variant
Every price tier needs a real carrier variant; the Function cannot create or select that cart line.
Allocation
Equal component allocation misstates line value when selected items have different economic weights.
Selling plans
Selling plan lines cannot be expanded, so this contract cannot price subscription kits.
Stale IDs
Stale component IDs are detected only when Shopify applies the expansion.
Currency
The money model supports only presentment currencies with two decimal places.

Original reference implementation. Not client source code.

Why a Function

The tier price exists before the Function runs. A catalog carrier variant represents each supported module count and frame size. The configurator chooses that variant and attaches the shopper's selected module IDs. The transform reads the carrier price in presentment currency. It does not maintain another tier table.

A theme can display the same total, but it cannot establish components that carry inventory in Shopify's checkout and order model. An application server can manage the catalog contract, but a request to that server is not intrinsic to every cart calculation. A Discount Function can reduce an amount, but it cannot replace a configurable carrier with the merchandise that must be fulfilled. A fixed bundle avoids custom transformation only by predefining every allowed composition.

Cart Transform owns the remaining step on the server: validate the selected composition, expand the existing carrier, and assign component amounts whose sum is exactly the selected catalog price. Shopify documents both lineExpand and its pricing model that uses the parent. The operational constraints are on the plate above.

Design

A metafield controlled by the merchant declares component count and allowed variant IDs, while a line property that the buyer can edit carries the selection. The browser can choose merchandise but cannot authorize it or submit a price. The first pass validates every marked carrier. The second pass creates operations only when the first pass has no failures.

This query shows that the Function receives the selected catalog amount, buyer selection, and merchant definition in the same invocation.

src/input.graphql
query TieredKitInput {
  cart {
    lines {
      id
      quantity
      cost { amountPerQuantity { amount currencyCode } }
      kitSelection: attribute(key: "_grow_kit_selection") { value }
      sellingPlanAllocation { __typename }
      merchandise {
        __typename
        ... on ProductVariant {
          id
          tierDefinition: metafield(
            namespace: "$app:growing-kits"
            key: "tier_definition"
          ) { jsonValue }
        }
      }
    }
  }
}

This parser makes the trust boundary concrete by refusing selections that disagree with merchant configuration.

src/tieredKit.ts
export function parseCarrier(line: CartLine): ParseResult {
  if (line.quantity !== 1) return reject("quantity_unsupported");
  if (line.sellingPlanAllocation) return reject("selling_plan_unsupported");
 
  const definition = parseDefinition(line.tierDefinition?.jsonValue);
  const selection = parseSelection(line.kitSelection?.value);
  if (!definition.ok) return definition;
  if (!selection.ok) return selection;
 
  const componentIds = selection.value.componentVariantIds;
  if (componentIds.length !== definition.value.componentCount)
    return reject("component_count_mismatch");
  const allowed = new Set(definition.value.allowedVariantIds);
  if (componentIds.some((id) => !allowed.has(id)))
    return reject("component_not_allowed");
 
  const totalMinor = parseMinor(line.cost.amountPerQuantity.amount);
  if (totalMinor === null) return reject("invalid_carrier_amount");
  return accept({
    lineId: line.id, totalMinor, componentIds,
    selectionToken: selection.value.selectionToken,
  });
}

For a carrier with three modules priced at USD 119.00, explicit equal allocation produces 39.66, 39.66, and 39.68. If prices are omitted, Shopify weights the same total using each component's standalone catalog price. Values of 30.00, 30.00, and 59.00 would produce the economically natural result at that moment, but later standalone price changes would also change the allocation while the set price remained 119.00. The reference implementation chooses a deterministic equal split for interchangeable modules in one tax class and accepts that it is wrong for modules with different economic weights.

This builder overrides native weighting, assigns every component a fixed amount, and places the complete remainder on the final component.

src/tieredKit.ts
export function pricedComponents(candidate: Candidate): ExpandedCartItem[] {
  const count = BigInt(candidate.componentIds.length);
  const base = candidate.totalMinor / count;
  const remainder = candidate.totalMinor % count;
 
  return candidate.componentIds.map((merchandiseId, index) => {
    const fixedMinor = base
      + (index === candidate.componentIds.length - 1 ? remainder : 0n);
    return {
      merchandiseId,
      quantity: 1,
      attributes: [
        { key: "_selection_token", value: candidate.selectionToken },
        { key: "_position", value: String(index + 1) },
      ],
      price: {
        adjustment: {
          fixedPricePerUnit: { amount: formatMinor(fixedMinor) },
        },
      },
    };
  });
}

This preflight across the complete cart returns before building a single lineExpand when any marked carrier is invalid.

src/tieredKit.ts
export function planCart(
  markedLines: readonly CartLine[],
  parseCarrier: (line: CartLine) => ParseResult,
): Plan {
  const candidates: Candidate[] = [];
 
  for (const line of markedLines) {
    const parsed = parseCarrier(line);
    if (!parsed.ok) {
      return { operations: [], diagnostic: parsed.reason };
    }
    candidates.push(parsed.value);
  }
 
  return {
    operations: candidates.map((candidate) => ({
      lineExpand: {
        cartLineId: candidate.lineId,
        expandedCartItems: pricedComponents(candidate),
      },
    })),
  };
}

What the tests cover

The offline suite checks one through six components, repeated choices, exact quotient and remainder allocation, zero amounts, malformed configuration, untrusted IDs, mismatched counts, unsupported quantities, selling plans, multiple configured kits, query cost, serialized output, and compiled resource limits. Two cases carry the architecture. One valid carrier combined with one invalid carrier produces no operations, making partial application unrepresentable. The pricing boundary test asserts the deliberate equal allocation inside Shopify's output shape.

This test pins the documented wrong answer in the actual lineExpand result and proves that the fixed amounts still conserve the carrier total.

src/tieredKit.test.ts
it("pins equal allocation in the lineExpand result", () => {
  const componentIds = ["71001", "71002", "71003"].map(variantGid);
  const result = planCart(
    [carrierFixture({
      id: "carrierA",
      amount: "119.00",
      componentIds,
    })],
    parseCarrier,
  );
 
  const expand = result.operations[0]?.lineExpand;
  const items = expand?.expandedCartItems ?? [];
  expect(expand?.cartLineId).toBe("carrierA");
  expect(items.map((item) => item.merchandiseId)).toEqual(componentIds);
  expect(items.map(
    (item) => item.price.adjustment.fixedPricePerUnit.amount,
  )).toEqual(["39.66", "39.66", "39.68"]);
  expect(sumMinor(items)).toBe(11_900n);
});

Reading the source

Start with src/input.graphql for the data boundary. Continue with src/tieredKit.ts for validation, fixed component pricing, and the cart wide preflight. Then read src/tieredKit.test.ts, especially the atomic failure and documented allocation cases. src/entry.ts is only the Shopify adapter.

Want to see how this holds up in production?

The case studies are the same problems with a client, a deadline, and a legacy codebase attached.