Gift qualification using cart relationships
A specialty coffee merchant needs one sample bag to become free only when a paid product at the top level qualifies, without bundle children creating or receiving manual entitlement.
Known limits
- Gift insertion
- The Discount Function cannot add a missing gift line; the shopper or storefront must put an eligible sample in the cart.
- Shopper intent
- When several samples qualify, the Function discounts the cheapest one rather than preserving shopper intent.
- Nested lines
- Nested merchandise can neither qualify nor receive the manual gift discount.
- Product lists
- The lists of allowed qualifier and gift products are capped at 50 product IDs each.
- Combinations
- Discount combinations remain an administrative policy outside this evaluator.
Original reference implementation. Not client source code.
Why a Discount Function
A storefront can place a sample in the cart and preview the offer, but it cannot make an enforceable checkout discount. A Cart Transform could change an amount, yet doing so would move promotion entitlement outside Shopify's discount combinations and reporting. An application backend would add a network boundary even though the rule uses a bounded list of allowed products.
The Discount Function receives current cart costs, product identities, quantities, parent relationships, and configuration controlled by the app inside Shopify's commerce loop. It can emit a fixed amount candidate for exactly one unit. Shopify documents cart line quantity targets, fixed amounts, and selection strategies. The operational constraints are on the plate above.
Design
The query exposes the policy owned by the app and Shopify's real parent relationship without product labels supplied by the buyer.
query RunInput {
cart {
lines {
id
quantity
parentRelationship { parent { id } }
cost { amountPerQuantity { amount } }
merchandise {
__typename
... on ProductVariant {
id
product { id }
}
}
}
}
discount {
configuration: metafield(key: "function-configuration") { jsonValue }
}
triggeringDiscountCode
}Money comparison aligns decimal scales with integers, avoiding binary floating point when targets have different precision.
export function compareMoney(left: Money, right: Money): number {
const scale = Math.max(left.scale, right.scale);
const leftValue = left.coefficient * 10n ** BigInt(scale - left.scale);
const rightValue = right.coefficient * 10n ** BigInt(scale - right.scale);
if (leftValue < rightValue) return -1;
if (leftValue > rightValue) return 1;
return 0;
}
export function byPriceThenLineId(left: PricedLine, right: PricedLine): number {
return compareMoney(left.unitPrice, right.unitPrice)
|| left.id.localeCompare(right.id);
}The evaluator excludes nested lines, reserves the possible gift unit, and then asks whether any paid qualifying unit remains.
export function chooseGift(
lines: readonly CartLine[],
promotion: Promotion,
): CartLine | null {
const topLevel = lines.filter(
(line) => line.parentLineId === null && line.quantity > 0,
);
const targets = topLevel.filter(
(line): line is PricedLine =>
promotion.giftIds.has(line.productId) && isPaidLine(line),
);
const eligible = targets.filter((target) => {
const paidUnits = topLevel.reduce((sum, line) => {
if (!promotion.qualifierIds.has(line.productId) || !isPaidLine(line)) return sum;
const reserved = line.id === target.id ? 1 : 0;
return sum + Math.max(0, line.quantity - reserved);
}, 0);
return paidUnits > 0;
});
return eligible.sort(byPriceThenLineId)[0] ?? null;
}The mapper discounts one unit by its current canonical amount and emits no operation when qualification is uncertain.
export function buildDiscount(
gift: CartLine | null,
promotion: Promotion,
): DiscountOutput {
if (!gift || gift.unitPrice === null) return { operations: [] };
return { operations: [{ productDiscountsAdd: {
selectionStrategy: "FIRST",
candidates: [{
message: promotion.message,
targets: [{ cartLine: { id: gift.id, quantity: 1 } }],
value: { fixedAmount: {
amount: gift.unitPrice.canonical,
appliesToEachItem: false,
} },
}],
} }] };
}What the tests cover
The suite runs without a store and covers malformed configuration, exact code matching, ordinary qualification, one unit targeting, overlapping target and qualifier catalogs, nested exclusions, bundle carriers at the cart root, invalid money, deterministic ties, unrelated merchandise, large carts, query cost, configuration size, and compiled resource limits. The central quantity pair proves that one overlapping unit cannot qualify itself, while quantity two leaves one paid unit that can qualify the other.
This test pins cheapest selection and the exact fixed amount as the documented answer when shopper intent is unavailable.
it("discounts the cheapest eligible sample when intent is unknown", () => {
const gift = chooseGift(
[
line("grinder", "89.00", { qualifier: true }),
line("ethiopia", "14.00", { gift: true }),
line("colombia", "9.00", { gift: true }),
],
promotion,
);
const output = buildDiscount(gift, promotion);
expect(gift?.id).toBe("colombia");
expect(output.operations[0].productDiscountsAdd.candidates[0]).toMatchObject({
targets: [{ cartLine: { id: "colombia", quantity: 1 } }],
value: { fixedAmount: { amount: "9.00", appliesToEachItem: false } },
});
});Reading the source
Start with src/sampleGift.ts for relationship classification, quantity accounting, and candidate construction. Then read src/money.ts and src/sampleGift.test.ts. src/run.graphql and src/run.ts form the Shopify boundary.
Want to see how this holds up in production?
The case studies are the same problems with a client, a deadline, and a legacy codebase attached.