← back to Dw Signup Fulfillment
retail: shared admin master-code model (function discounts are app-scoped; cross-app minting not possible) — service emails RETAIL_SHARED_CODE
471c9458681acb6d0797cd6a5db592354de7c054 · 2026-07-28 09:19:36 -0700 · Steve
Files touched
M .env.exampleM lib/config.jsM lib/retail-code.jsM scripts/selftest.js
Diff
commit 471c9458681acb6d0797cd6a5db592354de7c054
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Jul 28 09:19:36 2026 -0700
retail: shared admin master-code model (function discounts are app-scoped; cross-app minting not possible) — service emails RETAIL_SHARED_CODE
---
.env.example | 4 ++
lib/config.js | 15 ++++--
lib/retail-code.js | 129 +++++++++++++---------------------------------------
scripts/selftest.js | 57 ++++++++++-------------
4 files changed, 69 insertions(+), 136 deletions(-)
diff --git a/.env.example b/.env.example
index d3ecd20..92a0519 100644
--- a/.env.example
+++ b/.env.example
@@ -63,3 +63,7 @@ GEORGE_FROM=steve@designerwallcoverings.com
# --- Admin review surface (basic-auth) ------------------------------------
ADMIN_USER=admin
ADMIN_PASS=DW2024!
+
+# Retail: the exact code of the admin-created "DW Free Samples" discount (shared, one-use-per-customer).
+# Must match the code you set in Shopify admin (e.g. DWSAMPLES3). Required for retail emails.
+RETAIL_SHARED_CODE=
diff --git a/lib/config.js b/lib/config.js
index 0a78f42..b9c60c9 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -51,10 +51,17 @@ const config = {
FREE_SAMPLE_COUNT: parseInt(process.env.FREE_SAMPLE_COUNT || '3', 10),
CURRENCY: process.env.CURRENCY || 'USD',
- // WIRED retail path (lib/retail-code.js): the deployed "DW Free Samples" Discount
- // FUNCTION id. discountCodeAppCreate requires it (functionId). Get it once by
- // deploying extensions/free-samples-discount (see DEPLOY.md) and set it here.
- // If unset in DRY_RUN we still simulate but log a clear WARN.
+ // WIRED retail path (lib/retail-code.js): the SHARED discount code created once in
+ // Shopify admin against the "DW Free Samples" function, limited to one-use-per-
+ // customer. The service just emails this code to every new customer. Must match the
+ // admin-created code exactly. (Per-customer unique codes aren't possible from this
+ // token — the function is owned by a different app; see retail-code.js.)
+ // No default on purpose: must be set explicitly to the exact admin-created code, so
+ // a mismatch fails loud (WARN + skipped send) rather than emailing a wrong code.
+ RETAIL_SHARED_CODE: process.env.RETAIL_SHARED_CODE || '',
+
+ // The deployed function id — informational now (retail uses the shared admin code,
+ // not discountCodeAppCreate). Kept for reference / a possible future owned-app path.
DISCOUNT_FUNCTION_ID: process.env.DISCOUNT_FUNCTION_ID || '',
// Used only by the ALTERNATE lib/giftcode-discount.js (not wired) — scopes 100%-off
diff --git a/lib/retail-code.js b/lib/retail-code.js
index 042c856..f2671df 100644
--- a/lib/retail-code.js
+++ b/lib/retail-code.js
@@ -1,119 +1,52 @@
'use strict';
-// PRIMARY retail free-samples path (Steve's decision, TK-10006).
+// PRIMARY retail free-samples path (Steve's decision, TK-10006 — "shared master code").
//
-// For each new retail customer we generate a UNIQUE single-use CODE and create a
-// Shopify CODE app-discount that references the deployed "DW Free Samples"
-// Discount FUNCTION. We then email the customer the code:
-// "your code for 3 free samples: DWSAMP-XXXXXXXX".
+// Shopify scopes function-backed discounts to the OWNING app, and our automation
+// token (SHOPIFY_FULFILLMENT_TOKEN) belongs to a DIFFERENT app than the one that
+// owns the "DW Free Samples" function — so this service cannot mint per-customer
+// codes via discountCodeAppCreate (Shopify rejects cross-app function references).
//
-// WHY a function-backed CODE discount (and NOT a gift card, NOT a plain
-// product/collection-scoped code):
-// DW samples are VARIANTS (variant.title "Sample", $4.25) that SHARE a product
-// with the sellable "Yard" variant ($94.42). A plain product- or collection-
-// scoped 100%-off code would also zero the ROLL/Yard variant on that product —
-// a real money leak. The Discount FUNCTION (extensions/free-samples-discount)
-// targets ONLY sample lines (variant.title === "Sample" OR product metafield
-// custom.is_sample === "true") and caps the freebie at the first 3 sample UNITS.
-// Attaching that same function to a CODE discount (discountCodeAppCreate) gives
-// each customer a personal, single-use, sample-only 3-free code with zero risk
-// to roll pricing.
+// Instead: ONE shared code discount is created once in the Shopify admin, pointing
+// at the DW Free Samples function and limited to "one use per customer" (see
+// DEPLOY.md). This module simply EMAILS that shared code to every new customer on
+// customers/create. No Shopify write happens here.
//
-// The function is deployed ONCE (see DEPLOY.md) and its id set in
-// DISCOUNT_FUNCTION_ID. No function code change is needed — the identical function
-// backs either an automatic discount OR (as here) a code discount.
-//
-// DRY_RUN-safe: lib/shopify.graphql() short-circuits the mutation, logs the exact
-// WOULD-call + payload, and returns a synthetic { codeAppDiscount: { discountId } }
-// so the email template + result envelope still have something real-shaped to show.
-const crypto = require('crypto');
+// Safety comes from the FUNCTION, not the code: signed-in non-trade -> first 3
+// SAMPLE units free; trade -> all samples free; anonymous -> none. It never touches
+// the $94.42 "Yard" roll variant. "One use per customer" on the discount bounds reuse.
const config = require('./config');
-const shopify = require('./shopify');
const email = require('./email');
-// Unique code: DWSAMP-<8 uppercase base32 (Crockford-ish, no ambiguous chars)>.
-const BASE32 = 'ABCDEFGHIJKLMNPQRSTUVWXYZ23456789'; // 32 chars, no 0/O/1/I
-function uniqueCode() {
- const bytes = crypto.randomBytes(8);
- let s = '';
- for (let i = 0; i < 8; i++) s += BASE32[bytes[i] % 32];
- return 'DWSAMP-' + s;
-}
-
-// The discountCodeAppCreate mutation. Returns the created code discount's id.
-const MUTATION = `
-mutation dwSampleCodeCreate($codeAppDiscount: DiscountCodeAppInput!) {
- discountCodeAppCreate(codeAppDiscount: $codeAppDiscount) {
- codeAppDiscount { discountId title status }
- userErrors { field message }
- }
-}`.trim();
-
-function buildInput(code) {
- return {
- title: `DW Free Samples — ${code}`,
- functionId: config.DISCOUNT_FUNCTION_ID || null,
- code,
- startsAt: new Date().toISOString(),
- usageLimit: 1,
- appliesOncePerCustomer: true,
- customerSelection: { all: true },
- combinesWith: { orderDiscounts: false, productDiscounts: true, shippingDiscounts: true },
- };
-}
-
async function issueRetailCode(customer) {
- const code = uniqueCode();
- const input = buildInput(code);
+ const code = config.RETAIL_SHARED_CODE;
- // If the function id is missing we can still SIMULATE in DRY_RUN, but warn loudly:
- // at go-live an unset DISCOUNT_FUNCTION_ID means the code discount cannot bind to
- // the function and would be rejected by Shopify.
+ // At go-live RETAIL_SHARED_CODE must equal the admin-created code (e.g. DWSAMPLES3).
+ // If unset we still run (so the webhook never 500s) but skip the send + warn loudly.
let warn = null;
- if (!config.DISCOUNT_FUNCTION_ID) {
- warn = 'WARN(go-live): DISCOUNT_FUNCTION_ID is unset — deploy the free-samples function and set it, or discountCodeAppCreate will fail (functionId is required). Simulating anyway.';
+ if (!code) {
+ warn = 'WARN(go-live): RETAIL_SHARED_CODE is unset — create the shared "DW Free Samples" code discount in Shopify admin and set RETAIL_SHARED_CODE to its code. Skipping the email until then.';
console.warn('[retail-code] ' + warn);
}
- const gq = await shopify.graphql(
- MUTATION,
- { codeAppDiscount: input },
- {
- synthetic: () => ({
- discountCodeAppCreate: {
- codeAppDiscount: {
- discountId: 'gid://shopify/DiscountCodeApp/' + (900000000 + Math.floor(Math.random() * 1e6)),
- title: input.title,
- status: 'ACTIVE',
- },
- userErrors: [],
- },
- }),
- }
- );
-
- const payload = gq.json && gq.json.data && gq.json.data.discountCodeAppCreate ? gq.json.data.discountCodeAppCreate : {};
- const userErrors = payload.userErrors || [];
- const discountId = payload.codeAppDiscount ? payload.codeAppDiscount.discountId : null;
-
- // Email the code to the customer.
const firstName = customer.first_name || (customer.email ? customer.email.split('@')[0] : '');
- const tpl = email.retailCodeEmail({ firstName, code, count: config.FREE_SAMPLE_COUNT });
- const mail = await email.sendEmail({ to: customer.email, subject: tpl.subject, html: tpl.html, source: 'retail-code' });
+ const tpl = email.retailCodeEmail({ firstName, code: code || '(code not configured)', count: config.FREE_SAMPLE_COUNT });
+ const mail = code
+ ? await email.sendEmail({ to: customer.email, subject: tpl.subject, html: tpl.html, source: 'retail-code' })
+ : { dryRun: config.DRY_RUN, ok: false, skipped: true };
return {
- path: 'function_code',
- code,
+ path: 'shared_code',
+ code: code || null,
count: config.FREE_SAMPLE_COUNT,
- functionId: config.DISCOUNT_FUNCTION_ID || null,
- discountId,
- usageLimit: input.usageLimit,
- userErrors,
warn,
- email: { to: customer.email, subject: tpl.subject, dryRun: mail.dryRun || false, ok: mail.ok !== false },
- shopifyCall: gq.dryRun
- ? { WOULD: `${gq.method} ${gq.url} (discountCodeAppCreate)`, variables: gq.variables }
- : { status: gq.status },
+ email: {
+ to: customer.email,
+ subject: tpl.subject,
+ dryRun: mail.dryRun || false,
+ ok: mail.ok !== false,
+ skipped: mail.skipped || false,
+ },
};
}
-module.exports = { issueRetailCode, uniqueCode, MUTATION, buildInput };
+module.exports = { issueRetailCode };
diff --git a/scripts/selftest.js b/scripts/selftest.js
index 56f5d2c..fff9bc1 100644
--- a/scripts/selftest.js
+++ b/scripts/selftest.js
@@ -21,12 +21,12 @@ const fs = require('fs');
const path = require('path');
const TEST_SECRET = 'selftest-webhook-secret-abc123';
-const TEST_FUNCTION_ID = 'gid://shopify/Function/selftest-free-samples-fn';
+const TEST_SHARED_CODE = 'DWSAMPLES3';
process.env.DRY_RUN = '1';
process.env.SHOPIFY_WEBHOOK_SECRET = TEST_SECRET;
-// Pin a fake function id so the retail-code path asserts the functionId is carried
-// into the discountCodeAppCreate payload (in prod this is the real deployed id).
-process.env.DISCOUNT_FUNCTION_ID = TEST_FUNCTION_ID;
+// Pin the shared retail code so the retail path asserts it emails exactly that code
+// (in prod this is the code of the admin-created "DW Free Samples" discount).
+process.env.RETAIL_SHARED_CODE = TEST_SHARED_CODE;
// 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-'));
@@ -69,54 +69,43 @@ async function main() {
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 → function-backed unique CODE path');
+ hr('(a) customers/create webhook — VALID HMAC → shared-code email 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 function-code issuance (what it WOULD do) ---');
+ console.log(' --- retail shared-code issuance (what it WOULD do) ---');
const codeResult = await retailCode.issueRetailCode(fakeCustomer);
console.log(' result: ' + JSON.stringify(codeResult, null, 2));
- if (codeResult.path === 'function_code') ok('retail path is function_code (not gift card / not collection code)');
- else fail('retail path is not function_code');
- // Unique code shape: DWSAMP-<8 uppercase base32>
- if (/^DWSAMP-[A-Z2-9]{8}$/.test(codeResult.code)) ok('unique code has the DWSAMP-XXXXXXXX shape: ' + codeResult.code);
- else fail('bad code shape: ' + codeResult.code);
- // WOULD call discountCodeAppCreate with the function id + usageLimit 1 + the code.
- const would = codeResult.shopifyCall && codeResult.shopifyCall.WOULD;
- const vars = codeResult.shopifyCall && codeResult.shopifyCall.variables && codeResult.shopifyCall.variables.codeAppDiscount;
- if (would && /discountCodeAppCreate/.test(would)) ok('WOULD call discountCodeAppCreate (' + would + ')');
- else fail('did not record a WOULD discountCodeAppCreate call');
- if (vars && vars.functionId === TEST_FUNCTION_ID) ok('payload carries the deployed functionId (' + vars.functionId + ')');
- else fail('payload missing/incorrect functionId');
- if (vars && vars.usageLimit === 1) ok('payload usageLimit = 1 (single-use)'); else fail('usageLimit is not 1');
- if (vars && vars.appliesOncePerCustomer === true) ok('payload appliesOncePerCustomer = true'); else fail('appliesOncePerCustomer not set');
- if (vars && vars.code === codeResult.code) ok('payload code matches the emitted unique code'); else fail('payload code mismatch');
- if (vars && vars.combinesWith && vars.combinesWith.orderDiscounts === false) ok('combinesWith.orderDiscounts = false'); else fail('combinesWith not set as specified');
- if (codeResult.functionId === TEST_FUNCTION_ID) ok('result records the functionId'); else fail('result missing functionId');
- if (!codeResult.warn) ok('DISCOUNT_FUNCTION_ID set → no unset-function WARN'); else fail('unexpected WARN with function id set: ' + codeResult.warn);
- if (codeResult.email && codeResult.email.dryRun) ok('WOULD email the code to customer (dry-run, no real send)'); else fail('code email not dry-run');
+ if (codeResult.path === 'shared_code') ok('retail path is shared_code (email the admin-created master code)');
+ else fail('retail path is not shared_code');
+ if (codeResult.code === TEST_SHARED_CODE) ok('emits the configured shared code: ' + codeResult.code);
+ else fail('shared code mismatch: ' + codeResult.code);
+ if (!codeResult.warn) ok('RETAIL_SHARED_CODE set → no unset-code WARN'); else fail('unexpected WARN with code set: ' + codeResult.warn);
+ if (codeResult.email && codeResult.email.dryRun && !codeResult.email.skipped) ok('WOULD email the shared code to customer (dry-run, no real send)');
+ else fail('code email not dry-run / was skipped');
+ if (codeResult.email && codeResult.email.subject) ok('email has a subject: ' + codeResult.email.subject); else fail('no email subject');
// ---------------------------------------------------------------------------
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) retail-code with DISCOUNT_FUNCTION_ID UNSET → simulates + WARNs');
- const savedFid = process.env.DISCOUNT_FUNCTION_ID;
- delete process.env.DISCOUNT_FUNCTION_ID;
+ hr('(a2) retail-code with RETAIL_SHARED_CODE UNSET → skips send + WARNs');
+ const savedCode = process.env.RETAIL_SHARED_CODE;
+ process.env.RETAIL_SHARED_CODE = '';
delete require.cache[require.resolve('../lib/config')];
delete require.cache[require.resolve('../lib/shopify')];
delete require.cache[require.resolve('../lib/email')];
delete require.cache[require.resolve('../lib/retail-code')];
- const retailCodeNoFid = require('../lib/retail-code');
- const noFidResult = await retailCodeNoFid.issueRetailCode(fakeCustomer);
- if (noFidResult.path === 'function_code' && noFidResult.warn) ok('unset function id → still simulated, WARN raised: ' + noFidResult.warn);
- else fail('expected a simulate+WARN when DISCOUNT_FUNCTION_ID is unset');
- // restore env + module cache so the rest of the suite uses the pinned id.
- process.env.DISCOUNT_FUNCTION_ID = savedFid;
+ const retailCodeNoCode = require('../lib/retail-code');
+ const noCodeResult = await retailCodeNoCode.issueRetailCode(fakeCustomer);
+ if (noCodeResult.path === 'shared_code' && noCodeResult.warn && noCodeResult.email.skipped) ok('unset shared code → send skipped, WARN raised: ' + noCodeResult.warn);
+ else fail('expected a skip+WARN when RETAIL_SHARED_CODE is unset');
+ // restore env + module cache so the rest of the suite uses the pinned code.
+ process.env.RETAIL_SHARED_CODE = savedCode;
delete require.cache[require.resolve('../lib/config')];
delete require.cache[require.resolve('../lib/shopify')];
delete require.cache[require.resolve('../lib/email')];
← c323e03 retail: function-backed unique sample codes (safe sample-loc
·
back to Dw Signup Fulfillment
·
retail: wire automatic gift-card (unique code emailed per si a8ed621 →