280 lines
14 KiB
Markdown
280 lines
14 KiB
Markdown
# 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: WooCommerce Stripe (authorize/capture)
|
||
|
||
Deployment installs and activates the official `woocommerce-gateway-stripe`
|
||
extension and enables its **Issue an authorization on checkout, and capture
|
||
later** setting. Stripe owns card collection, Checkout Block integration,
|
||
webhooks, authorization, capture, and cancellation; `bookstore-core` never
|
||
handles card data or Stripe API credentials.
|
||
|
||
Set `STRIPE_MODE`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_SECRET_KEY`, and (when
|
||
available) `STRIPE_WEBHOOK_SECRET` in `.env.<environment>`, then deploy. The
|
||
deploy script writes them to gitignored Docker secret files and configures the
|
||
official gateway without printing the credentials. Stripe stays disabled when
|
||
either API key is missing. Use test keys until checkout, webhooks, capture, and
|
||
cancellation have been exercised end to end.
|
||
|
||
For manual API-key connections, create a Stripe webhook endpoint for
|
||
`https://<store-host>/wc-api/wc_stripe`, subscribe it to the events recommended
|
||
by the WooCommerce Stripe settings screen, and place its `whsec_...` signing
|
||
secret in `STRIPE_WEBHOOK_SECRET`. The workflow has seven days from checkout
|
||
to capture an online card authorization. Manually entered keys do not provide
|
||
Stripe-to-WooCommerce payment-method synchronization or every feature of the
|
||
OAuth connection.
|
||
|
||
An authorized Stripe order arrives as **On hold**. `Order\SupplierOrderJob`
|
||
runs the local fraud extension point, confirms the distributor order, and
|
||
then changes the order to **Processing**, which makes the Stripe extension
|
||
capture the authorization. A distributor failure changes the order to
|
||
**Cancelled** so Stripe releases the hold, then routes it to **Needs Review**.
|
||
|
||
### 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, payment flow revised)
|
||
|
||
A synchronous supplier/margin recheck runs right before payment
|
||
(`Order\CheckoutRecheck`, price/margin only — never charges anything).
|
||
The official WooCommerce Stripe extension then holds the card and its
|
||
On-hold transition runs the fraud check (`Payments\FraudCheck`) before
|
||
moving the order to `bsc-authorized`
|
||
(held, not charged). `Order\SupplierOrderJob` picks it up from there:
|
||
submits to the supplier, and only on success **captures** the hold (the
|
||
card is actually charged for the first time here) before moving to
|
||
`bsc-ordered` → `bsc-shipped` → `completed`. Any failure at any stage —
|
||
no sellable offer, margin no longer holds, fraud check fails, or the
|
||
supplier submission itself fails — lands the order in `bsc-exception`
|
||
("Needs Review"), which is the review queue: just a filterable
|
||
WooCommerce order status, not a separate table. Because nothing is charged
|
||
until capture, most failures request a zero-cost authorization release rather
|
||
than a refund. They remain in **Needs Review** until the Stripe order note or
|
||
dashboard confirms release; only then should `AuditTrail::record_void()` be
|
||
recorded. A refund is reserved for the rarer case of something going wrong
|
||
*after* a real capture.
|
||
|
||
`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-supplier-submission) 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.
|