# 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// 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.` and which `docker-compose..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: 1. Purchase/retrieve the Blocksy Business license key. 2. **Blocksy → General → License** → activate it. 3. **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: 1. Sign up for a free Helcim account. 2. Helcim dashboard → **Integrations → API Configuration** → create an API token. 3. Put it in `HELCIM_API_TOKEN` in `.env.`, then re-run `make deploy ENV=`. `deploy.sh` writes the token to a gitignored secrets file the same way it already does for `HARDCOVER_API_TOKEN` (see `secrets//` 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. 1. Clone this repo onto the box (e.g. into `/srv/bookstore`), `cp .env.example .env.staging` (or `.env.production`) and fill it in. 2. Add a host crontab entry (not inside a container — `crontab -e` on the box itself): ``` */2 * * * * /srv/bookstore/deploy/poll-deploy.sh staging >> /var/log/bookstore-deploy.log 2>&1 ``` (`production` + `main` on the production box.) 3. `poll-deploy.sh` fetches, compares against the last-deployed commit, and — only if it moved — checks out the branch and runs `deploy.sh`. Silent otherwise, so it's safe to run every couple of minutes. 4. 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 `origin` remote. You can also just run `./deploy/deploy.sh ` 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-.sql.gz ./backups/production/uploads-.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 ` (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; `SupplierOrderJob` still 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\CheckoutRecheck` and the state machine itself don't yet. - A real TLS-terminating public domain — Caddy will auto-provision certs once `SITE_DOMAIN` points at a real host with 80/443 reachable; until then `docker-compose.dev.yml` is the only one that works from `localhost`. - CI automation — `make test` runs the suite manually, but nothing wires it into the deploy pipeline (`poll-deploy.sh`) yet, so a broken test doesn't block a deploy.