Wires up Helcim's free WooCommerce plugin (installed inert until a sandbox token exists) alongside a synchronous pre-charge supplier/margin recheck and a fraud-check hook, both firing immediately before payment per the Appendix B immediate-charge decision. Adds a custom order-status sequence (bsc-ordered/bsc-shipped/bsc-exception) as the state machine and review queue, an Action Scheduler-backed supplier-submission job with idempotency guards, a bsc_order_audit ledger, and two new transactional emails. Hooks both the classic WooCommerce checkout actions and the real woocommerce_checkout_validate_order_before_payment hook, since this site's storefront runs WooCommerce Blocks/Store API rather than the classic shortcode checkout (confirmed by reading WooCommerce's own source after the classic hooks turned out not to fire in live testing). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2gmgaDVXcRGCwLfdXQajD
Bookstore
WordPress + WooCommerce bookstore. Design reference: see docs/ for the
pre-launch plan and the technical design doc (bookstore-core schema,
interfaces, order state machine — the design doc is the source of truth for
why this repo is laid out the way it is).
Weeks 1–2 of the 5-week plan are done: environments, deploy pipeline, the
WooCommerce/HPOS plugin scaffold, catalog schema, Gutenberg importer,
pricing engine, and membership shipping all live under
wp-content/plugins/bookstore-core/includes/. Checkout, the order state
machine, and live supplier integrations are Week 3+.
Layout
docker-compose.yml base services: db, redis, wordpress, cron
docker-compose.{dev,staging,production}.yml per-environment overrides (caddy, mailhog)
docker/php/ app image: WP core image + wp-cli, composer, redis ext
docker/caddy/ one Caddyfile per environment
docker/cron/ Action Scheduler driver (system cron has no host to run on in Docker)
deploy/deploy.sh idempotent bring-up + WP/WooCommerce config
deploy/poll-deploy.sh host-crontab script: redeploys when its branch moves on Gitea
deploy/backup.sh, restore.sh off-host backup; restore is staging-only, on purpose
secrets/<env>/ DB passwords, written fresh by deploy.sh — gitignored, not manually edited
wp-content/plugins/bookstore-core/ the one plugin that owns business logic
wp-content/mu-plugins/ local-mail-catcher.php — routes mail to MailHog outside production
Same code runs in every environment; only .env.<env> and which
docker-compose.<env>.yml you layer in differ (design doc §01).
Local development
cp .env.example .env.dev # fill in DB_PASSWORD etc.; localhost values are fine
make up ENV=dev
make deploy ENV=dev # installs WP, WooCommerce, activates bookstore-core, enables HPOS
Site is at http://localhost:8080. make wp ENV=dev ARGS="plugin list" runs
any wp-cli command; make logs ENV=dev tails everything; make shell ENV=dev
drops into the app container.
Staging (this is what goes on your Docker server)
cp .env.example .env.staging
# fill in SITE_DOMAIN, SITE_URL, DB_*
# SITE_DOMAIN must be a HOSTNAME, not a bare IP — see below
make deploy ENV=staging
SITE_DOMAIN needs to be a hostname, not the VM's IP directly. TLS SNI
(how Caddy picks which certificate to present) isn't sent for literal IP
connections per spec, so HTTPS to a bare IP fails at the handshake itself
no matter what Caddy does. Pick any hostname (e.g. bookstore.lan), add it
to /etc/hosts (or the Windows equivalent) on whatever machine you're
browsing from, pointing at the VM's LAN IP, and use that hostname as both
SITE_DOMAIN and in SITE_URL.
Caddyfile.staging forces tls internal — Caddy's own self-signed CA,
issued locally with no external network calls — rather than letting Caddy
guess whether the name looks "public" (its automatic heuristic only
recognizes bare IPs and localhost as obviously-private; anything else,
including a made-up LAN hostname, it assumes might be real and tries
Let's Encrypt, which then fails). Your browser will show an untrusted-cert
warning once; click through it, or trust Caddy's root cert to skip that:
docker compose -p bookstore-staging exec caddy cat /data/caddy/pki/authorities/local/root.crt
Staging is noindex'd (blog_public=0 plus the X-Robots-Tag header in
Caddyfile.staging) so search engines won't index it — there's no basic-auth
wall on top of that, since this box is LAN-only and not reachable from
outside. If that ever changes (a public domain, port-forwarding, etc.),
basic-auth is worth adding back before that happens, not after. Mail never
leaves the box: it's caught by MailHog, viewable at :8025.
Theme: Blocksy + the Book Store starter site
deploy.sh installs and activates the Blocksy theme and the free
Blocksy Companion plugin from wordpress.org automatically — nothing to
do here, in any environment.
The Book Store starter site itself is a paid Companion Pro template (Business plan, $99/year — design doc Appendix A), so it can't be scripted against a public API the way the free theme can. One-time manual step per environment, in wp-admin:
- Purchase/retrieve the Blocksy Business license key.
- Blocksy → General → License → activate it.
- Blocksy → Extensions → Starter Sites (Companion Pro) → import Book Store.
After that, the starter site's content and Customizer settings persist in
the database like any other WordPress content — a redeploy or a fresh
deploy.sh run doesn't touch it or need to repeat it.
Payments: Helcim
deploy.sh installs and activates the free, wordpress.org-hosted Helcim
for WooCommerce plugin (helcim-commerce-for-woocommerce) automatically
in every environment — nothing to do here for that part.
The plugin needs a Helcim sandbox API token, which isn't something a public API can hand out, so it's a one-time manual step per environment:
- Sign up for a free Helcim account.
- Helcim dashboard → Integrations → API Configuration → create an API token.
- Put it in
HELCIM_API_TOKENin.env.<environment>, then re-runmake deploy ENV=<environment>.
deploy.sh writes the token to a gitignored secrets file the same way it
already does for HARDCOVER_API_TOKEN (see secrets/<env>/ above), and
Payments\HelcimConfig::ensure_configured_from_env() seeds the gateway's
WooCommerce settings (sandbox mode, disabled by default — a human still
enables it at checkout once ready) the first time it finds a token. Until a
token exists, the plugin is installed and inert; nothing else in this repo
is blocked on it. Go-live (switching the gateway from sandbox to Helcim's
live environment) is a deliberate manual step on the settings screen, not
something this repo automates.
Deploy pipeline
The Git server (Gitea) and the staging/production Docker hosts are separate machines, so instead of a webhook receiver listening for an inbound POST from Gitea, each box just polls its branch and redeploys when it moves — no inbound port to expose or secure.
- Clone this repo onto the box (e.g. into
/srv/bookstore),cp .env.example .env.staging(or.env.production) and fill it in. - Add a host crontab entry (not inside a container —
crontab -eon the box itself):(*/2 * * * * /srv/bookstore/deploy/poll-deploy.sh staging >> /var/log/bookstore-deploy.log 2>&1production+mainon the production box.) poll-deploy.shfetches, compares against the last-deployed commit, and — only if it moved — checks out the branch and runsdeploy.sh. Silent otherwise, so it's safe to run every couple of minutes.- The box needs its own git credentials to fetch from Gitea (a read-only
access token works well) — set those up once via a credential helper or
an embedded token in that box's
originremote.
You can also just run ./deploy/deploy.sh <env> by hand at any time; the
poll script is only automation on top of the same idempotent script.
Backups
./deploy/backup.sh staging # or production
Dumps the database and archives wp-content/uploads, gzips both, and syncs
to $BACKUP_REMOTE via rclone if set (configure rclone config on the
server first — this repo doesn't manage remote credentials). Before launch,
run a real restore drill:
./deploy/restore.sh ./backups/production/db-<ts>.sql.gz ./backups/production/uploads-<ts>.tar.gz
restore.sh only ever targets staging — there's no production argument, so
a restore drill can't accidentally overwrite the live site.
Catalog pipeline (Week 2)
Staging data is built with wp bookstore sub-commands (see wp bookstore help for full options):
wp bookstore import-gutenberg --limit=10000 # Work/Edition/ISBN rows from the real PG catalog feed
wp bookstore generate-offers # synthetic SupplierOffer rows: stock, condition, price
wp bookstore sync-products # one WooCommerce product per Work
wp bookstore sync-covers --limit=20 # optional: real cover art via Open Library
wp bookstore sync-hardcover-tags --limit=20 # optional: genre/mood/content-warning tags
import-gutenberg --limit counts only new titles per run, so re-running
with a bigger --limit continues forward instead of rescanning. Pricing
(25% target margin) and the $25/$15 free-member shipping thresholds are in
includes/Pricing/PricingEngine.php and
includes/Shipping/BSC_Free_Shipping.php.
Checkout + order engine (Week 3)
Helcim's WooCommerce plugin is installed (see "Payments: Helcim" above),
paired with a synchronous pre-charge supplier/margin recheck and a
fraud-check hook — both run right before the payment gateway is called
(Order\CheckoutRecheck), never after. A passing order moves through a
custom status sequence beyond WooCommerce's own pending/processing/
completed: bsc-ordered (submitted to the supplier via Action
Scheduler, Order\SupplierOrderJob) → bsc-shipped → completed. Any
failure at any stage — no sellable offer, margin no longer holds, fraud
check fails, or the supplier submission itself fails after charging — lands
the order in bsc-exception ("Needs Review"), which is the review queue:
just a filterable WooCommerce order status, not a separate table. A
post-charge failure also triggers an automatic refund
(wc_create_refund(..., refund_payment: true)), per the design doc's
Appendix B decision that an undo is always a refund, never a void.
wp bookstore force-offer-status <isbn13> <active|stale|discontinued> (see
wp bookstore help) forces a specific ISBN's synthetic offer into a
specific state — SyntheticOfferGenerator's rolls are random, so this is
how to reliably exercise the recheck's failure paths (price-increase,
out-of-stock, discontinued-after-charge) in a real checkout.
Real BooksRun/Ingram supplier adapters are still Week 4 — SupplierOrderJob
runs against the synthetic gutenberg_test supplier for now, deliberately
built through the same Action Scheduler/locking path Week 4's real
integration will reuse rather than restructure.
What's deliberately not here yet
- Live BooksRun/Ingram supplier adapters — Week 4, see the design doc.
- Automated tests for the pricing/shipping-threshold/checkout-recheck logic — called for by the design doc's launch gates, not yet written.
- A real TLS-terminating public domain — Caddy will auto-provision certs
once
SITE_DOMAINpoints at a real host with 80/443 reachable; until thendocker-compose.dev.ymlis the only one that works fromlocalhost. - CI test/lint automation — nothing here runs tests yet because there's no application code to test yet.