[object Object]

← back to Dw Validator Debug TK11314

make free sample entitlement reliable

2ecca051eb77f787345bf17ef663865030f41e11 · 2026-08-28 00:31:22 -0700 · Steve

Files touched

Diff

commit 2ecca051eb77f787345bf17ef663865030f41e11
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 28 00:31:22 2026 -0700

    make free sample entitlement reliable
---
 .../staged/free-samples-banner/banner-block.liquid | 11 ++-
 shopify/staged/free-samples-function/DEPLOY.md     | 39 +++++++--
 shopify/staged/free-samples-function/README.md     | 49 +++++-------
 .../extensions/free-samples-discount/package.json  |  2 +-
 .../free-samples-discount/src/run.graphql          | 20 +++--
 .../extensions/free-samples-discount/src/run.js    | 35 ++++----
 .../free-samples-discount/src/run.test.js          | 37 ++++++---
 .../staged/free-samples-function/package-lock.json | 13 +++
 shopify/staged/free-samples-function/package.json  |  4 +-
 .../staged/free-samples-function/server/index.js   | 50 ++++++++++++
 .../free-samples-function/server/order-paid.js     | 93 ++++++++++++++++++++++
 .../server/order-paid.test.js                      | 67 ++++++++++++++++
 .../staged/free-samples-function/shopify.app.toml  |  8 +-
 .../snippets/product-form-content.liquid           | 10 +--
 14 files changed, 351 insertions(+), 87 deletions(-)

diff --git a/shopify/staged/free-samples-banner/banner-block.liquid b/shopify/staged/free-samples-banner/banner-block.liquid
index 27086f21..7ad759b3 100644
--- a/shopify/staged/free-samples-banner/banner-block.liquid
+++ b/shopify/staged/free-samples-banner/banner-block.liquid
@@ -1,7 +1,10 @@
 {%- comment -%} DW-SAMPLES-BANNER v1 — staged free-samples announcement (dismissible, trade-aware). NEVER touches live unless this theme is published. {%- endcomment -%}
 <!-- DW-SAMPLES-BANNER v1 -->
 {%- assign dw_is_trade = false -%}
-{%- if customer and customer.tags contains 'trade' -%}{%- assign dw_is_trade = true -%}{%- endif -%}
+{%- if customer and customer.tags contains 'trade_approved' -%}{%- assign dw_is_trade = true -%}{%- endif -%}
+{%- assign dw_samples_used = customer.metafields.custom.free_samples_used.value | default: 0 | plus: 0 -%}
+{%- assign dw_samples_remaining = 3 | minus: dw_samples_used -%}
+{%- if dw_samples_remaining < 0 -%}{%- assign dw_samples_remaining = 0 -%}{%- endif -%}
 <style>
   .dw-sample-banner{position:relative;z-index:60;background:#1a1714;color:#faf7f2;
     font-family:-apple-system,'Inter',system-ui,sans-serif;font-size:13px;line-height:1.4;
@@ -31,15 +34,15 @@
   {%- elsif customer -%}
     {%- comment -%} signed-in non-trade: offer is already unlocked, auto-applies at checkout {%- endcomment -%}
     <span class="dw-sb-lead">Welcome</span>
-    <span class="dw-sb-msg">Your first 3 samples are free &mdash; added at checkout, no code needed. <a class="dw-sb-cta" href="/collections/all">Order your free samples &rsaquo;</a> <span class="dw-sb-sub">Designers &amp; trade? <a class="dw-sb-cta" href="/account">Unlock unlimited &rsaquo;</a></span></span>
+    <span class="dw-sb-msg">You have {{ dw_samples_remaining }} of 3 complimentary samples remaining &mdash; applied automatically at checkout. <a class="dw-sb-cta" href="/collections/all">Choose samples &rsaquo;</a> <span class="dw-sb-sub">Design professionals? <a class="dw-sb-cta" href="/pages/trade-only-benefits">Apply for unlimited samples &rsaquo;</a></span></span>
   {%- else -%}
     {%- comment -%} anonymous: signing up IS the unlock (Function auto-applies on login) {%- endcomment -%}
     <span class="dw-sb-lead">Welcome</span>
-    <span class="dw-sb-msg"><a class="dw-sb-cta" href="/account/register">Sign up to get your 3 free samples &rsaquo;</a> <span class="dw-sb-sub">Designers &amp; trade get unlimited free samples.</span></span>
+    <span class="dw-sb-msg"><a class="dw-sb-cta" href="/account/login?return_url=/collections/all">Sign in or create an account for 3 complimentary samples &rsaquo;</a> <span class="dw-sb-sub">Approved design professionals receive unlimited complimentary samples.</span></span>
   {%- endif -%}
   <button class="dw-sb-x" type="button" aria-label="Dismiss" onclick="(function(b){try{localStorage.setItem('dw_sample_banner_dismissed','1');}catch(e){}b.parentNode.classList.add('dw-sb-hidden');})(this)">&times;</button>
 </div>
 <script>
   (function(){try{if(localStorage.getItem('dw_sample_banner_dismissed')==='1'){var b=document.getElementById('dwSampleBanner');if(b)b.classList.add('dw-sb-hidden');}}catch(e){}})();
 </script>
-<!-- /DW-SAMPLES-BANNER -->
\ No newline at end of file
+<!-- /DW-SAMPLES-BANNER -->
diff --git a/shopify/staged/free-samples-function/DEPLOY.md b/shopify/staged/free-samples-function/DEPLOY.md
index 28087a2a..5dbb2d33 100644
--- a/shopify/staged/free-samples-function/DEPLOY.md
+++ b/shopify/staged/free-samples-function/DEPLOY.md
@@ -4,7 +4,7 @@
 > `shopify app deploy` uploads the Function to the live store's app; creating the
 > discount object activates it on real carts. Order matters.
 
-Store: `designer-laboratory-sandbox.myshopify.com` · API: 2024-10
+Store: `designer-laboratory-sandbox.myshopify.com` · Function API: 2025-10 · Admin/Webhooks: 2026-07
 Run from: `~/Projects/Designer-Wallcoverings/shopify/staged/free-samples-function`
 
 ---
@@ -19,10 +19,12 @@ shopify app config link        # pick the DW Partner org + designer-laboratory-s
 
 ## Step 1 — install the build toolchain + run tests (local, $0)
 ```sh
+cd ~/Projects/Designer-Wallcoverings/shopify/staged/free-samples-function
+npm install
+npm test                        # expect all Function + ledger tests to pass
 cd extensions/free-samples-discount
 npm install
-node --test                    # expect: 8 pass / 0 fail
-shopify app function typegen    # regenerates ../generated/api types from run.graphql
+shopify app function typegen    # regenerate generated/api types from run.graphql
 cd ../..
 ```
 
@@ -58,7 +60,7 @@ mutation {
     functionId: "REPLACE_WITH_FUNCTION_ID",
     startsAt: "2099-01-01T00:00:00Z",   # future = inactive until go-live
     combinesWith: { orderDiscounts: true, productDiscounts: true, shippingDiscounts: true }
-    # metafields: optional $app:free-samples/config JSON to override freePerOrder/tradeTag
+    # metafields: optional $app:free-samples/config JSON to override freeLifetime
   }) {
     automaticAppDiscount { discountId title status }
     userErrors { field message }
@@ -66,9 +68,27 @@ mutation {
 }
 ```
 Token: `SHOPIFY_DRAFT_TOKEN` (has `write_discounts`). Endpoint:
-`https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json`.
+`https://designer-laboratory-sandbox.myshopify.com/admin/api/2026-07/graphql.json`.
+
+## Step 6 — deploy and verify the lifetime ledger service
+
+Before app deployment, replace the placeholder `application_url` and OAuth redirect in
+`shopify.app.toml` with the ledger's real HTTPS base URL. Run `npm run serve` behind the
+DW HTTPS reverse proxy with these environment variables:
+
+```sh
+SHOPIFY_API_SECRET=...
+SHOPIFY_STORE_DOMAIN=designer-laboratory-sandbox.myshopify.com
+SHOPIFY_ADMIN_ACCESS_TOKEN=...
+SHOPIFY_API_VERSION=2026-07
+PORT=3000
+```
+
+The app subscription sends `orders/paid` to `/webhooks/orders-paid`. Confirm
+`GET /healthz` returns 200, then send a signed Shopify test delivery and confirm it returns
+200. Do not activate the discount unless both checks pass.
 
-## Step 6 — GO LIVE: flip the discount active
+## Step 7 — GO LIVE: flip the discount active
 ```graphql
 mutation {
   discountAutomaticAppUpdate(
@@ -80,8 +100,11 @@ mutation {
 
 ## VERIFY (after go-live)
 1. Anonymous cart with a sample line -> NO discount at checkout.
-2. Sign up a fresh non-trade test account, add 4 sample lines -> first 3 free, 4th paid.
-3. Log in as a `trade` test customer, add 5 samples -> all 5 free.
+2. Fresh retail account, add 4 sample units -> first 3 free, 4th paid.
+3. Complete that order -> customer `custom.free_samples_used` becomes 3.
+4. Place another retail order -> no sample discount.
+5. Log in as a `trade_approved` test customer, add 5 samples -> all 5 free.
+6. A `trade_pending`, `designer`, or legacy `trade` tag alone -> retail rules only.
 
 ## ROLLBACK
 - Deactivate: `discountAutomaticAppUpdate` with `startsAt` in the future, OR
diff --git a/shopify/staged/free-samples-function/README.md b/shopify/staged/free-samples-function/README.md
index cee915be..71479469 100644
--- a/shopify/staged/free-samples-function/README.md
+++ b/shopify/staged/free-samples-function/README.md
@@ -9,22 +9,21 @@ Frictionless, account-gated free-samples offer for designer-laboratory-sandbox.
 | Buyer | Outcome | Mechanism |
 |---|---|---|
 | **Anonymous** (not signed in) | **No discount** — must register to unlock | Function returns `{operations:[]}` when `cart.buyerIdentity.customer` is null |
-| **Signed-in non-trade** | **First 3 sample UNITS free** this order, no code | 100% off whole sample lines + partial `fixedAmount` on the line that straddles the cap |
-| **Trade** (`tag:trade`) | **Every sample line free, unlimited** | 100% off all sample lines |
+| **Signed-in retail** | **First 3 sample UNITS free for the life of the account**, no code | Function subtracts `custom.free_samples_used`; order-paid ledger increments it idempotently |
+| **Approved trade** (`tag:trade_approved`) | **Every sample line free, unlimited** | 100% off all sample lines |
 
 The act of **signing up is the unlock** (DTD verdict A, 2/2): the moment a non-trade
-customer is logged in, their first 3 sample lines are auto-zeroed at checkout. No code
+customer is logged in, up to their first 3 lifetime sample units are auto-zeroed at checkout. No code
 typed, no email sent, no gift card.
 
 ## Sample identification
-- PRIMARY: variant title == `"Sample"` (case-insensitive). Every DW sample variant is
-  `{DW_SKU}-Sample` @ $4.25 with variant title "Sample". SKU-suffix casing drift
-  (`-Sample`/`-sample`/`-SAMPLE`) is irrelevant — we match on the variant *title*.
+- PRIMARY: variant title contains the word `Sample` or the SKU ends in `-Sample`
+  (both case-insensitive). This covers the current DW variant-title and SKU conventions.
 - OPTIONAL: product metafield `custom.is_sample == "true"` (also honored). Use this if
   you ever have a sample that isn't titled "Sample".
 
 ## API
-- 2024-10 unified **Cart & Checkout Discount** Function.
+- 2025-10 unified **Cart & Checkout Discount** Function.
 - Target: `cart.lines.discounts.generate.run`, export `run`.
 - Returns `productDiscountsAdd` with `selectionStrategy: ALL` + candidates.
 - Pure JS, compiled to WASM by Javy via `shopify app function build` — no Rust toolchain.
@@ -33,37 +32,33 @@ typed, no email sent, no gift card.
 The discount that points at this Function carries a metafield
 `$app:free-samples/config` (JSON), letting admin tune behavior with no redeploy:
 ```json
-{ "freePerOrder": 3, "tradeTag": "trade" }
+{ "freeLifetime": 3 }
 ```
-Defaults baked in (`freePerOrder: 3`, `tradeTag: "trade"`) if the metafield is absent.
+The default is 3 if the metafield is absent. Trade authorization is deliberately not
+configurable: only the exact `trade_approved` tag grants an unlimited entitlement.
 
-## KNOWN LIMITATION — lifetime cap (read this)
-A Shopify Function sees **one cart at a time**. It therefore enforces **"3 free per
-ORDER"**, NOT **"3 free ever"**. A signed-in non-trade customer could place multiple
-orders and get 3 free samples each time. This is acceptable for v1 because:
-1. the account gate already filters drive-by/anonymous abuse, and
-2. $4.25 sample chips are low-stakes.
+## Lifetime ledger
 
-### v2 lifetime-cap upgrade (documented, not built)
-To enforce a true per-customer lifetime cap of 3:
-1. Add customer metafield `custom.free_samples_used` (number_integer, default 0).
-2. Add it to `run.graphql` input: `cart.buyerIdentity.customer.metafield(namespace:"custom", key:"free_samples_used")`.
-3. In `run.js`, compute `remaining = max(0, freeLifetime - used)` instead of a flat per-order count.
-4. Add a post-purchase automation (Shopify Flow on `orders/create`, OR a webhook into a
-   small DW service) that increments `custom.free_samples_used` by the number of sample
-   units that were discounted on that order. (The increment must be server-side — a
-   Function cannot write metafields.)
-This v2 is a separate gated build (needs `write_customers` + a Flow or webhook worker).
+`server/index.js` accepts the signed `orders/paid` webhook. It counts only sample units
+discounted by `DW Free Samples (auto)`, increments the customer's
+`custom.free_samples_used` metafield, caps it at 3, and marks the order with
+`custom.free_samples_counted=true`. Duplicate webhook deliveries are ignored.
+The ledger uses Shopify metafield compare-digests, so simultaneous orders cannot silently
+overwrite a newer lifetime count; a conflict returns an error and Shopify retries the webhook.
+The service exposes `GET /healthz` for reverse-proxy monitoring and refuses to start when
+any required Shopify credential is missing.
 
 ## Files
 - `shopify.app.toml` — app config (client_id filled at `config link` time).
 - `extensions/free-samples-discount/shopify.extension.toml` — function extension config.
 - `extensions/free-samples-discount/src/run.graphql` — input query.
 - `extensions/free-samples-discount/src/run.js` — the discount logic.
-- `extensions/free-samples-discount/src/run.test.js` — 8 unit tests (all pass, `node --test`).
+- `extensions/free-samples-discount/src/run.test.js` — checkout entitlement tests.
+- `server/order-paid.js` — HMAC verification, discounted-unit counting, and idempotent ledger.
+- `server/order-paid.test.js` — webhook and ledger tests.
 - `DEPLOY.md` — exact, ordered deploy + activate commands (gated).
 
 ## Quick test (local, $0)
 ```sh
-cd extensions/free-samples-discount && node --test
+npm test
 ```
diff --git a/shopify/staged/free-samples-function/extensions/free-samples-discount/package.json b/shopify/staged/free-samples-function/extensions/free-samples-discount/package.json
index b0c0afd1..8c4633cd 100644
--- a/shopify/staged/free-samples-function/extensions/free-samples-discount/package.json
+++ b/shopify/staged/free-samples-function/extensions/free-samples-discount/package.json
@@ -1,7 +1,7 @@
 {
   "name": "dw-free-samples-discount",
   "version": "1.0.0",
-  "description": "DW Free Samples discount Function (trade unlimited, non-trade first-3-per-order, anonymous none).",
+  "description": "DW Free Samples discount Function (approved trade unlimited, retail first-3 lifetime, anonymous none).",
   "type": "module",
   "private": true,
   "scripts": {
diff --git a/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.graphql b/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.graphql
index 3fbd2f5e..1beefc30 100644
--- a/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.graphql
+++ b/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.graphql
@@ -1,9 +1,8 @@
 # Input query for the DW Free Samples discount Function.
 #
-# Sample identification (per Steve's note that SKU-suffix casing is inconsistent):
-#   PRIMARY  — variant title == "Sample" (every DW sample variant has variant title
-#              "Sample" at $4.25 — verified in the staged research). Case-insensitive
-#              comparison is done in run.js, so casing drift on the SKU is irrelevant.
+# Sample identification:
+#   PRIMARY  — variant title contains "Sample" OR SKU ends in "-Sample". Both checks
+#              are case-insensitive in run.js.
 #   OPTIONAL — a `samples` collection can additionally gate via product metafield
 #              "custom.is_sample" == "true" if Steve later prefers collection-driven
 #              targeting; that field is read here and honored by run.js when present.
@@ -15,21 +14,25 @@
 #   metafield, NOT live collection membership. (Documented limitation; see README.)
 #
 # Buyer identity:
-#   buyerIdentity.customer.hasTrade — whether the logged-in customer carries tag "trade".
+#   buyerIdentity.customer.hasTrade — whether the logged-in customer carries the exact
+#   approval tag "trade_approved".
 #   A NULL customer == anonymous == no discount (must register to unlock).
 #
-# discount.metafield carries merchant-tunable config (free-per-order count, trade tag,
-# sample price ceiling) so behavior is editable in admin without a code redeploy.
+# discount.metafield carries the merchant-tunable lifetime count so behavior is
+# editable in admin without a code redeploy.
 
 query Input {
   cart {
     buyerIdentity {
       customer {
         id
-        hasTrade: hasTags(tags: ["trade"]) {
+        hasTrade: hasTags(tags: ["trade_approved"]) {
           tag
           hasTag
         }
+        samplesUsed: metafield(namespace: "custom", key: "free_samples_used") {
+          value
+        }
       }
     }
     lines {
@@ -45,6 +48,7 @@ query Input {
         ... on ProductVariant {
           id
           title
+          sku
           product {
             isSampleMeta: metafield(namespace: "custom", key: "is_sample") {
               value
diff --git a/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.js b/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.js
index e8991033..9fe74882 100644
--- a/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.js
+++ b/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.js
@@ -1,17 +1,16 @@
 // @ts-check
 //
-// DW Free Samples — Cart & Checkout Discount Function (2024-10 unified API).
+// DW Free Samples — Cart & Checkout Discount Function (2025-10 unified API).
 // Target: cart.lines.discounts.generate.run
 //
 // Entitlement matrix (account-gated, frictionless — no code typed at checkout):
-//   - TRADE (customer carries tag "trade")  -> EVERY sample line is 100% off, unlimited.
-//   - SIGNED-IN NON-TRADE                    -> the first N sample UNITS (default 3) are
-//                                               100% off, this order. (Per-order cap; see
-//                                               README for the lifetime-cap v2 upgrade.)
+//   - TRADE (exact customer tag "trade_approved") -> EVERY sample line is free.
+//   - SIGNED-IN NON-TRADE -> up to 3 sample units lifetime, minus the customer's
+//                            custom.free_samples_used counter.
 //   - ANONYMOUS (no customer on the cart)    -> NO discount. Signing up IS the unlock.
 //
-// Sample identification: variant title === "Sample" (case-insensitive) OR product
-// metafield custom.is_sample === "true". SKU-suffix casing is intentionally ignored.
+// Sample identification: variant title contains "Sample", SKU ends in "-Sample"
+// (both case-insensitive), OR product metafield custom.is_sample === "true".
 //
 // All money is removed via a 100% percentage discount on the targeted lines, so the
 // $4.25 sample price is fully zeroed regardless of currency/rounding.
@@ -33,14 +32,13 @@ export function run(input) {
   }
 
   // ---- merchant-tunable config (discount metafield $app:free-samples/config) ----
-  let cfg = { freePerOrder: 3, tradeTag: "trade" };
+  let cfg = { freeLifetime: 3 };
   const rawCfg = input?.discount?.metafield?.value;
   if (rawCfg) {
     try {
       const parsed = JSON.parse(rawCfg);
       if (parsed && typeof parsed === "object") {
-        if (Number.isFinite(parsed.freePerOrder)) cfg.freePerOrder = parsed.freePerOrder;
-        if (typeof parsed.tradeTag === "string" && parsed.tradeTag) cfg.tradeTag = parsed.tradeTag;
+        if (Number.isFinite(parsed.freeLifetime)) cfg.freeLifetime = parsed.freeLifetime;
       }
     } catch (_) {
       /* malformed config -> fall back to defaults; never throw inside a Function */
@@ -74,10 +72,9 @@ export function run(input) {
       value: { percentage: { value: 100 } },
     });
   } else {
-    // Non-trade signed-in: first N sample UNITS free this order.
-    // Walk lines, consuming the per-order allowance unit-by-unit so a single line
-    // with quantity 5 only gets 3 of its units free when the cap is 3.
-    let remaining = Math.max(0, Math.floor(cfg.freePerOrder));
+    // Retail: remaining lifetime allowance is supplied by a customer metafield.
+    const used = Math.max(0, Math.floor(Number(customer.samplesUsed?.value || 0)));
+    let remaining = Math.max(0, Math.floor(cfg.freeLifetime) - used);
     if (remaining === 0) return EMPTY;
 
     for (const line of sampleLines) {
@@ -124,17 +121,17 @@ export function run(input) {
 }
 
 /**
- * A cart line is a sample if the variant title is "Sample" (case-insensitive)
- * or the product carries custom.is_sample == "true".
+ * A cart line is a sample if its variant title contains the word "Sample", its SKU
+ * ends in "-Sample" (case-insensitive), or the product carries custom.is_sample.
  * @param {any} line
  * @returns {boolean}
  */
 function isSampleLine(line) {
   const m = line?.merchandise;
   if (!m || m.__typename !== "ProductVariant") return false;
-  const titleIsSample =
-    typeof m.title === "string" && m.title.trim().toLowerCase() === "sample";
+  const titleIsSample = typeof m.title === "string" && /\bsample\b/i.test(m.title);
+  const skuIsSample = typeof m.sku === "string" && /-sample$/i.test(m.sku.trim());
   const metaIsSample =
     (m.product?.isSampleMeta?.value || "").toString().trim().toLowerCase() === "true";
-  return titleIsSample || metaIsSample;
+  return titleIsSample || skuIsSample || metaIsSample;
 }
diff --git a/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.test.js b/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.test.js
index 70d09260..bd36e6e7 100644
--- a/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.test.js
+++ b/shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.test.js
@@ -8,6 +8,7 @@ const sampleVariant = (title = "Sample", isSampleMeta) => ({
   __typename: "ProductVariant",
   id: "gid://shopify/ProductVariant/1",
   title,
+  sku: "ABC-Sample",
   product: { isSampleMeta: isSampleMeta ? { value: isSampleMeta } : null },
 });
 
@@ -19,8 +20,8 @@ const line = (id, qty, variant, perUnit = "4.25") => ({
 });
 
 const cart = (customer, lines) => ({ cart: { buyerIdentity: { customer }, lines } });
-const trade = { id: "c1", hasTrade: [{ tag: "trade", hasTag: true }] };
-const nonTrade = { id: "c2", hasTrade: [{ tag: "trade", hasTag: false }] };
+const trade = { id: "c1", hasTrade: [{ tag: "trade_approved", hasTag: true }], samplesUsed: { value: "99" } };
+const retail = (used = 0) => ({ id: "c2", hasTrade: [{ tag: "trade_approved", hasTag: false }], samplesUsed: { value: String(used) } });
 
 test("anonymous gets nothing (must register)", () => {
   const r = run(cart(null, [line(1, 1, sampleVariant())]));
@@ -37,7 +38,7 @@ test("trade gets every sample line free, unlimited", () => {
 
 test("non-trade: 4 separate sample lines -> first 3 whole lines free", () => {
   const r = run(
-    cart(nonTrade, [
+    cart(retail(), [
       line(1, 1, sampleVariant()),
       line(2, 1, sampleVariant()),
       line(3, 1, sampleVariant()),
@@ -50,7 +51,7 @@ test("non-trade: 4 separate sample lines -> first 3 whole lines free", () => {
 });
 
 test("non-trade: one line qty 5 -> only 3 units free (partial fixedAmount)", () => {
-  const r = run(cart(nonTrade, [line(1, 5, sampleVariant(), "4.25")]));
+  const r = run(cart(retail(), [line(1, 5, sampleVariant(), "4.25")]));
   const cands = r.operations[0].productDiscountsAdd.candidates;
   assert.equal(cands.length, 1);
   assert.ok(cands[0].value.fixedAmount, "partial line uses fixedAmount");
@@ -59,7 +60,7 @@ test("non-trade: one line qty 5 -> only 3 units free (partial fixedAmount)", ()
 
 test("non-trade ignores non-sample lines", () => {
   const roll = { __typename: "ProductVariant", id: "v9", title: "Single Roll", product: { isSampleMeta: null } };
-  const r = run(cart(nonTrade, [line(1, 1, roll), line(2, 1, sampleVariant())]));
+  const r = run(cart(retail(), [line(1, 1, roll), line(2, 1, sampleVariant())]));
   const cands = r.operations[0].productDiscountsAdd.candidates;
   assert.equal(cands.length, 1);
   assert.equal(cands[0].targets[0].cartLine.id, "gid://shopify/CartLine/2");
@@ -67,18 +68,34 @@ test("non-trade ignores non-sample lines", () => {
 
 test("sample detection via metafield when title differs", () => {
   const v = sampleVariant("4x6 Memo", "true");
-  const r = run(cart(nonTrade, [line(1, 1, v)]));
+  const r = run(cart(retail(), [line(1, 1, v)]));
   assert.equal(r.operations[0].productDiscountsAdd.candidates.length, 1);
 });
 
-test("config freePerOrder override respected", () => {
-  const c = cart(nonTrade, [line(1, 1, sampleVariant()), line(2, 1, sampleVariant())]);
-  c.discount = { metafield: { value: JSON.stringify({ freePerOrder: 1 }) } };
+test("config freeLifetime override respected", () => {
+  const c = cart(retail(), [line(1, 1, sampleVariant()), line(2, 1, sampleVariant())]);
+  c.discount = { metafield: { value: JSON.stringify({ freeLifetime: 1 }) } };
   const r = run(c);
   assert.equal(r.operations[0].productDiscountsAdd.candidates.length, 1);
 });
 
 test("no sample lines -> empty", () => {
   const roll = { __typename: "ProductVariant", id: "v9", title: "Single Roll", product: { isSampleMeta: null } };
-  assert.deepEqual(run(cart(nonTrade, [line(1, 1, roll)])).operations, []);
+  assert.deepEqual(run(cart(retail(), [line(1, 1, roll)])).operations, []);
+});
+
+test("retail lifetime usage reduces the remaining allowance", () => {
+  const r = run(cart(retail(2), [line(1, 4, sampleVariant(), "4.25")]));
+  const candidate = r.operations[0].productDiscountsAdd.candidates[0];
+  assert.equal(candidate.value.fixedAmount.amount, "4.25");
+});
+
+test("retail with all 3 lifetime samples used gets no discount", () => {
+  assert.deepEqual(run(cart(retail(3), [line(1, 1, sampleVariant())])).operations, []);
+});
+
+test("sample SKU suffix is recognized when title differs", () => {
+  const v = sampleVariant("Memo", null);
+  const r = run(cart(retail(), [line(1, 1, v)]));
+  assert.equal(r.operations[0].productDiscountsAdd.candidates.length, 1);
 });
diff --git a/shopify/staged/free-samples-function/package-lock.json b/shopify/staged/free-samples-function/package-lock.json
new file mode 100644
index 00000000..69fd183a
--- /dev/null
+++ b/shopify/staged/free-samples-function/package-lock.json
@@ -0,0 +1,13 @@
+{
+  "name": "dw-free-samples-app",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "dw-free-samples-app",
+      "version": "1.0.0",
+      "license": "UNLICENSED"
+    }
+  }
+}
diff --git a/shopify/staged/free-samples-function/package.json b/shopify/staged/free-samples-function/package.json
index 12743083..e0326f9b 100644
--- a/shopify/staged/free-samples-function/package.json
+++ b/shopify/staged/free-samples-function/package.json
@@ -7,6 +7,8 @@
   "scripts": {
     "shopify": "npm exec -- shopify",
     "build": "npm exec -- shopify app build",
-    "deploy": "npm exec -- shopify app deploy"
+    "deploy": "npm exec -- shopify app deploy",
+    "test": "node --test extensions/free-samples-discount/src/run.test.js server/order-paid.test.js",
+    "serve": "node server/index.js"
   }
 }
diff --git a/shopify/staged/free-samples-function/server/index.js b/shopify/staged/free-samples-function/server/index.js
new file mode 100644
index 00000000..19a9da75
--- /dev/null
+++ b/shopify/staged/free-samples-function/server/index.js
@@ -0,0 +1,50 @@
+import http from "node:http";
+import { SampleUsageLedger, createAdminGraphql, verifyShopifyHmac } from "./order-paid.js";
+
+const port = Number(process.env.PORT || 3000);
+const secret = process.env.SHOPIFY_API_SECRET;
+const shop = process.env.SHOPIFY_STORE_DOMAIN;
+const token = process.env.SHOPIFY_ADMIN_ACCESS_TOKEN;
+const missing = [
+  ["SHOPIFY_API_SECRET", secret],
+  ["SHOPIFY_STORE_DOMAIN", shop],
+  ["SHOPIFY_ADMIN_ACCESS_TOKEN", token]
+].filter(([, value]) => !value).map(([name]) => name);
+
+if (missing.length) {
+  throw new Error(`Missing required environment variables: ${missing.join(", ")}`);
+}
+
+const graphql = createAdminGraphql({
+  shop,
+  token,
+  apiVersion: process.env.SHOPIFY_API_VERSION || "2026-07"
+});
+const ledger = new SampleUsageLedger(graphql);
+
+http.createServer((request, response) => {
+  if (request.method === "GET" && request.url === "/healthz") {
+    response.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}');
+    return;
+  }
+  if (request.method !== "POST" || request.url !== "/webhooks/orders-paid") {
+    response.writeHead(404).end("Not found");
+    return;
+  }
+  const chunks = [];
+  request.on("data", (chunk) => chunks.push(chunk));
+  request.on("end", async () => {
+    const raw = Buffer.concat(chunks);
+    if (!verifyShopifyHmac(raw, request.headers["x-shopify-hmac-sha256"], secret)) {
+      response.writeHead(401).end("Invalid signature");
+      return;
+    }
+    try {
+      await ledger.process(JSON.parse(raw.toString("utf8")));
+      response.writeHead(200).end("OK");
+    } catch (error) {
+      console.error(error.message);
+      response.writeHead(500).end("Retry");
+    }
+  });
+}).listen(port, () => console.log(`DW sample ledger listening on ${port}`));
diff --git a/shopify/staged/free-samples-function/server/order-paid.js b/shopify/staged/free-samples-function/server/order-paid.js
new file mode 100644
index 00000000..303d719c
--- /dev/null
+++ b/shopify/staged/free-samples-function/server/order-paid.js
@@ -0,0 +1,93 @@
+import crypto from "node:crypto";
+
+const DISCOUNT_TITLE = "DW Free Samples (auto)";
+const USED_NAMESPACE = "custom";
+const USED_KEY = "free_samples_used";
+const COUNTED_KEY = "free_samples_counted";
+
+export function verifyShopifyHmac(rawBody, receivedHmac, secret) {
+  if (!rawBody || !receivedHmac || !secret) return false;
+  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("base64");
+  const left = Buffer.from(expected);
+  const right = Buffer.from(receivedHmac);
+  return left.length === right.length && crypto.timingSafeEqual(left, right);
+}
+
+export function isApprovedTrade(order) {
+  const tags = Array.isArray(order?.customer?.tags)
+    ? order.customer.tags
+    : String(order?.customer?.tags || "").split(",");
+  return tags.map((tag) => tag.trim()).includes("trade_approved");
+}
+
+export function countDiscountedRetailSamples(order) {
+  if (!order?.customer?.id || isApprovedTrade(order)) return 0;
+  const applications = order.discount_applications || [];
+
+  return (order.line_items || []).reduce((total, item) => {
+    const sample = /\bsample\b/i.test(item.variant_title || "") || /-sample$/i.test(item.sku || "");
+    const unitPrice = Number(item.price || 0);
+    if (!sample || unitPrice <= 0) return total;
+
+    const allocated = (item.discount_allocations || []).reduce((sum, allocation) => {
+      const application = applications[allocation.discount_application_index];
+      const label = application?.title || application?.code || "";
+      return label === DISCOUNT_TITLE ? sum + Number(allocation.amount || 0) : sum;
+    }, 0);
+    const units = Math.floor((allocated + 0.00001) / unitPrice);
+    return total + Math.min(Number(item.quantity || 0), Math.max(0, units));
+  }, 0);
+}
+
+export class SampleUsageLedger {
+  constructor(graphql) {
+    this.graphql = graphql;
+  }
+
+  async process(order) {
+    const increment = countDiscountedRetailSamples(order);
+    if (!increment) return { status: "ignored", increment: 0 };
+
+    const orderGid = `gid://shopify/Order/${order.id}`;
+    const customerGid = `gid://shopify/Customer/${order.customer.id}`;
+    const state = await this.graphql(
+      `query SampleUsageState($order: ID!, $customer: ID!) {
+        order: node(id: $order) { ... on Order { counted: metafield(namespace: "${USED_NAMESPACE}", key: "${COUNTED_KEY}") { value compareDigest } } }
+        customer: node(id: $customer) { ... on Customer { used: metafield(namespace: "${USED_NAMESPACE}", key: "${USED_KEY}") { value compareDigest } } }
+      }`,
+      { order: orderGid, customer: customerGid }
+    );
+
+    if (state.order?.counted?.value === "true") return { status: "duplicate", increment: 0 };
+    const used = Math.max(0, Number(state.customer?.used?.value || 0));
+    const next = Math.min(3, used + increment);
+
+    const result = await this.graphql(
+      `mutation RecordSampleUsage($metafields: [MetafieldsSetInput!]!) {
+        metafieldsSet(metafields: $metafields) { userErrors { field message code } }
+      }`,
+      {
+        metafields: [
+          { ownerId: customerGid, namespace: USED_NAMESPACE, key: USED_KEY, type: "number_integer", value: String(next), compareDigest: state.customer?.used?.compareDigest ?? null },
+          { ownerId: orderGid, namespace: USED_NAMESPACE, key: COUNTED_KEY, type: "boolean", value: "true", compareDigest: state.order?.counted?.compareDigest ?? null }
+        ]
+      }
+    );
+    const errors = result.metafieldsSet?.userErrors || [];
+    if (errors.length) throw new Error(errors.map((error) => error.message).join("; "));
+    return { status: "counted", increment: next - used, used: next };
+  }
+}
+
+export function createAdminGraphql({ shop, token, apiVersion = "2026-07" }) {
+  return async function graphql(query, variables) {
+    const response = await fetch(`https://${shop}/admin/api/${apiVersion}/graphql.json`, {
+      method: "POST",
+      headers: { "Content-Type": "application/json", "X-Shopify-Access-Token": token },
+      body: JSON.stringify({ query, variables })
+    });
+    const body = await response.json();
+    if (!response.ok || body.errors?.length) throw new Error(JSON.stringify(body.errors || body));
+    return body.data;
+  };
+}
diff --git a/shopify/staged/free-samples-function/server/order-paid.test.js b/shopify/staged/free-samples-function/server/order-paid.test.js
new file mode 100644
index 00000000..72da1fb6
--- /dev/null
+++ b/shopify/staged/free-samples-function/server/order-paid.test.js
@@ -0,0 +1,67 @@
+import assert from "node:assert/strict";
+import crypto from "node:crypto";
+import test from "node:test";
+import {
+  SampleUsageLedger,
+  countDiscountedRetailSamples,
+  isApprovedTrade,
+  verifyShopifyHmac
+} from "./order-paid.js";
+
+function order({ tags = "", quantity = 4, amount = "12.75", counted = false } = {}) {
+  return {
+    id: 10,
+    customer: { id: 20, tags },
+    discount_applications: [{ title: "DW Free Samples (auto)" }],
+    line_items: [{
+      quantity,
+      price: "4.25",
+      variant_title: "Sample",
+      sku: "ABC-Sample",
+      discount_allocations: [{ amount, discount_application_index: 0 }]
+    }],
+    counted
+  };
+}
+
+test("validates Shopify webhook HMAC", () => {
+  const body = Buffer.from('{"id":10}');
+  const hmac = Buffer.from("secret");
+  const signature = crypto.createHmac("sha256", hmac).update(body).digest("base64");
+  assert.equal(verifyShopifyHmac(body, signature, hmac), true);
+  assert.equal(verifyShopifyHmac(body, "bad", hmac), false);
+});
+
+test("counts only units discounted by the DW automatic discount", () => {
+  assert.equal(countDiscountedRetailSamples(order()), 3);
+  assert.equal(countDiscountedRetailSamples(order({ amount: "4.25" })), 1);
+  const other = order();
+  other.discount_applications[0].title = "Other discount";
+  assert.equal(countDiscountedRetailSamples(other), 0);
+});
+
+test("exact approved trade tag is required", () => {
+  assert.equal(isApprovedTrade(order({ tags: "trade_approved" })), true);
+  assert.equal(isApprovedTrade(order({ tags: "trade_pending, designer" })), false);
+  assert.equal(countDiscountedRetailSamples(order({ tags: "trade_approved" })), 0);
+});
+
+test("ledger increments lifetime use and marks the order", async () => {
+  const calls = [];
+  const ledger = new SampleUsageLedger(async (query, variables) => {
+    calls.push({ query, variables });
+    if (query.includes("query SampleUsageState")) return { order: { counted: null }, customer: { used: { value: "1", compareDigest: "digest-1" } } };
+    return { metafieldsSet: { userErrors: [] } };
+  });
+  const result = await ledger.process(order({ amount: "12.75" }));
+  assert.deepEqual(result, { status: "counted", increment: 2, used: 3 });
+  assert.equal(calls[1].variables.metafields[0].value, "3");
+  assert.equal(calls[1].variables.metafields[0].compareDigest, "digest-1");
+  assert.equal(calls[1].variables.metafields[1].value, "true");
+  assert.equal(calls[1].variables.metafields[1].compareDigest, null);
+});
+
+test("ledger ignores duplicate webhook delivery", async () => {
+  const ledger = new SampleUsageLedger(async () => ({ order: { counted: { value: "true" } }, customer: { used: { value: "2" } } }));
+  assert.deepEqual(await ledger.process(order()), { status: "duplicate", increment: 0 });
+});
diff --git a/shopify/staged/free-samples-function/shopify.app.toml b/shopify/staged/free-samples-function/shopify.app.toml
index 3303ec28..a7397d0b 100644
--- a/shopify/staged/free-samples-function/shopify.app.toml
+++ b/shopify/staged/free-samples-function/shopify.app.toml
@@ -8,10 +8,14 @@ client_id = "7e1f92e525f0eb348fb6057f1ba8e948"
 
 [access_scopes]
 # Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
-scopes = ""
+scopes = "read_customers,write_customers,read_orders,write_orders,write_discounts"
 
 [webhooks]
-api_version = "2026-10"
+api_version = "2026-07"
+
+  [[webhooks.subscriptions]]
+  topics = [ "orders/paid" ]
+  uri = "/webhooks/orders-paid"
 
 [auth]
 redirect_urls = [ "https://shopify.dev/apps/default-app-home/api/auth" ]
diff --git a/shopify/theme-LIVE-pull-20260728-colorbar/snippets/product-form-content.liquid b/shopify/theme-LIVE-pull-20260728-colorbar/snippets/product-form-content.liquid
index f176dda3..6b6fed8f 100644
--- a/shopify/theme-LIVE-pull-20260728-colorbar/snippets/product-form-content.liquid
+++ b/shopify/theme-LIVE-pull-20260728-colorbar/snippets/product-form-content.liquid
@@ -324,13 +324,9 @@
           plus the explicit tiers below. Add new tiers to the explicit list as needed.
         {%- endcomment -%}
         {%- assign dw_is_trade = false -%}
-        {%- for dw_t in customer.tags -%}
-          {%- assign dw_tl = dw_t | downcase | strip -%}
-          {%- if dw_tl contains 'designer' or dw_tl == 'trade' or dw_tl == 'architect' or dw_tl == 'stager' or dw_tl == 'hospitality' or dw_tl == 'retailer' -%}
-            {%- assign dw_is_trade = true -%}
-            {%- break -%}
-          {%- endif -%}
-        {%- endfor -%}
+        {%- if customer.tags contains 'trade_approved' -%}
+          {%- assign dw_is_trade = true -%}
+        {%- endif -%}
         <input type="hidden" id="customer_tag" value="{% if dw_is_trade %}trade{% endif %}">
         <p class="product-price">
           {% comment %} SA Wholesale (saw_/wbuyx/"WHO") app removed 2026-06-11 — it left the price span blank. Render native variant price unconditionally. {% endcomment %}

← a6461a60 improve Shopify purchase and collection UX  ·  back to Dw Validator Debug TK11314  ·  auto-data-snapshot: 2026-08-28T00:56:56 (1 data files) — sho db4de209 →