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.runfunction. - The function:
- Detects bundle lines via attributes:
_rlb_bundle_id,_rlb_bundle_selections,_rlb_bundle_checksum. - Validates an HMAC checksum to prevent tampering.
- Parses JSON selections (variant ID, quantity, price, flags like gifts/compulsory/extras).
- Expands each selection into its own
expandedCartItemwith correct merchandise ID and quantity. - 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:
- Open the bundle in your app dashboard.
- Go to Cart & Checkout settings.
- Toggle Enable Cart Transform on.
- 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:
fixedPricePerUnitfor each item is multiplied by the presentment currency rate.- Results are rounded to 2 decimal places.
- If the rate is
0or 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
fixedPricePerUnitis explicitly set to0.00. - They receive a visible attribute like
Gift: "Free with bundle!"so customers see why they are free. - They are flagged as
isGift: truein 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
fixedPricePerUnitset to0.00by 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,
extrasTotalis distributed onto the first eligible non-gift, non-compulsory item’sfixedPricePerUnit. - The function prefers an item with quantity
1to 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:
bundleImageline attribute set by the widget at add-to-cart time.imageUrlfrom the CartTransform metafield (for backward compatibility).
Only Shopify CDN URLs are accepted for security:
cdn.shopify.comcdn.shopifycdn.net
If the URL is not from these domains, it is ignored.
Troubleshooting
1. Bundle items not merging into one line
- Ensure
enableCartTransformis turned on for the bundle. - Confirm
parentVariantIdis 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
presentmentCurrencyRateis 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.
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