← back to Shopify Sample Shipping
TK-11333: fix theme engine per Cody red-team — honest price-over-band messaging, code fires only when it helps, soft-cap hides wallets; add band-truth verification + engine-logic test (9/9)
3963701d224e2e68b177883721aeee1a6b41c978 · 2026-09-09 22:53:14 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GGVzyUM2B8rVz9TxouGgY
Files touched
A all-profiles.mjsA band-source.mjsM create-freeship-codes.mjsM create-trade-segment-undo.mjsM create-trade-segment.mjsA engine-logic-test.mjsA find-band.mjsA list-profiles.mjsA ptype-distinct.mjsA samples-profile.mjsM theme/sample-shipping-cart-engine.liquid
Diff
commit 3963701d224e2e68b177883721aeee1a6b41c978
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 9 22:53:14 2026 -0700
TK-11333: fix theme engine per Cody red-team — honest price-over-band messaging, code fires only when it helps, soft-cap hides wallets; add band-truth verification + engine-logic test (9/9)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GGVzyUM2B8rVz9TxouGgY
---
all-profiles.mjs | 19 ++++
band-source.mjs | 15 +++
create-freeship-codes.mjs | 25 ++--
create-trade-segment-undo.mjs | 9 +-
create-trade-segment.mjs | 75 +++++++-----
engine-logic-test.mjs | 52 +++++++++
find-band.mjs | 24 ++++
list-profiles.mjs | 4 +
ptype-distinct.mjs | 13 +++
samples-profile.mjs | 21 ++++
theme/sample-shipping-cart-engine.liquid | 189 ++++++++++++++++++-------------
11 files changed, 322 insertions(+), 124 deletions(-)
diff --git a/all-profiles.mjs b/all-profiles.mjs
new file mode 100644
index 0000000..8b822d5
--- /dev/null
+++ b/all-profiles.mjs
@@ -0,0 +1,19 @@
+import {query} from './query.mjs';
+const q=`{deliveryProfiles(first:20){nodes{id name default productVariantsCount{count}
+ profileLocationGroups{locationGroupZones(first:30){nodes{zone{id name}
+ methodDefinitions(first:40){nodes{id name active rateProvider{__typename
+ ... on DeliveryRateDefinition{price{amount currencyCode}}
+ ... on DeliveryParticipant{carrierService{name}}}
+ methodConditions{operator field conditionCriteria{__typename ... on MoneyV2{amount currencyCode} ... on Weight{value unit}}}}}}}}}}}`;
+const d=await query(q);
+for(const p of d.deliveryProfiles.nodes){
+ console.log(`\n#### PROFILE: ${p.name} ${p.default?'(DEFAULT)':''} — variants:${p.productVariantsCount.count} — ${p.id}`);
+ for(const g of p.profileLocationGroups)for(const z of g.locationGroupZones.nodes){
+ for(const md of z.methodDefinitions.nodes){
+ const rp=md.rateProvider;
+ const price=rp?.price?`$${rp.price.amount}`:(rp?.carrierService?`carrier:${rp.carrierService.name}`:rp?.__typename);
+ const conds=md.methodConditions.map(c=>{const cr=c.conditionCriteria;const v=cr?.amount!==undefined?`$${cr.amount}`:(cr?.value!==undefined?`${cr.value}${cr.unit}`:'?');return `${c.field} ${c.operator} ${v}`;}).join(' AND ')||'(none)';
+ console.log(` [${z.zone.name}] ${md.name} [${md.active?'on':'OFF'}] ${price} {${conds}}`);
+ }
+ }
+}
diff --git a/band-source.mjs b/band-source.mjs
new file mode 100644
index 0000000..818d526
--- /dev/null
+++ b/band-source.mjs
@@ -0,0 +1,15 @@
+import {query} from './query.mjs';
+const S='gid://shopify/ProductVariant/44090076954675';
+const ROLL='gid://shopify/ProductVariant/40348093055027';
+// 1) what rates + titles does a 6-sample cart see?
+const qq=`query($items:[DraftOrderLineItemInput!]!){draftOrderAvailableDeliveryOptions(input:{lineItems:$items,shippingAddress:{address1:"15442 Ventura Blvd",city:"Sherman Oaks",provinceCode:"CA",countryCode:US,zip:"91403"}}){availableShippingRates{title price{amount}}}}`;
+const r6=(await query(qq,{items:[{variantId:S,quantity:6}]})).draftOrderAvailableDeliveryOptions.availableShippingRates;
+console.log('6-sample cart ($25.50) rates:'); r6.forEach(x=>console.log(` "${x.title}" = $${x.price.amount}`));
+const r11=(await query(qq,{items:[{variantId:S,quantity:11}]})).draftOrderAvailableDeliveryOptions.availableShippingRates;
+console.log('11-sample cart ($46.75) rates:'); r11.forEach(x=>console.log(` "${x.title}" = $${x.price.amount}`));
+// 2) which delivery profile does the sample variant belong to?
+const pq=`{productVariant(id:"${S}"){id sku title product{title}
+ deliveryProfile{id name}}}`;
+try{const pv=await query(pq);console.log('\nSAMPLE variant profile:',JSON.stringify(pv.productVariant,null,0));}catch(e){console.log('variant.deliveryProfile query err:',e.message);}
+const pq2=`{productVariant(id:"${ROLL}"){id sku title deliveryProfile{id name}}}`;
+try{const pv2=await query(pq2);console.log('ROLL variant profile:',JSON.stringify(pv2.productVariant,null,0));}catch(e){console.log('roll err:',e.message);}
diff --git a/create-freeship-codes.mjs b/create-freeship-codes.mjs
index cee7a88..3d217b3 100644
--- a/create-freeship-codes.mjs
+++ b/create-freeship-codes.mjs
@@ -22,24 +22,29 @@ const SEG_REC = new URL('./verification/trade-segment-created.json', import.meta
const OUT = new URL('./verification/freeship-codes-created.json', import.meta.url);
const LOGX = process.env.HOME + '/.claude/yolo-queue/executed-reversible/log-exec.mjs';
-let segId;
-if (fs.existsSync(SEG_REC)) segId = JSON.parse(fs.readFileSync(SEG_REC, 'utf8')).segmentId;
-if (!segId) {
- if (APPLY) { console.error('MISSING segmentId — run create-trade-segment.mjs --apply FIRST'); process.exit(1); }
- segId = 'gid://shopify/Segment/PENDING'; // dry-run placeholder so the mutation shape prints
- console.log('(dry-run: no segment created yet — using placeholder segId; create the segment before --apply)');
+let segIds = [];
+if (fs.existsSync(SEG_REC)) {
+ const rec = JSON.parse(fs.readFileSync(SEG_REC, 'utf8'));
+ segIds = rec.segmentIds || (rec.segmentId ? [rec.segmentId] : []);
+}
+if (!segIds.length) {
+ if (APPLY) { console.error('MISSING segmentIds — run create-trade-segment.mjs --apply FIRST'); process.exit(1); }
+ segIds = ['gid://shopify/Segment/PENDING']; // dry-run placeholder so the mutation shape prints
+ console.log('(dry-run: no segment created yet — using placeholder; create the segment before --apply)');
}
const now = new Date().toISOString();
const combines = { orderDiscounts: true, productDiscounts: true, shippingDiscounts: false };
const CODES = [
- { code: 'TRADESHIP', title: 'DW Trade Sample Free Shipping', context: { customerSegments: { add: [segId] } } },
+ { code: 'TRADESHIP', title: 'DW Trade Sample Free Shipping', context: { customerSegments: { add: segIds } } },
{ code: 'SAMPLESHIP', title: 'DW Retail Sample Free Shipping', context: { all: 'ALL' } },
];
const MUT = `mutation($fs:DiscountCodeFreeShippingInput!){discountCodeFreeShippingCreate(freeShippingCodeDiscount:$fs){codeDiscountNode{id codeDiscount{__typename ... on DiscountCodeFreeShipping{title status}}} userErrors{field message}}}`;
function buildInput(c) {
- return { title: c.title, code: c.code, startsAt: now, appliesOnOneTimePurchase: true, appliesOnSubscription: false,
+ // NOTE: appliesOnOneTimePurchase/appliesOnSubscription omitted — this shop has no subscriptions,
+ // and Shopify rejects those fields unless subscriptions are enabled.
+ return { title: c.title, code: c.code, startsAt: now,
destination: { all: true }, maximumShippingPrice: MAX_SHIP, combinesWith: combines, context: c.context };
}
@@ -50,10 +55,10 @@ async function codeExists(code) {
}
console.log('=== create-freeship-codes (' + (APPLY ? 'APPLY' : 'DRY-RUN') + ') ===');
-console.log('segment (designer code) :', segId);
+console.log('segments (designer code):', segIds.join(', '));
console.log('maximumShippingPrice : $' + MAX_SHIP);
-const result = { at: now, segId, maxShip: MAX_SHIP, created: [] };
+const result = { at: now, segIds, maxShip: MAX_SHIP, created: [] };
for (const c of CODES) {
const input = buildInput(c);
console.log(`\n• ${c.code} — ${c.title}`);
diff --git a/create-trade-segment-undo.mjs b/create-trade-segment-undo.mjs
index 28c5795..ca31c10 100644
--- a/create-trade-segment-undo.mjs
+++ b/create-trade-segment-undo.mjs
@@ -14,21 +14,22 @@ const APPLY = process.argv.includes('--apply');
const REC = new URL('./verification/trade-segment-created.json', import.meta.url);
if (!fs.existsSync(REC)) { console.error('no verification/trade-segment-created.json — nothing to undo'); process.exit(1); }
const rec = JSON.parse(fs.readFileSync(REC, 'utf8'));
+const segIds = rec.segmentIds || (rec.segmentId ? [rec.segmentId] : []);
console.log('=== create-trade-segment UNDO (' + (APPLY ? 'APPLY' : 'DRY-RUN') + ') ===');
-console.log('would delete segment:', rec.segmentId);
+console.log('would delete segment(s):', segIds.join(', '));
if (rec.brokenSnapshot) console.log('would recreate broken segment:', rec.brokenSnapshot.name);
// safety: is the segment still referenced by a free-ship code?
try {
const nodes = (await query(`{codeDiscountNodes(first:100){nodes{codeDiscount{__typename ... on DiscountCodeFreeShipping{title customerSelection{__typename ... on DiscountCustomerSegments{segments{id}}}}}}}}`)).codeDiscountNodes.nodes;
- const inUse = nodes.some(n => n.codeDiscount?.customerSelection?.segments?.some(s => s.id === rec.segmentId));
+ const inUse = nodes.some(n => n.codeDiscount?.customerSelection?.segments?.some(s => segIds.includes(s.id)));
if (inUse) { console.error('\nREFUSING: segment still referenced by a free-ship code. Run create-freeship-codes-undo.mjs --apply first.'); process.exit(2); }
} catch (e) { console.log('(in-use check skipped:', e.message, ')'); }
if (!APPLY) { console.log('\nDry-run only. Re-run with --apply.'); process.exit(0); }
-if (rec.segmentId) {
- const del = (await query(`mutation($id:ID!){segmentDelete(id:$id){deletedSegmentId userErrors{field message}}}`, { id: rec.segmentId })).segmentDelete;
+for (const id of segIds) {
+ const del = (await query(`mutation($id:ID!){segmentDelete(id:$id){deletedSegmentId userErrors{field message}}}`, { id })).segmentDelete;
console.log('deleted segment:', del.deletedSegmentId || JSON.stringify(del.userErrors));
}
if (rec.brokenSnapshot) {
diff --git a/create-trade-segment.mjs b/create-trade-segment.mjs
index c32c5c4..1e20957 100644
--- a/create-trade-segment.mjs
+++ b/create-trade-segment.mjs
@@ -1,17 +1,15 @@
#!/usr/bin/env node
-// TK-11333 — create the `DW Trade / Designers` customer segment, and delete the broken
+// TK-11333 — create the `DW Trade / Designers` customer segment(s), and delete the broken
// `interior-designer-res` segment (which queries a literal that matches 0 customers).
//
-// GATED (customer-facing config write). DRY-RUN BY DEFAULT — prints the exact segmentCreate
-// query it WOULD run. Pass --apply to actually write. Idempotent: if a segment with the same
-// name already exists it re-uses it and does NOT create a duplicate. Records created ids to
-// verification/trade-segment-created.json for the paired undo (create-trade-segment-undo.mjs).
+// Shopify caps a customer-segment query at 10 filters. Our tag set is 12 (19 with --with-confirm),
+// so we split into ≤10-filter segments ("DW Trade / Designers", "DW Trade / Designers (2)", …).
+// The free-ship discount code scopes to ALL of them (multi-segment) — see create-freeship-codes.mjs.
+// `sample-freeship` (the grandfather vehicle for the 2,663) is ALWAYS included.
//
-// VEHICLE DECISION (trade-grant-check.mjs verdict, 2026-09-09): the segment ORs the real trade
-// tags PLUS the dedicated `sample-freeship` grandfather tag — NOT mass-`trade` — because `trade`
-// on this store also gates PRICING (TRADECODE/TRADE15 15%-off codes + trade-only-benefits page +
-// trade_approved theme entitlement). Tagging 2,663 grandfathered customers `trade` would leak
-// those perks; `sample-freeship` is single-purpose.
+// GATED (customer-facing config write). DRY-RUN BY DEFAULT — prints the exact segmentCreate
+// queries it WOULD run. Pass --apply to write. Idempotent: reuses same-named segments, no dupes.
+// Records created ids to verification/trade-segment-created.json for the paired undo.
//
// Usage:
// node create-trade-segment.mjs # dry-run
@@ -34,44 +32,58 @@ const CORE = ['trade', 'trade_approved', 'Interior Designer - Residential', 'Int
const CONFIRM = ['Photography Studio', 'Graphic Designer', 'Illustrator', 'Visual Merchandiser',
'Production Company', 'Manufacturer', 'Developer'];
const tags = WITH_CONFIRM ? [...CORE, ...CONFIRM] : CORE;
-const SEG_QUERY = tags.map(t => `customer_tags CONTAINS '${t.replace(/'/g, "\\'")}'`).join(' OR ');
+
+// --- chunk into ≤10-filter segments; guarantee sample-freeship lands in a chunk (it's in CORE) ---
+const CHUNK = 10;
+const chunks = [];
+for (let i = 0; i < tags.length; i += CHUNK) chunks.push(tags.slice(i, i + CHUNK));
+const chunkQuery = c => c.map(t => `customer_tags CONTAINS '${t.replace(/'/g, "\\'")}'`).join(' OR ');
+const segNameFor = i => i === 0 ? SEG_NAME : `${SEG_NAME} (${i + 1})`;
async function existingSegments() {
return (await query(`{segments(first:200){nodes{id name query}}}`)).segments.nodes;
}
console.log('=== create-trade-segment (' + (APPLY ? 'APPLY' : 'DRY-RUN') + ') ===');
-console.log('segment name :', SEG_NAME);
-console.log('tag set :', WITH_CONFIRM ? 'CORE + confirm-these' : 'CORE only', `(${tags.length} tags)`);
-console.log('segment query:\n ' + SEG_QUERY);
+console.log('base name :', SEG_NAME);
+console.log('tag set :', WITH_CONFIRM ? 'CORE + confirm-these' : 'CORE only', `(${tags.length} tags → ${chunks.length} segment(s), ≤10 filters each)`);
+chunks.forEach((c, i) => console.log(` [${segNameFor(i)}] (${c.length}) ${chunkQuery(c)}`));
const segs = await existingSegments();
-const dupe = segs.find(s => s.name === SEG_NAME);
const broken = segs.find(s => s.name === BROKEN_NAME);
-console.log('\nexisting DW Trade / Designers?', dupe ? `YES ${dupe.id}` : 'no');
+chunks.forEach((c, i) => { const ex = segs.find(s => s.name === segNameFor(i)); console.log(`existing "${segNameFor(i)}"?`, ex ? `YES ${ex.id}` : 'no'); });
console.log('broken interior-designer-res?', broken ? `YES ${broken.id} (query: ${broken.query})` : 'no');
if (!APPLY) {
- console.log('\n-- WOULD segmentCreate(name, query) above');
+ chunks.forEach((c, i) => console.log(`-- WOULD segmentCreate("${segNameFor(i)}", <${c.length} filters>)`));
if (broken) console.log('-- WOULD segmentDelete(' + broken.id + ') [snapshot recorded first]');
console.log('\nDry-run only. Re-run with --apply to write.');
process.exit(0);
}
-const result = { at: new Date().toISOString(), segName: SEG_NAME, segQuery: SEG_QUERY, tags };
+const result = { at: new Date().toISOString(), segName: SEG_NAME, tags, segments: [] };
-// 1) create (or reuse) the segment
-let segId = dupe?.id;
-if (!segId) {
- const r = (await query(`mutation($name:String!,$q:String!){segmentCreate(name:$name,query:$q){segment{id name query} userErrors{field message}}}`,
- { name: SEG_NAME, q: SEG_QUERY })).segmentCreate;
- if (r.userErrors?.length) { console.error('segmentCreate ERR', JSON.stringify(r.userErrors)); process.exit(1); }
- segId = r.segment.id;
- console.log('created segment', segId);
-} else {
- console.log('re-using existing segment', segId, '(no duplicate created)');
+// 1) create (or reuse) each chunk segment
+const segmentIds = [];
+for (let i = 0; i < chunks.length; i++) {
+ const nm = segNameFor(i);
+ const q = chunkQuery(chunks[i]);
+ const ex = segs.find(s => s.name === nm);
+ let segId = ex?.id;
+ if (!segId) {
+ const r = (await query(`mutation($name:String!,$q:String!){segmentCreate(name:$name,query:$q){segment{id name query} userErrors{field message}}}`,
+ { name: nm, q })).segmentCreate;
+ if (r.userErrors?.length) { console.error('segmentCreate ERR', nm, JSON.stringify(r.userErrors)); process.exit(1); }
+ segId = r.segment.id;
+ console.log('created segment', nm, segId);
+ } else {
+ console.log('re-using existing segment', nm, segId, '(no duplicate created)');
+ }
+ segmentIds.push(segId);
+ result.segments.push({ name: nm, id: segId, query: q });
}
-result.segmentId = segId;
+result.segmentIds = segmentIds;
+result.segmentId = segmentIds[0]; // back-compat
// 2) snapshot + delete the broken segment
if (broken) {
@@ -82,13 +94,14 @@ if (broken) {
}
fs.writeFileSync(OUT, JSON.stringify(result, null, 2) + '\n');
-console.log('\nrecorded -> verification/trade-segment-created.json');
+console.log('\nsegmentIds:', segmentIds.join(', '));
+console.log('recorded -> verification/trade-segment-created.json');
// 3) ledger (reversible)
try {
const { execSync } = await import('node:child_process');
execSync(`node ${LOGX} --agent vp-dw-commerce --ticket TK-11333 ` +
- `--action ${JSON.stringify('created segment "' + SEG_NAME + '" + deleted broken interior-designer-res')} --blast 2 ` +
+ `--action ${JSON.stringify('created ' + segmentIds.length + ' segment(s) "' + SEG_NAME + '" + deleted broken interior-designer-res')} --blast ${segmentIds.length + (broken ? 1 : 0)} ` +
`--undo ${JSON.stringify('cd ~/Projects/shopify-sample-shipping && node create-trade-segment-undo.mjs --apply')} ` +
`--verify ${JSON.stringify('node list-designer-segments.mjs')}`, { stdio: 'inherit' });
} catch (e) { console.log('(ledger note skipped:', e.message, ')'); }
diff --git a/engine-logic-test.mjs b/engine-logic-test.mjs
new file mode 100644
index 0000000..09e442c
--- /dev/null
+++ b/engine-logic-test.mjs
@@ -0,0 +1,52 @@
+// Mirrors the theme engine evaluate() exactly, then asserts the TK-11333 policy scenarios.
+const CONFIG={designerCap:12,bandCapCents:4500,retailWC:5,retailFabric:5,softCap:20,
+ designerTags:['trade','sample-freeship','interior designer'],fabricTypes:['fabric','fabrics','textile','textiles']};
+const isSample=l=>(l.variant_title||'').toLowerCase()==='sample'||/-sample$/i.test(l.sku||'');
+const isFabric=l=>CONFIG.fabricTypes.indexOf((l.product_type||'').toLowerCase())!==-1;
+const isDesigner=t=>{const s=String(t||'').toLowerCase().split(',').map(x=>x.trim());return CONFIG.designerTags.some(d=>s.indexOf(d)!==-1);};
+function evaluate(d){
+ const samples=d.lines.filter(isSample);
+ const total=samples.reduce((n,l)=>n+l.quantity,0);
+ const wc=samples.filter(l=>!isFabric(l)).reduce((n,l)=>n+l.quantity,0);
+ const fab=samples.filter(isFabric).reduce((n,l)=>n+l.quantity,0);
+ const hasNonSample=d.lines.some(l=>!isSample(l));
+ const designer=!!d.isLoggedIn&&isDesigner(d.tags);
+ const overSoftCap=total>CONFIG.softCap;
+ const bandCovers=d.cartSubtotalCents<=CONFIG.bandCapCents;
+ const codeCovers=designer&&total<=CONFIG.designerCap&&total>0;
+ const willBeFree=bandCovers||codeCovers;
+ const willBeCharged=total>0&&!willBeFree;
+ const shouldApplyCode=designer&&!bandCovers&&total<=CONFIG.designerCap&&!overSoftCap;
+ return {total,wc,fab,designer,bandCovers,willBeFree,willBeCharged,shouldApplyCode,overSoftCap};
+}
+const S=(n,type='Wallcovering')=>({product_type:type,variant_title:'Sample',sku:'X-'+n+'-Sample',quantity:n});
+const ROLL={product_type:'Wallcovering',variant_title:'Sold Per Roll',sku:'X-123',quantity:1};
+const P=n=>Math.round(n*425); // n samples subtotal in cents
+let pass=0,fail=0;
+function t(name,d,exp){const g=evaluate(d);const ok=Object.keys(exp).every(k=>g[k]===exp[k]);
+ console.log((ok?'PASS':'FAIL')+' '+name+' => free:'+g.willBeFree+' charged:'+g.willBeCharged+' applyCode:'+g.shouldApplyCode+(ok?'':' EXPECTED '+JSON.stringify(exp)));ok?pass++:fail++;}
+
+// RETAIL (not logged in / no trade tag)
+t('retail 6 WC ($25.50) — under band, must NOT claim charged (the old lie)',
+ {isLoggedIn:false,tags:'',cartSubtotalCents:P(6),lines:[S(6)]},{willBeFree:true,willBeCharged:false,shouldApplyCode:false});
+t('retail 5 WC + 5 fabric ($42.50) — free by band, no code',
+ {isLoggedIn:false,tags:'',cartSubtotalCents:P(10),lines:[S(5,'Wallcovering'),S(5,'Fabric')]},{willBeFree:true,willBeCharged:false,shouldApplyCode:false});
+t('retail 11 samples ($46.75) — over band, charged',
+ {isLoggedIn:false,tags:'',cartSubtotalCents:P(11),lines:[S(11)]},{willBeFree:false,willBeCharged:true,shouldApplyCode:false});
+// DESIGNER
+t('designer 10 samples ($42.50) — free by band, NO needless redirect',
+ {isLoggedIn:true,tags:'sample-freeship',cartSubtotalCents:P(10),lines:[S(10)]},{willBeFree:true,willBeCharged:false,shouldApplyCode:false});
+t('designer 12 samples ($51) — over band, code extends -> free + applies TRADESHIP',
+ {isLoggedIn:true,tags:'trade',cartSubtotalCents:P(12),lines:[S(12)]},{willBeFree:true,willBeCharged:false,shouldApplyCode:true});
+t('designer 13 samples ($55.25) — over designer cap, charged, no code',
+ {isLoggedIn:true,tags:'interior designer',cartSubtotalCents:P(13),lines:[S(13)]},{willBeFree:false,willBeCharged:true,shouldApplyCode:false});
+// MIXED + SOFT CAP
+t('retail 5 samples + 1 roll — over band (roll), charged',
+ {isLoggedIn:false,tags:'',cartSubtotalCents:P(5)+16840,lines:[S(5),ROLL]},{willBeFree:false,willBeCharged:true,shouldApplyCode:false});
+t('any 21 samples — over soft cap (blocks checkout)',
+ {isLoggedIn:true,tags:'trade',cartSubtotalCents:P(21),lines:[S(21)]},{overSoftCap:true,shouldApplyCode:false});
+// Type classifier + Default Title sample edge case
+t('Default-Title sample sku (-Sample) still counts as a sample',
+ {isLoggedIn:false,tags:'',cartSubtotalCents:P(6),lines:[{product_type:'Wallcovering',variant_title:'Default Title',sku:'DWFE-1-2-Sample',quantity:6}]},{willBeFree:true,total:6});
+console.log('\n'+pass+' passed, '+fail+' failed');
+process.exit(fail?1:0);
diff --git a/find-band.mjs b/find-band.mjs
new file mode 100644
index 0000000..6d93b6c
--- /dev/null
+++ b/find-band.mjs
@@ -0,0 +1,24 @@
+import {query} from './query.mjs';
+// Read the General profile with ALL location groups + their location sets
+const q=`{deliveryProfile(id:"gid://shopify/DeliveryProfile/29033627699"){name
+ profileLocationGroups{
+ locationGroup{id locations(first:5){nodes{name}}}
+ locationGroupZones(first:30){nodes{zone{id name}
+ methodDefinitions(first:40){nodes{id name active
+ rateProvider{__typename ... on DeliveryRateDefinition{price{amount}}}
+ methodConditions{operator field conditionCriteria{__typename ... on MoneyV2{amount} ... on Weight{value unit}}}}}}}}}}`;
+const d=await query(q);
+let lgN=0;
+for(const g of d.deliveryProfile.profileLocationGroups){
+ lgN++;
+ console.log(`\n== LOCATION GROUP #${lgN} ${g.locationGroup.id.split('/').pop()} locs:[${g.locationGroup.locations.nodes.map(l=>l.name).join(', ')}]`);
+ for(const z of g.locationGroupZones.nodes){
+ console.log(` ZONE ${z.zone.id.split('/').pop()} ${z.zone.name}:`);
+ for(const md of z.methodDefinitions.nodes){
+ const rp=md.rateProvider;
+ const price=rp?.price?`$${rp.price.amount}`:rp?.__typename;
+ const conds=md.methodConditions.map(c=>{const cr=c.conditionCriteria;const v=cr?.amount!==undefined?`$${cr.amount}`:(cr?.value!==undefined?`${cr.value}${cr.unit}`:'?');return `${c.field} ${c.operator} ${v}`;}).join(' AND ')||'(none)';
+ console.log(` ${md.id.split('/').pop()} ${md.name} [${md.active?'on':'OFF'}] ${price} {${conds}}`);
+ }
+ }
+}
diff --git a/list-profiles.mjs b/list-profiles.mjs
new file mode 100644
index 0000000..544c2e0
--- /dev/null
+++ b/list-profiles.mjs
@@ -0,0 +1,4 @@
+import {query} from './query.mjs';
+const q=`{deliveryProfiles(first:20){nodes{id name default productVariantsCount{count}}}}`;
+const d=await query(q);
+for(const p of d.deliveryProfiles.nodes) console.log(`${p.default?'*':' '} ${p.id.split('/').pop().padEnd(14)} variants:${String(p.productVariantsCount.count).padStart(7)} ${p.name}`);
diff --git a/ptype-distinct.mjs b/ptype-distinct.mjs
new file mode 100644
index 0000000..7ddd0d4
--- /dev/null
+++ b/ptype-distinct.mjs
@@ -0,0 +1,13 @@
+import {query} from './query.mjs';
+// Sample a range of products to learn the real product.type vocabulary + sample-variant shape
+const q=`{products(first:60, query:"status:active"){nodes{productType title
+ variants(first:3){nodes{title sku}}}}}`;
+const d=await query(q);
+const types={};
+let sampleShapes=new Set();
+for(const p of d.products.nodes){
+ types[p.productType||'(empty)']=(types[p.productType||'(empty)']||0)+1;
+ for(const v of p.variants.nodes){ if(/sample/i.test(v.title)||/-sample$/i.test(v.sku||'')) sampleShapes.add(`title="${v.title}" sku~="${(v.sku||'').replace(/[0-9]+/g,'#')}"`); }
+}
+console.log('PRODUCT TYPES (60 active products):'); Object.entries(types).sort((a,b)=>b[1]-a[1]).forEach(([t,n])=>console.log(` ${String(n).padStart(3)} "${t}"`));
+console.log('\nSAMPLE VARIANT SHAPES seen:'); [...sampleShapes].forEach(s=>console.log(' '+s));
diff --git a/samples-profile.mjs b/samples-profile.mjs
new file mode 100644
index 0000000..8e24bad
--- /dev/null
+++ b/samples-profile.mjs
@@ -0,0 +1,21 @@
+import {query} from './query.mjs';
+const q=`{deliveryProfile(id:"gid://shopify/DeliveryProfile/96764067891"){name default productVariantsCount{count}
+ profileLocationGroups{locationGroup{id locations(first:5){nodes{name}}}
+ locationGroupZones(first:30){nodes{zone{id name countries{code{countryCode}}}
+ methodDefinitions(first:40){nodes{id name active
+ rateProvider{__typename ... on DeliveryRateDefinition{price{amount currencyCode}}}
+ methodConditions{operator field conditionCriteria{__typename ... on MoneyV2{amount} ... on Weight{value unit}}}}}}}}}}`;
+const d=await query(q);
+const p=d.deliveryProfile;
+console.log(`PROFILE "${p.name}" default:${p.default} variants:${p.productVariantsCount.count}`);
+for(const g of p.profileLocationGroups){
+ console.log(` LG ${g.locationGroup.id.split('/').pop()} locs:[${g.locationGroup.locations.nodes.map(l=>l.name).join(', ')}]`);
+ for(const z of g.locationGroupZones.nodes){
+ console.log(` ZONE ${z.zone.id.split('/').pop()} ${z.zone.name} [${z.zone.countries.map(c=>c.code.countryCode).join(',')}]`);
+ for(const md of z.methodDefinitions.nodes){
+ const rp=md.rateProvider;const price=rp?.price?`$${rp.price.amount}`:rp?.__typename;
+ const conds=md.methodConditions.map(c=>{const cr=c.conditionCriteria;const v=cr?.amount!==undefined?`$${cr.amount}`:(cr?.value!==undefined?`${cr.value}${cr.unit}`:'?');return `${c.field} ${c.operator} ${v}`;}).join(' AND ')||'(none)';
+ console.log(` METHOD ${md.id.split('/').pop()} "${md.name}" [${md.active?'on':'OFF'}] ${price} {${conds}}`);
+ }
+ }
+}
diff --git a/theme/sample-shipping-cart-engine.liquid b/theme/sample-shipping-cart-engine.liquid
index 6a3d224..ba7f7ad 100644
--- a/theme/sample-shipping-cart-engine.liquid
+++ b/theme/sample-shipping-cart-engine.liquid
@@ -1,39 +1,48 @@
{%- comment -%}
============================================================================
- TK-11333 — SAMPLE-SHIPPING CART POLICY ENGINE (starter, NEEDS DEV THEME)
+ TK-11333 — SAMPLE-SHIPPING CART POLICY ENGINE (Option A — DTD-committed)
============================================================================
- STATUS: STARTER IMPLEMENTATION. Do NOT paste into the LIVE theme. Install on a
- DEV/duplicate theme, QA with real carts, then Steve publishes. Go-live is gated
- on proto-autoapply.mjs returning PASS (does the /discount/CODE permalink make free
- shipping persist to checkout for an eligible logged-in customer?).
-
- WHY THIS EXISTS (platform reality): the store is Advanced, NO Shopify Functions,
- so a shipping RATE cannot be gated by customer tag nor count samples by type. The
- ONLY place counts/types/tag-logic can live is theme JS on the CART/product pages.
- The SERVER-ENFORCED gate is the segment-scoped free-ship CODE (TRADESHIP); this
- engine only AUTO-APPLIES the correct code when thresholds pass and MESSAGES when not.
-
- SPEC (locked, memo §1/§3):
- • Eligibility: DESIGNER if customer.tags includes any trade tag OR 'sample-freeship';
- otherwise RETAIL.
- • Count SAMPLE line items only, split by product.type: Wallcovering vs Fabric.
- (A line is a "sample" if variant.title == 'Sample' or sku ends in '-Sample'.)
- • DESIGNER free rule: total samples <= 12 -> apply TRADESHIP (free ship).
- 13+ -> do NOT apply; show the "why you're charged" message.
- • RETAIL free rule: WC samples <= 5 AND Fabric samples <= 5 -> apply SAMPLESHIP.
- Over EITHER type -> show the "why you're charged" message (>5 of that type).
- • SOFT CAP 20 samples/order: at 21+ disable the checkout button + message
- (theme-soft; a hard cap needs Plus/Functions).
- • Removal note: on Advanced there is no clean in-cart "remove code" API. This engine
- APPLIES when eligible and MESSAGES when not; a stale code is dropped at checkout
- (maximumShippingPrice '30' on both codes stops a freight roll ever riding free).
+ STATUS: install on a DUPLICATE/DEV theme only. Steve publishes. Do NOT paste
+ into the LIVE theme. Go-live gated on proto-autoapply proving the /discount
+ permalink makes free shipping persist to checkout for an eligible customer.
+
+ ARCHITECTURE (verified live 2026-09-09 — see verification/band-truth-2026-09-09.json):
+ • The FREE-SAMPLE floor is a delivery-profile RATE, not this engine: a $0
+ "Free Shipping (No Tracking)" method on the dedicated profile
+ "Samples — Free Shipping (No Tracking)" (gid 96764067891), gated by
+ cart TOTAL_PRICE <= $45. Samples are $4.25, so ~10 samples ship free with
+ NO code and NO login. VERIFIED the band evaluates WHOLE-CART subtotal:
+ any roll (General/carrier profile) pushes the total over $45 and the band
+ switches off for the whole cart.
+ • Store is Advanced Shopify — NO Shopify Functions — so a shipping RATE
+ CANNOT be gated by customer tag, sample count, or product type.
+ • PROVEN LIMIT: a TOTAL_PRICE band cannot tell 6 wallcovering samples
+ ($25.50) from 3 WC + 3 fabric ($25.50) — identical price. So the retail
+ per-type "5 and 5" rule CANNOT be a shipping charge below the cap. Below
+ $45 everyone is free regardless of type. Per-type "5 and 5" is therefore
+ GUIDANCE ONLY here (a true per-type charge needs Plus + Functions).
+
+ WHAT THIS ENGINE DOES (and only this):
+ 1. DESIGNER EXTENSION: a logged-in designer (tag match) whose sample cart is
+ OVER the band cap (11–12 samples, subtotal > $45) gets TRADESHIP
+ auto-applied so their free shipping extends to 12. This is the ONLY case
+ a code is auto-applied — it never fires when the band already gives free
+ (no needless redirect) and SAMPLESHIP is NOT auto-applied at all (the band
+ already covers the retail free range; SAMPLESHIP stays a manual code).
+ 2. HONEST MESSAGING: it shows a "why you're charged" message ONLY when a
+ charge will ACTUALLY be incurred (cart subtotal is over the band cap AND
+ no covering code applies). It NEVER claims a charge the live band won't
+ impose. When free, it says so; it never nags "charged" on a free cart.
+ 3. SOFT CAP 20: at 21+ samples it disables the standard checkout button AND
+ hides accelerated wallet buttons + shows a reduce-to-20 message. Honestly
+ SOFT — the direct /checkout URL and some wallet flows can still slip past;
+ a hard cap needs Plus/Functions.
INSTALL:
- 1. Save this file as snippets/sample-shipping-cart-engine.liquid on a DEV theme.
- 2. In the cart template/section, render it near the checkout button:
- {%- render 'sample-shipping-cart-engine' -%}
- 3. Confirm the CODES exist (create-freeship-codes.mjs) before enabling apply.
- 4. Adjust CONFIG below (codes, caps, product-type labels, selectors) to the theme.
+ 1. Save as snippets/sample-shipping-cart-engine.liquid on a DEV theme.
+ 2. Render near the cart checkout button: {%- render 'sample-shipping-cart-engine' -%}
+ 3. Confirm TRADESHIP exists (create-freeship-codes.mjs --apply) before relying on the extension.
+ 4. Tune CONFIG (bandCap, codes, caps, checkout selectors) to the theme.
============================================================================
{%- endcomment -%}
@@ -42,13 +51,15 @@
{
"isLoggedIn": {{ customer | default: false | json }},
"tags": {{ customer.tags | default: '' | json }},
+ "cartSubtotalCents": {{ cart.total_price | default: 0 }},
"lines": [
{%- for item in cart.items -%}
{
"product_type": {{ item.product.type | default: '' | json }},
"variant_title": {{ item.variant.title | default: '' | json }},
"sku": {{ item.sku | default: '' | json }},
- "quantity": {{ item.quantity }}
+ "quantity": {{ item.quantity }},
+ "line_price_cents": {{ item.final_line_price | default: 0 }}
}{%- unless forloop.last -%},{%- endunless -%}
{%- endfor -%}
]
@@ -59,33 +70,35 @@
(function () {
// ---------------- CONFIG (tune to the store) ----------------
var CONFIG = {
- designerCode: 'TRADESHIP',
- retailCode: 'SAMPLESHIP',
- designerCap: 12, // total samples free for designers
- retailWC: 5, // free wallcovering samples for retail
- retailFabric: 5, // free fabric samples for retail
- softCap: 20, // block checkout above this many samples
- // customer tags that mean "gets designer free shipping" (mirror the DW Trade/Designers segment)
+ designerCode: 'TRADESHIP', // segment-scoped free-ship code, maxShip $30
+ bandCapCents: 4500, // live band: free while whole-cart subtotal <= $45
+ designerCap: 12, // total samples free for designers (via code)
+ retailWC: 5, // per-type GUIDANCE only (cannot be a charge below the band)
+ retailFabric: 5, // per-type GUIDANCE only
+ softCap: 20, // block standard checkout above this many samples
+ // customer tags that mean "gets designer free shipping" — mirror the DW Trade/Designers segment
designerTags: ['trade','trade_approved','sample-freeship','interior designer',
'interior design','interior','architect','contractor','commercial property owner',
'wallcovering installer','interior designer - residential','interior designer - commercial'],
- // product.type values that count as Fabric; everything else with a sample counts as Wallcovering
+ // product.type values that count as Fabric (VERIFIED live: catalog uses "Fabric" and
+ // "Wallcovering"; toLowerCase makes "Fabric" -> "fabric"). Extra synonyms are harmless.
fabricTypes: ['fabric','fabrics','textile','textiles'],
- checkoutBtnSelector: '[name="checkout"], button[name="checkout"], .cart__checkout, #checkout',
+ checkoutBtnSelector: '[name="checkout"], button[name="checkout"], .cart__checkout, #checkout, #cart-checkout',
+ // accelerated wallet / dynamic checkout buttons to hide when over the soft cap
+ walletSelector: '.additional-checkout-buttons, .shopify-payment-button, [data-shopify="payment-button"], .dynamic-checkout__content',
messageMountSelector: '#dw-ship-policy'
};
var APPLIED_KEY = 'dw_ship_code_applied'; // sessionStorage guard against redirect loops
function readData() {
try { return JSON.parse(document.getElementById('dw-ship-cart-data').textContent); }
- catch (e) { return { isLoggedIn: false, tags: '', lines: [] }; }
+ catch (e) { return { isLoggedIn: false, tags: '', cartSubtotalCents: 0, lines: [] }; }
}
function isSample(l) {
return (l.variant_title || '').toLowerCase() === 'sample' || /-sample$/i.test(l.sku || '');
}
function isFabric(l) {
- var t = (l.product_type || '').toLowerCase();
- return CONFIG.fabricTypes.indexOf(t) !== -1;
+ return CONFIG.fabricTypes.indexOf((l.product_type || '').toLowerCase()) !== -1;
}
function isDesigner(tags) {
var set = String(tags || '').toLowerCase().split(',').map(function (s) { return s.trim(); });
@@ -93,35 +106,53 @@
}
function evaluate(d) {
- var samples = d.lines.filter(isSample);
- var total = samples.reduce(function (n, l) { return n + l.quantity; }, 0);
- var wc = samples.filter(function (l) { return !isFabric(l); }).reduce(function (n, l) { return n + l.quantity; }, 0);
- var fab = samples.filter(isFabric).reduce(function (n, l) { return n + l.quantity; }, 0);
- var designer = d.isLoggedIn && isDesigner(d.tags);
+ var samples = d.lines.filter(isSample);
+ var total = samples.reduce(function (n, l) { return n + l.quantity; }, 0);
+ var wc = samples.filter(function (l) { return !isFabric(l); }).reduce(function (n, l) { return n + l.quantity; }, 0);
+ var fab = samples.filter(isFabric).reduce(function (n, l) { return n + l.quantity; }, 0);
+ var hasNonSample = d.lines.some(function (l) { return !isSample(l); }); // e.g. a roll
+ var designer = !!d.isLoggedIn && isDesigner(d.tags);
var overSoftCap = total > CONFIG.softCap;
- var eligibleFree, code, reason;
- if (designer) {
- eligibleFree = total <= CONFIG.designerCap;
- code = CONFIG.designerCode;
- reason = eligibleFree ? '' : ('Free sample shipping covers up to ' + CONFIG.designerCap +
- ' samples for trade accounts. You have ' + total + ' — shipping on the rest is charged at cost.');
- } else {
- eligibleFree = wc <= CONFIG.retailWC && fab <= CONFIG.retailFabric;
- code = CONFIG.retailCode;
- if (!eligibleFree) {
- var bits = [];
- if (wc > CONFIG.retailWC) bits.push(wc + ' wallcovering (free up to ' + CONFIG.retailWC + ')');
- if (fab > CONFIG.retailFabric) bits.push(fab + ' fabric (free up to ' + CONFIG.retailFabric + ')');
- reason = 'Free sample shipping covers ' + CONFIG.retailWC + ' wallcovering and ' + CONFIG.retailFabric +
- ' fabric samples. You have ' + bits.join(' and ') + ' — shipping on the extras is charged.';
- } else reason = '';
+
+ // The LIVE band already gives free shipping while whole-cart subtotal <= $45.
+ var bandCovers = d.cartSubtotalCents <= CONFIG.bandCapCents;
+
+ // A covering code closes the gap ONLY for a logged-in designer within the 12-sample cap.
+ var codeCovers = designer && total <= CONFIG.designerCap && total > 0;
+
+ // Will the customer ACTUALLY be charged shipping? (this is what the message must reflect)
+ var willBeFree = bandCovers || codeCovers;
+ var willBeCharged = total > 0 && !willBeFree;
+
+ // Auto-apply the designer code ONLY when it changes the outcome:
+ // designer, over the band cap, still within the 12 cap, not over the soft cap.
+ var shouldApplyCode = designer && !bandCovers && total <= CONFIG.designerCap && !overSoftCap;
+
+ // Honest reason (only populated when a charge is real, or as a soft note).
+ var reason = '';
+ if (overSoftCap) {
+ reason = 'Sample orders are limited to ' + CONFIG.softCap + ' per order. Please reduce to ' +
+ CONFIG.softCap + ' or fewer to check out.';
+ } else if (willBeCharged) {
+ if (designer && total > CONFIG.designerCap) {
+ reason = 'Free sample shipping covers up to ' + CONFIG.designerCap + ' samples for trade accounts. ' +
+ 'You have ' + total + ' — shipping on the rest is at carrier cost.';
+ } else if (hasNonSample) {
+ reason = 'Free sample shipping covers sample-only orders up to $' + (CONFIG.bandCapCents/100).toFixed(0) +
+ '. Rolls and larger orders ship at standard carrier rates.';
+ } else {
+ reason = 'Free sample shipping covers orders up to $' + (CONFIG.bandCapCents/100).toFixed(0) +
+ ' (about 10 samples). Your order is over that — shipping is at carrier cost.';
+ }
}
- return { total: total, wc: wc, fab: fab, designer: designer, eligibleFree: eligibleFree, code: code, reason: reason, overSoftCap: overSoftCap };
+ return { total: total, wc: wc, fab: fab, designer: designer, bandCovers: bandCovers,
+ willBeFree: willBeFree, willBeCharged: willBeCharged, shouldApplyCode: shouldApplyCode,
+ overSoftCap: overSoftCap, reason: reason };
}
function applyCode(code) {
- // /discount/CODE?redirect=/cart sets the session discount cookie. Guard against loops:
- // only redirect once per cart state (keyed on code+item signature).
+ // /discount/CODE?redirect=/cart sets the session discount cookie (this is a full-page nav).
+ // Guarded so we redirect at most once per cart signature (no loops).
var sig = code + ':' + (document.getElementById('dw-ship-cart-data').textContent.length);
if (sessionStorage.getItem(APPLIED_KEY) === sig) return;
sessionStorage.setItem(APPLIED_KEY, sig);
@@ -134,29 +165,29 @@
mount.hidden = false;
if (state.overSoftCap) {
mount.innerHTML = '<div class="dw-ship-msg dw-ship-msg--block" role="alert" style="padding:.75rem 1rem;border:1px solid #b00;border-radius:8px;margin:.5rem 0;color:#b00;">' +
- 'Sample orders are limited to ' + CONFIG.softCap + ' per order. Please reduce to ' + CONFIG.softCap + ' or fewer to check out.</div>';
- } else if (state.reason) {
+ state.reason + '</div>';
+ } else if (state.reason) { // real charge -> honest "why charged"
mount.innerHTML = '<div class="dw-ship-msg" role="status" style="padding:.75rem 1rem;border:1px solid #d8c9a8;border-radius:8px;margin:.5rem 0;background:#faf6ee;">' +
state.reason + '</div>';
- } else if (state.eligibleFree) {
+ } else if (state.total > 0 && state.willBeFree) {
mount.innerHTML = '<div class="dw-ship-msg dw-ship-msg--ok" role="status" style="padding:.5rem 1rem;color:#2e6b2e;">Free sample shipping applied.</div>';
} else { mount.innerHTML = ''; }
}
- // soft cap: disable checkout
- var btns = document.querySelectorAll(CONFIG.checkoutBtnSelector);
- btns.forEach(function (b) {
- if (state.overSoftCap) { b.setAttribute('disabled', 'disabled'); b.setAttribute('aria-disabled', 'true'); b.style.opacity = '0.5'; b.style.pointerEvents = 'none'; }
- else { b.removeAttribute('disabled'); b.removeAttribute('aria-disabled'); b.style.opacity = ''; b.style.pointerEvents = ''; }
+ // soft cap: disable standard checkout + hide accelerated wallet buttons (still SOFT — /checkout URL can slip past)
+ var over = state.overSoftCap;
+ document.querySelectorAll(CONFIG.checkoutBtnSelector).forEach(function (b) {
+ if (over) { b.setAttribute('disabled','disabled'); b.setAttribute('aria-disabled','true'); b.style.opacity='0.5'; b.style.pointerEvents='none'; }
+ else { b.removeAttribute('disabled'); b.removeAttribute('aria-disabled'); b.style.opacity=''; b.style.pointerEvents=''; }
});
+ document.querySelectorAll(CONFIG.walletSelector).forEach(function (w) { w.style.display = over ? 'none' : ''; });
}
function run() {
var d = readData();
var state = evaluate(d);
render(state);
- // auto-apply the correct code only when the cart is all-eligible-free and NOT over soft cap
- if (state.total > 0 && state.eligibleFree && !state.overSoftCap) applyCode(state.code);
- // (removal is checkout-side on Advanced; maximumShippingPrice '30' guards a stale code)
+ // auto-apply the designer code ONLY when it actually changes the outcome (11-12 designer samples)
+ if (state.shouldApplyCode) applyCode(CONFIG.designerCode);
}
if (document.readyState !== 'loading') run();
← 30edcd5 auto-data-snapshot: 2026-09-09T22:50:09 (4 data files) — ver
·
back to Shopify Sample Shipping
·
TK-11333: correct band-truth (band is LIVE on dedicated Samp ffc2043 →