[object Object]

← back to Dw Signup Fulfillment

retail: function-backed unique sample codes (safe sample-lock) + theme modal proposal + house-account

c323e036065ffdd7ce82aab21eb1b85f9355ffad · 2026-07-28 08:18:15 -0700 · Steve Abrams

- lib/retail-code.js: per-customer DWSAMP-XXXXXXXX unique code via discountCodeAppCreate
  referencing the deployed DW Free Samples Discount Function (samples-only, first 3 free,
  usageLimit 1, appliesOncePerCustomer). Avoids the roll-zeroing money leak of a gift
  card / plain collection code.
- lib/shopify.js: add DRY_RUN-safe graphql() helper (mutations short-circuit + log WOULD-call).
- webhook customers/create now issues the function-code path; giftcard + giftcode-discount
  kept as clearly-labeled NOT-WIRED alternates.
- config + .env.example: DISCOUNT_FUNCTION_ID (required at go-live; simulate+WARN in DRY_RUN).
- selftest: asserts discountCodeAppCreate WOULD-call carries functionId + usageLimit 1 +
  unique code + emails the code; exits 0 in DRY_RUN.
- DEPLOY.md: one-time function deploy step; no auto-apply discount; renumbered runbook.
- docs/theme-modal-proposal.md: BEFORE/AFTER for retail copy, moderated Apply-for-Trade,
  and Continue-with-Google (proposal only, not deployed).

Files touched

Diff

commit c323e036065ffdd7ce82aab21eb1b85f9355ffad
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 28 08:18:15 2026 -0700

    retail: function-backed unique sample codes (safe sample-lock) + theme modal proposal + house-account
    
    - lib/retail-code.js: per-customer DWSAMP-XXXXXXXX unique code via discountCodeAppCreate
      referencing the deployed DW Free Samples Discount Function (samples-only, first 3 free,
      usageLimit 1, appliesOncePerCustomer). Avoids the roll-zeroing money leak of a gift
      card / plain collection code.
    - lib/shopify.js: add DRY_RUN-safe graphql() helper (mutations short-circuit + log WOULD-call).
    - webhook customers/create now issues the function-code path; giftcard + giftcode-discount
      kept as clearly-labeled NOT-WIRED alternates.
    - config + .env.example: DISCOUNT_FUNCTION_ID (required at go-live; simulate+WARN in DRY_RUN).
    - selftest: asserts discountCodeAppCreate WOULD-call carries functionId + usageLimit 1 +
      unique code + emails the code; exits 0 in DRY_RUN.
    - DEPLOY.md: one-time function deploy step; no auto-apply discount; renumbered runbook.
    - docs/theme-modal-proposal.md: BEFORE/AFTER for retail copy, moderated Apply-for-Trade,
      and Continue-with-Google (proposal only, not deployed).
---
 .env.example                 |  23 +++-
 DEPLOY.md                    | 113 +++++++++++++------
 README.md                    |  91 ++++++++--------
 docs/theme-modal-proposal.md | 251 +++++++++++++++++++++++++++++++++++++++++++
 lib/config.js                |  10 +-
 lib/email.js                 |  18 +++-
 lib/giftcard.js              |   8 +-
 lib/giftcode-discount.js     |   7 +-
 lib/retail-code.js           | 119 ++++++++++++++++++++
 lib/shopify.js               |  50 ++++++++-
 scripts/selftest.js          |  62 ++++++++---
 server.js                    |  22 ++--
 12 files changed, 662 insertions(+), 112 deletions(-)

diff --git a/.env.example b/.env.example
index 2d84325..d3ecd20 100644
--- a/.env.example
+++ b/.env.example
@@ -27,15 +27,28 @@ SHOPIFY_FULFILLMENT_TOKEN=
 SHOPIFY_WEBHOOK_SECRET=
 
 # --- Retail sample economics ---------------------------------------------
-# Gift-card face value = FREE_SAMPLE_COUNT × SAMPLE_PRICE  (default 3 × 4.25 = 12.75)
+# The WIRED retail path issues a UNIQUE single-use CODE per new customer, backed by
+# the "DW Free Samples" Discount FUNCTION (samples-only, first N free). FREE_SAMPLE_COUNT
+# is the freebie cap the function enforces; SAMPLE_PRICE is informational here.
 FREE_SAMPLE_COUNT=3
 SAMPLE_PRICE=4.25
 CURRENCY=USD
 
-# Only used by the ALTERNATIVE lib/giftcode-discount.js path (scoping the
-# 100%-off code to the Samples collection). Leave blank if you use the gift-card
-# default; if you switch to the discount-code path at go-live, set this to the
-# numeric id of your Samples collection.
+# --- Discount FUNCTION id (REQUIRED for retail at go-live) -----------------
+# The id of the deployed "DW Free Samples" Discount Function that the per-customer
+# CODE discount references (discountCodeAppCreate.functionId). Deploy the function
+# ONCE and read its id:
+#   cd ~/Projects/Designer-Wallcoverings/shopify/staged/free-samples-function
+#   shopify app config link && shopify app deploy
+#   # then: GraphQL  query { shopifyFunctions(first:25){ nodes{ id title apiType } } }
+#   # copy the id whose title == "DW Free Samples" (apiType discount) here.
+# Unset in DRY_RUN → the service still simulates but logs a WARN. It is REQUIRED
+# before flipping DRY_RUN=0 (Shopify rejects a code discount with no functionId).
+DISCOUNT_FUNCTION_ID=
+
+# Only used by the ALTERNATE lib/giftcode-discount.js path (NOT wired) — scoping a
+# 100%-off code to a Samples collection. Leave blank unless you deliberately switch
+# to that alternate at go-live.
 SAMPLES_COLLECTION_ID=
 
 # --- George email (:9850) -------------------------------------------------
diff --git a/DEPLOY.md b/DEPLOY.md
index 11f4122..e0be3ae 100644
--- a/DEPLOY.md
+++ b/DEPLOY.md
@@ -4,13 +4,17 @@ This service is **build-to-ready**. It ships in `DRY_RUN=1` and does nothing
 irreversible until you deliberately flip it. Follow these steps IN ORDER. Do not
 flip `DRY_RUN=0` until every prior step is green.
 
-> Reminder of what the three flows do once live:
-> - **Retail** — on `customers/create`, create a Shopify **gift card**
->   (`initial_value = FREE_SAMPLE_COUNT × SAMPLE_PRICE` = **$12.75**) and email
->   the customer the code ("here are your 3 free samples").
+> Reminder of what the flows do once live:
+> - **Retail** — on `customers/create`, generate a UNIQUE single-use code
+>   (`DWSAMP-XXXXXXXX`) and create a Shopify **code app-discount**
+>   (`discountCodeAppCreate`) that references the deployed **"DW Free Samples"
+>   Discount Function**, then email the customer the code ("your code for 3 free
+>   samples: …"). The function targets ONLY sample lines and caps the freebie at
+>   3 sample units, so full rolls are never zeroed. We do **NOT** create an
+>   automatic (auto-apply) discount — Steve chose the per-customer code path.
 > - **Trade** — `POST /trade/apply` → application queued `pending` → reviewed at
 >   `/admin/trade` → on **approve**: tag customer `trade` (unlocks free memos),
->   assign a rep round-robin, set `custom.assigned_rep` metafield, email rep +
+>   assign the DW House Account, set `custom.assigned_rep` metafield, email rep +
 >   applicant.
 
 ---
@@ -28,14 +32,47 @@ Confirm the store target is the LIVE prod store (legacy misnomer):
 
 ---
 
-## 1. Set the Shopify token
+## 1. One-time: deploy the free-samples Discount Function → get its ID
+
+The wired retail path (`lib/retail-code.js`) attaches each per-customer code
+discount to a deployed **Discount Function**. Deploy it ONCE, interactively
+(these are the only gated actions in this step):
+
+```sh
+cd ~/Projects/Designer-Wallcoverings/shopify/staged/free-samples-function
+shopify app config link     # pick DW Partner org + designer-laboratory-sandbox (writes client_id into shopify.app.toml)
+shopify app deploy          # uploads the Function; NO discount is created yet → zero customer impact
+```
+
+Then read the Function's id (read-only GraphQL, DRAFT token has the scope):
+
+```graphql
+query { shopifyFunctions(first: 25) { nodes { id title apiType } } }
+```
+
+Copy the `id` whose `title == "DW Free Samples"` (apiType `discount`) and set it
+in this project's `.env`:
+
+```sh
+DISCOUNT_FUNCTION_ID=<that id>
+```
+
+> We do NOT create an automatic discount (Steve rejected auto-apply). The function
+> is only ever referenced by the per-customer CODE discounts this service creates
+> via `discountCodeAppCreate`. A function with no discount pointing at it has zero
+> effect, so it is safe to deploy ahead of go-live.
+
+---
+
+## 2. Set the Shopify token
 
 `SHOPIFY_FULFILLMENT_TOKEN` needs scopes:
-`write_customers, write_gift_cards, write_discounts, write_price_rules,
-read_customers` + read/write webhooks.
+`write_customers, write_discounts, read_customers` + read/write webhooks.
+(`write_discounts` covers `discountCodeAppCreate`.)
 
 The existing `SHOPIFY_DRAFT_TOKEN` in `~/Projects/secrets-manager/.env` (value
-ends `a43b`) already carries all 139 of those scopes, so you can reuse it:
+ends `a43b`) already carries `write_discounts` + `write_customers`, so you can
+reuse it:
 
 ```sh
 # In this project's .env  (copy from .env.example first)
@@ -47,10 +84,10 @@ env only.
 
 ---
 
-## 2. Set the webhook secret
+## 3. Set the webhook secret
 
 You need `SHOPIFY_WEBHOOK_SECRET` so the HMAC on incoming webhooks verifies.
-Shopify returns / exposes this when you register the webhook (step 4). If you
+Shopify returns / exposes this when you register the webhook (step 5). If you
 register via the Admin UI, copy the signing secret it shows into `.env`:
 
 ```sh
@@ -59,7 +96,7 @@ SHOPIFY_WEBHOOK_SECRET=<the webhook signing secret>
 
 ---
 
-## 3. Deploy the service (still DRY_RUN)
+## 4. Deploy the service (still DRY_RUN)
 
 ```sh
 pm2 start ecosystem.config.js         # DRY_RUN=1 by env in ecosystem
@@ -76,7 +113,7 @@ uptime probes read it correctly.
 
 ---
 
-## 4. Register the customers/create webhook (Admin API)
+## 5. Register the customers/create webhook (Admin API)
 
 This is a WRITE and is DRY_RUN-gated in `lib/shopify.js`. Do it explicitly with
 curl at go-live so you control it. `address` = your public webhook URL.
@@ -93,13 +130,14 @@ curl -s -X POST "https://$SHOP/admin/api/2024-10/webhooks.json" \
 ```
 
 Record the returned webhook **id** (needed for rollback). Put the signing secret
-into `SHOPIFY_WEBHOOK_SECRET` (step 2) if you didn't already.
+into `SHOPIFY_WEBHOOK_SECRET` (step 3) if you didn't already.
 
 ---
 
-## 5. Flip DRY_RUN off
+## 6. Flip DRY_RUN off
 
-Only now:
+Only after `DISCOUNT_FUNCTION_ID` is set (step 1) — the selftest / logs will WARN
+if it is unset, and `discountCodeAppCreate` will fail live without it. Only now:
 
 ```sh
 pm2 set  # or edit ecosystem.config.js env.DRY_RUN to '0', OR set in .env:
@@ -110,15 +148,17 @@ curl -s http://127.0.0.1:9856/healthz   # -> "dry_run":false
 
 ---
 
-## 6. Smoke-test LIVE (carefully)
+## 7. Smoke-test LIVE (carefully)
 
 - Create ONE test retail customer in the store (or trigger a real signup with a
-  throwaway email you control) → confirm a gift card was created (Shopify admin →
-  Gift cards) and the email arrived.
+  throwaway email you control) → confirm a **code discount** was created (Shopify
+  admin → Discounts, title `DW Free Samples — DWSAMP-…`, usage limit 1) and the
+  email with the code arrived. Then, signed in as that customer, add 4 sample
+  swatches + 1 full roll to the cart and redeem the code → first 3 samples free,
+  4th sample paid, **roll unaffected**.
 - `POST /trade/apply` a test application → open `/admin/trade` → **Approve** →
   confirm the test customer now carries the `trade` tag, the `custom.assigned_rep`
   metafield is set, and the rep + applicant emails went out.
-- Verify a `trade`-tagged customer sees $0 memo samples (Regios discount).
 
 Watch `pm2 logs dw-signup-fulfillment` during the smoke test.
 
@@ -139,7 +179,7 @@ To stand down instantly:
    ```sh
    SHOP=designer-laboratory-sandbox.myshopify.com
    TOKEN=<SHOPIFY_FULFILLMENT_TOKEN>
-   WEBHOOK_ID=<id from step 4>
+   WEBHOOK_ID=<id from step 5>
    # list if you lost the id:
    curl -s "https://$SHOP/admin/api/2024-10/webhooks.json" -H "X-Shopify-Access-Token: $TOKEN"
    curl -s -X DELETE "https://$SHOP/admin/api/2024-10/webhooks/$WEBHOOK_ID.json" \
@@ -148,22 +188,27 @@ To stand down instantly:
 
 3. (Optional) `pm2 stop dw-signup-fulfillment`.
 
-Gift cards / discount codes already issued are not auto-revoked — disable or
-zero-balance them individually in the Shopify admin if needed.
+Code discounts already issued are not auto-revoked — the individual
+`DWSAMP-…` codes are single-use and expire on redemption; delete or deactivate
+them in the Shopify admin (Discounts) if you need to invalidate outstanding codes.
+To stop the function itself from having any effect, ensure no automatic discount
+references it (this service never creates one) and, if desired, `shopify app
+deploy` a version without the extension.
 
 ---
 
-## Optional: switch the retail path to the discount-code alternative
+## Alternates (NOT wired — reference only)
 
-The wired default is the **gift card** (`lib/giftcard.js`). If you prefer a
-unique single-use 100%-off code scoped to the Samples collection
-(`lib/giftcode-discount.js`):
+The two other retail modules are kept for reference and are reachable only for
+comparison via the manual admin endpoint; they are **not** on the webhook path:
 
-1. Set `SAMPLES_COLLECTION_ID` in `.env` to the numeric id of the Samples
-   collection.
-2. Route `customers/create` to the discount path (in `server.js`, swap
-   `giftcard.issueRetailGiftCode` → `giftcodeDiscount.issueRetailDiscountCode`
-   in the webhook handler), or use
-   `POST /admin/retail/issue?mode=discount` for a one-off.
+- `lib/giftcard.js` — stored-value gift card. Reachable via
+  `POST /admin/retail/issue?mode=giftcard`. **Not safe as default**: a gift-card
+  balance can be spent on any line item, including full rolls.
+- `lib/giftcode-discount.js` — price-rule + Samples-collection-scoped 100%-off
+  code. Reachable via `POST /admin/retail/issue?mode=discount` (needs
+  `SAMPLES_COLLECTION_ID`). Riskier than the function's variant-level targeting
+  because DW samples share a product with the sellable roll variant.
 
-See README "Gift card vs discount code" for the tradeoff.
+The wired default is `lib/retail-code.js` (function-backed unique code). Switching
+away from it is a deliberate `server.js` change; do not do it without cause.
diff --git a/README.md b/README.md
index 18e22d5..a72d18b 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
 
 Fixes the three broken Designer Wallcoverings signup / sample / rep flows in one
 small Node/Express service. **Ships DRY_RUN by default** — no live Shopify
-writes, no real emails, no webhook registration, no gift cards created until
+writes, no real emails, no webhook registration, no discounts created until
 go-live (see `DEPLOY.md`).
 
 ## The problem it fixes
@@ -21,9 +21,9 @@ untagged**, so:
 
 | Flow | Trigger | Action (all DRY_RUN-safe) |
 |---|---|---|
-| **Retail** | `customers/create` webhook (HMAC-verified) | Create a Shopify **gift card** worth `FREE_SAMPLE_COUNT × SAMPLE_PRICE` = **$12.75**, email the customer the code |
-| **Trade** | `POST /trade/apply` → moderated review | On **approve**: tag customer `trade`, assign a rep round-robin, set `custom.assigned_rep` metafield, email rep + applicant |
-| **Rep assignment** | on approve | Round-robin across `data/reps.json`, cursor persisted in `data/rep-cursor.json` |
+| **Retail** | `customers/create` webhook (HMAC-verified) | Generate a UNIQUE single-use code (`DWSAMP-XXXXXXXX`), create a Shopify **code app-discount** (`discountCodeAppCreate`) referencing the deployed **"DW Free Samples" Discount Function** (samples-only, first `FREE_SAMPLE_COUNT` free), email the customer the code |
+| **Trade** | `POST /trade/apply` → moderated review | On **approve**: tag customer `trade`, assign the DW House Account, set `custom.assigned_rep` metafield, email rep + applicant |
+| **Rep assignment** | on approve | Fixed to the DW House Account (`data/reps.json`) |
 
 Trade access is **moderated approval** — a professional signup is NOT tagged
 automatically; an application is submitted, reviewed at `/admin/trade`, and only
@@ -39,8 +39,9 @@ tagged `trade` on approval.
 - `GET /admin/trade` — basic-auth (`admin` / `DW2024!`), lists pending
   applications as cards, each with a created **date + time** chip.
 - `POST /admin/trade/:id/approve` · `POST /admin/trade/:id/reject` — decisions.
-- `POST /admin/retail/issue` — admin manual retail trigger (add
-  `?mode=discount` to exercise the discount-code alternative).
+- `POST /admin/retail/issue` — admin manual retail trigger. Default = the wired
+  function-backed code path; `?mode=giftcard` and `?mode=discount` exercise the two
+  (not-wired) alternates for comparison.
 - `GET /reps/next` — peek the next round-robin rep + cursor.
 
 ## Run
@@ -54,42 +55,46 @@ node server.js               # listens :9856 (PORT env), DRY_RUN on
 
 ## Design decisions
 
-### How "3 samples" is bounded (gift-card path)
+### Retail freebie = a function-backed UNIQUE single-use code (Steve's decision)
 
-The gift card is a **stored-value balance** sized to exactly cover the samples:
-`initial_value = FREE_SAMPLE_COUNT × SAMPLE_PRICE` = **$12.75** by default (both
-env-configurable). It is not a hard "exactly 3 sample line items" constraint — a
-customer could technically spend the $12.75 balance on anything up to that
-amount. In practice, with samples at $4.25 the balance covers three of them, and
-any overflow just draws down the balance. This is the mechanism Steve chose.
+On `customers/create`, `lib/retail-code.js` mints a unique code (`DWSAMP-` + 8
+uppercase base32) and creates a Shopify **code app-discount** via
+`discountCodeAppCreate` that references the deployed **"DW Free Samples" Discount
+Function** (`extensions/free-samples-discount`, id in `DISCOUNT_FUNCTION_ID`). The
+code is `usageLimit: 1`, `appliesOncePerCustomer: true`. The customer is then
+emailed the code.
 
-### Gift card vs discount code (the tradeoff)
+**Why a function-backed code and not a gift card or a plain product/collection
+code:** DW samples are **variants** (`variant.title == "Sample"`, $4.25) that
+share a product with the sellable **"Yard"** roll variant ($94.42). A gift-card
+balance or a plain product-/collection-scoped 100%-off code would also zero the
+roll variant — a real money leak. The Discount Function targets **only** sample
+lines (`variant.title == "Sample"` OR product metafield `custom.is_sample ==
+"true"`) and caps the freebie at the first `FREE_SAMPLE_COUNT` sample units, so
+full rolls are never discounted. The same function backs either an automatic
+discount or a code discount with no code change — we attach it to per-customer
+**code** discounts (Steve rejected auto-apply).
 
-Both are built; the **gift card is the wired default**. The alternative
-(`lib/giftcode-discount.js`) is included so Steve can choose it at go-live.
+### Alternates (kept, not wired)
 
-| | Gift card (default) | Discount code (alternative) |
-|---|---|---|
-| Model | Stored-value balance ($12.75) | Unique single-use 100%-off code |
-| Scope | Any line items | Scoped to the **Samples** collection (`SAMPLES_COLLECTION_ID`) |
-| "3 samples" | Bounded by **balance** ($12.75 ≈ 3 × $4.25) | Bounded by **allocation_limit = 3** on the price rule |
-| Reuse | Balance persists until spent | `usage_limit: 1`, `once_per_customer` |
-| Leftover value | Remaining balance stays usable | None — 100% off, then gone |
-| Shopify objects | 1 (`gift_card`) | 2 (`price_rule` + `discount_code`) |
-
-Gift card = simpler, one object, friendlier leftover-balance UX. Discount code =
-tighter enforcement (can't be spent on full rolls, exactly N items, single use)
-but needs the Samples collection id and creates two objects per customer.
-
-### Rep assignment = round-robin + notify
-
-Even round-robin across the roster (`data/reps.json`) with a persisted cursor
-(`data/rep-cursor.json`), so rotation survives restarts and — with an even
-roster — is even load distribution. The roster provided (from
-`designersalesreps`) carries `name`/`agency`/`contact_url` but no explicit
-`email`; `lib/reps.js` **derives** a `sales@<agency-domain>` contact from
-`contact_url` as a clearly-marked placeholder. At go-live, add a real `email`
-field per rep to `data/reps.json` and it is used verbatim.
+Two other retail modules remain in the repo for reference, reachable only via the
+manual admin endpoint (not the webhook):
+
+- `lib/giftcard.js` — stored-value gift card (`?mode=giftcard`). Not safe as
+  default: balance is spendable on any line item, including rolls.
+- `lib/giftcode-discount.js` — price-rule + Samples-collection-scoped 100%-off
+  code (`?mode=discount`). Riskier than the function's variant-level targeting
+  because samples share a product with the roll variant.
+
+Each carries a top-of-file "not wired — retail default is `lib/retail-code.js`"
+banner.
+
+### Rep assignment = fixed DW House Account
+
+On approve, the applicant is assigned to the single **DW House Account**
+(`info@designerwallcoverings.com`) from `data/reps.json` via `lib/reps.js`
+(`houseAccount()`), and `custom.assigned_rep` is set to it. There is no
+round-robin.
 
 ### DRY_RUN safety model
 
@@ -104,13 +109,15 @@ the email it WOULD send instead of hitting George.
 Set in `.env` (see `.env.example`; `.env` is gitignored):
 
 - `DRY_RUN=0` — the deliberate go-live flip (last, per DEPLOY.md).
+- `DISCOUNT_FUNCTION_ID` — **required for retail** at go-live: the id of the
+  deployed "DW Free Samples" Discount Function the code discount references. Unset
+  in DRY_RUN → still simulates but logs a WARN.
 - `SHOPIFY_FULFILLMENT_TOKEN` — can be the `SHOPIFY_DRAFT_TOKEN` value (ends
-  `a43b`, 139 scopes incl. write_customers/write_gift_cards/write_discounts/
-  write_price_rules/read_customers).
+  `a43b`), which carries `write_discounts` + `write_customers` + `read_customers`.
 - `SHOPIFY_WEBHOOK_SECRET` — the webhook signing secret (HMAC).
 - Optional: `FREE_SAMPLE_COUNT` (3), `SAMPLE_PRICE` (4.25), `PORT` (9856),
-  `SAMPLES_COLLECTION_ID` (only for the discount-code path), `ADMIN_USER` /
-  `ADMIN_PASS`.
+  `SAMPLES_COLLECTION_ID` (only for the not-wired discount alternate), `ADMIN_USER`
+  / `ADMIN_PASS`.
 
 George email creds (`GEORGE_EXTERNAL_SEND_TOKEN`, `GEORGE_BASIC_AUTH`) are
 resolved automatically from `~/Projects/george-gmail/.env` (Mac) or
diff --git a/docs/theme-modal-proposal.md b/docs/theme-modal-proposal.md
new file mode 100644
index 0000000..97bcaa8
--- /dev/null
+++ b/docs/theme-modal-proposal.md
@@ -0,0 +1,251 @@
+# PROPOSED theme change — `snippets/dw-signin-modal.liquid` (NOT deployed)
+
+Ticket TK-10006. This is a **proposal only** — nothing here has been pushed to the
+live theme. It documents the exact BEFORE → AFTER edits to the DW sign-in modal so
+Steve can sign off before any deploy.
+
+- **Store:** `designer-laboratory-sandbox.myshopify.com`
+- **Live theme:** `144396058675` (role: main)
+- **Asset:** `snippets/dw-signin-modal.liquid` (6089 bytes, `dwsm-*` classes)
+- **Read via:** `GET /admin/api/2024-10/themes/144396058675/assets.json?asset[key]=snippets/dw-signin-modal.liquid` with `SHOPIFY_THEME_TOKEN` (ends `2954`).
+
+Three faithful, minimal changes. Markup/classes and the existing JS behavior are
+preserved; only the listed lines change.
+
+---
+
+## Why these changes
+
+The wired retail flow (this service) does **not** hand out an instant free product
+at sign-in. On `customers/create`, the fulfillment service emails the customer a
+unique single-use **code** for 3 free samples (function-backed, sample-only). The
+modal copy must match that reality (no "instant free" promise), the Professional
+option must route to a **moderated Apply-for-Trade** step (not the same one-click
+account create), and Google sign-up should be offered up front (the hosted account
+portal's native Google works, and the `customers/create` webhook covers Google
+signups automatically).
+
+---
+
+## (a) RETAIL copy — stop implying instant free samples
+
+The current header ("Up to 10 Free Samples" + "3 free samples") reads as an instant
+entitlement. Reframe it around the emailed code.
+
+### BEFORE (lines 38–50)
+
+```liquid
+    <div class="dwsm-offer">
+      <h2>Up to 10 Free Samples</h2>
+      <p>Choose your account type, then sign in.</p>
+    </div>
+
+    <div class="dwsm-toggle">
+      <div class="dwsm-opt active" data-dwsm-type="retail" role="button" tabindex="0">
+        <span class="t">Retail</span><span class="s">3 free samples</span>
+      </div>
+      <div class="dwsm-opt" data-dwsm-type="professional" role="button" tabindex="0">
+        <span class="t">Professional</span><span class="s">up to 10 free</span>
+      </div>
+    </div>
+```
+
+### AFTER
+
+```liquid
+    <div class="dwsm-offer">
+      <h2>Free Samples, On Us</h2>
+      <p>Sign in — we'll email you a code for 3 free samples.</p>
+    </div>
+
+    <div class="dwsm-toggle">
+      <div class="dwsm-opt active" data-dwsm-type="retail" role="button" tabindex="0">
+        <span class="t">Retail</span><span class="s">code for 3 free</span>
+      </div>
+      <div class="dwsm-opt" data-dwsm-type="professional" role="button" tabindex="0">
+        <span class="t">Professional</span><span class="s">apply for trade</span>
+      </div>
+    </div>
+```
+
+Notes: only the text inside `<h2>`, the `<p>`, and the two `.s` spans changes. No
+class, structure, or JS touched. "Sign in — we'll email you a code for 3 free
+samples" is verbatim per the brief.
+
+---
+
+## (b) PROFESSIONAL chip — route to a moderated "Apply for Trade", not the same create-account
+
+Today, both Retail and Professional lead to the same passwordless
+`routes.account_login_url` (the Professional path just sets a `dw_trade`
+sessionStorage flag). Trade should be **moderated**: the Professional selection
+should present an **Apply for Trade** action that submits to the fulfillment
+service's `POST /trade/apply` endpoint (queued `pending`, reviewed at
+`/admin/trade`, approved → `trade` tag + rep assignment).
+
+Two coordinated edits.
+
+### (b1) Add the Apply-for-Trade panel (shown only when Professional is selected)
+
+Insert this block **after** the toggle (`</div>` closing `.dwsm-toggle`, line 50)
+and **before** the Google button / divider. It is hidden by default and revealed by
+the JS in (b3)/(c). It posts to the trade endpoint via `fetch`; on success it shows
+a confirmation. `TRADE_APPLY_URL` is the public origin of this fulfillment service
+(e.g. `https://signup.designerwallcoverings.com`) — set it once in the snippet.
+
+```liquid
+    {%- comment -%} Moderated trade application — shown when "Professional" is selected. {%- endcomment -%}
+    <form class="dwsm-trade" data-dwsm-trade style="display:none;" novalidate>
+      <div class="dwsm-field">
+        <label>Business name</label>
+        <input type="text" name="business_name" autocomplete="organization" required>
+      </div>
+      <div class="dwsm-field">
+        <label>Work email</label>
+        <input type="email" name="email" autocomplete="email" required>
+      </div>
+      <div class="dwsm-field">
+        <label>Resale / business license #</label>
+        <input type="text" name="resale_cert">
+      </div>
+      <input type="button" class="dwsm-submit" data-dwsm-trade-submit value="Apply for Trade">
+      <p class="dwsm-trade-note" style="font-size:.78rem;color:#787878;margin:12px 0 0;text-align:center;">
+        Trade applications are reviewed by our team. We'll email you once approved.
+      </p>
+    </form>
+```
+
+Add one JS constant near the top of the existing `<script>` (right after
+`if(!overlay) return;`, line 66) so the form knows where to POST:
+
+```javascript
+  var TRADE_APPLY_URL = 'https://signup.designerwallcoverings.com/trade/apply'; // <-- set to this service's public URL
+```
+
+### (b2) Hide the sign-in submit + create-account footer while Professional is active
+
+The existing Sign-In button (line 54) and footer (lines 56–59) stay for Retail; wrap
+them so the JS can hide them when Professional is chosen.
+
+BEFORE (lines 54–59):
+
+```liquid
+    <input type="button" class="dwsm-submit" data-dwsm-login="{{ routes.account_login_url }}" value="Sign In / Create Account">
+
+    <div class="dwsm-foot">
+      New to Designer Wallcoverings? <a href="{{ routes.account_register_url }}">Create account</a>
+      &nbsp;&middot;&nbsp; <a href="{{ routes.account_login_url }}#recover">Forgot password</a>
+    </div>
+```
+
+AFTER:
+
+```liquid
+    <div data-dwsm-retail>
+      <input type="button" class="dwsm-submit" data-dwsm-login="{{ routes.account_login_url }}" value="Sign In / Create Account">
+
+      <div class="dwsm-foot">
+        New to Designer Wallcoverings? <a href="{{ routes.account_register_url }}">Create account</a>
+        &nbsp;&middot;&nbsp; <a href="{{ routes.account_login_url }}#recover">Forgot password</a>
+      </div>
+    </div>
+```
+
+### (b3) Toggle the two panels in the existing `setType()` + wire the trade submit
+
+The snippet already has `setType(type)` (lines 83–86). Extend it to reveal the trade
+form for Professional and hide the retail sign-in (and vice-versa), and add a submit
+handler. BEFORE:
+
+```javascript
+  function setType(type){
+    opts.forEach(function(o){o.classList.toggle('active',o.getAttribute('data-dwsm-type')===type);});
+    try{type==='professional'?sessionStorage.setItem('dw_trade','1'):sessionStorage.removeItem('dw_trade');}catch(e){}
+  }
+```
+
+AFTER:
+
+```javascript
+  var retailBox=overlay.querySelector('[data-dwsm-retail]');
+  var googleBox=overlay.querySelector('[data-dwsm-google]');   // added in (c)
+  var tradeForm=overlay.querySelector('[data-dwsm-trade]');
+  function setType(type){
+    opts.forEach(function(o){o.classList.toggle('active',o.getAttribute('data-dwsm-type')===type);});
+    var pro=(type==='professional');
+    if(retailBox) retailBox.style.display=pro?'none':'';
+    if(googleBox) googleBox.style.display=pro?'none':'';
+    if(tradeForm) tradeForm.style.display=pro?'block':'none';
+    try{pro?sessionStorage.setItem('dw_trade','1'):sessionStorage.removeItem('dw_trade');}catch(e){}
+  }
+  // Moderated trade application submit -> POST /trade/apply on the fulfillment service.
+  overlay.addEventListener('click',function(e){
+    if(!e.target.closest('[data-dwsm-trade-submit]')) return;
+    e.preventDefault();
+    var f=tradeForm; if(!f) return;
+    var body={
+      business_name:(f.business_name.value||'').trim(),
+      email:(f.email.value||'').trim(),
+      resale_cert:(f.resale_cert.value||'').trim()
+    };
+    if(!body.email||!body.business_name){alert('Business name and work email are required.');return;}
+    fetch(TRADE_APPLY_URL,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
+      .then(function(r){return r.json();})
+      .then(function(){f.innerHTML='<p style="text-align:center;color:#444;padding:8px 0;">Thanks — your trade application is in review. We\'ll email you once it\'s approved.</p>';})
+      .catch(function(){alert('Sorry, something went wrong. Please try again.');});
+  });
+```
+
+No existing behavior is removed — the retail Sign-In button and the `dw_trade`
+persistence still work exactly as before when Retail is selected.
+
+---
+
+## (c) ADD "Continue with Google" — offered up front, deep-linked to the hosted portal
+
+The hosted account portal's native Google sign-in works, and the
+`customers/create` webhook covers Google signups automatically. Offer it up front
+in the modal. It deep-links to `routes.account_login_url` (the hosted portal, which
+shows the Google button). Reuse the existing `.dwsm-gbtn` / `.dwsm-div` styles that
+already ship in this snippet's `<style>` (lines 17–21) — no new CSS needed.
+
+Insert **between** the toggle (line 50) and the Sign-In submit (line 54), i.e. just
+before the `data-dwsm-retail` wrapper from (b2):
+
+```liquid
+    {%- comment -%} Google sign-up offered up front; deep-links to the hosted portal's native Google. {%- endcomment -%}
+    <div data-dwsm-google>
+      <button type="button" class="dwsm-gbtn" data-dwsm-login="{{ routes.account_login_url }}">
+        <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62z"/><path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.81.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.33A9 9 0 0 0 9 18z"/><path fill="#FBBC05" d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33z"/><path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58C13.47.9 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z"/></svg>
+        Continue with Google
+      </button>
+      <div class="dwsm-div"><i></i><span>or</span><i></i></div>
+    </div>
+```
+
+The Google button reuses the existing `[data-dwsm-login]` click handler (lines
+77–78), so it persists the `dw_trade` flag correctly and navigates to the hosted
+portal — no new JS wiring is required for the button itself (only the `googleBox`
+show/hide reference already added in (b3), so it hides when Professional is active).
+
+---
+
+## Summary of edits
+
+| # | Area | Lines touched | Nature |
+|---|------|---------------|--------|
+| a | Retail copy | 38–50 | text only (header, subhead, 2 chip subtitles) |
+| b1 | Trade form | insert after 50 | new hidden `<form data-dwsm-trade>` + one JS const |
+| b2 | Retail wrapper | 54–59 | wrap existing submit+footer in `<div data-dwsm-retail>` |
+| b3 | setType + submit | 83–86 (+ handler) | show/hide panels; POST to `/trade/apply` |
+| c | Google button | insert 50→54 | new `.dwsm-gbtn` reusing existing styles/handler |
+
+Nothing else in the snippet changes. Classes remain `dwsm-*`; the overlay
+open/close, Escape handling, and hash-open (`#dw-signin`) behavior are untouched.
+
+### Before deploy (Steve's call)
+1. Set `TRADE_APPLY_URL` to this service's real public origin.
+2. Back up the current asset (GET then save) before any PUT.
+3. Confirm the hosted account portal shows Google (it does per the webhook design).
+4. Deploy via the Asset API PUT to theme `144396058675` (or theme editor), then
+   verify with a `?preview_theme_id` render.
diff --git a/lib/config.js b/lib/config.js
index b23d3e9..0a78f42 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -51,8 +51,14 @@ const config = {
   FREE_SAMPLE_COUNT: parseInt(process.env.FREE_SAMPLE_COUNT || '3', 10),
   CURRENCY: process.env.CURRENCY || 'USD',
 
-  // The discount-code alternative (giftcode-discount.js) scopes 100%-off to this
-  // collection of Samples. Empty = the module logs a TODO for Steve to fill in.
+  // WIRED retail path (lib/retail-code.js): the deployed "DW Free Samples" Discount
+  // FUNCTION id. discountCodeAppCreate requires it (functionId). Get it once by
+  // deploying extensions/free-samples-discount (see DEPLOY.md) and set it here.
+  // If unset in DRY_RUN we still simulate but log a clear WARN.
+  DISCOUNT_FUNCTION_ID: process.env.DISCOUNT_FUNCTION_ID || '',
+
+  // Used only by the ALTERNATE lib/giftcode-discount.js (not wired) — scopes 100%-off
+  // to this Samples collection. Empty = that alternate logs a TODO.
   SAMPLES_COLLECTION_ID: process.env.SAMPLES_COLLECTION_ID || '',
 
   // George email sender.
diff --git a/lib/email.js b/lib/email.js
index a1d5bee..8b98a51 100644
--- a/lib/email.js
+++ b/lib/email.js
@@ -75,6 +75,22 @@ function retailGiftEmail({ firstName, code, value, count }) {
   return { subject, html };
 }
 
+// PRIMARY retail template: a unique function-backed sample code (NOT a gift card).
+// Copy is careful NOT to imply instant free product — it hands them a CODE to use
+// at checkout on sample swatches.
+function retailCodeEmail({ firstName, code, count }) {
+  const name = firstName ? ` ${firstName}` : '';
+  const subject = `Your code for ${count} free Designer Wallcoverings samples`;
+  const html = [
+    `<p>Hi${name},</p>`,
+    `<p>Welcome to Designer Wallcoverings! As a thank-you for creating your account, here is your code for <b>${count} free samples</b>.</p>`,
+    `<p style="font-size:18px;">Your code: <b style="letter-spacing:1px;">${esc(code)}</b></p>`,
+    `<p>Add up to ${count} sample swatches to your cart and enter this code at checkout — the samples are on us. (The code works only on sample swatches and can be used once.)</p>`,
+    `<p>Happy sampling!<br><br>Designer Wallcoverings<br>${config.GEORGE_FROM}</p>`,
+  ].join('\n');
+  return { subject, html };
+}
+
 function repNotifyEmail({ repName, applicant }) {
   const subject = `New trade account assigned to you — ${applicant.business_name || applicant.email}`;
   const html = [
@@ -118,4 +134,4 @@ function tradeRejectedEmail() {
 function money(v) { return `$${Number(v).toFixed(2)}`; }
 function esc(s) { return String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
 
-module.exports = { sendEmail, retailGiftEmail, repNotifyEmail, tradeApprovedEmail, tradeRejectedEmail, money, esc };
+module.exports = { sendEmail, retailCodeEmail, retailGiftEmail, repNotifyEmail, tradeApprovedEmail, tradeRejectedEmail, money, esc };
diff --git a/lib/giftcard.js b/lib/giftcard.js
index 5af7b1e..1ec79d1 100644
--- a/lib/giftcard.js
+++ b/lib/giftcard.js
@@ -1,5 +1,11 @@
 'use strict';
-// PRIMARY retail free-samples path: create a Shopify GIFT CARD worth
+// ALTERNATE — NOT WIRED. The retail default is lib/retail-code.js (function-backed
+// unique single-use sample code). This gift-card path is kept for reference only
+// (reachable via POST /admin/retail/issue?mode=giftcard). It is NOT safe as the
+// default: a gift-card balance can be spent on ANY line item (including full rolls),
+// not just sample swatches.
+//
+// (original notes) create a Shopify GIFT CARD worth
 // FREE_SAMPLE_COUNT × SAMPLE_PRICE (default 3 × $4.25 = $12.75), then email the
 // customer the code with a friendly "here are your 3 free samples" template.
 //
diff --git a/lib/giftcode-discount.js b/lib/giftcode-discount.js
index 3bb3978..97e7404 100644
--- a/lib/giftcode-discount.js
+++ b/lib/giftcode-discount.js
@@ -1,5 +1,10 @@
 'use strict';
-// ALTERNATIVE retail free-samples path (Steve chooses at go-live).
+// ALTERNATE — NOT WIRED. The retail default is lib/retail-code.js (function-backed
+// unique single-use sample code). This price-rule + collection-scoped discount path
+// is kept for reference only (reachable via POST /admin/retail/issue?mode=discount).
+// It relies on a Samples COLLECTION to scope the 100%-off; because DW samples share
+// a product with the sellable roll variant, collection scoping is riskier than the
+// function's variant-level targeting used by retail-code.js.
 //
 // Instead of a stored-value gift card, this creates a UNIQUE, SINGLE-USE,
 // 100%-off discount code SCOPED to the Samples product set. Compared to the
diff --git a/lib/retail-code.js b/lib/retail-code.js
new file mode 100644
index 0000000..042c856
--- /dev/null
+++ b/lib/retail-code.js
@@ -0,0 +1,119 @@
+'use strict';
+// PRIMARY retail free-samples path (Steve's decision, TK-10006).
+//
+// For each new retail customer we generate a UNIQUE single-use CODE and create a
+// Shopify CODE app-discount that references the deployed "DW Free Samples"
+// Discount FUNCTION. We then email the customer the code:
+//   "your code for 3 free samples: DWSAMP-XXXXXXXX".
+//
+// WHY a function-backed CODE discount (and NOT a gift card, NOT a plain
+// product/collection-scoped code):
+//   DW samples are VARIANTS (variant.title "Sample", $4.25) that SHARE a product
+//   with the sellable "Yard" variant ($94.42). A plain product- or collection-
+//   scoped 100%-off code would also zero the ROLL/Yard variant on that product —
+//   a real money leak. The Discount FUNCTION (extensions/free-samples-discount)
+//   targets ONLY sample lines (variant.title === "Sample" OR product metafield
+//   custom.is_sample === "true") and caps the freebie at the first 3 sample UNITS.
+//   Attaching that same function to a CODE discount (discountCodeAppCreate) gives
+//   each customer a personal, single-use, sample-only 3-free code with zero risk
+//   to roll pricing.
+//
+// The function is deployed ONCE (see DEPLOY.md) and its id set in
+// DISCOUNT_FUNCTION_ID. No function code change is needed — the identical function
+// backs either an automatic discount OR (as here) a code discount.
+//
+// DRY_RUN-safe: lib/shopify.graphql() short-circuits the mutation, logs the exact
+// WOULD-call + payload, and returns a synthetic { codeAppDiscount: { discountId } }
+// so the email template + result envelope still have something real-shaped to show.
+const crypto = require('crypto');
+const config = require('./config');
+const shopify = require('./shopify');
+const email = require('./email');
+
+// Unique code: DWSAMP-<8 uppercase base32 (Crockford-ish, no ambiguous chars)>.
+const BASE32 = 'ABCDEFGHIJKLMNPQRSTUVWXYZ23456789'; // 32 chars, no 0/O/1/I
+function uniqueCode() {
+  const bytes = crypto.randomBytes(8);
+  let s = '';
+  for (let i = 0; i < 8; i++) s += BASE32[bytes[i] % 32];
+  return 'DWSAMP-' + s;
+}
+
+// The discountCodeAppCreate mutation. Returns the created code discount's id.
+const MUTATION = `
+mutation dwSampleCodeCreate($codeAppDiscount: DiscountCodeAppInput!) {
+  discountCodeAppCreate(codeAppDiscount: $codeAppDiscount) {
+    codeAppDiscount { discountId title status }
+    userErrors { field message }
+  }
+}`.trim();
+
+function buildInput(code) {
+  return {
+    title: `DW Free Samples — ${code}`,
+    functionId: config.DISCOUNT_FUNCTION_ID || null,
+    code,
+    startsAt: new Date().toISOString(),
+    usageLimit: 1,
+    appliesOncePerCustomer: true,
+    customerSelection: { all: true },
+    combinesWith: { orderDiscounts: false, productDiscounts: true, shippingDiscounts: true },
+  };
+}
+
+async function issueRetailCode(customer) {
+  const code = uniqueCode();
+  const input = buildInput(code);
+
+  // If the function id is missing we can still SIMULATE in DRY_RUN, but warn loudly:
+  // at go-live an unset DISCOUNT_FUNCTION_ID means the code discount cannot bind to
+  // the function and would be rejected by Shopify.
+  let warn = null;
+  if (!config.DISCOUNT_FUNCTION_ID) {
+    warn = 'WARN(go-live): DISCOUNT_FUNCTION_ID is unset — deploy the free-samples function and set it, or discountCodeAppCreate will fail (functionId is required). Simulating anyway.';
+    console.warn('[retail-code] ' + warn);
+  }
+
+  const gq = await shopify.graphql(
+    MUTATION,
+    { codeAppDiscount: input },
+    {
+      synthetic: () => ({
+        discountCodeAppCreate: {
+          codeAppDiscount: {
+            discountId: 'gid://shopify/DiscountCodeApp/' + (900000000 + Math.floor(Math.random() * 1e6)),
+            title: input.title,
+            status: 'ACTIVE',
+          },
+          userErrors: [],
+        },
+      }),
+    }
+  );
+
+  const payload = gq.json && gq.json.data && gq.json.data.discountCodeAppCreate ? gq.json.data.discountCodeAppCreate : {};
+  const userErrors = payload.userErrors || [];
+  const discountId = payload.codeAppDiscount ? payload.codeAppDiscount.discountId : null;
+
+  // Email the code to the customer.
+  const firstName = customer.first_name || (customer.email ? customer.email.split('@')[0] : '');
+  const tpl = email.retailCodeEmail({ firstName, code, count: config.FREE_SAMPLE_COUNT });
+  const mail = await email.sendEmail({ to: customer.email, subject: tpl.subject, html: tpl.html, source: 'retail-code' });
+
+  return {
+    path: 'function_code',
+    code,
+    count: config.FREE_SAMPLE_COUNT,
+    functionId: config.DISCOUNT_FUNCTION_ID || null,
+    discountId,
+    usageLimit: input.usageLimit,
+    userErrors,
+    warn,
+    email: { to: customer.email, subject: tpl.subject, dryRun: mail.dryRun || false, ok: mail.ok !== false },
+    shopifyCall: gq.dryRun
+      ? { WOULD: `${gq.method} ${gq.url} (discountCodeAppCreate)`, variables: gq.variables }
+      : { status: gq.status },
+  };
+}
+
+module.exports = { issueRetailCode, uniqueCode, MUTATION, buildInput };
diff --git a/lib/shopify.js b/lib/shopify.js
index 84ff8a6..0d92d08 100644
--- a/lib/shopify.js
+++ b/lib/shopify.js
@@ -113,7 +113,55 @@ async function createWebhook({ topic, address, format }) {
   return request('POST', '/webhooks.json', { webhook: { topic, address, format: format || 'json' } });
 }
 
+// ---- GraphQL Admin API ----
+//
+// A GraphQL POST to /graphql.json. A GraphQL *mutation* is a WRITE, so in DRY_RUN
+// (or with no token) we short-circuit it exactly like a REST write: log the WOULD-
+// call + the query/variables and return a synthetic ok envelope so callers can read
+// back a plausible id. A GraphQL *query* (read) is allowed to hit the network in
+// DRY_RUN, but only if a real token exists — otherwise it too is stubbed.
+//
+//   graphql(query, variables, { synthetic })
+//     - `synthetic(query, variables)` (optional) builds the fake `data` object
+//       returned in DRY_RUN so downstream code sees a realistic shape.
+function isMutation(query) {
+  return /^\s*mutation\b/m.test(query || '') || /(^|\})\s*mutation\b/.test(query || '');
+}
+
+async function graphql(query, variables, opts = {}) {
+  const url = `${base()}/graphql.json`;
+  const write = isMutation(query);
+  const syn = typeof opts.synthetic === 'function' ? opts.synthetic(query, variables) : {};
+
+  if (config.DRY_RUN && write) {
+    log(`DRY_RUN — WOULD POST ${url} (GraphQL mutation)`);
+    log('DRY_RUN — query:', query.replace(/\s+/g, ' ').trim());
+    log('DRY_RUN — variables:', JSON.stringify(variables, null, 2));
+    return { ok: true, dryRun: true, method: 'POST', url, query, variables, status: 0, json: { data: syn } };
+  }
+
+  if (!config.SHOPIFY_FULFILLMENT_TOKEN) {
+    log(`NO TOKEN — WOULD POST ${url} (GraphQL ${write ? 'mutation' : 'query'}; SHOPIFY_FULFILLMENT_TOKEN unset)`);
+    log('NO TOKEN — query:', query.replace(/\s+/g, ' ').trim());
+    log('NO TOKEN — variables:', JSON.stringify(variables, null, 2));
+    return { ok: true, dryRun: true, noToken: true, method: 'POST', url, query, variables, status: 0, json: { data: syn } };
+  }
+
+  const res = await fetch(url, {
+    method: 'POST',
+    headers: {
+      'X-Shopify-Access-Token': config.SHOPIFY_FULFILLMENT_TOKEN,
+      'Content-Type': 'application/json',
+      'Accept': 'application/json',
+    },
+    body: JSON.stringify({ query, variables: variables || {} }),
+  });
+  let json = null;
+  try { json = await res.json(); } catch { json = null; }
+  return { ok: res.ok, status: res.status, method: 'POST', url, json };
+}
+
 module.exports = {
-  request, createGiftCard, updateCustomer, getCustomer, addTags,
+  request, graphql, createGiftCard, updateCustomer, getCustomer, addTags,
   setCustomerMetafield, createWebhook, base,
 };
diff --git a/scripts/selftest.js b/scripts/selftest.js
index 4f2b8fc..56f5d2c 100644
--- a/scripts/selftest.js
+++ b/scripts/selftest.js
@@ -21,8 +21,12 @@ const fs = require('fs');
 const path = require('path');
 
 const TEST_SECRET = 'selftest-webhook-secret-abc123';
+const TEST_FUNCTION_ID = 'gid://shopify/Function/selftest-free-samples-fn';
 process.env.DRY_RUN = '1';
 process.env.SHOPIFY_WEBHOOK_SECRET = TEST_SECRET;
+// Pin a fake function id so the retail-code path asserts the functionId is carried
+// into the discountCodeAppCreate payload (in prod this is the real deployed id).
+process.env.DISCOUNT_FUNCTION_ID = TEST_FUNCTION_ID;
 // Isolate persisted state to a temp dir so the roster is real but the cursor +
 // applications are throwaway. We copy the seed reps.json in.
 const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'dwsf-selftest-'));
@@ -49,8 +53,7 @@ function restore() {
 
 const config = require('../lib/config');
 const webhook = require('../lib/webhook');
-const giftcard = require('../lib/giftcard');
-const giftcodeDiscount = require('../lib/giftcode-discount');
+const retailCode = require('../lib/retail-code');
 const trade = require('../lib/trade');
 const reps = require('../lib/reps');
 
@@ -62,35 +65,62 @@ let failures = 0;
 async function main() {
   console.log('DW signup fulfillment — SELFTEST (DRY_RUN=' + config.DRY_RUN + ')');
   console.log('Store: ' + config.SHOP_DOMAIN + '  API ' + config.SHOPIFY_API_VERSION);
-  console.log('Sample economics: ' + config.FREE_SAMPLE_COUNT + ' × $' + config.SAMPLE_PRICE + ' = $' + config.SAMPLE_GIFT_VALUE + ' gift value');
+  console.log('Retail path: function-backed unique CODE (first ' + config.FREE_SAMPLE_COUNT + ' sample units free) · DISCOUNT_FUNCTION_ID=' + (config.DISCOUNT_FUNCTION_ID || '(unset)'));
   if (!config.DRY_RUN) { fail('DRY_RUN is OFF — refusing to run selftest that would make live writes'); return; }
 
   // ---------------------------------------------------------------------------
-  hr('(a) customers/create webhook — VALID HMAC → retail gift-card path');
+  hr('(a) customers/create webhook — VALID HMAC → function-backed unique CODE path');
   const fakeCustomer = { id: 8675309, email: 'newshopper@example.com', first_name: 'Dana', created_at: new Date().toISOString() };
   const raw = Buffer.from(JSON.stringify(fakeCustomer), 'utf8');
   const goodHmac = webhook.sign(raw, TEST_SECRET);
   console.log('  computed X-Shopify-Hmac-Sha256 = ' + goodHmac);
   if (webhook.verify(raw, goodHmac)) ok('HMAC verify ACCEPTED the valid signature'); else fail('valid HMAC was rejected');
 
-  console.log('  --- retail gift-card issuance (what it WOULD do) ---');
-  const giftResult = await giftcard.issueRetailGiftCode(fakeCustomer);
-  console.log('  result: ' + JSON.stringify(giftResult, null, 2));
-  if (giftResult.path === 'gift_card' && giftResult.value === config.SAMPLE_GIFT_VALUE) ok('gift card face value = $' + giftResult.value + ' (' + giftResult.count + ' samples)');
-  else fail('gift-card value mismatch');
-  if (giftResult.email && giftResult.email.dryRun) ok('customer email dry-run logged (no real send)'); else fail('email not dry-run');
+  console.log('  --- retail function-code issuance (what it WOULD do) ---');
+  const codeResult = await retailCode.issueRetailCode(fakeCustomer);
+  console.log('  result: ' + JSON.stringify(codeResult, null, 2));
+  if (codeResult.path === 'function_code') ok('retail path is function_code (not gift card / not collection code)');
+  else fail('retail path is not function_code');
+  // Unique code shape: DWSAMP-<8 uppercase base32>
+  if (/^DWSAMP-[A-Z2-9]{8}$/.test(codeResult.code)) ok('unique code has the DWSAMP-XXXXXXXX shape: ' + codeResult.code);
+  else fail('bad code shape: ' + codeResult.code);
+  // WOULD call discountCodeAppCreate with the function id + usageLimit 1 + the code.
+  const would = codeResult.shopifyCall && codeResult.shopifyCall.WOULD;
+  const vars = codeResult.shopifyCall && codeResult.shopifyCall.variables && codeResult.shopifyCall.variables.codeAppDiscount;
+  if (would && /discountCodeAppCreate/.test(would)) ok('WOULD call discountCodeAppCreate (' + would + ')');
+  else fail('did not record a WOULD discountCodeAppCreate call');
+  if (vars && vars.functionId === TEST_FUNCTION_ID) ok('payload carries the deployed functionId (' + vars.functionId + ')');
+  else fail('payload missing/incorrect functionId');
+  if (vars && vars.usageLimit === 1) ok('payload usageLimit = 1 (single-use)'); else fail('usageLimit is not 1');
+  if (vars && vars.appliesOncePerCustomer === true) ok('payload appliesOncePerCustomer = true'); else fail('appliesOncePerCustomer not set');
+  if (vars && vars.code === codeResult.code) ok('payload code matches the emitted unique code'); else fail('payload code mismatch');
+  if (vars && vars.combinesWith && vars.combinesWith.orderDiscounts === false) ok('combinesWith.orderDiscounts = false'); else fail('combinesWith not set as specified');
+  if (codeResult.functionId === TEST_FUNCTION_ID) ok('result records the functionId'); else fail('result missing functionId');
+  if (!codeResult.warn) ok('DISCOUNT_FUNCTION_ID set → no unset-function WARN'); else fail('unexpected WARN with function id set: ' + codeResult.warn);
+  if (codeResult.email && codeResult.email.dryRun) ok('WOULD email the code to customer (dry-run, no real send)'); else fail('code email not dry-run');
 
   // ---------------------------------------------------------------------------
   hr('(b) customers/create webhook — INVALID HMAC → rejected');
   if (!webhook.verify(raw, 'this-is-not-the-right-signature')) ok('HMAC verify REJECTED a bad signature'); else fail('bad HMAC was accepted');
 
   // ---------------------------------------------------------------------------
-  hr('(a2) ALTERNATIVE retail path — 100%-off discount code (for go-live choice)');
-  const discResult = await giftcodeDiscount.issueRetailDiscountCode(fakeCustomer);
-  console.log('  result: ' + JSON.stringify(discResult, null, 2));
-  if (discResult.path === 'discount_code') ok('discount-code alternative built its price_rule + discount_code payloads');
-  else fail('discount-code path failed');
-  if (discResult.todo) console.log('  NOTE: ' + discResult.todo);
+  hr('(a2) retail-code with DISCOUNT_FUNCTION_ID UNSET → simulates + WARNs');
+  const savedFid = process.env.DISCOUNT_FUNCTION_ID;
+  delete process.env.DISCOUNT_FUNCTION_ID;
+  delete require.cache[require.resolve('../lib/config')];
+  delete require.cache[require.resolve('../lib/shopify')];
+  delete require.cache[require.resolve('../lib/email')];
+  delete require.cache[require.resolve('../lib/retail-code')];
+  const retailCodeNoFid = require('../lib/retail-code');
+  const noFidResult = await retailCodeNoFid.issueRetailCode(fakeCustomer);
+  if (noFidResult.path === 'function_code' && noFidResult.warn) ok('unset function id → still simulated, WARN raised: ' + noFidResult.warn);
+  else fail('expected a simulate+WARN when DISCOUNT_FUNCTION_ID is unset');
+  // restore env + module cache so the rest of the suite uses the pinned id.
+  process.env.DISCOUNT_FUNCTION_ID = savedFid;
+  delete require.cache[require.resolve('../lib/config')];
+  delete require.cache[require.resolve('../lib/shopify')];
+  delete require.cache[require.resolve('../lib/email')];
+  delete require.cache[require.resolve('../lib/retail-code')];
 
   // ---------------------------------------------------------------------------
   hr('(c) trade application → moderated approve');
diff --git a/server.js b/server.js
index e50e4c0..aa2b144 100644
--- a/server.js
+++ b/server.js
@@ -11,8 +11,9 @@
 const express = require('express');
 const config = require('./lib/config');
 const webhook = require('./lib/webhook');
-const giftcard = require('./lib/giftcard');
-const giftcodeDiscount = require('./lib/giftcode-discount');
+const retailCode = require('./lib/retail-code');       // WIRED default: function-backed unique sample code
+const giftcard = require('./lib/giftcard');            // alternate (not wired)
+const giftcodeDiscount = require('./lib/giftcode-discount'); // alternate (not wired)
 const trade = require('./lib/trade');
 const reps = require('./lib/reps');
 
@@ -40,8 +41,8 @@ app.post('/webhooks/customers/create',
     res.status(200).json({ ok: true, received: true });
     try {
       console.log(`[webhook] customers/create id=${customer.id} email=${customer.email}`);
-      const result = await giftcard.issueRetailGiftCode(customer);
-      console.log('[webhook] retail gift-card result:', JSON.stringify(result));
+      const result = await retailCode.issueRetailCode(customer);
+      console.log('[webhook] retail function-code result:', JSON.stringify(result));
     } catch (e) {
       console.error('[webhook] fulfillment error:', e.message);
     }
@@ -91,14 +92,17 @@ app.post('/admin/trade/:id/reject', adminAuth, async (req, res) => {
 app.get('/reps/next', adminAuth, (_req, res) => res.json({ assigned: reps.houseAccount() }));
 
 // Manual retail-code trigger (admin) — handy for go-live smoke test without a
-// real webhook. Honors the ?mode=discount query to exercise the alternative path.
+// real webhook. Default is the WIRED function-backed code path. The two ALTERNATES
+// are reachable only for comparison: ?mode=giftcard and ?mode=discount.
 app.post('/admin/retail/issue', adminAuth, async (req, res) => {
   const customer = req.body || {};
   if (!customer.email) return res.status(400).json({ ok: false, error: 'email required' });
-  const result = req.query.mode === 'discount'
-    ? await giftcodeDiscount.issueRetailDiscountCode(customer)
-    : await giftcard.issueRetailGiftCode(customer);
-  res.json({ ok: true, mode: req.query.mode || 'gift_card', result });
+  let mode = req.query.mode || 'code';
+  let result;
+  if (mode === 'giftcard') result = await giftcard.issueRetailGiftCode(customer);         // alternate
+  else if (mode === 'discount') result = await giftcodeDiscount.issueRetailDiscountCode(customer); // alternate
+  else { mode = 'code'; result = await retailCode.issueRetailCode(customer); }             // WIRED default
+  res.json({ ok: true, mode, result });
 });
 
 // --- Admin HTML with created date+time chip (Steve's admin-card rule) ---

← d786b75 rep assignment: fixed DW House Account (not round-robin) per  ·  back to Dw Signup Fulfillment  ·  (newest)