← back to Dw Validator Debug TK11314
make free sample entitlement reliable
2ecca051eb77f787345bf17ef663865030f41e11 · 2026-08-28 00:31:22 -0700 · Steve
Files touched
M shopify/staged/free-samples-banner/banner-block.liquidM shopify/staged/free-samples-function/DEPLOY.mdM shopify/staged/free-samples-function/README.mdM shopify/staged/free-samples-function/extensions/free-samples-discount/package.jsonM shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.graphqlM shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.jsM shopify/staged/free-samples-function/extensions/free-samples-discount/src/run.test.jsA shopify/staged/free-samples-function/package-lock.jsonM shopify/staged/free-samples-function/package.jsonA shopify/staged/free-samples-function/server/index.jsA shopify/staged/free-samples-function/server/order-paid.jsA shopify/staged/free-samples-function/server/order-paid.test.jsM shopify/staged/free-samples-function/shopify.app.tomlM shopify/theme-LIVE-pull-20260728-colorbar/snippets/product-form-content.liquid
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 — added at checkout, no code needed. <a class="dw-sb-cta" href="/collections/all">Order your free samples ›</a> <span class="dw-sb-sub">Designers & trade? <a class="dw-sb-cta" href="/account">Unlock unlimited ›</a></span></span>
+ <span class="dw-sb-msg">You have {{ dw_samples_remaining }} of 3 complimentary samples remaining — applied automatically at checkout. <a class="dw-sb-cta" href="/collections/all">Choose samples ›</a> <span class="dw-sb-sub">Design professionals? <a class="dw-sb-cta" href="/pages/trade-only-benefits">Apply for unlimited samples ›</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 ›</a> <span class="dw-sb-sub">Designers & 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 ›</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)">×</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 →