NORTH JS TECH
Skip to content
North Js Tech
Cart & Checkout

Cart Transformation

Cart Transform Overview

The Cart Transform Shopify Function expands a single bundle parent line item into multiple child line items for accurate display, pricing, and fulfillment, while keeping quantity scaling and discount logic consistent.

Core Behavior

  • The bundle is initially added as one parent line item with all selections stored as line item attributes.
  • On every cart load/update, Shopify calls the cart.transform.run function.
  • The function:
  1. Detects bundle lines via attributes: _rlb_bundle_id, _rlb_bundle_selections, _rlb_bundle_checksum.
  2. Validates an HMAC checksum to prevent tampering.
  3. Parses JSON selections (variant ID, quantity, price, flags like gifts/compulsory/extras).
  4. Expands each selection into its own expandedCartItem with correct merchandise ID and quantity.
  5. Applies component pricing using fixedPricePerUnit, converted into the shopper’s presentment currency.

Because quantity is applied to the parent bundle line, changing the bundle quantity scales all child items proportionally (e.g., quantity 2 doubles every component).

Attributes on Expanded Child Items

Each expanded child line carries bundle metadata so the rest of the system can group and identify bundle components:

  • _rlb_bundle_id – Unique bundle identifier.
  • _rlb_bundle_name – Human-readable bundle name.
  • _rlb_bundle_key – Unique key for this specific bundle instance in the cart.
  • _rlb_bundle_qty – Quantity of this item within a single bundle unit.
  • _rlb_tier_config – Tier configuration payload used by the discount function (bypasses metafield size limits).
  • _rlb_bundle_token – HMAC token for discount validation.

These attributes allow downstream logic (discounts, analytics, fulfillment) to understand which items belong together.

Enabling Cart Transform

To enable Cart Transform for a bundle:

  1. Open the bundle in your app dashboard.
  2. Go to Cart & Checkout settings.
  3. Toggle Enable Cart Transform on.
  4. Set a Parent Variant ID.

Parent Variant ID Requirements

When Cart Transform is enabled, parentVariantId is required:

  • It is the placeholder product variant that appears as the single cart line before transformation.
  • It should have a descriptive title (e.g., "Summer Essentials Bundle").
  • It should have a representative image (can be overridden later via bundleImage).
  • It does not need inventory tracking because it is never fulfilled directly.

Currency Conversion

All bundle selection prices are stored in the shop’s base currency. The function converts them to the customer’s presentment currency using presentmentCurrencyRate:

  • fixedPricePerUnit for each item is multiplied by the presentment currency rate.
  • Results are rounded to 2 decimal places.
  • If the rate is 0 or negative, the transform is considered invalid and is skipped (to avoid incorrect pricing).

Gift Items

Gift items (free products from gift tiers) are handled specially:

  • Their fixedPricePerUnit is explicitly set to 0.00.
  • They receive a visible attribute like Gift: "Free with bundle!" so customers see why they are free.
  • They are flagged as isGift: true in the selections data.
  • The zero price must be explicitly set to satisfy Shopify’s "all or none" pricing rule (either all lines have prices or none do).

Compulsory Items

Compulsory (auto-added) items:

  • Are expanded with fixedPricePerUnit set to 0.00 by default.
  • Their cost is baked into the overall bundle price rather than shown as separate line prices.

This keeps the bundle’s advertised price consistent while still listing all components for fulfillment.

Manual Extras

Manual extras are optional add-on products within a bundle:

  • Their combined cost (extrasTotal) is not shown as separate line prices.
  • Instead, extrasTotal is distributed onto the first eligible non-gift, non-compulsory item’s fixedPricePerUnit.
  • The function prefers an item with quantity 1 to minimize rounding issues.

This preserves a clean per-item price display while keeping the cart total accurate.

Bundle Image Handling

The expanded bundle can show a custom image in the cart. The function checks, in order:

  1. bundleImage line attribute set by the widget at add-to-cart time.
  2. imageUrl from the CartTransform metafield (for backward compatibility).

Only Shopify CDN URLs are accepted for security:

  • cdn.shopify.com
  • cdn.shopifycdn.net

If the URL is not from these domains, it is ignored.

Troubleshooting

1. Bundle items not merging into one line

  • Ensure enableCartTransform is turned on for the bundle.
  • Confirm parentVariantId is set and the variant exists in Shopify.
  • Verify the bundle widget app embed is active in the theme.

2. Prices showing incorrectly after transform

  • Confirm shop currency and presentment currency are configured correctly.
  • Ensure presentmentCurrencyRate is being passed by Shopify.
  • Check for expected rounding differences in multi-currency scenarios.

3. Checksum validation failing

  • Usually indicates bundle selections were modified after signing.
  • Clear the cart and re-add the bundle.
  • If it persists, verify the HMAC secret is identical between your app and the Shopify Function.

In summary, Cart Transform converts a single, attribute-rich parent bundle line into fully priced, currency-aware child items, while preserving bundle grouping, gifts, compulsory items, manual extras, and custom imagery for a clean and accurate cart experience.

cart-transform.ts
typescript
1import {2  CartTransformRunInput,3  CartTransformRunResult,4} from "./types";5 6// Pseudo-implementation outline for the Cart Transform function7export function run(input: CartTransformRunInput): CartTransformRunResult {8  const { cart, presentmentCurrencyRate } = input;9 10  // Guard: invalid or missing rate => skip transform11  if (!presentmentCurrencyRate || presentmentCurrencyRate <= 0) {12    return { cart }; // no changes13  }14 15  const expandedLines: typeof cart.lines = [];16 17  for (const line of cart.lines) {18    const attrs = keyByAttribute(line.attributes);19 20    const bundleId = attrs["_rlb_bundle_id"];21    const selectionsJson = attrs["_rlb_bundle_selections"];22    const checksum = attrs["_rlb_bundle_checksum"];23 24    // If not a bundle parent line, just keep as-is25    if (!bundleId || !selectionsJson || !checksum) {26      expandedLines.push(line);27      continue;28    }29 30    // 1) Validate checksum31    if (!isValidChecksum(selectionsJson, checksum)) {32      // Fail-safe: keep original line if tampering suspected33      expandedLines.push(line);34      continue;35    }36 37    // 2) Parse selections38    const selections = JSON.parse(selectionsJson) as Array<{39      variantId: string;40      quantity: number;41      price: string; // base currency42      isGift?: boolean;43      isCompulsory?: boolean;44      isManualExtra?: boolean;45    }>;46 47    // 3) Compute extrasTotal and find target line for extras48    let extrasTotal = 0;49    for (const sel of selections) {50      if (sel.isManualExtra) {51        extrasTotal += Number(sel.price) * sel.quantity;52      }53    }54 55    const nonGiftNonCompulsory = selections.filter(56      (s) => !s.isGift && !s.isCompulsory57    );58 59    const extrasTarget =60      nonGiftNonCompulsory.find((s) => s.quantity === 1) ||61      nonGiftNonCompulsory[0];62 63    // 4) Expand each selection into its own line64    for (const sel of selections) {65      const basePrice = Number(sel.price);66      let fixedPricePerUnit = basePrice;67 68      // Gifts and compulsory items are zero-priced69      if (sel.isGift || sel.isCompulsory) {70        fixedPricePerUnit = 0;71      }72 73      // Apply extrasTotal to the chosen target line74      if (extrasTotal > 0 && extrasTarget && sel === extrasTarget) {75        fixedPricePerUnit += extrasTotal / sel.quantity;76      }77 78      // Convert to presentment currency and round79      const converted = Math.round(80        fixedPricePerUnit * presentmentCurrencyRate * 10081      ) / 100;82 83      const childLine = {84        ...line,85        merchandise: {86          ...line.merchandise,87          id: sel.variantId,88        },89        quantity: sel.quantity * line.quantity, // scale by bundle qty90        attributes: [91          ...line.attributes,92          { key: "_rlb_bundle_id", value: attrs["_rlb_bundle_id"] },93          { key: "_rlb_bundle_name", value: attrs["_rlb_bundle_name"] },94          { key: "_rlb_bundle_key", value: attrs["_rlb_bundle_key"] },95          { key: "_rlb_bundle_qty", value: String(sel.quantity) },96          { key: "_rlb_tier_config", value: attrs["_rlb_tier_config"] },97          { key: "_rlb_bundle_token", value: attrs["_rlb_bundle_token"] },98          // Visible gift label99          ...(sel.isGift100            ? [{ key: "Gift", value: "Free with bundle!" }]101            : []),102        ],103        cost: {104          ...line.cost,105          // Component pricing per unit in presentment currency106          fixedPricePerUnit: {107            amount: converted.toFixed(2),108            currencyCode: cart.buyerIdentity?.presentmentCurrencyCode ??109              cart.cost.totalAmount.currencyCode,110          },111        },112      };113 114      expandedLines.push(childLine);115    }116  }117 118  return {119    cart: {120      ...cart,121      lines: expandedLines,122    },123  };124}125 126function keyByAttribute(127  attrs: Array<{ key: string; value: string }>128): Record<string, string> {129  return attrs.reduce((acc, a) => {130    acc[a.key] = a.value;131    return acc;132  }, {} as Record<string, string>);133}134 135function isValidChecksum(payload: string, checksum: string): boolean {136  // Implementation depends on your HMAC secret and algorithm137  // This is a placeholder to show where validation occurs.138  return true;139}140 

Last updated March 23, 2026

Let's talk