Ecommerce User Guide
1. Executive Summary
The Ecommerce module (aegis-ecommerce, served from shop.purdyandfigg.dev)
is a storefront that lets a landing page carry a real cart and take a real
payment without Shopify in the path.
Shopify remains the backend of record. Products are pushed in here, and a paid order is written back out to Shopify for fulfilment the moment the payment succeeds. Nothing in this module is the truth about stock, fulfilment or customers — it is the truth about what was sold, for how much, on a page we control.
Two rules run through the whole module, and most of its behaviour follows from them:
Prices come from the database, never from the request. The page says which product and how many; the server says what it costs. A storefront that accepts a price from the page it renders can be bought from for £0 by anyone who opens devtools — and the order looks completely normal afterwards.
Money is integer minor units. Pence and cents, never decimals.
8000is £80.00. Seeshared/ecomMoney.js.
2. Where things are managed
There is no admin panel in the ecommerce worker. Everything staff-facing
lives in Aegis Admin under the Ecommerce tab, which is visible to
superadmins and to users with the access_ecommerce permission. Its sub-tabs:
| Tab | What it controls |
|---|---|
| Products | The catalogue: title, handle, price, compare-at, and per-product min/max quantity. Each product is matched to a Shopify variant — see §2.1. |
| Orders | Orders taken by this module, and their Shopify handoff status. |
| Cart | What the drawer says and does: remove-on-last-unit, the free-shipping and offers wording, and the zero-price blocks — see §2.2. |
| Upsells | Offers, their placement, and the post-purchase window. |
| Pages | Staff-authored page content served by this worker. |
| Shipping | Rate table per store, country and subtotal band. |
| Tax | The built-in rate table per jurisdiction. |
| Payments | Which gateway is live, per store, and test-vs-live. |
2.1 Products are matched to Shopify variants
A product here carries shopify_product_id and shopify_variant_id pointing at
the Shopify variant it represents. This is a link, not a content sync. Price,
compare-at and quantity limits are ours and stay ours — Shopify never overwrites
them, because prices come from our database and nothing else may set them.
What the link buys is the order handoff: a line with a variant id moves stock and shows up in Shopify product reporting. A line without one is still sent, as a custom line — so an order is never short — but it will do neither of those things.
Matching runs against a cached copy of the Shopify catalogue (shopify_cache,
filled by the discount audit — run that first, or the variant list is empty). The
automatic pass tries three things in order, and takes a match only when exactly
one candidate survives:
- SKU (falling back to handle)
- exact variant label
- exact product title
It is deliberately two steps — propose, then apply. The dry run names every product it could not match and why, which is the useful half: "3 Shopify variants have that name — pick one" needs a different reaction from "nothing in Shopify has that SKU or that exact title". A product mapped by hand is never overwritten by a guess, and an existing SKU outranks one copied from a match.
2.2 Exporting and tagging orders
The Orders tab's filter (search, status, shop, in Shopify or not, upsell, shipping) drives two buttons above the table. Both act on every order the filter matches, not only the page on screen:
- ⬇️ Export N: a CSV of the filtered orders, covering the Aegis and Shopify numbers, shop, date, email, status, money (total, saved, upsell, shipping, refunded), provider and mode, and the order's Shopify tags.
- 🏷️ Tag N in Shopify: adds or removes tags on the filtered orders that are in Shopify, each on its own store. Only the tags you type change; an order's other tags stay. Orders not yet in Shopify are left out and counted. Live-store orders ask you to confirm. Failures are listed by order.
Hover over an order's Shopify number to see its tags. Orders never tagged from Aegis have their tags read from Shopify once, in the background, when the list loads.
The list holds the latest 200 orders, so the filter, export and tag work on those.
2.3 What the Cart tab controls
These are the small behaviours a shop wants to change without a deploy — mostly the things Shopify does not give you a switch for.
| Setting | Default | What it does |
|---|---|---|
remove_on_last_unit |
on | With it on, pressing minus at a quantity of one removes the line. With it off the button is disabled and says why, rather than being a control that silently does nothing. |
free_shipping_prompt |
Add {amount} to unlock free shipping! |
{amount} is replaced with what is left to spend. A message with no placeholder is shown as written — somebody may not want the number in it. |
free_shipping_reached |
You have free shipping. |
Shown once the basket qualifies. |
offers_heading |
Add extra member-price offers |
The upsell heading in a basket that already holds something. |
empty_offers_heading |
Start with a member-price offer |
The heading for an empty basket — "add extra" is a sentence about a cart with items in it. |
block_zero_order |
blocking | Refuse a checkout whose total is zero. |
block_zero_line |
blocking | Refuse a checkout containing a zero-priced line. |
The two zero-price switches are separate on purpose, because they are two different mistakes. A free order is almost always a fault that stacked — a discount that compounded, a price that failed to load, a cart that emptied itself — and shipping goods for nothing is the expensive half. A free line is a real thing a shop does deliberately: a gift with purchase is a zero-priced line sitting beside paid ones. So a shop running those turns the line switch off and keeps the order switch on.
Both default to blocking. A shop that has never opened this screen is the one most likely to be surprised by a £0 order, and a refused checkout is recoverable in a way that a shipped-for-free pallet is not.
How it is stored, since the section is expected to grow: one row per store holding JSON, rather than a settings key per field. A key per field makes a migration-shaped decision out of every new configurable sentence. A blob costs one read however many fields it grows, an unknown field in it is ignored rather than fatal, and anything unreadable falls back to the defaults — a cart that cannot be drawn because a settings row has a stray character in it would be a shop that cannot sell.
Clearing a text field means say nothing, not use the default. Those are different intentions and the admin lets you express both. Messages are capped at 120 characters: a heading is a heading, not a paragraph.
2.4 Quantity limits, per product
Set on the product, enforced in two places. A minimum sells a kit as a pair; a maximum stops one person taking the whole run of a promotional price. Empty means no limit — the shop having no opinion, which is what it had before these existed. There is a hard ceiling of 99 whatever a product says, because a typo is not an order for 10,000.
Three behaviours worth knowing:
- Adding one of something sold in pairs adds the pair. Refusing the click would be a button that does nothing on a product the shop is trying to sell — the minimum is how it is sold, not a mistake the customer made.
- It raises, it never lowers. Asking for three of a one-per-customer product is blocked, not quietly reduced to one. A silent clamp is not a block, and it enforced the limit by saying nothing on the one screen where there was still time to say it.
- Zero is always allowed. Zero is not a quantity, it is removing the line, and a minimum of two must not trap somebody into owning two.
A product configured the wrong way round — min 5, max 2 — does not become unbuyable; the minimum wins, because it is the one somebody set deliberately.
The rule is enforced in the cart, so a customer is told while they can still do something about it, and again at checkout, because a cart is a number in somebody else's browser. Both call the same function: a rule written twice is a rule that disagrees with itself.
The reason there is no admin here: the router reverse-proxies this hostname
and forwards visitor query strings to it, so an authenticated panel sharing that
surface has nothing to gain. All three workers bind the same D1, so Aegis reads
these tables directly. What is left on shop. is public — catalogue, cart,
checkout, and the page itself.
3. Putting a cart on a page
The whole integration is one script tag and one attribute:
<script src="https://shop.purdyandfigg.dev/widget/cart.js" data-store="uk" defer></script>
<button data-aegis-add="spring-bundle">ADD TO CART</button>
data-store is uk or us and decides currency (GBP/USD), the rate table, the
tax table and which Shopify shop the order lands in.
Three things about the widget are worth knowing before you debug it:
- It lives in a shadow DOM. The landing pages it drops onto are built by other people and carry their own resets and z-index wars. The page cannot style the widget and the widget cannot style the page. If the cart looks wrong, it is not page CSS.
- The cart token is the cart. It lives in
localStorage; there is no login. Anyone holding the token may read and change that cart, which is why it is 32 random bytes rather than a sequential id. Clearing site data loses the cart. - The widget never computes a total. It renders what the API returns. If the widget and the server disagree about a price, the widget is wrong by definition.
4. Pages
An ecommerce page is staff-authored HTML served by this worker — the worker is
the origin. An ecommerce route is an ordinary proxied Aegis route whose Origin
URL happens to be shop.purdyandfigg.dev/<path>. That means monitoring, QA
sign-off, preview URLs and the admin preview token all applied to these pages on
day one, because there is nothing new about them.
Pages are served on pages.purdyandfigg.dev, not on shop. — deliberately.
On the same origin, a staff-authored page could call the admin API as whoever
happened to be signed in, with the session cookie travelling on its own. The
handler refuses to serve a page on the shop hostname at all.
Ecommerce → Pages lists each page as the Proxied and Shopify lists do: the same row (screenshot, path, flag, Built date, Hosting and Status pills), with the page's own state underneath (live or draft, unpublished edits, size, from Build Bot) and 📝 Edit page in Options for its HTML.
4.1 Create a page from a Build Bot page
Pages → Aegis Cart (Ecommerce → Pages) → + Create New Page opens the Page Setup Wizard (Ecommerce), laid out like the other page wizards:
| Step | |
|---|---|
| 1. Destination | Page type and Store assignment come from the built page you pick and can't be changed here; then the path; then the built page: Build Bot pages with an Aegis-cart version (page_ecommerce.html), searchable, gaps shown, the template and any route already using it on hover. |
| 2. Tracking | The UTMs. Orders and revenue are reported by campaign, so give each ecommerce page its own. |
| 3. Slack, 4. QA & Monitor | The same steps as the other wizards. QA this page and Monitor this page are unticked by default. Check against PIM is set on the built page itself (Build Bot → Options → Edit). |
| 5. Summary | Everything above, each row with Edit. ✅ Finalise & Create Page. |
| 6. Create | Makes the route (an ordinary ecommerce route), takes the built page as it is now as its page, and publishes it. The route serves once it is live, by its sign-offs, like every page. |
The page is a snapshot: rebuilding the page in Build Bot never changes a live ecommerce page. To bring one up to date, open it in Ecommerce → Pages and press ↻ Take the latest build (the old draft is kept in history), then Publish changes. Pages made by hand keep their ↺ Reset to template.
5. The checkout, step by step
POST /api/cartcreates a cart and returns its token.POST /api/cart/:id/itemsadds a line. Quantity is checked against the product's min/max.POST /api/shipping/ratesquotes delivery for an address and subtotal.POST /api/checkoutturns the cart into an order and a payment intent.POST /api/checkout/:orderId/confirm(or the Stripe webhook) marks it paid.- The paid order is pushed to Shopify immediately — not on a cron.
When the money goes back
Stripe's charge.refunded event is handled on the same webhook. A full
refund moves the order to refunded, so nothing that reads paid counts it any
more; a partial refund leaves it paid and records refunded_cents,
because the order still earned what was kept. Either way the refunded amount
comes back out of analytics_stats, which is what the Orders card and
Performance by Page Type read — an order marked refunded here would otherwise go
on counting as revenue there.
Stripe's amount_refunded is a running total for the charge, not the latest
refund. Only the part not already recorded is taken out, so a second refund and
a redelivered event are both safe.
charge.refunded has to be enabled on the webhook endpoint in the Stripe
dashboard, alongside the payment-intent events. Refunds made before it was
enabled generate no event and will never arrive — POST /api/refunds/sync
(bearer ECOM_ADMIN_TOKEN) asks Stripe about orders we think are paid and
applies whatever it says has gone back. It is bounded (?limit=, 200 by
default), safe to re-run, and reports three separate groups: corrected,
unverifiable (no recorded payment mode, or no charge on the intent) and
failed. It checks Stripe only — PayPal refunds are a different API and are
reported as not checked rather than counted as clean.
POST /api/checkout re-validates everything rather than trusting the cart it is
handed. The cart that was validated when items were added is a number in
somebody else's browser: a stale tab, a restored session or a direct POST all
arrive at this endpoint, and this is the one that turns a cart into money. It
re-checks quantity limits per line, rejects a £0 line and a £0 order if the shop
has those switches on, validates the delivery and billing addresses, and prices
the delivery service from the rate row rather than from the request.
Address validation is deliberately loose — presence, not shape. Every stricter rule rejects an address somebody actually lives at, and the cost of that is a lost order.
6. Upsells
The same product offered again, cheaper, at a moment when someone is already buying. Three placements:
| Placement | When |
|---|---|
cart |
In the cart drawer, before checkout. |
empty_cart |
In an empty drawer. |
post_purchase |
After payment, inside the upsell window. |
The post-purchase window defaults to 3 minutes and is configurable in the admin. A post-purchase acceptance adds a line to the existing Shopify order rather than creating a second one.
A PayPal order cannot be upsold after payment. A post-purchase offer charges something kept from the first payment, and PayPal's equivalent is a vaulted payment token that has to be asked for when the order is created and consented to then — so it cannot be added afterwards, and an order already paid has nothing to charge. This is read before the offer is shown rather than at the charge: it first failed in the worst possible place, after the line had already gone onto the Shopify order.
Known gap — US tax on post-purchase upsells. We quote the tax ourselves, and Shopify taxes the line it added by its own rules. The two have to agree or the order is left owing. This path is currently only exercised against the seeded rate table in tests. Prove it against a real Shopify order before the US store takes real money.
7. Payments
The card gateway
Stripe is the gateway. The admin can switch to Airwallex per store without a
deploy, and that setting wins over the PAYMENTS_PROVIDER var. Only one card
gateway is active at a time — Stripe or Airwallex, never both.
Airwallex is not broken in this code. Every request to their /api/v1/* is
refused by Google Cloud Armor before their gateway sees it — measured
2026-09-11 from three unrelated networks, while /api/v2/* and / reach APISIX
normally. No credential we hold has ever reached their API. If they lift that,
switching back is a dropdown.
Two shops, two Stripe accounts. The UK and US stores settle to different banks and file different returns, so an order paid through the wrong one is money in the wrong company. Keys are per store, with an unsuffixed pair as the fallback for a store that has none of its own. Test-vs-live is the key prefix, not a var — so one shop can go live while the other is still in test.
A third provider, stub, approves every payment and takes no card. It is
accepted only from the PAYMENTS_PROVIDER var and never from the admin
switch: it needs a deploy and a code review, not a click.
Which methods the checkout offers
A store's payment methods are chosen in the admin rather than only on the gateway, so an admin has one fewer place to go. It hides, it does not enable — that is the honest name for what it can do. A method must be activated on the gateway account before anything can render it, so nothing here turns PayPal on; it only takes an activated method off this shop's checkout.
What is stored is therefore the list of what is hidden, not what is offered, so the default — an empty list — means "show whatever the gateway decides" and cannot be confused with "offer nothing".
Cards and PayPal are method types the gateway renders; Apple Pay and Google Pay are wallets riding on the card method. Whoever is choosing what the checkout offers should not have to know which is which, so the distinction is held in the data and the page does the mapping.
PayPal, which is not the card gateway
PayPal stands beside whichever gateway takes the card rather than replacing it, and today it is US only.
The reason it is a gateway at all, rather than a tickbox: Stripe offers PayPal to European and UK accounts only — the US is not on their list. So in the UK, PayPal is a payment method Stripe renders; in the US it has to be a second gateway talking to PayPal directly. Pretending otherwise would put a switch in the admin that cannot work.
Three things have to be true before a customer is shown it, and they are different questions:
| Condition | Why it is separate |
|---|---|
The store is us |
By decision. A UK customer sees a card form and nothing else. |
| It is switched on | A shop can be fully configured and deliberately not offering it — for a campaign, or while something is wrong at PayPal's end. |
| It has credentials for the mode it would use | PayPal's sandbox and live are different accounts and different hosts, so a missing pair is not a degraded PayPal, it is no PayPal at all. |
Any one of them false and the page is told nothing about PayPal, so the checkout renders exactly as it did before. The same rule as customer sign-in: an offer that fails at PayPal is worse than no offer.
Note that with PayPal the mode is the environment — there is no sk_test_
prefix to read as there is with Stripe. The keys decide which mode is used, and
the admin setting only breaks the tie when both are configured.
The flow is not Stripe's. There is no client secret and no card element: the
order is created server-side, the customer approves it at PayPal, and the server
captures it afterwards. So createPaymentIntent returns an approval URL where
Stripe returns a secret, and the page sends the customer there.
Not yet verified against a live PayPal account. The request shapes come from PayPal's Orders v2 documentation. The provider refuses to run without credentials rather than charging nothing, and defaults to the sandbox, so a test cannot take real money.
8. Tax
TAX_PROVIDER is table — the built-in rate per jurisdiction. That is exactly
right for the UK's single VAT rate, and an estimate for a US address, where
county, city and district taxes stack on boundaries that do not follow
postcodes. 36 US states are charged the state rate plus the population-weighted
average local rate, which is not the rate at any particular address.
This is right for the bounded test it was built for and wrong as a steady state:
under-collection is owed at filing whatever was charged, so the error grows with
orders. The trigger to move is volume, not a defect. TaxJar and Avalara are
both written; switching is TAX_PROVIDER plus credentials.
GET /health names every unpriced and estimated jurisdiction, so a worker
estimating in 36 states does not look like one that is pricing them.
9. Checking it is healthy
GET https://shop.purdyandfigg.dev/health is the one call that answers "is this
shop taking real money, and is it charging the right tax?" It reports, per
store:
- the active gateway, where the choice came from (
admin settingorenv var), and whether the switch actually took LIVE,sandbox, orSTUB — approves everything, spelled out in anotefield rather than left as alive: falsethat is easy to skim past- which gateways have which keys configured, per store
- the tax provider, plus every jurisdiction it cannot price and every one it is only estimating
live is true if either store is live. The banner exists to stop somebody
believing no real money can move here, and one live store is enough to make that
false.
10. Customer sign-in
Signing in is optional and exists to fill the address in. It only appears for a
store listed in CUSTOMER_ACCOUNTS_STORES and holding a customer account
client id — today, US only. The UK shop is still on legacy Shopify customer
accounts, which have no Customer Account API, so a sign-in button there would
end at a Shopify error. Where it is not available,
/api/shipping/destinations returns customer_auth: null and the checkout
shows nothing at all: an offer that leads to a 404 is worse than no offer.
11. API reference
The generated endpoint reference is at Ecommerce API. Reads are public by design — the widget calls them cross-origin from landing pages, and the cart token travels in the URL rather than in a cookie, which is what makes a permissive CORS origin safe here.
The catalogue write endpoints require ECOM_ADMIN_TOKEN as a bearer token
and fail closed: with no token configured, nothing can write. The Stripe webhook
is authenticated by its signature, and refuses every delivery rather than
accepting anything if STRIPE_WEBHOOK_SECRET is unset.
12. Local development
npm run dev:ecommerce
Set SHOP_PUBLIC_ORIGIN=http://localhost:8790 in ecommerce/.dev.vars. This
cannot be derived: under wrangler dev the custom domain is simulated, so both
the request URL and the Host header say shop.purdyandfigg.dev even on
localhost — and a page built from that would load production's cart widget and
call production's catalogue from your dev machine.