← 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
M .env.exampleM DEPLOY.mdM README.mdA docs/theme-modal-proposal.mdM lib/config.jsM lib/email.jsM lib/giftcard.jsM lib/giftcode-discount.jsA lib/retail-code.jsM lib/shopify.jsM scripts/selftest.jsM server.js
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>
+ · <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>
+ · <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 => ({ '&': '&', '<': '<', '>': '>' }[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)