Replaces the flat "$10 off $100+, members only" discount with $5 off
per $50 spent, capped at $200 spend ($20 max) — an evolution of the
same idea, not an addition to it. Now sitewide by default: the guest
tier carries the same step_discount benefit as free_member, so
anonymous shoppers get it too, not just members. Toggling it on/off, or
switching between sitewide and members-only, needs no code change — an
admin unchecks the benefit on one tier's row in the settings screen.
Migration (schema v3->v4) overwrites guest/free_member's benefits to the
new shape directly rather than merging, per the established pre-launch
convention (no real customer/tier data exists to preserve yet).
Live-verified beyond the unit suite: the full discount table checked
against the real DB for both a guest and a real member, and the
sitewide/members-only toggle exercised through a real authenticated
settings-screen form POST (not just read back from code).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MqhgnCRjjVLFnZ3Jpn93Vg
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
Launch gate (design doc §5/§8) requires automated tests for both; the repo
had zero test infra until now. PHPUnit + Brain Monkey, run via `make test`
against the existing wordpress dev container.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MqhgnCRjjVLFnZ3Jpn93Vg
Adds a live per-ISBN BooksRun API check to the checkout-time price
recheck (Order\PriceRecheck) so a booksrun-sourced offer is verified
fresh, not trusted from a potentially day-stale local cache -- fails
closed on any problem (API error, no stock), matching Appendix B's
"stale supplier cost must never be trusted as final." Live values are
written back to the same offer_id row so SupplierOrderJob's later
fulfillment-time read sees them too.
Also wires a real daily Action Scheduler job (scheduling/dedup/batch-write
infrastructure) for BooksRun's FTP inventory feed, but leaves the actual
connect+parse step (Import\BooksRunOfferSync::connect_and_parse_feed())
as a deliberate stub -- no FTP access or sample feed exists yet, and
guessing at a file format would silently write wrong data once real
access arrives. The stub fails loud on the manual CLI trigger, quiet on
the scheduled job (no point alarming on an expected, known state).
Ingram, BooksRun AutoBuy, production email, and tax/legal/monitoring are
explicitly out of scope for this pass -- narrowed by user request.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2gmgaDVXcRGCwLfdXQajD
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
- SupplierOffer::delete_for_isbn() was the one Catalog method still
missing a $wpdb failure check. There's no unique constraint on isbn13
alone (schema only has a composite key on isbn13+status+base_price), so
SyntheticOfferGenerator's delete-then-insert pattern relied entirely on
the delete actually succeeding — a silent failure there left the stale
row in place alongside the new insert, two rows for one ISBN, with
best_offer_for_isbns() picking whichever was cheaper by chance and
generate-offers reporting full success throughout. Verified with the
same forced-failure reproduction as the review that found it (a BEFORE
DELETE trigger): before this fix, that scenario left 2 rows for the
ISBN; after, it throws immediately (caught by generate_all()'s existing
per-ISBN handler) and the row count stays at 1.
- deploy/lib/env.sh's env_get() didn't strip a trailing \r or leading/
trailing whitespace from extracted values. Consequential specifically
because of restore.sh's DB drop/recreate (added earlier this session):
DB_NAME_VALUE feeds directly into DROP DATABASE IF EXISTS/CREATE DATABASE
for that name — a CRLF-saved or hand-edited-with-trailing-space .env file
would silently target a different database name than the real one, with
no error, defeating the "clean restore" guarantee that fix exists for.
Also mattered for quote-stripping: a trailing \r landing after a closing
quote made the end-with-quote check silently fail to match, leaking
literal quote characters into the value. Verified against CRLF-terminated,
trailing-whitespace, and quoted+CRLF combinations, including a byte-level
check that no \r survives; confirmed no regression against normal
LF-terminated unquoted/quoted values via a full deploy.sh dev run.
A second review pass specifically targeted at the fixes just made (not the
original codebase) — caught one genuine regression from this session's own
earlier work, plus a couple of gaps the first pass didn't probe deeply
enough to find.
- REGRESSION: SupplierOffer::insert() throwing on failure (a Medium-severity
fix earlier this session, matching Work/Edition/Isbn) was never given a
catch anywhere in the offer-generation path. Before that fix, a bad
insert silently continued; after it, nothing stopped the exception from
aborting the ENTIRE generate-offers run on the first failure. Fixed with
the same per-item catch pattern GutenbergImporter already established.
Verified by forcing a real DB failure (renamed the table mid-run): before
this fix that aborted the batch immediately; after, it correctly
processed all 2000 ISBNs, reported each failure individually, and
completed with an accurate failed count — table restored after, 2000
offers confirmed intact.
- ProductSync::sync_one() never checked WC_Product::save()'s return value.
Verified directly (forced via the wp_insert_post_empty_content filter):
save() returns 0 rather than throwing on a wp_insert_post()-level
rejection, which would have gone through as Work::set_product_id($id, 0)
— silently "succeeding" and invisible in $failed[], inconsistent with
every other failure mode in this method already routing through the
per-product SAVEPOINT + $failed[] reporting added this session. Now
throws instead, confirmed it does NOT corrupt the existing pointer (stays
at its prior value, self-heals on a future successful run) rather than
writing 0.
- docker-compose.yml: the wordpress healthcheck's timeout budget (~130s)
could plausibly be exceeded by a legitimately slow (not broken) first
boot on the 2-core/8GB box this actually runs on — confirmed that a
service_healthy dependency timing out makes `docker compose up -d` (and
so deploy.sh, under set -euo pipefail) hard-fail rather than just start
late, a new deploy failure mode this session's healthcheck introduced.
Widened to ~4.5 minutes of headroom (retries 20->40, start_period
30s->60s), well above deploy.sh's own existing 120s precedent for just
the core-file-copy portion of the same boot.
- Makefile: the per-target `; rc=$?; rm -f ...; exit $rc` cleanup added
earlier this session doesn't reliably run under a real Ctrl+C — a
foreground SIGINT terminates that shell chain before the `;` continues.
Switched to a `trap 'rm -f ...' EXIT` (matching the idiom deploy.sh/
backup.sh already use), which fires on any shell termination and doesn't
need to manually thread the exit code through. Verified by actually
sending SIGINT to the whole process group (matching real terminal Ctrl+C,
not just a backgrounded job) during `make logs` — the compose env file
was correctly removed.
Also cleaned up one duplicate leftover product (work_id=1 briefly had two
products, from repeated manual test scenarios reusing the same work_id
across this long session's testing, not from an active bug — confirmed the
current code produces 0 new duplicates on repeated sync-products runs)
found while verifying the ProductSync fix above.
- PricingEngine::round_to_99(): ceil($price) - 0.01 undershoots whenever
$price's cents are already .99 or higher — an exact integer (ceil()
equals floor(), landing a full cent below $price) or, more subtly, any
fractional price above X.99 itself (a division result, not something
pre-rounded to 2 decimals, e.g. 20.995 -> old formula gave 20.99, below
the input). Rewritten as floor()+0.99, bumped by 1 if still under $price.
Verified against 8 cases including both boundary classes: every result
now >= its input.
- CoverSync::attach_cover(): wp_generate_attachment_metadata()'s return
value was never checked. Assumed it'd return empty on failure — verified
directly it does NOT: fed it 2000 bytes of garbage and got back
['filesize' => 2000], no width/height, since GD/Imagick couldn't decode
it. Old code would report "attached" for a degraded image with no
dimensions/srcset. Now checks for width+height specifically, and cleans
up the orphaned attachment on failure so a re-run retries the product.
Verified both the corrupt-image rejection (no orphan left, no thumbnail
set) and that a real image still attaches normally.
- Makefile: the per-invocation .env.$(ENV).compose file (holds every API
key and both DB passwords, stripped of DB_PASSWORD/DB_ROOT_PASSWORD only)
was never cleaned up, left at default 644 in the repo root after every
`make` command. Now chmod 600 on creation and removed at the end of every
target, preserving the underlying command's exit code through the
cleanup. Verified both the happy path (file gone after, exit 0) and the
failure path (bad ENV: file still cleaned up, real exit code still
propagates through make).
- docker-compose.yml: added a healthcheck to the wordpress service (bash's
/dev/tcp against php-fpm's port 9000 — no HTTP endpoint to hit directly,
and no `nc` in this image; verified it correctly succeeds once php-fpm is
listening and fails against a closed port) and switched cron's and
caddy's depends_on (across all three env overlays) from bare
container-started to condition: service_healthy. Previously both could
start against a wordpress container that had started but wasn't actually
ready yet. Verified via a full down/up cycle: db+redis healthy, then
wordpress starts and becomes healthy, only then do cron and caddy start.
- .env.dev/.env.staging/.env.production chmod'd 600 (were 644) — same
plaintext-credential content as secrets/<env>/, which is already 700/644
at the directory/file level respectively for a different reason (container
UID readability); these have no such constraint, only the host CLI reads
them. Also removed a stray .env.staging.compose left over from before the
Makefile fix above existed. Noted the convention in .env.example so newly
created env files follow it too.
- SupplierOffer::insert(): now checks $wpdb->insert()'s return value and
throws, matching Work/Edition/Isbn (the one Catalog class that hadn't
been hardened this way). Work::set_product_id() gets the same treatment
(was flagged low-severity but same fix, bundled here) — a real update
failure now counts as a per-work sync failure instead of silently
leaving a stale wc_product_id pointer.
- SupplierOffer: fetched_at was written via current_time('mysql') (site-
local) while expires_at (SyntheticOfferGenerator) is written in UTC, and
best_offer_for_isbns() compared against site-local time too — a mismatch
masked today only because dev's gmt_offset is 0. Switched both writer and
reader to current_time('mysql', true) (UTC). Verified the read path
still finds all active offers correctly under a simulated -5 (US
Eastern) offset, not just at offset 0.
- HardcoverAdapter: rate-limit throttling moved from "once per work" (in
Commands.php) to "once per actual HTTP request" (inside query() itself).
find_book()'s ISBN-then-title/author fallback can fire two real requests
per work — under the old scheme both shared one throttle sleep, roughly
doubling the real request rate against a beta API. query() also now
retries network errors/5xx/429 up to 3x (mirroring
OpenLibraryAdapter::get_with_retry()), while a GraphQL-level `errors` field
or other 4xx throws immediately (retrying a rejected query can't fix it).
Commands.php adds a 5-consecutive-failure circuit breaker so a bad token
or a wrong field in the still-unverified schema can't silently burn
through the whole catalog with zero progress. Verified all of this
directly against Hardcover's real API with a deliberately invalid token:
5 fast (non-retried) 401s, correct abort message, and confirmed the
failed works were NOT marked synced (so a real token can retry them).
- restore.sh: now drops and recreates the target database before restoring
the dump, and clears the uploads directory before extracting the
archive — previously both restored on top of existing state, so a stray
table or file NOT in the backup would silently survive a restore drill.
Verified end-to-end against a real local staging stack: planted a stray
table and a stray upload file after taking a backup, ran restore.sh, and
confirmed both were gone afterward while the actual backed-up data (20
works, a known upload file) came back correctly. Also fixed a real
permission gap hit during that same test: a fresh volume's uploads dir
is root-owned until something chowns it, which broke the new www-data
clear step — now clears as root and chowns to www-data afterward, which
also means restore self-heals the exact root-owned-uploads class of bug
fixed earlier this session for the cron sidecar.
- poll-deploy.sh: added a non-blocking flock so a deploy that runs longer
than the cron interval can't have a second poll fire mid-deploy and race
its git checkout/reset against the same live working tree. Verified: a
concurrent run correctly skips instantly while the lock is held, and
proceeds normally once it's released. (Full atomicity of the live PHP
file swap under real traffic is a bigger architectural question —
blue-green or symlinked releases — flagged to the user rather than
attempted here.)
- backup.sh: now also archives .env.<environment> itself (chmod 600) and
includes it in the off-host rclone sync alongside the DB dump and
uploads archive. Every API key and both DB passwords previously lived
only on the host in this one gitignored file — losing the host lost all
of it even with DB/uploads backups intact.
- ProductSync: bsc_work.wc_product_id could go stale if a product was ever
deleted out-of-band (wp-admin, a cleanup script). wc_get_product() then
correctly detects "no product" and creates a new one, but the repoint was
gated behind `if (!$existing_id)` — which was already true, so the stale
ID never got corrected. Every future sync repeated this, one duplicate
product per run. Fixed by making the repoint unconditional (cheap,
idempotent UPDATE either way). Reproduced the exact scenario against dev
(deleted a product out-of-band, ran sync-products twice) and confirmed:
one repoint, zero duplicates, product count and per-work product count
both correct across repeated runs.
- Commands.php (sync-hardcover-tags): wp_set_object_terms() with an empty
array clears the taxonomy rather than leaving it alone — verified
directly. Hardcover legitimately returns no moods/content-warnings for
plenty of books, so a --force re-sync could silently wipe existing tags,
including anything hand-tagged. Fixed by skipping the call per-category
when that category's array is empty. Verified via a direct eval test:
pre-existing genre tag survives a sync where genre comes back empty,
mood still gets set normally.
- docker-compose.yml: two stray root-owned secrets/db_password and
secrets/db_root_password directories were already sitting on disk —
Docker auto-creating a bind-mount source as a directory from an earlier
manual `docker compose` call that ran without ENVIRONMENT exported
(reproducing the exact bug already fixed once this session). Removed
the stray dirs and changed every `${ENVIRONMENT}` in a volume mount to
`${ENVIRONMENT:?ENVIRONMENT must be set}` so Compose now hard-fails
instead of silently defaulting to empty. Verified: unset ENVIRONMENT now
fails config validation with a clear error; set, it still works.
- HARDCOVER_API_TOKEN moved to the same _FILE secrets pattern already used
for DB_PASSWORD/DB_ROOT_PASSWORD (docker-compose.yml, deploy.sh,
HardcoverAdapter.php) — it was a live, consumed secret still going
through Compose's ${VAR} interpolation, exposed to the same
mangling bug already fixed for the DB passwords, plus visible via
`docker inspect`. deploy.sh now writes secrets/<env>/hardcover_api_token
(optionally empty). Verified via deploy.sh dev + wp eval: empty file ->
is_configured() false, a real token value -> true.
- backup.sh: mariadb-dump had no --single-transaction, so a dump against a
live site would either table-lock for its duration or produce a
non-atomic/inconsistent dump. Added; verified a real dump still runs
clean and produces a valid, restorable-looking .sql.gz.
Ahead of running this against a live site (not just staging bulk-imports),
addressed the two risks that only matter under concurrent traffic:
- One bad product no longer wastes a whole batch: each product save is
wrapped in its own SAVEPOINT within the batch transaction, so a failure
rolls back just that row (counted in the new failed[] result, surfaced
via WP_CLI::warning like import-gutenberg already does) instead of
discarding up to 200 already-good rows. Verified SAVEPOINT/ROLLBACK TO
SAVEPOINT actually works on this MariaDB instance via a direct $wpdb test
before relying on it.
- Cache invalidation suspension is now scoped per-batch instead of the
whole run, with clean_post_cache() called explicitly per product once
each batch commits, replacing the single end-of-run wp_cache_flush().
This site runs a shared Redis object cache across PHP-FPM workers, so a
whole-run suspension + full flush would let a concurrent visitor see
stale cached product data for the run's duration, and the flush itself
would evict sessions and everything else site-wide. Scoping bounds
staleness to one batch and targets only the products actually touched.
Also moved term resolution (term_exists()/wp_insert_term()) out of the
per-product savepoint entirely: warm_term_cache() now resolves every
distinct subject for a batch before its transaction opens, so a term
created for one product can never get silently rolled back by a *different*
product's savepoint failure while a stale term_id sits cached in memory
and gets attached to a later product (an orphaned term_relationships row,
checked for directly post-fix: 0 found).
Verified after: 2000-item sync still ~90s (savepoints add no measurable
overhead), 0 lookup-table mismatches, 0 term-count mismatches, 0 orphaned
term_relationships, idempotent re-run with 0 duplicates.
Measured 2000-product sync_all() at ~2.5 products/sec baseline (2-core/8GB
staging box). Confirmed via source inspection that WooCommerce's
wc_product_meta_lookup sync is synchronous inside WC_Product::save(), so
every product save was its own implicit autocommit transaction -> its own
fsync. Batching each 200-item chunk into one explicit transaction, deferring
product_cat term counting, suspending post cache invalidation for the
duration, and caching subject->term_id lookups in memory brings the same
2000-item sync to ~21 products/sec (~95s), with the update-path re-run at
~82/sec.
Verified correctness after, not just speed: wc_product_meta_lookup price/
stock matched postmeta exactly (0 mismatches across all 2000), product_cat
term counts matched actual term_relationships (0 mismatches), no orphaned
category assignments, no duplicate products on re-run.
Cover sync (OpenLibraryAdapter + CoverSync + sync-covers command): fetches
a real cover from Open Library's free, keyless, explicitly-licensed-for-
this-use Covers API and attaches it as a genuine Media Library attachment
(not a hotlinked <img> — WooCommerce's shop loop/gallery/structured data
all need a real _thumbnail_id). ISBN-first, title/author-search fallback
via the confirmed cover_i field, matching the pattern already established
in HardcoverAdapter.
Two real bugs found and fixed while verifying this against the actual
catalog, not assumed:
- A Range-header HEAD-equivalent probe (added to avoid double-fetching)
caused Open Library's server to redirect with a misleading content-type,
producing a false positive on a known-fake ISBN. Removed — fetch once,
verify the real bytes.
- Their search endpoint has genuine transient failures under repeated
querying (same request, same input, failed then succeeded seconds
later) — added retry-with-backoff on network errors/5xx, matching how
the rest of this codebase already treats transient failures as
retriable rather than fatal.
Also found chasing what looked like a third cover-sync bug, but wasn't
one: uploads/2026/08 was owned by root, silently blocking www-data-run
wp-cli from writing new files. Root cause was a gap in the earlier
root-hardening pass (docker-compose.yml, Commands.php) — it fixed our own
deploy.sh/backup.sh/Makefile invocations but missed the cron sidecar's
own internal process, which was still running its wp-cli loop as root via
a leftover --allow-root. Fixed at the container level (`user: www-data`
on the cron service) since it's a plain shell loop with none of
php-fpm's master-process-needs-root-to-drop-privileges concern.
Gutenberg importer: filters to actual literature via LoCC (Library of
Congress Classification) — verified directly against the real catalog
that novels consistently get a P* code while government documents/
speeches/law get E/JK/KF/DA and never a P code. Removes the Declaration
of Independence, Bill of Rights, etc. from what was importing as "books."
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Four flat taxonomies attached to product (bsc_genre, bsc_mood,
bsc_content_warning, bsc_tag) reuse WordPress's native taxonomy archive
system for the "click a tag, see everything with it" browsing AO3 is known
for — no custom archive templates or query logic needed. Verified: all
four register correctly, render as clickable chips on the product page
(content warnings get a distinct notice instead of just another chip),
and the archive page + term count both work end-to-end with real test
data.
HardcoverAdapter + `wp bookstore sync-hardcover-tags` pull genre/mood/
content-warning/freeform tags from Hardcover's API to populate these.
This part is explicitly NOT verified against a live response — Hardcover's
API is in beta with informal docs, and there was no API token available
to confirm the exact query/response shape. Flagged clearly in the adapter
itself; needs a real token + introspection query before trusting the
field-parsing logic in production. ISBN-first-then-title/author matching
handles both real future ISBNs and the current Gutenberg-synthetic
catalog's fake ones.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Builds bookstore-core's first real business logic (design doc §02-§05,§07),
replacing the plugin stub with:
- Schema: bsc_work/edition/isbn/cover_variant/supplier_offer/member tables
via dbDelta, with a versioned upgrade path.
- GutenbergImporter: imports Project Gutenberg's live bulk catalog as
synthetic staging data. Deterministic checksum-valid ISBN-13s (there are
no real ISBNs in PG data) keep re-imports idempotent.
- SyntheticOfferGenerator: the GutenbergTestAdapter of §04 — realistic
condition/stock/price distribution with deliberate out-of-stock/stale/
discontinued cases for later exception-path testing.
- PricingEngine: the §05 margin formula, verified against the design
doc's own worked example ($6.00 + $3.99 -> $14.99 exactly).
- ProductSync: one WooCommerce product per Work, never per ISBN (§03),
idempotent (verified: re-sync of 2000 works produces 0 duplicates).
- BSC_Free_Shipping: a real WC_Shipping_Method (WooCommerce's built-in
Free Shipping only supports one static threshold, not membership-
conditional), verified through actual browser-session cart flows in
both directions, not just unit calls.
Found and fixed one real data-integrity bug while scale-testing: PG's
Authors field lists every contributor for anthology works, overflowing
primary_author's column — Work::insert() was failing silently (Catalog
insert methods never checked $wpdb->insert()'s return value), while the
following Edition/Isbn inserts for that same row went ahead anyway and
attached to whichever work_id was last successful. Fixed at the root:
inserts now throw on failure, the importer takes just the first author
(matching the column's actual semantic intent), and the import loop
catches per-row failures so one bad title can't abort a bulk run.
Verified end-to-end against a real 2000-title import: exact work/edition/
isbn/product count parity, no duplicates on re-run, real HTTP cart tests
for both shipping thresholds, live storefront search.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>