[object Object]

← back to Dw Signup Fulfillment

initial scaffold: DW signup fulfillment (retail gift-card + moderated trade approval + rep round-robin), dry-run

bc2558d513d554f1b7045b8130a080bd23ede03d · 2026-07-28 07:47:32 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit bc2558d513d554f1b7045b8130a080bd23ede03d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 28 07:47:32 2026 -0700

    initial scaffold: DW signup fulfillment (retail gift-card + moderated trade approval + rep round-robin), dry-run
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .env.example             |  52 +++
 .gitignore               |  15 +
 DEPLOY.md                | 169 ++++++++++
 README.md                | 127 ++++++++
 data/reps.json           |  42 +++
 ecosystem.config.js      |  23 ++
 lib/config.js            |  72 ++++
 lib/email.js             | 121 +++++++
 lib/giftcard.js          |  41 +++
 lib/giftcode-discount.js |  79 +++++
 lib/reps.js              |  72 ++++
 lib/shopify.js           | 119 +++++++
 lib/trade.js             | 139 ++++++++
 lib/webhook.js           |  29 ++
 package-lock.json        | 829 +++++++++++++++++++++++++++++++++++++++++++++++
 package.json             |  17 +
 scripts/selftest.js      | 146 +++++++++
 server.js                | 163 ++++++++++
 18 files changed, 2255 insertions(+)

diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..2d84325
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,52 @@
+# dw-signup-fulfillment — environment. Copy to .env and fill for go-live.
+# .env is gitignored. NEVER commit real tokens.
+
+# --- Safety switch --------------------------------------------------------
+# DRY_RUN defaults ON. Only the literal 0 / false / no disables it, so a missing
+# or malformed value can NEVER accidentally arm live writes. Keep this 1 until
+# you have followed DEPLOY.md end-to-end.
+DRY_RUN=1
+
+# --- Service --------------------------------------------------------------
+PORT=9856
+
+# --- Shopify Admin API ----------------------------------------------------
+SHOP_DOMAIN=designer-laboratory-sandbox.myshopify.com
+SHOPIFY_API_VERSION=2024-10
+
+# The fulfillment token. It needs: write_customers, write_gift_cards,
+# write_discounts, write_price_rules, read_customers, plus read/write webhooks.
+# It can be set to the SHOPIFY_DRAFT_TOKEN value from
+# ~/Projects/secrets-manager/.env (that token ends a43b and already carries all
+# 139 of those scopes). The service reads it via config.js from the secrets
+# master if this is left blank, but prefer setting it explicitly here.
+SHOPIFY_FULFILLMENT_TOKEN=
+
+# Shopify signs each webhook body with this secret (X-Shopify-Hmac-Sha256).
+# You get it from the webhook registration response / the app's webhook settings.
+SHOPIFY_WEBHOOK_SECRET=
+
+# --- Retail sample economics ---------------------------------------------
+# Gift-card face value = FREE_SAMPLE_COUNT × SAMPLE_PRICE  (default 3 × 4.25 = 12.75)
+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.
+SAMPLES_COLLECTION_ID=
+
+# --- George email (:9850) -------------------------------------------------
+GEORGE_URL=http://127.0.0.1:9850
+GEORGE_ACCOUNT=steve-office
+GEORGE_FROM=steve@designerwallcoverings.com
+# These are normally resolved automatically from ~/Projects/george-gmail/.env
+# (Mac) or ~/DW-Agents/gmail-agent/.env (Kamatera). Override here only if needed.
+# GEORGE_EXTERNAL_SEND_TOKEN=
+# GEORGE_BASIC_AUTH=admin:
+
+# --- Admin review surface (basic-auth) ------------------------------------
+ADMIN_USER=admin
+ADMIN_PASS=DW2024!
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b990524
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+node_modules/
+.env
+.env.*
+!.env.example
+*.log
+tmp/
+dist/
+build/
+.next/
+.DS_Store
+
+# Runtime data — seeded roster is KEPT (data/reps.json), mutable state is ignored.
+data/*.jsonl
+data/rep-cursor.json
+!data/reps.json
diff --git a/DEPLOY.md b/DEPLOY.md
new file mode 100644
index 0000000..11f4122
--- /dev/null
+++ b/DEPLOY.md
@@ -0,0 +1,169 @@
+# DEPLOY — dw-signup-fulfillment go-live runbook
+
+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").
+> - **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 +
+>   applicant.
+
+---
+
+## 0. Pre-flight (still DRY_RUN)
+
+```sh
+cd ~/Projects/dw-signup-fulfillment
+npm install
+node scripts/selftest.js      # must print ALL CHECKS PASSED and exit 0
+```
+
+Confirm the store target is the LIVE prod store (legacy misnomer):
+`designer-laboratory-sandbox.myshopify.com`, Admin API `2024-10`.
+
+---
+
+## 1. Set the Shopify token
+
+`SHOPIFY_FULFILLMENT_TOKEN` needs scopes:
+`write_customers, write_gift_cards, write_discounts, write_price_rules,
+read_customers` + read/write webhooks.
+
+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:
+
+```sh
+# In this project's .env  (copy from .env.example first)
+SHOPIFY_FULFILLMENT_TOKEN=<value of SHOPIFY_DRAFT_TOKEN, ends a43b>
+```
+
+Never hardcode it in source; never echo it. The service reads it from `.env` /
+env only.
+
+---
+
+## 2. 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
+register via the Admin UI, copy the signing secret it shows into `.env`:
+
+```sh
+SHOPIFY_WEBHOOK_SECRET=<the webhook signing secret>
+```
+
+---
+
+## 3. Deploy the service (still DRY_RUN)
+
+```sh
+pm2 start ecosystem.config.js         # DRY_RUN=1 by env in ecosystem
+pm2 save
+curl -s http://127.0.0.1:9856/healthz # -> {"ok":true,...,"dry_run":true}
+```
+
+Make sure the service is reachable at a **public HTTPS URL** (Kamatera vhost /
+Cloudflare) — Shopify must be able to POST to it. Note that URL; call it
+`$PUBLIC_URL` below (e.g. `https://signup.designerwallcoverings.com`).
+
+Because `/healthz` returns 200 **before** any auth (fleet rule), the keepalive /
+uptime probes read it correctly.
+
+---
+
+## 4. 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.
+
+```sh
+SHOP=designer-laboratory-sandbox.myshopify.com
+TOKEN=<SHOPIFY_FULFILLMENT_TOKEN>
+PUBLIC_URL=https://signup.designerwallcoverings.com   # <-- your real URL
+
+curl -s -X POST "https://$SHOP/admin/api/2024-10/webhooks.json" \
+  -H "X-Shopify-Access-Token: $TOKEN" \
+  -H "Content-Type: application/json" \
+  -d "{\"webhook\":{\"topic\":\"customers/create\",\"address\":\"$PUBLIC_URL/webhooks/customers/create\",\"format\":\"json\"}}"
+```
+
+Record the returned webhook **id** (needed for rollback). Put the signing secret
+into `SHOPIFY_WEBHOOK_SECRET` (step 2) if you didn't already.
+
+---
+
+## 5. Flip DRY_RUN off
+
+Only now:
+
+```sh
+pm2 set  # or edit ecosystem.config.js env.DRY_RUN to '0', OR set in .env:
+#   DRY_RUN=0
+pm2 restart dw-signup-fulfillment --update-env
+curl -s http://127.0.0.1:9856/healthz   # -> "dry_run":false
+```
+
+---
+
+## 6. 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.
+- `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.
+
+---
+
+## ROLLBACK
+
+To stand down instantly:
+
+1. **Flip DRY_RUN back on** (stops all live writes immediately):
+   ```sh
+   # set DRY_RUN=1 in .env or ecosystem.config.js env
+   pm2 restart dw-signup-fulfillment --update-env
+   curl -s http://127.0.0.1:9856/healthz   # -> "dry_run":true
+   ```
+
+2. **Delete the webhook** so Shopify stops calling us:
+   ```sh
+   SHOP=designer-laboratory-sandbox.myshopify.com
+   TOKEN=<SHOPIFY_FULFILLMENT_TOKEN>
+   WEBHOOK_ID=<id from step 4>
+   # 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" \
+     -H "X-Shopify-Access-Token: $TOKEN"
+   ```
+
+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.
+
+---
+
+## Optional: switch the retail path to the discount-code alternative
+
+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`):
+
+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.
+
+See README "Gift card vs discount code" for the tradeoff.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..18e22d5
--- /dev/null
+++ b/README.md
@@ -0,0 +1,127 @@
+# dw-signup-fulfillment
+
+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
+go-live (see `DEPLOY.md`).
+
+## The problem it fixes
+
+New Customer Accounts (passwordless signup on shopify.com) is REQUIRED, so the
+theme's RETAIL vs PROFESSIONAL tier choice — which only sets `sessionStorage` on
+the DW origin — is discarded cross-origin. **Every new customer arrives
+untagged**, so:
+
+- **Retail** shoppers don't get the free samples they were promised.
+- **Professionals** don't get the `trade` tag, so their memo samples cost $4.25
+  instead of $0 (the `trade` tag is exactly what flips memos free via the Regios
+  tag-gated discount — verified).
+
+## What it does
+
+| 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` |
+
+Trade access is **moderated approval** — a professional signup is NOT tagged
+automatically; an application is submitted, reviewed at `/admin/trade`, and only
+tagged `trade` on approval.
+
+## Endpoints
+
+- `GET /healthz` — 200, unauthenticated, before any auth (fleet rule).
+- `POST /webhooks/customers/create` — Shopify webhook; verifies
+  `X-Shopify-Hmac-Sha256` over the RAW body using `SHOPIFY_WEBHOOK_SECRET`.
+- `POST /trade/apply` — `{ email, business_name, resale_cert, phone, ... }` →
+  appends to `data/trade-applications.jsonl` as `pending` with `created_at`.
+- `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).
+- `GET /reps/next` — peek the next round-robin rep + cursor.
+
+## Run
+
+```sh
+npm install
+node scripts/selftest.js     # dry-run proof: prints every Admin call + email it WOULD make, exits 0
+node server.js               # listens :9856 (PORT env), DRY_RUN on
+# or: pm2 start ecosystem.config.js
+```
+
+## Design decisions
+
+### How "3 samples" is bounded (gift-card path)
+
+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.
+
+### Gift card vs discount code (the tradeoff)
+
+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.
+
+| | 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.
+
+### DRY_RUN safety model
+
+`DRY_RUN` defaults ON; only the literal `0` / `false` / `no` disables it, so a
+missing or malformed value can never accidentally arm live writes. In DRY_RUN,
+`lib/shopify.js` short-circuits every write verb (POST/PUT/DELETE) — logging the
+method + URL + payload and returning a synthetic ok — and `lib/email.js` logs
+the email it WOULD send instead of hitting George.
+
+## Env vars for go-live
+
+Set in `.env` (see `.env.example`; `.env` is gitignored):
+
+- `DRY_RUN=0` — the deliberate go-live flip (last, per DEPLOY.md).
+- `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).
+- `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`.
+
+George email creds (`GEORGE_EXTERNAL_SEND_TOKEN`, `GEORGE_BASIC_AUTH`) are
+resolved automatically from `~/Projects/george-gmail/.env` (Mac) or
+`~/DW-Agents/gmail-agent/.env` (Kamatera).
+
+## George email note
+
+`lib/email.js` replicates the working fleet George caller
+(`astek-landing/lib/vendor-requests.js`): `POST http://127.0.0.1:9850/api/send`
+with Basic auth + the `X-Send-Approval` external-send token, `from` =
+`steve@designerwallcoverings.com`, `account` = `steve-office`. In DRY_RUN it
+only logs. The live path is written to match the fleet's proven caller but has
+NOT been exercised against George from this service (nothing is sent in
+dry-run) — confirm the George `.env` has `GEORGE_EXTERNAL_SEND_TOKEN` at go-live.
diff --git a/data/reps.json b/data/reps.json
new file mode 100644
index 0000000..fa371f8
--- /dev/null
+++ b/data/reps.json
@@ -0,0 +1,42 @@
+[
+  {"id":1,"name":"Patricia Langham","agency":"Langham Associates","territories":["CA","NV","AZ"],"lines":["Innovations USA","Phillip Jeffries","Elitis"],"city":"Los Angeles","vertical":["Residential","Contract"],"contact_url":"https://langhamassociates.com/contact"},
+  {"id":2,"name":"Marcus Rivera","agency":"Rivera Design Group","territories":["CA","OR","WA"],"lines":["Schumacher","Stark Carpet","Clarence House"],"city":"San Francisco","vertical":["Residential","Hospitality"],"contact_url":"https://riveradesigngroup.com/contact"},
+  {"id":3,"name":"Diane Forsythe","agency":"KDR Designer Showrooms","territories":["IL","WI","MN"],"lines":["Romo","Osborne & Little","Zoffany"],"city":"Chicago","vertical":["Residential","Commercial"],"contact_url":"https://kdrshowrooms.com/contact"},
+  {"id":4,"name":"Thomas Beaumont","agency":"Beaumont Rep Services","territories":["NY","NJ","CT"],"lines":["Pollack","S. Harris","Pindler"],"city":"New York","vertical":["Contract","Healthcare"],"contact_url":"https://beaumontrepservices.com/contact"},
+  {"id":5,"name":"Sandra Okoro","agency":"Pacific Rep Alliance","territories":["CA","HI"],"lines":["Lee Jofa","Groundworks","GP & J Baker"],"city":"Los Angeles","vertical":["Residential","Hospitality"],"contact_url":"https://pacificrepalliance.com/contact"},
+  {"id":6,"name":"James Whitfield","agency":"Southeast Design Alliance","territories":["GA","FL","SC","NC"],"lines":["Kravet","Fabricut","Stout"],"city":"Atlanta","vertical":["Residential","Commercial"],"contact_url":"https://southeastdesignalliance.com/contact"},
+  {"id":7,"name":"Rachel Kim","agency":"Kim & Associates","territories":["TX","OK","NM"],"lines":["Cowtan & Tout","Brunswick & Fils","Colefax & Fowler"],"city":"Dallas","vertical":["Residential"],"contact_url":"https://kimassociatesrep.com/contact"},
+  {"id":8,"name":"David Marchetti","agency":"Marchetti Contract Group","territories":["NY","PA","MD","DC"],"lines":["Designtex","Maharam","Arc-Com"],"city":"New York","vertical":["Contract","Healthcare","Education"],"contact_url":"https://marchetticontract.com/contact"},
+  {"id":9,"name":"Laura Chen","agency":"West Coast Trade Reps","territories":["CA","WA","OR","ID"],"lines":["Holly Hunt","Donghia","Edelman Leather"],"city":"Los Angeles","vertical":["Residential","Hospitality"],"contact_url":"https://westcoasttradereps.com/contact"},
+  {"id":10,"name":"Gregory Hoffman","agency":"Hoffman Design Source","territories":["CO","UT","WY","MT"],"lines":["Brunschwig & Fils","Manuel Canovas","Pierre Frey"],"city":"Denver","vertical":["Residential"],"contact_url":"https://hoffmandesignsource.com/contact"},
+  {"id":11,"name":"Monica Sullivan","agency":"Hospitality Rep Group","territories":["NV","AZ","CA"],"lines":["Carnegie","CF Stinson","Momentum"],"city":"Las Vegas","vertical":["Hospitality","F&B","Resort"],"contact_url":"https://hospitalityrepgroup.com/contact"},
+  {"id":12,"name":"William Park","agency":"Park & Partners","territories":["WA","OR","ID","MT"],"lines":["Innovations USA","Bolta","Bolon"],"city":"Seattle","vertical":["Contract","Commercial"],"contact_url":"https://parkandpartners.com/contact"},
+  {"id":13,"name":"Elizabeth Torres","agency":"Torres Textile Reps","territories":["TX","LA","MS","AR"],"lines":["Robert Allen","Duralee","P/Kaufmann"],"city":"Houston","vertical":["Residential","Contract"],"contact_url":"https://torrestextilesreps.com/contact"},
+  {"id":14,"name":"Christopher Vance","agency":"Vance Commercial Interiors","territories":["IL","IN","OH","MI"],"lines":["Interface","Milliken","Shaw Contract"],"city":"Chicago","vertical":["Commercial","Healthcare","Education"],"contact_url":"https://vancecommercialinteriors.com/contact"},
+  {"id":15,"name":"Ashley Greenwood","agency":"Greenwood Luxury Reps","territories":["FL","GA","AL"],"lines":["Elitis","Casamance","Texam"],"city":"Miami","vertical":["Residential","Hospitality","Resort"],"contact_url":"https://greenwoodluxuryreps.com/contact"},
+  {"id":16,"name":"Nathan Brody","agency":"Brody & Associates","territories":["MA","RI","VT","NH","ME"],"lines":["Jim Thompson","Osborne & Little","Etro Home"],"city":"Boston","vertical":["Residential","Hospitality"],"contact_url":"https://brodyassociatesrep.com/contact"},
+  {"id":17,"name":"Samantha Wu","agency":"Wu Design Reps","territories":["CA","NV"],"lines":["Arte International","Elitis","Nlxl"],"city":"Los Angeles","vertical":["Residential","Boutique"],"contact_url":"https://wudesignreps.com/contact"},
+  {"id":18,"name":"Robert Ashton","agency":"Ashton Contract Solutions","territories":["NY","NJ","PA","CT","MA"],"lines":["Knoll Textiles","Herman Miller","Kvadrat"],"city":"New York","vertical":["Contract","Office","Education"],"contact_url":"https://ashtoncontractsolutions.com/contact"},
+  {"id":19,"name":"Jennifer Caldwell","agency":"Caldwell Showroom Alliance","territories":["CA","AZ","NM"],"lines":["Schumacher","Groundworks","Stark Carpet"],"city":"Phoenix","vertical":["Residential","Commercial"],"contact_url":"https://caldwellshowroomalliance.com/contact"},
+  {"id":20,"name":"Michael Stern","agency":"Stern Rep Network","territories":["NY","FL","TX"],"lines":["Dedar","Loro Piana Interiors","Zimmer + Rohde"],"city":"New York","vertical":["Residential","Luxury"],"contact_url":"https://sternrepnetwork.com/contact"},
+  {"id":21,"name":"Caroline Baptiste","agency":"Baptiste Hotel Reps","territories":["FL","TN","NC","SC"],"lines":["Maharam","Momentum","CF Stinson"],"city":"Miami","vertical":["Hospitality","Hotel Brand","F&B"],"contact_url":"https://baptistehotelreps.com/contact"},
+  {"id":22,"name":"Steven Nakamura","agency":"Nakamura Pacific Group","territories":["CA","OR","WA","HI","AK"],"lines":["Innovations USA","Phillip Jeffries","Knoll Textiles"],"city":"Los Angeles","vertical":["Contract","Commercial"],"contact_url":"https://nakamurapacificgroup.com/contact"},
+  {"id":23,"name":"Diana Leblanc","agency":"Leblanc Design Associates","territories":["LA","MS","AL","TN"],"lines":["Duralee","Fabricut","Trend"],"city":"New Orleans","vertical":["Residential","Hospitality"],"contact_url":"https://leblancdesignassociates.com/contact"},
+  {"id":24,"name":"Richard Flores","agency":"Flores Healthcare Interiors","territories":["TX","NM","OK"],"lines":["Designtex","Arc-Com","Bolta"],"city":"Dallas","vertical":["Healthcare","Commercial"],"contact_url":"https://floreshealthcareinteriors.com/contact"},
+  {"id":25,"name":"Allison Hayes","agency":"Hayes Boutique Rep","territories":["NY","CT","NJ"],"lines":["Dedar","Etro Home","Loro Piana Interiors"],"city":"New York","vertical":["Residential","Boutique","Luxury"],"contact_url":"https://hayesboutiquerep.com/contact"},
+  {"id":26,"name":"Kevin Marsh","agency":"Marsh Education Group","territories":["CA","OR","WA","NV"],"lines":["Interface","Milliken","Patcraft"],"city":"San Francisco","vertical":["Education","Healthcare","Commercial"],"contact_url":"https://marsheducationgroup.com/contact"},
+  {"id":27,"name":"Tara Coleman","agency":"Coleman Resort Alliance","territories":["HI","CA","FL"],"lines":["Elitis","Casamance","Perennials"],"city":"Los Angeles","vertical":["Resort","Hospitality","F&B"],"contact_url":"https://colemanresortalliance.com/contact"},
+  {"id":28,"name":"Peter Donovan","agency":"Donovan Office Solutions","territories":["IL","MN","WI","IA"],"lines":["Knoll Textiles","Herman Miller","Kvadrat"],"city":"Chicago","vertical":["Office","Contract"],"contact_url":"https://donovanofficesolutions.com/contact"},
+  {"id":29,"name":"Angela Ross","agency":"Ross Residential Reps","territories":["GA","FL","SC"],"lines":["Kravet","Lee Jofa","GP & J Baker"],"city":"Atlanta","vertical":["Residential"],"contact_url":"https://rossresidentialreps.com/contact"},
+  {"id":30,"name":"Frank Deluca","agency":"DeLuca Contract Group","territories":["CA","NV","AZ"],"lines":["Carnegie","Designtex","Boltaflex"],"city":"Los Angeles","vertical":["Healthcare","Commercial","Office"],"contact_url":"https://delucacontractgroup.com/contact"},
+  {"id":31,"name":"Nicole Huang","agency":"Huang Luxury Interiors Rep","territories":["NY","CA"],"lines":["Zimmer + Rohde","Dedar","Sahco"],"city":"New York","vertical":["Residential","Luxury","Boutique"],"contact_url":"https://huangluxuryinteriors.com/contact"},
+  {"id":32,"name":"Andrew Blake","agency":"Blake Hospitality Reps","territories":["TN","KY","VA","WV"],"lines":["Maharam","Momentum","Bolta"],"city":"Nashville","vertical":["Hospitality","Hotel Brand","F&B"],"contact_url":"https://blakehospitalityreps.com/contact"},
+  {"id":33,"name":"Stephanie Osei","agency":"Osei Commercial Alliance","territories":["GA","AL","TN","SC"],"lines":["Interface","Shaw Contract","Patcraft"],"city":"Atlanta","vertical":["Commercial","Office","Retail"],"contact_url":"https://oseicommercialalliance.com/contact"},
+  {"id":34,"name":"Timothy Grant","agency":"Grant & Grant Design Reps","territories":["PA","DE","MD","VA"],"lines":["Schumacher","Brunschwig & Fils","Clarence House"],"city":"Philadelphia","vertical":["Residential","Contract"],"contact_url":"https://grantgrantdesignreps.com/contact"},
+  {"id":35,"name":"Lisa Johansson","agency":"Nordic West Reps","territories":["WA","OR","ID","MT","WY"],"lines":["Bolon","Baux","Kvadrat"],"city":"Seattle","vertical":["Commercial","Office","Education"],"contact_url":"https://nordicwestreps.com/contact"},
+  {"id":36,"name":"Omar Farouk","agency":"Farouk Luxury Reps","territories":["CA","TX","NY"],"lines":["Loro Piana Interiors","Dedar","Sahco"],"city":"Los Angeles","vertical":["Luxury","Residential","Boutique"],"contact_url":"https://faroukluxuryreps.com/contact"},
+  {"id":37,"name":"Catherine Moore","agency":"Moore Wallcovering Reps","territories":["IL","MO","KS","NE"],"lines":["Innovations USA","Arte International","Nlxl"],"city":"Chicago","vertical":["Residential","Contract"],"contact_url":"https://moorewallcoveringreps.com/contact"},
+  {"id":38,"name":"Henry Vasquez","agency":"Vasquez Southwest Reps","territories":["AZ","NM","TX","CO"],"lines":["Pollack","Pindler","Duralee"],"city":"Phoenix","vertical":["Residential","Commercial"],"contact_url":"https://vasquezSouthwestreps.com/contact"},
+  {"id":39,"name":"Pamela Nkosi","agency":"Nkosi Retail Design Reps","territories":["NY","NJ","CT","MA"],"lines":["Maharam","Momentum","Interface"],"city":"New York","vertical":["Retail","Commercial","Office"],"contact_url":"https://nkosiretaildesignreps.com/contact"},
+  {"id":40,"name":"Jonathan Reyes","agency":"Reyes Pacific Hospitality","territories":["CA","HI","NV"],"lines":["Perennials","Elitis","Casamance"],"city":"Los Angeles","vertical":["Hospitality","Resort","Boutique"],"contact_url":"https://reyespacifichospitality.com/contact"}
+]
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 0000000..153942a
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,23 @@
+// pm2 process definition for dw-signup-fulfillment.
+//   pm2 start ecosystem.config.js
+//
+// DRY_RUN defaults ON here — go-live is a deliberate flip to DRY_RUN=0 (see
+// DEPLOY.md), never an accident of forgetting to set it.
+module.exports = {
+  apps: [
+    {
+      name: 'dw-signup-fulfillment',
+      script: 'server.js',
+      cwd: __dirname,
+      instances: 1,
+      exec_mode: 'fork',
+      autorestart: true,
+      max_restarts: 10,
+      env: {
+        NODE_ENV: 'production',
+        PORT: '9856',
+        DRY_RUN: '1', // <-- flip to '0' at go-live (DEPLOY.md), and only then.
+      },
+    },
+  ],
+};
diff --git a/lib/config.js b/lib/config.js
new file mode 100644
index 0000000..b23d3e9
--- /dev/null
+++ b/lib/config.js
@@ -0,0 +1,72 @@
+'use strict';
+// Centralized config + env resolution. Reads the secrets master (never hardcodes
+// a token) and exposes DRY_RUN as the single global safety switch.
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const HOME = os.homedir();
+
+// firstEnv: process env wins, then each candidate .env file (Mac dev + Kamatera).
+function envFrom(file, key) {
+  try {
+    const m = fs.readFileSync(file, 'utf8').match(new RegExp('^' + key + '=(.*)$', 'm'));
+    return m ? m[1].replace(/^["']|["']$/g, '').trim() : '';
+  } catch { return ''; }
+}
+function firstEnv(key, files) {
+  if (process.env[key] != null && process.env[key] !== '') return process.env[key];
+  for (const f of files) { const v = envFrom(f, key); if (v) return v; }
+  return '';
+}
+
+const SECRETS_ENVS = [
+  path.join(HOME, 'Projects/secrets-manager/.env'),
+];
+const GEORGE_ENVS = [
+  path.join(HOME, 'Projects/george-gmail/.env'), // Mac dev
+  path.join(HOME, 'DW-Agents/gmail-agent/.env'),  // Kamatera prod
+];
+
+// DRY_RUN defaults ON. Only the literal '0' / 'false' disables it — so a missing
+// or malformed value can never accidentally arm live writes.
+const DRY_RAW = (process.env.DRY_RUN == null ? '1' : String(process.env.DRY_RUN)).toLowerCase();
+const DRY_RUN = !(DRY_RAW === '0' || DRY_RAW === 'false' || DRY_RAW === 'no');
+
+const config = {
+  DRY_RUN,
+  PORT: parseInt(process.env.PORT || '9856', 10),
+
+  // Shopify — the fulfillment token is separate from the products-only ADMIN token
+  // and the read-only CONTENT token. Steve grants write_customers/write_gift_cards/
+  // write_discounts/read+write webhooks on this token before go-live.
+  SHOP_DOMAIN: process.env.SHOP_DOMAIN || 'designer-laboratory-sandbox.myshopify.com',
+  SHOPIFY_API_VERSION: process.env.SHOPIFY_API_VERSION || '2024-10',
+  SHOPIFY_FULFILLMENT_TOKEN: firstEnv('SHOPIFY_FULFILLMENT_TOKEN', SECRETS_ENVS),
+  SHOPIFY_WEBHOOK_SECRET: firstEnv('SHOPIFY_WEBHOOK_SECRET', SECRETS_ENVS),
+
+  // Sample economics — retail "3 free samples". The gift-card face value is
+  // FREE_SAMPLE_COUNT × SAMPLE_PRICE (default 3 × 4.25 = 12.75).
+  SAMPLE_PRICE: parseFloat(process.env.SAMPLE_PRICE || '4.25'),
+  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.
+  SAMPLES_COLLECTION_ID: process.env.SAMPLES_COLLECTION_ID || '',
+
+  // George email sender.
+  GEORGE_URL: process.env.GEORGE_URL || 'http://127.0.0.1:9850',
+  GEORGE_ACCOUNT: process.env.GEORGE_ACCOUNT || 'steve-office',
+  GEORGE_FROM: process.env.GEORGE_FROM || 'steve@designerwallcoverings.com',
+  GEORGE_EXTERNAL_SEND_TOKEN: firstEnv('GEORGE_EXTERNAL_SEND_TOKEN', GEORGE_ENVS),
+  GEORGE_BASIC_AUTH: firstEnv('GEORGE_BASIC_AUTH', GEORGE_ENVS) || 'admin:',
+
+  // Admin review surface basic-auth (Steve's fleet standard).
+  ADMIN_USER: process.env.ADMIN_USER || 'admin',
+  ADMIN_PASS: process.env.ADMIN_PASS || 'DW2024!',
+
+  get SAMPLE_GIFT_VALUE() { return +(this.SAMPLE_PRICE * this.FREE_SAMPLE_COUNT).toFixed(2); },
+};
+
+module.exports = config;
diff --git a/lib/email.js b/lib/email.js
new file mode 100644
index 0000000..a1d5bee
--- /dev/null
+++ b/lib/email.js
@@ -0,0 +1,121 @@
+'use strict';
+// George email sender. Replicates the auth pattern used across the DW fleet
+// (astek-landing/lib/vendor-requests.js): POST JSON to George :9850 /api/send
+// with Basic auth + the X-Send-Approval external-send token.
+//
+// SAFETY: when config.DRY_RUN is true, sendEmail() does NOT hit George — it logs
+// the exact { to, subject, from, account } + body preview it WOULD send and
+// returns a synthetic { ok, dryRun } result.
+//
+// TODO (go-live wiring): the live path below is written and matches the fleet's
+// working George caller, but has NOT been exercised against George from this
+// service (no real email is sent in dry-run). At go-live, set DRY_RUN=0 and
+// confirm ~/Projects/george-gmail/.env has GEORGE_EXTERNAL_SEND_TOKEN populated;
+// George endpoint = http://127.0.0.1:9850/api/send.
+const http = require('http');
+const config = require('./config');
+
+function log(...a) { console.log('[email]', ...a); }
+
+function georgePost(payload) {
+  return new Promise((resolve) => {
+    const data = JSON.stringify(payload);
+    let u;
+    try { u = new URL(config.GEORGE_URL + '/api/send'); }
+    catch (e) { return resolve({ ok: false, status: 0, error: 'bad GEORGE_URL: ' + e.message }); }
+    const req = http.request({
+      hostname: u.hostname, port: u.port || 80, path: u.pathname, method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'Content-Length': Buffer.byteLength(data),
+        'X-Send-Approval': config.GEORGE_EXTERNAL_SEND_TOKEN,
+        'Authorization': 'Basic ' + Buffer.from(config.GEORGE_BASIC_AUTH).toString('base64'),
+      },
+    }, res => {
+      let d = ''; res.on('data', c => d += c);
+      res.on('end', () => { try { resolve({ ok: res.statusCode < 400, status: res.statusCode, ...JSON.parse(d) }); } catch { resolve({ ok: res.statusCode < 400, status: res.statusCode, raw: d.slice(0, 300) }); } });
+    });
+    req.on('error', e => resolve({ ok: false, status: 0, error: e.message }));
+    req.setTimeout(15000, () => { req.destroy(); resolve({ ok: false, status: 0, error: 'george timeout' }); });
+    req.end(data);
+  });
+}
+
+// sendEmail({ to, subject, html }) — the one function every flow calls.
+async function sendEmail({ to, subject, html, source }) {
+  const payload = {
+    account: config.GEORGE_ACCOUNT,
+    from: config.GEORGE_FROM,
+    to, subject, body: html,
+    source: source || 'dw-signup-fulfillment',
+  };
+  if (config.DRY_RUN) {
+    log(`DRY_RUN — WOULD send email via George (${config.GEORGE_URL}/api/send)`);
+    log(`         from=${payload.from} account=${payload.account}`);
+    log(`         to=${to}`);
+    log(`         subject=${subject}`);
+    log(`         body preview: ${String(html).replace(/\s+/g, ' ').slice(0, 160)}...`);
+    return { ok: true, dryRun: true, to, subject };
+  }
+  return georgePost(payload);
+}
+
+// ---- Templates ----
+
+function retailGiftEmail({ firstName, code, value, count }) {
+  const name = firstName ? ` ${firstName}` : '';
+  const subject = `Your ${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 are your <b>${count} free samples</b>.</p>`,
+    `<p style="font-size:18px;">Your gift code: <b style="letter-spacing:1px;">${code}</b> &nbsp;(${money(value)} value)</p>`,
+    `<p>Add any ${count} sample swatches to your cart and enter this code at checkout — they're on us.</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 = [
+    `<p>Hi ${esc(repName)},</p>`,
+    `<p>A new trade customer has been approved and assigned to you:</p>`,
+    `<p>`,
+    `&nbsp;&nbsp;<b>Business:</b> ${esc(applicant.business_name || '—')}<br>`,
+    `&nbsp;&nbsp;<b>Contact email:</b> ${esc(applicant.email || '—')}<br>`,
+    `&nbsp;&nbsp;<b>Phone:</b> ${esc(applicant.phone || '—')}<br>`,
+    `&nbsp;&nbsp;<b>Resale cert:</b> ${esc(applicant.resale_cert || '—')}`,
+    `</p>`,
+    `<p>Please reach out to welcome them. They now carry the <code>trade</code> tag (free memos enabled).</p>`,
+    `<p>— Designer Wallcoverings</p>`,
+  ].join('\n');
+  return { subject, html };
+}
+
+function tradeApprovedEmail({ applicant, repName }) {
+  const subject = `You're approved — Designer Wallcoverings Trade`;
+  const html = [
+    `<p>Hello,</p>`,
+    `<p>Great news — your Designer Wallcoverings <b>trade account</b> has been approved.</p>`,
+    `<p>You now get <b>free memo samples</b> and a dedicated sales rep${repName ? `, <b>${esc(repName)}</b>,` : ''} who will be in touch shortly.</p>`,
+    `<p>Sign in to your account to start ordering.</p>`,
+    `<p>Welcome aboard!<br><br>Designer Wallcoverings<br>${config.GEORGE_FROM}</p>`,
+  ].join('\n');
+  return { subject, html };
+}
+
+function tradeRejectedEmail() {
+  const subject = `Update on your Designer Wallcoverings trade application`;
+  const html = [
+    `<p>Hello,</p>`,
+    `<p>Thank you for your interest in a Designer Wallcoverings trade account. After review, we're unable to approve the application at this time.</p>`,
+    `<p>If you believe this was in error or can provide additional documentation, just reply to this email.</p>`,
+    `<p>Warm regards,<br>Designer Wallcoverings</p>`,
+  ].join('\n');
+  return { subject, html };
+}
+
+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 };
diff --git a/lib/giftcard.js b/lib/giftcard.js
new file mode 100644
index 0000000..5af7b1e
--- /dev/null
+++ b/lib/giftcard.js
@@ -0,0 +1,41 @@
+'use strict';
+// PRIMARY retail free-samples path: 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.
+//
+// Why a gift card (vs a discount code): a gift card is a stored-value balance the
+// customer redeems at checkout on ANY line items. The face value ($12.75) is
+// sized to exactly cover 3 samples at $4.25. It does NOT hard-limit to 3 samples
+// or to the Samples collection — the customer could spend it on anything up to
+// the balance. See giftcode-discount.js for the alternative that scopes 100%-off
+// to the Samples product set and enforces single-use (Steve chooses at go-live).
+//
+// DRY_RUN-safe: lib/shopify.js short-circuits the POST and returns a synthetic
+// gift_card with a DRYRUN… code so the email template still has something to show.
+const config = require('./config');
+const shopify = require('./shopify');
+const email = require('./email');
+
+async function issueRetailGiftCode(customer) {
+  const value = config.SAMPLE_GIFT_VALUE; // 3 × 4.25
+  const note = `DW retail free samples (${config.FREE_SAMPLE_COUNT} × $${config.SAMPLE_PRICE}) — customer ${customer.email || customer.id}`;
+
+  const gc = await shopify.createGiftCard({ value, note, currency: config.CURRENCY });
+  const card = gc.json && gc.json.gift_card ? gc.json.gift_card : {};
+  const code = card.code || '(code returned by Shopify at go-live)';
+
+  const firstName = customer.first_name || (customer.email ? customer.email.split('@')[0] : '');
+  const tpl = email.retailGiftEmail({ firstName, code, value, count: config.FREE_SAMPLE_COUNT });
+  const mail = await email.sendEmail({ to: customer.email, subject: tpl.subject, html: tpl.html, source: 'retail-gift' });
+
+  return {
+    path: 'gift_card',
+    value,
+    count: config.FREE_SAMPLE_COUNT,
+    giftCard: { id: card.id || null, code, dryRun: gc.dryRun || false },
+    email: { to: customer.email, subject: tpl.subject, dryRun: mail.dryRun || false, ok: mail.ok !== false },
+    shopifyCall: gc.dryRun ? { WOULD: `${gc.method} ${gc.url}`, payload: gc.body } : { status: gc.status },
+  };
+}
+
+module.exports = { issueRetailGiftCode };
diff --git a/lib/giftcode-discount.js b/lib/giftcode-discount.js
new file mode 100644
index 0000000..3bb3978
--- /dev/null
+++ b/lib/giftcode-discount.js
@@ -0,0 +1,79 @@
+'use strict';
+// ALTERNATIVE retail free-samples path (Steve chooses at go-live).
+//
+// 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
+// gift-card path this ENFORCES the "3 samples" intent more tightly:
+//   - target = the Samples collection only (can't be spent on full rolls),
+//   - value  = 100% off,
+//   - usage_limit = 1 (single use),
+//   - prerequisite_quantity_range caps the number of sample items to
+//     FREE_SAMPLE_COUNT so the code covers exactly N samples, not the whole cart.
+//
+// Shopify models this as a price_rule + a discount_code under it.
+//
+// DRY_RUN-safe: both POSTs are short-circuited by lib/shopify.js.
+//
+// NOTE: requires SAMPLES_COLLECTION_ID (env). If unset, we still build + log the
+// full intended payloads and mark a TODO — Steve fills the collection id at go-live.
+const config = require('./config');
+const shopify = require('./shopify');
+const email = require('./email');
+
+function uniqueCode() {
+  return 'DWSAMPLES-' + Math.random().toString(36).slice(2, 8).toUpperCase();
+}
+
+async function issueRetailDiscountCode(customer) {
+  const code = uniqueCode();
+  const collectionId = config.SAMPLES_COLLECTION_ID || null;
+
+  const pricePayload = {
+    price_rule: {
+      title: `Free ${config.FREE_SAMPLE_COUNT} samples — ${customer.email || customer.id}`,
+      target_type: 'line_item',
+      target_selection: 'entitled',
+      allocation_method: 'across',
+      value_type: 'percentage',
+      value: '-100.0',
+      customer_selection: 'all',
+      // scope to the Samples collection when we have the id
+      entitled_collection_ids: collectionId ? [Number(collectionId)] : [],
+      // cap to N sample items
+      prerequisite_quantity_range: { greater_than_or_equal_to: 1 },
+      allocation_limit: config.FREE_SAMPLE_COUNT,
+      usage_limit: 1,
+      once_per_customer: true,
+      starts_at: new Date().toISOString(),
+    },
+  };
+
+  const todo = collectionId ? null
+    : 'TODO(go-live): set SAMPLES_COLLECTION_ID so the code is scoped to the Samples collection; empty means it is NOT scoped yet.';
+
+  const pr = await shopify.request('POST', '/price_rules.json', pricePayload);
+  const priceRuleId = pr.json && pr.json.price_rule ? pr.json.price_rule.id : null;
+
+  const dcPayload = { discount_code: { code } };
+  const dc = await shopify.request('POST', `/price_rules/${priceRuleId}/discount_codes.json`, dcPayload);
+  const finalCode = (dc.json && dc.json.discount_code && dc.json.discount_code.code) || code;
+
+  const firstName = customer.first_name || (customer.email ? customer.email.split('@')[0] : '');
+  const tpl = email.retailGiftEmail({ firstName, code: finalCode, value: config.SAMPLE_GIFT_VALUE, count: config.FREE_SAMPLE_COUNT });
+  const mail = await email.sendEmail({ to: customer.email, subject: tpl.subject, html: tpl.html, source: 'retail-discount' });
+
+  return {
+    path: 'discount_code',
+    code: finalCode,
+    priceRuleId,
+    scopedToCollection: collectionId,
+    todo,
+    email: { to: customer.email, subject: tpl.subject, dryRun: mail.dryRun || false },
+    shopifyCalls: [
+      pr.dryRun ? { WOULD: `${pr.method} ${pr.url}`, payload: pr.body } : { status: pr.status },
+      dc.dryRun ? { WOULD: `${dc.method} ${dc.url}`, payload: dc.body } : { status: dc.status },
+    ],
+  };
+}
+
+module.exports = { issueRetailDiscountCode };
diff --git a/lib/reps.js b/lib/reps.js
new file mode 100644
index 0000000..fdad843
--- /dev/null
+++ b/lib/reps.js
@@ -0,0 +1,72 @@
+'use strict';
+// Rep round-robin with a persisted rotation cursor.
+//
+// The roster (data/reps.json) is the seeded designersalesreps roster: each rep
+// has at least { name, email }. This roster uses { name, agency, ... } and does
+// NOT carry an explicit email, so we DERIVE a contact email deterministically
+// from the agency domain in contact_url (adapting to the roster we were given,
+// per the brief). At go-live Steve can drop a real `email` field per rep into
+// data/reps.json and it will be used verbatim.
+const fs = require('fs');
+const path = require('path');
+
+const DATA_DIR = path.join(__dirname, '..', 'data');
+const REPS_PATH = path.join(DATA_DIR, 'reps.json');
+const CURSOR_PATH = path.join(DATA_DIR, 'rep-cursor.json');
+
+function loadReps() {
+  const raw = JSON.parse(fs.readFileSync(REPS_PATH, 'utf8'));
+  return raw.map((r, i) => ({
+    id: r.id != null ? r.id : i + 1,
+    name: r.name || `Rep ${i + 1}`,
+    email: r.email || deriveEmail(r),
+    agency: r.agency || null,
+    city: r.city || null,
+  }));
+}
+
+// Derive a plausible sales@<domain> from contact_url when no email is present.
+// Clearly a placeholder — at go-live the real per-rep email should be added to
+// reps.json. Never invents a domain: falls back to a routable internal address.
+function deriveEmail(r) {
+  try {
+    if (r.contact_url) {
+      const host = new URL(r.contact_url).hostname.replace(/^www\./, '');
+      return `sales@${host}`;
+    }
+  } catch { /* fall through */ }
+  return 'reps@designerwallcoverings.com';
+}
+
+function readCursor() {
+  try { return JSON.parse(fs.readFileSync(CURSOR_PATH, 'utf8')); }
+  catch { return { index: -1, lastRepId: null, assignments: 0 }; }
+}
+
+function writeCursor(c) {
+  fs.mkdirSync(DATA_DIR, { recursive: true });
+  fs.writeFileSync(CURSOR_PATH, JSON.stringify(c, null, 2));
+}
+
+// Advance the round-robin and return the next rep. Persists the cursor so the
+// rotation survives restarts. This is the "least-loaded via round-robin" the
+// brief asks for — with an even roster, round-robin IS even load distribution.
+function nextRep() {
+  const reps = loadReps();
+  if (!reps.length) throw new Error('no reps in roster');
+  const cur = readCursor();
+  const index = (cur.index + 1) % reps.length;
+  const rep = reps[index];
+  writeCursor({ index, lastRepId: rep.id, assignments: (cur.assignments || 0) + 1, updated_at: new Date().toISOString() });
+  return rep;
+}
+
+// Peek without advancing (for previews / dry inspection).
+function peekNext() {
+  const reps = loadReps();
+  const cur = readCursor();
+  const index = (cur.index + 1) % reps.length;
+  return reps[index];
+}
+
+module.exports = { loadReps, nextRep, peekNext, readCursor, deriveEmail, REPS_PATH, CURSOR_PATH };
diff --git a/lib/shopify.js b/lib/shopify.js
new file mode 100644
index 0000000..84ff8a6
--- /dev/null
+++ b/lib/shopify.js
@@ -0,0 +1,119 @@
+'use strict';
+// Thin Shopify Admin API client. Reads SHOPIFY_FULFILLMENT_TOKEN from config.
+//
+// SAFETY: when config.DRY_RUN is true, every WRITE verb (POST/PUT/DELETE) is
+// short-circuited — the intended method, URL and payload are logged and a
+// synthetic ok response is returned. No live call is made. Only GET is allowed
+// to hit the network in DRY_RUN (reads are harmless), and even that requires a
+// real token — otherwise it too is stubbed.
+const config = require('./config');
+
+const WRITE_VERBS = new Set(['POST', 'PUT', 'DELETE']);
+
+function base() {
+  return `https://${config.SHOP_DOMAIN}/admin/api/${config.SHOPIFY_API_VERSION}`;
+}
+
+function log(...a) { console.log('[shopify]', ...a); }
+
+// Core request. path is relative to /admin/api/<ver> (e.g. '/gift_cards.json').
+async function request(method, path, body) {
+  const url = base() + path;
+  const isWrite = WRITE_VERBS.has(method.toUpperCase());
+
+  if (config.DRY_RUN && isWrite) {
+    log(`DRY_RUN — WOULD ${method} ${url}`);
+    if (body !== undefined) log('DRY_RUN — payload:', JSON.stringify(body, null, 2));
+    return { ok: true, dryRun: true, method, url, body, status: 0, json: synthetic(path, body) };
+  }
+
+  if (!config.SHOPIFY_FULFILLMENT_TOKEN) {
+    // No token yet (pre go-live). Never throw — return a clearly-marked stub so
+    // callers and the selftest keep working. This is the expected state today.
+    log(`NO TOKEN — WOULD ${method} ${url} (SHOPIFY_FULFILLMENT_TOKEN unset)`);
+    if (body !== undefined) log('NO TOKEN — payload:', JSON.stringify(body, null, 2));
+    return { ok: true, dryRun: true, noToken: true, method, url, body, status: 0, json: synthetic(path, body) };
+  }
+
+  const res = await fetch(url, {
+    method,
+    headers: {
+      'X-Shopify-Access-Token': config.SHOPIFY_FULFILLMENT_TOKEN,
+      'Content-Type': 'application/json',
+      'Accept': 'application/json',
+    },
+    body: body !== undefined ? JSON.stringify(body) : undefined,
+  });
+  let json = null;
+  try { json = await res.json(); } catch { json = null; }
+  return { ok: res.ok, status: res.status, method, url, json };
+}
+
+// Build a plausible synthetic response so dry-run callers can read back an id/code.
+function synthetic(path, body) {
+  if (path.startsWith('/gift_cards')) {
+    const code = 'DRYRUN' + Math.random().toString(36).slice(2, 10).toUpperCase();
+    return { gift_card: { id: 900000000 + Math.floor(Math.random() * 1e6), code, masked_code: '••••' + code.slice(-4), balance: body?.gift_card?.initial_value, initial_value: body?.gift_card?.initial_value } };
+  }
+  if (path.startsWith('/price_rules') && !path.includes('discount_codes')) {
+    return { price_rule: { id: 800000000 + Math.floor(Math.random() * 1e6) } };
+  }
+  if (path.includes('discount_codes')) {
+    const code = body?.discount_code?.code || ('DRYRUN' + Math.random().toString(36).slice(2, 8).toUpperCase());
+    return { discount_code: { id: 700000000 + Math.floor(Math.random() * 1e6), code } };
+  }
+  if (path.startsWith('/customers') && body?.customer) {
+    return { customer: { id: body.customer.id || 600000000 + Math.floor(Math.random() * 1e6), ...body.customer } };
+  }
+  if (path.startsWith('/webhooks')) {
+    return { webhook: { id: 500000000 + Math.floor(Math.random() * 1e6), ...(body?.webhook || {}) } };
+  }
+  return {};
+}
+
+// ---- High-level helpers ----
+
+// Create a Shopify gift card with a fixed initial_value.
+async function createGiftCard({ value, note, currency }) {
+  return request('POST', '/gift_cards.json', {
+    gift_card: { initial_value: String(value), note: note || 'DW retail free-samples', currency: currency || config.CURRENCY },
+  });
+}
+
+// Add tags to a customer (tagsAdd equivalent — REST needs the merged tag string,
+// so the caller passes the already-merged value; addTags() merges for you).
+async function updateCustomer(customerId, fields) {
+  return request('PUT', `/customers/${customerId}.json`, { customer: { id: customerId, ...fields } });
+}
+
+// Read a customer's current tags so we can append rather than clobber.
+async function getCustomer(customerId) {
+  return request('GET', `/customers/${customerId}.json`, undefined);
+}
+
+// Append a tag to a customer without dropping existing tags.
+async function addTags(customerId, newTags) {
+  const wanted = (Array.isArray(newTags) ? newTags : [newTags]).map(t => t.trim()).filter(Boolean);
+  let existing = [];
+  const cur = await getCustomer(customerId);
+  if (cur?.json?.customer?.tags) existing = cur.json.customer.tags.split(',').map(t => t.trim()).filter(Boolean);
+  const merged = Array.from(new Set([...existing, ...wanted])).join(', ');
+  return updateCustomer(customerId, { tags: merged });
+}
+
+// Set a customer metafield (e.g. custom.assigned_rep).
+async function setCustomerMetafield(customerId, { namespace, key, value, type }) {
+  return request('POST', `/customers/${customerId}/metafields.json`, {
+    metafield: { namespace, key, value: String(value), type: type || 'single_line_text_field' },
+  });
+}
+
+// Register a webhook (used at go-live, from DEPLOY.md).
+async function createWebhook({ topic, address, format }) {
+  return request('POST', '/webhooks.json', { webhook: { topic, address, format: format || 'json' } });
+}
+
+module.exports = {
+  request, createGiftCard, updateCustomer, getCustomer, addTags,
+  setCustomerMetafield, createWebhook, base,
+};
diff --git a/lib/trade.js b/lib/trade.js
new file mode 100644
index 0000000..eb851d8
--- /dev/null
+++ b/lib/trade.js
@@ -0,0 +1,139 @@
+'use strict';
+// Trade application store + moderated approval flow.
+//
+// Applications are appended to data/trade-applications.jsonl (append-only). On
+// approve/reject we rewrite the file with the updated record (small volume, so a
+// full-file rewrite is simplest and safe).
+//
+// APPROVE performs, all DRY_RUN-safe:
+//   (a) pick the least-loaded rep via round-robin (lib/reps.js persisted cursor)
+//   (b) tag the customer `trade` via Admin API (tagsAdd — appends, no clobber)
+//   (c) set customer metafield custom.assigned_rep
+//   (d) notify the assigned rep by email (George)
+//   (e) email the applicant "you're approved"
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const reps = require('./reps');
+const shopify = require('./shopify');
+const email = require('./email');
+
+const DATA_DIR = path.join(__dirname, '..', 'data');
+const APPS_PATH = path.join(DATA_DIR, 'trade-applications.jsonl');
+
+function readAll() {
+  try {
+    return fs.readFileSync(APPS_PATH, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
+  } catch { return []; }
+}
+
+function rewriteAll(rows) {
+  fs.mkdirSync(DATA_DIR, { recursive: true });
+  fs.writeFileSync(APPS_PATH, rows.map(r => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : ''));
+}
+
+function appendOne(row) {
+  fs.mkdirSync(DATA_DIR, { recursive: true });
+  fs.appendFileSync(APPS_PATH, JSON.stringify(row) + '\n');
+}
+
+// Create a new pending application.
+function apply(body) {
+  const now = new Date().toISOString();
+  const app = {
+    id: 'TRADE-' + now.slice(0, 10).replace(/-/g, '') + '-' + crypto.randomBytes(3).toString('hex'),
+    email: (body.email || '').trim().toLowerCase(),
+    business_name: body.business_name || '',
+    resale_cert: body.resale_cert || '',
+    phone: body.phone || '',
+    // carry any extra fields the form sent, minus the ones we already captured
+    extra: Object.fromEntries(Object.entries(body).filter(([k]) => !['email', 'business_name', 'resale_cert', 'phone'].includes(k))),
+    // shopify customer id if the caller knows it (webhook/customer-account link);
+    // otherwise null — approval logs a note that the id must be resolved at go-live.
+    shopify_customer_id: body.shopify_customer_id || null,
+    status: 'pending',
+    created_at: now,
+    decided_at: null,
+    decision: null,
+    assigned_rep: null,
+  };
+  appendOne(app);
+  return app;
+}
+
+function listPending() {
+  return readAll().filter(a => a.status === 'pending').sort((a, b) => a.created_at.localeCompare(b.created_at));
+}
+
+function get(id) {
+  return readAll().find(a => a.id === id) || null;
+}
+
+// APPROVE — runs the full fan-out, all writes dry-run-safe via lib/shopify.
+async function approve(id) {
+  const rows = readAll();
+  const app = rows.find(a => a.id === id);
+  if (!app) return { ok: false, error: 'not found' };
+  if (app.status !== 'pending') return { ok: false, error: `already ${app.status}` };
+
+  const steps = [];
+
+  // (a) least-loaded rep via round-robin
+  const rep = reps.nextRep();
+  steps.push({ step: 'assign_rep', rep: { id: rep.id, name: rep.name, email: rep.email } });
+
+  const custId = app.shopify_customer_id;
+  const custRef = custId || '(shopify_customer_id UNKNOWN — resolve by email at go-live)';
+
+  // (b) tag `trade`
+  const tagRes = await shopify.addTags(custId || '__UNKNOWN__', ['trade']);
+  steps.push({ step: 'tag_trade', customer: custRef, result: summarizeShopify(tagRes) });
+
+  // (c) metafield custom.assigned_rep
+  const mfRes = await shopify.setCustomerMetafield(custId || '__UNKNOWN__', {
+    namespace: 'custom', key: 'assigned_rep', value: `${rep.name} <${rep.email}>`, type: 'single_line_text_field',
+  });
+  steps.push({ step: 'set_metafield', customer: custRef, result: summarizeShopify(mfRes) });
+
+  // (d) notify rep
+  const repTpl = email.repNotifyEmail({ repName: rep.name, applicant: app });
+  const repMail = await email.sendEmail({ to: rep.email, subject: repTpl.subject, html: repTpl.html, source: 'trade-rep-notify' });
+  steps.push({ step: 'email_rep', to: rep.email, subject: repTpl.subject, dryRun: repMail.dryRun || false });
+
+  // (e) email applicant approved
+  const appTpl = email.tradeApprovedEmail({ applicant: app, repName: rep.name });
+  const appMail = await email.sendEmail({ to: app.email, subject: appTpl.subject, html: appTpl.html, source: 'trade-approved' });
+  steps.push({ step: 'email_applicant', to: app.email, subject: appTpl.subject, dryRun: appMail.dryRun || false });
+
+  app.status = 'approved';
+  app.decision = 'approved';
+  app.decided_at = new Date().toISOString();
+  app.assigned_rep = { id: rep.id, name: rep.name, email: rep.email };
+  rewriteAll(rows);
+
+  return { ok: true, id, status: 'approved', rep: app.assigned_rep, steps };
+}
+
+async function reject(id) {
+  const rows = readAll();
+  const app = rows.find(a => a.id === id);
+  if (!app) return { ok: false, error: 'not found' };
+  if (app.status !== 'pending') return { ok: false, error: `already ${app.status}` };
+
+  const tpl = email.tradeRejectedEmail();
+  const mail = await email.sendEmail({ to: app.email, subject: tpl.subject, html: tpl.html, source: 'trade-rejected' });
+
+  app.status = 'rejected';
+  app.decision = 'rejected';
+  app.decided_at = new Date().toISOString();
+  rewriteAll(rows);
+
+  return { ok: true, id, status: 'rejected', email: { to: app.email, subject: tpl.subject, dryRun: mail.dryRun || false } };
+}
+
+function summarizeShopify(r) {
+  if (r.dryRun) return { WOULD: `${r.method} ${r.url}`, payload: r.body, noToken: r.noToken || false };
+  return { status: r.status, ok: r.ok };
+}
+
+module.exports = { apply, listPending, get, approve, reject, readAll, APPS_PATH };
diff --git a/lib/webhook.js b/lib/webhook.js
new file mode 100644
index 0000000..0b86326
--- /dev/null
+++ b/lib/webhook.js
@@ -0,0 +1,29 @@
+'use strict';
+// Shopify webhook HMAC verification. Shopify signs the RAW request body with the
+// app's webhook secret (SHA-256, base64) and sends it in X-Shopify-Hmac-Sha256.
+// We MUST verify against the raw bytes — parsed/re-stringified JSON will not match.
+const crypto = require('crypto');
+const config = require('./config');
+
+// verify(rawBody: Buffer|string, hmacHeader: string) -> boolean
+function verify(rawBody, hmacHeader) {
+  if (!config.SHOPIFY_WEBHOOK_SECRET) return false;
+  if (!hmacHeader) return false;
+  const digest = crypto
+    .createHmac('sha256', config.SHOPIFY_WEBHOOK_SECRET)
+    .update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8'))
+    .digest('base64');
+  const a = Buffer.from(digest);
+  const b = Buffer.from(hmacHeader);
+  if (a.length !== b.length) return false;
+  return crypto.timingSafeEqual(a, b);
+}
+
+// Helper for tests / go-live smoke: compute the header a given secret would produce.
+function sign(rawBody, secret) {
+  return crypto.createHmac('sha256', secret)
+    .update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8'))
+    .digest('base64');
+}
+
+module.exports = { verify, sign };
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..db8a7a0
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,829 @@
+{
+  "name": "dw-signup-fulfillment",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "dw-signup-fulfillment",
+      "version": "0.1.0",
+      "license": "UNLICENSED",
+      "dependencies": {
+        "express": "^4.19.2"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "license": "MIT"
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.6",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
+      "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.15.1",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+      "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.22.2",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+      "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.5",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.15.1",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+      "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+      "license": "MIT"
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.3",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "es-define-property": "^1.0.1",
+        "side-channel": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.3",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+      "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.1",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "~2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "~2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/serve-static": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+      "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "~0.19.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4",
+        "side-channel-list": "^1.0.1",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e30f150
--- /dev/null
+++ b/package.json
@@ -0,0 +1,17 @@
+{
+  "name": "dw-signup-fulfillment",
+  "version": "0.1.0",
+  "private": true,
+  "description": "DW signup/sample/rep fulfillment: retail gift-code + trade approval + rep round-robin. DRY_RUN by default.",
+  "type": "commonjs",
+  "main": "server.js",
+  "scripts": {
+    "start": "node server.js",
+    "selftest": "node scripts/selftest.js"
+  },
+  "dependencies": {
+    "express": "^4.19.2"
+  },
+  "author": "steve@designerwallcoverings.com",
+  "license": "UNLICENSED"
+}
diff --git a/scripts/selftest.js b/scripts/selftest.js
new file mode 100644
index 0000000..860afac
--- /dev/null
+++ b/scripts/selftest.js
@@ -0,0 +1,146 @@
+'use strict';
+// Self-test for dw-signup-fulfillment. Everything runs in DRY_RUN — no live
+// Shopify write, no real email, no webhook registration. It prints the exact
+// Admin API calls + emails the service WOULD make, then exits 0 on success.
+//
+//   node scripts/selftest.js
+//
+// Exercises:
+//   (a) the customers/create webhook with a fake payload + a VALID HMAC computed
+//       from a test secret (proves HMAC verify accepts a correct sig and the
+//       retail gift-card path fires),
+//   (b) an invalid-HMAC webhook (proves it is rejected),
+//   (c) /trade/apply then /admin/trade/:id/approve (proves the moderated flow +
+//       tag + metafield + rep-notify + applicant email),
+//   (d) round-robin across the whole rep roster (proves cursor rotation).
+
+// Force DRY_RUN on, pin a known webhook secret, use a throwaway data dir so we
+// never touch real trade-applications.jsonl / rep-cursor.json.
+const os = require('os');
+const fs = require('fs');
+const path = require('path');
+
+const TEST_SECRET = 'selftest-webhook-secret-abc123';
+process.env.DRY_RUN = '1';
+process.env.SHOPIFY_WEBHOOK_SECRET = TEST_SECRET;
+// 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-'));
+
+const REPO = path.join(__dirname, '..');
+// Seed the temp data dir with the real reps.json so round-robin uses the roster.
+const dataDir = path.join(REPO, 'data');
+fs.copyFileSync(path.join(dataDir, 'reps.json'), path.join(TMP, 'reps.json'));
+
+// Point the libs at the temp data dir by monkey-patching module paths is fragile;
+// instead we run against the real repo but reset the mutable files afterward.
+// Simpler + honest: snapshot then restore data/rep-cursor.json + trade-applications.jsonl.
+const cursorPath = path.join(dataDir, 'rep-cursor.json');
+const appsPath = path.join(dataDir, 'trade-applications.jsonl');
+const snap = {
+  cursor: fs.existsSync(cursorPath) ? fs.readFileSync(cursorPath) : null,
+  apps: fs.existsSync(appsPath) ? fs.readFileSync(appsPath) : null,
+};
+function restore() {
+  if (snap.cursor === null) { try { fs.unlinkSync(cursorPath); } catch {} } else fs.writeFileSync(cursorPath, snap.cursor);
+  if (snap.apps === null) { try { fs.unlinkSync(appsPath); } catch {} } else fs.writeFileSync(appsPath, snap.apps);
+  try { fs.rmSync(TMP, { recursive: true, force: true }); } catch {}
+}
+
+const config = require('../lib/config');
+const webhook = require('../lib/webhook');
+const giftcard = require('../lib/giftcard');
+const giftcodeDiscount = require('../lib/giftcode-discount');
+const trade = require('../lib/trade');
+const reps = require('../lib/reps');
+
+function hr(title) { console.log('\n' + '='.repeat(72) + '\n' + title + '\n' + '='.repeat(72)); }
+function ok(msg) { console.log('  ✔ ' + msg); }
+function fail(msg) { console.log('  x FAIL: ' + msg); failures++; }
+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');
+  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');
+  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');
+
+  // ---------------------------------------------------------------------------
+  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('(c) trade application → moderated approve');
+  const created = trade.apply({ email: 'Studio@BigDesignCo.com', business_name: 'Big Design Co', resale_cert: 'CA-RESALE-99887', phone: '310-555-0142', shopify_customer_id: 5551234 });
+  console.log('  applied: ' + JSON.stringify(created));
+  if (created.status === 'pending' && created.created_at) ok('application stored pending with created_at ' + created.created_at);
+  else fail('apply() did not produce a pending record with created_at');
+
+  const pendingBefore = trade.listPending().length;
+  const approve = await trade.approve(created.id);
+  console.log('  approve result: ' + JSON.stringify(approve, null, 2));
+  if (approve.ok && approve.status === 'approved') ok('approved; assigned rep = ' + approve.rep.name + ' <' + approve.rep.email + '>');
+  else fail('approve failed');
+  const tagStep = approve.steps.find(s => s.step === 'tag_trade');
+  if (tagStep && tagStep.result && tagStep.result.WOULD) ok('WOULD tag customer `trade`: ' + tagStep.result.WOULD);
+  else fail('no trade-tag step');
+  const mfStep = approve.steps.find(s => s.step === 'set_metafield');
+  if (mfStep && mfStep.result && mfStep.result.WOULD) ok('WOULD set custom.assigned_rep metafield: ' + mfStep.result.WOULD);
+  else fail('no metafield step');
+  const repMailStep = approve.steps.find(s => s.step === 'email_rep');
+  if (repMailStep && repMailStep.dryRun) ok('WOULD email rep ' + repMailStep.to + ' (dry-run)'); else fail('no rep email');
+  const appMailStep = approve.steps.find(s => s.step === 'email_applicant');
+  if (appMailStep && appMailStep.dryRun) ok('WOULD email applicant ' + appMailStep.to + ' (dry-run)'); else fail('no applicant email');
+  if (trade.listPending().length === pendingBefore - 1) ok('application moved out of pending queue');
+  else fail('application still pending after approve');
+
+  // ---------------------------------------------------------------------------
+  hr('(d) round-robin across the full rep roster');
+  const roster = reps.loadReps();
+  console.log('  roster size = ' + roster.length);
+  const seen = [];
+  for (let i = 0; i < roster.length + 2; i++) {
+    const r = reps.nextRep();
+    seen.push(r.name);
+  }
+  console.log('  rotation (' + seen.length + ' picks): ');
+  seen.forEach((n, i) => console.log('    ' + String(i + 1).padStart(2) + '. ' + n));
+  // The first roster.length picks should cover every distinct rep exactly once.
+  const firstCycle = new Set(seen.slice(0, roster.length));
+  if (firstCycle.size === roster.length) ok('first ' + roster.length + ' picks covered EVERY rep exactly once (even round-robin)');
+  else fail('round-robin did not cover all reps in one cycle (got ' + firstCycle.size + '/' + roster.length + ')');
+  // Wrap-around: pick N+1 should equal pick 1.
+  if (seen[roster.length] === seen[0]) ok('cursor wrapped around correctly (pick ' + (roster.length + 1) + ' === pick 1)');
+  else fail('cursor did not wrap');
+
+  hr('SUMMARY');
+  if (failures === 0) { console.log('  ALL CHECKS PASSED — DRY_RUN, nothing written to Shopify, no email sent.'); }
+  else { console.log('  ' + failures + ' CHECK(S) FAILED.'); }
+}
+
+main()
+  .then(() => { restore(); process.exit(failures === 0 ? 0 : 1); })
+  .catch(e => { console.error('SELFTEST ERROR:', e); restore(); process.exit(2); });
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..340e62e
--- /dev/null
+++ b/server.js
@@ -0,0 +1,163 @@
+'use strict';
+// DW signup / sample / rep fulfillment service.
+//
+// Fixes the three broken flows:
+//   1. Retail signup -> emailed 3-free-samples gift code (webhook customers/create)
+//   2. Trade application -> moderated approval -> `trade` tag + rep assignment
+//   3. Rep round-robin with persisted cursor
+//
+// EVERYTHING runs in DRY_RUN by default (config.DRY_RUN). No live Shopify writes,
+// no real emails, no webhook registration until Steve flips DRY_RUN=0 at go-live.
+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 trade = require('./lib/trade');
+const reps = require('./lib/reps');
+
+const app = express();
+
+// --- /healthz FIRST, before any auth/body parsing (Steve's fleet rule) ---
+app.get('/healthz', (_req, res) => res.status(200).json({ ok: true, service: 'dw-signup-fulfillment', dry_run: config.DRY_RUN }));
+
+// --- Webhook receiver needs the RAW body for HMAC. Mount raw parser on that path
+//     ONLY, before the global JSON parser. ---
+app.post('/webhooks/customers/create',
+  express.raw({ type: '*/*', limit: '2mb' }),
+  async (req, res) => {
+    const hmac = req.get('X-Shopify-Hmac-Sha256');
+    const raw = req.body; // Buffer
+    if (!webhook.verify(raw, hmac)) {
+      console.warn('[webhook] HMAC verification FAILED');
+      return res.status(401).json({ ok: false, error: 'hmac_invalid' });
+    }
+    let customer;
+    try { customer = JSON.parse(raw.toString('utf8')); }
+    catch { return res.status(400).json({ ok: false, error: 'bad_json' }); }
+
+    // Respond fast (Shopify wants a quick 200); do the work then log.
+    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));
+    } catch (e) {
+      console.error('[webhook] fulfillment error:', e.message);
+    }
+  });
+
+// Global JSON parser for the rest.
+app.use(express.json({ limit: '1mb' }));
+app.use(express.urlencoded({ extended: true }));
+
+// --- Trade application intake (public) ---
+app.post('/trade/apply', (req, res) => {
+  const b = req.body || {};
+  if (!b.email) return res.status(400).json({ ok: false, error: 'email required' });
+  const created = trade.apply(b);
+  res.json({ ok: true, id: created.id, status: created.status, created_at: created.created_at });
+});
+
+// --- Basic-auth guard for /admin/* ---
+function adminAuth(req, res, next) {
+  const hdr = req.get('Authorization') || '';
+  const [scheme, val] = hdr.split(' ');
+  if (scheme === 'Basic' && val) {
+    const [u, p] = Buffer.from(val, 'base64').toString('utf8').split(':');
+    if (u === config.ADMIN_USER && p === config.ADMIN_PASS) return next();
+  }
+  res.set('WWW-Authenticate', 'Basic realm="DW Trade Admin"');
+  return res.status(401).send('Auth required');
+}
+
+// --- Admin trade review surface ---
+app.get('/admin/trade', adminAuth, (_req, res) => {
+  const pending = trade.listPending();
+  res.type('html').send(renderAdmin(pending));
+});
+
+app.post('/admin/trade/:id/approve', adminAuth, async (req, res) => {
+  const result = await trade.approve(req.params.id);
+  res.status(result.ok ? 200 : 400).json(result);
+});
+
+app.post('/admin/trade/:id/reject', adminAuth, async (req, res) => {
+  const result = await trade.reject(req.params.id);
+  res.status(result.ok ? 200 : 400).json(result);
+});
+
+// --- Introspection helpers ---
+app.get('/reps/next', adminAuth, (_req, res) => res.json({ next: reps.peekNext(), cursor: reps.readCursor() }));
+
+// 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.
+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 });
+});
+
+// --- Admin HTML with created date+time chip (Steve's admin-card rule) ---
+function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
+function fmtDate(iso) {
+  try {
+    return new Date(iso).toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+  } catch { return iso; }
+}
+function renderAdmin(pending) {
+  const cards = pending.map(a => `
+    <div class="card">
+      <div class="when" title="${esc(a.created_at)}">🕓 ${esc(fmtDate(a.created_at))}</div>
+      <div class="biz">${esc(a.business_name || '(no business name)')}</div>
+      <div class="meta">${esc(a.email)}${a.phone ? ' · ' + esc(a.phone) : ''}</div>
+      <div class="meta">Resale cert: ${esc(a.resale_cert || '—')}</div>
+      <div class="id">${esc(a.id)}</div>
+      <div class="actions">
+        <button onclick="act('${a.id}','approve')">Approve</button>
+        <button class="rej" onclick="act('${a.id}','reject')">Reject</button>
+      </div>
+    </div>`).join('\n');
+  return `<!doctype html><html><head><meta charset="utf-8"><title>DW Trade Admin</title>
+  <style>
+    body{font:14px/1.5 -apple-system,system-ui,sans-serif;margin:24px;color:#1a1a1a;background:#faf9f7}
+    h1{font-weight:600}
+    .dry{display:inline-block;padding:2px 8px;border-radius:4px;background:${config.DRY_RUN ? '#fde68a' : '#bbf7d0'};font-size:12px}
+    .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px;margin-top:16px}
+    .card{border:1px solid #e5e2dd;border-radius:8px;padding:12px;background:#fff}
+    .when{font-size:12px;color:#6b7280}
+    .biz{font-size:16px;font-weight:600;margin:4px 0}
+    .meta{font-size:13px;color:#374151}
+    .id{font-size:11px;color:#9ca3af;margin-top:6px;font-family:monospace}
+    .actions{margin-top:10px;display:flex;gap:8px}
+    button{padding:6px 12px;border:1px solid #111;background:#111;color:#fff;border-radius:6px;cursor:pointer}
+    button.rej{background:#fff;color:#b91c1c;border-color:#b91c1c}
+    .empty{color:#6b7280;margin-top:20px}
+  </style></head><body>
+  <h1>Trade applications <span class="dry">DRY_RUN: ${config.DRY_RUN ? 'ON (no live writes)' : 'OFF (LIVE)'}</span></h1>
+  <div class="grid">${cards || '<p class="empty">No pending applications.</p>'}</div>
+  <script>
+    async function act(id, decision){
+      if(!confirm(decision.toUpperCase()+' '+id+'?')) return;
+      const r = await fetch('/admin/trade/'+id+'/'+decision, {method:'POST'});
+      const j = await r.json();
+      alert(JSON.stringify(j, null, 2));
+      location.reload();
+    }
+  </script></body></html>`;
+}
+
+if (require.main === module) {
+  app.listen(config.PORT, () => {
+    console.log(`[dw-signup-fulfillment] listening on :${config.PORT}  DRY_RUN=${config.DRY_RUN}`);
+    console.log(`  health:  http://127.0.0.1:${config.PORT}/healthz`);
+    console.log(`  admin:   http://127.0.0.1:${config.PORT}/admin/trade  (basic-auth ${config.ADMIN_USER}/${config.ADMIN_PASS})`);
+    console.log(`  webhook: POST /webhooks/customers/create  (HMAC-verified)`);
+    if (config.DRY_RUN) console.log('  ** DRY_RUN ON — no live Shopify writes, no real emails, nothing registered. **');
+  });
+}
+
+module.exports = app;

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