11 minutes de lecture

Réduction personnalisée avec l’API Product Discount de Shopify

Sommaire

La demande client

L’objectif est de développer une promotion appliquant des réductions sur une sélection de bougies dans le panier. Le moteur de promotions standard de Shopify ne permettant pas de répondre à cette exigence, la Product Discount API est utilisée pour une personnalisation avancée.

Le principe de la promotion

Une réduction de 2 € est appliquée pour l’achat de trois bougies, à condition qu’au moins l’une d’elles soit une “bougie du mois”.

L’offre est cumulable : pour six bougies achetées, si deux d’entre elles sont des “bougies du mois”, la réduction passe à 4 €.

Les cas particuliers

Le client indique que certaines situations peuvent complexifier l’application de l’offre. Sans définir de règle générale, il identifie les cas suivants :

  • 3 bougies du mois → réduction de 2 €.
  • 1 bougie normale + 2 bougies du mois → réduction de 2 €.
  • 2 bougies normales + 4 bougies du mois → réduction de 4 €.

Formalisation du problème

Le client parvient à obtenir le bon résultat, mais sans pouvoir expliciter son raisonnement.

Nous structurons donc l’approche en deux étapes :

  • Mathématique : formaliser le problème en une règle générale.
  • Informatique : traduire cette règle en algorithme dans Shopify.

Formule mathématique

Définition des variables

Soit :

  • AA le nombre de bougies achetées.
  • BB le nombre de bougies du mois parmi les bougies achetées.

Règle d’application des réductions

Les réductions s’appliquent par groupe de trois bougies achetées. Chaque groupe permet d’obtenir au maximum une réduction sur une bougie du mois.

  • Si un groupe contient plusieurs bougies du mois, une seule bénéficie de la réduction.
  • Si un groupe ne contient aucune bougie du mois, aucune réduction ne s’applique.

Calcul du nombre de réduction possibles

Le premier critère à considérer est le nombre total de groupes de trois bougies formés. Ce nombre est donné par :

A3\left\lfloor \frac{A}{3} \right\rfloor

Il représente le nombre maximal de réductions possibles, sans encore prendre en compte la présence des bougies du mois.

Formule finale

Le nombre total de réductions ne peut dépasser le nombre de bougies du mois disponibles. Il faut donc comparer le nombre de réductions théoriques avec BB.

Ainsi, la réduction totale appliquée est donnée par la formule suivante

xmin(A3,B)x \cdot \min\left(\left\lfloor \frac{A}{3} \right\rfloor, B\right)

où :

  • xx est le montant de la réduction par groupe en l’occurence 2 €.
  • A3\left\lfloor \dfrac{A}{3} \right\rfloor représente le nombre maximum de groupes de 3 bougies.
  • BB est le nombre de bougies du mois.

Les avantages de l’approche mathématique

  • La solution a une complexité constante soit O(1)O(1).
  • La formule est générale et fonctionne pour tous les cas.
  • La solution est indépendante de tout langage de programmation, ce qui la rend universelle et facilement adaptable.

Implémentation dans Shopify

Créer l’application

Assurez-vous d’avoir installer la CLI Shopify, vous pouvez retrouver les instructions ici.

L’application doit regrouper toutes les extensions et API Functions nécessaires. Pour l’initialiser, utilisez la commande suivante :

shopify app init

Pour des raisons de concision, nous sélectionnons Build an extension-only app

Choisir build an extension-only app

Il s’agit d’une nouvelle application :

Choisir create new app

Définissez un nom pour l’application :

Choisir app name

Si tout se passe correctement, vous verrez ce message de confirmation :

Choisir app name

Accédez au répertoire de votre projet :

cd tutorial

Une fois l’application créée, voici la structure du projet généré :

├── extensions
├── node_modules
├── package.json
├── package-lock.json
├── README.md
├── SECURITY.md
└── shopify.app.toml

Générer l’API Product Discount

Dans le dossier tutoriel, générez l’API Product Discount avec la commande suivante :

shopify app generate extension

Une invite de commande propose une liste d’extensions. Sélectionnez : Discount products - Function.

Choisir Discount products - Function

Ensuite, nommez la Product Discount Function et choisissez un langage de programmation. Pour simplifier l’exemple, nous utilisons JavaScript, mais il est fortement recommandé d’opter pour un langage typé (comme TypeScript ou Rust).

Nommer Discount products -Function

La fonction se trouve dans le dossier extensions de l’application. Pour y accéder, utilisez :

cd extensions

La structure est la suivante :

.
└── product-discount
    ├── generated
    │   └── api.ts
    ├── locales
    │   └── en.default.json
    ├── package.json
    ├── schema.graphql
    ├── shopify.extension.toml
    ├── src
    │   ├── index.js
    │   ├── run.graphql
    │   ├── run.js
    │   └── run.test.js
    └── vite.config.js

La logique métier de la réduction est implémentée dans le fichier src/run.js

Installer l’application sur le store

Saisir la commande

npm run dev

Une fenêtre s’ouvre

Installer application - Function

Présentation de l’API Product Discount

L’API Product Discount permet de créer des réductions appliquées directement aux produits présents dans le panier.

  • Définir le paramètre de la fonction : il contient les informations du panier et des produits.
  • Implémenter la logique métier : déterminer les réductions applicables selon les règles définies.
  • Retourner les promotions appliquées : la fonction renvoie la liste des réductions associées aux produits.

Définition du paramètre de la fonction

Le paramètre de la fonction est définie via GraphQL dans le fichier run.graphql. Par défaut, seules les informations suivantes sont disponibles :

  • L’ID des lignes du panier.
  • Leur quantité associée.
query RunInput {
  cart {
    lines {
      id
      quantity
    }
  }
}

Dans le panier, nous ciblons uniquement les bougies. Il faut donc récupérer les attributs permettant de les identifier :

  • L’ID de la variante : pour appliquer la réduction au bon produit.
  • Le type de produit : ici “Bougie”.
  • Le tag “bougie du mois” : pour distinguer ces bougies des autres.
  • La quantité associée : essentielle pour le calcul des réductions.
query RunInput {
  cart {
    lines {
      quantity
      merchandise {
        ... on ProductVariant {
          id
          product {
            productType
            hasTags(tags: ["bougie du mois"]) {
              hasTag
              tag
            }
          }
        }
      }
    }
  }
  discountNode {
    metafield(
      namespace: "$app:product-discount"
      key: "function-configuration"
    ) {
      value
    }
  }
}

Tous les attributs disponibles peuvent être retrouvés dans le fichier schema.graphql.

Pour générer les types associés à cet appel GraphQL, utilisez la commande suivante :

shopify app function typegen

Définition de la logique métier

Le fichier run.js contient la fonction principale qui applique la logique métier :

const EMPTY_DISCOUNT = {
  discountApplicationStrategy: DiscountApplicationStrategy.First,
  discounts: [],
};

export function run(input) {
  const configuration = JSON.parse(
    input?.discountNode?.metafield?.value ?? "{}",
  );

  return EMPTY_DISCOUNT;
}
  • Pour garder cet article concis, la variable configuration est ignorée.
  • L’approche proposée est volontairement rigide afin de simplifier l’explication.

Le paramètre input est défini dans la section précédente via GraphQL. Il contient :

  • Les lignes du panier
  • La quantité des articles
  • Le type et le nom du produit
  • La présence du tag “bougie du mois”

Pour éviter de surcharger la fonction run, nous regroupons les fonctions auxiliaires dans le fichier src/tools.js :

.
└── product-discount
    ├── generated
    │   └── api.ts
    ├── locales
    │   └── en.default.json
    ├── package.json
    ├── schema.graphql
    ├── shopify.extension.toml
    ├── src
    │   ├── index.js
    │   ├── run.graphql
    │   ├── run.js
    │   ├── run.test.js
    │   └── tools.js
    └── vite.config.js

Récupérer et compter les bougies

Nous avons désormais tous les outils nécessaires pour appliquer notre logique. L’étape suivante consiste à :

  • Récupérer toutes les bougies présentes dans le panier.
  • Compter le nombre de bougies du mois.

Dans le fichier src/tools.js nous définissons les fonctions suivantes :

// Filtre tous les produits du panier pour ne garder que les bougies
export function getCandles(input) {
  return input.cart.lines.filter(
    ({ merchandise: { product } }) => product.productType === "Bougie",
  );
}

// Filtre tous les produits du panier pour ne garder que les bougies du mois
export function getMonthlyCandles(input) {
  const { lines } = input.cart;
  return lines.filter(({ merchandise }) =>
    merchandise.product.hasTags?.some(isMonthlyCandle),
  );
}

// Vérifie si un élément est une "bougie du mois"
export function isMonthlyCandle({ tag, hasTag }) {
  return tag === "bougie du mois" && hasTag;
}

// Additionne les quantités
export function getTotalQuantity(lines) {
  return lines.reduce((acc, line) => acc + line.quantity, 0);
}

Dans le fichier src/run.js, on appelle les fonctions :

const EMPTY_DISCOUNT = {
  discountApplicationStrategy: DiscountApplicationStrategy.First,
  // Un tableau vide signifie l'absence de réduction
  discounts: [],
};

export function run(input) {
  // Montant de la réduction par bougie du mois éligible
  const discount = 2;

  const candles = getCandles(input);
  const monthlyCandles = getMonthlyCandles(input);
  const nbCandles = getTotalQuantity(candles);
  const nbMonthlyCandles = getTotalQuantity(monthlyCandles);

  return EMPTY_DISCOUNT;
}

Appliquer la formule mathématique

Dans tools.js, nous définissons la formule mathématique :

// Calcule la réduction totale
export function getTotalDiscount(discount, nbCandles, nbMonthlyCandles) {
  const groupSize = 3; // Réduction appliquée par groupe de 3 bougies
  const discountByGroup = Math.floor(nbCandles / groupSize);
  const discountedMonthlyCandles = Math.min(discountByGroup, nbMonthlyCandles);
  return discount * discountedMonthlyCandles;
}

Dans run.js, on appelle la fonction :

const EMPTY_DISCOUNT = {
  discountApplicationStrategy: DiscountApplicationStrategy.First,
  // Un tableau vide signifie l'absence de réduction
  discounts: [],
};

export function run(input) {
  // Montant de la réduction par bougie du mois éligible
  const discount = 2;

  const candles = getCandles(input);
  const monthlyCandles = getMonthlyCandles(input);
  const nbCandles = getTotalQuantity(candles);
  const nbMonthlyCandles = getTotalQuantity(monthlyCandles);
  const totalDiscount = getTotalDiscount(discount, nbCandles, nbMonthlyCandles);

  return EMPTY_DISCOUNT;
}

Attribuer les réductions par ligne

Si totalDiscount est égal à zéro, aucune réduction ne s’applique :

const EMPTY_DISCOUNT = {
  discountApplicationStrategy: DiscountApplicationStrategy.First,
  // Un tableau vide signifie l'absence de réduction
  discounts: [],
};

export function run(input) {
  // Montant de la réduction par bougie du mois éligible
  const discount = 2;

  const candles = getCandles(input);
  const monthlyCandles = getMonthlyCandles(input);
  const nbCandles = getTotalQuantity(candles);
  const nbMonthlyCandles = getTotalQuantity(monthlyCandles);
  const totalDiscount = getTotalDiscount(discount, nbCandles, nbMonthlyCandles);

  if (totalDiscount === 0) {
    return EMPTY_DISCOUNT;
  }
}

Les réductions sont appliquées aux bougies du mois éligibles. La distribution des remises est gérée dans la fonction applyDiscounts, définie dans tools.js :

// Distribue la reduction par ligne dans le panier
export function applyDiscounts(discount, totalDiscount, candles) {
  const discounts = [];
  let totalApplied = 0;
  let index = 0;
  while (totalApplied < totalDiscount && index < candles.length) {
    const candle = candles[index];
    const allocated = allocateDiscount(discount, totalDiscount, candle);
    const candleDiscount = createDiscount(candle, allocated);

    discounts.push(candleDiscount);

    totalApplied += allocated;
    index++;
  }

  return discounts;
}

export function allocateDiscount(discount, totalDiscount, candle) {
  const allocatedDiscount = discount * candle.quantity;
  return Math.min(allocatedDiscount, totalDiscount);
}

export function createDiscount(candle, discount) {
  return {
    value: {
      fixedAmount: {
        amount: discount,
      },
    },
    targets: [
      {
        productVariant: {
          id: candle.merchandise.id,
        },
      },
    ],
    message: "Offre Bougie du mois",
  };
}

Il ne reste plus qu’à appeler applyDiscounts dans la fonction run et retourner les réductions appliquées :

export function run(input) {
  // Montant de la réduction par bougie du mois éligible
  const discount = 2;

  const candles = getCandles(input);
  const monthlyCandles = getMonthlyCandles(input);
  const nbCandles = getTotalQuantity(candles);
  const nbMonthlyCandles = getTotalQuantity(monthlyCandles);
  const totalDiscount = getTotalDiscount(discount, nbCandles, nbMonthlyCandles);

  if (totalDiscount === 0) {
    return EMPTY_DISCOUNT;
  }

  return {
    discountApplicationStrategy: DiscountApplicationStrategy.First,
    discounts: applyDiscounts(discount, totalDiscount, monthlyCandles),
  };
}

Il ne reste plus qu’à déployer la fonction

npm run deploy

Aller plus loin

Testing de l’application

Une étape essentielle consiste à intégrer des tests unitaires dans run.test.js afin de valider le bon fonctionnement de la logique métier. Cela permet de s’assurer que les réductions sont correctement appliquées et de prévenir toute régression.

Rendre la logique plus adaptable à l’évolution métier

L’approche actuelle est rigide et ne prend pas en compte les évolutions possibles de la logique métier. Plusieurs éléments sont actuellement fixes :

  • Le montant de la réduction (actuellement fixé à 2 €).
  • Le nombre d’articles nécessaires pour qu’une réduction s’applique (groupe de 3 bougies).
  • Type de produit ciblé (“Bougie”).
  • Tag recherché (“bougie du mois”).

Pour une approche plus flexible, ces paramètres devraient être externalisés dans la configuration de l’application afin d’être modifiables sans toucher au code source.

Vous pouvez retrouvez toutes les informations nécessaires sur cette page.

Proposer une interface utilisateur

Lors de la création de l’application, l’option Build a Remix app aurait dû être privilégiée.

Obtenir le projet

Le code est source du projet est disponible dans ce repo.