← back to Dw Signup Fulfillment
README.md
135 lines
# 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 discounts 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) | Generate a UNIQUE single-use code (`DWSAMP-XXXXXXXX`), create a Shopify **code app-discount** (`discountCodeAppCreate`) referencing the deployed **"DW Free Samples" Discount Function** (samples-only, first `FREE_SAMPLE_COUNT` free), email the customer the code |
| **Trade** | `POST /trade/apply` → moderated review | On **approve**: tag customer `trade`, assign the DW House Account, set `custom.assigned_rep` metafield, email rep + applicant |
| **Rep assignment** | on approve | Fixed to the DW House Account (`data/reps.json`) |
Trade access is **moderated approval** — a professional signup is NOT tagged
automatically; an application is submitted, reviewed at `/admin/trade`, and only
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. Default = the wired
function-backed code path; `?mode=giftcard` and `?mode=discount` exercise the two
(not-wired) alternates for comparison.
- `GET /reps/next` — peek the next round-robin rep + cursor.
## Run
```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
### Retail freebie = a function-backed UNIQUE single-use code (Steve's decision)
On `customers/create`, `lib/retail-code.js` mints a unique code (`DWSAMP-` + 8
uppercase base32) and creates a Shopify **code app-discount** via
`discountCodeAppCreate` that references the deployed **"DW Free Samples" Discount
Function** (`extensions/free-samples-discount`, id in `DISCOUNT_FUNCTION_ID`). The
code is `usageLimit: 1`, `appliesOncePerCustomer: true`. The customer is then
emailed the code.
**Why a function-backed code and not a gift card or a plain product/collection
code:** DW samples are **variants** (`variant.title == "Sample"`, $4.25) that
share a product with the sellable **"Yard"** roll variant ($94.42). A gift-card
balance or a plain product-/collection-scoped 100%-off code would also zero the
roll variant — a real money leak. The Discount Function targets **only** sample
lines (`variant.title == "Sample"` OR product metafield `custom.is_sample ==
"true"`) and caps the freebie at the first `FREE_SAMPLE_COUNT` sample units, so
full rolls are never discounted. The same function backs either an automatic
discount or a code discount with no code change — we attach it to per-customer
**code** discounts (Steve rejected auto-apply).
### Alternates (kept, not wired)
Two other retail modules remain in the repo for reference, reachable only via the
manual admin endpoint (not the webhook):
- `lib/giftcard.js` — stored-value gift card (`?mode=giftcard`). Not safe as
default: balance is spendable on any line item, including rolls.
- `lib/giftcode-discount.js` — price-rule + Samples-collection-scoped 100%-off
code (`?mode=discount`). Riskier than the function's variant-level targeting
because samples share a product with the roll variant.
Each carries a top-of-file "not wired — retail default is `lib/retail-code.js`"
banner.
### Rep assignment = fixed DW House Account
On approve, the applicant is assigned to the single **DW House Account**
(`info@designerwallcoverings.com`) from `data/reps.json` via `lib/reps.js`
(`houseAccount()`), and `custom.assigned_rep` is set to it. There is no
round-robin.
### DRY_RUN safety model
`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).
- `DISCOUNT_FUNCTION_ID` — **required for retail** at go-live: the id of the
deployed "DW Free Samples" Discount Function the code discount references. Unset
in DRY_RUN → still simulates but logs a WARN.
- `SHOPIFY_FULFILLMENT_TOKEN` — can be the `SHOPIFY_DRAFT_TOKEN` value (ends
`a43b`), which carries `write_discounts` + `write_customers` + `read_customers`.
- `SHOPIFY_WEBHOOK_SECRET` — the webhook signing secret (HMAC).
- Optional: `FREE_SAMPLE_COUNT` (3), `SAMPLE_PRICE` (4.25), `PORT` (9856),
`SAMPLES_COLLECTION_ID` (only for the not-wired discount alternate), `ADMIN_USER`
/ `ADMIN_PASS`.
George email creds (`GEORGE_EXTERNAL_SEND_TOKEN`, `GEORGE_BASIC_AUTH`) are
resolved automatically from `~/Projects/george-gmail/.env` (Mac) or
`~/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.