Files
bookstore/README.md
T
twooeyandClaude Sonnet 5 7023044278 Sync README with current project state
Top summary still said Week 3+ was undone even though checkout/order
engine has its own finished section further down; "what's deliberately
not here yet" claimed no tests exist at all. Adds a Testing section and
corrects the Week 4/BooksRun/Ingram/CI status bullets to match reality.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MqhgnCRjjVLFnZ3Jpn93Vg
2026-08-28 11:21:15 -04:00

272 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
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.<environment>`, then re-run
`make 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.
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 <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;
`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.