← back to Dw Signup Fulfillment
harden per contrarian gate: resolve trade customer-id by email + hard-fail instead of __UNKNOWN__ live write (closes memo §5); DEPLOY.md rewritten for wired path B; selftest banner + boot log fixed (no pass echo) — still DRY_RUN
379738e7385c9b225cdfef5be05bc4a9363a0de0 · 2026-07-28 10:12:31 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M DEPLOY.mdM lib/shopify.jsM lib/trade.jsM scripts/selftest.jsM server.js
Diff
commit 379738e7385c9b225cdfef5be05bc4a9363a0de0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jul 28 10:12:31 2026 -0700
harden per contrarian gate: resolve trade customer-id by email + hard-fail instead of __UNKNOWN__ live write (closes memo §5); DEPLOY.md rewritten for wired path B; selftest banner + boot log fixed (no pass echo) — still DRY_RUN
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
DEPLOY.md | 201 ++++++++++++++++++++++------------------------------
lib/shopify.js | 13 +++-
lib/trade.js | 28 ++++++--
scripts/selftest.js | 2 +-
server.js | 2 +-
5 files changed, 122 insertions(+), 124 deletions(-)
diff --git a/DEPLOY.md b/DEPLOY.md
index e0be3ae..4ee21f9 100644
--- a/DEPLOY.md
+++ b/DEPLOY.md
@@ -1,21 +1,24 @@
-# DEPLOY — dw-signup-fulfillment go-live runbook
+# DEPLOY — dw-signup-fulfillment go-live runbook (path B: sample-locked shared code)
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 flows do once live:
-> - **Retail** — on `customers/create`, generate a UNIQUE single-use code
-> (`DWSAMP-XXXXXXXX`) and create a Shopify **code app-discount**
-> (`discountCodeAppCreate`) that references the deployed **"DW Free Samples"
-> Discount Function**, then email the customer the code ("your code for 3 free
-> samples: …"). The function targets ONLY sample lines and caps the freebie at
-> 3 sample units, so full rolls are never zeroed. We do **NOT** create an
-> automatic (auto-apply) discount — Steve chose the per-customer code path.
-> - **Trade** — `POST /trade/apply` → application queued `pending` → reviewed at
-> `/admin/trade` → on **approve**: tag customer `trade` (unlocks free memos),
-> assign the DW House Account, set `custom.assigned_rep` metafield, email rep +
-> applicant.
+> **Wired behavior** (DTD 2026-07-28, 5/5 → path B):
+> - **Retail** — on `customers/create`, the service **emails the new customer ONE
+> shared code** (`RETAIL_SHARED_CODE`, e.g. `DWSAMPLES3`). That code is a **code
+> discount you create ONCE in the Shopify admin**, pointing at the deployed
+> **"DW Free Samples" Discount Function**, limited to **one use per customer**.
+> The service makes **no Shopify discount write** — it only sends the email.
+> Safety comes from the FUNCTION: signed-in non-trade → first 3 **Sample** units
+> free; it **never discounts the `$94.42` roll variant**. (This is why the function
+> deploy in Step 1 is REQUIRED, not optional — the code without the function behind
+> it is just a plain discount with no roll protection.)
+> - **Trade** — `POST /trade/apply` → `pending` → reviewed at `/admin/trade` → on
+> **approve**: resolve the Shopify customer id (by email if the form didn't supply
+> one; **hard-fails** if no such customer exists — never writes a placeholder), tag
+> `trade` (unlocks free memos), assign the DW House Account, set `custom.assigned_rep`,
+> email rep + applicant.
---
@@ -27,73 +30,59 @@ 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):
+Store target is the LIVE prod store (legacy misnomer):
`designer-laboratory-sandbox.myshopify.com`, Admin API `2024-10`.
---
-## 1. One-time: deploy the free-samples Discount Function → get its ID
+## 1. One-time: deploy the "DW Free Samples" Discount Function (REQUIRED — the safety layer)
-The wired retail path (`lib/retail-code.js`) attaches each per-customer code
-discount to a deployed **Discount Function**. Deploy it ONCE, interactively
-(these are the only gated actions in this step):
+This is the roll-protection. The shared code you create in Step 2 must point at it,
+or the code offers no roll safety. Deploy it ONCE, interactively (Steve-only — Partner
+login):
```sh
cd ~/Projects/Designer-Wallcoverings/shopify/staged/free-samples-function
-shopify app config link # pick DW Partner org + designer-laboratory-sandbox (writes client_id into shopify.app.toml)
-shopify app deploy # uploads the Function; NO discount is created yet → zero customer impact
+shopify app config link # DW Partner org + designer-laboratory-sandbox
+shopify app deploy # uploads the function; NO discount created yet → zero customer impact
```
-Then read the Function's id (read-only GraphQL, DRAFT token has the scope):
+> If `shopify app config link` refuses over scope negotiation, confirm
+> `shopify.app.toml` `access_scopes` includes `write_discounts` before retrying.
-```graphql
-query { shopifyFunctions(first: 25) { nodes { id title apiType } } }
-```
-
-Copy the `id` whose `title == "DW Free Samples"` (apiType `discount`) and set it
-in this project's `.env`:
-
-```sh
-DISCOUNT_FUNCTION_ID=<that id>
-```
-
-> We do NOT create an automatic discount (Steve rejected auto-apply). The function
-> is only ever referenced by the per-customer CODE discounts this service creates
-> via `discountCodeAppCreate`. A function with no discount pointing at it has zero
-> effect, so it is safe to deploy ahead of go-live.
+(Optional, informational: read the function id for reference —
+`query { shopifyFunctions(first:25){ nodes{ id title apiType } } }` → set
+`DISCOUNT_FUNCTION_ID`. The service does not use it on the wired path; the admin
+discount is what references the function.)
---
-## 2. Set the Shopify token
+## 2. One-time: create the shared code discount in the Shopify admin
-`SHOPIFY_FULFILLMENT_TOKEN` needs scopes:
-`write_customers, write_discounts, read_customers` + read/write webhooks.
-(`write_discounts` covers `discountCodeAppCreate`.)
+Shopify admin → **Discounts** → create a **code** discount:
+- code: **`DWSAMPLES3`** (must match `RETAIL_SHARED_CODE` exactly),
+- discount type: the **"DW Free Samples"** app/function discount from Step 1,
+- usage limit: **one use per customer**.
-The existing `SHOPIFY_DRAFT_TOKEN` in `~/Projects/secrets-manager/.env` (value
-ends `a43b`) already carries `write_discounts` + `write_customers`, 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.
+The service emails this exact code to every new customer; it never mints codes.
---
-## 3. Set the webhook secret
-
-You need `SHOPIFY_WEBHOOK_SECRET` so the HMAC on incoming webhooks verifies.
-Shopify returns / exposes this when you register the webhook (step 5). If you
-register via the Admin UI, copy the signing secret it shows into `.env`:
+## 3. Set env (`.env` — gitignored, never commit/echo real tokens)
```sh
-SHOPIFY_WEBHOOK_SECRET=<the webhook signing secret>
+SHOPIFY_FULFILLMENT_TOKEN=<value of SHOPIFY_DRAFT_TOKEN from ~/Projects/secrets-manager/.env, ends a43b>
+SHOPIFY_WEBHOOK_SECRET=<signing secret from the webhook registration in Step 5>
+RETAIL_SHARED_CODE=DWSAMPLES3 # MUST equal the admin code from Step 2 (else WARN + skip send)
+HOUSE_ACCOUNT_EMAIL=info@designerwallcoverings.com
+# DISCOUNT_FUNCTION_ID — informational only on the wired path; safe to leave blank.
```
+The `…a43b` token carries `write_customers, read_customers, write_discounts,
+write_price_rules, write_gift_cards` (verified 2026-07-28 via
+`/admin/oauth/access_scopes.json`) — sufficient for tagging, metafields, and the
+email-lookup at approve.
+
---
## 4. Deploy the service (still DRY_RUN)
@@ -104,44 +93,37 @@ 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.
+Expose it at a public HTTPS URL (Kamatera vhost / Cloudflare) so Shopify can POST.
+Call that `$PUBLIC_URL`. `/healthz` returns 200 before any auth (fleet rule).
---
## 5. Register the customers/create webhook (Admin API)
-This is a WRITE and is DRY_RUN-gated in `lib/shopify.js`. Do it explicitly with
-curl at go-live so you control it. `address` = your public webhook URL.
+DRY_RUN-gated in `lib/shopify.js`; do it explicitly at go-live.
```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" \
+ -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 3) if you didn't already.
+Record the returned webhook **id** (rollback). Put its signing secret into
+`SHOPIFY_WEBHOOK_SECRET` (Step 3).
---
## 6. Flip DRY_RUN off
-Only after `DISCOUNT_FUNCTION_ID` is set (step 1) — the selftest / logs will WARN
-if it is unset, and `discountCodeAppCreate` will fail live without it. Only now:
+Only after Steps 1–5 are green (in particular the function is deployed and the shared
+code exists and equals `RETAIL_SHARED_CODE` — the selftest / logs WARN loudly if the
+code is unset):
```sh
-pm2 set # or edit ecosystem.config.js env.DRY_RUN to '0', OR set in .env:
-# DRY_RUN=0
+# set DRY_RUN=0 in .env or ecosystem.config.js env, then:
pm2 restart dw-signup-fulfillment --update-env
curl -s http://127.0.0.1:9856/healthz # -> "dry_run":false
```
@@ -150,65 +132,50 @@ curl -s http://127.0.0.1:9856/healthz # -> "dry_run":false
## 7. Smoke-test LIVE (carefully)
-- Create ONE test retail customer in the store (or trigger a real signup with a
- throwaway email you control) → confirm a **code discount** was created (Shopify
- admin → Discounts, title `DW Free Samples — DWSAMP-…`, usage limit 1) and the
- email with the code arrived. Then, signed in as that customer, add 4 sample
- swatches + 1 full roll to the cart and redeem the code → first 3 samples free,
- 4th sample paid, **roll unaffected**.
-- `POST /trade/apply` a test application → open `/admin/trade` → **Approve** →
- confirm the test customer now carries the `trade` tag, the `custom.assigned_rep`
- metafield is set, and the rep + applicant emails went out.
-
-Watch `pm2 logs dw-signup-fulfillment` during the smoke test.
+- Trigger a real signup with a throwaway email you control → confirm the email
+ arrives carrying **`DWSAMPLES3`** (NOT a `DWSAMP-…` unique code — the service does
+ not mint one), and confirm **no new discount record was created by the service**
+ in the admin. Then, signed in as that customer, add **4 sample swatches + 1 full
+ roll** and redeem the code → **first 3 samples free, 4th sample paid, roll
+ unaffected** (this is the live proof of the roll-safety guarantee).
+- `POST /trade/apply` a test application (email only, no `shopify_customer_id`, as the
+ public form sends) for an email that HAS a store account → open `/admin/trade` →
+ **Approve** → confirm the approve **resolved the customer by email**, the customer
+ now carries the `trade` tag + `custom.assigned_rep` metafield, and rep + applicant
+ emails went out. Then approve an application whose email has **no** store account →
+ confirm it **hard-fails** with `cannot_resolve_customer` and writes nothing.
+
+Watch `pm2 logs dw-signup-fulfillment` throughout.
---
## 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:
+2. **Delete the webhook**:
```sh
- SHOP=designer-laboratory-sandbox.myshopify.com
- TOKEN=<SHOPIFY_FULFILLMENT_TOKEN>
- WEBHOOK_ID=<id from step 5>
- # list if you lost the id:
- curl -s "https://$SHOP/admin/api/2024-10/webhooks.json" -H "X-Shopify-Access-Token: $TOKEN"
- curl -s -X DELETE "https://$SHOP/admin/api/2024-10/webhooks/$WEBHOOK_ID.json" \
- -H "X-Shopify-Access-Token: $TOKEN"
+ SHOP=designer-laboratory-sandbox.myshopify.com; TOKEN=<SHOPIFY_FULFILLMENT_TOKEN>
+ curl -s "https://$SHOP/admin/api/2024-10/webhooks.json" -H "X-Shopify-Access-Token: $TOKEN" # find the id
+ curl -s -X DELETE "https://$SHOP/admin/api/2024-10/webhooks/<id>.json" -H "X-Shopify-Access-Token: $TOKEN"
```
-
-3. (Optional) `pm2 stop dw-signup-fulfillment`.
-
-Code discounts already issued are not auto-revoked — the individual
-`DWSAMP-…` codes are single-use and expire on redemption; delete or deactivate
-them in the Shopify admin (Discounts) if you need to invalidate outstanding codes.
-To stop the function itself from having any effect, ensure no automatic discount
-references it (this service never creates one) and, if desired, `shopify app
-deploy` a version without the extension.
+3. To stop the retail offer entirely: **deactivate the `DWSAMPLES3` discount** in the
+ admin (Discounts). Already-emailed codes are one-use-per-customer and stop working
+ the moment the discount is deactivated. (Optional) `pm2 stop dw-signup-fulfillment`.
---
## Alternates (NOT wired — reference only)
-The two other retail modules are kept for reference and are reachable only for
-comparison via the manual admin endpoint; they are **not** on the webhook path:
-
- `lib/giftcard.js` — stored-value gift card. Reachable via
- `POST /admin/retail/issue?mode=giftcard`. **Not safe as default**: a gift-card
- balance can be spent on any line item, including full rolls.
-- `lib/giftcode-discount.js` — price-rule + Samples-collection-scoped 100%-off
- code. Reachable via `POST /admin/retail/issue?mode=discount` (needs
- `SAMPLES_COLLECTION_ID`). Riskier than the function's variant-level targeting
- because DW samples share a product with the sellable roll variant.
-
-The wired default is `lib/retail-code.js` (function-backed unique code). Switching
-away from it is a deliberate `server.js` change; do not do it without cause.
+ `POST /admin/retail/issue?mode=giftcard`. **Not safe as default**: the balance is
+ spendable on any line item, including full rolls (bounded to `$12.75`).
+- `lib/giftcode-discount.js` — price-rule + Samples-collection-scoped 100%-off code.
+ Reachable via `POST /admin/retail/issue?mode=discount` (needs `SAMPLES_COLLECTION_ID`).
+
+The wired default is `lib/retail-code.js` (sample-locked shared function code).
+Switching away is a deliberate `server.js` change; do not do it without cause.
diff --git a/lib/shopify.js b/lib/shopify.js
index 0d92d08..2ef5309 100644
--- a/lib/shopify.js
+++ b/lib/shopify.js
@@ -91,6 +91,17 @@ async function getCustomer(customerId) {
return request('GET', `/customers/${customerId}.json`, undefined);
}
+// Resolve a Shopify customer id from an email (read_customers scope). Used at trade
+// approval when the application came from the public form (no shopify_customer_id).
+// Returns the id, or null if no customer with that email exists / can't be read.
+async function findCustomerByEmail(emailAddr) {
+ const e = (emailAddr || '').trim().toLowerCase();
+ if (!e) return null;
+ const r = await request('GET', `/customers/search.json?query=${encodeURIComponent('email:' + e)}`, undefined);
+ const c = r && r.json && Array.isArray(r.json.customers) ? r.json.customers[0] : null;
+ return c && c.id ? c.id : null;
+}
+
// 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);
@@ -162,6 +173,6 @@ async function graphql(query, variables, opts = {}) {
}
module.exports = {
- request, graphql, createGiftCard, updateCustomer, getCustomer, addTags,
+ request, graphql, createGiftCard, updateCustomer, getCustomer, findCustomerByEmail, addTags,
setCustomerMetafield, createWebhook, base,
};
diff --git a/lib/trade.js b/lib/trade.js
index 90f8e80..5f75017 100644
--- a/lib/trade.js
+++ b/lib/trade.js
@@ -82,15 +82,34 @@ async function approve(id) {
const rep = reps.assignRep();
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)';
+ // Resolve the Shopify customer id. Public-form applications carry no id, so look it
+ // up by email (read_customers scope). NEVER write against a placeholder id — if we
+ // can't resolve a real customer, HARD-FAIL the approve with a clear admin message so
+ // the customer is never left silently untagged (which would still charge them for
+ // samples). (Closes the original memo §5 "customer-id-by-email" TODO + Cody #4.)
+ let custId = app.shopify_customer_id;
+ let resolvedBy = custId ? 'application' : null;
+ if (!custId) {
+ custId = await shopify.findCustomerByEmail(app.email);
+ if (custId) resolvedBy = 'email_lookup';
+ }
+ if (!custId) {
+ return {
+ ok: false,
+ error: 'cannot_resolve_customer',
+ message: `No Shopify customer found for ${app.email}. The applicant must have a store account (signed up) before trade approval. Not writing anything.`,
+ email: app.email,
+ };
+ }
+ steps.push({ step: 'resolve_customer', customer: custId, via: resolvedBy });
+ const custRef = custId;
// (b) tag `trade`
- const tagRes = await shopify.addTags(custId || '__UNKNOWN__', ['trade']);
+ const tagRes = await shopify.addTags(custId, ['trade']);
steps.push({ step: 'tag_trade', customer: custRef, result: summarizeShopify(tagRes) });
// (c) metafield custom.assigned_rep
- const mfRes = await shopify.setCustomerMetafield(custId || '__UNKNOWN__', {
+ const mfRes = await shopify.setCustomerMetafield(custId, {
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) });
@@ -108,6 +127,7 @@ async function approve(id) {
app.status = 'approved';
app.decision = 'approved';
app.decided_at = new Date().toISOString();
+ app.shopify_customer_id = custId; // persist the resolved id for audit
app.assigned_rep = { id: rep.id, name: rep.name, email: rep.email };
rewriteAll(rows);
diff --git a/scripts/selftest.js b/scripts/selftest.js
index 653c2dc..78eea4e 100644
--- a/scripts/selftest.js
+++ b/scripts/selftest.js
@@ -66,7 +66,7 @@ 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('Retail path: function-backed unique CODE (first ' + config.FREE_SAMPLE_COUNT + ' sample units free) · DISCOUNT_FUNCTION_ID=' + (config.DISCOUNT_FUNCTION_ID || '(unset)'));
+ console.log('Retail path (WIRED): sample-locked SHARED code (first ' + config.FREE_SAMPLE_COUNT + ' sample units free, function-enforced) · RETAIL_SHARED_CODE=' + (config.RETAIL_SHARED_CODE || '(unset — will WARN+skip send)'));
if (!config.DRY_RUN) { fail('DRY_RUN is OFF — refusing to run selftest that would make live writes'); return; }
// ---------------------------------------------------------------------------
diff --git a/server.js b/server.js
index 4509f47..16fd759 100644
--- a/server.js
+++ b/server.js
@@ -198,7 +198,7 @@ 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(` admin: http://127.0.0.1:${config.PORT}/admin/trade (basic-auth user=${config.ADMIN_USER}, pass in env/config — not logged)`);
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. **');
});
← d0059b8 retail: rewire webhook to sample-locked shared function code
·
back to Dw Signup Fulfillment
·
retail: FINAL = gift card (Steve, memo §2) — re-wire webhook f385a17 →