Skip to main content

Discount Functions

Discount functions apply custom discounts during checkout — VIP tiering, loyalty rewards, dynamic promotions, free-shipping thresholds, and any rule that goes beyond fixed-code discounts. Each discount your function returns is persisted as its own line so the cart, checkout, order summary, and receipts all render the app-supplied label next to its amount.

How It Works

The function runs at both cart verification (cart/checkout preview) and order placement so the totals you see in the cart match what gets persisted.

Function Manifest

{
  "handle": "my-discounts-app",
  "name": "Smart Discounts",
  "version": "1.0.0",
  "functions": {
    "discount": {
      "handle": "dynamic-discounts",
      "name": "Dynamic Discount Rules",
      "config": {
        "vipDiscount": 15,
        "freeShippingThreshold": 100
      }
    }
  }
}

Input Schema

interface DiscountInput {
  cart: {
    items: CartItem[];
    totalPrice: number;       // Subtotal in display currency (e.g. 199.99)
    itemCount: number;        // Sum of quantities
    currency: string;         // e.g. "INR", "USD"
  };
  customer?: {
    id: string;
    email: string;
  };
  shippingAddress: {
    address1: string;
    address2: string;
    city: string;
    province: string;          // State / province from the address form
    country: string;
    zip: string;               // Postal / ZIP code
  };
  discountCodes: string[];    // Customer-entered codes (couponId/code)
}

interface CartItem {
  id: string;                 // lineItemId — pass back in `lineIds` to scope a discount
  variantId: string;
  productId: string;
  title: string;
  quantity: number;
  price: number;              // Effective unit price (salePrice ?? discountPrice ?? price)
  originalPrice: number;      // Compare-at unit price
}
Prices are in display currency units, not minor units (cents). A price: 19.99 means ₹19.99 / $19.99 — no division required.
shippingAddress fields are empty strings until the customer completes the address step at checkout — always handle the empty case. Once the address is entered, the function re-runs with the full destination (including zip), which enables location-aware logic such as ZIP/district-level sales-tax adjustments for US stores.

Output Schema

Your function returns an array of discount entries. Each entry has the same shape — no separate percentage / fixed_amount types. The kind of discount is set by valueType, and the scope is set by target + targetSelection.
interface DiscountOutput {
  discounts: DiscountEntry[];
}

interface DiscountEntry {
  valueType: 'fixed' | 'percentage';
  value: number;
  //  - fixed:      amount to subtract (display currency, e.g. 10 = ₹10 off)
  //  - percentage: 0–100 (e.g. 15 = 15% off)

  target: 'order' | 'line_item' | 'shipping';
  //  - order:     apply to the line-item subtotal
  //  - line_item: apply only to specific lines (requires lineIds)
  //  - shipping:  reduce shipping + zone shipping

  targetSelection?: 'all' | 'specific';
  //  Only meaningful when target='line_item'. Defaults to 'all'.

  lineIds?: string[];
  //  CartItem.id values when targetSelection='specific'.

  title: string;
  //  REQUIRED. The discount label shown to the customer in cart, checkout,
  //  order summary, and emails. Trimmed to 120 chars. An entry without a
  //  string `title` fails output validation and is silently dropped.

  message?: string;
  //  Optional. When present, overrides `title` as the displayed label.
  //  `title` is still required for the entry to validate.
}
Every entry must include a string title — it’s part of the output contract (title, value, valueType, target). An entry missing title fails validation and the whole function result is dropped (the cart still ships, just with no discount from your function). message is an optional label override, not a substitute for title.

value semantics

valueTypevalue interpretationCap
fixedAmount in display currency (e.g. 10 = ₹10)Capped at the remaining base
percentageWhole percent (e.g. 15 = 15%)Clamped to [0, 100]

target semantics

targetBase reduced
orderFull line-item subtotal
line_itemSubtotal of lines in lineIds (or full subtotal if targetSelection: 'all')
shippingshippingPrice + shippingZonePrice
target: 'order' and target: 'line_item' both surface as appDiscount (and on the persisted order, as an appDiscounts[] entry). target: 'shipping' surfaces as shippingDiscount (and shippingDiscounts[]).

Examples

Percentage off the whole order

function calculateDiscounts(input) {
  return {
    discounts: [{
      valueType: 'percentage',
      value: 15,
      target: 'order',
      title: 'VIP: 15% off'
    }]
  };
}

Fixed amount off

function calculateDiscounts(input) {
  return {
    discounts: [{
      valueType: 'fixed',
      value: 10,
      target: 'order',
      title: 'Loyalty reward: $10 off'
    }]
  };
}

Discount on specific lines

function calculateDiscounts(input) {
  const saleItemIds = input.cart.items
    .filter(i => i.title.toLowerCase().includes('sale'))
    .map(i => i.id);

  if (saleItemIds.length === 0) return { discounts: [] };

  return {
    discounts: [{
      valueType: 'percentage',
      value: 30,
      target: 'line_item',
      targetSelection: 'specific',
      lineIds: saleItemIds,
      title: 'Sale items: 30% off'
    }]
  };
}

Free shipping above a threshold

function calculateDiscounts(input, config) {
  const threshold = config.freeShippingThreshold || 100;
  if (input.cart.totalPrice < threshold) return { discounts: [] };

  return {
    discounts: [{
      valueType: 'percentage',
      value: 100,
      target: 'shipping',
      title: `Free shipping on orders over $${threshold}`
    }]
  };
}

Tiered spending discount

function calculateDiscounts(input) {
  const tiers = [
    { threshold: 200, value: 20, label: 'Spend $200+, save 20%' },
    { threshold: 150, value: 15, label: 'Spend $150+, save 15%' },
    { threshold: 100, value: 10, label: 'Spend $100+, save 10%' }
  ];

  const tier = tiers.find(t => input.cart.totalPrice >= t.threshold);
  if (!tier) return { discounts: [] };

  return {
    discounts: [{
      valueType: 'percentage',
      value: tier.value,
      target: 'order',
      title: tier.label
    }]
  };
}

Multiple discounts in one call

A function can return as many entries as it likes — each renders as a separate row.
function calculateDiscounts(input) {
  const discounts = [];

  if (input.discountCodes.includes('WELCOME10')) {
    discounts.push({
      valueType: 'percentage',
      value: 10,
      target: 'order',
      title: 'WELCOME10: 10% off'
    });
  }

  if (input.cart.totalPrice >= 100) {
    discounts.push({
      valueType: 'percentage',
      value: 100,
      target: 'shipping',
      title: 'Free shipping over $100'
    });
  }

  return { discounts };
}

How the backend uses your output

For every DiscountEntry your function returns, the platform:
  1. Computes entryAmount in display currency, capped by the entry’s base (subtotal, line-set subtotal, or shipping).
  2. Subtracts entryAmount from finalPrice (and proportionally from shippingPrice / shippingZonePrice for shipping discounts).
  3. Appends an entry to additionalFields.appDiscounts[] (or shippingDiscounts[]):
    {
      "appId": "discount-app-handle",
      "message": "VIP: 15% off",
      "amount": 22.50,
      "target": "order",
      "valueType": "percentage",
      "value": 15
    }
    
  4. Updates the summed scalars additionalFields.appDiscount and additionalFields.shippingDiscount for backwards compatibility.
Both the per-entry arrays and the summed scalars are persisted on the order and returned by cart verification so the cart UI, checkout UI, order detail page, and order confirmation emails can all render the same data.

How it renders in the UI

The customer-facing summary renders one row per discount entry, using the exact message you supplied:
Subtotal                          $225.00
Sale items: 30% off               -$13.50
VIP: 15% off                      -$33.75
Free shipping over $100           -$8.00
─────────────────────────────────────────
Total                             $169.75
If a legacy order was placed before per-entry storage existed, the platform falls back to a single summed row labeled “App Discount” / “Shipping Discount” using the scalar appDiscount / shippingDiscount.

Stacking and ordering

  • Multiple discount apps run in install order; their outputs are concatenated.
  • Each entry’s base is independent — a percentage entry doesn’t see earlier reductions. If you need compounding, do it in a single function.
  • target: 'shipping' entries cap at the remaining shipping. Two free- shipping discounts won’t refund shipping twice.
  • appDiscount is surfaced separately from couponDiscount — coupons entered manually by the customer continue to apply in addition to your function output.
Be careful with stacking — a 20% VIP + 30% sale + 10% code can run total discount to 60%. Either exclude sale items via target: 'line_item' with targetSelection: 'specific', or short-circuit when `discountCodes.length
0`.

Best Practices

The title (or message, if you set it) shows up in the cart, checkout, order page, and email receipt. Prefer “VIP: 15% off” over “Discount applied based on tag vip_gold”.
if (!input.customer) return { discounts: [] } keeps unauthenticated carts fast.
value: 15 for 15% is easier to debug than 0.15.
The platform exposes cart-verification + order-placement parity — your function output must produce the same totals on both sides. Run the cart UI in a browser, not just the function’s JSON output, before shipping.
Entries with entryAmount capped to 0 are filtered out — they wouldn’t render anyway.

See Also