Skip to main content

Cart Transform Functions

Cart transform functions modify per-line prices and titles inside the cart — volume pricing, bundle discounts, expand-on-fulfillment bundles, VIP-tier prices, anything that needs to change what a customer sees on a specific line. The platform records each negative delta as its own cartTransformDiscounts[] entry on the persisted order, so cart, checkout, order page, and email receipts all render one line per transform op with the title you supplied.
Bundles live here. LaunchMyStore does not expose a dedicated productBundleCreate REST resource — bundles are authored as cart-transform merge ops (see Bundle pricing below). Apps that need fixed-price kits, mix-and-match sets, or expand-on-fulfillment bundles should ship a cart_transform function and treat each merchant-configured bundle as configuration passed to that function. Each bundle is expressed as a merge op that collapses child lines into a single bundle total at cart time, leaving the underlying lines on the order for refund and reporting.

How It Works

Runs in both cart verification and order placement so the preview matches the persisted order exactly. Don’t fold the savings into price yourself — the platform reports raw price plus a separate cartTransformDiscount line.

Function Manifest

{
  "handle": "my-cart-app",
  "name": "Cart Transformer",
  "version": "1.0.0",
  "functions": {
    "cart_transform": {
      "handle": "cart-rules",
      "name": "Cart Transformation Rules",
      "config": {
        "vipTier": "gold",
        "vipDiscount": 0.15
      }
    }
  }
}

Input Schema

interface CartTransformInput {
  cart: {
    items: CartItem[];
    totalPrice: number;       // Subtotal in display currency
    itemCount: number;        // Sum of quantities
    currency: string;
  };
}

interface CartItem {
  id: string;                 // lineItemId — use as `lineId` in ops
  variantId: string;
  productId: string;
  title: string;
  quantity: number;
  price: number;              // Effective unit price (discountPrice ?? salePrice ?? price)
  originalPrice: number;      // Compare-at unit price
}
Prices are in display currency units, not minor units (cents). price: 19.99 means ₹19.99 / $19.99.

Output Schema

A cart transform function returns operations. Three op kinds are supported: update, merge, and expand. Each op may carry a title that becomes the customer-facing label on the resulting discount row.
interface CartTransformOutput {
  operations: CartOperation[];
}

type CartOperation =
  | {
      update: {
        lineId: string;       // CartItem.id
        price?: number;       // New unit price (display currency)
        title?: string;       // Override line title and the discount-row label
      };
    }
  | {
      merge: {
        childLineIds: string[]; // Lines to collapse
        price: number;          // New BUNDLE TOTAL (display currency, not per-unit)
        title?: string;         // Discount-row label
      };
    }
  | {
      expand: {
        lineId: string;
        expandedItems: Array<{
          price?: number;       // Unit price for expanded item (display currency)
          quantity: number;
        }>;
        title?: string;         // Discount-row label
      };
    };
The platform does not support add, remove, or split ops in cart transforms. To add a free gift line, return a merge op that includes the gift as a child line, or use a discount function with target: 'line_item'. To prevent an item from being purchased, use an order_validation function.

Operations

update — change unit price / title for a line

The most common op. Use for VIP pricing, volume breaks, member-only prices.
{
  operations: [{
    update: {
      lineId: 'line-0',
      price: 19.99,
      title: 'T-Shirt (VIP price)'
    }
  }]
}
  • price is the new unit price. Per-line savings = (oldUnit - newUnit) * quantity.
  • title (optional) overrides the line title and the discount-row label. If omitted, the label falls back to "Bundle Discount".

merge — collapse N lines into one bundle total

Use for “buy these three together for $X” bundles. The child lines stay in the order (for refund/reporting), but the bundle savings get a single discount row.
{
  operations: [{
    merge: {
      childLineIds: ['line-0', 'line-1', 'line-2'],
      price: 89.99,           // BUNDLE TOTAL, not per-child
      title: 'Complete Outfit Bundle (25% off)'
    }
  }]
}
  • price is the total for the bundle — savings = sum(oldLineTotals) - price.
  • The savings appear as a single cartTransformDiscounts[] entry with your title as the label.

expand — replace one line with multiple

Use for mystery boxes, multi-pack splits, or any case where one purchased line needs to render as several line items.
{
  operations: [{
    expand: {
      lineId: 'line-mysterybox',
      expandedItems: [
        { price: 0, quantity: 1 },
        { price: 0, quantity: 1 },
        { price: 0, quantity: 1 }
      ],
      title: 'Mystery Box Reveal (-$45.00 off)'
    }
  }]
}
  • Each expandedItems[i] may set its own price (defaults to the original line’s unit price).
  • Total savings = originalLineTotal - sum(expandedItems[i].price * expandedItems[i].quantity).

Example Implementations

Volume / quantity-break pricing

function transformCart(input) {
  const ops = [];
  const breaks = {
    'prod_tshirt': [
      { minQty: 12, priceEach: 17.99 },
      { minQty: 6,  priceEach: 19.99 },
      { minQty: 3,  priceEach: 21.99 }
    ]
  };

  for (const item of input.cart.items) {
    const tiers = breaks[item.productId];
    if (!tiers) continue;
    const tier = tiers.find(t => item.quantity >= t.minQty);
    if (!tier || tier.priceEach >= item.price) continue;

    ops.push({
      update: {
        lineId: item.id,
        price: tier.priceEach,
        title: `${item.title} (Bulk x${item.quantity} — $${tier.priceEach} each)`
      }
    });
  }
  return { operations: ops };
}

Bundle pricing (merge)

function transformCart(input) {
  const required = ['prod_cleanser', 'prod_toner', 'prod_moisturizer'];
  const matching = input.cart.items.filter(i => required.includes(i.productId));

  const hasAll = required.every(pid =>
    matching.some(i => i.productId === pid)
  );
  if (!hasAll) return { operations: [] };

  const originalTotal = matching.reduce(
    (sum, i) => sum + (i.price * i.quantity), 0
  );
  const bundlePrice = +(originalTotal * 0.80).toFixed(2);  // 20% off

  return {
    operations: [{
      merge: {
        childLineIds: matching.map(i => i.id),
        price: bundlePrice,
        title: 'Complete Skincare Set (20% off bundle)'
      }
    }]
  };
}

Mystery box (expand)

function transformCart(input) {
  const ops = [];
  for (const item of input.cart.items) {
    if (item.productId !== 'prod_mystery_box') continue;
    ops.push({
      expand: {
        lineId: item.id,
        expandedItems: [
          { price: 0, quantity: 1 },
          { price: 0, quantity: 1 },
          { price: 0, quantity: 1 }
        ],
        title: `Mystery Box (3 items revealed)`
      }
    });
  }
  return { operations: ops };
}

How the backend uses your output

For every op that yields a negative delta (lower price than original), the platform:
  1. Mutates the per-line price/title on the order so receipts and refunds reflect the bundle.
  2. Appends an entry to additionalFields.cartTransformDiscounts[]:
    {
      "appId": "my-cart-app",
      "kind": "update",
      "title": "T-Shirt (Bulk x6 — $19.99 each)",
      "amount": 30.00
    }
    
  3. Sums all op deltas into the scalar additionalFields.cartTransformDiscount for backwards compatibility.
  4. Returns both shapes from cart verification.
A positive delta (a price increase) is allowed but is not recorded as a discount entry; the per-line price is still mutated.

How it renders in the UI

The customer-facing summary renders one row per op, using the title you supplied:
Subtotal                                        $225.00
Complete Outfit Bundle (25% off)                -$30.00
Mystery Box Reveal (-$45.00 off)                -$45.00
─────────────────────────────────────────────────────
Total                                           $150.00
Legacy orders (placed before per-entry storage) fall back to a single summed row labeled “Bundle Discount” using the scalar additionalFields.cartTransformDiscount.

Best Practices

The title becomes the discount-row label. Without it, the customer sees a generic “Bundle Discount” row — fine for prototypes, vague in production.
update ops re-run on every cart change. Always derive the new price from originalPrice (or the static config), not from the current price, so re-running doesn’t compound discounts.
Return the new unit price; the platform handles the bag total + separate discount row. If your function discounts and rewrites payload.price itself, the line total ends up doubly discounted.
merge.price is the bundle total, not per-child. The platform subtracts the sum of child line totals from this number.
Cart transforms run at both cart verification and order placement — they must produce identical totals on both sides. Compare the cart preview against the placed order’s totals after every change.

See Also