Skill · Sales and Marketing · Finance and Operations

BFCM Offer & Discount Config QA

Check your Shopify BFCM discounts against the offer plan before launch: stacking, sale windows, who gets each discount, code limits and free shipping, with test carts and a dated fix list.

Created by type.com

What it does

  • Compares every planned BFCM offer with the live Shopify discount: type, value, targeting, minimums, customer eligibility, usage limits, and old codes that should be off
  • Converts sale windows to store time and catches UTC slips, gaps and overlaps between phases, with a timeline of every phase
  • Models Shopify's combination rules to find the worst-case price of every SKU: stacks past your max discount, items below unit cost or under your margin floor
  • Flags uncapped or stacking creator codes, free shipping without its minimum, and compare-at prices raised before the sale or never charged (US FTC guidance; not EU/UK rules)
  • Writes test carts with the total to expect and the total that reveals each bug, plus a verdict and dated fix list. Read-only, with 23 end-to-end checks

Before you start

  • A Shopify store with its BFCM discounts built in the admin (scheduled is fine)
  • Your offer plan: each intended discount with its value, dates in store time, exclusions, limits and what it may combine with
  • Read-only access to discounts through the Shopify Admin API (read_discounts, API version 2025-10 or later), or time to copy the settings from the Discounts page
  • Your products with collections, unit cost and inventory; optionally 90 days of price history for the compare-at check
  • Python 3.8 or newer. Nothing to install, and no API keys pasted into chat

See an example

Example output from a sample run. Company names and figures are sample data.

Fictional sample store: Fernhill Goods. Offline demonstration only.

BFCM Offer & Discount Config QA: Fernhill Goods, as of 2026-10-06

Verdict: NOT READY. 5 critical issues and 3 warnings. VIP early access opens Tue Nov 24, 2026 (49 days away); Main sale runs Thu Nov 26 – Tue Dec 1. Fix the items below by Nov 10, then re-run this check and the test checkouts by Nov 17. Two are live today: fix those now. Checked 6 discounts (export of Tue Oct 6 2:05 pm ET) against 5 planned offers and 15 SKUs. Times are store time, America/New_York (ET).

Issues, most severe first (all detected, none changed)

  1. CRITICAL: BFCM 25% off sitewide applies to All products, so 5 SKUs the plan leaves out get 25% off: Last Chance (3) and New Arrivals (2).
  2. CRITICAL: BFCM 25% off sitewide and WELCOME10 stack: BFCM 25% off sitewide is set to combine with order discounts and WELCOME10 with product discounts, so all 11 discountable SKUs can go 32.5% off during Nov 26 – Dec 1 (plan max 25%).
  3. CRITICAL: 2 SKUs sell below unit cost at the worst-case stack (BFCM 25% off sitewide + WELCOME10, Nov 26 – Dec 1): Stoneware Mug Set (4) — Slate at $18.90 (unit cost $19.00) and Merino Wool Throw — Oat at $51.97 (unit cost $52.00).
  4. CRITICAL: Free shipping on orders $75+ has no minimum purchase ($0 instead of $75), so every order ships free.
  5. CRITICAL: SUMMER15 (15% off products) is still active with no end date; the plan says it must not be active.
  6. WARNING: MAYA20 (20% off products) has no usage limit (plan: 300 in total), isn't limited to one use per customer (plan: once per customer) and can combine with BFCM 25% off sitewide (plan: must not).
  7. WARNING: VIPEARLY ends Wed Nov 25 at 6:59 pm ET (23:59 UTC) instead of 11:59 pm ET, leaving a 5-hour gap (Wed Nov 25, 7:00 pm to midnight) before BFCM 25% off sitewide starts.
  8. WARNING: Walnut Serving Board: compare-at raised from $72.00 to $96.00 on Fri Oct 2, but in the 85 days of history before that it sold at $72.00 and never at $96.00. Reference-price risk to review, not a legal conclusion.
1. Planned offers vs live discounts
Planned offerLive discountStatusMatches the plan?
VIP early access: code VIPEARLY, 25% off productsVIPEARLYscheduledNo: dates (§2)
Main sale: automatic, 25% off productsBFCM 25% off sitewidescheduledNo: who gets it (§3), stacking (§4), settings (§5)
Free shipping $75+: automatic, free shippingFree shipping on orders $75+active, 7,412 usesNo: settings (§5)
Welcome offer: code WELCOME10, 10% off orderWELCOME10active, 1,734 usesNo: stacking (§4)
Creator code (Maya): code MAYA20, 20% off productsMAYA20active, 118 usesNo: settings (§5)
  • CRITICAL: SUMMER15 (15% off products) is still active with no end date; the plan says it must not be active. Active since Mon Jun 1, 23 uses, last edited Fri Aug 14. Shopify clears the end date when an expired discount is reactivated, a common way old codes come back. It doesn't combine with other discounts, so it can't stack, but anyone with the code gets 15% off everything, marked-down items included. The code is a common word plus a number (SUMMER + 15), the kind coupon sites and browser extensions try first.
2. Sale windows in store time (ET)
PhaseDiscountStarts (ET)Ends (ET)vs plan
VIP early accessVIPEARLYTue Nov 24 12:00 amWed Nov 25 6:59 pmends 5 h early: plan 11:59 pm, set as 23:59 UTC
—no sale discountWed Nov 25 7:00 pmThu Nov 26 12:00 am5 h gap (plan: none)
Main saleBFCM 25% off sitewideThu Nov 26 12:00 amTue Dec 1 11:59 pmmatches

Always on or longer-running: WELCOME10 (since Sat Feb 1, 2025, no end; plan: always on); Free shipping on orders $75+ (since Mon Mar 3, 2025, no end; plan: always on); SUMMER15 (since Mon Jun 1, no end; must not be active); MAYA20 (Sep 1 – Dec 31; matches the plan).

  • WARNING: VIPEARLY ends Wed Nov 25 at 6:59 pm ET (23:59 UTC) instead of 11:59 pm ET, leaving a 5-hour gap (Wed Nov 25, 7:00 pm to midnight) before BFCM 25% off sitewide starts. The time looks like it was entered in UTC (by an app, an import or an API script), not in the store's time zone. Shoppers who try the code in that window are told it isn't valid, and no sale discount applies.
3. Who gets each discount
DiscountApplies toSKUsPlanExtra (shouldn't get it)Missing
VIPEARLYcollection BFCM Eligible6everything except Gift Cards, New Arrivals and Last Chance (6)——
BFCM 25% off sitewideAll products11everything except Gift Cards, New Arrivals and Last Chance (6)5: Last Chance 3, New Arrivals 2—
MAYA20All products11everything (11)——

Order and shipping discounts (WELCOME10 and Free shipping on orders $75+) apply to the whole cart. Gift cards (4 SKUs) are only discounted by a discount that names the gift card product, never by All products or a collection (Shopify rule).

  • CRITICAL: BFCM 25% off sitewide applies to All products, so 5 SKUs the plan leaves out get 25% off: Last Chance (3) and New Arrivals (2). Last Chance: Merino Wool Throw — Oat $77.00 → $57.75 (unit cost $52.00), Linen Apron — Sage $29.00 → $21.75 (unit cost $14.00), Stoneware Mug Set (4) — Slate $28.00 → $21.00 (unit cost $19.00). New Arrivals: Stoneware Pasta Bowls (2) $52.00 → $39.00, Linen Napkins (Set of 4) $38.00 → $28.50. 3 of them are already marked down 40–50% from compare-at.
4. Stacking and worst-case price per SKU

Pairs that can combine (each discount must allow the other's class):

PairBoth live (ET)How they combinePlanWorst case on one item
BFCM 25% off sitewide + WELCOME10Nov 26 – Dec 1product discount first, then the order discount on the reduced pricenot allowed32.5%
BFCM 25% off sitewide + MAYA20Nov 26 – Dec 1separate items only (one product discount per item)not allowed25%
MAYA20 + Free shipping on orders $75+now onfree shipping on topallowed—
WELCOME10 + Free shipping on orders $75+now onfree shipping on topallowed—
VIPEARLY + Free shipping on orders $75+Nov 24 – Nov 25free shipping on topallowed—
BFCM 25% off sitewide + Free shipping on orders $75+Nov 26 – Dec 1free shipping on topallowed—
  • CRITICAL: BFCM 25% off sitewide and WELCOME10 stack: BFCM 25% off sitewide is set to combine with order discounts and WELCOME10 with product discounts, so all 11 discountable SKUs can go 32.5% off during Nov 26 – Dec 1 (plan max 25%). Shopify takes the 25% product discount first, then 10% off the reduced subtotal: 1 − 0.75 × 0.9 = 32.5%. The plan says they must not combine.
  • CRITICAL: 2 SKUs sell below unit cost at the worst-case stack (BFCM 25% off sitewide + WELCOME10, Nov 26 – Dec 1): Stoneware Mug Set (4) — Slate at $18.90 (unit cost $19.00) and Merino Wool Throw — Oat at $51.97 (unit cost $52.00). 44 units in stock. That is before shipping and payment fees. Configured as planned, their worst case is MAYA20 alone: $22.40 and $61.60.

Worst case per SKU, Oct 6 – Dec 1 (plan max 25%, margin floor 15%):

ItemPriceUnit costPlanned worst caseLive worst caseLive viaWhenMargin
Stoneware Mug Set (4) — Slate (Last Chance)$28.00$19.00$22.40 (20%)$18.90 (32.5%)BFCM 25% off sitewide + WELCOME10Nov 26 – Dec 1−0.5%
Merino Wool Throw — Oat (Last Chance)$77.00$52.00$61.60 (20%)$51.97 (32.5%)BFCM 25% off sitewide + WELCOME10Nov 26 – Dec 1−0.1%
Linen Apron — Sage (Last Chance)$29.00$14.00$23.20 (20%)$19.57 (32.5%)BFCM 25% off sitewide + WELCOME10Nov 26 – Dec 128.5%
Merino Wool Throw — Charcoal$140.00$52.00$105.00 (25%)$94.50 (32.5%)BFCM 25% off sitewide + WELCOME10Nov 26 – Dec 145.0%
Stoneware Pasta Bowls (2) (New Arrivals)$52.00$18.00$41.60 (20%)$35.10 (32.5%)BFCM 25% off sitewide + WELCOME10Nov 26 – Dec 148.7%
Stoneware Mug Set (4) — Speckled$56.00$19.00$42.00 (25%)$37.80 (32.5%)BFCM 25% off sitewide + WELCOME10Nov 26 – Dec 149.7%
Walnut Serving Board$72.00$24.00$54.00 (25%)$48.60 (32.5%)BFCM 25% off sitewide + WELCOME10Nov 26 – Dec 150.6%
Waxed Canvas Tote — Olive$78.00$26.00$58.50 (25%)$52.65 (32.5%)BFCM 25% off sitewide + WELCOME10Nov 26 – Dec 150.6%
Ceramic Pour-Over Set$64.00$21.00$48.00 (25%)$43.20 (32.5%)BFCM 25% off sitewide + WELCOME10Nov 26 – Dec 151.4%
Linen Apron — Natural$48.00$14.00$36.00 (25%)$32.40 (32.5%)BFCM 25% off sitewide + WELCOME10Nov 26 – Dec 156.8%
Linen Napkins (Set of 4) (New Arrivals)$38.00$11.00$30.40 (20%)$25.65 (32.5%)BFCM 25% off sitewide + WELCOME10Nov 26 – Dec 157.1%

Model (Shopify Help Center, Combining discounts): two discounts combine only if each allows the other's class; product discounts apply first, then order discounts on the reduced subtotal (each percentage on that subtotal), then one shipping discount; one product discount per item unless both are tagged to share a line (Shopify Plus); if discounts can't combine, the customer gets the best one. The worst case assumes minimums are met and ignores who is eligible. Margin is on the discounted price, before shipping and payment fees.

5. Codes, limits and free shipping
CodeOfferUsesUsage limit (plan)Once per customer (plan)Who can use itCombines with
MAYA2020% off products118none (300)no (yes)all customersproduct, shipping
SUMMER1515% off products23none (must not be active)noall customersnothing
VIPEARLY25% off products0none (none)no (no)segment SMS subscribersshipping
WELCOME1010% off order1,734none (none)yes (yes)segment Never purchasedproduct, shipping

Shipping discounts: Free shipping on orders $75+ (automatic): minimum $0 (plan $75).

  • CRITICAL: Free shipping on orders $75+ has no minimum purchase ($0 instead of $75), so every order ships free. Estimate, not a measurement: if this BFCM looks like last year's (1,356 orders, 556 under $75), that is about $6,116 of shipping on orders the plan wouldn't ship free (556 × $11.00 per order). It is live now: at about 32 orders a day and the same 41% share, roughly $144 a day. Last edited Tue Sep 29.
  • WARNING: MAYA20 (20% off products) has no usage limit (plan: 300 in total), isn't limited to one use per customer (plan: once per customer) and can combine with BFCM 25% off sitewide (plan: must not). 118 uses so far. The code is a name plus a number (MAYA + 20): easy to guess, and coupon sites repost codes like it, so an uncapped code can run far past its planned uses. Shopify gives each item only one product discount, so MAYA20 doesn't deepen any one item during BFCM 25% off sitewide, but it discounts the items BFCM 25% off sitewide leaves out in the same cart.
6. Reference prices (compare-at)

Compare-at below price: none. Compare-at prices checked against price history (raises, and compare-ats never seen as a selling price) for 15 SKUs from Jul 9. This check follows US (FTC 16 CFR 233.1) guidance. It does not apply the EU/UK rule that the reference price must be the lowest price in the prior 30 days.

  • WARNING: Walnut Serving Board: compare-at raised from $72.00 to $96.00 on Fri Oct 2, but in the 85 days of history before that it sold at $72.00 and never at $96.00. Reference-price risk to review, not a legal conclusion. Today the product page shows $96.00 crossed out next to $72.00 (25% off). In the sale it ends at $54.00, which reads as 43.8% off $96.00 for a 25% discount. The FTC's Guides Against Deceptive Pricing (16 CFR 233.1) treat a former price as genuine when the item was openly and actively offered at it for a reasonably substantial period in the recent, regular course of business. This check flags a compare-at the SKU wasn't sold at on at least 30 of the 90 days before it was set. State laws can be stricter.
  • OK: 3 compare-ats set to a price the SKU had sold at for 30+ days (markdowns on Mon Aug 17: Linen Apron — Sage, Merino Wool Throw — Oat, Stoneware Mug Set (4) — Slate).
7. Test-checkout script

Run these before launch. A scheduled discount can't be applied before it starts, so for sale-phase rows use a staff-only copy of each discount (same settings, eligibility limited to your test customer, starting now; delete it after) or test in the first minutes of the phase. Totals are merchandise after discounts, before shipping and tax; Shopify may round a cent differently. Row 1 is the worst case as exported; each other row isolates one problem and assumes the other fixes are in.

#When (ET)CustomerCartCodeExpect (as planned)If you seeIt means
1Thu Nov 26 12:05 amsegment Never purchasedMerino Wool Throw — Oat ($77.00)WELCOME10$69.30 (WELCOME10)$51.97 (BFCM 25% off sitewide + WELCOME10)as exported, BFCM 25% off sitewide and WELCOME10 both apply to this Last Chance item: $0.03 below its $52.00 unit cost
2Thu Nov 26 12:05 amanyMerino Wool Throw — Oat ($77.00) + Stoneware Pasta Bowls (2) ($52.00)—$129.00 (no discount)$96.75 (BFCM 25% off sitewide)BFCM 25% off sitewide reaches items the plan leaves out
3Thu Nov 26 12:05 amsegment Never purchasedMerino Wool Throw — Charcoal ($140.00)WELCOME10$105.00 (BFCM 25% off sitewide; WELCOME10 refused)$94.50 (BFCM 25% off sitewide + WELCOME10)BFCM 25% off sitewide and WELCOME10 stack
4todayanyWalnut Serving Board ($72.00)—$72.00 (no discount), shipping charged$72.00 (Free shipping on orders $75+), free shippingFree shipping on orders $75+ has no minimum
5todayanyWalnut Serving Board ($72.00)SUMMER15$72.00 (SUMMER15 refused)$61.20 (SUMMER15)SUMMER15 is still live
6Thu Nov 26 12:05 amanyMerino Wool Throw — Charcoal ($140.00) + Linen Napkins (Set of 4) ($38.00)MAYA20$142.40 (MAYA20)$135.40 (BFCM 25% off sitewide + MAYA20)MAYA20 combines with BFCM 25% off sitewide (as planned they can't, so Shopify applies only the better of the two)
7todaytest customerany item, then a second order with the same emailMAYA20second order: code refusedcode accepted againMAYA20 isn't limited to one use per customer
8Wed Nov 25 7:30 pmsegment SMS subscribersMerino Wool Throw — Charcoal ($140.00)VIPEARLY$105.00 (VIPEARLY)$140.00 (VIPEARLY refused)VIPEARLY ends at 6:59 pm ET
8. Fix list and timeline (nothing has been changed)
#SeverityWhat to doStateBy
1CRITICALChange BFCM 25% off sitewide's Applies to from All products to the BFCM Eligible collection (the one VIPEARLY uses), which leaves out Gift Cards, New Arrivals and Last Chance.recommendedNov 10
2CRITICALIn Combinations, on BFCM 25% off sitewide untick Order discounts; on WELCOME10 untick Product discounts. Either one stops the stack; change both so a later edit can't reopen it.recommendedNov 10
3CRITICALNo separate change: fixes 1 and 2 bring these back to the planned worst case ($22.40 and $61.60). Re-run to confirm. To keep them out of every code, exclude them there too; Promo Margin Guard sets per-SKU floors.recommendedNov 10
4CRITICALSet Free shipping on orders $75+'s minimum purchase amount to $75. It is live now.recommendedOct 6 (today)
5CRITICALDeactivate SUMMER15 (Discounts → SUMMER15 → Deactivate). It is live now.recommendedOct 6 (today)
6WARNINGOn MAYA20, set Maximum discount uses to 300 in total (182 left after the 118 so far), tick Limit to one use per customer and untick Product discounts in Combinations. On BFCM 25% off sitewide, untick Product discounts too.recommendedNov 10
7WARNINGSet VIPEARLY's end to Wed Nov 25, 11:59 pm ET in the admin (store time), or 2026-11-26T04:59:00Z through the API or an app.recommendedNov 10
8WARNINGPut Walnut Serving Board's compare-at back to $72.00, or keep a record that $96.00 was a real former price; have whoever owns pricing compliance review it before launch.recommendedNov 10
9PLANRe-run this check on a fresh export; it should say READY. Then run the test checkouts in §7 and mark a fix verified only when its row shows the expected total.recommendedNov 17
10PLANDiscount freeze through Dec 1: no new or edited discounts unless you re-run this check after the change.recommendedNov 21
11PLANFinal export and run. At 12:05 am Tue Nov 24 and 12:05 am Thu Nov 26 ET, place one order (or test order) in each phase and read the discount lines.recommendedNov 23

CRITICAL can lose margin or sell below cost; WARNING breaks the plan or the shopper's experience. Fixes are due 14 days before launch (Nov 10); items live today, now. Free-shipping costs are estimates. Reference-price notes are risks to review, not legal conclusions. States: detected → recommended → applied → verified. Read-only: no discounts, prices or Shopify settings were changed.

AI for marketing teams: a practical guide

Browse the technical files
---
name: bfcm-offer-config-qa
description: Pre-launch QA of a Shopify store's BFCM discounts against the offer plan. Catches sale windows set in the wrong time zone or with gaps, sales that reach clearance or new items, stacks deeper than planned and items sold below cost (modeled on Shopify's combination rules), uncapped creator codes, free shipping with no minimum and inflated compare-at prices. Writes a test-checkout script and a READY / FIX BEFORE LAUNCH / NOT READY verdict with a dated fix list. Read-only.
---

# BFCM Offer & Discount Config QA

The costly BFCM discount mistakes are rarely in the offer plan. They're in the settings: an automatic sale that applies to *All products* and takes another 25% off clearance; a welcome code allowed to combine with product discounts while the sale is allowed to combine with order discounts, so the two stack; a creator code with no usage limit that leaks on Black Friday; a free-shipping discount whose $75 minimum was deleted; an early-access code whose end time was entered in UTC and expires at 6:59 pm instead of midnight; an old summer code someone reactivated. Each one looks fine on the Discounts page and shows up only in orders.

This skill audits the live discount setup against the plan before launch, from local exports. It models how Shopify combines discounts (each discount must allow the other's class; product discounts first, then order discounts on the reduced subtotal; one product discount per item; the customer gets the best valid combination) to find the deepest price any SKU can reach. Then it writes the test carts that prove each fix: **plan vs config → windows in store time → who gets each discount → stacking and worst-case price → codes and free shipping → reference prices → test checkouts.**

It sits next to other library skills and does not repeat them:
- **Promo Margin Guard** works out what a promotion costs and the deepest discount each SKU can carry. Use it to set this plan's max discount and margin floor; this skill checks that the configuration enforces them.
- **Product Feed Auditor** checks sale price and price coherence in the Google feed. This skill checks the store's own compare-at reference prices.
- **BFCM Live Pulse** watches code usage once the sale is live, which is where a leaked code shows up. **BFCM Holiday Budget Planner** plans spend at the planned discount depth.

## Before you run

This skill ships scripts and sample data alongside this SKILL.md. Before running any command:

1. **Get the files.** Make sure the skill's other files (`scripts/`, `examples/`, `tests/`, `references/` and `DATA_CONTRACT.md`) are in your working folder at the same relative paths. Some environments load only SKILL.md. If yours did, fetch each file from this skill's published files and write it to the matching path. In type.com, read them with the skill-file tools. Anywhere else, the type.com Skills Library API lists every file with its path, content and `sha256`: GET `https://api.type.com/api/public/library/skills` and take the entry with slug `bfcm-offer-config-qa`.
2. **Check the copies are exact.** Compare each file's size in bytes, not characters (and its hash, where your tools report one), with the published version before running. A copy written out from the published file is fine once its byte size and hash match. Never run a script you summarised or reconstructed from memory.
3. **Run from the skill's folder**, calling interpreters explicitly: `python3 scripts/…` and `bash examples/run.sh`. Python 3.8+ and the standard library only; there is nothing to install.
4. **Try the sample first.** Run `bash examples/run.sh`. Its output must match `examples/expected_output.txt` exactly. If it doesn't, stop and report the first differing line rather than running on real data. `bash tests/run_tests.sh` runs the full check suite.

## When to use this

- Two to six weeks before BFCM (or any large sale), once the discounts are built in Shopify. Again after every discount change, and a final time the day before launch.
- Someone asks "are our Black Friday discounts set up right?", "will WELCOME10 stack with the sale?", "why did a code work on clearance?" or "what's the lowest price a customer can get?".
- After discounts were created or edited by an app, an import or an API script. Most time-zone slips come from there.
- An agency QA-ing each client's discounts before launch.
- On a schedule: weekly from mid-October, daily in launch week. Stay silent when the verdict is READY and nothing changed.

## Operating rules

1. **Read-only.** The scripts read local files and never call Shopify. Never create, edit, deactivate or delete a discount, or change a price, unless the user approves that specific change. When they do, make it, read the setting back, re-export, re-run this check, and run the matching test checkout.
2. **The plan is the reference.** When the plan and the setup disagree, report it; never edit the plan to match the setup. If the plan contradicts itself (it allows a stack deeper than its own max), say so and ask which one is right.
3. **Worst case, not typical case.** Report the lowest price a customer can reach. Shopify always gives the customer the best valid combination, so assume someone will find it.
4. **Label estimates and risks.** The free-shipping cost is an estimate from last year's numbers. Reference-price notes are risks to review, not legal conclusions.
5. **Quote exact numbers**: codes, X of Y SKUs, prices to the cent, times in store time (with UTC where the slip matters).
6. **A setting isn't verified until a checkout shows it.** Apps, Scripts and rollouts can change what checkout does. Mark a fix verified only when its test cart shows the expected total.
7. Say which state each item is in: **detected**, **recommended**, **applied**, or **verified**.

## Gathering the inputs

Three files are required and one is optional. Exact formats are in `DATA_CONTRACT.md`.

1. **Offer plan** (`offer_plan.json`). Write it with whoever owns the promotion calendar: store time zone, the maximum discount any item may get, a margin floor, and each intended offer (code or automatic title, type, *Amount off products* or *Amount off order*, value, store-time start and end, eligible collections and exclusions, minimum purchase, customer eligibility such as first order or a segment, usage limit, once per customer, and which discount classes it may combine with). Add the codes that must not be active, and last BFCM's order counts and your shipping cost per order for the free-shipping estimate.
2. **Live discounts** (`discounts.json`). Run the GraphQL query in `DATA_CONTRACT.md` (also printed by `python3 scripts/normalize_shopify_discounts.py --print-query`) with read-only `read_discounts` access, save each page, and normalize it. In type.com, use the connected Shopify integration and write the responses to files. Never ask the user to paste an API token into chat. Without API access, build the file by hand from the Discounts page.
3. **Catalog** (`products.csv`). One row per variant: handle, SKU, price, compare-at, unit cost, collections, inventory and units sold in the last 90 days. The product CSV export has no collections column, so use the products query in `DATA_CONTRACT.md` or add them.
4. **Price history** (`price_history.csv`, optional): SKU, date, price and compare-at for at least the last 90 days. Without it, compare-at raises are not checked.

## Running it

```bash
python3 scripts/normalize_shopify_discounts.py --in page1.json [--in page2.json] \
  --exported-at 2026-10-06T14:05:00-04:00 --shop "Store name" --out discounts.json
python3 scripts/offer_qa.py --plan offer_plan.json --discounts discounts.json --products products.csv \
  [--price-history price_history.csv] [--as-of YYYY-MM-DD] [--margin-floor PCT] [--json analysis.json]
```

`bash examples/run.sh` runs it on the bundled sample store (Fernhill Goods, fictional): six live discounts checked on Oct 6, 2026, against a five-offer plan.

What it does:

1. **Plan vs config.** Matches every planned offer to its live discount by code or title, and compares type, value, class, minimum, customer eligibility, usage limit and once-per-customer. Flags planned offers that are missing, live discounts that aren't in the plan, and must-not-be-active codes that are still active or scheduled.
2. **Windows in store time.** Converts Shopify's UTC times to the store's time zone, names a slip when a time matches the plan in UTC or another time zone (for example "23:59 UTC"), and prints a timeline of the phases with any gap or overlap the plan doesn't have.
3. **Who gets each discount.** Resolves each product discount's target to SKUs and compares it with the plan's eligible set. "All products" that pulls in clearance or new arrivals is critical. Gift cards are only discounted when the gift card product is named. App and multi-class discounts don't expose their targeting through the API, so they get an INFO line to check by hand; a target list the export cut off at 50 is skipped and listed under Not checked.
4. **Stacking and worst-case price.** Lists every pair of discounts that can combine and computes, for every SKU and every stretch of the sale calendar, the lowest price any valid combination reaches. A fixed amount split across items with a minimum is shared by a cart at that minimum. App, multi-class and fixed-amount buy X get Y discounts aren't modeled and are listed as "check manually". Flags depth past the plan's max, prices below unit cost and margins under the floor, with the planned worst case alongside.
5. **Codes, limits and free shipping.** Missing or higher usage limits, codes that aren't once per customer when planned, creator codes that combine with the sale, guessable WORD+NUMBER codes open to everyone, and free shipping below the planned minimum, with a labeled cost estimate.
6. **Reference prices.** Compare-at below the price, compare-at raises the SKU never actually sold at for 30 of the 90 days before, and compare-ats never seen as a selling price in the history (a review trigger based on the FTC's Guides Against Deceptive Pricing, 16 CFR 233.1, not a legal test). This check follows US (FTC 16 CFR 233.1) guidance. It does not apply the EU/UK rule that the reference price must be the lowest price in the prior 30 days.
7. **Test-checkout script.** Concrete carts with a time, a customer and a code, the total to expect if the setup matches the plan, and the total that shows the bug.
8. **Verdict and dated fix list.** `NOT READY` (any critical), `FIX BEFORE LAUNCH` (warnings only) or `READY`. Fixes are due 14 days before launch; anything costing money today is due today.

Exit codes: 0 means the report was written, 2 means invalid input (with the file, line or entry, and the reason). The normalizer also exits 2 when the API response has errors or more pages to fetch.

## Acting on the report

Present the verdict and the ranked issues, then walk through the fixes in this order. Each one needs the user's approval. Exact admin paths, the safe test method and templates are in `references/playbook.md`.

1. **Anything live today.** Free shipping below its minimum and codes that must not be active cost money every hour. Set the minimum; deactivate the code (reactivating it later clears its end date, so set one).
2. **Who gets the sale.** Point the sale's *Applies to* at a collection that holds only eligible products (the sample uses a "BFCM Eligible" collection). Never "All products" when anything is excluded.
3. **Combinations.** Untick the classes the plan forbids, on both discounts in each pair. Either side stops the stack; both sides survive a later edit.
4. **Code limits.** Usage caps and one use per customer on creator and VIP codes; one code per creator; end dates on everything.
5. **Windows.** Set times in the admin, which uses the store's time zone. For apps and API scripts, send UTC with `Z`, and remember US daylight time ends Nov 1, 2026 (ET is UTC−5 from then on).
6. **Reference prices.** Put a raised compare-at back or document that it was a real former price, and get a pricing-compliance review.
7. **Prove it.** Re-export, re-run until READY, run the test checkouts and mark items verified. Freeze discounts 3 days out; final run the day before; one real order in each phase as it opens.

## Files

- `scripts/offer_qa.py`: the analyzer. Local files only, deterministic output, optional `--json`.
- `scripts/normalize_shopify_discounts.py`: turns Admin GraphQL `discountNodes` pages into `discounts.json`; `--print-query` prints the query.
- `DATA_CONTRACT.md`: the four input formats, the GraphQL query and API-version notes, and every rule and threshold, with which Shopify behaviors are confirmed from Shopify's documentation and which are assumptions.
- `references/playbook.md`: Shopify admin paths for each fix, how to test scheduled discounts safely, creator and VIP code hygiene, time zones and DST, reference-price practice, and a status-update template.
- `examples/run.sh`, `examples/expected_output.txt`, `examples/data/`: sample run, including the raw GraphQL response the sample `discounts.json` was normalized from. `examples/make_fixtures.py` regenerates the sample data.
- `tests/run_tests.sh`, `tests/make_cases.py`: 23 end-to-end checks on hand-checkable inputs, including Shopify's own combination examples. No network needed.