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_idand_rlb_bundle_instance_id. - Reads tier configuration primarily from each line’s
_rlb_tier_configattribute, 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
excludeFromTierQuantityis enabled. - When cart transform is disabled and
continueSellingis 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 tocost.amountPerQuantityif missing. - Multiplies price by effective quantity.
- Converts tier thresholds (
minAmount,maxAmount) from shop currency to presentment currency usingpresentmentCurrencyRate.
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
bundleSelectionsdata. - Uses
buildExpandedBundleDiscountspath. - Tier quantity/subtotal derived from selections.
- Currency conversion applied to tier thresholds (cart transform already normalized item prices).
- Merged bundles
- Identified by
bundleItemCountwithout a token. - Applies a fixed-amount discount to the merged line to remove the gift value.
- Uses
giftTotalPriceto determine the amount to subtract.
Currency Handling
- Uses
presentmentCurrencyRateto 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
excludeFromTierQuantityandapplyDiscountsettings. - 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