Design doc §07 extension: admin-configurable tiers, each with an arbitrary JSON benefit set, instead of one hardcoded boolean. Ships two benefit types on top of the new Membership\Tier lookup: the existing free-shipping threshold (refactored, not just left on the old flag) and a new "$10 off orders $100+, members only" cart discount — bundling purchases into one $100+ order saves BooksRun's flat per-order shipping fee outright, more than covering the discount. Researched official/well-supported WooCommerce plugins first (Memberships, Points and Rewards, Smart Coupons) — none combine tiers with configurable per-tier benefit sets, so building custom on the existing wp_bsc_member table was the right call. Registration is enabled via WooCommerce's own native My Account signup (deploy.sh) rather than a bespoke form, with a marketing-consent checkbox added via the standard extension hooks. Admin management: new `wp bookstore tier-*` CLI commands plus a WooCommerce Settings screen (this plugin's first admin UI). Migration drops and recreates bsc_member rather than backfilling — pre-launch, only synthetic data exists anywhere. Live-verified beyond the unit suite: real migration run, CLI commands end-to-end, a real registration submitted through the actual WC form (with and without consent), and the settings screen edited/created/ deleted tiers through real authenticated form POSTs with real nonces. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MqhgnCRjjVLFnZ3Jpn93Vg
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–3 of the 5-week plan are done: environments, deploy pipeline, the
WooCommerce/HPOS plugin scaffold, catalog schema, Gutenberg importer,
pricing engine, membership shipping, checkout, and the order state
machine all live under wp-content/plugins/bookstore-core/includes/.
Week 4 is narrowed in scope and partially done: a live BooksRun
price/stock recheck runs at checkout, but BooksRun AutoBuy and Ingram are
deliberately deferred past initial launch (see "What's deliberately not
here yet" below).
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.
Admin access restriction (production)
/wp-admin and /wp-login.php can be restricted to specific IPs/CIDR
ranges in production via WP_ADMIN_ALLOWED_IPS in .env.production —
space-separated, matching Caddy's own remote_ip syntax directly (e.g.
203.0.113.5 198.51.100.0/24). Leave it blank and wp-admin stays
reachable from anywhere, same as today — this is opt-in, not on by
default, and only applies in production (staging is already LAN-only).
/wp-admin/admin-ajax.php is always exempt regardless of the IP list,
since WordPress/WooCommerce route some logged-out storefront AJAX
through it. Applying a changed value just needs make deploy ENV=production
(or restarting the caddy container) — no code change required.
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.
Testing
make test ENV=dev
Runs bookstore-core's PHPUnit suite
(wp-content/plugins/bookstore-core/tests/) against pure business
logic — currently the 25% pricing formula (Pricing\PricingEngine) and
the $25/$15 free-shipping thresholds (Shipping\BSC_Free_Shipping) —
using Brain Monkey/Mockery to stub WordPress/WooCommerce rather than a
full WP-integration bootstrap. First run installs dev dependencies via
Composer inside the wordpress container; since the bind-mounted plugin
directory is host-owned and www-data can't write into it, that install
step runs as root and then chowns vendor//composer.lock back to the
host user (see the test target's comment in Makefile). Not yet
covered: Order\CheckoutRecheck and the order state machine.
What's deliberately not here yet
- BooksRun AutoBuy and Ingram supplier adapters — deliberately deferred;
SupplierOrderJobstill submits synthetic (gutenberg_test) orders only. The BooksRun price/stock recheck itself is live (see "Checkout- order engine" above) — it's just automated purchase submission and Ingram entirely that aren't built.
- The daily BooksRun FTP bulk-sync job's scheduling/dedup/batch-write
infrastructure is real (
Import\BooksRunOfferSync), but actually connecting to a live feed is a deliberate stub — no FTP access or sample feed exists yet to build the parser against. - Automated tests for the checkout-recheck/order-state-machine logic —
the pricing formula and shipping thresholds now have tests (see
"Testing" above), but
Order\CheckoutRecheckand the state machine itself don't yet. - 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 automation —
make testruns the suite manually, but nothing wires it into the deploy pipeline (poll-deploy.sh) yet, so a broken test doesn't block a deploy.