[object Object]

← 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

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 →