Descuentos por cantidad con una Discount Function de Shopify
Cómo cobro los descuentos por 2 y 3 paquetes con una Discount Function: niveles en un metafield, gana el más alto y el checkout hace la cuenta.
- Shopify Functions
- Descuentos
- Checkout
- Pruebas
Los descuentos por cantidad parecen cosa de front-end: “2 paquetes ahorran 10%, 3 ahorran 20%”, el comprador elige y el botón se actualiza. Lo difícil es que el checkout cobre el precio que el comprador vio, entre por donde entre: la página de producto (PDP), el carrito lateral o un checkout acelerado.
Yo uso una Discount Function. En el lab hay una copia funcionando: los tres archivos de la extensión junto a un selector que imprime lo que devuelve la Function.
Por qué no los trucos en el tema
Antes de Shopify Functions me tocó heredar tres:
- Un script en el tema que aplica un código de descuento al pasar cierta cantidad. Le sirve a cualquiera que conozca el código, y nada lo quita si el comprador vuelve a una unidad en el carrito lateral.
- Variantes ocultas de “2 paquetes” y “3 paquetes” más baratas, hasta que el inventario, las reseñas y los reportes quedan repartidos en tres variantes de un producto.
- Pedidos preliminares (draft orders) con precios personalizados detrás de un app proxy. Exige un backend que no confíe en la cantidad ni el precio que manda el navegador, y saca al comprador del carrito normal.
Los tres ponen la lógica de precios al alcance del navegador. Una Function corre dentro de Shopify, sobre el carrito real, cada vez que cambia. Y es pura: sin red, sin reloj, sin aleatoriedad. Aburrida, justo lo que uno quiere en precios.
El target y el input
Los descuentos de línea y de orden corren en cart.lines.discounts.generate.run, y el input query pide solo lo necesario:
query CartInput {
cart {
lines {
id
quantity
merchandise {
__typename
... on ProductVariant {
product {
breaks: hasAnyTag(tags: ["qty-breaks"])
}
}
}
}
}
discount {
discountClasses
metafield(namespace: "$app", key: "tiers") {
jsonValue
}
}
}
- Una Function no puede leer los tags de un producto, así que
hasAnyTagdevuelve un booleano (aliasbreaks). Merchandising activa un producto con el tagqty-breaks. discountClassesdice qué clases se habilitaron en este descuento. Sin PRODUCT no hay nada que hacer y la Function retorna de inmediato.- Los niveles vienen de un metafield JSON del descuento, leído con
jsonValuepara que lleguen parseados.
Elegir el nivel más alto
export function cartLinesDiscountsGenerateRun(input) {
if (!input.discount.discountClasses.includes(DiscountClass.Product)) {
return { operations: [] };
}
const config = input.discount.metafield?.jsonValue ?? { tiers: [] };
const tiers = [...config.tiers].sort((a, b) => b.min - a.min);
const candidates = input.cart.lines.flatMap((line) => {
const eligible = line.merchandise.__typename === 'ProductVariant' && line.merchandise.product.breaks;
const tier = eligible && tiers.find((t) => line.quantity >= t.min);
if (!tier) return [];
return [{
message: `${tier.percent}% off ${tier.min}+`,
targets: [{ cartLine: { id: line.id } }],
value: { percentage: { value: tier.percent } },
}];
});
if (!candidates.length) return { operations: [] };
return {
operations: [
{ productDiscountsAdd: { candidates, selectionStrategy: ProductDiscountSelectionStrategy.All } },
],
};
}
Ordenar los niveles por min de mayor a menor y tomar el primero que alcanza la cantidad da el más alto sin casos especiales. Cuatro unidades con niveles en 2 y 3 reciben el de 3+, no los dos. Y el JSON del metafield no tiene que estar ordenado.
Si falta el metafield, no se aplica nada: el descuento falla del lado seguro. Prefiero quedarme sin descuento una hora que aplicar uno que nadie configuró.
selectionStrategy: ALL es donde más se equivoca la gente. La Function devuelve un candidato por línea elegible. Con FIRST gana un solo candidato, y un carrito con dos productos con el tag recibe el descuento en uno solo. ALL los aplica todos, sujeto a las reglas de combinación del descuento.
Un trade-off para acordar con la marca: esto cuenta la cantidad por línea. Uno de chocolate y uno de vainilla son dos líneas de una unidad, y ninguna llega al nivel de 2 paquetes. Si los sabores deben sumar, agrupa las cantidades por producto y aplica el descuento a todas sus líneas.
Los niveles en un metafield, no en el código
{ "tiers": [{ "min": 2, "percent": 10 }, { "min": 3, "percent": 20 }] } vive en el descuento, bajo el namespace $app de la app. Subir de 20% a 25% para una promo es editar ese valor en el admin, desde la configuración del descuento de la app, sin deploy. Como $app le pertenece a la app, ni el tema ni otra app pueden reescribir los niveles por accidente.
El tema igual necesita los niveles para el selector, y Liquid no puede leer el metafield del descuento, así que la PDP lee su propia copia de un metafield de producto o de tienda. Dos fuentes de verdad: por eso existe la última sección.
Probar los niveles
Como es pura, se prueba con inputs mínimos:
const input = (quantity) => ({
cart: { lines: [{ id: 'gid://shopify/CartLine/1', quantity,
merchandise: { __typename: 'ProductVariant', product: { breaks: true } } }] },
discount: { discountClasses: ['PRODUCT'],
metafield: { jsonValue: { tiers: [{ min: 2, percent: 10 }, { min: 3, percent: 20 }] } } },
});
describe('quantity breaks', () => {
it('does nothing below the first tier', () => {
expect(cartLinesDiscountsGenerateRun(input(1)).operations).toEqual([]);
});
it('applies the highest tier reached', () => {
const [op] = cartLinesDiscountsGenerateRun(input(4)).operations;
expect(op.productDiscountsAdd.candidates[0].value.percentage.value).toBe(20);
});
});
Agrego un caso de vitest por cada bug que he publicado o atrapado en revisión: un producto sin el tag, un descuento sin la clase PRODUCT, un metafield faltante, niveles en desorden.
La PDP promete, el checkout cumple
El selector del lab calcula el precio en el navegador con la misma regla: descarta el nivel de 0%, ordena de mayor a menor y toma el primero alcanzado. Pero ese número es una etiqueta, nunca una instrucción. El tema no le manda precios ni porcentajes a Shopify: el carrito lleva una variante y una cantidad, y la Function decide con lo que realmente hay.
Cuando la copia del tema y el metafield se desincronicen, y tarde o temprano pasa, el checkout tiene la razón y la PDP es un bug por arreglar, no un reembolso. Nunca confíes en la matemática del navegador.
Conclusiones
- Pon la regla en una Discount Function sobre
cart.lines.discounts.generate.runy los números en un metafield JSON$app, para cambiar niveles sin deploy. - Ordena los niveles de mayor a menor, toma el primero que aplique y usa
selectionStrategy: ALLpara que cada línea elegible reciba su descuento. - Que la PDP muestre el precio, pero nunca lo envíe. Prueba la Function con vitest y trata el checkout como la fuente de verdad.