One rule engine, two runtimes: a cart drawer and a Shopify Discount Function that agree

How one dependency-free JavaScript module decides bundle discounts both on our server and inside Shopify's WebAssembly checkout, and how the same fixtures test it in Node and in WASM.

DSDilawar Singh 10 min read 4 views
One rule engine, two runtimes: a cart drawer and a Shopify Discount Function that agree

A cart drawer that says "Add 1 more from Garment Care to unlock 15% off" is making a promise. If the shopper adds the detergent and checkout charges full price, the drawer has misled them. If checkout applies a discount the drawer never mentioned, the merchant has given away margin without getting the larger basket in return. When we built the DEV3 AI Upsell & Cross-Sell Cart Drawer, DEV3's Shopify app for add-on recommendations and bundle offers, this was the problem we spent the most time on. The bundle decision is made in two places that share no process, no network and no language runtime: our app server, which writes the drawer's message, and a Shopify Discount Function, which runs as WebAssembly inside Shopify's checkout. This article explains how one JavaScript module serves both runtimes, how we test it in each, and where the two can still drift apart.

Two places decide whether a bundle is unlocked

The drawer is a theme app embed. When a shopper adds a product, it reads /cart.js and posts the cart lines through the Shopify App Proxy to /apps/upsell/recommend. The app verifies the proxy's HMAC signature, ranks add-on recommendations, and returns a bundle status alongside them: which bundles are unlocked and which is closest to being unlocked.

The discount itself cannot come from the app server. Shopify applies automatic discounts at checkout through Shopify Functions: code compiled to WebAssembly that receives the cart as the result of a GraphQL input query and returns discount operations. A Function has no network access and no database, and it has to finish within Shopify's instruction budget. Whatever decides "this cart qualifies for 15% off" at checkout has to run inside that sandbox.

Writing the rules twice and syncing them by hand would drift: bundle logic has too many edge cases (minimum quantities, collection groups, overlaps, fixed amounts per set, once-per-order limits). So we wrote the rule engine once.

One dependency-free module, imported twice

The engine lives in extensions/bundle-discount/src/bundle.js. It is plain JavaScript with // @ts-check and JSDoc types, so the app's TypeScript build checks it without a compile step the Function toolchain would have to understand. It has no imports. Its header comment states the contract:

// @ts-check
/**
 * Bundle rule engine — pure JS, no dependencies. Shared by the Discount Function (WASM via Javy)
 * and the app server (drawer progress messages), so both always agree on what is unlocked.
 */

The Function's entry point imports it as a sibling file. The app server imports the same file by relative path:

// app/lib/bundles.server.ts
// Shared, dependency-free JS module (also compiled into the function's WASM).
import { bundleProgress, evaluateBundles, parseConfig, sortRules } from "../../extensions/bundle-discount/src/bundle.js";

No published package, no copy step, no version skew: a change to the engine changes the drawer and the checkout in the same commit.

The rules travel as a compact config

Merchants build bundles in the admin from groups. Each group is a set of products or collections with a minimum quantity, for example "the T-shirt (min 1) + any product in Garment Care (min 1), 15% off". The app stores those as rows, but the Function can only read what is attached to its discount. On save, publishBundles turns every active bundle into one compact JSON config and writes it to a metafield on a single automatic app discount named "AI Upsell bundles":

export function buildFunctionConfig(bundles: Parameters<typeof toRule>[0][]) {
  const rules = bundles.map(toRule);
  const collectionIds = [...new Set(rules.flatMap((r) => r.c.filter((c) => c.k === "c").flatMap((c) => c.ids)))];
  const config = { v: 1, collectionIds, rules };
  const json = JSON.stringify(config);
  return { config, json, input: JSON.stringify({ collectionIds }), bytes: Buffer.byteLength(json) };
}

The rule keys are deliberately terse (t for type, val, o for once per order, c for components, q for minimum quantity) because Function input is size-limited. The app refuses to publish a config over 9,000 bytes and tells the merchant to use collections instead of long product lists.

Collection membership needed a second metafield. A Function cannot ask "which collections is this product in?" in general. It can only test membership against IDs supplied to the input query. So the collection IDs go into $app:function-input, which the extension's TOML declares as the source of input-query variables, and the query asks Shopify to check those collections at checkout time (condensed):

query CartInput($collectionIds: [ID!]) {
  presentmentCurrencyRate
  cart {
    lines {
      id
      quantity
      cost { amountPerQuantity { amount } }
      merchandise {
        __typename
        ... on ProductVariant {
          product { id inCollections(ids: $collectionIds) { collectionId isMember } }
        }
      }
    }
  }
  discount {
    discountClasses
    metafield(namespace: "$app", key: "function-configuration") { jsonValue }
  }
}

The algorithm: greedy sets, cheapest units, nothing discounted twice

Both runtimes reduce the cart to the same shape: { id, qty, productId, collections, unitPrice } per line. evaluateBundles then works through the rules in a fixed order, highest priority first, then larger value, then rule ID as a tie-break. For each rule it allocates complete sets one at a time from units that earlier rules have not already used (condensed):

export function evaluateBundles(lines, rules) {
  const remaining = new Map(lines.map((l) => [l.id, Math.max(0, Math.floor(l.qty))]));
  const out = [];
  for (const rule of sortRules(rules)) {
    const used = new Map();
    let sets = 0;
    const maxSets = rule.o ? 1 : MAX_SETS;
    while (sets < maxSets) {
      const allocs = [];
      let ok = true;
      for (const comp of rule.c) {
        const a = take(lines, comp, remaining, comp.q);
        if (!a) { ok = false; break; }
        allocs.push(a);
      }
      if (!ok) {
        // Roll back the partial set.
        for (const a of allocs) for (const [id, n] of a) remaining.set(id, (remaining.get(id) ?? 0) + n);
        break;
      }
      for (const a of allocs) for (const [id, n] of a) used.set(id, (used.get(id) ?? 0) + n);
      sets++;
    }
    if (sets > 0) out.push({ rule, sets, targets: [...used].map(([lineId, qty]) => ({ lineId, qty })) });
  }
  return out;
}

Within a group, take consumes the cheapest matching units first. That is a choice for the merchant: an "any 3 socks" bundle discounts the three cheapest pairs, not the three most expensive. Two properties matter more than speed here. The result is deterministic, because every sort has a final tie-break on ID. And a unit of a cart line is never counted by two bundles, because every rule draws from the same remaining map. The priority-no-double-discount fixture checks this: when the higher-priority shirt + socks bundle takes the shirt, the "any 2 care products" bundle is left with one unit and does not apply.

The greedy approach is not an optimiser: with overlapping bundles it doesn't search for the largest total discount. Priority is the merchant's explicit control over which offer wins, and a result that is easy to explain in a drawer message.

toOperations turns the applications into the Function's output: one productDiscountsAdd operation with a candidate per applied rule, targeting exact line quantities. A percentage applies to the targeted units. A fixed amount is multiplied by the number of complete sets and applied once across them (appliesToEachItem: false), so "$5 off any 3 socks" with six pairs in the cart gives $10.

The drawer needs one more answer: how close is the shopper?

Checkout only cares about what is unlocked. The drawer also needs to say what is missing. bundleProgress takes the applied rules, subtracts the units they used, and for each rule that is still locked counts how many more units each group needs. The server keeps only bundles the shopper has started (touched), picks the one with the fewest missing units, and words it from the group's labels. The proxy test checks the wording end to end:

expect(data.bundles.next.message).toBe("Add 1 more from Garment Care to unlock 15% off");

Because progress is computed from the units left over after evaluateBundles, the drawer cannot advertise a bundle whose units are already committed to a higher-priority bundle. That is the same allocation checkout performs. A bundle the shopper has not started is never advertised. The test passes a cart with an unrelated product and expects { unlocked: [], next: null }.

The Function side: sanitise, convert, delegate

The Function's entry point is short, and the brevity is deliberate. Everything it does beyond adapting the input lives in the shared module (condensed):

export function cartLinesDiscountsGenerateRun(input) {
  if (!input.cart.lines.length) return EMPTY;
  if (!input.discount.discountClasses.includes("PRODUCT")) return EMPTY;
  const config = parseConfig(input.discount.metafield?.jsonValue);
  if (!config.rules.length) return EMPTY;
  const lines = [];
  for (const line of input.cart.lines) {
    const m = line.merchandise;
    if (m.__typename !== "ProductVariant" || !m.product) continue;
    lines.push({
      id: line.id, qty: line.quantity, productId: m.product.id,
      collections: (m.product.inCollections ?? []).filter((c) => c.isMember).map((c) => c.collectionId),
      unitPrice: Number(line.cost.amountPerQuantity.amount),
    });
  }
  // Fixed amounts are configured in the shop currency; convert to the cart's presentment currency.
  const rate = Number(input.presentmentCurrencyRate) || 1;
  const rules = config.rules.map((r) => (r.t === "f" ? { ...r, val: Math.round(r.val * rate * 100) / 100 } : r));
  return { operations: toOperations(evaluateBundles(lines, rules)) };
}

parseConfig drops bad rules instead of throwing, because a Function that throws produces no discount at all. Rules with an unknown type, a non-positive value, a percentage above 100 or an empty group are filtered out and the rest still apply. The admin validates the same constraints before saving, so this is a second line of defence.

Testing one engine in both runtimes

The point of sharing the module is lost if we only test it in Node. The Function extension has seven JSON fixtures in Shopify's function-test-helper format: no lines, no configuration, an order-class-only discount, a product bundle at 15%, an unsatisfied bundle, a collection bundle with a fixed amount under a 1.5 presentment rate, and the priority case above. Each fixture holds an input and a hand-written expected output, and three suites run against them:

  • Node run. The JS entry point runs directly against every fixture.
  • Schema validation. The input query and each fixture's input and output are validated against Shopify's function schema, so a fixture can't describe a cart shape Shopify would never send.
  • WASM run. Under RUN_WASM_TESTS=1, the suite builds the real WebAssembly with shopify app function build and runs each fixture through Shopify's function runner (condensed):
beforeAll(async () => {
  helpers = await import("@shopify/shopify-function-test-helpers");
  await helpers.buildFunction(functionDir);
  info = await helpers.getFunctionInfo(functionDir);
  schema = await helpers.loadSchema(info.schemaPath);
}, 120000);
// for each fixture:
const run = await helpers.runFunction(fixture, info.functionRunnerPath, info.wasmPath, queryPath, info.schemaPath);
expect(run.error).toBeNull();
expect(run.result.output).toEqual(fixture.expectedOutput);

All 22 Function tests pass, including the WASM run. The compiled module is 7.2 kB, and the build works offline once the toolchain is downloaded. The app's main Vitest suite (72 tests, typecheck 0 errors) exercises the same engine functions directly, and tests bundleStatus against a seeded catalog from "Add 1 more" to "unlocked".

Where the two sides can still disagree

Sharing the algorithm removes most of the risk, but the two runtimes still receive different inputs. These are the remaining gaps and what we did about each.

  • Collection membership has two sources. At checkout, inCollections is evaluated live by Shopify. The drawer reads collection IDs from the app's catalog index in Postgres. That index is refreshed by products/* webhooks, debounced per product for 30 seconds, and by a daily full sync. If a merchant moves a product into a bundle collection, the drawer message can lag briefly. Checkout is always correct, because it never uses our index.
  • Fixed amounts are labelled in the shop currency. The drawer formats "$5.00 off" from the stored rule, while the Function converts the amount with presentmentCurrencyRate. The percentage case, the most common one, is unaffected.
  • Rules are cached for 30 seconds in-process. After a merchant saves a bundle, a server instance can keep using the previous rules for up to 30 seconds.
  • The savings figure comes from Shopify. The drawer does not display a discount amount the app computed. It renders the line_level_discount_allocations that /cart.js returns, so the struck-through prices and the "You save" line show what Shopify actually applied. Our engine decides the wording, and Shopify supplies the numbers.
  • Quantity changes refresh the status. Changing a quantity can lock or unlock a bundle without changing which products are in the cart, so the drawer drops its cached recommendation signature after each cart/change.js and asks the server again. Recommendations are cached per product set, but bundle status is recomputed on every request.

Conclusion

When a decision must be made both on a server you control and in a sandbox you don't, put it in one pure module with no I/O, adapt each runtime's input to the same shape, and run the same fixtures in both. Around that engine, the app ranks add-ons with the Google Gemini API (gemini-2.5-flash, about 1.6 s observed, with a rule-based fallback). The app embed loads a 5.2 kB bootstrap that lazy-loads the Shadow DOM UI, and a Web Pixel attributes orders to recommendations. The app is in final QA before submission to the Shopify App Store; real-store testing is the next step. The case study covers the full architecture.

DS

Written by

Dilawar Singh

CTO, DEV3 Technologies · ClinicPro, Inventra & WorkPro

Dilawar Singh is the CTO of DEV3 Technologies. He leads the engineering of DEV3’s own products — ClinicPro, Inventra and WorkPro — which are built, sold and supported by the DEV3 team from Mohali, India.

Keep reading

All articles