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

Discount Application

Rich Landing Bundle – Discount Function Summary

  • Runs on cart.lines.discounts.generate.run, so all bundle discounts are calculated server-side and cannot be altered by customers.
  • Groups cart lines into bundle instances using _rlb_bundle_id and _rlb_bundle_instance_id.
  • Reads tier configuration primarily from each line’s _rlb_tier_config attribute, and secondarily from the discount metafield (subject to Shopify’s ~10KB limit).
  • Determines qualification based on either total quantity or subtotal (amount) of eligible items, then selects the highest applicable tier.
  • Builds product-level discount operations per eligible line and returns them for Shopify to apply.

Tier Configuration Sources

  • Per-line _rlb_tier_config (primary)
  • No size limit, scalable to many bundles.
  • Self-contained on each cart line.
  • Discount metafield (fallback)
  • Used for backward compatibility.
  • Limited to ~10KB, so large stores should rely on per-line attributes.

Tier Basis

  • Quantity-based (tierBasis: "quantity")
  • Counts eligible item quantities per bundle instance.
  • Excludes gift items with giftTierMinQty > 0.
  • Excludes compulsory items from the count when excludeFromTierQuantity is enabled.
  • When cart transform is disabled and continueSelling is off, caps quantities at the bundle’s intended quantity to avoid over-discounting.
  • Amount-based (tierBasis: "amount")
  • Uses subtotal of eligible items in cents.
  • Prefers bundleOriginalPrice; falls back to cost.amountPerQuantity if missing.
  • Multiplies price by effective quantity.
  • Converts tier thresholds (minAmount, maxAmount) from shop currency to presentment currency using presentmentCurrencyRate.

Discount Types

  • Percentage – Percentage off each eligible item (e.g., 15% off $40 → $34).
  • Fixed amount – Fixed value off, converted from shop to presentment currency before applying.
  • Gift – Gift items always receive a 100% discount, independent of the current tier; handled via separate gift discount candidates.
  • None – Tiers with discountType: "none" apply no price discount; useful for tiers that only unlock gifts or other benefits.

Compulsory Item Logic

  • excludeFromTierQuantity: false (default)
  • Compulsory items count toward tier qualification and receive the tier discount.
  • excludeFromTierQuantity: true
  • Compulsory items do not count toward tier quantity/subtotal.
  • applyDiscount: false → no discount on compulsory items.
  • applyDiscount: true → compulsory items still receive the tier discount even though they don’t help qualify.

Example: 3 compulsory items, Tier 2 requires 5 items. With excludeFromTierQuantity: true, the customer must add 5 more items (total 8). If applyDiscount: true, all 8 items get the Tier 2 discount.

Expanded vs. Merged Bundles

  • Expanded bundles (cart transform enabled)
  • Identified by bundleSelections data.
  • Uses buildExpandedBundleDiscounts path.
  • Tier quantity/subtotal derived from selections.
  • Currency conversion applied to tier thresholds (cart transform already normalized item prices).
  • Merged bundles
  • Identified by bundleItemCount without a token.
  • Applies a fixed-amount discount to the merged line to remove the gift value.
  • Uses giftTotalPrice to determine the amount to subtract.

Currency Handling

  • Uses presentmentCurrencyRate to convert between shop and customer currencies.
  • Fixed discount values and amount-based tier thresholds are converted.
  • Percentage discounts are currency-agnostic.

Discount Combination

  • Uses ProductDiscountSelectionStrategy.All, so all qualifying bundle discounts are applied.
  • Bundle discounts are automatic discounts created when bundles are activated.
  • Stacking with other discounts depends on Shopify’s discount combination settings in Settings → Discount combinations.

Troubleshooting

  • No discount at checkout
  • Ensure bundle is active.
  • Confirm cart products match bundle configuration.
  • Verify the discount function is installed under Settings → Checkout → Discount Functions.
  • Wrong discount amount
  • Check which tier is being met (highest matching tier is used).
  • For amount-based tiers, confirm subtotal is pre-discount.
  • In multi-currency, verify currency rate and thresholds.
  • Compulsory items priced incorrectly
  • Re-check excludeFromTierQuantity and applyDiscount settings.
  • Remember the default: compulsory items both count toward tiers and receive discounts.
rich-landing-bundle-discount-function.ts
typescript
1type TierBasis = "quantity" | "amount";2 3type DiscountType = "percentage" | "fixed" | "gift" | "none";4 5type Tier = {6  id: string;7  minQty?: number;8  maxQty?: number;9  minAmount?: number; // in shop currency cents10  maxAmount?: number; // in shop currency cents11  discountType: DiscountType;12  discountValue?: number; // percentage or fixed amount in shop currency13};14 15type LineAttributes = {16  _rlb_bundle_id?: string;17  _rlb_bundle_instance_id?: string;18  _rlb_tier_config?: string; // JSON string of TierConfig19  bundleOriginalPrice?: number; // in cents, presentment currency20  giftTotalPrice?: number; // for merged bundles21};22 23type TierConfig = {24  tierBasis: TierBasis;25  tiers: Tier[];26  excludeFromTierQuantity?: boolean;27  applyDiscount?: boolean;28};29 30function groupLinesByBundle(lines: CartLine[]): Map<string, CartLine[]> {31  const groups = new Map<string, CartLine[]>();32  for (const line of lines) {33    const attrs = line.attributes as LineAttributes;34    const bundleId = attrs._rlb_bundle_id;35    const instanceId = attrs._rlb_bundle_instance_id;36    if (!bundleId || !instanceId) continue;37    const key = `${bundleId}:${instanceId}`;38    if (!groups.has(key)) groups.set(key, []);39    groups.get(key)!.push(line);40  }41  return groups;42}43 44function getTierConfig(line: CartLine, discountMetafield?: string): TierConfig | null {45  const attrs = line.attributes as LineAttributes;46  if (attrs._rlb_tier_config) {47    return JSON.parse(attrs._rlb_tier_config) as TierConfig;48  }49  if (discountMetafield) {50    return JSON.parse(discountMetafield) as TierConfig;51  }52  return null;53}54 55function findApplicableTier(config: TierConfig, basisValue: number): Tier | null {56  const candidates = config.tiers.filter((tier) => {57    if (config.tierBasis === "quantity") {58      const min = tier.minQty ?? 0;59      const max = tier.maxQty ?? Number.MAX_SAFE_INTEGER;60      return basisValue >= min && basisValue <= max;61    } else {62      const min = tier.minAmount ?? 0;63      const max = tier.maxAmount ?? Number.MAX_SAFE_INTEGER;64      return basisValue >= min && basisValue <= max;65    }66  });67  if (!candidates.length) return null;68  // choose highest tier by min threshold69  return candidates.sort((a, b) => {70    const aMin = (config.tierBasis === "quantity" ? a.minQty : a.minAmount) ?? 0;71    const bMin = (config.tierBasis === "quantity" ? b.minQty : b.minAmount) ?? 0;72    return bMin - aMin;73  })[0];74}75 76function buildDiscountForLine(77  line: CartLine,78  tier: Tier,79  presentmentCurrencyRate: number80): ProductDiscount | null {81  const attrs = line.attributes as LineAttributes;82 83  // Gift discount: always 100%84  if (tier.discountType === "gift") {85    return {86      message: "Bundle gift",87      targets: [{ productVariant: { id: line.merchandise.id } }],88      value: { percentage: { value: 100 } },89    };90  }91 92  if (tier.discountType === "none") return null;93 94  if (tier.discountType === "percentage" && tier.discountValue != null) {95    return {96      message: "Bundle discount",97      targets: [{ productVariant: { id: line.merchandise.id } }],98      value: { percentage: { value: tier.discountValue } },99    };100  }101 102  if (tier.discountType === "fixed" && tier.discountValue != null) {103    const fixedInPresentment = Math.round(tier.discountValue * presentmentCurrencyRate);104    return {105      message: "Bundle discount",106      targets: [{ productVariant: { id: line.merchandise.id } }],107      value: { fixedAmount: { amount: fixedInPresentment, appliesToEachItem: true } },108    };109  }110 111  return null;112}113 114// High-level flow inside the Shopify Function handler115function generateBundleDiscounts(input: FunctionInput): FunctionResult {116  const discounts: ProductDiscount[] = [];117  const lines = input.cart.lines;118  const presentmentCurrencyRate = input.presentmentCurrencyRate ?? 1;119 120  const groups = groupLinesByBundle(lines);121 122  for (const [, groupLines] of groups) {123    if (!groupLines.length) continue;124 125    const config = getTierConfig(groupLines[0], input.discountMetafield);126    if (!config) continue;127 128    // Compute basis value (quantity or amount)129    let basisValue = 0;130 131    for (const line of groupLines) {132      const attrs = line.attributes as LineAttributes;133      const quantity = line.quantity;134 135      // Example: skip certain gift or compulsory items from basis136      const isGift = Boolean(line.bundleGiftTierMinQty && line.bundleGiftTierMinQty > 0);137      const isCompulsory = Boolean(line.bundleCompulsory);138 139      if (config.tierBasis === "quantity") {140        if (isGift) continue;141        if (config.excludeFromTierQuantity && isCompulsory) continue;142        basisValue += quantity;143      } else {144        if (isGift) continue;145        if (config.excludeFromTierQuantity && isCompulsory) continue;146        const price = attrs.bundleOriginalPrice ?? line.cost.amountPerQuantity;147        basisValue += price * quantity;148      }149    }150 151    const tier = findApplicableTier(config, basisValue);152    if (!tier) continue;153 154    for (const line of groupLines) {155      const isCompulsory = Boolean(line.bundleCompulsory);156 157      if (config.excludeFromTierQuantity && isCompulsory && config.applyDiscount === false) {158        continue; // no discount for compulsory in this mode159      }160 161      const discount = buildDiscountForLine(line, tier, presentmentCurrencyRate);162      if (discount) discounts.push(discount);163    }164  }165 166  return {167    discounts,168    discountApplicationStrategy: "ALL", // ProductDiscountSelectionStrategy.All169  };170}171 

Last updated March 23, 2026

Let's talk
Discount Application | North Js Tech